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
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:
+125
-4
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"]}"#);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")<{
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user