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
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:
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ mod drm_sync;
|
|||||||
mod encode;
|
mod encode;
|
||||||
mod gamestream;
|
mod gamestream;
|
||||||
mod inject;
|
mod inject;
|
||||||
|
mod library;
|
||||||
mod m0;
|
mod m0;
|
||||||
mod m3;
|
mod m3;
|
||||||
mod mgmt;
|
mod mgmt;
|
||||||
@@ -70,6 +71,12 @@ fn real_main() -> Result<()> {
|
|||||||
print!("{}", mgmt::openapi_json());
|
print!("{}", mgmt::openapi_json());
|
||||||
Ok(())
|
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
|
// 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`.
|
// backend and inject a scripted mouse/keyboard pattern. Watch a focused app / `wev`.
|
||||||
Some("input-test") => input_test(),
|
Some("input-test") => input_test(),
|
||||||
|
|||||||
@@ -149,7 +149,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(approve_pending_device))
|
.routes(routes!(approve_pending_device))
|
||||||
.routes(routes!(deny_pending_device))
|
.routes(routes!(deny_pending_device))
|
||||||
.routes(routes!(stop_session))
|
.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()
|
.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 = "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 = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
|
||||||
(name = "session", description = "Active streaming session control"),
|
(name = "session", description = "Active streaming session control"),
|
||||||
|
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
@@ -1067,6 +1071,108 @@ async fn request_idr(State(st): State<Arc<MgmtState>>) -> Response {
|
|||||||
StatusCode::ACCEPTED.into_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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -194,6 +194,238 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/library": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"library"
|
||||||
|
],
|
||||||
|
"summary": "List the game library",
|
||||||
|
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles).",
|
||||||
|
"operationId": "getLibrary",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Unified library across all stores",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/GameEntry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/library/custom": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"library"
|
||||||
|
],
|
||||||
|
"summary": "Add a custom library entry",
|
||||||
|
"description": "Creates a user-curated title (e.g. a non-Steam game, an emulator, a ROM) with caller-supplied\nartwork URLs. The host assigns a stable id, returned in the body.",
|
||||||
|
"operationId": "createCustomGame",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Entry created",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomEntry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Empty title",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not persist the catalog",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/library/custom/{id}": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"library"
|
||||||
|
],
|
||||||
|
"summary": "Update a custom library entry",
|
||||||
|
"operationId": "updateCustomGame",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The custom entry id (without the `custom:` prefix)",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Entry updated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomEntry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Empty title",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No custom entry with that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not persist the catalog",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"library"
|
||||||
|
],
|
||||||
|
"summary": "Delete a custom library entry",
|
||||||
|
"operationId": "deleteCustomGame",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The custom entry id (without the `custom:` prefix)",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Entry deleted"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No custom entry with that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not persist the catalog",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/native/clients": {
|
"/api/v1/native/clients": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -831,6 +1063,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Artwork": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).",
|
||||||
|
"properties": {
|
||||||
|
"header": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Horizontal header (Steam `header.jpg`) — the universal fallback."
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Wide background (Steam `library_hero.jpg`)."
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Transparent title logo (Steam `logo.png`)."
|
||||||
|
},
|
||||||
|
"portrait": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Vertical capsule / poster (Steam `library_600x900.jpg`). Best for a grid."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"AvailableCompositor": {
|
"AvailableCompositor": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.",
|
"description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.",
|
||||||
@@ -859,6 +1125,100 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CustomEntry": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"title"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"art": {
|
||||||
|
"$ref": "#/components/schemas/Artwork"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||||
|
},
|
||||||
|
"launch": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/LaunchSpec"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CustomInput": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Request body to create or replace a custom entry (no `id` — the host owns it).",
|
||||||
|
"required": [
|
||||||
|
"title"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"art": {
|
||||||
|
"$ref": "#/components/schemas/Artwork"
|
||||||
|
},
|
||||||
|
"launch": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/LaunchSpec"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GameEntry": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One title in the unified library, regardless of which store it came from.",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"store",
|
||||||
|
"title",
|
||||||
|
"art"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"art": {
|
||||||
|
"$ref": "#/components/schemas/Artwork"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||||
|
"example": "steam:570"
|
||||||
|
},
|
||||||
|
"launch": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/LaunchSpec",
|
||||||
|
"description": "How the host would launch it, when known."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"store": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
|
||||||
|
"example": "steam"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Health": {
|
"Health": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Liveness + version probe.",
|
"description": "Liveness + version probe.",
|
||||||
@@ -941,6 +1301,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"LaunchSpec": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;\n`command` → run `<value>` nested in a gamescope session.",
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`\"steam_appid\"` or `\"command\"`.",
|
||||||
|
"example": "steam_appid"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The appid (for `steam_appid`) or the shell command (for `command`)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"NativeClient": {
|
"NativeClient": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A paired native (punktfunk/1) client.",
|
"description": "A paired native (punktfunk/1) client.",
|
||||||
@@ -1313,6 +1692,10 @@
|
|||||||
{
|
{
|
||||||
"name": "session",
|
"name": "session",
|
||||||
"description": "Active streaming session control"
|
"description": "Active streaming session control"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "library",
|
||||||
|
"description": "Game library: installed-store titles (Steam) plus user-curated custom entries"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user