feat(host/library): game library API — Steam adapter + custom store
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 30s
apple / swift (push) Successful in 1m15s
ci / bench (push) Successful in 1m35s
ci / rust (push) Successful in 2m7s
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 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
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 4s
deb / build-publish (push) Successful in 2m19s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m53s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m31s

A new `library` module + four mgmt endpoints surface the host's games to clients
(plan: "surface the user's games"). An adapter layer (`LibraryProvider`) so future
stores (Heroic/Epic, GOG, Lutris) slot in behind one uniform `GameEntry`.

- SteamProvider: reads the LOCAL Steam install — no Steam Web API key, no network.
  Installed titles from steamapps/appmanifest_<appid>.acf; extra library folders
  (incl. paths with spaces) from libraryfolders.vdf; candidate roots cover classic,
  Flatpak and Deck layouts, canonicalized + deduped (the .steam/{steam,root}
  symlinks all fold to one). Runtimes/redistributables (Proton, Steam Linux Runtime,
  Steamworks Common, SteamVR) filtered out. Artwork = the public Steam CDN by appid
  (portrait/hero/logo/header), fetched directly by the client.
- Custom store: ~/.config/punktfunk/library.json, write-then-rename persisted,
  CRUD'd via the API — the "create custom entries via the admin web UI" requirement.
- API (under /api/v1, OpenAPI-documented + checked in): GET /library (all stores
  merged, sorted), POST /library/custom, PUT/DELETE /library/custom/{id}.
- `punktfunk-host library` subcommand dumps the resolved library as JSON (diagnostic,
  mirrors `openapi`).

Validated live against the real Steam library on the Bazzite box: 89 appmanifests →
78 games (11 tools filtered), correct titles/sort, and the CDN art URLs return 200.
5 unit tests for the VDF/ACF parsing, tool filter, art URLs, custom mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 13:43:03 +00:00
parent ee7984beb0
commit 6351d516e0
4 changed files with 932 additions and 1 deletions
+435
View File
@@ -0,0 +1,435 @@
//! Game library (plan: "surface the user's games"). A small adapter layer over the *stores*
//! installed on the host — today **Steam** (read from local files, no API key) and a
//! user-curated **custom** store (CRUD'd via the management API / web console). Every store
//! produces the same [`GameEntry`], so a client renders one uniform grid and never has to know
//! which launcher a title came from. Future stores (Heroic/Epic, GOG, Lutris, EmuDeck) are just
//! more [`LibraryProvider`]s.
//!
//! Artwork is keyed only by Steam appid against the public Steam CDN (no auth) — the client
//! fetches the posters directly. Custom entries carry user-supplied art URLs.
//!
//! This module is read-mostly metadata; *launching* a chosen title (mapping [`LaunchSpec`] onto a
//! gamescope session) is a later step — the launch hint is carried here so that wiring is trivial.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use utoipa::ToSchema;
/// Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for
/// custom). The client prefers `portrait` for a grid and falls back to `header` when a title has
/// no 600×900 capsule (common for older Steam apps).
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct Artwork {
/// Vertical capsule / poster (Steam `library_600x900.jpg`). Best for a grid.
#[serde(skip_serializing_if = "Option::is_none")]
pub portrait: Option<String>,
/// Wide background (Steam `library_hero.jpg`).
#[serde(skip_serializing_if = "Option::is_none")]
pub hero: Option<String>,
/// Transparent title logo (Steam `logo.png`).
#[serde(skip_serializing_if = "Option::is_none")]
pub logo: Option<String>,
/// Horizontal header (Steam `header.jpg`) — the universal fallback.
#[serde(skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
}
/// How the host would launch a title (consumed by the session launcher in a later step). Kept
/// open-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;
/// `command` → run `<value>` nested in a gamescope session.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct LaunchSpec {
/// `"steam_appid"` or `"command"`.
#[schema(example = "steam_appid")]
pub kind: String,
/// The appid (for `steam_appid`) or the shell command (for `command`).
pub value: String,
}
/// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry {
/// Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.
#[schema(example = "steam:570")]
pub id: String,
/// Which store surfaced it: `"steam"` or `"custom"`.
#[schema(example = "steam")]
pub store: String,
pub title: String,
pub art: Artwork,
/// How the host would launch it, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
}
/// A store that contributes titles to the library. The trait is the extension point for future
/// launchers; today only [`SteamProvider`] implements it.
pub trait LibraryProvider {
/// Stable store id (`"steam"`, …).
fn store(&self) -> &'static str;
/// Enumerate installed/owned titles. Best-effort: returns empty (not an error) when the store
/// isn't present, so one missing launcher never fails the whole library.
fn list(&self) -> Vec<GameEntry>;
}
// ---------------------------------------------------------------------------------------
// Steam
// ---------------------------------------------------------------------------------------
/// 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 (public, no auth). 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 base = format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}");
Artwork {
portrait: Some(format!("{base}/library_600x900.jpg")),
hero: Some(format!("{base}/library_hero.jpg")),
logo: Some(format!("{base}/logo.png")),
header: Some(format!("{base}/header.jpg")),
}
}
/// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped.
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
];
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 — Linux library paths never contain the `"` or `\`
/// that would require unescaping.
fn vdf_paths(text: &str) -> Vec<String> {
text.lines()
.filter_map(|l| vdf_value(l.trim(), "path"))
.map(str::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")
}
// ---------------------------------------------------------------------------------------
// Custom store (user-curated entries, persisted + CRUD'd via the mgmt API)
// ---------------------------------------------------------------------------------------
/// 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)?;
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)?;
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)?;
Ok(true)
}
// ---------------------------------------------------------------------------------------
// Unified library
// ---------------------------------------------------------------------------------------
/// The full library: every store's titles merged + the custom entries, sorted by title.
pub fn all_games() -> Vec<GameEntry> {
let mut games = SteamProvider.list();
games.extend(load_custom().into_iter().map(GameEntry::from));
games.sort_by_key(|g| g.title.to_lowercase());
games
}
#[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_uses_cdn_by_appid() {
let art = steam_art(570);
assert_eq!(
art.portrait.as_deref(),
Some("https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_600x900.jpg")
);
assert!(art.header.unwrap().ends_with("/570/header.jpg"));
}
#[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");
}
}
+7
View File
@@ -21,6 +21,7 @@ mod drm_sync;
mod encode;
mod gamestream;
mod inject;
mod library;
mod m0;
mod m3;
mod mgmt;
@@ -70,6 +71,12 @@ fn real_main() -> Result<()> {
print!("{}", mgmt::openapi_json());
Ok(())
}
// Dump the resolved game library (installed stores + custom entries) as JSON — the same
// payload `GET /api/v1/library` serves. A diagnostic for "does the host see my games?".
Some("library") => {
println!("{}", serde_json::to_string_pretty(&library::all_games())?);
Ok(())
}
// Standalone input-injection smoke test (no client needed): open the session's input
// backend and inject a scripted mouse/keyboard pattern. Watch a focused app / `wev`.
Some("input-test") => input_test(),
+107 -1
View File
@@ -149,7 +149,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(approve_pending_device))
.routes(routes!(deny_pending_device))
.routes(routes!(stop_session))
.routes(routes!(request_idr)),
.routes(routes!(request_idr))
.routes(routes!(get_library))
.routes(routes!(create_custom_game))
.routes(routes!(update_custom_game, delete_custom_game)),
)
.split_for_parts()
}
@@ -180,6 +183,7 @@ pub fn openapi_json() -> String {
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
(name = "session", description = "Active streaming session control"),
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
)
)]
struct ApiDoc;
@@ -1067,6 +1071,108 @@ async fn request_idr(State(st): State<Arc<MgmtState>>) -> Response {
StatusCode::ACCEPTED.into_response()
}
// ---------------------------------------------------------------------------------------
// Library
// ---------------------------------------------------------------------------------------
/// 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),
)
)]
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),
)
)]
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),
)
)]
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),
)
)]
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()),
}
}
// ---------------------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------------------