ludusavi-manifest/src/manifest.rs
2024-04-20 22:25:33 -04:00

549 lines
18 KiB
Rust

use std::collections::{BTreeMap, BTreeSet};
use crate::{
resource::ResourceFile,
steam::{self, SteamCache, SteamCacheEntry},
wiki::{PathKind, WikiCache, WikiCacheEntry},
Error,
};
pub mod placeholder {
pub const ROOT: &str = "<root>";
pub const GAME: &str = "<game>";
pub const BASE: &str = "<base>";
pub const HOME: &str = "<home>";
pub const STORE_USER_ID: &str = "<storeUserId>";
pub const OS_USER_NAME: &str = "<osUserName>";
pub const WIN_APP_DATA: &str = "<winAppData>";
pub const WIN_LOCAL_APP_DATA: &str = "<winLocalAppData>";
pub const WIN_DOCUMENTS: &str = "<winDocuments>";
pub const WIN_PUBLIC: &str = "<winPublic>";
pub const WIN_PROGRAM_DATA: &str = "<winProgramData>";
pub const WIN_DIR: &str = "<winDir>";
pub const XDG_DATA: &str = "<xdgData>";
pub const XDG_CONFIG: &str = "<xdgConfig>";
}
fn do_launch_paths_match(from_steam: Option<String>, from_manifest: Option<String>) -> bool {
match (from_steam, from_manifest) {
(None, None) => true,
(Some(from_steam), from_manifest) => normalize_launch_path(&from_steam) == from_manifest,
_ => false,
}
}
fn normalize_launch_path(raw: &str) -> Option<String> {
if raw.contains("://") {
return Some(raw.to_string());
}
let standardized = raw.replace('\\', "/").replace("//", "/");
let standardized = standardized
.trim_end_matches('/')
.trim_start_matches("./")
.trim_start_matches('/');
if standardized.is_empty() || standardized == "." {
None
} else {
Some(format!("{}/{}", placeholder::BASE, standardized))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Os {
Dos,
Windows,
Mac,
Linux,
#[default]
#[serde(other)]
Other,
}
impl From<&str> for Os {
fn from(value: &str) -> Self {
match value.to_lowercase().trim() {
"windows" => Self::Windows,
"linux" => Self::Linux,
"mac" | "macos" => Self::Mac,
"dos" => Self::Dos,
_ => Self::Other,
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Store {
Ea,
Epic,
Gog,
GogGalaxy,
Heroic,
Lutris,
Microsoft,
Origin,
Prime,
Steam,
Uplay,
OtherHome,
OtherWine,
#[default]
#[serde(other)]
Other,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Tag {
Config,
Save,
#[default]
#[serde(other)]
Other,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Manifest(pub BTreeMap<String, Game>);
impl ResourceFile for Manifest {
const FILE_NAME: &'static str = "data/manifest.yaml";
}
impl Manifest {
pub fn refresh(
&mut self,
overrides: &ManifestOverride,
wiki_cache: &WikiCache,
steam_cache: &SteamCache,
) -> Result<(), Error> {
self.0.clear();
for (title, info) in &wiki_cache.0 {
if overrides.0.get(title).map(|x| x.omit).unwrap_or(false) {
continue;
}
let mut game = Game::default();
game.integrate_wiki(info, title);
for rename in &info.renamed_from {
if rename.to_lowercase() == title.to_lowercase() || self.0.contains_key(rename) {
continue;
}
self.0.insert(
rename.to_string(),
Game {
alias: Some(title.to_string()),
..Default::default()
},
);
}
if let Some(id) = game.steam.id {
if let Some(info) = steam_cache.0.get(&id) {
game.integrate_steam(info);
}
}
if let Some(overridden) = overrides.0.get(title) {
game.integrate_overrides(overridden);
}
if !game.usable() {
continue;
}
self.0.insert(title.to_string(), game);
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Game {
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub files: BTreeMap<String, GameFileEntry>,
#[serde(skip_serializing_if = "GogMetadata::is_empty")]
pub gog: GogMetadata,
#[serde(skip_serializing_if = "IdMetadata::is_empty")]
pub id: IdMetadata,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub install_dir: BTreeMap<String, GameInstallDirEntry>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub launch: BTreeMap<String, Vec<LaunchEntry>>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub registry: BTreeMap<String, GameRegistryEntry>,
#[serde(skip_serializing_if = "SteamMetadata::is_empty")]
pub steam: SteamMetadata,
}
impl Game {
pub fn integrate_wiki(&mut self, cache: &WikiCacheEntry, title: &str) {
self.steam = SteamMetadata { id: cache.steam };
self.gog = GogMetadata { id: cache.gog };
self.id = IdMetadata {
flatpak: None,
gog_extra: cache.gog_side.clone(),
lutris: cache.lutris.clone(),
steam_extra: cache.steam_side.clone(),
};
let paths = cache.parse_paths(title.to_string());
for path in paths {
let tags = path.tags.clone();
let tags2 = path.tags.clone();
match path.kind {
None | Some(PathKind::File) => {
let constraint = GameFileConstraint {
os: path.os,
store: path.store,
};
let constraint2 = constraint.clone();
self.files
.entry(path.composite)
.and_modify(|x| {
x.tags.extend(tags);
if !constraint.is_empty() {
x.when.insert(constraint);
}
})
.or_insert_with(|| GameFileEntry {
tags: tags2.into_iter().collect(),
when: (if constraint2.is_empty() {
vec![]
} else {
vec![constraint2]
})
.into_iter()
.collect(),
});
}
Some(PathKind::Registry) => {
let constraint = GameRegistryConstraint { store: path.store };
let constraint2 = constraint.clone();
self.registry
.entry(path.composite)
.and_modify(|x| {
x.tags.extend(tags);
if !constraint.is_empty() {
x.when.insert(constraint);
}
})
.or_insert_with(|| GameRegistryEntry {
tags: tags2.into_iter().collect(),
when: (if constraint2.is_empty() {
vec![]
} else {
vec![constraint2]
})
.into_iter()
.collect(),
});
}
}
}
}
fn add_file_constraint(&mut self, path: String, constraint: GameFileConstraint) {
self.files
.entry(path.replace('\\', "/"))
.or_default()
.when
.insert(constraint);
}
pub fn integrate_steam(&mut self, cache: &SteamCacheEntry) {
if let Some(install_dir) = &cache.install_dir {
self.install_dir.insert(install_dir.to_string(), GameInstallDirEntry {});
}
for incoming in &cache.launch {
if incoming.executable.is_none()
|| incoming.executable.as_ref().map(|x| x.contains("://")).unwrap_or(false)
|| !matches!(incoming.r#type.as_deref(), None | Some("default" | "none"))
|| incoming.config.betakey.is_some()
|| incoming.config.ownsdlc.is_some()
{
continue;
}
let os = match incoming.config.oslist.as_deref() {
Some("windows") => Some(Os::Windows),
Some("macos" | "macosx") => Some(Os::Mac),
Some("linux") => Some(Os::Linux),
_ => None,
};
let bit = match incoming.config.osarch.as_deref() {
Some("32") => Some(32),
Some("64") => Some(64),
_ => None,
};
let constraint = LaunchConstraint {
bit,
os,
store: Some(Store::Steam),
};
let mut found_existing = false;
for (existing_executable, existing_options) in self.launch.iter_mut() {
for existing in existing_options {
if incoming.arguments == existing.arguments
&& do_launch_paths_match(incoming.executable.clone(), Some(existing_executable.to_string()))
&& do_launch_paths_match(incoming.workingdir.clone(), existing.working_dir.clone())
{
found_existing = true;
existing.when.insert(constraint.clone());
}
}
}
if !found_existing {
let Some(key) = incoming.executable.as_ref().and_then(|x| normalize_launch_path(x)) else {
continue;
};
let candidate = LaunchEntry {
arguments: incoming.arguments.clone(),
when: vec![constraint.clone()].into_iter().collect(),
working_dir: incoming.workingdir.as_ref().and_then(|x| normalize_launch_path(x)),
};
self.launch
.entry(key)
.and_modify(|x| x.push(candidate.clone()))
.or_insert_with(|| vec![candidate]);
}
}
// We only integrate cloud saves if there's no other save info.
let need_cloud = self.files.is_empty() && self.registry.is_empty();
for save in &cache.cloud.saves {
if !need_cloud {
break;
}
let Some(root) = steam::parse_root(&save.root) else {
continue;
};
let os = save.platforms.first().and_then(|x| steam::parse_platform(x));
let constraint = GameFileConstraint {
os,
store: Some(Store::Steam),
};
let path = save.path.trim_matches(['/', '\\']);
let pattern = save.pattern.trim_matches(['/', '\\']);
if &save.pattern == "*" {
self.add_file_constraint(format!("{}/{}", &root, path), constraint.clone());
} else if save.recursive {
self.add_file_constraint(format!("{}/{}/**/{}", &root, path, pattern), constraint.clone());
} else {
self.add_file_constraint(format!("{}/{}/{}", &root, path, pattern), constraint.clone());
}
for alt in &cache.cloud.overrides {
if save.root != alt.root {
continue;
}
let alt_os = steam::parse_os_comparison(alt.os.clone(), alt.os_compare.clone());
let constraint = GameFileConstraint {
os: alt_os.or(os),
store: Some(Store::Steam),
};
let root = if let Some(instead) = alt.use_instead.as_ref() {
steam::parse_root(instead)
} else {
steam::parse_root(&alt.root)
};
let Some(root) = root else { continue };
let mut path = if let Some(add) = alt.add_path.as_ref() {
if &save.pattern == "*" {
format!("{}/{}/{}", &root, add, path)
} else if save.recursive {
format!("{}/{}/{}/**/{}", &root, add, path, pattern)
} else {
format!("{}/{}/{}/{}", &root, add, path, pattern)
}
} else {
format!("{}/{}/{}", &root, path, pattern)
};
for transform in &alt.path_transforms {
path = path.replace(&transform.find, &transform.replace);
}
path = path
.replace('\\', "/")
.replace("{64BitSteamID}", placeholder::STORE_USER_ID)
.replace("{Steam3AccountID}", placeholder::STORE_USER_ID);
self.add_file_constraint(path, constraint.clone());
}
}
}
pub fn integrate_overrides(&mut self, overridden: &OverrideGame) {
if let Some(id) = overridden.game.steam.id {
self.steam.id = Some(id);
}
if let Some(id) = overridden.game.gog.id {
self.gog.id = Some(id);
}
if let Some(flatpak) = overridden.game.id.flatpak.as_ref() {
self.id.flatpak = Some(flatpak.clone());
}
self.install_dir.extend(overridden.game.install_dir.clone());
}
pub fn usable(&self) -> bool {
!(self.files.is_empty()
&& self.registry.is_empty()
&& self.steam.is_empty()
&& self.gog.is_empty()
&& self.id.is_empty())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GameFileEntry {
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub tags: BTreeSet<Tag>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub when: BTreeSet<GameFileConstraint>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GameInstallDirEntry {}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GameRegistryEntry {
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub tags: BTreeSet<Tag>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub when: BTreeSet<GameRegistryConstraint>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LaunchEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<String>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub when: BTreeSet<LaunchConstraint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GameFileConstraint {
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<Os>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<Store>,
}
impl GameFileConstraint {
pub fn is_empty(&self) -> bool {
self.os.is_none() && self.store.is_none()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GameRegistryConstraint {
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<Store>,
}
impl GameRegistryConstraint {
pub fn is_empty(&self) -> bool {
self.store.is_none()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LaunchConstraint {
#[serde(skip_serializing_if = "Option::is_none")]
pub bit: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<Os>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<Store>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SteamMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<u32>,
}
impl SteamMetadata {
pub fn is_empty(&self) -> bool {
self.id.is_none()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GogMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
}
impl GogMetadata {
pub fn is_empty(&self) -> bool {
self.id.is_none()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct IdMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub flatpak: Option<String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub gog_extra: BTreeSet<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lutris: Option<String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub steam_extra: BTreeSet<u32>,
}
impl IdMetadata {
pub fn is_empty(&self) -> bool {
self.flatpak.is_none() && self.gog_extra.is_empty() && self.lutris.is_none() && self.steam_extra.is_empty()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ManifestOverride(pub BTreeMap<String, OverrideGame>);
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OverrideGame {
pub omit: bool,
#[serde(flatten)]
pub game: Game,
}
impl ResourceFile for ManifestOverride {
const FILE_NAME: &'static str = "data/manifest-override.yaml";
}