From bfad91bf724c7585d4a7558cee21adc42fc9be20 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Thu, 25 Dec 2025 21:51:38 +0100 Subject: [PATCH] HTTP client, game cover art fetcher, game list image display, use `smol::LocalExecutor` for async runtime --- Cargo.lock | 298 +++++++++++++++++--- dash-frontend/Cargo.toml | 7 +- dash-frontend/src/frontend.rs | 9 + dash-frontend/src/tab/games.rs | 2 +- dash-frontend/src/tab/mod.rs | 6 +- dash-frontend/src/util/cover_art_fetcher.rs | 42 +++ dash-frontend/src/util/http_client.rs | 134 +++++++++ dash-frontend/src/util/mod.rs | 2 + dash-frontend/src/util/steam_utils.rs | 125 +------- dash-frontend/src/util/various.rs | 4 +- dash-frontend/src/views/game_list.rs | 151 ++++++++-- wlx-common/Cargo.toml | 1 + wlx-common/src/cache_dir.rs | 32 ++- 13 files changed, 614 insertions(+), 199 deletions(-) create mode 100644 dash-frontend/src/util/cover_art_fetcher.rs create mode 100644 dash-frontend/src/util/http_client.rs diff --git a/Cargo.lock b/Cargo.lock index 8f7a713e..9cea9e89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,12 +287,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -398,6 +392,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-native-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec" +dependencies = [ + "futures-util", + "native-tls", + "thiserror 1.0.69", + "url", +] + [[package]] name = "async-net" version = "2.0.0" @@ -795,12 +801,6 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - [[package]] name = "bytemuck" version = "1.24.0" @@ -1198,7 +1198,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1483,17 +1483,20 @@ name = "dash-frontend" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "async-native-tls", "chrono", "gio 0.21.5", "glam", "gtk", + "http-body-util", + "hyper", "keyvalues-parser", "log", "rust-embed", "serde", "serde_json", - "steam_shortcuts_util", + "smol", + "smol-hyper", "wayvr-ipc", "wgui", "wlx-common", @@ -1985,6 +1988,15 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1992,7 +2004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -2006,6 +2018,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2489,6 +2507,25 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.12.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2603,12 +2640,73 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "humantime" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -3378,6 +3476,23 @@ dependencies = [ "pxfm", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3469,17 +3584,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "nom_locate" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" -dependencies = [ - "bytecount", - "memchr", - "nom 7.1.3", -] - [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -3917,6 +4021,50 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "openxr" version = "0.19.0" @@ -4885,6 +5033,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4916,6 +5073,29 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.2.1" @@ -5272,6 +5452,36 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + +[[package]] +name = "smol-hyper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7428a49d323867702cd12b97b08a6b0104f39ec13b49117911f101271321bc1a" +dependencies = [ + "async-executor", + "async-io", + "futures-io", + "hyper", + "pin-project-lite", +] + [[package]] name = "smol_str" version = "0.2.2" @@ -5303,18 +5513,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "steam_shortcuts_util" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0543ebdb23a93b196aceebc53f70cc5a573bb74248a974b3f5fa3883e6a89b6" -dependencies = [ - "ascii", - "crc32fast", - "nom 7.1.3", - "nom_locate", -] - [[package]] name = "strict-num" version = "0.1.1" @@ -5903,6 +6101,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -6082,6 +6286,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -6170,6 +6380,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -7037,6 +7256,7 @@ dependencies = [ "idmap-derive", "log", "serde", + "smol", "wayvr-ipc", "xdg 3.0.0", ] diff --git a/dash-frontend/Cargo.toml b/dash-frontend/Cargo.toml index c1bf4752..72620cc6 100644 --- a/dash-frontend/Cargo.toml +++ b/dash-frontend/Cargo.toml @@ -16,6 +16,9 @@ serde.workspace = true serde_json.workspace = true wlx-common = { path = "../wlx-common" } wayvr-ipc = { path = "../wayvr-ipc", default-features = false } -base64 = "0.22.1" keyvalues-parser = { git = "https://github.com/CosmicHorrorDev/vdf-rs.git", rev = "fc6dcbea9eb13cacb98dea40063f6f56cde6e145" } -steam_shortcuts_util = "1.1.8" +smol = "2.0.2" +hyper = { version = "1.8.1", features = ["client", "http1", "http2"] } +http-body-util = "0.1.3" +async-native-tls = "0.5.0" +smol-hyper = "0.1.1" diff --git a/dash-frontend/src/frontend.rs b/dash-frontend/src/frontend.rs index 0cb0c544..ad4037ce 100644 --- a/dash-frontend/src/frontend.rs +++ b/dash-frontend/src/frontend.rs @@ -25,6 +25,7 @@ use crate::{ util::{ popup_manager::{MountPopupParams, PopupManager, PopupManagerParams}, toast_manager::ToastManager, + various::AsyncExecutor, }, views, }; @@ -43,6 +44,9 @@ pub struct Frontend { pub settings: Box, pub interface: BoxDashInterface, + // async runtime executor + pub executor: AsyncExecutor, + #[allow(dead_code)] state: ParserState, @@ -146,6 +150,7 @@ impl Frontend { toast_manager, window_audio_settings: WguiWindow::default(), view_audio_settings: None, + executor: Rc::new(smol::LocalExecutor::new()), }; // init some things first @@ -172,9 +177,13 @@ impl Frontend { tab.update(TabUpdateParams { layout: &mut layout, interface: &mut self.interface, + executor: &mut self.executor, })?; } + // process async runtime tasks + while self.executor.try_tick() {} + self.tick(width, height, timestep_alpha)?; self.ticks += 1; diff --git a/dash-frontend/src/tab/games.rs b/dash-frontend/src/tab/games.rs index 4bab982a..d2096988 100644 --- a/dash-frontend/src/tab/games.rs +++ b/dash-frontend/src/tab/games.rs @@ -21,7 +21,7 @@ impl Tab for TabGames { } fn update(&mut self, params: super::TabUpdateParams) -> anyhow::Result<()> { - self.view_game_list.update(params.layout, params.interface)?; + self.view_game_list.update(params.layout, params.executor)?; Ok(()) } diff --git a/dash-frontend/src/tab/mod.rs b/dash-frontend/src/tab/mod.rs index 205062b1..e9615f6d 100644 --- a/dash-frontend/src/tab/mod.rs +++ b/dash-frontend/src/tab/mod.rs @@ -4,7 +4,10 @@ use wgui::{ }; use wlx_common::dash_interface::BoxDashInterface; -use crate::frontend::{FrontendTasks, RcFrontend}; +use crate::{ + frontend::{FrontendTasks, RcFrontend}, + util::various::AsyncExecutor, +}; pub mod apps; pub mod games; @@ -35,6 +38,7 @@ pub struct TabParams<'a> { pub struct TabUpdateParams<'a> { pub layout: &'a mut Layout, pub interface: &'a mut BoxDashInterface, + pub executor: &'a mut AsyncExecutor, } pub trait Tab { diff --git a/dash-frontend/src/util/cover_art_fetcher.rs b/dash-frontend/src/util/cover_art_fetcher.rs new file mode 100644 index 00000000..734604f2 --- /dev/null +++ b/dash-frontend/src/util/cover_art_fetcher.rs @@ -0,0 +1,42 @@ +use wlx_common::cache_dir; + +use crate::util::{http_client, steam_utils::AppID, various::AsyncExecutor}; + +pub struct CoverArt { + // can be empty in case if data couldn't be fetched (use a fallback image then) + pub compressed_image_data: Vec, +} + +pub async fn request_image(executor: AsyncExecutor, app_id: AppID) -> anyhow::Result { + let cache_file_path = format!("cover_arts/{}.bin", app_id); + + // check if file already exists in cache directory + if let Some(data) = cache_dir::get_data(&cache_file_path).await { + return Ok(CoverArt { + compressed_image_data: data, + }); + } + + let url = format!( + "https://shared.steamstatic.com/store_item_assets/steam/apps/{}/library_600x900.jpg", + app_id + ); + + match http_client::get(&executor, &url).await { + Ok(response) => { + log::info!("Success"); + cache_dir::set_data(&cache_file_path, &response.data).await?; + Ok(CoverArt { + compressed_image_data: response.data, + }) + } + Err(e) => { + // fetch failed, write an empty file + log::error!("CoverArtFetcher: failed fetch for AppID {}: {}", app_id, e); + cache_dir::set_data(&cache_file_path, &[]).await?; + Ok(CoverArt { + compressed_image_data: Vec::new(), + }) + } + } +} diff --git a/dash-frontend/src/util/http_client.rs b/dash-frontend/src/util/http_client.rs new file mode 100644 index 00000000..b6af702c --- /dev/null +++ b/dash-frontend/src/util/http_client.rs @@ -0,0 +1,134 @@ +// +// example smol+hyper usage derived from +// https://github.com/smol-rs/smol/blob/master/examples/hyper-client.rs +// under Apache-2.0 + MIT license. +// Repository URL: https://github.com/smol-rs/smol +// + +use anyhow::Context as _; +use async_native_tls::TlsStream; +use http_body_util::{BodyStream, Empty}; +use hyper::Request; +use smol::{net::TcpStream, prelude::*}; +use std::convert::TryInto; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use crate::util::various::AsyncExecutor; +pub struct HttpClientResponse { + pub data: Vec, +} + +pub async fn get(executor: &AsyncExecutor, url: &str) -> anyhow::Result { + log::info!("fetching URL \"{}\"", url); + + let url: hyper::Uri = url.try_into()?; + let req = Request::builder() + .header( + hyper::header::HOST, + url.authority().context("invalid authority")?.clone().as_str(), + ) + .uri(url) + .body(Empty::new())?; + + let resp = fetch(executor, req).await?; + + if !resp.status().is_success() { + // non-200 HTTP response + anyhow::bail!("non-200 HTTP response: {}", resp.status().as_str()); + } + + let body = BodyStream::new(resp.into_body()) + .try_fold(Vec::new(), |mut body, chunk| { + if let Some(chunk) = chunk.data_ref() { + body.extend_from_slice(chunk); + } + Ok(body) + }) + .await?; + + Ok(HttpClientResponse { data: body }) +} + +async fn fetch( + ex: &AsyncExecutor, + req: hyper::Request>, +) -> anyhow::Result> { + let io = { + let host = req.uri().host().context("cannot parse host")?; + + match req.uri().scheme_str() { + Some("http") => { + let stream = { + let port = req.uri().port_u16().unwrap_or(80); + smol::net::TcpStream::connect((host, port)).await? + }; + SmolStream::Plain(stream) + } + Some("https") => { + // In case of HTTPS, establish a secure TLS connection first. + let stream = { + let port = req.uri().port_u16().unwrap_or(443); + smol::net::TcpStream::connect((host, port)).await? + }; + let stream = async_native_tls::connect(host, stream).await?; + SmolStream::Tls(stream) + } + scheme => anyhow::bail!("unsupported scheme: {:?}", scheme), + } + }; + + // Spawn the HTTP/1 connection. + let (mut sender, conn) = hyper::client::conn::http1::handshake(smol_hyper::rt::FuturesIo::new(io)).await?; + ex.spawn(async move { + if let Err(e) = conn.await { + println!("Connection failed: {:?}", e); + } + }) + .detach(); + + // Get the result + let result = sender.send_request(req).await?; + Ok(result) +} + +/// A TCP or TCP+TLS connection. +enum SmolStream { + /// A plain TCP connection. + Plain(TcpStream), + + /// A TCP connection secured by TLS. + Tls(TlsStream), +} + +impl AsyncRead for SmolStream { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut [u8]) -> Poll> { + match &mut *self { + SmolStream::Plain(stream) => Pin::new(stream).poll_read(cx, buf), + SmolStream::Tls(stream) => Pin::new(stream).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for SmolStream { + fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + match &mut *self { + SmolStream::Plain(stream) => Pin::new(stream).poll_write(cx, buf), + SmolStream::Tls(stream) => Pin::new(stream).poll_write(cx, buf), + } + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match &mut *self { + SmolStream::Plain(stream) => Pin::new(stream).poll_close(cx), + SmolStream::Tls(stream) => Pin::new(stream).poll_close(cx), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match &mut *self { + SmolStream::Plain(stream) => Pin::new(stream).poll_flush(cx), + SmolStream::Tls(stream) => Pin::new(stream).poll_flush(cx), + } + } +} diff --git a/dash-frontend/src/util/mod.rs b/dash-frontend/src/util/mod.rs index fb9588a3..3577d01b 100644 --- a/dash-frontend/src/util/mod.rs +++ b/dash-frontend/src/util/mod.rs @@ -1,4 +1,6 @@ +pub mod cover_art_fetcher; pub mod desktop_finder; +pub mod http_client; pub mod pactl_wrapper; pub mod popup_manager; pub mod steam_utils; diff --git a/dash-frontend/src/util/steam_utils.rs b/dash-frontend/src/util/steam_utils.rs index 782ffae5..42ceba54 100644 --- a/dash-frontend/src/util/steam_utils.rs +++ b/dash-frontend/src/util/steam_utils.rs @@ -1,11 +1,6 @@ -use base64::{Engine as _, engine::general_purpose}; use keyvalues_parser::{Obj, Vdf}; use serde::{Deserialize, Serialize}; -use std::fs; -use std::io::Read; -use std::path::Path; use std::path::PathBuf; -use steam_shortcuts_util::parse_shortcuts; pub struct SteamUtils { steam_root: PathBuf, @@ -19,12 +14,7 @@ fn get_steam_root() -> anyhow::Result { ".steam/debian-installation", ".var/app/com.valvesoftware.Steam/data/Steam", ]; - let Some(steam_path) = steam_paths - .iter() - .map(|path| home.join(path)) - .filter(|p| p.exists()) - .next() - else { + let Some(steam_path) = steam_paths.iter().map(|path| home.join(path)).find(|p| p.exists()) else { anyhow::bail!("Couldn't find Steam installation in search paths"); }; @@ -38,7 +28,6 @@ pub struct AppManifest { pub app_id: AppID, pub run_game_id: AppID, pub name: String, - pub cover_b64: Option, pub raw_state_flags: u64, // documentation: https://github.com/lutris/lutris/blob/master/docs/steam.rst pub last_played: Option, // unix timestamp } @@ -119,7 +108,6 @@ fn vdf_parse_appstate<'a>(app_id: AppID, vdf_root: &'a Vdf<'a>) -> Option, -} - pub fn list_running_games() -> anyhow::Result> { let mut res = Vec::::new(); @@ -191,10 +170,7 @@ pub fn list_running_games() -> anyhow::Result> { let args: Vec<&str> = cmdline .split(|byte| *byte == 0x00) - .filter_map(|arg| match std::str::from_utf8(arg) { - Ok(arg) => Some(arg), - Err(_) => None, - }) + .filter_map(|arg| std::str::from_utf8(arg).ok()) .collect(); let mut has_steam_launch = false; @@ -251,88 +227,7 @@ fn call_steam(arg: &str) -> anyhow::Result<()> { } } -fn shortcut_to_fake_manifest(shortcut: &Shortcut) -> AppManifest { - AppManifest { - app_id: shortcut.app_id.to_string(), - run_game_id: shortcut.run_game_id.to_string(), - name: shortcut.name.clone(), - cover_b64: shortcut.cover_b64.clone(), - raw_state_flags: 0, // Not applicable for shortcuts, 0 by default - last_played: None, // Steam does not use this for shortcuts - } -} - -fn compute_rungameid(app_id: u32) -> u64 { - (app_id as u64) << 32 | 0x02000000 -} - impl SteamUtils { - fn convert_cover_to_base64(app_id: &u32, original_path: &Path) -> std::io::Result> { - // List of supported extensions with their MIME types - let extensions = [ - ("png", "image/png"), - ("jpg", "image/jpeg"), - ("jpeg", "image/jpeg"), - ("webp", "image/webp"), - ("bmp", "image/bmp"), - ("gif", "image/gif"), - ]; - - for (ext, mime) in extensions.iter() { - let filepath = original_path.join("grid").join(format!("{}p.{}", app_id, ext)); - if filepath.exists() { - let mut file = fs::File::open(&filepath)?; - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer)?; - - let base64_string = general_purpose::STANDARD.encode(&buffer); - let data_uri = format!("data:{};base64,{}", mime, base64_string); - return Ok(Some(data_uri)); - } - } - - Ok(None) - } - - fn list_shortcuts(&self) -> Result, Box> { - let userdata_dir = self.steam_root.join("userdata"); - let user_dirs = fs::read_dir(userdata_dir)?; - - let mut shortcuts: Vec = Vec::new(); - - for user in user_dirs.flatten() { - let config_path = user.path().join("config"); - let shortcut_path = config_path.join("shortcuts.vdf"); - - if !shortcut_path.exists() { - continue; - } - - let content = std::fs::read(&shortcut_path)?; - let shortcuts_data = parse_shortcuts(content.as_slice())?; - - for s in shortcuts_data { - let run_game_id = compute_rungameid(s.app_id); - let cover_base64 = match SteamUtils::convert_cover_to_base64(&s.app_id, &config_path) { - Ok(path) => path, // If successful, use the new path - Err(e) => { - log::error!("Error converting cover for app {}: {}", s.app_id, e); - None - } - }; - shortcuts.push(Shortcut { - name: s.app_name.to_string(), - exe: s.exe.to_string(), - run_game_id, - app_id: s.app_id as u64, - cover_b64: cover_base64, - }); - } - } - - Ok(shortcuts) - } - fn get_dir_steamapps(&self) -> PathBuf { self.steam_root.join("steamapps") } @@ -373,7 +268,11 @@ impl SteamUtils { let manifest = match self.get_app_manifest(app_entry) { Ok(manifest) => manifest, Err(e) => { - log::error!("Failed to get app manifest for AppID {}: {}", app_entry.app_id, e); + log::warn!( + "Failed to get app manifest for AppID {}: {}. This entry won't show.", + app_entry.app_id, + e + ); return None; } }; @@ -381,16 +280,6 @@ impl SteamUtils { }) .collect(); - if let Ok(shortcuts) = self.list_shortcuts() { - let mut fake_manifests = shortcuts - .iter() - .map(shortcut_to_fake_manifest) - .collect::>(); - games.append(&mut fake_manifests); - } else { - log::error!("Failed to read non-Steam shortcuts"); - } - match sort_method { GameSortMethod::NameAsc => { games.sort_by(|a, b| a.name.cmp(&b.name)); diff --git a/dash-frontend/src/util/various.rs b/dash-frontend/src/util/various.rs index 8296009a..65614317 100644 --- a/dash-frontend/src/util/various.rs +++ b/dash-frontend/src/util/various.rs @@ -1,4 +1,4 @@ -use std::{path::PathBuf, str::FromStr}; +use std::{path::PathBuf, rc::Rc, str::FromStr}; use wgui::{ assets::{AssetPath, AssetPathOwned}, globals::WguiGlobals, @@ -12,6 +12,8 @@ use wgui::{ }, }; +pub type AsyncExecutor = Rc>; + use crate::util::desktop_finder; // the compiler wants to scream diff --git a/dash-frontend/src/views/game_list.rs b/dash-frontend/src/views/game_list.rs index 53a2c440..5ffe4ed4 100644 --- a/dash-frontend/src/views/game_list.rs +++ b/dash-frontend/src/views/game_list.rs @@ -1,6 +1,5 @@ -use std::{cell::RefCell, rc::Rc}; +use std::{collections::HashMap, rc::Rc}; -use wayvr_ipc::packet_server::{self, WvrWindowHandle}; use wgui::{ assets::AssetPath, components::{ @@ -13,32 +12,36 @@ use wgui::{ i18n::Translation, layout::{Layout, WidgetID, WidgetPair}, parser::{Fetchable, ParseDocumentParams, ParserState}, + renderer_vk::text::custom_glyph::{CustomGlyphContent, CustomGlyphData}, taffy::{ self, prelude::{length, percent}, }, widget::{ ConstructEssentials, + div::WidgetDiv, + image::{WidgetImage, WidgetImageParams}, label::{WidgetLabel, WidgetLabelParams}, rectangle, util::WLength, }, }; -use wlx_common::dash_interface::BoxDashInterface; use crate::{ frontend::{FrontendTask, FrontendTasks}, task::Tasks, util::{ + cover_art_fetcher::{self, CoverArt}, popup_manager::MountPopupParams, - steam_utils::{self, SteamUtils}, + steam_utils::{self, AppID, SteamUtils}, + various::AsyncExecutor, }, - views::window_options, }; #[derive(Clone)] enum Task { AppManifestClicked(steam_utils::AppManifest), + SetCoverArt((AppID, Rc)), Refresh, } @@ -49,6 +52,10 @@ pub struct Params<'a> { pub parent_id: WidgetID, } +struct Cell { + image_parent: WidgetID, +} + pub struct View { #[allow(dead_code)] pub parser_state: ParserState, @@ -57,6 +64,8 @@ pub struct View { globals: WguiGlobals, id_list_parent: WidgetID, steam_utils: steam_utils::SteamUtils, + + cells: HashMap, } impl View { @@ -83,10 +92,11 @@ impl View { globals: params.globals.clone(), id_list_parent: list_parent.id, steam_utils, + cells: HashMap::new(), }) } - pub fn update(&mut self, layout: &mut Layout, interface: &mut BoxDashInterface) -> anyhow::Result<()> { + pub fn update(&mut self, layout: &mut Layout, executor: &AsyncExecutor) -> anyhow::Result<()> { loop { let tasks = self.tasks.drain(); if tasks.is_empty() { @@ -94,8 +104,9 @@ impl View { } for task in tasks { match task { - Task::Refresh => self.refresh(layout, interface)?, + Task::Refresh => self.refresh(layout, executor)?, Task::AppManifestClicked(manifest) => self.action_app_manifest_clicked(manifest)?, + Task::SetCoverArt((app_id, cover_art)) => self.action_set_cover_art(layout, &app_id, cover_art)?, } } } @@ -114,11 +125,25 @@ const BORDER_COLOR_HOVERED: drawing::Color = drawing::Color::new(1.0, 1.0, 1.0, const GAME_COVER_SIZE_X: f32 = 140.0; const GAME_COVER_SIZE_Y: f32 = 210.0; -pub fn construct_game_cover( +async fn request_cover_image(executor: AsyncExecutor, manifest: steam_utils::AppManifest, tasks: Tasks) { + let cover_art = match cover_art_fetcher::request_image(executor, manifest.app_id.clone()).await { + Ok(cover_art) => cover_art, + Err(e) => { + log::error!("request_cover_image failed: {:?}", e); + return; + } + }; + + tasks.push(Task::SetCoverArt((manifest.app_id, Rc::from(cover_art)))); +} + +fn construct_game_cover( ess: &mut ConstructEssentials, - globals: &WguiGlobals, + executor: &AsyncExecutor, + tasks: &Tasks, + _globals: &WguiGlobals, manifest: &steam_utils::AppManifest, -) -> anyhow::Result<(WidgetPair, Rc)> { +) -> anyhow::Result<(WidgetPair, Rc, Cell)> { let (widget_button, button) = components::button::construct( ess, components::button::Params { @@ -145,6 +170,20 @@ pub fn construct_game_cover( }, )?; + let (image_parent, _) = ess.layout.add_child( + widget_button.id, + WidgetDiv::create(), + taffy::Style { + position: taffy::Position::Absolute, + size: taffy::Size { + width: percent(1.0), + height: percent(1.0), + }, + padding: taffy::Rect::length(2.0), + ..Default::default() + }, + )?; + let rect_gradient = |color: drawing::Color, color2: drawing::Color| { rectangle::WidgetRectangle::create(rectangle::WidgetRectangleParams { color, @@ -166,7 +205,7 @@ pub fn construct_game_cover( }; // top shine - ess.layout.add_child( + let (top_shine, _) = ess.layout.add_child( widget_button.id, rect_gradient( drawing::Color::new(1.0, 1.0, 1.0, 0.25), @@ -175,6 +214,9 @@ pub fn construct_game_cover( rect_gradient_style(taffy::AlignSelf::Baseline, 0.05), )?; + // not optimal, this forces us to create a new pass for every created cover art just to overlay various rectangles at the top of the image cover art + top_shine.widget.state().flags.new_pass = true; + // top white gradient ess.layout.add_child( widget_button.id, @@ -190,7 +232,7 @@ pub fn construct_game_cover( widget_button.id, rect_gradient( drawing::Color::new(0.0, 0.0, 0.0, 0.0), - drawing::Color::new(0.0, 0.0, 0.0, 0.2), + drawing::Color::new(0.0, 0.0, 0.0, 0.25), ), rect_gradient_style(taffy::AlignSelf::End, 0.5), )?; @@ -200,23 +242,35 @@ pub fn construct_game_cover( widget_button.id, rect_gradient( drawing::Color::new(0.0, 0.0, 0.0, 0.0), - drawing::Color::new(0.0, 0.0, 0.0, 0.2), + drawing::Color::new(0.0, 0.0, 0.0, 0.5), ), - rect_gradient_style(taffy::AlignSelf::End, 0.05), + rect_gradient_style(taffy::AlignSelf::End, 0.1), )?; - Ok((widget_button, button)) + // request cover image data from the internet or disk cache + executor + .spawn(request_cover_image(executor.clone(), manifest.clone(), tasks.clone())) + .detach(); + + Ok(( + widget_button, + button, + Cell { + image_parent: image_parent.id, + }, + )) } fn fill_game_list( globals: &WguiGlobals, ess: &mut ConstructEssentials, - interface: &mut BoxDashInterface, + executor: &AsyncExecutor, + cells: &mut HashMap, games: &Games, tasks: &Tasks, ) -> anyhow::Result<()> { for manifest in &games.manifests { - let (_, button) = construct_game_cover(ess, globals, manifest)?; + let (_, button, cell) = construct_game_cover(ess, executor, tasks, globals, manifest)?; button.on_click({ let tasks = tasks.clone(); @@ -226,6 +280,8 @@ fn fill_game_list( Ok(()) }) }); + + cells.insert(manifest.app_id.clone(), cell); } Ok(()) @@ -240,8 +296,9 @@ impl View { Ok(Games { manifests }) } - fn refresh(&mut self, layout: &mut Layout, interface: &mut BoxDashInterface) -> anyhow::Result<()> { + fn refresh(&mut self, layout: &mut Layout, executor: &AsyncExecutor) -> anyhow::Result<()> { layout.remove_children(self.id_list_parent); + self.cells.clear(); let mut text: Option = None; match self.game_list() { @@ -255,7 +312,8 @@ impl View { layout, parent: self.id_list_parent, }, - interface, + executor, + &mut self.cells, &list, &self.tasks, )? @@ -285,11 +343,7 @@ impl View { self.frontend_tasks.push(FrontendTask::MountPopup(MountPopupParams { title: Translation::from_raw_text(&manifest.name), on_content: { - let frontend_tasks = self.frontend_tasks.clone(); - let globals = self.globals.clone(); - let tasks = self.tasks.clone(); - - Rc::new(move |data| { + Rc::new(move |_data| { // todo Ok(()) }) @@ -298,4 +352,53 @@ impl View { Ok(()) } + + fn action_set_cover_art( + &mut self, + layout: &mut Layout, + app_id: &AppID, + cover_art: Rc, + ) -> anyhow::Result<()> { + if cover_art.compressed_image_data.is_empty() { + return Ok(()); // do nothing + } + + let Some(cell) = self.cells.get(app_id) else { + debug_assert!(false); // this shouldn't happen + return Ok(()); + }; + + let glyph_content = match CustomGlyphContent::from_bin_raster(&cover_art.compressed_image_data) { + Ok(c) => c, + Err(e) => { + log::warn!( + "failed to decode cover art image for AppID {} ({:?}), skipping", + app_id, + e + ); + return Ok(()); + } + }; + + let image = WidgetImage::create(WidgetImageParams { + round: WLength::Units(12.0), + glyph_data: Some(CustomGlyphData::new(glyph_content)), + ..Default::default() + }); + + let (a, _) = layout.add_child( + cell.image_parent, + image, + taffy::Style { + size: taffy::Size { + width: percent(1.0), + height: percent(1.0), + }, + ..Default::default() + }, + )?; + a.widget.state().flags.new_pass = true; + + Ok(()) + } } diff --git a/wlx-common/Cargo.toml b/wlx-common/Cargo.toml index 33362690..a241cbec 100644 --- a/wlx-common/Cargo.toml +++ b/wlx-common/Cargo.toml @@ -14,3 +14,4 @@ wayvr-ipc = { path = "../wayvr-ipc", default-features = false } anyhow = { workspace = true } xdg = "3.0" log = { workspace = true } +smol = "2.0.2" diff --git a/wlx-common/src/cache_dir.rs b/wlx-common/src/cache_dir.rs index 94ce9ba4..19b300b5 100644 --- a/wlx-common/src/cache_dir.rs +++ b/wlx-common/src/cache_dir.rs @@ -17,19 +17,25 @@ fn get_cache_root() -> PathBuf { CACHE_ROOT_PATH.clone() } -fn ensure_dir(cache_root_path: &PathBuf) { - let _ = std::fs::create_dir(cache_root_path); -} - -pub fn get_data(data_path: &str) -> Option> { - let mut path = get_cache_root(); - ensure_dir(&path); - path.push(data_path); - std::fs::read(path).ok() -} - -pub fn set_data(data_path: &str, data: &[u8]) -> std::io::Result<()> { +// todo: mutex +pub async fn get_data(data_path: &str) -> Option> { let mut path = get_cache_root(); path.push(data_path); - std::fs::write(path, data) + smol::fs::read(path).await.ok() +} + +// todo: mutex +pub async fn set_data(data_path: &str, data: &[u8]) -> std::io::Result<()> { + let mut path = get_cache_root(); + path.push(data_path); + log::debug!( + "Writing cache data ({} bytes) to path {}", + data.len(), + path.to_string_lossy() + ); + + let mut dir_path = path.clone(); + dir_path.pop(); + smol::fs::create_dir_all(dir_path).await?; // make sure directory is available + smol::fs::write(path, data).await }