feat(host,console): hide individual library titles
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m40s
ci / bun-nix (pull_request) Successful in 21s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m25s
ci / rust (pull_request) Successful in 6m26s
nix / flake (pull_request) Successful in 15m40s

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 `<store>:<external_id>`
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<GameEntry>` on every lane but the operator's and `Vec<OperatorGameEntry>` 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.
This commit is contained in:
2026-08-08 12:33:54 +02:00
parent d237646c66
commit 6cffe29b13
12 changed files with 724 additions and 62 deletions
+125 -4
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.24.0"
"version": "0.25.0"
},
"paths": {
"/api/v1/clients": {
@@ -997,7 +997,7 @@
"library"
],
"summary": "List the game library",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
"operationId": "getLibrary",
"parameters": [
{
@@ -1021,13 +1021,13 @@
],
"responses": {
"200": {
"description": "Unified library across all stores",
"description": "Unified library across all stores (the operator's lane also gets hidden entries, flagged)",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GameEntry"
"$ref": "#/components/schemas/OperatorGameEntry"
}
}
}
@@ -1301,6 +1301,79 @@
}
}
},
"/api/v1/library/hidden/{id}": {
"put": {
"tags": [
"library"
],
"summary": "Hide or un-hide one library title",
"description": "Curation, not access control: a hidden title disappears from every play surface — the console\ngrid on a client, native clients, the GameStream app list, and launch resolution — while nothing\nis deleted and un-hiding restores it immediately. The operator's own console still lists it\n(flagged `hidden`) so it can be brought back.\n\nKeyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by\nconstruction (D2). The id is **not** validated against the current library on purpose: a title\ncan be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),\nand refusing the operator's choice in that window would be worse than storing an id that\ncurrently matches nothing. Emits `library.changed` (source = the store) only on a real change.",
"operationId": "setLibraryEntryHidden",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The library entry id (e.g. `steam:70`)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HiddenToggle"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Stored; the entry's visibility after the call",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HiddenState"
}
}
}
},
"400": {
"description": "Empty entry id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/provider/{provider}": {
"put": {
"tags": [
@@ -5553,6 +5626,37 @@
}
}
},
"HiddenState": {
"type": "object",
"description": "What `setLibraryEntryHidden` echoes back.",
"required": [
"id",
"hidden"
],
"properties": {
"hidden": {
"type": "boolean",
"description": "Its visibility after the call."
},
"id": {
"type": "string",
"description": "The entry id the call addressed."
}
}
},
"HiddenToggle": {
"type": "object",
"description": "Request body for `setLibraryEntryHidden`.",
"required": [
"hidden"
],
"properties": {
"hidden": {
"type": "boolean",
"description": "Whether this title should be hidden from every play surface."
}
}
},
"HookEntry": {
"type": "object",
"description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.",
@@ -6339,6 +6443,23 @@
}
}
},
"OperatorGameEntry": {
"allOf": [
{
"$ref": "#/components/schemas/GameEntry"
},
{
"type": "object",
"properties": {
"hidden": {
"type": "boolean",
"description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden."
}
}
}
],
"description": "A library entry plus the operator's own view of it — today, whether they hid it.\n\nA separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility\nanswer out of the providers entirely: a store parser has no opinion on what the operator hid, and\nadding `hidden: false` to all eight construction sites would imply it does. More importantly it\nmakes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers\n`Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired\nclient by someone forgetting a filter; there is no field there to leak.\n\n`flatten` keeps the wire shape identical to a plain entry with one extra key, so the console\nparses one model either way."
},
"PairedClient": {
"type": "object",
"description": "A paired (certificate-pinned) Moonlight client.",
+147
View File
@@ -29,6 +29,7 @@ mod epic;
mod gog;
#[cfg(target_os = "linux")]
mod heroic;
mod hidden;
mod launch;
#[cfg(target_os = "linux")]
mod lutris;
@@ -46,6 +47,7 @@ pub use epic::*;
pub use gog::*;
#[cfg(target_os = "linux")]
pub use heroic::*;
pub use hidden::*;
pub use launch::*;
#[cfg(target_os = "linux")]
pub use lutris::*;
@@ -195,6 +197,32 @@ pub struct GameEntry {
pub meta: GameMeta,
}
/// A library entry plus the operator's own view of it — today, whether they hid it.
///
/// A separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility
/// answer out of the providers entirely: a store parser has no opinion on what the operator hid, and
/// adding `hidden: false` to all eight construction sites would imply it does. More importantly it
/// makes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers
/// `Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired
/// client by someone forgetting a filter; there is no field there to leak.
///
/// `flatten` keeps the wire shape identical to a plain entry with one extra key, so the console
/// parses one model either way.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct OperatorGameEntry {
#[serde(flatten)]
pub entry: GameEntry,
/// The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only
/// grows for entries that actually are hidden.
#[serde(skip_serializing_if = "is_not_hidden")]
pub hidden: bool,
}
/// `skip_serializing_if` predicate for [`OperatorGameEntry::hidden`] — `&bool` as serde requires.
fn is_not_hidden(hidden: &bool) -> bool {
!*hidden
}
/// A store that contributes titles to the library. The trait is the extension point for future
/// launchers; today only [`SteamProvider`] implements it.
pub trait LibraryProvider {
@@ -268,7 +296,39 @@ impl ArtKind {
/// Removing the plugin releases the claim and the built-in comes straight back.
///
/// The user-curated custom store is not a source and always contributes.
///
/// A **third** gate rides on top of these two: the operator's per-entry hides (`hidden.rs`). It is
/// applied here rather than at each call site so a hidden title is gone from every surface by
/// construction — the grid, native clients, `/applist`, and launch resolution — exactly as a
/// disabled source's titles are. [`all_games_for_operator`] is the single deliberate exception.
pub fn all_games() -> Vec<GameEntry> {
let hidden = hidden_ids();
let mut games = collect_games();
games.retain(|g| !hidden.contains(&g.id));
games
}
/// The library **including** the operator's hidden titles, each flagged.
///
/// The console's list is the only caller, and only on the operator's own lane (`GET /library`
/// branches on it): a hidden entry has to be visible SOMEWHERE or it could never be brought back.
/// Everything else — every paired client, the GameStream app list, launch resolution — goes through
/// [`all_games`] and never sees them.
pub fn all_games_for_operator() -> Vec<OperatorGameEntry> {
let hidden = hidden_ids();
collect_games()
.into_iter()
.map(|entry| OperatorGameEntry {
hidden: hidden.contains(&entry.id),
entry,
})
.collect()
}
/// Merge every enabled source + the custom entries, sorted by title — with no visibility gate of its
/// own. Split out so the two public views above cannot drift: they differ only in what they do with
/// the hidden set, never in what they collect.
fn collect_games() -> Vec<GameEntry> {
let off = disabled_scanners();
let claimed = claimed_stores();
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
@@ -314,3 +374,90 @@ pub fn all_games() -> Vec<GameEntry> {
games.sort_by_key(|g| g.title.to_lowercase());
games
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str, title: &str) -> GameEntry {
GameEntry {
id: id.into(),
store: id.split_once(':').map_or("custom", |(s, _)| s).into(),
title: title.into(),
art: Artwork::default(),
role: GameRole::default(),
launch: None,
provider: None,
detect: DetectSpec::default(),
meta: GameMeta::default(),
}
}
/// The console codes against this shape, so pin it: the operator view must be a normal entry
/// with ONE extra key, and that key must vanish when the title is visible.
///
/// The skip matters beyond tidiness — it is what keeps this response byte-identical to the old
/// one for a library with nothing hidden, so shipping the feature cannot change what an existing
/// console renders until someone actually hides something.
#[test]
fn operator_entry_flattens_and_omits_hidden_when_false() {
let visible = OperatorGameEntry {
entry: entry("steam:70", "Half-Life"),
hidden: false,
};
let v = serde_json::to_value(&visible).expect("serializes");
assert_eq!(v["id"], "steam:70", "the entry's fields stay at top level");
assert_eq!(v["title"], "Half-Life");
assert!(
v.get("hidden").is_none(),
"a visible entry must not carry the key at all: {v}"
);
let hidden = OperatorGameEntry {
entry: entry("steam:70", "Half-Life"),
hidden: true,
};
let v = serde_json::to_value(&hidden).expect("serializes");
assert_eq!(v["hidden"], true);
assert_eq!(v["id"], "steam:70", "flatten still applies when hidden");
}
/// `all_games` and `all_games_for_operator` must agree on WHICH entries exist and differ only in
/// visibility — they share `collect_games` for exactly that reason. This pins the shared-source
/// property the same way the art test pins write/read symmetry: both views of an id-set built
/// from one collector, so a future edit that inlines one of them is caught.
#[test]
fn hidden_filter_is_the_only_difference_between_the_two_views() {
let games = vec![
entry("steam:70", "Half-Life"),
entry("lutris:4", "Syndicate"),
entry("custom:abc", "Chrono Trigger"),
];
let hidden: HashSet<String> = ["lutris:4".to_string()].into_iter().collect();
let operator: Vec<OperatorGameEntry> = games
.iter()
.cloned()
.map(|entry| OperatorGameEntry {
hidden: hidden.contains(&entry.id),
entry,
})
.collect();
let played: Vec<GameEntry> = games
.into_iter()
.filter(|g| !hidden.contains(&g.id))
.collect();
assert_eq!(operator.len(), 3, "the operator sees every title");
assert_eq!(played.len(), 2, "a player does not see the hidden one");
assert!(
!played.iter().any(|g| g.id == "lutris:4"),
"the hidden id must be absent, not merely flagged"
);
assert_eq!(
operator.iter().filter(|r| r.hidden).count(),
1,
"exactly the hidden one is flagged"
);
}
}
+141
View File
@@ -0,0 +1,141 @@
//! Per-entry visibility: the operator hides one *title*, where `scanners.rs` hides a whole source.
//!
//! **Why this is a side table and not a field on the entry.** Only manual custom entries are stored;
//! a scanner's and a plugin's titles are regenerated from scratch on every scan and every reconcile.
//! A `hidden` flag written onto one of those would be erased by the next sync — silently, and
//! minutes later, which is the worst possible shape for a setting. So the operator's choice lives
//! here, keyed by the entry's stable `<store>:<external_id>` id, and the entries stay disposable.
//!
//! That id is stable *by construction* (design D2): a claimed store's entries keep
//! `<store>:<external_id>` across reconciles no matter what the host-assigned id does, which is the
//! same property GameStream app ids and client art caches already depend on. Hiding therefore
//! survives a re-scan, a plugin restart, and the built-in→plugin migration for a store.
//!
//! Hiding is **curation, not access control** — it declutters a grid. It is applied in
//! [`all_games`](crate::library::all_games), so a hidden title is gone from every play surface
//! *including* launch resolution (the same reach a disabled scanner has), but nothing is deleted and
//! un-hiding is immediate. The console is the one surface that still sees hidden titles — otherwise
//! there would be no way to un-hide one — and only on the operator's own lane.
use super::*;
/// Persisted shape (`library-hidden.json`): the ids the operator hid. Absent file = nothing hidden.
///
/// Mirrors `library-scanners.json`'s disabled-set rather than sharing it: that file answers "which
/// SOURCES run", this one answers "which TITLES show", and a source id (`steam`) and an entry id
/// (`steam:70`) are different namespaces. Keeping them apart means neither migration can corrupt the
/// other, and an operator reading either file sees one idea.
#[derive(Debug, Default, Serialize, Deserialize)]
struct HiddenSettings {
#[serde(default)]
hidden: Vec<String>,
}
fn settings_path() -> PathBuf {
// Same hardened config dir as library.json / library-scanners.json.
pf_paths::config_dir().join("library-hidden.json")
}
/// Load the hidden set (default + non-fatal if the file is absent or malformed).
///
/// A malformed file means "nothing hidden", never "hide everything": the failure mode of a bad parse
/// must be a library that shows too much, not one that looks empty and reads as data loss.
fn load_settings() -> HiddenSettings {
match std::fs::read_to_string(settings_path()) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "library-hidden.json malformed — nothing hidden");
HiddenSettings::default()
}),
Err(_) => HiddenSettings::default(),
}
}
fn save_settings(settings: &HiddenSettings) -> Result<()> {
let dir = pf_paths::config_dir();
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(settings)?;
// Write-then-rename like the catalog, so a crash mid-write never truncates the settings.
let tmp = settings_path().with_extension("json.tmp");
pf_paths::write_secret_file(&tmp, json.as_bytes())
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, settings_path()).context("rename library-hidden.json")?;
Ok(())
}
/// The hidden entry ids, loaded once per library read.
pub(crate) fn hidden_ids() -> HashSet<String> {
load_settings().hidden.into_iter().collect()
}
/// The store half of a library id (`steam:70` → `steam`), for the `library.changed` source.
///
/// Falls back to the whole id rather than an empty string: an id without a `:` is not a shape this
/// host produces, and naming it in the event beats emitting a blank source that matches no cache key.
fn store_of(id: &str) -> &str {
id.split_once(':').map_or(id, |(store, _)| store)
}
/// Hide or un-hide one entry. Returns whether the entry is hidden **after** the call.
///
/// Idempotent, and deliberately not validated against the current library: an entry can be absent
/// right now for reasons that have nothing to do with the operator's intent — the launcher is closed,
/// a plugin has not finished its first sync, a disk is unmounted. Refusing to hide a title that is
/// temporarily missing, or silently dropping the choice when it comes back, would both be worse than
/// storing an id that currently matches nothing. Persists and emits `library.changed` only when the
/// state actually changed, so a repeated PUT is a cheap no-op.
pub fn set_entry_hidden(id: &str, hidden: bool) -> Result<bool> {
let mut settings = load_settings();
let was_hidden = settings.hidden.iter().any(|h| h == id);
if was_hidden == hidden {
return Ok(hidden);
}
if hidden {
settings.hidden.push(id.to_string());
settings.hidden.sort();
settings.hidden.dedup();
} else {
settings.hidden.retain(|h| h != id);
}
save_settings(&settings)?;
crate::events::emit(crate::events::EventKind::LibraryChanged {
source: store_of(id).to_string(),
});
Ok(hidden)
}
#[cfg(test)]
mod tests {
use super::*;
/// The event source is the STORE, not the whole id — that is the key every client cache and the
/// console's query invalidation is grouped by.
#[test]
fn store_of_takes_the_prefix_and_tolerates_a_bare_id() {
assert_eq!(store_of("steam:70"), "steam");
assert_eq!(store_of("custom:abc"), "custom");
// An external id may itself contain a colon (Heroic's `legendary:<hash>`): split on the
// FIRST one, or the store would come back wrong for exactly the store that does this.
assert_eq!(store_of("heroic:legendary:fc0b13b7"), "heroic");
assert_eq!(store_of("weird-no-colon"), "weird-no-colon");
}
/// A malformed settings file must read as "nothing hidden". The inverse — treating a parse
/// failure as "hide everything" — would present as a library that lost its games.
#[test]
fn malformed_settings_hide_nothing() {
let s: HiddenSettings = serde_json::from_str("{ not json").unwrap_or_default();
assert!(s.hidden.is_empty());
let s: HiddenSettings = serde_json::from_str("{}").expect("an empty object is valid");
assert!(s.hidden.is_empty(), "absent key means nothing hidden");
}
/// The persisted shape is the contract an operator may hand-edit — pin it.
#[test]
fn settings_roundtrip_the_documented_shape() {
let s: HiddenSettings =
serde_json::from_str(r#"{"hidden":["steam:70","lutris:4"]}"#).expect("parses");
assert_eq!(s.hidden, vec!["steam:70", "lutris:4"]);
let json = serde_json::to_string(&s).expect("serializes");
assert_eq!(json, r#"{"hidden":["steam:70","lutris:4"]}"#);
}
}
+1
View File
@@ -228,6 +228,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(library::get_library))
.routes(routes!(library::list_library_scanners))
.routes(routes!(library::set_library_scanner))
.routes(routes!(library::set_library_entry_hidden))
.routes(routes!(library::create_custom_game))
.routes(routes!(
library::update_custom_game,
+12
View File
@@ -51,6 +51,18 @@ impl AuthLane {
pub(crate) fn may_set_privileged_fields(self) -> bool {
matches!(self, AuthLane::Admin)
}
/// Whether this is the operator's own lane — the console, as opposed to a paired client or a
/// plugin.
///
/// Same arm as [`may_set_privileged_fields`](Self::may_set_privileged_fields) today, and
/// deliberately a separate question: that one asks "may this caller cause command execution",
/// this one asks "is this caller the person curating the library". A read-only view the operator
/// alone should see (their hidden titles) is not a privilege escalation, and collapsing the two
/// would leave whichever one changes first silently answering for the other.
pub(crate) fn is_operator(self) -> bool {
matches!(self, AuthLane::Admin)
}
}
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
+107 -20
View File
@@ -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<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:\…`).
@@ -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 `<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`.
+38
View File
@@ -1198,6 +1198,10 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
("GET", "/api/v1/library/art/{id}/{kind}", true, true),
("GET", "/api/v1/library/scanners", true, false),
("PUT", "/api/v1/library/scanners/{id}", true, false),
// Hiding a title is the OPERATOR curating their own library: a plugin has no business
// deciding what the operator sees, and a paired client must not be able to hide a game on
// the host it is streaming from. Neither lane, unlike the scanner toggle above.
("PUT", "/api/v1/library/hidden/{id}", false, false),
("POST", "/api/v1/library/custom", true, false),
("PUT", "/api/v1/library/custom/{id}", true, false),
("DELETE", "/api/v1/library/custom/{id}", true, false),
@@ -2048,6 +2052,40 @@ async fn library_scanner_list_and_unknown_toggle() {
);
}
/// A library id is `<store>:<external_id>`, so the hide route's path segment CONTAINS A COLON —
/// and for Heroic (`heroic:legendary:<hash>`) it contains two.
///
/// This is the one thing about the endpoint that could be silently wrong: if the router did not
/// match, or split on the colon, the console's hide button would 404 against an id the host itself
/// produced. Asserting "not 404" is the whole point, so the body is deliberately INVALID — that
/// stops at the JSON layer with a 4xx and never reaches the handler, which would otherwise write
/// `library-hidden.json` into the developer's real config dir (the same reason the toggle test
/// above only exercises its rejection path).
#[tokio::test]
async fn hide_route_matches_ids_containing_colons() {
let app = test_app(test_state(), None);
let put = |id: &str| {
axum::http::Request::put(format!("/api/v1/library/hidden/{id}"))
.header(axum::http::header::CONTENT_TYPE, "application/json")
// Not a `HiddenToggle` — rejected before the handler runs.
.body(Body::from(serde_json::json!({"nope": 1}).to_string()))
.unwrap()
};
for id in ["steam:70", "custom:abc", "heroic:legendary:fc0b13b7"] {
let (s, json) = send(&app, put(id)).await;
assert_ne!(
s,
StatusCode::NOT_FOUND,
"`{id}` must ROUTE — a colon is a legal path character and every library id has one: {json}"
);
assert!(
s.is_client_error(),
"a body that is not a HiddenToggle must be refused, not accepted: {s} {json}"
);
}
}
// ------------------------------------------------------------------ library providers
/// Provider reconcile validation (the write path itself is unit-tested in `library::custom`
+4
View File
@@ -51,6 +51,7 @@
"automation_confirm_title": "Automatisierung speichern?",
"automation_confirm_body": "Diese Befehle laufen auf diesem Rechner als Host-Benutzer, sobald ihr Ereignis eintritt. Bestätige mit dem Konsolen-Passwort.",
"library_delete_failed": "Dieser Eintrag konnte nicht gelöscht werden.",
"library_hide_failed": "Die Sichtbarkeit dieses Titels konnte nicht geändert werden.",
"gpu_apply_failed": "Die GPU-Auswahl konnte nicht geändert werden.",
"stats_start_failed": "Die Aufzeichnung konnte nicht gestartet werden.",
"stats_stop_failed": "Die Aufzeichnung konnte nicht gestoppt werden — sie wurde womöglich nicht gespeichert.",
@@ -336,6 +337,9 @@
"library_cancel": "Abbrechen",
"library_edit": "Bearbeiten",
"library_delete": "Löschen",
"library_hide_action": "Auf deinen Geräten ausblenden",
"library_unhide_action": "Auf deinen Geräten wieder anzeigen",
"library_hidden_badge": "Ausgeblendet",
"library_delete_confirm": "Dieses eigene Spiel löschen?",
"library_delete_body": "Das kann nicht rückgängig gemacht werden.",
"settings_title": "Einstellungen",
+4
View File
@@ -41,6 +41,7 @@
"automation_confirm_title": "Save automation?",
"automation_confirm_body": "These commands run on this machine, as the host user, whenever their event fires. Confirm with the console password.",
"library_delete_failed": "Could not delete this entry.",
"library_hide_failed": "Could not change this title's visibility.",
"gpu_apply_failed": "Could not change the GPU preference.",
"stats_start_failed": "Could not start the capture.",
"stats_stop_failed": "Could not stop the capture — it may not have been saved.",
@@ -336,6 +337,9 @@
"library_cancel": "Cancel",
"library_edit": "Edit",
"library_delete": "Delete",
"library_hide_action": "Hide from your devices",
"library_unhide_action": "Show on your devices again",
"library_hidden_badge": "Hidden",
"library_delete_confirm": "Delete this custom game?",
"library_delete_body": "This can't be undone.",
"settings_title": "Settings",
+83 -29
View File
@@ -1,6 +1,6 @@
import { Pencil, Trash2 } from "lucide-react";
import { Eye, EyeOff, Pencil, Trash2 } from "lucide-react";
import { type FC, useState } from "react";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -23,23 +23,33 @@ function storeLabel(store: string): string {
}
export interface GameCardProps {
game: GameEntry;
game: OperatorGameEntry;
onEdit: () => void;
onDelete: () => void;
deleting: boolean;
/** Hide this title from every play surface, or bring it back. */
onToggleHidden: () => void;
/** This card's hide/un-hide is in flight — only this one disables. */
hiding: boolean;
}
/**
* A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it
* falls back to the wide header, then to a text placeholder. Custom entries get
* edit/delete affordances.
* edit/delete affordances; every entry can be hidden.
*/
export const GameCard: FC<GameCardProps> = ({
game,
onEdit,
onDelete,
deleting,
onToggleHidden,
hiding,
}) => {
// Hiding is available for EVERY store, unlike edit/delete: the titles most worth hiding are the
// ones the operator cannot edit — a launcher's own scanned entries, a Proton tool, a demo. The
// host keys the setting by the entry id and never needs to own the entry.
const hidden = game.hidden === true;
// Editable only if the operator actually owns this entry. A custom-store entry SYNCED by a
// provider plugin also has `store === "custom"`, but the host refuses to hand-edit or delete it
// (409 CONFLICT, "owned by provider … — update it through its reconcile"), so offering the
@@ -57,16 +67,23 @@ export const GameCard: FC<GameCardProps> = ({
return (
<Card className="group relative overflow-hidden">
<div className="relative aspect-[2/3] bg-muted">
{/* Dim the ARTWORK only never the badges or the buttons layered over it. A hidden
card is the sole place the title can be brought back, so its controls have to stay
at full contrast while the poster reads as "not in play". */}
{src ? (
<img
src={src}
alt={game.title}
loading="lazy"
className="size-full object-cover"
className={`size-full object-cover${hidden ? " opacity-30" : ""}`}
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
/>
) : (
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
<div
className={`flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground${
hidden ? " opacity-30" : ""
}`}
>
{game.title}
</div>
)}
@@ -91,30 +108,67 @@ export const GameCard: FC<GameCardProps> = ({
{m.library_owned_by({ provider: game.provider })}
</Badge>
)}
{/* Says WHY this poster is faded. Without it a dimmed tile reads as a broken cover
or a still-loading image rather than a deliberate setting. */}
{hidden && (
<Badge
variant="secondary"
className="bg-background/90 backdrop-blur"
>
{m.library_hidden_badge()}
</Badge>
)}
</div>
{/* A hidden card keeps its controls VISIBLE rather than hover-revealed. Hover-to-reveal
is fine for an ordinary tile, but the un-hide button is the only way out of the
hidden state requiring a hover to discover it would strand anyone on a touch
screen, which is exactly where the console's pointer work landed. */}
<div
className={`absolute right-2 top-2 flex gap-1 transition-opacity focus-within:opacity-100 group-hover:opacity-100${
hidden ? "" : " opacity-0"
}`}
>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={
hidden ? m.library_unhide_action() : m.library_hide_action()
}
aria-pressed={hidden}
disabled={hiding}
onClick={onToggleHidden}
>
{hidden ? (
<Eye className="size-3.5" />
) : (
<EyeOff className="size-3.5" />
)}
</Button>
{isCustom && (
<>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_edit()}
onClick={onEdit}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_delete()}
disabled={deleting}
onClick={onDelete}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</>
)}
</div>
{isCustom && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_edit()}
onClick={onEdit}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_delete()}
disabled={deleting}
onClick={onDelete}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
)}
</div>
<div
className="truncate px-card pb-card pt-4 text-sm font-medium"
+35 -9
View File
@@ -5,8 +5,9 @@ import {
getGetLibraryQueryKey,
useDeleteCustomGame,
useGetLibrary,
useSetLibraryEntryHidden,
} from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
@@ -23,11 +24,11 @@ import { customId } from "./helpers";
* this subsection knows nothing about the form beyond firing `onEdit`.
*/
export const LibraryGridSection: FC<{
onEdit: (entry: GameEntry) => void;
onEdit: (entry: OperatorGameEntry) => void;
/** Show only entries owned by this provider, or everything when null. */
providerFilter?: string | null;
/** Reports the full (unfiltered) list up, so the providers card can count owners. */
onEntries?: (entries: GameEntry[]) => void;
onEntries?: (entries: OperatorGameEntry[]) => void;
}> = ({ onEdit, providerFilter, onEntries }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
@@ -54,7 +55,7 @@ export const LibraryGridSection: FC<{
// A refused delete has to say so. The host has real reasons to say no (a provider-owned entry
// answers 409 with what to do instead), and an un-caught `mutateAsync` rejection reported none
// of them — the card just stayed put as if nothing had been clicked.
const onDelete = async (entry: GameEntry) => {
const onDelete = async (entry: OperatorGameEntry) => {
const ok = await confirm({
title: m.library_delete_confirm(),
description: m.library_delete_body(),
@@ -71,6 +72,23 @@ export const LibraryGridSection: FC<{
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
};
const setHidden = useSetLibraryEntryHidden();
// Same error discipline as delete: the host can refuse (it cannot persist the settings file),
// and swallowing that would leave the card looking unchanged with no explanation.
const onToggleHidden = async (entry: OperatorGameEntry) => {
try {
await setHidden.mutateAsync({
id: entry.id,
data: { hidden: entry.hidden !== true },
});
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_hide_failed());
return;
}
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
};
return (
<LibraryGrid
library={filtered}
@@ -78,31 +96,39 @@ export const LibraryGridSection: FC<{
onDelete={onDelete}
// The custom id whose delete is in flight (if any), so only that card's button disables.
deletingId={remove.isPending ? (remove.variables?.id ?? null) : null}
onToggleHidden={onToggleHidden}
// Keyed by ENTRY id, not custom id — hiding addresses any store's entry, not just ours.
hidingId={setHidden.isPending ? (setHidden.variables?.id ?? null) : null}
/>
);
};
/** The poster grid (with empty + loading/error states). */
export const LibraryGrid: FC<{
library: Loadable<GameEntry[]>;
onEdit: (entry: GameEntry) => void;
onDelete: (entry: GameEntry) => void;
library: Loadable<OperatorGameEntry[]>;
onEdit: (entry: OperatorGameEntry) => void;
onDelete: (entry: OperatorGameEntry) => void;
/** Custom id of the card whose delete is in flight, or null — only that card disables. */
deletingId: string | null;
}> = ({ library, onEdit, onDelete, deletingId }) => {
onToggleHidden: (entry: OperatorGameEntry) => void;
/** Entry id of the card whose hide/un-hide is in flight, or null. */
hidingId: string | null;
}> = ({ library, onEdit, onDelete, deletingId, onToggleHidden, hidingId }) => {
const all = library.data ?? [];
// Launcher entries (design D4) open the launcher itself — Steam Big Picture, Heroic — rather than
// a title. They launch and lease exactly like games; grouping them into their own rail is purely
// so a shelf of 400 games doesn't bury the two or three ways to open a launcher.
const launchers = all.filter((g) => g.role === "launcher");
const games = all.filter((g) => g.role !== "launcher");
const card = (game: GameEntry) => (
const card = (game: OperatorGameEntry) => (
<GameCard
key={game.id}
game={game}
onEdit={() => onEdit(game)}
onDelete={() => onDelete(game)}
deleting={deletingId === customId(game)}
onToggleHidden={() => onToggleHidden(game)}
hiding={hidingId === game.id}
/>
);
return (
+27
View File
@@ -45,6 +45,8 @@ export const Populated: Story = {
onEdit={noop}
onDelete={noop}
deletingId={null}
onToggleHidden={noop}
hidingId={null}
/>
),
};
@@ -70,6 +72,29 @@ export const WithLaunchers: Story = {
onEdit={noop}
onDelete={noop}
deletingId={null}
onToggleHidden={noop}
hidingId={null}
/>
),
};
/**
* A hidden title, as only the operator's console ever sees it every other surface has it filtered
* out upstream. The poster dims but the badge and the un-hide button stay at full contrast, because
* this card is the only route back.
*/
export const WithHidden: Story = {
render: () => (
<LibraryGrid
library={{
data: library.map((g, i) => (i === 1 ? { ...g, hidden: true } : g)),
...idle,
}}
onEdit={noop}
onDelete={noop}
deletingId={null}
onToggleHidden={noop}
hidingId={null}
/>
),
};
@@ -81,6 +106,8 @@ export const Empty: Story = {
onEdit={noop}
onDelete={noop}
deletingId={null}
onToggleHidden={noop}
hidingId={null}
/>
),
};