// This file contains various mod management functions use crate::constants::{BLACKLISTED_MODS, CORE_MODS}; use async_recursion::async_recursion; use crate::NorthstarMod; use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use std::str::FromStr; use std::string::ToString; use std::{fs, path::PathBuf}; mod legacy; use crate::GameInstall; #[derive(Debug, Clone)] pub struct ParsedThunderstoreModString { author_name: String, mod_name: String, version: String, } impl std::str::FromStr for ParsedThunderstoreModString { type Err = &'static str; // todo use an better error management fn from_str(s: &str) -> Result { // Check whether Thunderstore string passse reges let re = regex::Regex::new(r"^[a-zA-Z0-9_]+-[a-zA-Z0-9_]+-\d+\.\d+\.\d++$").unwrap(); if !re.is_match(s) { return Err("Incorrect format"); } let mut parts = s.split('-'); let author_name = parts.next().ok_or("None value on author_name")?.to_string(); let mod_name = parts.next().ok_or("None value on mod_name")?.to_string(); let version = parts.next().ok_or("None value on version")?.to_string(); Ok(ParsedThunderstoreModString { author_name, mod_name, version, }) } } impl ToString for ParsedThunderstoreModString { fn to_string(&self) -> String { format!("{}-{}-{}", self.author_name, self.mod_name, self.version) } } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ThunderstoreManifest { name: String, version_number: String, } /// A wrapper around a temporary file handle and its path. /// /// This struct is designed to be used for temporary files that should be automatically deleted /// when the `TempFile` instance goes out of scope. #[derive(Debug)] pub struct TempFile(fs::File, PathBuf); impl TempFile { pub fn new(file: fs::File, path: PathBuf) -> Self { Self(file, path) } pub fn file(&self) -> &fs::File { &self.0 } } impl Drop for TempFile { fn drop(&mut self) { _ = fs::remove_file(&self.1) } } impl std::ops::Deref for TempFile { type Target = fs::File; fn deref(&self) -> &Self::Target { &self.0 } } /// Returns a serde json object of the parsed `enabledmods.json` file pub fn get_enabled_mods(game_install: &GameInstall) -> Result { let enabledmods_json_path = format!("{}/R2Northstar/enabledmods.json", game_install.game_path); // Check for JSON file if !std::path::Path::new(&enabledmods_json_path).exists() { return Err("enabledmods.json not found".to_string()); } // Read file let data = match std::fs::read_to_string(enabledmods_json_path) { Ok(data) => data, Err(err) => return Err(err.to_string()), }; // Parse JSON let res: serde_json::Value = match serde_json::from_str(&data) { Ok(result) => result, Err(err) => return Err(format!("Failed to read JSON due to: {}", err)), }; // Return parsed data Ok(res) } /// Gets all currently installed and enabled/disabled mods to rebuild `enabledmods.json` pub fn rebuild_enabled_mods_json(game_install: &GameInstall) -> Result<(), String> { let enabledmods_json_path = format!("{}/R2Northstar/enabledmods.json", game_install.game_path); let mods_and_properties = get_installed_mods_and_properties(game_install.clone())?; // Create new mapping let mut my_map = serde_json::Map::new(); // Build mapping for ns_mod in mods_and_properties.into_iter() { my_map.insert(ns_mod.name, serde_json::Value::Bool(ns_mod.enabled)); } // Turn into serde object let obj = serde_json::Value::Object(my_map); // Write to file std::fs::write( enabledmods_json_path, serde_json::to_string_pretty(&obj).unwrap(), ) .unwrap(); Ok(()) } /// Set the status of a passed mod to enabled/disabled #[tauri::command] pub fn set_mod_enabled_status( game_install: GameInstall, mod_name: String, is_enabled: bool, ) -> Result<(), String> { let enabledmods_json_path = format!("{}/R2Northstar/enabledmods.json", game_install.game_path); // Parse JSON let mut res: serde_json::Value = match get_enabled_mods(&game_install) { Ok(res) => res, Err(err) => { log::warn!("Couldn't parse `enabledmod.json`: {}", err); log::warn!("Rebuilding file."); rebuild_enabled_mods_json(&game_install)?; // Then try again get_enabled_mods(&game_install)? } }; // Check if key exists if res.get(mod_name.clone()).is_none() { // If it doesn't exist, rebuild `enabledmod.json` log::info!("Value not found in `enabledmod.json`. Rebuilding file"); rebuild_enabled_mods_json(&game_install)?; // Then try again res = get_enabled_mods(&game_install)?; } // Update value res[mod_name] = serde_json::Value::Bool(is_enabled); // Save the JSON structure into the output file std::fs::write( enabledmods_json_path, serde_json::to_string_pretty(&res).unwrap(), ) .unwrap(); Ok(()) } /// Resembles the bare minimum keys in Northstar `mods.json` #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ModJson { #[serde(rename = "Name")] name: String, #[serde(rename = "Version")] version: Option, } /// Parse `mods` folder for installed mods. pub fn parse_mods_in_package( package_mods_path: PathBuf, thunderstore_mod_string: ParsedThunderstoreModString, ) -> Result, anyhow::Error> { let paths = match std::fs::read_dir(package_mods_path) { Ok(paths) => paths, Err(_err) => return Err(anyhow!("No mods folder found")), }; let mut directories: Vec = Vec::new(); let mut mods: Vec = Vec::new(); // Get list of folders in `mods` directory for path in paths { let my_path = path.unwrap().path(); let md = std::fs::metadata(my_path.clone()).unwrap(); if md.is_dir() { directories.push(my_path); } } // Iterate over folders and check if they are Northstar mods for directory in directories { let directory_str = directory.to_str().unwrap().to_string(); // Check if mod.json exists let mod_json_path = format!("{}/mod.json", directory_str); if !std::path::Path::new(&mod_json_path).exists() { continue; } // Read file into string and parse it let data = std::fs::read_to_string(mod_json_path.clone())?; let parsed_mod_json: ModJson = match json5::from_str(&data) { Ok(parsed_json) => parsed_json, Err(err) => { log::warn!("Failed parsing {} with {}", mod_json_path, err.to_string()); continue; } }; // Get directory path let mod_directory = directory.to_str().unwrap().to_string(); let ns_mod = NorthstarMod { name: parsed_mod_json.name, version: parsed_mod_json.version, thunderstore_mod_string: Some(thunderstore_mod_string.to_string()), enabled: false, // Placeholder directory: mod_directory, }; mods.push(ns_mod); } // Return found mod names Ok(mods) } /// Parse `packages` folder for installed mods. pub fn parse_installed_package_mods( game_install: &GameInstall, ) -> Result, anyhow::Error> { let mut collected_mods: Vec = Vec::new(); let packages_folder = format!("{}/R2Northstar/packages/", game_install.game_path); let packages_dir = match fs::read_dir(packages_folder) { Ok(res) => res, Err(err) => { // We couldn't read directory, probably cause it doesn't exist yet. // In that case we just say no package mods installed. log::warn!("{err}"); return Ok(vec![]); } }; // Iteratore over folders in `packages` dir for entry in packages_dir { let entry_path = entry?.path(); let entry_str = entry_path.file_name().unwrap().to_str().unwrap(); // Use the struct's from_str function to verify format if entry_path.is_dir() { let package_thunderstore_string = match ParsedThunderstoreModString::from_str(entry_str) { Ok(res) => res, Err(err) => { log::warn!( "Not a Thunderstore mod string \"{}\" cause: {}", entry_path.display(), err ); continue; } }; let manifest_path = entry_path.join("manifest.json"); let mods_path = entry_path.join("mods"); // Ensure `manifest.json` and `mods/` dir exist if manifest_path.exists() && mods_path.is_dir() { let mods = match parse_mods_in_package(mods_path, package_thunderstore_string.clone()) { Ok(res) => res, Err(err) => { log::warn!("Failed parsing cause: {err}"); continue; } }; collected_mods.extend(mods); } } } Ok(collected_mods) } /// Gets list of installed mods and their properties /// - name /// - is enabled? #[tauri::command] pub fn get_installed_mods_and_properties( game_install: GameInstall, ) -> Result, String> { // Get installed mods from packages let mut found_installed_mods = match parse_installed_package_mods(&game_install) { Ok(res) => res, Err(err) => return Err(err.to_string()), }; // Get installed legacy mods let found_installed_legacy_mods = match legacy::parse_installed_mods(&game_install) { Ok(res) => res, Err(err) => return Err(err.to_string()), }; // Combine list of package and legacy mods found_installed_mods.extend(found_installed_legacy_mods); // Get enabled mods as JSON let enabled_mods: serde_json::Value = match get_enabled_mods(&game_install) { Ok(enabled_mods) => enabled_mods, Err(_) => serde_json::from_str("{}").unwrap(), // `enabledmods.json` not found, create empty object }; let mut installed_mods = Vec::new(); let binding = serde_json::Map::new(); // Empty map in case treating as object fails let mapping = enabled_mods.as_object().unwrap_or(&binding); // Use list of installed mods and set enabled based on `enabledmods.json` for mut current_mod in found_installed_mods { let current_mod_enabled = match mapping.get(¤t_mod.name) { Some(enabled) => enabled.as_bool().unwrap(), None => true, // Northstar considers mods not in mapping as enabled. }; current_mod.enabled = current_mod_enabled; installed_mods.push(current_mod); } Ok(installed_mods) } async fn get_ns_mod_download_url(thunderstore_mod_string: &str) -> Result { // TODO: This will crash the thread if not internet connection exist. `match` should be used instead let index = thermite::api::get_package_index().unwrap().to_vec(); // Parse mod string let parsed_ts_mod_string: ParsedThunderstoreModString = match thunderstore_mod_string.parse() { Ok(res) => res, Err(_) => return Err("Failed to parse mod string".to_string()), }; // Encode as URL let ts_mod_string_url = format!( "{}/{}/{}", parsed_ts_mod_string.author_name, parsed_ts_mod_string.mod_name, parsed_ts_mod_string.version ); for ns_mod in index { // Iterate over all versions of a given mod for ns_mod in ns_mod.versions.values() { if ns_mod.url.contains(&ts_mod_string_url) { dbg!(ns_mod.clone()); return Ok(ns_mod.url.clone()); } } } Err("Could not find mod on Thunderstore".to_string()) } /// Returns a vector of modstrings containing the dependencies of a given mod async fn get_mod_dependencies(thunderstore_mod_string: &str) -> Result, anyhow::Error> { log::info!("Attempting to get dependencies for: {thunderstore_mod_string}"); let index = thermite::api::get_package_index()?.to_vec(); // String replace works but more care should be taken in the future let ts_mod_string_url = thunderstore_mod_string.replace('-', "/"); // Iterate over index for ns_mod in index { // Iterate over all versions of a given mod for ns_mod in ns_mod.versions.values() { if ns_mod.url.contains(&ts_mod_string_url) { dbg!(ns_mod.clone()); return Ok(ns_mod.deps.clone()); } } } Ok(Vec::::new()) } // Copied from `libtermite` source code and modified // Should be replaced with a library call to libthermite in the future /// Download and install mod to the specified target. #[async_recursion] pub async fn fc_download_mod_and_install( game_install: &GameInstall, thunderstore_mod_string: &str, ) -> Result<(), String> { log::info!("Attempting to install \"{thunderstore_mod_string}\" to {game_install:?}"); // Get mods and download directories let download_directory = format!( "{}/___flightcore-temp-download-dir/", game_install.game_path ); let mods_directory = format!("{}/R2Northstar/mods/", game_install.game_path); // Early return on empty string if thunderstore_mod_string.is_empty() { return Err("Passed empty string".to_string()); } let deps = match get_mod_dependencies(thunderstore_mod_string).await { Ok(deps) => deps, Err(err) => return Err(err.to_string()), }; log::info!("Mod dependencies: {deps:?}"); // Recursively install dependencies for dep in deps { match fc_download_mod_and_install(game_install, &dep).await { Ok(()) => (), Err(err) => { if err == "Cannot install Northstar as a mod!" { continue; // For Northstar as a dependency, we just skip it } else { return Err(err); } } }; } // Prevent installing Northstar as a mod // While it would fail during install anyway, having explicit error message is nicer for blacklisted_mod in BLACKLISTED_MODS { if thunderstore_mod_string.contains(blacklisted_mod) { return Err("Cannot install Northstar as a mod!".to_string()); } } // Get download URL for the specified mod let download_url = get_ns_mod_download_url(thunderstore_mod_string).await?; // Create download directory match std::fs::create_dir_all(download_directory.clone()) { Ok(()) => (), Err(err) => return Err(err.to_string()), }; let path = format!( "{}/___flightcore-temp-download-dir/{thunderstore_mod_string}.zip", game_install.game_path ); // Download the mod let temp_file = TempFile::new( std::fs::File::options() .read(true) .write(true) .truncate(true) .create(true) .open(&path) .map_err(|e| e.to_string())?, (&path).into(), ); match thermite::core::manage::download(temp_file.file(), download_url) { Ok(_written_bytes) => (), Err(err) => return Err(err.to_string()), }; // Get Thunderstore mod author let author = thunderstore_mod_string.split('-').next().unwrap(); // Extract the mod to the mods directory match thermite::core::manage::install_mod( author, temp_file.file(), std::path::Path::new(&mods_directory), ) { Ok(_) => (), Err(err) => { log::warn!("libthermite couldn't install mod {thunderstore_mod_string} due to {err:?}",); return Err(err.to_string()); } }; Ok(()) } /// Deletes a given Northstar mod folder fn delete_mod_folder(ns_mod_directory: &str) -> Result<(), String> { let ns_mod_dir_path = std::path::Path::new(&ns_mod_directory); // Safety check: Check whether `mod.json` exists and exit early if not // If it does not exist, we might not be dealing with a Northstar mod let mod_json_path = ns_mod_dir_path.join("mod.json"); if !mod_json_path.exists() { // If it doesn't exist, return an error return Err(format!("mod.json does not exist in {}", ns_mod_directory)); } match std::fs::remove_dir_all(ns_mod_directory) { Ok(()) => Ok(()), Err(err) => Err(format!("Failed deleting mod: {err}")), } } /// Deletes a Northstar mod based on its name #[tauri::command] pub fn delete_northstar_mod(game_install: GameInstall, nsmod_name: String) -> Result<(), String> { // Prevent deleting core mod for core_mod in CORE_MODS { if nsmod_name == core_mod { return Err(format!("Cannot remove core mod {nsmod_name}")); } } // Get installed mods let installed_ns_mods = get_installed_mods_and_properties(game_install)?; // Get folder name based on northstarmods for installed_ns_mod in installed_ns_mods { // Installed mod matches specified mod if installed_ns_mod.name == nsmod_name { // Delete folder return delete_mod_folder(&installed_ns_mod.directory); } } Err(format!("Mod {nsmod_name} not found to be installed")) } /// Deletes all NorthstarMods related to a Thunderstore mod #[tauri::command] pub fn delete_thunderstore_mod( game_install: GameInstall, thunderstore_mod_string: String, ) -> Result<(), String> { // Prevent deleting core mod for core_ts_mod in BLACKLISTED_MODS { if thunderstore_mod_string == core_ts_mod { return Err(format!("Cannot remove core mod {thunderstore_mod_string}")); } } let parsed_ts_mod_string: ParsedThunderstoreModString = thunderstore_mod_string.parse().unwrap(); // Get installed mods let installed_ns_mods = get_installed_mods_and_properties(game_install)?; // List of mod folders to remove let mut mod_folders_to_remove: Vec = Vec::new(); // Get folder name based on Thundestore mod string for installed_ns_mod in installed_ns_mods { if installed_ns_mod.thunderstore_mod_string.is_none() { // Not a Thunderstore mod continue; } let installed_ns_mod_ts_string: ParsedThunderstoreModString = installed_ns_mod .thunderstore_mod_string .unwrap() .parse() .unwrap(); // Installed mod matches specified Thunderstore mod string if parsed_ts_mod_string.author_name == installed_ns_mod_ts_string.author_name && parsed_ts_mod_string.mod_name == installed_ns_mod_ts_string.mod_name { // Add folder to list of folder to remove mod_folders_to_remove.push(installed_ns_mod.directory); } } if mod_folders_to_remove.is_empty() { return Err(format!( "No mods removed as no Northstar mods matching {thunderstore_mod_string} were found to be installed." )); } // Delete given folders for mod_folder in mod_folders_to_remove { delete_mod_folder(&mod_folder)?; } Ok(()) }