Files
punktfunk/crates/punktfunk-host/src/library/custom.rs
T
enricobuehler 017b083e32
deb / build-publish-host (push) Successful in 9m32s
deb / build-publish (push) Successful in 12m29s
windows-host / package (push) Successful in 16m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m48s
arch / build-publish (push) Successful in 19m57s
android / android (push) Successful in 22m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m30s
ci / rust (push) Successful in 29m59s
docker / deploy-docs (push) Successful in 23s
ci / web (push) Successful in 1m0s
apple / swift (push) Successful in 1m17s
ci / docs-site (push) Successful in 1m22s
decky / build-publish (push) Successful in 43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / bench (push) Successful in 6m34s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
apple / screenshots (push) Successful in 6m25s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m27s
feat(library): serve provider entries' local art via the host art proxy
A provider plugin that runs on the host (e.g. the Playnite sync plugin) can
now reconcile cover art as an on-host FILE PATH instead of an inlined data
URL. `GET /library` rewrites such local-file art into `/library/art/<id>/
<kind>` proxy URLs (the same relative-proxy shape Steam art already uses),
and the art proxy serves the bytes from the stored path. Moonlight's
/appasset proxy reads the local file too.

This is what lets a large Playnite library sync with covers: the reconcile
payload carries paths, not bytes, so it no longer blows past the mgmt API's
request-body limit and scales to thousands of titles.

Cross-platform; local-path detection is Windows-shaped (Playnite is
Windows-only). Unit-tested (compiles + 5/5 art tests on Linux).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:53:40 +02:00

471 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! User-curated custom store: CRUD (add/update/delete) over the persisted custom entries the web
//! console manages, and their mapping onto the uniform `GameEntry`. Split out of the `library` facade (plan §W5).
use super::*;
/// A user-added title, persisted in the hardened host config dir's `library.json` (see
/// [`custom_path`]). Same shape the API returns and the web console edits.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CustomEntry {
/// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).
pub id: String,
pub title: String,
#[serde(default)]
pub art: Artwork,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
/// Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each
/// `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).
#[derive(Clone, Debug, Deserialize, ToSchema)]
pub struct CustomInput {
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>,
}
/// 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 {
id: format!("custom:{}", c.id),
store: "custom".into(),
title: c.title,
art: c.art,
launch: c.launch,
provider: c.provider,
}
}
}
fn custom_path() -> PathBuf {
// The shared, hardened host config dir (`%ProgramData%\punktfunk` / `~/.config/punktfunk`, with
// the `PUNKTFUNK_CONFIG_DIR` override) — NOT a bespoke XDG/HOME resolver with a CWD-relative
// fallback. This file drives operator `prep`/`launch` command execution, so it must live where the
// rest of the privileged host config does and be DACL/0600-locked against a non-privileged local
// user planting one (security-review 2026-07-17). Matches hooks.json / the mgmt token.
pf_paths::config_dir().join("library.json")
}
/// Load the custom entries (empty + non-fatal if the file is absent or malformed).
pub fn load_custom() -> Vec<CustomEntry> {
match std::fs::read_to_string(custom_path()) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
Vec::new()
}),
Err(_) => Vec::new(),
}
}
/// Serve a custom/provider entry's stored **local** art file for one [`ArtKind`] — the non-Steam
/// branch of the art proxy (`GET /library/art/custom:<id>/<kind>`). `id` is the bare custom id (the
/// `custom:` prefix already stripped by the handler). `None` if the entry is unknown, has no art of
/// that kind, or that art value isn't a servable local file (e.g. an `http` URL the client fetches
/// itself). Blocking IO — call off the async runtime.
pub fn custom_local_art_bytes(id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
let entry = load_custom().into_iter().find(|e| e.id == id)?;
let field = match kind {
ArtKind::Portrait => entry.art.portrait,
ArtKind::Hero => entry.art.hero,
ArtKind::Logo => entry.art.logo,
ArtKind::Header => entry.art.header,
}?;
is_local_art_path(&field)
.then(|| local_art_bytes(&field))
.flatten()
}
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
let dir = pf_paths::config_dir();
// Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a
// library.json whose `prep`/`launch` commands the host would later execute — the same trust
// boundary hooks.json and the mgmt token already use.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(entries)?;
// Write-then-rename so a crash mid-write never truncates the catalog; `write_secret_file` gives
// the temp file its restrictive perms (0600 / SYSTEM+Admins DACL) before the rename carries them
// to the final path.
let tmp = custom_path().with_extension("json.tmp");
pf_paths::write_secret_file(&tmp, json.as_bytes())
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, custom_path()).context("rename library.json")?;
Ok(())
}
/// 12 hex chars from the title + wall-clock nanos — collision-free in practice, no uuid dep.
fn new_id(title: &str) -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
}
/// 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 {
id: new_id(&input.title),
title: input.title,
art: input.art,
launch: input.launch,
prep: input.prep,
provider: None,
external_id: None,
};
entries.push(entry.clone());
save_custom(&entries)?;
emit_changed("manual");
Ok(entry)
}
/// 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(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("manual");
Ok(MutateOutcome::Done(updated))
}
/// 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.provider.as_deref() != Some(provider));
let removed = before - entries.len();
if removed > 0 {
save_custom(&entries)?;
emit_changed(provider);
}
Ok(removed)
}
/// The prep/undo steps for a library id — `custom:<id>` entries only (the other stores have no
/// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead).
pub fn prep_for(library_id: &str) -> Vec<crate::hooks::PrepCmd> {
let Some(id) = library_id.strip_prefix("custom:") else {
return Vec::new();
};
load_custom()
.into_iter()
.find(|e| e.id == id)
.map(|e| e.prep)
.unwrap_or_default()
}
/// 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: source.to_string(),
});
}
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
/// ([`windows_launch_for`]).
pub(crate) fn valid_steam_appid(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
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(),
}
}
#[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"
);
}
}