From ebab98dbaa28cd26ae178e1ed44300642c90c0b9 Mon Sep 17 00:00:00 2001 From: Vegard Matthey Date: Mon, 27 Jan 2025 21:10:22 +0100 Subject: [PATCH] some minor restructuring and wip errors --- Cargo.toml | 1 + src/lib.rs | 95 +++++++++++++++--------------- src/main.rs | 122 ++------------------------------------- src/steam.rs | 21 +++---- src/ui.rs | 7 ++- src/util.rs | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/watch.rs | 25 ++++---- 7 files changed, 242 insertions(+), 188 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4ba6ab3..b5182e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,4 +22,5 @@ reqwest = "0.12.12" rust-ini = "0.21.1" serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.137" +thiserror = "2.0.11" tokio = { version = "1.43.0", default-features = false, features = ["rt-multi-thread", "macros"] } diff --git a/src/lib.rs b/src/lib.rs index 8ce32a7..fc270ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,13 @@ +use crate::steam::SteamApp; use reqwest::Client; -use steam::App; +use std::path::Path; pub mod steam; #[cfg(test)] mod test; +pub mod ui; pub mod util; +pub mod watch; #[derive(Debug)] pub struct Game { @@ -28,10 +31,10 @@ pub struct Achievement { pub struct Context { pub api_key: String, - pub game_name_cache_path: String, - pub json_cache_path: String, - pub icon_cache_path: String, - pub game_name_list: Vec, + pub game_name_cache_path: Box, + pub json_cache_path: Box, + pub icon_cache_path: Box, + pub game_name_list: Vec, pub dirs: Vec, } @@ -48,7 +51,14 @@ pub async fn get_achievement_data(client: &Client, app_id: u32, context: &Contex app_id, achievements: Vec::new(), }; - let game_data = steam::get_steam_data(client, &context.api_key, app_id, "en", &context.json_cache_path).await; + let game_data = steam::get_steam_data( + client, + &context.api_key, + app_id, + "en", + &context.json_cache_path, + ) + .await; let ini_data = steam::get_achievement_data_all_ini(&context.dirs); let ini_game = ini_data.iter().find(|m| m.id == app_id)?; for json_achievement in &game_data.game.available_game_stats.achievements { @@ -75,7 +85,8 @@ pub async fn get_achievement_data(client: &Client, app_id: u32, context: &Contex achievement.max_progress = ini_achievement.max_progress; achievement.current_progress = ini_achievement.current_progress; achievement.unlock_time = ini_achievement.unlock_time; - } + break; + } } game.achievements.push(achievement); } @@ -98,50 +109,42 @@ pub async fn get_achievement_data_all(client: &Client, context: &Context) -> Vec .await .unwrap(), }; - let game_data = steam::get_steam_data(client, &context.api_key, ini_game.id, "en", &context.json_cache_path).await; + let game_data = steam::get_steam_data( + client, + &context.api_key, + ini_game.id, + "en", + &context.json_cache_path, + ) + .await; for json_achievement in &game_data.game.available_game_stats.achievements { - let mut found_in_ini = false; + let mut achievement = Achievement { + achieved: false, + unlock_time: 0, + max_progress: 0, + current_progress: 0, + description: json_achievement.description.clone(), + name: json_achievement.display_name.clone(), + hidden: if json_achievement.hidden == 1 { + true + } else if json_achievement.hidden == 0 { + false + } else { + panic!("Unexpected hidden value") + }, + icon: json_achievement.icon.clone(), + icongray: json_achievement.icongray.clone(), + }; for ini_achievement in &ini_game.achievements { if json_achievement.name == ini_achievement.name { - game.achievements.push(Achievement { - achieved: ini_achievement.achieved, - max_progress: ini_achievement.max_progress, - current_progress: ini_achievement.current_progress, - description: json_achievement.description.clone(), - name: json_achievement.display_name.clone(), - hidden: if json_achievement.hidden == 1 { - true - } else if json_achievement.hidden == 0 { - false - } else { - panic!("Unexpected hidden value") - }, - icon: json_achievement.icon.clone(), - icongray: json_achievement.icongray.clone(), - unlock_time: ini_achievement.unlock_time, - }); - found_in_ini = true; + achievement.achieved = ini_achievement.achieved; + achievement.unlock_time = ini_achievement.unlock_time; + achievement.max_progress = ini_achievement.max_progress; + achievement.current_progress = ini_achievement.current_progress; + break; } } - if !found_in_ini { - game.achievements.push(Achievement { - achieved: false, - max_progress: 0, - current_progress: 0, - unlock_time: 0, - description: json_achievement.description.clone(), - name: json_achievement.display_name.clone(), - hidden: if json_achievement.hidden == 1 { - true - } else if json_achievement.hidden == 0 { - false - } else { - panic!("Unexpected hidden value") - }, - icon: json_achievement.icon.clone(), - icongray: json_achievement.icongray.clone(), - }); - } + game.achievements.push(achievement); } games.push(game); } diff --git a/src/main.rs b/src/main.rs index 9bd1421..cebcc6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,19 @@ -use achievement_watcher::{Context, Game}; +use achievement_watcher::{ui, util, watch}; use reqwest::Client; -use std::{env, fs}; - -mod ui; -mod watch; #[tokio::main] async fn main() { let client = Client::new(); - let context = match setup() { - Some(v) => v, - None => { - eprintln!("ERROR: was not able to create context"); + let context = match util::setup() { + Ok(v) => v, + Err(e) => { + eprintln!("ERROR: {e}"); std::process::exit(1); } }; let mut games = achievement_watcher::get_achievement_data_all(&client, &context).await; - cache_icons(&context, &games).await; + util::cache_icons(&context, &games).await; let args: Vec = std::env::args().skip(1).collect(); if args.is_empty() { @@ -34,109 +30,3 @@ async fn main() { panic!("wrong arg count"); } } - -fn setup() -> Option { - let config_dir = dirs::config_dir()?.to_str()?.to_owned() + "/achievement-watcher"; - let cache_dir = dirs::cache_dir()?.to_str()?.to_owned() + "/achievement-watcher"; - match std::fs::exists(&config_dir) { - Ok(false) => std::fs::create_dir(&config_dir).ok()?, - Err(e) => eprintln!("error in setup: config dir: {e}"), - Ok(true) => (), - } - match std::fs::exists(&cache_dir) { - Ok(false) => std::fs::create_dir(&cache_dir).ok()?, - Err(e) => eprintln!("error in setup: cache dir: {e}"), - Ok(true) => (), - } - let game_name_cache_path = format!("{cache_dir}/game_names.txt"); - let json_cache_path = format!("{cache_dir}/json"); - let icon_cache_path = format!("{cache_dir}/icon"); - let game_name_list = achievement_watcher::util::get_game_name_file_cache(&game_name_cache_path); - let api_key_path = format!("{config_dir}/api-key.txt"); - let api_key = match fs::read_to_string(&api_key_path) { - Ok(v) => v, - Err(e) => { - eprintln!("Failed to read api key: {e} from: {api_key_path}"); - std::process::exit(1); - } - }; - let api_key = api_key.trim(); - let wine_dir = env::var("WINEPREFIX").unwrap_or(env::var("HOME").ok()? + ".wine"); - let dirs = vec![ - wine_dir.clone() + "/drive_c/users/Public/Documents/Steam/CODEX", - wine_dir.clone() + "/drive_c/users/Public/Documents/Steam/RUNE", - ]; - Some(Context { - api_key: api_key.to_owned(), - dirs, - game_name_list, - game_name_cache_path, - json_cache_path, - icon_cache_path, - }) -} - -async fn cache_icons(context: &Context, games: &Vec) { - let client = Client::new(); - let mut futures = Vec::new(); - for game in games { - let header = client - .get(format!( - "https://cdn.cloudflare.steamstatic.com/steam/apps/{}/header.jpg", - game.app_id - )) - .send() - .await - .unwrap() - .bytes() - .await - .unwrap(); - fs::write( - &format!("{}/{}/header.jpg", context.icon_cache_path, game.name), - header, - ) - .unwrap(); - for achievement in &game.achievements { - let client = &client; - let future = async move { - let icon_dir = &format!("{}/{}", &context.icon_cache_path, game.name); - if !std::path::Path::new(icon_dir).exists() { - std::fs::create_dir(icon_dir).unwrap(); - } - let icon_path = format!( - "{}/{}/{}.png", - &context.icon_cache_path, game.name, achievement.name, - ); - if !std::path::Path::new(&icon_path).exists() { - let data = client - .get(&achievement.icon) - .send() - .await - .unwrap() - .bytes() - .await - .unwrap(); - std::fs::write(&icon_path, &data).unwrap(); - } - - let icon_path = format!( - "{}/{}/{}gray.png", - context.icon_cache_path, game.name, achievement.name, - ); - if !std::path::Path::new(&icon_path).exists() { - let data = client - .get(&achievement.icongray) - .send() - .await - .unwrap() - .bytes() - .await - .unwrap(); - std::fs::write(&icon_path, &data).unwrap(); - } - }; - futures.push(future); - } - } - futures::future::join_all(futures).await; -} diff --git a/src/steam.rs b/src/steam.rs index f97f25b..ae7d196 100644 --- a/src/steam.rs +++ b/src/steam.rs @@ -2,7 +2,9 @@ use ini::Ini; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::fs; -use std::path::Path; +use std::fs::OpenOptions; +use std::io::prelude::*; +use std::path::{Path, PathBuf}; #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] @@ -60,12 +62,12 @@ pub struct AppList { #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Apps { - pub apps: Vec, + pub apps: Vec, } #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] -pub struct App { +pub struct SteamApp { pub name: String, pub appid: u32, } @@ -96,10 +98,11 @@ pub async fn get_steam_data( key: &str, appid: u32, lang: &str, - json_cache_path: &str, + json_cache_path: &Path, ) -> JsonGame { - let json_file_path = format!("{json_cache_path}/{appid}.json"); - if Path::new(&json_file_path).exists() { + let mut json_file_path: PathBuf = json_cache_path.to_path_buf(); + json_file_path.push(format!("{appid}.json")); + if json_file_path.exists() { let json = fs::read_to_string(json_file_path).unwrap(); return serde_json::from_str(&json).unwrap(); } @@ -111,12 +114,10 @@ pub async fn get_steam_data( pub async fn get_game_name( client: &Client, - path: &str, - game_name_list: &Vec, + path: &Path, + game_name_list: &Vec, appid: u32, ) -> Option { - use std::fs::OpenOptions; - use std::io::prelude::*; for app in game_name_list { if app.appid == appid { return Some(app.name.clone()); diff --git a/src/ui.rs b/src/ui.rs index a1f03f0..bc2f058 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,4 +1,4 @@ -use achievement_watcher::{Context, Game}; +use crate::{Context, Game}; use eframe::egui; use std::path::Path; @@ -30,7 +30,8 @@ pub fn ui(context: Context, games: Vec) -> eframe::Result { ui.add( egui::Image::new(format!( "file://{}/{}/header.jpg", - context.icon_cache_path, game.name + context.icon_cache_path.to_str().unwrap(), + game.name )) .fit_to_original_size(1.), ); @@ -51,7 +52,7 @@ pub fn ui(context: Context, games: Vec) -> eframe::Result { } let icon_path = format!( "{}/{}/{}{}.png", - context.icon_cache_path, + context.icon_cache_path.to_str().unwrap(), game.name, achievement.name, if achievement.achieved { "" } else { "gray" } diff --git a/src/util.rs b/src/util.rs index 0a27e76..b6ad993 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,12 +1,40 @@ -use crate::steam::App; +use std::path::PathBuf; +use crate::{steam::SteamApp, Context, Game}; +use thiserror::Error; +use reqwest::Client; +use std::{env, fs}; -pub fn get_game_name_file_cache(path: &str) -> Vec { +#[derive(Error, Debug)] +pub enum SetupError { + #[error("failed to get configuration directory")] + GetConfigDir, + #[error("failed to create configuration directory: \"{0}\"")] + CreateConfigDir(std::io::Error, PathBuf), + #[error("failed to get cache directory")] + GetCacheDir, + #[error("failed to create cache directory: \"{1}\", with err: \"{0}\"")] + CreateCacheDir(std::io::Error, PathBuf), + + #[error("failed to create cache file: \"{1}\", with err: \"{0}\"")] + CreateCacheFile(std::io::Error, PathBuf), + + #[error("api key does not exist: \"{0}\"")] + NoApiKey(PathBuf), + + #[error("failed to read api key from path: \"{1}\", with err: \"{0}\"")] + ReadApiKey(std::io::Error, PathBuf), + + #[error("no home dir found: \"{0}\"")] + NoHomeDir(std::env::VarError), +} + +pub fn get_game_name_file_cache(path: &PathBuf) -> Vec { std::fs::read_to_string(path) .unwrap() .lines() .map(|m| { let a = m.split_once(":").unwrap(); - App { + SteamApp { appid: a.0.parse().unwrap(), name: a.1.to_owned(), } @@ -34,3 +62,128 @@ pub fn send_notification( .wait() .unwrap(); } + + +pub fn setup() -> Result { + let mut config_dir = dirs::config_dir().ok_or(SetupError::GetConfigDir)?; + config_dir.push("achievement-watcher"); + if !config_dir.exists() { + fs::create_dir(&config_dir) + .map_err(|e| SetupError::CreateConfigDir(e, config_dir.clone()))?; + } + + let mut cache_dir = dirs::cache_dir().ok_or(SetupError::GetCacheDir)?; + cache_dir.push("achievement-watcher"); + if !cache_dir.exists() { + fs::create_dir(&cache_dir).map_err(|e| SetupError::CreateCacheDir(e, cache_dir.clone()))?; + } + + let mut json_cache_path = cache_dir.clone(); + json_cache_path.push("json"); + if !json_cache_path.exists() { + fs::create_dir(&json_cache_path) + .map_err(|e| SetupError::CreateCacheDir(e, json_cache_path.clone()))?; + } + + let mut icon_cache_path = cache_dir.clone(); + icon_cache_path.push("icon"); + if !icon_cache_path.exists() { + fs::create_dir(&icon_cache_path) + .map_err(|e| SetupError::CreateCacheDir(e, icon_cache_path.clone()))?; + } + + let mut game_name_cache_path = cache_dir.clone(); + game_name_cache_path.push("game_names.txt"); + if !game_name_cache_path.exists() { + fs::write(&game_name_cache_path, "") + .map_err(|e| SetupError::CreateCacheFile(e, game_name_cache_path.clone()))?; + } + + let game_name_list = crate::util::get_game_name_file_cache(&game_name_cache_path); + + let mut api_key_path = config_dir.clone(); + api_key_path.push("api-key.txt"); + if !api_key_path.exists() { + return Err(SetupError::NoApiKey(api_key_path.clone())); + } + + let api_key = std::fs::read_to_string(&api_key_path) + .map_err(|e| SetupError::ReadApiKey(e, api_key_path))?; + let api_key = api_key.trim(); + + let wine_dir = env::var("WINEPREFIX") + .unwrap_or(env::var("HOME").map_err(SetupError::NoHomeDir)? + ".wine"); + let dirs = vec![ + wine_dir.clone() + "/drive_c/users/Public/Documents/Steam/CODEX", + wine_dir.clone() + "/drive_c/users/Public/Documents/Steam/RUNE", + ]; + Ok(Context { + api_key: api_key.to_owned(), + dirs, + game_name_list, + game_name_cache_path: game_name_cache_path.into(), + json_cache_path: json_cache_path.into(), + icon_cache_path: icon_cache_path.into(), + }) +} + +pub async fn cache_icons(context: &Context, games: &Vec) { + let client = Client::new(); + let mut futures = Vec::new(); + for game in games { + let mut icon_dir: PathBuf = context.icon_cache_path.to_path_buf(); + icon_dir.push(&game.name); + if !icon_dir.exists() { + std::fs::create_dir(&icon_dir).unwrap(); + } + let header = client + .get(format!( + "https://cdn.cloudflare.steamstatic.com/steam/apps/{}/header.jpg", + game.app_id + )) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + let mut header_path = icon_dir.clone(); + header_path.push("header.jpg"); + fs::write(header_path, header).unwrap(); + for achievement in &game.achievements { + let client = &client; + let mut icon_path = icon_dir.clone(); + let mut icon_path_gray = icon_dir.clone(); + let future = async move { + icon_path.push(format!("{}.png", achievement.name)); + if !icon_path.exists() { + let data = client + .get(&achievement.icon) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + std::fs::write(&icon_path, &data).unwrap(); + } + drop(icon_path); + + icon_path_gray.push(format!("{}gray.png", achievement.name)); + if !icon_path_gray.exists() { + let data = client + .get(&achievement.icongray) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + std::fs::write(&icon_path_gray, &data).unwrap(); + } + }; + futures.push(future); + } + } + futures::future::join_all(futures).await; +} diff --git a/src/watch.rs b/src/watch.rs index 77780a9..d45a5ac 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -1,10 +1,11 @@ -use achievement_watcher::{util, Achievement, Context, Game}; +use crate::{Achievement, Context, Game}; +use crate::util; use notify::{event::AccessKind, Event, EventKind, RecursiveMode, Result, Watcher}; use reqwest::Client; use std::path::Path; use std::sync::mpsc; -pub async fn watch_files(context: &Context, games: &mut Vec) -> Result<()> { +pub async fn watch_files(context: &Context, games: &mut [Game]) -> Result<()> { let (tx, rx) = mpsc::channel::>(); let mut watcher = notify::recommended_watcher(tx)?; let client = Client::new(); @@ -43,11 +44,11 @@ pub async fn watch_files(context: &Context, games: &mut Vec) -> Result<()> async fn check_for_new_achievement( client: &Client, - games: &mut Vec, + games: &mut [Game], app_id: u32, context: &Context, ) { - let new = achievement_watcher::get_achievement_data(&client, app_id, context) + let new = crate::get_achievement_data(client, app_id, context) .await .unwrap(); let old = games.iter().find(|m| m.app_id == app_id); @@ -57,8 +58,11 @@ async fn check_for_new_achievement( let new_count = new.achievements.iter().filter(|m| m.achieved).count(); let old_count = old.achievements.iter().filter(|m| m.achieved).count(); if new_count <= old_count { - if new_count < old_count { - println!("WATCHER: new count: {}, old count: {}, not updating.", new_count, old_count); + if new_count < old_count { + println!( + "WATCHER: new count: {}, old count: {}, not updating.", + new_count, old_count + ); } return; } @@ -71,7 +75,9 @@ async fn check_for_new_achievement( let new_achievement = &new.achievements[i]; let icon_path = format!( "{}/{}/{}.png", - context.icon_cache_path, old.name, new_achievement.name + context.icon_cache_path.to_str().unwrap(), + old.name, + new_achievement.name ); let icon_path = Path::new(&icon_path); @@ -103,12 +109,11 @@ async fn check_for_new_achievement( *old = new; } -fn compare_achievements(new: &Vec, old: &Vec) -> Vec { +fn compare_achievements(new: &[Achievement], old: &[Achievement]) -> Vec { new.iter() - .map(|n| { + .filter_map(|n| { old.iter() .position(|o| !o.achieved && n.achieved && o.name == n.name) }) - .flatten() .collect() }