feat(host/web): per-scanner library toggles in the console
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m29s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Failing after 24s
ci / bench (push) Successful in 5m31s
deb / build-publish (push) Successful in 9m21s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
android / android (push) Successful in 19m41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish-host (push) Successful in 9m44s
arch / build-publish (push) Successful in 18m17s
ci / rust (push) Successful in 19m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m10s
windows-host / package (push) Successful in 17m47s
docker / deploy-docs (push) Successful in 10s
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m29s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Failing after 24s
ci / bench (push) Successful in 5m31s
deb / build-publish (push) Successful in 9m21s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
android / android (push) Successful in 19m41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish-host (push) Successful in 9m44s
arch / build-publish (push) Successful in 18m17s
ci / rust (push) Successful in 19m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m10s
windows-host / package (push) Successful in 17m47s
docker / deploy-docs (push) Successful in 10s
Every installed-store scanner (Steam; Lutris+Heroic on Linux; Epic/GOG/
Xbox on Windows) was hardwired on. New library-scanners.json persists the
operator's disabled set (default all on; absent/malformed = all on);
all_games() gates each provider, so disabling one hides its titles from
every surface (console grid, native clients, GameStream app list, launch
resolve). GET /library/scanners lists this platform's scanners + state;
PUT /library/scanners/{id} toggles and emits library.changed — admin lane
only (the cert allowlist's exact-path /library match keeps both off the
LAN surface). The console's Library page grows a "Game sources" card with
one chip per scanner (platform-shaped by the API), EN+DE strings, story.
The scanners are slated to become plugins; the stable per-scanner ids are
the migration seam.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1348,6 +1348,117 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/library/scanners": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"library"
|
||||||
|
],
|
||||||
|
"summary": "List the library scanners",
|
||||||
|
"description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.",
|
||||||
|
"operationId": "listLibraryScanners",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "This host's scanners with their enable state",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ScannerInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/library/scanners/{id}": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"library"
|
||||||
|
],
|
||||||
|
"summary": "Enable or disable a library scanner",
|
||||||
|
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.",
|
||||||
|
"operationId": "setLibraryScanner",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The scanner id (e.g. `steam`)",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ScannerToggle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Toggle stored; the full scanner list",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ScannerInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No such scanner on this platform",
|
||||||
|
"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/local/summary": {
|
"/api/v1/local/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4671,6 +4782,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ScannerInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"label",
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether this host runs the scanner (default true)."
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
|
||||||
|
"example": "steam"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-facing name for the console toggle.",
|
||||||
|
"example": "Steam"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ScannerToggle": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Request body for `setLibraryScanner`.",
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the scanner should run on this host."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"SessionInfo": {
|
"SessionInfo": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Client-requested launch parameters (key material is never exposed here).",
|
"description": "Client-requested launch parameters (key material is never exposed here).",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ mod heroic;
|
|||||||
mod launch;
|
mod launch;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod lutris;
|
mod lutris;
|
||||||
|
mod scanners;
|
||||||
mod steam;
|
mod steam;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod xbox;
|
mod xbox;
|
||||||
@@ -46,6 +47,7 @@ pub use heroic::*;
|
|||||||
pub use launch::*;
|
pub use launch::*;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use lutris::*;
|
pub use lutris::*;
|
||||||
|
pub use scanners::*;
|
||||||
pub use steam::*;
|
pub use steam::*;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub use xbox::*;
|
pub use xbox::*;
|
||||||
@@ -161,22 +163,39 @@ impl ArtKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The full library: every store's titles merged + the custom entries, sorted by title.
|
/// The full library: every *enabled* store's titles merged + the custom entries, sorted by title.
|
||||||
|
/// The operator's scanner toggles (`scanners.rs`) gate each installed-store provider; the custom
|
||||||
|
/// store is not a scanner and always contributes.
|
||||||
pub fn all_games() -> Vec<GameEntry> {
|
pub fn all_games() -> Vec<GameEntry> {
|
||||||
let mut games = SteamProvider.list();
|
let off = disabled_scanners();
|
||||||
|
let on = |id: &str| !off.contains(id);
|
||||||
|
let mut games = Vec::new();
|
||||||
|
if on("steam") {
|
||||||
|
games.extend(SteamProvider.list());
|
||||||
|
}
|
||||||
// The Lutris + Heroic providers are Linux-only (their launchers are); on other hosts the library
|
// The Lutris + Heroic providers are Linux-only (their launchers are); on other hosts the library
|
||||||
// is Steam + custom. Each provider is best-effort (empty when its store isn't present).
|
// is Steam + custom. Each provider is best-effort (empty when its store isn't present).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
games.extend(LutrisProvider.list());
|
if on("lutris") {
|
||||||
games.extend(HeroicProvider.list());
|
games.extend(LutrisProvider.list());
|
||||||
|
}
|
||||||
|
if on("heroic") {
|
||||||
|
games.extend(HeroicProvider.list());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Windows store providers (their launchers are Windows-only): Epic + GOG + Xbox/Game Pass.
|
// Windows store providers (their launchers are Windows-only): Epic + GOG + Xbox/Game Pass.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
games.extend(EpicProvider.list());
|
if on("epic") {
|
||||||
games.extend(GogProvider.list());
|
games.extend(EpicProvider.list());
|
||||||
games.extend(XboxProvider.list());
|
}
|
||||||
|
if on("gog") {
|
||||||
|
games.extend(GogProvider.list());
|
||||||
|
}
|
||||||
|
if on("xbox") {
|
||||||
|
games.extend(XboxProvider.list());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
games.extend(load_custom().into_iter().map(GameEntry::from));
|
games.extend(load_custom().into_iter().map(GameEntry::from));
|
||||||
games.sort_by_key(|g| g.title.to_lowercase());
|
games.sort_by_key(|g| g.title.to_lowercase());
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//! Library-scanner settings: which installed-store scanners run on this host. Every scanner is
|
||||||
|
//! **on by default** (the shipped behavior before this existed); the operator can turn one off in
|
||||||
|
//! the web console, which hides its titles from every library surface (console grid, native
|
||||||
|
//! clients, the GameStream app list, launch resolution) from the next read. Only the *disabled*
|
||||||
|
//! set is persisted, so a scanner added in a future build starts enabled without a migration.
|
||||||
|
//!
|
||||||
|
//! The user-curated **custom** store is not a scanner (nothing is scanned — the operator typed the
|
||||||
|
//! entries in) and cannot be disabled here; provider plugins (RFC §8) likewise own their entries
|
||||||
|
//! through the reconcile API. Down the road the scanners themselves are slated to become plugins —
|
||||||
|
//! the stable per-scanner ids this module fixes (`steam`, `lutris`, …, matching each entry's
|
||||||
|
//! `store` field) are the forward seam for that migration.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// One installed-store scanner this host build supports, with its enable state — the unit the
|
||||||
|
/// console renders a toggle for. The list is platform-gated at compile time (the scanners are),
|
||||||
|
/// so the console never shows a toggle that cannot do anything on this host.
|
||||||
|
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||||
|
pub struct ScannerInfo {
|
||||||
|
/// Stable scanner id — the same string the scanner's entries carry in their `store` field.
|
||||||
|
#[schema(example = "steam")]
|
||||||
|
pub id: String,
|
||||||
|
/// Human-facing name for the console toggle.
|
||||||
|
#[schema(example = "Steam")]
|
||||||
|
pub label: String,
|
||||||
|
/// Whether this host runs the scanner (default true).
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are
|
||||||
|
/// platform-gated exactly like their provider modules in `library.rs` — keep the two in sync when
|
||||||
|
/// adding a store.
|
||||||
|
fn scanner_defs() -> Vec<(&'static str, &'static str)> {
|
||||||
|
let mut defs = vec![("steam", "Steam")];
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
defs.push(("lutris", "Lutris"));
|
||||||
|
defs.push(("heroic", "Heroic (Epic / GOG / Amazon)"));
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
defs.push(("epic", "Epic Games Launcher"));
|
||||||
|
defs.push(("gog", "GOG Galaxy"));
|
||||||
|
defs.push(("xbox", "Xbox / Game Pass"));
|
||||||
|
}
|
||||||
|
defs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted shape (`library-scanners.json`): only the ids the operator turned OFF. Absent file =
|
||||||
|
/// nothing disabled = the pre-existing all-scanners-on behavior.
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
struct ScannerSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
disabled: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_path() -> PathBuf {
|
||||||
|
// Same hardened config dir as library.json / hooks.json (see `custom_path` for the rationale).
|
||||||
|
pf_paths::config_dir().join("library-scanners.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the settings (default + non-fatal if the file is absent or malformed).
|
||||||
|
fn load_settings() -> ScannerSettings {
|
||||||
|
match std::fs::read_to_string(settings_path()) {
|
||||||
|
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(error = %e, "library-scanners.json malformed — all scanners on");
|
||||||
|
ScannerSettings::default()
|
||||||
|
}),
|
||||||
|
Err(_) => ScannerSettings::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_settings(settings: &ScannerSettings) -> 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-scanners.json")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The disabled-scanner ids, loaded once per library read ([`all_games`] consults it per store).
|
||||||
|
pub(crate) fn disabled_scanners() -> HashSet<String> {
|
||||||
|
load_settings().disabled.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scanners available on this platform with their current enable state, in the fixed
|
||||||
|
/// definition order (stable for the console).
|
||||||
|
pub fn list_scanners() -> Vec<ScannerInfo> {
|
||||||
|
let off = disabled_scanners();
|
||||||
|
scanner_defs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, label)| ScannerInfo {
|
||||||
|
id: id.to_string(),
|
||||||
|
label: label.to_string(),
|
||||||
|
enabled: !off.contains(id),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable/disable one scanner. `None` when `id` names no scanner available on this platform (the
|
||||||
|
/// mgmt layer maps that to 404 — the console only ever sees this host's own list). Persists and
|
||||||
|
/// emits `library.changed` (source = the scanner id) only when the state actually changed, so a
|
||||||
|
/// repeated PUT is a cheap no-op.
|
||||||
|
pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result<Option<Vec<ScannerInfo>>> {
|
||||||
|
if !scanner_defs().iter().any(|(sid, _)| *sid == id) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let mut settings = load_settings();
|
||||||
|
let was_disabled = settings.disabled.iter().any(|d| d == id);
|
||||||
|
if enabled != was_disabled {
|
||||||
|
return Ok(Some(list_scanners()));
|
||||||
|
}
|
||||||
|
if enabled {
|
||||||
|
settings.disabled.retain(|d| d != id);
|
||||||
|
} else {
|
||||||
|
settings.disabled.push(id.to_string());
|
||||||
|
settings.disabled.sort();
|
||||||
|
settings.disabled.dedup();
|
||||||
|
}
|
||||||
|
save_settings(&settings)?;
|
||||||
|
crate::events::emit(crate::events::EventKind::LibraryChanged {
|
||||||
|
source: id.to_string(),
|
||||||
|
});
|
||||||
|
Ok(Some(list_scanners()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steam_is_always_a_scanner_and_ids_are_unique() {
|
||||||
|
let defs = scanner_defs();
|
||||||
|
assert!(defs.iter().any(|(id, _)| *id == "steam"));
|
||||||
|
let ids: HashSet<_> = defs.iter().map(|(id, _)| *id).collect();
|
||||||
|
assert_eq!(ids.len(), defs.len(), "scanner ids must be unique");
|
||||||
|
// `custom` is a store but never a scanner — the toggle surface must not offer it.
|
||||||
|
assert!(!ids.contains("custom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_or_malformed_settings_mean_all_on() {
|
||||||
|
// The default (absent-file) settings disable nothing — the pre-feature behavior.
|
||||||
|
let s = ScannerSettings::default();
|
||||||
|
assert!(s.disabled.is_empty());
|
||||||
|
let parsed: ScannerSettings = serde_json::from_str("{}").unwrap();
|
||||||
|
assert!(parsed.disabled.is_empty(), "missing key defaults to empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,6 +203,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(session::stop_session))
|
.routes(routes!(session::stop_session))
|
||||||
.routes(routes!(session::request_idr))
|
.routes(routes!(session::request_idr))
|
||||||
.routes(routes!(library::get_library))
|
.routes(routes!(library::get_library))
|
||||||
|
.routes(routes!(library::list_library_scanners))
|
||||||
|
.routes(routes!(library::set_library_scanner))
|
||||||
.routes(routes!(library::create_custom_game))
|
.routes(routes!(library::create_custom_game))
|
||||||
.routes(routes!(
|
.routes(routes!(
|
||||||
library::update_custom_game,
|
library::update_custom_game,
|
||||||
|
|||||||
@@ -45,6 +45,72 @@ pub(crate) async fn get_library(
|
|||||||
Json(games)
|
Json(games)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request body for `setLibraryScanner`.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct ScannerToggle {
|
||||||
|
/// Whether the scanner should run on this host.
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List the library scanners
|
||||||
|
///
|
||||||
|
/// The installed-store scanners this host supports — the list is platform-dependent (Steam
|
||||||
|
/// everywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console
|
||||||
|
/// renders a toggle only for scanners that can do anything here. Scanners default to enabled;
|
||||||
|
/// disabling one hides its titles from every library surface from the next read. The user-curated
|
||||||
|
/// custom store is not a scanner and is always on.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/library/scanners",
|
||||||
|
tag = "library",
|
||||||
|
operation_id = "listLibraryScanners",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "This host's scanners with their enable state", body = [crate::library::ScannerInfo]),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn list_library_scanners() -> Json<Vec<crate::library::ScannerInfo>> {
|
||||||
|
Json(crate::library::list_scanners())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable a library scanner
|
||||||
|
///
|
||||||
|
/// Persists the toggle and applies it from the next library read (no restart). Disabling a scanner
|
||||||
|
/// hides its titles everywhere — the console grid, native clients, and the GameStream app list —
|
||||||
|
/// and re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits
|
||||||
|
/// `library.changed` with the scanner id as `source` when the state changed.
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/library/scanners/{id}",
|
||||||
|
tag = "library",
|
||||||
|
operation_id = "setLibraryScanner",
|
||||||
|
params(("id" = String, Path, description = "The scanner id (e.g. `steam`)")),
|
||||||
|
request_body = ScannerToggle,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Toggle stored; the full scanner list", body = [crate::library::ScannerInfo]),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = NOT_FOUND, description = "No such scanner on this platform", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the settings", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn set_library_scanner(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
ApiJson(toggle): ApiJson<ScannerToggle>,
|
||||||
|
) -> Response {
|
||||||
|
match crate::library::set_scanner_enabled(&id, toggle.enabled) {
|
||||||
|
Ok(Some(scanners)) => {
|
||||||
|
tracing::info!(
|
||||||
|
scanner = %id,
|
||||||
|
enabled = toggle.enabled,
|
||||||
|
"management API: library scanner toggled"
|
||||||
|
);
|
||||||
|
Json(scanners).into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => api_error(StatusCode::NOT_FOUND, "no such scanner on this platform"),
|
||||||
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a custom library entry
|
/// Add a custom library entry
|
||||||
///
|
///
|
||||||
/// Creates a user-curated title (e.g. a non-Steam game, an emulator, a ROM) with caller-supplied
|
/// Creates a user-curated title (e.g. a non-Steam game, an emulator, a ROM) with caller-supplied
|
||||||
|
|||||||
@@ -138,6 +138,13 @@ async fn cert_auth_is_a_read_only_allowlist() {
|
|||||||
"the client roster {p} must require the bearer token, not just a paired cert"
|
"the client roster {p} must require the bearer token, not just a paired cert"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The scanner settings are admin-only in BOTH directions: the exact-path `/api/v1/library`
|
||||||
|
// cert match must not leak the settings GET, and the toggle PUT is operator configuration.
|
||||||
|
assert_eq!(
|
||||||
|
send_cert(&app, get_req("/api/v1/library/scanners"), fp).await,
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"the scanner settings must require the bearer token, not just a paired cert"
|
||||||
|
);
|
||||||
// The plugin directory is admin-only — a paired streaming cert has no business enumerating the
|
// The plugin directory is admin-only — a paired streaming cert has no business enumerating the
|
||||||
// host's running plugins or reaching a plugin UI's proxy credential (plugin-ui-surface §3).
|
// host's running plugins or reaching a plugin UI's proxy credential (plugin-ui-surface §3).
|
||||||
for p in [
|
for p in [
|
||||||
@@ -1309,6 +1316,45 @@ async fn hooks_get_shape_and_put_validation() {
|
|||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ library scanners
|
||||||
|
|
||||||
|
/// The scanner list is platform-shaped and read-only-safe; the toggle rejects unknown ids with
|
||||||
|
/// 404. (A successful toggle PUT would write the developer's real `library-scanners.json`, so the
|
||||||
|
/// write path is exercised only through the unknown-id rejection here — the settings round-trip
|
||||||
|
/// itself is unit-tested in `library::scanners` against pure shapes.)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn library_scanner_list_and_unknown_toggle() {
|
||||||
|
let app = test_app(test_state(), None);
|
||||||
|
|
||||||
|
let (s, json) = send(&app, get_req("/api/v1/library/scanners")).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let scanners = json.as_array().expect("a scanner array");
|
||||||
|
assert!(
|
||||||
|
scanners
|
||||||
|
.iter()
|
||||||
|
.any(|sc| sc["id"] == "steam" && sc["label"].is_string() && sc["enabled"].is_boolean()),
|
||||||
|
"steam must be a scanner on every platform: {json}"
|
||||||
|
);
|
||||||
|
// Only platform-available scanners appear (`custom` is a store, never a scanner).
|
||||||
|
assert!(scanners.iter().all(|sc| sc["id"] != "custom"));
|
||||||
|
|
||||||
|
let (s, json) = send(
|
||||||
|
&app,
|
||||||
|
axum::http::Request::put("/api/v1/library/scanners/not-a-store")
|
||||||
|
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::json!({"enabled": false}).to_string(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
s,
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"unknown scanner id must 404: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ library providers
|
// ------------------------------------------------------------------ library providers
|
||||||
|
|
||||||
/// Provider reconcile validation (the write path itself is unit-tested in `library::custom`
|
/// Provider reconcile validation (the write path itself is unit-tested in `library::custom`
|
||||||
|
|||||||
@@ -170,6 +170,9 @@
|
|||||||
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
||||||
"library_title": "Bibliothek",
|
"library_title": "Bibliothek",
|
||||||
"library_empty": "Noch keine Spiele gefunden.",
|
"library_empty": "Noch keine Spiele gefunden.",
|
||||||
|
"library_sources_title": "Spielequellen",
|
||||||
|
"library_sources_help": "Launcher, die dieser Host nach installierten Spielen durchsucht. Eine Quelle abschalten blendet ihre Spiele auf allen Geräten aus — nichts wird gelöscht, und beim Wiedereinschalten sind sie sofort zurück.",
|
||||||
|
"library_sources_failed": "Die Spielequelle konnte nicht aktualisiert werden.",
|
||||||
"library_store_steam": "Steam",
|
"library_store_steam": "Steam",
|
||||||
"library_store_custom": "Eigene",
|
"library_store_custom": "Eigene",
|
||||||
"library_add_title": "Eigenes Spiel hinzufügen",
|
"library_add_title": "Eigenes Spiel hinzufügen",
|
||||||
|
|||||||
@@ -170,6 +170,9 @@
|
|||||||
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
||||||
"library_title": "Library",
|
"library_title": "Library",
|
||||||
"library_empty": "No games found yet.",
|
"library_empty": "No games found yet.",
|
||||||
|
"library_sources_title": "Game sources",
|
||||||
|
"library_sources_help": "Launchers this host scans for installed games. Turn one off to hide its games from every device — nothing is deleted, and turning it back on brings them right back.",
|
||||||
|
"library_sources_failed": "Could not update the game source.",
|
||||||
"library_store_steam": "Steam",
|
"library_store_steam": "Steam",
|
||||||
"library_store_custom": "Custom",
|
"library_store_custom": "Custom",
|
||||||
"library_add_title": "Add a custom game",
|
"library_add_title": "Add a custom game",
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "@unom/ui/toast";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import {
|
||||||
|
getGetLibraryQueryKey,
|
||||||
|
getListLibraryScannersQueryKey,
|
||||||
|
useListLibraryScanners,
|
||||||
|
useSetLibraryScanner,
|
||||||
|
} from "@/api/gen/library/library";
|
||||||
|
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container: the game-source (library scanner) toggles — owns the scanner query and the toggle
|
||||||
|
* mutation. The host only reports the scanners its platform actually has (Steam everywhere,
|
||||||
|
* Lutris/Heroic on Linux, Epic/GOG/Xbox on Windows), so whatever arrives is renderable as-is.
|
||||||
|
* Rendered only once the list is loaded: this is a secondary control, and when the API is down
|
||||||
|
* the grid's own QueryState already tells the story — no second error banner.
|
||||||
|
*/
|
||||||
|
export const SourceTogglesSection: FC = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const scanners = useListLibraryScanners();
|
||||||
|
const toggle = useSetLibraryScanner();
|
||||||
|
|
||||||
|
const onToggle = async (scanner: ScannerInfo) => {
|
||||||
|
try {
|
||||||
|
// The PUT answers with the full updated list — seed the query cache with it directly,
|
||||||
|
// then refetch the library so the grid reflects the new source set.
|
||||||
|
const list = await toggle.mutateAsync({
|
||||||
|
id: scanner.id,
|
||||||
|
data: { enabled: !scanner.enabled },
|
||||||
|
});
|
||||||
|
qc.setQueryData(getListLibraryScannersQueryKey(), list);
|
||||||
|
await qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||||
|
} catch {
|
||||||
|
toast.error(m.library_sources_failed());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!scanners.data) return null;
|
||||||
|
return (
|
||||||
|
<SourceToggles
|
||||||
|
scanners={scanners.data}
|
||||||
|
busyId={toggle.isPending ? (toggle.variables?.id ?? null) : null}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The sources card: one pressed/unpressed chip per scanner (pressed = the host scans it). */
|
||||||
|
export const SourceToggles: FC<{
|
||||||
|
scanners: ScannerInfo[];
|
||||||
|
/** Scanner id whose toggle is in flight, or null — only that chip disables. */
|
||||||
|
busyId: string | null;
|
||||||
|
onToggle: (scanner: ScannerInfo) => void;
|
||||||
|
}> = ({ scanners, busyId, onToggle }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">{m.library_sources_title()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{scanners.map((scanner) => (
|
||||||
|
<Button
|
||||||
|
key={scanner.id}
|
||||||
|
size="sm"
|
||||||
|
variant={scanner.enabled ? "default" : "outline"}
|
||||||
|
aria-pressed={scanner.enabled}
|
||||||
|
disabled={busyId === scanner.id}
|
||||||
|
onClick={() => onToggle(scanner)}
|
||||||
|
>
|
||||||
|
{scanner.enabled && <Check className="size-4" />}
|
||||||
|
{scanner.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="max-w-prose text-xs text-muted-foreground">
|
||||||
|
{m.library_sources_help()}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -6,6 +6,7 @@ import { useLocale } from "@/lib/i18n";
|
|||||||
import { m } from "@/paraglide/messages";
|
import { m } from "@/paraglide/messages";
|
||||||
import { type FormTarget, GameFormSection } from "./GameForm";
|
import { type FormTarget, GameFormSection } from "./GameForm";
|
||||||
import { LibraryGridSection } from "./LibraryGrid";
|
import { LibraryGridSection } from "./LibraryGrid";
|
||||||
|
import { SourceTogglesSection } from "./SourceToggles";
|
||||||
|
|
||||||
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
|
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
|
||||||
// (LibraryGrid / GameForm) so the two concerns never share a component. This container owns only the
|
// (LibraryGrid / GameForm) so the two concerns never share a component. This container owns only the
|
||||||
@@ -37,6 +38,8 @@ export const SectionLibrary: FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SourceTogglesSection />
|
||||||
|
|
||||||
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
|
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { GameForm } from "@/sections/Library/GameForm";
|
import { GameForm } from "@/sections/Library/GameForm";
|
||||||
import { LibraryGrid } from "@/sections/Library/LibraryGrid";
|
import { LibraryGrid } from "@/sections/Library/LibraryGrid";
|
||||||
|
import { SourceToggles } from "@/sections/Library/SourceToggles";
|
||||||
import { library } from "./lib/fixtures";
|
import { library } from "./lib/fixtures";
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
@@ -46,6 +47,21 @@ export const Empty: Story = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Sources: Story = {
|
||||||
|
render: () => (
|
||||||
|
<SourceToggles
|
||||||
|
// A Linux host's scanner set, one turned off — the widest built-in list.
|
||||||
|
scanners={[
|
||||||
|
{ id: "steam", label: "Steam", enabled: true },
|
||||||
|
{ id: "lutris", label: "Lutris", enabled: false },
|
||||||
|
{ id: "heroic", label: "Heroic (Epic / GOG / Amazon)", enabled: true },
|
||||||
|
]}
|
||||||
|
busyId={null}
|
||||||
|
onToggle={noop}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
export const AddForm: Story = {
|
export const AddForm: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<GameForm
|
<GameForm
|
||||||
|
|||||||
Reference in New Issue
Block a user