apple / swift (push) Successful in 5m38s
windows-host / package (push) Successful in 19m0s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
ci / bench (push) Successful in 5m41s
ci / rust-arm64 (push) Successful in 9m32s
ci / rust (push) Failing after 12m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
android / android (push) Successful in 17m49s
decky / build-publish (push) Successful in 1m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
arch / build-publish (push) Successful in 19m46s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 39s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m22s
deb / build-publish (push) Successful in 9m9s
deb / build-publish-client-arm64 (push) Successful in 7m26s
deb / build-publish-host (push) Successful in 9m46s
flatpak / build-publish (push) Failing after 8m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m56s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 11s
apple / screenshots (push) Successful in 25m51s
Emulation-and-beyond libraries need more than a title and a poster. Every library shape (GameEntry, CustomEntry, CustomInput, ProviderEntryInput) now carries a shared, flattened GameMeta: platform, description, developer, publisher, release_year, genres, tags, region, players. All fields are optional and flat on the wire, so existing library.json files, provider plugins, and clients keep working unchanged. - Installed-store scanners (Steam, Lutris, Heroic, Epic, GOG, Xbox) stamp platform=PC; custom/provider entries carry whatever was authored. - GET /library grows a ?platform= filter (case-insensitive) beside ?provider=. - Console: the add/edit form gets a Details section (round-tripping every field, since update replaces the entry), the poster tile a platform badge (non-PC only — the store badge already implies PC) and the year. - plugin-kit: ProviderEntry accepts the same fields (new GameMeta schema, spread flat); SDK + OpenAPI spec regenerated. - pf-client-core decodes platform for future client badges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
309 lines
13 KiB
Rust
309 lines
13 KiB
Rust
//! 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,
|
||
/// The system the title runs on (`"PC"`, `"PS2"`, …) — free-form display string from the
|
||
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
|
||
#[serde(default)]
|
||
pub platform: Option<String>,
|
||
}
|
||
|
||
/// 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(punktfunk_core::tls::PinVerify::new(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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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","platform":"PC",
|
||
"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_eq!(games[0].platform.as_deref(), Some("PC"));
|
||
assert!(games[1].art.portrait.is_none());
|
||
assert!(
|
||
games[1].platform.is_none(),
|
||
"pre-metadata hosts still parse"
|
||
);
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|