From 6cffe29b1325e4eaa424055429f8ab712a201943 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 8 Aug 2026 12:33:54 +0200 Subject: [PATCH] feat(host,console): hide individual library titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library had one visibility control and it was all-or-nothing: turn a SOURCE off and every one of its games goes. There was no way to drop a single title — a Proton tool the filter missed, a demo, a game someone doesn't want on the TV — short of hiding the whole launcher it came from. **Where the setting lives.** Not on the entry. Only manual custom entries are stored; a scanner's and a plugin's titles are rebuilt from scratch on every scan and every reconcile, so a flag written onto one would be erased by the next sync — silently, and minutes later, which is the worst possible shape for a setting. So `library-hidden.json` holds the ids, mirroring how `library-scanners.json` holds disabled sources. The id is stable by construction (D2: a claimed store's entries keep `:` across reconciles), so a hide survives a re-scan, a plugin restart, and a store's built-in→plugin migration. **Where it takes effect.** In `all_games`, which is the one place every play surface already funnels through — the grid on a client, native clients, the GameStream app list, and launch resolution. Putting it there rather than at each call site is deliberate: a per-surface filter is a rule someone has to remember, and forgetting one is precisely the class of bug the `file://` art asymmetry in the previous commit was. Hiding is curation, not access control — nothing is deleted, and un-hiding is instant. **The console is the one surface that still sees them**, or a hidden title could never be brought back. That exception is a TYPE, not a flag: `GET /library` answers `Vec` on every lane but the operator's and `Vec` on theirs, so a hidden entry cannot reach a paired streaming client by someone forgetting a filter — there is no field there to leak. `hidden` is skipped when false, so the response is byte-identical to today's for a library with nothing hidden. `PUT /library/hidden/{id}` is operator-only — neither the plugin lane nor a paired cert, unlike the scanner toggle. A plugin has no business deciding what its operator sees, and a client must not be able to hide a game on the host it is streaming from. The id is not validated against the current library on purpose: a title can be legitimately absent at that moment (launcher closed, plugin mid-sync, drive unmounted), and refusing the operator's choice in that window is worse than storing an id that matches nothing today. On the card, the poster dims and a Hidden badge says why — a faded tile with no label reads as a broken cover. Its controls stay at full contrast and, unlike an ordinary card's, are not hover-revealed: the un-hide button is the only way out of the state, and hiding it behind a hover would strand anyone on a touch screen. Verified on .21 (Linux): 469 host tests pass (5 new), clippy clean under `-D warnings`, `cargo fmt --all --check` clean. The routing test is the one that earns its keep — every library id contains a colon and Heroic's contain two, so a router that split on it would 404 the console against ids the host itself produced. Console: tsc clean, production build clean, i18n 633 messages across en+de, biome clean on the touched files. --- api/openapi.json | 129 ++++++++++++++++- crates/punktfunk-host/src/library.rs | 147 ++++++++++++++++++++ crates/punktfunk-host/src/library/hidden.rs | 141 +++++++++++++++++++ crates/punktfunk-host/src/mgmt.rs | 1 + crates/punktfunk-host/src/mgmt/auth.rs | 12 ++ crates/punktfunk-host/src/mgmt/library.rs | 127 ++++++++++++++--- crates/punktfunk-host/src/mgmt/tests.rs | 38 +++++ web/messages/de.json | 4 + web/messages/en.json | 4 + web/src/sections/Library/GameCard.tsx | 112 +++++++++++---- web/src/sections/Library/LibraryGrid.tsx | 44 ++++-- web/src/stories/Library.stories.tsx | 27 ++++ 12 files changed, 724 insertions(+), 62 deletions(-) create mode 100644 crates/punktfunk-host/src/library/hidden.rs diff --git a/api/openapi.json b/api/openapi.json index d463c6cb..7106610a 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -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 `:` 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` 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.", diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index a1a24ffd..89d55064 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -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` 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 { + 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 { + 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 { 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 { 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 = ["lutris:4".to_string()].into_iter().collect(); + + let operator: Vec = games + .iter() + .cloned() + .map(|entry| OperatorGameEntry { + hidden: hidden.contains(&entry.id), + entry, + }) + .collect(); + let played: Vec = 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" + ); + } +} diff --git a/crates/punktfunk-host/src/library/hidden.rs b/crates/punktfunk-host/src/library/hidden.rs new file mode 100644 index 00000000..dc29b341 --- /dev/null +++ b/crates/punktfunk-host/src/library/hidden.rs @@ -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 `:` id, and the entries stay disposable. +//! +//! That id is stable *by construction* (design D2): a claimed store's entries keep +//! `:` 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, +} + +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 { + 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 { + 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:`): 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"]}"#); + } +} diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index a1dbf8a3..2cd94a31 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -228,6 +228,7 @@ fn api_router_parts() -> (Router>, 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, diff --git a/crates/punktfunk-host/src/mgmt/auth.rs b/crates/punktfunk-host/src/mgmt/auth.rs index 198559e7..e05fca76 100644 --- a/crates/punktfunk-host/src/mgmt/auth.rs +++ b/crates/punktfunk-host/src/mgmt/auth.rs @@ -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 diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index a4dfddfc..7e66ace1 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -67,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", @@ -77,26 +81,28 @@ pub(crate) struct LibraryQuery { ("platform" = Option, 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, Query(q): Query, -) -> Json> { +) -> 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:\…`). @@ -112,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 `:` 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, + ApiJson(toggle): ApiJson, +) -> 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`. diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index a1d25851..cc2ff8cb 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -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 `:`, so the hide route's path segment CONTAINS A COLON — +/// and for Heroic (`heroic:legendary:`) 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` diff --git a/web/messages/de.json b/web/messages/de.json index 0af6c264..4dda7b10 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -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", diff --git a/web/messages/en.json b/web/messages/en.json index 87da9002..1d171e5a 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -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", diff --git a/web/src/sections/Library/GameCard.tsx b/web/src/sections/Library/GameCard.tsx index a3e3515d..fdb5d723 100644 --- a/web/src/sections/Library/GameCard.tsx +++ b/web/src/sections/Library/GameCard.tsx @@ -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 = ({ 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 = ({ return (
+ {/* 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 ? ( {game.title} setFailed((prev) => ({ ...prev, [src]: true }))} /> ) : ( -
+ )} @@ -91,30 +108,67 @@ export const GameCard: FC = ({ {m.library_owned_by({ provider: game.provider })} )} + {/* 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 && ( + + {m.library_hidden_badge()} + + )} +
+ {/* 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. */} + - {isCustom && ( -
- - -
- )}
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 ( ); }; /** The poster grid (with empty + loading/error states). */ export const LibraryGrid: FC<{ - library: Loadable; - onEdit: (entry: GameEntry) => void; - onDelete: (entry: GameEntry) => void; + library: Loadable; + 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) => ( onEdit(game)} onDelete={() => onDelete(game)} deleting={deletingId === customId(game)} + onToggleHidden={() => onToggleHidden(game)} + hiding={hidingId === game.id} /> ); return ( diff --git a/web/src/stories/Library.stories.tsx b/web/src/stories/Library.stories.tsx index 02753729..b46d0f8c 100644 --- a/web/src/stories/Library.stories.tsx +++ b/web/src/stories/Library.stories.tsx @@ -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: () => ( + (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} /> ), };