forked from unom/punktfunk
refactor(host/library): launch helpers into launch.rs, art proxy resolves any id
M1 of design/library-scanner-plugins-implementation-plan.md — behavior-frozen groundwork for lifting the six scanners out into plugins. WP1.1: heroic_command/heroic_launch_prefix, epic_launch_uri, gog_spawn, valid_steam_appid and shortcut_gameid move into library/launch.rs with their unit tests. The scanner modules beside it now do enumeration only, so they can be deleted wholesale later without taking launch logic with them (D1). WP1.2: is_local_art_path accepts file:// (the plugin contract) and POSIX absolute paths, excluding the two /-leading shapes the host itself emits (its own /api/ proxy path and protocol-relative CDN URLs). local_art_bytes percent-decodes and converts a file:// value first. The art proxy and fetch_box_art resolve ANY id against library.json before the legacy steam: branch, so a plugin's entries serve art without the host knowing its store. No API change; no user-visible change.
This commit is contained in:
+2
-2
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.23.0"
|
||||
"version": "0.24.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1052,7 +1052,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Fetch one cover-art image for a library entry",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
|
||||
"operationId": "getLibraryArt",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -147,24 +147,91 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
||||
|
||||
/// A stored [`Artwork`] value that is a **local filesystem path** to an image on the host — as
|
||||
/// opposed to an `http(s)`/`data:` URL or an already-relative host proxy path. Provider plugins that
|
||||
/// run on the host (e.g. the Playnite sync plugin) set these: the reconcile payload stays tiny
|
||||
/// (paths, not inlined bytes, so it scales to thousands of titles) and the host serves the bytes
|
||||
/// through the art proxy, exactly like Steam's cache art. Windows-shaped only (`C:\…`, `C:/…`, or a
|
||||
/// `\\server\share` UNC) — Playnite, the only local-art provider, is Windows-only, and this keeps the
|
||||
/// check from ever mistaking the `/api/…` proxy path (or a POSIX abs path) for a local file.
|
||||
/// run on the host (the Playnite sync plugin, and every library scanner plugin) set these: the
|
||||
/// reconcile payload stays tiny (paths, not inlined bytes, so it scales to thousands of titles) and
|
||||
/// the host serves the bytes through the art proxy, exactly like Steam's cache art.
|
||||
///
|
||||
/// Four accepted shapes:
|
||||
/// * `file://…` — the **documented plugin contract** ([`file_url_to_path`]), unambiguous on every
|
||||
/// platform, and what `@punktfunk/plugin-kit/library` emits.
|
||||
/// * `C:\…` / `C:/…` drive-absolute and `\\server\share` UNC — Windows bare paths, kept for
|
||||
/// Playnite back-compat (it predates the `file://` contract).
|
||||
/// * POSIX absolute (`/home/u/covers/x.jpg`) — Lutris covers and Steam's `librarycache`.
|
||||
///
|
||||
/// The POSIX widening is why the two `/`-leading shapes the **host itself emits** must be excluded
|
||||
/// explicitly: its own art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and
|
||||
/// which must survive a second pass unchanged) and a protocol-relative URL (`//cdn/…`, what GOG's and
|
||||
/// Microsoft's catalogs return — see [`abs_url`]). Mistaking either for a file would break the proxy
|
||||
/// round-trip or silently drop CDN art.
|
||||
pub fn is_local_art_path(v: &str) -> bool {
|
||||
if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("data:") {
|
||||
return false;
|
||||
}
|
||||
if v.starts_with("file://") {
|
||||
return true;
|
||||
}
|
||||
let b = v.as_bytes();
|
||||
(b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\")
|
||||
// Windows drive-absolute (`C:\…`, `C:/…`) or UNC (`\\server\share`).
|
||||
if (b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\") {
|
||||
return true;
|
||||
}
|
||||
// POSIX absolute, minus the host's own `/`-leading shapes (see the doc comment).
|
||||
v.starts_with('/') && !v.starts_with("//") && !v.starts_with("/api/")
|
||||
}
|
||||
|
||||
/// Turn a `file://` art value into a plain filesystem path, percent-decoding it. The kit emits
|
||||
/// properly encoded URLs (`file:///home/u/My%20Cover.jpg`); a raw path that happens to contain no
|
||||
/// `%` round-trips either way, which keeps hand-written plugin payloads working.
|
||||
///
|
||||
/// `file:///home/u/c.jpg` → `/home/u/c.jpg`; `file:///C:/covers/c.jpg` → `C:/covers/c.jpg` (Windows
|
||||
/// drive letters arrive after the empty authority's slash); a NON-empty authority
|
||||
/// (`file://nas/share/c.jpg`) is a UNC reference → `\\nas\share\c.jpg`. Anything without the prefix
|
||||
/// is returned untouched.
|
||||
fn file_url_to_path(v: &str) -> std::borrow::Cow<'_, str> {
|
||||
use std::borrow::Cow;
|
||||
let Some(rest) = v.strip_prefix("file://") else {
|
||||
return Cow::Borrowed(v);
|
||||
};
|
||||
let decoded = percent_decode(rest);
|
||||
match decoded.strip_prefix('/') {
|
||||
// `file:///…` — the empty-authority form. A Windows drive letter (`/C:/…`) loses the slash;
|
||||
// a POSIX path keeps it.
|
||||
Some(after) if after.as_bytes().get(1) == Some(&b':') => Cow::Owned(after.to_string()),
|
||||
Some(_) => Cow::Owned(decoded),
|
||||
// `file://server/share/…` — a UNC path in URL clothing.
|
||||
None => Cow::Owned(format!("\\\\{}", decoded.replace('/', "\\"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-decode `%XX` escapes. Invalid escapes are left verbatim (a bare `%` in a real path is far
|
||||
/// likelier than a malformed URL from our own kit), and the result is only ever used as a path that
|
||||
/// must then exist as a regular file — so a wrong decode degrades to "no art", never to a wrong read.
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'%' && i + 2 < b.len() {
|
||||
let hex = |c: u8| (c as char).to_digit(16);
|
||||
if let (Some(hi), Some(lo)) = (hex(b[i + 1]), hex(b[i + 2])) {
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(b[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
|
||||
}
|
||||
|
||||
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
|
||||
/// existing regular file, is empty, or exceeds 16 MiB (a cover never approaches that; the cap bounds
|
||||
/// host memory). Content-type is guessed from the extension.
|
||||
/// host memory). Content-type is guessed from the extension. Accepts every shape
|
||||
/// [`is_local_art_path`] does — a `file://` value is converted to a path first.
|
||||
pub fn local_art_bytes(path: &str) -> Option<(Vec<u8>, String)> {
|
||||
let p = std::path::Path::new(path);
|
||||
let path = file_url_to_path(path);
|
||||
let p = std::path::Path::new(&*path);
|
||||
let meta = std::fs::metadata(p).ok()?;
|
||||
if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 {
|
||||
return None;
|
||||
@@ -221,9 +288,22 @@ pub fn proxy_local_art(id: &str, art: &mut Artwork) {
|
||||
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
|
||||
/// async runtime (e.g. `spawn_blocking`).
|
||||
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
|
||||
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
|
||||
// those kinds directly instead of going through the URL fields.
|
||||
// Same resolution order as the management art proxy (WP1.2): the stored catalog first, for ANY
|
||||
// id, so a library plugin's entries resolve without the warmer knowing its store.
|
||||
if let Some(entry) = entry_for_library_id(id) {
|
||||
return [
|
||||
ArtKind::Portrait,
|
||||
ArtKind::Header,
|
||||
ArtKind::Hero,
|
||||
ArtKind::Logo,
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|kind| art_field(&entry.art, kind))
|
||||
.find_map(|v| resolve_art_bytes(&v));
|
||||
}
|
||||
// Legacy in-host Steam scanner: its `Artwork` fields are relative proxy paths (see `steam_art`)
|
||||
// the *client* resolves against the host — meaningless to `fetch_image`, which expects an
|
||||
// absolute URL. Resolve those kinds directly instead of going through the URL fields.
|
||||
if let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
@@ -237,6 +317,7 @@ pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||
.into_iter()
|
||||
.find_map(|kind| steam_art_bytes(appid, kind));
|
||||
}
|
||||
// The remaining in-host scanners (heroic/lutris/epic/gog/xbox) carry absolute CDN URLs.
|
||||
let g = all_games().into_iter().find(|g| g.id == id)?;
|
||||
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
|
||||
.into_iter()
|
||||
@@ -335,19 +416,60 @@ mod tests {
|
||||
assert!(fetch_image("data:image/png;base64,").is_none());
|
||||
}
|
||||
|
||||
/// The full accept/exclude table (WP1.2). The exclusions are the load-bearing half: two of the
|
||||
/// three `/`-leading shapes here are emitted by the host ITSELF, so a POSIX rule that swallowed
|
||||
/// them would break the proxy round-trip and silently drop CDN art.
|
||||
#[test]
|
||||
fn local_art_path_detection() {
|
||||
// Windows-shaped local paths a provider (Playnite) would store.
|
||||
assert!(is_local_art_path(r"C:\Users\me\cover.jpg"));
|
||||
assert!(is_local_art_path("C:/Users/me/cover.png"));
|
||||
assert!(is_local_art_path(r"\\nas\share\art.jpg"));
|
||||
// URLs and the host proxy path are NOT local files.
|
||||
// The `file://` plugin contract, on both platform shapes.
|
||||
assert!(is_local_art_path("file:///home/u/covers/x.jpg"));
|
||||
assert!(is_local_art_path("file:///C:/covers/x.jpg"));
|
||||
// POSIX absolute — lutris covers, steam librarycache.
|
||||
assert!(is_local_art_path("/home/u/.cache/lutris/coverart/x.jpg"));
|
||||
assert!(is_local_art_path("/var/lib/steam/librarycache/570/h.jpg"));
|
||||
// URLs are NOT local files.
|
||||
assert!(!is_local_art_path("https://cdn/x.jpg"));
|
||||
assert!(!is_local_art_path("http://host/x.jpg"));
|
||||
assert!(!is_local_art_path("data:image/png;base64,AAAA"));
|
||||
// …nor is the host's OWN art-proxy path (it must survive a second `proxy_local_art` pass).
|
||||
assert!(!is_local_art_path(
|
||||
"/api/v1/library/art/custom:abc/portrait"
|
||||
));
|
||||
assert!(!is_local_art_path("/api/v1/library/art/steam:570/hero"));
|
||||
// …nor a protocol-relative CDN URL (what GOG / the MS catalog return — see `abs_url`).
|
||||
assert!(!is_local_art_path("//images.gog.com/abc_vertical.jpg"));
|
||||
// A relative path is not absolute — nothing to serve.
|
||||
assert!(!is_local_art_path("covers/x.jpg"));
|
||||
assert!(!is_local_art_path(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_url_converts_to_a_path_and_percent_decodes() {
|
||||
assert_eq!(file_url_to_path("file:///home/u/c.jpg"), "/home/u/c.jpg");
|
||||
// Percent-encoded spaces — what a correct URL encoder emits for a real-world cover path.
|
||||
assert_eq!(
|
||||
file_url_to_path("file:///home/u/My%20Games/c%2Bx.jpg"),
|
||||
"/home/u/My Games/c+x.jpg"
|
||||
);
|
||||
// Windows drive letters arrive after the empty authority's slash and lose it.
|
||||
assert_eq!(
|
||||
file_url_to_path("file:///C:/covers/c.jpg"),
|
||||
"C:/covers/c.jpg"
|
||||
);
|
||||
// A non-empty authority is a UNC reference.
|
||||
assert_eq!(
|
||||
file_url_to_path("file://nas/share/c.jpg"),
|
||||
r"\\nas\share\c.jpg"
|
||||
);
|
||||
// Non-`file://` values are returned untouched (bare paths still work).
|
||||
assert_eq!(file_url_to_path("/home/u/c.jpg"), "/home/u/c.jpg");
|
||||
assert_eq!(file_url_to_path(r"C:\c.jpg"), r"C:\c.jpg");
|
||||
// A lone `%` (a legal path character) is not mangled into a decode failure.
|
||||
assert_eq!(file_url_to_path("file:///home/100%.jpg"), "/home/100%.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -371,6 +493,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A POSIX local cover — the shape the lutris pilot and the steam plugin emit — makes the whole
|
||||
/// round trip: detected as local, rewritten to the proxy path, and read back as bytes. This is
|
||||
/// the case G4 blocked (Lutris art was inlined as `data:` URLs and blew the 2 MB body limit).
|
||||
#[test]
|
||||
fn posix_local_art_round_trips_through_the_proxy() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-posix-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("cover.jpg");
|
||||
std::fs::write(&f, [9u8, 9, 9]).unwrap();
|
||||
let path = f.to_str().unwrap().to_string();
|
||||
|
||||
let mut art = Artwork {
|
||||
portrait: Some(path.clone()),
|
||||
hero: Some(format!("file://{path}")),
|
||||
logo: Some("https://cdn/l.png".into()),
|
||||
header: None,
|
||||
};
|
||||
// Only on non-Windows is a temp path POSIX-absolute; on Windows it is drive-absolute, which
|
||||
// the pre-existing rule already accepted — either way both fields are local.
|
||||
assert!(is_local_art_path(&path));
|
||||
proxy_local_art("lutris:42", &mut art);
|
||||
assert_eq!(
|
||||
art.portrait.as_deref(),
|
||||
Some("/api/v1/library/art/lutris:42/portrait")
|
||||
);
|
||||
assert_eq!(
|
||||
art.hero.as_deref(),
|
||||
Some("/api/v1/library/art/lutris:42/hero"),
|
||||
"a file:// value is local art too"
|
||||
);
|
||||
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
|
||||
|
||||
// Re-running the rewrite is a no-op — the emitted proxy path must not be mistaken for a file.
|
||||
let before = art.portrait.clone();
|
||||
proxy_local_art("lutris:42", &mut art);
|
||||
assert_eq!(art.portrait, before);
|
||||
|
||||
// Both spellings read back to the same bytes.
|
||||
assert_eq!(local_art_bytes(&path).expect("bare path").0, vec![9, 9, 9]);
|
||||
assert_eq!(
|
||||
local_art_bytes(&format!("file://{path}"))
|
||||
.expect("file url")
|
||||
.0,
|
||||
vec![9, 9, 9]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_art_bytes_reads_a_real_file() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
|
||||
@@ -381,6 +551,15 @@ mod tests {
|
||||
assert_eq!(bytes, vec![1, 2, 3, 4]);
|
||||
assert_eq!(ctype, "image/png");
|
||||
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
|
||||
// A directory is not a servable cover, and neither is a traversal that lands on one — the
|
||||
// "existing REGULAR file" check is the confinement, since a plugin's art values are
|
||||
// operator-trusted paths but must still never turn the proxy into a directory reader.
|
||||
assert!(local_art_bytes(dir.to_str().unwrap()).is_none());
|
||||
let up = dir.join("..").join(dir.file_name().unwrap());
|
||||
assert!(
|
||||
local_art_bytes(up.to_str().unwrap()).is_none(),
|
||||
"dir via .."
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ impl From<CustomEntry> for GameEntry {
|
||||
.unwrap_or_default()
|
||||
.or_hint(&c.detect);
|
||||
GameEntry {
|
||||
id: format!("custom:{}", c.id),
|
||||
id: library_id_for(&c),
|
||||
store: "custom".into(),
|
||||
title: c.title,
|
||||
art: c.art,
|
||||
@@ -133,24 +133,44 @@ pub fn load_custom() -> Vec<CustomEntry> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a custom/provider entry's stored **local** art file for one [`ArtKind`] — the non-Steam
|
||||
/// branch of the art proxy (`GET /library/art/custom:<id>/<kind>`). `id` is the bare custom id (the
|
||||
/// `custom:` prefix already stripped by the handler). `None` if the entry is unknown, has no art of
|
||||
/// that kind, or that art value isn't a servable local file (e.g. an `http` URL the client fetches
|
||||
/// itself). Blocking IO — call off the async runtime.
|
||||
pub fn custom_local_art_bytes(id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
let entry = load_custom().into_iter().find(|e| e.id == id)?;
|
||||
let field = match kind {
|
||||
ArtKind::Portrait => entry.art.portrait,
|
||||
ArtKind::Hero => entry.art.hero,
|
||||
ArtKind::Logo => entry.art.logo,
|
||||
ArtKind::Header => entry.art.header,
|
||||
}?;
|
||||
/// The library id a stored entry surfaces as. **The single source of truth for the mapping** —
|
||||
/// [`From<CustomEntry> for GameEntry`] and every id→entry lookup go through it, so a change to the
|
||||
/// id scheme (the store claims of D2 will make claimed entries `<store>:<external_id>`) lands in one
|
||||
/// place instead of drifting between the catalog and the art proxy.
|
||||
pub(crate) fn library_id_for(e: &CustomEntry) -> String {
|
||||
format!("custom:{}", e.id)
|
||||
}
|
||||
|
||||
/// The stored entry a full **library id** refers to, or `None`. The art proxy resolves *any* id this
|
||||
/// way before falling back to the legacy per-store branches (WP1.2), which is what lets a plugin's
|
||||
/// entries be served regardless of what their ids look like.
|
||||
pub fn entry_for_library_id(library_id: &str) -> Option<CustomEntry> {
|
||||
load_custom()
|
||||
.into_iter()
|
||||
.find(|e| library_id_for(e) == library_id)
|
||||
}
|
||||
|
||||
/// Serve a stored entry's **local** art file for one [`ArtKind`] — the `library.json` branch of the
|
||||
/// art proxy (`GET /library/art/<library id>/<kind>`). `None` if the id names no stored entry, it has
|
||||
/// no art of that kind, or that art value isn't a servable local file (e.g. an `http` URL the client
|
||||
/// fetches itself). Blocking IO — call off the async runtime.
|
||||
pub fn library_local_art_bytes(library_id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
let field = art_field(&entry_for_library_id(library_id)?.art, kind)?;
|
||||
is_local_art_path(&field)
|
||||
.then(|| local_art_bytes(&field))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// One [`Artwork`] field by kind — the tiny mapping the proxy and the box-art ladder share.
|
||||
pub(crate) fn art_field(art: &Artwork, kind: ArtKind) -> Option<String> {
|
||||
match kind {
|
||||
ArtKind::Portrait => art.portrait.clone(),
|
||||
ArtKind::Hero => art.hero.clone(),
|
||||
ArtKind::Logo => art.logo.clone(),
|
||||
ArtKind::Header => art.header.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
|
||||
let dir = pf_paths::config_dir();
|
||||
// Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a
|
||||
@@ -375,13 +395,7 @@ fn emit_changed(source: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
|
||||
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
|
||||
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
|
||||
/// ([`windows_launch_for`]).
|
||||
pub(crate) fn valid_steam_appid(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
// `valid_steam_appid` moved to `launch.rs` (WP1.1) — it validates a launch value, not a store entry.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -186,25 +186,8 @@ fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork>
|
||||
map
|
||||
}
|
||||
|
||||
/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple
|
||||
/// `<namespace>:<catalogItemId>:<appName>` (colons URL-encoded), or a bare `<appName>` fallback.
|
||||
/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
|
||||
let ok = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
};
|
||||
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
|
||||
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
|
||||
[app] if ok(app) => (*app).to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
|
||||
))
|
||||
}
|
||||
// The `epic` launch mapping (`epic_launch_uri`) lives in `launch.rs` (WP1.1) — this module
|
||||
// enumerates, it does not launch.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -236,19 +219,4 @@ mod tests {
|
||||
assert!(epic_entry(&gone, &empty).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn epic_launch_uri_triple_bare_and_guard() {
|
||||
assert_eq!(
|
||||
epic_launch_uri("fn:abc:Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
|
||||
);
|
||||
assert_eq!(
|
||||
epic_launch_uri("Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
|
||||
);
|
||||
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
|
||||
assert!(epic_launch_uri("").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,38 +133,13 @@ fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the spawn `(command line, working dir)` for a `gog` launch value (`exe \t args \t workdir`,
|
||||
/// all host-resolved from the operator's own disk). Direct exe — no shell, no Galaxy.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
||||
let mut parts = value.split('\t');
|
||||
let exe = parts.next().filter(|s| !s.is_empty())?;
|
||||
let args = parts.next().unwrap_or("");
|
||||
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
|
||||
let cmdline = if args.trim().is_empty() {
|
||||
format!("\"{exe}\"")
|
||||
} else {
|
||||
format!("\"{exe}\" {args}")
|
||||
};
|
||||
Some((cmdline, workdir))
|
||||
}
|
||||
// The `gog` launch mapping (`gog_spawn`) lives in `launch.rs` (WP1.1) — this module enumerates and
|
||||
// resolves the spawn triple off disk, but turning that triple into a command line is launch-side.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_spawn_parses_and_guards() {
|
||||
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
|
||||
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
|
||||
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
|
||||
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
|
||||
assert_eq!(cmd2, "\"C:\\g.exe\"");
|
||||
assert!(wd2.is_none());
|
||||
assert!(gog_spawn("").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_play_task_picks_primary_filetask() {
|
||||
|
||||
@@ -128,48 +128,8 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
||||
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
||||
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
|
||||
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
|
||||
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn heroic_command(value: &str) -> Option<String> {
|
||||
let (runner, app) = value.split_once(':')?;
|
||||
if !matches!(runner, "legendary" | "gog" | "nile") {
|
||||
return None;
|
||||
}
|
||||
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
|
||||
if app.is_empty()
|
||||
|| !app
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let prefix = heroic_launch_prefix()?;
|
||||
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
|
||||
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
|
||||
Some(format!(
|
||||
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
|
||||
))
|
||||
}
|
||||
|
||||
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
|
||||
/// root is present. `None` ⇒ Heroic not found, so no launch command.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_launch_prefix() -> Option<String> {
|
||||
let on_path = std::env::var_os("PATH")
|
||||
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
|
||||
if on_path {
|
||||
return Some("heroic".into());
|
||||
}
|
||||
let flatpak = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
|
||||
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
|
||||
}
|
||||
// The `heroic` launch mapping (`heroic_command` + its launcher-prefix probe) lives in `launch.rs`
|
||||
// (WP1.1) — this module enumerates, it does not launch.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! Title launch: resolve a library id / raw command into an executable command line (per-store +
|
||||
//! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5).
|
||||
//!
|
||||
//! This module owns the **whole launch side** of the library: the `kind` vocabulary, its per-kind
|
||||
//! charset validators, and the per-OS resolvers. That split is deliberate and load-bearing — the
|
||||
//! scanner modules beside it do *enumeration only*, so they can be lifted out into library plugins
|
||||
//! without taking any launch logic with them (design/library-scanner-plugins.md D1: a client sends
|
||||
//! only an entry id and the host resolves the [`LaunchSpec`] it holds, which stays true whether the
|
||||
//! entry was enumerated in-process or reconciled in by a plugin).
|
||||
|
||||
use super::custom::valid_steam_appid;
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::heroic::heroic_command;
|
||||
use super::*;
|
||||
#[cfg(windows)]
|
||||
use super::{epic::epic_launch_uri, gog::gog_spawn};
|
||||
|
||||
/// Everything a session needs about the title it is launching, resolved in **one** library scan:
|
||||
/// what to run, what to call it, and how to recognize it once it is running.
|
||||
@@ -191,6 +193,112 @@ fn steam_exe() -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- per-kind launch values (host-owned ABI)
|
||||
//
|
||||
// Each helper below turns a store's launch VALUE — the only part a scanner (or, after extraction, a
|
||||
// library plugin) supplies — into the URI/command line the host actually runs. They live here rather
|
||||
// than beside the enumeration that produces the value because the host keeps owning URI construction
|
||||
// and spawning no matter where the enumeration came from (D1). Every one of them is total and
|
||||
// validating: an unparseable or hostile value yields `None`, never a partially-interpolated command.
|
||||
|
||||
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
|
||||
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
|
||||
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
|
||||
/// ([`windows_launch_for`]).
|
||||
///
|
||||
/// Also accepts the 64-bit non-Steam-shortcut game id ([`shortcut_gameid`]), which is likewise
|
||||
/// digits — the two share the `steam_appid` kind precisely because `rungameid` takes either.
|
||||
pub(crate) fn valid_steam_appid(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
|
||||
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
|
||||
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
|
||||
pub(crate) fn shortcut_gameid(appid: u32) -> u64 {
|
||||
((appid as u64) << 32) | 0x0200_0000
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
||||
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
||||
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
|
||||
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
|
||||
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn heroic_command(value: &str) -> Option<String> {
|
||||
let (runner, app) = value.split_once(':')?;
|
||||
if !matches!(runner, "legendary" | "gog" | "nile") {
|
||||
return None;
|
||||
}
|
||||
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
|
||||
if app.is_empty()
|
||||
|| !app
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let prefix = heroic_launch_prefix()?;
|
||||
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
|
||||
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
|
||||
Some(format!(
|
||||
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
|
||||
))
|
||||
}
|
||||
|
||||
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
|
||||
/// root is present. `None` ⇒ Heroic not found, so no launch command.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_launch_prefix() -> Option<String> {
|
||||
let on_path = std::env::var_os("PATH")
|
||||
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
|
||||
if on_path {
|
||||
return Some("heroic".into());
|
||||
}
|
||||
let flatpak = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
|
||||
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
|
||||
}
|
||||
|
||||
/// Map an `epic` LaunchSpec value to the Epic Games Launcher URI. The value is either the full
|
||||
/// `<namespace>:<catalogItemId>:<appName>` triple (what the manifests carry) or a bare `appName`;
|
||||
/// every part is charset-checked so the URI stays one safe argv token.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
|
||||
let ok = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
};
|
||||
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
|
||||
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
|
||||
[app] if ok(app) => (*app).to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
|
||||
))
|
||||
}
|
||||
|
||||
/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple the scanner
|
||||
/// derived from `goggame-<id>.info` — to a `(command line, working dir)`. GOG games are spawned
|
||||
/// directly (no Galaxy), so the exe is quoted and the arguments ride verbatim.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
||||
let mut parts = value.split('\t');
|
||||
let exe = parts.next().filter(|s| !s.is_empty())?;
|
||||
let args = parts.next().unwrap_or("");
|
||||
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
|
||||
let cmdline = if args.trim().is_empty() {
|
||||
format!("\"{exe}\"")
|
||||
} else {
|
||||
format!("\"{exe}\" {args}")
|
||||
};
|
||||
Some((cmdline, workdir))
|
||||
}
|
||||
|
||||
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
|
||||
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
|
||||
/// through the compositor-aware [`launch_session_command`] instead.
|
||||
@@ -360,6 +468,54 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_appid_validation_accepts_appids_and_shortcut_gameids() {
|
||||
assert!(valid_steam_appid("570"));
|
||||
// The 64-bit shortcut game id shares the `steam_appid` kind — `rungameid` takes either.
|
||||
assert!(valid_steam_appid(
|
||||
&shortcut_gameid(2_456_789_012).to_string()
|
||||
));
|
||||
assert!(!valid_steam_appid(""));
|
||||
assert!(!valid_steam_appid("570; rm -rf ~"));
|
||||
assert!(!valid_steam_appid("-1"));
|
||||
}
|
||||
|
||||
/// Moved here with `shortcut_gameid` (WP1.1): the composed id is launch vocabulary, not
|
||||
/// enumeration — the scanner only supplies the 32-bit appid it read out of `shortcuts.vdf`.
|
||||
#[test]
|
||||
fn shortcut_gameid_composes_appid_and_marker() {
|
||||
let id = shortcut_gameid(0x8000_0000);
|
||||
assert_eq!(id >> 32, 0x8000_0000, "high dword is the shortcut appid");
|
||||
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000, "low dword is the marker");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn epic_launch_uri_triple_bare_and_guard() {
|
||||
assert_eq!(
|
||||
epic_launch_uri("fn:abc:Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
|
||||
);
|
||||
assert_eq!(
|
||||
epic_launch_uri("Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
|
||||
);
|
||||
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
|
||||
assert!(epic_launch_uri("").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_spawn_parses_and_guards() {
|
||||
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
|
||||
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
|
||||
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
|
||||
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
|
||||
assert_eq!(cmd2, "\"C:\\g.exe\"");
|
||||
assert!(wd2.is_none());
|
||||
assert!(gog_spawn("").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_launch_for_maps_and_guards() {
|
||||
|
||||
@@ -426,12 +426,8 @@ fn shortcuts_files() -> Vec<PathBuf> {
|
||||
files
|
||||
}
|
||||
|
||||
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
|
||||
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
|
||||
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
|
||||
fn shortcut_gameid(appid: u32) -> u64 {
|
||||
((appid as u64) << 32) | 0x0200_0000
|
||||
}
|
||||
// `shortcut_gameid` (the 64-bit `rungameid` composition) moved to `launch.rs` (WP1.1) — it is launch
|
||||
// vocabulary; this module only reads the 32-bit appid out of `shortcuts.vdf`.
|
||||
|
||||
/// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
|
||||
/// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
|
||||
@@ -762,12 +758,7 @@ mod tests {
|
||||
assert!(launch.value.bytes().all(|b| b.is_ascii_digit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_gameid_composes_appid_and_marker() {
|
||||
let id = shortcut_gameid(0x8000_0000);
|
||||
assert_eq!(id >> 32, 0x8000_0000); // high dword is the appid
|
||||
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000); // low dword is the shortcut marker
|
||||
}
|
||||
// `shortcut_gameid_composes_appid_and_marker` moved with the function to `launch.rs` (WP1.1).
|
||||
|
||||
#[test]
|
||||
fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() {
|
||||
|
||||
@@ -306,11 +306,12 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
|
||||
/// Fetch one cover-art image for a library entry
|
||||
///
|
||||
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
||||
/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
|
||||
/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
|
||||
/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
|
||||
/// can't predict, in which case this 404s and the client falls through to its next art candidate).
|
||||
/// Only Steam ids are backed today; any other store 404s.
|
||||
/// the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,
|
||||
/// and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back
|
||||
/// to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the
|
||||
/// user's Steam client already shows for it), the public Steam CDN's flat URL convention second
|
||||
/// (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case
|
||||
/// this 404s and the client falls through to its next art candidate).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/library/art/{id}/{kind}",
|
||||
@@ -330,7 +331,20 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
|
||||
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
|
||||
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
|
||||
};
|
||||
// Steam: CDN / local-cache proxy (id `steam:<appid>`).
|
||||
// `library.json` FIRST, for ANY id (WP1.2). Stored entries — manual, provider-synced, and (once
|
||||
// store claims land) a scanner plugin's `steam:570` — all serve their local art file from here,
|
||||
// so the proxy never has to know which store an id belongs to. Steam ids aren't stored today, so
|
||||
// this misses and the legacy branch below still answers them.
|
||||
let stored = {
|
||||
let id = id.clone();
|
||||
tokio::task::spawn_blocking(move || crate::library::library_local_art_bytes(&id, kind))
|
||||
.await
|
||||
};
|
||||
if let Ok(Some((bytes, ctype))) = stored {
|
||||
return ([(header::CONTENT_TYPE, ctype)], bytes).into_response();
|
||||
}
|
||||
// Legacy in-host Steam scanner: local Steam cache, then the flat CDN URL. Retired with the
|
||||
// scanner itself once the steam plugin claims the store (M6).
|
||||
if let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
@@ -344,17 +358,5 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
};
|
||||
}
|
||||
// Custom/provider entry (id `custom:<id>`): serve its stored LOCAL art file — e.g. the Playnite
|
||||
// plugin's covers, reconciled as on-host paths rather than inlined bytes.
|
||||
if let Some(cid) = id.strip_prefix("custom:").map(str::to_owned) {
|
||||
return match tokio::task::spawn_blocking(move || {
|
||||
crate::library::custom_local_art_bytes(&cid, kind)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
};
|
||||
}
|
||||
api_error(StatusCode::NOT_FOUND, "no art proxy for this store")
|
||||
api_error(StatusCode::NOT_FOUND, "no art of that kind for this title")
|
||||
}
|
||||
|
||||
@@ -1543,11 +1543,12 @@ readonly "getHostInfo": <Config extends OperationConfig>(options: { readonly con
|
||||
readonly "getLibrary": <Config extends OperationConfig>(options: { readonly params?: typeof GetLibraryParams.Encoded | undefined; readonly config?: Config | undefined } | undefined) => Effect.Effect<WithOptionalResponse<typeof GetLibrary200.Type, Config>, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibrary401", typeof GetLibrary401.Type>>
|
||||
/**
|
||||
* Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
||||
* the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
|
||||
* it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
|
||||
* convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
|
||||
* can't predict, in which case this 404s and the client falls through to its next art candidate).
|
||||
* Only Steam ids are backed today; any other store 404s.
|
||||
* the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,
|
||||
* and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back
|
||||
* to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the
|
||||
* user's Steam client already shows for it), the public Steam CDN's flat URL convention second
|
||||
* (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case
|
||||
* this 404s and the client falls through to its next art candidate).
|
||||
*/
|
||||
readonly "getLibraryArt": <Config extends OperationConfig>(id: string, kind: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect<WithOptionalResponse<void, Config>, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibraryArt401", typeof GetLibraryArt401.Type> | PunktfunkError<"GetLibraryArt404", typeof GetLibraryArt404.Type>>
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user