add gui, watch wip

This commit is contained in:
2025-01-23 23:30:34 +01:00
parent 31f5d7b2ea
commit da93898b64
10 changed files with 482 additions and 2033 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
/target
*.json
/icons
Cargo.lock

1694
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,12 @@ edition = "2021"
[dependencies]
dirs = "6.0.0"
eframe = "0.30.0"
egui_extras = { version = "0.30.0", features = ["default", "all_loaders"] }
env_logger = "0.11.6"
futures = "0.3.31"
image = { version = "0.25.5", default-features = false, features = ["jpeg", "png"] }
notify = "8.0.0"
reqwest = "0.12.12"
rust-ini = "0.21.1"
serde = { version = "1.0.217", features = ["derive"] }

131
src/lib.rs Normal file
View File

@@ -0,0 +1,131 @@
use reqwest::Client;
use steam::App;
pub mod steam;
#[cfg(test)]
mod test;
pub mod util;
#[derive(Debug)]
pub struct Game {
pub app_id: u32,
pub name: String,
pub achievements: Vec<Achievement>,
}
#[derive(Debug)]
pub struct Achievement {
pub name: String,
pub description: Option<String>,
pub achieved: bool,
pub hidden: bool,
pub icon: String,
pub icongray: String,
pub current_progress: u32,
pub max_progress: u32,
pub unlock_time: u32,
}
pub async fn get_achievement_data(
client: &Client,
api_key: &str,
app_id: u32,
game_name_cache_path: &str,
game_name_list: &Vec<App>,
) -> Game {
let mut game = Game {
name: steam::get_game_name(client, game_name_cache_path, game_name_list, app_id)
.await
.unwrap(),
app_id,
achievements: Vec::new(),
};
let game_data = steam::get_steam_data(client, api_key, app_id, "en").await;
for json_achievement in &game_data.game.available_game_stats.achievements {
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
}
pub async fn get_achievement_data_all(
client: &Client,
api_key: &str,
game_name_cache_path: &str,
game_name_list: &Vec<App>,
dirs: Vec<String>,
) -> Vec<Game> {
let mut games: Vec<Game> = Vec::new();
let ini_games = steam::get_achievement_data_all_ini(dirs);
for ini_game in ini_games {
let mut game = Game {
app_id: ini_game.id,
achievements: Vec::new(),
name: steam::get_game_name(client, game_name_cache_path, game_name_list, ini_game.id)
.await
.unwrap(),
};
let game_data = steam::get_steam_data(client, api_key, ini_game.id, "en").await;
for json_achievement in &game_data.game.available_game_stats.achievements {
let mut found_in_ini = false;
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;
}
}
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(),
});
}
}
games.push(game);
}
games
}

View File

@@ -1,75 +1,8 @@
use dirs;
use ini::Ini;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json;
use std::{env, fs};
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct JsonGame {
game: JsonGameContent,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct JsonGameContent {
game_name: String,
game_version: String,
available_game_stats: JsonAvailableGameStats,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct JsonAvailableGameStats {
achievements: Vec<JsonAchievement>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct JsonAchievement {
name: String,
defaultvalue: i32,
display_name: String,
hidden: i32,
description: Option<String>,
icon: String,
icongray: String,
}
#[derive(Debug)]
struct IniAchievement {
name: String,
achieved: bool,
current_progress: u32,
max_progress: u32,
unlock_time: u32,
}
#[derive(Debug)]
struct IniGame {
id: u32,
achievements: Vec<IniAchievement>,
}
#[derive(Debug)]
struct Game {
app_id: u32,
name: String,
achievements: Vec<Achievement>,
}
#[derive(Debug)]
struct Achievement {
name: String,
description: Option<String>,
achieved: bool,
hidden: bool,
icon: String,
icongray: String,
current_progress: u32,
max_progress: u32,
unlock_time: u32,
}
mod ui;
mod watch;
#[tokio::main]
async fn main() {
@@ -77,7 +10,7 @@ async fn main() {
let config_dir = dirs::config_dir().unwrap().to_str().unwrap().to_owned();
let cache_dir = dirs::cache_dir().unwrap().to_str().unwrap().to_owned();
let game_name_cache_path = format!("{cache_dir}/achievement-watcher/game_names.txt");
let game_name_list = get_game_name_file_cache(&game_name_cache_path);
let game_name_list = achievement_watcher::util::get_game_name_file_cache(&game_name_cache_path);
let api_key =
fs::read_to_string(format!("{config_dir}/achievement-watcher/api-key.txt")).unwrap();
let api_key = api_key.trim();
@@ -86,7 +19,7 @@ async fn main() {
wine_dir.clone() + "/drive_c/users/Public/Documents/Steam/CODEX",
wine_dir.clone() + "/drive_c/users/Public/Documents/Steam/RUNE",
];
let games = get_achievement_data_all(
let games = achievement_watcher::get_achievement_data_all(
&client,
api_key,
&game_name_cache_path,
@@ -94,274 +27,29 @@ async fn main() {
dirs,
)
.await;
println!("games: {:#?}", games);
}
impl IniGame {
fn new(id: u32) -> Self {
Self {
id,
achievements: Vec::new(),
// for game in &games {
// for achievement in game.achievements.iter() {
// let image = client
// .get(&achievement.icon)
// .send()
// .await
// .unwrap()
// .bytes()
// .await
// .unwrap();
// fs::write(format!("icons/{}_{}.jpg", game.name, achievement.name), image).unwrap();
// }
// break;
// }
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
ui::ui(games).unwrap();
} else if args.len() == 3 {
match args[0].as_str() {
// "-w" => watch::watch_file(&args[1], args[2].parse().unwrap()).unwrap(),
_ => todo!(),
}
}
}
impl IniAchievement {
fn new(name: String) -> Self {
Self {
name,
achieved: false,
current_progress: 0,
max_progress: 0,
unlock_time: 0,
}
}
}
async fn get_steam_data(client: &Client, key: &str, appid: u32, lang: &str) -> JsonGame {
let url = format!("https://api.steampowered.com/ISteamUserStats/GetSchemaForGame/v0002/?key={key}&appid={appid}&l={lang}&format=json");
let json = client.get(url).send().await.unwrap().text().await.unwrap();
serde_json::from_str(&json).unwrap()
}
async fn get_achievement_data(
client: &Client,
api_key: &str,
app_id: u32,
game_name_cache_path: &str,
game_name_list: &Vec<App>,
) -> Game {
let mut game = Game {
name: get_game_name(&client, &game_name_cache_path, &game_name_list, app_id)
.await
.unwrap(),
app_id,
achievements: Vec::new(),
};
let game_data = get_steam_data(&client, api_key, app_id, "en").await;
for json_achievement in &game_data.game.available_game_stats.achievements {
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
}
async fn get_achievement_data_all(
client: &Client,
api_key: &str,
game_name_cache_path: &str,
game_name_list: &Vec<App>,
dirs: Vec<String>,
) -> Vec<Game> {
let mut games: Vec<Game> = Vec::new();
let ini_games = get_achievement_data_all_ini(dirs);
for ini_game in ini_games {
let mut game = Game {
app_id: ini_game.id,
achievements: Vec::new(),
name: get_game_name(&client, &game_name_cache_path, &game_name_list, ini_game.id)
.await
.unwrap(),
};
let game_data = get_steam_data(&client, api_key, ini_game.id, "en").await;
for json_achievement in &game_data.game.available_game_stats.achievements {
let mut found_in_ini = false;
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;
}
}
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(),
});
}
}
games.push(game);
}
games
}
fn get_achievement_data_all_ini(dirs: Vec<String>) -> Vec<IniGame> {
let mut ini_games = Vec::new();
for dir in dirs {
let game_dirs = fs::read_dir(dir).unwrap();
for game_dir in game_dirs {
let game_dir = game_dir.unwrap();
let mut ini_game = IniGame::new(
game_dir
.file_name()
.to_str()
.unwrap()
.to_owned()
.parse()
.unwrap(),
);
let game_dir_path = game_dir.path().to_str().unwrap().to_owned();
let i = Ini::load_from_file(format!("{game_dir_path}/achievements.ini",)).unwrap();
for (sec, prop) in i.iter() {
if let Some(section) = sec {
if section == "SteamAchievements" {
continue;
}
let mut ini_achievement = IniAchievement::new(section.to_owned());
for (k, v) in prop.iter() {
match k {
"Achieved" => ini_achievement.achieved = v.parse::<u32>().unwrap() == 1,
"CurProgress" => ini_achievement.current_progress = v.parse().unwrap(),
"MaxProgress" => ini_achievement.max_progress = v.parse().unwrap(),
"UnlockTime" => ini_achievement.unlock_time = v.parse().unwrap(),
s => panic!("unexpected achievement stat {s}"),
}
}
ini_game.achievements.push(ini_achievement);
}
}
ini_games.push(ini_game);
}
}
ini_games
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct AppList {
applist: Apps,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct Apps {
apps: Vec<App>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct App {
name: String,
appid: u32,
}
async fn get_game_name(
client: &Client,
path: &str,
game_name_list: &Vec<App>,
appid: u32,
) -> Option<String> {
use std::fs::OpenOptions;
use std::io::prelude::*;
for app in game_name_list {
if app.appid == appid {
return Some(app.name.clone());
}
}
let json = client
.get("https://api.steampowered.com/ISteamApps/GetAppList/v0002/?format=json")
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let applist: AppList = serde_json::from_str(&json).unwrap();
let applist = applist.applist.apps;
for app in applist {
if app.appid == appid {
let mut file = OpenOptions::new()
.write(true)
.append(true)
.open(path)
.unwrap();
writeln!(file, "{}:{}", app.appid, app.name).unwrap();
return Some(app.name);
}
}
None
}
fn get_game_name_file_cache(path: &str) -> Vec<App> {
std::fs::read_to_string(path)
.unwrap()
.lines()
.map(|m| {
let a = m.split_once(":").unwrap();
App {
appid: a.0.parse().unwrap(),
name: a.1.to_owned(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn check_id_to_name() {
let client = Client::new();
let config_dir = dirs::config_dir().unwrap().to_str().unwrap().to_owned();
let cache_dir = dirs::cache_dir().unwrap().to_str().unwrap().to_owned();
let game_name_cache_path = format!("{cache_dir}/achievement-watcher/game_names.txt");
let game_name_list = get_game_name_file_cache(&game_name_cache_path);
let api_key =
fs::read_to_string(format!("{config_dir}/achievement-watcher/api-key.txt")).unwrap();
let api_key = api_key.trim();
let game = get_achievement_data(
&client,
api_key,
570940,
&game_name_cache_path,
&game_name_list,
)
.await;
println!("game name: \"{}\"", game.name);
assert_eq!(game.name, "DARK SOULS™: REMASTERED");
} else {
panic!("wrong arg count");
}
}

174
src/steam.rs Normal file
View File

@@ -0,0 +1,174 @@
use ini::Ini;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fs;
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct JsonGame {
pub game: JsonGameContent,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct JsonGameContent {
pub game_name: String,
pub game_version: String,
pub available_game_stats: JsonAvailableGameStats,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct JsonAvailableGameStats {
pub achievements: Vec<JsonAchievement>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct JsonAchievement {
pub name: String,
pub defaultvalue: i32,
pub display_name: String,
pub hidden: i32,
pub description: Option<String>,
pub icon: String,
pub icongray: String,
}
#[derive(Debug)]
pub struct IniAchievement {
pub name: String,
pub achieved: bool,
pub current_progress: u32,
pub max_progress: u32,
pub unlock_time: u32,
}
#[derive(Debug)]
pub struct IniGame {
pub id: u32,
pub achievements: Vec<IniAchievement>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct AppList {
pub applist: Apps,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Apps {
pub apps: Vec<App>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct App {
pub name: String,
pub appid: u32,
}
impl IniGame {
fn new(id: u32) -> Self {
Self {
id,
achievements: Vec::new(),
}
}
}
impl IniAchievement {
fn new(name: String) -> Self {
Self {
name,
achieved: false,
current_progress: 0,
max_progress: 0,
unlock_time: 0,
}
}
}
pub async fn get_steam_data(client: &Client, key: &str, appid: u32, lang: &str) -> JsonGame {
let url = format!("https://api.steampowered.com/ISteamUserStats/GetSchemaForGame/v0002/?key={key}&appid={appid}&l={lang}&format=json");
let json = client.get(url).send().await.unwrap().text().await.unwrap();
serde_json::from_str(&json).unwrap()
}
pub async fn get_game_name(
client: &Client,
path: &str,
game_name_list: &Vec<App>,
appid: u32,
) -> Option<String> {
use std::fs::OpenOptions;
use std::io::prelude::*;
for app in game_name_list {
if app.appid == appid {
return Some(app.name.clone());
}
}
let json = client
.get("https://api.steampowered.com/ISteamApps/GetAppList/v0002/?format=json")
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let applist: AppList = serde_json::from_str(&json).unwrap();
let applist = applist.applist.apps;
for app in applist {
if app.appid == appid {
let mut file = OpenOptions::new().append(true).open(path).unwrap();
writeln!(file, "{}:{}", app.appid, app.name).unwrap();
return Some(app.name);
}
}
None
}
pub fn get_achievement_data_all_ini(dirs: Vec<String>) -> Vec<IniGame> {
let mut ini_games = Vec::new();
for dir in dirs {
let game_dirs = fs::read_dir(dir).unwrap();
for game_dir in game_dirs {
let game_dir = game_dir.unwrap();
let app_id = game_dir
.file_name()
.to_str()
.unwrap()
.to_owned()
.parse()
.unwrap();
let game_dir_path = game_dir.path().to_str().unwrap().to_owned();
let ini_game = parse_ini_file(&format!("{game_dir_path}/achievements.ini"), app_id);
ini_games.push(ini_game);
}
}
ini_games
}
pub fn parse_ini_file(path: &str, app_id: u32) -> IniGame {
let mut ini_game = IniGame::new(app_id);
let ini = Ini::load_from_file(path).unwrap();
for (sec, prop) in ini.iter() {
if let Some(section) = sec {
if section == "SteamAchievements" {
continue;
}
let mut ini_achievement = IniAchievement::new(section.to_owned());
for (k, v) in prop.iter() {
match k {
"Achieved" => ini_achievement.achieved = v.parse::<u32>().unwrap() == 1,
"CurProgress" => ini_achievement.current_progress = v.parse().unwrap(),
"MaxProgress" => ini_achievement.max_progress = v.parse().unwrap(),
"UnlockTime" => ini_achievement.unlock_time = v.parse().unwrap(),
s => panic!("unexpected achievement stat {s}"),
}
}
ini_game.achievements.push(ini_achievement);
}
}
ini_game
}

32
src/test.rs Normal file
View File

@@ -0,0 +1,32 @@
use std::fs;
use reqwest::Client;
//use achievement_watcher::noe
use crate::util;
use crate::get_achievement_data;
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn check_id_to_name() {
let client = Client::new();
let config_dir = dirs::config_dir().unwrap().to_str().unwrap().to_owned();
let cache_dir = dirs::cache_dir().unwrap().to_str().unwrap().to_owned();
let game_name_cache_path = format!("{cache_dir}/achievement-watcher/game_names.txt");
let game_name_list = util::get_game_name_file_cache(&game_name_cache_path);
let api_key =
fs::read_to_string(format!("{config_dir}/achievement-watcher/api-key.txt")).unwrap();
let api_key = api_key.trim();
let game = get_achievement_data(
&client,
api_key,
570940,
&game_name_cache_path,
&game_name_list,
)
.await;
println!("game name: \"{}\"", game.name);
assert_eq!(game.name, "DARK SOULS™: REMASTERED");
}
}

60
src/ui.rs Normal file
View File

@@ -0,0 +1,60 @@
use achievement_watcher::Game;
use eframe::egui;
pub fn ui(games: Vec<Game>) -> eframe::Result {
env_logger::init();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default(),
..Default::default()
};
let mut selected_game = 0;
eframe::run_simple_native("Achievement Watcher", options, move |ctx, _frame| {
egui::CentralPanel::default().show(ctx, |ui| {
egui::SidePanel::left("left_panel").show_inside(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
for (i, game) in games.iter().enumerate() {
if ui.button(&game.name).clicked() {
selected_game = i;
}
}
});
});
egui::ScrollArea::both().show(ui, |ui| {
egui_extras::install_image_loaders(ctx);
let game = &games[selected_game];
let achieved_count = game.achievements.iter().filter(|m| m.achieved).count();
ui.heading(&game.name);
ui.add(
egui::Image::new(format!(
"https://cdn.cloudflare.steamstatic.com/steam/apps/{}/header.jpg",
game.app_id
))
.fit_to_original_size(1.),
);
ui.label(format!(
"Completed: [{}/{}]",
achieved_count,
game.achievements.len()
));
ui.add(egui::ProgressBar::new(
achieved_count as f32 / game.achievements.len() as f32,
));
for achievement in &game.achievements {
ui.heading(&achievement.name);
if let Some(desc) = &achievement.description {
ui.label(desc);
}
let icon = if achievement.achieved {
&achievement.icon
} else {
&achievement.icongray
};
ui.add(egui::Image::new(icon).fit_to_original_size(1.));
}
});
});
})
}

23
src/util.rs Normal file
View File

@@ -0,0 +1,23 @@
use crate::steam::App;
pub fn get_game_name_file_cache(path: &str) -> Vec<App> {
std::fs::read_to_string(path)
.unwrap()
.lines()
.map(|m| {
let a = m.split_once(":").unwrap();
App {
appid: a.0.parse().unwrap(),
name: a.1.to_owned(),
}
})
.collect()
}
fn send_notification(icon_path: &str, achievement_name: &str, achievement_description: &str) {
std::process::Command::new("notify-send")
.args(["-i", icon_path, achievement_name, achievement_description])
.spawn()
.unwrap();
}

28
src/watch.rs Normal file
View File

@@ -0,0 +1,28 @@
use achievement_watcher::steam;
use notify::{Event, RecursiveMode, Result, Watcher};
use std::path::Path;
use std::sync::mpsc;
use reqwest::Client;
use achievement_watcher::steam::App;
pub fn watch_file(path: &str, app_id: u32, api_key: &str, game_name_cache_path: &str, game_name_list: Vec<App>) -> Result<()> {
let (tx, rx) = mpsc::channel::<Result<Event>>();
let mut watcher = notify::recommended_watcher(tx)?;
watcher.watch(Path::new(path), RecursiveMode::NonRecursive)?;
let client = Client::new();
for res in rx {
match res {
Ok(event) => {
println!("event: {:?}", event);
let ini_game = steam::parse_ini_file(path, app_id);
let json_game = steam::get_steam_data(&client, api_key, app_id, "en");
let result = achievement_watcher::get_achievement_data(&client, api_key, app_id, game_name_cache_path, &game_name_list);
}
Err(e) => panic!("watch error: {:?}", e),
}
}
Ok(())
}