refactor: drop milestone names + consolidate clients; loss-recovery & rumble fixes
apple / swift (push) Failing after 40s
audit / cargo-audit (push) Failing after 1m12s
windows-msix / package (push) Successful in 1m37s
windows / build (push) Successful in 1m14s
android / android (push) Successful in 4m48s
ci / web (push) Successful in 27s
ci / rust (push) Successful in 4m21s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
deb / build-publish (push) Successful in 6m3s
flatpak / build-publish (push) Successful in 4m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m15s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m16s
docker / deploy-docs (push) Successful in 18s

Two bodies of work in one commit (the rename moved files the fixes also touched).

Naming/structure cleanup (pre-launch):
- Host modules m3.rs->punktfunk1.rs, m0.rs->spike.rs; CLI m3-host->punktfunk1-host,
  m0->spike; bare `punktfunk-host` now prints help. Types M3Options/M3Source->
  Punktfunk1Options/Punktfunk1Source.
- Clients consolidated out of crates/ into clients/: punktfunk-client-rs->
  clients/probe (crate punktfunk-probe), client-linux->clients/linux,
  client-windows->clients/windows, punktfunk-android->clients/android/native
  (crate punktfunk-client-android; kept [lib] name=punktfunk_android so the JNI
  contract is unchanged). crates/ now holds only core + host.
- Milestone codes M0-M4 purged from code/CLI/CLAUDE.md/README/docs/docs-site,
  kept only in docs/implementation-plan.md. docs/m2-plan.md->
  docs/gamestream-host-plan.md. CI/gradle/flatpak paths updated.

Client loss-recovery (video froze and never recovered after a brief drop):
- Export punktfunk_connection_frames_dropped through the C ABI (the core already
  tracked it for the client keyframe-recovery loop; it was never reachable from
  the ABI clients). Regenerated punktfunk_core.h.
- Apple (StreamPump + Stage2Pipeline) and Android (decode.rs) now poll
  frames_dropped and request a keyframe when it climbs -- the same loss-driven
  recovery Linux/Windows already had. Under infinite GOP the decoder silently
  conceals reference-missing frames, so the decode-error trigger rarely fires.

Apple rumble robustness (worked then went spotty -- DualSense + Xbox):
- Add CHHapticEngine stopped/reset handlers (rebuild on app background / audio
  interruption / server reset) and drop the permanent `broken` latch on a
  transient drive failure; latch only when the controller truly has no haptics.
- Surface swallowed SDL set_rumble errors on Linux/Windows + diagnostic logging.

Verified: cargo build/clippy/fmt --workspace, C-ABI harness, header drift.
Not runnable on this box (verify in CI): Gitea workflows, gradle/Android,
flatpak, Swift/decky.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 21:03:55 +00:00
parent 1faa6c6ad4
commit 9c8fa9340c
110 changed files with 534 additions and 341 deletions
-35
View File
@@ -1,35 +0,0 @@
[package]
name = "punktfunk-android"
description = "punktfunk Android client — JNI bridge ('nativecore') over punktfunk-core (Rust-heavy client model)"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[lib]
# `libpunktfunk_android.so` — loaded by Kotlin via `System.loadLibrary("punktfunk_android")`.
name = "punktfunk_android"
crate-type = ["cdylib"]
[dependencies]
# The whole protocol/transport/FEC/crypto + the embeddable NativeClient connector. `quic` pulls
# the punktfunk/1 control plane (now ring-only — no aws-lc, see punktfunk-core/Cargo.toml).
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
jni = "0.21"
log = "0.4"
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
# compiles this crate (as a host cdylib) — the Android-framework glue (logging now; AMediaCodec via
# `ndk` and Oboe/Opus audio later) is only pulled in for the real `*-linux-android` targets.
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14"
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
ndk = { version = "0.9", features = ["media", "audio"] }
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
opus = "0.3"
-196
View File
@@ -1,196 +0,0 @@
//! 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).
use ndk::audio::{
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
AudioStream, AudioStreamBuilder,
};
use punktfunk_core::client::NativeClient;
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::Arc;
use std::time::Duration;
const CHANNELS: usize = 2;
const SAMPLE_RATE: i32 = 48_000;
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
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;
/// 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)]
struct Counters {
opus_decoded: AtomicU64, // Opus packets decoded OK (~200/s at 5 ms frames)
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
ring_depth: AtomicU64, // ring sample count at the last callback
}
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
pub struct AudioPlayback {
_stream: AudioStream, // dropping it stops + closes the AAudio stream
shutdown: Arc<AtomicBool>,
join: Option<std::thread::JoinHandle<()>>,
}
impl AudioPlayback {
/// Open AAudio (LowLatency, 48 kHz/stereo/f32) with a realtime callback draining a jitter ring,
/// then spawn the Opus decode thread. `None` on failure (the caller leaves video streaming).
pub fn start(client: Arc<NativeClient>) -> Option<AudioPlayback> {
let counters = Arc::new(Counters::default());
let (tx, 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`.
let cb_counters = counters.clone();
let mut ring: VecDeque<f32> = VecDeque::with_capacity(PCM_SCRATCH);
let mut primed = false;
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);
}
// 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 {
ring.pop_front();
}
if !primed && ring.len() >= target {
primed = true;
}
if primed {
for slot in out.iter_mut() {
*slot = ring.pop_front().unwrap_or(0.0);
}
cb_counters
.pcm_written
.fetch_add(num_frames as u64, Ordering::Relaxed);
} else {
out.fill(0.0);
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
}
if ring.is_empty() {
primed = false; // re-prime after a genuine drain (avoids sustained crackle on loss)
}
cb_counters
.ring_depth
.store(ring.len() as u64, Ordering::Relaxed);
AudioCallbackResult::Continue
};
let stream = AudioStreamBuilder::new()
.map_err(|e| log::error!("audio: AudioStreamBuilder::new: {e}"))
.ok()?
.direction(AudioDirection::Output)
.sample_rate(SAMPLE_RATE)
.channel_count(CHANNELS as i32)
.format(AudioFormat::PCM_Float)
.performance_mode(AudioPerformanceMode::LowLatency)
.sharing_mode(AudioSharingMode::Shared)
.data_callback(Box::new(callback))
.error_callback(Box::new(|_s, e| {
log::warn!("audio: AAudio error (device reroute/disconnect?): {e:?}");
}))
.open_stream()
.map_err(|e| log::error!("audio: open_stream: {e}"))
.ok()?;
if let Err(e) = stream.request_start() {
log::error!("audio: request_start: {e}");
return None;
}
log::info!(
"audio: AAudio started rate={} ch={} fmt={:?} burst={}",
stream.sample_rate(),
stream.channel_count(),
stream.format(),
stream.frames_per_burst(),
);
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))
.ok();
Some(AudioPlayback {
_stream: stream,
shutdown,
join,
})
}
}
impl Drop for AudioPlayback {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(j) = self.join.take() {
let _ = j.join();
}
// `_stream` drops here → AAudio request_stop + close.
}
}
/// Producer: `next_audio` → Opus `decode_float` → push interleaved f32 into the ring channel.
fn decode_loop(
client: Arc<NativeClient>,
tx: SyncSender<Vec<f32>>,
shutdown: Arc<AtomicBool>,
counters: Arc<Counters>,
) {
let mut dec = match opus::Decoder::new(SAMPLE_RATE as u32, opus::Channels::Stereo) {
Ok(d) => d,
Err(e) => {
log::error!("audio: opus decoder init: {e} — audio disabled");
return;
}
};
let mut pcm = vec![0f32; PCM_SCRATCH];
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
while !shutdown.load(Ordering::Relaxed) {
match client.next_audio(Duration::from_millis(5)) {
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) {
Ok(samples) => {
let n = samples * CHANNELS;
for &s in &pcm[..n] {
window_peak = window_peak.max(s.abs());
}
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
match tx.try_send(pcm[..n].to_vec()) {
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure
Err(TrySendError::Disconnected(_)) => break,
}
if count % 600 == 0 {
log::info!(
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
counters.pcm_written.load(Ordering::Relaxed),
counters.underruns.load(Ordering::Relaxed),
counters.ring_depth.load(Ordering::Relaxed),
);
window_peak = 0.0;
}
}
Err(e) => log::debug!("audio: opus decode: {e}"),
},
Err(PunktfunkError::NoFrame) => {} // timeout
Err(_) => break, // session closed
}
}
log::info!(
"audio: stopped (opus={} pcm_frames={} underruns={})",
counters.opus_decoded.load(Ordering::Relaxed),
counters.pcm_written.load(Ordering::Relaxed),
counters.underruns.load(Ordering::Relaxed),
);
}
-138
View File
@@ -1,138 +0,0 @@
//! Android video decode (android-only): pull HEVC access units from the connector and render them
//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI.
//!
//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the
//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated
//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns
//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag.
use ndk::media::media_codec::{
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection,
};
use ndk::media::media_format::MediaFormat;
use ndk::native_window::NativeWindow;
use punktfunk_core::client::NativeClient;
use punktfunk_core::error::PunktfunkError;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes.
pub fn run(client: Arc<NativeClient>, window: NativeWindow, shutdown: Arc<AtomicBool>) {
let mode = client.mode();
let codec = match MediaCodec::from_decoder_type("video/hevc") {
Some(c) => c,
None => {
log::error!("decode: no HEVC decoder on this device");
return;
}
};
let mut format = MediaFormat::new();
format.set_str("mime", "video/hevc");
format.set_i32("width", mode.width as i32);
format.set_i32("height", mode.height as i32);
// Generous input buffer so a large keyframe AU is never truncated.
format.set_i32(
"max-input-size",
(mode.width * mode.height).max(2_000_000) as i32,
);
// Ask for the low-latency decode path where the decoder supports it (no reordering buffer).
format.set_i32("low-latency", 1);
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
log::error!("decode: configure failed: {e}");
return;
}
if let Err(e) = codec.start() {
log::error!("decode: start failed: {e}");
return;
}
log::info!(
"decode: HEVC decoder started at {}x{}",
mode.width,
mode.height
);
let mut fed: u64 = 0;
let mut rendered: u64 = 0;
while !shutdown.load(Ordering::Relaxed) {
match client.next_frame(Duration::from_millis(5)) {
Ok(frame) => {
if fed == 0 {
let p = &frame.data;
log::info!(
"decode: first AU {} bytes, head {:02x?}",
p.len(),
&p[..p.len().min(6)]
);
}
fed += 1;
feed(&codec, &frame.data, frame.pts_ns / 1000);
}
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
Err(_) => break, // session closed
}
rendered += drain(&codec);
if fed > 0 && fed % 300 == 0 {
log::info!("decode: fed={fed} rendered={rendered}");
}
}
let _ = codec.stop();
log::info!("decode: stopped (fed={fed} rendered={rendered})");
}
/// Copy one access unit into a codec input buffer and queue it.
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) {
match codec.dequeue_input_buffer(Duration::from_millis(10)) {
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
let n = {
let dst = buf.buffer_mut();
let n = au.len().min(dst.len());
if n < au.len() {
log::warn!(
"decode: AU {} > input buffer {}, truncated",
au.len(),
dst.len()
);
}
for (slot, &b) in dst.iter_mut().zip(&au[..n]) {
slot.write(b);
}
n
};
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
log::warn!("decode: queue_input_buffer: {e}");
}
}
Ok(DequeuedInputBufferResult::TryAgainLater) => {
// No input buffer free right now; the AU is dropped (FEC/keyframes recover).
}
Err(e) => log::warn!("decode: dequeue_input_buffer: {e}"),
}
}
/// Release any ready output buffers to the surface (render = true), latency-first. Returns the
/// number of frames presented.
fn drain(codec: &MediaCodec) -> u64 {
let mut n = 0;
loop {
match codec.dequeue_output_buffer(Duration::from_millis(0)) {
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
if let Err(e) = codec.release_output_buffer(buf, true) {
log::warn!("decode: release_output_buffer: {e}");
break;
}
n += 1;
}
// TryAgainLater / OutputFormatChanged / OutputBuffersChanged — nothing to render now.
Ok(_) => break,
Err(e) => {
log::warn!("decode: dequeue_output_buffer: {e}");
break;
}
}
}
n
}
-114
View File
@@ -1,114 +0,0 @@
//! Host→client gamepad feedback pulls (Option B): blocking JNI shims that forward to the connector's
//! rumble (0xCA) / HID-output (0xCD) planes and return one decoded event. Kotlin owns the poll
//! threads + the Android Vibrator/Lights rendering (see `GamepadFeedback.kt`) — no JNI upcalls, no
//! `JavaVM` attach, no cached method ids. Mirrors the audio plane's one-thread-per-plane contract,
//! except the thread lives in Kotlin and we just expose the blocking pull.
//!
//! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these
//! compile on the host build too (parity with the input shims in [`crate::session`]).
use crate::session::SessionHandle;
use jni::objects::{JByteBuffer, JObject};
use jni::sys::{jint, jlong};
use jni::JNIEnv;
use punktfunk_core::quic::HidOutput;
use std::time::Duration;
/// Short blocking timeout: long enough not to busy-spin, short enough that the Kotlin poll thread
/// observes its `running=false` flag promptly on teardown.
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
const TAG_LED: u8 = 0x01;
const TAG_PLAYER_LEDS: u8 = 0x02;
const TAG_TRIGGER: u8 = 0x03;
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
/// Returns `(low << 16) | high` (each 0..=0xFFFF; `0` = stop), or `-1` on timeout / session closed.
/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jlong {
if handle == 0 {
return -1;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
// threads (and joins them) before nativeClose frees the handle.
let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble(PULL_TIMEOUT) {
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
}
}
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`:
/// Led → `[0x01][r][g][b]` (len 4)
/// PlayerLeds → `[0x02][bits]` (len 2)
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len())
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
env: JNIEnv,
_this: JObject,
handle: jlong,
buf: JByteBuffer,
) -> jint {
if handle == 0 {
return -1;
}
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
let h = unsafe { &*(handle as *const SessionHandle) };
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
Ok(ev) => ev,
Err(_) => return -1, // timeout or closed — Kotlin loops
};
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return -1,
};
let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p,
_ => return -1,
};
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
let n = match ev {
HidOutput::Led { r, g, b, .. } => {
if cap < 4 {
return -1;
}
out[0] = TAG_LED;
out[1] = r;
out[2] = g;
out[3] = b;
4
}
HidOutput::PlayerLeds { bits, .. } => {
if cap < 2 {
return -1;
}
out[0] = TAG_PLAYER_LEDS;
out[1] = bits;
2
}
HidOutput::Trigger { which, effect, .. } => {
let n = 2 + effect.len();
if cap < n {
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
}
out[0] = TAG_TRIGGER;
out[1] = which;
out[2..n].copy_from_slice(&effect);
n
}
};
n as jint
}
-74
View File
@@ -1,74 +0,0 @@
//! punktfunk Android client — the JNI bridge ("nativecore") over `punktfunk-core`.
//!
//! Architecture: the **Rust-heavy** client model (like `punktfunk-client-linux`, *not* the
//! thin-native-over-C-ABI Apple model). This `cdylib` links `punktfunk-core` directly and drives
//! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture,
//! `NsdManager` discovery, Keystore). The JNI seam below is the one place the two languages meet.
//!
//! Why Rust-heavy: Kotlin cannot `import` the cbindgen C header the way Swift can, so a native
//! bridge is unavoidable. Writing it in Rust lets the Android client reuse the Linux client's
//! orchestration verbatim — audio jitter ring, the VK keymap inverse, latency/skew math, the
//! input capture state machine, trust/pairing logic — instead of re-porting it into Kotlin.
//!
//! JNI symbols map to `io.unom.punktfunk.kit.NativeBridge` in the `:kit` Gradle module
//! (`clients/android`). The current surface is the scaffold's native-link proof
//! (`abiVersion`/`coreVersion`) plus the session handle lifecycle in [`session`]; the per-plane
//! pumps (video → AMediaCodec, audio → Oboe), input, audio, pairing and mode renegotiation are
//! the next milestone (see the TODOs in [`session`]).
use jni::objects::JObject;
use jni::sys::jint;
use jni::JNIEnv;
#[cfg(target_os = "android")]
mod audio;
#[cfg(target_os = "android")]
mod decode;
mod feedback;
#[cfg(target_os = "android")]
mod mic;
mod session;
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn JNI_OnLoad(
_vm: *mut jni::sys::JavaVM,
_reserved: *mut std::ffi::c_void,
) -> jint {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("punktfunk"),
);
log::info!(
"punktfunk_android loaded (core ABI v{})",
punktfunk_core::ABI_VERSION
);
jni::sys::JNI_VERSION_1_6
}
/// `NativeBridge.abiVersion(): Int` — the core's C-ABI version. A non-error return is the
/// scaffold's proof that `System.loadLibrary` found the `.so`, the JNI symbol resolved, and the
/// linked `punktfunk-core` is the one we expect.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
_env: JNIEnv,
_this: JObject,
) -> jint {
punktfunk_core::ABI_VERSION as jint
}
/// `NativeBridge.coreVersion(): String` — the crate version, proving JNI string marshaling works.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_coreVersion<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
) -> jni::sys::jstring {
match env.new_string(env!("CARGO_PKG_VERSION")) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
}
-174
View File
@@ -1,174 +0,0 @@
//! Android microphone uplink (android-only): capture mic PCM via AAudio (LowLatency **input**),
//! Opus-encode 20 ms stereo frames, and push them to the host over the connector's mic plane
//! (`send_mic` → 0xCB datagram). The mirror of [`crate::audio`] in reverse: AAudio's realtime input
//! callback hands captured interleaved f32 to a channel; a worker thread we own does the Opus encode
//! + send (encoding is too heavy for the realtime callback, exactly as decode is on the playback
//! side). Format matches the host decoder + the Linux client: 48 kHz **stereo**, 20 ms, Opus VOIP.
use ndk::audio::{
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
AudioStream, AudioStreamBuilder,
};
use punktfunk_core::client::NativeClient;
use std::collections::VecDeque;
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, TrySendError};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const CHANNELS: usize = 2;
const SAMPLE_RATE: i32 = 48_000;
/// 20 ms per channel @ 48 kHz — the Linux client's frame; the host accepts ≤ 120 ms.
const FRAME_SAMPLES: usize = 960;
/// Captured-chunk hand-off depth (each ~ one burst); drops on overflow (best-effort uplink).
const RING_CHUNKS: usize = 64;
/// Opus VOIP target bitrate (speech; tunable).
const MIC_BITRATE: i32 = 64_000;
/// Owned by [`crate::session::SessionHandle`]: the live AAudio input stream + the encode thread.
pub struct MicCapture {
_stream: AudioStream, // dropping it stops + closes the AAudio input stream
shutdown: Arc<AtomicBool>,
join: Option<std::thread::JoinHandle<()>>,
}
impl MicCapture {
/// Open AAudio (LowLatency, 48 kHz/stereo/f32) for **input** with a realtime callback that
/// forwards captured PCM to a channel, then spawn the Opus encode + uplink thread. `None` on
/// failure (the caller leaves the rest of the session streaming).
pub fn start(client: Arc<NativeClient>) -> Option<MicCapture> {
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
let captured = Arc::new(AtomicU64::new(0));
let cb_captured = captured.clone();
let callback = move |_s: &AudioStream, data: *mut c_void, num_frames: i32| {
let n = num_frames as usize * CHANNELS;
// SAFETY: for an input stream AAudio provides `num_frames * channel_count` captured F32
// samples at `data` (read-only for us).
let inp = unsafe { std::slice::from_raw_parts(data as *const f32, n) };
match tx.try_send(inp.to_vec()) {
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest if the encoder lags
Err(TrySendError::Disconnected(_)) => return AudioCallbackResult::Stop,
}
cb_captured.fetch_add(num_frames as u64, Ordering::Relaxed);
AudioCallbackResult::Continue
};
let stream = AudioStreamBuilder::new()
.map_err(|e| log::error!("mic: AudioStreamBuilder::new: {e}"))
.ok()?
.direction(AudioDirection::Input)
.sample_rate(SAMPLE_RATE)
.channel_count(CHANNELS as i32)
.format(AudioFormat::PCM_Float)
.performance_mode(AudioPerformanceMode::LowLatency)
.sharing_mode(AudioSharingMode::Shared)
.data_callback(Box::new(callback))
.error_callback(Box::new(|_s, e| {
log::warn!("mic: AAudio error (device reroute/disconnect?): {e:?}");
}))
.open_stream()
.map_err(|e| log::error!("mic: open_stream (RECORD_AUDIO granted?): {e}"))
.ok()?;
if let Err(e) = stream.request_start() {
log::error!("mic: request_start: {e}");
return None;
}
log::info!(
"mic: AAudio input started rate={} ch={} fmt={:?}",
stream.sample_rate(),
stream.channel_count(),
stream.format(),
);
let shutdown = Arc::new(AtomicBool::new(false));
let sd = shutdown.clone();
let join = std::thread::Builder::new()
.name("pf-mic".into())
.spawn(move || encode_loop(client, rx, sd, captured))
.ok();
Some(MicCapture {
_stream: stream,
shutdown,
join,
})
}
}
impl Drop for MicCapture {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(j) = self.join.take() {
let _ = j.join();
}
// `_stream` drops here → AAudio request_stop + close.
}
}
/// Consumer: drain captured f32 → accumulate → Opus `encode_float` 20 ms stereo frames → `send_mic`.
fn encode_loop(
client: Arc<NativeClient>,
rx: Receiver<Vec<f32>>,
shutdown: Arc<AtomicBool>,
captured: Arc<AtomicU64>,
) {
let mut enc = match opus::Encoder::new(
SAMPLE_RATE as u32,
opus::Channels::Stereo,
opus::Application::Voip,
) {
Ok(e) => e,
Err(e) => {
log::error!("mic: opus encoder init: {e} — mic disabled");
return;
}
};
let _ = enc.set_bitrate(opus::Bitrate::Bits(MIC_BITRATE));
let frame = FRAME_SAMPLES * CHANNELS;
let mut ring: VecDeque<f32> = VecDeque::with_capacity(frame * 4);
let mut out = vec![0u8; 4000]; // max Opus packet for a 20 ms frame fits easily
let mut seq: u32 = 0;
let mut sent: u64 = 0;
let mut peak = 0f32; // loudest |sample| since the last log — tells speech from silence
while !shutdown.load(Ordering::Relaxed) {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(chunk) => ring.extend(chunk),
Err(RecvTimeoutError::Timeout) => continue, // wake to re-check shutdown
Err(RecvTimeoutError::Disconnected) => break,
}
while ring.len() >= frame {
let pcm: Vec<f32> = ring.drain(..frame).collect();
for &s in &pcm {
peak = peak.max(s.abs());
}
match enc.encode_float(&pcm, &mut out) {
Ok(len) => {
let pts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let _ = client.send_mic(seq, pts, out[..len].to_vec());
seq = seq.wrapping_add(1);
sent += 1;
if sent % 250 == 0 {
log::info!(
"mic: sent={sent} captured_frames={} peak={peak:.3}",
captured.load(Ordering::Relaxed),
);
peak = 0.0;
}
}
Err(e) => log::debug!("mic: opus encode: {e}"),
}
}
}
log::info!(
"mic: stopped (sent={sent} captured_frames={})",
captured.load(Ordering::Relaxed),
);
}
-610
View File
@@ -1,610 +0,0 @@
//! Session lifecycle + plane wiring over JNI.
//!
//! A connected session is a [`SessionHandle`] — an `Arc<NativeClient>` plus the decode thread it
//! feeds — boxed and handed to Kotlin as an opaque `jlong`. The connector is `Sync`, so the decode
//! thread pulls the video plane (`next_frame`) directly while Kotlin still holds the handle.
//!
//! Wired: connect/close, the video plane (HEVC `next_frame` → NDK AMediaCodec → the SurfaceView's
//! `ANativeWindow`, see [`crate::decode`]), host→client audio ([`crate::audio`]), input
//! (`send_input` — mouse/keyboard/gamepad), rumble/DualSense HID feedback ([`crate::feedback`]),
//! and the trust surface: `nativeGenerateIdentity` (persistent identity, Keystore-wrapped on the
//! Kotlin side), `nativeConnect` with identity + pin (TOFU / pinned), and `nativePair` (SPAKE2 PIN).
//!
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
//! renegotiation. Port the remaining orchestration from `crates/punktfunk-client-linux`.
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong};
use jni::JNIEnv;
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use punktfunk_core::input::{InputEvent, InputKind};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
pub(crate) struct SessionHandle {
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
// build (CI's workspace clippy/build) those readers are cfg'd out, so it's intentionally unused.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub client: Arc<NativeClient>,
video: Mutex<Option<VideoThread>>,
#[cfg(target_os = "android")]
audio: Mutex<Option<crate::audio::AudioPlayback>>,
#[cfg(target_os = "android")]
mic: Mutex<Option<crate::mic::MicCapture>>,
}
struct VideoThread {
shutdown: Arc<AtomicBool>,
join: Option<JoinHandle<()>>,
}
impl SessionHandle {
/// Signal the decode thread to stop and join it. Idempotent.
fn stop_video(&self) {
if let Some(mut vt) = self.video.lock().unwrap().take() {
vt.shutdown.store(true, Ordering::SeqCst);
if let Some(j) = vt.join.take() {
let _ = j.join();
}
}
}
/// Stop + close audio playback. Dropping the [`crate::audio::AudioPlayback`] joins its decode
/// thread and closes the AAudio stream. Idempotent.
#[cfg(target_os = "android")]
fn stop_audio(&self) {
let _ = self.audio.lock().unwrap().take();
}
/// Stop mic uplink. Dropping the [`crate::mic::MicCapture`] joins its encode thread and closes
/// the AAudio input stream. Idempotent.
#[cfg(target_os = "android")]
fn stop_mic(&self) {
let _ = self.mic.lock().unwrap().take();
}
}
impl Drop for SessionHandle {
fn drop(&mut self) {
self.stop_video();
#[cfg(target_os = "android")]
self.stop_audio();
#[cfg(target_os = "android")]
self.stop_mic();
}
}
/// SHA-256 fingerprint → 64 lowercase hex chars (matches the host log + client-rs).
fn hex32(fp: &[u8; 32]) -> String {
use std::fmt::Write;
fp.iter().fold(String::with_capacity(64), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
/// 64-hex → [u8; 32]; `None` on bad length/char.
fn parse_hex32(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, b) in out.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
}
Some(out)
}
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
) -> jni::sys::jstring {
let out = match punktfunk_core::quic::endpoint::generate_identity() {
Ok((cert, key)) => format!("{cert}\n-----PUNKTFUNK-KEY-----\n{key}"),
Err(e) => {
log::error!("nativeGenerateIdentity failed: {e}");
String::new()
}
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
}
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
/// compositorPref, gamepadPref): Long`. `certPem`/`keyPem` empty = anonymous, else presented as the
/// persistent identity. `pinHex` empty = TOFU (read `nativeHostFingerprint` after), else 64-hex
/// SHA-256 to pin the host (mismatch → 0). `bitrateKbps` 0 = host default. `compositorPref`/
/// `gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes (0 = Auto; unknown → Auto).
/// Returns an opaque handle, or 0 on failure (logged).
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
mut env: JNIEnv<'local>,
_this: JObject<'local>,
host: JString<'local>,
port: jint,
width: jint,
height: jint,
refresh_hz: jint,
cert_pem: JString<'local>,
key_pem: JString<'local>,
pin_hex: JString<'local>,
bitrate_kbps: jint,
compositor_pref: jint,
gamepad_pref: jint,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
Err(_) => return 0,
};
let cert: String = env
.get_string(&cert_pem)
.map(Into::into)
.unwrap_or_default();
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
None
} else {
Some((cert, key))
};
let pin: Option<[u8; 32]> = if pin_hex.is_empty() {
None
} else {
match parse_hex32(&pin_hex) {
Some(fp) => Some(fp),
None => {
log::error!("nativeConnect: bad pin hex (len {})", pin_hex.len());
return 0;
}
}
};
let mode = Mode {
width: width as u32,
height: height as u32,
refresh_hz: refresh_hz as u32,
};
match NativeClient::connect(
&host,
port as u16,
mode,
CompositorPref::from_u8(compositor_pref.clamp(0, u8::MAX as jint) as u8),
GamepadPref::from_u8(gamepad_pref.clamp(0, u8::MAX as jint) as u8),
bitrate_kbps.max(0) as u32, // 0 = host default
0, // video_caps: 8-bit only on Android for now
None, // launch: default app
pin, // Some → Crypto on host-fp mismatch
identity, // owned (cert, key) PEM, or None (anonymous)
Duration::from_secs(10),
) {
Ok(client) => {
let handle = SessionHandle {
client: Arc::new(client),
video: Mutex::new(None),
#[cfg(target_os = "android")]
audio: Mutex::new(None),
#[cfg(target_os = "android")]
mic: Mutex::new(None),
};
Box::into_raw(Box::new(handle)) as jlong
}
Err(e) => {
log::error!("nativeConnect to {host}:{port} failed: {e}");
0
}
}
}
/// `NativeBridge.nativeClose(handle)` — drop the session (stops the decode thread, then RAII-tears
/// down the connector). No-op on `0`.
///
/// # Safety contract
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
if handle != 0 {
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) };
}
}
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
/// connect. `""` on a `0` handle.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jni::sys::jstring {
let out = if handle == 0 {
String::new()
} else {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
hex32(&h.client.host_fingerprint)
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
}
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
/// `""` (logged). Blocking — Kotlin calls it off the UI thread.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
mut env: JNIEnv<'local>,
_this: JObject<'local>,
host: JString<'local>,
port: jint,
cert_pem: JString<'local>,
key_pem: JString<'local>,
pin: JString<'local>,
name: JString<'local>,
) -> jni::sys::jstring {
let g = |e: &mut JNIEnv<'local>, j: &JString<'local>| -> String {
e.get_string(j).map(Into::into).unwrap_or_default()
};
let host = g(&mut env, &host);
let cert = g(&mut env, &cert_pem);
let key = g(&mut env, &key_pem);
let pin = g(&mut env, &pin);
let name = g(&mut env, &name);
let out = if host.is_empty() || cert.is_empty() || key.is_empty() {
log::error!("nativePair: missing host/identity");
String::new()
} else {
match NativeClient::pair(
&host,
port as u16,
(&cert, &key), // borrowed identity
&pin,
&name,
Duration::from_secs(60),
) {
Ok(host_fp) => hex32(&host_fp),
Err(e) => {
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
log::error!("nativePair to {host}:{port} failed: {e}");
String::new()
}
}
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
}
/// `NativeBridge.nativeStartVideo(handle, surface)` — wrap the SurfaceView's `Surface` as an
/// `ANativeWindow` and start the HEVC decode thread rendering onto it. No-op if already started.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
env: JNIEnv,
_this: JObject,
handle: jlong,
surface: JObject,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.video.lock().unwrap();
if guard.is_some() {
return; // already streaming
}
// SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any
// jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers).
let window = match unsafe {
ndk::native_window::NativeWindow::from_surface(
env.get_native_interface() as *mut _,
surface.as_raw() as *mut _,
)
} {
Some(w) => w,
None => {
log::error!("nativeStartVideo: no ANativeWindow from Surface");
return;
}
};
let shutdown = Arc::new(AtomicBool::new(false));
let client = h.client.clone();
let sd = shutdown.clone();
let join = std::thread::Builder::new()
.name("pf-decode".into())
.spawn(move || crate::decode::run(client, window, sd))
.ok();
*guard = Some(VideoThread { shutdown, join });
}
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
/// session). No-op on `0`.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
if handle != 0 {
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_video();
}
}
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
/// started or on a `0` handle. Best-effort: a failure leaves video streaming.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.audio.lock().unwrap();
if guard.is_some() {
return; // already playing
}
match crate::audio::AudioPlayback::start(h.client.clone()) {
Some(p) => *guard = Some(p),
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
}
}
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
/// closing the session). No-op on `0`.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
if handle != 0 {
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_audio();
}
}
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
/// No-op if already running or on a `0` handle. Caller MUST hold RECORD_AUDIO; a failure (e.g. no
/// permission) leaves the rest of the session streaming.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.mic.lock().unwrap();
if guard.is_some() {
return; // already capturing
}
match crate::mic::MicCapture::start(h.client.clone()) {
Some(m) => *guard = Some(m),
None => log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)"),
}
}
/// `NativeBridge.nativeStopMic(handle)` — stop + join the mic thread and close the AAudio input
/// stream (without closing the session). No-op on `0`.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
if handle != 0 {
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_mic();
}
}
// ---- Input plane: Kotlin capture → NativeClient::send_input ----------------------------------
// All four are `&self` on the `Sync` connector (send_input is a non-blocking datagram push), safe
// from the Kotlin UI thread. NOT android-gated — send_input exists on the host build too, so these
// compile everywhere (parity with nativeConnect/nativeClose). The wire codes are the GameStream
// conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
// signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
/// `NativeBridge.nativeSendPointerMove(handle, dx, dy)` — relative mouse motion (screen +y down).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerMove(
_env: JNIEnv,
_this: JObject,
handle: jlong,
dx: jint,
dy: jint,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_input(&InputEvent {
kind: InputKind::MouseMove,
_pad: [0; 3],
code: 0,
x: dx,
y: dy,
flags: 0,
});
}
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerButton(
_env: JNIEnv,
_this: JObject,
handle: jlong,
button: jint,
down: jboolean,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_input(&InputEvent {
kind: if down != 0 {
InputKind::MouseButtonDown
} else {
InputKind::MouseButtonUp
},
_pad: [0; 3],
code: button as u32,
x: 0,
y: 0,
flags: 0,
});
}
/// `NativeBridge.nativeSendScroll(handle, axis, delta)` — one scroll step. `axis`: 0=vertical,
/// 1=horizontal. `delta`: signed, WHEEL_DELTA(120)-scaled, +=up/right.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
_env: JNIEnv,
_this: JObject,
handle: jlong,
axis: jint,
delta: jint,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_input(&InputEvent {
kind: InputKind::MouseScroll,
_pad: [0; 3],
code: axis as u32,
x: delta,
y: 0,
flags: 0,
});
}
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
_env: JNIEnv,
_this: JObject,
handle: jlong,
vk: jint,
down: jboolean,
mods: jint,
) {
if handle == 0 || vk == 0 {
return;
}
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_input(&InputEvent {
kind: if down != 0 {
InputKind::KeyDown
} else {
InputKind::KeyUp
},
_pad: [0; 3],
code: vk as u32,
x: 0,
y: 0,
flags: mods as u32,
});
}
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
// Single-pad model: exactly one controller, forwarded as pad 0 (flags = 0). Buttons carry the
// gamepad::BTN_* bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id
// in `code` and the value in `x` (sticks i16 32768..32767, +y = up; triggers 0..255). The host
// accumulates the incremental events into its virtual xpad. Wire contract: input.rs::gamepad.
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down)` — one gamepad button transition.
/// `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press, 0=release.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
_env: JNIEnv,
_this: JObject,
handle: jlong,
bit: jint,
down: jboolean,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_input(&InputEvent {
kind: InputKind::GamepadButton,
_pad: [0; 3],
code: bit as u32,
x: i32::from(down != 0),
y: 0,
flags: 0, // pad index 0 — single-pad model
});
}
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value)` — one gamepad axis update.
/// `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16 (32768..32767, +y=up) or
/// trigger 0..255.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
_env: JNIEnv,
_this: JObject,
handle: jlong,
axis_id: jint,
value: jint,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_input(&InputEvent {
kind: InputKind::GamepadAxis,
_pad: [0; 3],
code: axis_id as u32,
x: value,
y: 0,
flags: 0, // pad index 0 — single-pad model
});
}
-40
View File
@@ -1,40 +0,0 @@
[package]
name = "punktfunk-client-linux"
description = "Native Linux punktfunk/1 client — GTK4/libadwaita shell, FFmpeg decode, PipeWire audio, SDL3 gamepads"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[[bin]]
name = "punktfunk-client"
path = "src/main.rs"
# Everything is Linux-gated so `cargo build --workspace` stays green on macOS (the Mac
# client lives in clients/apple); on other platforms this builds as a stub binary.
[target.'cfg(target_os = "linux")'.dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
# UI shell. GraphicsOffload needs GTK ≥ 4.14; black-background ≥ 4.16. AlertDialog/
# PreferencesDialog need libadwaita ≥ 1.5.
gtk = { package = "gtk4", version = "0.11", features = ["v4_16"] }
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
async-channel = "2"
# Video decode (same FFmpeg pin as the host) and audio.
ffmpeg-next = "8"
opus = "0.3"
pipewire = "0.9"
# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
# need the hidapi driver).
sdl3 = { version = "0.18", features = ["hidapi"] }
mdns-sd = "0.20"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
-579
View File
@@ -1,579 +0,0 @@
//! The application shell: window, navigation, trust dialogs, session lifecycle.
use crate::session::{SessionEvent, SessionParams};
use crate::trust::{KnownHost, KnownHosts, Settings};
use crate::ui_hosts::ConnectRequest;
use adw::prelude::*;
use gtk::{gdk, glib};
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref};
use std::cell::RefCell;
use std::rc::Rc;
const APP_ID: &str = "io.unom.Punktfunk";
struct App {
window: adw::ApplicationWindow,
nav: adw::NavigationView,
toasts: adw::ToastOverlay,
settings: Rc<RefCell<Settings>>,
identity: (String, String),
/// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback.
gamepad: crate::gamepad::GamepadService,
/// One session at a time — ignore connects while one is starting/running.
busy: std::cell::Cell<bool>,
}
impl App {
fn toast(&self, msg: &str) {
self.toasts.add_toast(adw::Toast::new(msg));
}
}
pub fn run() -> glib::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
if let Some(pin) = arg_value("--pair") {
return headless_pair(&pin);
}
let app = adw::Application::builder().application_id(APP_ID).build();
app.connect_activate(build_ui);
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
// keeps GApplication from rejecting unknown options.
app.run_with_args(&[] as &[&str])
}
/// The value following `flag` in argv, if present (`--flag value`).
fn arg_value(flag: &str) -> Option<String> {
std::env::args()
.skip_while(|a| a != flag)
.nth(1)
.filter(|v| !v.starts_with("--"))
}
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
/// store the streaming path uses (same binary), so pairing here makes the stream work.
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
fn headless_pair(pin: &str) -> glib::ExitCode {
let Some(target) = arg_value("--connect") else {
eprintln!("--pair requires --connect host[:port]");
return glib::ExitCode::FAILURE;
};
let (addr, port) = match target.rsplit_once(':') {
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
None => (target.clone(), 9777),
};
// The label the HOST stores this client under (its paired-devices list).
let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string());
let identity = match crate::trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
eprintln!("client identity: {e:#}");
return glib::ExitCode::FAILURE;
}
};
match NativeClient::pair(
&addr,
port,
(&identity.0, &identity.1),
pin.trim(),
&name,
std::time::Duration::from_secs(90),
) {
Ok(fp) => {
let fp_hex = crate::trust::hex(&fp);
let mut known = KnownHosts::load();
known.upsert(KnownHost {
name: arg_value("--host-label").unwrap_or_else(|| addr.clone()),
addr: addr.clone(),
port,
fp_hex: fp_hex.clone(),
paired: true,
});
let _ = known.save();
println!("paired {addr}:{port} fp={fp_hex}");
glib::ExitCode::SUCCESS
}
Err(e) => {
eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)");
glib::ExitCode::FAILURE
}
}
}
/// `--connect host[:port]` — skip the hosts page and start a session immediately
/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host
/// already pinned at this address connects silently on its stored pin; an unknown host is
/// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are
/// unset, so `initiate_connect`'s manual arm mandates pairing).
fn cli_connect_request() -> Option<ConnectRequest> {
let args: Vec<String> = std::env::args().collect();
let target = args
.iter()
.skip_while(|a| *a != "--connect")
.nth(1)?
.clone();
let (addr, port) = match target.rsplit_once(':') {
Some((a, p)) => (a.to_string(), p.parse().ok()?),
None => (target.clone(), 9777),
};
Some(ConnectRequest {
name: addr.clone(),
addr,
port,
fp_hex: None,
pair_optional: false,
})
}
fn build_ui(gtk_app: &adw::Application) {
let identity = match crate::trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
tracing::error!("client identity: {e:#}");
std::process::exit(1);
}
};
let nav = adw::NavigationView::new();
let toasts = adw::ToastOverlay::new();
toasts.set_child(Some(&nav));
let window = adw::ApplicationWindow::builder()
.application(gtk_app)
.title("Punktfunk")
.default_width(1100)
.default_height(720)
.content(&toasts)
.build();
let app = Rc::new(App {
window: window.clone(),
nav: nav.clone(),
toasts,
settings: Rc::new(RefCell::new(Settings::load())),
identity,
gamepad: crate::gamepad::GamepadService::start(),
busy: std::cell::Cell::new(false),
});
let hosts_page = crate::ui_hosts::new(
{
let app = app.clone();
Rc::new(move |req| initiate_connect(app.clone(), req))
},
{
let app = app.clone();
Rc::new(move || {
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad)
})
},
{
let app = app.clone();
Rc::new(move |req| speed_test(app.clone(), req))
},
);
nav.add(&hosts_page);
window.present();
if let Some(req) = cli_connect_request() {
initiate_connect(app, req);
}
}
/// The trust gate in front of every connect. The host is the policy authority (it
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
/// its trust UI from that:
/// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently.
/// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer
/// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of
/// the advertised policy.
/// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a);
/// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is
/// mandatory (rule 3b).
///
/// A new host is never auto-connected without a stored pin or an explicit trust decision.
fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
if app.busy.get() {
return;
}
let known = KnownHosts::load();
match &req.fp_hex {
Some(fp_hex) => {
if known.find_by_fp(fp_hex).is_some() {
// Rule 1: pinned fingerprint matches — silent connect.
start_session(app, req.clone(), crate::trust::parse_hex32(fp_hex));
} else if known.find_by_addr(&req.addr, req.port).is_some() {
// Rule 2: we trust a host at this address but the fingerprint changed —
// the impostor signal. Re-pair via the PIN ceremony (no TOFU shortcut).
app.toast("Host fingerprint changed — re-pair with a PIN to continue");
pin_dialog(app, req);
} else if req.pair_optional {
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
tofu_dialog(app, req);
} else {
// Rule 3b: pair=required or unknown policy — PIN pairing is mandatory.
pin_dialog(app, req);
}
}
None => {
// Manual entry (no advertised fingerprint). A known address connects silently
// on its stored pin (rule 1); an unknown one must pair — never silent TOFU.
match known
.find_by_addr(&req.addr, req.port)
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex))
{
Some(pin) => start_session(app, req, Some(pin)),
None => pin_dialog(app, req), // rule 3b
}
}
}
}
/// First contact with a discovered host: show the advertised fingerprint and let the user
/// trust it (TOFU), run the PIN ceremony instead, or walk away.
fn tofu_dialog(app: Rc<App>, req: ConnectRequest) {
let fp = req.fp_hex.clone().unwrap_or_default();
let dialog = adw::AlertDialog::new(
Some("New Host"),
Some(&format!(
"{} at {}:{}\n\nCertificate fingerprint:\n{}\n\nPairing with a PIN verifies it; \
trusting accepts it as-is.",
req.name, req.addr, req.port, fp
)),
);
dialog.add_responses(&[
("cancel", "Cancel"),
("pair", "Pair with PIN…"),
("trust", "Trust & Connect"),
]);
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("trust"));
dialog.set_close_response("cancel");
let parent = app.window.clone();
dialog.connect_response(None, move |_, response| match response {
"trust" => {
let mut known = KnownHosts::load();
known.upsert(KnownHost {
name: req.name.clone(),
addr: req.addr.clone(),
port: req.port,
fp_hex: fp.clone(),
paired: false,
});
let _ = known.save();
start_session(app.clone(), req.clone(), crate::trust::parse_hex32(&fp));
}
"pair" => pin_dialog(app.clone(), req.clone()),
_ => {}
});
dialog.present(Some(&parent));
}
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
/// of it pins the host's certificate (and registers ours) with no offline-guessable
/// transcript.
fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
let entry = gtk::Entry::builder()
.input_purpose(gtk::InputPurpose::Digits)
.placeholder_text("4-digit PIN shown by the host")
.activates_default(true)
.build();
let dialog = adw::AlertDialog::new(
Some("Pair with PIN"),
Some(&format!(
"Arm pairing on {} (console or web UI), then enter the PIN it displays.",
req.name
)),
);
dialog.set_extra_child(Some(&entry));
dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]);
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("pair"));
dialog.set_close_response("cancel");
let parent = app.window.clone();
dialog.connect_response(Some("pair"), move |_, _| {
let pin = entry.text().to_string();
let app = app.clone();
let req = req.clone();
let identity = app.identity.clone();
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
let (host, port, name) = (req.addr.clone(), req.port, glib::host_name().to_string());
std::thread::spawn(move || {
let result = NativeClient::pair(
&host,
port,
(&identity.0, &identity.1),
pin.trim(),
&name,
std::time::Duration::from_secs(90),
)
.map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)"));
let _ = tx.send_blocking(result);
});
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(fp)) => {
let fp_hex = crate::trust::hex(&fp);
let mut known = KnownHosts::load();
known.upsert(KnownHost {
name: req.name.clone(),
addr: req.addr.clone(),
port: req.port,
fp_hex,
paired: true,
});
let _ = known.save();
app.toast("Paired — connecting…");
start_session(app.clone(), req, Some(fp));
}
Ok(Err(msg)) => app.toast(&msg),
Err(_) => {}
}
});
});
dialog.present(Some(&parent));
}
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
fn speed_test(app: Rc<App>, req: ConnectRequest) {
if app.busy.replace(true) {
return;
}
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
let status = gtk::Label::new(Some("Connecting…"));
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
dialog.set_extra_child(Some(&status));
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
dialog.set_response_enabled("apply", false);
dialog.set_close_response("close");
dialog.present(Some(&app.window));
let (tx, rx) =
async_channel::bounded::<Result<punktfunk_core::client::ProbeOutcome, String>>(1);
let identity = app.identity.clone();
let (host, port) = (req.addr.clone(), req.port);
std::thread::spawn(move || {
let result = (|| {
let c = NativeClient::connect(
&host,
port,
punktfunk_core::config::Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
CompositorPref::Auto,
GamepadPref::Auto,
0, // bitrate_kbps (host default)
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
None, // launch: speed-test probe connect, no game
pin,
Some(identity),
std::time::Duration::from_secs(15),
)
.map_err(|e| format!("connect: {e:?}"))?;
c.request_probe(3_000_000, 2_000)
.map_err(|e| format!("probe: {e:?}"))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
std::thread::sleep(std::time::Duration::from_millis(250));
let r = c.probe_result();
if r.done {
// Let the last UDP shards land before tearing down.
std::thread::sleep(std::time::Duration::from_millis(400));
return Ok(c.probe_result());
}
if std::time::Instant::now() > deadline {
return Err("probe timed out".to_string());
}
}
})();
let _ = tx.send_blocking(result);
});
glib::spawn_future_local(async move {
let outcome = rx.recv().await;
app.busy.set(false);
match outcome {
Ok(Ok(r)) => {
let mbps = f64::from(r.throughput_kbps) / 1000.0;
let recommended_kbps = r.throughput_kbps / 10 * 7;
status.set_text(&format!(
"{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s",
r.loss_pct,
f64::from(recommended_kbps) / 1000.0,
));
dialog.set_response_enabled("apply", true);
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
let settings = app.settings.clone();
let toasts = app.toasts.clone();
dialog.connect_response(Some("apply"), move |_, _| {
let mut s = settings.borrow_mut();
s.bitrate_kbps = recommended_kbps;
s.save();
toasts.add_toast(adw::Toast::new(&format!(
"Bitrate set to {:.0} Mbit/s",
f64::from(recommended_kbps) / 1000.0
)));
});
}
Ok(Err(msg)) => status.set_text(&msg),
Err(_) => {}
}
});
}
/// The mode to request: explicit settings, with `0` fields resolved to the native
/// size/refresh of the monitor the window currently occupies (mirrors the Swift client's
/// native-display default).
fn resolve_mode(app: &App) -> punktfunk_core::config::Mode {
let s = app.settings.borrow();
let mut mode = punktfunk_core::config::Mode {
width: s.width,
height: s.height,
refresh_hz: s.refresh_hz,
};
if mode.width == 0 || mode.refresh_hz == 0 {
let monitor = app
.window
.surface()
.zip(gdk::Display::default())
.and_then(|(surf, d)| d.monitor_at_surface(&surf));
if let Some(m) = monitor {
let geo = m.geometry();
let scale = m.scale_factor().max(1);
if mode.width == 0 {
mode.width = (geo.width() * scale) as u32;
mode.height = (geo.height() * scale) as u32;
}
if mode.refresh_hz == 0 {
mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32;
}
}
}
// No monitor info (early call, odd compositor) — a sane floor.
if mode.width == 0 {
(mode.width, mode.height) = (1920, 1080);
}
if mode.refresh_hz == 0 {
mode.refresh_hz = 60;
}
mode
}
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
if app.busy.replace(true) {
return;
}
let mode = resolve_mode(&app);
let s = app.settings.borrow();
let params = SessionParams {
host: req.addr.clone(),
port: req.port,
mode,
compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto),
// "Automatic" matches the physical pad (Swift parity); an explicit choice wins.
gamepad: match GamepadPref::from_name(&s.gamepad) {
Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(),
Some(explicit) => explicit,
},
bitrate_kbps: s.bitrate_kbps,
mic_enabled: s.mic_enabled,
pin,
identity: app.identity.clone(),
};
let inhibit = s.inhibit_shortcuts;
drop(s);
let tofu = pin.is_none();
let mut handle = crate::session::start(params);
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
glib::spawn_future_local(async move {
let mut frames = Some(frames);
let mut page: Option<crate::ui_stream::StreamPage> = None;
while let Ok(event) = handle.events.recv().await {
match event {
SessionEvent::Connected {
connector,
mode,
fingerprint,
} => {
// A TOFU connect just observed the real fingerprint — pin it from now on.
if tofu {
let fp_hex = crate::trust::hex(&fingerprint);
let mut known = KnownHosts::load();
known.upsert(KnownHost {
name: req.name.clone(),
addr: req.addr.clone(),
port: req.port,
fp_hex: fp_hex.clone(),
paired: false,
});
let _ = known.save();
app.toast(&format!(
"Trusted on first use — fingerprint {}",
&fp_hex[..16]
));
}
tracing::debug!(?mode, "connected — pushing stream page");
let title = format!(
"{} · {}×{}@{}",
req.name, mode.width, mode.height, mode.refresh_hz
);
app.gamepad.attach(connector.clone());
let p = crate::ui_stream::new(
&app.window,
connector,
frames.take().expect("Connected delivered once"),
app.gamepad.escape_events(),
handle.stop.clone(),
inhibit,
&title,
);
app.nav.push(&p.page);
page = Some(p);
}
SessionEvent::Stats(s) => {
if let Some(p) = &page {
p.update_stats(s);
}
}
SessionEvent::Failed {
msg,
trust_rejected,
} => {
tracing::warn!(%msg, trust_rejected, "connect failed");
app.busy.set(false);
// A pinned connect rejected on trust grounds means the host's cert no
// longer matches the stored pin (rotated cert or impostor) — route to
// the PIN ceremony to re-establish trust rather than dead-ending.
if trust_rejected && !tofu {
app.toast("Host fingerprint changed — re-pair with a PIN to continue");
pin_dialog(app.clone(), req.clone());
} else {
app.toast(&msg);
}
break;
}
SessionEvent::Ended(err) => {
app.gamepad.detach();
app.nav.pop_to_tag("hosts");
if let Some(e) = err {
app.toast(&e);
}
app.busy.set(false);
break;
}
}
}
});
}
-382
View File
@@ -1,382 +0,0 @@
//! Audio: playback (decoded PCM → a PipeWire playback stream) and the microphone uplink
//! (PipeWire capture → Opus → 0xCB datagrams, the inverse of the host's virtual mic).
//!
//! Playback mirrors the host's virtual-mic producer (`punktfunk-host::audio::linux`) with
//! the same adaptive jitter buffer: the session pump pushes 5 ms Opus-decoded chunks on
//! the network clock; PipeWire pulls whole quanta on the device clock. Prime to ~3
//! quanta before producing, cap the ring so latency stays bounded, re-prime after a real
//! drain.
use anyhow::{Context, Result};
use punktfunk_core::client::NativeClient;
use std::collections::VecDeque;
use std::sync::mpsc::{Receiver, SyncSender, TrySendError};
use std::sync::Arc;
const SAMPLE_RATE: u32 = 48_000;
const CHANNELS: usize = 2;
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
const MIC_FRAME: usize = 960;
struct Terminate;
pub struct AudioPlayer {
pcm_tx: SyncSender<Vec<f32>>,
quit_tx: pipewire::channel::Sender<Terminate>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl AudioPlayer {
/// Spawn the PipeWire playback thread. Failure (no PipeWire in the session) is
/// survivable — the caller streams video-only.
pub fn spawn() -> Result<AudioPlayer> {
// 64 × 5 ms = 320 ms of slack between the pump and the PipeWire loop.
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
let thread = std::thread::Builder::new()
.name("punktfunk-audio".into())
.spawn(move || {
if let Err(e) = pw_thread(pcm_rx, quit_rx) {
tracing::warn!(error = %e, "audio playback thread ended");
}
})
.context("spawn audio thread")?;
Ok(AudioPlayer {
pcm_tx,
quit_tx,
thread: Some(thread),
})
}
/// Queue one interleaved-stereo f32 chunk. Drops the chunk if the PipeWire side is
/// wedged (the renderer conceals the gap; never block the session pump).
pub fn push(&self, pcm: Vec<f32>) {
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
// Thread already dead — Drop will reap it; nothing to do per-chunk.
}
}
}
impl Drop for AudioPlayer {
fn drop(&mut self) {
let _ = self.quit_tx.send(Terminate);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Producer-side state: incoming decoded PCM and the ring the process callback drains.
struct PlayerData {
rx: Receiver<Vec<f32>>,
ring: VecDeque<f32>,
primed: bool,
}
fn pw_thread(
pcm_rx: Receiver<Vec<f32>>,
quit_rx: pipewire::channel::Receiver<Terminate>,
) -> Result<()> {
use pipewire as pw;
use pw::{properties::properties, spa};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use spa::pod::Pod;
static PW_INIT: std::sync::Once = std::sync::Once::new();
PW_INIT.call_once(pw::init);
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
let core = context
.connect_rc(None)
.context("pw connect (is PipeWire running in this session?)")?;
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
let mainloop = mainloop.clone();
move |_| mainloop.quit()
});
let stream = pw::stream::StreamBox::new(
&core,
"punktfunk-client",
properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
*pw::keys::MEDIA_ROLE => "Game",
*pw::keys::NODE_NAME => "punktfunk-client",
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
*pw::keys::NODE_LATENCY => "240/48000",
},
)
.context("pw Stream")?;
let ud = PlayerData {
rx: pcm_rx,
ring: VecDeque::new(),
primed: false,
};
let _listener = stream
.add_local_listener_with_user_data(ud)
.state_changed(|_s, _ud, old, new| {
tracing::debug!(?old, ?new, "pipewire playback stream state");
})
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
while let Ok(chunk) = ud.rx.try_recv() {
ud.ring.extend(chunk);
}
let stride = 4 * CHANNELS; // F32LE interleaved
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data = &mut datas[0];
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
let want = want_frames * CHANNELS;
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
// genuine drain.
let target = (3 * want).clamp(720 * CHANNELS, 9600 * CHANNELS);
while ud.ring.len() > target.max(want) + want {
ud.ring.pop_front();
}
if !ud.primed && ud.ring.len() >= target {
ud.primed = true;
}
let n_frames = if let Some(slice) = data.data() {
for k in 0..want {
let s = if ud.primed {
ud.ring.pop_front().unwrap_or(0.0)
} else {
0.0
};
let off = k * 4;
slice[off..off + 4].copy_from_slice(&s.to_le_bytes());
}
want_frames
} else {
0
};
if ud.ring.is_empty() {
ud.primed = false;
}
let chunk = data.chunk_mut();
*chunk.offset_mut() = 0;
*chunk.stride_mut() = stride as _;
*chunk.size_mut() = (stride * n_frames) as _;
}));
if outcome.is_err() {
tracing::error!("panic in pipewire playback callback");
}
})
.register()
.context("register playback listener")?;
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_rate(SAMPLE_RATE);
info.set_channels(CHANNELS as u32);
let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: info.into(),
};
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.context("serialize format pod")?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).context("pod from bytes")?];
stream
.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
&mut params,
)
.context("pw stream connect")?;
mainloop.run();
tracing::debug!("pipewire playback loop exited");
Ok(())
}
/// The microphone uplink: capture the default input device, Opus-encode 20 ms chunks,
/// ship them as 0xCB datagrams into the host's virtual PipeWire source.
pub struct MicStreamer {
quit_tx: pipewire::channel::Sender<Terminate>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl MicStreamer {
pub fn spawn(connector: Arc<NativeClient>) -> Result<MicStreamer> {
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
let thread = std::thread::Builder::new()
.name("punktfunk-mic".into())
.spawn(move || {
if let Err(e) = mic_thread(&connector, quit_rx) {
tracing::warn!(error = %e, "mic uplink thread ended");
}
})
.context("spawn mic thread")?;
Ok(MicStreamer {
quit_tx,
thread: Some(thread),
})
}
}
impl Drop for MicStreamer {
fn drop(&mut self) {
let _ = self.quit_tx.send(Terminate);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Capture-side state: accumulated PCM and the Opus encoder (encoding a 20 ms frame is
/// ~100 µs — fine inside the process callback).
struct MicData {
connector: Arc<NativeClient>,
ring: VecDeque<f32>,
encoder: opus::Encoder,
seq: u32,
out: Vec<u8>,
}
fn mic_thread(
connector: &Arc<NativeClient>,
quit_rx: pipewire::channel::Receiver<Terminate>,
) -> Result<()> {
use pipewire as pw;
use pw::{properties::properties, spa};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use spa::pod::Pod;
static PW_INIT: std::sync::Once = std::sync::Once::new();
PW_INIT.call_once(pw::init);
let mut encoder =
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Stereo, opus::Application::Voip)
.map_err(|e| anyhow::anyhow!("opus encoder: {e}"))?;
let _ = encoder.set_bitrate(opus::Bitrate::Bits(64_000));
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw mic MainLoop")?;
let context = pw::context::ContextRc::new(&mainloop, None).context("pw mic Context")?;
let core = context
.connect_rc(None)
.context("pw mic connect (is PipeWire running in this session?)")?;
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
let mainloop = mainloop.clone();
move |_| mainloop.quit()
});
let stream = pw::stream::StreamBox::new(
&core,
"punktfunk-mic-capture",
properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Communication",
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
},
)
.context("pw mic Stream")?;
let ud = MicData {
connector: connector.clone(),
ring: VecDeque::new(),
encoder,
seq: 0,
out: vec![0u8; 4000],
};
let _listener = stream
.add_local_listener_with_user_data(ud)
.state_changed(|_s, _ud, old, new| {
tracing::debug!(?old, ?new, "pipewire mic capture stream state");
})
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data = &mut datas[0];
let n = data.chunk().size() as usize;
if let Some(slice) = data.data() {
for s in slice[..n.min(slice.len())].chunks_exact(4) {
ud.ring
.push_back(f32::from_le_bytes([s[0], s[1], s[2], s[3]]));
}
}
// Ship every complete 20 ms stereo frame.
while ud.ring.len() >= MIC_FRAME * CHANNELS {
let pcm: Vec<f32> = ud.ring.drain(..MIC_FRAME * CHANNELS).collect();
match ud.encoder.encode_float(&pcm, &mut ud.out) {
Ok(len) => {
let pts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let _ = ud.connector.send_mic(ud.seq, pts, ud.out[..len].to_vec());
ud.seq = ud.seq.wrapping_add(1);
}
Err(e) => tracing::debug!(error = %e, "opus mic encode"),
}
}
}));
if outcome.is_err() {
tracing::error!("panic in pipewire mic callback");
}
})
.register()
.context("register mic listener")?;
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_rate(SAMPLE_RATE);
info.set_channels(CHANNELS as u32);
let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: info.into(),
};
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.context("serialize mic format pod")?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).context("mic pod from bytes")?];
stream
.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
&mut params,
)
.context("pw mic stream connect")?;
mainloop.run();
tracing::debug!("pipewire mic capture loop exited");
Ok(())
}
@@ -1,76 +0,0 @@
//! LAN host discovery: browse the host's mDNS advert (`_punktfunk._udp`, TXT keys
//! `fp`/`pair`/`id` — see the host crate's `discovery.rs`) on a worker thread and stream
//! results to the UI.
use mdns_sd::{ServiceDaemon, ServiceEvent};
#[derive(Clone, Debug)]
pub struct DiscoveredHost {
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
pub key: String,
pub name: String,
pub addr: String,
pub port: u16,
/// Host certificate fingerprint to pin (lowercase hex), empty if not advertised.
pub fp_hex: String,
/// Pairing requirement: `"required"` or `"optional"`.
pub pair: String,
}
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
/// dropped (the send fails) or the daemon dies.
pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
let (tx, rx) = async_channel::unbounded();
std::thread::Builder::new()
.name("punktfunk-mdns".into())
.spawn(move || {
let daemon = match ServiceDaemon::new() {
Ok(d) => d,
Err(e) => {
tracing::warn!(error = %e, "mDNS daemon failed — discovery disabled");
return;
}
};
let receiver = match daemon.browse("_punktfunk._udp.local.") {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
return;
}
};
while let Ok(event) = receiver.recv() {
if let ServiceEvent::ServiceResolved(info) = event {
let props = info.get_properties();
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
else {
continue;
};
let id = val("id");
let host = DiscoveredHost {
key: if id.is_empty() {
info.get_fullname().to_string()
} else {
id
},
name: info
.get_fullname()
.split('.')
.next()
.unwrap_or("?")
.to_string(),
addr,
port: info.get_port(),
fp_hex: val("fp"),
pair: val("pair"),
};
if tx.send_blocking(host).is_err() {
break; // UI gone — stop browsing
}
}
}
let _ = daemon.shutdown();
})
.expect("spawn mdns thread");
rx
}
@@ -1,577 +0,0 @@
//! App-lifetime gamepad service over SDL3 (mirrors the Swift client's `GamepadManager` +
//! `GamepadCapture`/`GamepadFeedback`).
//!
//! One worker thread owns SDL for the process lifetime: it tracks connected pads for the
//! Settings UI, selects the ONE controller forwarded as pad 0 (user pin, else the most
//! recently connected), and — while a session is attached — forwards buttons/axes,
//! DualSense touchpad contacts and motion samples (0xCC), and renders feedback: rumble on
//! every pad, lightbar via SDL, and on a real DualSense the raw effects packet
//! (adaptive-trigger blocks replayed verbatim, player LEDs). Held state is zeroed on the
//! wire when the active pad switches or the session detaches, so nothing sticks down.
//!
//! This thread is also the single consumer of the rumble and HID-output pull planes.
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::GamepadPref;
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
/// us gyro in rad/s and accel in m/s²; the DualSense report wants raw LSBs.
const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
const ACCEL_LSB_PER_G: f32 = 10_000.0;
const G: f32 = 9.80665;
/// The controller "escape" chord (Moonlight convention): L1 + R1 + Start + Select held
/// together. Intercepted by the client to leave fullscreen + release input capture — the
/// Deck has no F11 key and fullscreen hides the window chrome, so with a controller this
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
/// is leaving anyway); we only also raise the escape signal.
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
#[derive(Clone, Debug)]
pub struct PadInfo {
pub id: u32,
pub name: String,
pub is_dualsense: bool,
}
enum Ctl {
Attach(Arc<NativeClient>),
Detach,
Pin(Option<u32>),
}
#[derive(Clone)]
pub struct GamepadService {
pads: Arc<Mutex<Vec<PadInfo>>>,
active: Arc<Mutex<Option<PadInfo>>>,
pinned: Arc<Mutex<Option<u32>>>,
ctl: Sender<Ctl>,
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
/// fullscreen + release capture.
escape_rx: async_channel::Receiver<()>,
}
impl GamepadService {
pub fn start() -> GamepadService {
let pads = Arc::new(Mutex::new(Vec::new()));
let active = Arc::new(Mutex::new(None));
let pinned = Arc::new(Mutex::new(None));
let (ctl, ctl_rx) = std::sync::mpsc::channel();
let (escape_tx, escape_rx) = async_channel::unbounded();
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-gamepad".into())
.spawn(move || {
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
}
})
{
tracing::warn!(error = %e, "gamepad service failed to start");
}
GamepadService {
pads,
active,
pinned,
ctl,
escape_rx,
}
}
/// A receiver that yields one `()` each time the controller escape chord is pressed.
/// A fresh clone per call (shared mpmc channel); the stream page spawns a future on it.
pub fn escape_events(&self) -> async_channel::Receiver<()> {
self.escape_rx.clone()
}
pub fn pads(&self) -> Vec<PadInfo> {
self.pads.lock().unwrap().clone()
}
pub fn active(&self) -> Option<PadInfo> {
self.active.lock().unwrap().clone()
}
pub fn pinned(&self) -> Option<u32> {
*self.pinned.lock().unwrap()
}
pub fn set_pinned(&self, id: Option<u32>) {
let _ = self.ctl.send(Ctl::Pin(id));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
pub fn detach(&self) {
let _ = self.ctl.send(Ctl::Detach);
}
/// What "Automatic" resolves to right now — the virtual pad matching the physical one
/// (Swift parity); no pad connected leaves the host's own default.
pub fn auto_pref(&self) -> GamepadPref {
match self.active() {
Some(p) if p.is_dualsense => GamepadPref::DualSense,
Some(_) => GamepadPref::Xbox360,
None => GamepadPref::Auto,
}
}
}
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
let _ = connector.send_input(&InputEvent {
kind,
_pad: [0; 3],
code,
x,
y: 0,
flags: 0, // pad index 0 — single-pad model
});
}
fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
use sdl3::gamepad::Button;
Some(match b {
Button::South => wire::BTN_A,
Button::East => wire::BTN_B,
Button::West => wire::BTN_X,
Button::North => wire::BTN_Y,
Button::Back => wire::BTN_BACK,
Button::Start => wire::BTN_START,
Button::Guide => wire::BTN_GUIDE,
Button::LeftStick => wire::BTN_LS_CLICK,
Button::RightStick => wire::BTN_RS_CLICK,
Button::LeftShoulder => wire::BTN_LB,
Button::RightShoulder => wire::BTN_RB,
Button::DPadUp => wire::BTN_DPAD_UP,
Button::DPadDown => wire::BTN_DPAD_DOWN,
Button::DPadLeft => wire::BTN_DPAD_LEFT,
Button::DPadRight => wire::BTN_DPAD_RIGHT,
Button::Touchpad => wire::BTN_TOUCHPAD,
_ => return None,
})
}
/// SDL axis → (wire axis id, wire value). SDL sticks are +y = down; the wire (XInput
/// convention) is +y = up. SDL triggers span 0..32767; the wire wants 0..255.
fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
use sdl3::gamepad::Axis;
match axis {
Axis::LeftX => (wire::AXIS_LS_X, v as i32),
Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)),
Axis::RightX => (wire::AXIS_RS_X, v as i32),
Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)),
Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7),
Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7),
}
}
/// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the
/// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim.
/// Enable bits select only the fields each update touches, so rumble (driven separately
/// through SDL) and untouched fields keep their state.
#[derive(Default)]
struct Ds5Feedback;
impl Ds5Feedback {
const RIGHT_TRIGGER: usize = 10;
const LEFT_TRIGGER: usize = 21;
const PAD_LIGHTS: usize = 43;
const LED_RGB: usize = 44;
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
let mut p = [0u8; 47];
let (flag, off) = if which == 1 {
(0x04, Self::RIGHT_TRIGGER)
} else {
(0x08, Self::LEFT_TRIGGER)
};
p[0] = flag;
let n = effect.len().min(11);
p[off..off + n].copy_from_slice(&effect[..n]);
p
}
fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x04; // lightbar enable
p[Self::LED_RGB] = r;
p[Self::LED_RGB + 1] = g;
p[Self::LED_RGB + 2] = b;
p
}
fn player_packet(bits: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x10; // player-LED enable
p[Self::PAD_LIGHTS] = bits & 0x1F;
p
}
}
struct Worker {
subsystem: sdl3::GamepadSubsystem,
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
/// Connection order; the most recently connected is the auto selection.
order: Vec<u32>,
pinned: Option<u32>,
attached: Option<Arc<NativeClient>>,
/// Wire state of the active pad — zeroed on the wire at switch/detach.
last_axis: [i32; 6],
held_buttons: Vec<u32>,
last_accel: [i16; 3],
/// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>,
/// The escape chord is fully held — latched so it fires once, not every poll.
chord_armed: bool,
}
impl Worker {
fn active_id(&self) -> Option<u32> {
self.pinned
.filter(|id| self.opened.contains_key(id))
.or_else(|| self.order.last().copied())
}
fn pad_info(&self, id: u32) -> Option<PadInfo> {
let pad = self.opened.get(&id)?;
Some(PadInfo {
id,
name: pad.name().unwrap_or_else(|| "Controller".into()),
is_dualsense: matches!(
self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
sdl3::gamepad::GamepadType::PS5
),
})
}
/// Zero everything the host believes is held — on pad switch and detach.
fn flush_held(&mut self) {
if let Some(c) = &self.attached {
for b in self.held_buttons.drain(..) {
send(c, InputKind::GamepadButton, b, 0);
}
for (id, v) in self.last_axis.iter_mut().enumerate() {
if *v != 0 && *v != i32::MIN {
send(c, InputKind::GamepadAxis, id as u32, 0);
}
*v = i32::MIN;
}
} else {
self.held_buttons.clear();
self.last_axis = [i32::MIN; 6];
}
}
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
/// fires once per press). Called after each button-down updates `held_buttons`.
fn maybe_fire_escape(&mut self) {
if self.chord_armed {
return;
}
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
self.chord_armed = true;
let _ = self.escape_tx.try_send(());
tracing::info!("gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen");
}
}
/// Re-arm once the chord is broken (any of its buttons released).
fn rearm_escape(&mut self) {
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
self.chord_armed = false;
}
}
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
fn set_sensors(&mut self, enabled: bool) {
let Some(id) = self.active_id() else { return };
if let Some(pad) = self.opened.get_mut(&id) {
use sdl3::sensor::SensorType;
for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
if unsafe { pad.has_sensor(s) } {
let _ = pad.sensor_set_enabled(s, enabled);
}
}
}
}
}
#[allow(clippy::too_many_lines)]
fn run(
pads_out: &Mutex<Vec<PadInfo>>,
active_out: &Mutex<Option<PadInfo>>,
pinned_out: &Mutex<Option<u32>>,
ctl: &Receiver<Ctl>,
escape_tx: &async_channel::Sender<()>,
) -> Result<(), String> {
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
// own thread.
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
let sdl = sdl3::init().map_err(|e| e.to_string())?;
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
let mut w = Worker {
subsystem,
opened: HashMap::new(),
order: Vec::new(),
pinned: None,
attached: None,
last_axis: [i32::MIN; 6],
held_buttons: Vec::new(),
last_accel: [0; 3],
escape_tx: escape_tx.clone(),
chord_armed: false,
};
let publish = |w: &Worker| {
let mut list: Vec<PadInfo> = w.order.iter().filter_map(|&id| w.pad_info(id)).collect();
list.reverse(); // most recent first — the Settings list order
*pads_out.lock().unwrap() = list;
*active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id));
*pinned_out.lock().unwrap() = w.pinned;
};
loop {
// Control plane from the UI thread.
loop {
match ctl.try_recv() {
Ok(Ctl::Attach(c)) => {
w.attached = Some(c);
w.last_axis = [i32::MIN; 6];
w.set_sensors(true);
}
Ok(Ctl::Detach) => {
w.flush_held();
w.set_sensors(false);
w.attached = None;
}
Ok(Ctl::Pin(id)) => {
let before = w.active_id();
w.pinned = id;
if w.active_id() != before {
w.flush_held();
if w.attached.is_some() {
w.set_sensors(true);
}
}
publish(&w);
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone
}
}
while let Some(event) = pump.poll_event() {
use sdl3::event::Event;
let active = w.active_id();
match event {
Event::ControllerDeviceAdded { which, .. } => {
if !w.opened.contains_key(&which) {
match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) {
Ok(pad) => {
tracing::info!(
name = pad.name().unwrap_or_default(),
"gamepad attached"
);
w.opened.insert(which, pad);
w.order.push(which);
if w.attached.is_some() && w.active_id() == Some(which) {
w.set_sensors(true);
}
publish(&w);
}
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
}
}
}
Event::ControllerDeviceRemoved { which, .. } => {
if w.opened.remove(&which).is_some() {
w.order.retain(|&id| id != which);
if active == Some(which) {
w.flush_held();
}
tracing::info!("gamepad detached");
publish(&w);
}
}
Event::ControllerButtonDown { which, button, .. }
if active == Some(which) && w.attached.is_some() =>
{
if let Some(bit) = button_bit(button) {
w.held_buttons.push(bit);
send(
w.attached.as_ref().unwrap(),
InputKind::GamepadButton,
bit,
1,
);
w.maybe_fire_escape();
}
}
Event::ControllerButtonUp { which, button, .. }
if active == Some(which) && w.attached.is_some() =>
{
if let Some(bit) = button_bit(button) {
w.held_buttons.retain(|&b| b != bit);
send(
w.attached.as_ref().unwrap(),
InputKind::GamepadButton,
bit,
0,
);
w.rearm_escape();
}
}
Event::ControllerAxisMotion {
which, axis, value, ..
} if active == Some(which) && w.attached.is_some() => {
let (id, v) = axis_value(axis, value);
if w.last_axis[id as usize] != v {
w.last_axis[id as usize] = v;
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
}
}
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
Event::ControllerTouchpadDown {
which,
finger,
x,
y,
..
}
| Event::ControllerTouchpadMotion {
which,
finger,
x,
y,
..
} if active == Some(which) && w.attached.is_some() => {
let _ = w
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: true,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
}
Event::ControllerTouchpadUp {
which,
finger,
x,
y,
..
} if active == Some(which) && w.attached.is_some() => {
let _ = w
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: false,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
}
// Motion: accel events update the cache; each gyro event ships a sample
// (the DualSense reports both at ~250 Hz). Scale convention shared with
// the Swift client — sign/scale derived, not yet live-verified.
Event::ControllerSensorUpdated {
which,
sensor,
data,
..
} if active == Some(which) && w.attached.is_some() => {
use sdl3::sensor::SensorType;
match sensor {
SensorType::Accelerometer => {
for (i, v) in data.iter().enumerate() {
w.last_accel[i] =
(v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16;
}
}
SensorType::Gyroscope => {
let mut gyro = [0i16; 3];
for (i, v) in data.iter().enumerate() {
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
}
let _ =
w.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Motion {
pad: 0,
gyro,
accel: w.last_accel,
});
}
_ => {}
}
}
_ => {}
}
}
// Feedback planes (this thread is their single consumer). The host re-sends
// rumble state periodically, so a generous duration with refresh-on-update is
// safe — a dropped stop heals within ~500 ms.
if let Some(connector) = w.attached.clone() {
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
if pad == 0 {
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
let _ = p.set_rumble(low, high, 5_000);
}
}
}
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let Some(id) = w.active_id() else { continue };
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense);
let Some(pad) = w.opened.get_mut(&id) else {
continue;
};
match hid {
HidOutput::Led { pad: 0, r, g, b } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
}
HidOutput::Led { pad: 0, r, g, b } => {
let _ = pad.set_led(r, g, b);
}
HidOutput::PlayerLeds { pad: 0, bits } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::player_packet(bits));
}
HidOutput::Trigger {
pad: 0,
which,
ref effect,
} if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect));
}
_ => {}
}
}
}
std::thread::sleep(Duration::from_millis(if w.attached.is_some() {
2
} else {
30
}));
}
}
-203
View File
@@ -1,203 +0,0 @@
//! Local key/button codes → the punktfunk input wire contract.
//!
//! The wire carries Windows Virtual-Key codes (the GameStream convention; the host maps
//! them back with `inject::vk_to_evdev`). GTK hands us the hardware keycode, which on
//! Wayland (and X11) is the evdev code + 8 — so this table is the exact inverse of the
//! host's, keyed on evdev codes. Layout-independent by construction: positional keys map
//! positionally, exactly what a game expects.
/// Map a Linux evdev key code to the Windows VK code the host expects. `None` = a key the
/// wire contract doesn't cover (media keys etc.) — drop it rather than guess.
pub fn evdev_to_vk(evdev: u16) -> Option<u8> {
Some(match evdev {
// --- Navigation / editing / whitespace ---
14 => 0x08, // KEY_BACKSPACE -> VK_BACK
15 => 0x09, // KEY_TAB -> VK_TAB
28 => 0x0D, // KEY_ENTER -> VK_RETURN
119 => 0x13, // KEY_PAUSE -> VK_PAUSE
58 => 0x14, // KEY_CAPSLOCK -> VK_CAPITAL
1 => 0x1B, // KEY_ESC -> VK_ESCAPE
57 => 0x20, // KEY_SPACE -> VK_SPACE
104 => 0x21, // KEY_PAGEUP -> VK_PRIOR
109 => 0x22, // KEY_PAGEDOWN -> VK_NEXT
107 => 0x23, // KEY_END -> VK_END
102 => 0x24, // KEY_HOME -> VK_HOME
105 => 0x25, // KEY_LEFT -> VK_LEFT
103 => 0x26, // KEY_UP -> VK_UP
106 => 0x27, // KEY_RIGHT -> VK_RIGHT
108 => 0x28, // KEY_DOWN -> VK_DOWN
99 => 0x2C, // KEY_SYSRQ -> VK_SNAPSHOT
110 => 0x2D, // KEY_INSERT -> VK_INSERT
111 => 0x2E, // KEY_DELETE -> VK_DELETE
// --- Digit row (KEY_1..KEY_9 are 2..10, KEY_0 is 11) ---
11 => 0x30,
2 => 0x31,
3 => 0x32,
4 => 0x33,
5 => 0x34,
6 => 0x35,
7 => 0x36,
8 => 0x37,
9 => 0x38,
10 => 0x39,
// --- Letters (evdev order is QWERTY rows, not alphabetical) ---
30 => 0x41, // A
48 => 0x42, // B
46 => 0x43, // C
32 => 0x44, // D
18 => 0x45, // E
33 => 0x46, // F
34 => 0x47, // G
35 => 0x48, // H
23 => 0x49, // I
36 => 0x4A, // J
37 => 0x4B, // K
38 => 0x4C, // L
50 => 0x4D, // M
49 => 0x4E, // N
24 => 0x4F, // O
25 => 0x50, // P
16 => 0x51, // Q
19 => 0x52, // R
31 => 0x53, // S
20 => 0x54, // T
22 => 0x55, // U
47 => 0x56, // V
17 => 0x57, // W
45 => 0x58, // X
21 => 0x59, // Y
44 => 0x5A, // Z
// --- Meta / context-menu ---
125 => 0x5B, // KEY_LEFTMETA -> VK_LWIN
126 => 0x5C, // KEY_RIGHTMETA -> VK_RWIN
127 => 0x5D, // KEY_COMPOSE -> VK_APPS
// --- Numpad ---
82 => 0x60, // KP0
79 => 0x61,
80 => 0x62,
81 => 0x63,
75 => 0x64,
76 => 0x65,
77 => 0x66,
71 => 0x67,
72 => 0x68,
73 => 0x69, // KP9
55 => 0x6A, // KEY_KPASTERISK -> VK_MULTIPLY
78 => 0x6B, // KEY_KPPLUS -> VK_ADD
96 => 0x6C, // KEY_KPENTER -> VK_SEPARATOR
74 => 0x6D, // KEY_KPMINUS -> VK_SUBTRACT
83 => 0x6E, // KEY_KPDOT -> VK_DECIMAL
98 => 0x6F, // KEY_KPSLASH -> VK_DIVIDE
// --- Function keys ---
59 => 0x70, // F1
60 => 0x71,
61 => 0x72,
62 => 0x73,
63 => 0x74,
64 => 0x75,
65 => 0x76,
66 => 0x77,
67 => 0x78,
68 => 0x79, // F10
87 => 0x7A, // F11
88 => 0x7B, // F12
// --- Locks ---
69 => 0x90, // KEY_NUMLOCK -> VK_NUMLOCK
70 => 0x91, // KEY_SCROLLLOCK -> VK_SCROLL
// --- Left/right modifiers (specific VKs; the host maps both generics here too) ---
42 => 0xA0, // KEY_LEFTSHIFT -> VK_LSHIFT
54 => 0xA1, // KEY_RIGHTSHIFT -> VK_RSHIFT
29 => 0xA2, // KEY_LEFTCTRL -> VK_LCONTROL
97 => 0xA3, // KEY_RIGHTCTRL -> VK_RCONTROL
56 => 0xA4, // KEY_LEFTALT -> VK_LMENU
100 => 0xA5, // KEY_RIGHTALT -> VK_RMENU
// --- OEM punctuation (US-layout positions) ---
39 => 0xBA, // KEY_SEMICOLON -> VK_OEM_1
13 => 0xBB, // KEY_EQUAL -> VK_OEM_PLUS
51 => 0xBC, // KEY_COMMA -> VK_OEM_COMMA
12 => 0xBD, // KEY_MINUS -> VK_OEM_MINUS
52 => 0xBE, // KEY_DOT -> VK_OEM_PERIOD
53 => 0xBF, // KEY_SLASH -> VK_OEM_2
41 => 0xC0, // KEY_GRAVE -> VK_OEM_3
26 => 0xDB, // KEY_LEFTBRACE -> VK_OEM_4
43 => 0xDC, // KEY_BACKSLASH -> VK_OEM_5
27 => 0xDD, // KEY_RIGHTBRACE -> VK_OEM_6
40 => 0xDE, // KEY_APOSTROPHE -> VK_OEM_7
86 => 0xE2, // KEY_102ND -> VK_OEM_102
_ => return None,
})
}
/// Map a GTK/GDK mouse button number to the GameStream button id the wire expects
/// (1=left, 2=middle, 3=right, 4=X1, 5=X2). GDK reports back/forward as 8/9.
pub fn gdk_button_to_gs(button: u32) -> Option<u32> {
Some(match button {
1 => 1,
2 => 2,
3 => 3,
8 => 4,
9 => 5,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The table must be the exact inverse of the host's `vk_to_evdev` for every key the
/// host knows (modulo the generic-modifier VKs, which collapse onto the same evdev
/// codes as the specific left-hand ones).
#[test]
fn roundtrips_through_the_host_table() {
// Mirror of the host's table (inject::vk_to_evdev), generic modifiers excluded.
let host_pairs: &[(u8, u16)] = &[
(0x08, 14),
(0x09, 15),
(0x0D, 28),
(0x13, 119),
(0x14, 58),
(0x1B, 1),
(0x20, 57),
(0x21, 104),
(0x22, 109),
(0x23, 107),
(0x24, 102),
(0x25, 105),
(0x26, 103),
(0x27, 106),
(0x28, 108),
(0x2C, 99),
(0x2D, 110),
(0x2E, 111),
(0x30, 11),
(0x31, 2),
(0x39, 10),
(0x41, 30),
(0x5A, 44),
(0x5B, 125),
(0x60, 82),
(0x69, 73),
(0x70, 59),
(0x7B, 88),
(0x90, 69),
(0xA0, 42),
(0xA5, 100),
(0xBA, 39),
(0xE2, 86),
];
for &(vk, evdev) in host_pairs {
assert_eq!(evdev_to_vk(evdev), Some(vk), "evdev {evdev}");
}
assert_eq!(evdev_to_vk(113), None); // KEY_MUTE — not in the wire contract
}
}
-42
View File
@@ -1,42 +0,0 @@
//! `punktfunk-client` — the native Linux punktfunk/1 client (design: Option A, 2026-06-12).
//!
//! GTK4/libadwaita shell · `NativeClient` linked as a crate (no C ABI) · FFmpeg decode →
//! `GtkGraphicsOffload` present · PipeWire audio · SDL3 gamepads. The trust surface
//! mirrors the Apple client: persistent identity, TOFU prompt with the host fingerprint,
//! SPAKE2 PIN pairing.
#[cfg(target_os = "linux")]
mod app;
#[cfg(target_os = "linux")]
mod audio;
#[cfg(target_os = "linux")]
mod discovery;
#[cfg(target_os = "linux")]
mod gamepad;
#[cfg(target_os = "linux")]
mod keymap;
#[cfg(target_os = "linux")]
mod session;
#[cfg(target_os = "linux")]
mod trust;
#[cfg(target_os = "linux")]
mod ui_hosts;
#[cfg(target_os = "linux")]
mod ui_settings;
#[cfg(target_os = "linux")]
mod ui_stream;
#[cfg(target_os = "linux")]
mod video;
#[cfg(target_os = "linux")]
fn main() -> gtk::glib::ExitCode {
app::run()
}
/// GTK4/PipeWire/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on
/// macOS (the Mac client lives in clients/apple).
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
std::process::exit(2);
}
@@ -1,265 +0,0 @@
//! Session controller: one worker thread runs connect → pump (video pull + decode, audio
//! pull + Opus decode, stats), feeding the GTK main loop over channels. The UI keeps the
//! `Arc<NativeClient>` from the `Connected` event for direct input sends (no extra hop on
//! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread:
//! video+audio here, rumble+hidout on the gamepad thread.
use crate::audio;
use crate::video::{DecodedFrame, Decoder};
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use punktfunk_core::PunktfunkError;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct SessionParams {
pub host: String,
pub port: u16,
pub mode: Mode,
pub compositor: CompositorPref,
pub gamepad: GamepadPref,
pub bitrate_kbps: u32,
/// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool,
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
pub pin: Option<[u8; 32]>,
pub identity: (String, String),
}
#[derive(Clone, Copy, Default)]
pub struct Stats {
pub fps: f32,
pub mbps: f32,
pub decode_ms: f32,
/// Median capture→decoded latency over the last window (host-clock corrected).
pub latency_ms: f32,
}
pub enum SessionEvent {
Connected {
connector: Arc<NativeClient>,
mode: Mode,
fingerprint: [u8; 32],
},
/// `trust_rejected` is set when the connect failed the TLS trust check (a `Crypto`
/// error): for a pinned connect this is the fingerprint-changed signal, so the UI can
/// offer a re-pair (PIN) path rather than a dead-end error.
Failed {
msg: String,
trust_rejected: bool,
},
Ended(Option<String>),
Stats(Stats),
}
pub struct SessionHandle {
pub events: async_channel::Receiver<SessionEvent>,
pub frames: async_channel::Receiver<DecodedFrame>,
pub stop: Arc<AtomicBool>,
}
pub fn start(params: SessionParams) -> SessionHandle {
let (ev_tx, ev_rx) = async_channel::unbounded();
// Tiny frame queue, newest wins: force_send displaces the oldest when the UI lags.
let (frame_tx, frame_rx) = async_channel::bounded(2);
let stop = Arc::new(AtomicBool::new(false));
let stop_w = stop.clone();
std::thread::Builder::new()
.name("punktfunk-session".into())
.spawn(move || pump(params, ev_tx, frame_tx, stop_w))
.expect("spawn session thread");
SessionHandle {
events: ev_rx,
frames: frame_rx,
stop,
}
}
fn now_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
fn pump(
params: SessionParams,
ev_tx: async_channel::Sender<SessionEvent>,
frame_tx: async_channel::Sender<DecodedFrame>,
stop: Arc<AtomicBool>,
) {
let connector = match NativeClient::connect(
&params.host,
params.port,
params.mode,
params.compositor,
params.gamepad,
params.bitrate_kbps,
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
None, // launch: the Linux client has no library picker yet
params.pin,
Some(params.identity),
Duration::from_secs(15),
) {
Ok(c) => Arc::new(c),
Err(e) => {
let trust_rejected = matches!(e, PunktfunkError::Crypto);
let msg = match e {
PunktfunkError::Crypto => {
"Host identity rejected — wrong fingerprint, or the host requires pairing"
.to_string()
}
PunktfunkError::Timeout => "Connection timed out".to_string(),
other => format!("Connect failed: {other:?}"),
};
let _ = ev_tx.send_blocking(SessionEvent::Failed {
msg,
trust_rejected,
});
return;
}
};
let _ = ev_tx.send_blocking(SessionEvent::Connected {
connector: connector.clone(),
mode: connector.mode(),
fingerprint: connector.host_fingerprint,
});
let mut decoder = match Decoder::new() {
Ok(d) => d,
Err(e) => {
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
return;
}
};
// Audio is best-effort: a session without it still streams. Gamepads are the
// app-lifetime service's job (the UI attaches it on Connected).
let player = audio::AudioPlayer::spawn()
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
.ok();
let mut opus_dec = opus::Decoder::new(48_000, opus::Channels::Stereo)
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
.ok();
let _mic = params
.mic_enabled
.then(|| {
audio::MicStreamer::spawn(connector.clone())
.map_err(|e| tracing::warn!(error = %e, "mic uplink disabled"))
.ok()
})
.flatten();
let clock_offset = connector.clock_offset_ns;
let mut total_frames = 0u64;
let mut window_start = Instant::now();
let mut frames_n = 0u32;
let mut bytes_n = 0u64;
let mut decode_us_sum = 0u64;
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
let mut pcm = vec![0f32; 5760 * 2]; // decode scratch: max Opus frame (120 ms stereo)
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
let mut last_dropped = connector.frames_dropped();
let mut last_kf_req: Option<Instant> = None;
let end: Option<String> = loop {
if stop.load(Ordering::SeqCst) {
break None;
}
match connector.next_frame(Duration::from_millis(4)) {
Ok(frame) => {
let t0 = Instant::now();
match decoder.decode(&frame.data) {
Ok(Some(decoded)) => {
total_frames += 1;
if total_frames == 1 {
let (w, h, path) = match &decoded {
DecodedFrame::Cpu(c) => (c.width, c.height, "software"),
DecodedFrame::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"),
};
tracing::info!(width = w, height = h, path, "first frame decoded");
}
// Latency: our wall clock expressed in the host's capture clock,
// minus the host-stamped capture pts (same math as client-rs).
let lat = (now_ns() as i128 + clock_offset as i128 - frame.pts_ns as i128)
.max(0) as u64;
if lat > 0 && lat < 10_000_000_000 {
lat_us.push(lat / 1000);
}
decode_us_sum += t0.elapsed().as_micros() as u64;
frames_n += 1;
bytes_n += frame.data.len() as u64;
let _ = frame_tx.force_send(decoded);
}
Ok(None) => {}
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
}
}
Err(PunktfunkError::NoFrame) => {}
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
Err(e) => break Some(format!("session: {e:?}")),
}
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
// reference-missing delta frames that follow and returns Ok, so keying off a decode error
// rarely fires. Request an IDR when the drop count climbs, throttled — the decode stays
// wedged for several frames until the IDR lands, so requesting every frame would flood.
let dropped = connector.frames_dropped();
if dropped > last_dropped {
last_dropped = dropped;
let now = Instant::now();
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
last_kf_req = Some(now);
let _ = connector.request_keyframe();
tracing::debug!(dropped, "requested keyframe (loss recovery)");
}
}
// Drain audio between frames (packets land every 5 ms; the queue holds 320 ms).
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
match dec.decode_float(&pkt.data, &mut pcm, false) {
Ok(samples) => player.push(pcm[..samples * 2].to_vec()),
Err(e) => tracing::debug!(error = %e, "opus decode"),
}
}
}
if window_start.elapsed() >= Duration::from_secs(1) {
let secs = window_start.elapsed().as_secs_f32();
lat_us.sort_unstable();
let p50 = lat_us.get(lat_us.len() / 2).copied().unwrap_or(0);
tracing::debug!(
fps = frames_n,
lat_p50_us = p50,
total_frames,
"stream window"
);
let _ = ev_tx.try_send(SessionEvent::Stats(Stats {
fps: frames_n as f32 / secs,
mbps: bytes_n as f32 * 8.0 / 1e6 / secs,
decode_ms: if frames_n > 0 {
decode_us_sum as f32 / frames_n as f32 / 1000.0
} else {
0.0
},
latency_ms: p50 as f32 / 1000.0,
}));
window_start = Instant::now();
frames_n = 0;
bytes_n = 0;
decode_us_sum = 0;
lat_us.clear();
}
};
tracing::info!(
total_frames,
reason = end.as_deref().unwrap_or("user"),
"session ended"
);
stop.store(true, Ordering::SeqCst);
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
}
-164
View File
@@ -1,164 +0,0 @@
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
//!
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` with `punktfunk-client-rs`
//! so a box pairs once whichever client it uses.
use anyhow::{anyhow, Context, Result};
use punktfunk_core::quic::endpoint;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub fn config_dir() -> Result<PathBuf> {
let home = std::env::var("HOME").context("HOME unset")?;
Ok(PathBuf::from(home).join(".config/punktfunk"))
}
/// This client's persistent identity, generated on first use — presented on every connect
/// so hosts can recognize it once paired.
pub fn load_or_create_identity() -> Result<(String, String)> {
let dir = config_dir()?;
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
return Ok((c, k));
}
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
std::fs::create_dir_all(&dir)?;
std::fs::write(&cp, &c)?;
std::fs::write(&kp, &k)?;
tracing::info!(cert = %cp.display(), "generated client identity");
Ok((c, k))
}
pub fn hex(fp: &[u8; 32]) -> String {
fp.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn parse_hex32(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, b) in out.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
}
Some(out)
}
/// One trusted host: its pinned certificate fingerprint plus how we got there (TOFU or a
/// PIN ceremony) and where we last reached it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KnownHost {
pub name: String,
pub addr: String,
pub port: u16,
/// SHA-256 of the host certificate, lowercase hex — the pin for every later connect.
pub fp_hex: String,
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
pub paired: bool,
}
#[derive(Default, Serialize, Deserialize)]
pub struct KnownHosts {
pub hosts: Vec<KnownHost>,
}
impl KnownHosts {
fn path() -> Result<PathBuf> {
Ok(config_dir()?.join("client-known-hosts.json"))
}
pub fn load() -> KnownHosts {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) -> Result<()> {
let p = Self::path()?;
std::fs::create_dir_all(p.parent().unwrap())?;
std::fs::write(&p, serde_json::to_string_pretty(self)?)?;
Ok(())
}
pub fn find_by_fp(&self, fp_hex: &str) -> Option<&KnownHost> {
self.hosts.iter().find(|h| h.fp_hex == fp_hex)
}
pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> {
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
}
/// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades
/// (a later TOFU connect must not demote a PIN-paired host).
pub fn upsert(&mut self, entry: KnownHost) {
if let Some(h) = self.hosts.iter_mut().find(|h| h.fp_hex == entry.fp_hex) {
h.name = entry.name;
h.addr = entry.addr;
h.port = entry.port;
h.paired |= entry.paired;
} else {
self.hosts.push(entry);
}
}
}
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
/// Stream mode; `0` = the native size/refresh of the monitor the window is on,
/// resolved at connect time.
pub width: u32,
pub height: u32,
pub refresh_hz: u32,
/// Requested encoder bitrate (kbps); 0 = host default.
pub bitrate_kbps: u32,
pub gamepad: String,
/// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable).
pub compositor: String,
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
pub inhibit_shortcuts: bool,
/// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool,
}
impl Default for Settings {
fn default() -> Self {
Settings {
width: 0,
height: 0,
refresh_hz: 0,
bitrate_kbps: 0,
gamepad: "auto".into(),
compositor: "auto".into(),
inhibit_shortcuts: true,
mic_enabled: false,
}
}
}
impl Settings {
fn path() -> Result<PathBuf> {
Ok(config_dir()?.join("client-gtk-settings.json"))
}
pub fn load() -> Settings {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) {
let Ok(p) = Self::path() else { return };
let _ = std::fs::create_dir_all(p.parent().unwrap());
if let Ok(s) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(&p, s);
}
}
}
@@ -1,247 +0,0 @@
//! The hosts page: saved (trusted) hosts, live mDNS discovery, manual connect entry.
use crate::discovery::{self, DiscoveredHost};
use crate::trust::KnownHosts;
use adw::prelude::*;
use gtk::glib;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
/// What the user asked to connect to. `fp_hex` comes from the mDNS TXT record when the
/// host was discovered (drives the trust decision *before* connecting); manual entries have
/// none. `pair_optional` is true ONLY when a discovered host advertised `pair=optional`,
/// which is the sole case in which the reduced-security TOFU path may be offered — every
/// other case (pair=required, unknown/empty policy, manual entry) mandates PIN pairing.
#[derive(Clone, Debug)]
pub struct ConnectRequest {
pub name: String,
pub addr: String,
pub port: u16,
pub fp_hex: Option<String>,
pub pair_optional: bool,
}
pub fn new(
on_connect: Rc<dyn Fn(ConnectRequest)>,
on_settings: Rc<dyn Fn()>,
on_speed_test: Rc<dyn Fn(ConnectRequest)>,
) -> adw::NavigationPage {
let list = gtk::ListBox::new();
list.add_css_class("boxed-list");
list.set_selection_mode(gtk::SelectionMode::None);
let placeholder = gtk::Label::new(Some("Searching the LAN for hosts…"));
placeholder.add_css_class("dim-label");
placeholder.set_margin_top(24);
placeholder.set_margin_bottom(24);
list.set_placeholder(Some(&placeholder));
// key → (row, latest advert); the activation closure looks the advert up by key so
// re-adverts (new address, pairing flipped) take effect without rebuilding rows.
type Rows = Rc<RefCell<HashMap<String, (adw::ActionRow, DiscoveredHost)>>>;
let rows: Rows = Rc::new(RefCell::new(HashMap::new()));
{
let rx = discovery::browse();
let rows = rows.clone();
let list = list.downgrade();
let on_connect = on_connect.clone();
glib::spawn_future_local(async move {
while let Ok(host) = rx.recv().await {
let Some(list) = list.upgrade() else { break };
let mut map = rows.borrow_mut();
let subtitle = format!(
"{}:{} · pairing {}",
host.addr,
host.port,
if host.pair.is_empty() {
"optional"
} else {
&host.pair
}
);
if let Some((row, stored)) = map.get_mut(&host.key) {
row.set_title(&host.name);
row.set_subtitle(&subtitle);
*stored = host;
} else {
let row = adw::ActionRow::builder()
.title(&host.name)
.subtitle(&subtitle)
.activatable(true)
.build();
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
{
let rows = rows.clone();
let key = host.key.clone();
let on_connect = on_connect.clone();
row.connect_activated(move |_| {
if let Some((_, h)) = rows.borrow().get(&key) {
on_connect(ConnectRequest {
name: h.name.clone(),
addr: h.addr.clone(),
port: h.port,
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
// TOFU is offered only when the host explicitly opts in
// with pair=optional; required/empty means mandatory PIN.
pair_optional: h.pair == "optional",
});
}
});
}
list.append(&row);
map.insert(host.key.clone(), (row, host));
}
}
});
}
// Manual connect: host:port (punktfunk/1 default port 9777).
let manual = adw::EntryRow::builder().title("host:port").build();
let connect_btn = gtk::Button::with_label("Connect");
connect_btn.set_valign(gtk::Align::Center);
connect_btn.add_css_class("suggested-action");
manual.add_suffix(&connect_btn);
let submit = {
let manual = manual.clone();
let on_connect = on_connect.clone();
move || {
let text = manual.text().to_string();
let text = text.trim();
if text.is_empty() {
return;
}
let (addr, port) = match text.rsplit_once(':') {
Some((a, p)) => match p.parse::<u16>() {
Ok(port) => (a.to_string(), port),
Err(_) => return,
},
None => (text.to_string(), 9777),
};
on_connect(ConnectRequest {
name: addr.clone(),
addr,
port,
fp_hex: None,
// Manual entry carries no advertised policy — never eligible for TOFU.
pair_optional: false,
});
}
};
{
let submit = submit.clone();
connect_btn.connect_clicked(move |_| submit());
}
manual.connect_entry_activated(move |_| submit());
let manual_list = gtk::ListBox::new();
manual_list.add_css_class("boxed-list");
manual_list.set_selection_mode(gtk::SelectionMode::None);
manual_list.append(&manual);
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. Rebuilt every time
// the page is shown, so fresh TOFU/pairing entries appear on return.
let saved_label = gtk::Label::new(Some("Saved hosts"));
saved_label.add_css_class("heading");
saved_label.set_halign(gtk::Align::Start);
let saved_list = gtk::ListBox::new();
saved_list.add_css_class("boxed-list");
saved_list.set_selection_mode(gtk::SelectionMode::None);
let rebuild_saved = {
let saved_list = saved_list.clone();
let saved_label = saved_label.clone();
let on_connect = on_connect.clone();
let on_speed_test = on_speed_test.clone();
move || {
saved_list.remove_all();
let known = KnownHosts::load();
saved_label.set_visible(!known.hosts.is_empty());
saved_list.set_visible(!known.hosts.is_empty());
for k in &known.hosts {
let row = adw::ActionRow::builder()
.title(&k.name)
.subtitle(format!(
"{}:{}{}",
k.addr,
k.port,
if k.paired {
" · paired"
} else {
" · trusted"
}
))
.activatable(true)
.build();
let req = ConnectRequest {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
fp_hex: Some(k.fp_hex.clone()),
// Saved host: its fp is already pinned, so this routes to a silent
// pinned connect; TOFU eligibility is irrelevant.
pair_optional: false,
};
let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic");
speed_btn.set_tooltip_text(Some("Test network speed"));
speed_btn.set_valign(gtk::Align::Center);
speed_btn.add_css_class("flat");
{
let on_speed_test = on_speed_test.clone();
let req = req.clone();
speed_btn.connect_clicked(move |_| on_speed_test(req.clone()));
}
row.add_suffix(&speed_btn);
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let on_connect = on_connect.clone();
row.connect_activated(move |_| on_connect(req.clone()));
saved_list.append(&row);
}
}
};
rebuild_saved();
let content = gtk::Box::new(gtk::Orientation::Vertical, 18);
content.set_margin_top(24);
content.set_margin_bottom(24);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&saved_label);
content.append(&saved_list);
let discovered_label = gtk::Label::new(Some("Hosts on this network"));
discovered_label.add_css_class("heading");
discovered_label.set_halign(gtk::Align::Start);
content.append(&discovered_label);
content.append(&list);
let manual_label = gtk::Label::new(Some("Manual connection"));
manual_label.add_css_class("heading");
manual_label.set_halign(gtk::Align::Start);
content.append(&manual_label);
content.append(&manual_list);
let clamp = adw::Clamp::builder()
.maximum_size(560)
.child(&content)
.build();
let scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&clamp)
.build();
let header = adw::HeaderBar::new();
let settings_btn = gtk::Button::from_icon_name("preferences-system-symbolic");
settings_btn.set_tooltip_text(Some("Preferences"));
settings_btn.connect_clicked(move |_| on_settings());
header.pack_end(&settings_btn);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&scrolled));
let page = adw::NavigationPage::builder()
.title("Punktfunk")
.tag("hosts")
.child(&toolbar)
.build();
page.connect_shown(move |_| rebuild_saved());
page
}
@@ -1,189 +0,0 @@
//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
//! capture behavior. Written back to disk when the dialog closes.
use crate::trust::Settings;
use adw::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
const RESOLUTIONS: &[(u32, u32)] = &[
(0, 0),
(1280, 720),
(1920, 1080),
(2560, 1440),
(3840, 2160),
];
/// `0` = the monitor's native refresh, resolved at connect.
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense"];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
pub fn show(
parent: &impl IsA<gtk::Widget>,
settings: Rc<RefCell<Settings>>,
gamepads: &crate::gamepad::GamepadService,
) {
let page = adw::PreferencesPage::new();
let stream = adw::PreferencesGroup::builder().title("Stream").build();
let res_names: Vec<String> = RESOLUTIONS
.iter()
.map(|&(w, h)| {
if w == 0 {
"Native display".to_string()
} else {
format!("{w} × {h}")
}
})
.collect();
let res_row = adw::ComboRow::builder()
.title("Resolution")
.subtitle("The host creates a virtual output at exactly this size")
.model(&gtk::StringList::new(
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let hz_names: Vec<String> = REFRESH
.iter()
.map(|&r| {
if r == 0 {
"Native".to_string()
} else {
format!("{r} Hz")
}
})
.collect();
let hz_row = adw::ComboRow::builder()
.title("Refresh rate")
.model(&gtk::StringList::new(
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
bitrate_row.set_title("Bitrate");
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
let compositor_row = adw::ComboRow::builder()
.title("Host compositor")
.subtitle("Advisory — the host falls back to auto-detect when unavailable")
.model(&gtk::StringList::new(&[
"Automatic",
"KWin",
"wlroots (Sway/Hyprland)",
"Mutter (GNOME)",
"gamescope",
]))
.build();
stream.add(&res_row);
stream.add(&hz_row);
stream.add(&bitrate_row);
stream.add(&compositor_row);
let input = adw::PreferencesGroup::builder().title("Input").build();
// Which physical controller forwards as pad 0: automatic = the most recently
// connected; pinning survives until the app exits (Swift parity).
let pads = gamepads.pads();
let mut pad_names = vec!["Automatic (most recent)".to_string()];
pad_names.extend(pads.iter().map(|p| {
if p.is_dualsense {
format!("{} · DualSense", p.name)
} else {
p.name.clone()
}
}));
let forward_row = adw::ComboRow::builder()
.title("Forwarded controller")
.subtitle(if pads.is_empty() {
"No controllers detected"
} else {
"Exactly one controller is forwarded to the host"
})
.model(&gtk::StringList::new(
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let pinned_i = gamepads
.pinned()
.and_then(|id| pads.iter().position(|p| p.id == id))
.map_or(0, |i| i + 1);
forward_row.set_selected(pinned_i as u32);
{
let svc = gamepads.clone();
let ids: Vec<u32> = pads.iter().map(|p| p.id).collect();
forward_row.connect_selected_notify(move |row| {
let sel = row.selected() as usize;
svc.set_pinned(if sel == 0 {
None
} else {
ids.get(sel - 1).copied()
});
});
}
let pad_row = adw::ComboRow::builder()
.title("Gamepad type")
.subtitle("The virtual pad the host creates — Automatic matches the physical pad")
.model(&gtk::StringList::new(&[
"Automatic",
"Xbox 360",
"DualSense",
]))
.build();
let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build();
input.add(&forward_row);
input.add(&pad_row);
input.add(&inhibit_row);
let audio = adw::PreferencesGroup::builder().title("Audio").build();
let mic_row = adw::SwitchRow::builder()
.title("Stream microphone")
.subtitle("Send the default input device to the host's virtual microphone")
.build();
audio.add(&mic_row);
page.add(&stream);
page.add(&input);
page.add(&audio);
// Seed from the current settings.
{
let s = settings.borrow();
let res_i = RESOLUTIONS
.iter()
.position(|&(w, h)| w == s.width && h == s.height)
.unwrap_or(0);
res_row.set_selected(res_i as u32);
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
hz_row.set_selected(hz_i as u32);
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
pad_row.set_selected(pad_i as u32);
let comp_i = COMPOSITORS
.iter()
.position(|&c| c == s.compositor)
.unwrap_or(0);
compositor_row.set_selected(comp_i as u32);
inhibit_row.set_active(s.inhibit_shortcuts);
mic_row.set_active(s.mic_enabled);
}
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
dialog.add(&page);
dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut();
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)];
(s.width, s.height) = (w, h);
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string();
s.inhibit_shortcuts = inhibit_row.is_active();
s.mic_enabled = mic_row.is_active();
s.save();
});
dialog.present(Some(parent));
}
@@ -1,469 +0,0 @@
//! The stream page: decoded frames into a `GtkGraphicsOffload`-wrapped picture, local
//! input captured and forwarded on the wire contract.
//!
//! Input capture is a deliberate, reversible STATE (Moonlight-style, mirroring the Swift
//! client): engaged when the stream starts and when the user clicks into the video (that
//! click is suppressed toward the host); released by Ctrl+Alt+Shift+Q (toggles) or focus
//! loss — held keys/buttons are flushed host-side on release so nothing sticks down.
//! While captured the local cursor is hidden (the host renders its own) and compositor
//! shortcuts are inhibited (configurable); while released nothing is forwarded and the
//! HUD says how to recapture.
//!
//! Keys are hardware keycodes (evdev + 8 on Wayland) → VK via `keymap`, layout-
//! independent. Mouse is absolute (`MouseMoveAbs` scaled into the negotiated mode through
//! the letterbox transform, surface size packed in `flags`) — pointer-lock relative
//! capture is the stage-2 presenter's job. F11 toggles fullscreen locally.
use crate::keymap;
use crate::session::Stats;
use crate::video::DecodedFrame;
use adw::prelude::*;
use gtk::{gdk, glib};
use punktfunk_core::client::NativeClient;
use punktfunk_core::input::{InputEvent, InputKind};
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
pub struct StreamPage {
pub page: adw::NavigationPage,
stats_label: gtk::Label,
}
impl StreamPage {
pub fn update_stats(&self, s: Stats) {
self.stats_label.set_text(&format!(
"{:.0} fps · {:.1} Mbit/s · dec {:.1} ms · lat {:.1} ms",
s.fps, s.mbps, s.decode_ms, s.latency_ms
));
}
}
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
let _ = connector.send_input(&InputEvent {
kind,
_pad: [0; 3],
code,
x,
y,
flags,
});
}
/// Forward an absolute pointer position: widget coordinates → video pixels through the
/// Contain-fit letterbox. `flags` packs the coordinate-space size (`(w << 16) | h`, the
/// same contract as touch) — the host normalizes against it before mapping into the EIS
/// region; without it the event is dropped.
fn send_abs(widget: &impl IsA<gtk::Widget>, connector: &NativeClient, x: f64, y: f64) {
let w = widget.as_ref();
let mode = connector.mode();
let (ww, wh) = (w.width().max(1) as f64, w.height().max(1) as f64);
let (vw, vh) = (mode.width.max(1) as f64, mode.height.max(1) as f64);
let scale = (ww / vw).min(wh / vh);
let (ox, oy) = ((ww - vw * scale) / 2.0, (wh - vh * scale) / 2.0);
let px = (((x - ox) / scale).round()).clamp(0.0, vw - 1.0) as i32;
let py = (((y - oy) / scale).round()).clamp(0.0, vh - 1.0) as i32;
let flags = (mode.width << 16) | (mode.height & 0xffff);
send(connector, InputKind::MouseMoveAbs, 0, px, py, flags);
}
/// The capture state shared by every input controller on the page.
struct Capture {
connector: Arc<NativeClient>,
window: adw::ApplicationWindow,
overlay: gtk::Overlay,
hint: gtk::Label,
inhibit_shortcuts: bool,
captured: Cell<bool>,
/// VKs / GameStream button ids currently held — flushed up on release.
held_keys: RefCell<HashSet<u8>>,
held_buttons: RefCell<HashSet<u32>>,
}
impl Capture {
fn engage(&self) {
if self.captured.replace(true) {
return;
}
self.overlay
.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
self.hint.set_visible(false);
if self.inhibit_shortcuts {
if let Some(tl) = self
.window
.surface()
.and_then(|s| s.downcast::<gdk::Toplevel>().ok())
{
tl.inhibit_system_shortcuts(None::<&gdk::Event>);
}
}
}
fn release(&self) {
if !self.captured.replace(false) {
return;
}
self.overlay.set_cursor(None);
self.hint.set_visible(true);
if let Some(tl) = self
.window
.surface()
.and_then(|s| s.downcast::<gdk::Toplevel>().ok())
{
tl.restore_system_shortcuts();
}
// Flush everything held so nothing sticks down on the host.
for vk in self.held_keys.borrow_mut().drain() {
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
}
for b in self.held_buttons.borrow_mut().drain() {
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0);
}
}
}
#[allow(clippy::too_many_lines)]
pub fn new(
window: &adw::ApplicationWindow,
connector: Arc<NativeClient>,
frames: async_channel::Receiver<DecodedFrame>,
escape_rx: async_channel::Receiver<()>,
stop: Arc<AtomicBool>,
inhibit_shortcuts: bool,
title: &str,
) -> StreamPage {
let picture = gtk::Picture::new();
picture.set_content_fit(gtk::ContentFit::Contain);
// The offload path: with a dmabuf-backed texture (stage 1.5) this becomes a
// subsurface the compositor can scan out directly; with memory textures it is a
// no-op wrapper. Black letterboxing keeps fullscreen scanout-eligible.
let offload = gtk::GraphicsOffload::new(Some(&picture));
offload.set_black_background(true);
let stats_label = gtk::Label::new(None);
stats_label.add_css_class("osd");
stats_label.add_css_class("numeric");
stats_label.set_halign(gtk::Align::Start);
stats_label.set_valign(gtk::Align::Start);
stats_label.set_margin_start(12);
stats_label.set_margin_top(12);
let hint = gtk::Label::new(Some(
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases",
));
hint.add_css_class("osd");
hint.set_halign(gtk::Align::Center);
hint.set_valign(gtk::Align::End);
hint.set_margin_bottom(24);
hint.set_visible(false);
// Flashed when entering fullscreen — the only exit affordances once the header bar is
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
// only way out on a Steam Deck).
let fs_hint = gtk::Label::new(Some("F11 · L1 + R1 + Start + Select — exit fullscreen"));
fs_hint.add_css_class("osd");
fs_hint.set_halign(gtk::Align::Center);
fs_hint.set_valign(gtk::Align::Start);
fs_hint.set_margin_top(12);
fs_hint.set_visible(false);
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&offload));
overlay.add_overlay(&stats_label);
overlay.add_overlay(&hint);
overlay.add_overlay(&fs_hint);
overlay.set_focusable(true);
let capture = Rc::new(Capture {
connector: connector.clone(),
window: window.clone(),
overlay: overlay.clone(),
hint: hint.clone(),
inhibit_shortcuts,
captured: Cell::new(false),
held_keys: RefCell::new(HashSet::new()),
held_buttons: RefCell::new(HashSet::new()),
});
let header = adw::HeaderBar::new();
let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic");
fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)"));
{
let window = window.clone();
fullscreen_btn.connect_clicked(move |_| {
if window.is_fullscreen() {
window.unfullscreen();
} else {
window.fullscreen();
}
});
}
header.pack_end(&fullscreen_btn);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&overlay));
// Fullscreen = the stream and nothing else. (Window handlers are disconnected when
// the page dies — the window outlives every session.)
let fs_handler = {
let toolbar = toolbar.clone();
let fs_hint = fs_hint.clone();
window.connect_fullscreened_notify(move |w| {
let fs = w.is_fullscreen();
toolbar.set_reveal_top_bars(!fs);
if fs {
fs_hint.set_visible(true);
let fs_hint = fs_hint.clone();
glib::timeout_add_seconds_local_once(4, move || fs_hint.set_visible(false));
} else {
fs_hint.set_visible(false);
}
})
};
let page = adw::NavigationPage::builder()
.title(title)
.tag("stream")
.child(&toolbar)
.build();
// --- Frame consumer: newest texture wins, set on the GTK frame clock's cadence. ---
{
let picture = picture.downgrade();
// The host encodes BT.709 limited-range; without an explicit color state GDK
// would convert NV12 dmabufs with the (BT.601) dmabuf default.
let rec709 = {
let cicp = gdk::CicpParams::new();
cicp.set_color_primaries(1);
cicp.set_transfer_function(1);
cicp.set_matrix_coefficients(1);
cicp.set_range(gdk::CicpRange::Narrow);
cicp.build_color_state().ok()
};
glib::spawn_future_local(async move {
while let Ok(f) = frames.recv().await {
let Some(picture) = picture.upgrade() else {
break;
};
match f {
DecodedFrame::Cpu(c) => {
let bytes = glib::Bytes::from_owned(c.rgba);
let tex = gdk::MemoryTexture::new(
c.width as i32,
c.height as i32,
gdk::MemoryFormat::R8g8b8a8,
&bytes,
c.stride,
);
picture.set_paintable(Some(&tex));
}
DecodedFrame::Dmabuf(d) => {
let mut b = gdk::DmabufTextureBuilder::new()
.set_display(&picture.display())
.set_width(d.width)
.set_height(d.height)
.set_fourcc(d.fourcc)
.set_modifier(d.modifier)
.set_n_planes(d.planes.len() as u32)
.set_color_state(rec709.as_ref());
for (i, p) in d.planes.iter().enumerate() {
b = unsafe { b.set_fd(i as u32, p.fd) }
.set_offset(i as u32, p.offset)
.set_stride(i as u32, p.stride);
}
let guard = d.guard;
// GDK runs the release func whether the import succeeds or not.
match unsafe { b.build_with_release_func(move || drop(guard)) } {
Ok(tex) => picture.set_paintable(Some(&tex)),
Err(e) => {
// Import rejected (format/modifier) — surfaces once per
// session in practice; the stream continues on the next
// frame, and PUNKTFUNK_DECODER=software is the escape.
tracing::warn!(error = %e, "dmabuf texture import failed");
}
}
}
}
}
});
}
// --- Keyboard ---
{
let key = gtk::EventControllerKey::new();
key.set_propagation_phase(gtk::PropagationPhase::Capture);
let cap = capture.clone();
let window_k = window.clone();
key.connect_key_pressed(move |_, keyval, keycode, state| {
let chord = gdk::ModifierType::CONTROL_MASK
| gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SHIFT_MASK;
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
if cap.captured.get() {
cap.release();
} else {
cap.engage();
}
return glib::Propagation::Stop;
}
if keyval == gdk::Key::F11 {
if window_k.is_fullscreen() {
window_k.unfullscreen();
} else {
window_k.fullscreen();
}
return glib::Propagation::Stop;
}
if !cap.captured.get() {
return glib::Propagation::Proceed;
}
if let Some(vk) = keycode
.checked_sub(8)
.and_then(|c| keymap::evdev_to_vk(c as u16))
{
cap.held_keys.borrow_mut().insert(vk);
send(&cap.connector, InputKind::KeyDown, vk as u32, 0, 0, 0);
}
glib::Propagation::Stop
});
let cap = capture.clone();
key.connect_key_released(move |_, _keyval, keycode, _state| {
if let Some(vk) = keycode
.checked_sub(8)
.and_then(|c| keymap::evdev_to_vk(c as u16))
{
// Flush-on-release may have beaten us to it — only forward if still held.
if cap.held_keys.borrow_mut().remove(&vk) {
send(&cap.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
}
}
});
overlay.add_controller(key);
}
// --- Mouse: absolute motion, buttons, wheel — forwarded only while captured ---
{
let motion = gtk::EventControllerMotion::new();
let cap = capture.clone();
motion.connect_motion(move |_, x, y| {
if cap.captured.get() {
send_abs(&cap.overlay, &cap.connector, x, y);
}
});
overlay.add_controller(motion);
}
{
let click = gtk::GestureClick::builder().button(0).build();
let cap = capture.clone();
click.connect_pressed(move |g, _n, x, y| {
cap.overlay.grab_focus();
if !cap.captured.get() {
cap.engage(); // the engaging click is suppressed toward the host
return;
}
send_abs(&cap.overlay, &cap.connector, x, y);
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
cap.held_buttons.borrow_mut().insert(gs);
send(&cap.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
}
});
let cap = capture.clone();
click.connect_released(move |g, _n, _x, _y| {
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
if cap.held_buttons.borrow_mut().remove(&gs) {
send(&cap.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
}
}
});
overlay.add_controller(click);
}
{
let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES);
let cap = capture.clone();
scroll.connect_scroll(move |_, dx, dy| {
if !cap.captured.get() {
return glib::Propagation::Proceed;
}
// The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
// positive = down. Smooth fractions survive — libei's discrete scroll is
// 120-based too.
let vy = (-dy * 120.0) as i32;
if vy != 0 {
send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0);
}
let vx = (dx * 120.0) as i32;
if vx != 0 {
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
}
glib::Propagation::Stop
});
overlay.add_controller(scroll);
}
// --- Capture lifecycle ---
{
// Engaged when the stream starts (trust is already confirmed by then).
let cap = capture.clone();
overlay.connect_map(move |w| {
w.grab_focus();
cap.engage();
});
}
// Focus loss releases (Alt-Tab away, another window) — Swift does the same.
let active_handler = {
let cap = capture.clone();
window.connect_is_active_notify(move |w| {
if !w.is_active() {
cap.release();
}
})
};
{
let cap = capture.clone();
overlay.connect_unmap(move |_| cap.release());
}
// Controller escape chord (gamepad service) → leave fullscreen + release capture. The
// chord is the only fullscreen exit a controller has (no F11 key; fullscreen hides the
// chrome). Aborted on page-hidden so a stale future can't act on the shared window.
let escape_future = {
let window = window.clone();
let cap = capture.clone();
glib::spawn_future_local(async move {
while escape_rx.recv().await.is_ok() {
if window.is_fullscreen() {
window.unfullscreen();
}
cap.release();
}
})
};
// The page's `hidden` fires once navigation away completes (back button, pop on
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
{
let window = window.clone();
let stop_h = stop.clone();
let handlers = RefCell::new(Some((fs_handler, active_handler)));
let escape_future = RefCell::new(Some(escape_future));
page.connect_hidden(move |_| {
tracing::debug!("stream page hidden — ending session");
if let Some((fs, active)) = handlers.borrow_mut().take() {
window.disconnect(fs);
window.disconnect(active);
}
if let Some(f) = escape_future.borrow_mut().take() {
f.abort();
}
if window.is_fullscreen() {
window.unfullscreen();
}
stop_h.store(true, Ordering::SeqCst);
});
}
StreamPage { page, stats_label }
}
-443
View File
@@ -1,443 +0,0 @@
//! Video decode: reassembled HEVC access units → frames for the GTK presenter.
//!
//! Two backends, picked at session start (override: `PUNKTFUNK_DECODER=software|vaapi`):
//!
//! * **VAAPI** (Intel/AMD): libavcodec hwaccel decodes on the GPU; each frame is mapped
//! to a DRM-PRIME dmabuf (`av_hwframe_map`, zero copy) and handed to the UI as fds +
//! plane layout for `GdkDmabufTextureBuilder` — inside `GtkGraphicsOffload` that is the
//! decoder-to-subsurface path, direct-scanout eligible when fullscreen. NVIDIA boxes
//! have no usable VAAPI (nvidia-vaapi-driver is broken for this — Moonlight blacklists
//! it); device creation fails there and the software path takes over. A mid-session
//! VAAPI error also falls back — the host's IDR/RFI recovery resynchronizes.
//! * **Software**: libavcodec on the CPU + swscale to RGBA (`GdkMemoryTexture` upload).
//! Slice threading only — frame threading would add a frame of latency per thread.
//!
//! Both run `AV_CODEC_FLAG_LOW_DELAY`; the host encodes zero-reorder streams (no
//! B-frames, in-band parameter sets on every IDR), so decode is strictly one-in/one-out.
use anyhow::{anyhow, bail, Context as _, Result};
use ffmpeg::format::Pixel;
use ffmpeg::software::scaling;
use ffmpeg::util::frame::Video as AvFrame;
use ffmpeg_next as ffmpeg;
use std::os::fd::RawFd;
use std::ptr;
pub enum DecodedFrame {
Cpu(CpuFrame),
Dmabuf(DmabufFrame),
}
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
pub struct CpuFrame {
pub width: u32,
pub height: u32,
/// RGBA row stride in bytes (≥ width*4 — swscale pads rows for SIMD).
pub stride: usize,
pub rgba: Vec<u8>,
}
/// A decoded frame still on the GPU: dmabuf fds + plane layout for
/// `GdkDmabufTextureBuilder`. The fds belong to `guard`'s mapped DRM frame — they stay
/// valid until the guard drops (the texture's release func).
pub struct DmabufFrame {
pub width: u32,
pub height: u32,
/// Combined DRM fourcc of the whole surface (NV12 for 8-bit VAAPI output), derived
/// from the decoder's software format — NOT the per-plane component formats.
pub fourcc: u32,
pub modifier: u64,
pub planes: Vec<DmabufPlane>,
pub guard: DrmFrameGuard,
}
pub struct DmabufPlane {
pub fd: RawFd,
pub offset: u32,
pub stride: u32,
}
/// Owns the mapped DRM-PRIME `AVFrame` (which in turn references the VAAPI surface).
/// Dropping it releases the surface back to the decoder pool and closes the fds.
pub struct DrmFrameGuard(*mut ffmpeg::ffi::AVFrame);
// An AVFrame is plain refcounted data; freeing it from the GTK main thread is fine.
unsafe impl Send for DrmFrameGuard {}
impl Drop for DrmFrameGuard {
fn drop(&mut self) {
unsafe { ffmpeg::ffi::av_frame_free(&mut self.0) };
}
}
enum Backend {
Vaapi(VaapiDecoder),
Software(SoftwareDecoder),
}
pub struct Decoder {
backend: Backend,
}
impl Decoder {
pub fn new() -> Result<Decoder> {
ffmpeg::init().context("ffmpeg init")?;
let choice = std::env::var("PUNKTFUNK_DECODER").unwrap_or_default();
if choice != "software" {
match VaapiDecoder::new() {
Ok(v) => {
tracing::info!("VAAPI hardware decode active (zero-copy dmabuf)");
return Ok(Decoder {
backend: Backend::Vaapi(v),
});
}
Err(e) => {
if choice == "vaapi" {
return Err(e.context("PUNKTFUNK_DECODER=vaapi but VAAPI failed"));
}
tracing::info!(reason = %e, "VAAPI unavailable — software decode");
}
}
}
Ok(Decoder {
backend: Backend::Software(SoftwareDecoder::new()?),
})
}
/// Feed one access unit; returns the decoded frame (the host's streams are
/// one-in/one-out). A software decode error after packet loss is survivable — log
/// upstream and keep feeding. A VAAPI error demotes to software for the rest of the
/// session (broken driver, e.g. nvidia-vaapi-driver) — the next IDR resynchronizes.
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedFrame>> {
match &mut self.backend {
Backend::Vaapi(v) => match v.decode(au) {
Ok(f) => Ok(f.map(DecodedFrame::Dmabuf)),
Err(e) => {
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software");
self.backend = Backend::Software(SoftwareDecoder::new()?);
Ok(None)
}
},
Backend::Software(s) => Ok(s.decode(au)?.map(DecodedFrame::Cpu)),
}
}
}
// --- software backend ---------------------------------------------------------------
struct SoftwareDecoder {
decoder: ffmpeg::decoder::Video,
/// Rebuilt whenever the decoded format/size changes (mid-stream `Reconfigure`).
sws: Option<(scaling::Context, Pixel, u32, u32)>,
}
impl SoftwareDecoder {
fn new() -> Result<SoftwareDecoder> {
let codec =
ffmpeg::decoder::find(ffmpeg::codec::Id::HEVC).ok_or(anyhow!("no HEVC decoder"))?;
let mut ctx = ffmpeg::codec::Context::new_with_codec(codec);
unsafe {
let raw = ctx.as_mut_ptr();
(*raw).flags |= ffmpeg::ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
// Slice threading adds no frame delay (frame threading adds thread_count-1).
(*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE;
(*raw).thread_count = 0; // auto
}
let decoder = ctx.decoder().video().context("open HEVC decoder")?;
Ok(SoftwareDecoder { decoder, sws: None })
}
fn decode(&mut self, au: &[u8]) -> Result<Option<CpuFrame>> {
let packet = ffmpeg::Packet::copy(au);
self.decoder
.send_packet(&packet)
.map_err(|e| anyhow!("send_packet: {e}"))?;
let mut frame = AvFrame::empty();
let mut out = None;
while self.decoder.receive_frame(&mut frame).is_ok() {
out = Some(self.convert_rgba(&frame)?);
}
Ok(out)
}
fn convert_rgba(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
let rebuild =
!matches!(&self.sws, Some((_, f, sw, sh)) if *f == fmt && *sw == w && *sh == h);
if rebuild {
let ctx = scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT)
.context("swscale context")?;
self.sws = Some((ctx, fmt, w, h));
}
let (sws, ..) = self.sws.as_mut().unwrap();
let mut rgba = AvFrame::empty();
sws.run(frame, &mut rgba).map_err(|e| anyhow!("sws: {e}"))?;
Ok(CpuFrame {
width: w,
height: h,
stride: rgba.stride(0),
rgba: rgba.data(0).to_vec(),
})
}
}
// --- VAAPI backend --------------------------------------------------------------------
//
// Raw FFI: ffmpeg-next has no hwaccel wrappers. All pointers are owned here and freed in
// Drop; decoded surfaces transfer out through DrmFrameGuard.
const AVERROR_EAGAIN: i32 = -11; // -EAGAIN; Linux-only crate
fn averr(what: &str, code: i32) -> anyhow::Error {
anyhow!("{what}: {}", ffmpeg::Error::from(code))
}
/// libavcodec offers the formats it can decode into; pick the VAAPI hw surface. Falling
/// back to the first (software) entry would silently decode on the CPU *and* break our
/// dmabuf mapping — return NONE instead so the error surfaces and the session demotes
/// to the software backend explicitly.
unsafe extern "C" fn pick_vaapi(
_ctx: *mut ffmpeg::ffi::AVCodecContext,
mut list: *const ffmpeg::ffi::AVPixelFormat,
) -> ffmpeg::ffi::AVPixelFormat {
unsafe {
while *list != ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE {
if *list == ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VAAPI {
return ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
}
list = list.add(1);
}
}
ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE
}
struct VaapiDecoder {
ctx: *mut ffmpeg::ffi::AVCodecContext,
hw_device: *mut ffmpeg::ffi::AVBufferRef,
packet: *mut ffmpeg::ffi::AVPacket,
frame: *mut ffmpeg::ffi::AVFrame,
}
// Single-owner pointers, only touched from the session pump thread.
unsafe impl Send for VaapiDecoder {}
impl VaapiDecoder {
fn new() -> Result<VaapiDecoder> {
use ffmpeg::ffi;
unsafe {
let mut hw_device: *mut ffi::AVBufferRef = ptr::null_mut();
let r = ffi::av_hwdevice_ctx_create(
&mut hw_device,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
ptr::null(),
ptr::null_mut(),
0,
);
if r < 0 {
bail!("no VAAPI device ({})", ffmpeg::Error::from(r));
}
let codec = ffi::avcodec_find_decoder(ffi::AVCodecID::AV_CODEC_ID_HEVC);
if codec.is_null() {
ffi::av_buffer_unref(&mut hw_device);
bail!("no HEVC decoder");
}
let ctx = ffi::avcodec_alloc_context3(codec);
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
(*ctx).get_format = Some(pick_vaapi);
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
(*ctx).thread_count = 1; // hwaccel: threads only add latency
let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut());
if r < 0 {
let mut ctx = ctx;
ffi::avcodec_free_context(&mut ctx);
let mut hw_device = hw_device;
ffi::av_buffer_unref(&mut hw_device);
bail!("avcodec_open2: {}", ffmpeg::Error::from(r));
}
Ok(VaapiDecoder {
ctx,
hw_device,
packet: ffi::av_packet_alloc(),
frame: ffi::av_frame_alloc(),
})
}
}
fn decode(&mut self, au: &[u8]) -> Result<Option<DmabufFrame>> {
use ffmpeg::ffi;
unsafe {
let r = ffi::av_new_packet(self.packet, au.len() as i32);
if r < 0 {
return Err(averr("av_new_packet", r));
}
ptr::copy_nonoverlapping(au.as_ptr(), (*self.packet).data, au.len());
let r = ffi::avcodec_send_packet(self.ctx, self.packet);
ffi::av_packet_unref(self.packet);
if r < 0 {
return Err(averr("send_packet", r));
}
let mut out = None;
loop {
let r = ffi::avcodec_receive_frame(self.ctx, self.frame);
if r == AVERROR_EAGAIN {
break;
}
if r < 0 {
return Err(averr("receive_frame", r));
}
out = Some(self.map_dmabuf()?); // newest wins; older guards drop here
ffi::av_frame_unref(self.frame);
}
Ok(out)
}
}
/// Map the VAAPI surface to DRM PRIME (zero copy) and lift the descriptor into a
/// `DmabufFrame`. The mapped frame keeps the surface alive via its buffer refs.
///
/// FFmpeg's VAAPI export uses `VA_EXPORT_SURFACE_SEPARATE_LAYERS`, so an NV12 surface
/// comes back as TWO layers (`R8` luma + `GR88` chroma), each one plane — NOT a single
/// `NV12` layer. The previous code took `layers[0]` only: GTK then saw an `R8`
/// single-plane texture with the chroma dropped, painting the screen green. The fix:
/// derive the COMBINED fourcc from the decoder's software pixel format (NV12 →
/// `DRM_FORMAT_NV12`) and flatten every plane across every layer in order (Y then UV).
unsafe fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
use ffmpeg::ffi;
unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
bail!("decoder returned a software frame (no VAAPI surface)");
}
// The real pixel layout lives on the hardware frames context, not the
// DRM-PRIME layer formats (those are the per-plane R8/GR88 component formats).
let sw_format = {
let hwfc = (*self.frame).hw_frames_ctx;
if hwfc.is_null() {
bail!("VAAPI frame without a hardware frames context");
}
(*((*hwfc).data as *const ffi::AVHWFramesContext)).sw_format
};
let fourcc = drm_fourcc_for(sw_format)
.ok_or_else(|| anyhow!("unsupported VAAPI output format {sw_format:?}"))?;
let drm = ffi::av_frame_alloc();
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
let r = ffi::av_hwframe_map(drm, self.frame, ffi::AV_HWFRAME_MAP_READ as i32);
if r < 0 {
let mut drm = drm;
ffi::av_frame_free(&mut drm);
return Err(averr("av_hwframe_map", r));
}
let desc = (*drm).data[0] as *const ffi::AVDRMFrameDescriptor;
let guard = DrmFrameGuard(drm);
let d = &*desc;
if d.nb_layers < 1 || d.nb_objects < 1 {
bail!("DRM descriptor without layers/objects");
}
// Flatten planes across ALL layers, in declared order — the combined fourcc's
// plane order (Y, then UV for NV12) matches the layer order FFmpeg emits.
let mut planes = Vec::new();
for layer in &d.layers[..d.nb_layers as usize] {
for p in &layer.planes[..layer.nb_planes as usize] {
let obj = &d.objects[p.object_index as usize];
planes.push(DmabufPlane {
fd: obj.fd,
offset: p.offset as u32,
stride: p.pitch as u32,
});
}
}
// The whole surface shares one tiling modifier (one BO on radeonsi); GTK takes
// a single modifier for the texture.
let modifier = d.objects[0].format_modifier;
log_descriptor_once(d, sw_format, fourcc, modifier);
Ok(DmabufFrame {
width: (*self.frame).width as u32,
height: (*self.frame).height as u32,
fourcc,
modifier,
planes,
guard,
})
}
}
}
/// `fourcc(a,b,c,d)` — the DRM FourCC packing (little-endian, `a | b<<8 | c<<16 | d<<24`).
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
}
/// The combined DRM FourCC for a decoder software pixel format. The host streams 8-bit
/// 4:2:0 (NV12); P010 is here for the eventual 10-bit/HDR path.
fn drm_fourcc_for(sw: ffmpeg_next::ffi::AVPixelFormat) -> Option<u32> {
use ffmpeg_next::ffi::AVPixelFormat::*;
Some(match sw {
AV_PIX_FMT_NV12 => fourcc(b'N', b'V', b'1', b'2'),
AV_PIX_FMT_P010LE => fourcc(b'P', b'0', b'1', b'0'),
_ => return None,
})
}
/// One-time dump of the DRM descriptor layout (objects, layers, planes, modifier) — so a
/// new client/driver combination's real layout is visible in the logs without a debugger.
fn log_descriptor_once(
d: &ffmpeg_next::ffi::AVDRMFrameDescriptor,
sw: ffmpeg_next::ffi::AVPixelFormat,
fourcc: u32,
modifier: u64,
) {
use std::sync::atomic::{AtomicBool, Ordering};
static ONCE: AtomicBool = AtomicBool::new(true);
if !ONCE.swap(false, Ordering::Relaxed) {
return;
}
let layers: Vec<(u32, i32)> = d.layers[..d.nb_layers.max(0) as usize]
.iter()
.map(|l| (l.format, l.nb_planes))
.collect();
tracing::info!(
sw_format = ?sw,
chosen_fourcc = format_args!("{:#010x}", fourcc),
nb_objects = d.nb_objects,
nb_layers = d.nb_layers,
?layers,
modifier = format_args!("{:#018x}", modifier),
"VAAPI dmabuf descriptor layout (first frame)"
);
}
impl Drop for VaapiDecoder {
fn drop(&mut self) {
use ffmpeg::ffi;
unsafe {
ffi::av_packet_free(&mut self.packet);
ffi::av_frame_free(&mut self.frame);
ffi::avcodec_free_context(&mut self.ctx);
ffi::av_buffer_unref(&mut self.hw_device);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
#[test]
fn drm_fourcc_constants() {
assert_eq!(fourcc(b'N', b'V', b'1', b'2'), 0x3231_564e);
assert_eq!(fourcc(b'P', b'0', b'1', b'0'), 0x3031_3050);
assert_eq!(
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NV12),
Some(0x3231_564e)
);
assert_eq!(
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_RGBA),
None
);
}
}
-25
View File
@@ -1,25 +0,0 @@
[package]
name = "punktfunk-client-rs"
description = "punktfunk reference client (M4): VAAPI decode + wgpu/Vulkan present"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
quinn = "0.11"
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros"] }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# LAN host discovery (`--discover`): browse the native `_punktfunk._udp` mDNS service the host
# advertises (same crate/version the host advertises with).
mdns-sd = "0.20"
# Linux-only: --mic-test's Opus encoder (libopus). The mic UPLINK itself is portable —
# only this synthetic-tone test rig needs the encoder.
[target.'cfg(target_os = "linux")'.dependencies]
opus = "0.3"
-979
View File
@@ -1,979 +0,0 @@
//! `punktfunk-client-rs` — the reference client for `punktfunk/1` (M3): QUIC control plane, UDP data
//! plane, input over QUIC datagrams. Two modes, decided by the host's Welcome:
//!
//! * **verification** (`frames > 0`, synthetic host): byte-checks deterministic test frames;
//! * **stream** (`frames == 0`, virtual host): receives real NVENC AUs, writes a playable
//! `.h265`, and reports per-frame **capture→…→reassembled latency** percentiles (the host
//! stamps each frame with its capture wall clock; same-host runs share that clock).
//!
//! `--input-test` exercises the input plane: scripted mouse/keyboard datagrams during the
//! stream (watch them land in the host session, e.g. xev inside gamescope). `--mic-test`
//! exercises the mic uplink: a synthetic 440 Hz tone streamed as Opus (0xCB) → the host's
//! virtual microphone source (record it host-side to hear the tone). `--touch-test` drags a
//! synthetic finger in a circle → host libei `ei_touchscreen` injection. `--rich-input-test`
//! drives a virtual DualSense touchpad + motion over the 0xCC plane (host on
//! `PUNKTFUNK_GAMEPAD=dualsense`) and logs the 0xCD HID-output feedback (lightbar / adaptive
//! triggers) that comes back.
//!
//! `--pin <64-hex>` pins the host's certificate fingerprint (the host logs it at startup);
//! without it the client trusts on first use and prints the observed fingerprint to pin.
//! `--pair <PIN>` runs the SPAKE2 pairing ceremony: read the PIN the host prints when it
//! arms pairing (`--allow-pairing`/`--require-pairing`), pass it here; on success the
//! client prints the verified host fingerprint to `--pin` from then on.
//! Host→client datagrams (Opus audio, rumble) are counted and reported with the stream
//! stats — decode/playback is the platform clients' job.
//!
//! `--compositor NAME` requests a host compositor backend (`auto`|`kwin`|`wlroots`|`mutter`|
//! `gamescope`); the host honors it if available, else auto-detects and reports the resolved
//! choice in its Welcome (logged as `session offer … compositor=…`).
//!
//! `--gamepad NAME` requests a host virtual-pad backend (`auto`|`xbox360`|`dualsense`); the
//! host honors it where available (DualSense needs Linux UHID), else falls back to X-Box 360,
//! and reports the resolved choice in its Welcome (logged as `session offer … gamepad=…`).
//!
//! `--discover [SECS]` browses the LAN for native (`_punktfunk._udp`) hosts the host advertises
//! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and
//! exits without connecting.
//!
//! Usage: `punktfunk-client-rs [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test]
//! [--pin HEX] [--compositor NAME] [--gamepad NAME] | --discover [SECS]`
//! (M4 adds VAAPI decode + wgpu present on this skeleton.)
use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::GamepadPref;
use punktfunk_core::config::Role;
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::FLAG_PROBE;
use punktfunk_core::quic::{
endpoint, io, Hello, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, Start, Welcome,
};
use punktfunk_core::transport::UdpTransport;
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
use std::io::Write;
struct Args {
connect: String,
mode: Mode,
out: Option<String>,
input_test: bool,
/// `--mic-test` — stream a synthetic 440 Hz tone as the mic uplink (proves the mic path).
mic_test: bool,
/// `--touch-test` — drag a synthetic finger in a circle (proves the touch path).
touch_test: bool,
/// `--rich-input-test` — drive the DualSense touchpad + motion over 0xCC (host needs
/// `PUNKTFUNK_GAMEPAD=dualsense`); also logs the 0xCD HID-output feedback that comes back.
rich_input_test: bool,
pin: Option<[u8; 32]>,
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
remode: Option<(Mode, u32)>,
/// `--pair PIN` — run the pairing ceremony instead of a session.
pair: Option<String>,
/// `--name LABEL` — how the host labels this client when pairing.
name: String,
/// `--compositor NAME` — request a host compositor backend (auto|kwin|wlroots|mutter|gamescope).
compositor: CompositorPref,
/// `--gamepad NAME` — request a host virtual-pad backend (auto|xbox360|dualsense).
gamepad: GamepadPref,
/// `--bitrate KBPS` — request this encoder bitrate (kilobits/s); 0 = host default.
bitrate_kbps: u32,
/// `--launch ID` — ask the host to launch a library title in this session (a store-qualified
/// id from the host's `GET /api/v1/library`, e.g. `steam:570`). Host resolves it; `None` = none.
launch: Option<String>,
/// `--speed-test KBPS:MS` — after the stream starts, ask the host for a `MS`-millisecond
/// bandwidth probe burst at `KBPS`, then report measured throughput + loss.
speed_test: Option<(u32, u32)>,
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
/// seconds (default 4), print what's found, and exit. No connection is made.
discover: Option<u64>,
}
fn parse_mode(m: &str) -> Option<Mode> {
let mut it = m.split('x');
Some(Mode {
width: it.next()?.parse().ok()?,
height: it.next()?.parse().ok()?,
refresh_hz: it.next()?.parse().ok()?,
})
}
fn parse_hex32(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, b) in out.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
}
Some(out)
}
fn hex(fp: &[u8; 32]) -> String {
fp.iter().map(|b| format!("{b:02x}")).collect()
}
/// This client's persistent identity (`~/.config/punktfunk/client-{cert,key}.pem`),
/// generated on first use — presented on every connect so hosts can recognize it once
/// paired.
fn load_or_create_identity() -> Result<(String, String)> {
let home = std::env::var("HOME").context("HOME unset")?;
let dir = std::path::PathBuf::from(home).join(".config/punktfunk");
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
return Ok((c, k));
}
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
std::fs::create_dir_all(&dir)?;
std::fs::write(&cp, &c)?;
std::fs::write(&kp, &k)?;
tracing::info!(cert = %cp.display(), "generated client identity");
Ok((c, k))
}
fn parse_args() -> Args {
let argv: Vec<String> = std::env::args().collect();
let get = |flag: &str| {
argv.iter()
.skip_while(|a| *a != flag)
.nth(1)
.map(String::as_str)
};
let mode = get("--mode").and_then(parse_mode).unwrap_or(Mode {
width: 1280,
height: 720,
refresh_hz: 60,
});
let remode = get("--remode").and_then(|s| {
let (m, secs) = s.split_once(':')?;
Some((parse_mode(m)?, secs.parse().ok()?))
});
// A present-but-malformed --pin must abort, not silently downgrade to trust-on-first-use
// (the user asked for verification; fail closed).
let pin = match get("--pin") {
None => None,
Some(s) => {
match parse_hex32(s) {
Some(p) => Some(p),
None => {
eprintln!("--pin must be exactly 64 hex chars (the host logs its fingerprint at startup)");
std::process::exit(2);
}
}
}
};
// A present-but-unrecognized --compositor must abort rather than silently auto-detect.
let compositor = match get("--compositor") {
None => CompositorPref::Auto,
Some(s) => match CompositorPref::from_name(s) {
Some(c) => c,
None => {
eprintln!("--compositor must be one of: auto, kwin, wlroots, mutter, gamescope");
std::process::exit(2);
}
},
};
// Same fail-closed discipline for --gamepad.
let gamepad = match get("--gamepad") {
None => GamepadPref::Auto,
Some(s) => match GamepadPref::from_name(s) {
Some(g) => g,
None => {
eprintln!("--gamepad must be one of: auto, xbox360, dualsense");
std::process::exit(2);
}
},
};
Args {
connect: get("--connect").unwrap_or("127.0.0.1:9777").to_string(),
mode,
out: get("--out").map(String::from),
input_test: argv.iter().any(|a| a == "--input-test"),
mic_test: argv.iter().any(|a| a == "--mic-test"),
touch_test: argv.iter().any(|a| a == "--touch-test"),
rich_input_test: argv.iter().any(|a| a == "--rich-input-test"),
pin,
remode,
pair: get("--pair").map(String::from),
name: get("--name").unwrap_or("punktfunk-client-rs").to_string(),
compositor,
gamepad,
bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0),
launch: get("--launch").map(str::to_string),
speed_test: get("--speed-test").and_then(|s| {
let (kbps, ms) = s.split_once(':')?;
Some((kbps.parse().ok()?, ms.parse().ok()?))
}),
// `--discover` may be a bare flag or carry a seconds value (`--discover 8`); only treat
// the following token as a count when it parses as a number (else it's the next flag).
discover: argv
.iter()
.any(|a| a == "--discover")
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
}
}
fn now_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
let args = parse_args();
if let Err(e) = run(args) {
tracing::error!("{e:#}");
std::process::exit(1);
}
}
fn run(args: Args) -> Result<()> {
// Discovery mode: browse the LAN for native hosts, print them, and exit (no connection).
if let Some(secs) = args.discover {
return discover(secs);
}
// Pairing mode: run the PIN ceremony and print the fingerprint to pin, then exit.
if let Some(pin) = &args.pair {
let (host, port) = args
.connect
.rsplit_once(':')
.context("--connect host:port")?;
let identity = load_or_create_identity()?;
let fp = punktfunk_core::client::NativeClient::pair(
host,
port.parse().context("port")?,
(&identity.0, &identity.1),
pin,
&args.name,
std::time::Duration::from_secs(90),
)
.map_err(|e| anyhow!("pairing failed: {e:?} (wrong PIN?)"))?;
tracing::info!(
fingerprint = %hex(&fp),
"PAIRED — connect with --pin {} from now on",
hex(&fp)
);
return Ok(());
}
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()?;
rt.block_on(session(args))
}
/// Browse the LAN for native (`_punktfunk._udp`) hosts for `secs` seconds and print them, then
/// exit — the discovery side of the host's mDNS advert (host crate `discovery.rs`). TXT keys:
/// `fp` (host cert fingerprint to pin), `pair` (required|optional), `id` (stable host id).
fn discover(secs: u64) -> Result<()> {
use mdns_sd::{ServiceDaemon, ServiceEvent};
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
let receiver = daemon
.browse("_punktfunk._udp.local.")
.context("browse _punktfunk._udp")?;
tracing::info!(
secs,
"browsing for native punktfunk/1 hosts (_punktfunk._udp)…"
);
// One row per host, keyed by the stable uniqueid (falls back to the fullname) so the same
// host re-advertising or answering on several interfaces collapses to a single entry.
let mut hosts: BTreeMap<String, String> = BTreeMap::new();
let deadline = Instant::now() + Duration::from_secs(secs);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
// Timeout == time left to the deadline: an event returns immediately, otherwise the recv
// returns Err exactly at the deadline (or if the daemon channel closes) and we stop.
match receiver.recv_timeout(remaining) {
Ok(ServiceEvent::ServiceResolved(info)) => {
let props = info.get_properties();
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
let addr = info
.get_addresses()
.iter()
.next()
.map(|a| a.to_string())
.unwrap_or_else(|| "?".into());
let fp = val("fp");
let fp_short = fp.get(..16).unwrap_or(fp.as_str());
let name = info
.get_fullname()
.split('.')
.next()
.unwrap_or("?")
.to_string();
let id = val("id");
let key = if id.is_empty() {
info.get_fullname().to_string()
} else {
id
};
let row = format!(
" {name:<24} {addr}:{:<6} pair={:<9} fp={fp_short}",
info.get_port(),
val("pair"),
);
hosts.insert(key, row);
}
Ok(_) => {} // SearchStarted / ServiceFound / removals — ignore
Err(_) => break,
}
}
let _ = daemon.shutdown();
if hosts.is_empty() {
println!("no native punktfunk/1 hosts found on the LAN ({secs}s)");
} else {
println!("native punktfunk/1 hosts ({}):", hosts.len());
for row in hosts.values() {
println!("{row}");
}
println!(
"\nconnect with: punktfunk-client-rs --connect <addr:port> [--pin <fp> | --pair <PIN>]"
);
}
Ok(())
}
async fn session(args: Args) -> Result<()> {
let remote: std::net::SocketAddr = args.connect.parse().context("--connect host:port")?;
let identity = load_or_create_identity()?;
let (ep, observed) = endpoint::client_pinned_with_identity(
args.pin,
Some((identity.0.as_str(), identity.1.as_str())),
);
let ep = ep.map_err(|e| anyhow!("QUIC client endpoint: {e}"))?;
let conn = ep
.connect(remote, "punktfunk")
.context("connect")?
.await
.context("QUIC handshake (a pin mismatch fails here)")?;
match (args.pin, *observed.lock().unwrap()) {
(Some(_), _) => tracing::info!(%remote, "punktfunk/1 connected — host fingerprint pinned"),
(None, Some(fp)) => tracing::info!(
%remote,
fingerprint = %hex(&fp),
"punktfunk/1 connected (trust-on-first-use) — pass --pin to verify this host"
),
(None, None) => tracing::info!(%remote, "punktfunk/1 connected"),
}
let (mut send, mut recv) = conn.open_bi().await.context("open control stream")?;
io::write_msg(
&mut send,
&Hello {
abi_version: punktfunk_core::ABI_VERSION,
mode: args.mode,
compositor: args.compositor,
gamepad: args.gamepad,
bitrate_kbps: args.bitrate_kbps,
// `--name` (also the pairing label) — shown in the host's pending-approval list when
// this client knocks on a pairing-required host.
name: Some(args.name.clone()),
// `--launch ID` — host resolves it against its own library and runs it this session.
launch: args.launch.clone(),
// This headless tool just dumps the bitstream (no decode), so it can always claim
// 10-bit support. Gated by env so latency runs stay on the 8-bit baseline:
// PUNKTFUNK_CLIENT_10BIT=1 advertises VIDEO_CAP_10BIT to exercise the host Main10 path.
video_caps: if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
punktfunk_core::quic::VIDEO_CAP_10BIT
} else {
0
},
}
.encode(),
)
.await?;
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)
.map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
tracing::info!(
mode = ?welcome.mode,
fec = ?welcome.fec,
encrypt = welcome.encrypt,
frames = welcome.frames,
compositor = welcome.compositor.as_str(),
gamepad = welcome.gamepad.as_str(),
"session offer"
);
// Reserve our data-plane port, then tell the host to start.
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
let udp_port = probe.local_addr()?.port();
drop(probe);
io::write_msg(
&mut send,
&Start {
client_udp_port: udp_port,
}
.encode(),
)
.await?;
// Wall-clock skew handshake on the still-private control stream (before --remode/--speed-test
// take it): align our clock to the host's so the per-frame capture→reassembled latency is valid
// across machines. `None` ⇒ an old host that doesn't answer — fall back to a shared clock (0).
let clock_offset_ns = match punktfunk_core::quic::clock_sync(&mut send, &mut recv).await {
Some(skew) => {
tracing::info!(
offset_ns = skew.offset_ns,
rtt_us = skew.rtt_ns / 1000,
rounds = skew.rounds,
"clock skew estimated (host-client); latency now cross-machine valid"
);
Some(skew.offset_ns)
}
None => None,
};
// Speed-test accumulators: the data-plane loop folds each FLAG_PROBE filler AU in here; the
// --speed-test reporter below reads them once the host's ProbeResult lands. first/last hold
// now_ns timestamps of the receive window (0 = unset).
let probe_recv_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let probe_recv_packets = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let probe_first_ns = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let probe_last_ns = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
// Mid-stream renegotiation test: after a delay, ask the host to switch modes on the
// still-open control stream. The stream then carries new-mode AUs (IDR + in-band
// parameter sets) — ffprobe the --out file to see both resolutions. Mutually exclusive with
// --speed-test (both own the control stream).
if let Some((new_mode, after_secs)) = args.remode {
let mut rs = send;
let mut rr = recv;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(after_secs as u64)).await;
tracing::info!(?new_mode, "requesting mid-stream mode switch");
if io::write_msg(&mut rs, &Reconfigure { mode: new_mode }.encode())
.await
.is_err()
{
tracing::error!("Reconfigure write failed");
return;
}
match io::read_msg(&mut rr)
.await
.map(|b| Reconfigured::decode(&b))
{
Ok(Ok(ack)) if ack.accepted => {
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
}
Ok(Ok(ack)) => tracing::warn!(active = ?ack.mode, "mode switch REJECTED"),
other => tracing::error!(?other, "bad Reconfigured"),
}
});
} else if let Some((target_kbps, duration_ms)) = args.speed_test {
// Bandwidth probe: after the stream warms up, ask the host to burst FLAG_PROBE filler;
// measure what arrives vs. what it reports sending.
let mut ss = send;
let mut sr = recv;
let (pb, pp, pf, pl) = (
probe_recv_bytes.clone(),
probe_recv_packets.clone(),
probe_first_ns.clone(),
probe_last_ns.clone(),
);
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // let the stream warm up
tracing::info!(target_kbps, duration_ms, "requesting speed-test probe");
if io::write_msg(
&mut ss,
&ProbeRequest {
target_kbps,
duration_ms,
}
.encode(),
)
.await
.is_err()
{
tracing::error!("ProbeRequest write failed");
return;
}
let res = match io::read_msg(&mut sr).await.map(|b| ProbeResult::decode(&b)) {
Ok(Ok(r)) => r,
other => {
tracing::error!(?other, "bad ProbeResult");
return;
}
};
// The reliable result can beat the last UDP shards — let them reassemble.
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let recv_bytes = pb.load(Relaxed);
let recv_packets = pp.load(Relaxed);
let (first, last) = (pf.load(Relaxed), pl.load(Relaxed));
let window_ms = if first > 0 && last > first {
(last - first) / 1_000_000
} else {
0
};
let throughput_kbps = recv_bytes
.saturating_mul(8)
.checked_div(window_ms)
.unwrap_or(0);
let loss_pct = if res.bytes_sent > 0 {
res.bytes_sent.saturating_sub(recv_bytes) as f64 / res.bytes_sent as f64 * 100.0
} else {
0.0
};
tracing::info!(
target_kbps,
host_sent_bytes = res.bytes_sent,
host_sent_packets = res.packets_sent,
recv_bytes,
recv_packets,
window_ms,
throughput_kbps,
loss_pct = format!("{loss_pct:.1}%"),
"SPEED TEST complete",
);
});
}
// Input plane: scripted events as QUIC datagrams (mouse square + 'A' taps), proving the
// low-latency input path without a real input device.
if args.input_test {
let conn2 = conn.clone();
let (mw, mh) = (args.mode.width, args.mode.height);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
tracing::info!("input-test: sending scripted datagrams for ~6s");
for i in 0..160u32 {
let (dx, dy) = match (i / 10) % 4 {
0 => (12, 0),
1 => (0, 12),
2 => (-12, 0),
_ => (0, -12),
};
let mv = InputEvent {
kind: InputKind::MouseMove,
_pad: [0; 3],
code: 0,
x: dx,
y: dy,
flags: 0,
};
let _ = conn2.send_datagram(mv.encode().to_vec().into());
// Absolute motion too (the GTK client's path): a diagonal sweep, with the
// coordinate-space size packed in `flags` — the contract injectors require.
let abs = InputEvent {
kind: InputKind::MouseMoveAbs,
_pad: [0; 3],
code: 0,
x: ((i * mw) / 160) as i32,
y: ((i * mh) / 160) as i32,
flags: (mw << 16) | (mh & 0xffff),
};
let _ = conn2.send_datagram(abs.encode().to_vec().into());
if i % 20 == 0 {
for kind in [InputKind::KeyDown, InputKind::KeyUp] {
let key = InputEvent {
kind,
_pad: [0; 3],
code: 0x41, // VK 'A'
x: 0,
y: 0,
flags: 0,
};
let _ = conn2.send_datagram(key.encode().to_vec().into());
}
// Gamepad plane: tap A + sweep the left stick on pad 0 (the host
// accumulates these into its virtual xpad; needs /dev/uinput access).
use punktfunk_core::input::gamepad::{AXIS_LS_X, BTN_A};
let pad_events = [
(InputKind::GamepadButton, BTN_A, 1),
(InputKind::GamepadButton, BTN_A, 0),
(
InputKind::GamepadAxis,
AXIS_LS_X,
((i as i32) % 64 - 32) * 1024,
),
];
for (kind, code, x) in pad_events {
let ev = InputEvent {
kind,
_pad: [0; 3],
code,
x,
y: 0,
flags: 0, // pad index 0
};
let _ = conn2.send_datagram(ev.encode().to_vec().into());
}
}
tokio::time::sleep(std::time::Duration::from_millis(40)).await;
}
tracing::info!("input-test: done");
});
}
// Mic plane: stream a synthetic 440 Hz tone as the mic uplink (0xCB), Opus-encoded 5 ms
// stereo frames — proves client→host mic passthrough end to end without a real microphone
// (the host decodes it into its virtual PipeWire source; record that source to hear the tone).
#[cfg(not(target_os = "linux"))]
if args.mic_test {
tracing::warn!("--mic-test requires Linux (libopus) — skipped");
}
#[cfg(target_os = "linux")]
if args.mic_test {
let conn2 = conn.clone();
tokio::spawn(async move {
let mut enc =
match opus::Encoder::new(48_000, opus::Channels::Stereo, opus::Application::Voip) {
Ok(e) => e,
Err(e) => {
tracing::error!(error = %e, "mic-test: opus encoder init failed");
return;
}
};
let _ = enc.set_bitrate(opus::Bitrate::Bits(64_000));
tracing::info!("mic-test: streaming a 440 Hz tone as the mic uplink");
let mut phase = 0.0f32;
let step = 2.0 * std::f32::consts::PI * 440.0 / 48_000.0;
let mut pcm = [0f32; 240 * 2]; // 5 ms stereo
let mut out = [0u8; 4000];
let mut interval = tokio::time::interval(std::time::Duration::from_millis(5));
for seq in 0u32.. {
interval.tick().await;
for f in 0..240 {
let s = (phase.sin()) * 0.25;
phase += step;
if phase > std::f32::consts::PI * 2.0 {
phase -= std::f32::consts::PI * 2.0;
}
pcm[f * 2] = s;
pcm[f * 2 + 1] = s;
}
if let Ok(n) = enc.encode_float(&pcm, &mut out) {
let d = punktfunk_core::quic::encode_mic_datagram(seq, now_ns(), &out[..n]);
if conn2.send_datagram(d.into()).is_err() {
break;
}
}
}
tracing::info!("mic-test: done");
});
}
// Touch plane: drag a synthetic finger (touch id 0) in a circle on the client surface, so
// the host injects it via libei ei_touchscreen — proves the touch path end to end. `flags`
// packs the surface w/h; x/y are pixels (the host maps them into the device region).
if args.touch_test {
let conn2 = conn.clone();
let (w, h) = (args.mode.width, args.mode.height);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let flags = (w << 16) | (h & 0xffff);
let (cx, cy, r) = (w as f32 / 2.0, h as f32 / 2.0, h as f32 / 4.0);
let touch = |kind, x: f32, y: f32| InputEvent {
kind,
_pad: [0; 3],
code: 0, // touch id 0
x: x as i32,
y: y as i32,
flags,
};
tracing::info!("touch-test: dragging a finger in a circle for ~6s");
for loop_i in 0..3u32 {
let _ = conn2.send_datagram(
touch(InputKind::TouchDown, cx + r, cy)
.encode()
.to_vec()
.into(),
);
for i in 0..60u32 {
let a = std::f32::consts::TAU * i as f32 / 60.0;
let mv = touch(InputKind::TouchMove, cx + r * a.cos(), cy + r * a.sin());
let _ = conn2.send_datagram(mv.encode().to_vec().into());
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
let _ = conn2.send_datagram(
touch(InputKind::TouchUp, cx + r, cy)
.encode()
.to_vec()
.into(),
);
let _ = loop_i;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
tracing::info!("touch-test: done");
});
}
// Rich-input plane: instantiate pad 0 on the host (a gamepad event creates the virtual
// DualSense), then drive its touchpad (drag a finger across) + motion (gyro wobble) over the
// 0xCC plane. Proves the rich client→host path; the 0xCD feedback is logged by the receive
// loop below. Requires the host on the DualSense backend (`PUNKTFUNK_GAMEPAD=dualsense`).
if args.rich_input_test {
let conn2 = conn.clone();
tokio::spawn(async move {
use punktfunk_core::input::gamepad::AXIS_LS_X;
use punktfunk_core::quic::RichInput;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// A neutral gamepad axis event makes the host create the virtual DualSense pad 0.
let arrive = InputEvent {
kind: InputKind::GamepadAxis,
_pad: [0; 3],
code: AXIS_LS_X,
x: 0,
y: 0,
flags: 0,
};
let _ = conn2.send_datagram(arrive.encode().to_vec().into());
tracing::info!(
"rich-input-test: dragging the DualSense touchpad + wobbling motion for ~6s"
);
let touch = |active, x, y| RichInput::Touchpad {
pad: 0,
finger: 0,
active,
x,
y,
};
for _ in 0..3u32 {
let _ = conn2.send_datagram(touch(true, 0, 32768).encode().into());
for i in 0..60u32 {
let x = ((i * 65535) / 60) as u16;
let _ = conn2.send_datagram(touch(true, x, 32768).encode().into());
let g = (((i as i32 % 20) - 10) * 500) as i16; // gyro wobble
let _ = conn2.send_datagram(
RichInput::Motion {
pad: 0,
gyro: [g, 0, 0],
accel: [0, 0, 16384],
}
.encode()
.into(),
);
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
let _ = conn2.send_datagram(touch(false, 65535, 32768).encode().into());
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
tracing::info!("rich-input-test: done");
});
}
// Closed-flag for the blocking receive loop.
let closed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
{
let closed = closed.clone();
let conn2 = conn.clone();
tokio::spawn(async move {
conn2.closed().await;
closed.store(true, std::sync::atomic::Ordering::SeqCst);
});
}
// Host→client datagrams: count Opus audio + rumble (playback is the platform clients'
// job; here we verify the planes flow).
let audio_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let audio_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let rumble_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let hidout_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
{
let (a, ab, r, h) = (
audio_pkts.clone(),
audio_bytes.clone(),
rumble_pkts.clone(),
hidout_pkts.clone(),
);
let conn2 = conn.clone();
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
while let Ok(d) = conn2.read_datagram().await {
if let Some((_, _, opus)) = punktfunk_core::quic::decode_audio_datagram(&d) {
a.fetch_add(1, Relaxed);
ab.fetch_add(opus.len() as u64, Relaxed);
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() {
r.fetch_add(1, Relaxed);
} else if let Some(hid) = punktfunk_core::quic::HidOutput::decode(&d) {
// The DualSense feedback plane (lightbar / player LEDs / adaptive triggers).
// Log the first few so a playtest can see triggers/LEDs arrive without spam.
if h.fetch_add(1, Relaxed) < 12 {
tracing::info!(?hid, "DualSense HID output (0xCD)");
}
}
}
});
}
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
let cfg = welcome.session_config(Role::Client);
let expected = welcome.frames;
let out_path = args.out.clone();
let (pb, pp, pf, pl) = (
probe_recv_bytes.clone(),
probe_recv_packets.clone(),
probe_first_ns.clone(),
probe_last_ns.clone(),
);
// Express our receive time in the host clock before differencing against the host-stamped
// capture pts. 0 ⇒ same-host or an old host that didn't answer the skew handshake (the latency
// is then only valid same-host, as before).
let clock_offset = clock_offset_ns.unwrap_or(0);
let skew_corrected = clock_offset_ns.is_some();
// Data plane on a blocking thread (native threads only on the frame path).
let result = tokio::task::spawn_blocking(move || -> Result<()> {
let transport =
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())
.context("bind data plane")?;
// Hole-punch the host's data port so video traverses a NAT / inter-VLAN firewall. This
// tool runs one session then exits, so the keepalive thread dies with the process — no
// explicit stop needed (the flag is never set).
if let Ok(sock) = transport.try_clone_socket() {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
punktfunk_core::transport::spawn_data_punch(sock, stop);
}
let mut session =
Session::new(cfg, Box::new(transport)).map_err(|e| anyhow!("client session: {e:?}"))?;
let mut sink = match &out_path {
Some(p) => Some(std::io::BufWriter::new(
std::fs::File::create(p).with_context(|| format!("create {p}"))?,
)),
None => None,
};
let mut ok = 0u32;
let mut mismatched = 0u32;
let mut bytes = 0u64;
let mut latencies_us: Vec<u64> = Vec::new();
let mut last_rx = std::time::Instant::now();
let started = std::time::Instant::now();
loop {
if expected > 0 && ok + mismatched >= expected {
break;
}
if closed.load(std::sync::atomic::Ordering::SeqCst)
&& last_rx.elapsed() > std::time::Duration::from_millis(300)
{
break;
}
if started.elapsed() > std::time::Duration::from_secs(120)
|| last_rx.elapsed() > std::time::Duration::from_secs(8)
{
break;
}
match session.poll_frame() {
Ok(frame) => {
last_rx = std::time::Instant::now();
// Speed-test filler isn't video: fold it into the probe accumulators and skip
// verification / the --out sink.
if frame.flags & FLAG_PROBE as u32 != 0 {
use std::sync::atomic::Ordering::Relaxed;
let n = now_ns();
let _ = pf.compare_exchange(0, n, Relaxed, Relaxed);
pl.store(n, Relaxed);
pb.fetch_add(frame.data.len() as u64, Relaxed);
pp.fetch_add(1, Relaxed);
continue;
}
bytes += frame.data.len() as u64;
// capture→reassembled: our receive instant in the host clock (now + offset)
// minus the host's capture pts. offset is 0 same-host / old host.
let lat = (now_ns() as i128 + clock_offset as i128 - frame.pts_ns as i128)
.max(0) as u64;
if lat > 0 && lat < 10_000_000_000 {
latencies_us.push(lat / 1000);
}
if expected > 0 {
// Verification mode: deterministic content.
let idx = u32::from_le_bytes(frame.data[0..4].try_into().unwrap());
if frame.data == test_frame(idx, frame.data.len()) {
ok += 1;
} else {
mismatched += 1;
}
} else {
ok += 1;
if let Some(s) = sink.as_mut() {
s.write_all(&frame.data).context("write AU")?;
}
}
}
Err(PunktfunkError::NoFrame) => {
std::thread::sleep(std::time::Duration::from_micros(300));
}
Err(e) => return Err(anyhow!("poll_frame: {e:?}")),
}
}
if let Some(mut s) = sink {
s.flush().ok();
}
latencies_us.sort_unstable();
let pct = |p: f64| -> u64 {
if latencies_us.is_empty() {
return 0;
}
let i = ((latencies_us.len() as f64 * p) as usize).min(latencies_us.len() - 1);
latencies_us[i]
};
tracing::info!(
frames = ok,
mismatched,
mb = bytes / 1_000_000,
lat_p50_us = pct(0.50),
lat_p95_us = pct(0.95),
lat_p99_us = pct(0.99),
lat_max_us = latencies_us.last().copied().unwrap_or(0),
skew_corrected,
"punktfunk/1 stream complete (capture→reassembled latency; skew_corrected=true ⇒ \
cross-machine valid, false ⇒ same-host clock)"
);
if expected > 0 {
anyhow::ensure!(mismatched == 0, "{mismatched} corrupted frames");
anyhow::ensure!(ok == expected, "received {ok}/{expected} frames");
tracing::info!("verification PASSED");
} else {
anyhow::ensure!(ok > 0, "no frames received");
}
Ok(())
})
.await?;
// Report the side planes whether or not the video plane succeeded.
{
use std::sync::atomic::Ordering::Relaxed;
let (a, ab, r, h) = (
audio_pkts.load(Relaxed),
audio_bytes.load(Relaxed),
rumble_pkts.load(Relaxed),
hidout_pkts.load(Relaxed),
);
if a > 0 || r > 0 || h > 0 {
tracing::info!(
audio_pkts = a,
audio_kb = ab / 1000,
rumble_pkts = r,
hidout_pkts = h,
"host→client datagrams (Opus 48 kHz stereo, 5 ms frames; rumble; DualSense HID)"
);
}
}
conn.close(0u32.into(), b"done");
result
}
/// The host's deterministic test frame (mirror of `punktfunk-host::m3::test_frame`).
fn test_frame(idx: u32, len: usize) -> Vec<u8> {
let mut d = vec![0u8; len];
if len >= 4 {
d[0..4].copy_from_slice(&idx.to_le_bytes());
}
for (i, b) in d.iter_mut().enumerate().skip(4) {
*b = (idx as u8).wrapping_add(i as u8);
}
d
}
@@ -1,64 +0,0 @@
[package]
name = "punktfunk-client-windows"
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, D3D11/SwapChainPanel present, FFmpeg decode, WASAPI audio, SDL3 gamepads"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[[bin]]
name = "punktfunk-client"
path = "src/main.rs"
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
# other native clients live in crates/punktfunk-client-linux and clients/apple); on other
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
# gating exactly.
[target.'cfg(windows)'.dependencies]
# The protocol core, linked directly (no C ABI) — same as the GTK Linux client. NativeClient
# is Sync (mutexed plane receivers), so it drops into a UI app cleanly.
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
# next to the exe; it requires `CARGO_WORKSPACE_DIR` to be set in the build env. Unpublished
# (version 0.0.0) and fast-moving, so pinned to a verified commit.
windows-reactor = { git = "https://github.com/microsoft/windows-rs", rev = "b4129fcc1ae81eec8bf1217539883db821bca3a1" }
# Win32 / Direct3D11 / DXGI for the SwapChainPanel composition swapchain. Pulled from the SAME
# windows-rs commit as windows-reactor so their `windows-core` unifies — the `IDXGISwapChain1`
# we hand to `SwapChainPanelHandle::set_swap_chain` must satisfy reactor's `windows_core::Interface`.
windows = { git = "https://github.com/microsoft/windows-rs", rev = "b4129fcc1ae81eec8bf1217539883db821bca3a1", features = [
"Win32_Foundation",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Direct3D_Fxc",
"Win32_Graphics_Gdi",
"Win32_System_Console",
"Win32_System_LibraryLoader",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
] }
# Video decode (same FFmpeg pin as the host/Linux client) — software HEVC on the GPU-less dev
# box; D3D11VA hardware decode is a follow-up for the real-GPU box.
ffmpeg-next = "8"
opus = "0.3"
# Audio render + mic capture (the WASAPI analogue of the Linux client's PipeWire backend).
wasapi = "0.23"
# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform;
# built from source via the bundled CMake on Windows (no system SDL3).
sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] }
mdns-sd = "0.20"
async-channel = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -1,65 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
MSIX package manifest for the punktfunk Windows client (WinUI 3 via windows-reactor).
This is a TEMPLATE: packaging/pack-msix.ps1 substitutes {VERSION} (4-part numeric, e.g.
0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — default
`CN=unom` for the self-signed CI cert; a real code-signing cert just passes its own subject).
Why this packages cleanly even though the app was built "unpackaged": windows-reactor calls
MddBootstrapInitialize2 with OnPackageIdentity_NOOP (crates/libs/reactor/src/app.rs), so under
MSIX package identity the App SDK bootstrapper is a no-op and the runtime is resolved from the
<PackageDependency> below instead. The framework family + min version mirror what the runner has
installed and what reactor pins (WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000 = 2.0 ->
Microsoft.WindowsAppRuntime.2).
Full-trust Win32 app (EntryPoint Windows.FullTrustApplication + runFullTrust) — it owns raw D3D11,
Win32 low-level input hooks, WASAPI and SDL3, none of which fit the UWP app container.
-->
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity
Name="unom.Punktfunk"
Publisher="{PUBLISHER}"
Version="{VERSION}"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>Punktfunk</DisplayName>
<PublisherDisplayName>unom</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26100.0" />
<PackageDependency
Name="Microsoft.WindowsAppRuntime.2"
MinVersion="2.2.0.0"
Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" />
</Dependencies>
<Resources>
<Resource Language="en-us" />
</Resources>
<Applications>
<Application Id="Punktfunk" Executable="punktfunk-client.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="Punktfunk"
Description="Low-latency desktop and game streaming client"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>
@@ -1,80 +0,0 @@
# punktfunk Windows client — MSIX packaging
The Windows client ships as a **signed MSIX** so Windows boxes get a real package (Start tile,
clean install/uninstall) instead of a loose exe. CI builds + publishes it from
[`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
touches the client and on `win-v*` release tags.
## What's in the package
`pack-msix.ps1` assembles a layout from a `cargo build --release` and runs `makeappx` + `signtool`:
| File | Source |
|---|---|
| `punktfunk-client.exe` | the release build |
| `Microsoft.WindowsAppRuntime.Bootstrap.dll`, `resources.pri` | auto-staged by windows-reactor's `build.rs` |
| `SDL3.dll` | auto-staged by the `sdl3` crate |
| `avcodec/avformat/avutil/swscale/swresample/...-*.dll` | `FFMPEG_DIR\bin` |
| `Assets\*.png` | checked-in tile/store logos (rasterized from `packaging/flatpak/io.unom.Punktfunk.svg`) |
| `AppxManifest.xml` | the template here, with `{VERSION}`/`{PUBLISHER}` substituted |
### Why an "unpackaged" WinUI app packages cleanly
windows-reactor calls `MddBootstrapInitialize2` with `OnPackageIdentity_NOOP`
(`crates/libs/reactor/src/app.rs`), so under MSIX **package identity** the App SDK bootstrapper is
a no-op and the runtime is resolved from the manifest's `<PackageDependency>` on
`Microsoft.WindowsAppRuntime.2` instead (reactor pins `WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000`
= 2.0). It's a full-trust Win32 app (`EntryPoint="Windows.FullTrustApplication"` + `runFullTrust`)
because it owns raw D3D11, Win32 low-level input hooks, WASAPI and SDL3.
## Versioning
MSIX requires a strictly 4-part numeric version. The workflow computes:
- `win-vX.Y.Z` tag → `X.Y.Z.0` (a real client release; `win-v*` is its own tag namespace, kept off
the host's `host-v*` and Apple's `v*` to avoid the version-shadow bug).
- `main` push / `workflow_dispatch``0.2.<run_number>.0` (rolling, climbs by run number).
## Signing & install
CI signs every build with a **stable self-signed code-signing cert** (`CN=unom`, SHA-1
`CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`, valid to 2036). Its public half is checked in as
[`punktfunk-codesign.cer`](punktfunk-codesign.cer); the private `.pfx` + password live in the
`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD` Actions secrets. Because it's the *same* cert every build,
trusting it is **one-time, per machine** — once imported, every future build and in-place upgrade is
trusted with no further prompt:
```powershell
# once per machine (elevated): trust the publisher
Import-Certificate -FilePath .\punktfunk-codesign.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople
# then install (and re-run for each upgrade — no re-trust needed)
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_x64.msix
```
The matching `.cer` is also published next to each `.msix` in the registry, so it's always at hand.
The MSIX declares a dependency on the Windows App SDK 2.x runtime; install
[the App SDK runtime](https://aka.ms/windowsappsdk) if `Add-AppxPackage` reports a missing
`Microsoft.WindowsAppRuntime.2` framework.
`pack-msix.ps1` signing precedence: it uses the **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** secrets
when present (the stable cert above), else generates an *ephemeral* self-signed cert (forks / local
builds without the secrets). Either way it exports the signing cert's public `.cer` for the import.
**To move to a publicly-trusted (no-import) cert** — Azure Artifact Signing or a public OV cert —
replace the two secrets with the new `.pfx`; the cert's subject DN must equal the manifest
`Publisher`, so pass a matching `-Publisher` (it's stamped into the package `Identity`, and changing
it changes the package identity → a one-time reinstall).
## Building locally
On the Windows runner / dev VM (MSVC + Windows SDK present), after a release build:
```powershell
cargo build --release -p punktfunk-client-windows
pwsh -File crates/punktfunk-client-windows/packaging/pack-msix.ps1 `
-Version 0.2.0.0 -TargetDir C:\t\release -OutDir C:\t\msix
```
Validated end-to-end on the build VM (pack → sign → `Add-AppxPackage` → framework-dependency
resolution). The only step that needs a real display is *launching* the WinUI window (same
on-glass constraint as the rest of the client).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,144 +0,0 @@
<#
.SYNOPSIS
Assemble, pack and sign the punktfunk Windows client as a signed MSIX.
.DESCRIPTION
Builds a packaging layout from a release `cargo build` output (exe + the reactor/SDL3 auto-staged
DLLs + resources.pri + FFmpeg DLLs + the checked-in Assets + the manifest), runs makeappx, and
signs with signtool. Idempotent; safe to re-run.
Signing cert precedence:
1. -PfxBase64 / -PfxPassword (a real or shared code-signing cert, e.g. from CI secrets) — the
cert's subject DN MUST match -Publisher (which is stamped into the manifest Identity).
2. otherwise an EPHEMERAL self-signed code-signing cert with subject = -Publisher is generated
in-process. The package installs only where that cert is trusted, so the matching public
.cer is exported next to the .msix for the user to import (Trusted People) before install.
Swap in a real cert later with zero manifest changes — just pass -PfxBase64/-Publisher.
Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present.
.EXAMPLE
pwsh -File pack-msix.ps1 -Version 0.2.137.0 -TargetDir C:\t\release -FfmpegBin C:\Users\Public\ffmpeg\bin -OutDir C:\t\msix
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, e.g. 0.2.137.0
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe)
[string]$FfmpegBin = $(if ($env:FFMPEG_DIR) { Join-Path $env:FFMPEG_DIR 'bin' } else { 'C:\Users\Public\ffmpeg\bin' }),
[string]$OutDir = (Join-Path $TargetDir 'msix'),
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
if ($Version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
throw "Version must be 4-part numeric (Major.Minor.Build.Revision); got '$Version'."
}
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$assets = Join-Path $here 'assets'
$manifestTemplate = Join-Path $here 'AppxManifest.xml'
# --- locate the Windows SDK tools (newest makeappx/signtool under the x64 kit bin) ---
function Find-SdkTool([string]$name) {
$root = 'C:\Program Files (x86)\Windows Kits\10\bin'
# match only versioned x64 kit bins (…\10\bin\10.0.NNNNN.N\x64\tool.exe) and pick the newest
$hit = Get-ChildItem -Path $root -Recurse -Filter $name -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\(10\.0\.\d+\.\d+)\\x64\\' } |
Sort-Object { [version]([regex]::Match($_.FullName, '\\(10\.0\.\d+\.\d+)\\x64\\').Groups[1].Value) } |
Select-Object -Last 1
if (-not $hit) { throw "$name not found under $root — install the Windows 10/11 SDK." }
$hit.FullName
}
$makeappx = Find-SdkTool 'makeappx.exe'
$signtool = Find-SdkTool 'signtool.exe'
Write-Host "makeappx: $makeappx"
Write-Host "signtool: $signtool"
# --- assemble the package layout ---
$layout = Join-Path $OutDir 'layout'
if (Test-Path $layout) { Remove-Item -Recurse -Force $layout }
New-Item -ItemType Directory -Force -Path (Join-Path $layout 'Assets') | Out-Null
# binary + auto-staged runtime bits (reactor stages the App SDK bootstrap DLL + resources.pri,
# the sdl3 crate stages SDL3.dll — see crate build output).
$required = @('punktfunk-client.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
foreach ($f in $required) {
$src = Join-Path $TargetDir $f
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
Copy-Item $src (Join-Path $layout $f) -Force
}
# FFmpeg runtime DLLs (the exe link-imports the decode set; copy them all — small and correct)
$ff = Get-ChildItem -Path $FfmpegBin -Filter *.dll -ErrorAction SilentlyContinue
if (-not $ff) { throw "no FFmpeg DLLs in $FfmpegBin" }
$ff | ForEach-Object { Copy-Item $_.FullName (Join-Path $layout $_.Name) -Force }
# tile/store assets
Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force
# manifest with version + publisher substituted
$manifest = (Get-Content -Raw $manifestTemplate).Replace('{VERSION}', $Version).Replace('{PUBLISHER}', $Publisher)
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
Write-Host "layout assembled at $layout :"
Get-ChildItem $layout -Recurse -File | ForEach-Object { " $($_.FullName.Substring($layout.Length + 1))" }
# --- pack ---
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$msix = Join-Path $OutDir "punktfunk-client-windows_${Version}_x64.msix"
& $makeappx pack /o /d $layout /p $msix
if ($LASTEXITCODE -ne 0) { throw "makeappx pack failed ($LASTEXITCODE)" }
# --- signing cert (supplied stable pfx OR ephemeral self-signed) ---
$pfxPath = Join-Path $OutDir 'signing.pfx'
$cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_x64.cer"
if ($PfxBase64) {
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
} else {
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
-KeyUsage DigitalSignature -FriendlyName 'punktfunk MSIX (self-signed)' `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
$sec = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
}
# Always export the public .cer from the pfx. For a self-signed / private-trust cert it's the file
# users import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
# import is a one-time, per-machine step that keeps working across upgrades. For a public-CA cert
# it's just an unused extra (harmless). The manifest Publisher must equal the cert's subject DN.
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
if ($pubCert.Subject -ne $Publisher) {
Write-Warning "cert subject '$($pubCert.Subject)' != manifest Publisher '$Publisher' — Add-AppxPackage will reject the mismatch. Pass -Publisher '$($pubCert.Subject)'."
}
# --- sign (timestamp best-effort) ---
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
& $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $msix))
if ($LASTEXITCODE -ne 0) {
Write-Warning "timestamped sign failed — retrying without a timestamp"
& $signtool ($signArgs + @($msix))
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed ($LASTEXITCODE)" }
}
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "==> MSIX: $msix"
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
# emit paths for the workflow to publish (only under CI, where GITHUB_ENV is set)
if ($env:GITHUB_ENV) {
"MSIX_PATH=$msix" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
-876
View File
@@ -1,876 +0,0 @@
//! The WinUI 3 (windows-reactor) application shell — host list, settings, PIN/TOFU pairing, and
//! the stream page (a `SwapChainPanel` bound to the D3D11 composition swapchain in
//! [`crate::present`], driven by reactor's per-frame `on_rendering`).
//!
//! Declarative React-like model: a single root component routes on a `Screen` value held in
//! `use_async_state` so background threads (discovery, the session pump) can drive navigation.
//! The present + decoded-frame handoff crosses to the UI thread through a `Mutex` side-channel
//! and thread-locals (the windows-reactor SwapChainPanel sample's pattern), since the per-frame
//! present must not go through state/rerender.
//!
//! The chrome follows the windows-reactor gallery's look: Mica backdrop, a centred max-width
//! column, theme brushes (`ThemeRef`), and rounded `border` cards.
use crate::discovery::{self, DiscoveredHost};
use crate::gamepad::GamepadService;
use crate::present::Presenter;
use crate::session::{self, SessionEvent, SessionParams, Stats};
use crate::trust::{self, KnownHost, KnownHosts, Settings};
use crate::video::DecodedFrame;
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::cell::RefCell;
use std::sync::{Arc, Mutex};
use windows_reactor::*;
const RESOLUTIONS: &[(u32, u32)] = &[
(0, 0),
(1280, 720),
(1920, 1080),
(2560, 1440),
(3840, 2160),
];
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
#[derive(Clone, PartialEq)]
enum Screen {
Hosts,
Connecting,
Stream,
Settings,
Pair,
}
/// The host we're about to connect to / pair with (carried into the Pair screen).
#[derive(Clone, Default)]
struct Target {
name: String,
addr: String,
port: u16,
fp_hex: Option<String>,
pair_optional: bool,
}
/// Stable app services handed to the page components as props. Each routed screen that uses
/// hooks (`hosts_page`/`pair_page`/`stream_page`) is mounted as its own `component(...)`, so
/// its hooks live in an isolated slot list — calling them on the shared parent `cx` would
/// change the hook order whenever the screen changes (reactor's Rules-of-Hooks guard aborts).
///
/// `Svc` compares equal by `ctx` identity (it never meaningfully changes across renders), so a
/// page whose props are just `Svc` re-renders only via its own state hooks, never spuriously
/// from the parent.
#[derive(Clone)]
struct Svc {
ctx: Arc<AppCtx>,
set_screen: AsyncSetState<Screen>,
set_status: AsyncSetState<String>,
}
impl PartialEq for Svc {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.ctx, &other.ctx)
}
}
/// Props for the hosts page: the services plus the changing discovery/status data that must
/// drive its re-render (compared by value, so a new host list or error refreshes the page).
#[derive(Clone)]
struct HostsProps {
svc: Svc,
hosts: Vec<DiscoveredHost>,
status: String,
}
impl PartialEq for HostsProps {
fn eq(&self, other: &Self) -> bool {
self.svc == other.svc && self.hosts == other.hosts && self.status == other.status
}
}
/// Props for the stream page: the services plus the live stats that drive the HUD overlay
/// (compared by value, so each new sample re-renders the overlay).
#[derive(Clone)]
struct StreamProps {
svc: Svc,
stats: Stats,
}
impl PartialEq for StreamProps {
fn eq(&self, other: &Self) -> bool {
self.svc == other.svc && self.stats == other.stats
}
}
/// UI-thread-only present context: the D3D11 presenter plus the decoded-frame receiver.
struct PresentCtx {
presenter: Presenter,
frames: async_channel::Receiver<DecodedFrame>,
}
thread_local! {
static PRESENT: RefCell<Option<PresentCtx>> = const { RefCell::new(None) };
static PENDING_FRAMES: RefCell<Option<async_channel::Receiver<DecodedFrame>>> =
const { RefCell::new(None) };
}
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread).
#[derive(Default)]
struct Shared {
handoff: Mutex<Option<(Arc<NativeClient>, async_channel::Receiver<DecodedFrame>)>>,
target: Mutex<Target>,
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
/// by the stream page's HUD poll thread to drive the overlay.
stats: Mutex<Stats>,
}
pub struct AppCtx {
identity: (String, String),
settings: Mutex<Settings>,
gamepad: GamepadService,
shared: Arc<Shared>,
}
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
let ctx = Arc::new(AppCtx {
identity,
settings: Mutex::new(Settings::load()),
gamepad,
shared: Arc::new(Shared::default()),
});
App::new()
.title("Punktfunk")
.inner_size(1000.0, 720.0)
.backdrop(Backdrop::Mica)
.render(move |cx| root(cx, &ctx))
}
// --- shared styling -----------------------------------------------------------------------
fn uniform(v: f64) -> Thickness {
Thickness::uniform(v)
}
fn edges(left: f64, top: f64, right: f64, bottom: f64) -> Thickness {
Thickness {
left,
top,
right,
bottom,
}
}
/// A rounded, bordered surface in the theme's card colours.
fn card(child: impl Into<Element>) -> Border {
border(child.into())
.background(ThemeRef::CardBackground)
.border_brush(ThemeRef::CardStroke)
.border_thickness(uniform(1.0))
.corner_radius(8.0)
.padding(uniform(16.0))
}
/// A small all-caps section label above a group of cards.
fn section(label: &str) -> Element {
text_block(label)
.font_size(12.0)
.semibold()
.foreground(ThemeRef::SecondaryText)
.margin(edges(2.0, 10.0, 0.0, 0.0))
.into()
}
/// Wrap a screen's children in a scrollable, centred, max-width column.
fn page(children: Vec<Element>) -> Element {
let col = vstack(children)
.spacing(10.0)
.max_width(640.0)
.horizontal_alignment(HorizontalAlignment::Center)
.margin(edges(24.0, 24.0, 24.0, 40.0));
scroll_view(col).into()
}
/// A clickable host row: name + address/badge + chevron.
fn host_card(name: &str, sub: &str, badge: &str, on_tap: impl Fn() + 'static) -> Element {
card(
grid((
vstack((
text_block(name).font_size(15.0).semibold(),
text_block(sub)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText),
))
.spacing(2.0)
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
text_block(badge)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.grid_column(1)
.vertical_alignment(VerticalAlignment::Center)
.margin(edges(0.0, 0.0, 12.0, 0.0)),
text_block("\u{203A}")
.font_size(18.0)
.foreground(ThemeRef::SecondaryText)
.grid_column(2)
.vertical_alignment(VerticalAlignment::Center),
))
.columns([GridLength::Star(1.0), GridLength::Auto, GridLength::Auto]),
)
.on_tapped(on_tap)
.into()
}
// --- screens ------------------------------------------------------------------------------
fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
let (screen, set_screen) = cx.use_async_state(Screen::Hosts);
let (hosts, set_hosts) = cx.use_async_state(Vec::<DiscoveredHost>::new());
let (status, set_status) = cx.use_async_state(String::new());
let (stats, set_stats) = cx.use_async_state(Stats::default());
// Continuous LAN discovery (spawned once).
cx.use_effect((), {
let set_hosts = set_hosts.clone();
move || {
let rx = discovery::browse();
std::thread::spawn(move || {
let mut acc: Vec<DiscoveredHost> = Vec::new();
while let Ok(h) = rx.recv_blocking() {
if let Some(e) = acc.iter_mut().find(|e| e.key == h.key) {
*e = h;
} else {
acc.push(h);
}
set_hosts.call(acc.clone());
}
});
}
});
// HUD stats: the session event loop writes `shared.stats`; this poll thread mirrors it into
// root state so the stream page gets it as a *prop*. (A child component's own async-state
// update is pruned when its props are unchanged — only a prop change re-renders it, exactly
// like discovery → hosts above.)
cx.use_effect((), {
let shared = ctx.shared.clone();
let set_stats = set_stats.clone();
move || {
std::thread::Builder::new()
.name("pf-hud".into())
.spawn(move || {
let mut last = Stats::default();
loop {
std::thread::sleep(std::time::Duration::from_millis(400));
let s = *shared.stats.lock().unwrap();
if s != last {
last = s;
set_stats.call(s);
}
}
})
.ok();
}
});
// Each hook-using screen is mounted as its own component so its hooks are isolated from
// root's (root's own hooks above stay a stable prefix regardless of which screen renders).
let svc = Svc {
ctx: ctx.clone(),
set_screen: set_screen.clone(),
set_status: set_status.clone(),
};
match screen {
Screen::Hosts => component(hosts_page, HostsProps { svc, hosts, status }),
Screen::Connecting => vstack((
ProgressRing::indeterminate()
.width(48.0)
.height(48.0)
.horizontal_alignment(HorizontalAlignment::Center),
text_block("Connecting\u{2026}")
.font_size(16.0)
.horizontal_alignment(HorizontalAlignment::Center),
text_block(status.clone())
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Center),
))
.spacing(16.0)
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center)
.into(),
// settings_page uses no hooks (it never touches `cx`), so calling it inline is sound.
Screen::Settings => settings_page(ctx, &set_screen),
Screen::Pair => component(pair_page, svc),
Screen::Stream => component(stream_page, StreamProps { svc, stats }),
}
}
fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let ctx = &props.svc.ctx;
let hosts = props.hosts.as_slice();
let status = props.status.as_str();
let set_screen = &props.svc.set_screen;
let set_status = &props.svc.set_status;
let (manual, set_manual) = cx.use_state(String::new());
let known = KnownHosts::load();
let mut body: Vec<Element> = Vec::new();
// Header: title block + Settings button.
body.push(
grid((
vstack((
text_block("Punktfunk").font_size(30.0).bold(),
text_block("Stream from a host on your network.")
.foreground(ThemeRef::SecondaryText),
))
.spacing(2.0)
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
button("Settings")
.on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
})
.grid_column(1)
.vertical_alignment(VerticalAlignment::Center),
))
.columns([GridLength::Star(1.0), GridLength::Auto])
.margin(edges(0.0, 0.0, 0.0, 6.0))
.into(),
);
if !status.is_empty() {
body.push(card(text_block(status.to_string()).foreground(ThemeRef::SystemCritical)).into());
}
// Saved (trusted/paired) hosts.
if !known.hosts.is_empty() {
body.push(section("SAVED HOSTS"));
for k in &known.hosts {
let target = Target {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
fp_hex: Some(k.fp_hex.clone()),
pair_optional: false,
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
body.push(host_card(
&k.name,
&format!("{}:{}", k.addr, k.port),
if k.paired { "Paired" } else { "Trusted" },
move || initiate(&ctx2, target.clone(), &ss, &st),
));
}
}
// Discovered hosts.
body.push(section("ON YOUR NETWORK"));
if hosts.is_empty() {
body.push(
card(
hstack((
ProgressRing::indeterminate().width(18.0).height(18.0),
text_block("Searching the LAN\u{2026}").foreground(ThemeRef::SecondaryText),
))
.spacing(12.0),
)
.into(),
);
} else {
for h in hosts {
let target = Target {
name: h.name.clone(),
addr: h.addr.clone(),
port: h.port,
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
pair_optional: h.pair == "optional",
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let badge = if h.pair == "required" { "PIN" } else { "Open" };
body.push(host_card(
&h.name,
&format!("{}:{}", h.addr, h.port),
badge,
move || initiate(&ctx2, target.clone(), &ss, &st),
));
}
}
// Manual connection.
body.push(section("CONNECT MANUALLY"));
let connect_manual = {
let (ctx2, ss, st, text) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
manual.clone(),
);
move || {
let text = text.trim();
if text.is_empty() {
return;
}
let (addr, port) = match text.rsplit_once(':') {
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
None => (text.to_string(), 9777),
};
initiate(
&ctx2,
Target {
name: addr.clone(),
addr,
port,
fp_hex: None,
pair_optional: false,
},
&ss,
&st,
);
}
};
body.push(
card(
grid((
text_box(manual)
.placeholder("host or host:port")
.on_changed(move |s| set_manual.call(s))
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
button("Connect")
.accent()
.on_click(connect_manual)
.grid_column(1)
.margin(edges(8.0, 0.0, 0.0, 0.0)),
))
.columns([GridLength::Star(1.0), GridLength::Auto]),
)
.into(),
);
page(body)
}
/// The trust gate (mirrors the GTK client's `initiate_connect`): pinned fingerprint → silent
/// connect; known address → stored pin; advertised `pair=optional` → TOFU; otherwise → PIN
/// pairing.
fn initiate(
ctx: &Arc<AppCtx>,
target: Target,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
let known = KnownHosts::load();
let pin = target
.fp_hex
.as_ref()
.and_then(|fp| known.find_by_fp(fp).map(|_| fp.clone()))
.or_else(|| {
known
.find_by_addr(&target.addr, target.port)
.map(|k| k.fp_hex.clone())
})
.and_then(|fp| trust::parse_hex32(&fp));
if let Some(pin) = pin {
connect(ctx, &target, Some(pin), set_screen, set_status);
} else if target.pair_optional {
connect(ctx, &target, None, set_screen, set_status); // TOFU
} else {
*ctx.shared.target.lock().unwrap() = target;
set_screen.call(Screen::Pair);
}
}
fn connect(
ctx: &Arc<AppCtx>,
target: &Target,
pin: Option<[u8; 32]>,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
let s = ctx.settings.lock().unwrap().clone();
let mode = if s.width != 0 && s.refresh_hz != 0 {
Mode {
width: s.width,
height: s.height,
refresh_hz: s.refresh_hz,
}
} else {
Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
}
};
let gamepad_pref = match GamepadPref::from_name(&s.gamepad) {
Some(GamepadPref::Auto) | None => ctx.gamepad.auto_pref(),
Some(explicit) => explicit,
};
let handle = session::start(SessionParams {
host: target.addr.clone(),
port: target.port,
mode,
compositor: CompositorPref::Auto,
gamepad: gamepad_pref,
bitrate_kbps: s.bitrate_kbps,
mic_enabled: s.mic_enabled,
pin,
identity: ctx.identity.clone(),
});
set_status.call(String::new());
set_screen.call(Screen::Connecting);
let tofu = pin.is_none();
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
let (ss, st) = (set_screen.clone(), set_status.clone());
let target = target.clone();
std::thread::spawn(move || loop {
match handle.events.recv_blocking() {
Ok(SessionEvent::Connected {
connector,
fingerprint,
..
}) => {
if tofu {
let mut k = KnownHosts::load();
k.upsert(KnownHost {
name: target.name.clone(),
addr: target.addr.clone(),
port: target.port,
fp_hex: trust::hex(&fingerprint),
paired: false,
});
let _ = k.save();
}
gamepad.attach(connector.clone());
*shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
ss.call(Screen::Stream);
}
Ok(SessionEvent::Failed {
msg,
trust_rejected,
}) => {
st.call(msg);
gamepad.detach();
if trust_rejected {
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
*shared.target.lock().unwrap() = target.clone();
ss.call(Screen::Pair);
} else {
ss.call(Screen::Hosts);
}
break;
}
Ok(SessionEvent::Ended(err)) => {
st.call(err.unwrap_or_else(|| "Session ended".into()));
gamepad.detach();
ss.call(Screen::Hosts);
break;
}
Ok(SessionEvent::Stats(s)) => *shared.stats.lock().unwrap() = s,
Err(_) => {
gamepad.detach();
ss.call(Screen::Hosts);
break;
}
}
});
}
fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
let ctx = &props.ctx;
let set_screen = &props.set_screen;
let set_status = &props.set_status;
let (code, set_code) = cx.use_state(String::new());
let target = ctx.shared.target.lock().unwrap().clone();
let pair_btn = {
let (ctx2, ss, st, code2, target2) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
code.clone(),
target.clone(),
);
button("Pair & Connect").accent().on_click(move || {
let pin = code2.trim().to_string();
let (ctx3, ss, st, target3) = (ctx2.clone(), ss.clone(), st.clone(), target2.clone());
std::thread::spawn(move || {
let name =
std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into());
match NativeClient::pair(
&target3.addr,
target3.port,
(&ctx3.identity.0, &ctx3.identity.1),
&pin,
&name,
std::time::Duration::from_secs(90),
) {
Ok(fp) => {
let mut k = KnownHosts::load();
k.upsert(KnownHost {
name: target3.name.clone(),
addr: target3.addr.clone(),
port: target3.port,
fp_hex: trust::hex(&fp),
paired: true,
});
let _ = k.save();
connect(&ctx3, &target3, Some(fp), &ss, &st);
}
Err(e) => {
st.call(format!("Pairing failed: {e:?} (wrong PIN, or not armed?)"));
ss.call(Screen::Hosts);
}
}
});
})
};
let cancel_btn = {
let ss = set_screen.clone();
button("Cancel").on_click(move || ss.call(Screen::Hosts))
};
let content = card(vstack((
text_block(format!("Pair with {}", target.name))
.font_size(20.0)
.semibold(),
text_block(
"Arm pairing on the host (its console or web console), then enter the 4-digit PIN it \
shows.",
)
.foreground(ThemeRef::SecondaryText)
.max_width(440.0),
text_box(code)
.placeholder("PIN")
.on_changed(move |s| set_code.call(s)),
hstack((pair_btn, cancel_btn)).spacing(8.0),
))
.spacing(14.0))
.max_width(480.0)
.horizontal_alignment(HorizontalAlignment::Center)
.margin(edges(0.0, 80.0, 0.0, 0.0));
page(vec![content.into()])
}
fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
let s = ctx.settings.lock().unwrap().clone();
let res_i = RESOLUTIONS
.iter()
.position(|&(w, h)| w == s.width && h == s.height)
.unwrap_or(0) as i32;
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0) as i32;
let res_names: Vec<String> = RESOLUTIONS
.iter()
.map(|&(w, h)| {
if w == 0 {
"Native display".into()
} else {
format!("{w} \u{00D7} {h}")
}
})
.collect();
let hz_names: Vec<String> = REFRESH
.iter()
.map(|&r| {
if r == 0 {
"Native".into()
} else {
format!("{r} Hz")
}
})
.collect();
let res_combo = {
let ctx = ctx.clone();
ComboBox::new(res_names)
.header("Resolution")
.selected_index(res_i)
.on_selection_changed(move |i: i32| {
let (w, h) = RESOLUTIONS[(i.max(0) as usize).min(RESOLUTIONS.len() - 1)];
let mut s = ctx.settings.lock().unwrap();
(s.width, s.height) = (w, h);
s.save();
})
};
let hz_combo = {
let ctx = ctx.clone();
ComboBox::new(hz_names)
.header("Refresh rate")
.selected_index(hz_i)
.on_selection_changed(move |i: i32| {
let mut s = ctx.settings.lock().unwrap();
s.refresh_hz = REFRESH[(i.max(0) as usize).min(REFRESH.len() - 1)];
s.save();
})
};
let mic_toggle = {
let ctx = ctx.clone();
check_box(s.mic_enabled)
.label("Stream microphone to the host")
.on_changed(move |on: bool| {
let mut s = ctx.settings.lock().unwrap();
s.mic_enabled = on;
s.save();
})
};
let header = grid((
text_block("Settings")
.font_size(30.0)
.bold()
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
button("Back")
.accent()
.on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Hosts)
})
.grid_column(1)
.vertical_alignment(VerticalAlignment::Center),
))
.columns([GridLength::Star(1.0), GridLength::Auto])
.margin(edges(0.0, 0.0, 0.0, 6.0));
let stream_card = card(
vstack((
text_block("Stream").font_size(15.0).semibold(),
text_block("The host creates a virtual display at exactly this mode.")
.font_size(12.0)
.foreground(ThemeRef::SecondaryText),
res_combo,
hz_combo,
))
.spacing(10.0),
);
let audio_card =
card(vstack((text_block("Audio").font_size(15.0).semibold(), mic_toggle)).spacing(10.0));
page(vec![
header.into(),
section("STREAM"),
stream_card.into(),
section("AUDIO"),
audio_card.into(),
])
}
// --- stream page --------------------------------------------------------------------------
fn present_newest(ctx: &mut PresentCtx) {
let mut newest = None;
while let Ok(f) = ctx.frames.try_recv() {
newest = Some(f);
}
let cpu = newest.as_ref().map(|DecodedFrame::Cpu(c)| c);
ctx.presenter.present(cpu);
}
fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
let ctx = &props.svc.ctx;
// Take the connector + frames handoff once on mount; keep the connector alive (and for input)
// in a use_ref, stash frames for `on_ready`, install the input hooks (and remove on unmount).
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
cx.use_effect_with_cleanup((), {
let shared = ctx.shared.clone();
let connector_ref = connector_ref.clone();
move || {
if let Some((connector, frames)) = shared.handoff.lock().unwrap().take() {
let mode = connector.mode();
connector_ref.set(Some(connector.clone()));
PENDING_FRAMES.with(|c| *c.borrow_mut() = Some(frames));
crate::input::install(connector, mode);
}
Some(crate::input::uninstall)
}
});
let rendering = cx.use_ref::<Option<Rendering>>(None);
cx.use_effect((), {
let rendering = rendering.clone();
move || {
if let Ok(r) = on_rendering(move || {
PRESENT.with(|cell| {
if let Some(ctx) = cell.borrow_mut().as_mut() {
present_newest(ctx);
}
});
}) {
rendering.set(Some(r));
}
}
});
let mode = connector_ref.borrow().as_ref().map(|c| c.mode());
grid((
swap_chain_panel()
.on_ready(|panel| match Presenter::new(1280, 720) {
Ok(p) => {
if let Err(e) = panel.set_swap_chain(p.swap_chain()) {
tracing::error!(error = %e, "set_swap_chain");
}
if let Some(frames) = PENDING_FRAMES.with(|c| c.borrow_mut().take()) {
PRESENT.with(|cell| {
*cell.borrow_mut() = Some(PresentCtx {
presenter: p,
frames,
});
});
tracing::info!("stream presenter bound to SwapChainPanel");
}
}
Err(e) => tracing::error!(error = %e, "create presenter"),
})
.on_resize(|w, h| {
PRESENT.with(|cell| {
if let Some(ctx) = cell.borrow_mut().as_mut() {
ctx.presenter.resize(w as u32, h as u32);
}
});
}),
hud_overlay(&props.stats, mode),
))
.into()
}
/// The streaming HUD overlay (top-right), mirroring the Apple client: mode + fps/throughput, the
/// capture→client latency + decode time, and the release-cursor hint. Layered over the
/// `SwapChainPanel` in the same grid cell.
fn hud_overlay(stats: &Stats, mode: Option<Mode>) -> Element {
let res = mode
.map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz))
.unwrap_or_else(|| "\u{2014}".into());
let line1 = format!("{res} {:.0} fps {:.1} Mb/s", stats.fps, stats.mbps);
let line2 = format!(
"capture\u{2192}client {:.1} ms p50 \u{00B7} decode {:.1} ms",
stats.latency_ms, stats.decode_ms
);
border(
vstack((
text_block(line1)
.font_size(12.0)
.foreground(Color::rgb(255, 255, 255)),
text_block(line2)
.font_size(11.0)
.foreground(Color::rgb(200, 200, 200)),
text_block("Ctrl+Alt+Shift+Q releases the mouse")
.font_size(11.0)
.foreground(Color::rgb(160, 160, 160)),
))
.spacing(2.0),
)
.background(Color::rgb(0, 0, 0))
.corner_radius(8.0)
.padding(uniform(10.0))
.opacity(0.82)
.horizontal_alignment(HorizontalAlignment::Right)
.vertical_alignment(VerticalAlignment::Top)
.margin(uniform(12.0))
.into()
}
@@ -1,289 +0,0 @@
//! Audio: playback (decoded PCM → a WASAPI shared-mode render stream) and the microphone
//! uplink (WASAPI capture → Opus → 0xCB datagrams, the inverse of the host's virtual mic).
//!
//! The WASAPI analogue of the Linux client's PipeWire backend. Playback mirrors the host's
//! virtual-mic producer's adaptive jitter buffer: the session pump pushes 5 ms Opus-decoded
//! chunks on the network clock; the WASAPI render thread pulls whole event-driven quanta on
//! the device clock. Prime to ~3 quanta before producing, cap the ring so latency stays
//! bounded, re-prime after a real drain.
//!
//! WASAPI objects are COM-apartment-bound and not `Send`, so they live on a dedicated thread
//! (the same discipline as the host's `wasapi_cap`); only the channel + stop flag + join
//! handle cross the boundary.
use anyhow::{anyhow, Context, Result};
use punktfunk_core::client::NativeClient;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError};
use std::sync::Arc;
use std::time::Duration;
use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
const SAMPLE_RATE: usize = 48_000;
const CHANNELS: usize = 2;
/// 48 kHz stereo f32: 2 channels * 4 bytes = 8 bytes per frame.
const BLOCK_ALIGN: usize = CHANNELS * 4;
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
const MIC_FRAME: usize = 960;
pub struct AudioPlayer {
pcm_tx: SyncSender<Vec<f32>>,
stop: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl AudioPlayer {
/// Spawn the WASAPI render thread. Failure (no render endpoint on this box) is
/// survivable — the caller streams video-only.
pub fn spawn() -> Result<AudioPlayer> {
// 64 × 5 ms = 320 ms of slack between the pump and the WASAPI loop.
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
let stop = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<Result<()>>(1);
let stop_t = stop.clone();
let thread = std::thread::Builder::new()
.name("punktfunk-audio".into())
.spawn(move || {
if let Err(e) = render_thread(pcm_rx, stop_t, ready_tx) {
tracing::warn!(error = format!("{e:#}"), "audio playback thread ended");
}
})
.context("spawn audio thread")?;
match ready_rx.recv_timeout(Duration::from_secs(3)) {
Ok(Ok(())) => {
tracing::info!("WASAPI render: 48 kHz stereo f32 (default endpoint)");
Ok(AudioPlayer {
pcm_tx,
stop,
thread: Some(thread),
})
}
Ok(Err(e)) => Err(e),
Err(_) => Err(anyhow!(
"wasapi render init timed out (no render endpoint?)"
)),
}
}
/// Queue one interleaved-stereo f32 chunk. Drops the chunk if the WASAPI side is wedged
/// (the renderer conceals the gap; never block the session pump).
pub fn push(&self, pcm: Vec<f32>) {
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
// Thread already dead — Drop will reap it; nothing to do per-chunk.
}
}
}
impl Drop for AudioPlayer {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
fn render_thread(
pcm_rx: Receiver<Vec<f32>>,
stop: Arc<AtomicBool>,
ready: SyncSender<Result<()>>,
) -> Result<()> {
if let Err(e) = wasapi::initialize_mta()
.ok()
.context("CoInitializeEx (MTA)")
{
let _ = ready.send(Err(e));
return Ok(());
}
let res = (|| -> Result<()> {
let device = DeviceEnumerator::new()
.context("DeviceEnumerator")?
.get_default_device(&Direction::Render)
.context("default render endpoint")?;
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE, CHANNELS, None);
let (default_period, _min_period) =
audio_client.get_device_period().context("device period")?;
let mode = StreamMode::EventsShared {
autoconvert: true,
buffer_duration_hns: default_period,
};
audio_client
.initialize_client(&desired, &Direction::Render, &mode)
.context("initialize render client")?;
let h_event = audio_client.set_get_eventhandle().context("event handle")?;
let render_client = audio_client
.get_audiorenderclient()
.context("IAudioRenderClient")?;
audio_client.start_stream().context("start render stream")?;
let _ = ready.send(Ok(()));
// Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic).
let mut ring: VecDeque<u8> = VecDeque::new();
let mut primed = false;
while !stop.load(Ordering::Relaxed) {
if h_event.wait_for_event(100).is_err() {
continue;
}
// Drain everything the pump has queued into the ring.
while let Ok(chunk) = pcm_rx.try_recv() {
for s in chunk {
ring.extend(s.to_le_bytes());
}
}
let avail_frames = audio_client
.get_available_space_in_frames()
.context("available space")? as usize;
if avail_frames == 0 {
continue;
}
let want_bytes = avail_frames * BLOCK_ALIGN;
// Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain.
let target = (3 * want_bytes).clamp(720 * BLOCK_ALIGN, 9600 * BLOCK_ALIGN);
while ring.len() > target.max(want_bytes) + want_bytes {
ring.pop_front();
}
if !primed && ring.len() >= target {
primed = true;
}
let mut out = vec![0u8; want_bytes];
if primed {
let n = ring.len().min(want_bytes);
for (dst, b) in out.iter_mut().zip(ring.drain(..n)) {
*dst = b;
}
}
if ring.is_empty() {
primed = false;
}
render_client
.write_to_device(avail_frames, &out, None)
.context("write_to_device")?;
}
audio_client.stop_stream().ok();
Ok(())
})();
if let Err(ref e) = res {
let _ = ready.send(Err(anyhow!("{e:#}")));
}
res
}
/// The microphone uplink: capture the default input device, Opus-encode 20 ms chunks, ship
/// them as 0xCB datagrams into the host's virtual mic source.
pub struct MicStreamer {
stop: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl MicStreamer {
pub fn spawn(connector: Arc<NativeClient>) -> Result<MicStreamer> {
let stop = Arc::new(AtomicBool::new(false));
let stop_t = stop.clone();
let thread = std::thread::Builder::new()
.name("punktfunk-mic".into())
.spawn(move || {
if let Err(e) = mic_thread(&connector, stop_t) {
tracing::warn!(error = format!("{e:#}"), "mic uplink thread ended");
}
})
.context("spawn mic thread")?;
Ok(MicStreamer {
stop,
thread: Some(thread),
})
}
}
impl Drop for MicStreamer {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
fn mic_thread(connector: &Arc<NativeClient>, stop: Arc<AtomicBool>) -> Result<()> {
wasapi::initialize_mta()
.ok()
.context("CoInitializeEx (MTA)")?;
let mut encoder = opus::Encoder::new(
SAMPLE_RATE as u32,
opus::Channels::Stereo,
opus::Application::Voip,
)
.map_err(|e| anyhow!("opus encoder: {e}"))?;
let _ = encoder.set_bitrate(opus::Bitrate::Bits(64_000));
let device = DeviceEnumerator::new()
.context("DeviceEnumerator")?
.get_default_device(&Direction::Capture)
.context("default capture endpoint (no microphone?)")?;
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE, CHANNELS, None);
let (default_period, _min_period) =
audio_client.get_device_period().context("device period")?;
let mode = StreamMode::EventsShared {
autoconvert: true,
buffer_duration_hns: default_period,
};
audio_client
.initialize_client(&desired, &Direction::Capture, &mode)
.context("initialize capture client")?;
let h_event = audio_client.set_get_eventhandle().context("event handle")?;
let capture_client = audio_client
.get_audiocaptureclient()
.context("IAudioCaptureClient")?;
audio_client
.start_stream()
.context("start capture stream")?;
let mut bytes: VecDeque<u8> = VecDeque::new();
let mut ring: VecDeque<f32> = VecDeque::new();
let mut out = vec![0u8; 4000];
let mut seq = 0u32;
while !stop.load(Ordering::Relaxed) {
if h_event.wait_for_event(100).is_err() {
continue;
}
loop {
match capture_client.get_next_packet_size() {
Ok(Some(0)) | Ok(None) => break,
Ok(Some(_n)) => {
capture_client
.read_from_device_to_deque(&mut bytes)
.context("read capture")?;
}
Err(e) => return Err(anyhow!("get_next_packet_size: {e}")),
}
}
let whole = (bytes.len() / 4) * 4;
for c in bytes.drain(..whole).collect::<Vec<u8>>().chunks_exact(4) {
ring.push_back(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
}
// Ship every complete 20 ms stereo frame.
while ring.len() >= MIC_FRAME * CHANNELS {
let pcm: Vec<f32> = ring.drain(..MIC_FRAME * CHANNELS).collect();
match encoder.encode_float(&pcm, &mut out) {
Ok(len) => {
let pts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let _ = connector.send_mic(seq, pts, out[..len].to_vec());
seq = seq.wrapping_add(1);
}
Err(e) => tracing::debug!(error = %e, "opus mic encode"),
}
}
}
audio_client.stop_stream().ok();
Ok(())
}
@@ -1,76 +0,0 @@
//! LAN host discovery: browse the host's mDNS advert (`_punktfunk._udp`, TXT keys
//! `fp`/`pair`/`id` — see the host crate's `discovery.rs`) on a worker thread and stream
//! results to the UI. Ported verbatim from the GTK client (`mdns-sd` is cross-platform).
use mdns_sd::{ServiceDaemon, ServiceEvent};
#[derive(Clone, Debug, PartialEq)]
pub struct DiscoveredHost {
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
pub key: String,
pub name: String,
pub addr: String,
pub port: u16,
/// Host certificate fingerprint to pin (lowercase hex), empty if not advertised.
pub fp_hex: String,
/// Pairing requirement: `"required"` or `"optional"`.
pub pair: String,
}
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
/// dropped (the send fails) or the daemon dies.
pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
let (tx, rx) = async_channel::unbounded();
std::thread::Builder::new()
.name("punktfunk-mdns".into())
.spawn(move || {
let daemon = match ServiceDaemon::new() {
Ok(d) => d,
Err(e) => {
tracing::warn!(error = %e, "mDNS daemon failed — discovery disabled");
return;
}
};
let receiver = match daemon.browse("_punktfunk._udp.local.") {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
return;
}
};
while let Ok(event) = receiver.recv() {
if let ServiceEvent::ServiceResolved(info) = event {
let props = info.get_properties();
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
else {
continue;
};
let id = val("id");
let host = DiscoveredHost {
key: if id.is_empty() {
info.get_fullname().to_string()
} else {
id
},
name: info
.get_fullname()
.split('.')
.next()
.unwrap_or("?")
.to_string(),
addr,
port: info.get_port(),
fp_hex: val("fp"),
pair: val("pair"),
};
if tx.send_blocking(host).is_err() {
break; // UI gone — stop browsing
}
}
}
let _ = daemon.shutdown();
})
.expect("spawn mdns thread");
rx
}
@@ -1,540 +0,0 @@
//! App-lifetime gamepad service over SDL3 (mirrors the Swift/GTK clients' `GamepadManager` +
//! capture/feedback). Ported near-verbatim from the GTK Linux client — SDL3 is cross-platform,
//! so the only Windows change is the build (`sdl3` is compiled from source via the bundled
//! CMake, since there is no system SDL3).
//!
//! One worker thread owns SDL for the process lifetime: it tracks connected pads, selects the
//! ONE controller forwarded as pad 0 (user pin, else the most recently connected), and — while
//! a session is attached — forwards buttons/axes, DualSense touchpad contacts and motion
//! samples (0xCC), and renders feedback: rumble on every pad, lightbar via SDL, and on a real
//! DualSense the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs).
//! Held state is zeroed on the wire when the active pad switches or the session detaches, so
//! nothing sticks down.
//!
//! This thread is also the single consumer of the rumble and HID-output pull planes.
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::GamepadPref;
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Motion scale constants, shared convention with the other clients (`GamepadWire`): derived
/// from hid-playstation's math over the host's fixed calibration blob. SDL hands us gyro in
/// rad/s and accel in m/s²; the DualSense report wants raw LSBs.
const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
const ACCEL_LSB_PER_G: f32 = 10_000.0;
const G: f32 = 9.80665;
#[derive(Clone, Debug)]
pub struct PadInfo {
// `id`/`name` feed the settings GUI's pad list (a follow-up); the windowed client only
// reads `is_dualsense` (via `auto_pref`), so they're unused in reachable code for now.
#[allow(dead_code)]
pub id: u32,
#[allow(dead_code)]
pub name: String,
pub is_dualsense: bool,
}
enum Ctl {
Attach(Arc<NativeClient>),
Detach,
Pin(Option<u32>),
}
#[derive(Clone)]
pub struct GamepadService {
pads: Arc<Mutex<Vec<PadInfo>>>,
active: Arc<Mutex<Option<PadInfo>>>,
pinned: Arc<Mutex<Option<u32>>>,
// `Arc<Mutex<…>>` (not a bare `Sender`, which is `!Sync`) so the service is `Sync` — the
// WinUI app shares it across the UI thread and the session-pump thread (attach/detach).
ctl: Arc<Mutex<Sender<Ctl>>>,
}
impl GamepadService {
pub fn start() -> GamepadService {
let pads = Arc::new(Mutex::new(Vec::new()));
let active = Arc::new(Mutex::new(None));
let pinned = Arc::new(Mutex::new(None));
let (ctl, ctl_rx) = std::sync::mpsc::channel();
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-gamepad".into())
.spawn(move || {
if let Err(e) = run(&p, &a, &pin, &ctl_rx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
}
})
{
tracing::warn!(error = %e, "gamepad service failed to start");
}
GamepadService {
pads,
active,
pinned,
ctl: Arc::new(Mutex::new(ctl)),
}
}
#[allow(dead_code)] // consumed by the settings GUI (follow-up)
pub fn pads(&self) -> Vec<PadInfo> {
self.pads.lock().unwrap().clone()
}
pub fn active(&self) -> Option<PadInfo> {
self.active.lock().unwrap().clone()
}
#[allow(dead_code)] // consumed by the settings GUI (follow-up)
pub fn pinned(&self) -> Option<u32> {
*self.pinned.lock().unwrap()
}
#[allow(dead_code)] // consumed by the settings GUI (follow-up)
pub fn set_pinned(&self, id: Option<u32>) {
let _ = self.ctl.lock().unwrap().send(Ctl::Pin(id));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.lock().unwrap().send(Ctl::Attach(connector));
}
pub fn detach(&self) {
let _ = self.ctl.lock().unwrap().send(Ctl::Detach);
}
/// What "Automatic" resolves to right now — the virtual pad matching the physical one
/// (Swift parity); no pad connected leaves the host's own default.
pub fn auto_pref(&self) -> GamepadPref {
match self.active() {
Some(p) if p.is_dualsense => GamepadPref::DualSense,
Some(_) => GamepadPref::Xbox360,
None => GamepadPref::Auto,
}
}
}
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
let _ = connector.send_input(&InputEvent {
kind,
_pad: [0; 3],
code,
x,
y: 0,
flags: 0, // pad index 0 — single-pad model
});
}
fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
use sdl3::gamepad::Button;
Some(match b {
Button::South => wire::BTN_A,
Button::East => wire::BTN_B,
Button::West => wire::BTN_X,
Button::North => wire::BTN_Y,
Button::Back => wire::BTN_BACK,
Button::Start => wire::BTN_START,
Button::Guide => wire::BTN_GUIDE,
Button::LeftStick => wire::BTN_LS_CLICK,
Button::RightStick => wire::BTN_RS_CLICK,
Button::LeftShoulder => wire::BTN_LB,
Button::RightShoulder => wire::BTN_RB,
Button::DPadUp => wire::BTN_DPAD_UP,
Button::DPadDown => wire::BTN_DPAD_DOWN,
Button::DPadLeft => wire::BTN_DPAD_LEFT,
Button::DPadRight => wire::BTN_DPAD_RIGHT,
Button::Touchpad => wire::BTN_TOUCHPAD,
_ => return None,
})
}
/// SDL axis → (wire axis id, wire value). SDL sticks are +y = down; the wire (XInput
/// convention) is +y = up. SDL triggers span 0..32767; the wire wants 0..255.
fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
use sdl3::gamepad::Axis;
match axis {
Axis::LeftX => (wire::AXIS_LS_X, v as i32),
Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)),
Axis::RightX => (wire::AXIS_RS_X, v as i32),
Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)),
Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7),
Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7),
}
}
/// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the host
/// parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim. Enable bits
/// select only the fields each update touches, so rumble (driven separately through SDL) and
/// untouched fields keep their state.
#[derive(Default)]
struct Ds5Feedback;
impl Ds5Feedback {
const RIGHT_TRIGGER: usize = 10;
const LEFT_TRIGGER: usize = 21;
const PAD_LIGHTS: usize = 43;
const LED_RGB: usize = 44;
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
let mut p = [0u8; 47];
let (flag, off) = if which == 1 {
(0x04, Self::RIGHT_TRIGGER)
} else {
(0x08, Self::LEFT_TRIGGER)
};
p[0] = flag;
let n = effect.len().min(11);
p[off..off + n].copy_from_slice(&effect[..n]);
p
}
fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x04; // lightbar enable
p[Self::LED_RGB] = r;
p[Self::LED_RGB + 1] = g;
p[Self::LED_RGB + 2] = b;
p
}
fn player_packet(bits: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x10; // player-LED enable
p[Self::PAD_LIGHTS] = bits & 0x1F;
p
}
}
struct Worker {
subsystem: sdl3::GamepadSubsystem,
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
/// Connection order; the most recently connected is the auto selection.
order: Vec<u32>,
pinned: Option<u32>,
attached: Option<Arc<NativeClient>>,
/// Wire state of the active pad — zeroed on the wire at switch/detach.
last_axis: [i32; 6],
held_buttons: Vec<u32>,
last_accel: [i16; 3],
}
impl Worker {
fn active_id(&self) -> Option<u32> {
self.pinned
.filter(|id| self.opened.contains_key(id))
.or_else(|| self.order.last().copied())
}
fn pad_info(&self, id: u32) -> Option<PadInfo> {
let pad = self.opened.get(&id)?;
Some(PadInfo {
id,
name: pad.name().unwrap_or_else(|| "Controller".into()),
is_dualsense: matches!(
self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
sdl3::gamepad::GamepadType::PS5
),
})
}
/// Zero everything the host believes is held — on pad switch and detach.
fn flush_held(&mut self) {
if let Some(c) = &self.attached {
for b in self.held_buttons.drain(..) {
send(c, InputKind::GamepadButton, b, 0);
}
for (id, v) in self.last_axis.iter_mut().enumerate() {
if *v != 0 && *v != i32::MIN {
send(c, InputKind::GamepadAxis, id as u32, 0);
}
*v = i32::MIN;
}
} else {
self.held_buttons.clear();
self.last_axis = [i32::MIN; 6];
}
}
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
fn set_sensors(&mut self, enabled: bool) {
let Some(id) = self.active_id() else { return };
if let Some(pad) = self.opened.get_mut(&id) {
use sdl3::sensor::SensorType;
for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
if unsafe { pad.has_sensor(s) } {
let _ = pad.sensor_set_enabled(s, enabled);
}
}
}
}
}
#[allow(clippy::too_many_lines)]
fn run(
pads_out: &Mutex<Vec<PadInfo>>,
active_out: &Mutex<Option<PadInfo>>,
pinned_out: &Mutex<Option<u32>>,
ctl: &Receiver<Ctl>,
) -> Result<(), String> {
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its own
// thread.
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
let sdl = sdl3::init().map_err(|e| e.to_string())?;
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
let mut w = Worker {
subsystem,
opened: HashMap::new(),
order: Vec::new(),
pinned: None,
attached: None,
last_axis: [i32::MIN; 6],
held_buttons: Vec::new(),
last_accel: [0; 3],
};
let publish = |w: &Worker| {
let mut list: Vec<PadInfo> = w.order.iter().filter_map(|&id| w.pad_info(id)).collect();
list.reverse(); // most recent first — the Settings list order
*pads_out.lock().unwrap() = list;
*active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id));
*pinned_out.lock().unwrap() = w.pinned;
};
loop {
// Control plane from the UI thread.
loop {
match ctl.try_recv() {
Ok(Ctl::Attach(c)) => {
w.attached = Some(c);
w.last_axis = [i32::MIN; 6];
w.set_sensors(true);
}
Ok(Ctl::Detach) => {
w.flush_held();
w.set_sensors(false);
w.attached = None;
}
Ok(Ctl::Pin(id)) => {
let before = w.active_id();
w.pinned = id;
if w.active_id() != before {
w.flush_held();
if w.attached.is_some() {
w.set_sensors(true);
}
}
publish(&w);
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone
}
}
while let Some(event) = pump.poll_event() {
use sdl3::event::Event;
let active = w.active_id();
match event {
Event::ControllerDeviceAdded { which, .. } => {
if !w.opened.contains_key(&which) {
match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) {
Ok(pad) => {
tracing::info!(
name = pad.name().unwrap_or_default(),
"gamepad attached"
);
w.opened.insert(which, pad);
w.order.push(which);
if w.attached.is_some() && w.active_id() == Some(which) {
w.set_sensors(true);
}
publish(&w);
}
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
}
}
}
Event::ControllerDeviceRemoved { which, .. } => {
if w.opened.remove(&which).is_some() {
w.order.retain(|&id| id != which);
if active == Some(which) {
w.flush_held();
}
tracing::info!("gamepad detached");
publish(&w);
}
}
Event::ControllerButtonDown { which, button, .. }
if active == Some(which) && w.attached.is_some() =>
{
if let Some(bit) = button_bit(button) {
w.held_buttons.push(bit);
send(
w.attached.as_ref().unwrap(),
InputKind::GamepadButton,
bit,
1,
);
}
}
Event::ControllerButtonUp { which, button, .. }
if active == Some(which) && w.attached.is_some() =>
{
if let Some(bit) = button_bit(button) {
w.held_buttons.retain(|&b| b != bit);
send(
w.attached.as_ref().unwrap(),
InputKind::GamepadButton,
bit,
0,
);
}
}
Event::ControllerAxisMotion {
which, axis, value, ..
} if active == Some(which) && w.attached.is_some() => {
let (id, v) = axis_value(axis, value);
if w.last_axis[id as usize] != v {
w.last_axis[id as usize] = v;
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
}
}
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
Event::ControllerTouchpadDown {
which,
finger,
x,
y,
..
}
| Event::ControllerTouchpadMotion {
which,
finger,
x,
y,
..
} if active == Some(which) && w.attached.is_some() => {
let _ = w
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: true,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
}
Event::ControllerTouchpadUp {
which,
finger,
x,
y,
..
} if active == Some(which) && w.attached.is_some() => {
let _ = w
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: false,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
}
// Motion: accel events update the cache; each gyro event ships a sample (the
// DualSense reports both at ~250 Hz). Scale convention shared with the other
// clients — sign/scale derived, not yet live-verified.
Event::ControllerSensorUpdated {
which,
sensor,
data,
..
} if active == Some(which) && w.attached.is_some() => {
use sdl3::sensor::SensorType;
match sensor {
SensorType::Accelerometer => {
for (i, v) in data.iter().enumerate() {
w.last_accel[i] =
(v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16;
}
}
SensorType::Gyroscope => {
let mut gyro = [0i16; 3];
for (i, v) in data.iter().enumerate() {
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
}
let _ =
w.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Motion {
pad: 0,
gyro,
accel: w.last_accel,
});
}
_ => {}
}
}
_ => {}
}
}
// Feedback planes (this thread is their single consumer). The host re-sends rumble state
// periodically, so a generous duration with refresh-on-update is safe — a dropped stop
// heals within ~500 ms.
if let Some(connector) = w.attached.clone() {
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
if pad == 0 {
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
let _ = p.set_rumble(low, high, 5_000);
}
}
}
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let Some(id) = w.active_id() else { continue };
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense);
let Some(pad) = w.opened.get_mut(&id) else {
continue;
};
match hid {
HidOutput::Led { pad: 0, r, g, b } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
}
HidOutput::Led { pad: 0, r, g, b } => {
let _ = pad.set_led(r, g, b);
}
HidOutput::PlayerLeds { pad: 0, bits } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::player_packet(bits));
}
HidOutput::Trigger {
pad: 0,
which,
ref effect,
} if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect));
}
_ => {}
}
}
}
std::thread::sleep(Duration::from_millis(if w.attached.is_some() {
2
} else {
30
}));
}
}
@@ -1,333 +0,0 @@
//! Stream input: Win32 low-level keyboard + mouse hooks forwarding to the host while the WinUI
//! window is focused and the pointer is captured.
//!
//! windows-reactor exposes no raw key-down/up or pointer-position/wheel events (only keyboard
//! *accelerators* and pointer button-state), which is insufficient for a game stream. So this
//! drops below XAML to `WH_KEYBOARD_LL` / `WH_MOUSE_LL`, installed on the UI thread when the
//! stream page mounts and removed when it unmounts.
//!
//! **Pointer lock.** While captured the cursor is *locked* the way a game-streaming client locks
//! it (Moonlight/Parsec): the OS cursor is hidden + confined to the window (`ClipCursor`), and
//! every physical move is turned into a **relative** delta (`InputKind::MouseMove`) — we read the
//! offset from the window centre, ship it (scaled screen→host through the Contain-fit factor, with
//! sub-pixel remainder carried so slow drags aren't lost), then warp the cursor back to centre so
//! it never reaches a screen edge. This is why the old absolute path froze: swallowing
//! `WM_MOUSEMOVE` pinned the OS cursor, so `pt` never travelled and the absolute coordinate
//! snapped to one point. Keys carry the native Windows VK directly (the wire contract).
//!
//! **Ctrl+Alt+Shift+Q** toggles capture — releasing the lock hands the cursor back to the local
//! desktop (and re-grabs on the next toggle). Losing foreground also releases the lock so the
//! cursor is never stranded.
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::Mode;
use punktfunk_core::input::{InputEvent, InputKind};
use std::collections::HashSet;
use std::sync::atomic::{AtomicIsize, Ordering};
use std::sync::{Arc, Mutex};
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
use windows::Win32::Graphics::Gdi::ClientToScreen;
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::Input::KeyboardAndMouse::VK_Q;
use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos,
SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT,
LLMHF_INJECTED, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_KEYUP, WM_LBUTTONDOWN,
WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL,
WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP,
};
struct State {
connector: Arc<NativeClient>,
mode: Mode,
/// Our window handle, stored as the raw `isize` so `State` is `Send` (`HWND` is not).
hwnd: isize,
/// User intent: forward input to the host (toggled by Ctrl+Alt+Shift+Q).
captured: bool,
/// The OS pointer is currently locked (hidden + confined + recentering). Tracks the real
/// `ClipCursor`/`ShowCursor` state so we engage/disengage exactly once per transition.
locked: bool,
/// Lock centre in screen coordinates (the cursor is warped here after every move).
center_x: i32,
center_y: i32,
/// Sub-pixel remainder of the screen→host scale, carried so slow drags aren't truncated away.
acc_x: f32,
acc_y: f32,
/// Modifier state, tracked from the hook's own event stream (see `kbd_proc`).
ctrl: bool,
alt: bool,
shift: bool,
held_keys: HashSet<u8>,
held_buttons: HashSet<u32>,
}
// `State` carries no `!Send` handle (hwnd is an `isize`), so the static is sound. The hook procs
// run on the same UI thread that installs/removes the hooks, so the lock is uncontended.
static STATE: Mutex<Option<State>> = Mutex::new(None);
static KBD_HOOK: AtomicIsize = AtomicIsize::new(0);
static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0);
/// Install the hooks for a streaming session. Call from the UI thread once the window is shown.
pub fn install(connector: Arc<NativeClient>, mode: Mode) {
let hwnd = unsafe { GetForegroundWindow() };
let mut st = State {
connector,
mode,
hwnd: hwnd.0 as isize,
captured: true,
locked: false,
center_x: 0,
center_y: 0,
acc_x: 0.0,
acc_y: 0.0,
ctrl: false,
alt: false,
shift: false,
held_keys: HashSet::new(),
held_buttons: HashSet::new(),
};
// Lock immediately (the window is foreground at mount, like Moonlight grabbing on stream start).
set_locked(&mut st, true);
*STATE.lock().unwrap() = Some(st);
unsafe {
let hinst = GetModuleHandleW(None).ok();
if let Ok(h) = SetWindowsHookExW(WH_KEYBOARD_LL, Some(kbd_proc), hinst.map(Into::into), 0) {
KBD_HOOK.store(h.0 as isize, Ordering::SeqCst);
}
if let Ok(h) = SetWindowsHookExW(WH_MOUSE_LL, Some(mouse_proc), hinst.map(Into::into), 0) {
MOUSE_HOOK.store(h.0 as isize, Ordering::SeqCst);
}
}
tracing::info!(
"stream input hooks installed — pointer locked (Ctrl+Alt+Shift+Q toggles capture)"
);
}
/// Remove the hooks, release the pointer lock, and flush any held keys/buttons (so nothing
/// sticks down on the host).
pub fn uninstall() {
unsafe {
let k = KBD_HOOK.swap(0, Ordering::SeqCst);
if k != 0 {
let _ = UnhookWindowsHookEx(HHOOK(k as *mut _));
}
let m = MOUSE_HOOK.swap(0, Ordering::SeqCst);
if m != 0 {
let _ = UnhookWindowsHookEx(HHOOK(m as *mut _));
}
}
if let Some(mut st) = STATE.lock().unwrap().take() {
set_locked(&mut st, false); // hand the cursor back to the desktop
flush_held(&mut st);
}
}
/// Release every held key/button on the host, so nothing sticks down when capture is dropped
/// (toggled off) or the session ends.
fn flush_held(st: &mut State) {
let c = st.connector.clone();
for vk in st.held_keys.drain() {
send(&c, InputKind::KeyUp, vk as u32, 0, 0, 0);
}
for b in st.held_buttons.drain() {
send(&c, InputKind::MouseButtonUp, b, 0, 0, 0);
}
}
/// Engage or release the pointer lock: confine + hide + recentre on, free + show on off.
/// Guarded so the `ClipCursor`/`ShowCursor` calls stay balanced (one each per transition).
fn set_locked(st: &mut State, on: bool) {
if on == st.locked {
return;
}
let hwnd = HWND(st.hwnd as *mut _);
unsafe {
if on {
let mut rc = RECT::default();
if GetClientRect(hwnd, &mut rc).is_ok() {
let mut tl = POINT {
x: rc.left,
y: rc.top,
};
let mut br = POINT {
x: rc.right,
y: rc.bottom,
};
let _ = ClientToScreen(hwnd, &mut tl);
let _ = ClientToScreen(hwnd, &mut br);
let clip = RECT {
left: tl.x,
top: tl.y,
right: br.x,
bottom: br.y,
};
let _ = ClipCursor(Some(&clip as *const RECT));
st.center_x = (tl.x + br.x) / 2;
st.center_y = (tl.y + br.y) / 2;
let _ = SetCursorPos(st.center_x, st.center_y);
}
let _ = ShowCursor(false);
st.acc_x = 0.0;
st.acc_y = 0.0;
} else {
let _ = ClipCursor(None);
let _ = ShowCursor(true);
}
}
st.locked = on;
}
fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
let _ = c.send_input(&InputEvent {
kind,
_pad: [0; 3],
code,
x,
y,
flags,
});
}
unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if code == HC_ACTION as i32 {
let kb = unsafe { &*(lparam.0 as *const KBDLLHOOKSTRUCT) };
let msg = wparam.0 as u32;
let up = msg == WM_KEYUP || msg == WM_SYSKEYUP;
let vk = kb.vkCode as u16;
let mut guard = STATE.lock().unwrap();
if let Some(st) = guard.as_mut() {
// Track modifier state from the hook's own event stream — reliable even while we
// swallow these keys (GetAsyncKeyState doesn't reflect keys suppressed by our own LL
// hook, which is why the shortcut never fired). Handles the generic + L/R vk codes.
match kb.vkCode {
0x11 | 0xA2 | 0xA3 => st.ctrl = !up, // (L/R)CONTROL
0x12 | 0xA4 | 0xA5 => st.alt = !up, // (L/R)MENU (Alt)
0x10 | 0xA0 | 0xA1 => st.shift = !up, // (L/R)SHIFT
_ => {}
}
let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd;
if foreground {
// Capture toggle: Ctrl+Alt+Shift+Q (consumed locally, never forwarded).
if !up && vk == VK_Q.0 && st.ctrl && st.alt && st.shift {
let on = !st.captured;
st.captured = on;
set_locked(st, on); // grab/release the cursor immediately
if !on {
flush_held(st); // release held keys/buttons so nothing sticks on the host
}
tracing::info!(captured = on, "capture toggled (Ctrl+Alt+Shift+Q)");
return LRESULT(1);
}
if st.captured {
let v = vk as u8;
if up {
if st.held_keys.remove(&v) {
send(&st.connector, InputKind::KeyUp, v as u32, 0, 0, 0);
}
} else {
st.held_keys.insert(v);
send(&st.connector, InputKind::KeyDown, v as u32, 0, 0, 0);
}
return LRESULT(1); // swallow so it reaches the host, not the local OS
}
}
}
}
unsafe { CallNextHookEx(None, code, wparam, lparam) }
}
/// Client-area size in pixels (for the screen→host relative-motion scale).
fn client_size(hwnd: isize) -> (f32, f32) {
let mut rc = RECT::default();
if unsafe { GetClientRect(HWND(hwnd as *mut _), &mut rc) }.is_ok() {
(
(rc.right - rc.left).max(1) as f32,
(rc.bottom - rc.top).max(1) as f32,
)
} else {
(1.0, 1.0)
}
}
unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if code == HC_ACTION as i32 {
let ms = unsafe { &*(lparam.0 as *const MSLLHOOKSTRUCT) };
let msg = wparam.0 as u32;
let injected = (ms.flags & LLMHF_INJECTED) != 0;
let mut guard = STATE.lock().unwrap();
if let Some(st) = guard.as_mut() {
let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd;
let want_lock = st.captured && foreground;
if want_lock != st.locked {
set_locked(st, want_lock); // sync to focus changes (e.g. lost foreground)
}
if st.locked {
// Skip the synthetic move our own SetCursorPos recentre generates.
if injected {
return unsafe { CallNextHookEx(None, code, wparam, lparam) };
}
let c = st.connector.clone();
match msg {
WM_MOUSEMOVE => {
let dx = (ms.pt.x - st.center_x) as f32;
let dy = (ms.pt.y - st.center_y) as f32;
if dx != 0.0 || dy != 0.0 {
// screen px → host px: the Contain-fit display scale's inverse, so the
// host cursor tracks the physical mouse 1:1 on screen at any window size.
let (ww, wh) = client_size(st.hwnd);
let (vw, vh) =
(st.mode.width.max(1) as f32, st.mode.height.max(1) as f32);
let s = (ww / vw).min(wh / vh).max(0.01);
st.acc_x += dx / s;
st.acc_y += dy / s;
let (hx, hy) = (st.acc_x.trunc() as i32, st.acc_y.trunc() as i32);
st.acc_x -= hx as f32;
st.acc_y -= hy as f32;
if hx != 0 || hy != 0 {
send(&c, InputKind::MouseMove, 0, hx, hy, 0);
}
}
let _ = unsafe { SetCursorPos(st.center_x, st.center_y) };
}
WM_LBUTTONDOWN => button(st, 1, true),
WM_LBUTTONUP => button(st, 1, false),
WM_RBUTTONDOWN => button(st, 3, true),
WM_RBUTTONUP => button(st, 3, false),
WM_MBUTTONDOWN => button(st, 2, true),
WM_MBUTTONUP => button(st, 2, false),
WM_XBUTTONDOWN => button(st, 3 + ((ms.mouseData >> 16) as u16 as u32), true),
WM_XBUTTONUP => button(st, 3 + ((ms.mouseData >> 16) as u16 as u32), false),
WM_MOUSEWHEEL => send(
&c,
InputKind::MouseScroll,
0,
(ms.mouseData >> 16) as i16 as i32,
0,
0,
),
WM_MOUSEHWHEEL => send(
&c,
InputKind::MouseScroll,
1,
(ms.mouseData >> 16) as i16 as i32,
0,
0,
),
_ => {}
}
return LRESULT(1); // swallow inside the locked window
}
}
}
unsafe { CallNextHookEx(None, code, wparam, lparam) }
}
fn button(st: &mut State, id: u32, down: bool) {
let c = st.connector.clone();
if down {
st.held_buttons.insert(id);
send(&c, InputKind::MouseButtonDown, id, 0, 0, 0);
} else if st.held_buttons.remove(&id) {
send(&c, InputKind::MouseButtonUp, id, 0, 0, 0);
}
}
-258
View File
@@ -1,258 +0,0 @@
//! `punktfunk-client` — the native Windows punktfunk/1 client.
//!
//! Pure Rust: `NativeClient` linked as a crate (no C ABI, like the GTK Linux client) · FFmpeg
//! decode · WASAPI audio · SDL3 gamepads · a **WinUI 3** shell (windows-reactor) with the video
//! on a `SwapChainPanel` bound to a D3D11 composition swapchain. The trust surface mirrors the
//! other native clients: persistent identity, trust-on-first-use, SPAKE2 PIN pairing — all in-app
//! (host list, settings, pairing). `--headless` keeps a CLI connect path for tests/measurement.
//!
//! Usage:
//! punktfunk-client (open the WinUI 3 window: host list, settings, pairing)
//! punktfunk-client --discover (list punktfunk hosts on the LAN)
//! punktfunk-client --headless --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz]
//! [--bitrate MBPS] [--mic] (no window; count frames + print stats)
// Link as a GUI (windows) subsystem binary so the default windowed launch (MSIX / double-click)
// does NOT pop a console window. The CLI paths (--headless/--discover) reattach to the launching
// terminal's console at startup (see main), so their output is still visible when run from a shell.
#![cfg_attr(windows, windows_subsystem = "windows")]
#[cfg(windows)]
mod app;
#[cfg(windows)]
mod audio;
#[cfg(windows)]
mod discovery;
#[cfg(windows)]
mod gamepad;
#[cfg(windows)]
mod input;
#[cfg(windows)]
mod present;
#[cfg(windows)]
mod session;
#[cfg(windows)]
mod trust;
#[cfg(windows)]
mod video;
#[cfg(windows)]
fn main() {
// With #![windows_subsystem = "windows"] the process starts with no console, so the GUI/MSIX
// launch is window-free. AttachConsole only binds to an ALREADY-EXISTING parent console (it
// never creates one), so when launched from a terminal — `--headless`/`--discover` — stdout and
// the tracing writer below land in that terminal; from Explorer/MSIX it's a harmless no-op.
unsafe {
use windows::Win32::System::Console::{AttachConsole, ATTACH_PARENT_PROCESS};
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
let args: Vec<String> = std::env::args().collect();
let flag = |name: &str| args.iter().any(|a| a == name);
if flag("--discover") {
discover_and_print();
return;
}
let identity = match trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
eprintln!("client identity: {e:#}");
std::process::exit(1);
}
};
if flag("--headless") {
run_headless_cli(&args, identity);
return;
}
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
let gamepad = gamepad::GamepadService::start();
if let Err(e) = app::run(identity, gamepad) {
tracing::error!(error = %e, "WinUI app failed");
std::process::exit(1);
}
}
/// `--headless --connect host[:port] …`: connect from the CLI, count frames, print stats — the
/// Windows analogue of `punktfunk-client-rs`.
#[cfg(windows)]
fn run_headless_cli(args: &[String], identity: (String, String)) {
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::time::{Duration, Instant};
let arg = |name: &str| -> Option<String> {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1))
.cloned()
};
let flag = |name: &str| args.iter().any(|a| a == name);
let Some(target) = arg("--connect") else {
eprintln!("--headless requires --connect host[:port]");
std::process::exit(2);
};
let (host, port) = match target.rsplit_once(':') {
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
None => (target.clone(), 9777u16),
};
let mode = arg("--mode")
.and_then(|m| {
let mut it = m.split(['x', 'X']);
Some(Mode {
width: it.next()?.parse().ok()?,
height: it.next()?.parse().ok()?,
refresh_hz: it.next()?.parse().ok()?,
})
})
.unwrap_or(Mode {
width: 1280,
height: 720,
refresh_hz: 60,
});
let bitrate_kbps = arg("--bitrate")
.and_then(|b| b.parse::<u32>().ok())
.map(|m| m * 1000)
.unwrap_or(0);
let known = trust::KnownHosts::load();
let mut pin = arg("--pin")
.and_then(|h| trust::parse_hex32(&h))
.or_else(|| {
known
.find_by_addr(&host, port)
.and_then(|k| trust::parse_hex32(&k.fp_hex))
});
if let Some(code) = arg("--pair") {
let name = std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into());
match punktfunk_core::client::NativeClient::pair(
&host,
port,
(&identity.0, &identity.1),
code.trim(),
&name,
Duration::from_secs(90),
) {
Ok(fp) => {
let mut k = trust::KnownHosts::load();
k.upsert(trust::KnownHost {
name: host.clone(),
addr: host.clone(),
port,
fp_hex: trust::hex(&fp),
paired: true,
});
let _ = k.save();
tracing::info!(fp = %trust::hex(&fp), "paired");
pin = Some(fp);
}
Err(e) => {
eprintln!("Pairing failed: {e:?}");
std::process::exit(1);
}
}
}
tracing::info!(%host, port, ?mode, tofu = pin.is_none(), "connecting (headless)");
let handle = session::start(session::SessionParams {
host,
port,
mode,
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps,
mic_enabled: flag("--mic"),
pin,
identity,
});
let deadline = Instant::now() + Duration::from_secs(60);
let mut frames_seen = 0u64;
loop {
while let Ok(ev) = handle.events.try_recv() {
match ev {
session::SessionEvent::Connected {
mode, fingerprint, ..
} => tracing::info!(?mode, fp = %trust::hex(&fingerprint), "connected"),
session::SessionEvent::Stats(s) => tracing::info!(
fps = format!("{:.0}", s.fps),
mbps = format!("{:.1}", s.mbps),
decode_ms = format!("{:.2}", s.decode_ms),
lat_ms = format!("{:.2}", s.latency_ms),
frames_seen,
"stats"
),
session::SessionEvent::Failed { msg, .. } => {
tracing::error!(%msg, "connect failed");
return;
}
session::SessionEvent::Ended(err) => {
tracing::info!(reason = err.as_deref().unwrap_or("done"), "session ended");
return;
}
}
}
while handle.frames.try_recv().is_ok() {
frames_seen += 1;
}
if Instant::now() > deadline {
tracing::info!(frames_seen, "harness deadline — stopping");
handle.stop.store(true, std::sync::atomic::Ordering::SeqCst);
return;
}
std::thread::sleep(Duration::from_millis(2));
}
}
/// `--discover`: browse the LAN for punktfunk hosts (mDNS) and print them, then exit.
#[cfg(windows)]
fn discover_and_print() {
use std::time::{Duration, Instant};
println!("Browsing the LAN for punktfunk hosts (~5 s)…");
let rx = discovery::browse();
let deadline = Instant::now() + Duration::from_secs(5);
let mut seen = std::collections::HashSet::new();
while Instant::now() < deadline {
while let Ok(h) = rx.try_recv() {
if seen.insert(h.key.clone()) {
println!(
" {} {}:{} pair={} fp={}",
h.name,
h.addr,
h.port,
if h.pair.is_empty() {
"optional"
} else {
&h.pair
},
if h.fp_hex.is_empty() { "-" } else { &h.fp_hex },
);
}
}
std::thread::sleep(Duration::from_millis(100));
}
if seen.is_empty() {
println!(" (none found — is a host running with --native / m3-host?)");
}
}
/// WinUI 3 / Direct3D11 / WASAPI / SDL3 are Windows turf; this stub keeps `cargo build
/// --workspace` green on Linux/macOS (the other native clients live in
/// crates/punktfunk-client-linux and clients/apple).
#[cfg(not(windows))]
fn main() {
eprintln!(
"punktfunk-client-windows is Windows-only — the Linux client lives in \
crates/punktfunk-client-linux, the macOS client in clients/apple"
);
std::process::exit(2);
}
@@ -1,444 +0,0 @@
//! Direct3D11 presenter for a WinUI 3 `SwapChainPanel`: upload a decoded `CpuFrame` (RGBA)
//! into a dynamic texture and draw it Contain-fit into a **composition** flip-model swapchain,
//! which the reactor stream page binds to the panel via `SwapChainPanelHandle::set_swap_chain`.
//!
//! The device prefers a hardware adapter and falls back to **WARP** (the GPU-less dev box runs
//! the whole present path in software). The draw is a single full-screen triangle sampling the
//! video texture; a letterbox is produced by clearing the back buffer black and setting the
//! viewport to the Contain-fit rect (no per-frame vertex buffer).
//!
//! **HDR10**: when a frame is BT.2020 PQ (`CpuFrame::hdr`), the swapchain flips to
//! `R10G10B10A2` + `DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020` (+ HDR10 metadata) via
//! `ResizeBuffers`/`SetColorSpace1`; the decoded samples are already PQ-encoded so the shader is a
//! plain passthrough and the compositor maps PQ→display. SDR stays 8-bit B8G8R8A8.
//!
//! All `windows` types here come from the same windows-rs commit as `windows-reactor`, so the
//! `IDXGISwapChain1` handed to `set_swap_chain` satisfies reactor's `windows_core::Interface`.
use crate::video::CpuFrame;
use anyhow::{anyhow, Context, Result};
use windows::core::{Interface, PCSTR};
use windows::Win32::Graphics::Direct3D::Fxc::{D3DCompile, D3DCOMPILE_OPTIMIZATION_LEVEL3};
use windows::Win32::Graphics::Direct3D::{
ID3DBlob, D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP, D3D_FEATURE_LEVEL_11_0,
D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST,
};
use windows::Win32::Graphics::Direct3D11::*;
use windows::Win32::Graphics::Dxgi::Common::*;
use windows::Win32::Graphics::Dxgi::*;
const SHADER_HLSL: &str = r#"
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
VSOut vs_main(uint vid : SV_VertexID) {
float2 uv = float2((vid << 1) & 2, vid & 2);
VSOut o;
o.pos = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1);
o.uv = uv;
return o;
}
Texture2D tex : register(t0);
SamplerState smp : register(s0);
float4 ps_main(VSOut i) : SV_Target { return tex.Sample(smp, i.uv); }
"#;
pub struct Presenter {
device: ID3D11Device,
context: ID3D11DeviceContext,
vs: ID3D11VertexShader,
ps: ID3D11PixelShader,
sampler: ID3D11SamplerState,
swap: IDXGISwapChain1,
rtv: Option<ID3D11RenderTargetView>,
/// Video texture + SRV + dimensions; recreated when the decoded size changes.
tex: Option<(ID3D11Texture2D, ID3D11ShaderResourceView, u32, u32)>,
/// Panel (swapchain) size in pixels, updated on resize.
panel_w: u32,
panel_h: u32,
/// Whether the swapchain is currently in 10-bit HDR10 (R10G10B10A2 + ST.2084) mode; flipped
/// to match each frame's `hdr` flag.
hdr: bool,
}
impl Presenter {
/// Create the D3D11 device + composition swapchain + shaders, sized to the panel.
pub fn new(width: u32, height: u32) -> Result<Presenter> {
let (device, context) = create_device()?;
let (vs, ps, sampler) = build_pipeline(&device)?;
let swap = create_composition_swapchain(&device, width.max(1), height.max(1))?;
Ok(Presenter {
device,
context,
vs,
ps,
sampler,
swap,
rtv: None,
tex: None,
panel_w: width.max(1),
panel_h: height.max(1),
hdr: false,
})
}
/// The DXGI swapchain to hand to `SwapChainPanelHandle::set_swap_chain`.
pub fn swap_chain(&self) -> &IDXGISwapChain1 {
&self.swap
}
/// Resize the back buffers to the panel's new size (drops the stale RTV).
pub fn resize(&mut self, width: u32, height: u32) {
if width == 0 || height == 0 || (width == self.panel_w && height == self.panel_h) {
return;
}
self.rtv = None; // release all back-buffer refs before ResizeBuffers
unsafe {
let _ = self.swap.ResizeBuffers(
0,
width,
height,
DXGI_FORMAT_UNKNOWN,
DXGI_SWAP_CHAIN_FLAG(0),
);
}
self.panel_w = width;
self.panel_h = height;
}
/// Present one decoded frame (Contain-fit) — or, when `frame` is `None`, just re-present the
/// last texture (or black). Called from the reactor `on_rendering` per-frame callback.
pub fn present(&mut self, frame: Option<&CpuFrame>) {
if let Some(f) = frame {
if f.hdr != self.hdr {
self.set_hdr(f.hdr);
}
if let Err(e) = self.upload(f) {
tracing::warn!(error = %e, "frame upload failed");
}
}
let Ok(rtv) = self.rtv() else {
return;
};
let (pw, ph) = (self.panel_w, self.panel_h);
unsafe {
let c = &self.context;
c.ClearRenderTargetView(&rtv, &[0.0, 0.0, 0.0, 1.0]);
if let Some((_, srv, vw, vh)) = &self.tex {
// Contain-fit viewport: scale to the smaller axis, centre, letterbox the rest.
let (ww, wh, vfw, vfh) = (
pw as f32,
ph as f32,
(*vw).max(1) as f32,
(*vh).max(1) as f32,
);
let scale = (ww / vfw).min(wh / vfh);
let (dw, dh) = (vfw * scale, vfh * scale);
let (ox, oy) = ((ww - dw) / 2.0, (wh - dh) / 2.0);
c.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None);
let vp = D3D11_VIEWPORT {
TopLeftX: ox,
TopLeftY: oy,
Width: dw,
Height: dh,
MinDepth: 0.0,
MaxDepth: 1.0,
};
c.RSSetViewports(Some(&[vp]));
c.IASetInputLayout(None);
c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
c.VSSetShader(&self.vs, None);
c.PSSetShader(&self.ps, None);
c.PSSetShaderResources(0, Some(&[Some(srv.clone())]));
c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
c.Draw(3, 0);
}
let _ = self.swap.Present(1, DXGI_PRESENT(0));
}
}
/// Switch the swapchain between 8-bit SDR (B8G8R8A8, sRGB/BT.709) and 10-bit HDR10
/// (R10G10B10A2, ST.2084 PQ BT.2020). `ResizeBuffers` can change the back-buffer format in
/// place, so the panel binding (`set_swap_chain`) stays valid — no rebind needed. The decoded
/// samples are already PQ-encoded BT.2020 (see `video::convert`), so the colour space is all the
/// compositor needs to map them to the display.
fn set_hdr(&mut self, on: bool) {
self.rtv = None; // release back-buffer refs before ResizeBuffers
self.tex = None; // texture format changes (R10G10B10A2 vs R8G8B8A8)
let format = if on {
DXGI_FORMAT_R10G10B10A2_UNORM
} else {
DXGI_FORMAT_B8G8R8A8_UNORM
};
unsafe {
if let Err(e) = self.swap.ResizeBuffers(
0,
self.panel_w,
self.panel_h,
format,
DXGI_SWAP_CHAIN_FLAG(0),
) {
tracing::warn!(error = %e, "ResizeBuffers for HDR switch failed");
return;
}
let colorspace = if on {
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020
} else {
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709
};
if let Ok(sc3) = self.swap.cast::<IDXGISwapChain3>() {
// Only set a colour space the swapchain accepts for present (on an SDR desktop the
// DWM still tone-maps HDR10 → SDR, so leaving the default there is fine).
if let Ok(support) = sc3.CheckColorSpaceSupport(colorspace) {
if support & DXGI_SWAP_CHAIN_COLOR_SPACE_SUPPORT_FLAG_PRESENT.0 as u32 != 0 {
let _ = sc3.SetColorSpace1(colorspace);
}
}
}
if on {
if let Ok(sc4) = self.swap.cast::<IDXGISwapChain4>() {
let md = hdr10_metadata();
let bytes = std::slice::from_raw_parts(
&md as *const DXGI_HDR_METADATA_HDR10 as *const u8,
std::mem::size_of::<DXGI_HDR_METADATA_HDR10>(),
);
let _ = sc4.SetHDRMetaData(DXGI_HDR_METADATA_TYPE_HDR10, Some(bytes));
}
}
}
self.hdr = on;
tracing::info!(hdr = on, "swapchain colour mode switched");
}
fn upload(&mut self, frame: &CpuFrame) -> Result<()> {
let (w, h) = (frame.width, frame.height);
let need_new = !matches!(&self.tex, Some((_, _, tw, th)) if *tw == w && *th == h);
if need_new {
let format = if self.hdr {
DXGI_FORMAT_R10G10B10A2_UNORM
} else {
DXGI_FORMAT_R8G8B8A8_UNORM
};
let desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
Format: format,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DYNAMIC,
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
MiscFlags: 0,
};
let texture = unsafe {
let mut t = None;
self.device
.CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D")?;
t.unwrap()
};
let srv = unsafe {
let mut s = None;
self.device
.CreateShaderResourceView(&texture, None, Some(&mut s))
.context("CreateShaderResourceView")?;
s.unwrap()
};
self.tex = Some((texture, srv, w, h));
}
let (texture, _, _, _) = self.tex.as_ref().unwrap();
unsafe {
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
self.context
.Map(texture, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
.context("Map video texture")?;
let dst = mapped.pData as *mut u8;
let dst_pitch = mapped.RowPitch as usize;
let src_pitch = frame.stride;
let row_bytes = (w as usize) * 4;
for y in 0..h as usize {
std::ptr::copy_nonoverlapping(
frame.pixels.as_ptr().add(y * src_pitch),
dst.add(y * dst_pitch),
row_bytes.min(src_pitch),
);
}
self.context.Unmap(texture, 0);
}
Ok(())
}
fn rtv(&mut self) -> Result<ID3D11RenderTargetView> {
if self.rtv.is_none() {
let back: ID3D11Texture2D = unsafe { self.swap.GetBuffer(0).context("GetBuffer")? };
let rtv = unsafe {
let mut v = None;
self.device
.CreateRenderTargetView(&back, None, Some(&mut v))
.context("CreateRenderTargetView")?;
v.unwrap()
};
self.rtv = Some(rtv);
}
Ok(self.rtv.clone().unwrap())
}
}
fn create_device() -> Result<(ID3D11Device, ID3D11DeviceContext)> {
for driver in [D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP] {
let mut device = None;
let mut context = None;
let r = unsafe {
D3D11CreateDevice(
None,
driver,
None,
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
};
if r.is_ok() {
let name = if driver == D3D_DRIVER_TYPE_HARDWARE {
"hardware"
} else {
"WARP (software)"
};
tracing::info!(driver = name, "D3D11 device created");
return Ok((device.unwrap(), context.unwrap()));
}
}
Err(anyhow!(
"D3D11CreateDevice failed for both hardware and WARP"
))
}
/// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`.
fn create_composition_swapchain(
device: &ID3D11Device,
width: u32,
height: u32,
) -> Result<IDXGISwapChain1> {
let dxdev: IDXGIDevice = device.cast().context("IDXGIDevice cast")?;
let factory: IDXGIFactory2 = unsafe {
let adapter = dxdev.GetAdapter().context("GetAdapter")?;
adapter.GetParent().context("GetParent (IDXGIFactory2)")?
};
let desc = DXGI_SWAP_CHAIN_DESC1 {
Width: width,
Height: height,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
Stereo: false.into(),
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
BufferCount: 2,
Scaling: DXGI_SCALING_STRETCH,
SwapEffect: DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL,
// IGNORE (opaque), not PREMULTIPLIED: the video fills the panel and the HDR `X2BGR10`
// upload leaves the 2 padding/alpha bits 0 — premultiplied alpha would then make HDR frames
// transparent. Opaque is correct for a full-frame video surface either way.
AlphaMode: DXGI_ALPHA_MODE_IGNORE,
Flags: 0,
};
unsafe {
factory
.CreateSwapChainForComposition(device, &desc, None)
.context("CreateSwapChainForComposition")
}
}
fn build_pipeline(
device: &ID3D11Device,
) -> Result<(ID3D11VertexShader, ID3D11PixelShader, ID3D11SamplerState)> {
let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?;
let ps_blob = compile(SHADER_HLSL, "ps_main", "ps_5_0")?;
unsafe {
let mut vs = None;
device
.CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs))
.context("CreateVertexShader")?;
let mut ps = None;
device
.CreatePixelShader(blob_bytes(&ps_blob), None, Some(&mut ps))
.context("CreatePixelShader")?;
let sdesc = D3D11_SAMPLER_DESC {
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
MaxLOD: D3D11_FLOAT32_MAX,
..Default::default()
};
let mut sampler = None;
device
.CreateSamplerState(&sdesc, Some(&mut sampler))
.context("CreateSamplerState")?;
Ok((vs.unwrap(), ps.unwrap(), sampler.unwrap()))
}
}
fn compile(src: &str, entry: &str, target: &str) -> Result<ID3DBlob> {
let entry_c = std::ffi::CString::new(entry).unwrap();
let target_c = std::ffi::CString::new(target).unwrap();
let mut code = None;
let mut errors = None;
let r = unsafe {
D3DCompile(
src.as_ptr() as *const _,
src.len(),
PCSTR::null(),
None,
None,
PCSTR(entry_c.as_ptr() as *const u8),
PCSTR(target_c.as_ptr() as *const u8),
D3DCOMPILE_OPTIMIZATION_LEVEL3,
0,
&mut code,
Some(&mut errors),
)
};
if r.is_err() {
let msg = errors
.as_ref()
.map(|b| unsafe {
let p = b.GetBufferPointer() as *const u8;
let n = b.GetBufferSize();
String::from_utf8_lossy(std::slice::from_raw_parts(p, n)).to_string()
})
.unwrap_or_default();
return Err(anyhow!("D3DCompile {entry}: {msg}"));
}
code.ok_or_else(|| anyhow!("D3DCompile produced no bytecode"))
}
fn blob_bytes(blob: &ID3DBlob) -> &[u8] {
unsafe {
let p = blob.GetBufferPointer() as *const u8;
let n = blob.GetBufferSize();
std::slice::from_raw_parts(p, n)
}
}
/// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white (0.00002 units), a 1000-nit
/// mastering display, MaxCLL 1000 / MaxFALL 400. The protocol doesn't carry the stream's real
/// mastering metadata yet (host follow-up), so these are sane defaults the display tone-maps from.
fn hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
DXGI_HDR_METADATA_HDR10 {
RedPrimary: [35400, 14600],
GreenPrimary: [8500, 39850],
BluePrimary: [6550, 2300],
WhitePoint: [15635, 16450],
MaxMasteringLuminance: 1000,
MinMasteringLuminance: 1, // 0.0001-nit units → 0.0001 nits
MaxContentLightLevel: 1000,
MaxFrameAverageLightLevel: 400,
}
}
@@ -1,272 +0,0 @@
//! Session controller: one worker thread runs connect → pump (video pull + decode, audio
//! pull + Opus decode, stats), feeding the UI over channels. The UI keeps the
//! `Arc<NativeClient>` from the `Connected` event for direct input sends (no extra hop on
//! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread:
//! video+audio here, rumble+hidout on the gamepad thread.
//!
//! Ported from the GTK Linux client; the platform-specific pieces are the video decoder
//! (software-only here) and the audio backend (WASAPI). The pump body is identical.
use crate::audio;
use crate::video::{DecodedFrame, Decoder};
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use punktfunk_core::PunktfunkError;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct SessionParams {
pub host: String,
pub port: u16,
pub mode: Mode,
pub compositor: CompositorPref,
pub gamepad: GamepadPref,
pub bitrate_kbps: u32,
/// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool,
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
pub pin: Option<[u8; 32]>,
pub identity: (String, String),
}
#[derive(Clone, Copy, Default, PartialEq)]
pub struct Stats {
pub fps: f32,
pub mbps: f32,
pub decode_ms: f32,
/// Median capture→decoded latency over the last window (host-clock corrected).
pub latency_ms: f32,
}
pub enum SessionEvent {
Connected {
connector: Arc<NativeClient>,
mode: Mode,
fingerprint: [u8; 32],
},
/// `trust_rejected` is set when the connect failed the TLS trust check (a `Crypto`
/// error): for a pinned connect this is the fingerprint-changed signal, so the UI can
/// offer a re-pair (PIN) path rather than a dead-end error.
Failed {
msg: String,
trust_rejected: bool,
},
Ended(Option<String>),
Stats(Stats),
}
pub struct SessionHandle {
pub events: async_channel::Receiver<SessionEvent>,
pub frames: async_channel::Receiver<DecodedFrame>,
pub stop: Arc<AtomicBool>,
}
pub fn start(params: SessionParams) -> SessionHandle {
let (ev_tx, ev_rx) = async_channel::unbounded();
// Tiny frame queue, newest wins: force_send displaces the oldest when the UI lags.
let (frame_tx, frame_rx) = async_channel::bounded(2);
let stop = Arc::new(AtomicBool::new(false));
let stop_w = stop.clone();
std::thread::Builder::new()
.name("punktfunk-session".into())
.spawn(move || pump(params, ev_tx, frame_tx, stop_w))
.expect("spawn session thread");
SessionHandle {
events: ev_rx,
frames: frame_rx,
stop,
}
}
fn now_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
fn pump(
params: SessionParams,
ev_tx: async_channel::Sender<SessionEvent>,
frame_tx: async_channel::Sender<DecodedFrame>,
stop: Arc<AtomicBool>,
) {
let connector = match NativeClient::connect(
&params.host,
params.port,
params.mode,
params.compositor,
params.gamepad,
params.bitrate_kbps,
// Advertise 10-bit + HDR10: the presenter handles BT.2020 PQ (R10G10B10A2) frames, so the
// host may upgrade HDR content to a Main10/PQ stream (it still only does so for actual HDR
// content with its own 10-bit gate). 8-bit SDR is unaffected.
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR,
None, // launch: the Windows client has no library picker yet
params.pin,
Some(params.identity),
Duration::from_secs(15),
) {
Ok(c) => Arc::new(c),
Err(e) => {
let trust_rejected = matches!(e, PunktfunkError::Crypto);
let msg = match e {
PunktfunkError::Crypto => {
"Host identity rejected — wrong fingerprint, or the host requires pairing"
.to_string()
}
PunktfunkError::Timeout => "Connection timed out".to_string(),
other => format!("Connect failed: {other:?}"),
};
let _ = ev_tx.send_blocking(SessionEvent::Failed {
msg,
trust_rejected,
});
return;
}
};
let _ = ev_tx.send_blocking(SessionEvent::Connected {
connector: connector.clone(),
mode: connector.mode(),
fingerprint: connector.host_fingerprint,
});
let mut decoder = match Decoder::new() {
Ok(d) => d,
Err(e) => {
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
return;
}
};
// Audio is best-effort: a session without it still streams. Gamepads are the
// app-lifetime service's job (the UI attaches it on Connected).
let player = audio::AudioPlayer::spawn()
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
.ok();
let mut opus_dec = opus::Decoder::new(48_000, opus::Channels::Stereo)
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
.ok();
let _mic = params
.mic_enabled
.then(|| {
audio::MicStreamer::spawn(connector.clone())
.map_err(|e| tracing::warn!(error = %e, "mic uplink disabled"))
.ok()
})
.flatten();
let clock_offset = connector.clock_offset_ns;
let mut total_frames = 0u64;
let mut window_start = Instant::now();
let mut frames_n = 0u32;
let mut bytes_n = 0u64;
let mut decode_us_sum = 0u64;
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
let mut pcm = vec![0f32; 5760 * 2]; // decode scratch: max Opus frame (120 ms stereo)
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
let mut last_dropped = connector.frames_dropped();
let mut last_kf_req: Option<Instant> = None;
let end: Option<String> = loop {
if stop.load(Ordering::SeqCst) {
break None;
}
match connector.next_frame(Duration::from_millis(4)) {
Ok(frame) => {
let t0 = Instant::now();
match decoder.decode(&frame.data) {
Ok(Some(decoded)) => {
total_frames += 1;
if total_frames == 1 {
let DecodedFrame::Cpu(c) = &decoded;
tracing::info!(
width = c.width,
height = c.height,
path = "software",
"first frame decoded"
);
}
// Latency: our wall clock expressed in the host's capture clock,
// minus the host-stamped capture pts (same math as client-rs).
let lat = (now_ns() as i128 + clock_offset as i128 - frame.pts_ns as i128)
.max(0) as u64;
if lat > 0 && lat < 10_000_000_000 {
lat_us.push(lat / 1000);
}
decode_us_sum += t0.elapsed().as_micros() as u64;
frames_n += 1;
bytes_n += frame.data.len() as u64;
let _ = frame_tx.force_send(decoded);
}
Ok(None) => {}
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
}
}
Err(PunktfunkError::NoFrame) => {}
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
Err(e) => break Some(format!("session: {e:?}")),
}
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
// reassembler drops unrecoverable AUs (frames_dropped); the decoder conceals the
// reference-missing delta frames that follow and returns Ok, so keying off a decode error
// rarely fires. Request an IDR when the drop count climbs, throttled.
let dropped = connector.frames_dropped();
if dropped > last_dropped {
last_dropped = dropped;
let now = Instant::now();
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
last_kf_req = Some(now);
let _ = connector.request_keyframe();
tracing::debug!(dropped, "requested keyframe (loss recovery)");
}
}
// Drain audio between frames (packets land every 5 ms; the queue holds 320 ms).
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
match dec.decode_float(&pkt.data, &mut pcm, false) {
Ok(samples) => player.push(pcm[..samples * 2].to_vec()),
Err(e) => tracing::debug!(error = %e, "opus decode"),
}
}
}
if window_start.elapsed() >= Duration::from_secs(1) {
let secs = window_start.elapsed().as_secs_f32();
lat_us.sort_unstable();
let p50 = lat_us.get(lat_us.len() / 2).copied().unwrap_or(0);
tracing::debug!(
fps = frames_n,
lat_p50_us = p50,
total_frames,
"stream window"
);
let _ = ev_tx.try_send(SessionEvent::Stats(Stats {
fps: frames_n as f32 / secs,
mbps: bytes_n as f32 * 8.0 / 1e6 / secs,
decode_ms: if frames_n > 0 {
decode_us_sum as f32 / frames_n as f32 / 1000.0
} else {
0.0
},
latency_ms: p50 as f32 / 1000.0,
}));
window_start = Instant::now();
frames_n = 0;
bytes_n = 0;
decode_us_sum = 0;
lat_us.clear();
}
};
tracing::info!(
total_frames,
reason = end.as_deref().unwrap_or("user"),
"session ended"
);
stop.store(true, Ordering::SeqCst);
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
}
@@ -1,170 +0,0 @@
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
//!
//! Ported near-verbatim from the GTK Linux client; the only platform change is the config
//! directory — `%APPDATA%\punktfunk` (the Windows analogue of `~/.config/punktfunk`), shared
//! with the Windows host's identity location. The identity files (`client-{cert,key}.pem`)
//! keep the same names so the trust model is identical across the native clients.
use anyhow::{anyhow, Context, Result};
use punktfunk_core::quic::endpoint;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub fn config_dir() -> Result<PathBuf> {
let appdata = std::env::var("APPDATA").context("APPDATA unset")?;
Ok(PathBuf::from(appdata).join("punktfunk"))
}
/// This client's persistent identity, generated on first use — presented on every connect
/// so hosts can recognize it once paired.
pub fn load_or_create_identity() -> Result<(String, String)> {
let dir = config_dir()?;
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
return Ok((c, k));
}
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
std::fs::create_dir_all(&dir)?;
std::fs::write(&cp, &c)?;
std::fs::write(&kp, &k)?;
tracing::info!(cert = %cp.display(), "generated client identity");
Ok((c, k))
}
pub fn hex(fp: &[u8; 32]) -> String {
fp.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn parse_hex32(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, b) in out.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
}
Some(out)
}
/// One trusted host: its pinned certificate fingerprint plus how we got there (TOFU or a
/// PIN ceremony) and where we last reached it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KnownHost {
pub name: String,
pub addr: String,
pub port: u16,
/// SHA-256 of the host certificate, lowercase hex — the pin for every later connect.
pub fp_hex: String,
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
pub paired: bool,
}
#[derive(Default, Serialize, Deserialize)]
pub struct KnownHosts {
pub hosts: Vec<KnownHost>,
}
impl KnownHosts {
fn path() -> Result<PathBuf> {
Ok(config_dir()?.join("client-known-hosts.json"))
}
pub fn load() -> KnownHosts {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) -> Result<()> {
let p = Self::path()?;
std::fs::create_dir_all(p.parent().unwrap())?;
std::fs::write(&p, serde_json::to_string_pretty(self)?)?;
Ok(())
}
// Used by the GUI host-list's pinned-fingerprint trust decision (the silent-reconnect
// path); the current CLI trust flow keys on address. Kept for parity with the other
// clients' known-hosts API — wired when the discovered-hosts UI lands.
#[allow(dead_code)]
pub fn find_by_fp(&self, fp_hex: &str) -> Option<&KnownHost> {
self.hosts.iter().find(|h| h.fp_hex == fp_hex)
}
pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> {
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
}
/// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades
/// (a later TOFU connect must not demote a PIN-paired host).
pub fn upsert(&mut self, entry: KnownHost) {
if let Some(h) = self.hosts.iter_mut().find(|h| h.fp_hex == entry.fp_hex) {
h.name = entry.name;
h.addr = entry.addr;
h.port = entry.port;
h.paired |= entry.paired;
} else {
self.hosts.push(entry);
}
}
}
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
/// Stream mode; `0` = the native size/refresh of the monitor the window is on,
/// resolved at connect time.
pub width: u32,
pub height: u32,
pub refresh_hz: u32,
/// Requested encoder bitrate (kbps); 0 = host default.
pub bitrate_kbps: u32,
pub gamepad: String,
/// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable).
pub compositor: String,
/// Grab system shortcuts (Alt+Tab, Win…) while input is captured.
pub inhibit_shortcuts: bool,
/// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool,
}
impl Default for Settings {
fn default() -> Self {
Settings {
width: 0,
height: 0,
refresh_hz: 0,
bitrate_kbps: 0,
gamepad: "auto".into(),
compositor: "auto".into(),
inhibit_shortcuts: true,
mic_enabled: false,
}
}
}
impl Settings {
fn path() -> Result<PathBuf> {
Ok(config_dir()?.join("client-windows-settings.json"))
}
pub fn load() -> Settings {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) {
let Ok(p) = Self::path() else { return };
let _ = std::fs::create_dir_all(p.parent().unwrap());
if let Ok(s) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(&p, s);
}
}
}
@@ -1,136 +0,0 @@
//! Video decode: reassembled HEVC access units → frames for the D3D11 presenter.
//!
//! The dev box has no working GPU, so this ships the **software** backend first: libavcodec
//! on the CPU + swscale to RGBA, uploaded into a D3D11 texture by the presenter. It runs
//! `AV_CODEC_FLAG_LOW_DELAY` with slice threading only — the host encodes zero-reorder
//! streams (no B-frames, in-band parameter sets on every IDR), so decode is strictly
//! one-in/one-out and frame threading would only add latency.
//!
//! `DecodedFrame` is an enum so the real-GPU **D3D11VA** path (decode → `NV12`/`P010`
//! `ID3D11Texture2D`, zero-copy into the swapchain) can be added as a second variant without
//! touching the session pump or the presenter's frame contract.
use anyhow::{anyhow, Context as _, Result};
use ffmpeg::format::Pixel;
use ffmpeg::software::scaling;
use ffmpeg::util::frame::Video as AvFrame;
use ffmpeg_next as ffmpeg;
pub enum DecodedFrame {
Cpu(CpuFrame),
}
/// Packed 4-byte-per-pixel frame for a D3D11 texture upload (which takes a row pitch). The bytes
/// are `R8G8B8A8` for SDR and `X2BGR10` (== DXGI `R10G10B10A2`, R in the low 10 bits) for HDR.
pub struct CpuFrame {
pub width: u32,
pub height: u32,
/// Row stride in bytes (≥ width*4 — swscale pads rows for SIMD).
pub stride: usize,
pub pixels: Vec<u8>,
/// BT.2020 PQ HDR10 frame: `pixels` is `X2BGR10` and the presenter switches to a 10-bit
/// R10G10B10A2 + ST.2084 swapchain. `false` = ordinary 8-bit BT.709 SDR.
pub hdr: bool,
}
pub struct Decoder {
inner: SoftwareDecoder,
}
impl Decoder {
pub fn new() -> Result<Decoder> {
ffmpeg::init().context("ffmpeg init")?;
Ok(Decoder {
inner: SoftwareDecoder::new()?,
})
}
/// Feed one access unit; returns the decoded frame (the host's streams are
/// one-in/one-out). A decode error after packet loss is survivable — log upstream and
/// keep feeding; the host's IDR/RFI recovery resynchronizes on the next keyframe.
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedFrame>> {
Ok(self.inner.decode(au)?.map(DecodedFrame::Cpu))
}
}
struct SoftwareDecoder {
decoder: ffmpeg::decoder::Video,
/// Rebuilt whenever the decoded format/size **or output format** changes (mid-stream
/// `Reconfigure`, or an SDR↔HDR flip): `(ctx, src_fmt, w, h, dst_fmt)`.
sws: Option<(scaling::Context, Pixel, u32, u32, Pixel)>,
}
impl SoftwareDecoder {
fn new() -> Result<SoftwareDecoder> {
let codec =
ffmpeg::decoder::find(ffmpeg::codec::Id::HEVC).ok_or(anyhow!("no HEVC decoder"))?;
let mut ctx = ffmpeg::codec::Context::new_with_codec(codec);
unsafe {
let raw = ctx.as_mut_ptr();
(*raw).flags |= ffmpeg::ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
// Slice threading adds no frame delay (frame threading adds thread_count-1).
(*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE;
(*raw).thread_count = 0; // auto
}
let decoder = ctx.decoder().video().context("open HEVC decoder")?;
Ok(SoftwareDecoder { decoder, sws: None })
}
fn decode(&mut self, au: &[u8]) -> Result<Option<CpuFrame>> {
let packet = ffmpeg::Packet::copy(au);
self.decoder
.send_packet(&packet)
.map_err(|e| anyhow!("send_packet: {e}"))?;
let mut frame = AvFrame::empty();
let mut out = None;
while self.decoder.receive_frame(&mut frame).is_ok() {
out = Some(self.convert(&frame)?);
}
Ok(out)
}
/// Convert the decoded YUV frame to a packed 4-byte format the presenter uploads directly:
/// SDR → `RGBA` (BT.709), HDR (SMPTE ST.2084 / PQ transfer) → `X2BGR10` (10-bit, == DXGI
/// R10G10B10A2) using the BT.2020 matrix. For HDR the PQ-encoded values pass through unchanged
/// (swscale only applies the YUV→RGB matrix + range, never the transfer) — exactly what an
/// HDR10/ST.2084 swapchain wants.
fn convert(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
use ffmpeg::color::TransferCharacteristic;
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
let hdr = frame.color_transfer_characteristic() == TransferCharacteristic::SMPTE2084;
let dst = if hdr { Pixel::X2BGR10LE } else { Pixel::RGBA };
let rebuild = !matches!(&self.sws, Some((_, f, sw, sh, d)) if *f == fmt && *sw == w && *sh == h && *d == dst);
if rebuild {
let mut ctx = scaling::Context::get(fmt, w, h, dst, w, h, scaling::Flags::POINT)
.context("swscale context")?;
if hdr {
// BT.2020 non-constant-luminance YUV (limited range) → full-range RGB. swscale
// applies only the matrix + range here, so the samples stay PQ-encoded.
unsafe {
let coef = ffmpeg::ffi::sws_getCoefficients(ffmpeg::ffi::SWS_CS_BT2020);
ffmpeg::ffi::sws_setColorspaceDetails(
ctx.as_mut_ptr(),
coef,
0, // src range: limited (video)
coef,
1, // dst range: full
0,
1 << 16,
1 << 16, // brightness / contrast / saturation defaults (16.16)
);
}
}
self.sws = Some((ctx, fmt, w, h, dst));
}
let (sws, ..) = self.sws.as_mut().unwrap();
let mut conv = AvFrame::empty();
sws.run(frame, &mut conv).map_err(|e| anyhow!("sws: {e}"))?;
Ok(CpuFrame {
width: w,
height: h,
stride: conv.stride(0),
pixels: conv.data(0).to_vec(),
hdr,
})
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ repository.workspace = true
[lib]
name = "punktfunk_core"
# `lib` — so punktfunk-host / punktfunk-client-rs / tools link it as a normal Rust crate.
# `lib` — so punktfunk-host / punktfunk-probe / tools link it as a normal Rust crate.
# `staticlib` — `libpunktfunk_core.a` for the C test harness and static embedding.
# `cdylib` — `libpunktfunk_core.{so,dylib}` for Swift/Kotlin clients via the C ABI.
crate-type = ["lib", "cdylib", "staticlib"]
+29
View File
@@ -1494,6 +1494,35 @@ pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
})
}
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
/// rebuild them). A video loop polls this and calls [`punktfunk_connection_request_keyframe`]
/// when it climbs — the correct loss trigger under the host's infinite GOP, where unrecoverable
/// loss yields reference-missing delta frames the decoder *silently conceals* (frozen / garbage
/// picture, no decode error), so a decode-error trigger rarely fires. Monotonic for the session;
/// compare against the last observed value. Writes 0 to `out` on a NULL connection.
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
c: *const PunktfunkConnection,
out: *mut u64,
) -> PunktfunkStatus {
guard(|| {
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
unsafe {
if !out.is_null() {
*out = c.inner.frames_dropped();
}
}
PunktfunkStatus::Ok
})
}
/// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
/// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
/// measured goodput to drive a bitrate choice from; `loss_pct` is the delivery loss at that rate.
+3 -3
View File
@@ -1,10 +1,10 @@
//! The embeddable `punktfunk/1` client connector (M4 groundwork), behind the `quic` feature.
//! The embeddable `punktfunk/1` client connector, behind the `quic` feature.
//!
//! [`NativeClient::connect`] runs the full client side of the protocol — QUIC handshake
//! ([`crate::quic`]), UDP data plane ([`crate::session::Session`] on a native thread), input
//! datagrams — and hands the embedder a dead-simple surface: *pull reassembled access units,
//! push input events*. This is what the platform clients (SwiftUI/VideoToolbox, Android, …)
//! link via the C ABI (`punktfunk_connect` & co. in [`crate::abi`]); `punktfunk-client-rs` is the
//! link via the C ABI (`punktfunk_connect` & co. in [`crate::abi`]); `punktfunk-probe` is the
//! Rust-native consumer of the same flow.
//!
//! Threading: one worker thread owns a tokio runtime (QUIC control plane only — design
@@ -166,7 +166,7 @@ pub struct NativeClient {
/// kernel sees a high-QoS thread parked waiting on a lower-QoS one and the Thread Performance
/// Checker flags a priority inversion. Matching the producers to the consumers' QoS removes
/// the inversion without slowing the Swift side. No-op off Apple (the Linux client/host don't
/// run a QoS scheduler, and `punktfunk-client-rs` doesn't care).
/// run a QoS scheduler, and `punktfunk-probe` doesn't care).
#[cfg(target_vendor = "apple")]
fn pin_thread_user_interactive() {
// SAFETY: sets only the current thread's QoS class — always valid to call.
+1 -1
View File
@@ -12,7 +12,7 @@
//! `frame_index`↔`frameIndex`, `stream_seq`↔`streamPacketIndex`,
//! (`block_index`,`block_count`)↔the `multiFecBlocks` nibbles, and
//! (`data_shards`,`recovery_shards`,`shard_index`)↔the `fecInfo` bitfield. We carry them
//! as explicit fields rather than bit-packing; full GameStream wire-exactness is an M2
//! as explicit fields rather than bit-packing; full GameStream wire-exactness is a GameStream-host
//! concern (it also needs RTP framing + RTSP), this is the coherent internal format.
use crate::config::Config;
+3 -3
View File
@@ -1,4 +1,4 @@
//! `punktfunk/1` — the native control plane (M3), gated behind the `quic` feature.
//! `punktfunk/1` — the native control plane, gated behind the `quic` feature.
//!
//! GameStream is punktfunk's compatibility layer; this is the start of its own protocol. A QUIC
//! connection (quinn, tokio — control plane only, never the per-frame path) carries a
@@ -12,9 +12,9 @@
//!
//! after which both sides bring up a [`crate::session::Session`] over a plain
//! [`UdpTransport`](crate::transport::udp) (native threads, no async) and the host streams.
//! The Welcome carries everything the M1 core negotiates — FEC scheme (including GF(2¹⁶)
//! The Welcome carries everything the core negotiates — FEC scheme (including GF(2¹⁶)
//! Leopard, which GameStream can't express), shard sizing, crypto key/salt — so the data
//! plane is exactly the hardened M1 `Session`.
//! plane is exactly the hardened core `Session`.
//!
//! Transport security: the host presents a long-lived self-signed certificate
//! ([`endpoint::server_with_identity`]) and the client pins its SHA-256 fingerprint
+1 -1
View File
@@ -31,7 +31,7 @@ pub struct Frame {
/// Note: the AEAD layer authenticates each datagram but does **not** provide anti-replay.
/// Video replays are largely absorbed by the reassembler's per-frame dedup, but replayed
/// input events are not yet filtered. A sliding-window replay filter keyed on the
/// authenticated sequence belongs with the pairing/handshake layer (M2); until then,
/// authenticated sequence belongs with the pairing/handshake layer (the GameStream host); until then,
/// rely on the LAN/VPN transport assumption (plan §1).
pub struct Session {
config: Config,
+2 -2
View File
@@ -3,7 +3,7 @@
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) — the 1 Gbps+ syscall lever
//! (~125k → a few-k syscalls/sec at line rate). The host additionally paces each frame's send
//! across the frame interval (see `m3.rs::paced_submit`) so a real NIC doesn't drop a line-rate
//! across the frame interval (see `punktfunk1.rs::paced_submit`) so a real NIC doesn't drop a line-rate
//! burst. All three layer on this same [`Transport`] seam (scalar fallbacks for loopback/non-Linux).
use super::Transport;
@@ -397,7 +397,7 @@ impl UdpTransport {
/// Sized for 1 Gbps+: at ~1.2 Gbps on the wire an 8 MB buffer is only ~49 ms of steady state,
/// and a single multi-MB IDR keyframe (~4 MB ≈ 3300 packets) instantly fills most of it. 32 MB
/// gives ~200 ms of headroom and absorbs a keyframe burst without EAGAIN drops. (Paced sending
/// — `m3.rs::paced_submit` — now spreads a big frame's overflow, so this buffer mostly absorbs
/// — `punktfunk1.rs::paced_submit` — now spreads a big frame's overflow, so this buffer mostly absorbs
/// the immediate microburst rather than a whole unpaced frame.)
const TARGET_SOCKBUF: usize = 32 * 1024 * 1024;
+1 -1
View File
@@ -1,4 +1,4 @@
//! M1 acceptance: round-trip access units through the full host→client path
//! Core acceptance: round-trip access units through the full host→client path
//! (packetize → FEC → loopback with simulated loss → recover → reassemble) and assert
//! byte-exact recovery, for both FEC schemes, with and without encryption. Plus
//! property tests over the FEC layer's loss patterns.
+1 -1
View File
@@ -8,7 +8,7 @@ use anyhow::Result;
/// Opus/GameStream audio is 48 kHz.
pub const SAMPLE_RATE: u32 = 48_000;
/// Stereo channel count — the default and the punktfunk/1 (M3) audio plane's fixed layout.
/// Stereo channel count — the default and the punktfunk/1 audio plane's fixed layout.
pub const CHANNELS: usize = 2;
/// Produces interleaved `f32` PCM at [`SAMPLE_RATE`] in the channel count it was opened
+3 -3
View File
@@ -1,4 +1,4 @@
//! Frame capture (plan §7). On Linux: a PipeWire ScreenCast portal stream. M0 uses the
//! Frame capture (plan §7). On Linux: a PipeWire ScreenCast portal stream. The spike uses the
//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU
//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk).
@@ -45,7 +45,7 @@ impl PixelFormat {
}
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the M0/fallback path)
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the spike/fallback path)
/// or a GPU buffer already on the device (the zero-copy path, plan §9).
pub struct CapturedFrame {
pub width: u32,
@@ -103,7 +103,7 @@ pub trait Capturer: Send {
fn set_active(&self, _active: bool) {}
}
/// A deterministic moving test pattern (BGRx). Lets M0 exercise the encode → file →
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
/// `punktfunk_core` path with no live capture session, and produces obviously non-static
/// content (a sweeping bar + animated gradient) so the encoded output is verifiable.
pub struct SyntheticCapturer {
+1 -1
View File
@@ -1319,7 +1319,7 @@ pub struct DuplCapturer {
ever_got_frame: bool,
/// Consecutive rebuilds that produced a BORN-LOST duplication (created OK, but its first
/// AcquireNextFrame instantly returned ACCESS_LOST). On the NORMAL desktop this is the hybrid
/// reparent/flip storm — once it persists, `acquire` returns Err so the m3 loop cold-rebuilds the
/// reparent/flip storm — once it persists, `acquire` returns Err so the punktfunk1 loop cold-rebuilds the
/// whole pipeline (new device/output) instead of spinning on a dead dup forever (the bug where the
/// stream froze on the last frame). Reset to 0 by any real frame. NOT armed on the secure
/// (Winlogon) desktop, where a long static dwell is legitimate and must never end the session.
@@ -2,7 +2,7 @@
//! docs/windows-secure-desktop.md — step 4).
//!
//! WGC won't activate under the SYSTEM account, so the SYSTEM host can't capture the normal desktop
//! itself. Instead it spawns `m3-host wgc-helper` in the **interactive user session** (so WGC works)
//! itself. Instead it spawns `punktfunk-host wgc-helper` in the **interactive user session** (so WGC works)
//! via `CreateProcessAsUserW`, with the helper's **stdout** redirected to an anonymous pipe the host
//! reads. The helper ships framed Annex-B access units; this module parses them back into AUs the
//! host relays onto the live QUIC session (same `EncodedFrame` flow, just sourced over a pipe instead
+1 -1
View File
@@ -1,5 +1,5 @@
//! Hardware video encode (plan §7). Binds FFmpeg (NVENC); never rewrites codecs.
//! Low-latency preset, B-frames off. M0 feeds BGRx CPU frames directly — `*_nvenc`
//! Low-latency preset, B-frames off. The spike feeds BGRx CPU frames directly — `*_nvenc`
//! accepts `bgr0` input and converts to YUV on the GPU, so no host-side swscale is
//! needed (dmabuf zero-copy import is deferred; plan §9).
+7 -4
View File
@@ -1,10 +1,10 @@
//! GameStream (P1) control plane — what a stock Moonlight/Artemis client talks to around
//! the media streams: mDNS discovery, the nvhttp serverinfo + pairing HTTP(S) API, RTSP,
//! and the ENet control stream. `tokio`/`axum` live here (control plane, I/O-bound — never
//! the per-frame hot path; that is `punktfunk_core`'s P1 wire codec). See `docs/m2-plan.md`.
//! the per-frame hot path; that is `punktfunk_core`'s P1 wire codec). See `docs/gamestream-host-plan.md`.
//!
//! Status: P1.1 — mDNS `_nvstream._tcp` advertisement + `/serverinfo`. Pairing, RTSP, and
//! the media streams follow (see the M2 task list / plan).
//! the media streams follow (see the GameStream host task list / plan).
pub mod apps;
// Platform-neutral wire/negotiation logic + the Linux capture/encode pipeline (non-Linux
@@ -149,7 +149,10 @@ impl AppState {
/// QUIC server on `cfg.port` in the same process, sharing one [`crate::native_pairing`] handle with
/// the management API so the web console can arm pairing and show the PIN. `None` = GameStream only
/// (the mgmt API's native endpoints report `enabled: false`).
pub fn serve(mgmt: crate::mgmt::Options, native: Option<crate::m3::NativeServe>) -> Result<()> {
pub fn serve(
mgmt: crate::mgmt::Options,
native: Option<crate::punktfunk1::NativeServe>,
) -> Result<()> {
let host = Host::detect()?;
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
let state = Arc::new(AppState::new(host, identity));
@@ -187,7 +190,7 @@ pub fn serve(mgmt: crate::mgmt::Options, native: Option<crate::m3::NativeServe>)
tokio::try_join!(
nvhttp::run(state.clone()),
crate::mgmt::run(state.clone(), mgmt, Some(np.clone())),
crate::m3::serve(crate::m3::native_serve_opts(&cfg), np),
crate::punktfunk1::serve(crate::punktfunk1::native_serve_opts(&cfg), np),
)?;
}
_ => {
@@ -1,6 +1,6 @@
//! The video data plane: on RTSP PLAY, learn the client's UDP endpoint (it pings the video
//! port), then run capture → NVENC encode → [`VideoPacketizer`] → UDP send. The source is
//! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the M0 PipeWire path) or
//! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the portal PipeWire path) or
//! a synthetic test pattern (default). Runs on its own native thread.
use super::video::{FrameType, VideoPacketizer};
+33 -31
View File
@@ -6,9 +6,10 @@
//! `#[cfg(target_os = "linux")]`; the crate compiles everywhere so the workspace builds
//! on non-Linux dev machines — it just can't run the pipeline there.
//!
//! Status: M0. The `m0` subcommand runs the capture→encode→file pipeline spike and feeds
//! the encoded AUs through a `punktfunk_core` loopback. M2 wires the full P1 host that a stock
//! Moonlight client connects to.
//! Subcommands: `serve` runs the GameStream-compatible host + management REST API (and, with
//! `--native`, the native punktfunk/1 host in-process); `punktfunk1-host` runs the native
//! punktfunk/1 host standalone; `spike` is a capture→encode→file pipeline dev tool that also
//! round-trips the encoded AUs through a `punktfunk_core` loopback.
// Scaffold: trait methods and config paths are defined ahead of their backends.
#![allow(dead_code)]
@@ -24,15 +25,15 @@ mod encode;
mod gamestream;
mod inject;
mod library;
mod m0;
mod m3;
mod mgmt;
mod mgmt_token;
mod native_pairing;
mod pipeline;
mod punktfunk1;
mod pwinit;
#[cfg(target_os = "windows")]
mod service;
mod spike;
mod vdisplay;
#[cfg(target_os = "windows")]
mod wgc_helper;
@@ -41,7 +42,7 @@ mod zerocopy;
use anyhow::{bail, Context, Result};
use encode::Codec;
use m0::{Options, Source};
use spike::{Options, Source};
use std::path::PathBuf;
fn main() {
@@ -185,10 +186,10 @@ fn real_main() -> Result<()> {
println!("dualsense-test: done");
Ok(())
}
// M0 pipeline spike.
Some("m0") => m0::run(parse_m0(&args[1..])?),
// M3: native punktfunk/1 host (QUIC control plane + UDP data plane).
Some("m3-host") => {
// Capture→encode→file pipeline spike (dev tool).
Some("spike") => spike::run(parse_spike(&args[1..])?),
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
Some("punktfunk1-host") => {
let get = |flag: &str| {
args.iter()
.skip_while(|a| *a != flag)
@@ -196,10 +197,10 @@ fn real_main() -> Result<()> {
.map(String::as_str)
};
let source = match get("--source") {
Some("virtual") => m3::M3Source::Virtual,
_ => m3::M3Source::Synthetic,
Some("virtual") => punktfunk1::Punktfunk1Source::Virtual,
_ => punktfunk1::Punktfunk1Source::Synthetic,
};
m3::run(m3::M3Options {
punktfunk1::run(punktfunk1::Punktfunk1Options {
port: get("--port").and_then(|s| s.parse().ok()).unwrap_or(9777),
source,
seconds: get("--seconds").and_then(|s| s.parse().ok()).unwrap_or(30),
@@ -209,7 +210,7 @@ fn real_main() -> Result<()> {
.unwrap_or(0),
max_concurrent: get("--max-concurrent")
.and_then(|s| s.parse().ok())
.unwrap_or(m3::DEFAULT_MAX_CONCURRENT),
.unwrap_or(punktfunk1::DEFAULT_MAX_CONCURRENT),
// Secure by default: REQUIRE PIN pairing (reject unpaired clients) unless
// --allow-tofu opts into trust-on-first-use — the host then accepts unpaired
// clients and advertises pair=optional. Pairing is always armed so a PIN is
@@ -259,8 +260,9 @@ fn real_main() -> Result<()> {
print_usage();
Ok(())
}
// Bare flags (no subcommand) default to the m0 spike for back-compat.
Some(_) => m0::run(parse_m0(&args)?),
// Unknown subcommand → usage. (No implicit default; a bare `punktfunk-host` with no
// args hits the None arm above and prints help.)
Some(other) => bail!("unknown command '{other}' (try --help)"),
}
}
@@ -320,7 +322,7 @@ fn input_test() -> Result<()> {
/// the native punktfunk/1 host in-process (`--native`, the unified host). Returns the mgmt options
/// and the native host config (`None` = GameStream only). Native pairing is **required by default**
/// (an open host any LAN device can stream from is insecure); `--open` turns it off.
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, Option<m3::NativeServe>)> {
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, Option<punktfunk1::NativeServe>)> {
let mut opts = mgmt::Options::default();
let mut native_port: Option<u16> = None;
let mut open = false;
@@ -377,14 +379,14 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, Option<m3::NativeServe
if opts.token.is_none() {
opts.token = Some(crate::mgmt_token::load_or_generate()?);
}
let native = native_port.map(|port| m3::NativeServe {
let native = native_port.map(|port| punktfunk1::NativeServe {
port,
require_pairing: !open,
});
Ok((opts, native))
}
fn parse_m0(args: &[String]) -> Result<Options> {
fn parse_spike(args: &[String]) -> Result<Options> {
let mut source = Source::Portal;
let mut width = 1920u32;
let mut height = 1080u32;
@@ -465,7 +467,7 @@ fn parse_m0(args: &[String]) -> Result<Options> {
Codec::H265 => "h265",
Codec::Av1 => "obu",
};
PathBuf::from(format!("/tmp/punktfunk-m0.{ext}"))
PathBuf::from(format!("/tmp/punktfunk-spike.{ext}"))
});
Ok(Options {
@@ -486,12 +488,12 @@ fn print_usage() {
"punktfunk-host — Linux streaming host
USAGE:
punktfunk-host serve [OPTIONS] GameStream host control plane (M2: mDNS + serverinfo …)
+ the management REST API
punktfunk-host openapi print the management API's OpenAPI document (codegen)
punktfunk-host m3-host [OPTIONS] native punktfunk/1 host (QUIC control plane + UDP data plane)
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (session-bringup gate)
punktfunk-host m0 [OPTIONS] M0 capture→encode→file pipeline spike
punktfunk-host serve [OPTIONS] GameStream host control plane (mDNS + serverinfo …)
+ the management REST API
punktfunk-host openapi print the management API's OpenAPI document (codegen)
punktfunk-host punktfunk1-host [OPTIONS] native punktfunk/1 host (QUIC control + UDP data plane)
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate)
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
SERVE OPTIONS:
--mgmt-bind <IP:PORT> management API address (default: 127.0.0.1:47990)
@@ -503,7 +505,7 @@ SERVE OPTIONS:
--open disable mandatory native pairing (default: pairing REQUIRED —
an open host any LAN device can stream from is insecure)
M3-HOST OPTIONS:
PUNKTFUNK1-HOST OPTIONS:
--port <N> QUIC listen port (default: 9777)
--source <synthetic|virtual> test frames, or virtual display + NVENC (default: synthetic)
--seconds <N> per-session stream duration, virtual source (default: 30)
@@ -516,7 +518,7 @@ M3-HOST OPTIONS:
unpaired clients and logs a 4-digit pairing PIN at startup;
TOFU without pairing is insecure on a LAN
M0 OPTIONS:
SPIKE OPTIONS:
--source <synthetic|portal|kwin-virtual>
frame source (default: portal). 'kwin-virtual' creates a
KWin virtual output at --width x --height and captures it
@@ -525,7 +527,7 @@ M0 OPTIONS:
--codec <h264|h265|av1> NVENC codec (default: h265)
--bitrate <MBPS> target bitrate in Mbps (default: 20)
--width <W> --height <H> synthetic source size (default: 1920x1080)
--out <PATH> raw Annex-B output (default: /tmp/punktfunk-m0.<ext>)
--out <PATH> raw Annex-B output (default: /tmp/punktfunk-spike.<ext>)
--no-loopback skip the punktfunk_core round-trip verification
-h, --help this help
@@ -534,8 +536,8 @@ NOTES:
(see docs/linux-setup.md). 'synthetic' needs no capture session and always runs.
Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a
punktfunk_core host→client loopback that reassembles and byte-verifies each one.
Both 'serve --native' and 'm3-host' advertise the native service over mDNS
(_punktfunk._udp) for client auto-discovery — 'punktfunk-client-rs --discover' lists them."
Both 'serve --native' and 'punktfunk1-host' advertise the native service over mDNS
(_punktfunk._udp) for client auto-discovery — 'punktfunk-probe --discover' lists them."
);
#[cfg(target_os = "windows")]
eprintln!(
+1 -1
View File
@@ -1,6 +1,6 @@
//! Shared native (`punktfunk/1`) pairing state — the on-demand arming PIN (with expiry) plus the
//! persistent paired-clients store. One [`NativePairing`] handle is shared by the punktfunk/1 QUIC
//! accept loop ([`crate::m3`]) and the management API ([`crate::mgmt`]), so an operator can **arm
//! accept loop ([`crate::punktfunk1`]) and the management API ([`crate::mgmt`]), so an operator can **arm
//! pairing and read the PIN from the web console** instead of the service log.
//!
//! The PIN direction is inherent to the SPAKE2 ceremony: the *host* mints the PIN and the *client*
@@ -1,4 +1,4 @@
//! M3 — the `punktfunk/1` native host: QUIC control plane + the hardened M1 data plane over UDP.
//! The `punktfunk/1` native host: QUIC control plane + the hardened core data plane over UDP.
//! This is punktfunk's own protocol, past the GameStream compatibility layer:
//!
//! * the Welcome negotiates **GF(2¹⁶) Leopard FEC** (inexpressible in GameStream) + AES-GCM;
@@ -9,9 +9,9 @@
//! * video frames carry a wall-clock `pts_ns`, so a same-host client measures the full
//! capture→encode→FEC→UDP→reassemble latency per frame.
//!
//! `punktfunk-host m3-host [--port 9777] [--source synthetic|virtual] [--seconds 30]
//! `punktfunk-host punktfunk1-host [--port 9777] [--source synthetic|virtual] [--seconds 30]
//! [--frames 300]` serves sessions back to back (one at a time — the virtual output and
//! encoder are single-tenant); `punktfunk-client-rs --connect host:9777` is the counterpart.
//! encoder are single-tenant); `punktfunk-probe --connect host:9777` is the counterpart.
//! The data plane runs on native threads (no async on the frame path).
//!
//! Alongside video + input, a session carries **audio** (desktop Opus, 5 ms frames, host →
@@ -37,16 +37,16 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum M3Source {
pub enum Punktfunk1Source {
/// Deterministic test frames (protocol verification; the client byte-checks them).
Synthetic,
/// Real capture: virtual display at the client's requested mode → NVENC.
Virtual,
}
pub struct M3Options {
pub struct Punktfunk1Options {
pub port: u16,
pub source: M3Source,
pub source: Punktfunk1Source,
/// Virtual-source stream duration.
pub seconds: u32,
/// Synthetic-source frame count.
@@ -97,7 +97,7 @@ fn now_ns() -> u64 {
.unwrap_or(0)
}
pub fn run(opts: M3Options) -> Result<()> {
pub fn run(opts: Punktfunk1Options) -> Result<()> {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
@@ -138,10 +138,10 @@ pub(crate) struct NativeServe {
/// overflow clients wait in the accept queue. Override with `--max-concurrent`.
pub(crate) const DEFAULT_MAX_CONCURRENT: usize = 4;
pub(crate) fn native_serve_opts(cfg: &NativeServe) -> M3Options {
M3Options {
pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
Punktfunk1Options {
port: cfg.port,
source: M3Source::Virtual,
source: Punktfunk1Source::Virtual,
seconds: 7 * 24 * 3600, // per-session cap; large enough not to cut a live stream
frames: 0,
max_sessions: 0,
@@ -153,7 +153,7 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> M3Options {
}
}
pub(crate) async fn serve(opts: M3Options, np: Arc<NativePairing>) -> Result<()> {
pub(crate) async fn serve(opts: Punktfunk1Options, np: Arc<NativePairing>) -> Result<()> {
let identity = crate::gamestream::cert::ServerIdentity::load_or_create()
.context("load host identity (~/.config/punktfunk)")?;
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
@@ -427,7 +427,7 @@ async fn pair_ceremony(
#[allow(clippy::too_many_arguments)]
async fn serve_session(
conn: quinn::Connection,
opts: &M3Options,
opts: &Punktfunk1Options,
audio_cap: &AudioCapSlot,
inj_tx: std::sync::mpsc::Sender<InputEvent>,
mic_tx: std::sync::mpsc::Sender<Vec<u8>>,
@@ -514,7 +514,7 @@ async fn serve_session(
// can report what we'll actually drive. Only the Virtual source has a compositor; the
// synthetic source has no virtual output. Blocking probes → spawn_blocking.
let compositor = match source {
M3Source::Virtual => {
Punktfunk1Source::Virtual => {
let pref = hello.compositor;
Some(
tokio::task::spawn_blocking(move || resolve_compositor(pref))
@@ -522,7 +522,7 @@ async fn serve_session(
.context("resolve compositor task")??,
)
}
M3Source::Synthetic => None,
Punktfunk1Source::Synthetic => None,
};
// Resolve a requested library launch (the client sends only the store-qualified id;
@@ -600,8 +600,8 @@ async fn serve_session(
key,
salt: *b"pkf1",
frames: match source {
M3Source::Synthetic => frames,
M3Source::Virtual => 0, // unbounded — client streams until we close
Punktfunk1Source::Synthetic => frames,
Punktfunk1Source::Virtual => 0, // unbounded — client streams until we close
},
// Report the resolved backends back to the client (compositor: Auto for the
// synthetic source).
@@ -726,7 +726,7 @@ async fn serve_session(
let conn = conn.clone();
let gamepad = welcome.gamepad;
std::thread::Builder::new()
.name("punktfunk-m3-input".into())
.name("punktfunk1-input".into())
.spawn(move || input_thread(input_rx, rich_rx, conn, inj_tx, gamepad))
.context("spawn input thread")?
};
@@ -778,12 +778,12 @@ async fn serve_session(
// → host→client QUIC datagrams, on its own native thread. Best-effort on every failure
// (no PipeWire audio, spawn error): the session continues without audio — and a spawn
// error must NOT early-return here, the threads above are already running.
let audio_handle = if opts.source == M3Source::Virtual {
let audio_handle = if opts.source == Punktfunk1Source::Virtual {
let conn = conn.clone();
let stop = stop.clone();
let cap = audio_cap.clone();
std::thread::Builder::new()
.name("punktfunk-m3-audio".into())
.name("punktfunk1-audio".into())
.spawn(move || audio_thread(conn, stop, cap))
.map_err(|e| tracing::error!(error = %e, "audio thread spawn failed — session continues without audio"))
.ok()
@@ -794,7 +794,7 @@ async fn serve_session(
// Test hook (synthetic source only): a scripted feedback burst on the host→client
// planes — rumble (0xCA) + DualSense HID-output (0xCD) — so loopback tests can assert
// the client's feedback path without a real game writing output reports to a real pad.
if opts.source == M3Source::Synthetic
if opts.source == Punktfunk1Source::Synthetic
&& std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1")
{
use punktfunk_core::quic::HidOutput;
@@ -852,14 +852,14 @@ async fn serve_session(
let mut session = Session::new(cfg, Box::new(transport))
.map_err(|e| anyhow!("host session: {e:?}"))?;
match source {
M3Source::Synthetic => synthetic_stream(
Punktfunk1Source::Synthetic => synthetic_stream(
&mut session,
frames,
&stop_stream,
&probe_rx,
&probe_result_tx,
),
M3Source::Virtual => {
Punktfunk1Source::Virtual => {
let compositor = compositor
.expect("the Virtual source resolves a compositor during the handshake");
virtual_stream(
@@ -986,7 +986,7 @@ impl InjectorService {
fn start() -> InjectorService {
let (tx, rx) = std::sync::mpsc::channel::<InputEvent>();
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-m3-injector".into())
.name("punktfunk1-injector".into())
.spawn(move || injector_service_thread(rx))
{
tracing::error!(error = %e, "injector service thread spawn failed — pointer/keyboard input disabled");
@@ -1080,7 +1080,7 @@ impl MicService {
fn start() -> MicService {
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-m3-mic".into())
.name("punktfunk1-mic".into())
.spawn(move || mic_service_thread(rx))
{
tracing::error!(error = %e, "mic service thread spawn failed — mic passthrough disabled");
@@ -2117,7 +2117,7 @@ fn virtual_stream(
let _watcher = if watch {
let stop = stop.clone();
std::thread::Builder::new()
.name("punktfunk-m3-watcher".into())
.name("punktfunk1-watcher".into())
.spawn(move || session_watcher_loop(session_tx, stop))
.ok()
} else {
@@ -3014,9 +3014,9 @@ mod tests {
use punktfunk_core::error::PunktfunkStatus;
let host = std::thread::spawn(|| {
run(M3Options {
run(Punktfunk1Options {
port: 19777,
source: M3Source::Synthetic,
source: Punktfunk1Source::Synthetic,
seconds: 0,
frames: 25,
max_sessions: 3,
@@ -3182,9 +3182,9 @@ mod tests {
.build()
.unwrap();
rt.block_on(serve(
M3Options {
Punktfunk1Options {
port: 19779,
source: M3Source::Synthetic,
source: Punktfunk1Source::Synthetic,
seconds: 0,
frames: 25,
max_sessions: 2, // the knock + the post-approval session
@@ -3268,9 +3268,9 @@ mod tests {
use punktfunk_core::quic::endpoint;
let host = std::thread::spawn(|| {
run(M3Options {
run(Punktfunk1Options {
port: 19778,
source: M3Source::Synthetic,
source: Punktfunk1Source::Synthetic,
seconds: 0,
frames: 25,
max_sessions: 4,
@@ -1,9 +1,9 @@
//! M0 — the pipeline spike (plan §8): capture → NVENC encode → playable file, with the
//! The pipeline spike (plan §8): capture → NVENC encode → playable file, with the
//! encoded access units also fed through a `punktfunk_core` host→client `Session` over an
//! in-process loopback to prove the core's FEC + packetize + reassemble path on real
//! encoder output.
//!
//! This is the spike runner, not the M2 hot path: it drives the stages on one thread (the
//! This is the spike runner, not the production host path: it drives the stages on one thread (the
//! per-stage-thread pipeline with bounded channels is [`crate::pipeline`]). Source is
//! either a synthetic BGRx test pattern (no capture session needed) or the live xdg
//! ScreenCast portal monitor.
@@ -52,12 +52,12 @@ pub fn run(opts: Options) -> Result<()> {
width = opts.width,
height = opts.height,
fps = opts.fps,
"M0 source: synthetic BGRx test pattern"
"spike source: synthetic BGRx test pattern"
);
Box::new(SyntheticCapturer::new(opts.width, opts.height, opts.fps))
}
Source::Portal => {
tracing::info!("M0 source: xdg ScreenCast portal (live monitor)");
tracing::info!("spike source: xdg ScreenCast portal (live monitor)");
capture::open_portal_monitor().context("open portal capturer")?
}
Source::KwinVirtual => {
@@ -66,7 +66,7 @@ pub fn run(opts: Options) -> Result<()> {
width = opts.width,
height = opts.height,
?compositor,
"M0 source: virtual output (PUNKTFUNK_COMPOSITOR)"
"spike source: virtual output (PUNKTFUNK_COMPOSITOR)"
);
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
let vout = vd
@@ -104,7 +104,7 @@ pub fn run(opts: Options) -> Result<()> {
opts.fps,
opts.bitrate_bps,
first.is_cuda(),
8, // m0 synthetic harness: 8-bit
8, // spike synthetic harness: 8-bit
)
.context("open encoder")?;
@@ -147,7 +147,7 @@ pub fn run(opts: Options) -> Result<()> {
out = %opts.out.display(),
elapsed_s = format!("{elapsed:.2}"),
encode_fps = format!("{:.1}", stats.encoded as f64 / elapsed.max(1e-9)),
"M0 capture→encode→file complete"
"spike capture→encode→file complete"
);
if let Some(lb) = lb {
@@ -194,7 +194,7 @@ fn drain_encoder(
/// A host↔client `punktfunk_core` pair over a lossless in-process loopback. Each encoded AU is
/// FEC-protected, packetized, sent, then reassembled on the client and byte-compared to the
/// original — exercising the core on real encoder output (the M0 "feed into a Session" goal).
/// original — exercising the core on real encoder output (the spike "feed into a Session" goal).
struct Loopback {
host: Session,
client: Session,
+1 -1
View File
@@ -125,7 +125,7 @@ impl Compositor {
/// installed (it spawns a nested session — independent of the running desktop), plus the live
/// session's own compositor (KWin / Mutter / wlroots) when the host runs inside it. Cheap,
/// side-effect-free probes — safe to call per management request. A concrete client preference
/// is validated against this set before it's honored (see the m3 handshake's resolution).
/// is validated against this set before it's honored (see the punktfunk/1 handshake's resolution).
pub fn available() -> Vec<Compositor> {
#[cfg(target_os = "linux")]
{
@@ -40,7 +40,7 @@ fn chooser_file() -> String {
}
/// The managed xdpw config: per-session output selection with no GUI. The `|| echo` fallback
/// keeps plain portal capture (`--source portal`, M0 flow) working when no session has written
/// keeps plain portal capture (`--source portal` flow) working when no session has written
/// the chooser file. xdpw runs `chooser_cmd` via `/bin/sh -c`, reads stdout.
fn xdpw_config() -> String {
format!(
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn run(opts: HelperOptions) -> Result<()> {
// path. Elevate its OS priority so a CPU-heavy game can't deschedule it and delay submission (which
// would leave our HIGH GPU priority with nothing queued to prioritise). Apollo's capture thread is
// likewise CRITICAL.
crate::m3::boost_thread_priority(true);
crate::punktfunk1::boost_thread_priority(true);
// Capture the EXISTING SudoVDA output by GDI name / target id — do NOT create one (the host owns
// the virtual output + its isolate/restore; a second topology owner breaks DDA recovery).