Files
punktfunk/clients/linux/src/library.rs
T
enricobuehler 38078fe7ee
apple / swift (push) Successful in 1m9s
ci / rust (push) Successful in 1m54s
ci / web (push) Successful in 54s
android / android (push) Successful in 3m33s
ci / docs-site (push) Successful in 1m2s
windows-host / package (push) Successful in 6m43s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m12s
ci / bench (push) Successful in 4m47s
deb / build-publish (push) Successful in 4m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
decky / build-publish (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
release / apple (push) Successful in 8m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
flatpak / build-publish (push) Successful in 4m4s
apple / screenshots (push) Successful in 5m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m48s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m20s
feat(linux-client): gamepad library launcher — console-style coverflow (--browse)
A controller-driven, chrome-less library launcher for the Steam Deck flow
(the Decky plugin's "Open library on screen" + pinned games, 8470419):
`--browse host[:port]` opens a paired host's game library as a coverflow
over a drifting aurora — A streams the focused title (the id rides the
Hello), session end returns to the launcher, B quits back to Gaming Mode.
`--connect` gains `--launch <id>` for direct-to-game starts; `--mgmt`
overrides the library port. Scope is deliberately library-only: host
selection/settings stay in the touch UI, pairing stays in the plugin (no
dialog can map under gamescope — every state renders in-page).

- gamepad.rs menu mode: the worker holds the active pad open while idle
  (WITHOUT the Valve HIDAPI drivers — Deck lizard mode survives) and
  translates it through a pure MenuNav state machine: edge-triggered
  buttons, held-state snapshot on entry/detach (the escape chord that ends
  a stream can't ghost-fire in the menu), 380/160 ms stick/dpad repeat,
  menu rumble ticks. Keyboard fallback (arrows/Enter/Esc) drives the same
  handler — fully usable with no pad, no host (PUNKTFUNK_FAKE_LIBRARY).
- Coverflow: ±38° corridor-facing tilt under per-card perspective
  (gsk rotate_3d), dense overlapping side shelves with paint-order
  restacking (gtk::Fixed draws in child order), opaque card faces + a
  darkening veil for the recede (translucency would bleed the stack
  through). The strip lives in an External-policy ScrolledWindow because
  a bare gtk::Fixed measures its TRANSFORMED children and inflates the
  page min-width past the window.
- Spring-driven motion: semi-implicit Euler in ≤8 ms substeps (a raw
  50 ms frame leaves the stiff recoil spring ringing at ω·dt ≈ 1.2 —
  regression-tested), ζ≈0.85 cursor chase + ζ≈0.55 boundary wobble;
  velocity carries across retargets so held-repeat scrolling glides.
- Shot scene `gamepad-library` (GTK animations force-disabled in shot mode
  — nav transitions froze mid-slide in headless captures); shared poster
  fetch extracted to library::spawn_art_fetch.

Verified here: 21 unit tests (MenuNav, cursor stepping, spring
convergence/stability), clippy -D warnings clean, screenshot scene
pixel-checked, --browse smoke runs (fake-library + unpaired) on the
headless session. On-Deck validation pending (virtual-pad input, lizard
mode, rumble via Steam Input, full Decky→browse→stream→launcher loop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:41:43 +00:00

365 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Game-library client for the host's management REST API (the Apple `LibraryClient`
//! ported): `GET https://<host>:<mgmt>/api/v1/library` plus the per-title art proxy.
//! Authentication is **mTLS** — this client presents its persistent identity (the same
//! cert the host paired over QUIC) and the host authorizes paired certificates for the
//! read-only library routes, no bearer token. The host's self-signed certificate is
//! verified by its pinned SHA-256 fingerprint (`KnownHost::fp_hex`), not a CA chain.
use serde::Deserialize;
use std::collections::VecDeque;
use std::io::Read;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// The management API's default port — matches `mgmt::DEFAULT_PORT` on the host. A
/// discovered host may override it via its mDNS `mgmt` TXT (`DiscoveredHost::mgmt_port`);
/// saved-but-not-advertising hosts fall back here (Apple parity).
pub const DEFAULT_MGMT_PORT: u16 = 47990;
/// Cover-art URLs, mirroring the host's `library::Artwork`: absolute CDN URLs for custom
/// entries, host-relative proxy paths (`/api/v1/library/art/...`) for Steam titles. The
/// wire shape also carries a `logo` (a transparent title logo) — not a poster kind, so
/// serde just skips it here.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Artwork {
#[serde(default)]
pub portrait: Option<String>,
#[serde(default)]
pub hero: Option<String>,
#[serde(default)]
pub header: Option<String>,
}
impl Artwork {
/// Poster candidates in the Apple client's fallback order — portrait (the 600×900
/// capsule) → header (near-universal) → hero — with host-relative paths resolved
/// against `base` so the loader only ever sees absolute URLs.
pub fn poster_candidates(&self, base: &str) -> Vec<String> {
[&self.portrait, &self.header, &self.hero]
.into_iter()
.flatten()
.map(|u| {
if u.starts_with('/') {
format!("{base}{u}")
} else {
u.clone()
}
})
.collect()
}
}
/// One title in the host's unified library. `id` is store-qualified (`steam:<appid>`,
/// `custom:<id>`) and is also the launch handle the Hello carries when a session is
/// started from the library. The host's `launch` spec field is deliberately not
/// deserialized — launching goes by id, the host resolves the spec itself.
#[derive(Clone, Debug, Deserialize)]
pub struct GameEntry {
pub id: String,
/// Which store surfaced it (`"steam"`, `"custom"`, future `"heroic"`/`"gog"`/…) —
/// drives the poster's store badge.
pub store: String,
pub title: String,
#[serde(default)]
pub art: Artwork,
}
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
#[derive(Debug)]
pub enum LibraryError {
/// The host rejected our certificate — this device isn't on its paired list.
NotPaired,
/// The host's certificate didn't hash to the pinned fingerprint (impostor/rotated cert).
PinMismatch,
Http(u16),
Unreachable(String),
}
impl std::fmt::Display for LibraryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LibraryError::NotPaired => f.write_str(
"The host didn't recognize this device. Pair with the host first — the \
library is authorized by this device's certificate (no token needed).",
),
LibraryError::PinMismatch => f.write_str(
"The host's certificate doesn't match the pinned fingerprint. \
Re-pair with a PIN to re-establish trust.",
),
LibraryError::Http(code) => {
write!(f, "The management API returned HTTP {code}.")
}
LibraryError::Unreachable(why) => write!(
f,
"Couldn't reach the host's management API: {why}. Check the host is \
updated and reachable (a host pinned to --mgmt-bind 127.0.0.1 is \
loopback-only and can't be browsed remotely)."
),
}
}
}
/// `https://addr:port`, IPv6 literals bracketed.
pub fn base_url(addr: &str, mgmt_port: u16) -> String {
if addr.contains(':') {
format!("https://[{addr}]:{mgmt_port}")
} else {
format!("https://{addr}:{mgmt_port}")
}
}
/// An HTTPS agent presenting `identity` via TLS client auth and verifying the server by
/// `pin` (`None` = accept any cert, the TOFU special case — same semantics as the QUIC
/// connect). Reused across a whole grid's worth of poster loads.
pub fn agent(
identity: &(String, String),
pin: Option<[u8; 32]>,
) -> Result<ureq::Agent, LibraryError> {
use rustls::pki_types::pem::PemObject;
let bad =
|what: &str, e: &dyn std::fmt::Display| LibraryError::Unreachable(format!("{what}: {e}"));
// The ring provider, explicitly — the same one core's QUIC endpoints install, so the
// process never mixes rustls crypto providers.
let provider = Arc::new(rustls::crypto::ring::default_provider());
let builder = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| bad("tls config", &e))?
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinVerify { pin }));
let cert = rustls::pki_types::CertificateDer::from_pem_slice(identity.0.as_bytes())
.map_err(|e| bad("client cert pem", &e))?;
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(identity.1.as_bytes())
.map_err(|e| bad("client key pem", &e))?;
let cfg = builder
.with_client_auth_cert(vec![cert], key)
.map_err(|e| bad("client auth", &e))?;
Ok(ureq::AgentBuilder::new()
.tls_config(Arc::new(cfg))
.timeout_connect(Duration::from_secs(5))
.timeout(Duration::from_secs(10))
.build())
}
/// Fetch the host's unified library. Errors are pre-classified for the UI (401/403 →
/// [`LibraryError::NotPaired`], a pin-verifier rejection → [`LibraryError::PinMismatch`]).
pub fn fetch_games(
addr: &str,
mgmt_port: u16,
identity: &(String, String),
pin: Option<[u8; 32]>,
) -> Result<Vec<GameEntry>, LibraryError> {
let agent = agent(identity, pin)?;
let url = format!("{}/api/v1/library", base_url(addr, mgmt_port));
let body = match agent.get(&url).call() {
Ok(resp) => resp
.into_string()
.map_err(|e| LibraryError::Unreachable(format!("read body: {e}")))?,
Err(e) => return Err(classify(e)),
};
serde_json::from_str(&body).map_err(|e| LibraryError::Unreachable(format!("bad JSON: {e}")))
}
/// Poster-art byte fetch cap — largest Steam hero assets run a few MB; anything bigger is
/// not an image we want to hand to the texture decoder.
const ART_MAX_BYTES: u64 = 16 * 1024 * 1024;
/// Fetch one cover-art image. URLs on the host itself (under `base`) go through the
/// pinned mTLS agent (the host's art proxy requires the paired cert); any other origin —
/// a public CDN URL on a custom entry — uses ureq's default agent with normal webpki
/// trust and no client cert (Apple's `LibraryTLSDelegate` does the same split).
pub fn fetch_art(pinned: &ureq::Agent, base: &str, url: &str) -> Result<Vec<u8>, LibraryError> {
let resp = if url.starts_with(base) {
pinned.get(url).call()
} else {
ureq::get(url).timeout(Duration::from_secs(10)).call()
}
.map_err(classify)?;
let mut bytes = Vec::new();
resp.into_reader()
.take(ART_MAX_BYTES)
.read_to_end(&mut bytes)
.map_err(|e| LibraryError::Unreachable(format!("read image: {e}")))?;
Ok(bytes)
}
/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a
/// big library into a connection burst.
const ART_WORKERS: usize = 3;
/// Fetch poster bytes for `jobs` (entry id → candidate URLs, walked in order until one
/// loads) on a small worker pool; results stream on the returned channel as they land.
/// Dropping the receiver (the consuming page popped) winds the workers down. Shared by
/// the touch grid and the gamepad launcher — the consumer does its own texture decode on
/// the main loop.
pub fn spawn_art_fetch(
base: String,
identity: (String, String),
pin: Option<[u8; 32]>,
jobs: VecDeque<(String, Vec<String>)>,
) -> async_channel::Receiver<(String, Vec<u8>)> {
let queue = Arc::new(Mutex::new(jobs));
let (tx, rx) = async_channel::unbounded::<(String, Vec<u8>)>();
for _ in 0..ART_WORKERS {
let queue = queue.clone();
let tx = tx.clone();
let base = base.clone();
let identity = identity.clone();
std::thread::Builder::new()
.name("punktfunk-lib-art".into())
.spawn(move || {
let Ok(agent) = agent(&identity, pin) else {
return;
};
loop {
let job = queue.lock().unwrap().pop_front();
let Some((id, candidates)) = job else { break };
for url in &candidates {
match fetch_art(&agent, &base, url) {
Ok(bytes) => {
// Receiver gone (page popped) — stop fetching.
if tx.send_blocking((id, bytes)).is_err() {
return;
}
break;
}
// 404 on a guessed CDN path is routine — try the next kind.
Err(e) => tracing::debug!(%id, url, error = %e, "poster miss"),
}
}
}
})
.expect("spawn art thread");
}
rx
}
fn classify(e: ureq::Error) -> LibraryError {
match e {
ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired,
ureq::Error::Status(code, _) => LibraryError::Http(code),
ureq::Error::Transport(t) => {
// A pin rejection surfaces as a TLS alert wrapped in a transport error; the
// verifier's error kind survives in the message.
let msg = t.to_string();
if msg.contains("ApplicationVerificationFailure") || msg.contains("InvalidCertificate")
{
LibraryError::PinMismatch
} else {
LibraryError::Unreachable(msg)
}
}
}
}
/// Fingerprint-pinning verifier — the client-HTTP twin of core's (private) QUIC
/// `PinVerify`: trust is the SHA-256 of the host's self-signed leaf cert. The handshake
/// signatures MUST still be verified for real: CertificateVerify is what proves the peer
/// *holds the pinned cert's private key* — skip it and an active MITM can replay the
/// host's (public) certificate, match the pin, and complete the handshake with its own key.
#[derive(Debug)]
struct PinVerify {
pin: Option<[u8; 32]>,
}
impl rustls::client::danger::ServerCertVerifier for PinVerify {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
if let Some(expected) = self.pin {
let fp = punktfunk_core::quic::endpoint::cert_fingerprint(end_entity.as_ref());
if fp != expected {
return Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
));
}
}
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn poster_candidates_order_and_resolution() {
// Fallback order is portrait → header → hero, host-relative paths resolved.
let art = Artwork {
portrait: Some("/api/v1/library/art/steam:570/portrait".into()),
hero: Some("https://cdn.example/hero.jpg".into()),
header: Some("/api/v1/library/art/steam:570/header".into()),
};
assert_eq!(
art.poster_candidates("https://192.168.1.42:47990"),
vec![
"https://192.168.1.42:47990/api/v1/library/art/steam:570/portrait",
"https://192.168.1.42:47990/api/v1/library/art/steam:570/header",
"https://cdn.example/hero.jpg",
]
);
assert!(Artwork::default()
.poster_candidates("https://h:47990")
.is_empty());
}
#[test]
fn game_entry_decodes_the_wire_shape() {
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
let json = r#"[
{"id":"steam:570","store":"steam","title":"Dota 2",
"art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
"launch":{"kind":"steam_appid","value":"570"}},
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
]"#;
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
assert_eq!(games.len(), 2);
assert_eq!(games[0].id, "steam:570");
assert!(games[1].art.portrait.is_none());
}
#[test]
fn ipv6_base_url_is_bracketed() {
assert_eq!(base_url("fe80::1", 47990), "https://[fe80::1]:47990");
assert_eq!(base_url("192.168.1.42", 1234), "https://192.168.1.42:1234");
}
}