From c1015f10fd910358e2b5ee5467c2988813f91d58 Mon Sep 17 00:00:00 2001 From: hheik <4469778+hheik@users.noreply.github.com> Date: Sat, 24 Aug 2024 21:53:09 +0300 Subject: [PATCH] Added mod installation. First working version. --- src/installer.rs | 135 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 18 ++++++- src/main.rs | 47 +++++++++++++---- 3 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 src/installer.rs diff --git a/src/installer.rs b/src/installer.rs new file mode 100644 index 0000000..fe8389f --- /dev/null +++ b/src/installer.rs @@ -0,0 +1,135 @@ +use std::{ + fs::{create_dir_all, read_dir, remove_dir_all, remove_file, DirEntry}, + path::{Path, PathBuf}, +}; + +use munsikka::*; + +const IGNORED_INSTALL: [&str; 7] = [ + "icon.png", + "README.md", + "manifest.json", + "LICENSE", + "LICENSE.md", + "LICENSE.txt", + "CHANGELOG.md", +]; + +// TODO: Remove hardcoding +const MOD_LOADER_FILES: [&str; 3] = ["BepInEx", "doorstop_config.ini", "winhttp.dll"]; + +/// Removes any mod files from the game directory +pub fn clean(game_dir: &Path) -> std::io::Result<()> { + println!("Cleaning old installation..."); + MOD_LOADER_FILES + .iter() + .map(|item| game_dir.join(PathBuf::from(item))) + .for_each(|removal_target| { + // TODO: Only suppress NotFound errors + if removal_target.is_dir() { + remove_dir_all(removal_target).ok(); + } else { + remove_file(removal_target).ok(); + } + }); + Ok(()) +} + +pub fn install(from: &Path, game_dir: &Path, mod_info: &ModInfo) -> std::io::Result<()> { + for path in [from, game_dir].iter() { + if !path.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Couldn't find {path:?}"), + )); + } + } + + print!( + " {version: >10} | {name: <30} | {author: <30} ", + name = mod_info.name, + author = mod_info.author, + version = mod_info.version_string(), + ); + + let mut flavour_string = String::new(); + + for entry in read_dir(from)? { + let entry = entry.unwrap(); + if IGNORED_INSTALL + .iter() + .any(|ignored| *ignored == entry.file_name()) + { + continue; + } + + match choose_install_strategy(&entry) { + InstallStrategy::PluginFile => { + recursive_install( + &entry.path(), + &game_dir.join(PathBuf::from("BepInEx/plugins")), + ) + .unwrap(); + flavour_string.push_str("📦"); + } + InstallStrategy::LoaderSubDirectory => { + recursive_install(&entry.path(), &game_dir.join(PathBuf::from("BepInEx"))).unwrap(); + flavour_string.push_str("📂"); + } + InstallStrategy::LoaderDirectory => { + recursive_install(&entry.path(), &game_dir).unwrap(); + flavour_string.push_str("📂"); + } + InstallStrategy::GameDirectory => { + for item in MOD_LOADER_FILES.iter() { + recursive_install(&entry.path().join(PathBuf::from(item)), &game_dir).unwrap(); + } + flavour_string.push_str("🛠️"); + } + } + } + + println!("✅ {flavour_string}"); + Ok(()) +} + +enum InstallStrategy { + /// Simple .dll files and other resources + PluginFile, + /// Directory contained within the mod loader directory + LoaderSubDirectory, + /// The directory is the top-level directory + LoaderDirectory, + /// Contents should be installed to the game directory, used by the mod loader. + GameDirectory, +} + +fn choose_install_strategy(top_level_file: &DirEntry) -> InstallStrategy { + if top_level_file.file_name() == "BepInExPack" { + return InstallStrategy::GameDirectory; + } + if top_level_file.file_name() == "BepInEx" { + return InstallStrategy::LoaderDirectory; + } + if top_level_file.path().is_dir() { + return InstallStrategy::LoaderSubDirectory; + } + InstallStrategy::PluginFile +} + +fn recursive_install(src: &Path, dest: &Path) -> std::io::Result<()> { + if src.is_dir() { + for entry in read_dir(src)? { + let entry = entry?; + recursive_install( + &src.join(entry.file_name()), + &dest.join(src.file_name().unwrap()), + )?; + } + } else { + let dest = dest.join(src.file_name().unwrap()); + create_dir_all(dest.parent().unwrap())?; + std::fs::copy(src, dest)?; + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 9ec415a..bbe797b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,13 +9,14 @@ use yaml_rust2::Yaml; pub const BASE_URL: &str = "http://thunderstore.io"; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ModInfo { pub name: String, pub author: String, pub version: (i64, i64, i64), pub website_url: String, pub enabled: bool, + pub dependencies: Vec>, } impl ModInfo { @@ -30,6 +31,11 @@ impl ModInfo { ), website_url: yaml["websiteUrl"].as_str()?.to_owned(), enabled: yaml["enabled"].as_bool()?, + dependencies: yaml["dependencies"] + .as_vec()? + .iter() + .map(|y| y.as_str().map(|str| str.to_owned())) + .collect(), }) } @@ -167,7 +173,7 @@ impl FetchExtractor { } fn unzip_to(from: &Path, to: &Path) -> Result<(), ()> { - // TODO: use the zip library + // TODO: use a zip library Command::new("unzip") .arg(from) .arg("-d") @@ -180,3 +186,11 @@ fn unzip_to(from: &Path, to: &Path) -> Result<(), ()> { pub fn data_dir() -> PathBuf { PathBuf::from(var("HOME").unwrap()).join(".local/share/munsikka") } + +pub fn game_dir() -> PathBuf { + match var("GAME_DIR").ok() { + Some(game_dir) => game_dir.into(), + None => PathBuf::from(var("HOME").unwrap()) + .join(".steam/steam/steamapps/common/Lethal Company"), + } +} diff --git a/src/main.rs b/src/main.rs index a93773c..bff6004 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,8 @@ use munsikka::*; use base64::prelude::*; use yaml_rust2::{Yaml, YamlLoader}; +pub mod installer; + fn main() { // First and only argument is the (legacy) profile UUID from a mod manager user let profile_uuid = std::env::args() @@ -31,6 +33,18 @@ fn main() { let mod_list = fetch_mod_list(&profile_fetcher, &profile_uuid).unwrap(); fetch_mods(&mod_fetcher, &mod_list).unwrap(); + + println!(""); + installer::clean(&game_dir()).unwrap(); + println!("Installing mods"); + for mod_info in mod_list.iter() { + installer::install( + &mod_fetcher.extracted_path(&mod_info.unique_name()), + &game_dir(), + &mod_info, + ) + .unwrap(); + } } /// Map the legacy profile response into a zip binary @@ -47,6 +61,7 @@ fn profile_preprocessor(profile_response: Vec) -> Vec { BASE64_STANDARD.decode(base64_zip).unwrap() } +/// Gets the list of required mods from a profile uuid fn fetch_mod_list( fetcher: &FetchExtractor, profile_uuid: &str, @@ -64,18 +79,30 @@ fn fetch_mod_list( // Handle yaml let mods = &YamlLoader::load_from_str(&mods_yaml)?[0]; - Ok(mods - .as_vec() - .ok_or(std::io::Error::new( - std::io::ErrorKind::Other, - format!("YAML file is not a list: {:?}", mods_yaml_path), - ))? - .iter() - .filter(|yaml| yaml["enabled"] == Yaml::Boolean(true)) - .map(|yaml| ModInfo::from_yaml(yaml).unwrap()) - .collect()) + + Ok(sort_mods( + &mods + .as_vec() + .ok_or(std::io::Error::new( + std::io::ErrorKind::Other, + format!("YAML file is not a list: {:?}", mods_yaml_path), + ))? + .iter() + .filter(|yaml| yaml["enabled"] == Yaml::Boolean(true)) + .map(|yaml| ModInfo::from_yaml(yaml).unwrap()) + .collect(), + )) } +/// Sort mods by dependency order +fn sort_mods(mods: &Vec) -> Vec { + // TODO: Actual dependecy sorting + let mut mods = mods.clone(); + mods.sort_unstable_by_key(|mod_info| mod_info.dependencies.len()); + mods +} + +/// Download and extract mods fn fetch_mods(fetcher: &FetchExtractor, mod_list: &Vec) -> Result<(), Box> { println!("Fetching {} mods...", mod_list.len());