diff --git a/crates/punktfunk-host/src/gamestream/apps.rs b/crates/punktfunk-host/src/gamestream/apps.rs index 3d90bf9..9741c7f 100644 --- a/crates/punktfunk-host/src/gamestream/apps.rs +++ b/crates/punktfunk-host/src/gamestream/apps.rs @@ -17,6 +17,10 @@ pub struct AppEntry { pub compositor: Option, /// Command gamescope runs nested (gamescope entries only). pub cmd: Option, + /// Store-qualified library id (`steam:570`, `epic:…`) for entries surfaced from the host's game + /// library ([`crate::library`]). When set, the launch path resolves + launches it against the + /// host's own library instead of running [`cmd`](Self::cmd). `None` for Desktop / apps.json entries. + pub library_id: Option, } fn config_path() -> Option { @@ -35,9 +39,18 @@ fn parse_compositor(s: &str) -> Option { } } -/// The catalog: the user's `apps.json` if present, else defaults (Desktop, plus gamescope -/// entries when gamescope is installed). +/// The GameStream catalog Moonlight sees in `/applist`: the operator base ([`base_catalog`] — Desktop + +/// apps.json) with the host's auto-detected game library ([`append_library`]) layered on top, so a +/// Moonlight client sees the same Steam/Epic/GOG/Xbox titles the native clients do instead of just Desktop. pub fn catalog() -> Vec { + let mut apps = base_catalog(); + append_library(&mut apps); + apps +} + +/// The operator base: the user's `apps.json` if present, else defaults (Desktop, plus gamescope +/// entries when gamescope is installed). The installed game library is layered on by [`append_library`]. +fn base_catalog() -> Vec { if let Some(path) = config_path() { if let Ok(raw) = std::fs::read_to_string(&path) { match serde_json::from_str::(&raw) { @@ -53,6 +66,7 @@ pub fn catalog() -> Vec { .and_then(|c| c.as_str()) .and_then(parse_compositor), cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from), + library_id: None, }) }) .collect(); @@ -72,6 +86,7 @@ pub fn catalog() -> Vec { title: "Desktop".into(), compositor: None, cmd: None, + library_id: None, }]; if which("gamescope") { if which("steam") { @@ -80,6 +95,7 @@ pub fn catalog() -> Vec { title: "Steam".into(), compositor: Some(crate::vdisplay::Compositor::Gamescope), cmd: Some("steam -gamepadui".into()), + library_id: None, }); } if which("vkcube") { @@ -88,23 +104,79 @@ pub fn catalog() -> Vec { title: "vkcube (test)".into(), compositor: Some(crate::vdisplay::Compositor::Gamescope), cmd: Some("vkcube".into()), + library_id: None, }); } } apps } +/// The high half of the positive `i32` range — where library-derived GameStream ids live, kept clear of +/// the small Desktop/apps.json ids so the two never collide. +const LIBRARY_ID_BASE: u32 = 0x4000_0000; + +/// Append the host's installed game library ([`crate::library::all_games`] — Steam/Epic/GOG/Xbox/custom) +/// to `apps`. Each title gets a STABLE GameStream `` derived from its store-qualified library id +/// (Moonlight caches appids, so a title keeps its id across host restarts), carries that library id so +/// the launch path resolves it against the host's own library, and is de-duplicated (by id) against the +/// base catalog and the other library entries. Titles with no launch recipe are skipped (un-startable). +fn append_library(apps: &mut Vec) { + let mut used: std::collections::HashSet = apps.iter().map(|a| a.id).collect(); + for g in crate::library::all_games() { + if g.launch.is_none() { + continue; + } + let mut id = stable_app_id(&g.id); + // Linear-probe within the library range on the (rare) hash collision — deterministic given the + // stable all_games() order, so a title keeps its id run to run. + while !used.insert(id) { + id = LIBRARY_ID_BASE | (id.wrapping_add(1) & 0x3FFF_FFFF); + } + apps.push(AppEntry { + id, + title: g.title, + compositor: None, // auto-detect the desktop session (Windows ignores the compositor) + cmd: None, + library_id: Some(g.id), + }); + } +} + +/// A STABLE GameStream `` for a store-qualified library id (`steam:570`): FNV-1a-32 folded into the +/// high half of the positive `i32` range ([`LIBRARY_ID_BASE`]). Deterministic across runs and clear of +/// the reserved small Desktop/apps.json ids. +fn stable_app_id(library_id: &str) -> u32 { + let mut h: u32 = 0x811c_9dc5; + for b in library_id.bytes() { + h ^= b as u32; + h = h.wrapping_mul(0x0100_0193); + } + LIBRARY_ID_BASE | (h & 0x3FFF_FFFF) +} + pub fn by_id(id: u32) -> Option { catalog().into_iter().find(|a| a.id == id) } -/// Render the GameStream `/applist` XML. +/// Box-art bytes for the GameStream `/appasset` cover proxy: resolve the Moonlight appid to its catalog +/// entry, then (for a library title) fetch its cover from the host's library. `(bytes, content-type)`, +/// or `None` for Desktop / apps.json entries (no art) or a fetch failure. Blocking (disk + network) — +/// call off the async runtime. +pub fn appasset_bytes(appid: u32) -> Option<(Vec, String)> { + let lib_id = by_id(appid)?.library_id?; + crate::library::fetch_box_art(&lib_id) +} + +/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver +/// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when +/// true, Moonlight offers its per-app HDR toggle. pub fn applist_xml() -> String { + let hdr = u8::from(crate::gamestream::host_hdr_capable()); let mut xml = String::from("\n\n"); for app in catalog() { xml.push_str(&format!( - "\n0\n{}\n{}\n\n", + "\n{hdr}\n{}\n{}\n\n", xml_escape(&app.title), app.id )); @@ -130,10 +202,46 @@ mod tests { #[test] fn default_catalog_has_desktop() { + // catalog() = base (Desktop + apps.json) + the installed library; Desktop (id 1) is always present. let apps = catalog(); assert!(apps.iter().any(|a| a.id == 1 && a.title == "Desktop")); } + #[test] + fn stable_app_id_is_deterministic_and_in_library_range() { + // Same id every run (Moonlight caches appids), distinct per title, and always in the high + // half of the positive i32 range so it never collides with the small Desktop/apps.json ids. + let a = stable_app_id("steam:570"); + let b = stable_app_id("steam:570"); + let c = stable_app_id("steam:271590"); + assert_eq!(a, b); + assert_ne!(a, c); + for id in [a, c] { + assert!(id >= LIBRARY_ID_BASE, "id {id:#x} below library base"); + assert!(id <= 0x7FFF_FFFF, "id {id:#x} not a positive i32"); + assert_ne!(id, 1, "must not collide with Desktop"); + } + } + + #[test] + fn append_library_dedups_against_base_ids() { + // A base app whose id happens to fall in the library range must not be clobbered by a library + // entry that hashes to it — append_library probes past any used id. + let mut apps = vec![AppEntry { + id: stable_app_id("steam:570"), + title: "Pinned".into(), + compositor: None, + cmd: None, + library_id: None, + }]; + append_library(&mut apps); + let ids: Vec = apps.iter().map(|a| a.id).collect(); + let mut uniq = ids.clone(); + uniq.sort_unstable(); + uniq.dedup(); + assert_eq!(ids.len(), uniq.len(), "duplicate GameStream ids in catalog"); + } + #[test] fn applist_xml_is_wellformed_ish() { let xml = applist_xml(); diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 47b5f34..748ddad 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -48,13 +48,26 @@ pub const SCM_HEVC: u32 = 0x0000_0100; pub const SCM_HEVC_MAIN10: u32 = 0x0000_0200; pub const SCM_AV1_MAIN8: u32 = 0x0001_0000; pub const SCM_AV1_MAIN10: u32 = 0x0002_0000; -/// What we actually encode via NVENC: H.264, HEVC Main, AV1 Main 8-bit (= 65793). The -/// 10-bit flags are deliberately NOT advertised: Moonlight only selects Main10 profiles for -/// HDR streaming, and our capture path is 8-bit SDR BGRx with no HDR metadata plumbing — -/// advertising them would let clients enable an HDR mode we can't deliver. (The previous -/// placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 + 4:4:4 and *no* AV1.) +/// The **SDR baseline** codec mask: H.264, HEVC Main, AV1 Main 8-bit (= 65793). HEVC Main10 (HDR) is +/// layered on top of this at runtime by `serverinfo::codec_mode_support` when — and only when — the +/// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR +/// host (Linux, or a Windows host without the `PUNKTFUNK_10BIT` opt-in) must not invite a client into +/// an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 + +/// 4:4:4 and *no* AV1.) 4:4:4 stays off entirely: stock Moonlight is 4:2:0 and the Windows IDD-push +/// capturer can't yet deliver full-chroma frames (`crate::capture::capturer_supports_444`). pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8; +/// Whether this host can deliver an **HDR** (HEVC Main10 / BT.2020 PQ) GameStream — the single gate +/// for advertising [`SCM_HEVC_MAIN10`] in serverinfo and `IsHdrSupported` per app, and for honoring a +/// client's `dynamicRangeMode` request. HDR capture+encode is **Windows-only** (the Linux host is +/// 8-bit, blocked upstream) and behind the operator's `PUNKTFUNK_10BIT` opt-in — the same policy gate +/// the native punktfunk/1 plane honors. When this is true the IDD-push capturer streams HEVC Main10 PQ +/// whenever the desktop is HDR, and a client HDR request makes the GameStream video path proactively +/// enable advanced color on the per-session virtual display so PQ flows even from an SDR desktop. +pub fn host_hdr_capable() -> bool { + cfg!(target_os = "windows") && crate::config::config().ten_bit +} + /// Stable host identity + advertised capabilities, shared across control-plane handlers. pub struct Host { pub hostname: String, diff --git a/crates/punktfunk-host/src/gamestream/nvhttp.rs b/crates/punktfunk-host/src/gamestream/nvhttp.rs index 67e4961..7c02d25 100644 --- a/crates/punktfunk-host/src/gamestream/nvhttp.rs +++ b/crates/punktfunk-host/src/gamestream/nvhttp.rs @@ -13,8 +13,8 @@ use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_POR use anyhow::{anyhow, Context, Result}; use axum::{ extract::{Query, State}, - http::header, - response::IntoResponse, + http::{header, StatusCode}, + response::{IntoResponse, Response}, routing::get, Extension, Router, }; @@ -64,6 +64,7 @@ fn router(state: Arc, https: bool) -> Router { .route("/serverinfo", get(h_serverinfo)) .route("/pair", get(h_pair)) .route("/applist", get(h_applist)) + .route("/appasset", get(h_appasset)) .route("/launch", get(h_launch)) .route("/resume", get(h_resume)) .route("/cancel", get(h_cancel)) @@ -94,10 +95,32 @@ async fn h_applist( tracing::warn!("applist rejected — client is not paired"); return xml(error_xml()); } - // One app for now: the headless desktop (the wlroots virtual output). xml(super::apps::applist_xml()) } +/// Box-art cover proxy (`/appasset?appid=N&AssetType=2&AssetIdx=0`). Moonlight fetches per-app covers +/// from the HOST, so we resolve the appid to its library title and proxy the cover image bytes (Steam/ +/// Epic CDN, etc.). 404 for Desktop / apps.json entries (no art) or any fetch failure — Moonlight then +/// shows its title-only placeholder. Paired clients only (same gate as `/applist`). The resolve+fetch is +/// blocking (disk + network), so it runs on a blocking thread off the async runtime. +async fn h_appasset( + State(st): State>, + peer: Option>, + Query(q): Query>, +) -> Response { + if !peer_is_paired(&peer, &st) { + tracing::warn!("appasset rejected — client is not paired"); + return StatusCode::FORBIDDEN.into_response(); + } + let Some(appid) = q.get("appid").and_then(|s| s.parse::().ok()) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + match tokio::task::spawn_blocking(move || super::apps::appasset_bytes(appid)).await { + Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(), + _ => StatusCode::NOT_FOUND.into_response(), + } +} + async fn h_launch( State(st): State>, peer: Option>, diff --git a/crates/punktfunk-host/src/gamestream/rtsp.rs b/crates/punktfunk-host/src/gamestream/rtsp.rs index 9da60b5..46953df 100644 --- a/crates/punktfunk-host/src/gamestream/rtsp.rs +++ b/crates/punktfunk-host/src/gamestream/rtsp.rs @@ -357,12 +357,17 @@ fn stream_config(map: &HashMap) -> Option { Some("2") => Codec::Av1, _ => Codec::H264, }; - // 10-bit/HDR request flag. We never advertise the Main10 SCM bits, so a compliant - // client can't ask — if one does anyway, stream 8-bit SDR rather than failing. - if parse_u("x-nv-video[0].dynamicRangeMode").unwrap_or(0) != 0 { + // 10-bit/HDR request (Moonlight sets `dynamicRangeMode != 0` only when it both saw our Main10 SCM + // bit AND the user enabled HDR). Honor it only when the host can actually deliver Main10 (Windows + + // PUNKTFUNK_10BIT, `host_hdr_capable`); when honored, the video path proactively enables advanced + // color on the virtual display so a PQ stream flows even from an SDR desktop. A request we can't + // honor degrades to 8-bit SDR (and a desktop that is ALREADY HDR still streams PQ regardless, since + // the IDD-push capturer follows the display). + let hdr_requested = parse_u("x-nv-video[0].dynamicRangeMode").unwrap_or(0) != 0; + let hdr = hdr_requested && crate::gamestream::host_hdr_capable(); + if hdr_requested && !hdr { tracing::warn!( - "client requested HDR/10-bit (dynamicRangeMode != 0) — not advertised/supported, \ - streaming 8-bit SDR" + "client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR" ); } // Parity floor the client asks for (protects small frames); clamp to a sane max. @@ -377,6 +382,7 @@ fn stream_config(map: &HashMap) -> Option { bitrate_kbps, codec, min_fec, + hdr, }) } diff --git a/crates/punktfunk-host/src/gamestream/serverinfo.rs b/crates/punktfunk-host/src/gamestream/serverinfo.rs index eb3ab01..99fb2c7 100644 --- a/crates/punktfunk-host/src/gamestream/serverinfo.rs +++ b/crates/punktfunk-host/src/gamestream/serverinfo.rs @@ -43,11 +43,33 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String { ) } -/// The `` mask to advertise. On the VAAPI (AMD/Intel) backend it reflects -/// what the GPU can ACTUALLY encode (probed — AV1 is narrow, and an old iGPU might lack HEVC), so a -/// Moonlight client never negotiates a codec the encoder can't open. NVENC and Windows keep the -/// Moonlight-validated static superset. +/// The `` mask to advertise: the SDR baseline ([`base_codec_mode_support`]) plus +/// the HEVC Main10 (HDR) bit when the host can actually deliver HDR ([`apply_hdr`] / +/// [`crate::gamestream::host_hdr_capable`]). Without the Main10 bit Moonlight never offers its HDR +/// toggle; with it, enabling HDR client-side negotiates Main10 and the IDD-push path streams BT.2020 PQ. fn codec_mode_support() -> u32 { + apply_hdr( + base_codec_mode_support(), + crate::gamestream::host_hdr_capable(), + ) +} + +/// Add the HEVC Main10 (HDR) bit to `base` when `hdr` and HEVC is advertised — pure so the +/// HDR-layering is unit-testable without a GPU. (HDR streaming uses HEVC Main10; AV1 Main10 is left +/// off until the GameStream AV1 path is live-confirmed.) +fn apply_hdr(base: u32, hdr: bool) -> u32 { + if hdr && base & super::SCM_HEVC != 0 { + base | super::SCM_HEVC_MAIN10 + } else { + base + } +} + +/// The **SDR baseline** mask. On the VAAPI (AMD/Intel) backend it reflects what the GPU can ACTUALLY +/// encode (probed — AV1 is narrow, and an old iGPU might lack HEVC), so a Moonlight client never +/// negotiates a codec the encoder can't open. NVENC and the GPU-less software path keep the +/// Moonlight-validated static superset. HDR (Main10) is layered on by [`codec_mode_support`]. +fn base_codec_mode_support() -> u32 { #[cfg(target_os = "linux")] if crate::encode::linux_zero_copy_is_vaapi() { if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) { @@ -108,6 +130,22 @@ mod tests { ); } + #[test] + fn apply_hdr_adds_main10_only_when_capable_and_hevc() { + // HDR-capable + HEVC advertised → Main10 added. + assert_eq!( + apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, true), + SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8 | SCM_HEVC_MAIN10 + ); + // Not HDR-capable → baseline unchanged (no HDR claim). + assert_eq!( + apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, false), + SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8 + ); + // HDR-capable but a GPU with no HEVC at all → no Main10 (you can't do Main10 without HEVC). + assert_eq!(apply_hdr(SCM_H264, true), SCM_H264); + } + #[test] fn serverinfo_xml_carries_codec_mask() { let host = Host { diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 8796692..74ab89a 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -28,6 +28,10 @@ pub struct StreamConfig { pub codec: Codec, /// Client's `x-nv-vqos[0].fec.minRequiredFecPackets` — parity floor per FEC block. pub min_fec: u8, + /// Client requested HDR (`dynamicRangeMode != 0`) AND the host can deliver it ([`host_hdr_capable`]). + /// Drives the capturer's proactive advanced-color enable; the encoder picks Main10 from the captured + /// (P010) frame format. Always `false` on a non-HDR host, so the SDR path is unchanged. + pub hdr: bool, } /// Slot for the persistent screen capturer, shared with the control plane and reused across @@ -137,7 +141,15 @@ fn run( let launch_here = compositor != crate::vdisplay::Compositor::Gamescope; #[cfg(any(windows, target_os = "linux"))] if launch_here { - if let Some(cmd) = app + // A library title (Steam/Epic/GOG/Xbox/custom, surfaced in /applist) carries its + // store-qualified id — resolve + launch it against the host's OWN library (the client can + // only pick an existing title, never inject a command). An apps.json entry instead carries + // an operator-typed `cmd`. Library id wins when both are set. + if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) { + if let Err(e) = crate::library::launch_gamestream_library(lib_id) { + tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title"); + } + } else if let Some(cmd) = app .and_then(|a| a.cmd.as_deref()) .filter(|c| !c.trim().is_empty()) { @@ -245,11 +257,13 @@ fn open_gs_virtual_source( refresh_hz: cfg.fps, }) .context("create virtual output at client resolution")?; - // want_hdr=false: GameStream HDR is not negotiated into StreamConfig here (the default WGC backend - // still auto-detects HDR from the output colorspace; only the opt-in IDD-push path streams SDR). + // HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the + // Windows IDD-push path this proactively enables advanced color on the virtual display so a Main10 + // PQ stream flows even from an SDR desktop; an already-HDR desktop streams PQ regardless (the + // capturer follows the display). No-op on Linux (8-bit, and `cfg.hdr` is always false there). let capturer = capture::capture_virtual_output( vout, - capture::OutputFormat::resolve(false), + capture::OutputFormat::resolve(cfg.hdr), crate::session_plan::CaptureBackend::resolve(), ) .context("capture virtual output")?; @@ -257,6 +271,19 @@ fn open_gs_virtual_source( Ok((capturer, compositor)) } +/// The encoder bit depth implied by the captured frame's pixel format: a 10-bit (HDR) source — the +/// Windows IDD-push capturer's `P010`/`Rgb10a2` when the desktop is HDR — opens NVENC as HEVC Main10 +/// (BT.2020 PQ); everything else is 8-bit. The encoder backends already key the real profile off the +/// `format`, so this just keeps the `bit_depth` argument honest (the old hard-coded `8` mislabeled an +/// HDR stream that the format had already promoted to 10-bit). +fn gs_bit_depth(format: crate::capture::PixelFormat) -> u8 { + use crate::capture::PixelFormat; + match format { + PixelFormat::P010 | PixelFormat::Rgb10a2 => 10, + _ => 8, + } +} + /// One frame's packets, handed from the encode thread to the send thread. type PacketBatch = Vec>; @@ -442,9 +469,10 @@ fn stream_body( cfg.fps, cfg.bitrate_kbps as u64 * 1000, frame.is_cuda(), - 8, // GameStream/Moonlight path: 8-bit (its own codec negotiation) + // 8-bit SDR, or 10-bit when the captured frame is HDR (P010) — see `gs_bit_depth`. + gs_bit_depth(frame.format), // GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the - // protocol has no chroma negotiation. 4:4:4 is punktfunk/1-native only. + // Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only. encode::ChromaFormat::Yuv420, ) .context("open video encoder for stream")?; @@ -574,7 +602,7 @@ fn stream_body( cfg.fps, cfg.bitrate_kbps as u64 * 1000, frame.is_cuda(), - 8, + gs_bit_depth(frame.format), encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0 ) .context("reopen encoder after rebuild")?; diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index fd355b8..859f64d 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -1116,6 +1116,58 @@ fn fetch_json(url: &str) -> Option { serde_json::from_str(&body).ok() } +/// Fetch one image URL for the GameStream `/appasset` cover proxy, as `(bytes, content-type)`. Handles +/// `data:` URLs (Lutris inlines art that way) by decoding inline, and `http(s)` URLs by a bounded GET +/// (8 MiB cap so a hostile/huge art URL can't balloon host memory). `None` on any non-image scheme, +/// network/decoder error, or empty body. Blocking (ureq) — call off the async runtime. +fn fetch_image(url: &str) -> Option<(Vec, String)> { + use base64::Engine as _; + use std::io::Read as _; + if let Some(rest) = url.strip_prefix("data:") { + // data:[][;base64], + let (meta, data) = rest.split_once(',')?; + let ctype = meta + .split(';') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("image/jpeg") + .to_string(); + let bytes = if meta.contains(";base64") { + base64::engine::general_purpose::STANDARD.decode(data).ok()? + } else { + data.as_bytes().to_vec() + }; + return (!bytes.is_empty()).then_some((bytes, ctype)); + } + if !(url.starts_with("http://") || url.starts_with("https://")) { + return None; + } + let agent = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(10)) + .build(); + let resp = agent.get(url).call().ok()?; + let ctype = resp.header("Content-Type").unwrap_or("image/jpeg").to_string(); + let mut bytes = Vec::new(); + resp.into_reader() + .take(8 * 1024 * 1024) + .read_to_end(&mut bytes) + .ok()?; + (!bytes.is_empty()).then_some((bytes, ctype)) +} + +/// Resolve + fetch the best box-art cover for a library id (the GameStream `/appasset` proxy — Moonlight +/// fetches per-app covers from the HOST, not the CDN, so we proxy the bytes). Tries the portrait (tall +/// capsule Moonlight wants) → header → hero → logo, returning the first that fetches as +/// `(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, String)> { + 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() + .flatten() + .find_map(|url| fetch_image(&url)) +} + /// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https. fn abs_url(u: &str) -> String { u.strip_prefix("//") @@ -1487,6 +1539,25 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<()> { } } +/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried +/// on the `AppEntry`, resolved from the numeric Moonlight appid). Windows spawns it into the interactive +/// user session ([`launch_title`]); Linux resolves its shell command ([`launch_command`]) and runs it +/// into the live session ([`launch_gamestream_command`]). The id is resolved against the host's OWN +/// library, so a client can only ever pick an existing title — never inject a command. +#[cfg(any(windows, target_os = "linux"))] +pub fn launch_gamestream_library(id: &str) -> Result<()> { + #[cfg(windows)] + { + launch_title(id) + } + #[cfg(target_os = "linux")] + { + let cmd = launch_command(id) + .ok_or_else(|| anyhow::anyhow!("library id '{id}' has no launch recipe"))?; + launch_gamestream_command(&cmd) + } +} + /// The full library: every store's titles merged + the custom entries, sorted by title. pub fn all_games() -> Vec { let mut games = SteamProvider.list(); @@ -1608,6 +1679,18 @@ mod tests { ); } + #[test] + fn fetch_image_decodes_data_url() { + // "Hi" base64 == "SGk=" — the data: branch is pure (no network), so it's deterministic. + let (bytes, ctype) = fetch_image("data:image/png;base64,SGk=").expect("data url decodes"); + assert_eq!(bytes, b"Hi"); + assert_eq!(ctype, "image/png"); + // A non-image scheme is rejected (no launcher art ever points at file://, but be defensive). + assert!(fetch_image("file:///etc/passwd").is_none()); + // Empty payload → None (never serve a 0-byte cover). + assert!(fetch_image("data:image/png;base64,").is_none()); + } + #[test] fn custom_entry_maps_to_game_entry() { let g: GameEntry = CustomEntry {