feat(host/library): external provider API — declarative reconcile (M4)
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m37s
windows-host / package (push) Successful in 8m57s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 58s
ci / bench (push) Successful in 5m51s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
arch / build-publish (push) Successful in 16m51s
android / android (push) Successful in 17m6s
deb / build-publish (push) Successful in 17m13s
ci / rust (push) Successful in 18m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m31s
docker / deploy-docs (push) Successful in 10s

External game-library providers become first-class (RFC §8): a plugin
computes its desired title list and PUTs it — the host owns the diff.

- CustomEntry gains `provider` + `external_id` (API-set only; never on
  manual entries). GameEntry surfaces `provider` for console attribution
  and the new `GET /library?provider=` filter.
- PUT /api/v1/library/provider/{p}: atomic declarative reconcile keyed
  on the provider's `external_id` — host ids stay stable across syncs,
  orphans drop, manual entries and other providers are never touched,
  an empty array clears the set. Validated: provider id [a-z0-9._-]
  (`manual` reserved), unique non-empty external_ids.
- DELETE /api/v1/library/provider/{p}: clean uninstall, returns the
  removed count.
- Ownership is unambiguous both ways: manual CRUD now returns 409 for a
  provider-owned entry (MutateOutcome::ProviderOwned) instead of letting
  an edit be silently clobbered at the next sync.
- library.changed now carries the mutating source (`manual` or the
  provider id) — hooks and the SDK filter on it.
- Spec + SDK schemas regenerated; sdk/examples/provider-sync.ts is the
  provider-plugin skeleton.

347 host tests green (pure reconcile: stable ids, orphan drop,
idempotence, bystanders untouched; name/payload validation; route 400s)
+ 11 SDK tests. Live-verified end to end THROUGH the SDK against a real
host: sync → filtered list → manual-delete 409 → re-sync with stable id
+ orphan drop → uninstall (removed=2), with three
library.changed(source=romm) events observed on the live stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 00:33:42 +02:00
parent 87114ab186
commit f2a58f3a91
15 changed files with 815 additions and 35 deletions
+224 -1
View File
@@ -913,8 +913,19 @@
"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).",
"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). `?provider=` narrows to the\nentries a given external provider owns.",
"operationId": "getLibrary",
"parameters": [
{
"name": "provider",
"in": "query",
"description": "Only entries owned by this external provider",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Unified library across all stores",
@@ -1197,6 +1208,146 @@
}
}
},
"/api/v1/library/provider/{provider}": {
"put": {
"tags": [
"library"
],
"summary": "Replace a provider's library entries (declarative reconcile)",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
"operationId": "reconcileProviderEntries",
"parameters": [
{
"name": "provider",
"in": "path",
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ProviderEntryInput"
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The provider's resulting entries (host ids assigned/kept)",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CustomEntry"
}
}
}
}
},
"400": {
"description": "Invalid provider id or payload",
"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"
}
}
}
}
}
},
"delete": {
"tags": [
"library"
],
"summary": "Remove a provider's library entries",
"description": "Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin\n(RFC §8). Emits `library.changed` when anything was removed.",
"operationId": "deleteProviderEntries",
"parameters": [
{
"name": "provider",
"in": "path",
"description": "The provider id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "How many entries were removed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRemoved"
}
}
}
},
"400": {
"description": "Invalid provider id",
"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/local/summary": {
"get": {
"tags": [
@@ -2602,6 +2753,13 @@
"art": {
"$ref": "#/components/schemas/Artwork"
},
"external_id": {
"type": [
"string",
"null"
],
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
},
"id": {
"type": "string",
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
@@ -2623,6 +2781,13 @@
},
"description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])."
},
"provider": {
"type": [
"string",
"null"
],
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
},
"title": {
"type": "string"
}
@@ -3198,6 +3363,13 @@
}
]
},
"provider": {
"type": [
"string",
"null"
],
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
},
"store": {
"type": "string",
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
@@ -4053,6 +4225,57 @@
}
}
},
"ProviderEntryInput": {
"type": "object",
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key.",
"required": [
"external_id",
"title"
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
},
"external_id": {
"type": "string",
"description": "The provider's stable id for this title (the reconcile diff key)."
},
"launch": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/LaunchSpec"
}
]
},
"prep": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PrepCmd"
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"title": {
"type": "string"
}
}
},
"ProviderRemoved": {
"type": "object",
"description": "The count envelope a provider uninstall returns.",
"required": [
"removed"
],
"properties": {
"removed": {
"type": "integer",
"description": "How many entries the provider owned (and were removed).",
"minimum": 0
}
}
},
"ReleaseDisplayRequest": {
"type": "object",
"description": "Request body for `releaseDisplay`.",
+5
View File
@@ -95,6 +95,11 @@ pub struct GameEntry {
/// How the host would launch it, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
/// The external provider owning this entry (custom-store entries synced by a provider
/// plugin, RFC §8) — `None` for installed-store titles and manual custom entries. The
/// console uses it for attribution; `GET /library?provider=` filters on it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
}
/// A store that contributes titles to the library. The trait is the extension point for future
+292 -25
View File
@@ -18,6 +18,16 @@ pub struct CustomEntry {
/// `undo` at session end in reverse order (see [`crate::hooks::run_prep`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prep: Vec<crate::hooks::PrepCmd>,
/// The external provider owning this entry (RFC §8), set ONLY by the provider reconcile
/// API — `None` = a manual entry, which no provider operation ever touches, and which the
/// manual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned
/// entries, so ownership is never ambiguous).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// The provider's own stable key for this title — the reconcile diff key, so the
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_id: Option<String>,
}
/// Request body to create or replace a custom entry (no `id` — the host owns it).
@@ -33,6 +43,22 @@ pub struct CustomInput {
pub prep: Vec<crate::hooks::PrepCmd>,
}
/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the
/// provider's required stable key.
#[derive(Clone, Debug, Deserialize, ToSchema)]
pub struct ProviderEntryInput {
/// The provider's stable id for this title (the reconcile diff key).
pub external_id: String,
pub title: String,
#[serde(default)]
pub art: Artwork,
#[serde(default)]
pub launch: Option<LaunchSpec>,
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
}
impl From<CustomEntry> for GameEntry {
fn from(c: CustomEntry) -> Self {
GameEntry {
@@ -41,6 +67,7 @@ impl From<CustomEntry> for GameEntry {
title: c.title,
art: c.art,
launch: c.launch,
provider: c.provider,
}
}
}
@@ -88,7 +115,17 @@ fn new_id(title: &str) -> String {
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
}
/// Create a custom entry, returning it with its assigned id.
/// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists,
/// but a provider owns it" (the mgmt layer maps the latter to 409, not 404).
pub enum MutateOutcome<T> {
Done(T),
NotFound,
/// The entry belongs to this provider — mutate it through the provider reconcile API
/// (or remove the whole provider set); manual edits would be clobbered at the next sync.
ProviderOwned(String),
}
/// Create a custom (manual) entry, returning it with its assigned id.
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
let mut entries = load_custom();
let entry = CustomEntry {
@@ -97,40 +134,156 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
art: input.art,
launch: input.launch,
prep: input.prep,
provider: None,
external_id: None,
};
entries.push(entry.clone());
save_custom(&entries)?;
emit_changed();
emit_changed("manual");
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>> {
/// Replace a manual entry's fields (id preserved). Provider-owned entries are refused —
/// their state belongs to the provider's reconcile (RFC §8 ownership rule).
pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<CustomEntry>> {
let mut entries = load_custom();
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
return Ok(None);
return Ok(MutateOutcome::NotFound);
};
if let Some(provider) = &slot.provider {
return Ok(MutateOutcome::ProviderOwned(provider.clone()));
}
slot.title = input.title;
slot.art = input.art;
slot.launch = input.launch;
slot.prep = input.prep;
let updated = slot.clone();
save_custom(&entries)?;
emit_changed();
Ok(Some(updated))
emit_changed("manual");
Ok(MutateOutcome::Done(updated))
}
/// Delete a custom entry. `false` ⇒ no entry with that id.
pub fn delete_custom(id: &str) -> Result<bool> {
/// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]).
pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
let mut entries = load_custom();
let Some(entry) = entries.iter().find(|e| e.id == id) else {
return Ok(MutateOutcome::NotFound);
};
if let Some(provider) = &entry.provider {
return Ok(MutateOutcome::ProviderOwned(provider.clone()));
}
entries.retain(|e| e.id != id);
save_custom(&entries)?;
emit_changed("manual");
Ok(MutateOutcome::Done(()))
}
// ------------------------------------------------------------------ providers (RFC §8)
/// Provider ids are path segments, event sources, and console labels: keep them tame.
/// `manual` is reserved (it is the no-provider sentinel in `library.changed`).
pub fn validate_provider_name(provider: &str) -> Result<(), String> {
if provider == "manual" {
return Err("provider id `manual` is reserved".into());
}
let ok = !provider.is_empty()
&& provider.len() <= 64
&& provider
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
&& provider.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit());
if ok {
Ok(())
} else {
Err("provider id must be 164 chars of [a-z0-9._-], starting alphanumeric".into())
}
}
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
let mut seen = std::collections::HashSet::new();
for (i, e) in inputs.iter().enumerate() {
if e.external_id.trim().is_empty() {
return Err(format!("entries[{i}]: `external_id` must not be empty"));
}
if e.title.trim().is_empty() {
return Err(format!("entries[{i}]: `title` must not be empty"));
}
if !seen.insert(e.external_id.as_str()) {
return Err(format!(
"entries[{i}]: duplicate `external_id` \"{}\"",
e.external_id
));
}
}
Ok(())
}
/// The pure reconcile (unit-tested without the filesystem): replace `provider`'s entry set
/// with `inputs` inside `entries` — keeping each surviving title's host id stable (keyed on
/// `external_id`), dropping the provider's orphans, and never touching manual entries or
/// other providers'. Returns the provider's resulting entries, payload order.
fn reconcile_entries(
entries: &mut Vec<CustomEntry>,
provider: &str,
inputs: Vec<ProviderEntryInput>,
) -> Vec<CustomEntry> {
// The provider's current entries, keyed by its own stable id.
let mut existing: std::collections::HashMap<String, CustomEntry> = entries
.iter()
.filter(|e| e.provider.as_deref() == Some(provider))
.filter_map(|e| e.external_id.clone().map(|x| (x, e.clone())))
.collect();
// Everything the provider does NOT own survives untouched.
entries.retain(|e| e.provider.as_deref() != Some(provider));
let mut result = Vec::with_capacity(inputs.len());
for input in inputs {
let id = existing
.remove(&input.external_id)
.map(|prev| prev.id) // same title as last sync → keep its host id
.unwrap_or_else(|| new_id(&format!("{provider}:{}", input.external_id)));
result.push(CustomEntry {
id,
title: input.title,
art: input.art,
launch: input.launch,
prep: input.prep,
provider: Some(provider.to_string()),
external_id: Some(input.external_id),
});
}
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
entries.extend(result.iter().cloned());
result
}
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`).
/// The caller validates the name and payload first. Emits `library.changed` with the provider
/// as the source.
pub fn reconcile_provider(
provider: &str,
inputs: Vec<ProviderEntryInput>,
) -> Result<Vec<CustomEntry>> {
let mut entries = load_custom();
let result = reconcile_entries(&mut entries, provider, inputs);
save_custom(&entries)?;
emit_changed(provider);
Ok(result)
}
/// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the
/// clean-uninstall path). Returns how many were removed; no event when nothing was.
pub fn delete_provider(provider: &str) -> Result<usize> {
let mut entries = load_custom();
let before = entries.len();
entries.retain(|e| e.id != id);
if entries.len() == before {
return Ok(false);
entries.retain(|e| e.provider.as_deref() != Some(provider));
let removed = before - entries.len();
if removed > 0 {
save_custom(&entries)?;
emit_changed(provider);
}
save_custom(&entries)?;
emit_changed();
Ok(true)
Ok(removed)
}
/// The prep/undo steps for a library id — `custom:<id>` entries only (the other stores have no
@@ -146,11 +299,11 @@ pub fn prep_for(library_id: &str) -> Vec<crate::hooks::PrepCmd> {
.unwrap_or_default()
}
/// The custom-entry mutations are the only library writes today, all operator-driven — hence
/// `source: "manual"` (RFC §4; a provider id once the provider API of RFC §8 lands).
fn emit_changed() {
/// Every library mutation announces itself (RFC §4): `source` is `"manual"` for the operator
/// CRUD, the provider id for a reconcile/uninstall — hooks and the SDK filter on it.
fn emit_changed(source: &str) {
crate::events::emit(crate::events::EventKind::LibraryChanged {
source: "manual".to_string(),
source: source.to_string(),
});
}
@@ -166,17 +319,131 @@ pub(crate) fn valid_steam_appid(value: &str) -> bool {
mod tests {
use super::*;
#[test]
fn custom_entry_maps_to_game_entry() {
let g: GameEntry = CustomEntry {
id: "abc123".into(),
title: "My ROM".into(),
fn manual(id: &str, title: &str) -> CustomEntry {
CustomEntry {
id: id.into(),
title: title.into(),
art: Artwork::default(),
launch: None,
prep: Vec::new(),
provider: None,
external_id: None,
}
}
fn input(external_id: &str, title: &str) -> ProviderEntryInput {
ProviderEntryInput {
external_id: external_id.into(),
title: title.into(),
art: Artwork::default(),
launch: None,
prep: Vec::new(),
}
.into();
}
#[test]
fn custom_entry_maps_to_game_entry_with_provider() {
let g: GameEntry = manual("abc123", "My ROM").into();
assert_eq!(g.id, "custom:abc123");
assert_eq!(g.store, "custom");
assert_eq!(g.provider, None);
let mut e = manual("def456", "Synced");
e.provider = Some("romm".into());
e.external_id = Some("rom-1".into());
let g: GameEntry = e.into();
assert_eq!(g.provider.as_deref(), Some("romm"));
}
/// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow,
/// orphans drop, and neither manual entries nor other providers are ever touched.
#[test]
fn reconcile_is_declarative_with_stable_ids() {
let mut entries = vec![manual("man1", "Hand-added")];
// Another provider's entry must survive every romm reconcile.
let mut other = manual("oth1", "Other title");
other.provider = Some("itch".into());
other.external_id = Some("x1".into());
entries.push(other);
// First sync: two titles appear.
let r1 = reconcile_entries(
&mut entries,
"romm",
vec![input("rom-a", "Game A"), input("rom-b", "Game B")],
);
assert_eq!(r1.len(), 2);
assert!(r1.iter().all(|e| e.provider.as_deref() == Some("romm")));
let id_a = r1[0].id.clone();
assert_eq!(entries.len(), 4);
// Second sync: A renamed, B gone, C new — A's host id must be STABLE.
let r2 = reconcile_entries(
&mut entries,
"romm",
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
);
assert_eq!(r2.len(), 2);
assert_eq!(r2[0].id, id_a, "same external_id keeps its host id");
assert_eq!(r2[0].title, "Game A (v2)");
assert_ne!(r2[1].id, id_a);
assert!(
!entries
.iter()
.any(|e| e.external_id.as_deref() == Some("rom-b")),
"orphan dropped"
);
// Idempotence: an identical re-PUT changes nothing.
let snapshot: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
let r3 = reconcile_entries(
&mut entries,
"romm",
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
);
assert_eq!(
r3.iter().map(|e| &e.id).collect::<Vec<_>>(),
r2.iter().map(|e| &e.id).collect::<Vec<_>>()
);
assert_eq!(
entries.iter().map(|e| e.id.clone()).collect::<Vec<_>>(),
snapshot
);
// The bystanders never moved.
assert!(entries
.iter()
.any(|e| e.id == "man1" && e.provider.is_none()));
assert!(entries
.iter()
.any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch")));
// Empty payload = remove everything the provider owns (same as DELETE).
let r4 = reconcile_entries(&mut entries, "romm", Vec::new());
assert!(r4.is_empty());
assert_eq!(
entries.len(),
2,
"only the manual + other-provider entries remain"
);
}
#[test]
fn provider_name_and_payload_validation() {
assert!(validate_provider_name("romm").is_ok());
assert!(validate_provider_name("my-provider.v2").is_ok());
assert!(validate_provider_name("manual").is_err(), "reserved");
assert!(validate_provider_name("").is_err());
assert!(validate_provider_name("Bad/Name").is_err());
assert!(validate_provider_name("-lead").is_err());
assert!(validate_provider_name(&"x".repeat(65)).is_err());
assert!(validate_provider_payload(&[input("a", "A")]).is_ok());
assert!(validate_provider_payload(&[input("", "A")]).is_err());
assert!(validate_provider_payload(&[input("a", " ")]).is_err());
assert!(
validate_provider_payload(&[input("a", "A"), input("a", "B")]).is_err(),
"duplicate external_id"
);
}
}
@@ -89,6 +89,7 @@ fn epic_entry(
app_name.clone()
};
Some(GameEntry {
provider: None,
id: format!("epic:{app_name}"),
store: "epic".into(),
title,
+1
View File
@@ -53,6 +53,7 @@ fn gog_games() -> Vec<GameEntry> {
// whatever it has cached (title-only until warmed).
let art = cached_art(&id).unwrap_or_default();
out.push(GameEntry {
provider: None,
id,
store: "gog".into(),
title,
@@ -106,6 +106,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
logo: http("art_logo"),
};
games.push(GameEntry {
provider: None,
id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(),
title,
@@ -70,6 +70,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
let mut games = Vec::new();
for (id, slug, name) in rows.flatten() {
games.push(GameEntry {
provider: None,
id: format!("lutris:{id}"),
store: "lutris".into(),
title: name,
@@ -25,6 +25,7 @@ impl LibraryProvider for SteamProvider {
.into_iter()
.filter(|(appid, name)| !is_steam_tool(*appid, name))
.map(|(appid, title)| GameEntry {
provider: None,
id: format!("steam:{appid}"),
store: "steam".into(),
title,
@@ -61,6 +61,7 @@ fn xbox_games() -> Vec<GameEntry> {
// background warmer; read whatever it has cached (title-only until warmed / if no StoreId).
let art = cached_art(&id).unwrap_or_default();
games.push(GameEntry {
provider: None,
id,
store: "xbox".into(),
title,
+4
View File
@@ -207,6 +207,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
library::update_custom_game,
library::delete_custom_game
))
.routes(routes!(
library::reconcile_provider_entries,
library::delete_provider_entries
))
.routes(routes!(library::get_library_art))
.routes(routes!(stats::stats_capture_start))
.routes(routes!(stats::stats_capture_stop))
+122 -7
View File
@@ -4,23 +4,39 @@
use super::shared::*;
use axum::http::header;
#[derive(Deserialize)]
pub(crate) struct LibraryQuery {
/// Only entries owned by this external provider (RFC §8).
provider: Option<String>,
}
/// 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).
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
/// entries a given external provider owns.
#[utoipa::path(
get,
path = "/library",
tag = "library",
operation_id = "getLibrary",
params(
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"),
),
responses(
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_library() -> Json<Vec<crate::library::GameEntry>> {
Json(crate::library::all_games())
pub(crate) async fn get_library(
Query(q): Query<LibraryQuery>,
) -> Json<Vec<crate::library::GameEntry>> {
let mut games = crate::library::all_games();
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
}
Json(games)
}
/// Add a custom library entry
@@ -75,9 +91,16 @@ pub(crate) async fn update_custom_game(
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
use crate::library::MutateOutcome;
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"),
Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(),
Ok(MutateOutcome::NotFound) => {
api_error(StatusCode::NOT_FOUND, "no custom entry with that id")
}
Ok(MutateOutcome::ProviderOwned(p)) => api_error(
StatusCode::CONFLICT,
&format!("entry is owned by provider `{p}` — update it through its reconcile"),
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -97,9 +120,101 @@ pub(crate) async fn update_custom_game(
)
)]
pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
use crate::library::MutateOutcome;
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"),
Ok(MutateOutcome::Done(())) => StatusCode::NO_CONTENT.into_response(),
Ok(MutateOutcome::NotFound) => {
api_error(StatusCode::NOT_FOUND, "no custom entry with that id")
}
Ok(MutateOutcome::ProviderOwned(p)) => api_error(
StatusCode::CONFLICT,
&format!(
"entry is owned by provider `{p}` — remove it there, or DELETE the provider set"
),
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// The count envelope a provider uninstall returns.
#[derive(Serialize, ToSchema)]
pub(crate) struct ProviderRemoved {
/// How many entries the provider owned (and were removed).
removed: usize,
}
/// Replace a provider's library entries (declarative reconcile)
///
/// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
/// provider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each
/// surviving title's host id stable across reconciles, drops orphans, and never touches manual
/// entries or other providers'. An empty array removes everything the provider owns. Emits
/// `library.changed` with the provider as `source`.
#[utoipa::path(
put,
path = "/library/provider/{provider}",
tag = "library",
operation_id = "reconcileProviderEntries",
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
request_body = Vec<crate::library::ProviderEntryInput>,
responses(
(status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]),
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn reconcile_provider_entries(
Path(provider): Path<String>,
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
match crate::library::reconcile_provider(&provider, inputs) {
Ok(entries) => {
tracing::info!(
provider,
count = entries.len(),
"library provider reconciled"
);
Json(entries).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Remove a provider's library entries
///
/// Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin
/// (RFC §8). Emits `library.changed` when anything was removed.
#[utoipa::path(
delete,
path = "/library/provider/{provider}",
tag = "library",
operation_id = "deleteProviderEntries",
params(("provider" = String, Path, description = "The provider id")),
responses(
(status = OK, description = "How many entries were removed", body = ProviderRemoved),
(status = BAD_REQUEST, description = "Invalid provider id", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
match crate::library::delete_provider(&provider) {
Ok(removed) => {
if removed > 0 {
tracing::info!(provider, removed, "library provider entries removed");
}
Json(ProviderRemoved { removed }).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
+53
View File
@@ -1206,3 +1206,56 @@ async fn hooks_get_shape_and_put_validation() {
let resp = app.clone().oneshot(req).await.expect("infallible");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// ------------------------------------------------------------------ library providers
/// Provider reconcile validation (the write path itself is unit-tested in `library::custom`
/// against pure functions — a successful PUT here would touch the developer's real catalog).
#[tokio::test]
async fn provider_reconcile_validation() {
let app = test_app(test_state(), None);
let put = |provider: &str, body: serde_json::Value| {
axum::http::Request::put(format!("/api/v1/library/provider/{provider}"))
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
// Reserved / malformed provider ids.
let (s, json) = send(&app, put("manual", serde_json::json!([]))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(json["error"].as_str().unwrap().contains("reserved"));
let (s, _) = send(&app, put("Bad%2FName", serde_json::json!([]))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// Payload rules: empty external_id, duplicate external_id.
let (s, _) = send(
&app,
put(
"romm",
serde_json::json!([{"external_id": "", "title": "X"}]),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let (s, json) = send(
&app,
put(
"romm",
serde_json::json!([
{"external_id": "a", "title": "A"},
{"external_id": "a", "title": "B"}
]),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(json["error"].as_str().unwrap().contains("duplicate"));
// DELETE validates the name too.
let del = axum::http::Request::delete("/api/v1/library/provider/manual")
.body(Body::empty())
.unwrap();
let (s, _) = send(&app, del).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
+1 -1
View File
@@ -28,7 +28,7 @@ and nothing you configure here runs anywhere near the streaming path.
| `pairing.pending` | an unpaired device knocks (once per device, not per retry) | device name, fingerprint, plane |
| `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | device name, fingerprint, plane |
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
| `library.changed` | the game library is mutated | source (`manual`) |
| `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) |
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
+26
View File
@@ -0,0 +1,26 @@
// The external game-library provider pattern (RFC §8): compute your desired title list and
// declaratively PUT it — the host diffs by your `external_id`, keeps host ids stable across
// syncs, drops orphans, and never touches manual entries. Uninstall = one DELETE.
import { connect } from "../src/index.js";
const PROVIDER = "romm"; // your punktfunk-plugin-* name
const pf = await connect();
pf.events.on("library.changed", (e) => {
if (e.source === PROVIDER) console.log("library synced");
});
// Fetch your source of truth (a ROM manager, itch.io, a curated list…), then reconcile:
const desired = [
{ external_id: "rom-1", title: "Chrono Trigger", launch: { command: "retroarch ..." } },
{ external_id: "rom-2", title: "Super Metroid", launch: { command: "retroarch ..." } },
];
const entries = (await pf.request("PUT", `/library/provider/${PROVIDER}`, desired)) as {
id: string;
title: string;
}[];
console.log(`synced ${entries.length} titles:`, entries.map((e) => `${e.title} (custom:${e.id})`));
// …run on a schedule, or keep watching your source. Clean uninstall:
// await pf.request("DELETE", `/library/provider/${PROVIDER}`);
pf.close();
+82 -1
View File
@@ -963,9 +963,14 @@ export const GetHostInfoResponse = S.Struct({
/**
* 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).
* fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
* entries a given external provider owns.
* @summary List the game library
*/
export const GetLibraryQueryParams = S.Struct({
"provider": S.optional(S.String).annotations({ description: 'Only entries owned by this external provider' })
})
export const GetLibraryResponseItem = S.Struct({
"art": S.Struct({
"header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }),
@@ -978,6 +983,7 @@ export const GetLibraryResponseItem = S.Struct({
"kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }),
"value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' })
}).annotations({ description: 'How the host would launch it, when known.' }))),
"provider": S.optional(S.NullOr(S.String)).annotations({ description: 'The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET \/library?provider=` filters on it.' }),
"store": S.String.annotations({ description: 'Which store surfaced it: `\"steam\"` or `\"custom\"`.' }),
"title": S.String
}).annotations({ description: 'One title in the unified library, regardless of which store it came from.' })
@@ -1055,6 +1061,7 @@ export const UpdateCustomGameResponse = S.Struct({
"logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }),
"portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' })
})).annotations({ 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).' }),
"external_id": S.optional(S.NullOr(S.String)).annotations({ description: 'The provider\'s own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is.' }),
"id": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }),
"launch": S.optional(S.Union(S.Null, S.Struct({
"kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }),
@@ -1064,6 +1071,7 @@ export const UpdateCustomGameResponse = S.Struct({
"do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }),
"undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' })
}).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`]).' }),
"provider": S.optional(S.NullOr(S.String)).annotations({ description: 'The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous).' }),
"title": S.String
}).annotations({ description: 'A user-added title, persisted in `~\/.config\/punktfunk\/library.json`. Same shape the API\nreturns and the web console edits.' })
@@ -1076,6 +1084,79 @@ export const DeleteCustomGameParams = S.Struct({
})
/**
* Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
* provider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each
* surviving title's host id stable across reconciles, drops orphans, and never touches manual
* entries or other providers'. An empty array removes everything the provider owns. Emits
* `library.changed` with the provider as `source`.
* @summary Replace a provider's library entries (declarative reconcile)
*/
export const ReconcileProviderEntriesParams = S.Struct({
"provider": S.String.annotations({ description: 'The provider id ([a-z0-9._-], `manual` reserved)' })
})
export const ReconcileProviderEntriesBodyItem = S.Struct({
"art": S.optional(S.Struct({
"header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }),
"hero": S.optional(S.NullOr(S.String)).annotations({ description: 'Wide background (Steam `library_hero.jpg`).' }),
"logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }),
"portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' })
})).annotations({ 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).' }),
"external_id": S.String.annotations({ description: 'The provider\'s stable id for this title (the reconcile diff key).' }),
"launch": S.optional(S.Union(S.Null, S.Struct({
"kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }),
"value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' })
}).annotations({ 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.' }))),
"prep": S.optional(S.Array(S.Struct({
"do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }),
"undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' })
}).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps — commands run as the host user; operator-privileged config.' }),
"title": S.String
}).annotations({ description: 'One title in a provider\'s declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider\'s required stable key.' })
export const ReconcileProviderEntriesBody = S.Array(ReconcileProviderEntriesBodyItem)
export const ReconcileProviderEntriesResponseItem = S.Struct({
"art": S.optional(S.Struct({
"header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }),
"hero": S.optional(S.NullOr(S.String)).annotations({ description: 'Wide background (Steam `library_hero.jpg`).' }),
"logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }),
"portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' })
})).annotations({ 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).' }),
"external_id": S.optional(S.NullOr(S.String)).annotations({ description: 'The provider\'s own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is.' }),
"id": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }),
"launch": S.optional(S.Union(S.Null, S.Struct({
"kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }),
"value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' })
}).annotations({ 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.' }))),
"prep": S.optional(S.Array(S.Struct({
"do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }),
"undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' })
}).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`]).' }),
"provider": S.optional(S.NullOr(S.String)).annotations({ description: 'The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous).' }),
"title": S.String
}).annotations({ description: 'A user-added title, persisted in `~\/.config\/punktfunk\/library.json`. Same shape the API\nreturns and the web console edits.' })
export const ReconcileProviderEntriesResponse = S.Array(ReconcileProviderEntriesResponseItem)
/**
* Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin
* (RFC §8). Emits `library.changed` when anything was removed.
* @summary Remove a provider's library entries
*/
export const DeleteProviderEntriesParams = S.Struct({
"provider": S.String.annotations({ description: 'The provider id' })
})
export const deleteProviderEntriesResponseRemovedMin = 0;
export const DeleteProviderEntriesResponse = S.Struct({
"removed": S.Number.pipe(S.greaterThanOrEqualTo(deleteProviderEntriesResponseRemovedMin)).annotations({ description: 'How many entries the provider owned (and were removed).' })
}).annotations({ description: 'The count envelope a provider uninstall returns.' })
/**
* Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device
* names). Unauthenticated, but served to loopback peers only.