feat(gamestream): advertise HDR + surface the game library (with covers) to Moonlight
apple / swift (push) Successful in 1m6s
ci / rust (push) Failing after 1m11s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m2s
android / android (push) Successful in 4m52s
apple / screenshots (push) Successful in 5m20s
windows-host / package (push) Successful in 6m30s
ci / bench (push) Successful in 4m42s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m34s
docker / deploy-docs (push) Successful in 18s
apple / swift (push) Successful in 1m6s
ci / rust (push) Failing after 1m11s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m2s
android / android (push) Successful in 4m52s
apple / screenshots (push) Successful in 5m20s
windows-host / package (push) Successful in 6m30s
ci / bench (push) Successful in 4m42s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m34s
docker / deploy-docs (push) Successful in 18s
Bring the GameStream/Moonlight plane up to the native plane's capability parity. HDR (Windows only): - New host_hdr_capable() gate (Windows + PUNKTFUNK_10BIT, matching the native policy). serverinfo layers SCM_HEVC_MAIN10 onto the probed/static codec mask, so Moonlight finally offers its HDR toggle (live: mask 0x10101 -> 0x10301). - Parse the client's dynamicRangeMode into StreamConfig.hdr and pass it through to OutputFormat::resolve, so a client HDR request proactively enables advanced color on the per-session virtual display (PQ flows even from an SDR desktop). The encoder bit depth now derives from the captured frame format (gs_bit_depth) rather than a hard-coded 8 that mislabeled the already-Main10 HDR stream. Game library in /applist: - The catalog now layers library::all_games() (Steam/Epic/GOG/Xbox/custom) on top of Desktop/apps.json, each with a STABLE GameStream id (FNV-1a, dedup-probed) and the store-qualified library id. Launch routes through the existing security-reviewed launch_title/launch_command via library::launch_gamestream_library — a client can only pick an existing title, never inject a command. - /appasset cover proxy: Moonlight fetches per-app covers from the host, so resolve appid -> library cover URL and proxy the bytes (portrait -> header -> hero -> logo; data: + bounded http(s) fetch), on a blocking thread. IsHdrSupported reflects the host HDR capability. 4:4:4 stays off on GameStream by design: stock Moonlight is 4:2:0 and the Windows IDD-push capturer can't deliver full chroma yet (capturer_supports_444() == false); the gate is documented so it lights up once IDD-push full-chroma capture lands. Validated live (Moonlight -> Windows NVENC host): HDR advertised, the Epic library shows with covers, launch works. clippy clean; apps/serverinfo/library unit tests cover the HDR mask, stable-id, dedup, and data-URL paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1116,6 +1116,58 @@ fn fetch_json(url: &str) -> Option<serde_json::Value> {
|
||||
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<u8>, String)> {
|
||||
use base64::Engine as _;
|
||||
use std::io::Read as _;
|
||||
if let Some(rest) = url.strip_prefix("data:") {
|
||||
// data:[<mediatype>][;base64],<payload>
|
||||
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<u8>, 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<GameEntry> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user