chore: consolidate all in-progress parallel-session WIP
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s
Wholesale commit of every uncommitted change across the tree, at the user's explicit request — host refactor-campaign W1 (native.rs facade + native/ dir, library/ + mgmt/ splits), Android, core. These streams were mid-flight and not individually built/tested together; this supersedes the per-session HOLD markers. Consolidating so everything lands on main in one pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
/// 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/ENOBUFS drops. (Paced sending —
|
||||
/// `punktfunk1.rs::paced_submit` — spreads a big frame's overflow, so this buffer mostly absorbs the
|
||||
/// `native.rs::paced_submit` — spreads a big frame's overflow, so this buffer mostly absorbs the
|
||||
/// immediate microburst rather than a whole unpaced frame.)
|
||||
pub(crate) const TARGET_SOCKBUF: usize = 32 * 1024 * 1024;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! ([`Transport::recv_batch`], ≤128/syscall into a reused ring) on Linux AND Android (which is
|
||||
//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`])
|
||||
//! — 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 `punktfunk1.rs::paced_submit`) so a real
|
||||
//! paces each frame's send across the frame interval (see `native.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 and the remaining targets).
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ src/
|
||||
inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4)
|
||||
audio/ · audio.rs Opus out + virtual mic (PipeWire / WASAPI)
|
||||
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
||||
punktfunk1.rs the native punktfunk/1 host (QUIC control + native-thread UDP data plane)
|
||||
native.rs the native punktfunk/1 host (QUIC control + native-thread UDP data plane)
|
||||
mgmt.rs · native_pairing.rs · stats_recorder.rs management API, pairing, perf capture
|
||||
hdr.rs · library.rs HDR metadata; multi-store game library
|
||||
linux/ · windows/ platform-confined backends
|
||||
|
||||
@@ -1035,6 +1035,19 @@ impl IddPushCapturer {
|
||||
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||
let display_hdr = enabled_hdr
|
||||
|| crate::win_display::advanced_color_enabled(target.target_id).unwrap_or(false);
|
||||
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
||||
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
|
||||
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
||||
// BT.709, so the client's label overstates the stream until the descriptor poller sees
|
||||
// HDR come on. Loud, because every frame of this session is affected.
|
||||
if client_10bit && !display_hdr {
|
||||
tracing::error!(
|
||||
target = target.target_id,
|
||||
"IDD push: 10-bit HDR was negotiated but enabling advanced color on the \
|
||||
virtual display FAILED — encoding 8-bit SDR while the client was told HDR \
|
||||
(check the display driver / Windows HDR support on this box)"
|
||||
);
|
||||
}
|
||||
let ring_fmt = if display_hdr {
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT
|
||||
} else {
|
||||
|
||||
@@ -49,7 +49,12 @@ pub struct HostConfig {
|
||||
/// `PUNKTFUNK_ZEROCOPY` — Windows D3D11 zero-copy encode input override. `None` (unset) defers to
|
||||
/// the per-vendor default (AMF on, QSV off — see module docs and `encode/ffmpeg_win.rs`).
|
||||
pub zerocopy: Option<bool>,
|
||||
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
|
||||
/// `PUNKTFUNK_10BIT` — host policy gate for 10-bit encode (HEVC Main10 / AV1 10-bit).
|
||||
/// **Default ON** (since 10-bit went probe-gated end-to-end, 2026-07-16): the host merely
|
||||
/// *allows* 10-bit — a session only becomes 10-bit when the client advertised `VIDEO_CAP_10BIT`
|
||||
/// (behind its HDR setting + display-capability gate), the codec supports it (HEVC/AV1), and
|
||||
/// the GPU/backend passed the encode probe (`can_encode_10bit`) — otherwise 8-bit SDR.
|
||||
/// `PUNKTFUNK_10BIT=0`/`false`/`off`/`no` disables. Independent of `four_four_four` (depth vs chroma).
|
||||
pub ten_bit: bool,
|
||||
/// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions).
|
||||
/// **Default ON** (since the pipeline went zero-copy + honest end-to-end, 2026-07-10): the
|
||||
@@ -108,7 +113,16 @@ impl HostConfig {
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
}),
|
||||
ten_bit: flag("PUNKTFUNK_10BIT"),
|
||||
// Default ON, explicit-off grammar (mirrors `four_four_four`: the client's HDR setting
|
||||
// is the real per-session switch; the encode probe keeps incapable GPUs honest at 8-bit).
|
||||
ten_bit: val("PUNKTFUNK_10BIT")
|
||||
.map(|s| {
|
||||
!matches!(
|
||||
s.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true),
|
||||
// Default ON, explicit-off grammar (the client's own 4:4:4 setting — default OFF —
|
||||
// is the real switch; see the field doc).
|
||||
four_four_four: val("PUNKTFUNK_444")
|
||||
|
||||
@@ -845,6 +845,76 @@ pub fn can_encode_444(_codec: Codec) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the active GPU encode backend can actually produce a **10-bit** stream for `codec`
|
||||
/// (HEVC Main10 / AV1 10-bit). Resolved (and cached per selected GPU) *before* the Welcome so the
|
||||
/// negotiated bit depth — and the HDR/SDR colour label derived from it — matches what the encoder
|
||||
/// will really emit: the honest-downgrade channel, exactly like [`can_encode_444`]. Without this
|
||||
/// gate a default-on `PUNKTFUNK_10BIT` would negotiate 10-bit on a GPU/backend that then silently
|
||||
/// falls back to 8-bit post-Welcome (label HDR / stream SDR).
|
||||
///
|
||||
/// Backend truth: Windows **NVENC** queries the per-codec `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` cap;
|
||||
/// native **AMF** `Init`s a tiny P010 encoder with the 10-bit profile props (the driver rejects
|
||||
/// what the VCN can't do). **QSV** stays `false` until validated on Intel glass — the libavcodec
|
||||
/// Main10 incantation can silently encode 8-bit, the same stance as its 4:4:4 probe. Every
|
||||
/// **Linux** backend is `false` today: direct-NVENC/CUDA pins 8-bit until a P010 capture path
|
||||
/// exists (Phase 5.1), libav `hevc_nvenc` needs a 10-bit input format the capturer never feeds,
|
||||
/// VAAPI 10-bit isn't wired, and Vulkan-video hardcodes 8-bit — so Linux hosts honestly negotiate
|
||||
/// 8-bit SDR.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub fn can_encode_10bit(codec: Codec) -> bool {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
if !codec.supports_10bit() {
|
||||
return false;
|
||||
}
|
||||
// Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly
|
||||
// selected adapter before the next Welcome, mirroring `can_encode_444`.
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||||
let key = (crate::gpu::selection_key(), codec.label());
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(v) = cache.lock().unwrap().get(&key) {
|
||||
return *v;
|
||||
}
|
||||
let supported = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// No Linux backend encodes 10-bit yet (see the fn doc) — never negotiate it.
|
||||
false
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
match windows_resolved_backend() {
|
||||
WindowsBackend::Nvenc => {
|
||||
#[cfg(feature = "nvenc")]
|
||||
{
|
||||
nvenc::probe_can_encode_10bit(codec)
|
||||
}
|
||||
#[cfg(not(feature = "nvenc"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
WindowsBackend::Amf => amf::probe_can_encode_10bit(codec),
|
||||
// QSV: deferred like its 4:4:4 probe (`ffmpeg_win::probe_can_encode_444`) — no
|
||||
// Intel Windows box in the lab to validate that the libavcodec profile really
|
||||
// emits Main10 rather than silently 8-bit.
|
||||
WindowsBackend::Qsv => false,
|
||||
WindowsBackend::Software => false,
|
||||
}
|
||||
}
|
||||
};
|
||||
tracing::info!(codec = ?codec, supported, "10-bit encode capability probed");
|
||||
cache.lock().unwrap().insert(key, supported);
|
||||
supported
|
||||
}
|
||||
|
||||
/// Non-Linux/Windows (the macOS dev/test build of the host — synthetic-source loopback only):
|
||||
/// no GPU encode backend exists here, so 10-bit is never negotiated.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub fn can_encode_10bit(_codec: Codec) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
|
||||
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the vendor of the
|
||||
|
||||
@@ -104,6 +104,15 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit).
|
||||
/// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation
|
||||
/// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the
|
||||
/// *codec-level* gate: the active GPU/backend must still pass
|
||||
/// [`can_encode_10bit`](crate::encode::can_encode_10bit) before the host negotiates 10-bit.
|
||||
pub fn supports_10bit(self) -> bool {
|
||||
matches!(self, Codec::H265 | Codec::Av1)
|
||||
}
|
||||
|
||||
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
|
||||
/// pick the software encoder).
|
||||
pub fn nvenc_name(self) -> &'static str {
|
||||
|
||||
@@ -1770,6 +1770,30 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
/// [`probe_can_encode`] against an explicit device (separated so the live tests can pin the AMD
|
||||
/// adapter on a hybrid box).
|
||||
fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
|
||||
probe_open_on(device, codec, false)
|
||||
}
|
||||
|
||||
/// Native factory probe for **10-bit** encode: can this GPU's AMF runtime `Init` a `codec`
|
||||
/// encoder at 10-bit (Main10 profile / `*ColorBitDepth` 10, P010 input)? The driver rejects the
|
||||
/// profile/depth props on VCN generations that can't encode them, so a successful tiny `Init` is
|
||||
/// the honest per-codec answer — read *before* the Welcome by
|
||||
/// [`crate::encode::can_encode_10bit`] so the negotiated bit depth matches what the session's
|
||||
/// encoder will really open. H.264 is always `false` (High10 is not a VCN mode — the session
|
||||
/// open bails on it too).
|
||||
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
if !codec.supports_10bit() {
|
||||
return false;
|
||||
}
|
||||
let Some(device) = selected_adapter_device() else {
|
||||
return false;
|
||||
};
|
||||
probe_open_on(&device, codec, true)
|
||||
}
|
||||
|
||||
/// Shared probe body: a context on `device`, the codec's component, the usage preset, optionally
|
||||
/// the 10-bit profile/depth props, then a tiny `Init` (P010 surface when `ten_bit`, NV12
|
||||
/// otherwise). Everything is torn down before returning; `false` on any failure.
|
||||
fn probe_open_on(device: &ID3D11Device, codec: Codec, ten_bit: bool) -> bool {
|
||||
if try_factory().is_err() {
|
||||
return false;
|
||||
}
|
||||
@@ -1778,7 +1802,8 @@ fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
|
||||
// object is moved into a guard (`Ctx`/`Component`) immediately, so each early return releases
|
||||
// exactly once; `InitDX11` borrows the live `device` for the synchronous call (AMF holds its
|
||||
// own device reference until the guard's Terminate). Usage must be set before `Init` (the
|
||||
// header marks its default "N/A") — the probe mirrors the session's open order.
|
||||
// header marks its default "N/A") — the probe mirrors the session's open order, including
|
||||
// the 10-bit profile/depth props (`configure`'s required set_props) when probing 10-bit.
|
||||
unsafe {
|
||||
let Ok(lib) = try_factory() else { return false };
|
||||
let mut ctx: *mut sys::AmfContext = ptr::null_mut();
|
||||
@@ -1811,7 +1836,32 @@ fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
((*(*comp.0).vtbl).init)(comp.0, sys::AMF_SURFACE_NV12, 640, 480) == sys::AMF_OK
|
||||
if ten_bit {
|
||||
// The same required props `configure` sets for a 10-bit session — a driver that can't
|
||||
// honor them rejects here, which is exactly the probe's answer.
|
||||
let depth_props: &[(PCWSTR, i64)] = match codec {
|
||||
Codec::H265 => &[
|
||||
(w!("HevcProfile"), HEVC_PROFILE_MAIN_10),
|
||||
(w!("HevcColorBitDepth"), COLOR_BIT_DEPTH_10),
|
||||
],
|
||||
// 10-bit is part of AV1 Main profile — only the surface depth needs forcing.
|
||||
Codec::Av1 => &[(w!("Av1ColorBitDepth"), COLOR_BIT_DEPTH_10)],
|
||||
Codec::H264 | Codec::PyroWave => return false,
|
||||
};
|
||||
for (name, value) in depth_props {
|
||||
if ((*(*comp.0).vtbl).set_property)(comp.0, name.0, AmfVariant::from_i64(*value))
|
||||
!= sys::AMF_OK
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let surface = if ten_bit {
|
||||
sys::AMF_SURFACE_P010
|
||||
} else {
|
||||
sys::AMF_SURFACE_NV12
|
||||
};
|
||||
((*(*comp.0).vtbl).init)(comp.0, surface, 640, 480) == sys::AMF_OK
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2701,7 +2751,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Drive `enc` at the real frame cadence and return each frame's **submit→AU** wall-clock
|
||||
/// (µs) — the `encode_us` the punktfunk1 loop records. Mirrors the depth-1 loop exactly:
|
||||
/// (µs) — the `encode_us` the native loop records. Mirrors the depth-1 loop exactly:
|
||||
/// pace to `1/fps`, timestamp the submit, then drain whatever AUs are ready and FIFO-pair
|
||||
/// them to their submit stamps. The libavcodec AMF wrapper's ~2-frame output hold therefore
|
||||
/// shows up here as ~2 frame periods (the AU for frame N emerges only once N+2 is submitted),
|
||||
|
||||
@@ -321,7 +321,7 @@ fn retrieve_loop(
|
||||
work_rx: mpsc::Receiver<RetrieveJob>,
|
||||
done_tx: mpsc::Sender<RetrieveDone>,
|
||||
) {
|
||||
crate::punktfunk1::boost_thread_priority(false);
|
||||
crate::native::boost_thread_priority(false);
|
||||
while let Ok(job) = work_rx.recv() {
|
||||
// SAFETY: `job.event` is one of the auto-reset events `init_session` created and
|
||||
// registered for exactly this session, and `job.bs` one of its pool bitstreams; both stay
|
||||
@@ -1119,7 +1119,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// 4:4:4 honesty: the FREXT/chromaFormatIDC=3 config engages only on an RGB input (a
|
||||
// subsampled NV12/P010 source can't reconstruct full chroma). If the capturer handed
|
||||
// native YUV despite a 4:4:4 negotiation, this session encodes 4:2:0 — clear the flag
|
||||
// NOW so `caps().chroma_444` (and punktfunk1's post-open cross-check) reports what
|
||||
// NOW so `caps().chroma_444` (and native's post-open cross-check) reports what
|
||||
// the stream really carries instead of silently claiming full chroma.
|
||||
if self.chroma_444
|
||||
&& !matches!(
|
||||
@@ -1333,7 +1333,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
||||
EncoderCaps {
|
||||
supports_rfi: self.rfi_supported,
|
||||
supports_hdr_metadata: self.hdr,
|
||||
// In-band mastering/CLL is attached as keyframe SEI on HEVC/H.264 only — AV1 carries
|
||||
// it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
|
||||
// (see `submit`); the grade still reaches punktfunk clients out-of-band via the 0xCE
|
||||
// datagram. Don't claim a capability the AV1 path doesn't have.
|
||||
supports_hdr_metadata: self.hdr && self.codec != Codec::Av1,
|
||||
// Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
|
||||
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
|
||||
chroma_444: self.chroma_444,
|
||||
@@ -1564,10 +1568,34 @@ impl Drop for NvencD3d11Encoder {
|
||||
}
|
||||
|
||||
/// Probe whether the active NVIDIA GPU can encode HEVC **4:4:4** (`NV_ENC_CAPS_SUPPORT_YUV444_ENCODE`).
|
||||
/// Creates a throwaway hardware D3D11 device + NVENC session, queries the cap, and tears down. HEVC-only;
|
||||
/// the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before* the Welcome
|
||||
/// so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a card without it).
|
||||
/// HEVC-only; the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before*
|
||||
/// the Welcome so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a
|
||||
/// card without it). See [`probe_encode_cap`] for the throwaway-session mechanics.
|
||||
pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
if codec != Codec::H265 {
|
||||
return false;
|
||||
}
|
||||
probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE)
|
||||
}
|
||||
|
||||
/// Probe whether the active NVIDIA GPU can encode `codec` at **10-bit**
|
||||
/// (`NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` against the codec's own GUID — HEVC Main10 / AV1 10-bit).
|
||||
/// The result is cached by the caller ([`crate::encode::can_encode_10bit`]) and read *before* the
|
||||
/// Welcome so the negotiated bit depth — and the HDR label derived from it — matches what NVENC
|
||||
/// will really emit. The session-open path re-checks the same cap as a belt-and-braces guard
|
||||
/// ([`NvencD3d11Encoder::probe_caps`]'s 8-bit fallback).
|
||||
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
if !codec.supports_10bit() {
|
||||
return false;
|
||||
}
|
||||
probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE)
|
||||
}
|
||||
|
||||
/// Query ONE NVENC capability for `codec`: creates a throwaway hardware D3D11 device + NVENC
|
||||
/// session on the **selected render adapter**, reads the cap, and tears everything down. `false`
|
||||
/// on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
|
||||
/// capability that couldn't be confirmed.
|
||||
fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
||||
use windows::Win32::Foundation::HMODULE;
|
||||
use windows::Win32::Graphics::Direct3D::{
|
||||
D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0,
|
||||
@@ -1576,9 +1604,6 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
if codec != Codec::H265 {
|
||||
return false;
|
||||
}
|
||||
// No loadable NVENC on this box (non-NVIDIA / no driver) → the honest 4:4:4 answer is "no".
|
||||
// This is also the `api()` gate for every NVENC call below.
|
||||
if try_api().is_err() {
|
||||
@@ -1651,11 +1676,11 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
}
|
||||
let mut param = nv::NV_ENC_CAPS_PARAM {
|
||||
version: nv::NV_ENC_CAPS_PARAM_VER,
|
||||
capsToQuery: nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE,
|
||||
capsToQuery: cap,
|
||||
reserved: [0; 62],
|
||||
};
|
||||
let mut val: i32 = 0;
|
||||
let ok = (api().get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
|
||||
let ok = (api().get_encode_caps)(enc, codec_guid(codec), &mut param, &mut val)
|
||||
.nv_ok()
|
||||
.is_ok()
|
||||
&& val != 0;
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Host lifecycle event bus (scripting-and-hooks RFC §4, phase 0).
|
||||
//!
|
||||
//! A process-wide broadcast bus + bounded catch-up ring for **lifecycle events**: client
|
||||
//! connect/disconnect, session and stream start/end, pairing decisions, virtual-display
|
||||
//! create/release, library mutations, host start/stop. Fire sites on BOTH planes call
|
||||
//! [`emit`]; consumers ([`EventBus::subscribe`]) get a ring-backed catch-up plus a live
|
||||
//! tail — the shape `GET /api/v1/events` (SSE, phase 1) and the hook runner (phase 2)
|
||||
//! consume. Until those land, the bus is host-internal.
|
||||
//!
|
||||
//! Design notes (mirrors [`crate::log_capture`], the shipped ring precedent):
|
||||
//! - Events carry a monotonically increasing `seq` (1-based) and a wall-clock `ts_ms`.
|
||||
//! A consumer resumes with `since = last seen seq`; one that fell off the ring gets
|
||||
//! `dropped = true` and should resync via the REST snapshots.
|
||||
//! - The wire shape is **versioned and additive-only** within a major ([`SCHEMA_VERSION`]):
|
||||
//! fields and kinds may be added, never removed or renamed. The JSON snapshot tests below
|
||||
//! are the review gate — a failing snapshot IS a schema change.
|
||||
//! - **Payload hygiene: events never carry secrets** — no PINs, no tokens, no key material.
|
||||
//! Client names and fingerprints are fine (already exposed via the management API).
|
||||
//! - Emission is fire-and-forget and cheap (a mutex push + a non-blocking broadcast send);
|
||||
//! nothing here sits in a streaming hot path. Slow consumers lag (`RecvError::Lagged`)
|
||||
//! rather than buffering unboundedly; the SSE layer disconnects them.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Wire-shape major version, carried on every event. Additive-only within a major; removing
|
||||
/// or renaming a field is a new major (and, at the API layer, a new endpoint negotiation).
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Catch-up ring capacity. Events are small (a few hundred bytes) and low-rate (lifecycle,
|
||||
/// not per-frame), so 1024 spans hours of ordinary host activity.
|
||||
const RING_CAPACITY: usize = 1024;
|
||||
/// Live-tail channel depth per subscriber before a slow consumer starts lagging.
|
||||
const BROADCAST_CAPACITY: usize = 256;
|
||||
|
||||
/// One host lifecycle event, as it will appear on the wire (`data:` of one SSE frame).
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
pub struct HostEvent {
|
||||
/// Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.
|
||||
pub seq: u64,
|
||||
/// Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).
|
||||
pub ts_ms: u64,
|
||||
/// Wire-shape version ([`SCHEMA_VERSION`]).
|
||||
pub schema: u32,
|
||||
/// The event kind + payload, flattened: `"kind": "stream.started", …payload…`.
|
||||
#[serde(flatten)]
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
/// Which protocol plane an event originated from. Hooks and scripts filter on it — a hook
|
||||
/// that fires for native clients but not Moonlight clients is a bug, not a v2 feature.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Plane {
|
||||
/// The native punktfunk/1 plane (QUIC).
|
||||
Native,
|
||||
/// The GameStream/Moonlight compat plane (`--gamestream`).
|
||||
Gamestream,
|
||||
}
|
||||
|
||||
/// Why a client went away. `Quit` is a deliberate user "stop" (the typed close code);
|
||||
/// `Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DisconnectReason {
|
||||
Quit,
|
||||
Timeout,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// The connecting/disconnecting client's identity.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
pub struct ClientRef {
|
||||
/// Client-supplied device name; may be empty (an anonymous or compat-plane client).
|
||||
pub name: String,
|
||||
/// Hex SHA-256 certificate fingerprint, when the client presented one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fingerprint: Option<String>,
|
||||
pub plane: Plane,
|
||||
}
|
||||
|
||||
/// A live A/V session (the plane-neutral notion the Dashboard shows).
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
pub struct SessionRef {
|
||||
/// Host-local session id (unique within this host process).
|
||||
pub id: u64,
|
||||
/// Short client label (cert-fingerprint prefix, or peer IP for an anonymous client).
|
||||
pub client: String,
|
||||
/// Negotiated mode, `WxH@Hz` (e.g. `"3840x2160@120"`).
|
||||
pub mode: String,
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
/// A live video stream (what the stream marker file reflects).
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
pub struct StreamRef {
|
||||
/// Negotiated mode, `WxH@Hz`.
|
||||
pub mode: String,
|
||||
pub hdr: bool,
|
||||
/// Client-supplied device name; may be empty.
|
||||
pub client: String,
|
||||
/// The launched app/title for this stream, when one was requested (store-qualified id on
|
||||
/// the native plane, app title on the GameStream plane).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app: Option<String>,
|
||||
pub plane: Plane,
|
||||
}
|
||||
|
||||
/// A device in the pairing flow.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
pub struct DeviceRef {
|
||||
/// Sanitized device name (the pairing store's copy).
|
||||
pub name: String,
|
||||
/// Hex certificate fingerprint.
|
||||
pub fingerprint: String,
|
||||
pub plane: Plane,
|
||||
}
|
||||
|
||||
/// The event catalog (RFC §4). Serialized internally tagged as `"kind": "<domain>.<verb>"`,
|
||||
/// flattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`].
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum EventKind {
|
||||
#[serde(rename = "client.connected")]
|
||||
ClientConnected { client: ClientRef },
|
||||
#[serde(rename = "client.disconnected")]
|
||||
ClientDisconnected {
|
||||
client: ClientRef,
|
||||
reason: DisconnectReason,
|
||||
},
|
||||
#[serde(rename = "session.started")]
|
||||
SessionStarted { session: SessionRef },
|
||||
#[serde(rename = "session.ended")]
|
||||
SessionEnded { session: SessionRef },
|
||||
#[serde(rename = "stream.started")]
|
||||
StreamStarted { stream: StreamRef },
|
||||
#[serde(rename = "stream.stopped")]
|
||||
StreamStopped { stream: StreamRef },
|
||||
#[serde(rename = "pairing.pending")]
|
||||
PairingPending { device: DeviceRef },
|
||||
#[serde(rename = "pairing.completed")]
|
||||
PairingCompleted { device: DeviceRef },
|
||||
#[serde(rename = "pairing.denied")]
|
||||
PairingDenied { device: DeviceRef },
|
||||
#[serde(rename = "display.created")]
|
||||
DisplayCreated {
|
||||
/// The virtual-display backend that minted it (`VirtualDisplay::name`).
|
||||
backend: String,
|
||||
/// `WxH@Hz`.
|
||||
mode: String,
|
||||
},
|
||||
#[serde(rename = "display.released")]
|
||||
DisplayReleased {
|
||||
/// How many kept displays this release retired.
|
||||
count: u32,
|
||||
},
|
||||
#[serde(rename = "library.changed")]
|
||||
LibraryChanged {
|
||||
/// What mutated the library: `"manual"` today; a provider id once the provider
|
||||
/// API (RFC §8) lands.
|
||||
source: String,
|
||||
},
|
||||
#[serde(rename = "host.started")]
|
||||
HostStarted {
|
||||
version: String,
|
||||
/// Whether the GameStream/Moonlight compat plane is enabled.
|
||||
gamestream: bool,
|
||||
},
|
||||
#[serde(rename = "host.stopping")]
|
||||
HostStopping,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
/// The wire kind string (`"stream.started"`, …) — for filters and log lines.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
EventKind::ClientConnected { .. } => "client.connected",
|
||||
EventKind::ClientDisconnected { .. } => "client.disconnected",
|
||||
EventKind::SessionStarted { .. } => "session.started",
|
||||
EventKind::SessionEnded { .. } => "session.ended",
|
||||
EventKind::StreamStarted { .. } => "stream.started",
|
||||
EventKind::StreamStopped { .. } => "stream.stopped",
|
||||
EventKind::PairingPending { .. } => "pairing.pending",
|
||||
EventKind::PairingCompleted { .. } => "pairing.completed",
|
||||
EventKind::PairingDenied { .. } => "pairing.denied",
|
||||
EventKind::DisplayCreated { .. } => "display.created",
|
||||
EventKind::DisplayReleased { .. } => "display.released",
|
||||
EventKind::LibraryChanged { .. } => "library.changed",
|
||||
EventKind::HostStarted { .. } => "host.started",
|
||||
EventKind::HostStopping => "host.stopping",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a mode as the wire's `WxH@Hz` string.
|
||||
pub fn mode_str(width: u32, height: u32, hz: u32) -> String {
|
||||
format!("{width}x{height}@{hz}")
|
||||
}
|
||||
|
||||
/// One consumer's view: the ring-backed catch-up plus a live-tail receiver, taken atomically
|
||||
/// (no event can fall between `catch_up` and the first `rx.recv()`, and none is in both).
|
||||
pub struct Subscription {
|
||||
/// Events with `seq > since`, oldest first.
|
||||
pub catch_up: Vec<HostEvent>,
|
||||
/// True when events between `since` and the first caught-up one were already evicted —
|
||||
/// the consumer should resync via the REST snapshots (the `LogPage.dropped` contract).
|
||||
pub dropped: bool,
|
||||
/// The live tail. A consumer that can't keep up sees `RecvError::Lagged`.
|
||||
pub rx: broadcast::Receiver<HostEvent>,
|
||||
}
|
||||
|
||||
/// The process-wide event bus: a bounded seq-numbered ring (catch-up) + a broadcast channel
|
||||
/// (live tail).
|
||||
pub struct EventBus {
|
||||
inner: Mutex<Ring>,
|
||||
tx: broadcast::Sender<HostEvent>,
|
||||
}
|
||||
|
||||
struct Ring {
|
||||
events: VecDeque<HostEvent>,
|
||||
next_seq: u64,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
fn new() -> Self {
|
||||
let (tx, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||
Self {
|
||||
inner: Mutex::new(Ring {
|
||||
events: VecDeque::with_capacity(RING_CAPACITY),
|
||||
next_seq: 1,
|
||||
}),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp, ring-buffer, and broadcast one event. Fire-and-forget: no receivers is fine
|
||||
/// (the ring still records it for a later subscriber's catch-up).
|
||||
pub fn emit(&self, kind: EventKind) {
|
||||
let ts_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let mut ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let ev = HostEvent {
|
||||
seq: ring.next_seq,
|
||||
ts_ms,
|
||||
schema: SCHEMA_VERSION,
|
||||
kind,
|
||||
};
|
||||
ring.next_seq += 1;
|
||||
if ring.events.len() == RING_CAPACITY {
|
||||
ring.events.pop_front();
|
||||
}
|
||||
ring.events.push_back(ev.clone());
|
||||
// Send while still holding the ring lock: it serializes with `subscribe` (which also
|
||||
// takes the lock), so an event lands either in a subscriber's catch-up or on its live
|
||||
// tail — never both, never neither. `send` is non-blocking, the hold is trivial.
|
||||
let _ = self.tx.send(ev);
|
||||
}
|
||||
|
||||
/// Subscribe with a resume cursor: events with `seq > since` come back as catch-up, the
|
||||
/// returned receiver carries everything after. `since = 0` means "from the ring start".
|
||||
pub fn subscribe(&self, since: u64) -> Subscription {
|
||||
let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let rx = self.tx.subscribe();
|
||||
let first_seq = ring.events.front().map_or(ring.next_seq, |e| e.seq);
|
||||
let dropped = since != 0 && since + 1 < first_seq;
|
||||
let catch_up = ring
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| e.seq > since)
|
||||
.cloned()
|
||||
.collect();
|
||||
Subscription {
|
||||
catch_up,
|
||||
dropped,
|
||||
rx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide bus — a `OnceLock` singleton (the [`crate::log_capture::ring`] shape) so
|
||||
/// fire sites across both planes and the API layer share it without threading an `Arc`.
|
||||
pub fn bus() -> &'static EventBus {
|
||||
static BUS: OnceLock<EventBus> = OnceLock::new();
|
||||
BUS.get_or_init(EventBus::new)
|
||||
}
|
||||
|
||||
/// Emit one lifecycle event on the process-wide bus. Cheap and non-blocking — safe from any
|
||||
/// thread, including RAII `Drop` paths.
|
||||
pub fn emit(kind: EventKind) {
|
||||
bus().emit(kind);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ev(name: &str) -> EventKind {
|
||||
EventKind::LibraryChanged {
|
||||
source: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seq_is_monotonic_and_catch_up_resumes() {
|
||||
let bus = EventBus::new();
|
||||
for i in 0..5 {
|
||||
bus.emit(ev(&format!("m{i}")));
|
||||
}
|
||||
let sub = bus.subscribe(0);
|
||||
assert_eq!(
|
||||
sub.catch_up.iter().map(|e| e.seq).collect::<Vec<_>>(),
|
||||
vec![1, 2, 3, 4, 5]
|
||||
);
|
||||
assert!(!sub.dropped);
|
||||
assert!(sub.catch_up.iter().all(|e| e.schema == SCHEMA_VERSION));
|
||||
|
||||
// Resume from a cursor mid-ring.
|
||||
let sub = bus.subscribe(3);
|
||||
assert_eq!(
|
||||
sub.catch_up.iter().map(|e| e.seq).collect::<Vec<_>>(),
|
||||
vec![4, 5]
|
||||
);
|
||||
assert!(!sub.dropped);
|
||||
|
||||
// Cursor at the tip: empty catch-up, not a gap.
|
||||
let sub = bus.subscribe(5);
|
||||
assert!(sub.catch_up.is_empty());
|
||||
assert!(!sub.dropped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_reports_dropped() {
|
||||
let bus = EventBus::new();
|
||||
for i in 0..(RING_CAPACITY + 50) {
|
||||
bus.emit(ev(&format!("m{i}")));
|
||||
}
|
||||
// Seqs 1..=50 were evicted; a cursor inside the gap must flag it.
|
||||
let sub = bus.subscribe(10);
|
||||
assert!(sub.dropped);
|
||||
assert_eq!(sub.catch_up.first().map(|e| e.seq), Some(51));
|
||||
// A fresh consumer (since = 0) is a backfill, not a gap.
|
||||
let sub = bus.subscribe(0);
|
||||
assert!(!sub.dropped);
|
||||
assert_eq!(sub.catch_up.len(), RING_CAPACITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_tail_continues_exactly_after_catch_up() {
|
||||
let bus = EventBus::new();
|
||||
bus.emit(ev("before-1"));
|
||||
bus.emit(ev("before-2"));
|
||||
let mut sub = bus.subscribe(0);
|
||||
assert_eq!(sub.catch_up.len(), 2);
|
||||
// Emitted after subscribe → on the live tail only, starting at exactly seq 3.
|
||||
bus.emit(ev("after"));
|
||||
let live = sub.rx.recv().await.expect("live event");
|
||||
assert_eq!(live.seq, 3);
|
||||
assert_eq!(live.kind.name(), "library.changed");
|
||||
// Nothing duplicated: the tail holds only what wasn't in the catch-up.
|
||||
assert!(sub.rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
/// The wire shape IS the contract (additive-only, RFC §4): these snapshots are the review
|
||||
/// gate — if one fails, the change renames/removes a field and needs a schema-version bump,
|
||||
/// not a test update.
|
||||
#[test]
|
||||
fn wire_shape_snapshots() {
|
||||
let ev = HostEvent {
|
||||
seq: 4182,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
schema: 1,
|
||||
kind: EventKind::StreamStarted {
|
||||
stream: StreamRef {
|
||||
mode: mode_str(3840, 2160, 120),
|
||||
hdr: true,
|
||||
client: "Living Room TV".into(),
|
||||
app: Some("steam:570".into()),
|
||||
plane: Plane::Native,
|
||||
},
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":4182,"ts_ms":1700000000000,"schema":1,"kind":"stream.started","stream":{"mode":"3840x2160@120","hdr":true,"client":"Living Room TV","app":"steam:570","plane":"native"}}"#
|
||||
);
|
||||
|
||||
let ev = HostEvent {
|
||||
seq: 1,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
schema: 1,
|
||||
kind: EventKind::ClientDisconnected {
|
||||
client: ClientRef {
|
||||
name: "Deck".into(),
|
||||
fingerprint: Some("b1c2".into()),
|
||||
plane: Plane::Gamestream,
|
||||
},
|
||||
reason: DisconnectReason::Timeout,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":1,"ts_ms":1700000000000,"schema":1,"kind":"client.disconnected","client":{"name":"Deck","fingerprint":"b1c2","plane":"gamestream"},"reason":"timeout"}"#
|
||||
);
|
||||
|
||||
let ev = HostEvent {
|
||||
seq: 2,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
schema: 1,
|
||||
kind: EventKind::HostStopping,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_shape_roundtrips() {
|
||||
let ev = HostEvent {
|
||||
seq: 7,
|
||||
ts_ms: 3,
|
||||
schema: 1,
|
||||
kind: EventKind::PairingPending {
|
||||
device: DeviceRef {
|
||||
name: "iPad Pro".into(),
|
||||
fingerprint: "ab12".into(),
|
||||
plane: Plane::Native,
|
||||
},
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&ev).unwrap();
|
||||
let back: HostEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.seq, 7);
|
||||
assert_eq!(back.kind.name(), "pairing.pending");
|
||||
match back.kind {
|
||||
EventKind::PairingPending { device } => {
|
||||
assert_eq!(device.name, "iPad Pro");
|
||||
assert_eq!(device.plane, Plane::Native);
|
||||
}
|
||||
other => panic!("wrong kind: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ pub struct GamepadFrame {
|
||||
// These are `pub const` aliases rather than a `pub use` re-export on purpose: on Windows the sole
|
||||
// consumer (the Linux uinput map) is cfg'd out, and an unused re-export lints as an error there,
|
||||
// whereas an unused `pub const` does not. The values still come only from core, so they can't drift;
|
||||
// the exact wire values are pinned by `punktfunk1.rs::gamepad_wire_bits_are_pinned`.
|
||||
// the exact wire values are pinned by `native.rs::gamepad_wire_bits_are_pinned`.
|
||||
use punktfunk_core::input::gamepad as wire;
|
||||
pub const BTN_DPAD_UP: u32 = wire::BTN_DPAD_UP;
|
||||
pub const BTN_DPAD_DOWN: u32 = wire::BTN_DPAD_DOWN;
|
||||
|
||||
@@ -194,13 +194,13 @@ impl AppState {
|
||||
/// #5/#9) — so it is **opt-in** (`serve --gamestream`) and gated on a trusted LAN.
|
||||
pub fn serve(
|
||||
mgmt: crate::mgmt::Options,
|
||||
native: crate::punktfunk1::NativeServe,
|
||||
native: crate::native::NativeServe,
|
||||
gamestream: bool,
|
||||
) -> Result<()> {
|
||||
let host = Host::detect()?;
|
||||
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
||||
// The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop
|
||||
// (via `AppState`), and the native punktfunk/1 loops (passed to `punktfunk1::serve`).
|
||||
// (via `AppState`), and the native punktfunk/1 loops (passed to `native::serve`).
|
||||
let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir());
|
||||
let state = Arc::new(AppState::new(host, identity, stats.clone()));
|
||||
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
|
||||
@@ -241,8 +241,15 @@ pub fn serve(
|
||||
rt.block_on(async move {
|
||||
// rustls needs a process-wide crypto provider before any TLS config is built.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let native_opts = crate::punktfunk1::native_serve_opts(&native);
|
||||
if gamestream {
|
||||
let native_opts = crate::native::native_serve_opts(&native);
|
||||
// Lifecycle events (RFC §4): `host.started` as the serve planes come up; `host.stopping`
|
||||
// when they wind down (clean end OR error exit) — the ring holds it for a consumer that
|
||||
// reconnects, and a graceful-signal path can move the emit earlier when one exists.
|
||||
crate::events::emit(crate::events::EventKind::HostStarted {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
gamestream,
|
||||
});
|
||||
let served: anyhow::Result<()> = if gamestream {
|
||||
// Unified host: GameStream compat planes + native + mgmt. The `_nvstream` advert is
|
||||
// fatal on failure when enabled (Moonlight clients can't find the host without it) —
|
||||
// `--no-mdns` / PUNKTFUNK_MDNS=0 skips it for multicast-dead environments (stock
|
||||
@@ -270,8 +277,9 @@ pub fn serve(
|
||||
stats.clone(),
|
||||
gamestream
|
||||
),
|
||||
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
|
||||
)?;
|
||||
crate::native::serve(native_opts, native.mgmt_port, np, stats.clone()),
|
||||
)
|
||||
.map(|_| ())
|
||||
} else {
|
||||
// Secure default: native punktfunk/1 + management API only (no GameStream surface).
|
||||
tracing::info!(
|
||||
@@ -287,10 +295,12 @@ pub fn serve(
|
||||
stats.clone(),
|
||||
gamestream
|
||||
),
|
||||
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
crate::native::serve(native_opts, native.mgmt_port, np, stats.clone()),
|
||||
)
|
||||
.map(|_| ())
|
||||
};
|
||||
crate::events::emit(crate::events::EventKind::HostStopping);
|
||||
served
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,15 @@ impl Pairing {
|
||||
super::save_paired(&store);
|
||||
}
|
||||
tracing::info!(uniqueid, "pairing phase 4 complete — client cert pinned");
|
||||
// Lifecycle event, plane parity with `NativePairing::add` (RFC §4). GameStream
|
||||
// pairing has no device name — the client's uniqueid is the identity it presents.
|
||||
crate::events::emit(crate::events::EventKind::PairingCompleted {
|
||||
device: crate::events::DeviceRef {
|
||||
name: uniqueid.to_string(),
|
||||
fingerprint: hex::encode(crypto::sha256(&[s.client_cert_der.as_slice()])),
|
||||
plane: crate::events::Plane::Gamestream,
|
||||
},
|
||||
});
|
||||
Ok(paired_xml("", true))
|
||||
} else {
|
||||
tracing::warn!(
|
||||
|
||||
@@ -58,9 +58,31 @@ pub fn start(
|
||||
.spawn(move || {
|
||||
// Same scheduling posture as the native path's capture/encode thread (Linux nice -10 /
|
||||
// Windows HIGHEST + session tuning) — GameStream previously ran unboosted on Linux.
|
||||
crate::punktfunk1::boost_thread_priority(true);
|
||||
crate::native::boost_thread_priority(true);
|
||||
tracing::info!(?cfg, "video stream starting");
|
||||
if let Err(e) = run(
|
||||
// Lifecycle events, plane parity with the native loop (RFC §4): the RTSP layer
|
||||
// carries no client device name, so `client` is empty here — the `plane` field is
|
||||
// what hooks key on. `client.connected` fires alongside `stream.started` because a
|
||||
// Moonlight client has no persistent connection to anchor it to.
|
||||
let event_stream = crate::events::StreamRef {
|
||||
mode: crate::events::mode_str(cfg.width, cfg.height, cfg.fps),
|
||||
hdr: cfg.hdr,
|
||||
client: String::new(),
|
||||
app: app.as_ref().map(|a| a.title.clone()),
|
||||
plane: crate::events::Plane::Gamestream,
|
||||
};
|
||||
let event_client = crate::events::ClientRef {
|
||||
name: String::new(),
|
||||
fingerprint: None,
|
||||
plane: crate::events::Plane::Gamestream,
|
||||
};
|
||||
crate::events::emit(crate::events::EventKind::StreamStarted {
|
||||
stream: event_stream.clone(),
|
||||
});
|
||||
crate::events::emit(crate::events::EventKind::ClientConnected {
|
||||
client: event_client.clone(),
|
||||
});
|
||||
let result = run(
|
||||
cfg,
|
||||
app.as_ref(),
|
||||
&running,
|
||||
@@ -68,10 +90,25 @@ pub fn start(
|
||||
&rfi_range,
|
||||
&video_cap,
|
||||
&stats,
|
||||
) {
|
||||
);
|
||||
// A clean return is a stop (RTSP teardown / cancel / client unreachable) → `quit`;
|
||||
// an error return is `error`. The compat plane can't tell a user stop from an idle
|
||||
// vanish the way the native plane's typed close code can.
|
||||
let reason = match &result {
|
||||
Ok(()) => crate::events::DisconnectReason::Quit,
|
||||
Err(_) => crate::events::DisconnectReason::Error,
|
||||
};
|
||||
if let Err(e) = result {
|
||||
tracing::error!(error = %format!("{e:#}"), "video stream failed");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
crate::events::emit(crate::events::EventKind::StreamStopped {
|
||||
stream: event_stream,
|
||||
});
|
||||
crate::events::emit(crate::events::EventKind::ClientDisconnected {
|
||||
client: event_client,
|
||||
reason,
|
||||
});
|
||||
tracing::info!("video stream stopped");
|
||||
});
|
||||
}
|
||||
@@ -227,7 +264,7 @@ fn run(
|
||||
|
||||
/// Open the virtual-display video source for a GameStream session: pick the LIVE compositor + normalize
|
||||
/// the session env (apply_session_env/apply_input_env — gamescope ATTACH/resize, KWin/Mutter
|
||||
/// retargeting) exactly like the native plane (punktfunk1.rs resolve_compositor), create a virtual
|
||||
/// retargeting) exactly like the native plane (native.rs resolve_compositor), create a virtual
|
||||
/// output at the client's mode, and capture it. Returns the capturer (it owns the output's keepalive;
|
||||
/// the stateless VirtualDisplay factory is dropped here) plus the resolved compositor. An apps.json
|
||||
/// entry can PIN a compositor (skips the live detect/retarget). Re-run on a mid-stream capture loss to
|
||||
@@ -242,7 +279,7 @@ fn open_gs_virtual_source(
|
||||
} else {
|
||||
// Windows has a single virtual-display backend (pf-vdisplay); `vdisplay::open` ignores the
|
||||
// compositor arg there, so short-circuit the Linux session-detection state machine with a
|
||||
// placeholder — mirrors `punktfunk1::resolve_compositor`. Without this, the Linux `detect()`
|
||||
// placeholder — mirrors `native::resolve_compositor`. Without this, the Linux `detect()`
|
||||
// below bails on Windows ("could not detect compositor … XDG_CURRENT_DESKTOP=''"), which
|
||||
// killed the GameStream video thread → black screen (the native plane was already guarded).
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -460,7 +497,7 @@ fn spawn_packetizer(
|
||||
.name("punktfunk-pkt".into())
|
||||
.spawn(move || {
|
||||
// Above-normal, like the send thread — this stage is on the per-frame critical path.
|
||||
crate::punktfunk1::boost_thread_priority(false);
|
||||
crate::native::boost_thread_priority(false);
|
||||
while let Ok(frame) = rx.recv() {
|
||||
let mut batch: PacketBatch = Vec::new();
|
||||
for (au, ft, idx) in frame.aus {
|
||||
@@ -498,7 +535,7 @@ fn spawn_sender(
|
||||
.spawn(move || {
|
||||
// Transmit thread: above-normal, matching the native path's send thread (includes the
|
||||
// Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5).
|
||||
crate::punktfunk1::boost_thread_priority(false);
|
||||
crate::native::boost_thread_priority(false);
|
||||
let budget = frame_interval.mul_f32(0.75);
|
||||
let cfg = crate::send_pacing::PaceCfg {
|
||||
burst_bytes: None, // no microburst stage — the whole frame spreads
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
//! Artwork cache + background warmer: the on-disk poster cache, the per-store fetchers, and the
|
||||
//! `fetch_box_art` dispatch the management art proxy serves from. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The persisted art cache: GameEntry id → resolved [`Artwork`]. An entry's PRESENCE means "already
|
||||
/// resolved" (even an empty Artwork = fetched, none found) so the warmer never re-fetches it.
|
||||
fn art_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Artwork>> {
|
||||
static CACHE: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, Artwork>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
CACHE.get_or_init(|| {
|
||||
let loaded = std::fs::read_to_string(art_cache_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
std::sync::Mutex::new(loaded)
|
||||
})
|
||||
}
|
||||
|
||||
/// The art cache lives in the canonical HOST config dir (`%ProgramData%\punktfunk` on Windows /
|
||||
/// `~/.config/punktfunk` on Linux — gamestream::config_dir, NOT the legacy XDG/HOME `config_dir`
|
||||
/// below that the custom store still uses).
|
||||
fn art_cache_path() -> PathBuf {
|
||||
crate::gamestream::config_dir().join("library-art-cache.json")
|
||||
}
|
||||
|
||||
/// The cached art for a library id, if it has been resolved (positive or negative). `None` = not yet
|
||||
/// warmed → the provider shows title-only until the warmer fills it in.
|
||||
pub(crate) fn cached_art(id: &str) -> Option<Artwork> {
|
||||
art_cache().lock().unwrap().get(id).cloned()
|
||||
}
|
||||
|
||||
/// Record resolved art for a library id + persist the cache (write-then-rename; best-effort).
|
||||
fn store_art(id: &str, art: Artwork) {
|
||||
let mut cache = art_cache().lock().unwrap();
|
||||
cache.insert(id.to_string(), art);
|
||||
if let Ok(json) = serde_json::to_string(&*cache) {
|
||||
let path = art_cache_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, json).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the host-lifetime cover-art warmer: every few minutes, fetch + cache art for any library
|
||||
/// entry whose store needs a network lookup (GOG / Xbox) and isn't cached yet. Idempotent — once
|
||||
/// everything is cached a pass makes no network calls (and a host with only self-art stores never
|
||||
/// fetches at all). Call once from `serve()`; the returned handle can be dropped to detach it.
|
||||
pub fn start_art_warmer() -> std::thread::JoinHandle<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("pf-art-warmer".into())
|
||||
.spawn(|| loop {
|
||||
warm_art_once();
|
||||
std::thread::sleep(std::time::Duration::from_secs(300));
|
||||
})
|
||||
.expect("spawn art warmer thread")
|
||||
}
|
||||
|
||||
/// One warming pass: resolve uncached GOG/Xbox art. Other stores carry their own art (Steam CDN
|
||||
/// template, Heroic CDN URLs, Lutris data: URLs, custom user URLs) and are skipped.
|
||||
fn warm_art_once() {
|
||||
for g in all_games() {
|
||||
if cached_art(&g.id).is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some((store, localid)) = g.id.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let art = match store {
|
||||
"gog" => fetch_gog_art(localid),
|
||||
// The xbox id is the StoreId when present, else the PFN (contains '_', no displaycatalog
|
||||
// entry) → cache empty for those so they aren't retried every pass.
|
||||
"xbox" if !localid.contains('_') => fetch_xbox_art(localid),
|
||||
"xbox" => Artwork::default(),
|
||||
_ => continue, // steam/heroic/lutris/custom resolve their own art
|
||||
};
|
||||
store_art(&g.id, art);
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP GET + parse JSON with a bounded timeout. `None` on any network/parse failure (best-effort —
|
||||
/// art is non-essential, so a failure just leaves the title-only card).
|
||||
fn fetch_json(url: &str) -> Option<serde_json::Value> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build();
|
||||
let body = agent.get(url).call().ok()?.into_string().ok()?;
|
||||
serde_json::from_str(&body).ok()
|
||||
}
|
||||
|
||||
/// Fetch one image URL for the GameStream `/appasset` cover proxy, as `(bytes, content-type)`. Handles
|
||||
/// `data:` URLs (Lutris inlines art that way) by decoding inline, and `http(s)` URLs by a bounded GET
|
||||
/// (8 MiB cap so a hostile/huge art URL can't balloon host memory). `None` on any non-image scheme,
|
||||
/// network/decoder error, or empty body. Blocking (ureq) — call off the async runtime.
|
||||
pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
||||
use base64::Engine as _;
|
||||
use std::io::Read as _;
|
||||
if let Some(rest) = url.strip_prefix("data:") {
|
||||
// data:[<mediatype>][;base64],<payload>
|
||||
let (meta, data) = rest.split_once(',')?;
|
||||
let ctype = meta
|
||||
.split(';')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("image/jpeg")
|
||||
.to_string();
|
||||
let bytes = if meta.contains(";base64") {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(data)
|
||||
.ok()?
|
||||
} else {
|
||||
data.as_bytes().to_vec()
|
||||
};
|
||||
return (!bytes.is_empty()).then_some((bytes, ctype));
|
||||
}
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
return None;
|
||||
}
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build();
|
||||
let resp = agent.get(url).call().ok()?;
|
||||
let ctype = resp
|
||||
.header("Content-Type")
|
||||
.unwrap_or("image/jpeg")
|
||||
.to_string();
|
||||
let mut bytes = Vec::new();
|
||||
resp.into_reader()
|
||||
.take(8 * 1024 * 1024)
|
||||
.read_to_end(&mut bytes)
|
||||
.ok()?;
|
||||
(!bytes.is_empty()).then_some((bytes, ctype))
|
||||
}
|
||||
|
||||
/// Resolve + fetch the best box-art cover for a library id (the GameStream `/appasset` proxy — Moonlight
|
||||
/// fetches per-app covers from the HOST, not the CDN, so we proxy the bytes). Tries the portrait (tall
|
||||
/// capsule Moonlight wants) → header → hero → logo, returning the first that fetches as
|
||||
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
|
||||
/// async runtime (e.g. `spawn_blocking`).
|
||||
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
|
||||
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
|
||||
// those kinds directly instead of going through the URL fields.
|
||||
if let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
{
|
||||
return [
|
||||
ArtKind::Portrait,
|
||||
ArtKind::Header,
|
||||
ArtKind::Hero,
|
||||
ArtKind::Logo,
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|kind| steam_art_bytes(appid, kind));
|
||||
}
|
||||
let g = all_games().into_iter().find(|g| g.id == id)?;
|
||||
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|url| fetch_image(&url))
|
||||
}
|
||||
|
||||
/// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https.
|
||||
fn abs_url(u: &str) -> String {
|
||||
u.strip_prefix("//")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
.unwrap_or_else(|| u.to_string())
|
||||
}
|
||||
|
||||
/// GOG cover art via the public (no-auth) product API. Field names / URL shapes are GOG-specific and
|
||||
/// best-effort (worth on-box confirmation); a wrong URL just degrades to the title card client-side.
|
||||
fn fetch_gog_art(product_id: &str) -> Artwork {
|
||||
let Some(v) = fetch_json(&format!(
|
||||
"https://api.gog.com/products/{product_id}?expand=images"
|
||||
)) else {
|
||||
return Artwork::default();
|
||||
};
|
||||
let img = |k: &str| {
|
||||
v.get("images")
|
||||
.and_then(|i| i.get(k))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(abs_url)
|
||||
};
|
||||
Artwork {
|
||||
portrait: img("verticalCover"),
|
||||
hero: img("background"),
|
||||
logo: img("logo2x"),
|
||||
header: img("logo"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Xbox cover art via the (unofficial, no-auth) Microsoft display catalog, keyed by StoreId. Best-
|
||||
/// effort: the endpoint is internal/unstable, so on drift this just yields no art (title-only).
|
||||
fn fetch_xbox_art(store_id: &str) -> Artwork {
|
||||
let Some(v) = fetch_json(&format!(
|
||||
"https://displaycatalog.mp.microsoft.com/v7.0/products/{store_id}?market=US&languages=en-us&fieldsTemplate=Details"
|
||||
)) else {
|
||||
return Artwork::default();
|
||||
};
|
||||
let images = v
|
||||
.get("Products")
|
||||
.and_then(|p| p.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|p| p.get("LocalizedProperties"))
|
||||
.and_then(|l| l.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|lp| lp.get("Images"))
|
||||
.and_then(|i| i.as_array());
|
||||
let mut art = Artwork::default();
|
||||
for img in images.into_iter().flatten() {
|
||||
let (Some(purpose), Some(uri)) = (
|
||||
img.get("ImagePurpose").and_then(|v| v.as_str()),
|
||||
img.get("Uri").and_then(|v| v.as_str()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let url = abs_url(uri);
|
||||
match purpose {
|
||||
"Poster" => art.portrait = Some(url),
|
||||
"SuperHeroArt" | "Hero" => art.hero = Some(url),
|
||||
"Logo" => art.logo = Some(url),
|
||||
"BoxArt" => art.header = Some(url),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
art
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn art_kind_parses_known_names_only() {
|
||||
assert_eq!(ArtKind::parse("portrait"), Some(ArtKind::Portrait));
|
||||
assert_eq!(ArtKind::parse("hero"), Some(ArtKind::Hero));
|
||||
assert_eq!(ArtKind::parse("logo"), Some(ArtKind::Logo));
|
||||
assert_eq!(ArtKind::parse("header"), Some(ArtKind::Header));
|
||||
assert_eq!(ArtKind::parse("background"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_image_decodes_data_url() {
|
||||
// "Hi" base64 == "SGk=" — the data: branch is pure (no network), so it's deterministic.
|
||||
let (bytes, ctype) = fetch_image("data:image/png;base64,SGk=").expect("data url decodes");
|
||||
assert_eq!(bytes, b"Hi");
|
||||
assert_eq!(ctype, "image/png");
|
||||
// A non-image scheme is rejected (no launcher art ever points at file://, but be defensive).
|
||||
assert!(fetch_image("file:///etc/passwd").is_none());
|
||||
// Empty payload → None (never serve a 0-byte cover).
|
||||
assert!(fetch_image("data:image/png;base64,").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! User-curated custom store: CRUD (add/update/delete) over the persisted custom entries the web
|
||||
//! console manages, and their mapping onto the uniform `GameEntry`. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API
|
||||
/// returns and the web console edits.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CustomEntry {
|
||||
/// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub art: Artwork,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub launch: Option<LaunchSpec>,
|
||||
}
|
||||
|
||||
/// Request body to create or replace a custom entry (no `id` — the host owns it).
|
||||
#[derive(Clone, Debug, Deserialize, ToSchema)]
|
||||
pub struct CustomInput {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub art: Artwork,
|
||||
#[serde(default)]
|
||||
pub launch: Option<LaunchSpec>,
|
||||
}
|
||||
|
||||
impl From<CustomEntry> for GameEntry {
|
||||
fn from(c: CustomEntry) -> Self {
|
||||
GameEntry {
|
||||
id: format!("custom:{}", c.id),
|
||||
store: "custom".into(),
|
||||
title: c.title,
|
||||
art: c.art,
|
||||
launch: c.launch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn config_dir() -> PathBuf {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("punktfunk")
|
||||
}
|
||||
|
||||
fn custom_path() -> PathBuf {
|
||||
config_dir().join("library.json")
|
||||
}
|
||||
|
||||
/// Load the custom entries (empty + non-fatal if the file is absent or malformed).
|
||||
pub fn load_custom() -> Vec<CustomEntry> {
|
||||
match std::fs::read_to_string(custom_path()) {
|
||||
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
|
||||
Vec::new()
|
||||
}),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
|
||||
let dir = config_dir();
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
let json = serde_json::to_string_pretty(entries)?;
|
||||
// Write-then-rename so a crash mid-write never truncates the catalog.
|
||||
let tmp = custom_path().with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, custom_path()).context("rename library.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 12 hex chars from the title + wall-clock nanos — collision-free in practice, no uuid dep.
|
||||
fn new_id(title: &str) -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
|
||||
}
|
||||
|
||||
/// Create a custom entry, returning it with its assigned id.
|
||||
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||
let mut entries = load_custom();
|
||||
let entry = CustomEntry {
|
||||
id: new_id(&input.title),
|
||||
title: input.title,
|
||||
art: input.art,
|
||||
launch: input.launch,
|
||||
};
|
||||
entries.push(entry.clone());
|
||||
save_custom(&entries)?;
|
||||
emit_changed();
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Replace a custom entry's fields (id preserved). `None` ⇒ no entry with that id.
|
||||
pub fn update_custom(id: &str, input: CustomInput) -> Result<Option<CustomEntry>> {
|
||||
let mut entries = load_custom();
|
||||
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
slot.title = input.title;
|
||||
slot.art = input.art;
|
||||
slot.launch = input.launch;
|
||||
let updated = slot.clone();
|
||||
save_custom(&entries)?;
|
||||
emit_changed();
|
||||
Ok(Some(updated))
|
||||
}
|
||||
|
||||
/// Delete a custom entry. `false` ⇒ no entry with that id.
|
||||
pub fn delete_custom(id: &str) -> Result<bool> {
|
||||
let mut entries = load_custom();
|
||||
let before = entries.len();
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() == before {
|
||||
return Ok(false);
|
||||
}
|
||||
save_custom(&entries)?;
|
||||
emit_changed();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The custom-entry mutations are the only library writes today, all operator-driven — hence
|
||||
/// `source: "manual"` (RFC §4; a provider id once the provider API of RFC §8 lands).
|
||||
fn emit_changed() {
|
||||
crate::events::emit(crate::events::EventKind::LibraryChanged {
|
||||
source: "manual".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
|
||||
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
|
||||
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
|
||||
/// ([`windows_launch_for`]).
|
||||
pub(crate) fn valid_steam_appid(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_entry_maps_to_game_entry() {
|
||||
let g: GameEntry = CustomEntry {
|
||||
id: "abc123".into(),
|
||||
title: "My ROM".into(),
|
||||
art: Artwork::default(),
|
||||
launch: None,
|
||||
}
|
||||
.into();
|
||||
assert_eq!(g.id, "custom:abc123");
|
||||
assert_eq!(g.store, "custom");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Epic Games Store provider: installed manifests + the catalog-cache art index + launch URIs. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Reads the Epic Games Launcher's local install manifests. Windows-only. Best-effort: empty when
|
||||
/// the launcher (or its manifest dir) isn't present.
|
||||
#[cfg(windows)]
|
||||
pub struct EpicProvider;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl LibraryProvider for EpicProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"epic"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let data = epic_data_dir();
|
||||
let Ok(rd) = std::fs::read_dir(data.join("Manifests")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Parse the (best-effort) artwork cache ONCE: catalogItemId -> Artwork.
|
||||
let art = epic_art_index(&data.join("Catalog").join("catcache.bin"));
|
||||
let mut games = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
if p.extension().and_then(|e| e.to_str()) != Some("item") {
|
||||
continue;
|
||||
}
|
||||
// `.item` manifests are small JSON; cap the read so a planted giant can't OOM the host.
|
||||
let Some(bytes) = read_capped(&p, 1024 * 1024) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(g) = epic_entry(&v, &art) {
|
||||
games.push(g);
|
||||
}
|
||||
}
|
||||
games
|
||||
}
|
||||
}
|
||||
|
||||
/// `%ProgramData%\Epic\EpicGamesLauncher\Data` (machine-wide, SYSTEM-readable).
|
||||
#[cfg(windows)]
|
||||
fn epic_data_dir() -> PathBuf {
|
||||
std::env::var_os("ProgramData")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
||||
.join("Epic")
|
||||
.join("EpicGamesLauncher")
|
||||
.join("Data")
|
||||
}
|
||||
|
||||
/// Map one `.item` manifest to a [`GameEntry`], or `None` if it isn't a launchable game. Uses
|
||||
/// Playnite's proven EXCLUSION filter (skip `UE_*` Unreal components; skip a DLC/addon unless it is
|
||||
/// `addons/launchable`) rather than a positive `games`-category match, which can drop legit titles.
|
||||
#[cfg(windows)]
|
||||
fn epic_entry(
|
||||
v: &serde_json::Value,
|
||||
art: &std::collections::HashMap<String, Artwork>,
|
||||
) -> Option<GameEntry> {
|
||||
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
|
||||
let app_name = s("AppName")?.to_string();
|
||||
if app_name.starts_with("UE_") {
|
||||
return None; // Unreal Engine component, not a game
|
||||
}
|
||||
let cats: Vec<&str> = v
|
||||
.get("AppCategories")
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|a| a.iter().filter_map(|x| x.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
if cats.contains(&"addons") && !cats.contains(&"addons/launchable") {
|
||||
return None; // non-launchable DLC/addon
|
||||
}
|
||||
// Drop stale records whose install dir is gone.
|
||||
let install = s("InstallLocation")?;
|
||||
if !Path::new(install).is_dir() {
|
||||
return None;
|
||||
}
|
||||
let title = s("DisplayName").unwrap_or(&app_name).to_string();
|
||||
let namespace = s("CatalogNamespace").unwrap_or("");
|
||||
let catalog = s("CatalogItemId").unwrap_or("");
|
||||
// The robust launch form is the namespace:catalogItemId:appName triple; fall back to the bare
|
||||
// appName when those ids are absent (some manifests lack them) — never drop the launch entirely.
|
||||
let value = if !namespace.is_empty() && !catalog.is_empty() {
|
||||
format!("{namespace}:{catalog}:{app_name}")
|
||||
} else {
|
||||
app_name.clone()
|
||||
};
|
||||
Some(GameEntry {
|
||||
id: format!("epic:{app_name}"),
|
||||
store: "epic".into(),
|
||||
title,
|
||||
art: art.get(catalog).cloned().unwrap_or_default(),
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "epic".into(),
|
||||
value,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a launcher cache/manifest with a hard size cap, so a local unprivileged user can't plant a
|
||||
/// multi-GB file under the launcher's (Users-writable) data dir that OOMs the privileged host when
|
||||
/// it's loaded — then base64/JSON-decoded into further copies — during library enumeration
|
||||
/// (security-review 2026-06-28 S4). Returns `None` if missing, empty, or over `max`. Mirrors the
|
||||
/// Linux lutris-art reader's 1 MiB cap.
|
||||
#[cfg(windows)]
|
||||
fn read_capped(path: &Path, max: u64) -> Option<Vec<u8>> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if meta.len() == 0 || meta.len() > max {
|
||||
if meta.len() > max {
|
||||
tracing::warn!(path = %path.display(), len = meta.len(), max, "launcher cache exceeds size cap — skipping");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
std::fs::read(path).ok()
|
||||
}
|
||||
|
||||
/// Best-effort parse of `catcache.bin` (base64-encoded JSON array of catalog items) into
|
||||
/// catalogItemId → [`Artwork`] from each item's `keyImages`. Empty map on any read/decode failure
|
||||
/// (the format is community-reverse-engineered + can lag a fresh install → titles just show no art).
|
||||
#[cfg(windows)]
|
||||
fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork> {
|
||||
use base64::Engine as _;
|
||||
let mut map = std::collections::HashMap::new();
|
||||
// 32 MiB cap: comfortably fits a real catalog cache, blocks a planted giant (S4).
|
||||
let Some(raw) = read_capped(catcache, 32 * 1024 * 1024) else {
|
||||
return map;
|
||||
};
|
||||
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw) else {
|
||||
return map;
|
||||
};
|
||||
let Ok(items) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
|
||||
return map;
|
||||
};
|
||||
let Some(arr) = items.as_array() else {
|
||||
return map;
|
||||
};
|
||||
for item in arr {
|
||||
let Some(cat) = item
|
||||
.get("id")
|
||||
.or_else(|| item.get("catalogItemId"))
|
||||
.and_then(|v| v.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(images) = item.get("keyImages").and_then(|v| v.as_array()) else {
|
||||
continue;
|
||||
};
|
||||
let mut art = Artwork::default();
|
||||
for img in images {
|
||||
let (Some(ty), Some(url)) = (
|
||||
img.get("type").and_then(|v| v.as_str()),
|
||||
img.get("url").and_then(|v| v.as_str()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
continue;
|
||||
}
|
||||
match ty {
|
||||
"DieselGameBoxTall" => art.portrait = Some(url.to_string()),
|
||||
"DieselGameBox" => art.hero = Some(url.to_string()),
|
||||
"DieselGameBoxLogo" => art.logo = Some(url.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if art.portrait.is_some() || art.hero.is_some() || art.logo.is_some() {
|
||||
map.insert(cat.to_string(), art);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple
|
||||
/// `<namespace>:<catalogItemId>:<appName>` (colons URL-encoded), or a bare `<appName>` fallback.
|
||||
/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
|
||||
let ok = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
};
|
||||
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
|
||||
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
|
||||
[app] if ok(app) => (*app).to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn epic_filters_and_builds_launch() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-epic-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let inst = dir.to_string_lossy().into_owned();
|
||||
let empty = std::collections::HashMap::new();
|
||||
// Normal game with the full triple → kept, triple launch value.
|
||||
let game = serde_json::json!({
|
||||
"AppName": "Fortnite", "DisplayName": "Fortnite", "CatalogNamespace": "fn",
|
||||
"CatalogItemId": "abc123", "InstallLocation": inst.clone(),
|
||||
"AppCategories": ["public", "games", "applications"]
|
||||
});
|
||||
let e = epic_entry(&game, &empty).expect("game kept");
|
||||
assert_eq!(e.id, "epic:Fortnite");
|
||||
assert_eq!(e.launch.as_ref().unwrap().value, "fn:abc123:Fortnite");
|
||||
// UE component, non-launchable addon, and a missing install dir are all skipped.
|
||||
let ue = serde_json::json!({"AppName":"UE_5.3","InstallLocation":inst.clone(),"AppCategories":["engines"]});
|
||||
assert!(epic_entry(&ue, &empty).is_none());
|
||||
let dlc =
|
||||
serde_json::json!({"AppName":"DLC","InstallLocation":inst,"AppCategories":["addons"]});
|
||||
assert!(epic_entry(&dlc, &empty).is_none());
|
||||
let gone = serde_json::json!({"AppName":"Gone","InstallLocation":"C:\\nope-xyz","AppCategories":["games"]});
|
||||
assert!(epic_entry(&gone, &empty).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn epic_launch_uri_triple_bare_and_guard() {
|
||||
assert_eq!(
|
||||
epic_launch_uri("fn:abc:Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
|
||||
);
|
||||
assert_eq!(
|
||||
epic_launch_uri("Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
|
||||
);
|
||||
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
|
||||
assert!(epic_launch_uri("").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! GOG Galaxy store provider: installed games from the Galaxy DB + play-task launch resolution. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::art::cached_art;
|
||||
use super::*;
|
||||
|
||||
/// Reads the GOG.com install registry + per-game `.info` files. Windows-only. Best-effort: empty
|
||||
/// when GOG isn't installed.
|
||||
#[cfg(windows)]
|
||||
pub struct GogProvider;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl LibraryProvider for GogProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"gog"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
gog_games()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn gog_games() -> Vec<GameEntry> {
|
||||
use winreg::enums::HKEY_LOCAL_MACHINE;
|
||||
use winreg::RegKey;
|
||||
// 32-bit GOG writes under WOW6432Node; a 64-bit process reads the explicit path directly.
|
||||
let Ok(games_key) =
|
||||
RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey("SOFTWARE\\WOW6432Node\\GOG.com\\Games")
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for sub in games_key.enum_keys().flatten() {
|
||||
// The subkey name IS the GOG product id.
|
||||
let Ok(k) = games_key.open_subkey(&sub) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(path) = k.get_value::<String, _>("PATH") else {
|
||||
continue;
|
||||
};
|
||||
if !Path::new(&path).is_dir() {
|
||||
continue;
|
||||
}
|
||||
let title = k
|
||||
.get_value::<String, _>("GAMENAME")
|
||||
.unwrap_or_else(|_| sub.clone());
|
||||
// Resolve the primary play task (exe + args + workdir) from goggame-<id>.info; skip if absent.
|
||||
let Some((exe, args, workdir)) = gog_play_task(&path, &sub) else {
|
||||
continue;
|
||||
};
|
||||
let id = format!("gog:{sub}");
|
||||
// Art (public api.gog.com) is resolved off the hot path by the background warmer; read
|
||||
// whatever it has cached (title-only until warmed).
|
||||
let art = cached_art(&id).unwrap_or_default();
|
||||
out.push(GameEntry {
|
||||
id,
|
||||
store: "gog".into(),
|
||||
title,
|
||||
art,
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "gog".into(),
|
||||
value: format!("{exe}\t{args}\t{workdir}"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The primary play task from `<install>\goggame-<id>.info`: `(absolute exe, args, working dir)`.
|
||||
/// Prefers `isPrimary` + `FileTask`, else the first `FileTask`. Paths are resolved against `install`.
|
||||
#[cfg(windows)]
|
||||
fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
|
||||
let text =
|
||||
std::fs::read_to_string(Path::new(install).join(format!("goggame-{id}.info"))).ok()?;
|
||||
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
let tasks = v.get("playTasks")?.as_array()?;
|
||||
let is_file =
|
||||
|t: &serde_json::Value| t.get("type").and_then(|s| s.as_str()) == Some("FileTask");
|
||||
let pick = tasks
|
||||
.iter()
|
||||
.find(|t| {
|
||||
t.get("isPrimary")
|
||||
.and_then(|b| b.as_bool())
|
||||
.unwrap_or(false)
|
||||
&& is_file(t)
|
||||
})
|
||||
.or_else(|| tasks.iter().find(|t| is_file(t)))?;
|
||||
let rel = pick.get("path").and_then(|s| s.as_str())?;
|
||||
let exe = Path::new(install).join(rel);
|
||||
let args = pick
|
||||
.get("arguments")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let workdir = pick
|
||||
.get("workingDir")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|w| Path::new(install).join(w))
|
||||
.unwrap_or_else(|| Path::new(install).to_path_buf());
|
||||
Some((
|
||||
exe.to_string_lossy().into_owned(),
|
||||
args,
|
||||
workdir.to_string_lossy().into_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the spawn `(command line, working dir)` for a `gog` launch value (`exe \t args \t workdir`,
|
||||
/// all host-resolved from the operator's own disk). Direct exe — no shell, no Galaxy.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
||||
let mut parts = value.split('\t');
|
||||
let exe = parts.next().filter(|s| !s.is_empty())?;
|
||||
let args = parts.next().unwrap_or("");
|
||||
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
|
||||
let cmdline = if args.trim().is_empty() {
|
||||
format!("\"{exe}\"")
|
||||
} else {
|
||||
format!("\"{exe}\" {args}")
|
||||
};
|
||||
Some((cmdline, workdir))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_spawn_parses_and_guards() {
|
||||
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
|
||||
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
|
||||
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
|
||||
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
|
||||
assert_eq!(cmd2, "\"C:\\g.exe\"");
|
||||
assert!(wd2.is_none());
|
||||
assert!(gog_spawn("").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_play_task_picks_primary_filetask() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-gog-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let id = "1207658924";
|
||||
std::fs::write(
|
||||
dir.join(format!("goggame-{id}.info")),
|
||||
r#"{"playTasks":[
|
||||
{"isPrimary":false,"type":"FileTask","path":"other.exe"},
|
||||
{"isPrimary":true,"type":"FileTask","path":"bin\\game.exe","arguments":"-w","workingDir":"bin"}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (exe, args, wd) = gog_play_task(&dir.to_string_lossy(), id).unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert!(exe.ends_with("bin\\game.exe"), "exe={exe}");
|
||||
assert_eq!(args, "-w");
|
||||
assert!(wd.ends_with("bin"), "wd={wd}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Heroic (Epic/GOG) store provider: installed games from Heroic's JSON stores + CDN art. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Reads Heroic Games Launcher's local library cache. One provider surfaces all three of Heroic's
|
||||
/// backends (legendary=Epic, gog=GOG, nile=Amazon). Linux-only for now (Heroic on Windows uses a
|
||||
/// different config path and the launch path isn't wired there yet).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct HeroicProvider;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl LibraryProvider for HeroicProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"heroic"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let Some(root) = heroic_root() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut games = Vec::new();
|
||||
// (cache file, runner id, the electron-store data key holding the games array)
|
||||
for (file, runner, key) in [
|
||||
("legendary_library.json", "legendary", "library"),
|
||||
("gog_library.json", "gog", "games"),
|
||||
("nile_library.json", "nile", "library"),
|
||||
] {
|
||||
let path = root.join("store_cache").join(file);
|
||||
match heroic_games(&path, runner, key) {
|
||||
Ok(mut g) => games.append(&mut g),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, file, "heroic store_cache not read (store unused?)")
|
||||
}
|
||||
}
|
||||
}
|
||||
games
|
||||
}
|
||||
}
|
||||
|
||||
/// The first existing Heroic config root: `$XDG_CONFIG_HOME/heroic`, classic `~/.config/heroic`, or
|
||||
/// the Flatpak path.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_root() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
candidates.push(PathBuf::from(d).join("heroic"));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
candidates.push(home.join(".config/heroic"));
|
||||
candidates.push(home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic"));
|
||||
}
|
||||
candidates.into_iter().find(|p| p.is_dir())
|
||||
}
|
||||
|
||||
/// Parse one runner's `store_cache/*_library.json` (an electron-store object whose `key` holds the
|
||||
/// games array). Keeps only installed titles whose install dir still exists (the latter works around
|
||||
/// Heroic's gog `is_installed` bug, #2691). Art comes straight from the cached public CDN URLs.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<GameEntry>> {
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
let root: serde_json::Value = serde_json::from_str(&raw)?;
|
||||
let arr = root
|
||||
.get(key)
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("no '{key}' array in {}", path.display()))?;
|
||||
let mut games = Vec::new();
|
||||
for g in arr {
|
||||
if !g
|
||||
.get("is_installed")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue; // the cache also lists owned-but-not-installed titles
|
||||
}
|
||||
let install_ok = g
|
||||
.get("install")
|
||||
.and_then(|i| i.get("install_path"))
|
||||
.and_then(|p| p.as_str())
|
||||
.is_some_and(|p| Path::new(p).is_dir());
|
||||
if !install_ok {
|
||||
continue;
|
||||
}
|
||||
let Some(app_name) = g
|
||||
.get("app_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let title = g
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(app_name)
|
||||
.to_string();
|
||||
// Only emit http(s) art (sideloaded titles can carry local file:// paths the client can't fetch).
|
||||
let http = |k: &str| {
|
||||
g.get(k)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| s.starts_with("http://") || s.starts_with("https://"))
|
||||
.map(String::from)
|
||||
};
|
||||
let art = Artwork {
|
||||
portrait: http("art_square"),
|
||||
header: http("art_cover"),
|
||||
hero: http("art_background").or_else(|| http("art_cover")),
|
||||
logo: http("art_logo"),
|
||||
};
|
||||
games.push(GameEntry {
|
||||
id: format!("heroic:{runner}:{app_name}"),
|
||||
store: "heroic".into(),
|
||||
title,
|
||||
art,
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "heroic".into(),
|
||||
value: format!("{runner}:{app_name}"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
||||
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
||||
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
|
||||
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
|
||||
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn heroic_command(value: &str) -> Option<String> {
|
||||
let (runner, app) = value.split_once(':')?;
|
||||
if !matches!(runner, "legendary" | "gog" | "nile") {
|
||||
return None;
|
||||
}
|
||||
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
|
||||
if app.is_empty()
|
||||
|| !app
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let prefix = heroic_launch_prefix()?;
|
||||
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
|
||||
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
|
||||
Some(format!(
|
||||
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
|
||||
))
|
||||
}
|
||||
|
||||
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
|
||||
/// root is present. `None` ⇒ Heroic not found, so no launch command.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_launch_prefix() -> Option<String> {
|
||||
let on_path = std::env::var_os("PATH")
|
||||
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
|
||||
if on_path {
|
||||
return Some("heroic".into());
|
||||
}
|
||||
let flatpak = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
|
||||
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn heroic_games_parses_installed_with_cdn_art() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-heroic-test-{}", std::process::id()));
|
||||
let install = dir.join("game-install");
|
||||
std::fs::create_dir_all(&install).unwrap();
|
||||
let path = dir.join("legendary_library.json");
|
||||
let json = format!(
|
||||
r#"{{"library":[
|
||||
{{"app_name":"Quail","title":"Quail","is_installed":true,
|
||||
"install":{{"install_path":"{inst}"}},
|
||||
"art_square":"https://cdn/quail_tall.jpg","art_cover":"https://cdn/quail_wide.jpg",
|
||||
"art_logo":"file:///local/logo.png"}},
|
||||
{{"app_name":"Owned","title":"Owned Only","is_installed":false,
|
||||
"install":{{"install_path":"{inst}"}}}}
|
||||
]}}"#,
|
||||
inst = install.display()
|
||||
);
|
||||
std::fs::write(&path, json).unwrap();
|
||||
let games = heroic_games(&path, "legendary", "library").unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert_eq!(games.len(), 1); // the uninstalled title is filtered out
|
||||
assert_eq!(games[0].id, "heroic:legendary:Quail");
|
||||
assert_eq!(games[0].title, "Quail");
|
||||
assert_eq!(
|
||||
games[0].art.portrait.as_deref(),
|
||||
Some("https://cdn/quail_tall.jpg")
|
||||
);
|
||||
assert_eq!(
|
||||
games[0].art.header.as_deref(),
|
||||
Some("https://cdn/quail_wide.jpg")
|
||||
);
|
||||
assert!(games[0].art.logo.is_none()); // file:// art is dropped (client can't fetch it)
|
||||
let l = games[0].launch.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(l.kind.as_str(), l.value.as_str()),
|
||||
("heroic", "legendary:Quail")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Title launch: resolve a library id / raw command into an executable command line (per-store +
|
||||
//! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::custom::valid_steam_appid;
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::heroic::heroic_command;
|
||||
use super::*;
|
||||
#[cfg(windows)]
|
||||
use super::{epic::epic_launch_uri, gog::gog_spawn};
|
||||
|
||||
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell
|
||||
/// command the host should run for it — looked up in the host's OWN library so a client can only
|
||||
/// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a
|
||||
/// malformed Steam appid.
|
||||
///
|
||||
/// **Linux only**: the resolved command is run nested inside the per-session gamescope. On Windows
|
||||
/// there is no gamescope to nest into; the host launches a title into the interactive user session
|
||||
/// via [`launch_title`] instead.
|
||||
///
|
||||
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits).
|
||||
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
|
||||
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
|
||||
#[cfg(not(windows))]
|
||||
pub fn launch_command(id: &str) -> Option<String> {
|
||||
let spec = all_games().into_iter().find(|g| g.id == id)?.launch?;
|
||||
command_for(&spec)
|
||||
}
|
||||
|
||||
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
|
||||
/// [`launch_command`], split out so the appid-validation can be tested without a Steam install).
|
||||
#[cfg(not(windows))]
|
||||
fn command_for(spec: &LaunchSpec) -> Option<String> {
|
||||
match spec.kind.as_str() {
|
||||
"steam_appid" => valid_steam_appid(&spec.value)
|
||||
.then(|| format!("steam steam://rungameid/{}", spec.value)),
|
||||
// Lutris: a digits-only pga.db game id (same guard as steam_appid) → its run URI.
|
||||
#[cfg(target_os = "linux")]
|
||||
"lutris_id" => (!spec.value.is_empty() && spec.value.bytes().all(|b| b.is_ascii_digit()))
|
||||
.then(|| format!("lutris lutris:rungameid/{}", spec.value)),
|
||||
// Heroic: `<runner>:<appName>` → the validated heroic://launch command (see heroic_command).
|
||||
#[cfg(target_os = "linux")]
|
||||
"heroic" => heroic_command(&spec.value),
|
||||
// Trusted: the command comes from the host's own custom store, never the client.
|
||||
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: launch a store-qualified library id into the **interactive user session** — the Windows
|
||||
/// analogue of the Linux gamescope-nested [`launch_command`]. The id is resolved against the host's
|
||||
/// OWN library (the client never sends a command), mapped to a concrete process by
|
||||
/// [`windows_launch_for`], and spawned via [`crate::interactive::spawn_in_active_session`].
|
||||
///
|
||||
/// Wired into the data plane *after* capture is live, so the title renders onto the already-captured
|
||||
/// desktop and grabs foreground.
|
||||
#[cfg(windows)]
|
||||
pub fn launch_title(id: &str) -> Result<()> {
|
||||
let spec = all_games()
|
||||
.into_iter()
|
||||
.find(|g| g.id == id)
|
||||
.and_then(|g| g.launch)
|
||||
.ok_or_else(|| anyhow::anyhow!("no launchable library entry '{id}'"))?;
|
||||
let (cmdline, workdir) = windows_launch_for(&spec).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"library entry '{id}' has no Windows launch recipe (kind '{}')",
|
||||
spec.kind
|
||||
)
|
||||
})?;
|
||||
let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref())
|
||||
.with_context(|| format!("launch '{id}' in the interactive session"))?;
|
||||
tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows: map a resolved [`LaunchSpec`] to a `(command line, working dir)` to spawn into the
|
||||
/// interactive session. Pure + unit-testable. `None` = no Windows recipe for this kind.
|
||||
///
|
||||
/// CreateProcessAsUserW does NO shell or protocol resolution, so the URI/flags are handed to a
|
||||
/// concrete EXE as plain arguments — a (host-derived) URI string can never reach a command interpreter.
|
||||
#[cfg(windows)]
|
||||
fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::PathBuf>)> {
|
||||
match spec.kind.as_str() {
|
||||
"steam_appid" => {
|
||||
if !valid_steam_appid(&spec.value) {
|
||||
return None;
|
||||
}
|
||||
let uri = format!("steam://rungameid/{}", spec.value);
|
||||
// Prefer launching Steam.exe with the URI as an argument; fall back to explorer.exe, which
|
||||
// resolves the steam:// handler from the user hive. (The appid is digits-validated, so the
|
||||
// only variable part of the line is a number either way.)
|
||||
let cmdline = match steam_exe() {
|
||||
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
|
||||
None => format!("explorer.exe \"{uri}\""),
|
||||
};
|
||||
Some((cmdline, None))
|
||||
}
|
||||
// Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a
|
||||
// concrete EXE that resolves the registered protocol handler as the user; the URI is a single
|
||||
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
|
||||
"epic" => epic_launch_uri(&spec.value).map(|uri| (format!("explorer.exe \"{uri}\""), None)),
|
||||
// GOG: spawn the resolved game exe directly (host-derived from goggame-<id>.info), no Galaxy.
|
||||
"gog" => gog_spawn(&spec.value),
|
||||
// Xbox/Game Pass: activate the UWP/GDK package by its AUMID (<PFN>!<AppId>) via explorer's
|
||||
// shell:AppsFolder — which runs in the interactive user session (UWP activation fails as
|
||||
// SYSTEM/session-0; spawn_in_active_session uses the user token). Guard the charset (the value
|
||||
// is host-derived from MicrosoftGame.config + AppRepository, but belt-and-suspenders).
|
||||
"aumid" => {
|
||||
let valid = spec.value.split_once('!').is_some_and(|(pfn, app)| {
|
||||
let part = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
};
|
||||
part(pfn) && part(app)
|
||||
});
|
||||
valid.then(|| {
|
||||
(
|
||||
format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value),
|
||||
None,
|
||||
)
|
||||
})
|
||||
}
|
||||
// Operator-typed custom command (host-owned, never client-set): run it through the shell in the
|
||||
// interactive session. `cmd.exe /c` is acceptable here precisely because the value is operator
|
||||
// input — the same trust as the operator typing it — not a client-influenced string.
|
||||
"command" => {
|
||||
let v = spec.value.trim();
|
||||
(!v.is_empty()).then(|| (format!("cmd.exe /c {v}"), None))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: the default Steam install's `steam.exe`, if present. A non-default Steam install dir
|
||||
/// (registry `Valve\Steam\InstallPath`) isn't covered — the explorer.exe protocol fallback handles
|
||||
/// that case. Mirrors [`steam_roots`]' "default Program Files dirs" approach.
|
||||
#[cfg(windows)]
|
||||
fn steam_exe() -> Option<std::path::PathBuf> {
|
||||
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
|
||||
if let Some(pf) = std::env::var_os(var) {
|
||||
let p = std::path::PathBuf::from(pf).join("Steam").join("steam.exe");
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
|
||||
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
|
||||
/// through the compositor-aware [`launch_session_command`] instead.
|
||||
#[cfg(windows)]
|
||||
pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
|
||||
let cmd = cmd.trim();
|
||||
anyhow::ensure!(!cmd.is_empty(), "empty command");
|
||||
// cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a
|
||||
// client-influenced string (same trust as the custom-store `command` kind).
|
||||
let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None)
|
||||
.context("spawn gamestream command in the interactive session")?;
|
||||
tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
|
||||
/// on the `AppEntry`, resolved from the numeric Moonlight appid) into the interactive Windows user
|
||||
/// session ([`launch_title`]). The id is resolved against the host's OWN library, so a client can
|
||||
/// only ever pick an existing title — never inject a command. Linux resolves the id via
|
||||
/// [`launch_command`] and goes through [`launch_session_command`] instead.
|
||||
#[cfg(windows)]
|
||||
pub fn launch_gamestream_library(id: &str) -> Result<()> {
|
||||
launch_title(id)
|
||||
}
|
||||
|
||||
/// Launch a resolved shell command into the **live Linux session** for the session's compositor —
|
||||
/// the one launch entry point shared by the native (punktfunk/1) and GameStream planes, called
|
||||
/// AFTER capture is up so the app renders onto the streamed output. The command is host-resolved
|
||||
/// (a library id via [`launch_command`], or an operator-typed apps.json/custom command) — never a
|
||||
/// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed)
|
||||
/// desktop/session rather than tearing the stream down.
|
||||
///
|
||||
/// * **KWin / Mutter / wlroots** — the host runs inside the user's graphical session (the process
|
||||
/// env was retargeted at it by `apply_session_env`, and the per-session virtual output is
|
||||
/// promoted primary), so a plain spawn lands the app on the streamed output.
|
||||
/// * **gamescope (managed / SteamOS / attach)** — the app must open *inside* the running gamescope
|
||||
/// session: spawned with the session's own `DISPLAY`/Wayland env
|
||||
/// ([`crate::vdisplay::launch_into_gamescope_session`]). A `steam steam://…` command additionally
|
||||
/// forwards over the running Steam instance's own pipe, so the dominant Steam case is
|
||||
/// env-independent.
|
||||
/// * **gamescope (bare spawn)** — not routed here: the command was nested into the fresh gamescope
|
||||
/// via `set_launch_command` (the caller gates on `vdisplay::launch_is_nested`).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str) -> Result<()> {
|
||||
let cmd = cmd.trim();
|
||||
anyhow::ensure!(!cmd.is_empty(), "empty command");
|
||||
let child = match compositor {
|
||||
crate::vdisplay::Compositor::Gamescope => {
|
||||
crate::vdisplay::launch_into_gamescope_session(cmd)?
|
||||
}
|
||||
_ => std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.spawn()
|
||||
.context("spawn launch command")?,
|
||||
};
|
||||
tracing::info!(
|
||||
command = %cmd,
|
||||
pid = child.id(),
|
||||
compositor = compositor.id(),
|
||||
"launched app into the live session"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the launch command for a session app selection on Linux: a store-qualified library id
|
||||
/// (from either plane) wins, else the operator-typed command. `None` = nothing to launch (or an
|
||||
/// unknown/recipe-less id — warned, so a client picking a stale title sees why nothing started).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn resolve_session_launch(library_id: Option<&str>, command: Option<&str>) -> Option<String> {
|
||||
if let Some(id) = library_id {
|
||||
match launch_command(id) {
|
||||
Some(cmd) => return Some(cmd),
|
||||
None => tracing::warn!(
|
||||
launch_id = id,
|
||||
"requested launch id not in this host's library (or no launch recipe) — ignoring"
|
||||
),
|
||||
}
|
||||
}
|
||||
command
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn launch_command_resolves_and_guards() {
|
||||
let steam = LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
value: "570".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
command_for(&steam).as_deref(),
|
||||
Some("steam steam://rungameid/570")
|
||||
);
|
||||
// A non-numeric "appid" (e.g. a client trying to inject) is rejected, never interpolated.
|
||||
let evil = LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
value: "570; rm -rf ~".into(),
|
||||
};
|
||||
assert_eq!(command_for(&evil), None);
|
||||
// Custom commands (from the host's own store) pass through verbatim.
|
||||
let custom = LaunchSpec {
|
||||
kind: "command".into(),
|
||||
value: "dolphin-emu --batch".into(),
|
||||
};
|
||||
assert_eq!(command_for(&custom).as_deref(), Some("dolphin-emu --batch"));
|
||||
// Empty / unknown kinds → no command.
|
||||
assert_eq!(
|
||||
command_for(&LaunchSpec {
|
||||
kind: "command".into(),
|
||||
value: " ".into()
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
command_for(&LaunchSpec {
|
||||
kind: "wat".into(),
|
||||
value: "x".into()
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn command_for_lutris_and_heroic_guards() {
|
||||
// Lutris: digits → its run URI; a non-numeric id (injection attempt) is rejected.
|
||||
assert_eq!(
|
||||
command_for(&LaunchSpec {
|
||||
kind: "lutris_id".into(),
|
||||
value: "42".into()
|
||||
})
|
||||
.as_deref(),
|
||||
Some("lutris lutris:rungameid/42")
|
||||
);
|
||||
assert_eq!(
|
||||
command_for(&LaunchSpec {
|
||||
kind: "lutris_id".into(),
|
||||
value: "42; rm -rf ~".into()
|
||||
}),
|
||||
None
|
||||
);
|
||||
// Heroic guards (independent of whether Heroic is installed): bad runner / appName → None.
|
||||
assert_eq!(heroic_command("badrunner:Quail"), None);
|
||||
assert_eq!(heroic_command("legendary:bad name"), None);
|
||||
assert_eq!(heroic_command("nile:"), None);
|
||||
// When Heroic IS resolvable (a dev box), a valid id yields the launch URI; on CI (no Heroic)
|
||||
// it's None — assert the URI shape only when a launcher prefix exists.
|
||||
if let Some(cmd) = heroic_command("legendary:Quail-1.2_x") {
|
||||
assert!(cmd.contains("heroic://launch?appName=Quail-1.2_x&runner=legendary"));
|
||||
assert!(cmd.contains("--no-gui"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_launch_for_maps_and_guards() {
|
||||
// Steam: a digits-only appid → a steam:// URI line (via Steam.exe or explorer.exe, depending
|
||||
// on the box) with no working dir.
|
||||
let steam = LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
value: "570".into(),
|
||||
};
|
||||
let (line, wd) = windows_launch_for(&steam).expect("steam recipe");
|
||||
assert!(line.contains("steam://rungameid/570"), "line was {line:?}");
|
||||
assert!(wd.is_none());
|
||||
// A non-numeric "appid" (a client trying to inject) is rejected, never interpolated.
|
||||
let evil = LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
value: "570\" & calc".into(),
|
||||
};
|
||||
assert!(windows_launch_for(&evil).is_none());
|
||||
// Operator command → cmd /c passthrough (trusted host input).
|
||||
let cmd = LaunchSpec {
|
||||
kind: "command".into(),
|
||||
value: "notepad.exe".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
windows_launch_for(&cmd).unwrap().0,
|
||||
"cmd.exe /c notepad.exe"
|
||||
);
|
||||
// Xbox AUMID → explorer shell:AppsFolder activation; a value without '!' is rejected.
|
||||
let aumid = LaunchSpec {
|
||||
kind: "aumid".into(),
|
||||
value: "Microsoft.X_8wekyb3d8bbwe!Game".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
windows_launch_for(&aumid).unwrap().0,
|
||||
"explorer.exe \"shell:AppsFolder\\Microsoft.X_8wekyb3d8bbwe!Game\""
|
||||
);
|
||||
assert!(windows_launch_for(&LaunchSpec {
|
||||
kind: "aumid".into(),
|
||||
value: "no-bang".into()
|
||||
})
|
||||
.is_none());
|
||||
// Empty / unknown kinds → no recipe.
|
||||
assert!(windows_launch_for(&LaunchSpec {
|
||||
kind: "command".into(),
|
||||
value: " ".into()
|
||||
})
|
||||
.is_none());
|
||||
assert!(windows_launch_for(&LaunchSpec {
|
||||
kind: "wat".into(),
|
||||
value: "x".into()
|
||||
})
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Lutris store provider: installed games from the Lutris SQLite DB + lutris.net CDN art. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Reads the **local** Lutris library DB (`pga.db`) — no network. Installed titles only; cover art
|
||||
/// from Lutris's on-disk cache, inlined as `data:` URLs. Linux-only (Lutris is Linux-only).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct LutrisProvider;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl LibraryProvider for LutrisProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"lutris"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let Some(db) = lutris_db() else {
|
||||
return Vec::new();
|
||||
};
|
||||
lutris_games(&db).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, db = %db.display(), "lutris pga.db read failed — skipping");
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The first existing Lutris `pga.db`: XDG data dir, the classic `~/.local/share`, or Flatpak.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_db() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
|
||||
candidates.push(PathBuf::from(d).join("lutris/pga.db"));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
candidates.push(home.join(".local/share/lutris/pga.db"));
|
||||
candidates.push(home.join(".var/app/net.lutris.Lutris/data/lutris/pga.db"));
|
||||
}
|
||||
candidates.into_iter().find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Installed games from a Lutris `pga.db`. Opened **read-only + immutable** (via a SQLite URI) so a
|
||||
/// running Lutris holding the file can't make us block or fail, and we never write to it.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
||||
use rusqlite::OpenFlags;
|
||||
// `immutable=1` treats the DB as read-only-and-unchanging → no locking against a live Lutris. The
|
||||
// path goes into the URI literally; a `?`/`#` in it (vanishingly rare on Linux) would mis-parse,
|
||||
// so fall back to a plain read-only open in that case.
|
||||
let path = db.to_string_lossy();
|
||||
let conn = if path.contains('?') || path.contains('#') {
|
||||
rusqlite::Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?
|
||||
} else {
|
||||
rusqlite::Connection::open_with_flags(
|
||||
format!("file:{path}?immutable=1"),
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
|
||||
)?
|
||||
};
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, slug, name FROM games \
|
||||
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
|
||||
ORDER BY name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
let mut games = Vec::new();
|
||||
for (id, slug, name) in rows.flatten() {
|
||||
games.push(GameEntry {
|
||||
id: format!("lutris:{id}"),
|
||||
store: "lutris".into(),
|
||||
title: name,
|
||||
art: slug.as_deref().map(lutris_art).unwrap_or_default(),
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "lutris_id".into(),
|
||||
value: id.to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
/// Lutris cover art (local files keyed by slug) inlined as `data:` URLs — Lutris has no public CDN
|
||||
/// keyed by a stable id (unlike Steam/Heroic), and `Artwork` fields are URLs the client fetches, so a
|
||||
/// self-contained `data:` URL needs no host-served endpoint. `coverart` → portrait, `banners` → header.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_art(slug: &str) -> Artwork {
|
||||
Artwork {
|
||||
portrait: lutris_image("coverart", slug),
|
||||
header: lutris_image("banners", slug),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Find `<kind>/<slug>.jpg` across the current (0.5.18+), legacy (`~/.cache`), and Flatpak Lutris
|
||||
/// dirs and inline it as `data:image/jpeg;base64,…`. Skips a missing or implausibly large file (a
|
||||
/// 1 MiB cap bounds the catalog JSON so a few big files can't bloat it).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_image(kind: &str, slug: &str) -> Option<String> {
|
||||
use base64::Engine as _;
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from)?;
|
||||
let roots = [
|
||||
home.join(".local/share/lutris"),
|
||||
home.join(".cache/lutris"),
|
||||
home.join(".var/app/net.lutris.Lutris/data/lutris"),
|
||||
home.join(".var/app/net.lutris.Lutris/cache/lutris"),
|
||||
];
|
||||
for root in roots {
|
||||
let p = root.join(kind).join(format!("{slug}.jpg"));
|
||||
let Ok(meta) = std::fs::metadata(&p) else {
|
||||
continue;
|
||||
};
|
||||
if meta.len() == 0 || meta.len() > 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
if let Ok(bytes) = std::fs::read(&p) {
|
||||
let enc = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
return Some(format!("data:image/jpeg;base64,{enc}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn lutris_games_reads_installed_only() {
|
||||
use rusqlite::Connection;
|
||||
let dir = std::env::temp_dir().join(format!("pf-lutris-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = dir.join("pga.db");
|
||||
{
|
||||
let c = Connection::open(&db).unwrap();
|
||||
c.execute_batch(
|
||||
"CREATE TABLE games (id INTEGER PRIMARY KEY, slug TEXT, name TEXT, installed INTEGER);
|
||||
INSERT INTO games (id,slug,name,installed) VALUES (42,'elden-ring','ELDEN RING',1);
|
||||
INSERT INTO games (id,slug,name,installed) VALUES (7,'owned','Owned Only',0);
|
||||
INSERT INTO games (id,slug,name,installed) VALUES (9,'noname',NULL,1);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let games = lutris_games(&db).unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
// Only the installed, named row; the uninstalled + NULL-name rows are filtered out.
|
||||
assert_eq!(games.len(), 1);
|
||||
assert_eq!(games[0].id, "lutris:42");
|
||||
assert_eq!(games[0].store, "lutris");
|
||||
assert_eq!(games[0].title, "ELDEN RING");
|
||||
let l = games[0].launch.as_ref().unwrap();
|
||||
assert_eq!((l.kind.as_str(), l.value.as_str()), ("lutris_id", "42"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Steam store provider: installed-title scan (local `libraryfolders.vdf` + app manifests, no
|
||||
//! API key) and Steam-CDN / local-`librarycache` artwork. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::art::fetch_image;
|
||||
use super::*;
|
||||
|
||||
/// Reads the **local** Steam install — no Steam Web API key, no network. Installed titles come
|
||||
/// from `steamapps/appmanifest_<appid>.acf`; extra library folders from
|
||||
/// `steamapps/libraryfolders.vdf`; artwork from the public Steam CDN by appid.
|
||||
pub struct SteamProvider;
|
||||
|
||||
impl LibraryProvider for SteamProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"steam"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let mut by_appid: std::collections::BTreeMap<u32, String> = Default::default();
|
||||
for steamapps in steam_library_dirs() {
|
||||
for (appid, name) in scan_manifests(&steamapps) {
|
||||
by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids
|
||||
}
|
||||
}
|
||||
by_appid
|
||||
.into_iter()
|
||||
.filter(|(appid, name)| !is_steam_tool(*appid, name))
|
||||
.map(|(appid, title)| GameEntry {
|
||||
id: format!("steam:{appid}"),
|
||||
store: "steam".into(),
|
||||
title,
|
||||
art: steam_art(appid),
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
value: appid.to_string(),
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The Steam CDN poster/hero/logo/header for an appid — relative proxy paths the *client* resolves
|
||||
/// against the host it just talked to (so they work the same whichever interface/port the client
|
||||
/// reached the host on), backed by [`steam_art_bytes`] on the way out. Not every appid has a
|
||||
/// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it.
|
||||
fn steam_art(appid: u32) -> Artwork {
|
||||
let url = |kind: &str| Some(format!("/api/v1/library/art/steam:{appid}/{kind}"));
|
||||
Artwork {
|
||||
portrait: url("portrait"),
|
||||
hero: url("hero"),
|
||||
logo: url("logo"),
|
||||
header: url("header"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one Steam cover-art kind to bytes: the host's own local Steam cache first (exact — it's
|
||||
/// literally what the user's Steam client already shows for this title), the legacy flat CDN URL
|
||||
/// as a fallback. `None` when neither has it (the client then falls through to its next art
|
||||
/// candidate). Blocking (disk + network) — call off the async runtime.
|
||||
pub fn steam_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
steam_local_art_bytes(appid, kind).or_else(|| {
|
||||
let url = format!(
|
||||
"https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{}",
|
||||
kind.cdn_filename()
|
||||
);
|
||||
fetch_image(&url)
|
||||
})
|
||||
}
|
||||
|
||||
/// Cap on a local librarycache file we'll read into memory — generous for a Steam-quality JPEG/PNG
|
||||
/// (these run well under 2 MiB in practice) while bounding a pathological file.
|
||||
const LOCAL_ART_MAX_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// `appcache/librarycache/<appid>/<hash>/<filename>` across every Steam root, for whichever
|
||||
/// `<hash>` subdirectory actually has this kind's file (Steam reuses one hash dir per asset
|
||||
/// version, so there's normally exactly one candidate per kind).
|
||||
fn steam_local_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
steam_roots()
|
||||
.into_iter()
|
||||
.find_map(|root| find_local_art_file(&root, appid, kind))
|
||||
.and_then(|path| {
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
let ctype = if path.extension().is_some_and(|e| e == "png") {
|
||||
"image/png"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
};
|
||||
Some((bytes, ctype.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Find this kind's cached file under one Steam root's `appcache/librarycache/<appid>/<hash>/`,
|
||||
/// trying each hash subdirectory (normally just one) and each candidate filename in priority
|
||||
/// order. Pure path lookup — no env/HOME dependency — so it's unit-testable against a plain
|
||||
/// directory fixture.
|
||||
fn find_local_art_file(root: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
|
||||
let cache_dir = root
|
||||
.join("appcache")
|
||||
.join("librarycache")
|
||||
.join(appid.to_string());
|
||||
let hash_dirs = std::fs::read_dir(&cache_dir).ok()?;
|
||||
for hash_dir in hash_dirs.flatten() {
|
||||
for name in kind.local_filenames() {
|
||||
let path = hash_dir.path().join(name);
|
||||
let Ok(meta) = std::fs::metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn steam_roots() -> Vec<PathBuf> {
|
||||
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let candidates = [
|
||||
home.join(".local/share/Steam"),
|
||||
home.join(".steam/steam"),
|
||||
home.join(".steam/root"),
|
||||
home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), // Flatpak Steam
|
||||
];
|
||||
steam_roots_existing(candidates)
|
||||
}
|
||||
|
||||
/// Windows Steam roots: the default install dirs under Program Files. Games installed on other
|
||||
/// drives are still found via each root's `libraryfolders.vdf` (see [`steam_library_dirs`]). A
|
||||
/// non-default Steam install dir (registry `Valve\Steam\InstallPath`) isn't covered yet.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn steam_roots() -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
|
||||
if let Some(pf) = std::env::var_os(var) {
|
||||
candidates.push(PathBuf::from(pf).join("Steam"));
|
||||
}
|
||||
}
|
||||
steam_roots_existing(candidates)
|
||||
}
|
||||
|
||||
/// Keep only the candidate roots that exist (have a `steamapps` dir), canonicalized + deduped.
|
||||
fn steam_roots_existing(candidates: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut roots = Vec::new();
|
||||
for c in candidates {
|
||||
if let Ok(canon) = c.canonicalize() {
|
||||
if canon.join("steamapps").is_dir() && seen.insert(canon.clone()) {
|
||||
roots.push(canon);
|
||||
}
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// Every `steamapps` dir holding installed titles: each root's own, plus the extra library
|
||||
/// folders listed in `libraryfolders.vdf` (Steam lets you install games on other drives).
|
||||
fn steam_library_dirs() -> Vec<PathBuf> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut dirs = Vec::new();
|
||||
let mut push = |steamapps: PathBuf, dirs: &mut Vec<PathBuf>| {
|
||||
if let Ok(canon) = steamapps.canonicalize() {
|
||||
if canon.is_dir() && seen.insert(canon.clone()) {
|
||||
dirs.push(canon);
|
||||
}
|
||||
}
|
||||
};
|
||||
for root in steam_roots() {
|
||||
let steamapps = root.join("steamapps");
|
||||
if let Ok(text) = std::fs::read_to_string(steamapps.join("libraryfolders.vdf")) {
|
||||
for path in vdf_paths(&text) {
|
||||
push(PathBuf::from(path).join("steamapps"), &mut dirs);
|
||||
}
|
||||
}
|
||||
push(steamapps, &mut dirs);
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Pull every `"path" "<dir>"` value out of a `libraryfolders.vdf`. We don't need a full VDF
|
||||
/// parser for the two flat fields we read. On Windows the values are backslash-escaped
|
||||
/// (`D:\\SteamLibrary`), so unescape `\\` → `\`; Linux paths need no unescaping.
|
||||
fn vdf_paths(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.filter_map(|l| vdf_value(l.trim(), "path"))
|
||||
.map(|p| {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
p.replace("\\\\", "\\")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
p.to_string()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `"<key>" "<value>"` on a single line → `<value>`. Used for both VDF and ACF flat fields.
|
||||
fn vdf_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
|
||||
let rest = line.strip_prefix(&format!("\"{key}\""))?;
|
||||
let after = &rest[rest.find('"')? + 1..];
|
||||
Some(&after[..after.find('"')?])
|
||||
}
|
||||
|
||||
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → (appid, name) of installed titles.
|
||||
fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> {
|
||||
let Ok(rd) = std::fs::read_dir(steamapps) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let fname = entry.file_name();
|
||||
let fname = fname.to_string_lossy();
|
||||
if !(fname.starts_with("appmanifest_") && fname.ends_with(".acf")) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(text) = std::fs::read_to_string(entry.path()) {
|
||||
let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid"));
|
||||
let name = text.lines().find_map(|l| vdf_value(l.trim(), "name"));
|
||||
if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::<u32>), name) {
|
||||
out.push((appid, name.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Steam installs runtimes/redistributables as "apps" too — keep them out of a *game* library.
|
||||
fn is_steam_tool(appid: u32, name: &str) -> bool {
|
||||
// Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier).
|
||||
const TOOL_IDS: &[u32] = &[228980, 1070560, 1391110, 1628350, 1493710];
|
||||
if TOOL_IDS.contains(&appid) {
|
||||
return true;
|
||||
}
|
||||
let n = name.to_ascii_lowercase();
|
||||
n.contains("proton")
|
||||
|| n.starts_with("steam linux runtime")
|
||||
|| n.contains("steamworks common")
|
||||
|| n.contains("steamvr")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vdf_value_extracts_quoted_field() {
|
||||
assert_eq!(
|
||||
vdf_value("\"path\"\t\t\"/mnt/games/SteamLibrary\"", "path"),
|
||||
Some("/mnt/games/SteamLibrary")
|
||||
);
|
||||
assert_eq!(vdf_value("\"appid\"\t\t\"570\"", "appid"), Some("570"));
|
||||
assert_eq!(vdf_value("\"name\"\t\t\"Dota 2\"", "name"), Some("Dota 2"));
|
||||
assert_eq!(vdf_value("\"installdir\"\t\t\"x\"", "appid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vdf_paths_pulls_all_library_folders() {
|
||||
let vdf = r#"
|
||||
"libraryfolders"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"path" "/home/u/.local/share/Steam"
|
||||
"apps" { "570" "123" }
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"path" "/mnt/ssd/SteamLibrary"
|
||||
}
|
||||
}
|
||||
"#;
|
||||
assert_eq!(
|
||||
vdf_paths(vdf),
|
||||
vec![
|
||||
"/home/u/.local/share/Steam".to_string(),
|
||||
"/mnt/ssd/SteamLibrary".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_are_filtered_but_games_kept() {
|
||||
assert!(is_steam_tool(228980, "Steamworks Common Redistributables"));
|
||||
assert!(is_steam_tool(1493710, "Proton Experimental"));
|
||||
assert!(is_steam_tool(0, "Steam Linux Runtime 3.0 (sniper)"));
|
||||
assert!(!is_steam_tool(570, "Dota 2"));
|
||||
assert!(!is_steam_tool(1245620, "ELDEN RING"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_art_points_at_the_host_art_proxy() {
|
||||
let art = steam_art(570);
|
||||
assert_eq!(
|
||||
art.portrait.as_deref(),
|
||||
Some("/api/v1/library/art/steam:570/portrait")
|
||||
);
|
||||
assert_eq!(
|
||||
art.header.as_deref(),
|
||||
Some("/api/v1/library/art/steam:570/header")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_local_art_file_matches_the_hashed_librarycache_layout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = dir
|
||||
.path()
|
||||
.join("appcache/librarycache/3527290/480bd879ac737921bfa2529a6fea15961267ad21");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
std::fs::write(cache.join("library_600x900.jpg"), b"not really a jpeg").unwrap();
|
||||
|
||||
let found = find_local_art_file(dir.path(), 3527290, ArtKind::Portrait).unwrap();
|
||||
assert_eq!(found, cache.join("library_600x900.jpg"));
|
||||
// A kind with no cached file, and an appid with no cache dir at all, both miss cleanly.
|
||||
assert_eq!(
|
||||
find_local_art_file(dir.path(), 3527290, ArtKind::Hero),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
find_local_art_file(dir.path(), 570, ArtKind::Portrait),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_local_art_file_prefers_the_2x_portrait() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = dir.path().join("appcache/librarycache/570/somehash");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
std::fs::write(cache.join("library_600x900.jpg"), b"1x").unwrap();
|
||||
std::fs::write(cache.join("library_600x900_2x.jpg"), b"2x").unwrap();
|
||||
|
||||
let found = find_local_art_file(dir.path(), 570, ArtKind::Portrait).unwrap();
|
||||
assert_eq!(found, cache.join("library_600x900_2x.jpg"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Xbox / Microsoft Store (UWP) provider: installed packages, PFN resolution, and store art. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::art::cached_art;
|
||||
use super::*;
|
||||
|
||||
/// Reads installed Xbox / Game Pass / Store GDK games from the flat-file install dirs. Windows-only.
|
||||
/// Best-effort: empty when no `XboxGames` dir exists.
|
||||
#[cfg(windows)]
|
||||
pub struct XboxProvider;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl LibraryProvider for XboxProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"xbox"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
xbox_games()
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan each fixed drive's default `<drive>:\XboxGames` for GDK games — the presence of
|
||||
/// `Content\MicrosoftGame.config` is the game marker (so we list games, not ordinary UWP apps). A
|
||||
/// custom install folder (set via the undocumented `.GamingRoot`) isn't covered; the default folder
|
||||
/// is the common case. Non-GDK pure-UWP Store games (under the ACL-locked WindowsApps) are missed too.
|
||||
#[cfg(windows)]
|
||||
fn xbox_games() -> Vec<GameEntry> {
|
||||
let mut games = Vec::new();
|
||||
for letter in b'C'..=b'Z' {
|
||||
let root = PathBuf::from(format!("{}:\\XboxGames", letter as char));
|
||||
let Ok(rd) = std::fs::read_dir(&root) else {
|
||||
continue;
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let title_dir = entry.path();
|
||||
let cfg = title_dir.join("Content").join("MicrosoftGame.config");
|
||||
if !cfg.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&cfg) else {
|
||||
continue;
|
||||
};
|
||||
let folder = title_dir
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned());
|
||||
let Some((name, app_id, title, store_id)) = xbox_parse_config(&text, folder.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(pfn) = xbox_pfn(&name) else {
|
||||
tracing::debug!(package = %name, "xbox: no AppRepository entry → can't resolve PFN, skipping");
|
||||
continue;
|
||||
};
|
||||
let id_key = if store_id.is_empty() {
|
||||
pfn.clone()
|
||||
} else {
|
||||
store_id
|
||||
};
|
||||
let id = format!("xbox:{id_key}");
|
||||
// Art (unofficial displaycatalog, keyed by StoreId) is resolved off the hot path by the
|
||||
// background warmer; read whatever it has cached (title-only until warmed / if no StoreId).
|
||||
let art = cached_art(&id).unwrap_or_default();
|
||||
games.push(GameEntry {
|
||||
id,
|
||||
store: "xbox".into(),
|
||||
title,
|
||||
art,
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "aumid".into(),
|
||||
value: format!("{pfn}!{app_id}"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
games.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
games.dedup_by(|a, b| a.id == b.id); // same game on two drives → one entry
|
||||
games
|
||||
}
|
||||
|
||||
/// Parse the fields we need from a `MicrosoftGame.config`: `(Identity Name, AppId, title, StoreId)`.
|
||||
/// AppId is the `<Executable>`'s `Id` (the AUMID app id, typically "Game"). The title prefers
|
||||
/// `ShellVisuals@DefaultDisplayName`, but that can be an unresolved `ms-resource:` ref → fall back to
|
||||
/// the install folder name, then the package name.
|
||||
#[cfg(windows)]
|
||||
fn xbox_parse_config(text: &str, folder: Option<&str>) -> Option<(String, String, String, String)> {
|
||||
let doc = roxmltree::Document::parse(text).ok()?;
|
||||
let root = doc.root_element();
|
||||
let name = root
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("Identity"))?
|
||||
.attribute("Name")?
|
||||
.to_string();
|
||||
let app_id = root
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("ExecutableList"))
|
||||
.and_then(|el| {
|
||||
el.children()
|
||||
.filter(|n| n.has_tag_name("Executable"))
|
||||
.find_map(|e| e.attribute("Id"))
|
||||
})?
|
||||
.to_string();
|
||||
let ddn = root
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("ShellVisuals"))
|
||||
.and_then(|sv| sv.attribute("DefaultDisplayName"))
|
||||
.filter(|s| !s.is_empty() && !s.starts_with("ms-resource"));
|
||||
let title = ddn
|
||||
.map(String::from)
|
||||
.or_else(|| folder.map(String::from))
|
||||
.unwrap_or_else(|| name.clone());
|
||||
let store_id = root
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("StoreId"))
|
||||
.and_then(|n| n.text())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some((name, app_id, title, store_id))
|
||||
}
|
||||
|
||||
/// Resolve a package's PackageFamilyName by finding its
|
||||
/// `AppRepository\Packages\<PackageFullName>` dir (machine-wide, SYSTEM-readable) and reducing the
|
||||
/// full name to `Name_PublisherHash`. This READS the authoritative PFN — never compute the hash.
|
||||
#[cfg(windows)]
|
||||
fn xbox_pfn(identity: &str) -> Option<String> {
|
||||
let pkgs = PathBuf::from(std::env::var_os("ProgramData")?)
|
||||
.join("Microsoft")
|
||||
.join("Windows")
|
||||
.join("AppRepository")
|
||||
.join("Packages");
|
||||
let prefix = format!("{identity}_");
|
||||
for e in std::fs::read_dir(&pkgs).ok()?.flatten() {
|
||||
let dn = e.file_name().to_string_lossy().into_owned();
|
||||
if dn.starts_with(&prefix) {
|
||||
if let Some(pfn) = pfn_from_full(&dn, identity) {
|
||||
return Some(pfn);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// PackageFamilyName from a PackageFullName dir name
|
||||
/// (`Name_Version_Arch_ResourceId_PublisherHash`) → `Name_PublisherHash`. The hash is the last
|
||||
/// `_`-segment; `Name` is the caller's identity.
|
||||
#[cfg(windows)]
|
||||
fn pfn_from_full(dir_name: &str, identity: &str) -> Option<String> {
|
||||
let hash = dir_name.rsplit('_').next()?;
|
||||
(!hash.is_empty() && hash != dir_name).then(|| format!("{identity}_{hash}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn xbox_parse_config_and_pfn() {
|
||||
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<Game configVersion="1">
|
||||
<Identity Name="Microsoft.624F8B84B80" Publisher="CN=Microsoft" Version="1.0.0.0" />
|
||||
<ExecutableList>
|
||||
<Executable Name="gamelaunchhelper.exe" Id="Game" />
|
||||
</ExecutableList>
|
||||
<StoreId>9NBLGGH4R315</StoreId>
|
||||
<ShellVisuals DefaultDisplayName="Halo Infinite" Square150x150Logo="x.png" />
|
||||
</Game>"#;
|
||||
let (name, app_id, title, store_id) = xbox_parse_config(xml, Some("HaloInfinite")).unwrap();
|
||||
assert_eq!(name, "Microsoft.624F8B84B80");
|
||||
assert_eq!(app_id, "Game");
|
||||
assert_eq!(title, "Halo Infinite");
|
||||
assert_eq!(store_id, "9NBLGGH4R315");
|
||||
// An ms-resource DefaultDisplayName is unresolvable → fall back to the install folder name.
|
||||
let xml2 = r#"<Game><Identity Name="Pkg.Name"/>
|
||||
<ExecutableList><Executable Id="App"/></ExecutableList>
|
||||
<ShellVisuals DefaultDisplayName="ms-resource:DisplayName"/></Game>"#;
|
||||
let (_, app2, title2, sid2) = xbox_parse_config(xml2, Some("MyGameFolder")).unwrap();
|
||||
assert_eq!(app2, "App");
|
||||
assert_eq!(title2, "MyGameFolder");
|
||||
assert_eq!(sid2, "");
|
||||
// PackageFamilyName reduced from a PackageFullName dir name (the hash is the last segment).
|
||||
assert_eq!(
|
||||
pfn_from_full(
|
||||
"Microsoft.624F8B84B80_1.0.0.0_x64__8wekyb3d8bbwe",
|
||||
"Microsoft.624F8B84B80"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("Microsoft.624F8B84B80_8wekyb3d8bbwe")
|
||||
);
|
||||
assert!(pfn_from_full("NoUnderscore", "NoUnderscore").is_none());
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ mod dmabuf_fence;
|
||||
#[path = "linux/drm_sync.rs"]
|
||||
mod drm_sync;
|
||||
mod encode;
|
||||
mod events;
|
||||
mod gamestream;
|
||||
mod gpu;
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -64,9 +65,9 @@ mod mgmt_token;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/monitor_devnode.rs"]
|
||||
mod monitor_devnode;
|
||||
mod native;
|
||||
mod native_pairing;
|
||||
mod pipeline;
|
||||
mod punktfunk1;
|
||||
mod pwinit;
|
||||
mod send_pacing;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -296,8 +297,8 @@ fn real_main() -> Result<()> {
|
||||
.map(String::as_str)
|
||||
};
|
||||
let source = match get("--source") {
|
||||
Some("virtual") => punktfunk1::Punktfunk1Source::Virtual,
|
||||
_ => punktfunk1::Punktfunk1Source::Synthetic,
|
||||
Some("virtual") => native::Punktfunk1Source::Virtual,
|
||||
_ => native::Punktfunk1Source::Synthetic,
|
||||
};
|
||||
// Fixed pairing PIN for test harnesses/CI (deterministic ceremony instead of scraping
|
||||
// the logged random PIN). An empty value would arm SPAKE2 with an empty password —
|
||||
@@ -306,7 +307,7 @@ fn real_main() -> Result<()> {
|
||||
Some(p) if p.trim().is_empty() => bail!("--pairing-pin must not be empty"),
|
||||
p => p.map(str::to_string),
|
||||
};
|
||||
punktfunk1::run(punktfunk1::Punktfunk1Options {
|
||||
native::run(native::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),
|
||||
@@ -316,7 +317,7 @@ fn real_main() -> Result<()> {
|
||||
.unwrap_or(0),
|
||||
max_concurrent: get("--max-concurrent")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(punktfunk1::DEFAULT_MAX_CONCURRENT),
|
||||
.unwrap_or(native::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
|
||||
@@ -339,7 +340,7 @@ fn real_main() -> Result<()> {
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.filter(|&ms| ms > 0)
|
||||
.map(std::time::Duration::from_millis)
|
||||
.or_else(punktfunk1::idle_timeout_from_env),
|
||||
.or_else(native::idle_timeout_from_env),
|
||||
mdns: !args.iter().any(|a| a == "--no-mdns") && discovery::mdns_enabled(),
|
||||
})
|
||||
}
|
||||
@@ -369,7 +370,7 @@ fn real_main() -> Result<()> {
|
||||
/// carry the inherent on-path #5/#9 weaknesses, so only on a trusted LAN). Returns the mgmt options,
|
||||
/// the native host config, and whether GameStream is enabled. 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, punktfunk1::NativeServe, bool)> {
|
||||
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, bool)> {
|
||||
let mut opts = mgmt::Options::default();
|
||||
let mut native_port: u16 = 9777; // the native plane always runs now
|
||||
|
||||
@@ -459,7 +460,7 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
|
||||
if !mgmt_bind_explicit {
|
||||
opts.bind = std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT));
|
||||
}
|
||||
let native = punktfunk1::NativeServe {
|
||||
let native = native::NativeServe {
|
||||
port: native_port,
|
||||
require_pairing: !open,
|
||||
// Advertise the mgmt port over mDNS so clients learn where to browse the library (rather than
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! disturbance on a stable multi-second period (display-topology churn, display-poller software,
|
||||
//! virtual-display present timing). Random network loss is bursty and irregular; a stable period is
|
||||
//! a machine, and saying so in the host log turns a "nothing in the logs :/" report into a
|
||||
//! self-diagnosis. Two feeds today: served client-recovery IDRs (`punktfunk1`) and IDD-push capture
|
||||
//! self-diagnosis. Two feeds today: served client-recovery IDRs (`native`) and IDD-push capture
|
||||
//! stalls (`capture::windows::idd_push`).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
+72
-3166
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
//! Auth gate for the management API `/api/v1` routes: paired client cert (mTLS, from anywhere)
|
||||
//! or the bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5).
|
||||
|
||||
use super::shared::*;
|
||||
use crate::gamestream::tls::PeerAddr;
|
||||
use crate::gamestream::tls::PeerCertFingerprint;
|
||||
use axum::extract::Request;
|
||||
use axum::http::header;
|
||||
use axum::http::Method;
|
||||
use axum::middleware::Next;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
|
||||
/// (from a **loopback** peer only) — required always (the host runs with a token by construction).
|
||||
/// `/api/v1/health` stays open for probes; `/api/v1/local/summary` is open to loopback peers only
|
||||
/// (the tray icon's status source). The cert path authorizes only the read-only allowlist
|
||||
/// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined
|
||||
/// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default.
|
||||
pub(crate) async fn require_auth(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if req.uri().path() == "/api/v1/health" {
|
||||
return next.run(req).await; // liveness probe is always open
|
||||
}
|
||||
// The tray icon's status source: non-sensitive counts/booleans only, unauthenticated but
|
||||
// confined to LOOPBACK peers. The bearer-token file (and cert.pem) are SYSTEM/Administrators-
|
||||
// DACL'd on Windows, so the per-user tray process cannot authenticate — this one narrow
|
||||
// read-only route is deliberately all it needs. Not on the cert allowlist: LAN mTLS clients
|
||||
// already have the richer `/status`. (No PeerAddr ⇒ a unit test → treat as loopback, matching
|
||||
// the bearer path below.)
|
||||
if req.uri().path() == "/api/v1/local/summary" {
|
||||
let from_loopback = req
|
||||
.extensions()
|
||||
.get::<PeerAddr>()
|
||||
.is_none_or(|a| a.0.ip().is_loopback());
|
||||
return if from_loopback {
|
||||
next.run(req).await
|
||||
} else {
|
||||
api_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"the local summary is loopback-only",
|
||||
)
|
||||
};
|
||||
}
|
||||
// A paired native client authenticates by its mTLS certificate — the same identity + trust the
|
||||
// QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert
|
||||
// authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration
|
||||
// routes (which would let one paired client unpair others, read/arm the pairing PIN, stop
|
||||
// sessions, or edit the library). Everything outside the allowlist requires the operator's bearer
|
||||
// token. The fingerprint is attached by `serve_https` from the verified peer cert.
|
||||
if let Some(PeerCertFingerprint(Some(fp))) = req.extensions().get::<PeerCertFingerprint>() {
|
||||
if cert_may_access(req.method(), req.uri().path())
|
||||
&& st.native.as_ref().is_some_and(|n| n.is_paired(fp))
|
||||
{
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
// Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer.
|
||||
// The token authorizes the full admin surface, so confining it to loopback keeps that surface off
|
||||
// the LAN even though the listener now binds all interfaces by default (so paired clients can
|
||||
// browse the library). The web console BFF — the sole token holder — always connects over
|
||||
// loopback, so nothing first-party is affected; a LAN caller must use a paired client cert and is
|
||||
// limited to the read-only allowlist above. (No PeerAddr ⇒ a non-`serve_https` caller, e.g. a unit
|
||||
// test → treat as loopback so handler tests still authenticate by token.)
|
||||
let from_loopback = req
|
||||
.extensions()
|
||||
.get::<PeerAddr>()
|
||||
.is_none_or(|a| a.0.ip().is_loopback());
|
||||
if !from_loopback {
|
||||
return api_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"the admin API is loopback-only — a LAN client must present a paired client certificate",
|
||||
);
|
||||
}
|
||||
// `run` always passes a token, so no-token means a misconfigured caller (e.g. a test constructing
|
||||
// `app` directly) — deny.
|
||||
let Some(expected) = st.token.as_deref() else {
|
||||
return api_error(StatusCode::UNAUTHORIZED, "authentication required");
|
||||
};
|
||||
let presented = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "));
|
||||
match presented {
|
||||
Some(token) if token_eq(token, expected) => next.run(req).await,
|
||||
_ => api_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing or invalid credentials (a paired client cert, or a bearer token)",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
|
||||
/// safe, read-only status routes only. Deny-by-default — every state-changing route and every route
|
||||
/// that exposes a pairing PIN or the pending-approval queue requires the operator's bearer token, so
|
||||
/// a streaming client can't administer the host (unpair others, arm/read the PIN, stop sessions,
|
||||
/// edit the library). `/health` is handled separately (always open).
|
||||
pub(crate) fn cert_may_access(method: &Method, path: &str) -> bool {
|
||||
method == Method::GET
|
||||
&& (matches!(
|
||||
path,
|
||||
"/api/v1/host"
|
||||
| "/api/v1/compositors"
|
||||
| "/api/v1/status"
|
||||
| "/api/v1/clients"
|
||||
| "/api/v1/native/clients"
|
||||
// The native clients browse the game library with their cert (no bearer token); the
|
||||
// library MUTATIONS (POST/PUT/DELETE /library/custom) stay token-only via the exact
|
||||
// GET-path match above.
|
||||
| "/api/v1/library"
|
||||
) || path.starts_with("/api/v1/library/art/"))
|
||||
}
|
||||
|
||||
/// Compare SHA-256 digests instead of the strings — constant-time with respect to the
|
||||
/// secret without pulling in a ct-eq dependency.
|
||||
pub(crate) fn token_eq(presented: &str, expected: &str) -> bool {
|
||||
Sha256::digest(presented.as_bytes()) == Sha256::digest(expected.as_bytes())
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Client/pairing-tagged management endpoints: paired Moonlight clients and the GameStream
|
||||
//! pairing PIN flow. Split out of the `mgmt` facade (plan §W5).
|
||||
|
||||
use super::shared::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// A paired (certificate-pinned) Moonlight client.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct PairedClient {
|
||||
/// Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.
|
||||
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
|
||||
fingerprint: String,
|
||||
/// Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.
|
||||
subject: Option<String>,
|
||||
/// Certificate validity start (unix seconds).
|
||||
not_before_unix: Option<i64>,
|
||||
/// Certificate validity end (unix seconds).
|
||||
not_after_unix: Option<i64>,
|
||||
}
|
||||
|
||||
/// Pairing-flow status.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct PairingStatus {
|
||||
/// True while a pairing handshake is parked waiting for the user's PIN.
|
||||
pin_pending: bool,
|
||||
}
|
||||
|
||||
/// The PIN Moonlight displays during pairing.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct SubmitPin {
|
||||
/// 1–16 ASCII digits (Moonlight shows 4).
|
||||
#[schema(example = "1234")]
|
||||
pin: String,
|
||||
}
|
||||
|
||||
/// List paired clients
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/clients",
|
||||
tag = "clients",
|
||||
operation_id = "listPairedClients",
|
||||
responses(
|
||||
(status = OK, description = "All certificate-pinned clients", body = [PairedClient]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_paired_clients(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
) -> Json<Vec<PairedClient>> {
|
||||
let ders = st.app.paired.lock().unwrap().clone();
|
||||
Json(ders.iter().map(|der| client_info(der)).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn client_info(der: &[u8]) -> PairedClient {
|
||||
let fingerprint = hex::encode(Sha256::digest(der));
|
||||
match x509_parser::parse_x509_certificate(der) {
|
||||
Ok((_, x509)) => PairedClient {
|
||||
fingerprint,
|
||||
subject: Some(x509.subject().to_string()),
|
||||
not_before_unix: Some(x509.validity().not_before.timestamp()),
|
||||
not_after_unix: Some(x509.validity().not_after.timestamp()),
|
||||
},
|
||||
Err(_) => PairedClient {
|
||||
fingerprint,
|
||||
subject: None,
|
||||
not_before_unix: None,
|
||||
not_after_unix: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpair a client
|
||||
///
|
||||
/// Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer
|
||||
/// does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed
|
||||
/// client cert — a planned hardening step), so until that lands this removes the client
|
||||
/// from the listing without severing its ability to reconnect.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/clients/{fingerprint}",
|
||||
tag = "clients",
|
||||
operation_id = "unpairClient",
|
||||
params(
|
||||
("fingerprint" = String, Path,
|
||||
description = "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)")
|
||||
),
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Client unpaired"),
|
||||
(status = BAD_REQUEST, description = "Malformed fingerprint", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = NOT_FOUND, description = "No paired client with that fingerprint", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn unpair_client(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> Response {
|
||||
if fingerprint.len() != 64 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"fingerprint must be the 64-char hex SHA-256 of the client certificate DER",
|
||||
);
|
||||
}
|
||||
let mut paired = st.app.paired.lock().unwrap();
|
||||
let before = paired.len();
|
||||
paired.retain(|der| !hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint));
|
||||
if paired.len() < before {
|
||||
tracing::info!(fingerprint, "management API: client unpaired");
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
} else {
|
||||
api_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"no paired client with that fingerprint",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pairing-flow status
|
||||
///
|
||||
/// Poll this to know when to prompt the user for the PIN Moonlight displays.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/pair",
|
||||
tag = "pairing",
|
||||
operation_id = "getPairingStatus",
|
||||
responses(
|
||||
(status = OK, description = "Whether a pairing handshake is waiting for a PIN", body = PairingStatus),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_pairing_status(State(st): State<Arc<MgmtState>>) -> Json<PairingStatus> {
|
||||
Json(PairingStatus {
|
||||
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Submit the pairing PIN
|
||||
///
|
||||
/// Delivers the PIN the Moonlight client is displaying, completing the out-of-band half
|
||||
/// of the pairing handshake.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/pair/pin",
|
||||
tag = "pairing",
|
||||
operation_id = "submitPairingPin",
|
||||
request_body = SubmitPin,
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "PIN delivered to the waiting handshake"),
|
||||
(status = BAD_REQUEST, description = "Malformed PIN or unparseable JSON body", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = CONFLICT, description = "No pairing handshake is waiting for a PIN", body = ApiError),
|
||||
(status = UNSUPPORTED_MEDIA_TYPE, description = "Body is not application/json", body = ApiError),
|
||||
(status = UNPROCESSABLE_ENTITY, description = "JSON body does not match the schema", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn submit_pairing_pin(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
ApiJson(req): ApiJson<SubmitPin>,
|
||||
) -> Response {
|
||||
let pin = req.pin.trim();
|
||||
if pin.is_empty() || pin.len() > 16 || !pin.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return api_error(StatusCode::BAD_REQUEST, "pin must be 1-16 ASCII digits");
|
||||
}
|
||||
if !st.app.pairing.pin.awaiting_pin() {
|
||||
// Refusing (rather than parking the PIN) prevents a stale PIN from silently
|
||||
// satisfying a *future* pairing attempt.
|
||||
return api_error(
|
||||
StatusCode::CONFLICT,
|
||||
"no pairing handshake is waiting for a PIN",
|
||||
);
|
||||
}
|
||||
st.app.pairing.pin.submit(pin.to_string());
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
//! Display-tagged management endpoints: virtual-display policy, state, layout, and custom
|
||||
//! presets. Split out of the `mgmt` facade (plan §W5).
|
||||
|
||||
use super::shared::*;
|
||||
|
||||
/// One preset's human-facing description + the fields it expands to, so the console can render a
|
||||
/// preset picker with an accurate "what this does" preview without hardcoding the expansion.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct PresetInfo {
|
||||
/// The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`).
|
||||
id: String,
|
||||
/// One-line story shown next to the option.
|
||||
summary: String,
|
||||
/// The effective policy this preset expands to (the same fields a `custom` policy carries).
|
||||
fields: crate::vdisplay::policy::EffectivePolicy,
|
||||
}
|
||||
|
||||
/// Full display-management state for the console: the stored policy, every preset's expansion, the
|
||||
/// resolved effective policy, and which options this build actually enforces yet (Stage 0 wires
|
||||
/// keep-alive linger + topology; the rest are stored but not yet acted on).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct DisplaySettingsState {
|
||||
/// The stored policy (preset + custom fields), or the built-in default when unconfigured.
|
||||
settings: crate::vdisplay::policy::DisplayPolicy,
|
||||
/// True once a `display-settings.json` exists (the console has configured this host).
|
||||
configured: bool,
|
||||
/// The effective (preset-expanded) policy currently in force.
|
||||
effective: crate::vdisplay::policy::EffectivePolicy,
|
||||
/// Every named preset and what it expands to (for the picker's preview).
|
||||
presets: Vec<PresetInfo>,
|
||||
/// The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered
|
||||
/// alongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a
|
||||
/// `Custom` policy carrying the preset's fields.
|
||||
custom_presets: Vec<crate::vdisplay::policy::CustomPreset>,
|
||||
/// Option names this build enforces right now. All five axes are now acted on (keep_alive +
|
||||
/// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console
|
||||
/// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout
|
||||
/// position apply being KWin-only, is reported per display in `/display/state`).
|
||||
enforced: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn preset_summary(id: &str) -> &'static str {
|
||||
match id {
|
||||
"default" => "Good for most setups. Reconnects resume quickly, the stream is the whole desktop, and extra viewers each get their own screen.",
|
||||
"gaming-rig" => "For a machine with no monitor that you only stream from. The game keeps running when you disconnect, and whoever connects next takes it over.",
|
||||
"shared-desktop" => "For a PC you also use in person. Your real monitors are never blanked or left with a leftover display, and extra viewers each get their own screen.",
|
||||
"hotdesk" => "One person at a time — roam between your own devices with an instant reconnect. Anyone else is told the box is busy.",
|
||||
"workstation" => "Your multi-monitor daily driver. Displays come back exactly where you arranged them, each client keeps its own settings, and the desktop is yours alone.",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn display_settings_state() -> DisplaySettingsState {
|
||||
use crate::vdisplay::policy::{self, Preset};
|
||||
let store = policy::prefs();
|
||||
let settings = store.get();
|
||||
let configured = store.configured().is_some();
|
||||
let presets = [
|
||||
("default", Preset::Default),
|
||||
("gaming-rig", Preset::GamingRig),
|
||||
("shared-desktop", Preset::SharedDesktop),
|
||||
("hotdesk", Preset::Hotdesk),
|
||||
("workstation", Preset::Workstation),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(id, p)| {
|
||||
policy::preset_fields(p).map(|e| PresetInfo {
|
||||
id: id.to_string(),
|
||||
summary: preset_summary(id).to_string(),
|
||||
fields: e,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
DisplaySettingsState {
|
||||
effective: settings.effective(),
|
||||
settings,
|
||||
configured,
|
||||
presets,
|
||||
custom_presets: policy::load_custom_presets(),
|
||||
enforced: vec![
|
||||
"keep_alive".into(),
|
||||
"topology".into(),
|
||||
"mode_conflict".into(),
|
||||
"identity".into(),
|
||||
"layout".into(),
|
||||
"game_session".into(),
|
||||
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
|
||||
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
|
||||
"ddc_power_off".into(),
|
||||
"pnp_disable_monitors".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Display-management policy
|
||||
///
|
||||
/// The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),
|
||||
/// every preset's expansion, and which options this build enforces yet. See
|
||||
/// `design/display-management.md`.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/display/settings",
|
||||
tag = "display",
|
||||
operation_id = "getDisplaySettings",
|
||||
responses(
|
||||
(status = OK, description = "Stored policy + preset expansions + enforced options", body = DisplaySettingsState),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_display_settings() -> Json<DisplaySettingsState> {
|
||||
Json(display_settings_state())
|
||||
}
|
||||
|
||||
/// Set the display-management policy
|
||||
///
|
||||
/// Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a
|
||||
/// running session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is
|
||||
/// honored (the display is Pinned; free it via `POST /display/release`).
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/display/settings",
|
||||
tag = "display",
|
||||
operation_id = "setDisplaySettings",
|
||||
request_body = crate::vdisplay::policy::DisplayPolicy,
|
||||
responses(
|
||||
(status = OK, description = "Policy stored; the new state", body = DisplaySettingsState),
|
||||
(status = BAD_REQUEST, description = "Malformed policy body", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Policy could not be persisted", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn set_display_settings(
|
||||
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
|
||||
) -> Response {
|
||||
// `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux
|
||||
// registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch).
|
||||
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
|
||||
return api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("persist display policy: {e:#}"),
|
||||
);
|
||||
}
|
||||
tracing::info!("management API: display policy updated");
|
||||
Json(display_settings_state()).into_response()
|
||||
}
|
||||
|
||||
/// One live or kept virtual display.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ApiDisplayInfo {
|
||||
/// Stable-enough id for the `/display/release` `slot` argument.
|
||||
slot: u64,
|
||||
/// Backend name (`pf-vdisplay`, `kwin`, …).
|
||||
backend: String,
|
||||
/// `WIDTHxHEIGHT@HZ`.
|
||||
mode: String,
|
||||
/// `active` | `lingering` | `pinned`.
|
||||
state: String,
|
||||
/// Milliseconds until a lingering display is torn down (absent when active/pinned).
|
||||
expires_in_ms: Option<u64>,
|
||||
/// Live sessions holding the display.
|
||||
sessions: u32,
|
||||
/// Short client label, when the owner tracks it.
|
||||
client: Option<String>,
|
||||
/// Display group (shared desktop) id — several displays with the same group form one desktop (§6A).
|
||||
group: u32,
|
||||
/// This display's ordinal within its group, in acquire order (0-based).
|
||||
display_index: u32,
|
||||
/// Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2).
|
||||
x: i32,
|
||||
/// Desktop-space top-left `y`.
|
||||
y: i32,
|
||||
/// Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous).
|
||||
identity_slot: Option<u32>,
|
||||
/// Effective topology for this display's group (`extend` | `primary` | `exclusive`).
|
||||
topology: String,
|
||||
}
|
||||
|
||||
/// The host's managed virtual displays right now.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct DisplayStateResponse {
|
||||
displays: Vec<ApiDisplayInfo>,
|
||||
}
|
||||
|
||||
/// Request body for `releaseDisplay`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ReleaseDisplayRequest {
|
||||
/// Slot to release (see `state`); omit to release **all** kept displays.
|
||||
#[serde(default)]
|
||||
slot: Option<u64>,
|
||||
}
|
||||
|
||||
/// Result of a `/display/release`.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ReleaseDisplayResult {
|
||||
/// Number of kept displays torn down.
|
||||
released: usize,
|
||||
}
|
||||
|
||||
/// Live virtual displays
|
||||
///
|
||||
/// The host's managed virtual displays right now — active (streaming), lingering (kept after
|
||||
/// disconnect, counting down to teardown), or pinned (kept indefinitely). See
|
||||
/// `design/display-management.md`.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/display/state",
|
||||
tag = "display",
|
||||
operation_id = "getDisplayState",
|
||||
responses(
|
||||
(status = OK, description = "The live/kept virtual displays", body = DisplayStateResponse),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_display_state() -> Json<DisplayStateResponse> {
|
||||
let snap = crate::vdisplay::registry::snapshot();
|
||||
Json(DisplayStateResponse {
|
||||
displays: snap
|
||||
.displays
|
||||
.into_iter()
|
||||
.map(|d| ApiDisplayInfo {
|
||||
slot: d.slot,
|
||||
backend: d.backend,
|
||||
mode: format!("{}x{}@{}", d.mode.0, d.mode.1, d.mode.2),
|
||||
state: d.state,
|
||||
expires_in_ms: d.expires_in_ms,
|
||||
sessions: d.sessions,
|
||||
client: d.client,
|
||||
group: d.group,
|
||||
display_index: d.display_index,
|
||||
x: d.position.0,
|
||||
y: d.position.1,
|
||||
identity_slot: d.identity_slot,
|
||||
topology: d.topology,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Release kept virtual displays
|
||||
///
|
||||
/// Tear down lingering/pinned displays now — so a physical-screen user gets their screen back
|
||||
/// without waiting out the linger. `slot` releases one; omit it to release all kept displays.
|
||||
/// Active (streaming) displays are never torn down here (that is session control).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/display/release",
|
||||
tag = "display",
|
||||
operation_id = "releaseDisplay",
|
||||
request_body = ReleaseDisplayRequest,
|
||||
responses(
|
||||
(status = OK, description = "The number of kept displays released", body = ReleaseDisplayResult),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn release_display(
|
||||
ApiJson(req): ApiJson<ReleaseDisplayRequest>,
|
||||
) -> Json<ReleaseDisplayResult> {
|
||||
let released = crate::vdisplay::registry::release(req.slot);
|
||||
tracing::info!(slot = ?req.slot, released, "management API: display release");
|
||||
Json(ReleaseDisplayResult { released })
|
||||
}
|
||||
|
||||
/// Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot
|
||||
/// id as a string (the same id `/display/state` reports as `identity_slot`).
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct DisplayLayoutRequest {
|
||||
/// `{"<identity_slot>": {"x": …, "y": …}}` — where each arranged display's top-left sits.
|
||||
#[serde(default)]
|
||||
positions: std::collections::BTreeMap<String, crate::vdisplay::policy::Position>,
|
||||
}
|
||||
|
||||
/// Arrange virtual displays
|
||||
///
|
||||
/// Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor
|
||||
/// group (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block
|
||||
/// and switched to manual mode; applied from the next connect (a live group re-applies on its next
|
||||
/// acquire). Locks in the current effective behavior as explicit fields, so arranging displays never
|
||||
/// silently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/display/layout",
|
||||
tag = "display",
|
||||
operation_id = "setDisplayLayout",
|
||||
request_body = DisplayLayoutRequest,
|
||||
responses(
|
||||
(status = OK, description = "Layout stored; the new settings state", body = DisplaySettingsState),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Layout could not be persisted", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Response {
|
||||
let store = crate::vdisplay::policy::prefs();
|
||||
// Lock the current effective behavior into explicit fields + set the manual arrangement (pure
|
||||
// transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy
|
||||
// axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.)
|
||||
let policy = store.get().effective().with_manual_layout(
|
||||
req.positions,
|
||||
store.game_session(),
|
||||
store.ddc_power_off(),
|
||||
store.pnp_disable_monitors(),
|
||||
);
|
||||
if let Err(e) = store.set(policy) {
|
||||
return api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("persist display layout: {e:#}"),
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
positions = display_settings_state().settings.layout.positions.len(),
|
||||
"management API: display layout updated"
|
||||
);
|
||||
Json(display_settings_state()).into_response()
|
||||
}
|
||||
|
||||
/// List the saved custom presets
|
||||
///
|
||||
/// The operator's named field-bundles (`display-presets.json`). These also ride the
|
||||
/// `GET /display/settings` response (`custom_presets`), so the console rarely needs this directly.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/display/presets",
|
||||
tag = "display",
|
||||
operation_id = "listCustomPresets",
|
||||
responses(
|
||||
(status = OK, description = "The saved custom presets", body = Vec<crate::vdisplay::policy::CustomPreset>),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_custom_presets() -> Json<Vec<crate::vdisplay::policy::CustomPreset>> {
|
||||
Json(crate::vdisplay::policy::load_custom_presets())
|
||||
}
|
||||
|
||||
/// Save a custom preset
|
||||
///
|
||||
/// Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can
|
||||
/// apply later. The host assigns a stable id, returned in the body. Applying a preset is a
|
||||
/// `PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/display/presets",
|
||||
tag = "display",
|
||||
operation_id = "createCustomPreset",
|
||||
request_body = crate::vdisplay::policy::CustomPresetInput,
|
||||
responses(
|
||||
(status = CREATED, description = "Preset created", body = crate::vdisplay::policy::CustomPreset),
|
||||
(status = BAD_REQUEST, description = "Empty name", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn create_custom_preset(
|
||||
ApiJson(input): ApiJson<crate::vdisplay::policy::CustomPresetInput>,
|
||||
) -> Response {
|
||||
if input.name.trim().is_empty() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty");
|
||||
}
|
||||
match crate::vdisplay::policy::add_custom_preset(input) {
|
||||
Ok(preset) => (StatusCode::CREATED, Json(preset)).into_response(),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a custom preset
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/display/presets/{id}",
|
||||
tag = "display",
|
||||
operation_id = "updateCustomPreset",
|
||||
params(("id" = String, Path, description = "The custom preset id")),
|
||||
request_body = crate::vdisplay::policy::CustomPresetInput,
|
||||
responses(
|
||||
(status = OK, description = "Preset updated", body = crate::vdisplay::policy::CustomPreset),
|
||||
(status = BAD_REQUEST, description = "Empty name", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = NOT_FOUND, description = "No custom preset with that id", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn update_custom_preset(
|
||||
Path(id): Path<String>,
|
||||
ApiJson(input): ApiJson<crate::vdisplay::policy::CustomPresetInput>,
|
||||
) -> Response {
|
||||
if input.name.trim().is_empty() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty");
|
||||
}
|
||||
match crate::vdisplay::policy::update_custom_preset(&id, input) {
|
||||
Ok(Some(preset)) => Json(preset).into_response(),
|
||||
Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a custom preset
|
||||
///
|
||||
/// Removes it from the catalog. The active policy is untouched — if this preset was the one applied,
|
||||
/// the running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled).
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/display/presets/{id}",
|
||||
tag = "display",
|
||||
operation_id = "deleteCustomPreset",
|
||||
params(("id" = String, Path, description = "The custom preset id")),
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Preset deleted"),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = NOT_FOUND, description = "No custom preset with that id", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn delete_custom_preset(Path(id): Path<String>) -> Response {
|
||||
match crate::vdisplay::policy::delete_custom_preset(&id) {
|
||||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! GPU-tagged management endpoints: inventory + automatic/preferred selection. Split out of the
|
||||
//! `mgmt` facade (plan §W5).
|
||||
|
||||
use super::shared::*;
|
||||
|
||||
/// One hardware GPU on the host (software/WARP adapters are never listed).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ApiGpu {
|
||||
/// Stable identifier (`vendorid-deviceid-occurrence`, hex PCI ids) — pass to `setGpuPreference`.
|
||||
/// Stable across reboots and driver updates, unlike an adapter index or LUID.
|
||||
#[schema(example = "10de-2c05-0")]
|
||||
id: String,
|
||||
/// Adapter/marketing name.
|
||||
#[schema(example = "NVIDIA GeForce RTX 5070 Ti")]
|
||||
name: String,
|
||||
/// `nvidia` | `amd` | `intel` | `other`.
|
||||
vendor: String,
|
||||
/// Dedicated VRAM in MiB (0 where the platform doesn't expose it).
|
||||
vram_mb: u64,
|
||||
}
|
||||
|
||||
/// The GPU the **next** session's pipeline will be created on, and why. (A preference change
|
||||
/// applies to the next session; a running session keeps the GPU it opened on.)
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ApiSelectedGpu {
|
||||
id: String,
|
||||
name: String,
|
||||
/// `nvidia` | `amd` | `intel` | `other`.
|
||||
vendor: String,
|
||||
/// Why this GPU was selected: `preference` (the manual choice), `env`
|
||||
/// (`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or
|
||||
/// `preference_missing` (a manual choice is set but that GPU is absent — auto-selected
|
||||
/// instead so the host keeps streaming).
|
||||
source: String,
|
||||
}
|
||||
|
||||
/// The GPU live sessions are encoding on right now.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ApiActiveGpu {
|
||||
/// Stable id matching an entry of `gpus` (empty for the CPU/software encoder).
|
||||
id: String,
|
||||
name: String,
|
||||
/// `nvidia` | `amd` | `intel` | `other`.
|
||||
vendor: String,
|
||||
/// The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`).
|
||||
backend: String,
|
||||
/// Number of live encode sessions on it.
|
||||
sessions: u32,
|
||||
}
|
||||
|
||||
/// Full GPU-selection state for the console: inventory, the persisted preference, what the next
|
||||
/// session will use, and what is in use right now.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct GpuState {
|
||||
/// The host's hardware GPUs.
|
||||
gpus: Vec<ApiGpu>,
|
||||
/// `auto` or `manual`.
|
||||
mode: String,
|
||||
/// The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so
|
||||
/// a console can offer returning to it). May reference a GPU that is currently absent.
|
||||
preferred_id: Option<String>,
|
||||
/// The stored name of the preferred GPU (a usable label even when it is absent).
|
||||
preferred_name: Option<String>,
|
||||
/// Whether the preferred GPU is currently present.
|
||||
preferred_available: bool,
|
||||
/// `PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is
|
||||
/// `auto`; a manual preference overrides it.
|
||||
env_override: Option<String>,
|
||||
/// The GPU the next session will use.
|
||||
selected: Option<ApiSelectedGpu>,
|
||||
/// The GPU live sessions use right now (absent while nothing is streaming).
|
||||
active: Option<ApiActiveGpu>,
|
||||
}
|
||||
|
||||
/// Request body for `setGpuPreference`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct SetGpuPreference {
|
||||
/// `auto` (env pin, else max dedicated VRAM — the default) or `manual`.
|
||||
#[schema(example = "manual")]
|
||||
mode: String,
|
||||
/// Required when `mode` is `manual`: the stable `id` of a currently listed GPU
|
||||
/// (see `listGpus`).
|
||||
#[schema(example = "10de-2c05-0")]
|
||||
gpu_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the [`GpuState`] snapshot (shared by the GET and the PUT's response).
|
||||
pub(crate) fn gpu_state() -> GpuState {
|
||||
let gpus = crate::gpu::enumerate();
|
||||
let pref = crate::gpu::prefs().get();
|
||||
let (preferred_id, preferred_name, preferred_available) = match &pref.gpu {
|
||||
Some(want) => {
|
||||
let found = crate::gpu::find_preferred(&gpus, want);
|
||||
let id = match found {
|
||||
// Canonical: the present GPU's id (identity may have matched loosely).
|
||||
Some(i) => gpus[i].id.clone(),
|
||||
None => format!(
|
||||
"{:04x}-{:04x}-{}",
|
||||
want.vendor_id, want.device_id, want.occurrence
|
||||
),
|
||||
};
|
||||
let name = match found {
|
||||
Some(i) => gpus[i].name.clone(),
|
||||
None => want.name.clone(),
|
||||
};
|
||||
(Some(id), Some(name), found.is_some())
|
||||
}
|
||||
None => (None, None, false),
|
||||
};
|
||||
let selected = crate::gpu::selected_gpu().map(|sel| ApiSelectedGpu {
|
||||
vendor: sel.info.vendor_tag().into(),
|
||||
id: sel.info.id,
|
||||
name: sel.info.name,
|
||||
source: sel.source.tag().into(),
|
||||
});
|
||||
let active = crate::gpu::active().and_then(|(g, sessions)| {
|
||||
(sessions > 0).then(|| ApiActiveGpu {
|
||||
vendor: crate::gpu::vendor_tag(g.vendor_id).into(),
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
backend: g.backend.into(),
|
||||
sessions,
|
||||
})
|
||||
});
|
||||
GpuState {
|
||||
gpus: gpus
|
||||
.into_iter()
|
||||
.map(|g| ApiGpu {
|
||||
vendor: g.vendor_tag().into(),
|
||||
vram_mb: g.vram_bytes / (1024 * 1024),
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
})
|
||||
.collect(),
|
||||
mode: match pref.mode {
|
||||
crate::gpu::GpuMode::Auto => "auto".into(),
|
||||
crate::gpu::GpuMode::Manual => "manual".into(),
|
||||
},
|
||||
preferred_id,
|
||||
preferred_name,
|
||||
preferred_available,
|
||||
env_override: crate::config::config()
|
||||
.render_adapter
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty()),
|
||||
selected,
|
||||
active,
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU inventory and selection
|
||||
///
|
||||
/// Lists the host's hardware GPUs, the persisted auto/manual preference, the GPU the next session
|
||||
/// will use (and why), and the GPU live sessions encode on right now.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/gpus",
|
||||
tag = "gpu",
|
||||
operation_id = "listGpus",
|
||||
responses(
|
||||
(status = OK, description = "GPU inventory + selection state", body = GpuState),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_gpus() -> Json<GpuState> {
|
||||
Json(gpu_state())
|
||||
}
|
||||
|
||||
/// Set the GPU preference
|
||||
///
|
||||
/// `auto` restores automatic selection (`PUNKTFUNK_RENDER_ADAPTER` pin, else max dedicated VRAM);
|
||||
/// `manual` pins capture + encode to the given GPU. Persisted across restarts; applies to the
|
||||
/// **next** session (a running session keeps its GPU). If the preferred GPU is absent at session
|
||||
/// start the host falls back to automatic selection rather than failing.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/gpus/preference",
|
||||
tag = "gpu",
|
||||
operation_id = "setGpuPreference",
|
||||
request_body = SetGpuPreference,
|
||||
responses(
|
||||
(status = OK, description = "Preference stored; the new selection state", body = GpuState),
|
||||
(status = BAD_REQUEST, description = "Unknown mode, or `gpu_id` missing / not a listed GPU", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Preference could not be persisted", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn set_gpu_preference(ApiJson(req): ApiJson<SetGpuPreference>) -> Response {
|
||||
let pref = match req.mode.to_ascii_lowercase().as_str() {
|
||||
"auto" => {
|
||||
// Keep the stored manual pick so the console can offer switching back to it.
|
||||
let mut p = crate::gpu::prefs().get();
|
||||
p.mode = crate::gpu::GpuMode::Auto;
|
||||
p
|
||||
}
|
||||
"manual" => {
|
||||
let Some(id) = req
|
||||
.gpu_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
return api_error(StatusCode::BAD_REQUEST, "mode `manual` requires `gpu_id`");
|
||||
};
|
||||
let Some(g) = crate::gpu::enumerate().into_iter().find(|g| g.id == id) else {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"gpu_id does not match a present GPU (see GET /gpus)",
|
||||
);
|
||||
};
|
||||
crate::gpu::GpuPreference {
|
||||
mode: crate::gpu::GpuMode::Manual,
|
||||
gpu: Some(crate::gpu::PreferredGpu {
|
||||
vendor_id: g.vendor_id,
|
||||
device_id: g.device_id,
|
||||
occurrence: g.occurrence,
|
||||
name: g.name,
|
||||
}),
|
||||
}
|
||||
}
|
||||
other => {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("unknown mode {other:?} — use `auto` or `manual`"),
|
||||
)
|
||||
}
|
||||
};
|
||||
if let Err(e) = crate::gpu::prefs().set(pref) {
|
||||
return api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("persist GPU preference: {e:#}"),
|
||||
);
|
||||
}
|
||||
tracing::info!(mode = %req.mode, gpu_id = ?req.gpu_id, "management API: GPU preference updated");
|
||||
Json(gpu_state()).into_response()
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Host-tagged management endpoints: identity + capabilities, liveness, compositor list, live
|
||||
//! runtime status, and the loopback tray summary. Split out of the `mgmt` facade (plan §W5).
|
||||
|
||||
use super::shared::*;
|
||||
use crate::encode::Codec;
|
||||
use crate::gamestream::APP_VERSION;
|
||||
use crate::gamestream::AUDIO_PORT;
|
||||
use crate::gamestream::CONTROL_PORT;
|
||||
use crate::gamestream::GFE_VERSION;
|
||||
use crate::gamestream::RTSP_PORT;
|
||||
use crate::gamestream::VIDEO_PORT;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
/// Liveness + version probe.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct Health {
|
||||
/// Always `"ok"` when the host responds.
|
||||
#[schema(example = "ok")]
|
||||
status: String,
|
||||
/// `punktfunk-host` crate version.
|
||||
version: String,
|
||||
/// `punktfunk-core` C ABI version.
|
||||
abi_version: u32,
|
||||
}
|
||||
|
||||
/// Host identity and advertised capabilities (static for the life of the process).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct HostInfo {
|
||||
hostname: String,
|
||||
/// Stable per-host id (persisted across restarts), matched on pairing.
|
||||
uniqueid: String,
|
||||
/// Best-effort primary LAN IP.
|
||||
local_ip: String,
|
||||
/// `punktfunk-host` crate version.
|
||||
version: String,
|
||||
/// `punktfunk-core` C ABI version.
|
||||
abi_version: u32,
|
||||
/// GameStream host version advertised to Moonlight clients.
|
||||
app_version: String,
|
||||
/// GFE version advertised to Moonlight clients.
|
||||
gfe_version: String,
|
||||
/// Codecs the host can encode (NVENC).
|
||||
codecs: Vec<ApiCodec>,
|
||||
/// Whether the GameStream/Moonlight-compat planes are running (`--gamestream`). `false` on the
|
||||
/// secure default (native punktfunk/1 only) — a console can hide Moonlight-only UI (e.g. the
|
||||
/// Moonlight PIN pairing card, which could never receive a PIN when this is `false`).
|
||||
gamestream: bool,
|
||||
ports: PortMap,
|
||||
}
|
||||
|
||||
/// Every port a client integration may need (Moonlight derives the stream ports from the
|
||||
/// HTTP base; a control pane should not have to).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct PortMap {
|
||||
/// This management API.
|
||||
mgmt: u16,
|
||||
/// nvhttp plain HTTP (serverinfo, pairing).
|
||||
http: u16,
|
||||
/// nvhttp mutual-TLS HTTPS (post-pairing).
|
||||
https: u16,
|
||||
rtsp: u16,
|
||||
video: u16,
|
||||
control: u16,
|
||||
audio: u16,
|
||||
}
|
||||
|
||||
/// Video codec identifier. The wire token matches the codec's canonical name used across the
|
||||
/// stack (SDP/GameStream advertisement, the stats-capture `CaptureMeta.codec`, and the encoder's
|
||||
/// [`Codec::label`]) — notably `H.265` serializes as `"hevc"`, not `"h265"`, so the same codec
|
||||
/// reads identically on every console page.
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum ApiCodec {
|
||||
H264,
|
||||
#[serde(rename = "hevc")]
|
||||
H265,
|
||||
Av1,
|
||||
/// PyroWave — the opt-in wired-LAN intra-only wavelet codec.
|
||||
PyroWave,
|
||||
}
|
||||
|
||||
impl From<Codec> for ApiCodec {
|
||||
fn from(c: Codec) -> Self {
|
||||
match c {
|
||||
Codec::H264 => ApiCodec::H264,
|
||||
Codec::H265 => ApiCodec::H265,
|
||||
Codec::Av1 => ApiCodec::Av1,
|
||||
Codec::PyroWave => ApiCodec::PyroWave,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live host status (changes as clients launch/end sessions).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct RuntimeStatus {
|
||||
/// True while the video stream thread is running.
|
||||
video_streaming: bool,
|
||||
/// True while the audio stream thread is running.
|
||||
audio_streaming: bool,
|
||||
/// True while a pairing handshake is parked waiting for the user's PIN
|
||||
/// (submit it via `POST /api/v1/pair/pin`).
|
||||
pin_pending: bool,
|
||||
/// Number of pinned (paired) client certificates.
|
||||
paired_clients: u32,
|
||||
/// Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The
|
||||
/// native server admits concurrent sessions, so this can exceed 1; `session`/`stream` below
|
||||
/// describe a single representative session for the detail card.
|
||||
active_sessions: u32,
|
||||
/// A representative active session. GameStream's launch (Moonlight `/launch`) when present, else
|
||||
/// the first live native session. `null` when nothing is streaming.
|
||||
session: Option<SessionInfo>,
|
||||
/// The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's
|
||||
/// mode/codec/bitrate. `null` when nothing is streaming.
|
||||
stream: Option<StreamInfo>,
|
||||
}
|
||||
|
||||
/// Client-requested launch parameters (key material is never exposed here).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct SessionInfo {
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
}
|
||||
|
||||
/// RTSP-negotiated stream parameters.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct StreamInfo {
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_kbps: u32,
|
||||
/// Video payload size per packet (bytes).
|
||||
packet_size: u32,
|
||||
/// Client's parity floor per FEC block (`minRequiredFecPackets`).
|
||||
min_fec: u8,
|
||||
codec: ApiCodec,
|
||||
}
|
||||
|
||||
/// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,
|
||||
/// no fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see
|
||||
/// `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the
|
||||
/// per-user tray process cannot authenticate — this narrow read-only route is its status source.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct LocalSummary {
|
||||
/// Host version (mirrors `/health`).
|
||||
version: String,
|
||||
/// True while the video stream thread is running.
|
||||
video_streaming: bool,
|
||||
/// True while the audio stream thread is running.
|
||||
audio_streaming: bool,
|
||||
/// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop).
|
||||
session: Option<SessionInfo>,
|
||||
/// Number of pinned (paired) GameStream client certificates.
|
||||
paired_clients: u32,
|
||||
/// Number of paired native (punktfunk/1) devices.
|
||||
native_paired_clients: u32,
|
||||
/// True while a GameStream pairing handshake is parked waiting for the user's PIN.
|
||||
pin_pending: bool,
|
||||
/// Native pairing knocks awaiting the operator's approval (count only).
|
||||
pending_approvals: u32,
|
||||
/// Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned
|
||||
/// (`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is
|
||||
/// held; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.
|
||||
kept_displays: u32,
|
||||
/// Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —
|
||||
/// running one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);
|
||||
/// the tray/console surface them so the clash is visible before pairing silently fails.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
conflicts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Liveness probe
|
||||
///
|
||||
/// Always available without authentication.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
tag = "host",
|
||||
operation_id = "getHealth",
|
||||
// Override the document-global bearerAuth: this route is exempt in `require_auth`.
|
||||
security(()),
|
||||
responses((status = OK, description = "Host is up", body = Health))
|
||||
)]
|
||||
pub(crate) async fn get_health() -> Json<Health> {
|
||||
Json(Health {
|
||||
status: "ok".into(),
|
||||
version: env!("PUNKTFUNK_VERSION").into(),
|
||||
abi_version: punktfunk_core::ABI_VERSION,
|
||||
})
|
||||
}
|
||||
|
||||
/// Host identity and capabilities
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/host",
|
||||
tag = "host",
|
||||
operation_id = "getHostInfo",
|
||||
responses(
|
||||
(status = OK, description = "Host identity, versions, codecs, and port map", body = HostInfo),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_host_info(State(st): State<Arc<MgmtState>>) -> Json<HostInfo> {
|
||||
let h = &st.app.host;
|
||||
Json(HostInfo {
|
||||
hostname: h.hostname.clone(),
|
||||
uniqueid: h.uniqueid.clone(),
|
||||
local_ip: h.local_ip.to_string(),
|
||||
version: env!("PUNKTFUNK_VERSION").into(),
|
||||
abi_version: punktfunk_core::ABI_VERSION,
|
||||
app_version: APP_VERSION.into(),
|
||||
gfe_version: GFE_VERSION.into(),
|
||||
// What this host can ACTUALLY encode on its resolved backend — GPU-aware, straight from the
|
||||
// same capability mask that drives GameStream/QUIC negotiation ([`Codec::host_wire_caps`]).
|
||||
// So an iGPU without AV1 encode won't advertise AV1, a software-only host reports H.264 only,
|
||||
// and PyroWave appears only when its opt-in feature is built and the backend can open.
|
||||
codecs: {
|
||||
let caps = Codec::host_wire_caps();
|
||||
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC, CODEC_PYROWAVE};
|
||||
[
|
||||
(CODEC_H264, ApiCodec::H264),
|
||||
(CODEC_HEVC, ApiCodec::H265),
|
||||
(CODEC_AV1, ApiCodec::Av1),
|
||||
(CODEC_PYROWAVE, ApiCodec::PyroWave),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(bit, _)| caps & bit != 0)
|
||||
.map(|(_, codec)| codec)
|
||||
.collect()
|
||||
},
|
||||
gamestream: st.gamestream_enabled,
|
||||
ports: PortMap {
|
||||
mgmt: st.port,
|
||||
http: h.http_port,
|
||||
https: h.https_port,
|
||||
rtsp: RTSP_PORT,
|
||||
video: VIDEO_PORT,
|
||||
control: CONTROL_PORT,
|
||||
audio: AUDIO_PORT,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// A compositor backend the host can drive a virtual output on, and whether it's usable now.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct AvailableCompositor {
|
||||
/// Stable identifier (`"kwin"` | `"wlroots"` | `"mutter"` | `"gamescope"`) — pass this to a
|
||||
/// client's `--compositor` flag.
|
||||
id: String,
|
||||
/// Human-readable label for UIs.
|
||||
label: String,
|
||||
/// Usable on this host right now: the live session's own compositor, or gamescope wherever
|
||||
/// its binary is installed.
|
||||
available: bool,
|
||||
/// True for the backend an `Auto` (unspecified) request resolves to right now.
|
||||
default: bool,
|
||||
}
|
||||
|
||||
/// Available compositor backends
|
||||
///
|
||||
/// Lists every backend the host knows how to drive, flags which are usable right now, and marks
|
||||
/// the one an unspecified (`Auto`) client request resolves to. Clients pass an `id` to their
|
||||
/// `--compositor` flag (or `PUNKTFUNK_COMPOSITOR_*` over the C ABI) to request it.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/compositors",
|
||||
tag = "host",
|
||||
operation_id = "listCompositors",
|
||||
responses(
|
||||
(status = OK, description = "Compositor backends with availability + the auto-detected default", body = [AvailableCompositor]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_compositors() -> Json<Vec<AvailableCompositor>> {
|
||||
let available = crate::vdisplay::available();
|
||||
let default = crate::vdisplay::detect().ok();
|
||||
Json(
|
||||
crate::vdisplay::Compositor::all()
|
||||
.into_iter()
|
||||
.map(|c| AvailableCompositor {
|
||||
id: c.id().into(),
|
||||
label: c.label().into(),
|
||||
available: available.contains(&c),
|
||||
default: default == Some(c),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Live host status
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/status",
|
||||
tag = "host",
|
||||
operation_id = "getStatus",
|
||||
responses(
|
||||
(status = OK, description = "Streaming/pairing state and the active session, if any", body = RuntimeStatus),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<RuntimeStatus> {
|
||||
// GameStream plane (set by RTSP/nvhttp on the compat path).
|
||||
let gs_launch = *st.app.launch.lock().unwrap();
|
||||
let gs_stream = *st.app.stream.lock().unwrap();
|
||||
let gs_video = st.app.streaming.load(Ordering::SeqCst);
|
||||
let gs_audio = st.app.audio_streaming.load(Ordering::SeqCst);
|
||||
// Native punktfunk/1 plane (published by the native video loop; the default plane). See
|
||||
// [`crate::session_status`] for why this lives outside `AppState`.
|
||||
let native = crate::session_status::snapshot();
|
||||
|
||||
// Detail card is singular: prefer a live GameStream session, else the first native one.
|
||||
// `active_sessions` conveys the true count when several native clients stream at once.
|
||||
let session = gs_launch
|
||||
.map(|l| SessionInfo {
|
||||
width: l.width,
|
||||
height: l.height,
|
||||
fps: l.fps,
|
||||
})
|
||||
.or_else(|| {
|
||||
native.first().map(|s| SessionInfo {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
fps: s.fps,
|
||||
})
|
||||
});
|
||||
let stream = gs_stream
|
||||
.map(|c| StreamInfo {
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
fps: c.fps,
|
||||
bitrate_kbps: c.bitrate_kbps,
|
||||
packet_size: c.packet_size as u32,
|
||||
min_fec: c.min_fec,
|
||||
codec: c.codec.into(),
|
||||
})
|
||||
.or_else(|| {
|
||||
native.first().map(|s| StreamInfo {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
fps: s.fps,
|
||||
bitrate_kbps: s.bitrate_kbps,
|
||||
// FEC/packetization are RTSP-negotiated (GameStream only); the native QUIC plane
|
||||
// shards differently, so these are 0 (not applicable) for a native session.
|
||||
packet_size: 0,
|
||||
min_fec: 0,
|
||||
codec: s.codec.into(),
|
||||
})
|
||||
});
|
||||
Json(RuntimeStatus {
|
||||
video_streaming: gs_video || !native.is_empty(),
|
||||
audio_streaming: gs_audio || !native.is_empty(),
|
||||
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
||||
paired_clients: st.app.paired.lock().unwrap().len() as u32,
|
||||
active_sessions: native.len() as u32 + u32::from(gs_video),
|
||||
session,
|
||||
stream,
|
||||
})
|
||||
}
|
||||
|
||||
/// Local status summary for the tray icon
|
||||
///
|
||||
/// Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device
|
||||
/// names). Unauthenticated, but served to loopback peers only.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/local/summary",
|
||||
tag = "host",
|
||||
operation_id = "getLocalSummary",
|
||||
// Override the document-global bearerAuth: loopback peers are exempt in `require_auth`.
|
||||
security(()),
|
||||
responses(
|
||||
(status = OK, description = "Non-sensitive local host status (loopback peers only)", body = LocalSummary),
|
||||
(status = UNAUTHORIZED, description = "Non-loopback peer", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
|
||||
// GameStream launch, else the first live native session — so the tray reflects a native session
|
||||
// too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`).
|
||||
let session = st
|
||||
.app
|
||||
.launch
|
||||
.lock()
|
||||
.unwrap()
|
||||
.map(|l| SessionInfo {
|
||||
width: l.width,
|
||||
height: l.height,
|
||||
fps: l.fps,
|
||||
})
|
||||
.or_else(|| {
|
||||
crate::session_status::snapshot()
|
||||
.first()
|
||||
.map(|s| SessionInfo {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
fps: s.fps,
|
||||
})
|
||||
});
|
||||
let (native_paired_clients, pending_approvals) = st
|
||||
.native
|
||||
.as_ref()
|
||||
.map(|n| (n.status().paired_clients, n.pending().len() as u32))
|
||||
.unwrap_or((0, 0));
|
||||
Json(LocalSummary {
|
||||
version: env!("PUNKTFUNK_VERSION").into(),
|
||||
video_streaming: st.app.streaming.load(Ordering::SeqCst),
|
||||
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
|
||||
session,
|
||||
paired_clients: st.app.paired.lock().unwrap().len() as u32,
|
||||
native_paired_clients,
|
||||
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
||||
pending_approvals,
|
||||
kept_displays: crate::vdisplay::registry::snapshot()
|
||||
.displays
|
||||
.iter()
|
||||
.filter(|d| d.state == "lingering" || d.state == "pinned")
|
||||
.count() as u32,
|
||||
// Cached at `serve` startup (empty when nothing was detected / never scanned) — no per-poll
|
||||
// process enumeration.
|
||||
conflicts: crate::detect::summary_labels(crate::detect::snapshot()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Library-tagged management endpoints: installed-store + custom game entries and box art.
|
||||
//! Split out of the `mgmt` facade (plan §W5).
|
||||
|
||||
use super::shared::*;
|
||||
use axum::http::header;
|
||||
|
||||
/// List the game library
|
||||
///
|
||||
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
|
||||
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
|
||||
/// fetches directly (the public Steam CDN for Steam titles).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/library",
|
||||
tag = "library",
|
||||
operation_id = "getLibrary",
|
||||
responses(
|
||||
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_library() -> Json<Vec<crate::library::GameEntry>> {
|
||||
Json(crate::library::all_games())
|
||||
}
|
||||
|
||||
/// Add a custom library entry
|
||||
///
|
||||
/// Creates a user-curated title (e.g. a non-Steam game, an emulator, a ROM) with caller-supplied
|
||||
/// artwork URLs. The host assigns a stable id, returned in the body.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/library/custom",
|
||||
tag = "library",
|
||||
operation_id = "createCustomGame",
|
||||
request_body = crate::library::CustomInput,
|
||||
responses(
|
||||
(status = CREATED, description = "Entry created", body = crate::library::CustomEntry),
|
||||
(status = BAD_REQUEST, description = "Empty title", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn create_custom_game(
|
||||
ApiJson(input): ApiJson<crate::library::CustomInput>,
|
||||
) -> Response {
|
||||
if input.title.trim().is_empty() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
||||
}
|
||||
match crate::library::add_custom(input) {
|
||||
Ok(entry) => (StatusCode::CREATED, Json(entry)).into_response(),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a custom library entry
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/library/custom/{id}",
|
||||
tag = "library",
|
||||
operation_id = "updateCustomGame",
|
||||
params(("id" = String, Path, description = "The custom entry id (without the `custom:` prefix)")),
|
||||
request_body = crate::library::CustomInput,
|
||||
responses(
|
||||
(status = OK, description = "Entry updated", body = crate::library::CustomEntry),
|
||||
(status = BAD_REQUEST, description = "Empty title", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = NOT_FOUND, description = "No custom entry with that id", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn update_custom_game(
|
||||
Path(id): Path<String>,
|
||||
ApiJson(input): ApiJson<crate::library::CustomInput>,
|
||||
) -> Response {
|
||||
if input.title.trim().is_empty() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
||||
}
|
||||
match crate::library::update_custom(&id, input) {
|
||||
Ok(Some(entry)) => Json(entry).into_response(),
|
||||
Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a custom library entry
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/library/custom/{id}",
|
||||
tag = "library",
|
||||
operation_id = "deleteCustomGame",
|
||||
params(("id" = String, Path, description = "The custom entry id (without the `custom:` prefix)")),
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Entry deleted"),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = NOT_FOUND, description = "No custom entry with that id", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
|
||||
match crate::library::delete_custom(&id) {
|
||||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch one cover-art image for a library entry
|
||||
///
|
||||
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
||||
/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
|
||||
/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
|
||||
/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
|
||||
/// can't predict, in which case this 404s and the client falls through to its next art candidate).
|
||||
/// Only Steam ids are backed today; any other store 404s.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/library/art/{id}/{kind}",
|
||||
tag = "library",
|
||||
operation_id = "getLibraryArt",
|
||||
params(
|
||||
("id" = String, Path, description = "The store-qualified library id, e.g. `steam:570`"),
|
||||
("kind" = String, Path, description = "`portrait` | `hero` | `logo` | `header`"),
|
||||
),
|
||||
responses(
|
||||
(status = OK, description = "Image bytes", content_type = "image/jpeg"),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid credentials", body = ApiError),
|
||||
(status = NOT_FOUND, description = "No art of that kind for that id", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) -> Response {
|
||||
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
|
||||
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
|
||||
};
|
||||
let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
else {
|
||||
return api_error(StatusCode::NOT_FOUND, "no art proxy for this store");
|
||||
};
|
||||
match tokio::task::spawn_blocking(move || crate::library::steam_art_bytes(appid, kind)).await {
|
||||
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Native (punktfunk/1) pairing endpoints: arm/disarm a window, paired-device management, and
|
||||
//! delegated approval of pending knocks. Split out of the `mgmt` facade (plan §W5).
|
||||
|
||||
use super::shared::*;
|
||||
|
||||
/// Native (punktfunk/1) pairing status. Unlike GameStream, the **host** mints the PIN (the SPAKE2
|
||||
/// ceremony needs it client-side first), so the console **displays** `pin` for the user to enter on
|
||||
/// their device — armed on demand for a short window.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct NativePairStatus {
|
||||
/// Whether the native host is running (the unified host started with `--native`).
|
||||
enabled: bool,
|
||||
/// True while a pairing window is open.
|
||||
armed: bool,
|
||||
/// The PIN to display while armed (null when disarmed).
|
||||
#[schema(example = "1234")]
|
||||
pin: Option<String>,
|
||||
/// Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag).
|
||||
expires_in_secs: Option<u64>,
|
||||
/// Number of paired native clients.
|
||||
paired_clients: u32,
|
||||
}
|
||||
|
||||
/// Arm-native-pairing request body.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ArmNativePairing {
|
||||
/// Window length in seconds (default 120; clamped to 15–600).
|
||||
#[schema(example = 120)]
|
||||
ttl_secs: Option<u32>,
|
||||
/// Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).
|
||||
/// When set, only a pairing attempt from that fingerprint consumes the window — so an unpaired
|
||||
/// LAN peer can neither pair nor burn a window armed for a specific device (security-review #9).
|
||||
/// Omit for an unbound window (any device may use the PIN — trusted-LAN only).
|
||||
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
|
||||
fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
/// A paired native (punktfunk/1) client.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct NativeClient {
|
||||
/// The name the client supplied when pairing.
|
||||
#[schema(example = "Living Room iPad")]
|
||||
name: String,
|
||||
/// Hex SHA-256 of the client certificate — its stable id here.
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
/// An unpaired device that tried to connect while the host requires pairing — awaiting
|
||||
/// **delegated approval** (approve it here instead of fetching the host PIN out of band).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct PendingDevice {
|
||||
/// Id to address approve/deny (per-process; entries expire after ~10 minutes).
|
||||
id: u32,
|
||||
/// Best-effort device label (the client's own name, else fingerprint-derived).
|
||||
#[schema(example = "Enrico's MacBook")]
|
||||
name: String,
|
||||
/// Hex SHA-256 of the device's certificate — what approval pins.
|
||||
fingerprint: String,
|
||||
/// Seconds since the device last knocked.
|
||||
age_secs: u64,
|
||||
}
|
||||
|
||||
/// Approve-pending-device request body. Send `{}` to keep the device's own name.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ApprovePending {
|
||||
/// Operator-chosen label for the device (defaults to the name it knocked with).
|
||||
#[schema(example = "Living Room TV")]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn native_status(st: &MgmtState) -> NativePairStatus {
|
||||
match &st.native {
|
||||
Some(np) => {
|
||||
let s = np.status();
|
||||
NativePairStatus {
|
||||
enabled: true,
|
||||
armed: s.armed,
|
||||
pin: s.pin,
|
||||
expires_in_secs: s.expires_in_secs,
|
||||
paired_clients: s.paired_clients,
|
||||
}
|
||||
}
|
||||
None => NativePairStatus {
|
||||
enabled: false,
|
||||
armed: false,
|
||||
pin: None,
|
||||
expires_in_secs: None,
|
||||
paired_clients: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Native pairing status
|
||||
///
|
||||
/// The native (punktfunk/1) pairing window. Poll while armed to show the PIN + countdown.
|
||||
/// `enabled: false` means this host runs GameStream only (no `--native`).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/native/pair",
|
||||
tag = "native",
|
||||
operation_id = "getNativePairing",
|
||||
responses(
|
||||
(status = OK, description = "Native pairing status", body = NativePairStatus),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_native_pairing(State(st): State<Arc<MgmtState>>) -> Json<NativePairStatus> {
|
||||
Json(native_status(&st))
|
||||
}
|
||||
|
||||
/// Arm native pairing
|
||||
///
|
||||
/// Opens a pairing window and mints a fresh PIN to display. The user enters it on their device
|
||||
/// within `ttl_secs`; the device then appears in the native client list.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/native/pair/arm",
|
||||
tag = "native",
|
||||
operation_id = "armNativePairing",
|
||||
request_body = ArmNativePairing,
|
||||
responses(
|
||||
(status = OK, description = "Pairing armed; the response carries the PIN to display", body = NativePairStatus),
|
||||
(status = SERVICE_UNAVAILABLE, description = "Native host not available in this process", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn arm_native_pairing(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
ApiJson(req): ApiJson<ArmNativePairing>,
|
||||
) -> Response {
|
||||
let Some(np) = &st.native else {
|
||||
return api_error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"native host not available in this process",
|
||||
);
|
||||
};
|
||||
let ttl = req.ttl_secs.unwrap_or(120).clamp(15, 600);
|
||||
// A bound window (operator selected a specific device) is DoS-proof: only that fingerprint can
|
||||
// consume it (#9). An unbound window (no fingerprint) keeps the legacy any-device behavior.
|
||||
let bound = req
|
||||
.fingerprint
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
let bound_to_device = bound.is_some();
|
||||
let _pin = np.arm_for(std::time::Duration::from_secs(ttl as u64), bound);
|
||||
tracing::info!(
|
||||
ttl_secs = ttl,
|
||||
bound_to_device,
|
||||
"management API: native pairing armed"
|
||||
);
|
||||
Json(native_status(&st)).into_response()
|
||||
}
|
||||
|
||||
/// Disarm native pairing
|
||||
///
|
||||
/// Closes the pairing window immediately (no new ceremonies accepted).
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/native/pair",
|
||||
tag = "native",
|
||||
operation_id = "disarmNativePairing",
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Pairing disarmed"),
|
||||
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn disarm_native_pairing(State(st): State<Arc<MgmtState>>) -> Response {
|
||||
let Some(np) = &st.native else {
|
||||
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
|
||||
};
|
||||
np.disarm();
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
/// List native paired clients
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/native/clients",
|
||||
tag = "native",
|
||||
operation_id = "listNativeClients",
|
||||
responses(
|
||||
(status = OK, description = "Paired native clients", body = [NativeClient]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_native_clients(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
) -> Json<Vec<NativeClient>> {
|
||||
let clients = match &st.native {
|
||||
Some(np) => np
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|c| NativeClient {
|
||||
name: c.name,
|
||||
fingerprint: c.fingerprint,
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
Json(clients)
|
||||
}
|
||||
|
||||
/// Unpair a native client
|
||||
///
|
||||
/// Removes a punktfunk/1 client from the native trust store by fingerprint.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/native/clients/{fingerprint}",
|
||||
tag = "native",
|
||||
operation_id = "unpairNativeClient",
|
||||
params(
|
||||
("fingerprint" = String, Path,
|
||||
description = "Hex SHA-256 of the client certificate (case-insensitive)")
|
||||
),
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Client unpaired"),
|
||||
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
|
||||
(status = NOT_FOUND, description = "No paired native client with that fingerprint", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn unpair_native_client(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> Response {
|
||||
let Some(np) = &st.native else {
|
||||
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
|
||||
};
|
||||
match np.remove(&fingerprint) {
|
||||
Ok(true) => {
|
||||
tracing::info!(fingerprint, "management API: native client unpaired");
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Ok(false) => api_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"no paired native client with that fingerprint",
|
||||
),
|
||||
Err(e) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("could not persist trust store: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// List devices awaiting pairing approval
|
||||
///
|
||||
/// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair
|
||||
/// it without a PIN (delegated approval); entries expire after ~10 minutes.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/native/pending",
|
||||
tag = "native",
|
||||
operation_id = "listPendingDevices",
|
||||
responses(
|
||||
(status = OK, description = "Devices awaiting approval (empty when none, or when the \
|
||||
native host is not enabled)", body = Vec<PendingDevice>),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_pending_devices(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
) -> Json<Vec<PendingDevice>> {
|
||||
let pending = st
|
||||
.native
|
||||
.as_ref()
|
||||
.map(|np| np.pending())
|
||||
.unwrap_or_default();
|
||||
Json(
|
||||
pending
|
||||
.into_iter()
|
||||
.map(|p| PendingDevice {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
fingerprint: p.fingerprint,
|
||||
age_secs: p.age_secs,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Approve a pending device
|
||||
///
|
||||
/// Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally
|
||||
/// relabel it via the body; send `{}` to keep the name it knocked with.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/native/pending/{id}/approve",
|
||||
tag = "native",
|
||||
operation_id = "approvePendingDevice",
|
||||
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
|
||||
request_body = ApprovePending,
|
||||
responses(
|
||||
(status = OK, description = "Device paired", body = NativeClient),
|
||||
(status = NOT_FOUND, description = "No pending request with that id (expired?)", body = ApiError),
|
||||
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn approve_pending_device(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
Path(id): Path<u32>,
|
||||
ApiJson(req): ApiJson<ApprovePending>,
|
||||
) -> Response {
|
||||
let Some(np) = &st.native else {
|
||||
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
|
||||
};
|
||||
match np.approve_pending(id, req.name.as_deref()) {
|
||||
Ok(Some(client)) => {
|
||||
tracing::info!(name = %client.name, fingerprint = %client.fingerprint,
|
||||
"management API: pending device approved (delegated pairing)");
|
||||
Json(NativeClient {
|
||||
name: client.name,
|
||||
fingerprint: client.fingerprint,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Ok(None) => api_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"no pending request with that id (it may have expired — have the device retry)",
|
||||
),
|
||||
Err(e) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("could not persist trust store: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deny a pending device
|
||||
///
|
||||
/// Drops the request. Not a blocklist — the device's next attempt knocks again.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/native/pending/{id}/deny",
|
||||
tag = "native",
|
||||
operation_id = "denyPendingDevice",
|
||||
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Request dropped"),
|
||||
(status = NOT_FOUND, description = "No pending request with that id", body = ApiError),
|
||||
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn deny_pending_device(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
Path(id): Path<u32>,
|
||||
) -> Response {
|
||||
let Some(np) = &st.native else {
|
||||
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
|
||||
};
|
||||
if np.deny_pending(id) {
|
||||
tracing::info!(id, "management API: pending device denied");
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
} else {
|
||||
api_error(StatusCode::NOT_FOUND, "no pending request with that id")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Session-tagged management endpoints: stop the active session, force an IDR. Split out of the
|
||||
//! `mgmt` facade (plan §W5).
|
||||
|
||||
use super::shared::*;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
/// Stop the active session
|
||||
///
|
||||
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
|
||||
/// state. Idempotent — succeeds even when nothing is streaming.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/session",
|
||||
tag = "session",
|
||||
operation_id = "stopSession",
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Session stopped (or none was active)"),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode {
|
||||
let was_streaming = st.app.streaming.swap(false, Ordering::SeqCst);
|
||||
st.app.audio_streaming.store(false, Ordering::SeqCst);
|
||||
*st.app.launch.lock().unwrap() = None;
|
||||
*st.app.stream.lock().unwrap() = None;
|
||||
// Native plane: the GameStream flags above don't reach it (it runs its own loops off the shared
|
||||
// session registry), so signal every live native session to tear down too.
|
||||
let native = crate::session_status::count();
|
||||
crate::session_status::stop_all();
|
||||
tracing::info!(
|
||||
was_streaming,
|
||||
native_sessions = native,
|
||||
"management API: session stopped"
|
||||
);
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
/// Force a keyframe
|
||||
///
|
||||
/// Asks the encoder for an IDR frame on the active video stream (what a client requests
|
||||
/// after unrecoverable loss — exposed for debugging).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/session/idr",
|
||||
tag = "session",
|
||||
operation_id = "requestIdr",
|
||||
responses(
|
||||
(status = ACCEPTED, description = "Keyframe requested"),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = CONFLICT, description = "No active video stream", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn request_idr(State(st): State<Arc<MgmtState>>) -> Response {
|
||||
let gs = st.app.streaming.load(Ordering::SeqCst);
|
||||
let native = crate::session_status::count();
|
||||
if !gs && native == 0 {
|
||||
return api_error(StatusCode::CONFLICT, "no active video stream");
|
||||
}
|
||||
if gs {
|
||||
st.app.force_idr.store(true, Ordering::SeqCst);
|
||||
}
|
||||
// Native sessions get the keyframe request through their registry flag (see `session_status`).
|
||||
crate::session_status::force_idr_all();
|
||||
StatusCode::ACCEPTED.into_response()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Shared control-plane plumbing for the management submodules: the [`ApiError`] envelope
|
||||
//! every non-2xx response wears, [`api_error`], the [`ApiJson`] extractor that keeps axum's own
|
||||
//! rejections in that envelope, and a small re-export prelude of the axum/serde/utoipa vocabulary
|
||||
//! the handler modules share. Split out of the `mgmt` facade (plan §W5).
|
||||
|
||||
use axum::extract::Request;
|
||||
|
||||
// Re-export prelude: the vocabulary every handler submodule pulls in via `use super::shared::*`.
|
||||
pub(crate) use super::MgmtState;
|
||||
pub(crate) use axum::extract::{Path, Query, State};
|
||||
pub(crate) use axum::http::StatusCode;
|
||||
pub(crate) use axum::response::{IntoResponse, Response};
|
||||
pub(crate) use axum::Json;
|
||||
pub(crate) use serde::{Deserialize, Serialize};
|
||||
pub(crate) use std::sync::Arc;
|
||||
pub(crate) use utoipa::ToSchema;
|
||||
|
||||
/// Error envelope for every non-2xx response.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct ApiError {
|
||||
error: String,
|
||||
}
|
||||
|
||||
pub(crate) fn api_error(status: StatusCode, message: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(ApiError {
|
||||
error: message.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// `axum::Json` whose rejections (bad JSON → 400/422, wrong content-type → 415) are
|
||||
/// rewrapped in the [`ApiError`] envelope, keeping "every non-2xx body is `ApiError`" true.
|
||||
pub(crate) struct ApiJson<T>(pub(crate) T);
|
||||
|
||||
impl<S, T> axum::extract::FromRequest<S> for ApiJson<T>
|
||||
where
|
||||
Json<T>: axum::extract::FromRequest<S, Rejection = axum::extract::rejection::JsonRejection>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match Json::<T>::from_request(req, state).await {
|
||||
Ok(Json(value)) => Ok(ApiJson(value)),
|
||||
Err(rejection) => Err(api_error(rejection.status(), &rejection.body_text())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Stats/logs-tagged management endpoints: performance-capture control + time-series and the
|
||||
//! in-memory log stream. Split out of the `mgmt` facade (plan §W5).
|
||||
|
||||
use super::shared::*;
|
||||
use crate::log_capture::LogPage;
|
||||
use crate::stats_recorder::Capture;
|
||||
use crate::stats_recorder::CaptureMeta;
|
||||
use crate::stats_recorder::StatsStatus;
|
||||
|
||||
/// Start a stats capture
|
||||
///
|
||||
/// Arms a new performance-stats capture. Idempotent: if a capture is already running this returns
|
||||
/// the current status unchanged. While armed, the streaming loops emit aggregated samples (~ every
|
||||
/// 1–2 s) into the in-progress capture, readable live via `GET /stats/capture/live`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/stats/capture/start",
|
||||
tag = "stats",
|
||||
operation_id = "statsCaptureStart",
|
||||
responses(
|
||||
(status = OK, description = "Capture armed (or already running)", body = StatsStatus),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn stats_capture_start(State(st): State<Arc<MgmtState>>) -> Json<StatsStatus> {
|
||||
let status = st.stats.start();
|
||||
tracing::info!(
|
||||
started_unix_ms = status.started_unix_ms,
|
||||
"management API: stats capture armed"
|
||||
);
|
||||
Json(status)
|
||||
}
|
||||
|
||||
/// Stop the stats capture
|
||||
///
|
||||
/// Disarms the in-progress capture and writes it to disk atomically, returning its summary. If
|
||||
/// nothing was recording, returns `204 No Content`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/stats/capture/stop",
|
||||
tag = "stats",
|
||||
operation_id = "statsCaptureStop",
|
||||
responses(
|
||||
(status = OK, description = "Capture stopped and saved", body = CaptureMeta),
|
||||
(status = NO_CONTENT, description = "Nothing was recording"),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not write the recording to disk", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn stats_capture_stop(State(st): State<Arc<MgmtState>>) -> Response {
|
||||
match st.stats.stop() {
|
||||
Ok(Some(meta)) => {
|
||||
tracing::info!(id = %meta.id, samples = meta.sample_count, "management API: stats capture saved");
|
||||
(StatusCode::OK, Json(meta)).into_response()
|
||||
}
|
||||
Ok(None) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("could not save capture: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stats capture status
|
||||
///
|
||||
/// Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to
|
||||
/// drive the capture-control UI.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/stats/capture/status",
|
||||
tag = "stats",
|
||||
operation_id = "statsCaptureStatus",
|
||||
responses(
|
||||
(status = OK, description = "In-progress capture status (idle when not armed)", body = StatsStatus),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn stats_capture_status(State(st): State<Arc<MgmtState>>) -> Json<StatsStatus> {
|
||||
Json(st.stats.status())
|
||||
}
|
||||
|
||||
/// Live in-progress capture
|
||||
///
|
||||
/// The full sample time-series of the capture currently recording, for live graphing. `404` when
|
||||
/// nothing is armed.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/stats/capture/live",
|
||||
tag = "stats",
|
||||
operation_id = "statsCaptureLive",
|
||||
responses(
|
||||
(status = OK, description = "The in-progress capture (meta + samples so far)", body = Capture),
|
||||
(status = NOT_FOUND, description = "No capture is currently recording", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn stats_capture_live(State(st): State<Arc<MgmtState>>) -> Response {
|
||||
match st.stats.live_snapshot() {
|
||||
Some(capture) => Json(capture).into_response(),
|
||||
None => api_error(StatusCode::NOT_FOUND, "no capture is currently recording"),
|
||||
}
|
||||
}
|
||||
|
||||
/// List saved recordings
|
||||
///
|
||||
/// Every saved capture's summary (the `meta` head only — not the sample body), newest first.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/stats/recordings",
|
||||
tag = "stats",
|
||||
operation_id = "statsRecordingsList",
|
||||
responses(
|
||||
(status = OK, description = "Saved capture summaries, newest first", body = [CaptureMeta]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn stats_recordings_list(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
) -> Json<Vec<CaptureMeta>> {
|
||||
Json(st.stats.list())
|
||||
}
|
||||
|
||||
/// Get a saved recording
|
||||
///
|
||||
/// The full capture (meta + samples) for `id`, for graphing or download.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/stats/recordings/{id}",
|
||||
tag = "stats",
|
||||
operation_id = "statsRecordingGet",
|
||||
params(("id" = String, Path, description = "The recording id (its filename stem)")),
|
||||
responses(
|
||||
(status = OK, description = "The full capture", body = Capture),
|
||||
(status = NOT_FOUND, description = "No recording with that id", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "The recording file is unreadable", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn stats_recording_get(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
match st.stats.load(&id) {
|
||||
Ok(capture) => Json(capture).into_response(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
api_error(StatusCode::NOT_FOUND, "no recording with that id")
|
||||
}
|
||||
Err(e) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("could not read recording: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a saved recording
|
||||
///
|
||||
/// Removes the recording `id` from disk. `404` if there is no such recording.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/stats/recordings/{id}",
|
||||
tag = "stats",
|
||||
operation_id = "statsRecordingDelete",
|
||||
params(("id" = String, Path, description = "The recording id (its filename stem)")),
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Recording deleted"),
|
||||
(status = NOT_FOUND, description = "No recording with that id", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not delete the recording", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn stats_recording_delete(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
match st.stats.delete(&id) {
|
||||
Ok(()) => {
|
||||
tracing::info!(id, "management API: recording deleted");
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
api_error(StatusCode::NOT_FOUND, "no recording with that id")
|
||||
}
|
||||
Err(e) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("could not delete recording: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query for `GET /logs` — a cursor poll.
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct LogsQuery {
|
||||
after: Option<u64>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// Host logs
|
||||
///
|
||||
/// The host's recent log entries — an in-memory ring of the newest few thousand, captured at
|
||||
/// DEBUG and above regardless of `RUST_LOG`. Follow live by polling with `after` set to the last
|
||||
/// response's `next` cursor; a `dropped: true` means entries were evicted between polls (the ring
|
||||
/// wrapped). Bearer-only: logs can reference client identities and host paths, so this is part of
|
||||
/// the loopback-only admin surface, never the LAN-readable mTLS one.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/logs",
|
||||
tag = "logs",
|
||||
operation_id = "logsGet",
|
||||
params(
|
||||
("after" = Option<u64>, Query, description = "Return entries with seq greater than this (omitted/0 = oldest retained)"),
|
||||
("limit" = Option<u32>, Query, description = "Max entries per response (default and cap 1000)"),
|
||||
),
|
||||
responses(
|
||||
(status = OK, description = "Entries after the cursor, oldest first", body = LogPage),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn logs_get(Query(q): Query<LogsQuery>) -> Json<LogPage> {
|
||||
let limit = q.limit.map_or(crate::log_capture::MAX_PAGE, |l| l as usize);
|
||||
Json(crate::log_capture::ring().since(q.after.unwrap_or(0), limit))
|
||||
}
|
||||
@@ -0,0 +1,948 @@
|
||||
//! Handler + auth tests for the management API, exercised through `app()`. Split out of the
|
||||
//! `mgmt` facade (plan §W5).
|
||||
|
||||
use super::*;
|
||||
use crate::encode::Codec;
|
||||
use crate::gamestream::tls::{PeerAddr, PeerCertFingerprint};
|
||||
use crate::gamestream::{cert::ServerIdentity, Host, LaunchSession, HTTPS_PORT, HTTP_PORT};
|
||||
use axum::body::Body;
|
||||
use axum::http::StatusCode;
|
||||
use http_body_util::BodyExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::atomic::Ordering;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// A throwaway stats recorder rooted in a unique temp dir (never touches the real config dir).
|
||||
fn test_stats() -> Arc<crate::stats_recorder::StatsRecorder> {
|
||||
crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!(
|
||||
"pf-mgmt-stats-{}-{:p}",
|
||||
std::process::id(),
|
||||
&0u8 as *const u8
|
||||
)))
|
||||
}
|
||||
|
||||
fn test_state() -> Arc<AppState> {
|
||||
let host = Host {
|
||||
hostname: "test-host".into(),
|
||||
uniqueid: "deadbeef".into(),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
};
|
||||
let identity = ServerIdentity::ephemeral().expect("ephemeral identity");
|
||||
Arc::new(AppState::new(host, identity, test_stats()))
|
||||
}
|
||||
|
||||
// The mgmt API now always requires auth, so the router always has a token. A test that passes
|
||||
// `None` gets the default "test-secret" (and `send` auto-attaches the matching bearer); a test
|
||||
// that passes an explicit token exercises a mismatch (e.g. `bearer_token_is_enforced`).
|
||||
fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
|
||||
let stats = state.stats.clone();
|
||||
app(
|
||||
state,
|
||||
Some(token.unwrap_or("test-secret").to_string()),
|
||||
DEFAULT_PORT,
|
||||
None,
|
||||
stats,
|
||||
// GameStream-compat planes off (the secure default the native-only tests model).
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn test_app_native(state: Arc<AppState>, np: Arc<crate::native_pairing::NativePairing>) -> Router {
|
||||
// Auth required always; the paired-cert tests inject a fingerprint (cert branch wins), the
|
||||
// rest authenticate via the `send`-attached default bearer.
|
||||
let stats = state.stats.clone();
|
||||
app(
|
||||
state,
|
||||
Some("test-secret".to_string()),
|
||||
DEFAULT_PORT,
|
||||
Some(np),
|
||||
stats,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
async fn send(app: &Router, mut req: axum::http::Request<Body>) -> (StatusCode, serde_json::Value) {
|
||||
// Auto-attach the default bearer unless the test set its own Authorization (e.g. the
|
||||
// mismatch cases in `bearer_token_is_enforced`). Open routes ignore it; authed routes
|
||||
// accept it against the `test-secret` default token.
|
||||
if !req
|
||||
.headers()
|
||||
.contains_key(axum::http::header::AUTHORIZATION)
|
||||
{
|
||||
req.headers_mut().insert(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::HeaderValue::from_static("Bearer test-secret"),
|
||||
);
|
||||
}
|
||||
let resp = app.clone().oneshot(req).await.expect("infallible");
|
||||
let status = resp.status();
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let json = if bytes.is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
|
||||
};
|
||||
(status, json)
|
||||
}
|
||||
|
||||
fn get_req(path: &str) -> axum::http::Request<Body> {
|
||||
axum::http::Request::get(path).body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
/// Send a request authenticated ONLY by a paired streaming cert (the `PeerCertFingerprint`
|
||||
/// `serve_https` would attach) — no bearer header — so `require_auth`'s cert branch decides.
|
||||
async fn send_cert(app: &Router, mut req: axum::http::Request<Body>, fp: &str) -> StatusCode {
|
||||
req.extensions_mut()
|
||||
.insert(PeerCertFingerprint(Some(fp.to_string())));
|
||||
app.clone().oneshot(req).await.expect("infallible").status()
|
||||
}
|
||||
|
||||
/// A paired *streaming* cert (mTLS, no bearer) authorizes only the read-only allowlist; every
|
||||
/// state-changing or PIN-exposing route still requires the operator's bearer token (audit #4).
|
||||
#[tokio::test]
|
||||
async fn cert_auth_is_a_read_only_allowlist() {
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(std::env::temp_dir().join(format!("pf-mgmt-cert-{}.json", std::process::id()))),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let fp = "deadbeefcafe";
|
||||
np.add("streaming-client", fp).unwrap();
|
||||
let app = test_app_native(test_state(), np);
|
||||
|
||||
// Allowlisted read-only GETs → the cert authorizes them (not 401).
|
||||
for p in [
|
||||
"/api/v1/host",
|
||||
"/api/v1/status",
|
||||
"/api/v1/compositors",
|
||||
"/api/v1/clients",
|
||||
"/api/v1/native/clients",
|
||||
"/api/v1/library",
|
||||
] {
|
||||
assert_ne!(
|
||||
send_cert(&app, get_req(p), fp).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a paired streaming cert should authorize GET {p}"
|
||||
);
|
||||
}
|
||||
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
|
||||
assert_eq!(
|
||||
send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"GET /native/pair exposes the PIN → must require the bearer token"
|
||||
);
|
||||
assert_eq!(
|
||||
send_cert(
|
||||
&app,
|
||||
post_json(
|
||||
"/api/v1/native/pair/arm",
|
||||
serde_json::json!({"ttl_secs": 60})
|
||||
),
|
||||
fp,
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"arming pairing must require the bearer token"
|
||||
);
|
||||
assert_eq!(
|
||||
send_cert(
|
||||
&app,
|
||||
axum::http::Request::delete("/api/v1/native/clients/deadbeefcafe")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
fp,
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unpair (DELETE) must require the bearer token"
|
||||
);
|
||||
// An UNPAIRED cert is rejected even on an allowlisted path.
|
||||
assert_eq!(
|
||||
send_cert(&app, get_req("/api/v1/status"), "not-paired").await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"an unpaired cert must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bearer-token (admin) path is honored only from a LOOPBACK peer: the same token from a LAN
|
||||
/// peer is rejected, so binding the listener to all interfaces (so paired clients can browse the
|
||||
/// library by default) never LAN-exposes the admin surface. A paired *cert*, by contrast, reaches
|
||||
/// the read-only allowlist from anywhere.
|
||||
#[tokio::test]
|
||||
async fn bearer_admin_is_loopback_only() {
|
||||
let lan: SocketAddr = "192.168.1.50:54321".parse().unwrap();
|
||||
let loopback: SocketAddr = "127.0.0.1:33333".parse().unwrap();
|
||||
let bearer = |peer: SocketAddr| {
|
||||
let mut req = get_req("/api/v1/stats/recordings"); // a bearer-only (admin) route
|
||||
req.extensions_mut().insert(PeerAddr(peer));
|
||||
req.headers_mut().insert(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::HeaderValue::from_static("Bearer test-secret"),
|
||||
);
|
||||
req
|
||||
};
|
||||
|
||||
let app = test_app(test_state(), None);
|
||||
// A valid bearer from a LAN peer → rejected on the admin API.
|
||||
assert_eq!(
|
||||
app.clone()
|
||||
.oneshot(bearer(lan))
|
||||
.await
|
||||
.expect("infallible")
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a bearer token from a LAN peer must be rejected on the admin API"
|
||||
);
|
||||
// The SAME token from a loopback peer (the web console BFF) → accepted.
|
||||
assert_ne!(
|
||||
app.clone()
|
||||
.oneshot(bearer(loopback))
|
||||
.await
|
||||
.expect("infallible")
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"the bearer token must be accepted from a loopback peer"
|
||||
);
|
||||
|
||||
// A paired cert from a LAN peer still reaches the read-only library (the feature this enables).
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(std::env::temp_dir().join(format!("pf-mgmt-lanlib-{}.json", std::process::id()))),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let fp = "deadbeefcafe";
|
||||
np.add("lan-client", fp).unwrap();
|
||||
let app = test_app_native(test_state(), np);
|
||||
let mut req = get_req("/api/v1/library");
|
||||
req.extensions_mut().insert(PeerAddr(lan));
|
||||
req.extensions_mut()
|
||||
.insert(PeerCertFingerprint(Some(fp.to_string())));
|
||||
assert_ne!(
|
||||
app.clone().oneshot(req).await.expect("infallible").status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a paired cert must reach the library from a LAN peer"
|
||||
);
|
||||
|
||||
// The per-image art proxy (`/api/v1/library/art/{id}/{kind}`) is a prefix match in
|
||||
// `cert_may_access`, not an exact one (dynamic id/kind segments) — exercise it directly. An
|
||||
// unknown `kind` 404s before any disk/network I/O, so this stays a fast, deterministic check
|
||||
// of the auth gate (not of art resolution, which `library::tests` covers).
|
||||
let mut req = get_req("/api/v1/library/art/steam:570/not-a-real-kind");
|
||||
req.extensions_mut().insert(PeerAddr(lan));
|
||||
req.extensions_mut()
|
||||
.insert(PeerCertFingerprint(Some(fp.to_string())));
|
||||
assert_eq!(
|
||||
app.clone().oneshot(req).await.expect("infallible").status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a paired cert must reach the per-image library art proxy from a LAN peer \
|
||||
(and an unknown kind 404s, rather than ever being rejected as unauthorized)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_is_open_and_versioned() {
|
||||
let app = test_app(test_state(), None);
|
||||
let (status, body) = send(&app, get_req("/api/v1/health")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["status"], "ok");
|
||||
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
|
||||
}
|
||||
|
||||
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
|
||||
/// rejected even though the route needs no bearer token, and the body never carries secret
|
||||
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
|
||||
#[tokio::test]
|
||||
async fn local_summary_is_loopback_only_and_non_sensitive() {
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(std::env::temp_dir().join(format!("pf-mgmt-summary-{}.json", std::process::id()))),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
np.add("secret-device-name", "deadbeefcafe0123").unwrap();
|
||||
let app = test_app_native(test_state(), np);
|
||||
|
||||
// Loopback peer, NO auth header → 200 with the expected shape.
|
||||
let mut req = get_req("/api/v1/local/summary");
|
||||
req.extensions_mut()
|
||||
.insert(PeerAddr("127.0.0.1:40000".parse().unwrap()));
|
||||
let (status, body) = send(&app, req).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["video_streaming"], false);
|
||||
assert_eq!(body["native_paired_clients"], 1);
|
||||
assert_eq!(body["pending_approvals"], 0);
|
||||
assert!(body["version"].is_string());
|
||||
// No secret material anywhere in the body (paired name / fingerprint must not leak).
|
||||
let raw = body.to_string();
|
||||
assert!(
|
||||
!raw.contains("deadbeefcafe0123") && !raw.contains("secret-device-name"),
|
||||
"summary must not leak fingerprints or device names: {raw}"
|
||||
);
|
||||
|
||||
// The same request from a LAN peer → rejected (route is loopback-gated, not just tokenless).
|
||||
let mut req = get_req("/api/v1/local/summary");
|
||||
req.extensions_mut()
|
||||
.insert(PeerAddr("192.168.1.50:40000".parse().unwrap()));
|
||||
let (status, _) = send(&app, req).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"the local summary must be rejected for a LAN peer"
|
||||
);
|
||||
|
||||
// IPv6 loopback counts as loopback.
|
||||
let mut req = get_req("/api/v1/local/summary");
|
||||
req.extensions_mut()
|
||||
.insert(PeerAddr("[::1]:40000".parse().unwrap()));
|
||||
let (status, _) = send(&app, req).await;
|
||||
assert_eq!(status, StatusCode::OK, "::1 is a loopback peer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bearer_token_is_enforced() {
|
||||
let app = test_app(test_state(), Some("sekrit"));
|
||||
|
||||
// No/wrong token → 401 with the error envelope.
|
||||
let (status, body) = send(&app, get_req("/api/v1/status")).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert!(body["error"].as_str().unwrap().contains("bearer"));
|
||||
let wrong = axum::http::Request::get("/api/v1/status")
|
||||
.header("authorization", "Bearer nope")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, wrong).await.0, StatusCode::UNAUTHORIZED);
|
||||
|
||||
// Right token → 200.
|
||||
let right = axum::http::Request::get("/api/v1/status")
|
||||
.header("authorization", "Bearer sekrit")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, right).await.0, StatusCode::OK);
|
||||
|
||||
// Health + the spec/docs stay open.
|
||||
assert_eq!(
|
||||
send(&app, get_req("/api/v1/health")).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
send(&app, get_req("/api/v1/openapi.json")).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
let docs = app.clone().oneshot(get_req("/api/docs")).await.unwrap();
|
||||
assert_eq!(docs.status(), StatusCode::OK);
|
||||
let html = docs.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(
|
||||
html.starts_with(b"<!doctype html>"),
|
||||
"Scalar UI should serve HTML"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_info_reports_identity_and_ports() {
|
||||
let app = test_app(test_state(), None);
|
||||
let (status, body) = send(&app, get_req("/api/v1/host")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["hostname"], "test-host");
|
||||
assert_eq!(body["uniqueid"], "deadbeef");
|
||||
assert_eq!(body["ports"]["http"], HTTP_PORT);
|
||||
assert_eq!(body["ports"]["mgmt"], DEFAULT_PORT);
|
||||
// Codecs are GPU-aware (derived from `Codec::host_wire_caps`), so assert against that mask
|
||||
// rather than a fixed set — and confirm HEVC serializes as "hevc" (the unified codec label),
|
||||
// never "h265".
|
||||
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC, CODEC_PYROWAVE};
|
||||
let caps = Codec::host_wire_caps();
|
||||
let expected: Vec<&str> = [
|
||||
(CODEC_H264, "h264"),
|
||||
(CODEC_HEVC, "hevc"),
|
||||
(CODEC_AV1, "av1"),
|
||||
(CODEC_PYROWAVE, "pyrowave"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(bit, _)| caps & bit != 0)
|
||||
.map(|(_, name)| name)
|
||||
.collect();
|
||||
assert_eq!(body["codecs"], serde_json::json!(expected));
|
||||
assert!(caps & CODEC_H264 != 0, "H.264 is always encodable");
|
||||
// test_app models the secure default (GameStream-compat off).
|
||||
assert_eq!(body["gamestream"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compositors_lists_all_backends_with_flags() {
|
||||
let app = test_app(test_state(), None);
|
||||
let (status, body) = send(&app, get_req("/api/v1/compositors")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let arr = body.as_array().expect("array");
|
||||
// Every backend the host knows, in stable order.
|
||||
let ids: Vec<&str> = arr.iter().map(|c| c["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["kwin", "gamescope", "mutter", "wlroots", "hyprland"]);
|
||||
for c in arr {
|
||||
assert!(c["available"].is_boolean());
|
||||
assert!(c["default"].is_boolean());
|
||||
assert!(c["label"].as_str().is_some_and(|s| !s.is_empty()));
|
||||
}
|
||||
// At most one backend is the auto-detect default (none, if the test env has no desktop).
|
||||
assert!(arr.iter().filter(|c| c["default"] == true).count() <= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_reflects_runtime_state() {
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
|
||||
let (_, body) = send(&app, get_req("/api/v1/status")).await;
|
||||
assert_eq!(body["video_streaming"], false);
|
||||
assert_eq!(body["session"], serde_json::Value::Null);
|
||||
|
||||
*state.launch.lock().unwrap() = Some(LaunchSession {
|
||||
gcm_key: [0; 16],
|
||||
rikeyid: 1,
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
fps: 120,
|
||||
appid: 1,
|
||||
peer_ip: None,
|
||||
owner_fp: None,
|
||||
});
|
||||
state.streaming.store(true, Ordering::SeqCst);
|
||||
|
||||
let (_, body) = send(&app, get_req("/api/v1/status")).await;
|
||||
assert_eq!(body["video_streaming"], true);
|
||||
assert_eq!(body["session"]["width"], 2560);
|
||||
assert_eq!(body["session"]["fps"], 120);
|
||||
// Key material must never appear anywhere in the response.
|
||||
assert!(!body.to_string().contains("gcm"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paired_clients_list_and_unpair() {
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
|
||||
// Pin the host's own cert DER as a stand-in client.
|
||||
let (_, pem) = x509_parser::pem::parse_x509_pem(state.identity.cert_pem.as_bytes()).unwrap();
|
||||
let der = pem.contents.clone();
|
||||
let fingerprint = hex::encode(Sha256::digest(&der));
|
||||
// Isolate from any real paired store on the dev box: AppState::new loads
|
||||
// ~/.config/punktfunk/paired.json, so clear it before seeding our stand-in — otherwise
|
||||
// a real GameStream-paired client lands at body[0] and this assertion sees its hash.
|
||||
{
|
||||
let mut p = state.paired.lock().unwrap();
|
||||
p.clear();
|
||||
p.push(der);
|
||||
}
|
||||
|
||||
let (status, body) = send(&app, get_req("/api/v1/clients")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body[0]["fingerprint"], fingerprint);
|
||||
assert_eq!(body[0]["subject"], "CN=punktfunk");
|
||||
|
||||
// Malformed fingerprint → 400.
|
||||
let bad = axum::http::Request::delete("/api/v1/clients/zz")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, bad).await.0, StatusCode::BAD_REQUEST);
|
||||
|
||||
// Unpair (uppercase hex must match too) → 204, list empties, second delete → 404.
|
||||
let del = |fp: String| {
|
||||
axum::http::Request::delete(format!("/api/v1/clients/{fp}"))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
send(&app, del(fingerprint.to_uppercase())).await.0,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
|
||||
assert_eq!(body, serde_json::json!([]));
|
||||
assert_eq!(send(&app, del(fingerprint)).await.0, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_pin_validates_and_requires_pending_pairing() {
|
||||
let app = test_app(test_state(), None);
|
||||
let post = |body: &str| {
|
||||
axum::http::Request::post("/api/v1/pair/pin")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Malformed PINs → 400.
|
||||
assert_eq!(
|
||||
send(&app, post(r#"{"pin":""}"#)).await.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
send(&app, post(r#"{"pin":"12ab"}"#)).await.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
// Well-formed but nothing waiting → 409 (a parked stale PIN would poison the
|
||||
// next pairing attempt).
|
||||
assert_eq!(
|
||||
send(&app, post(r#"{"pin":"1234"}"#)).await.0,
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
|
||||
// axum's own body rejections must still wear the ApiError envelope (ApiJson).
|
||||
let (status, body) = send(&app, post("{not json")).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(body["error"].is_string(), "syntax error: {body}");
|
||||
let (status, body) = send(&app, post(r#"{"wrong":"shape"}"#)).await;
|
||||
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(body["error"].is_string(), "schema mismatch: {body}");
|
||||
let no_ct = axum::http::Request::post("/api/v1/pair/pin")
|
||||
.body(Body::from(r#"{"pin":"1234"}"#))
|
||||
.unwrap();
|
||||
let (status, body) = send(&app, no_ct).await;
|
||||
assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||||
assert!(body["error"].is_string(), "media type: {body}");
|
||||
}
|
||||
|
||||
/// A blank token is treated as no token: the mgmt API requires auth always (even on loopback),
|
||||
/// so `run` refuses to start unauthenticated rather than serve open.
|
||||
#[tokio::test]
|
||||
async fn blank_token_rejected() {
|
||||
let opts = Options {
|
||||
bind: "127.0.0.1:0".parse().unwrap(),
|
||||
token: Some(" ".into()),
|
||||
};
|
||||
let err = run(test_state(), opts, None, test_stats(), false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("no token"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_session_clears_runtime_state() {
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
state.streaming.store(true, Ordering::SeqCst);
|
||||
state.audio_streaming.store(true, Ordering::SeqCst);
|
||||
*state.launch.lock().unwrap() = Some(LaunchSession {
|
||||
gcm_key: [0; 16],
|
||||
rikeyid: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
appid: 1,
|
||||
peer_ip: None,
|
||||
owner_fp: None,
|
||||
});
|
||||
|
||||
let del = axum::http::Request::delete("/api/v1/session")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
|
||||
assert!(!state.streaming.load(Ordering::SeqCst));
|
||||
assert!(!state.audio_streaming.load(Ordering::SeqCst));
|
||||
assert!(state.launch.lock().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idr_requires_an_active_stream() {
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
let post = || {
|
||||
axum::http::Request::post("/api/v1/session/idr")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(send(&app, post()).await.0, StatusCode::CONFLICT);
|
||||
|
||||
state.streaming.store(true, Ordering::SeqCst);
|
||||
assert_eq!(send(&app, post()).await.0, StatusCode::ACCEPTED);
|
||||
assert!(state.force_idr.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
/// The OpenAPI document lists every route with a unique operationId (codegen relies
|
||||
/// on both), and the checked-in copy is current.
|
||||
#[test]
|
||||
fn openapi_document_is_complete_and_checked_in() {
|
||||
let json = openapi_json();
|
||||
let doc: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
|
||||
let paths = doc["paths"].as_object().unwrap();
|
||||
for p in [
|
||||
"/api/v1/health",
|
||||
"/api/v1/host",
|
||||
"/api/v1/status",
|
||||
"/api/v1/clients",
|
||||
"/api/v1/clients/{fingerprint}",
|
||||
"/api/v1/pair",
|
||||
"/api/v1/pair/pin",
|
||||
"/api/v1/session",
|
||||
"/api/v1/session/idr",
|
||||
] {
|
||||
assert!(paths.contains_key(p), "spec is missing {p}");
|
||||
}
|
||||
|
||||
let mut op_ids: Vec<&str> = paths
|
||||
.values()
|
||||
.flat_map(|ops| ops.as_object().unwrap().values())
|
||||
.filter_map(|op| op["operationId"].as_str())
|
||||
.collect();
|
||||
let total = op_ids.len();
|
||||
op_ids.sort_unstable();
|
||||
op_ids.dedup();
|
||||
assert_eq!(total, op_ids.len(), "duplicate operationIds");
|
||||
assert!(doc["components"]["securitySchemes"]["bearerAuth"].is_object());
|
||||
// The health probe overrides the document-global bearer requirement (the server
|
||||
// exempts it in `require_auth`; the spec must agree).
|
||||
assert_eq!(
|
||||
doc["paths"]["/api/v1/health"]["get"]["security"],
|
||||
serde_json::json!([{}])
|
||||
);
|
||||
|
||||
let checked_in = include_str!("../../../../api/openapi.json");
|
||||
// Compare STRUCTURALLY with `info.version` normalized on both sides: the served document
|
||||
// stamps the live crate version, but a version bump alone must never invalidate the
|
||||
// snapshot — the API *surface* is what drift-control protects (the 0.5.0 release tripped
|
||||
// on exactly this). Structural comparison also makes line endings a non-issue (git may
|
||||
// check the file out CRLF on Windows).
|
||||
let mut generated = doc;
|
||||
let mut snapshot: serde_json::Value = serde_json::from_str(checked_in).unwrap();
|
||||
generated["info"]["version"] = serde_json::json!("<any>");
|
||||
snapshot["info"]["version"] = serde_json::json!("<any>");
|
||||
assert_eq!(
|
||||
generated, snapshot,
|
||||
"api/openapi.json is stale — regenerate with: \
|
||||
cargo run -p punktfunk-host -- openapi > api/openapi.json"
|
||||
);
|
||||
}
|
||||
|
||||
fn post_json(path: &str, body: serde_json::Value) -> axum::http::Request<Body> {
|
||||
axum::http::Request::post(path)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The display-management GET surface (presets + effective + the enforced-axes list). READ-ONLY
|
||||
/// on purpose: `prefs()` is a process-global `OnceLock`, so a PUT here would clobber it and race
|
||||
/// other tests running in the same process. `keep_alive: forever` (gaming-rig) is now accepted
|
||||
/// (not rejected) — that acceptance is covered on-glass (`.116`) + by the pure `policy` tests, and
|
||||
/// the `forever` value is read off the surfaced preset below without writing.
|
||||
#[tokio::test]
|
||||
async fn display_settings_surface() {
|
||||
let app = test_app(test_state(), None);
|
||||
|
||||
let (status, body) = send(&app, get_req("/api/v1/display/settings")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let presets = body["presets"].as_array().expect("presets array");
|
||||
assert_eq!(
|
||||
presets.len(),
|
||||
5,
|
||||
"all five named presets are surfaced for the console picker"
|
||||
);
|
||||
assert!(
|
||||
body["effective"]["keep_alive"].is_object(),
|
||||
"the effective policy is echoed"
|
||||
);
|
||||
// gaming-rig surfaces keep_alive: forever (no longer rejected) — read it off the preset list.
|
||||
let gaming = presets
|
||||
.iter()
|
||||
.find(|p| p["id"] == "gaming-rig")
|
||||
.expect("gaming-rig preset surfaced");
|
||||
assert_eq!(
|
||||
gaming["fields"]["keep_alive"]["mode"], "forever",
|
||||
"gaming-rig is keep_alive: forever"
|
||||
);
|
||||
let enforced: Vec<&str> = body["enforced"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
// All five axes are enforced now (Stages 0-5).
|
||||
assert!(enforced.contains(&"keep_alive"));
|
||||
assert!(enforced.contains(&"topology"));
|
||||
assert!(enforced.contains(&"mode_conflict"));
|
||||
assert!(enforced.contains(&"identity"));
|
||||
assert!(enforced.contains(&"layout"));
|
||||
// The experimental DDC/CI + PnP-disable axes are acted on (Windows exclusive-isolate path).
|
||||
assert!(enforced.contains(&"ddc_power_off"));
|
||||
assert!(enforced.contains(&"pnp_disable_monitors"));
|
||||
}
|
||||
|
||||
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
|
||||
/// created a display (and non-Windows reports none), so `/state` is empty and `/release` is a
|
||||
/// no-op — the shapes + the "nothing to release" path, without touching any global owner.
|
||||
#[tokio::test]
|
||||
async fn display_state_and_release_empty() {
|
||||
let app = test_app(test_state(), None);
|
||||
|
||||
let (status, body) = send(&app, get_req("/api/v1/display/state")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
body["displays"].as_array().map(|a| a.len()),
|
||||
Some(0),
|
||||
"no managed displays on an idle test host"
|
||||
);
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post_json("/api/v1/display/release", serde_json::json!({})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["released"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_pairing_arm_show_and_unpair() {
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(std::env::temp_dir().join(format!("pf-mgmt-np-{}.json", std::process::id()))),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let app = test_app_native(test_state(), np.clone());
|
||||
|
||||
// Disarmed: enabled, not armed, no PIN.
|
||||
let (s, b) = send(&app, get_req("/api/v1/native/pair")).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b["enabled"], true);
|
||||
assert_eq!(b["armed"], false);
|
||||
assert!(b["pin"].is_null());
|
||||
|
||||
// Arm → a PIN appears and is readable via status.
|
||||
let (s, b) = send(
|
||||
&app,
|
||||
post_json(
|
||||
"/api/v1/native/pair/arm",
|
||||
serde_json::json!({"ttl_secs": 60}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b["armed"], true);
|
||||
let pin = b["pin"].as_str().unwrap().to_string();
|
||||
assert_eq!(pin.len(), 4);
|
||||
let (_, b) = send(&app, get_req("/api/v1/native/pair")).await;
|
||||
assert_eq!(b["pin"], pin);
|
||||
assert!(b["expires_in_secs"].as_u64().unwrap() <= 60);
|
||||
|
||||
// The QUIC side would read the same live PIN.
|
||||
assert_eq!(np.current_pin().as_deref(), Some(pin.as_str()));
|
||||
|
||||
// Pair a client out-of-band, then it shows in the list + can be unpaired.
|
||||
np.add("Test Device", "abc123").unwrap();
|
||||
let (s, b) = send(&app, get_req("/api/v1/native/clients")).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b[0]["name"], "Test Device");
|
||||
assert_eq!(b[0]["fingerprint"], "abc123");
|
||||
let del = axum::http::Request::delete("/api/v1/native/clients/ABC123")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
|
||||
let missing = axum::http::Request::delete("/api/v1/native/clients/abc123")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, missing).await.0, StatusCode::NOT_FOUND);
|
||||
|
||||
// Disarm clears the window.
|
||||
let del = axum::http::Request::delete("/api/v1/native/pair")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
|
||||
let (_, b) = send(&app, get_req("/api/v1/native/pair")).await;
|
||||
assert_eq!(b["armed"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_devices_approve_and_deny() {
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(std::env::temp_dir().join(format!("pf-mgmt-pending-{}.json", std::process::id()))),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let app = test_app_native(test_state(), np.clone());
|
||||
|
||||
// Empty queue.
|
||||
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b.as_array().unwrap().len(), 0);
|
||||
|
||||
// Two devices knock (what the QUIC gate records); they appear in the list.
|
||||
np.note_pending("Enrico's MacBook", "aa11", None);
|
||||
np.note_pending("device bb22cc33", "bb22", None);
|
||||
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
|
||||
assert_eq!(b.as_array().unwrap().len(), 2);
|
||||
assert_eq!(b[0]["name"], "Enrico's MacBook");
|
||||
let approve_id = b[0]["id"].as_u64().unwrap();
|
||||
let deny_id = b[1]["id"].as_u64().unwrap();
|
||||
|
||||
// Approve the first with an operator label → paired under that name, gone from pending.
|
||||
let (s, b) = send(
|
||||
&app,
|
||||
post_json(
|
||||
&format!("/api/v1/native/pending/{approve_id}/approve"),
|
||||
serde_json::json!({"name": "Office MacBook"}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b["name"], "Office MacBook");
|
||||
assert_eq!(b["fingerprint"], "aa11");
|
||||
assert!(np.is_paired("AA11"), "approval pins the fingerprint");
|
||||
|
||||
// Deny the second → dropped, not paired; a re-deny is 404.
|
||||
let deny = post_json(
|
||||
&format!("/api/v1/native/pending/{deny_id}/deny"),
|
||||
serde_json::json!({}),
|
||||
);
|
||||
assert_eq!(send(&app, deny).await.0, StatusCode::NO_CONTENT);
|
||||
assert!(!np.is_paired("bb22"));
|
||||
let (s, _) = send(
|
||||
&app,
|
||||
post_json(
|
||||
&format!("/api/v1/native/pending/{deny_id}/deny"),
|
||||
serde_json::json!({}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||
|
||||
// Queue is empty again; approving a stale id is 404 (keep `{}` = device's own name).
|
||||
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
|
||||
assert_eq!(b.as_array().unwrap().len(), 0);
|
||||
let (s, _) = send(
|
||||
&app,
|
||||
post_json("/api/v1/native/pending/123/approve", serde_json::json!({})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_endpoints_report_disabled_without_native_host() {
|
||||
let app = test_app(test_state(), None);
|
||||
let (s, b) = send(&app, get_req("/api/v1/native/pair")).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b["enabled"], false);
|
||||
// Arming a host that isn't running the native server is a 503.
|
||||
let (s, _) = send(
|
||||
&app,
|
||||
post_json("/api/v1/native/pair/arm", serde_json::json!({})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
|
||||
// Pending list reads as an empty array (like /native/clients), not a 503.
|
||||
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b.as_array().unwrap().len(), 0);
|
||||
// Approve/deny without a native host are 503.
|
||||
let (s, _) = send(
|
||||
&app,
|
||||
post_json("/api/v1/native/pending/0/approve", serde_json::json!({})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
|
||||
let (s, _) = send(
|
||||
&app,
|
||||
post_json("/api/v1/native/pending/0/deny", serde_json::json!({})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
fn put_json(path: &str, body: serde_json::Value) -> axum::http::Request<Body> {
|
||||
axum::http::Request::put(path)
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The GPU endpoints: the inventory GET always answers (an empty list on a GPU-less box —
|
||||
/// the schema is platform-independent), and the preference PUT validates mode + gpu_id
|
||||
/// BEFORE touching the persisted store, so a bad request can never write.
|
||||
#[tokio::test]
|
||||
async fn gpu_endpoints_list_and_validate() {
|
||||
let app = test_app(test_state(), None);
|
||||
|
||||
let (s, b) = send(&app, get_req("/api/v1/gpus")).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert!(b["gpus"].is_array());
|
||||
assert!(b["mode"].is_string());
|
||||
|
||||
// Unknown mode → 400.
|
||||
let (s, _) = send(
|
||||
&app,
|
||||
put_json(
|
||||
"/api/v1/gpus/preference",
|
||||
serde_json::json!({"mode": "fastest"}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
|
||||
// `manual` without a gpu_id → 400.
|
||||
let (s, _) = send(
|
||||
&app,
|
||||
put_json(
|
||||
"/api/v1/gpus/preference",
|
||||
serde_json::json!({"mode": "manual"}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
|
||||
// `manual` with an id that is not a present GPU → 400 (the console only offers listed ids).
|
||||
let (s, _) = send(
|
||||
&app,
|
||||
put_json(
|
||||
"/api/v1/gpus/preference",
|
||||
serde_json::json!({"mode": "manual", "gpu_id": "ffff-ffff-9"}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logs_endpoint_pages_by_cursor() {
|
||||
let app = test_app(test_state(), None);
|
||||
|
||||
// The ring is a process-wide singleton — start from wherever its cursor currently is.
|
||||
let (s, json) = send(&app, get_req("/api/v1/logs")).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let start = json["next"].as_u64().unwrap();
|
||||
|
||||
let ring = crate::log_capture::ring();
|
||||
ring.push(&tracing::Level::WARN, "mgmt::tests", "first".into());
|
||||
ring.push(&tracing::Level::INFO, "mgmt::tests", "second".into());
|
||||
|
||||
let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={start}"))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let entries = json["entries"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0]["msg"], "first");
|
||||
assert_eq!(entries[0]["level"], "WARN");
|
||||
assert_eq!(json["next"].as_u64().unwrap(), start + 2);
|
||||
assert_eq!(json["dropped"], false);
|
||||
|
||||
// Nothing newer → empty page, cursor unchanged.
|
||||
let after = start + 2;
|
||||
let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={after}"))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert!(json["entries"].as_array().unwrap().is_empty());
|
||||
assert_eq!(json["next"].as_u64().unwrap(), after);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
//! The native audio plane (plan §W1 — carved out of the [`super`] module): desktop capture → Opus
|
||||
//! (48 kHz, 5 ms, CBR — the same tuning as the GameStream path) → `AUDIO_MAGIC` QUIC datagrams, at
|
||||
//! the negotiated channel count. The encoder ([`NativeAudioEnc`]) and the capture/encode/send loop
|
||||
//! ([`audio_thread`]) are gated to linux/windows (libopus + a real capturer); other targets get the
|
||||
//! stub, so a dev build streams video-only rather than failing to compile.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Opus encoder for the native audio plane: a plain stereo encoder (the live-validated,
|
||||
/// byte-identical path) or a libopus *multistream* encoder for 5.1/7.1, both behind one
|
||||
/// `encode_float`. Surround uses the safe `opus::MSEncoder` (no `audiopus_sys`).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
enum NativeAudioEnc {
|
||||
Stereo(opus::Encoder),
|
||||
Surround(opus::MSEncoder),
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
impl NativeAudioEnc {
|
||||
/// Build the encoder for `channels` (2/6/8), hard-CBR + RESTRICTED_LOWDELAY like the
|
||||
/// GameStream path; bitrate from the shared layout table (stereo keeps the validated 128 kbps).
|
||||
fn new(channels: u8) -> Result<NativeAudioEnc, opus::Error> {
|
||||
if channels == 2 {
|
||||
let mut e = opus::Encoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
opus::Channels::Stereo,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
e.set_bitrate(opus::Bitrate::Bits(128_000)).ok();
|
||||
e.set_vbr(false).ok();
|
||||
Ok(NativeAudioEnc::Stereo(e))
|
||||
} else {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
let mut e = opus::MSEncoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
l.streams,
|
||||
l.coupled,
|
||||
l.mapping,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
e.set_bitrate(opus::Bitrate::Bits(l.bitrate)).ok();
|
||||
e.set_vbr(false).ok();
|
||||
Ok(NativeAudioEnc::Surround(e))
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_float(&mut self, frame: &[f32], out: &mut [u8]) -> Result<usize, opus::Error> {
|
||||
match self {
|
||||
NativeAudioEnc::Stereo(e) => e.encode_float(frame, out),
|
||||
NativeAudioEnc::Surround(e) => e.encode_float(frame, out),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, CBR — same tuning as the GameStream
|
||||
/// path) → `AUDIO_MAGIC` datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
|
||||
/// canonical wire order FL FR FC LFE RL RR SL SR). QUIC already encrypts; no extra layer. The
|
||||
/// capturer comes from (and returns to) the persistent slot — see [`AudioCapSlot`].
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub(super) fn audio_thread(
|
||||
conn: quinn::Connection,
|
||||
stop: Arc<AtomicBool>,
|
||||
audio_cap: AudioCapSlot,
|
||||
channels: u8,
|
||||
) {
|
||||
use crate::audio::SAMPLE_RATE;
|
||||
const FRAME_MS: usize = 5;
|
||||
const SAMPLES_PER_FRAME: usize = SAMPLE_RATE as usize * FRAME_MS / 1000; // 240
|
||||
let want = punktfunk_core::audio::normalize_channels(channels);
|
||||
|
||||
// Reuse the cached capturer ONLY when its channel count matches this session's; a stereo
|
||||
// capturer left by a prior session must not feed a 5.1/7.1 session (the encoder + the client's
|
||||
// decoder are sized for `want`, so a mismatched capturer would garble/desync the audio).
|
||||
let capturer = match audio_cap.lock().unwrap().take() {
|
||||
Some(mut c) if c.channels() == want as u32 => {
|
||||
c.drain(); // discard audio captured between sessions
|
||||
c
|
||||
}
|
||||
prev => {
|
||||
drop(prev); // wrong channel count (or none): clean teardown, open fresh at `want`
|
||||
match crate::audio::open_audio_capture(want as u32) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "punktfunk/1 audio unavailable — session continues without it");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut enc = match NativeAudioEnc::new(want) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
|
||||
*audio_cap.lock().unwrap() = Some(capturer);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let frame_len = SAMPLES_PER_FRAME * want as usize;
|
||||
let mut acc: Vec<f32> = Vec::with_capacity(frame_len * 4);
|
||||
// Sized for the largest surround frame (7.1 HQ ≈ 1.3 KB at 5 ms); ample for normal quality.
|
||||
let mut opus_buf = vec![0u8; 4096];
|
||||
let mut seq: u32 = 0;
|
||||
// Reopen-with-backoff: hold the capturer in an Option so a mid-session capture-thread death
|
||||
// (device unplug, daemon restart) reopens instead of muting the rest of a multi-hour session.
|
||||
// A quiet sink is NOT a death — `next_chunk` returns an empty chunk on its idle timeout — so only
|
||||
// a genuine thread-ended Err drops the capturer. Reopens are throttled by INJECTOR_REOPEN_BACKOFF.
|
||||
// The Opus encoder and the monotonic `seq` are kept across reopens (the client sees a gap, not a
|
||||
// restart). The first open already happened above; failing THAT still ends the session quietly.
|
||||
let mut capturer = Some(capturer);
|
||||
let mut last_failed: Option<std::time::Instant> = None;
|
||||
// A stuck Opus encoder would fail on every 5 ms frame (~200/s); power-of-two throttle the
|
||||
// warn so it can't flood stderr + the log ring while still surfacing that it's failing.
|
||||
let mut opus_encode_errs: u64 = 0;
|
||||
tracing::info!(
|
||||
channels = want,
|
||||
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
|
||||
);
|
||||
'session: while !stop.load(Ordering::SeqCst) {
|
||||
if capturer.is_none() {
|
||||
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
match crate::audio::open_audio_capture(want as u32) {
|
||||
Ok(c) => {
|
||||
tracing::info!("punktfunk/1 audio capture reopened");
|
||||
capturer = Some(c);
|
||||
last_failed = None;
|
||||
acc.clear(); // drop the partial frame straddling the gap
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "audio reopen failed — will retry");
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let chunk = match capturer.as_mut().unwrap().next_chunk() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "audio capture lost — reopening");
|
||||
capturer = None;
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
acc.extend_from_slice(&chunk);
|
||||
while acc.len() >= frame_len {
|
||||
let frame: Vec<f32> = acc.drain(..frame_len).collect();
|
||||
let pts_ns = now_ns();
|
||||
match enc.encode_float(&frame, &mut opus_buf) {
|
||||
Ok(n) => {
|
||||
let d =
|
||||
punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, &opus_buf[..n]);
|
||||
if conn.send_datagram(d.into()).is_err() {
|
||||
break 'session; // connection gone
|
||||
}
|
||||
seq = seq.wrapping_add(1);
|
||||
}
|
||||
Err(e) => {
|
||||
opus_encode_errs += 1;
|
||||
if opus_encode_errs.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
count = opus_encode_errs,
|
||||
"opus encode failed — dropping audio frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return the live capturer for the next session (None if it died and never reopened).
|
||||
if let Some(c) = capturer {
|
||||
*audio_cap.lock().unwrap() = Some(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub — punktfunk/1 audio needs Linux (PipeWire capture + libopus); non-Linux dev builds
|
||||
/// run sessions without it, same as when the capturer fails to open.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub(super) fn audio_thread(
|
||||
_conn: quinn::Connection,
|
||||
_stop: Arc<AtomicBool>,
|
||||
_audio_cap: AudioCapSlot,
|
||||
_channels: u8,
|
||||
) {
|
||||
tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it");
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Compositor-preference resolution for the native handshake (plan §W1 — carved out of the
|
||||
//! [`super`] module): map the client's [`CompositorPref`] to a concrete `crate::vdisplay::Compositor`
|
||||
//! backend, honoring an explicit request when the named backend is live and otherwise auto-detecting
|
||||
//! the active graphical session. The pure decision ([`pick_compositor`]) is separated from the I/O
|
||||
//! shell ([`resolve_compositor`]) that runs the blocking session probes.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Pure selection: choose the backend to drive from the client's `pref`, the set `available`
|
||||
/// right now, and the auto-`detected` default. A concrete preference wins only if it's available;
|
||||
/// otherwise (and for `Auto`) fall back to the detected default. `None` only when nothing is
|
||||
/// available *and* nothing was detected — the caller turns that into a handshake error.
|
||||
fn pick_compositor(
|
||||
pref: CompositorPref,
|
||||
available: &[crate::vdisplay::Compositor],
|
||||
detected: Option<crate::vdisplay::Compositor>,
|
||||
) -> Option<crate::vdisplay::Compositor> {
|
||||
use crate::vdisplay::Compositor;
|
||||
match Compositor::from_pref(pref) {
|
||||
Some(want) if available.contains(&want) => Some(want),
|
||||
// `CompositorPref::Wlroots` names the wlroots *family* (D2): sway/river ([`Wlroots`]) and
|
||||
// Hyprland are distinct backends but mutually-exclusive live sessions, so honor the request
|
||||
// with whichever family member is actually available — the detected one if it's a family
|
||||
// member, else the first available of the two.
|
||||
Some(Compositor::Wlroots) => match detected {
|
||||
Some(d @ (Compositor::Wlroots | Compositor::Hyprland)) => Some(d),
|
||||
_ => [Compositor::Wlroots, Compositor::Hyprland]
|
||||
.into_iter()
|
||||
.find(|c| available.contains(c))
|
||||
.or(detected),
|
||||
},
|
||||
_ => detected,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the client's compositor preference to a concrete backend (the I/O shell around
|
||||
/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log
|
||||
/// whether the explicit request was honored or fell back. Runs blocking probes — call off the
|
||||
/// async reactor (`spawn_blocking`).
|
||||
pub(super) fn resolve_compositor(
|
||||
pref: CompositorPref,
|
||||
dedicated_launch: bool,
|
||||
) -> Result<crate::vdisplay::Compositor> {
|
||||
use crate::vdisplay::Compositor;
|
||||
// Windows has a single virtual-display backend (pf-vdisplay); vdisplay::open ignores the compositor
|
||||
// arg there, so short-circuit the Linux session-detection state machine with a placeholder.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = (pref, dedicated_launch);
|
||||
Ok(Compositor::Kwin)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// A client is (re)connecting → cancel any pending TV-session restore so the box stays in the
|
||||
// streamed session (covers the keep-alive REUSE reconnect, which skips create_managed_session's
|
||||
// own cancel — review #3). No-op when nothing is pending.
|
||||
crate::vdisplay::cancel_pending_tv_restore();
|
||||
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
|
||||
// to come with a hand-set env — don't retarget the process env in that case.
|
||||
let overridden = crate::config::config().compositor.is_some();
|
||||
let detected = if overridden {
|
||||
crate::vdisplay::detect().ok()
|
||||
} else {
|
||||
// Auto: detect the LIVE session (Gaming vs Desktop) and retarget the process env at it so
|
||||
// every backend (video capture + input) this connect opens against the active session —
|
||||
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
|
||||
let active = crate::vdisplay::detect_active_session();
|
||||
// A4: if the compositor instance changed since the last connect (an idle-time Game↔Desktop
|
||||
// switch), bump the epoch + invalidate the old backend's kept displays so this connect never
|
||||
// reuses a node id from the dead instance.
|
||||
crate::vdisplay::observe_session_instance(&active);
|
||||
crate::vdisplay::apply_session_env(&active);
|
||||
tracing::info!(
|
||||
active = ?active.kind,
|
||||
wayland = active.env.wayland_display.as_deref().unwrap_or("-"),
|
||||
"detected active graphical session"
|
||||
);
|
||||
crate::vdisplay::compositor_for_kind(active.kind)
|
||||
};
|
||||
// Dedicated game session (design/gamemode-and-dedicated-sessions.md B0): a launching session
|
||||
// under `game_session=dedicated` (gamescope confirmed available) forces its OWN headless
|
||||
// gamescope spawn at the client's mode, overriding the detected desktop/game-mode backend. The
|
||||
// env was already retargeted above (for XDG_RUNTIME_DIR / the PipeWire daemon); we just pin the
|
||||
// backend + input to the spawn sub-mode. Skipped under an explicit operator compositor pin.
|
||||
if dedicated_launch && !overridden {
|
||||
crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
|
||||
tracing::info!(
|
||||
"dedicated game session — routing to a headless gamescope spawn at the client mode"
|
||||
);
|
||||
return Ok(Compositor::Gamescope);
|
||||
}
|
||||
let available = crate::vdisplay::available();
|
||||
let chosen = match pick_compositor(pref, &available, detected) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
// No live session — the state a compositor crash leaves behind (gnome-shell
|
||||
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
|
||||
// configured a recovery hook, fire it (debounced) and tell the client to retry:
|
||||
// its next knock lands in the recovered desktop.
|
||||
if crate::vdisplay::try_recover_session() {
|
||||
anyhow::bail!(
|
||||
"no live graphical session for this uid — host session recovery launched \
|
||||
(PUNKTFUNK_RECOVER_SESSION_CMD); retry in a few seconds"
|
||||
);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"no usable compositor (no live graphical session for this uid; set \
|
||||
PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)"
|
||||
);
|
||||
}
|
||||
};
|
||||
if !overridden {
|
||||
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
||||
// session infra exists, attach to a foreign gamescope, else per-session bare spawn).
|
||||
crate::vdisplay::apply_input_env(chosen, false);
|
||||
}
|
||||
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
|
||||
match Compositor::from_pref(pref) {
|
||||
Some(want) if want == chosen => {
|
||||
tracing::info!(
|
||||
compositor = chosen.id(),
|
||||
"honoring client compositor request"
|
||||
)
|
||||
}
|
||||
Some(want) => tracing::warn!(
|
||||
requested = want.id(),
|
||||
chosen = chosen.id(),
|
||||
available = ?avail_ids,
|
||||
"client-requested compositor unavailable — falling back to auto-detect"
|
||||
),
|
||||
None => tracing::info!(
|
||||
compositor = chosen.id(),
|
||||
"auto-detected compositor (client: auto)"
|
||||
),
|
||||
}
|
||||
Ok(chosen)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::pick_compositor;
|
||||
use punktfunk_core::config::CompositorPref;
|
||||
|
||||
#[test]
|
||||
fn compositor_resolution_precedence() {
|
||||
use crate::vdisplay::Compositor::*;
|
||||
// A concrete, available preference is honored.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Gamescope, &[Kwin, Gamescope], Some(Kwin)),
|
||||
Some(Gamescope)
|
||||
);
|
||||
// A concrete but UNavailable preference falls back to the detected default.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Mutter, &[Kwin, Gamescope], Some(Kwin)),
|
||||
Some(Kwin)
|
||||
);
|
||||
// Auto always uses the detected default.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Auto, &[Kwin, Gamescope], Some(Kwin)),
|
||||
Some(Kwin)
|
||||
);
|
||||
// Unavailable preference + nothing detected → None (caller errors the handshake).
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Mutter, &[Gamescope], None),
|
||||
None
|
||||
);
|
||||
// Available preference still wins even when nothing was auto-detected.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Gamescope, &[Gamescope], None),
|
||||
Some(Gamescope)
|
||||
);
|
||||
// Wlroots family (D2): the shared `Wlroots` pref resolves to whichever of sway/river
|
||||
// (Wlroots) and Hyprland is the live session.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Wlroots, &[Hyprland], Some(Hyprland)),
|
||||
Some(Hyprland)
|
||||
);
|
||||
// …and to Wlroots-proper on a sway/river host.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Wlroots, &[Wlroots], Some(Wlroots)),
|
||||
Some(Wlroots)
|
||||
);
|
||||
// Family fallback even if detection came back empty but a member is available.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Wlroots, &[Hyprland], None),
|
||||
Some(Hyprland)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Virtual-gamepad backend resolution for the native session (plan §W1 — carved out of the
|
||||
//! [`super`] module). Maps the client's [`GamepadPref`] (plus the host `PUNKTFUNK_GAMEPAD` env and
|
||||
//! the live platform) to a backend this host can actually build, applying the runtime UHID /
|
||||
//! Steam-conflict degrades. Pure selection ([`pick_gamepad`]) is separated from the env/logging
|
||||
//! shell ([`resolve_gamepad`]) and the per-pad variant ([`resolve_pad_kind`]); the platform degrades
|
||||
//! (`degrade_if_no_uhid`, `physical_steam_controller_present`, `degrade_steam_on_conflict`) are
|
||||
//! cfg-split linux/other and MUST be re-verified on Windows when touched (Linux clippy can't see the
|
||||
//! non-linux copies).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The per-pad routing decision for one frame ([`Pads::handle`]): given `owner` (the manager
|
||||
/// holding a live device at this index, if any), the client-`declared` kind, and whether this is a
|
||||
/// create/update frame (`present`) vs a removal, return `(kind to route to, new owner)`.
|
||||
///
|
||||
/// A live device stays in its owning manager even if the declared kind later changes (so a pad is
|
||||
/// never duplicated across managers); the declared kind takes effect only when no device exists
|
||||
/// yet; a removal routes to the owner's manager (so it tears the right device down) and clears the
|
||||
/// owner.
|
||||
pub(super) fn route_decision(
|
||||
owner: Option<GamepadPref>,
|
||||
declared: GamepadPref,
|
||||
present: bool,
|
||||
) -> (GamepadPref, Option<GamepadPref>) {
|
||||
match (owner, present) {
|
||||
(Some(k), true) => (k, Some(k)), // keep the existing device in its manager
|
||||
(Some(k), false) => (k, None), // removal → owner's manager, then clear
|
||||
(None, true) => (declared, Some(declared)), // create in the declared kind's manager
|
||||
(None, false) => (declared, None), // removal with no device — a harmless no-op
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one client-declared per-pad kind to a backend this host can actually build (mixed
|
||||
/// types): the platform map + the runtime UHID / Steam-conflict degrades that [`resolve_gamepad`]
|
||||
/// applies to the session default, minus the Auto/env session logic (a per-pad declaration is
|
||||
/// always a concrete kind).
|
||||
pub(super) fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref {
|
||||
let chosen = pick_gamepad(
|
||||
kind,
|
||||
None,
|
||||
cfg!(target_os = "linux"),
|
||||
cfg!(target_os = "windows"),
|
||||
);
|
||||
degrade_steam_on_conflict(degrade_if_no_uhid(chosen))
|
||||
}
|
||||
|
||||
/// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins,
|
||||
/// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360.
|
||||
///
|
||||
/// `linux`/`windows` flag the host platform. DualSense and DualShock 4 each have both a Linux (UHID
|
||||
/// hid-playstation) and a Windows (UMDF minidriver) backend; on any other platform such a wish degrades
|
||||
/// to X-Box 360 (never an error: a session without rich pads still streams). X-Box One/Series is a
|
||||
/// distinct uinput *identity* on Linux, but XInput-identical to the 360 pad on Windows (the XUSB
|
||||
/// companion presents a 360 identity), so it degrades to `Xbox360` there.
|
||||
fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool) -> GamepadPref {
|
||||
let want = match pref {
|
||||
GamepadPref::Auto => env
|
||||
.and_then(GamepadPref::from_name)
|
||||
.unwrap_or(GamepadPref::Auto),
|
||||
explicit => explicit,
|
||||
};
|
||||
match want {
|
||||
// DualSense / DualShock 4: Linux UHID hid-playstation, or the Windows UMDF minidriver backend.
|
||||
GamepadPref::DualSense if linux || windows => GamepadPref::DualSense,
|
||||
GamepadPref::DualShock4 if linux || windows => GamepadPref::DualShock4,
|
||||
// One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on
|
||||
// Windows (XInput can't tell them apart anyway).
|
||||
GamepadPref::XboxOne if linux => GamepadPref::XboxOne,
|
||||
// Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices
|
||||
// are the N4 spike).
|
||||
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
||||
GamepadPref::SteamController if linux => GamepadPref::SteamController,
|
||||
// Windows virtual Deck: the UMDF device-type-3 identity, Steam-Input-promoted via the
|
||||
// MI_02 hardware-id synthesis (gamepad-new-types N4) — native Deck glyphs + trackpads +
|
||||
// gyro + back grips, replacing the old fold to DualSense.
|
||||
GamepadPref::SteamDeck if windows => GamepadPref::SteamDeck,
|
||||
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
|
||||
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
|
||||
// policy. Degrades to Xbox360 elsewhere like its siblings.
|
||||
GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSenseEdge,
|
||||
// Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional
|
||||
// layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there.
|
||||
GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro,
|
||||
// New Steam Controller (2026, `28DE:1302`): passed through as-is on Linux — the Triton
|
||||
// UHID backend mirrors the client's raw reports under the real identity and Steam on
|
||||
// the host drives it over hidraw (no kernel driver binds the PID; Steam Input is the
|
||||
// consumer). No Windows backend; folds to Xbox360 there.
|
||||
GamepadPref::SteamController2 if linux => GamepadPref::SteamController2,
|
||||
GamepadPref::SteamController2Puck if linux => GamepadPref::SteamController2Puck,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime degrade for the Linux UHID backends (DualSense / DualShock 4 / Steam Deck): if
|
||||
/// `/dev/uhid` can't be opened for write *now*, fall back to the uinput X-Box 360 pad rather than a
|
||||
/// dead controller (the UHID device-create would just fail). Cheap — opens + drops the char device,
|
||||
/// no `UHID_CREATE2`, so no device is created. A no-op on non-Linux (those backends are UMDF/uinput).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
let needs_uhid = matches!(
|
||||
chosen,
|
||||
GamepadPref::DualSense
|
||||
| GamepadPref::DualSenseEdge
|
||||
| GamepadPref::DualShock4
|
||||
| GamepadPref::SteamDeck
|
||||
| GamepadPref::SteamController
|
||||
| GamepadPref::SteamController2
|
||||
| GamepadPref::SteamController2Puck
|
||||
| GamepadPref::SwitchPro
|
||||
);
|
||||
if needs_uhid
|
||||
&& std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open("/dev/uhid")
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
wanted = chosen.as_str(),
|
||||
"/dev/uhid not writable — falling back to the X-Box 360 pad"
|
||||
);
|
||||
return GamepadPref::Xbox360;
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
chosen
|
||||
}
|
||||
|
||||
/// True if a **physical** Valve Steam controller (`28DE`) is already attached. The host's own Steam
|
||||
/// Input is then managing a `28DE` device, and presenting a second (virtual) one makes Steam juggle
|
||||
/// two Decks — confirmed conflict-prone on a Deck-as-host (the physical `28DE:1205` + Steam's
|
||||
/// `28DE:11FF` XInput output pad are both live). HID device dirs are named `BUS:VID:PID.INST`
|
||||
/// (uppercase); a UHID virtual device resolves through `/devices/virtual/…`, a real one does not.
|
||||
///
|
||||
/// Punktfunk's OWN virtual Decks must never count: the usbip/gadget transports present a real USB
|
||||
/// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's
|
||||
/// pad still detaching — or a concurrent session's live one — read as "physical" and degraded
|
||||
/// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are
|
||||
/// recognizable by the `FVPF…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
|
||||
/// vhci path as belt and braces.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn physical_steam_controller_present() -> bool {
|
||||
let Ok(entries) = std::fs::read_dir("/sys/bus/hid/devices") else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
if !e.file_name().to_string_lossy().contains(":28DE:") {
|
||||
return false;
|
||||
}
|
||||
if std::fs::read_to_string(e.path().join("uevent"))
|
||||
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=FVPF")))
|
||||
{
|
||||
return false; // one of our own virtual Decks
|
||||
}
|
||||
match std::fs::read_link(e.path()) {
|
||||
Ok(target) => {
|
||||
let t = target.to_string_lossy();
|
||||
!t.contains("/virtual/") && !t.contains("vhci_hcd")
|
||||
}
|
||||
Err(_) => true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Gate a virtual Steam pad off when a physical Steam controller is attached (§ conflict). Degrade to
|
||||
/// DualSense (then the uhid ladder), which Steam treats as an ordinary, distinct pad. Override with
|
||||
/// `PUNKTFUNK_STEAM_FORCE=1` when the host has no competing Steam Input (e.g. a remote-only box).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
|
||||
if !matches!(
|
||||
chosen,
|
||||
GamepadPref::SteamDeck
|
||||
| GamepadPref::SteamController
|
||||
| GamepadPref::SteamController2
|
||||
| GamepadPref::SteamController2Puck
|
||||
) {
|
||||
return chosen;
|
||||
}
|
||||
let forced = std::env::var("PUNKTFUNK_STEAM_FORCE")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
if !forced && physical_steam_controller_present() {
|
||||
tracing::warn!(
|
||||
wanted = chosen.as_str(),
|
||||
"a physical Steam controller is attached — the host's Steam Input would manage two 28DE \
|
||||
devices; falling back to DualSense (set PUNKTFUNK_STEAM_FORCE=1 to override)"
|
||||
);
|
||||
return degrade_if_no_uhid(GamepadPref::DualSense);
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
|
||||
chosen
|
||||
}
|
||||
|
||||
/// Resolve the client's gamepad-backend preference (the env/logging shell around
|
||||
/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive.
|
||||
pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
|
||||
let env = crate::config::config().gamepad.clone();
|
||||
let chosen = pick_gamepad(
|
||||
pref,
|
||||
env.as_deref(),
|
||||
cfg!(target_os = "linux"),
|
||||
cfg!(target_os = "windows"),
|
||||
);
|
||||
// Runtime degrade (separate from the compile-time platform check above): the Linux UHID
|
||||
// backends need `/dev/uhid` usable *now*, else creating the device just fails and the controller
|
||||
// goes dead — fall back to the always-available uinput X-Box 360 pad instead.
|
||||
let chosen = degrade_if_no_uhid(chosen);
|
||||
// Conflict gate: don't present a virtual Steam (28DE) pad when the host already has a physical
|
||||
// Steam controller — its own Steam Input would then manage two Decks (confirmed conflict-prone on
|
||||
// a Deck-as-host). `PUNKTFUNK_STEAM_FORCE=1` overrides.
|
||||
let chosen = degrade_steam_on_conflict(chosen);
|
||||
match pref {
|
||||
GamepadPref::Auto => {
|
||||
// The operator's env knob deserves a diagnostic when it didn't drive the
|
||||
// choice — a typo, or a DualSense wish on a non-UHID host, would otherwise
|
||||
// degrade silently.
|
||||
if let Some(env) = env.as_deref() {
|
||||
if GamepadPref::from_name(env) != Some(chosen) {
|
||||
tracing::warn!(
|
||||
env,
|
||||
chosen = chosen.as_str(),
|
||||
"PUNKTFUNK_GAMEPAD unrecognized or unavailable — falling back"
|
||||
);
|
||||
}
|
||||
}
|
||||
tracing::info!(gamepad = chosen.as_str(), "gamepad backend (client: auto)")
|
||||
}
|
||||
want if want == chosen => {
|
||||
tracing::info!(gamepad = chosen.as_str(), "honoring client gamepad request")
|
||||
}
|
||||
want => tracing::warn!(
|
||||
requested = want.as_str(),
|
||||
chosen = chosen.as_str(),
|
||||
"client-requested gamepad backend unavailable — falling back"
|
||||
),
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pick_gamepad, route_decision};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
|
||||
#[test]
|
||||
fn per_pad_route_decision() {
|
||||
use GamepadPref::{DualSense, Xbox360};
|
||||
// First frame with no device: create in the declared kind's manager, record ownership.
|
||||
assert_eq!(
|
||||
route_decision(None, DualSense, true),
|
||||
(DualSense, Some(DualSense))
|
||||
);
|
||||
// Subsequent frame: stays in the owning manager even if the declared kind now differs
|
||||
// (the arrival-after-first-frame reorder) — never a second device in another manager.
|
||||
assert_eq!(
|
||||
route_decision(Some(DualSense), Xbox360, true),
|
||||
(DualSense, Some(DualSense))
|
||||
);
|
||||
// Removal (cleared bit): routes to the owner so the RIGHT device is torn down, then clears.
|
||||
assert_eq!(
|
||||
route_decision(Some(DualSense), Xbox360, false),
|
||||
(DualSense, None)
|
||||
);
|
||||
// Removal with no device is a harmless no-op route (owner stays cleared).
|
||||
assert_eq!(route_decision(None, Xbox360, false), (Xbox360, None));
|
||||
// A fresh device after a re-plug picks up the newly-declared kind (owner was cleared).
|
||||
assert_eq!(
|
||||
route_decision(None, Xbox360, true),
|
||||
(Xbox360, Some(Xbox360))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_resolution_precedence() {
|
||||
use GamepadPref::*;
|
||||
// Trailing args are (linux, windows).
|
||||
// An explicit client choice wins over the env var.
|
||||
assert_eq!(
|
||||
pick_gamepad(DualSense, Some("xbox360"), true, false),
|
||||
DualSense
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Xbox360, Some("dualsense"), true, false),
|
||||
Xbox360
|
||||
);
|
||||
// Client Auto defers to the env var.
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("dualsense"), true, false),
|
||||
DualSense
|
||||
);
|
||||
assert_eq!(pick_gamepad(Auto, Some("xbox360"), true, false), Xbox360);
|
||||
// Auto + no env (or an unparseable one) → X-Box 360.
|
||||
assert_eq!(pick_gamepad(Auto, None, true, false), Xbox360);
|
||||
assert_eq!(pick_gamepad(Auto, Some("bogus"), true, false), Xbox360);
|
||||
// DualSense: honored on Linux (UHID) AND Windows (UMDF minidriver); degrades elsewhere.
|
||||
assert_eq!(pick_gamepad(DualSense, None, false, true), DualSense);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("dualsense"), false, true),
|
||||
DualSense
|
||||
);
|
||||
assert_eq!(pick_gamepad(DualSense, None, false, false), Xbox360);
|
||||
assert_eq!(pick_gamepad(Auto, Some("dualsense"), false, false), Xbox360);
|
||||
// DualShock 4: honored on Linux (UHID) AND Windows (UMDF minidriver); degrades elsewhere.
|
||||
assert_eq!(pick_gamepad(DualShock4, None, true, false), DualShock4);
|
||||
assert_eq!(pick_gamepad(Auto, Some("ps4"), true, false), DualShock4);
|
||||
assert_eq!(pick_gamepad(DualShock4, None, false, true), DualShock4);
|
||||
assert_eq!(pick_gamepad(DualShock4, None, false, false), Xbox360);
|
||||
// X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows.
|
||||
assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne);
|
||||
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
|
||||
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
|
||||
|
||||
// Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3,
|
||||
// Steam-Input-promoted via MI_02 — gamepad-new-types N4); Xbox360 elsewhere.
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, false, true), SteamDeck);
|
||||
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), SteamDeck);
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
|
||||
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController, None, true, false),
|
||||
SteamController
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("steamcontroller"), true, false),
|
||||
SteamController
|
||||
);
|
||||
assert_eq!(pick_gamepad(SteamController, None, false, true), Xbox360);
|
||||
|
||||
// DualSense Edge: native on Linux (UHID) AND Windows (UMDF device-type 2); Xbox360
|
||||
// elsewhere.
|
||||
assert_eq!(
|
||||
pick_gamepad(DualSenseEdge, None, true, false),
|
||||
DualSenseEdge
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(DualSenseEdge, None, false, true),
|
||||
DualSenseEdge
|
||||
);
|
||||
assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSenseEdge);
|
||||
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
|
||||
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
|
||||
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("switchpro"), true, false),
|
||||
SwitchPro
|
||||
);
|
||||
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
|
||||
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
|
||||
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
|
||||
// New Steam Controller (as-is Triton passthrough): native on Linux (UHID, Steam-driven);
|
||||
// Xbox360 on Windows and elsewhere.
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController2, None, true, false),
|
||||
SteamController2
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("sc2"), true, false),
|
||||
SteamController2
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("ibex"), true, false),
|
||||
SteamController2
|
||||
);
|
||||
assert_eq!(pick_gamepad(SteamController2, None, false, true), Xbox360);
|
||||
assert_eq!(pick_gamepad(SteamController2, None, false, false), Xbox360);
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController2Puck, None, true, false),
|
||||
SteamController2Puck
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("sc2puck"), true, false),
|
||||
SteamController2Puck
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController2Puck, None, false, true),
|
||||
Xbox360
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! The native `punktfunk/1` handshake negotiation (plan §W1 — carved out of the [`super`] module).
|
||||
//! After the pairing gate (which stays in `serve_session`, since its delegated-approval wait must
|
||||
//! outlive the short handshake timeout and release the session permit), this decodes the client's
|
||||
//! [`Hello`], runs mode-conflict admission, negotiates codec / compositor / gamepad / bitrate /
|
||||
//! audio channels / bit-depth / chroma, reserves the data-plane UDP socket, sends the [`Welcome`],
|
||||
//! and reads the client's [`Start`] — returning everything `serve_session` needs to stand the
|
||||
//! session up.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Run the Hello→Welcome→Start negotiation. Borrows the control streams (the caller keeps them for
|
||||
/// mid-stream renegotiation afterwards). `first` is the already-read first control message.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) async fn negotiate(
|
||||
conn: &quinn::Connection,
|
||||
send: &mut quinn::SendStream,
|
||||
recv: &mut quinn::RecvStream,
|
||||
first: &[u8],
|
||||
source: Punktfunk1Source,
|
||||
frames: u32,
|
||||
data_port: Option<u16>,
|
||||
) -> Result<(
|
||||
Hello,
|
||||
Welcome,
|
||||
u16,
|
||||
std::net::UdpSocket,
|
||||
bool,
|
||||
Start,
|
||||
Option<crate::vdisplay::Compositor>,
|
||||
)> {
|
||||
let peer = conn.remote_address();
|
||||
let mut hello = Hello::decode(first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||
if hello.abi_version != punktfunk_core::WIRE_VERSION {
|
||||
close_rejected(
|
||||
conn,
|
||||
punktfunk_core::reject::RejectReason::WireVersionMismatch,
|
||||
);
|
||||
anyhow::bail!(
|
||||
"wire version mismatch: client {} host {}",
|
||||
hello.abi_version,
|
||||
punktfunk_core::WIRE_VERSION
|
||||
);
|
||||
}
|
||||
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
|
||||
// before this future, so a client reaching here is paired (or the host is `--open`).
|
||||
|
||||
// Codec negotiation: pick the one codec this host will emit (its GPU-probed backend
|
||||
// capability ∩ the client's advertised codecs, honoring the client's soft preference).
|
||||
// A GPU-less software host emits H.264 only, so an HEVC-only client shares nothing with
|
||||
// it → refuse honestly rather than send a stream it can't decode.
|
||||
let host_codecs = crate::encode::Codec::host_wire_caps();
|
||||
let codec_bit =
|
||||
punktfunk_core::quic::resolve_codec(hello.video_codecs, host_codecs, hello.preferred_codec)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"no shared video codec: client advertised 0x{:02x}, host can emit 0x{:02x} \
|
||||
(a software-encode host produces H.264 — the client must advertise CODEC_H264)",
|
||||
hello.video_codecs,
|
||||
host_codecs
|
||||
)
|
||||
})?;
|
||||
let codec = crate::encode::Codec::from_wire(codec_bit);
|
||||
tracing::info!(
|
||||
?codec,
|
||||
client_codecs = format_args!("0x{:02x}", hello.video_codecs),
|
||||
host_codecs = format_args!("0x{host_codecs:02x}"),
|
||||
"video codec negotiated"
|
||||
);
|
||||
|
||||
// Mode-conflict ADMISSION (Stage 4): a DIFFERENT client connecting while another client's
|
||||
// session is live is resolved by the `mode_conflict` policy BEFORE the Welcome — `separate`
|
||||
// (default, no change), `join` (serve at the live mode — an honest downgrade the client
|
||||
// renders from the Welcome), `steal` (preempt the victim), or `reject` (refuse the handshake).
|
||||
// A same-client reconnect never conflicts. THIS session registers in the live set once its
|
||||
// data plane is up (below the handshake), so a later client can see + steal it.
|
||||
{
|
||||
use crate::vdisplay::admission::{admit, preempt_same_identity, Admission};
|
||||
let peer_fp = endpoint::peer_fingerprint(conn);
|
||||
|
||||
// Same-client RECONNECT preempt (design §5.3 "preempts downstream"): if THIS client
|
||||
// already has a live session, it's the zombie of an unwanted disconnect whose QUIC idle
|
||||
// timer hasn't fired yet (detection lags a drop by up to `max_idle_timeout`). Signal it to
|
||||
// stop and give it the release grace so it tears its display down — which, keep-alive on,
|
||||
// lingers — and THIS reconnect REUSES that kept display below instead of landing on a
|
||||
// fresh SECOND one. Independent of the mode_conflict arm (it's our OWN prior session, not
|
||||
// a conflict with a different client), and it runs before we register ourselves so we
|
||||
// never signal our own stop flag.
|
||||
let own_zombies = preempt_same_identity(peer_fp);
|
||||
if !own_zombies.is_empty() {
|
||||
tracing::info!(
|
||||
count = own_zombies.len(),
|
||||
"reconnect: preempting this client's own zombie session(s) so the kept display is reused"
|
||||
);
|
||||
for z in &own_zombies {
|
||||
z.store(true, Ordering::SeqCst);
|
||||
}
|
||||
// Same blind release grace the steal path uses — lets the zombie's loops notice the
|
||||
// stop flag and drop its display (→ Lingering) before we acquire below.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
}
|
||||
|
||||
match admit(peer_fp) {
|
||||
Admission::Separate => {}
|
||||
Admission::Join(m) => {
|
||||
tracing::info!(
|
||||
requested =
|
||||
%format_args!("{}x{}@{}", hello.mode.width, hello.mode.height, hello.mode.refresh_hz),
|
||||
live = %format_args!("{}x{}@{}", m.0, m.1, m.2),
|
||||
"mode-conflict: JOIN — admitting at the live display's mode"
|
||||
);
|
||||
hello.mode.width = m.0;
|
||||
hello.mode.height = m.1;
|
||||
hello.mode.refresh_hz = m.2;
|
||||
}
|
||||
Admission::Steal(victims) => {
|
||||
tracing::info!(
|
||||
victims = victims.len(),
|
||||
"mode-conflict: STEAL — preempting the live session(s)"
|
||||
);
|
||||
for v in &victims {
|
||||
v.store(true, Ordering::SeqCst);
|
||||
}
|
||||
// Give the victims the release grace to tear their display down before we acquire.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
}
|
||||
Admission::Reject(reason) => {
|
||||
tracing::warn!("mode-conflict: REJECT — {reason}");
|
||||
// Deliver the reason to the client as a TYPED refusal: close the QUIC connection
|
||||
// with the BUSY application code + the reason bytes, which the client reads from
|
||||
// the `ApplicationClosed` error (so its UI can say "host is streaming X to <name>")
|
||||
// instead of seeing a bare connection drop. Then end the handshake.
|
||||
conn.close(REJECT_BUSY_CODE.into(), reason.as_bytes());
|
||||
anyhow::bail!("{reason}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height)
|
||||
.context("client-requested mode")?;
|
||||
|
||||
// Resolve the client's compositor preference to a concrete backend *now*, so the Welcome
|
||||
// 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 {
|
||||
Punktfunk1Source::Virtual => {
|
||||
let pref = hello.compositor;
|
||||
// Dedicated game session (B0): a launching client under `game_session=dedicated`
|
||||
// (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on
|
||||
// whether the launch id actually RESOLVES to a command in the host's library — an unknown
|
||||
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
|
||||
// (review #9). (dedicated is Linux-only; the resolver is the non-Windows launch_command.)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let has_resolvable_launch = hello
|
||||
.launch
|
||||
.as_deref()
|
||||
.and_then(crate::library::launch_command)
|
||||
.is_some();
|
||||
#[cfg(target_os = "windows")]
|
||||
let has_resolvable_launch = false;
|
||||
let dedicated = crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch);
|
||||
Some(
|
||||
tokio::task::spawn_blocking(move || resolve_compositor(pref, dedicated))
|
||||
.await
|
||||
.context("resolve compositor task")??,
|
||||
)
|
||||
}
|
||||
Punktfunk1Source::Synthetic => None,
|
||||
};
|
||||
|
||||
// A requested library launch (the client sends only the store-qualified id; we look it up
|
||||
// in OUR library so a client can't inject a command) is resolved below — after the Welcome,
|
||||
// where it's threaded per-session into the data plane as `SessionContext.launch` (no
|
||||
// process-global env: the old `PUNKTFUNK_GAMESCOPE_APP` write leaked across sessions, and
|
||||
// only gamescope's bare-spawn path ever read it, so launches on every other backend were
|
||||
// silently dropped).
|
||||
|
||||
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
|
||||
// needed; the actual pads are created lazily by the input thread).
|
||||
let gamepad = resolve_gamepad(hello.gamepad);
|
||||
|
||||
// Resolve the encoder bitrate (client request clamped to a sane range, or a
|
||||
// codec-aware host default — PyroWave pins ~1.6 bpp for the mode).
|
||||
let bitrate_kbps = resolve_bitrate_kbps_for(codec, hello.bitrate_kbps, &hello.mode);
|
||||
tracing::info!(
|
||||
requested_kbps = hello.bitrate_kbps,
|
||||
resolved_kbps = bitrate_kbps,
|
||||
"encoder bitrate"
|
||||
);
|
||||
|
||||
// Resolve the audio channel count (client request → stereo / 5.1 / 7.1). The capturer opens
|
||||
// at this count: PipeWire synthesizes the requested positions (padding with silence when the
|
||||
// sink has fewer), WASAPI loopback up/downmixes via AUTOCONVERTPCM — so a client always gets
|
||||
// the channels it asked for, and the Welcome echoes the value the audio thread will encode.
|
||||
let audio_channels = resolve_audio_channels(hello.audio_channels);
|
||||
tracing::info!(
|
||||
requested = hello.audio_channels,
|
||||
resolved = audio_channels,
|
||||
"audio channels"
|
||||
);
|
||||
|
||||
// Resolve the encode bit depth: 10-bit (HEVC Main10 / AV1 10-bit) only when ALL of — the
|
||||
// host allows it (PUNKTFUNK_10BIT, default ON with explicit-off grammar; the CLIENT's HDR
|
||||
// setting behind VIDEO_CAP_10BIT is the per-session policy switch), the client advertised
|
||||
// VIDEO_CAP_10BIT (a client that can't decode 10-bit, or an older client, always gets the
|
||||
// 8-bit stream), the codec has a 10-bit path (HEVC/AV1 — H.264 never), and the active
|
||||
// GPU/backend actually encodes 10-bit for that codec (probed, cached). Resolved BEFORE the
|
||||
// Welcome, exactly like the 4:4:4 gate below, so `color` reflects what we'll really emit —
|
||||
// the honest-downgrade channel: a GPU/backend that can't 10-bit yields 8-bit AND an SDR
|
||||
// label that matches the stream.
|
||||
let host_wants_10bit = crate::config::config().ten_bit;
|
||||
let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0;
|
||||
// The GPU probe may open a tiny encoder on first use, so run it off the reactor like the
|
||||
// 4:4:4 probe below (blocking probes → spawn_blocking), short-circuited behind the cheap
|
||||
// gates. The result is cached process-wide per (GPU, codec).
|
||||
let gpu_can_10bit = if host_wants_10bit && client_supports_10bit && codec.supports_10bit() {
|
||||
tokio::task::spawn_blocking(move || crate::encode::can_encode_10bit(codec))
|
||||
.await
|
||||
.context("10-bit capability probe task")?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let bit_depth: u8 = if gpu_can_10bit { 10 } else { 8 };
|
||||
tracing::info!(
|
||||
bit_depth,
|
||||
host_wants_10bit,
|
||||
client_supports_10bit,
|
||||
codec = ?codec,
|
||||
gpu_can_10bit,
|
||||
client_video_caps = hello.video_caps,
|
||||
"encode bit depth"
|
||||
);
|
||||
|
||||
// Resolve the chroma subsampling: full-chroma HEVC 4:4:4 only when ALL of — the host
|
||||
// allows it (PUNKTFUNK_444, default ON; the CLIENT's 4:4:4 setting — default OFF — is the
|
||||
// per-session policy switch behind VIDEO_CAP_444), the client advertised VIDEO_CAP_444,
|
||||
// the session is single-process (the two-process WGC relay encodes 4:2:0 in v1), and the
|
||||
// active GPU/driver actually supports a 4:4:4 encode (probed, cached). The native path
|
||||
// always encodes HEVC. We resolve this BEFORE the Welcome so `chroma_format` reflects
|
||||
// what we'll really emit — the honest-downgrade channel: if any gate fails the client is
|
||||
// told 4:2:0 before it builds its decoder. The probe opens a tiny encoder; it runs only
|
||||
// when the earlier gates pass and is cached after the first.
|
||||
let host_wants_444 = crate::config::config().four_four_four;
|
||||
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
|
||||
// The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade
|
||||
// gate. Linux's portal capturer can; the Windows IDD-push path delivers subsampled NV12/P010
|
||||
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
|
||||
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
|
||||
// topology, and 4:4:4 routed to DDA, which was removed.)
|
||||
let capture_supports_444 = crate::capture::capturer_supports_444();
|
||||
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
|
||||
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
|
||||
// the cheap gates already pass. The result is cached process-wide (a negative latches until
|
||||
// restart — acceptable: a GPU either supports HEVC 4:4:4 or it doesn't, and a transient open
|
||||
// failure here is rare since the session's own encoder isn't open yet).
|
||||
let gpu_supports_444 = if codec == crate::encode::Codec::H265
|
||||
&& host_wants_444
|
||||
&& client_supports_444
|
||||
&& capture_supports_444
|
||||
{
|
||||
tokio::task::spawn_blocking(|| crate::encode::can_encode_444(crate::encode::Codec::H265))
|
||||
.await
|
||||
.context("4:4:4 capability probe task")?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let chroma = if gpu_supports_444 {
|
||||
crate::encode::ChromaFormat::Yuv444
|
||||
} else {
|
||||
crate::encode::ChromaFormat::Yuv420
|
||||
};
|
||||
tracing::info!(
|
||||
chroma = ?chroma,
|
||||
host_wants_444,
|
||||
client_supports_444,
|
||||
capture_supports_444,
|
||||
"encode chroma"
|
||||
);
|
||||
|
||||
// Linux 4:4:4 rides the CPU swscale → 8-bit `YUV444P` path (see `encode/linux`) — there
|
||||
// is no 10-bit 4:4:4 input there, so a 10-bit-negotiated session would silently encode
|
||||
// 8-bit. Resolve the depth DOWN before the Welcome so the wire never overstates what the
|
||||
// stream carries. (Windows NVENC composes Main 4:4:4 10 from an RGB input, so it keeps
|
||||
// the resolved depth — this clamp is Linux-only.)
|
||||
#[cfg(target_os = "linux")]
|
||||
let bit_depth: u8 = if chroma.is_444() && bit_depth == 10 {
|
||||
tracing::info!("4:4:4 on the Linux path encodes 8-bit YUV444P — resolving bit depth 8");
|
||||
8
|
||||
} else {
|
||||
bit_depth
|
||||
};
|
||||
|
||||
// Reserve the data-plane UDP socket up front and HOLD it through streaming (no
|
||||
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
|
||||
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
|
||||
// no punch-wait); otherwise a random ephemeral port + hole-punch.
|
||||
let (data_sock, direct) = bind_data_socket(data_port)?;
|
||||
let udp_port = data_sock.local_addr()?.port();
|
||||
|
||||
let mut key = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
// Fresh per-session salt alongside the fresh key. GCM nonce uniqueness only *requires* one
|
||||
// of the two to be unique per session (the nonce is salt || sequence under the session
|
||||
// key), but a constant salt would make a key-reuse bug catastrophic instead of merely
|
||||
// wrong — this keeps the second line of defense real. Negotiated via Welcome, so clients
|
||||
// just follow.
|
||||
let mut salt = [0u8; 4];
|
||||
rand::thread_rng().fill_bytes(&mut salt);
|
||||
let welcome = Welcome {
|
||||
abi_version: punktfunk_core::WIRE_VERSION,
|
||||
udp_port,
|
||||
mode: hello.mode,
|
||||
// The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption.
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
// Static override pins it; otherwise sessions start at the adaptive midpoint and the
|
||||
// host re-sizes FEC live from the client's LossReports (adaptive FEC).
|
||||
fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START),
|
||||
max_data_per_block: 4096,
|
||||
},
|
||||
// The largest even payload whose sealed datagram (header + shard + crypto) fits an
|
||||
// unfragmented UDP packet on a 1500 MTU for THIS client's address family — 1408 over
|
||||
// IPv4 (1472 = the exact ceiling), 1388 over IPv6 (40-byte header, and v6 routers
|
||||
// don't fragment: overshooting there blackholes instead of degrading). The data plane
|
||||
// dials the same family as this QUIC connection, so the remote decides. The previous
|
||||
// hardcoded 1452 overshot the v4 ceiling (its math forgot the header/crypto ride
|
||||
// inside the UDP payload) and silently IP-fragmented EVERY video datagram, doubling
|
||||
// per-datagram loss on Wi-Fi — the "100 Mbps badly fails on the phone" root cause.
|
||||
// Negotiated, so the client follows. Jumbo (≈8900) is a future negotiated bump (needs
|
||||
// MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
|
||||
shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16,
|
||||
encrypt: true,
|
||||
key,
|
||||
salt,
|
||||
frames: match source {
|
||||
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).
|
||||
compositor: compositor
|
||||
.map(|c| c.as_pref())
|
||||
.unwrap_or(CompositorPref::Auto),
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
// Colour signalling the client configures its decoder/presenter from. A negotiated
|
||||
// 10-bit session is our HDR path (BT.2020 PQ — what the NVENC HEVC VUI emits from a
|
||||
// 10-bit capture format); 8-bit stays BT.709 SDR. The mastering metadata (ST.2086 +
|
||||
// CLL) rides the 0xCE datagram below. (A future step can refine this to the capturer's
|
||||
// actual monitor HDR state and announce a mid-stream flip.)
|
||||
color: if bit_depth >= 10 {
|
||||
ColorInfo::HDR10_BT2020_PQ
|
||||
} else {
|
||||
ColorInfo::SDR_BT709
|
||||
},
|
||||
// The chroma the encoder will actually emit (resolved + GPU-probed above) — 4:4:4 only
|
||||
// when every gate passed, else 4:2:0. The client sizes its decoder from this.
|
||||
chroma_format: chroma.idc(),
|
||||
// The resolved audio channel count the audio thread will capture + Opus-(multi)stream
|
||||
// encode (2/6/8). The client builds its decoder from this echoed value.
|
||||
audio_channels,
|
||||
// The negotiated codec the encoder will emit (client preference ∩ GPU capability;
|
||||
// HEVC-precedence tie-break). The client builds its decoder from this instead of
|
||||
// assuming HEVC.
|
||||
codec: codec_bit,
|
||||
// This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState),
|
||||
// so capable clients send those instead of the loss-fragile per-transition events.
|
||||
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE,
|
||||
};
|
||||
io::write_msg(send, &welcome.encode()).await?;
|
||||
|
||||
let start =
|
||||
Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?;
|
||||
Ok::<_, anyhow::Error>((
|
||||
hello, welcome, udp_port, data_sock, direct, start, compositor,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
//! The native input plane (plan §W1 — carved out of the [`super`] module): the client→host input
|
||||
//! thread and the per-pad virtual-gamepad router ([`Pads`]) that fans mixed controller kinds out to
|
||||
//! the right injector backend (uinput / UHID on Linux, XUSB / UMDF on Windows), plus rumble
|
||||
//! feedback. `serve_session` spawns [`input_thread`] and feeds it a channel of [`ClientInput`].
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Per-pad accumulated state: punktfunk/1 gamepad events are incremental (one button or axis
|
||||
/// per datagram, see `punktfunk_core::input::gamepad`), the virtual xpad applies full frames.
|
||||
/// A snapshot-capable client replaces the whole state at once ([`PadState::set_snapshot`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
struct PadState {
|
||||
buttons: u32,
|
||||
left_trigger: u8,
|
||||
right_trigger: u8,
|
||||
ls_x: i16,
|
||||
ls_y: i16,
|
||||
rs_x: i16,
|
||||
rs_y: i16,
|
||||
}
|
||||
|
||||
impl PadState {
|
||||
/// Fold one wire event into the state. `false` = unknown axis id (event dropped).
|
||||
fn apply(&mut self, ev: &InputEvent) -> bool {
|
||||
if ev.kind == InputKind::GamepadButton {
|
||||
if ev.x != 0 {
|
||||
self.buttons |= ev.code;
|
||||
} else {
|
||||
self.buttons &= !ev.code;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
use punktfunk_core::input::gamepad::*;
|
||||
let stick = ev.x.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
|
||||
let trigger = ev.x.clamp(0, 255) as u8;
|
||||
match ev.code {
|
||||
AXIS_LS_X => self.ls_x = stick,
|
||||
AXIS_LS_Y => self.ls_y = stick,
|
||||
AXIS_RS_X => self.rs_x = stick,
|
||||
AXIS_RS_Y => self.rs_y = stick,
|
||||
AXIS_LT => self.left_trigger = trigger,
|
||||
AXIS_RT => self.right_trigger = trigger,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace the whole state from one client snapshot (the [`InputKind::GamepadState`] form).
|
||||
fn set_snapshot(&mut self, s: &punktfunk_core::input::GamepadSnapshot) {
|
||||
self.buttons = s.buttons;
|
||||
self.left_trigger = s.left_trigger;
|
||||
self.right_trigger = s.right_trigger;
|
||||
self.ls_x = s.ls_x;
|
||||
self.ls_y = s.ls_y;
|
||||
self.rs_x = s.rs_x;
|
||||
self.rs_y = s.rs_y;
|
||||
}
|
||||
|
||||
fn frame(&self, index: usize, active_mask: u16) -> crate::gamestream::gamepad::GamepadFrame {
|
||||
crate::gamestream::gamepad::GamepadFrame {
|
||||
index: index as i16,
|
||||
active_mask,
|
||||
buttons: self.buttons,
|
||||
left_trigger: self.left_trigger,
|
||||
right_trigger: self.right_trigger,
|
||||
ls_x: self.ls_x,
|
||||
ls_y: self.ls_y,
|
||||
rs_x: self.rs_x,
|
||||
rs_y: self.rs_y,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest pad index addressable on the wire (`flags` field / snapshot `pad`); the uinput
|
||||
/// manager caps actual pad creation at its own MAX_PADS.
|
||||
const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
|
||||
|
||||
/// Per-pad virtual-gamepad router: each pad index is served by a backend of that pad's declared
|
||||
/// kind ([`InputKind::GamepadArrival`](punktfunk_core::input::InputKind::GamepadArrival)), so ONE
|
||||
/// session can MIX controller types — pad 0 a DualSense, pad 1 an Xbox pad. A pad the client never
|
||||
/// declares uses `default` (the session kind resolved from the Hello — the pre-existing single-kind
|
||||
/// behaviour).
|
||||
///
|
||||
/// Backends are created lazily per kind (an empty manager holds no device), and each owns only the
|
||||
/// indices routed to it. A manager's `active_mask` unplug sweep stays correct across managers
|
||||
/// because an index another manager owns is `None` in this one, so the sweep never touches it.
|
||||
///
|
||||
/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
|
||||
/// two identities), the XUSB companion driver (classic XInput) on Windows.
|
||||
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
|
||||
/// minidriver (device-type 0/2/1).
|
||||
/// - Steam Deck — Linux UHID `hid-steam` (or usbip/gadget), or the Windows UMDF minidriver
|
||||
/// (device-type 3, Steam-Input-promoted).
|
||||
///
|
||||
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
|
||||
/// constructs a manager the build lacks.
|
||||
struct Pads {
|
||||
/// Declared (and host-resolved) kind per pad index; `default` until a `GamepadArrival` lands.
|
||||
kinds: [GamepadPref; MAX_WIRE_PADS],
|
||||
/// The kind of the manager that currently OWNS a built device at each index (`None` = no
|
||||
/// device). A live device stays in its manager even if `kinds[idx]` later changes (the rare
|
||||
/// arrival-after-first-frame reorder), so a pad is never duplicated across managers and its
|
||||
/// removal always reaches the manager that actually holds it.
|
||||
owner: [Option<GamepadPref>; MAX_WIRE_PADS],
|
||||
xbox360: Option<crate::inject::gamepad::GamepadManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
xboxone: Option<crate::inject::gamepad::GamepadManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense: Option<crate::inject::dualsense::DualSenseManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense_edge: Option<crate::inject::dualsense::DualSenseEdgeManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2: Option<crate::inject::steam_controller2::Triton2Manager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2_puck: Option<crate::inject::steam_controller2::Triton2Manager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
steamdeck_win: Option<crate::inject::steam_deck_windows::SteamDeckWindowsManager>,
|
||||
}
|
||||
|
||||
impl Pads {
|
||||
/// `default` is the session kind (see [`resolve_gamepad`]); every pad starts on it until the
|
||||
/// client declares its own kind.
|
||||
fn new(default: GamepadPref) -> Pads {
|
||||
let default = resolve_pad_kind(default);
|
||||
tracing::info!(
|
||||
default = default.as_str(),
|
||||
"gamepad backends: per-pad router (session default)"
|
||||
);
|
||||
Pads {
|
||||
kinds: [default; MAX_WIRE_PADS],
|
||||
owner: [None; MAX_WIRE_PADS],
|
||||
xbox360: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
xboxone: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense_edge: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualshock4: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamdeck: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
switchpro: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2_puck: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_edge_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualshock4_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
steamdeck_win: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a pad's client-declared kind (resolved to a buildable backend). Takes effect on the
|
||||
/// pad's next frame; the arrival is sent before the pad's first input, so a device already
|
||||
/// built under the wrong kind is only the rare arrival-after-first-frame reorder — it then
|
||||
/// keeps the earlier kind until re-plug (no live device swap).
|
||||
fn set_kind(&mut self, idx: usize, kind: GamepadPref) {
|
||||
if idx >= MAX_WIRE_PADS {
|
||||
return;
|
||||
}
|
||||
let resolved = resolve_pad_kind(kind);
|
||||
if self.kinds[idx] != resolved {
|
||||
tracing::info!(
|
||||
pad = idx,
|
||||
kind = resolved.as_str(),
|
||||
"gamepad kind declared (per-pad)"
|
||||
);
|
||||
}
|
||||
self.kinds[idx] = resolved;
|
||||
}
|
||||
|
||||
fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
|
||||
use crate::gamestream::gamepad::GamepadEvent;
|
||||
// Present = a create/update frame (the pad's mask bit is set); a cleared bit is the
|
||||
// removal frame emitted by the native detach path (`GamepadRemove`).
|
||||
let (idx, present) = match ev {
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
(idx, f.active_mask & (1 << idx) != 0)
|
||||
}
|
||||
GamepadEvent::Arrival { index, .. } => (*index as usize, true),
|
||||
};
|
||||
if idx >= MAX_WIRE_PADS {
|
||||
return;
|
||||
}
|
||||
let (kind, new_owner) = route_decision(self.owner[idx], self.kinds[idx], present);
|
||||
self.owner[idx] = new_owner;
|
||||
self.route_handle(kind, ev);
|
||||
}
|
||||
|
||||
/// Dispatch a decoded event to the manager for `kind`, creating it lazily.
|
||||
fn route_handle(&mut self, kind: GamepadPref, ev: &crate::gamestream::gamepad::GamepadEvent) {
|
||||
match kind {
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualSense => self
|
||||
.dualsense
|
||||
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualSenseEdge => self
|
||||
.dualsense_edge
|
||||
.get_or_insert_with(crate::inject::dualsense::DualSenseEdgeManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualShock4 => self
|
||||
.dualshock4
|
||||
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamDeck => self
|
||||
.steamdeck
|
||||
.get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SwitchPro => self
|
||||
.switchpro
|
||||
.get_or_insert_with(crate::inject::switch_pro::SwitchProManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController => self
|
||||
.steamctrl
|
||||
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2 => self
|
||||
.steamctrl2
|
||||
.get_or_insert_with(crate::inject::steam_controller2::Triton2Manager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2Puck => self
|
||||
.steamctrl2_puck
|
||||
.get_or_insert_with(|| {
|
||||
crate::inject::steam_controller2::Triton2Manager::with_backend(
|
||||
crate::inject::steam_controller2::TritonProto::puck(),
|
||||
)
|
||||
})
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::XboxOne => self
|
||||
.xboxone
|
||||
.get_or_insert_with(|| {
|
||||
crate::inject::gamepad::GamepadManager::with_identity(
|
||||
crate::inject::gamepad::PadIdentity::xbox_one(),
|
||||
)
|
||||
})
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSense => self
|
||||
.dualsense_win
|
||||
.get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSenseEdge => self
|
||||
.dualsense_edge_win
|
||||
.get_or_insert_with(
|
||||
crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new,
|
||||
)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualShock4 => self
|
||||
.dualshock4_win
|
||||
.get_or_insert_with(
|
||||
crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
|
||||
)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::SteamDeck => self
|
||||
.steamdeck_win
|
||||
.get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new)
|
||||
.handle(ev),
|
||||
_ => self
|
||||
.xbox360
|
||||
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
|
||||
.handle(ev),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a rich client→host event (touchpad / motion) to the pad's kind manager, if it exists
|
||||
/// (rich before the first frame = no device yet = a no-op anyway). The X-Box pads have no rich
|
||||
/// plane, so those indices ignore it.
|
||||
fn apply_rich(&mut self, rich: punktfunk_core::quic::RichInput) {
|
||||
use punktfunk_core::quic::RichInput;
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. }
|
||||
| RichInput::HidReport { pad, .. } => pad as usize,
|
||||
};
|
||||
// Route to the manager that actually owns the device (falling back to the declared kind
|
||||
// before the first frame builds it), so a pad's touchpad/motion never lands on the wrong
|
||||
// backend after a kind change.
|
||||
let kind = self
|
||||
.owner
|
||||
.get(idx)
|
||||
.copied()
|
||||
.flatten()
|
||||
.or_else(|| self.kinds.get(idx).copied())
|
||||
.unwrap_or(GamepadPref::Xbox360);
|
||||
match kind {
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualSenseEdge => {
|
||||
if let Some(m) = &mut self.dualsense_edge {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualShock4 => {
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamDeck => {
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SwitchPro => {
|
||||
if let Some(m) = &mut self.switchpro {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController => {
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2 => {
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2Puck => {
|
||||
if let Some(m) = &mut self.steamctrl2_puck {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSenseEdge => {
|
||||
if let Some(m) = &mut self.dualsense_edge_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualShock4 => {
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::SteamDeck => {
|
||||
if let Some(m) = &mut self.steamdeck_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Triton's USB output endpoint is polled at 1 kHz. Service its raw haptic writes on the same
|
||||
/// cadence so PC-generated trackpad pulses do not sit for up to 4 ms and then arrive at the
|
||||
/// client in bursts. Other backends keep the lower-frequency poll to avoid idle churn.
|
||||
fn feedback_poll_interval(&self) -> std::time::Duration {
|
||||
#[cfg(target_os = "linux")]
|
||||
if self.steamctrl2.is_some() || self.steamctrl2_puck.is_some() {
|
||||
return std::time::Duration::from_millis(1);
|
||||
}
|
||||
std::time::Duration::from_millis(4)
|
||||
}
|
||||
|
||||
/// Service feedback for every instantiated backend each cycle. `rumble` carries motor
|
||||
/// force-feedback on the universal plane (every backend, tagged with its own pad index);
|
||||
/// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF
|
||||
/// pads. The `&mut` closure re-borrows satisfy `FnMut` for each backend.
|
||||
fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(punktfunk_core::quic::HidOutput),
|
||||
) {
|
||||
if let Some(m) = &mut self.xbox360 {
|
||||
m.pump_rumble(&mut rumble); // the X-Box pad has no rich-feedback plane
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(m) = &mut self.xboxone {
|
||||
m.pump_rumble(&mut rumble);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.switchpro {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2_puck {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep every instantiated virtual UHID/UMDF pad alive during input silence (re-emit its HID
|
||||
/// report so the kernel driver / SDL don't drop a held-steady pad). The X-Box pads need no
|
||||
/// heartbeat (evdev holds last-known state). Per-pad gap timers inside each manager govern the
|
||||
/// actual emit cadence, not this per-tick call.
|
||||
fn heartbeat(&mut self) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let gap = std::time::Duration::from_millis(8);
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.switchpro {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let gap = std::time::Duration::from_millis(8);
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One client→host input item, both planes on ONE channel so the input thread wakes the
|
||||
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
|
||||
/// every pure-gyro motion sample up to 4 ms of quantization).
|
||||
pub(super) enum ClientInput {
|
||||
/// The 0xC8 plane: pointer / keyboard / gamepad button+axis.
|
||||
Event(InputEvent),
|
||||
/// The 0xCC plane: touchpad contacts + motion samples.
|
||||
Rich(punktfunk_core::quic::RichInput),
|
||||
}
|
||||
|
||||
/// Default TTL stamped on a non-zero rumble envelope (0xCA v2): how long the client renders the
|
||||
/// level before silencing unless the host renews it. Tolerates 2–3 lost renewals (same loss
|
||||
/// margin the old flat 500 ms refresh gave) while capping a host-abandoned rumble at this on every
|
||||
/// client — versus the per-platform client heuristics it replaces (SDL 1.5 s, Apple 1.6 s, Android
|
||||
/// up to the QUIC idle-timeout). Overridable via `PUNKTFUNK_RUMBLE_TTL_MS` (floored at
|
||||
/// [`RUMBLE_TTL_FLOOR_MS`] so expiry jitter stays below the clients' tick granularity).
|
||||
const RUMBLE_TTL_MS: u16 = 400;
|
||||
/// Floor for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch — below this the ~50 ms client ticks make expiry
|
||||
/// audible (see `rumble-envelope-plan.md` §5).
|
||||
const RUMBLE_TTL_FLOOR_MS: u16 = 150;
|
||||
/// Ceiling for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch. A lease longer than a few seconds defeats the
|
||||
/// design's "an abandoned rumble stops promptly" goal, and keeping it well under `u16::MAX` means
|
||||
/// the wire never emits a TTL a narrower client-side slot could mistake for a sentinel.
|
||||
const RUMBLE_TTL_CEIL_MS: u16 = 5_000;
|
||||
/// Floor for the derived renewal interval (renew = ttl × 3/10) so an aggressive TTL hatch can't
|
||||
/// spin the renewal loop faster than this.
|
||||
const RUMBLE_RENEW_FLOOR_MS: u64 = 60;
|
||||
/// How many times a transition-to-zero (a stop) is re-sent on the renewal ticks after the
|
||||
/// immediate stop datagram, before the pad goes quiet. Covers stop-datagram loss for legacy
|
||||
/// clients (a v2 client also self-silences at TTL); even a fully lost burst heals via the client's
|
||||
/// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends.
|
||||
const RUMBLE_STOP_BURST: u8 = 2;
|
||||
|
||||
/// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating
|
||||
/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the
|
||||
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
|
||||
fn send_rumble(
|
||||
conn: &quinn::Connection,
|
||||
envelope_on: bool,
|
||||
pad: u16,
|
||||
low: u16,
|
||||
high: u16,
|
||||
seq: u8,
|
||||
ttl_ms: u16,
|
||||
) {
|
||||
let d: Vec<u8> = if envelope_on {
|
||||
punktfunk_core::quic::encode_rumble_datagram_v2(pad, low, high, seq, ttl_ms).to_vec()
|
||||
} else {
|
||||
punktfunk_core::quic::encode_rumble_datagram(pad, low, high).to_vec()
|
||||
};
|
||||
let _ = conn.send_datagram(d.into());
|
||||
}
|
||||
|
||||
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
|
||||
/// service (`inj_tx`) and gamepad events to this session's [`Pads`] router (`gamepad` — the
|
||||
/// resolved Hello preference is the per-pad default; clients declare each pad's kind so a session
|
||||
/// can mix uinput X-Box pads and virtual DualSense pads), with rich
|
||||
/// client→host input (touchpad / motion, [`ClientInput::Rich`]) applied on arrival and
|
||||
/// feedback pumped between events — rumble on the universal datagram plane, DualSense
|
||||
/// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with
|
||||
/// the session; the pointer/keyboard injector (and its portal grant) lives in the service,
|
||||
/// across sessions.
|
||||
///
|
||||
/// Rumble is emitted as self-terminating 0xCA v2 envelopes (`[level][seq][ttl_ms]`): the host owns
|
||||
/// the timeline, renewing an active level every ~`RUMBLE_TTL_MS × 3/10` ms and letting an
|
||||
/// abandoned one expire client-side, so "stuck rumble" is inexpressible on the wire (see
|
||||
/// `punktfunk-planning/design/rumble-envelope-plan.md`). `PUNKTFUNK_RUMBLE_ENVELOPE=0` reverts to
|
||||
/// legacy v1 level datagrams + the flat 500 ms refresh (bisect hatch).
|
||||
pub(super) fn input_thread(
|
||||
rx: std::sync::mpsc::Receiver<ClientInput>,
|
||||
conn: quinn::Connection,
|
||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
gamepad: GamepadPref,
|
||||
) {
|
||||
let mut pads = Pads::new(gamepad);
|
||||
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
|
||||
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
|
||||
// is 5000 u32s.
|
||||
let mut motion_gaps_us: Vec<u32> = Vec::new();
|
||||
let mut last_motion: Option<std::time::Instant> = None;
|
||||
let mut motion_window = std::time::Instant::now();
|
||||
let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
|
||||
let mut pad_mask = 0u16;
|
||||
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
|
||||
// `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back.
|
||||
let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS];
|
||||
// Rumble self-terminating envelopes (0xCA v2). Each non-zero level is authorized for
|
||||
// `rumble_ttl_ms`; the host renews an active pad every `rumble_renew` and lets an abandoned
|
||||
// one expire on the client, so a dropped transition heals on the next renewal and a stop that
|
||||
// is lost heals via the stop burst (or the client's own TTL expiry). `rumble_seq` is the
|
||||
// per-pad wrapping reorder counter (bumped on changes AND renewals) the client gates on;
|
||||
// `rumble_stop_burst` counts the post-stop zero re-sends still owed. `PUNKTFUNK_RUMBLE_ENVELOPE=0`
|
||||
// reverts to legacy v1 datagrams re-sent flat every 500 ms.
|
||||
let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS];
|
||||
let mut rumble_seen = [false; MAX_WIRE_PADS];
|
||||
let mut rumble_seq = [0u8; MAX_WIRE_PADS];
|
||||
let mut rumble_stop_burst = [0u8; MAX_WIRE_PADS];
|
||||
let mut last_refresh = std::time::Instant::now();
|
||||
let rumble_envelope_on = std::env::var("PUNKTFUNK_RUMBLE_ENVELOPE").as_deref() != Ok("0");
|
||||
let rumble_ttl_ms: u16 = std::env::var("PUNKTFUNK_RUMBLE_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.map(|v| v.clamp(RUMBLE_TTL_FLOOR_MS, RUMBLE_TTL_CEIL_MS))
|
||||
.unwrap_or(RUMBLE_TTL_MS);
|
||||
// Renew at 30 % of the TTL (≈120 ms for the 400 ms default) so 2–3 renewals cover the lease;
|
||||
// in legacy mode the periodic block instead runs the old flat 500 ms full-state refresh.
|
||||
let rumble_refresh_interval = if rumble_envelope_on {
|
||||
std::time::Duration::from_millis((rumble_ttl_ms as u64 * 3 / 10).max(RUMBLE_RENEW_FLOOR_MS))
|
||||
} else {
|
||||
std::time::Duration::from_millis(500)
|
||||
};
|
||||
// Pointer buttons / keys the client currently holds down. The injector is host-lifetime, so a
|
||||
// press left dangling by an abrupt client disconnect stays latched in the compositor across the
|
||||
// reconnect (Mutter keeps the implicit pointer grab of the still-pressed button — a stuck
|
||||
// left-button-down then turns every later click into a drag: windows move, but clicking buttons
|
||||
// and text inputs does nothing). We synthesize the matching up-events when this session ends —
|
||||
// see the release loop after the `break`.
|
||||
// Sets (not Vecs) so the presence test is O(1), not O(n) per event, and bounded by `MAX_HELD`
|
||||
// so a client flooding distinct never-released codes can't grow the tracking state or spike the
|
||||
// input thread (security-review 2026-06-28 S3). A real keyboard+mouse holds far fewer at once;
|
||||
// codes past the cap simply aren't tracked for end-of-session release (worst case: one unreleased
|
||||
// key on a pathological disconnect, which the injector's own state still bounds).
|
||||
const MAX_HELD: usize = 256;
|
||||
let mut held_buttons: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
let mut held_keys: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
loop {
|
||||
match rx.recv_timeout(pads.feedback_poll_interval()) {
|
||||
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
|
||||
// wakes for gyro samples instead of making them wait out the feedback poll interval.
|
||||
Ok(ClientInput::Rich(rich)) => {
|
||||
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) {
|
||||
let now = std::time::Instant::now();
|
||||
if let Some(prev) = last_motion.replace(now) {
|
||||
let gap = now.duration_since(prev);
|
||||
if gap < std::time::Duration::from_secs(1) {
|
||||
motion_gaps_us.push(gap.as_micros() as u32);
|
||||
}
|
||||
}
|
||||
if motion_window.elapsed() >= std::time::Duration::from_secs(5)
|
||||
&& !motion_gaps_us.is_empty()
|
||||
{
|
||||
motion_gaps_us.sort_unstable();
|
||||
let p = |q: f64| {
|
||||
motion_gaps_us[(q * (motion_gaps_us.len() - 1) as f64) as usize]
|
||||
};
|
||||
tracing::debug!(
|
||||
samples = motion_gaps_us.len() + 1,
|
||||
gap_p50_us = p(0.5),
|
||||
gap_p95_us = p(0.95),
|
||||
gap_max_us = motion_gaps_us.last().copied().unwrap_or(0),
|
||||
"motion cadence (client gyro inter-arrival, 5 s window)"
|
||||
);
|
||||
motion_gaps_us.clear();
|
||||
motion_window = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
pads.apply_rich(rich);
|
||||
}
|
||||
Ok(ClientInput::Event(ev)) => match ev.kind {
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => {
|
||||
// A bad index / unknown axis just doesn't update a pad — fall through (no
|
||||
// `continue`) so the rich-input drain + feedback pump below still run every
|
||||
// iteration (the DualSense GET_REPORT handshake must be serviced promptly).
|
||||
let idx = ev.flags as usize;
|
||||
if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) {
|
||||
pad_mask |= 1 << idx;
|
||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
|
||||
}
|
||||
}
|
||||
InputKind::GamepadState => {
|
||||
// Idempotent full-state snapshot from a capable client (see
|
||||
// `GamepadSnapshot`): applied only when its seq supersedes the last one, so
|
||||
// a datagram the network reordered can't roll held state backwards. The
|
||||
// client refreshes touched pads every ~100 ms, so an unchanged refresh is
|
||||
// the common case — skip the frame emit then (an XInput packet-number bump
|
||||
// for identical state is pure churn), but always advance the gate.
|
||||
use punktfunk_core::input::GamepadSnapshot;
|
||||
if let Some(snap) = GamepadSnapshot::from_event(&ev) {
|
||||
let idx = snap.pad as usize;
|
||||
if idx < MAX_WIRE_PADS && GamepadSnapshot::seq_newer(snap.seq, pad_seq[idx])
|
||||
{
|
||||
pad_seq[idx] = Some(snap.seq);
|
||||
let before = pad_state[idx];
|
||||
pad_state[idx].set_snapshot(&snap);
|
||||
let first = pad_mask & (1 << idx) == 0;
|
||||
if first || pad_state[idx] != before {
|
||||
pad_mask |= 1 << idx;
|
||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(
|
||||
frame,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
InputKind::GamepadRemove => {
|
||||
// Mid-session hot-unplug from a snapshot-capable client (the native plane's
|
||||
// `activeGamepadMask` equivalent). Seq-gated in the SAME per-pad sequence
|
||||
// space as snapshots, so a snapshot the network reordered past this removal
|
||||
// is dropped (older seq) and can't resurrect the pad — while a later re-plug
|
||||
// on the same index arrives with a still-newer seq and is accepted. Clearing
|
||||
// the `active_mask` bit and re-emitting the frame fires every backend's
|
||||
// unplug sweep (`inject/*/gamepad.rs`), tearing down just this pad's device.
|
||||
let (pad, seq) = punktfunk_core::input::decode_gamepad_remove(ev.flags);
|
||||
let idx = pad as usize;
|
||||
if idx < MAX_WIRE_PADS
|
||||
&& punktfunk_core::input::GamepadSnapshot::seq_newer(seq, pad_seq[idx])
|
||||
{
|
||||
pad_seq[idx] = Some(seq);
|
||||
if pad_mask & (1 << idx) != 0 {
|
||||
pad_mask &= !(1 << idx);
|
||||
pad_state[idx] = PadState::default();
|
||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
|
||||
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
|
||||
}
|
||||
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
|
||||
// stale rumble lease/seq (a lease still ticking would buzz the new pad).
|
||||
rumble_state[idx] = (0, 0);
|
||||
rumble_seen[idx] = false;
|
||||
rumble_seq[idx] = 0;
|
||||
rumble_stop_burst[idx] = 0;
|
||||
}
|
||||
}
|
||||
InputKind::GamepadArrival => {
|
||||
// Per-pad controller kind declaration (mixed types): route this pad's future
|
||||
// frames to a backend of the declared kind. `code` = the GamepadPref wire byte,
|
||||
// `flags` = pad index. Applied before the pad's first frame (the client sends it
|
||||
// on slot open), so the device is built as the right type from the start.
|
||||
let idx = ev.flags as usize;
|
||||
let kind = GamepadPref::from_u8(ev.code as u8);
|
||||
pads.set_kind(idx, kind);
|
||||
}
|
||||
_ => {
|
||||
// Track press/release so a mid-press disconnect can be undone below.
|
||||
match ev.kind {
|
||||
InputKind::MouseButtonDown if held_buttons.len() < MAX_HELD => {
|
||||
held_buttons.insert(ev.code);
|
||||
}
|
||||
InputKind::MouseButtonUp => {
|
||||
held_buttons.remove(&ev.code);
|
||||
}
|
||||
InputKind::KeyDown if held_keys.len() < MAX_HELD => {
|
||||
held_keys.insert(ev.code);
|
||||
}
|
||||
InputKind::KeyUp => {
|
||||
held_keys.remove(&ev.code);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Pointer/keyboard → the host-lifetime injector service (one persistent
|
||||
// portal session for every punktfunk/1 session). A send error only means the
|
||||
// service thread is gone (host shutting down) — dropping the event is fine,
|
||||
// input is lossy by design.
|
||||
let _ = inj_tx.send(ev);
|
||||
}
|
||||
},
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
// Service feedback every iteration (≤1 ms for Triton, ≤4 ms otherwise; games block on
|
||||
// EVIOCSFF, and HID handshakes must be answered promptly). Rumble → the universal 0xCA
|
||||
// plane; rich/raw HID feedback → 0xCD.
|
||||
pads.pump(
|
||||
|pad, low, high| {
|
||||
let idx = pad as usize;
|
||||
if idx < MAX_WIRE_PADS {
|
||||
let prev = rumble_state[idx];
|
||||
// Log the silent→active transition (once per buzz) so a live test can tell
|
||||
// "host never gets rumble from the game" apart from "client doesn't render it".
|
||||
if prev == (0, 0) && (low != 0 || high != 0) {
|
||||
tracing::debug!(pad, low, high, "rumble: forwarding to client (0xCA)");
|
||||
}
|
||||
rumble_state[idx] = (low, high);
|
||||
rumble_seen[idx] = true;
|
||||
// Bump the reorder counter on every change, then arm the stop burst on a
|
||||
// transition to zero (so a lost stop still reaches a legacy client) and clear
|
||||
// it when the game re-asserts a non-zero level.
|
||||
rumble_seq[idx] = rumble_seq[idx].wrapping_add(1);
|
||||
if (low, high) == (0, 0) {
|
||||
rumble_stop_burst[idx] = if prev != (0, 0) { RUMBLE_STOP_BURST } else { 0 };
|
||||
} else {
|
||||
rumble_stop_burst[idx] = 0;
|
||||
}
|
||||
let ttl = if (low, high) == (0, 0) {
|
||||
0
|
||||
} else {
|
||||
rumble_ttl_ms
|
||||
};
|
||||
send_rumble(
|
||||
&conn,
|
||||
rumble_envelope_on,
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
rumble_seq[idx],
|
||||
ttl,
|
||||
);
|
||||
} else {
|
||||
// Out-of-range pad (a backend never produces these) — forward without gating.
|
||||
send_rumble(&conn, rumble_envelope_on, pad, low, high, 0, rumble_ttl_ms);
|
||||
}
|
||||
},
|
||||
|h| {
|
||||
let _ = conn.send_datagram(h.encode().into());
|
||||
},
|
||||
);
|
||||
// Keep the virtual DualSense from going silent during steady input (no-op for X-Box): a
|
||||
// held-steady pad sends no wire events, so without a periodic re-emit the kernel/SDL drop
|
||||
// it as unplugged. The 8 ms gap inside heartbeat() governs the rate, not this ≤4 ms tick.
|
||||
pads.heartbeat();
|
||||
if last_refresh.elapsed() >= rumble_refresh_interval {
|
||||
last_refresh = std::time::Instant::now();
|
||||
if rumble_envelope_on {
|
||||
// Renewal: refresh an active pad's lease (bump seq, fresh TTL), and drain each
|
||||
// pad's post-stop zero burst, then let it go quiet — no perpetual zero refreshes.
|
||||
for i in 0..MAX_WIRE_PADS {
|
||||
if !rumble_seen[i] {
|
||||
continue;
|
||||
}
|
||||
let (low, high) = rumble_state[i];
|
||||
if (low, high) != (0, 0) {
|
||||
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
|
||||
send_rumble(
|
||||
&conn,
|
||||
true,
|
||||
i as u16,
|
||||
low,
|
||||
high,
|
||||
rumble_seq[i],
|
||||
rumble_ttl_ms,
|
||||
);
|
||||
} else if rumble_stop_burst[i] > 0 {
|
||||
rumble_stop_burst[i] -= 1;
|
||||
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
|
||||
send_rumble(&conn, true, i as u16, 0, 0, rumble_seq[i], 0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy: re-send the current level of every seen pad every 500 ms (v1).
|
||||
for (i, &(low, high)) in rumble_state.iter().enumerate() {
|
||||
if rumble_seen[i] {
|
||||
let d = punktfunk_core::quic::encode_rumble_datagram(i as u16, low, high);
|
||||
let _ = conn.send_datagram(d.to_vec().into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Session ended (client gone). Release anything still held through the host-lifetime injector —
|
||||
// its EIS connection (and any implicit grab Mutter holds for our pressed button) outlives this
|
||||
// session, so without this a button pressed at disconnect stays latched and breaks clicks for
|
||||
// the next session. Mirror of the injector's own release_all, but keyed off the session, which
|
||||
// is where a client actually vanishes mid-press.
|
||||
if !held_buttons.is_empty() || !held_keys.is_empty() {
|
||||
tracing::debug!(
|
||||
buttons = held_buttons.len(),
|
||||
keys = held_keys.len(),
|
||||
"input: releasing held buttons/keys at session end"
|
||||
);
|
||||
}
|
||||
for code in held_buttons {
|
||||
let _ = inj_tx.send(InputEvent {
|
||||
kind: InputKind::MouseButtonUp,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
});
|
||||
}
|
||||
for code in held_keys {
|
||||
let _ = inj_tx.send(InputEvent {
|
||||
kind: InputKind::KeyUp,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
|
||||
#[test]
|
||||
fn pad_snapshot_replaces_state_and_seq_gates() {
|
||||
use punktfunk_core::input::{gamepad, GamepadSnapshot};
|
||||
let mut state = PadState::default();
|
||||
let mut last_seq: Option<u8> = None;
|
||||
|
||||
// Legacy accumulation first (an older client), then a snapshot replaces it wholesale.
|
||||
let axis = InputEvent {
|
||||
kind: InputKind::GamepadAxis,
|
||||
_pad: [0; 3],
|
||||
code: gamepad::AXIS_LT,
|
||||
x: 200,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
assert!(state.apply(&axis));
|
||||
assert_eq!(state.left_trigger, 200);
|
||||
|
||||
let snap = GamepadSnapshot {
|
||||
pad: 0,
|
||||
seq: 1,
|
||||
buttons: gamepad::BTN_A,
|
||||
left_trigger: 255,
|
||||
right_trigger: 0,
|
||||
ls_x: 100,
|
||||
ls_y: -100,
|
||||
rs_x: 0,
|
||||
rs_y: 0,
|
||||
};
|
||||
assert!(GamepadSnapshot::seq_newer(snap.seq, last_seq));
|
||||
last_seq = Some(snap.seq);
|
||||
state.set_snapshot(&snap);
|
||||
assert_eq!(state.left_trigger, 255);
|
||||
assert_eq!(state.buttons, gamepad::BTN_A);
|
||||
assert_eq!((state.ls_x, state.ls_y), (100, -100));
|
||||
|
||||
// A reordered (stale) snapshot must not roll the trigger back.
|
||||
let stale = GamepadSnapshot {
|
||||
seq: 0,
|
||||
left_trigger: 10,
|
||||
..snap
|
||||
};
|
||||
assert!(!GamepadSnapshot::seq_newer(stale.seq, last_seq));
|
||||
|
||||
// The unchanged-refresh case the input thread skips the frame emit for: identical
|
||||
// payload with a newer seq compares equal after apply.
|
||||
let refresh = GamepadSnapshot { seq: 2, ..snap };
|
||||
assert!(GamepadSnapshot::seq_newer(refresh.seq, last_seq));
|
||||
let before = state;
|
||||
state.set_snapshot(&refresh);
|
||||
assert_eq!(state, before);
|
||||
|
||||
// The snapshot survives the wire roundtrip into the same PadState shape.
|
||||
let dec =
|
||||
GamepadSnapshot::from_event(&InputEvent::decode(&snap.to_event().encode()).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(dec, snap);
|
||||
}
|
||||
|
||||
fn gp(kind: InputKind, code: u32, x: i32, pad: u32) -> InputEvent {
|
||||
InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x,
|
||||
y: 0,
|
||||
flags: pad,
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental wire events accumulate into the full pad frame the virtual xpad applies.
|
||||
#[test]
|
||||
fn gamepad_accumulator() {
|
||||
use punktfunk_core::input::gamepad::*;
|
||||
let mut s = PadState::default();
|
||||
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_A, 1, 0)));
|
||||
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_LB, 1, 0)));
|
||||
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LS_X, -32768, 0)));
|
||||
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_RT, 255, 0)));
|
||||
let f = s.frame(2, 0b0100);
|
||||
assert_eq!(f.buttons, BTN_A | BTN_LB);
|
||||
assert_eq!((f.ls_x, f.right_trigger), (-32768, 255));
|
||||
assert_eq!((f.index, f.active_mask), (2, 0b0100));
|
||||
|
||||
// Release folds out; axis values clamp; unknown axis ids are rejected.
|
||||
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_A, 0, 0)));
|
||||
assert_eq!(s.frame(0, 1).buttons, BTN_LB);
|
||||
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LT, 9_999, 0)));
|
||||
assert_eq!(s.left_trigger, 255);
|
||||
assert!(!s.apply(&gp(InputKind::GamepadAxis, 42, 1, 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The host side of the native SPAKE2 pairing ceremony (plan §W1 — carved out of the [`super`]
|
||||
//! module). `serve_session` dispatches a connection whose first message is a `PairRequest` here,
|
||||
//! after it has resolved the live arming PIN (honoring fingerprint binding, #9); this runs the
|
||||
//! ceremony, enforces the single online guess, and persists the client's fingerprint on success.
|
||||
|
||||
use super::*;
|
||||
// The ceremony-only wire messages: imported directly (native.rs no longer references them, so they
|
||||
// were dropped from its `use` and won't come through `use super::*`). `PairRequest` still arrives
|
||||
// via the glob (serve_session decodes it).
|
||||
use punktfunk_core::quic::{PairChallenge, PairProof, PairResult};
|
||||
|
||||
/// Pairing needs a human in the loop (reading the PIN off the host, typing it into the
|
||||
/// client), so its budget is far larger than the machine-speed session handshake.
|
||||
const PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// The host side of the SPAKE2 pairing ceremony (see `punktfunk_core::quic::pake`):
|
||||
/// generate + display a PIN, run SPAKE2 as B binding both cert fingerprints, verify the
|
||||
/// client's key-confirmation MAC (its single online guess), and persist the client's
|
||||
/// fingerprint on success.
|
||||
pub(super) async fn pair_ceremony(
|
||||
conn: &quinn::Connection,
|
||||
mut send: quinn::SendStream,
|
||||
mut recv: quinn::RecvStream,
|
||||
req: PairRequest,
|
||||
host_fp: &[u8; 32],
|
||||
np: &NativePairing,
|
||||
pin: &str,
|
||||
) -> Result<()> {
|
||||
use punktfunk_core::quic::pake;
|
||||
let client_fp = endpoint::peer_fingerprint(conn)
|
||||
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
|
||||
|
||||
tracing::info!(
|
||||
name = %req.name,
|
||||
client = %fingerprint_hex(&client_fp),
|
||||
"PAIRING REQUEST — verifying against the armed PIN"
|
||||
);
|
||||
|
||||
// SPAKE2 as B; bind our own host_fp + the client cert we actually received.
|
||||
let (pake, spake_b) = pake::start(false, pin, &client_fp, host_fp);
|
||||
let confirms = pake.finish(&req.spake_a)?; // Err only on a malformed peer message
|
||||
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&PairChallenge {
|
||||
spake_b,
|
||||
confirm: confirms.host,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// SINGLE-USE PIN: we've now sent the host key-confirmation, which lets the client TEST this one
|
||||
// guess (a right PIN → its proof will match; a wrong PIN → the client detects the mismatch and
|
||||
// aborts *without* sending its proof). So consume the PIN HERE — before reading the proof —
|
||||
// regardless of the outcome: an attacker gets EXACTLY ONE online guess (the documented guarantee),
|
||||
// not an unbounded brute-force of the 4-digit space against a static, never-rotating PIN. A
|
||||
// malformed request that errored at `pake.finish` above never reached here, so it doesn't burn the
|
||||
// window (no DoS from garbage). The operator re-arms (web console / restart) for the next device —
|
||||
// including after a successful pair; the protocol gives no reliable host-observable "wrong PIN"
|
||||
// signal to scope this to failures only (the client just disconnects).
|
||||
np.disarm();
|
||||
|
||||
let proof = tokio::time::timeout(PAIRING_TIMEOUT, io::read_msg(&mut recv))
|
||||
.await
|
||||
.map_err(|_| anyhow!("pairing timed out waiting for the client's confirmation"))??;
|
||||
let proof = PairProof::decode(&proof).map_err(|e| anyhow!("PairProof decode: {e:?}"))?;
|
||||
|
||||
// A wrong PIN (or a MITM with mismatched cert views) yields a different SPAKE2 key, so
|
||||
// the client's confirmation MAC won't match ours — one online attempt, no offline search.
|
||||
let ok = pake::verify(&confirms.client, &proof.confirm);
|
||||
|
||||
if ok {
|
||||
if let Err(e) = np.add(&req.name, &fingerprint_hex(&client_fp)) {
|
||||
tracing::error!(error = %format!("{e:#}"), "could not persist paired clients");
|
||||
}
|
||||
tracing::info!(name = %req.name, "pairing complete — client trusted");
|
||||
} else {
|
||||
tracing::warn!(name = %req.name, "pairing rejected (wrong PIN) — fingerprint not stored");
|
||||
}
|
||||
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
|
||||
let _ = send.finish();
|
||||
// Wait for the client to acknowledge by closing, so the PairResult isn't dropped by our
|
||||
// close on a slow link (bounded so a vanished client can't wedge the sequential host).
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), conn.closed()).await;
|
||||
conn.close(0u32.into(), b"pairing done");
|
||||
anyhow::ensure!(ok, "pairing rejected (wrong PIN)");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Per-thread OS scheduling QoS for the native data plane (plan §W1 — carved out of the [`super`]
|
||||
//! module). The capture/encode and send threads raise their own priority so a CPU-saturating game
|
||||
//! can't deschedule them; the GameStream path and the direct-NVENC send thread reach this the same
|
||||
//! way (`crate::native::boost_thread_priority`).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
/// Raise the current thread's OS scheduling priority so a CPU-heavy game can't deschedule our
|
||||
/// capture/encode/send threads. This matters even though our GPU work is already HIGH priority: the
|
||||
/// GPU scheduler can only favour commands we've actually SUBMITTED, so if a normal-priority thread is
|
||||
/// descheduled by the game it submits the convert/encode late and the GPU priority never bites. Apollo
|
||||
/// does the same (capture thread CRITICAL, encoder ABOVE_NORMAL). The Linux host needs this too: an
|
||||
/// uncapped GPU-saturating title (e.g. CS2 direct on a virtual output, not capped by gamescope) is
|
||||
/// also a CPU hog and can deschedule our submit threads. `critical` → highest non-realtime class
|
||||
/// (the capture+encode loop); otherwise above-normal (the send/relay thread).
|
||||
pub(crate) fn boost_thread_priority(critical: bool) {
|
||||
// Windows host-process/thread session tuning (timer 1ms, DWM MMCSS, HIGH class once; MMCSS +
|
||||
// keep-display-awake per thread). No-op off Windows. Both stream threads call us, so this covers
|
||||
// capture/encode (critical) and send (non-critical).
|
||||
crate::session_tuning::on_hot_thread();
|
||||
#[cfg(target_os = "windows")]
|
||||
// SAFETY: `GetCurrentThread()` returns the constant pseudo-handle for the calling thread — always
|
||||
// valid, thread-local in meaning, and never closed (no leak/double-close). `SetThreadPriority`
|
||||
// takes that handle plus a `THREAD_PRIORITY_*` value the windows crate defines (HIGHEST or
|
||||
// ABOVE_NORMAL here); it only reprioritizes this OS thread, borrows no Rust memory, and its
|
||||
// `Result` is matched (a failure is logged, never UB). No pointers, lifetimes, or aliasing.
|
||||
unsafe {
|
||||
use windows::Win32::System::Threading::{
|
||||
GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_ABOVE_NORMAL,
|
||||
THREAD_PRIORITY_HIGHEST,
|
||||
};
|
||||
let prio = if critical {
|
||||
THREAD_PRIORITY_HIGHEST
|
||||
} else {
|
||||
THREAD_PRIORITY_ABOVE_NORMAL
|
||||
};
|
||||
match SetThreadPriority(GetCurrentThread(), prio) {
|
||||
Ok(()) => tracing::debug!(critical, "thread priority raised"),
|
||||
Err(e) => {
|
||||
tracing::debug!(critical, error = ?e, "SetThreadPriority failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Best-effort nice of the CALLING thread. On Linux `setpriority(PRIO_PROCESS, 0, …)` acts on
|
||||
// the calling thread (the kernel resolves who==0 to the current task/tid), and both call
|
||||
// sites run inside their worker thread — so this nices exactly the capture/encode (critical)
|
||||
// and send (non-critical) threads, nothing else. Silently no-ops without CAP_SYS_NICE / a
|
||||
// raised RLIMIT_NICE, which is fine. We deliberately do NOT use SCHED_RR/FIFO by default: a
|
||||
// realtime CPU class can preempt the compositor AND the game's own render thread, adding the
|
||||
// very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR).
|
||||
let nice = if critical { -10 } else { -5 };
|
||||
// SAFETY: `setpriority` takes three by-value integers and no pointers, so there is nothing to
|
||||
// alias or outlive. `PRIO_PROCESS` with `who == 0` targets the calling task on Linux and
|
||||
// `nice` is in range; the call only adjusts this thread's scheduling nice value and returns an
|
||||
// `int` we inspect. No memory is touched.
|
||||
let rc = unsafe { libc::setpriority(libc::PRIO_PROCESS, 0, nice) };
|
||||
if rc == 0 {
|
||||
tracing::debug!(critical, nice, "thread nice raised");
|
||||
} else {
|
||||
tracing::debug!(
|
||||
critical,
|
||||
"setpriority(nice) no-op (needs CAP_SYS_NICE / RLIMIT_NICE)"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
let _ = critical;
|
||||
}
|
||||
}
|
||||
@@ -1,158 +1,49 @@
|
||||
//! 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::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.
|
||||
//! persistent paired-clients store and the delegated-approval queue. One [`NativePairing`] handle is
|
||||
//! shared by the punktfunk/1 QUIC accept loop ([`crate::native`]) 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*
|
||||
//! enters it (the client needs it to build its first message). So the UI **displays** the PIN —
|
||||
//! armed on demand for a short window — rather than accepting one.
|
||||
//!
|
||||
//! This is a thin facade (plan §W5); the three concerns each own their state in a submodule:
|
||||
//! - `arming` — the on-demand PIN window (`ArmState`),
|
||||
//! - `store` — the persistent trust store (`TrustStore`),
|
||||
//! - `approval` — the pending-knock queue + delegated approval (`ApprovalQueue`),
|
||||
//! - `sanitize` — the untrusted-device-name scrubber.
|
||||
//!
|
||||
//! Admitting a device is the one cross-cutting flow: pinning the fingerprint lives in `store` and
|
||||
//! clearing the pending knock lives in `approval`, so [`NativePairing::add`] drives both in order
|
||||
//! (pin, THEN clear + notify) and [`NativePairing::wait_for_decision`] injects an `is_paired` closure
|
||||
//! into the store-blind approval queue.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Notify;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`.
|
||||
/// (Separate from GameStream pairing, which has its own store and ceremony.)
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PairedClients {
|
||||
pub clients: Vec<PairedClient>,
|
||||
}
|
||||
mod approval;
|
||||
mod arming;
|
||||
mod sanitize;
|
||||
mod store;
|
||||
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PairedClient {
|
||||
pub name: String,
|
||||
/// Hex SHA-256 of the client's certificate.
|
||||
pub fingerprint: String,
|
||||
}
|
||||
pub use approval::{PairingDecision, PendingRequest};
|
||||
pub use arming::PinAttempt;
|
||||
pub use store::PairedClient;
|
||||
|
||||
impl PairedClients {
|
||||
fn contains(&self, fp_hex: &str) -> bool {
|
||||
self.clients
|
||||
.iter()
|
||||
.any(|c| c.fingerprint.eq_ignore_ascii_case(fp_hex))
|
||||
}
|
||||
}
|
||||
|
||||
struct PairedState {
|
||||
path: PathBuf,
|
||||
clients: PairedClients,
|
||||
}
|
||||
|
||||
/// The current arming window. `pin == None` ⇒ disarmed. `expires_at == None` ⇒ armed with no
|
||||
/// expiry (the CLI `--allow-pairing` flag); `Some(t)` ⇒ a web-armed window that auto-disarms.
|
||||
///
|
||||
/// `bound_fp == Some(fp)` ⇒ the window is **bound to one operator-selected device fingerprint**:
|
||||
/// only a pairing attempt from that fingerprint may consume it (security-review 2026-06-28 #9). This
|
||||
/// closes the window-burn DoS — an unpaired LAN peer cannot consume a window armed for a specific
|
||||
/// device, because the QUIC client-auth proves cert possession (it can't forge the bound fingerprint).
|
||||
/// `None` ⇒ unbound (the CLI flag / a console "arm open"): any well-formed attempt consumes it (the
|
||||
/// legacy behavior, retaining the window-burn DoS — acceptable only on a trusted LAN).
|
||||
#[derive(Default)]
|
||||
struct Armed {
|
||||
pin: Option<String>,
|
||||
expires_at: Option<Instant>,
|
||||
bound_fp: Option<String>,
|
||||
}
|
||||
|
||||
/// The result of resolving the armed PIN for a specific client fingerprint ([`NativePairing::pin_for_attempt`]).
|
||||
pub enum PinAttempt {
|
||||
/// No window is armed (disarmed/expired) — reject; do not run the ceremony.
|
||||
Disarmed,
|
||||
/// A window IS armed but **bound to a different fingerprint** — reject WITHOUT consuming it, so
|
||||
/// an unrelated (attacker) fingerprint can't burn the operator's armed window (#9).
|
||||
BoundToOther,
|
||||
/// Proceed: the PIN to run the ceremony with (the window is unbound, or bound to this fingerprint).
|
||||
Pin(String),
|
||||
}
|
||||
|
||||
/// An unpaired (but identified) device that knocked on a pairing-required host — held for
|
||||
/// **delegated approval** from the management console (roadmap §8b-1) instead of being silently
|
||||
/// forgotten. In-memory only: pending knocks don't survive a restart (the device just knocks
|
||||
/// again), and they expire after [`PENDING_TTL`].
|
||||
struct Pending {
|
||||
id: u32,
|
||||
name: String,
|
||||
fp_hex: String,
|
||||
requested_at: Instant,
|
||||
/// QUIC-validated source address of the knock — used for the per-source cap (#13), so one host
|
||||
/// can't fill the queue. `None` if unknown (e.g. tests / a caller that doesn't supply it).
|
||||
src_ip: Option<IpAddr>,
|
||||
/// True while a connection is held open in [`NativePairing::wait_for_decision`] for this knock.
|
||||
/// A live parked knock is a genuine device waiting for the operator — eviction skips it unless
|
||||
/// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13).
|
||||
parked: bool,
|
||||
/// Generation of the MOST RECENT knock for this fingerprint. A re-knock bumps it (and wakes
|
||||
/// waiters), so a stale parked connection resolves [`PairingDecision::Superseded`] instead of
|
||||
/// being admitted alongside the newest one — one Approve must admit exactly ONE session.
|
||||
/// (Observed live: a client retried 3× while parked, one console Approve admitted all three,
|
||||
/// and the three concurrent Mutter virtual monitors segfaulted gnome-shell.)
|
||||
knock_seq: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingState {
|
||||
next_id: u32,
|
||||
items: Vec<Pending>,
|
||||
/// Fingerprint → the knock generation an approval admitted, kept briefly after [`NativePairing::add`]
|
||||
/// clears the pending entry. Closes the last double-admit window: a superseded waiter that only
|
||||
/// polls AFTER the approval (entry gone, fingerprint paired) can't tell it lost from the entry
|
||||
/// alone — this marker lets it resolve `Superseded` instead of a second `Approved`. Pruned on
|
||||
/// the pending TTL and overwritten per fingerprint, so it stays a handful of tuples.
|
||||
admitted: Vec<(String, u32, Instant)>,
|
||||
}
|
||||
|
||||
/// A pending-approval snapshot for the management API / web console.
|
||||
pub struct PendingRequest {
|
||||
/// Per-process id used to address approve/deny (stable for the entry's lifetime).
|
||||
pub id: u32,
|
||||
/// Best-effort device label (the client's `Hello` name, else fingerprint-derived).
|
||||
pub name: String,
|
||||
/// Hex SHA-256 of the knocking client's certificate — what approval pins.
|
||||
pub fingerprint: String,
|
||||
/// Seconds since the (most recent) knock.
|
||||
pub age_secs: u64,
|
||||
}
|
||||
|
||||
/// The outcome of [`NativePairing::wait_for_decision`] — what an operator did with a parked,
|
||||
/// unpaired knock (delegated approval, roadmap §8b-1).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PairingDecision {
|
||||
/// The operator clicked Approve (the fingerprint is now paired) — admit the session.
|
||||
Approved,
|
||||
/// The operator denied, or the pending entry was otherwise dropped without pairing — reject.
|
||||
Denied,
|
||||
/// No decision within the wait window — reject; the device can knock again.
|
||||
TimedOut,
|
||||
/// A NEWER knock from the same fingerprint replaced this one — close this connection; the
|
||||
/// newest parked connection is the one an approval admits (a retrying client abandons its
|
||||
/// older attempts, and admitting them all crashes compositors — see [`Pending::knock_seq`]).
|
||||
Superseded,
|
||||
}
|
||||
|
||||
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
|
||||
/// approvable days later when the operator no longer remembers the context).
|
||||
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop.
|
||||
const PENDING_CAP: usize = 32;
|
||||
/// Max pending knocks one source IP may occupy, so a single host can't fill the whole queue and hide
|
||||
/// / evict a genuine device's knock (security-review 2026-06-28 #13). The QUIC path is address-
|
||||
/// validated, so the source IP isn't off-path spoofable; an attacker would need that many real hosts.
|
||||
const MAX_PENDING_PER_IP: usize = 4;
|
||||
/// The untrusted-device-name sanitizer lives in its own module (plan §W5); re-exported so
|
||||
/// `crate::native_pairing::sanitize_device_name` stays stable (the `native` accept loop
|
||||
/// reaches it there).
|
||||
pub(crate) use sanitize::sanitize_device_name;
|
||||
|
||||
/// Shared native-pairing state: the arming PIN window + the persistent trust store + the
|
||||
/// pending-approval queue.
|
||||
pub struct NativePairing {
|
||||
arm: Mutex<Armed>,
|
||||
paired: Mutex<PairedState>,
|
||||
pending: Mutex<PendingState>,
|
||||
/// Notified whenever the trust/pending state changes (a fingerprint paired, or a pending knock
|
||||
/// denied/dropped), so a QUIC connection parked in [`NativePairing::wait_for_decision`] wakes
|
||||
/// the instant an operator acts in the console — the substrate for delegated approval admitting
|
||||
/// a session with no client reconnect.
|
||||
changed: Notify,
|
||||
arm: arming::ArmState,
|
||||
store: store::TrustStore,
|
||||
approval: approval::ApprovalQueue,
|
||||
}
|
||||
|
||||
/// A snapshot for the management API / web console.
|
||||
@@ -165,43 +56,6 @@ pub struct NativePairingStatus {
|
||||
pub paired_clients: u32,
|
||||
}
|
||||
|
||||
fn default_path() -> Result<PathBuf> {
|
||||
// `config_dir()` resolves XDG/HOME on Linux and falls back to %APPDATA% on Windows — so the
|
||||
// native paired-store works without a HOME env var (which a Windows service/task doesn't set).
|
||||
Ok(crate::gamestream::config_dir().join("punktfunk1-paired.json"))
|
||||
}
|
||||
|
||||
fn load(path: &std::path::Path) -> PairedClients {
|
||||
std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save(state: &PairedState) -> Result<()> {
|
||||
if let Some(dir) = state.path.parent() {
|
||||
crate::gamestream::create_private_dir(dir)?;
|
||||
}
|
||||
// Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which would
|
||||
// silently lock out every paired client on a --require-pairing host). Temp + rename. The temp is
|
||||
// written owner-only so a local user can't inject a fingerprint to pair themselves.
|
||||
let tmp = state.path.with_extension("json.tmp");
|
||||
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&state.clients)?)?;
|
||||
std::fs::rename(&tmp, &state.path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn random_pin() -> String {
|
||||
use rand::Rng;
|
||||
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
|
||||
}
|
||||
|
||||
/// The untrusted-device-name sanitizer lives in its own module (plan §W5); re-exported so
|
||||
/// `crate::native_pairing::sanitize_device_name` stays stable (the `punktfunk1` accept loop
|
||||
/// reaches it there).
|
||||
mod sanitize;
|
||||
pub(crate) use sanitize::sanitize_device_name;
|
||||
|
||||
impl NativePairing {
|
||||
/// Load the trust store. `store_path = None` uses the default config path. If `arm_at_start`
|
||||
/// (the CLI `--allow-pairing`/`--require-pairing` flags), arm immediately with `fixed_pin`
|
||||
@@ -211,368 +65,142 @@ impl NativePairing {
|
||||
fixed_pin: Option<String>,
|
||||
arm_at_start: bool,
|
||||
) -> Result<NativePairing> {
|
||||
let path = match store_path {
|
||||
Some(p) => p,
|
||||
None => default_path()?,
|
||||
};
|
||||
let clients = load(&path);
|
||||
let arm = if arm_at_start {
|
||||
Armed {
|
||||
pin: Some(fixed_pin.unwrap_or_else(random_pin)),
|
||||
expires_at: None,
|
||||
bound_fp: None,
|
||||
}
|
||||
} else {
|
||||
Armed::default()
|
||||
};
|
||||
Ok(NativePairing {
|
||||
arm: Mutex::new(arm),
|
||||
paired: Mutex::new(PairedState { path, clients }),
|
||||
pending: Mutex::new(PendingState::default()),
|
||||
changed: Notify::new(),
|
||||
arm: arming::ArmState::new(arm_at_start, fixed_pin),
|
||||
store: store::TrustStore::open(store_path)?,
|
||||
approval: approval::ApprovalQueue::new(),
|
||||
})
|
||||
}
|
||||
|
||||
// -- Arming window ------------------------------------------------------
|
||||
|
||||
/// Arm pairing with a fresh random PIN, valid for `ttl`, **unbound** (any well-formed attempt
|
||||
/// consumes it). Returns the PIN to display. Prefer [`Self::arm_for`] with a specific device
|
||||
/// fingerprint on untrusted LANs — an unbound window is burnable by any peer (#9).
|
||||
pub fn arm(&self, ttl: Duration) -> String {
|
||||
self.arm_for(ttl, None)
|
||||
self.arm.arm_for(ttl, None)
|
||||
}
|
||||
|
||||
/// Arm pairing with a fresh random PIN, valid for `ttl`. If `bound_fp` is `Some`, the window is
|
||||
/// bound to that device fingerprint: only a pairing attempt from it consumes the window, so an
|
||||
/// unrelated (attacker) fingerprint can neither pair nor burn the window (#9). Returns the PIN.
|
||||
pub fn arm_for(&self, ttl: Duration, bound_fp: Option<String>) -> String {
|
||||
let pin = random_pin();
|
||||
*self.arm.lock().unwrap() = Armed {
|
||||
pin: Some(pin.clone()),
|
||||
expires_at: Some(Instant::now() + ttl),
|
||||
bound_fp,
|
||||
};
|
||||
pin
|
||||
self.arm.arm_for(ttl, bound_fp)
|
||||
}
|
||||
|
||||
/// Resolve the PIN for an attempt from `client_fp_hex`, honoring fingerprint binding (#9):
|
||||
/// `Disarmed` if no window is armed; `BoundToOther` if a window is armed but bound to a different
|
||||
/// fingerprint (the caller MUST reject without consuming it); else `Pin` to run the ceremony.
|
||||
pub fn pin_for_attempt(&self, client_fp_hex: &str) -> PinAttempt {
|
||||
let mut arm = self.arm.lock().unwrap();
|
||||
Self::expire(&mut arm);
|
||||
match &arm.pin {
|
||||
None => PinAttempt::Disarmed,
|
||||
Some(pin) => match &arm.bound_fp {
|
||||
Some(bound) if !bound.eq_ignore_ascii_case(client_fp_hex) => {
|
||||
PinAttempt::BoundToOther
|
||||
}
|
||||
_ => PinAttempt::Pin(pin.clone()),
|
||||
},
|
||||
}
|
||||
self.arm.pin_for_attempt(client_fp_hex)
|
||||
}
|
||||
|
||||
/// Disarm pairing (no new ceremonies accepted).
|
||||
pub fn disarm(&self) {
|
||||
*self.arm.lock().unwrap() = Armed::default();
|
||||
}
|
||||
|
||||
/// Expire a timed window if its deadline passed (called under the lock before any read).
|
||||
fn expire(arm: &mut Armed) {
|
||||
if let Some(t) = arm.expires_at {
|
||||
if Instant::now() >= t {
|
||||
*arm = Armed::default();
|
||||
}
|
||||
}
|
||||
self.arm.disarm()
|
||||
}
|
||||
|
||||
/// The current valid PIN, or `None` if disarmed/expired. The QUIC ceremony reads this
|
||||
/// per-attempt, so a window that lapsed mid-connection no longer pairs.
|
||||
pub fn current_pin(&self) -> Option<String> {
|
||||
let mut arm = self.arm.lock().unwrap();
|
||||
Self::expire(&mut arm);
|
||||
arm.pin.clone()
|
||||
self.arm.current_pin()
|
||||
}
|
||||
|
||||
/// A snapshot for the management API.
|
||||
pub fn status(&self) -> NativePairingStatus {
|
||||
let mut arm = self.arm.lock().unwrap();
|
||||
Self::expire(&mut arm);
|
||||
let expires_in_secs = arm
|
||||
.expires_at
|
||||
.map(|t| t.saturating_duration_since(Instant::now()).as_secs());
|
||||
let (armed, pin, expires_in_secs) = self.arm.snapshot();
|
||||
NativePairingStatus {
|
||||
armed: arm.pin.is_some(),
|
||||
pin: arm.pin.clone(),
|
||||
armed,
|
||||
pin,
|
||||
expires_in_secs,
|
||||
paired_clients: self.paired.lock().unwrap().clients.clients.len() as u32,
|
||||
paired_clients: self.store.count(),
|
||||
}
|
||||
}
|
||||
|
||||
// -- Trust store --------------------------------------------------------
|
||||
|
||||
/// Is this client (hex SHA-256 fingerprint) in the paired set?
|
||||
pub fn is_paired(&self, fp_hex: &str) -> bool {
|
||||
self.paired.lock().unwrap().clients.contains(fp_hex)
|
||||
self.store.is_paired(fp_hex)
|
||||
}
|
||||
|
||||
/// Record a successful pairing (re-pairing the same fingerprint just updates the name —
|
||||
/// matched case-insensitively, like every other fingerprint comparison here). The name is
|
||||
/// sanitized (untrusted). On a persist failure the in-memory store is rolled back so it never
|
||||
/// diverges from disk. Also clears any pending knock for this fingerprint (it's now paired).
|
||||
/// Record a successful pairing (re-pairing the same fingerprint just updates the name). The name
|
||||
/// is sanitized (untrusted); a persist failure rolls the in-memory store back. Pins the
|
||||
/// fingerprint in the store FIRST, then clears any pending knock for it and wakes parked waiters
|
||||
/// — an order [`Self::wait_for_decision`] relies on (a woken waiter must observe the fully
|
||||
/// settled state: paired = true, no longer pending).
|
||||
pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
|
||||
let name = sanitize_device_name(name, fp_hex);
|
||||
{
|
||||
let mut p = self.paired.lock().unwrap();
|
||||
let snapshot = p.clients.clients.clone(); // restore on a failed save
|
||||
p.clients
|
||||
.clients
|
||||
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
|
||||
p.clients.clients.push(PairedClient {
|
||||
name,
|
||||
self.store.add(name, fp_hex)?;
|
||||
self.approval.admit_and_clear(fp_hex);
|
||||
// The one choke point every successful pairing passes through (PIN ceremony AND
|
||||
// delegated approval), so the lifecycle event fires exactly once per pairing.
|
||||
crate::events::emit(crate::events::EventKind::PairingCompleted {
|
||||
device: crate::events::DeviceRef {
|
||||
name: sanitize_device_name(name, fp_hex),
|
||||
fingerprint: fp_hex.to_string(),
|
||||
});
|
||||
if let Err(e) = save(&p) {
|
||||
p.clients.clients = snapshot;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
// A device that knocked and is now paired shouldn't linger in the approval list. Record
|
||||
// WHICH knock generation this pairing admits before clearing the entry: only the waiter
|
||||
// holding that generation may return `Approved`; a superseded sibling that polls after the
|
||||
// clear resolves `Superseded` off this marker (exactly-one-admission — see `admitted`).
|
||||
{
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
let admitted_seq = pending
|
||||
.items
|
||||
.iter()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
.map(|p| p.knock_seq);
|
||||
if let Some(seq) = admitted_seq {
|
||||
pending
|
||||
.admitted
|
||||
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
|
||||
pending
|
||||
.admitted
|
||||
.push((fp_hex.to_string(), seq, Instant::now()));
|
||||
}
|
||||
pending
|
||||
.items
|
||||
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
|
||||
}
|
||||
// Wake any connection parked in `wait_for_decision` for this fingerprint: pairing just
|
||||
// completed (console approve or the PIN ceremony), so it can admit the session with no
|
||||
// reconnect. Notified AFTER the pin AND the pending-clear so a woken waiter observes the
|
||||
// fully settled state (paired = true, no longer pending) — see `wait_for_decision`.
|
||||
self.changed.notify_waiters();
|
||||
plane: crate::events::Plane::Native,
|
||||
},
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The paired clients (for the management API's device list).
|
||||
pub fn list(&self) -> Vec<PairedClient> {
|
||||
self.paired.lock().unwrap().clients.clients.clone()
|
||||
self.store.list()
|
||||
}
|
||||
|
||||
/// Remove a paired client by fingerprint. Returns whether one was removed. On a persist
|
||||
/// failure the in-memory store is rolled back (it never diverges from disk).
|
||||
pub fn remove(&self, fp_hex: &str) -> Result<bool> {
|
||||
let mut p = self.paired.lock().unwrap();
|
||||
let before = p.clients.clients.len();
|
||||
let snapshot = p.clients.clients.clone();
|
||||
p.clients
|
||||
.clients
|
||||
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
|
||||
let removed = p.clients.clients.len() != before;
|
||||
if removed {
|
||||
if let Err(e) = save(&p) {
|
||||
p.clients.clients = snapshot;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
self.store.remove(fp_hex)
|
||||
}
|
||||
|
||||
// -- Delegated approval (roadmap §8b-1) --------------------------------
|
||||
|
||||
/// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]). The
|
||||
/// admitted-generation markers share the TTL — they only matter while a superseded waiter
|
||||
/// could still be parked, which is bounded by the approval wait (well under the TTL).
|
||||
fn expire_pending(pending: &mut PendingState) {
|
||||
pending
|
||||
.items
|
||||
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
|
||||
pending
|
||||
.admitted
|
||||
.retain(|(_, _, at)| at.elapsed() < PENDING_TTL);
|
||||
}
|
||||
|
||||
/// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active
|
||||
/// **non-parked** entry (a live parked knock is a genuine device awaiting the operator — never
|
||||
/// evict it under load); only if every candidate is parked does it fall back to the oldest of
|
||||
/// those (#13). Returns the index, or `None` if there's nothing to evict.
|
||||
fn evict_index(items: &[Pending], only_ip: Option<IpAddr>) -> Option<usize> {
|
||||
let pick = |allow_parked: bool| {
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| only_ip.is_none_or(|ip| p.src_ip == Some(ip)))
|
||||
.filter(|(_, p)| allow_parked || !p.parked)
|
||||
.min_by_key(|(_, p)| p.requested_at)
|
||||
.map(|(i, _)| i)
|
||||
};
|
||||
pick(false).or_else(|| pick(true))
|
||||
}
|
||||
// -- Delegated approval (roadmap §8b-1) ---------------------------------
|
||||
|
||||
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
|
||||
/// refresh the existing entry in place (same id; a connect-retry loop must not spam the list) and
|
||||
/// bump its knock generation — the returned generation is what [`Self::wait_for_decision`] admits,
|
||||
/// so the NEWEST connection wins and any older parked sibling resolves `Superseded`. A
|
||||
/// fresh fingerprint gets a new id; the queue is bounded two ways so a flood can't crowd out a
|
||||
/// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at
|
||||
/// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked**
|
||||
/// entry (never a live, held-open parked knock). The name is sanitized (untrusted).
|
||||
/// refresh the existing entry in place (same id) and bump its knock generation — the returned
|
||||
/// generation is what [`Self::wait_for_decision`] admits. See [`approval::ApprovalQueue::note_pending`].
|
||||
pub fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) -> u32 {
|
||||
let name = sanitize_device_name(name, fp_hex);
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
if let Some(p) = pending
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
{
|
||||
p.requested_at = Instant::now();
|
||||
p.name = name;
|
||||
if p.src_ip.is_none() {
|
||||
p.src_ip = src_ip;
|
||||
}
|
||||
p.knock_seq = p.knock_seq.wrapping_add(1);
|
||||
let seq = p.knock_seq;
|
||||
drop(pending);
|
||||
// Wake the previous knock's parked waiter so it sees it was superseded NOW instead of
|
||||
// holding its dead connection open until the approval window lapses.
|
||||
self.changed.notify_waiters();
|
||||
return seq;
|
||||
// Only a NEW fingerprint emits `pairing.pending` — a re-knock refreshes the existing
|
||||
// entry in place, and a client auto-retrying while parked must not spam the operator's
|
||||
// notification hook once per retry.
|
||||
let was_pending = self.approval.pending_contains(fp_hex);
|
||||
let seq = self.approval.note_pending(name, fp_hex, src_ip);
|
||||
if !was_pending {
|
||||
crate::events::emit(crate::events::EventKind::PairingPending {
|
||||
device: crate::events::DeviceRef {
|
||||
name: sanitize_device_name(name, fp_hex),
|
||||
fingerprint: fp_hex.to_string(),
|
||||
plane: crate::events::Plane::Native,
|
||||
},
|
||||
});
|
||||
}
|
||||
// A fresh knock lifecycle: drop any admitted-generation marker left from a previous
|
||||
// pair→unpair round of this fingerprint, or it would wrongly supersede the new waiter.
|
||||
pending
|
||||
.admitted
|
||||
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
|
||||
// Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its
|
||||
// own oldest entry first so it can't crowd out other devices' knocks (#13).
|
||||
if let Some(ip) = src_ip {
|
||||
if pending
|
||||
.items
|
||||
.iter()
|
||||
.filter(|p| p.src_ip == Some(ip))
|
||||
.count()
|
||||
>= MAX_PENDING_PER_IP
|
||||
{
|
||||
if let Some(i) = Self::evict_index(&pending.items, Some(ip)) {
|
||||
pending.items.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Global cap: evict the least-recently-active non-parked entry (Vec order no longer tracks
|
||||
// recency after in-place refreshes, so pick explicitly).
|
||||
if pending.items.len() >= PENDING_CAP {
|
||||
if let Some(i) = Self::evict_index(&pending.items, None) {
|
||||
pending.items.remove(i);
|
||||
}
|
||||
}
|
||||
let id = pending.next_id;
|
||||
pending.next_id = pending.next_id.wrapping_add(1);
|
||||
pending.items.push(Pending {
|
||||
id,
|
||||
name,
|
||||
fp_hex: fp_hex.to_string(),
|
||||
requested_at: Instant::now(),
|
||||
src_ip,
|
||||
parked: false,
|
||||
knock_seq: 0,
|
||||
});
|
||||
0
|
||||
}
|
||||
|
||||
/// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone).
|
||||
/// A parked entry is protected from eviction under load (#13). Gated on `knock_seq` so a
|
||||
/// superseded waiter's exit can't unmark the flag the NEWER waiter (a bumped generation) owns.
|
||||
fn set_parked(&self, fp_hex: &str, knock_seq: u32, parked: bool) {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
if let Some(p) = pending
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex) && p.knock_seq == knock_seq)
|
||||
{
|
||||
p.parked = parked;
|
||||
}
|
||||
}
|
||||
|
||||
/// The current knock generation for `fp_hex`, `None` when no entry is pending. A parked waiter
|
||||
/// compares this against its own generation to detect it was superseded by a re-knock.
|
||||
fn knock_seq_of(&self, fp_hex: &str) -> Option<u32> {
|
||||
let pending = self.pending.lock().unwrap();
|
||||
pending
|
||||
.items
|
||||
.iter()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
.map(|p| p.knock_seq)
|
||||
}
|
||||
|
||||
/// The knock generation the approval of `fp_hex` admitted, if one was recorded (see
|
||||
/// [`PendingState::admitted`]).
|
||||
fn admitted_seq(&self, fp_hex: &str) -> Option<u32> {
|
||||
let pending = self.pending.lock().unwrap();
|
||||
pending
|
||||
.admitted
|
||||
.iter()
|
||||
.find(|(fp, _, _)| fp.eq_ignore_ascii_case(fp_hex))
|
||||
.map(|(_, seq, _)| *seq)
|
||||
seq
|
||||
}
|
||||
|
||||
/// The devices currently awaiting approval (for the management API).
|
||||
pub fn pending(&self) -> Vec<PendingRequest> {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
pending
|
||||
.items
|
||||
.iter()
|
||||
.map(|p| PendingRequest {
|
||||
id: p.id,
|
||||
name: p.name.clone(),
|
||||
fingerprint: p.fp_hex.clone(),
|
||||
age_secs: p.requested_at.elapsed().as_secs(),
|
||||
})
|
||||
.collect()
|
||||
self.approval.pending()
|
||||
}
|
||||
|
||||
/// Is a knock for this fingerprint still awaiting approval? (Expired entries are dropped
|
||||
/// first, so this also reports whether a parked knock is still live.)
|
||||
/// Is a knock for this fingerprint still awaiting approval? (Expired entries are dropped first.)
|
||||
pub fn pending_contains(&self, fp_hex: &str) -> bool {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
pending
|
||||
.items
|
||||
.iter()
|
||||
.any(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
self.approval.pending_contains(fp_hex)
|
||||
}
|
||||
|
||||
/// Approve a pending knock: pair its fingerprint (under `name_override` if the operator
|
||||
/// labeled it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such
|
||||
/// (or expired) id.
|
||||
/// Approve a pending knock: pair its fingerprint (under `name_override` if the operator labeled
|
||||
/// it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such (or expired)
|
||||
/// id. Reads (does NOT pre-remove) the entry, then [`Self::add`] pins the fingerprint and clears
|
||||
/// the pending entry — an order a parked waiter relies on (see [`Self::wait_for_decision`]).
|
||||
pub fn approve_pending(
|
||||
&self,
|
||||
id: u32,
|
||||
name_override: Option<&str>,
|
||||
) -> Result<Option<PairedClient>> {
|
||||
// Read (do NOT pre-remove) the entry: `add()` pins the fingerprint and THEN clears its
|
||||
// pending entry — an order `wait_for_decision` relies on so a parked waiter never observes
|
||||
// the device as "neither pending nor paired" (which would read as a denial). Removing here
|
||||
// first would open exactly that window.
|
||||
let (knock_name, fp_hex) = {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
match pending.items.iter().find(|p| p.id == id) {
|
||||
Some(p) => (p.name.clone(), p.fp_hex.clone()),
|
||||
None => return Ok(None),
|
||||
}
|
||||
}; // pending lock released — add() takes the paired then pending locks
|
||||
let (knock_name, fp_hex) = match self.approval.read_entry(id) {
|
||||
Some(x) => x,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let name = name_override.unwrap_or(&knock_name).to_string();
|
||||
self.add(&name, &fp_hex)?; // pins, clears the pending entry, and notifies waiters
|
||||
Ok(Some(PairedClient {
|
||||
@@ -584,106 +212,50 @@ impl NativePairing {
|
||||
/// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock
|
||||
/// re-creates an entry — deny is "not now", not a blocklist.
|
||||
pub fn deny_pending(&self, id: u32) -> bool {
|
||||
let removed = {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
let before = pending.items.len();
|
||||
pending.items.retain(|p| p.id != id);
|
||||
pending.items.len() != before
|
||||
};
|
||||
if removed {
|
||||
// Wake a parked waiter so it returns `Denied` at once instead of holding the
|
||||
// connection open until the approval window lapses.
|
||||
self.changed.notify_waiters();
|
||||
// Read the entry first so the lifecycle event can carry the device's identity.
|
||||
let entry = self.approval.read_entry(id);
|
||||
let denied = self.approval.deny_pending(id);
|
||||
if denied {
|
||||
if let Some((name, fp_hex)) = entry {
|
||||
crate::events::emit(crate::events::EventKind::PairingDenied {
|
||||
device: crate::events::DeviceRef {
|
||||
name: sanitize_device_name(&name, &fp_hex),
|
||||
fingerprint: fp_hex,
|
||||
plane: crate::events::Plane::Native,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
removed
|
||||
denied
|
||||
}
|
||||
|
||||
/// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`.
|
||||
/// `knock_seq` is the generation [`Self::note_pending`] returned for THIS connection's knock.
|
||||
/// Returns [`PairingDecision::Approved`] the instant the fingerprint is paired (console
|
||||
/// approve or a concurrent PIN ceremony), [`PairingDecision::Superseded`] the instant a newer
|
||||
/// knock from the same fingerprint replaces this one (a retrying client — only the newest
|
||||
/// connection is admitted; three siblings admitted at once has crashed gnome-shell live),
|
||||
/// [`PairingDecision::Denied`] if its pending entry is dropped without pairing, or
|
||||
/// [`PairingDecision::TimedOut`] if the window lapses. Holds no lock across the await. The
|
||||
/// QUIC accept path calls this right after [`Self::note_pending`] to keep the knocking
|
||||
/// connection open until a human clicks Approve — so the device pairs and streams with no
|
||||
/// reconnect (delegated approval, roadmap §8b-1).
|
||||
/// The store-blind approval queue is handed an `is_paired` closure so it can resolve
|
||||
/// [`PairingDecision::Approved`] the instant the fingerprint pairs. See
|
||||
/// [`approval::ApprovalQueue::wait_for_decision`] for the full decision contract.
|
||||
pub async fn wait_for_decision(
|
||||
&self,
|
||||
fp_hex: &str,
|
||||
knock_seq: u32,
|
||||
timeout: Duration,
|
||||
) -> PairingDecision {
|
||||
// Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open
|
||||
// connection out of the pending queue while the operator decides (#13). Cleared on every
|
||||
// exit path by the guard's Drop (generation-gated, so a superseded waiter's exit never
|
||||
// unmarks the newer waiter's flag).
|
||||
self.set_parked(fp_hex, knock_seq, true);
|
||||
struct ParkGuard<'a> {
|
||||
np: &'a NativePairing,
|
||||
fp: &'a str,
|
||||
seq: u32,
|
||||
}
|
||||
impl Drop for ParkGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.np.set_parked(self.fp, self.seq, false);
|
||||
}
|
||||
}
|
||||
let _park = ParkGuard {
|
||||
np: self,
|
||||
fp: fp_hex,
|
||||
seq: knock_seq,
|
||||
};
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
// Arm the wakeup BEFORE re-reading state, and `enable()` it, so an approve/deny that
|
||||
// lands between the state check and the await still wakes us (no lost notification).
|
||||
let notified = self.changed.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
self.approval
|
||||
.wait_for_decision(fp_hex, knock_seq, timeout, |fp| self.store.is_paired(fp))
|
||||
.await
|
||||
}
|
||||
|
||||
// Superseded check FIRST: once a newer knock owns the fingerprint, this connection
|
||||
// must never be admitted — not even if the approval lands before we wake.
|
||||
match self.knock_seq_of(fp_hex) {
|
||||
Some(cur) if cur != knock_seq => return PairingDecision::Superseded,
|
||||
_ => {}
|
||||
}
|
||||
if self.is_paired(fp_hex) {
|
||||
// Paired with the pending entry already cleared: make sure the approval admitted
|
||||
// OUR generation. A superseded waiter that first polls after `add()` sees the same
|
||||
// paired/no-entry state as the winner — the admitted marker breaks the tie.
|
||||
match self.admitted_seq(fp_hex) {
|
||||
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
|
||||
_ => return PairingDecision::Approved,
|
||||
}
|
||||
}
|
||||
if !self.pending_contains(fp_hex) {
|
||||
// Neither pending nor paired. This is almost always a denial — but it can also be
|
||||
// the tiny interval inside `add()` between pinning and clearing the pending entry.
|
||||
// Re-check `is_paired` once: because `add()` pins BEFORE it clears pending, a
|
||||
// cleared-pending observation that is really an approval will now read as paired —
|
||||
// with the same generation tie-break as above (the admitted marker is written in
|
||||
// the same critical section that clears the entry).
|
||||
if self.is_paired(fp_hex) {
|
||||
match self.admitted_seq(fp_hex) {
|
||||
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
|
||||
_ => return PairingDecision::Approved,
|
||||
}
|
||||
}
|
||||
return PairingDecision::Denied;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut notified => {}
|
||||
_ = tokio::time::sleep_until(deadline) => return PairingDecision::TimedOut,
|
||||
}
|
||||
}
|
||||
/// Test-only reach into the approval queue's park flag (the behavior tests assert a parked,
|
||||
/// held-open knock survives a flood).
|
||||
#[cfg(test)]
|
||||
fn set_parked(&self, fp_hex: &str, knock_seq: u32, parked: bool) {
|
||||
self.approval.set_parked(fp_hex, knock_seq, parked)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::approval::{MAX_PENDING_PER_IP, PENDING_CAP};
|
||||
use super::*;
|
||||
|
||||
fn temp() -> PathBuf {
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
//! The pending-approval queue + delegated approval (roadmap §8b-1; plan §W5 — carved out of the
|
||||
//! [`super`] facade). Owns the pending-knock [`Mutex`] and the change [`Notify`] that wakes a QUIC
|
||||
//! connection parked in [`ApprovalQueue::wait_for_decision`] the instant an operator acts.
|
||||
//!
|
||||
//! This module is deliberately blind to the trust store: whether a fingerprint became paired is
|
||||
//! injected into [`ApprovalQueue::wait_for_decision`] as an `is_paired` closure, and the store side
|
||||
//! of admitting a device (persisting the pairing) is the facade's job — this queue only records the
|
||||
//! admitted knock generation and clears the entry ([`ApprovalQueue::admit_and_clear`]).
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// An unpaired (but identified) device that knocked on a pairing-required host — held for
|
||||
/// **delegated approval** from the management console (roadmap §8b-1) instead of being silently
|
||||
/// forgotten. In-memory only: pending knocks don't survive a restart (the device just knocks
|
||||
/// again), and they expire after [`PENDING_TTL`].
|
||||
struct Pending {
|
||||
id: u32,
|
||||
name: String,
|
||||
fp_hex: String,
|
||||
requested_at: Instant,
|
||||
/// QUIC-validated source address of the knock — used for the per-source cap (#13), so one host
|
||||
/// can't fill the queue. `None` if unknown (e.g. tests / a caller that doesn't supply it).
|
||||
src_ip: Option<IpAddr>,
|
||||
/// True while a connection is held open in [`ApprovalQueue::wait_for_decision`] for this knock.
|
||||
/// A live parked knock is a genuine device waiting for the operator — eviction skips it unless
|
||||
/// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13).
|
||||
parked: bool,
|
||||
/// Generation of the MOST RECENT knock for this fingerprint. A re-knock bumps it (and wakes
|
||||
/// waiters), so a stale parked connection resolves [`PairingDecision::Superseded`] instead of
|
||||
/// being admitted alongside the newest one — one Approve must admit exactly ONE session.
|
||||
/// (Observed live: a client retried 3× while parked, one console Approve admitted all three,
|
||||
/// and the three concurrent Mutter virtual monitors segfaulted gnome-shell.)
|
||||
knock_seq: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingState {
|
||||
next_id: u32,
|
||||
items: Vec<Pending>,
|
||||
/// Fingerprint → the knock generation an approval admitted, kept briefly after
|
||||
/// [`ApprovalQueue::admit_and_clear`] clears the pending entry. Closes the last double-admit
|
||||
/// window: a superseded waiter that only polls AFTER the approval (entry gone, fingerprint
|
||||
/// paired) can't tell it lost from the entry alone — this marker lets it resolve `Superseded`
|
||||
/// instead of a second `Approved`. Pruned on the pending TTL and overwritten per fingerprint, so
|
||||
/// it stays a handful of tuples.
|
||||
admitted: Vec<(String, u32, Instant)>,
|
||||
}
|
||||
|
||||
/// A pending-approval snapshot for the management API / web console.
|
||||
pub struct PendingRequest {
|
||||
/// Per-process id used to address approve/deny (stable for the entry's lifetime).
|
||||
pub id: u32,
|
||||
/// Best-effort device label (the client's `Hello` name, else fingerprint-derived).
|
||||
pub name: String,
|
||||
/// Hex SHA-256 of the knocking client's certificate — what approval pins.
|
||||
pub fingerprint: String,
|
||||
/// Seconds since the (most recent) knock.
|
||||
pub age_secs: u64,
|
||||
}
|
||||
|
||||
/// The outcome of a `wait_for_decision` park — what an operator did with a parked,
|
||||
/// unpaired knock (delegated approval, roadmap §8b-1).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PairingDecision {
|
||||
/// The operator clicked Approve (the fingerprint is now paired) — admit the session.
|
||||
Approved,
|
||||
/// The operator denied, or the pending entry was otherwise dropped without pairing — reject.
|
||||
Denied,
|
||||
/// No decision within the wait window — reject; the device can knock again.
|
||||
TimedOut,
|
||||
/// A NEWER knock from the same fingerprint replaced this one — close this connection; the
|
||||
/// newest parked connection is the one an approval admits (a retrying client abandons its
|
||||
/// older attempts, and admitting them all crashes compositors — see [`Pending::knock_seq`]).
|
||||
Superseded,
|
||||
}
|
||||
|
||||
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
|
||||
/// approvable days later when the operator no longer remembers the context).
|
||||
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop.
|
||||
/// (`pub(super)` so the facade's behavior tests can assert the cap holds.)
|
||||
pub(super) const PENDING_CAP: usize = 32;
|
||||
/// Max pending knocks one source IP may occupy, so a single host can't fill the whole queue and hide
|
||||
/// / evict a genuine device's knock (security-review 2026-06-28 #13). The QUIC path is address-
|
||||
/// validated, so the source IP isn't off-path spoofable; an attacker would need that many real hosts.
|
||||
/// (`pub(super)` so the facade's behavior tests can assert the per-IP cap holds.)
|
||||
pub(super) const MAX_PENDING_PER_IP: usize = 4;
|
||||
|
||||
/// The pending-approval queue: the pending-knock list behind a [`Mutex`], plus the [`Notify`] that
|
||||
/// wakes parked waiters when the trust/pending state changes.
|
||||
pub(super) struct ApprovalQueue {
|
||||
pending: Mutex<PendingState>,
|
||||
/// Notified whenever the trust/pending state changes (a fingerprint paired, or a pending knock
|
||||
/// denied/dropped), so a QUIC connection parked in [`ApprovalQueue::wait_for_decision`] wakes
|
||||
/// the instant an operator acts in the console — the substrate for delegated approval admitting
|
||||
/// a session with no client reconnect.
|
||||
changed: Notify,
|
||||
}
|
||||
|
||||
impl ApprovalQueue {
|
||||
pub(super) fn new() -> ApprovalQueue {
|
||||
ApprovalQueue {
|
||||
pending: Mutex::new(PendingState::default()),
|
||||
changed: Notify::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop expired pending knocks (called under the lock, mirroring the arming expiry). The
|
||||
/// admitted-generation markers share the TTL — they only matter while a superseded waiter
|
||||
/// could still be parked, which is bounded by the approval wait (well under the TTL).
|
||||
fn expire_pending(pending: &mut PendingState) {
|
||||
pending
|
||||
.items
|
||||
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
|
||||
pending
|
||||
.admitted
|
||||
.retain(|(_, _, at)| at.elapsed() < PENDING_TTL);
|
||||
}
|
||||
|
||||
/// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active
|
||||
/// **non-parked** entry (a live parked knock is a genuine device awaiting the operator — never
|
||||
/// evict it under load); only if every candidate is parked does it fall back to the oldest of
|
||||
/// those (#13). Returns the index, or `None` if there's nothing to evict.
|
||||
fn evict_index(items: &[Pending], only_ip: Option<IpAddr>) -> Option<usize> {
|
||||
let pick = |allow_parked: bool| {
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| only_ip.is_none_or(|ip| p.src_ip == Some(ip)))
|
||||
.filter(|(_, p)| allow_parked || !p.parked)
|
||||
.min_by_key(|(_, p)| p.requested_at)
|
||||
.map(|(i, _)| i)
|
||||
};
|
||||
pick(false).or_else(|| pick(true))
|
||||
}
|
||||
|
||||
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
|
||||
/// refresh the existing entry in place (same id; a connect-retry loop must not spam the list) and
|
||||
/// bump its knock generation — the returned generation is what [`Self::wait_for_decision`] admits,
|
||||
/// so the NEWEST connection wins and any older parked sibling resolves `Superseded`. A
|
||||
/// fresh fingerprint gets a new id; the queue is bounded two ways so a flood can't crowd out a
|
||||
/// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at
|
||||
/// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked**
|
||||
/// entry (never a live, held-open parked knock). The name is sanitized (untrusted).
|
||||
pub(super) fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) -> u32 {
|
||||
let name = super::sanitize_device_name(name, fp_hex);
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
if let Some(p) = pending
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
{
|
||||
p.requested_at = Instant::now();
|
||||
p.name = name;
|
||||
if p.src_ip.is_none() {
|
||||
p.src_ip = src_ip;
|
||||
}
|
||||
p.knock_seq = p.knock_seq.wrapping_add(1);
|
||||
let seq = p.knock_seq;
|
||||
drop(pending);
|
||||
// Wake the previous knock's parked waiter so it sees it was superseded NOW instead of
|
||||
// holding its dead connection open until the approval window lapses.
|
||||
self.changed.notify_waiters();
|
||||
return seq;
|
||||
}
|
||||
// A fresh knock lifecycle: drop any admitted-generation marker left from a previous
|
||||
// pair→unpair round of this fingerprint, or it would wrongly supersede the new waiter.
|
||||
pending
|
||||
.admitted
|
||||
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
|
||||
// Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its
|
||||
// own oldest entry first so it can't crowd out other devices' knocks (#13).
|
||||
if let Some(ip) = src_ip {
|
||||
if pending
|
||||
.items
|
||||
.iter()
|
||||
.filter(|p| p.src_ip == Some(ip))
|
||||
.count()
|
||||
>= MAX_PENDING_PER_IP
|
||||
{
|
||||
if let Some(i) = Self::evict_index(&pending.items, Some(ip)) {
|
||||
pending.items.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Global cap: evict the least-recently-active non-parked entry (Vec order no longer tracks
|
||||
// recency after in-place refreshes, so pick explicitly).
|
||||
if pending.items.len() >= PENDING_CAP {
|
||||
if let Some(i) = Self::evict_index(&pending.items, None) {
|
||||
pending.items.remove(i);
|
||||
}
|
||||
}
|
||||
let id = pending.next_id;
|
||||
pending.next_id = pending.next_id.wrapping_add(1);
|
||||
pending.items.push(Pending {
|
||||
id,
|
||||
name,
|
||||
fp_hex: fp_hex.to_string(),
|
||||
requested_at: Instant::now(),
|
||||
src_ip,
|
||||
parked: false,
|
||||
knock_seq: 0,
|
||||
});
|
||||
0
|
||||
}
|
||||
|
||||
/// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone).
|
||||
/// A parked entry is protected from eviction under load (#13). Gated on `knock_seq` so a
|
||||
/// superseded waiter's exit can't unmark the flag the NEWER waiter (a bumped generation) owns.
|
||||
pub(super) fn set_parked(&self, fp_hex: &str, knock_seq: u32, parked: bool) {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
if let Some(p) = pending
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex) && p.knock_seq == knock_seq)
|
||||
{
|
||||
p.parked = parked;
|
||||
}
|
||||
}
|
||||
|
||||
/// The current knock generation for `fp_hex`, `None` when no entry is pending. A parked waiter
|
||||
/// compares this against its own generation to detect it was superseded by a re-knock.
|
||||
fn knock_seq_of(&self, fp_hex: &str) -> Option<u32> {
|
||||
let pending = self.pending.lock().unwrap();
|
||||
pending
|
||||
.items
|
||||
.iter()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
.map(|p| p.knock_seq)
|
||||
}
|
||||
|
||||
/// The knock generation the approval of `fp_hex` admitted, if one was recorded (see
|
||||
/// [`PendingState::admitted`]).
|
||||
fn admitted_seq(&self, fp_hex: &str) -> Option<u32> {
|
||||
let pending = self.pending.lock().unwrap();
|
||||
pending
|
||||
.admitted
|
||||
.iter()
|
||||
.find(|(fp, _, _)| fp.eq_ignore_ascii_case(fp_hex))
|
||||
.map(|(_, seq, _)| *seq)
|
||||
}
|
||||
|
||||
/// The devices currently awaiting approval (for the management API).
|
||||
pub(super) fn pending(&self) -> Vec<PendingRequest> {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
pending
|
||||
.items
|
||||
.iter()
|
||||
.map(|p| PendingRequest {
|
||||
id: p.id,
|
||||
name: p.name.clone(),
|
||||
fingerprint: p.fp_hex.clone(),
|
||||
age_secs: p.requested_at.elapsed().as_secs(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Is a knock for this fingerprint still awaiting approval? (Expired entries are dropped
|
||||
/// first, so this also reports whether a parked knock is still live.)
|
||||
pub(super) fn pending_contains(&self, fp_hex: &str) -> bool {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
pending
|
||||
.items
|
||||
.iter()
|
||||
.any(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
}
|
||||
|
||||
/// Read (do NOT remove) the `(name, fingerprint)` of the pending entry with `id`, expiring
|
||||
/// stale entries first. `None` = no such (or expired) id. The facade approves by reading here,
|
||||
/// pairing the fingerprint in the trust store, and THEN [`Self::admit_and_clear`]-ing — an order
|
||||
/// [`Self::wait_for_decision`] relies on so a parked waiter never observes the device as
|
||||
/// "neither pending nor paired" (which would read as a denial). Removing here first would open
|
||||
/// exactly that window.
|
||||
pub(super) fn read_entry(&self, id: u32) -> Option<(String, String)> {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
pending
|
||||
.items
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.map(|p| (p.name.clone(), p.fp_hex.clone()))
|
||||
}
|
||||
|
||||
/// The pending side of admitting a now-paired device: record WHICH knock generation this pairing
|
||||
/// admits (so only the waiter holding that generation returns `Approved`; a superseded sibling
|
||||
/// that polls after the clear resolves `Superseded` off this marker — exactly-one-admission, see
|
||||
/// [`PendingState::admitted`]), clear the entry, then wake parked waiters. The caller MUST have
|
||||
/// pinned the fingerprint in the trust store FIRST, so a woken waiter observes the fully settled
|
||||
/// state (paired = true, no longer pending) — see [`Self::wait_for_decision`].
|
||||
pub(super) fn admit_and_clear(&self, fp_hex: &str) {
|
||||
{
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
let admitted_seq = pending
|
||||
.items
|
||||
.iter()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
.map(|p| p.knock_seq);
|
||||
if let Some(seq) = admitted_seq {
|
||||
pending
|
||||
.admitted
|
||||
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
|
||||
pending
|
||||
.admitted
|
||||
.push((fp_hex.to_string(), seq, Instant::now()));
|
||||
}
|
||||
pending
|
||||
.items
|
||||
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
|
||||
}
|
||||
// Wake any connection parked in `wait_for_decision` for this fingerprint: pairing just
|
||||
// completed (console approve or the PIN ceremony), so it can admit the session with no
|
||||
// reconnect. Notified AFTER the pin AND the pending-clear so a woken waiter observes the
|
||||
// fully settled state — see `wait_for_decision`.
|
||||
self.changed.notify_waiters();
|
||||
}
|
||||
|
||||
/// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock
|
||||
/// re-creates an entry — deny is "not now", not a blocklist.
|
||||
pub(super) fn deny_pending(&self, id: u32) -> bool {
|
||||
let removed = {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
let before = pending.items.len();
|
||||
pending.items.retain(|p| p.id != id);
|
||||
pending.items.len() != before
|
||||
};
|
||||
if removed {
|
||||
// Wake a parked waiter so it returns `Denied` at once instead of holding the
|
||||
// connection open until the approval window lapses.
|
||||
self.changed.notify_waiters();
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`.
|
||||
/// `knock_seq` is the generation [`Self::note_pending`] returned for THIS connection's knock;
|
||||
/// `is_paired` is injected by the facade (this queue is blind to the trust store). Returns
|
||||
/// [`PairingDecision::Approved`] the instant the fingerprint is paired (console approve or a
|
||||
/// concurrent PIN ceremony), [`PairingDecision::Superseded`] the instant a newer knock from the
|
||||
/// same fingerprint replaces this one (a retrying client — only the newest connection is
|
||||
/// admitted; three siblings admitted at once has crashed gnome-shell live),
|
||||
/// [`PairingDecision::Denied`] if its pending entry is dropped without pairing, or
|
||||
/// [`PairingDecision::TimedOut`] if the window lapses. Holds no lock across the await. The
|
||||
/// QUIC accept path calls this right after [`Self::note_pending`] to keep the knocking
|
||||
/// connection open until a human clicks Approve — so the device pairs and streams with no
|
||||
/// reconnect (delegated approval, roadmap §8b-1).
|
||||
pub(super) async fn wait_for_decision(
|
||||
&self,
|
||||
fp_hex: &str,
|
||||
knock_seq: u32,
|
||||
timeout: Duration,
|
||||
is_paired: impl Fn(&str) -> bool,
|
||||
) -> PairingDecision {
|
||||
// Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open
|
||||
// connection out of the pending queue while the operator decides (#13). Cleared on every
|
||||
// exit path by the guard's Drop (generation-gated, so a superseded waiter's exit never
|
||||
// unmarks the newer waiter's flag).
|
||||
self.set_parked(fp_hex, knock_seq, true);
|
||||
struct ParkGuard<'a> {
|
||||
q: &'a ApprovalQueue,
|
||||
fp: &'a str,
|
||||
seq: u32,
|
||||
}
|
||||
impl Drop for ParkGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.q.set_parked(self.fp, self.seq, false);
|
||||
}
|
||||
}
|
||||
let _park = ParkGuard {
|
||||
q: self,
|
||||
fp: fp_hex,
|
||||
seq: knock_seq,
|
||||
};
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
// Arm the wakeup BEFORE re-reading state, and `enable()` it, so an approve/deny that
|
||||
// lands between the state check and the await still wakes us (no lost notification).
|
||||
let notified = self.changed.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
|
||||
// Superseded check FIRST: once a newer knock owns the fingerprint, this connection
|
||||
// must never be admitted — not even if the approval lands before we wake.
|
||||
match self.knock_seq_of(fp_hex) {
|
||||
Some(cur) if cur != knock_seq => return PairingDecision::Superseded,
|
||||
_ => {}
|
||||
}
|
||||
if is_paired(fp_hex) {
|
||||
// Paired with the pending entry already cleared: make sure the approval admitted
|
||||
// OUR generation. A superseded waiter that first polls after the pairing sees the
|
||||
// same paired/no-entry state as the winner — the admitted marker breaks the tie.
|
||||
match self.admitted_seq(fp_hex) {
|
||||
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
|
||||
_ => return PairingDecision::Approved,
|
||||
}
|
||||
}
|
||||
if !self.pending_contains(fp_hex) {
|
||||
// Neither pending nor paired. This is almost always a denial — but it can also be
|
||||
// the tiny interval inside the facade's `add()` between pinning and clearing the
|
||||
// pending entry. Re-check `is_paired` once: because the facade pins BEFORE it clears
|
||||
// pending, a cleared-pending observation that is really an approval will now read as
|
||||
// paired — with the same generation tie-break as above (the admitted marker is
|
||||
// written in the same critical section that clears the entry).
|
||||
if is_paired(fp_hex) {
|
||||
match self.admitted_seq(fp_hex) {
|
||||
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
|
||||
_ => return PairingDecision::Approved,
|
||||
}
|
||||
}
|
||||
return PairingDecision::Denied;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut notified => {}
|
||||
_ = tokio::time::sleep_until(deadline) => return PairingDecision::TimedOut,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! The on-demand arming PIN window (plan §W5 — carved out of the [`super`] facade). Owns the
|
||||
//! [`Armed`] state behind a [`Mutex`]: a short-lived (or CLI-flag, no-expiry) PIN the host mints
|
||||
//! and the operator reads from the web console, optionally bound to one device fingerprint (#9).
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The current arming window. `pin == None` ⇒ disarmed. `expires_at == None` ⇒ armed with no
|
||||
/// expiry (the CLI `--allow-pairing` flag); `Some(t)` ⇒ a web-armed window that auto-disarms.
|
||||
///
|
||||
/// `bound_fp == Some(fp)` ⇒ the window is **bound to one operator-selected device fingerprint**:
|
||||
/// only a pairing attempt from that fingerprint may consume it (security-review 2026-06-28 #9). This
|
||||
/// closes the window-burn DoS — an unpaired LAN peer cannot consume a window armed for a specific
|
||||
/// device, because the QUIC client-auth proves cert possession (it can't forge the bound fingerprint).
|
||||
/// `None` ⇒ unbound (the CLI flag / a console "arm open"): any well-formed attempt consumes it (the
|
||||
/// legacy behavior, retaining the window-burn DoS — acceptable only on a trusted LAN).
|
||||
#[derive(Default)]
|
||||
struct Armed {
|
||||
pin: Option<String>,
|
||||
expires_at: Option<Instant>,
|
||||
bound_fp: Option<String>,
|
||||
}
|
||||
|
||||
/// The result of resolving the armed PIN for a specific client fingerprint
|
||||
/// (`NativePairing::pin_for_attempt`).
|
||||
pub enum PinAttempt {
|
||||
/// No window is armed (disarmed/expired) — reject; do not run the ceremony.
|
||||
Disarmed,
|
||||
/// A window IS armed but **bound to a different fingerprint** — reject WITHOUT consuming it, so
|
||||
/// an unrelated (attacker) fingerprint can't burn the operator's armed window (#9).
|
||||
BoundToOther,
|
||||
/// Proceed: the PIN to run the ceremony with (the window is unbound, or bound to this fingerprint).
|
||||
Pin(String),
|
||||
}
|
||||
|
||||
fn random_pin() -> String {
|
||||
use rand::Rng;
|
||||
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
|
||||
}
|
||||
|
||||
/// A snapshot of the arming window for the management API: `(armed, pin, expires_in_secs)`.
|
||||
pub(super) type ArmSnapshot = (bool, Option<String>, Option<u64>);
|
||||
|
||||
/// The arming-PIN window behind a [`Mutex`].
|
||||
pub(super) struct ArmState {
|
||||
arm: Mutex<Armed>,
|
||||
}
|
||||
|
||||
impl ArmState {
|
||||
/// A fresh window. If `arm_at_start` (the CLI `--allow-pairing`/`--require-pairing` flags), arm
|
||||
/// immediately with `fixed_pin` (or a fresh random PIN) and **no expiry** — back-compat with the
|
||||
/// headless CLI flow. Otherwise disarmed.
|
||||
pub(super) fn new(arm_at_start: bool, fixed_pin: Option<String>) -> ArmState {
|
||||
let arm = if arm_at_start {
|
||||
Armed {
|
||||
pin: Some(fixed_pin.unwrap_or_else(random_pin)),
|
||||
expires_at: None,
|
||||
bound_fp: None,
|
||||
}
|
||||
} else {
|
||||
Armed::default()
|
||||
};
|
||||
ArmState {
|
||||
arm: Mutex::new(arm),
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm pairing with a fresh random PIN, valid for `ttl`. If `bound_fp` is `Some`, the window is
|
||||
/// bound to that device fingerprint: only a pairing attempt from it consumes the window, so an
|
||||
/// unrelated (attacker) fingerprint can neither pair nor burn the window (#9). Returns the PIN.
|
||||
pub(super) fn arm_for(&self, ttl: Duration, bound_fp: Option<String>) -> String {
|
||||
let pin = random_pin();
|
||||
*self.arm.lock().unwrap() = Armed {
|
||||
pin: Some(pin.clone()),
|
||||
expires_at: Some(Instant::now() + ttl),
|
||||
bound_fp,
|
||||
};
|
||||
pin
|
||||
}
|
||||
|
||||
/// Resolve the PIN for an attempt from `client_fp_hex`, honoring fingerprint binding (#9):
|
||||
/// `Disarmed` if no window is armed; `BoundToOther` if a window is armed but bound to a different
|
||||
/// fingerprint (the caller MUST reject without consuming it); else `Pin` to run the ceremony.
|
||||
pub(super) fn pin_for_attempt(&self, client_fp_hex: &str) -> PinAttempt {
|
||||
let mut arm = self.arm.lock().unwrap();
|
||||
Self::expire(&mut arm);
|
||||
match &arm.pin {
|
||||
None => PinAttempt::Disarmed,
|
||||
Some(pin) => match &arm.bound_fp {
|
||||
Some(bound) if !bound.eq_ignore_ascii_case(client_fp_hex) => {
|
||||
PinAttempt::BoundToOther
|
||||
}
|
||||
_ => PinAttempt::Pin(pin.clone()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Disarm pairing (no new ceremonies accepted).
|
||||
pub(super) fn disarm(&self) {
|
||||
*self.arm.lock().unwrap() = Armed::default();
|
||||
}
|
||||
|
||||
/// Expire a timed window if its deadline passed (called under the lock before any read).
|
||||
fn expire(arm: &mut Armed) {
|
||||
if let Some(t) = arm.expires_at {
|
||||
if Instant::now() >= t {
|
||||
*arm = Armed::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current valid PIN, or `None` if disarmed/expired. The QUIC ceremony reads this
|
||||
/// per-attempt, so a window that lapsed mid-connection no longer pairs.
|
||||
pub(super) fn current_pin(&self) -> Option<String> {
|
||||
let mut arm = self.arm.lock().unwrap();
|
||||
Self::expire(&mut arm);
|
||||
arm.pin.clone()
|
||||
}
|
||||
|
||||
/// A snapshot for the management API: `(armed, pin, expires_in_secs)`.
|
||||
pub(super) fn snapshot(&self) -> ArmSnapshot {
|
||||
let mut arm = self.arm.lock().unwrap();
|
||||
Self::expire(&mut arm);
|
||||
let expires_in_secs = arm
|
||||
.expires_at
|
||||
.map(|t| t.saturating_duration_since(Instant::now()).as_secs());
|
||||
(arm.pin.is_some(), arm.pin.clone(), expires_in_secs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! The persistent native-pairing trust store: `~/.config/punktfunk/punktfunk1-paired.json`
|
||||
//! (plan §W5 — carved out of the [`super`] facade). Owns the paired-clients [`Mutex`] and the
|
||||
//! atomic-replace persistence; the pending-approval side of a pairing lives in [`super::approval`].
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`.
|
||||
/// (Separate from GameStream pairing, which has its own store and ceremony.)
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PairedClients {
|
||||
pub clients: Vec<PairedClient>,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PairedClient {
|
||||
pub name: String,
|
||||
/// Hex SHA-256 of the client's certificate.
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
impl PairedClients {
|
||||
fn contains(&self, fp_hex: &str) -> bool {
|
||||
self.clients
|
||||
.iter()
|
||||
.any(|c| c.fingerprint.eq_ignore_ascii_case(fp_hex))
|
||||
}
|
||||
}
|
||||
|
||||
struct PairedState {
|
||||
path: PathBuf,
|
||||
clients: PairedClients,
|
||||
}
|
||||
|
||||
fn default_path() -> Result<PathBuf> {
|
||||
// `config_dir()` resolves XDG/HOME on Linux and falls back to %APPDATA% on Windows — so the
|
||||
// native paired-store works without a HOME env var (which a Windows service/task doesn't set).
|
||||
Ok(crate::gamestream::config_dir().join("punktfunk1-paired.json"))
|
||||
}
|
||||
|
||||
fn load(path: &Path) -> PairedClients {
|
||||
std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save(state: &PairedState) -> Result<()> {
|
||||
if let Some(dir) = state.path.parent() {
|
||||
crate::gamestream::create_private_dir(dir)?;
|
||||
}
|
||||
// Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which would
|
||||
// silently lock out every paired client on a --require-pairing host). Temp + rename. The temp is
|
||||
// written owner-only so a local user can't inject a fingerprint to pair themselves.
|
||||
let tmp = state.path.with_extension("json.tmp");
|
||||
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&state.clients)?)?;
|
||||
std::fs::rename(&tmp, &state.path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The persistent trust store — the paired-clients set behind a [`Mutex`], backed by an
|
||||
/// atomic-replace JSON file.
|
||||
pub(super) struct TrustStore {
|
||||
paired: Mutex<PairedState>,
|
||||
}
|
||||
|
||||
impl TrustStore {
|
||||
/// Open (load) the trust store. `store_path = None` uses the default config path.
|
||||
pub(super) fn open(store_path: Option<PathBuf>) -> Result<TrustStore> {
|
||||
let path = match store_path {
|
||||
Some(p) => p,
|
||||
None => default_path()?,
|
||||
};
|
||||
let clients = load(&path);
|
||||
Ok(TrustStore {
|
||||
paired: Mutex::new(PairedState { path, clients }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Is this client (hex SHA-256 fingerprint) in the paired set?
|
||||
pub(super) fn is_paired(&self, fp_hex: &str) -> bool {
|
||||
self.paired.lock().unwrap().clients.contains(fp_hex)
|
||||
}
|
||||
|
||||
/// Record a successful pairing (re-pairing the same fingerprint just updates the name —
|
||||
/// matched case-insensitively, like every other fingerprint comparison here). The name is
|
||||
/// sanitized (untrusted). On a persist failure the in-memory store is rolled back so it never
|
||||
/// diverges from disk. (Clearing any pending knock for this fingerprint is the caller's job —
|
||||
/// see [`super::approval::ApprovalQueue::admit_and_clear`].)
|
||||
pub(super) fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
|
||||
let name = super::sanitize_device_name(name, fp_hex);
|
||||
let mut p = self.paired.lock().unwrap();
|
||||
let snapshot = p.clients.clients.clone(); // restore on a failed save
|
||||
p.clients
|
||||
.clients
|
||||
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
|
||||
p.clients.clients.push(PairedClient {
|
||||
name,
|
||||
fingerprint: fp_hex.to_string(),
|
||||
});
|
||||
if let Err(e) = save(&p) {
|
||||
p.clients.clients = snapshot;
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The paired clients (for the management API's device list).
|
||||
pub(super) fn list(&self) -> Vec<PairedClient> {
|
||||
self.paired.lock().unwrap().clients.clients.clone()
|
||||
}
|
||||
|
||||
/// Remove a paired client by fingerprint. Returns whether one was removed. On a persist
|
||||
/// failure the in-memory store is rolled back (it never diverges from disk).
|
||||
pub(super) fn remove(&self, fp_hex: &str) -> Result<bool> {
|
||||
let mut p = self.paired.lock().unwrap();
|
||||
let before = p.clients.clients.len();
|
||||
let snapshot = p.clients.clients.clone();
|
||||
p.clients
|
||||
.clients
|
||||
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
|
||||
let removed = p.clients.clients.len() != before;
|
||||
if removed {
|
||||
if let Err(e) = save(&p) {
|
||||
p.clients.clients = snapshot;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// The number of paired clients (for the status snapshot).
|
||||
pub(super) fn count(&self) -> u32 {
|
||||
self.paired.lock().unwrap().clients.clients.len() as u32
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Shared microburst pacing POLICY for the two video send planes (networking-audit deferred
|
||||
//! plan §5): the native plane (`punktfunk1::paced_submit`, GSO via the core `Session`) and the
|
||||
//! plan §5): the native plane (`native::paced_submit`, GSO via the core `Session`) and the
|
||||
//! GameStream compat plane (`gamestream::stream::spawn_sender`, `sendmmsg` over its own RTP
|
||||
//! socket). Both spread a frame's packets across a time budget in chunked bursts so a real link
|
||||
//! doesn't drop the frame as one line-rate burst; the syscall layers stay deliberately separate
|
||||
@@ -244,7 +244,7 @@ pub(crate) fn percentile(v: &mut [u32], q: f64) -> u32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The native plane's canonical parameters (mirrors `punktfunk1::paced_submit`).
|
||||
/// The native plane's canonical parameters (mirrors `native::paced_submit`).
|
||||
fn native_cfg(burst_cap: usize) -> PaceCfg {
|
||||
PaceCfg {
|
||||
burst_bytes: Some(burst_cap),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//!
|
||||
//! **Goal-1 stage 3** (`design/windows-host-rewrite.md` §2.2): before this, the Windows session decision was
|
||||
//! re-derived at three call sites — the capture backend inside `capture::capture_virtual_output`, the
|
||||
//! process topology in `punktfunk1::should_use_helper`, and the encode backend in
|
||||
//! process topology in `native::should_use_helper`, and the encode backend in
|
||||
//! `encode::windows_resolved_backend` — each reading [`config`](crate::config) independently, with no
|
||||
//! single owner (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `SessionPlan`
|
||||
//! resolves them together, once, so the deployed path reads one typed artifact.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
//!
|
||||
//! The GameStream media pipeline records its session in `AppState.{launch, stream, streaming}`
|
||||
//! (consumed by RTSP/media), but the native punktfunk/1 plane never touches `AppState` — by design
|
||||
//! it is handed only the shared stats recorder ([`crate::punktfunk1::serve`]). So a native session,
|
||||
//! it is handed only the shared stats recorder ([`crate::native::serve`]). So a native session,
|
||||
//! which is the DEFAULT plane (GameStream is opt-in, `--gamestream`), was invisible on the Dashboard:
|
||||
//! `GET /status` reported `video_streaming: false` and no session/stream card while a client was
|
||||
//! actively streaming (the Stats page worked because it shares the recorder — hence the confusing
|
||||
//! "stats move but the dashboard says idle").
|
||||
//!
|
||||
//! This module is the small shared surface the native video loop ([`crate::punktfunk1::virtual_stream`])
|
||||
//! This module is the small shared surface the native video loop ([`crate::native::virtual_stream`])
|
||||
//! publishes a live snapshot to, keyed per session so CONCURRENT native sessions each get an entry
|
||||
//! (the native server admits up to `max_sessions`, unbounded by default). The loop registers on
|
||||
//! stream start and the returned [`LiveSessionGuard`] removes the entry on ANY scope exit (return,
|
||||
@@ -26,7 +26,7 @@ use crate::encode::Codec;
|
||||
/// second update path — plus the flags the mgmt API flips to control the session.
|
||||
struct LiveSession {
|
||||
id: u64,
|
||||
/// Packed `w:16|h:16|hz:16` ([`crate::punktfunk1::pack_mode`]); updated on a mode switch.
|
||||
/// Packed `w:16|h:16|hz:16` ([`crate::native::pack_mode`]); updated on a mode switch.
|
||||
mode: Arc<AtomicU64>,
|
||||
/// Live encoder target (kbps); updated on an adaptive-bitrate change.
|
||||
bitrate_kbps: Arc<AtomicU32>,
|
||||
@@ -36,6 +36,10 @@ struct LiveSession {
|
||||
/// One-shot force-keyframe flag ([`force_idr_all`] → mgmt `POST /session/idr`); the encode loop
|
||||
/// drains it alongside a client's decode-recovery keyframe request.
|
||||
force_idr: Arc<AtomicBool>,
|
||||
/// Short client label (cert-fingerprint prefix / peer IP) — carried on the lifecycle events.
|
||||
client: String,
|
||||
/// Whether the session negotiated HDR — carried on the lifecycle events.
|
||||
hdr: bool,
|
||||
}
|
||||
|
||||
/// A resolved read of one live session, for the `/status` view.
|
||||
@@ -58,23 +62,44 @@ fn next_id() -> u64 {
|
||||
ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Resolves one session's [`crate::events::SessionRef`] (mode read live) for the lifecycle events.
|
||||
fn session_ref(s: &LiveSession) -> crate::events::SessionRef {
|
||||
let (width, height, fps) = crate::native::unpack_mode(s.mode.load(Ordering::Relaxed));
|
||||
crate::events::SessionRef {
|
||||
id: s.id,
|
||||
client: s.client.clone(),
|
||||
mode: crate::events::mode_str(width, height, fps),
|
||||
hdr: s.hdr,
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a live native session; the returned guard removes it on drop (session end).
|
||||
/// Emits the `session.started` lifecycle event; the guard's drop emits `session.ended` — RAII,
|
||||
/// so every exit path (return, `?`, panic-unwind) pairs them.
|
||||
pub fn register(
|
||||
mode: Arc<AtomicU64>,
|
||||
bitrate_kbps: Arc<AtomicU32>,
|
||||
codec: Codec,
|
||||
stop: Arc<AtomicBool>,
|
||||
force_idr: Arc<AtomicBool>,
|
||||
client: String,
|
||||
hdr: bool,
|
||||
) -> LiveSessionGuard {
|
||||
let id = next_id();
|
||||
registry().lock().unwrap().push(LiveSession {
|
||||
let session = LiveSession {
|
||||
id,
|
||||
mode,
|
||||
bitrate_kbps,
|
||||
codec,
|
||||
stop,
|
||||
force_idr,
|
||||
client,
|
||||
hdr,
|
||||
};
|
||||
crate::events::emit(crate::events::EventKind::SessionStarted {
|
||||
session: session_ref(&session),
|
||||
});
|
||||
registry().lock().unwrap().push(session);
|
||||
LiveSessionGuard { id }
|
||||
}
|
||||
|
||||
@@ -85,7 +110,14 @@ pub struct LiveSessionGuard {
|
||||
|
||||
impl Drop for LiveSessionGuard {
|
||||
fn drop(&mut self) {
|
||||
registry().lock().unwrap().retain(|s| s.id != self.id);
|
||||
let mut reg = registry().lock().unwrap();
|
||||
if let Some(pos) = reg.iter().position(|s| s.id == self.id) {
|
||||
let session = reg.remove(pos);
|
||||
drop(reg); // emit outside the registry lock — the bus takes its own
|
||||
crate::events::emit(crate::events::EventKind::SessionEnded {
|
||||
session: session_ref(&session),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +133,7 @@ pub fn snapshot() -> Vec<SessionSnapshot> {
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let (width, height, fps) =
|
||||
crate::punktfunk1::unpack_mode(s.mode.load(Ordering::Relaxed));
|
||||
let (width, height, fps) = crate::native::unpack_mode(s.mode.load(Ordering::Relaxed));
|
||||
SessionSnapshot {
|
||||
width,
|
||||
height,
|
||||
|
||||
@@ -97,6 +97,6 @@ mod imp {
|
||||
pub use imp::on_hot_thread;
|
||||
|
||||
/// No-op on non-Windows (Linux uses `setpriority` nice + CUDA stream priority instead — see
|
||||
/// `punktfunk1::boost_thread_priority` and `zerocopy::cuda`).
|
||||
/// `native::boost_thread_priority` and `zerocopy::cuda`).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn on_hot_thread() {}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! [`StatsRecorder`] handle is created once in the unified host entry
|
||||
//! (`gamestream::serve`) alongside [`crate::native_pairing::NativePairing`], and shared with
|
||||
//! **both** the management API ([`crate::mgmt`]) and the streaming loops (threaded through
|
||||
//! [`crate::punktfunk1::serve`] → `SessionContext` and into the GameStream encode loop). The
|
||||
//! [`crate::native::serve`] → `SessionContext` and into the GameStream encode loop). The
|
||||
//! operator arms a capture from the web console, plays a session, stops, and reviews the
|
||||
//! captured time-series as graphs; captures are saved to disk and survive a host restart.
|
||||
//!
|
||||
|
||||
@@ -53,6 +53,21 @@ pub struct StreamInfo {
|
||||
pub hdr: bool,
|
||||
/// Client-supplied device name (may be empty); sanitized before it reaches the file.
|
||||
pub client: String,
|
||||
/// Store-qualified launch id this session requested, if any — carried on the stream
|
||||
/// lifecycle events, NOT written to the marker file (its key set is a stable contract).
|
||||
pub launch: Option<String>,
|
||||
}
|
||||
|
||||
/// The announce points double as the `stream.started`/`stream.stopped` lifecycle fire sites
|
||||
/// (RFC §4) — only the native loop announces the marker today, hence the fixed plane.
|
||||
fn stream_ref(info: &StreamInfo) -> crate::events::StreamRef {
|
||||
crate::events::StreamRef {
|
||||
mode: crate::events::mode_str(info.width, info.height, info.refresh_hz),
|
||||
hdr: info.hdr,
|
||||
client: info.client.clone(),
|
||||
app: info.launch.clone(),
|
||||
plane: crate::events::Plane::Native,
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII handle for one announced session. While it is alive the session counts toward the marker;
|
||||
@@ -62,25 +77,36 @@ pub struct StreamInfo {
|
||||
pub struct Guard {
|
||||
#[cfg(unix)]
|
||||
id: u64,
|
||||
/// The announced stream, re-emitted as `stream.stopped` when the guard drops.
|
||||
stream: crate::events::StreamRef,
|
||||
}
|
||||
|
||||
/// Announce that a client has started streaming at `info`'s mode. Returns a [`Guard`] that must be
|
||||
/// held for the streaming lifetime — drop it when the session ends.
|
||||
/// held for the streaming lifetime — drop it when the session ends. Emits `stream.started` on all
|
||||
/// platforms (the marker file itself is unix-only); the guard's drop emits `stream.stopped`.
|
||||
pub fn announce(info: StreamInfo) -> Guard {
|
||||
crate::events::emit(crate::events::EventKind::StreamStarted {
|
||||
stream: stream_ref(&info),
|
||||
});
|
||||
#[cfg(unix)]
|
||||
{
|
||||
imp::announce(info)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = info;
|
||||
Guard {}
|
||||
Guard {
|
||||
stream: stream_ref(&info),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {}
|
||||
fn drop(&mut self) {
|
||||
crate::events::emit(crate::events::EventKind::StreamStopped {
|
||||
stream: self.stream.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -141,10 +167,11 @@ mod imp {
|
||||
}
|
||||
|
||||
pub(super) fn announce(info: StreamInfo) -> Guard {
|
||||
let stream = super::stream_ref(&info);
|
||||
let mut reg = REGISTRY.lock().unwrap();
|
||||
let id = reg.insert(info);
|
||||
rewrite_to(&marker_path(), ®);
|
||||
Guard { id }
|
||||
Guard { id, stream }
|
||||
}
|
||||
|
||||
/// Rewrite (or remove) the marker at `path` to match `reg`. Called under the registry lock.
|
||||
@@ -199,9 +226,14 @@ mod imp {
|
||||
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
let mut reg = REGISTRY.lock().unwrap();
|
||||
reg.remove(self.id);
|
||||
rewrite_to(&marker_path(), ®);
|
||||
{
|
||||
let mut reg = REGISTRY.lock().unwrap();
|
||||
reg.remove(self.id);
|
||||
rewrite_to(&marker_path(), ®);
|
||||
}
|
||||
crate::events::emit(crate::events::EventKind::StreamStopped {
|
||||
stream: self.stream.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +251,7 @@ mod imp {
|
||||
|
||||
// The marker file lifecycle is exercised against a real path so the atomic-write + remove
|
||||
// logic is covered end to end. It drives a LOCAL registry at an explicit temp path: the
|
||||
// process-global one is shared with the punktfunk1 integration tests (which announce real
|
||||
// process-global one is shared with the native integration tests (which announce real
|
||||
// sessions concurrently), and mutating XDG_RUNTIME_DIR mid-run would race them too.
|
||||
#[test]
|
||||
fn marker_appears_while_held_and_vanishes_after() {
|
||||
@@ -237,6 +269,7 @@ mod imp {
|
||||
refresh_hz: 120,
|
||||
hdr: true,
|
||||
client: "Couch'TV".to_string(),
|
||||
launch: None,
|
||||
});
|
||||
rewrite_to(&path, ®);
|
||||
let text = std::fs::read_to_string(&path).expect("marker exists while streaming");
|
||||
@@ -255,6 +288,7 @@ mod imp {
|
||||
refresh_hz: 60,
|
||||
hdr: false,
|
||||
client: "Phone".to_string(),
|
||||
launch: None,
|
||||
});
|
||||
rewrite_to(&path, ®);
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
|
||||
@@ -349,7 +349,7 @@ impl Compositor {
|
||||
Compositor::Gamescope => P::Gamescope,
|
||||
// D2: no distinct wire byte for Hyprland — it shares the wlroots-family `Wlroots` pref.
|
||||
// A client asking for `wlroots`/`hyprland` gets whichever of the two is the live session
|
||||
// ([`pick_compositor`](crate::punktfunk1::pick_compositor) resolves the family).
|
||||
// ([`pick_compositor`](crate::native::pick_compositor) resolves the family).
|
||||
Compositor::Hyprland => P::Wlroots,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,18 +89,24 @@ pub fn acquire(
|
||||
mode: super::Mode,
|
||||
quit: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
) -> Result<super::VirtualOutput> {
|
||||
let backend = vd.name();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::acquire(vd, mode, quit)
|
||||
}
|
||||
let out = linux::acquire(vd, mode, quit);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let out = {
|
||||
// Windows leases in the manager (its own linger); its deliberate-quit skip is wired through
|
||||
// `VirtualDisplay::set_quit_flag` on the backend instance (set by the session before any
|
||||
// `create`, so the retry-hold lease gets it too) — not through this parameter.
|
||||
let _ = quit;
|
||||
vd.create(mode)
|
||||
};
|
||||
if out.is_ok() {
|
||||
crate::events::emit(crate::events::EventKind::DisplayCreated {
|
||||
backend: backend.to_string(),
|
||||
mode: crate::events::mode_str(mode.width, mode.height, mode.refresh_hz),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Snapshot the host's managed virtual displays. Cheap + side-effect-free (a state-lock read);
|
||||
@@ -149,20 +155,22 @@ pub fn snapshot() -> Snapshot {
|
||||
/// released.
|
||||
pub fn release(slot: Option<u64>) -> usize {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows slots (Stage W1): `slot` selects one kept monitor by its gen stamp
|
||||
// ([`DisplayInfo::slot`]); `None` releases every kept one.
|
||||
super::manager::force_release(slot)
|
||||
}
|
||||
// Windows slots (Stage W1): `slot` selects one kept monitor by its gen stamp
|
||||
// ([`DisplayInfo::slot`]); `None` releases every kept one.
|
||||
let released = super::manager::force_release(slot);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::force_release(slot)
|
||||
}
|
||||
let released = linux::force_release(slot);
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
let released = {
|
||||
let _ = slot;
|
||||
0
|
||||
};
|
||||
if released > 0 {
|
||||
crate::events::emit(crate::events::EventKind::DisplayReleased {
|
||||
count: released as u32,
|
||||
});
|
||||
}
|
||||
released
|
||||
}
|
||||
|
||||
/// Tear down a **reused-but-dead** pool entry by its generation stamp (A2). Called by the pipeline
|
||||
|
||||
@@ -260,7 +260,7 @@ pub(crate) struct VirtualDisplayManager {
|
||||
/// Serializes IDD-push session SETUP (preempt + monitor create) — MANAGER-WIDE even with slots:
|
||||
/// monitor create/teardown stays serialized (the 400 ms async-departure settle and the IddCx
|
||||
/// slot-budget wedge both want zero concurrent ADD/REMOVE). Held by the session across the
|
||||
/// pipeline build (was the `IDD_SETUP_LOCK` global in `punktfunk1`).
|
||||
/// pipeline build (was the `IDD_SETUP_LOCK` global in `native`).
|
||||
setup_lock: Mutex<()>,
|
||||
/// Per-SLOT IDD-push session stop flags: a new connection signals only the stop of a session
|
||||
/// holding *that identity's* slot (the same-client zombie-reconnect preempt, slot-scoped since
|
||||
@@ -1392,7 +1392,7 @@ impl VirtualDisplayManager {
|
||||
}
|
||||
|
||||
/// Begin an IDD-push session setup (Goal-1 §2.5 — was the `IDD_SETUP_LOCK` / `IDD_SESSION_STOP` /
|
||||
/// `wait_for_monitor_released` dance smeared across `punktfunk1`). Serializes via the (manager-wide)
|
||||
/// `wait_for_monitor_released` dance smeared across `native`). Serializes via the (manager-wide)
|
||||
/// setup lock, registers THIS session's stop flag on its SLOT while signalling the prior session
|
||||
/// holding that slot to stop, and waits for it to release the slot's monitor — so a reconnect
|
||||
/// (whose reused IddCx swap-chain is dead) preempts the stale session cleanly before a fresh
|
||||
|
||||
Reference in New Issue
Block a user