This repository has been archived on 2025-06-27. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
ludusavi-manifest/src/steam.rs
2024-04-20 22:45:12 -04:00

541 lines
17 KiB
Rust

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<u32, SteamCacheEntry>);
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<Vec<u32>>,
limit: Option<usize>,
from: Option<u32>,
) -> 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<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub launch: Vec<Launch>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub name_localized: BTreeMap<String, String>,
}
#[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<CloudSave>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub overrides: Vec<CloudOverride>,
}
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<String>,
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub os_compare: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub path_transforms: Vec<CloudTransform>,
#[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<String>,
}
#[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<String>,
#[serde(skip_serializing_if = "LaunchConfig::is_empty")]
pub config: LaunchConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub executable: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workingdir: Option<String>,
}
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub osarch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub oslist: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ownsdlc: Option<String>,
}
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<u32>,
}
impl ProductInfo {
fn fetch(app_ids: &[u32]) -> Result<ProductInfo, Error> {
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::<product_info::Response>(&stdout)
.map_err(Error::SteamProductInfoDecoding)?,
irregular: Default::default(),
};
// Debugging:
let raw = serde_json::from_str::<serde_json::Value>(&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<bool, D::Error>
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<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned,
{
use serde::de::Deserialize;
let mut out = vec![];
let raw = match BTreeMap::<String, serde_json::Value>::deserialize(deserializer) {
Ok(x) => x,
Err(e) => {
println!(" parse_vec: total failure - {e:?}");
return Err(e);
}
};
for (key, value) in raw {
if key.parse::<u32>().is_err() {
println!(" parse_vec: unexpected key '{}'", key);
continue;
}
match serde_json::from_value::<T>(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<String, App>,
}
#[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<String, String>,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct AppConfig {
pub installdir: Option<String>,
pub launch: BTreeMap<String, AppLaunch>,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct AppLaunch {
pub executable: Option<String>,
pub arguments: Option<String>,
pub workingdir: Option<String>,
pub r#type: Option<String>,
pub config: AppLaunchConfig,
pub description: Option<String>,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct AppLaunchConfig {
pub betakey: Option<String>,
pub osarch: Option<String>,
pub oslist: Option<String>,
pub ownsdlc: Option<String>,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct AppUfs {
#[serde(rename = "savefiles", deserialize_with = "parse_vec")]
pub save_files: Vec<AppUfsSaveFile>,
#[serde(rename = "rootoverrides")]
pub root_overrides: BTreeMap<String, AppUfsRootOverride>,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct AppUfsSaveFile {
pub path: String,
pub pattern: String,
pub platforms: BTreeMap<String, String>,
#[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<String>,
pub os: Option<String>,
#[serde(rename = "oscompare")]
pub os_compare: Option<String>,
#[serde(rename = "pathtransforms")]
pub path_transforms: Option<BTreeMap<String, AppUfsPathTransform>>,
pub platforms: BTreeMap<String, String>,
#[serde(deserialize_with = "parse_bool")]
pub recursive: bool,
pub root: String,
#[serde(rename = "useinstead")]
pub use_instead: Option<String>,
}
#[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<Option<Self>, 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("<home>/Library/Application Support"),
"madocuments" => Some("<home>/Documents"),
"machome" => Some(placeholder::HOME),
"winappdataroaming" => Some(placeholder::WIN_APP_DATA),
"winappdatalocal" => Some(placeholder::WIN_LOCAL_APP_DATA),
"winappdatalocallow" => Some("<home>/AppData/LocalLow"),
"winmydocuments" => Some(placeholder::WIN_DOCUMENTS),
"winsavedgames" => Some("<home>/Saved Games"),
_ => {
println!("[Steam] unknown root: {}", value);
None
}
}
}
pub fn parse_platform(value: &str) -> Option<Os> {
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<String>, comparison: Option<String>) -> Option<Os> {
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,
}
}