use std::{ collections::{BTreeMap, HashSet}, process::Command, }; use crate::{ manifest::{placeholder, Os}, resource::ResourceFile, should_cancel, wiki::WikiCache, Error, State, REPO, }; const SAVE_INTERVAL: u32 = 250; const CHUNK_SIZE: usize = 25; #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] pub struct SteamCache(pub BTreeMap); impl ResourceFile for SteamCache { const FILE_NAME: &'static str = "data/steam-game-cache.yaml"; } impl SteamCache { pub fn refresh( &mut self, outdated_only: bool, app_ids: Option>, limit: Option, from: Option, ) -> Result<(), Error> { let mut i = 0; let app_ids: Vec<_> = app_ids.unwrap_or_else(|| { self.0 .iter() .filter(|(_, v)| !outdated_only || v.state == State::Outdated) .skip_while(|(k, _)| from.is_some_and(|from| &from != *k)) .take(limit.unwrap_or(usize::MAX)) .map(|(k, _)| *k) .collect() }); for app_ids in app_ids.chunks(CHUNK_SIZE) { if should_cancel() { break; } let info = ProductInfo::fetch(app_ids)?; for app_id in app_ids { let latest = SteamCacheEntry::parse_app(*app_id, &info)?; self.0.insert( *app_id, latest.unwrap_or_else(|| SteamCacheEntry { state: State::Handled, ..Default::default() }), ); i += 1; if i % SAVE_INTERVAL == 0 { self.save(); println!("\n:: saved\n"); } } } Ok(()) } pub fn transition_states_from(&mut self, wiki_cache: &mut WikiCache) { for wiki in wiki_cache.0.values_mut() { if wiki.state == State::Updated { if let Some(id) = wiki.steam { self.0 .entry(id) .and_modify(|x| { x.state = State::Outdated; }) .or_insert(SteamCacheEntry { state: State::Outdated, ..Default::default() }); } wiki.state = State::Handled; } } } } #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct SteamCacheEntry { #[serde(skip_serializing_if = "State::is_handled")] pub state: State, #[serde(skip_serializing_if = "std::ops::Not::not")] pub irregular: bool, #[serde(skip_serializing_if = "Cloud::is_empty")] pub cloud: Cloud, #[serde(skip_serializing_if = "Option::is_none")] pub install_dir: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub launch: Vec, #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub name_localized: BTreeMap, } #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Cloud { #[serde(skip_serializing_if = "Vec::is_empty")] pub saves: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub overrides: Vec, } impl Cloud { pub fn is_empty(&self) -> bool { self.saves.is_empty() && self.overrides.is_empty() } } #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct CloudSave { pub path: String, pub pattern: String, #[serde(skip_serializing_if = "Vec::is_empty")] pub platforms: Vec, #[serde(skip_serializing_if = "std::ops::Not::not")] pub recursive: bool, pub root: String, } #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct CloudOverride { #[serde(skip_serializing_if = "Option::is_none")] pub add_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub os: Option, #[serde(skip_serializing_if = "Option::is_none")] pub os_compare: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub path_transforms: Vec, #[serde(skip_serializing_if = "std::ops::Not::not")] pub recursive: bool, pub root: String, #[serde(skip_serializing_if = "Option::is_none")] pub use_instead: Option, } #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct CloudTransform { pub find: String, pub replace: String, } #[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Launch { #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option, #[serde(skip_serializing_if = "LaunchConfig::is_empty")] pub config: LaunchConfig, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub executable: Option, #[serde(skip_serializing_if = "Option::is_none")] pub r#type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub workingdir: Option, } impl Launch { pub fn is_empty(&self) -> bool { self.arguments.is_none() && self.config.is_empty() && self.description.is_none() && self.executable.is_none() && self.r#type.is_none() && self.workingdir.is_none() } } #[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct LaunchConfig { #[serde(skip_serializing_if = "Option::is_none")] pub betakey: Option, #[serde(skip_serializing_if = "Option::is_none")] pub osarch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub oslist: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ownsdlc: Option, } impl LaunchConfig { pub fn is_empty(&self) -> bool { self.betakey.is_none() && self.osarch.is_none() && self.oslist.is_none() && self.ownsdlc.is_none() } } struct ProductInfo { response: product_info::Response, irregular: HashSet, } impl ProductInfo { fn fetch(app_ids: &[u32]) -> Result { println!("Steam batch: {:?} to {:?}", app_ids.first(), app_ids.last()); let mut cmd = Command::new("python"); cmd.arg(format!("{}/scripts/get-steam-app-info.py", REPO)); for app_id in app_ids { cmd.arg(app_id.to_string()); } let output = cmd.output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("Steam product info failure: {}", &stderr); return Err(Error::SteamProductInfo); } let stdout = String::from_utf8_lossy(&output.stdout); let mut info = ProductInfo { response: serde_json::from_str::(&stdout) .map_err(Error::SteamProductInfoDecoding)?, irregular: Default::default(), }; // Debugging: let raw = serde_json::from_str::(&stdout).map_err(Error::SteamProductInfoDecoding)?; for app_id in app_ids { if let Some(ufs) = raw["apps"][app_id.to_string()]["ufs"]["save_files"].as_object() { let keys: Vec<_> = ufs.keys().collect(); for key in keys { let key = key.to_string(); if !["path", "pattern", "platforms", "recursive", "root"].contains(&key.as_str()) { info.irregular.insert(*app_id); println!("[Steam] Unknown save key: {}", key); } } } if let Some(ufs) = raw["apps"][app_id.to_string()]["ufs"]["root_overrides"].as_object() { let keys: Vec<_> = ufs.keys().collect(); for key in keys { let key = key.to_string(); if ![ "add_path", "os", "os_compare", "path_transforms", "recursive", "root", "use_instead", ] .contains(&key.as_str()) { info.irregular.insert(*app_id); println!("[Steam] Unknown override key: {}", key); } } } } Ok(info) } } mod product_info { use super::*; fn parse_bool<'de, D>(deserializer: D) -> Result where D: serde::de::Deserializer<'de>, { let s: String = serde::de::Deserialize::deserialize(deserializer)?; Ok(s == "1") } fn parse_vec<'de, D, T>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, T: serde::de::DeserializeOwned, { use serde::de::Deserialize; let mut out = vec![]; let raw = match BTreeMap::::deserialize(deserializer) { Ok(x) => x, Err(e) => { println!(" parse_vec: total failure - {e:?}"); return Err(e); } }; for (key, value) in raw { if key.parse::().is_err() { println!(" parse_vec: unexpected key '{}'", key); continue; } match serde_json::from_value::(value) { Ok(value) => out.push(value), Err(e) => { println!(" parse_vec: type failure - {e:?}"); return Err(serde::de::Error::custom(format!("parse_vec: type failure - {e:?}"))); } } } Ok(out) } #[derive(Debug, Default, Clone, serde::Deserialize)] pub struct Response { pub apps: BTreeMap, } #[derive(Debug, Default, Clone, serde::Deserialize)] #[serde(default)] pub struct App { pub common: AppCommon, pub config: AppConfig, pub ufs: AppUfs, } #[derive(Debug, Default, Clone, serde::Deserialize)] #[serde(default)] pub struct AppCommon { pub name_localized: BTreeMap, } #[derive(Debug, Default, Clone, serde::Deserialize)] #[serde(default)] pub struct AppConfig { pub installdir: Option, pub launch: BTreeMap, } #[derive(Debug, Default, Clone, serde::Deserialize)] #[serde(default)] pub struct AppLaunch { pub executable: Option, pub arguments: Option, pub workingdir: Option, pub r#type: Option, pub config: AppLaunchConfig, pub description: Option, } #[derive(Debug, Default, Clone, serde::Deserialize)] #[serde(default)] pub struct AppLaunchConfig { pub betakey: Option, pub osarch: Option, pub oslist: Option, pub ownsdlc: Option, } #[derive(Debug, Default, Clone, serde::Deserialize)] #[serde(default)] pub struct AppUfs { #[serde(rename = "savefiles", deserialize_with = "parse_vec")] pub save_files: Vec, #[serde(rename = "rootoverrides")] pub root_overrides: BTreeMap, } #[derive(Debug, Default, Clone, serde::Deserialize)] #[serde(default)] pub struct AppUfsSaveFile { pub path: String, pub pattern: String, pub platforms: BTreeMap, #[serde(deserialize_with = "parse_bool")] pub recursive: bool, pub root: String, } #[derive(Debug, Default, Clone, serde::Deserialize)] #[serde(default)] pub struct AppUfsRootOverride { #[serde(rename = "addpath")] pub add_path: Option, pub os: Option, #[serde(rename = "oscompare")] pub os_compare: Option, #[serde(rename = "pathtransforms")] pub path_transforms: Option>, pub platforms: BTreeMap, #[serde(deserialize_with = "parse_bool")] pub recursive: bool, pub root: String, #[serde(rename = "useinstead")] pub use_instead: Option, } #[derive(Debug, Default, Clone, serde::Deserialize)] #[serde(default)] pub struct AppUfsPathTransform { pub find: String, pub replace: String, } } impl SteamCacheEntry { fn parse_app(app_id: u32, info: &ProductInfo) -> Result, Error> { println!("Steam: {}", app_id); let Some(app) = info.response.apps.get(&app_id.to_string()).cloned() else { eprintln!("No results for Steam ID: {}", app_id); return Ok(None); }; let launch: Vec<_> = app .config .launch .into_values() .map(|x| Launch { executable: x.executable, arguments: x.arguments, workingdir: x.workingdir, r#type: x.r#type, description: x.description, config: LaunchConfig { betakey: x.config.betakey, osarch: x.config.osarch, oslist: x.config.oslist, ownsdlc: x.config.ownsdlc, }, }) .filter(|x| !x.is_empty()) .collect(); let cloud = Cloud { saves: app .ufs .save_files .into_iter() .map(|x| CloudSave { path: x.path, pattern: x.pattern, platforms: x.platforms.into_values().collect(), recursive: x.recursive, root: x.root, }) .collect(), overrides: app .ufs .root_overrides .into_values() .map(|x| CloudOverride { add_path: x.add_path, os: x.os, os_compare: x.os_compare, path_transforms: x .path_transforms .map(|x| { x.into_values() .map(|x| CloudTransform { find: x.find, replace: x.replace, }) .collect() }) .unwrap_or_default(), recursive: x.recursive, root: x.root, use_instead: x.use_instead, }) .collect(), }; Ok(Some(Self { state: State::Handled, irregular: info.irregular.contains(&app_id), cloud, install_dir: app.config.installdir, name_localized: app.common.name_localized, launch, })) } } pub fn parse_root(value: &str) -> Option<&'static str> { match value.to_lowercase().as_ref() { "gameinstall" => Some(placeholder::BASE), "linuxhome" => Some(placeholder::HOME), "linuxxdgdatahome" => Some(placeholder::XDG_DATA), "macappsupport" => Some("/Library/Application Support"), "madocuments" => Some("/Documents"), "machome" => Some(placeholder::HOME), "winappdataroaming" => Some(placeholder::WIN_APP_DATA), "winappdatalocal" => Some(placeholder::WIN_LOCAL_APP_DATA), "winappdatalocallow" => Some("/AppData/LocalLow"), "winmydocuments" => Some(placeholder::WIN_DOCUMENTS), "winsavedgames" => Some("/Saved Games"), _ => { println!("[Steam] unknown root: {}", value); None } } } pub fn parse_platform(value: &str) -> Option { match value.to_lowercase().as_ref() { "linux" => Some(Os::Linux), "macos" => Some(Os::Mac), "windows" => Some(Os::Windows), "all" => None, _ => { println!("[Steam] unknown platform: {}", value); None } } } pub fn parse_os_comparison(os: Option, comparison: Option) -> Option { let comparison = comparison.unwrap_or_else(|| "=".to_string()); let os = os.map(|x| x.to_lowercase()).unwrap_or_default(); match (comparison.as_ref(), os.as_ref()) { ("=", "windows") => Some(Os::Windows), ("=", "linux") => Some(Os::Linux), ("=", "macos") => Some(Os::Mac), (x, _) if x != "=" => { println!("[Steam] unknown OS operator: {}", x); None } (_, x) if !x.is_empty() => { println!("[Steam] unknown OS: {}", x); None } _ => None, } }