feat(apple): gamepad ui
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s

This commit is contained in:
2026-07-01 15:14:19 +02:00
parent c8be614d9a
commit ecbbff5544
22 changed files with 1782 additions and 74 deletions
+57 -2
View File
@@ -171,6 +171,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(get_library))
.routes(routes!(create_custom_game))
.routes(routes!(update_custom_game, delete_custom_game))
.routes(routes!(get_library_art))
.routes(routes!(stats_capture_start))
.routes(routes!(stats_capture_stop))
.routes(routes!(stats_capture_status))
@@ -544,7 +545,7 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
/// edit the library). `/health` is handled separately (always open).
fn cert_may_access(method: &Method, path: &str) -> bool {
method == Method::GET
&& matches!(
&& (matches!(
path,
"/api/v1/host"
| "/api/v1/compositors"
@@ -555,7 +556,7 @@ fn cert_may_access(method: &Method, path: &str) -> bool {
// 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
@@ -1276,6 +1277,45 @@ async fn delete_custom_game(Path(id): Path<String>) -> Response {
}
}
/// 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),
)
)]
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"),
}
}
// ---------------------------------------------------------------------------------------
// Streaming stats capture (design/stats-capture-plan.md §2)
// ---------------------------------------------------------------------------------------
@@ -1694,6 +1734,21 @@ mod tests {
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]