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
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>
471 lines
19 KiB
Rust
471 lines
19 KiB
Rust
//! 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 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 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"
|
||
);
|
||
}
|
||
}
|