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
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:
+224
-1
@@ -913,8 +913,19 @@
|
|||||||
"library"
|
"library"
|
||||||
],
|
],
|
||||||
"summary": "List the game 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",
|
"operationId": "getLibrary",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "provider",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Only entries owned by this external provider",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Unified library across all stores",
|
"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": {
|
"/api/v1/local/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2602,6 +2753,13 @@
|
|||||||
"art": {
|
"art": {
|
||||||
"$ref": "#/components/schemas/Artwork"
|
"$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": {
|
"id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
"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`])."
|
"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": {
|
"title": {
|
||||||
"type": "string"
|
"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": {
|
"store": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
|
"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": {
|
"ReleaseDisplayRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Request body for `releaseDisplay`.",
|
"description": "Request body for `releaseDisplay`.",
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ pub struct GameEntry {
|
|||||||
/// How the host would launch it, when known.
|
/// How the host would launch it, when known.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub launch: Option<LaunchSpec>,
|
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
|
/// A store that contributes titles to the library. The trait is the extension point for future
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ pub struct CustomEntry {
|
|||||||
/// `undo` at session end in reverse order (see [`crate::hooks::run_prep`]).
|
/// `undo` at session end in reverse order (see [`crate::hooks::run_prep`]).
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub prep: Vec<crate::hooks::PrepCmd>,
|
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).
|
/// 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>,
|
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 {
|
impl From<CustomEntry> for GameEntry {
|
||||||
fn from(c: CustomEntry) -> Self {
|
fn from(c: CustomEntry) -> Self {
|
||||||
GameEntry {
|
GameEntry {
|
||||||
@@ -41,6 +67,7 @@ impl From<CustomEntry> for GameEntry {
|
|||||||
title: c.title,
|
title: c.title,
|
||||||
art: c.art,
|
art: c.art,
|
||||||
launch: c.launch,
|
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])
|
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> {
|
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||||
let mut entries = load_custom();
|
let mut entries = load_custom();
|
||||||
let entry = CustomEntry {
|
let entry = CustomEntry {
|
||||||
@@ -97,40 +134,156 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
|||||||
art: input.art,
|
art: input.art,
|
||||||
launch: input.launch,
|
launch: input.launch,
|
||||||
prep: input.prep,
|
prep: input.prep,
|
||||||
|
provider: None,
|
||||||
|
external_id: None,
|
||||||
};
|
};
|
||||||
entries.push(entry.clone());
|
entries.push(entry.clone());
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
emit_changed();
|
emit_changed("manual");
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace a custom entry's fields (id preserved). `None` ⇒ no entry with that id.
|
/// Replace a manual entry's fields (id preserved). Provider-owned entries are refused —
|
||||||
pub fn update_custom(id: &str, input: CustomInput) -> Result<Option<CustomEntry>> {
|
/// 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 mut entries = load_custom();
|
||||||
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
|
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.title = input.title;
|
||||||
slot.art = input.art;
|
slot.art = input.art;
|
||||||
slot.launch = input.launch;
|
slot.launch = input.launch;
|
||||||
slot.prep = input.prep;
|
slot.prep = input.prep;
|
||||||
let updated = slot.clone();
|
let updated = slot.clone();
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
emit_changed();
|
emit_changed("manual");
|
||||||
Ok(Some(updated))
|
Ok(MutateOutcome::Done(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a custom entry. `false` ⇒ no entry with that id.
|
/// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]).
|
||||||
pub fn delete_custom(id: &str) -> Result<bool> {
|
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 1–64 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 mut entries = load_custom();
|
||||||
let before = entries.len();
|
let before = entries.len();
|
||||||
entries.retain(|e| e.id != id);
|
entries.retain(|e| e.provider.as_deref() != Some(provider));
|
||||||
if entries.len() == before {
|
let removed = before - entries.len();
|
||||||
return Ok(false);
|
if removed > 0 {
|
||||||
}
|
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
emit_changed();
|
emit_changed(provider);
|
||||||
Ok(true)
|
}
|
||||||
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The prep/undo steps for a library id — `custom:<id>` entries only (the other stores have no
|
/// 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()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The custom-entry mutations are the only library writes today, all operator-driven — hence
|
/// Every library mutation announces itself (RFC §4): `source` is `"manual"` for the operator
|
||||||
/// `source: "manual"` (RFC §4; a provider id once the provider API of RFC §8 lands).
|
/// CRUD, the provider id for a reconcile/uninstall — hooks and the SDK filter on it.
|
||||||
fn emit_changed() {
|
fn emit_changed(source: &str) {
|
||||||
crate::events::emit(crate::events::EventKind::LibraryChanged {
|
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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
fn manual(id: &str, title: &str) -> CustomEntry {
|
||||||
fn custom_entry_maps_to_game_entry() {
|
CustomEntry {
|
||||||
let g: GameEntry = CustomEntry {
|
id: id.into(),
|
||||||
id: "abc123".into(),
|
title: title.into(),
|
||||||
title: "My ROM".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(),
|
art: Artwork::default(),
|
||||||
launch: None,
|
launch: None,
|
||||||
prep: Vec::new(),
|
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.id, "custom:abc123");
|
||||||
assert_eq!(g.store, "custom");
|
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()
|
app_name.clone()
|
||||||
};
|
};
|
||||||
Some(GameEntry {
|
Some(GameEntry {
|
||||||
|
provider: None,
|
||||||
id: format!("epic:{app_name}"),
|
id: format!("epic:{app_name}"),
|
||||||
store: "epic".into(),
|
store: "epic".into(),
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ fn gog_games() -> Vec<GameEntry> {
|
|||||||
// whatever it has cached (title-only until warmed).
|
// whatever it has cached (title-only until warmed).
|
||||||
let art = cached_art(&id).unwrap_or_default();
|
let art = cached_art(&id).unwrap_or_default();
|
||||||
out.push(GameEntry {
|
out.push(GameEntry {
|
||||||
|
provider: None,
|
||||||
id,
|
id,
|
||||||
store: "gog".into(),
|
store: "gog".into(),
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
|||||||
logo: http("art_logo"),
|
logo: http("art_logo"),
|
||||||
};
|
};
|
||||||
games.push(GameEntry {
|
games.push(GameEntry {
|
||||||
|
provider: None,
|
||||||
id: format!("heroic:{runner}:{app_name}"),
|
id: format!("heroic:{runner}:{app_name}"),
|
||||||
store: "heroic".into(),
|
store: "heroic".into(),
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
|||||||
let mut games = Vec::new();
|
let mut games = Vec::new();
|
||||||
for (id, slug, name) in rows.flatten() {
|
for (id, slug, name) in rows.flatten() {
|
||||||
games.push(GameEntry {
|
games.push(GameEntry {
|
||||||
|
provider: None,
|
||||||
id: format!("lutris:{id}"),
|
id: format!("lutris:{id}"),
|
||||||
store: "lutris".into(),
|
store: "lutris".into(),
|
||||||
title: name,
|
title: name,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ impl LibraryProvider for SteamProvider {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(appid, name)| !is_steam_tool(*appid, name))
|
.filter(|(appid, name)| !is_steam_tool(*appid, name))
|
||||||
.map(|(appid, title)| GameEntry {
|
.map(|(appid, title)| GameEntry {
|
||||||
|
provider: None,
|
||||||
id: format!("steam:{appid}"),
|
id: format!("steam:{appid}"),
|
||||||
store: "steam".into(),
|
store: "steam".into(),
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ fn xbox_games() -> Vec<GameEntry> {
|
|||||||
// background warmer; read whatever it has cached (title-only until warmed / if no StoreId).
|
// background warmer; read whatever it has cached (title-only until warmed / if no StoreId).
|
||||||
let art = cached_art(&id).unwrap_or_default();
|
let art = cached_art(&id).unwrap_or_default();
|
||||||
games.push(GameEntry {
|
games.push(GameEntry {
|
||||||
|
provider: None,
|
||||||
id,
|
id,
|
||||||
store: "xbox".into(),
|
store: "xbox".into(),
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -207,6 +207,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
library::update_custom_game,
|
library::update_custom_game,
|
||||||
library::delete_custom_game
|
library::delete_custom_game
|
||||||
))
|
))
|
||||||
|
.routes(routes!(
|
||||||
|
library::reconcile_provider_entries,
|
||||||
|
library::delete_provider_entries
|
||||||
|
))
|
||||||
.routes(routes!(library::get_library_art))
|
.routes(routes!(library::get_library_art))
|
||||||
.routes(routes!(stats::stats_capture_start))
|
.routes(routes!(stats::stats_capture_start))
|
||||||
.routes(routes!(stats::stats_capture_stop))
|
.routes(routes!(stats::stats_capture_stop))
|
||||||
|
|||||||
@@ -4,23 +4,39 @@
|
|||||||
use super::shared::*;
|
use super::shared::*;
|
||||||
use axum::http::header;
|
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
|
/// List the game library
|
||||||
///
|
///
|
||||||
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
|
/// 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
|
/// 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(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/library",
|
path = "/library",
|
||||||
tag = "library",
|
tag = "library",
|
||||||
operation_id = "getLibrary",
|
operation_id = "getLibrary",
|
||||||
|
params(
|
||||||
|
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"),
|
||||||
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
|
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
|
||||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn get_library() -> Json<Vec<crate::library::GameEntry>> {
|
pub(crate) async fn get_library(
|
||||||
Json(crate::library::all_games())
|
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
|
/// Add a custom library entry
|
||||||
@@ -75,9 +91,16 @@ pub(crate) async fn update_custom_game(
|
|||||||
if input.title.trim().is_empty() {
|
if input.title.trim().is_empty() {
|
||||||
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
||||||
}
|
}
|
||||||
|
use crate::library::MutateOutcome;
|
||||||
match crate::library::update_custom(&id, input) {
|
match crate::library::update_custom(&id, input) {
|
||||||
Ok(Some(entry)) => Json(entry).into_response(),
|
Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(),
|
||||||
Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"),
|
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()),
|
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 {
|
pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
|
||||||
|
use crate::library::MutateOutcome;
|
||||||
match crate::library::delete_custom(&id) {
|
match crate::library::delete_custom(&id) {
|
||||||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
Ok(MutateOutcome::Done(())) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"),
|
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()),
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1206,3 +1206,56 @@ async fn hooks_get_shape_and_put_validation() {
|
|||||||
let resp = app.clone().oneshot(req).await.expect("infallible");
|
let resp = app.clone().oneshot(req).await.expect("infallible");
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.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 |
|
| `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 |
|
| `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 |
|
| `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`
|
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
|
||||||
|
|||||||
@@ -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
@@ -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)
|
* 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
|
* 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
|
* @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({
|
export const GetLibraryResponseItem = S.Struct({
|
||||||
"art": S.Struct({
|
"art": S.Struct({
|
||||||
"header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }),
|
"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\"`.' }),
|
"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`).' })
|
"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.' }))),
|
}).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\"`.' }),
|
"store": S.String.annotations({ description: 'Which store surfaced it: `\"steam\"` or `\"custom\"`.' }),
|
||||||
"title": S.String
|
"title": S.String
|
||||||
}).annotations({ description: 'One title in the unified library, regardless of which store it came from.' })
|
}).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`).' }),
|
"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.' })
|
"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).' }),
|
})).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).' }),
|
"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({
|
"launch": S.optional(S.Union(S.Null, S.Struct({
|
||||||
"kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }),
|
"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).' }),
|
"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).' })
|
"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`]).' }),
|
}).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
|
"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.' })
|
}).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
|
* Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device
|
||||||
* names). Unauthenticated, but served to loopback peers only.
|
* names). Unauthenticated, but served to loopback peers only.
|
||||||
|
|||||||
Reference in New Issue
Block a user