HTTP client, game cover art fetcher, game list image display, use smol::LocalExecutor for async runtime

This commit is contained in:
Aleksander
2025-12-25 21:51:38 +01:00
parent 382735956a
commit bfad91bf72
13 changed files with 614 additions and 199 deletions

View File

@@ -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"

View File

@@ -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<dyn settings::SettingsIO>,
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;

View File

@@ -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(())
}

View File

@@ -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 {

View File

@@ -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<u8>,
}
pub async fn request_image(executor: AsyncExecutor, app_id: AppID) -> anyhow::Result<CoverArt> {
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(),
})
}
}
}

View File

@@ -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<u8>,
}
pub async fn get(executor: &AsyncExecutor, url: &str) -> anyhow::Result<HttpClientResponse> {
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<http_body_util::Empty<&'static [u8]>>,
) -> anyhow::Result<hyper::Response<hyper::body::Incoming>> {
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<TcpStream>),
}
impl AsyncRead for SmolStream {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut [u8]) -> Poll<smol::io::Result<usize>> {
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<smol::io::Result<usize>> {
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<smol::io::Result<()>> {
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<smol::io::Result<()>> {
match &mut *self {
SmolStream::Plain(stream) => Pin::new(stream).poll_flush(cx),
SmolStream::Tls(stream) => Pin::new(stream).poll_flush(cx),
}
}
}

View File

@@ -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;

View File

@@ -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<PathBuf> {
".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<String>,
pub raw_state_flags: u64, // documentation: https://github.com/lutris/lutris/blob/master/docs/steam.rst
pub last_played: Option<u64>, // unix timestamp
}
@@ -119,7 +108,6 @@ fn vdf_parse_appstate<'a>(app_id: AppID, vdf_root: &'a Vdf<'a>) -> Option<AppMan
Some(AppManifest {
app_id: app_id.clone(),
run_game_id: app_id,
cover_b64: None,
name: String::from(name),
raw_state_flags,
last_played,
@@ -161,15 +149,6 @@ pub struct RunningGame {
pub pid: i32,
}
#[derive(Serialize)]
struct Shortcut {
name: String,
exe: String,
run_game_id: u64,
app_id: u64,
cover_b64: Option<String>,
}
pub fn list_running_games() -> anyhow::Result<Vec<RunningGame>> {
let mut res = Vec::<RunningGame>::new();
@@ -191,10 +170,7 @@ pub fn list_running_games() -> anyhow::Result<Vec<RunningGame>> {
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<Option<String>> {
// 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<Vec<Shortcut>, Box<dyn std::error::Error>> {
let userdata_dir = self.steam_root.join("userdata");
let user_dirs = fs::read_dir(userdata_dir)?;
let mut shortcuts: Vec<Shortcut> = 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::<Vec<AppManifest>>();
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));

View File

@@ -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<smol::LocalExecutor<'static>>;
use crate::util::desktop_finder;
// the compiler wants to scream

View File

@@ -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<CoverArt>)),
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<AppID, Cell>,
}
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<Task>) {
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<Task>,
_globals: &WguiGlobals,
manifest: &steam_utils::AppManifest,
) -> anyhow::Result<(WidgetPair, Rc<ComponentButton>)> {
) -> anyhow::Result<(WidgetPair, Rc<ComponentButton>, 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<AppID, Cell>,
games: &Games,
tasks: &Tasks<Task>,
) -> 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<Translation> = 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<CoverArt>,
) -> 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(())
}
}