Merge pull request 'Library scanners sat in the nav and could not sync local art — and you can now hide one game' (#113) from worktree-plugin-nav-category-and-art into main
audit / bun-audit (plugin-kit) (push) Successful in 19s
apple / swift (push) Successful in 1m38s
audit / bun-audit (sdk) (push) Successful in 48s
audit / pnpm-audit (push) Successful in 11s
audit / docs-site-audit (push) Successful in 1m8s
audit / bun-audit (web) (push) Failing after 1m14s
apple / screenshots (push) Successful in 5m46s
ci / rust-arm64 (push) Successful in 4m32s
audit / license-gate (push) Successful in 5m12s
ci / bun-nix (push) Successful in 38s
arch / build-publish (push) Successful in 8m1s
ci / docs-site (push) Successful in 1m12s
ci / web (push) Successful in 1m28s
android / android (push) Successful in 9m3s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 33s
deb / build-publish-client-arm64 (push) Successful in 1m25s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 27s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 28s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
audit / cargo-audit (push) Failing after 10m5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 27s
ci / rust (push) Successful in 7m55s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
sdk-publish / publish (push) Failing after 31s
docker / builders-arm64cross (push) Successful in 11s
deb / build-publish-host (push) Successful in 4m20s
docker / deploy-docs (push) Successful in 35s
windows-host / package (push) Successful in 16m9s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 31s
deb / build-publish (push) Successful in 12m39s
nix / flake (push) Canceled after 14m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13m18s

Reviewed-on: #113
This commit was merged in pull request #113.
This commit is contained in:
2026-08-08 10:39:25 +00:00
19 changed files with 1016 additions and 88 deletions
+125 -4
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.24.0"
"version": "0.25.0"
},
"paths": {
"/api/v1/clients": {
@@ -997,7 +997,7 @@
"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). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
"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; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
"operationId": "getLibrary",
"parameters": [
{
@@ -1021,13 +1021,13 @@
],
"responses": {
"200": {
"description": "Unified library across all stores",
"description": "Unified library across all stores (the operator's lane also gets hidden entries, flagged)",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GameEntry"
"$ref": "#/components/schemas/OperatorGameEntry"
}
}
}
@@ -1301,6 +1301,79 @@
}
}
},
"/api/v1/library/hidden/{id}": {
"put": {
"tags": [
"library"
],
"summary": "Hide or un-hide one library title",
"description": "Curation, not access control: a hidden title disappears from every play surface — the console\ngrid on a client, native clients, the GameStream app list, and launch resolution — while nothing\nis deleted and un-hiding restores it immediately. The operator's own console still lists it\n(flagged `hidden`) so it can be brought back.\n\nKeyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by\nconstruction (D2). The id is **not** validated against the current library on purpose: a title\ncan be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),\nand refusing the operator's choice in that window would be worse than storing an id that\ncurrently matches nothing. Emits `library.changed` (source = the store) only on a real change.",
"operationId": "setLibraryEntryHidden",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The library entry id (e.g. `steam:70`)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HiddenToggle"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Stored; the entry's visibility after the call",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HiddenState"
}
}
}
},
"400": {
"description": "Empty entry 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 settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/provider/{provider}": {
"put": {
"tags": [
@@ -5553,6 +5626,37 @@
}
}
},
"HiddenState": {
"type": "object",
"description": "What `setLibraryEntryHidden` echoes back.",
"required": [
"id",
"hidden"
],
"properties": {
"hidden": {
"type": "boolean",
"description": "Its visibility after the call."
},
"id": {
"type": "string",
"description": "The entry id the call addressed."
}
}
},
"HiddenToggle": {
"type": "object",
"description": "Request body for `setLibraryEntryHidden`.",
"required": [
"hidden"
],
"properties": {
"hidden": {
"type": "boolean",
"description": "Whether this title should be hidden from every play surface."
}
}
},
"HookEntry": {
"type": "object",
"description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.",
@@ -6339,6 +6443,23 @@
}
}
},
"OperatorGameEntry": {
"allOf": [
{
"$ref": "#/components/schemas/GameEntry"
},
{
"type": "object",
"properties": {
"hidden": {
"type": "boolean",
"description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden."
}
}
}
],
"description": "A library entry plus the operator's own view of it — today, whether they hid it.\n\nA separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility\nanswer out of the providers entirely: a store parser has no opinion on what the operator hid, and\nadding `hidden: false` to all eight construction sites would imply it does. More importantly it\nmakes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers\n`Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired\nclient by someone forgetting a filter; there is no field there to leak.\n\n`flatten` keeps the wire shape identical to a plain entry with one extra key, so the console\nparses one model either way."
},
"PairedClient": {
"type": "object",
"description": "A paired (certificate-pinned) Moonlight client.",
+147
View File
@@ -29,6 +29,7 @@ mod epic;
mod gog;
#[cfg(target_os = "linux")]
mod heroic;
mod hidden;
mod launch;
#[cfg(target_os = "linux")]
mod lutris;
@@ -46,6 +47,7 @@ pub use epic::*;
pub use gog::*;
#[cfg(target_os = "linux")]
pub use heroic::*;
pub use hidden::*;
pub use launch::*;
#[cfg(target_os = "linux")]
pub use lutris::*;
@@ -195,6 +197,32 @@ pub struct GameEntry {
pub meta: GameMeta,
}
/// A library entry plus the operator's own view of it — today, whether they hid it.
///
/// A separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility
/// answer out of the providers entirely: a store parser has no opinion on what the operator hid, and
/// adding `hidden: false` to all eight construction sites would imply it does. More importantly it
/// makes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers
/// `Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired
/// client by someone forgetting a filter; there is no field there to leak.
///
/// `flatten` keeps the wire shape identical to a plain entry with one extra key, so the console
/// parses one model either way.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct OperatorGameEntry {
#[serde(flatten)]
pub entry: GameEntry,
/// The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only
/// grows for entries that actually are hidden.
#[serde(skip_serializing_if = "is_not_hidden")]
pub hidden: bool,
}
/// `skip_serializing_if` predicate for [`OperatorGameEntry::hidden`] — `&bool` as serde requires.
fn is_not_hidden(hidden: &bool) -> bool {
!*hidden
}
/// A store that contributes titles to the library. The trait is the extension point for future
/// launchers; today only [`SteamProvider`] implements it.
pub trait LibraryProvider {
@@ -268,7 +296,39 @@ impl ArtKind {
/// Removing the plugin releases the claim and the built-in comes straight back.
///
/// The user-curated custom store is not a source and always contributes.
///
/// A **third** gate rides on top of these two: the operator's per-entry hides (`hidden.rs`). It is
/// applied here rather than at each call site so a hidden title is gone from every surface by
/// construction — the grid, native clients, `/applist`, and launch resolution — exactly as a
/// disabled source's titles are. [`all_games_for_operator`] is the single deliberate exception.
pub fn all_games() -> Vec<GameEntry> {
let hidden = hidden_ids();
let mut games = collect_games();
games.retain(|g| !hidden.contains(&g.id));
games
}
/// The library **including** the operator's hidden titles, each flagged.
///
/// The console's list is the only caller, and only on the operator's own lane (`GET /library`
/// branches on it): a hidden entry has to be visible SOMEWHERE or it could never be brought back.
/// Everything else — every paired client, the GameStream app list, launch resolution — goes through
/// [`all_games`] and never sees them.
pub fn all_games_for_operator() -> Vec<OperatorGameEntry> {
let hidden = hidden_ids();
collect_games()
.into_iter()
.map(|entry| OperatorGameEntry {
hidden: hidden.contains(&entry.id),
entry,
})
.collect()
}
/// Merge every enabled source + the custom entries, sorted by title — with no visibility gate of its
/// own. Split out so the two public views above cannot drift: they differ only in what they do with
/// the hidden set, never in what they collect.
fn collect_games() -> Vec<GameEntry> {
let off = disabled_scanners();
let claimed = claimed_stores();
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
@@ -314,3 +374,90 @@ pub fn all_games() -> Vec<GameEntry> {
games.sort_by_key(|g| g.title.to_lowercase());
games
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str, title: &str) -> GameEntry {
GameEntry {
id: id.into(),
store: id.split_once(':').map_or("custom", |(s, _)| s).into(),
title: title.into(),
art: Artwork::default(),
role: GameRole::default(),
launch: None,
provider: None,
detect: DetectSpec::default(),
meta: GameMeta::default(),
}
}
/// The console codes against this shape, so pin it: the operator view must be a normal entry
/// with ONE extra key, and that key must vanish when the title is visible.
///
/// The skip matters beyond tidiness — it is what keeps this response byte-identical to the old
/// one for a library with nothing hidden, so shipping the feature cannot change what an existing
/// console renders until someone actually hides something.
#[test]
fn operator_entry_flattens_and_omits_hidden_when_false() {
let visible = OperatorGameEntry {
entry: entry("steam:70", "Half-Life"),
hidden: false,
};
let v = serde_json::to_value(&visible).expect("serializes");
assert_eq!(v["id"], "steam:70", "the entry's fields stay at top level");
assert_eq!(v["title"], "Half-Life");
assert!(
v.get("hidden").is_none(),
"a visible entry must not carry the key at all: {v}"
);
let hidden = OperatorGameEntry {
entry: entry("steam:70", "Half-Life"),
hidden: true,
};
let v = serde_json::to_value(&hidden).expect("serializes");
assert_eq!(v["hidden"], true);
assert_eq!(v["id"], "steam:70", "flatten still applies when hidden");
}
/// `all_games` and `all_games_for_operator` must agree on WHICH entries exist and differ only in
/// visibility — they share `collect_games` for exactly that reason. This pins the shared-source
/// property the same way the art test pins write/read symmetry: both views of an id-set built
/// from one collector, so a future edit that inlines one of them is caught.
#[test]
fn hidden_filter_is_the_only_difference_between_the_two_views() {
let games = vec![
entry("steam:70", "Half-Life"),
entry("lutris:4", "Syndicate"),
entry("custom:abc", "Chrono Trigger"),
];
let hidden: HashSet<String> = ["lutris:4".to_string()].into_iter().collect();
let operator: Vec<OperatorGameEntry> = games
.iter()
.cloned()
.map(|entry| OperatorGameEntry {
hidden: hidden.contains(&entry.id),
entry,
})
.collect();
let played: Vec<GameEntry> = games
.into_iter()
.filter(|g| !hidden.contains(&g.id))
.collect();
assert_eq!(operator.len(), 3, "the operator sees every title");
assert_eq!(played.len(), 2, "a player does not see the hidden one");
assert!(
!played.iter().any(|g| g.id == "lutris:4"),
"the hidden id must be absent, not merely flagged"
);
assert_eq!(
operator.iter().filter(|r| r.hidden).count(),
1,
"exactly the hidden one is flagged"
);
}
}
+99 -1
View File
@@ -350,8 +350,21 @@ fn sniff_image_type(bytes: &[u8]) -> Option<&'static str> {
/// write-time half of the art confinement — [`validate_art_paths`] refuses to persist a value this
/// rejects, so an out-of-root path never reaches the catalog in the first place, and
/// [`local_art_bytes`] re-checks at read time so an entry written before this existed is still safe.
///
/// A `file://` value is decoded to a plain path FIRST, exactly as [`local_art_bytes`] does. Both
/// halves of the confinement must judge the *same* string or they disagree: `Path::new` on a raw
/// `file:///home/u/c.jpg` yields a RELATIVE path whose first component is `file:`, which
/// canonicalizes against the cwd, fails, and reads as "outside every root". That is not a
/// conservative failure — it rejected every `file://` cover the plugin kit emits (`fileUrl`, the
/// documented way for a library plugin to publish local art), so the Lutris and Steam scanners
/// could not reconcile a single entry while the read path would have served those same files
/// happily.
pub fn art_path_is_servable(value: &str) -> bool {
let p = Path::new(value);
// Idempotent for the already-decoded caller: the decoded form no longer carries the prefix,
// so `local_art_bytes` passing its own output back through here is a no-op, not a second
// percent-decode of a path that legitimately contains `%`.
let value = file_url_to_path(value);
let p = Path::new(&*value);
let ext_ok = p
.extension()
.and_then(|e| e.to_str())
@@ -699,11 +712,23 @@ mod tests {
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` is process-global while cargo runs tests as threads, so the
/// tests that repoint it must not overlap — one clearing the variable mid-flight makes the
/// other's temp root stop being a root, which fails as a confinement bug that isn't there.
/// Poisoning is recovered rather than propagated: a panic in one test should report ITS
/// failure, not cascade into an unrelated `PoisonError`.
static ART_ROOTS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn lock_art_roots() -> std::sync::MutexGuard<'static, ()> {
ART_ROOTS_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
/// plugin lane can write — so what it will and will not read IS the security boundary
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
#[test]
fn local_art_bytes_is_confined_and_image_only() {
let _guard = lock_art_roots();
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
@@ -837,6 +862,79 @@ mod tests {
);
}
/// The write gate and the read gate must judge the SAME string.
///
/// Regression for 2026-08-08: `validate_art_paths` handed the raw value to `Path::new`, so a
/// `file:///…` cover became a *relative* path starting with a `file:` component, canonicalized
/// against the cwd, failed, and was refused as "outside every art root" — while
/// `local_art_bytes` decoded the very same value and served the file. Every Lutris and Steam
/// entry carrying local art was rejected with a 400 the plugin could only report as
/// `HostRequestError`, so neither scanner could sync a single game. Asserting servable and
/// readable together is the point: either alone passes with the bug present.
#[test]
fn file_url_art_is_accepted_at_write_time_exactly_as_at_read_time() {
let _guard = lock_art_roots();
let dir = std::env::temp_dir().join(format!("pf-art-wr-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-wr-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
// What the kit's `fileUrl` actually emits for a Lutris/Steam cover.
let url = file_url(&cover);
assert!(
is_local_art_path(&url),
"a file:// value is local art, so the confinement applies to it"
);
assert!(
art_path_is_servable(&url),
"write time must accept the file:// form of a servable cover"
);
assert!(
validate_art_paths(&Artwork {
portrait: Some(url.clone()),
header: Some(url),
..Default::default()
})
.is_ok(),
"a real Lutris-shaped payload must reconcile"
);
// A percent-encoded name (the reason the decode exists at all) survives the round trip.
let spaced = dir.join("My Cover.png");
std::fs::write(&spaced, PNG).unwrap();
let spaced_url = file_url(&spaced).replace(' ', "%20");
assert!(
art_path_is_servable(&spaced_url),
"percent-encoded names must decode before the containment test: {spaced_url}"
);
assert!(local_art_bytes(&spaced_url).is_some(), "read time agrees");
// Loosening the write gate must not loosen the confinement: outside the root is still
// refused in file:// clothing, which is what the raw-string bug was accidentally doing.
let elsewhere = outside.join("cover.png");
std::fs::write(&elsewhere, PNG).unwrap();
assert!(
!art_path_is_servable(&file_url(&elsewhere)),
"file:// must not escape the art roots at write time either"
);
assert!(
validate_art_paths(&Artwork {
portrait: Some(file_url(&elsewhere)),
..Default::default()
})
.is_err(),
"an out-of-root file:// cover is still refused"
);
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
#[test]
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
assert_eq!(sniff_image_type(PNG), Some("image/png"));
+141
View File
@@ -0,0 +1,141 @@
//! Per-entry visibility: the operator hides one *title*, where `scanners.rs` hides a whole source.
//!
//! **Why this is a side table and not a field on the entry.** Only manual custom entries are stored;
//! a scanner's and a plugin's titles are regenerated from scratch on every scan and every reconcile.
//! A `hidden` flag written onto one of those would be erased by the next sync — silently, and
//! minutes later, which is the worst possible shape for a setting. So the operator's choice lives
//! here, keyed by the entry's stable `<store>:<external_id>` id, and the entries stay disposable.
//!
//! That id is stable *by construction* (design D2): a claimed store's entries keep
//! `<store>:<external_id>` across reconciles no matter what the host-assigned id does, which is the
//! same property GameStream app ids and client art caches already depend on. Hiding therefore
//! survives a re-scan, a plugin restart, and the built-in→plugin migration for a store.
//!
//! Hiding is **curation, not access control** — it declutters a grid. It is applied in
//! [`all_games`](crate::library::all_games), so a hidden title is gone from every play surface
//! *including* launch resolution (the same reach a disabled scanner has), but nothing is deleted and
//! un-hiding is immediate. The console is the one surface that still sees hidden titles — otherwise
//! there would be no way to un-hide one — and only on the operator's own lane.
use super::*;
/// Persisted shape (`library-hidden.json`): the ids the operator hid. Absent file = nothing hidden.
///
/// Mirrors `library-scanners.json`'s disabled-set rather than sharing it: that file answers "which
/// SOURCES run", this one answers "which TITLES show", and a source id (`steam`) and an entry id
/// (`steam:70`) are different namespaces. Keeping them apart means neither migration can corrupt the
/// other, and an operator reading either file sees one idea.
#[derive(Debug, Default, Serialize, Deserialize)]
struct HiddenSettings {
#[serde(default)]
hidden: Vec<String>,
}
fn settings_path() -> PathBuf {
// Same hardened config dir as library.json / library-scanners.json.
pf_paths::config_dir().join("library-hidden.json")
}
/// Load the hidden set (default + non-fatal if the file is absent or malformed).
///
/// A malformed file means "nothing hidden", never "hide everything": the failure mode of a bad parse
/// must be a library that shows too much, not one that looks empty and reads as data loss.
fn load_settings() -> HiddenSettings {
match std::fs::read_to_string(settings_path()) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "library-hidden.json malformed — nothing hidden");
HiddenSettings::default()
}),
Err(_) => HiddenSettings::default(),
}
}
fn save_settings(settings: &HiddenSettings) -> Result<()> {
let dir = pf_paths::config_dir();
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(settings)?;
// Write-then-rename like the catalog, so a crash mid-write never truncates the settings.
let tmp = settings_path().with_extension("json.tmp");
pf_paths::write_secret_file(&tmp, json.as_bytes())
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, settings_path()).context("rename library-hidden.json")?;
Ok(())
}
/// The hidden entry ids, loaded once per library read.
pub(crate) fn hidden_ids() -> HashSet<String> {
load_settings().hidden.into_iter().collect()
}
/// The store half of a library id (`steam:70` → `steam`), for the `library.changed` source.
///
/// Falls back to the whole id rather than an empty string: an id without a `:` is not a shape this
/// host produces, and naming it in the event beats emitting a blank source that matches no cache key.
fn store_of(id: &str) -> &str {
id.split_once(':').map_or(id, |(store, _)| store)
}
/// Hide or un-hide one entry. Returns whether the entry is hidden **after** the call.
///
/// Idempotent, and deliberately not validated against the current library: an entry can be absent
/// right now for reasons that have nothing to do with the operator's intent — the launcher is closed,
/// a plugin has not finished its first sync, a disk is unmounted. Refusing to hide a title that is
/// temporarily missing, or silently dropping the choice when it comes back, would both be worse than
/// storing an id that currently matches nothing. Persists and emits `library.changed` only when the
/// state actually changed, so a repeated PUT is a cheap no-op.
pub fn set_entry_hidden(id: &str, hidden: bool) -> Result<bool> {
let mut settings = load_settings();
let was_hidden = settings.hidden.iter().any(|h| h == id);
if was_hidden == hidden {
return Ok(hidden);
}
if hidden {
settings.hidden.push(id.to_string());
settings.hidden.sort();
settings.hidden.dedup();
} else {
settings.hidden.retain(|h| h != id);
}
save_settings(&settings)?;
crate::events::emit(crate::events::EventKind::LibraryChanged {
source: store_of(id).to_string(),
});
Ok(hidden)
}
#[cfg(test)]
mod tests {
use super::*;
/// The event source is the STORE, not the whole id — that is the key every client cache and the
/// console's query invalidation is grouped by.
#[test]
fn store_of_takes_the_prefix_and_tolerates_a_bare_id() {
assert_eq!(store_of("steam:70"), "steam");
assert_eq!(store_of("custom:abc"), "custom");
// An external id may itself contain a colon (Heroic's `legendary:<hash>`): split on the
// FIRST one, or the store would come back wrong for exactly the store that does this.
assert_eq!(store_of("heroic:legendary:fc0b13b7"), "heroic");
assert_eq!(store_of("weird-no-colon"), "weird-no-colon");
}
/// A malformed settings file must read as "nothing hidden". The inverse — treating a parse
/// failure as "hide everything" — would present as a library that lost its games.
#[test]
fn malformed_settings_hide_nothing() {
let s: HiddenSettings = serde_json::from_str("{ not json").unwrap_or_default();
assert!(s.hidden.is_empty());
let s: HiddenSettings = serde_json::from_str("{}").expect("an empty object is valid");
assert!(s.hidden.is_empty(), "absent key means nothing hidden");
}
/// The persisted shape is the contract an operator may hand-edit — pin it.
#[test]
fn settings_roundtrip_the_documented_shape() {
let s: HiddenSettings =
serde_json::from_str(r#"{"hidden":["steam:70","lutris:4"]}"#).expect("parses");
assert_eq!(s.hidden, vec!["steam:70", "lutris:4"]);
let json = serde_json::to_string(&s).expect("serializes");
assert_eq!(json, r#"{"hidden":["steam:70","lutris:4"]}"#);
}
}
+1
View File
@@ -228,6 +228,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(library::get_library))
.routes(routes!(library::list_library_scanners))
.routes(routes!(library::set_library_scanner))
.routes(routes!(library::set_library_entry_hidden))
.routes(routes!(library::create_custom_game))
.routes(routes!(
library::update_custom_game,
+12
View File
@@ -51,6 +51,18 @@ impl AuthLane {
pub(crate) fn may_set_privileged_fields(self) -> bool {
matches!(self, AuthLane::Admin)
}
/// Whether this is the operator's own lane — the console, as opposed to a paired client or a
/// plugin.
///
/// Same arm as [`may_set_privileged_fields`](Self::may_set_privileged_fields) today, and
/// deliberately a separate question: that one asks "may this caller cause command execution",
/// this one asks "is this caller the person curating the library". A read-only view the operator
/// alone should see (their hidden titles) is not a privilege escalation, and collapsing the two
/// would leave whichever one changes first silently answering for the other.
pub(crate) fn is_operator(self) -> bool {
matches!(self, AuthLane::Admin)
}
}
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
+140 -37
View File
@@ -14,33 +14,42 @@ use axum::Extension;
/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's
/// authority alone. Route reachability and field authority are separate questions.
///
/// `Some(response)` is the refusal to return; `None` means the payload may proceed. Deliberately
/// not `Result<(), Response>`: the "error" here IS the response the handler sends, so there is no
/// error value to propagate, and a 128-byte `Response` in an `Err` variant is what
/// `Some((reason, response))` is the refusal to return; `None` means the payload may proceed.
/// Deliberately not `Result<(), Response>`: the "error" here IS the response the handler sends, so
/// there is no error value to propagate, and a 128-byte `Response` in an `Err` variant is what
/// `clippy::result_large_err` objects to.
///
/// `reason` is the caller's log line. It exists because these are TWO different refusals — an
/// operator-privileged field (403) and an unservable art path (400) — and logging both as "carries
/// a field this lane may not set" sent the Lutris/Steam `file://` art rejection looking like an
/// auth problem. The plugin only ever sees `HostRequestError`, so this log line is the sole
/// diagnosis surface for whoever has to explain why a scanner syncs nothing.
fn check_entry_fields(
lane: AuthLane,
art: &crate::library::Artwork,
launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
) -> Option<Response> {
) -> Option<(String, Response)> {
if !lane.may_set_privileged_fields() {
if let Some(field) = crate::library::privileged_field(launch, prep) {
return Some(api_error(
StatusCode::FORBIDDEN,
&format!(
"`{field}` is executed as the host user and may only be set with the \
operator's admin token a plugin may publish entries with any host-resolved \
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, xbox, lutris_id, \
heroic, playnite) \
instead"
return Some((
format!("payload carries `{field}`, which this lane may not set"),
api_error(
StatusCode::FORBIDDEN,
&format!(
"`{field}` is executed as the host user and may only be set with the \
operator's admin token a plugin may publish entries with any host-resolved \
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, xbox, lutris_id, \
heroic, playnite) \
instead"
),
),
));
}
}
crate::library::validate_art_paths(art)
.err()
.map(|e| api_error(StatusCode::BAD_REQUEST, &e))
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
}
#[derive(Deserialize)]
@@ -58,6 +67,10 @@ pub(crate) struct LibraryQuery {
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
/// entries a given external provider owns; `?platform=` to one platform (case-insensitive —
/// installed-store titles are `PC`, custom/provider entries carry whatever was authored).
///
/// **The operator's own lane additionally sees the titles they have HIDDEN**, each carrying
/// `hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The
/// console needs them to offer "un-hide", and it is the only surface that does.
#[utoipa::path(
get,
path = "/library",
@@ -68,26 +81,28 @@ pub(crate) struct LibraryQuery {
("platform" = Option<String>, Query, description = "Only entries on this platform (case-insensitive, e.g. `PS2`)"),
),
responses(
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
(status = OK, description = "Unified library across all stores (the operator's lane also gets hidden entries, flagged)", body = [crate::library::OperatorGameEntry]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_library(
Extension(lane): Extension<AuthLane>,
Query(q): Query<LibraryQuery>,
) -> Json<Vec<crate::library::GameEntry>> {
) -> Response {
// The operator's list is a DIFFERENT TYPE, not the same one with a flag set — which is what
// makes "a hidden title never reaches a paired client" structural rather than a filter someone
// has to remember. The redaction below is skipped here because this arm is the operator's own
// token: the command line being redacted is the one they typed.
if lane.is_operator() {
let mut rows = crate::library::all_games_for_operator();
rows.retain(|r| matches_query(&r.entry, &q));
for r in &mut rows {
crate::library::proxy_local_art(&r.entry.id, &mut r.entry.art);
}
return Json(rows).into_response();
}
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()));
}
if let Some(platform) = q.platform.filter(|p| !p.is_empty()) {
games.retain(|g| {
g.meta
.platform
.as_deref()
.is_some_and(|p| p.eq_ignore_ascii_case(&platform))
});
}
games.retain(|g| matches_query(g, &q));
// Rewrite provider entries' local-file art into host art-proxy URLs so a client fetches covers
// from the host (a provider like Playnite stores on-host paths; the payload stays tiny at any
// library size, and the client never sees an unreachable `C:\…`).
@@ -103,16 +118,97 @@ pub(crate) async fn get_library(
// a client picks a title by ID and the host resolves the recipe itself (`resolve_launch`),
// which is the invariant that stops a client injecting a command in the first place. The
// `kind` stays, so "this is launchable, and how" still renders.
if !lane.may_set_privileged_fields() {
for g in &mut games {
if let Some(l) = g.launch.as_mut() {
if l.kind == "command" {
l.value.clear();
}
//
// Unconditional now: the operator's lane returned above, so reaching here IS "some lane but
// theirs". Leaving the old `if !lane.may_set_privileged_fields()` would read as though an
// unredacted path still existed here, and would quietly stop redacting if that early return
// ever moved.
for g in &mut games {
if let Some(l) = g.launch.as_mut() {
if l.kind == "command" {
l.value.clear();
}
}
}
Json(games)
Json(games).into_response()
}
/// The `?provider=` / `?platform=` narrowing, shared by both lane arms so they cannot drift.
fn matches_query(g: &crate::library::GameEntry, q: &LibraryQuery) -> bool {
if let Some(provider) = q.provider.as_deref().filter(|p| !p.is_empty()) {
if g.provider.as_deref() != Some(provider) {
return false;
}
}
if let Some(platform) = q.platform.as_deref().filter(|p| !p.is_empty()) {
if !g
.meta
.platform
.as_deref()
.is_some_and(|p| p.eq_ignore_ascii_case(platform))
{
return false;
}
}
true
}
/// Request body for `setLibraryEntryHidden`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct HiddenToggle {
/// Whether this title should be hidden from every play surface.
hidden: bool,
}
/// What `setLibraryEntryHidden` echoes back.
#[derive(Serialize, ToSchema)]
pub(crate) struct HiddenState {
/// The entry id the call addressed.
id: String,
/// Its visibility after the call.
hidden: bool,
}
/// Hide or un-hide one library title
///
/// Curation, not access control: a hidden title disappears from every play surface — the console
/// grid on a client, native clients, the GameStream app list, and launch resolution — while nothing
/// is deleted and un-hiding restores it immediately. The operator's own console still lists it
/// (flagged `hidden`) so it can be brought back.
///
/// Keyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by
/// construction (D2). The id is **not** validated against the current library on purpose: a title
/// can be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),
/// and refusing the operator's choice in that window would be worse than storing an id that
/// currently matches nothing. Emits `library.changed` (source = the store) only on a real change.
#[utoipa::path(
put,
path = "/library/hidden/{id}",
tag = "library",
operation_id = "setLibraryEntryHidden",
params(("id" = String, Path, description = "The library entry id (e.g. `steam:70`)")),
request_body = HiddenToggle,
responses(
(status = OK, description = "Stored; the entry's visibility after the call", body = HiddenState),
(status = BAD_REQUEST, description = "Empty entry id", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the settings", body = ApiError),
)
)]
pub(crate) async fn set_library_entry_hidden(
Path(id): Path<String>,
ApiJson(toggle): ApiJson<HiddenToggle>,
) -> Response {
if id.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "entry id must not be empty");
}
match crate::library::set_entry_hidden(&id, toggle.hidden) {
Ok(hidden) => {
tracing::info!(entry = %id, hidden, "management API: library entry visibility set");
Json(HiddenState { id, hidden }).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Request body for `setLibraryScanner`.
@@ -205,7 +301,9 @@ pub(crate) async fn create_custom_game(
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
if let Some((_, denied)) =
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep)
{
return denied;
}
match crate::library::add_custom(input) {
@@ -238,7 +336,9 @@ pub(crate) async fn update_custom_game(
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
if let Some((_, denied)) =
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep)
{
return denied;
}
use crate::library::MutateOutcome;
@@ -364,11 +464,14 @@ pub(crate) async fn reconcile_provider_entries(
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so
// one privileged field anywhere in it is one command execution.
for (i, e) in inputs.iter().enumerate() {
if let Some(denied) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep) {
if let Some((reason, denied)) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep)
{
tracing::warn!(
provider,
index = i,
"library reconcile refused: payload carries a field this lane may not set"
title = %e.title,
reason = %reason,
"library reconcile refused"
);
return denied;
}
+38
View File
@@ -1198,6 +1198,10 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
("GET", "/api/v1/library/art/{id}/{kind}", true, true),
("GET", "/api/v1/library/scanners", true, false),
("PUT", "/api/v1/library/scanners/{id}", true, false),
// Hiding a title is the OPERATOR curating their own library: a plugin has no business
// deciding what the operator sees, and a paired client must not be able to hide a game on
// the host it is streaming from. Neither lane, unlike the scanner toggle above.
("PUT", "/api/v1/library/hidden/{id}", false, false),
("POST", "/api/v1/library/custom", true, false),
("PUT", "/api/v1/library/custom/{id}", true, false),
("DELETE", "/api/v1/library/custom/{id}", true, false),
@@ -2048,6 +2052,40 @@ async fn library_scanner_list_and_unknown_toggle() {
);
}
/// A library id is `<store>:<external_id>`, so the hide route's path segment CONTAINS A COLON —
/// and for Heroic (`heroic:legendary:<hash>`) it contains two.
///
/// This is the one thing about the endpoint that could be silently wrong: if the router did not
/// match, or split on the colon, the console's hide button would 404 against an id the host itself
/// produced. Asserting "not 404" is the whole point, so the body is deliberately INVALID — that
/// stops at the JSON layer with a 4xx and never reaches the handler, which would otherwise write
/// `library-hidden.json` into the developer's real config dir (the same reason the toggle test
/// above only exercises its rejection path).
#[tokio::test]
async fn hide_route_matches_ids_containing_colons() {
let app = test_app(test_state(), None);
let put = |id: &str| {
axum::http::Request::put(format!("/api/v1/library/hidden/{id}"))
.header(axum::http::header::CONTENT_TYPE, "application/json")
// Not a `HiddenToggle` — rejected before the handler runs.
.body(Body::from(serde_json::json!({"nope": 1}).to_string()))
.unwrap()
};
for id in ["steam:70", "custom:abc", "heroic:legendary:fc0b13b7"] {
let (s, json) = send(&app, put(id)).await;
assert_ne!(
s,
StatusCode::NOT_FOUND,
"`{id}` must ROUTE — a colon is a legal path character and every library id has one: {json}"
);
assert!(
s.is_client_error(),
"a body that is not a HiddenToggle must be refused, not accepted: {s} {json}"
);
}
}
// ------------------------------------------------------------------ library providers
/// Provider reconcile validation (the write path itself is unit-tested in `library::custom`
+1 -1
View File
@@ -13,7 +13,7 @@
"typescript": "^5.9.3",
},
"peerDependencies": {
"@punktfunk/host": "^0.1.2",
"@punktfunk/host": "^0.1.3",
"effect": "^4.0.0-beta.98",
"react": "^19.2.0",
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-kit",
"version": "0.3.2",
"version": "0.3.3",
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
"type": "module",
"license": "MIT OR Apache-2.0",
@@ -56,7 +56,7 @@
},
"peerDependencies": {
"effect": "^4.0.0-beta.98",
"@punktfunk/host": "^0.1.2",
"@punktfunk/host": "^0.1.3",
"react": "^19.2.0"
},
"peerDependenciesMeta": {
+36 -2
View File
@@ -3,12 +3,46 @@
// Schema-based errors with status annotations.
import { Data } from "effect";
/** A management-API call through the pf facade failed. */
/**
* A management-API call through the pf facade failed.
*
* The `message` getter is load-bearing, not decoration. `Data.TaggedError`'s default string form is
* the bare tag, and the sync engine logs `sync (${reason}) failed: ${e.cause}` so a host that
* refused a reconcile with a perfectly clear 400 surfaced in the plugin log as exactly
* `sync (startup) failed: HostRequestError`, with the method, the path and the host's own
* explanation all discarded. Diagnosing the 2026-08-08 Lutris/Steam art rejection meant reading the
* HOST's journal instead, because the plugin's own log could not distinguish a validation refusal
* from the host being down.
*/
export class HostRequestError extends Data.TaggedError("HostRequestError")<{
readonly method: string;
readonly path: string;
readonly cause: unknown;
}> {}
}> {
override get message(): string {
return `${this.method} ${this.path} failed: ${describeCause(this.cause)}`;
}
}
/**
* Render whatever `pf.request` rejected with into one line.
*
* An `Error` stringifies usefully already; a plain object (the host's `{error: "…"}` body, which is
* what a rejected reconcile actually carries) stringifies to `[object Object]`, which is how the
* useful half of the message got lost. JSON is the fallback so a body-shaped cause survives, and a
* cycle or a BigInt degrades to `String(cause)` rather than throwing inside error formatting.
*/
const describeCause = (cause: unknown): string => {
if (cause instanceof Error) return cause.message;
if (typeof cause === "object" && cause !== null) {
try {
return JSON.stringify(cause);
} catch {
return String(cause);
}
}
return String(cause);
};
/** config.json exists but does not parse/decode. */
export class ConfigParseError extends Data.TaggedError("ConfigParseError")<{
+56 -2
View File
@@ -7,7 +7,11 @@ import { Effect, FileSystem, Layer, Path, Schema, type Scope } from "effect";
import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http";
import type { ConfigService } from "./config.js";
import { UiServeError } from "./errors.js";
import { HostClient, PluginInfo } from "./host-client.js";
import {
HostClient,
type HostClientService,
PluginInfo,
} from "./host-client.js";
/**
* Everything `HttpApiBuilder.layer` needs beyond the router, satisfied from effect core
@@ -192,7 +196,7 @@ export const serveUi = (
return handler(req);
};
return yield* Effect.acquireRelease(
const handle = yield* Effect.acquireRelease(
Effect.tryPromise({
try: () =>
servePluginUi(host.facade, {
@@ -212,4 +216,54 @@ export const serveUi = (
}),
(handle) => Effect.promise(() => handle.close()).pipe(Effect.ignore),
);
yield* verifyCategoryLanded(opts.category, info.name, host);
return handle;
});
/**
* Read our own directory entry back and warn if the requested `category` is not on it.
*
* `category` travels through the UNTYPED `pf.request` seam precisely so an older host ignores it
* instead of rejecting the registration which means dropping it is SILENT by design, at three
* different layers (an old host, an old runner-resolved SDK, a typo). On 2026-08-08 the middle one
* happened: `@punktfunk/host@0.1.2` was published before it forwarded the field, so every installed
* library scanner registered without a category. The visible result was Lutris and Heroic sitting in
* the console nav which they explicitly opt out of and their settings unreachable, because the
* Library section's Game sources surface lists exactly the plugins whose category IS `library`.
* Nothing logged anything.
*
* So this asks the host what it actually recorded. Same spirit as the store-claim degradation
* warning in `defineLibraryPlugin`: turn a silent no-op into one line that names the fix. Purely
* advisory a failed read, or a host too old to report the field, must never keep a working plugin
* from starting.
*/
const verifyCategoryLanded = (
category: string | undefined,
id: string,
host: { readonly request: HostClientService["request"] },
): Effect.Effect<void> => {
if (category === undefined) return Effect.void;
return host.request("GET", "/plugins").pipe(
Effect.flatMap((body) => {
const mine = (Array.isArray(body) ? body : []).find(
(p): p is { id: string; category?: string } =>
typeof p === "object" &&
p !== null &&
(p as { id?: unknown }).id === id,
);
// Not finding ourselves is not evidence of anything: the lease is registered
// best-effort, so a host that was momentarily away simply has not listed us yet.
if (!mine || mine.category === category) return Effect.void;
return Effect.logWarning(
`registered without category "${category}" (the host reports ` +
`${mine.category === undefined ? "none" : `"${mine.category}"`}). ` +
`This plugin will appear in the console's sidebar instead of its intended ` +
`section. The usual cause is an @punktfunk/host older than 0.1.3, which drops ` +
`the field before registering — update it, or the host, to resolve it.`,
);
}),
// Advisory only: never let a diagnostic take down the plugin it is diagnosing.
Effect.ignore,
);
};
+64
View File
@@ -0,0 +1,64 @@
// What a kit error says when something interpolates it — which is the whole diagnosis surface a
// plugin operator gets, because `sync-engine`'s failure path logs `${e.cause}` and nothing else.
import { describe, expect, test } from "bun:test";
import { HostRequestError } from "../src/errors.js";
describe("HostRequestError", () => {
// Regression for 2026-08-08: this printed the bare tag, so `plugin:lutris sync (startup)
// failed: HostRequestError` was the ENTIRE record of a host that had answered with a precise
// 400. Interpolation is the assertion because interpolation is what the sync engine does.
test("names the call and carries the host's explanation", () => {
const err = new HostRequestError({
method: "PUT",
path: "/library/provider/lutris?store=lutris",
cause: new Error("art.portrait: local art must be an image file"),
});
expect(`${err}`).toContain("PUT");
expect(`${err}`).toContain("/library/provider/lutris?store=lutris");
expect(`${err}`).toContain("art.portrait");
expect(`${err}`).not.toBe("HostRequestError");
});
// The host's rejection arrives as a parsed `{error: "…"}` body, not an Error. Left to default
// stringification that is `[object Object]` — the useful half lost a second way.
test("renders an object cause instead of [object Object]", () => {
const err = new HostRequestError({
method: "PUT",
path: "/library/provider/steam",
cause: { error: "art.header: local art must be an image file" },
});
expect(`${err}`).toContain("art.header");
expect(`${err}`).not.toContain("[object Object]");
});
// Error formatting must never itself throw: a cycle (or a BigInt) would make JSON.stringify
// blow up INSIDE the catch that is trying to report the original failure.
test("survives a cause that cannot be serialized", () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
const err = new HostRequestError({
method: "GET",
path: "/library",
cause: cyclic,
});
expect(() => `${err}`).not.toThrow();
expect(`${err}`).toContain("/library");
});
// The tag stays matchable — `Effect.catchTag`/`_tag` narrowing must not be traded away for a
// readable message.
test("keeps its tag and its fields", () => {
const err = new HostRequestError({
method: "DELETE",
path: "/library/provider/heroic",
cause: "boom",
});
expect(err._tag).toBe("HostRequestError");
expect(err.method).toBe("DELETE");
expect(err.path).toBe("/library/provider/heroic");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/host",
"version": "0.1.2",
"version": "0.1.3",
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
"type": "module",
"license": "MIT OR Apache-2.0",
+4
View File
@@ -51,6 +51,7 @@
"automation_confirm_title": "Automatisierung speichern?",
"automation_confirm_body": "Diese Befehle laufen auf diesem Rechner als Host-Benutzer, sobald ihr Ereignis eintritt. Bestätige mit dem Konsolen-Passwort.",
"library_delete_failed": "Dieser Eintrag konnte nicht gelöscht werden.",
"library_hide_failed": "Die Sichtbarkeit dieses Titels konnte nicht geändert werden.",
"gpu_apply_failed": "Die GPU-Auswahl konnte nicht geändert werden.",
"stats_start_failed": "Die Aufzeichnung konnte nicht gestartet werden.",
"stats_stop_failed": "Die Aufzeichnung konnte nicht gestoppt werden — sie wurde womöglich nicht gespeichert.",
@@ -336,6 +337,9 @@
"library_cancel": "Abbrechen",
"library_edit": "Bearbeiten",
"library_delete": "Löschen",
"library_hide_action": "Auf deinen Geräten ausblenden",
"library_unhide_action": "Auf deinen Geräten wieder anzeigen",
"library_hidden_badge": "Ausgeblendet",
"library_delete_confirm": "Dieses eigene Spiel löschen?",
"library_delete_body": "Das kann nicht rückgängig gemacht werden.",
"settings_title": "Einstellungen",
+4
View File
@@ -41,6 +41,7 @@
"automation_confirm_title": "Save automation?",
"automation_confirm_body": "These commands run on this machine, as the host user, whenever their event fires. Confirm with the console password.",
"library_delete_failed": "Could not delete this entry.",
"library_hide_failed": "Could not change this title's visibility.",
"gpu_apply_failed": "Could not change the GPU preference.",
"stats_start_failed": "Could not start the capture.",
"stats_stop_failed": "Could not stop the capture — it may not have been saved.",
@@ -336,6 +337,9 @@
"library_cancel": "Cancel",
"library_edit": "Edit",
"library_delete": "Delete",
"library_hide_action": "Hide from your devices",
"library_unhide_action": "Show on your devices again",
"library_hidden_badge": "Hidden",
"library_delete_confirm": "Delete this custom game?",
"library_delete_body": "This can't be undone.",
"settings_title": "Settings",
+83 -29
View File
@@ -1,6 +1,6 @@
import { Pencil, Trash2 } from "lucide-react";
import { Eye, EyeOff, Pencil, Trash2 } from "lucide-react";
import { type FC, useState } from "react";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -23,23 +23,33 @@ function storeLabel(store: string): string {
}
export interface GameCardProps {
game: GameEntry;
game: OperatorGameEntry;
onEdit: () => void;
onDelete: () => void;
deleting: boolean;
/** Hide this title from every play surface, or bring it back. */
onToggleHidden: () => void;
/** This card's hide/un-hide is in flight — only this one disables. */
hiding: boolean;
}
/**
* A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it
* falls back to the wide header, then to a text placeholder. Custom entries get
* edit/delete affordances.
* edit/delete affordances; every entry can be hidden.
*/
export const GameCard: FC<GameCardProps> = ({
game,
onEdit,
onDelete,
deleting,
onToggleHidden,
hiding,
}) => {
// Hiding is available for EVERY store, unlike edit/delete: the titles most worth hiding are the
// ones the operator cannot edit — a launcher's own scanned entries, a Proton tool, a demo. The
// host keys the setting by the entry id and never needs to own the entry.
const hidden = game.hidden === true;
// Editable only if the operator actually owns this entry. A custom-store entry SYNCED by a
// provider plugin also has `store === "custom"`, but the host refuses to hand-edit or delete it
// (409 CONFLICT, "owned by provider … — update it through its reconcile"), so offering the
@@ -57,16 +67,23 @@ export const GameCard: FC<GameCardProps> = ({
return (
<Card className="group relative overflow-hidden">
<div className="relative aspect-[2/3] bg-muted">
{/* Dim the ARTWORK only never the badges or the buttons layered over it. A hidden
card is the sole place the title can be brought back, so its controls have to stay
at full contrast while the poster reads as "not in play". */}
{src ? (
<img
src={src}
alt={game.title}
loading="lazy"
className="size-full object-cover"
className={`size-full object-cover${hidden ? " opacity-30" : ""}`}
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
/>
) : (
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
<div
className={`flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground${
hidden ? " opacity-30" : ""
}`}
>
{game.title}
</div>
)}
@@ -91,30 +108,67 @@ export const GameCard: FC<GameCardProps> = ({
{m.library_owned_by({ provider: game.provider })}
</Badge>
)}
{/* Says WHY this poster is faded. Without it a dimmed tile reads as a broken cover
or a still-loading image rather than a deliberate setting. */}
{hidden && (
<Badge
variant="secondary"
className="bg-background/90 backdrop-blur"
>
{m.library_hidden_badge()}
</Badge>
)}
</div>
{/* A hidden card keeps its controls VISIBLE rather than hover-revealed. Hover-to-reveal
is fine for an ordinary tile, but the un-hide button is the only way out of the
hidden state requiring a hover to discover it would strand anyone on a touch
screen, which is exactly where the console's pointer work landed. */}
<div
className={`absolute right-2 top-2 flex gap-1 transition-opacity focus-within:opacity-100 group-hover:opacity-100${
hidden ? "" : " opacity-0"
}`}
>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={
hidden ? m.library_unhide_action() : m.library_hide_action()
}
aria-pressed={hidden}
disabled={hiding}
onClick={onToggleHidden}
>
{hidden ? (
<Eye className="size-3.5" />
) : (
<EyeOff className="size-3.5" />
)}
</Button>
{isCustom && (
<>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_edit()}
onClick={onEdit}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_delete()}
disabled={deleting}
onClick={onDelete}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</>
)}
</div>
{isCustom && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_edit()}
onClick={onEdit}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_delete()}
disabled={deleting}
onClick={onDelete}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
)}
</div>
<div
className="truncate px-card pb-card pt-4 text-sm font-medium"
+35 -9
View File
@@ -5,8 +5,9 @@ import {
getGetLibraryQueryKey,
useDeleteCustomGame,
useGetLibrary,
useSetLibraryEntryHidden,
} from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
@@ -23,11 +24,11 @@ import { customId } from "./helpers";
* this subsection knows nothing about the form beyond firing `onEdit`.
*/
export const LibraryGridSection: FC<{
onEdit: (entry: GameEntry) => void;
onEdit: (entry: OperatorGameEntry) => void;
/** Show only entries owned by this provider, or everything when null. */
providerFilter?: string | null;
/** Reports the full (unfiltered) list up, so the providers card can count owners. */
onEntries?: (entries: GameEntry[]) => void;
onEntries?: (entries: OperatorGameEntry[]) => void;
}> = ({ onEdit, providerFilter, onEntries }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
@@ -54,7 +55,7 @@ export const LibraryGridSection: FC<{
// A refused delete has to say so. The host has real reasons to say no (a provider-owned entry
// answers 409 with what to do instead), and an un-caught `mutateAsync` rejection reported none
// of them — the card just stayed put as if nothing had been clicked.
const onDelete = async (entry: GameEntry) => {
const onDelete = async (entry: OperatorGameEntry) => {
const ok = await confirm({
title: m.library_delete_confirm(),
description: m.library_delete_body(),
@@ -71,6 +72,23 @@ export const LibraryGridSection: FC<{
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
};
const setHidden = useSetLibraryEntryHidden();
// Same error discipline as delete: the host can refuse (it cannot persist the settings file),
// and swallowing that would leave the card looking unchanged with no explanation.
const onToggleHidden = async (entry: OperatorGameEntry) => {
try {
await setHidden.mutateAsync({
id: entry.id,
data: { hidden: entry.hidden !== true },
});
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_hide_failed());
return;
}
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
};
return (
<LibraryGrid
library={filtered}
@@ -78,31 +96,39 @@ export const LibraryGridSection: FC<{
onDelete={onDelete}
// The custom id whose delete is in flight (if any), so only that card's button disables.
deletingId={remove.isPending ? (remove.variables?.id ?? null) : null}
onToggleHidden={onToggleHidden}
// Keyed by ENTRY id, not custom id — hiding addresses any store's entry, not just ours.
hidingId={setHidden.isPending ? (setHidden.variables?.id ?? null) : null}
/>
);
};
/** The poster grid (with empty + loading/error states). */
export const LibraryGrid: FC<{
library: Loadable<GameEntry[]>;
onEdit: (entry: GameEntry) => void;
onDelete: (entry: GameEntry) => void;
library: Loadable<OperatorGameEntry[]>;
onEdit: (entry: OperatorGameEntry) => void;
onDelete: (entry: OperatorGameEntry) => void;
/** Custom id of the card whose delete is in flight, or null — only that card disables. */
deletingId: string | null;
}> = ({ library, onEdit, onDelete, deletingId }) => {
onToggleHidden: (entry: OperatorGameEntry) => void;
/** Entry id of the card whose hide/un-hide is in flight, or null. */
hidingId: string | null;
}> = ({ library, onEdit, onDelete, deletingId, onToggleHidden, hidingId }) => {
const all = library.data ?? [];
// Launcher entries (design D4) open the launcher itself — Steam Big Picture, Heroic — rather than
// a title. They launch and lease exactly like games; grouping them into their own rail is purely
// so a shelf of 400 games doesn't bury the two or three ways to open a launcher.
const launchers = all.filter((g) => g.role === "launcher");
const games = all.filter((g) => g.role !== "launcher");
const card = (game: GameEntry) => (
const card = (game: OperatorGameEntry) => (
<GameCard
key={game.id}
game={game}
onEdit={() => onEdit(game)}
onDelete={() => onDelete(game)}
deleting={deletingId === customId(game)}
onToggleHidden={() => onToggleHidden(game)}
hiding={hidingId === game.id}
/>
);
return (
+27
View File
@@ -45,6 +45,8 @@ export const Populated: Story = {
onEdit={noop}
onDelete={noop}
deletingId={null}
onToggleHidden={noop}
hidingId={null}
/>
),
};
@@ -70,6 +72,29 @@ export const WithLaunchers: Story = {
onEdit={noop}
onDelete={noop}
deletingId={null}
onToggleHidden={noop}
hidingId={null}
/>
),
};
/**
* A hidden title, as only the operator's console ever sees it every other surface has it filtered
* out upstream. The poster dims but the badge and the un-hide button stay at full contrast, because
* this card is the only route back.
*/
export const WithHidden: Story = {
render: () => (
<LibraryGrid
library={{
data: library.map((g, i) => (i === 1 ? { ...g, hidden: true } : g)),
...idle,
}}
onEdit={noop}
onDelete={noop}
deletingId={null}
onToggleHidden={noop}
hidingId={null}
/>
),
};
@@ -81,6 +106,8 @@ export const Empty: Story = {
onEdit={noop}
onDelete={noop}
deletingId={null}
onToggleHidden={noop}
hidingId={null}
/>
),
};