HTTP client, game cover art fetcher, game list image display, use smol::LocalExecutor for async runtime
This commit is contained in:
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user