diff options
author | GeckoEidechse <gecko.eidechse+git@pm.me> | 2023-01-29 15:19:18 +0100 |
---|---|---|
committer | GeckoEidechse <gecko.eidechse+git@pm.me> | 2023-01-29 15:19:18 +0100 |
commit | 672201785222ce0905cda8b228b1390899c1d1e8 (patch) | |
tree | c3d94ec262697b8024d89f39c1209791c2c72476 | |
parent | b66410e8c19e89acd39628350c4fe70e0e85ff32 (diff) | |
parent | 31b32c725ce4f47a6726cb0d8ef5f21ec6030580 (diff) | |
download | FlightCore-fix/add-empty-dist-folder.tar.gz FlightCore-fix/add-empty-dist-folder.zip |
Merge branch 'main' into fix/add-empty-dist-folderfix/add-empty-dist-folder
26 files changed, 1093 insertions, 461 deletions
@@ -2,7 +2,7 @@ <img src="docs/assets/Square310x310Logo.png" width="200px"> <br> <br> - <a href="https://github.com/R2NorthstarTools/FlightCore/releases/download/v1.3.2/FlightCore_1.3.2_x64_en-US.msi"><img src="docs/assets/downloadbutton.png" width="300px"></a> + <a href="https://github.com/R2NorthstarTools/FlightCore/releases/download/v1.6.0/FlightCore_1.6.0_x64_en-US.msi"><img src="docs/assets/downloadbutton.png" width="300px"></a> <br> </p> diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cc2f4fbb..0376d592 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -81,7 +81,7 @@ checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" [[package]] name = "app" -version = "1.3.2" +version = "1.6.0" dependencies = [ "anyhow", "async-recursion", @@ -1818,17 +1818,14 @@ dependencies = [ [[package]] name = "libthermite" -version = "0.3.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ced5f2756672c1379a5ee41771af8e1ead525a9f6b1e2a2139105a6754f6bb5" +checksum = "31ec1288a3f54f21ee6bd7eec92c5d098d9acc82d3bfdcf3c5ae29498ddfdc0e" dependencies = [ - "directories", "futures-util", "indicatif", "log", - "regex", "reqwest", - "ron", "serde", "serde_json", "thiserror", @@ -2995,17 +2992,6 @@ dependencies = [ ] [[package]] -name = "ron" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300a51053b1cb55c80b7a9fde4120726ddf25ca241a1cbb926626f62fb136bff" -dependencies = [ - "base64", - "bitflags", - "serde", -] - -[[package]] name = "rusqlite" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3251,18 +3237,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.149" +version = "1.0.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "256b9932320c590e707b94576e3cc1f7c9024d0ee6612dfbcf1cb106cbe8e055" +checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.149" +version = "1.0.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4eae9b04cbffdfd550eb462ed33bc6a1b68c935127d008b27444d08380f94e4" +checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" dependencies = [ "proc-macro2", "quote", @@ -3271,9 +3257,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.89" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020ff22c755c2ed3f8cf162dbb41a7268d934702f3ed3631656ea597e08fc3db" +checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" dependencies = [ "itoa 1.0.4", "ryu", @@ -3953,18 +3939,18 @@ checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" [[package]] name = "thiserror" -version = "1.0.37" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" +checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.37" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" +checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" dependencies = [ "proc-macro2", "quote", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7f3f073c..dcdf8d3b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "1.3.2" +version = "1.6.0" description = "A Tauri App" authors = ["you"] license = "" @@ -25,7 +25,7 @@ steamlocate = "1.0.2" # Error messages anyhow = "1.0" # libthermite for Northstar/mod install handling -libthermite = "0.3.4" +libthermite = "0.4.0" # zip stuff zip = "0.6.2" # Regex diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs new file mode 100644 index 00000000..4cc3c204 --- /dev/null +++ b/src-tauri/src/constants.rs @@ -0,0 +1,9 @@ +// This file stores various global constants values + +pub const APP_USER_AGENT: &str = "R2NorthstarTools/FlightCore"; + +// URL of the Northstar masterserver +pub const MASTER_SERVER_URL: &str = "https://northstar.tf"; + +// server list endpoint +pub const SERVER_BROWSER_ENDPOINT: &str = "/client/servers"; diff --git a/src-tauri/src/github/release_notes.rs b/src-tauri/src/github/release_notes.rs index f139ead5..758f7ace 100644 --- a/src-tauri/src/github/release_notes.rs +++ b/src-tauri/src/github/release_notes.rs @@ -1,3 +1,4 @@ +use crate::constants::APP_USER_AGENT; use serde::{Deserialize, Serialize}; use std::vec::Vec; @@ -8,15 +9,20 @@ pub struct ReleaseInfo { pub body: String, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FlightCoreVersion { + tag_name: String, + published_at: String, +} + // Fetches repo release API and returns response as string async fn fetch_github_releases_api(url: &str) -> Result<String, String> { println!("Fetching releases notes from GitHub API"); - let user_agent = "R2NorthstarTools/FlightCore"; let client = reqwest::Client::new(); let res = client .get(url) - .header(reqwest::header::USER_AGENT, user_agent) + .header(reqwest::header::USER_AGENT, APP_USER_AGENT) .send() .await .unwrap() @@ -27,24 +33,26 @@ async fn fetch_github_releases_api(url: &str) -> Result<String, String> { Ok(res) } -/// Checks if installed FlightCore version is up-to-date -/// false -> FlightCore install is up-to-date -/// true -> FlightCore install is outdated -pub async fn check_is_flightcore_outdated() -> Result<bool, String> { +/// Gets newest FlighCore version from GitHub +#[tauri::command] +pub async fn get_newest_flightcore_version() -> Result<FlightCoreVersion, String> { // Get newest version number from GitHub API println!("Checking GitHub API"); let url = "https://api.github.com/repos/R2NorthstarTools/FlightCore/releases/latest"; let res = fetch_github_releases_api(url).await?; - let json_response: serde_json::Value = + let flightcore_version: FlightCoreVersion = serde_json::from_str(&res).expect("JSON was not well-formatted"); println!("Done checking GitHub API"); - // Extract version number from JSON - let newest_release_version = json_response - .get("tag_name") - .and_then(|value| value.as_str()) - .unwrap(); + Ok(flightcore_version) +} + +/// Checks if installed FlightCore version is up-to-date +/// false -> FlightCore install is up-to-date +/// true -> FlightCore install is outdated +pub async fn check_is_flightcore_outdated() -> Result<bool, String> { + let newest_flightcore_release = get_newest_flightcore_version().await?; // Get version of installed FlightCore... let version = env!("CARGO_PKG_VERSION"); @@ -52,16 +60,10 @@ pub async fn check_is_flightcore_outdated() -> Result<bool, String> { let version = format!("v{}", version); // TODO: This shouldn't be a string compare but promper semver compare - let is_outdated = version != newest_release_version; + let is_outdated = version != newest_flightcore_release.tag_name; // If outdated, check how new the update is if is_outdated { - // Extract release date from JSON - let release_date = json_response - .get("published_at") - .and_then(|value| value.as_str()) - .unwrap(); - // Time to wait (2h) h * m * s let threshold_seconds = 2 * 60 * 60; @@ -69,7 +71,7 @@ pub async fn check_is_flightcore_outdated() -> Result<bool, String> { let current_time = chrono::Utc::now(); // Get latest release time from GitHub API response - let result = chrono::DateTime::parse_from_rfc3339(release_date) + let result = chrono::DateTime::parse_from_rfc3339(&newest_flightcore_release.published_at) .unwrap() .with_timezone(&chrono::Utc); @@ -91,28 +93,9 @@ pub async fn get_northstar_release_notes() -> Result<Vec<ReleaseInfo>, String> { let url = "https://api.github.com/repos/R2Northstar/Northstar/releases"; let res = fetch_github_releases_api(url).await?; - let json_response: Vec<serde_json::Value> = + let release_info_vector: Vec<ReleaseInfo> = serde_json::from_str(&res).expect("JSON was not well-formatted"); println!("Done checking GitHub API"); - return Ok(json_response - .iter() - .map(|release| ReleaseInfo { - name: release - .get("name") - .and_then(|value| value.as_str()) - .unwrap() - .to_string(), - published_at: release - .get("published_at") - .and_then(|value| value.as_str()) - .unwrap() - .to_string(), - body: release - .get("body") - .and_then(|value| value.as_str()) - .unwrap() - .to_string(), - }) - .collect()); + return Ok(release_info_vector); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f8ea6aba..67f42f85 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ use anyhow::{anyhow, Context, Result}; mod northstar; +pub mod constants; mod platform_specific; #[cfg(target_os = "windows")] use platform_specific::windows; @@ -38,6 +39,12 @@ pub struct NorthstarMod { pub directory: String, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct NorthstarServer { + #[serde(rename = "playerCount")] + pub player_count: i32, +} + /// Check version number of a mod pub fn check_mod_version_number(path_to_mod_folder: String) -> Result<String, anyhow::Error> { // println!("{}", format!("{}/mod.json", path_to_mod_folder)); @@ -183,7 +190,7 @@ async fn do_install(nmod: &thermite::model::ModVersion, game_path: &std::path::P let download_path = format!("{}/{}", download_directory.clone(), filename); println!("{}", download_path); - let nfile = thermite::core::actions::download_file(&nmod.url, download_path) + let nfile = thermite::core::manage::download_file(&nmod.url, download_path) .await .unwrap(); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e8912d60..4eab7a00 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -9,10 +9,15 @@ use std::{ time::Duration, }; -use app::*; +use app::{ + constants::{APP_USER_AGENT, MASTER_SERVER_URL, SERVER_BROWSER_ENDPOINT}, + *, +}; mod github; -use github::release_notes::{check_is_flightcore_outdated, get_northstar_release_notes}; +use github::release_notes::{ + check_is_flightcore_outdated, get_newest_flightcore_version, get_northstar_release_notes, +}; mod repair_and_verify; use repair_and_verify::{ @@ -21,7 +26,8 @@ use repair_and_verify::{ mod mod_management; use mod_management::{ - fc_download_mod_and_install, get_installed_mods_and_properties, set_mod_enabled_status, + delete_northstar_mod, delete_thunderstore_mod, fc_download_mod_and_install, + get_installed_mods_and_properties, set_mod_enabled_status, }; mod northstar; @@ -90,17 +96,20 @@ fn main() { update_northstar_caller, launch_northstar_caller, check_is_flightcore_outdated_caller, - get_log_list_caller, - verify_game_files_caller, - get_enabled_mods_caller, - set_mod_enabled_status_caller, - disable_all_but_core_caller, + get_log_list, + verify_game_files, + set_mod_enabled_status, + disable_all_but_core, is_debug_mode, get_northstar_release_notes, linux_checks, - get_installed_mods_caller, + get_installed_mods_and_properties, install_mod_caller, clean_up_download_folder_caller, + get_newest_flightcore_version, + delete_northstar_mod, + get_server_player_count, + delete_thunderstore_mod, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -274,43 +283,6 @@ async fn launch_northstar_caller( } #[tauri::command] -/// Get list of Northstar logs -async fn get_log_list_caller(game_install: GameInstall) -> Result<Vec<std::path::PathBuf>, String> { - get_log_list(game_install) -} - -#[tauri::command] -async fn verify_game_files_caller(game_install: GameInstall) -> Result<String, String> { - verify_game_files(game_install) -} - -#[tauri::command] -async fn get_enabled_mods_caller( - game_install: GameInstall, -) -> Result<serde_json::value::Value, String> { - get_enabled_mods(game_install) -} - -#[tauri::command] -async fn set_mod_enabled_status_caller( - game_install: GameInstall, - mod_name: String, - is_enabled: bool, -) -> Result<(), String> { - set_mod_enabled_status(game_install, mod_name, is_enabled) -} - -#[tauri::command] -async fn disable_all_but_core_caller(game_install: GameInstall) -> Result<(), String> { - disable_all_but_core(game_install) -} - -#[tauri::command] -async fn get_installed_mods_caller(game_install: GameInstall) -> Result<Vec<NorthstarMod>, String> { - get_installed_mods_and_properties(game_install) -} - -#[tauri::command] /// Installs the specified mod async fn install_mod_caller( game_install: GameInstall, @@ -339,3 +311,32 @@ async fn clean_up_download_folder_caller( Err(err) => Err(err.to_string()), } } + +/// Gets server and playercount from master server API +#[tauri::command] +async fn get_server_player_count() -> Result<(i32, usize), String> { + let url = format!("{MASTER_SERVER_URL}{SERVER_BROWSER_ENDPOINT}"); + let client = reqwest::Client::new(); + let res = client + .get(url) + .header(reqwest::header::USER_AGENT, APP_USER_AGENT) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + let ns_servers: Vec<NorthstarServer> = + serde_json::from_str(&res).expect("JSON was not well-formatted"); + + // Get server count + let server_count = ns_servers.len(); + + // Sum up player count + let total_player_count: i32 = ns_servers.iter().map(|server| server.player_count).sum(); + + dbg!((total_player_count, server_count)); + + Ok((total_player_count, server_count)) +} diff --git a/src-tauri/src/mod_management/mod.rs b/src-tauri/src/mod_management/mod.rs index 4fa52127..c37882fe 100644 --- a/src-tauri/src/mod_management/mod.rs +++ b/src-tauri/src/mod_management/mod.rs @@ -3,6 +3,8 @@ use async_recursion::async_recursion; use anyhow::{anyhow, Result}; use app::NorthstarMod; +use serde::{Deserialize, Serialize}; +use std::io::Read; use std::path::PathBuf; use app::get_enabled_mods; @@ -10,12 +12,45 @@ use app::GameInstall; use json5; +use crate::northstar::CORE_MODS; + pub const BLACKLISTED_MODS: [&str; 3] = [ "northstar-Northstar", "northstar-NorthstarReleaseCandidate", "ebkr-r2modman", ]; +#[derive(Debug, Clone)] +struct ParsedThunderstoreModString { + author_name: String, + mod_name: String, + version: Option<String>, +} + +impl std::str::FromStr for ParsedThunderstoreModString { + type Err = (); + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let mut parts = s.split("-"); + + let author_name = parts.next().unwrap().to_string(); + let mod_name = parts.next().unwrap().to_string(); + let version = parts.next().map(|s| s.to_string()); + + Ok(ParsedThunderstoreModString { + author_name, + mod_name, + version, + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ThunderstoreManifest { + name: String, + version_number: String, +} + /// 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); @@ -43,6 +78,7 @@ pub fn rebuild_enabled_mods_json(game_install: GameInstall) -> Result<(), String } /// Set the status of a passed mod to enabled/disabled +#[tauri::command] pub fn set_mod_enabled_status( game_install: GameInstall, mod_name: String, @@ -105,6 +141,7 @@ fn parse_mod_json_for_mod_name(mod_json_path: String) -> Result<String, anyhow:: } /// Parses `mod.json` for Thunderstore mod string +/// This is a legacy function as nowadays `manifest.json` should be in Northtar mods folder and read from there // TODO: Maybe pass PathBuf or serde json object fn parse_mod_json_for_thunderstore_mod_string( mod_json_path: String, @@ -125,6 +162,36 @@ fn parse_mod_json_for_thunderstore_mod_string( Ok(thunderstore_mod_string.to_string()) } +/// Parses `manifest.json` for Thunderstore mod string +fn parse_for_thunderstore_mod_string(nsmod_path: String) -> Result<String, anyhow::Error> { + let mod_json_path = format!("{}/mod.json", nsmod_path); + let manifest_json_path = format!("{}/manifest.json", nsmod_path); + let ts_author_txt_path = format!("{}/thunderstore_author.txt", nsmod_path); + + // Attempt legacy method for getting Thunderstore string first + match parse_mod_json_for_thunderstore_mod_string(mod_json_path) { + Ok(thunderstore_mod_string) => return Ok(thunderstore_mod_string), + Err(_err) => (), + } + + // Check if `manifest.json` exists and parse + let data = std::fs::read_to_string(manifest_json_path)?; + let thunderstore_manifest: ThunderstoreManifest = json5::from_str(&data)?; + + // Check if `thunderstore_author.txt` exists and parse + let mut file = std::fs::File::open(ts_author_txt_path)?; + let mut thunderstore_author = String::new(); + file.read_to_string(&mut thunderstore_author)?; + + // Build mod string + let thunderstore_mod_string = format!( + "{}-{}-{}", + thunderstore_author, thunderstore_manifest.name, thunderstore_manifest.version_number + ); + + Ok(thunderstore_mod_string) +} + /// Parse `mods` folder for installed mods. fn parse_installed_mods(game_install: GameInstall) -> Result<Vec<NorthstarMod>, String> { let ns_mods_folder = format!("{}/R2Northstar/mods/", game_install.game_path); @@ -146,8 +213,9 @@ fn parse_installed_mods(game_install: GameInstall) -> Result<Vec<NorthstarMod>, // 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.to_str().unwrap()); + let mod_json_path = format!("{}/mod.json", directory_str); if !std::path::Path::new(&mod_json_path).exists() { continue; } @@ -160,11 +228,11 @@ fn parse_installed_mods(game_install: GameInstall) -> Result<Vec<NorthstarMod>, continue; } }; - let thunderstore_mod_string = - match parse_mod_json_for_thunderstore_mod_string(mod_json_path.clone()) { - Ok(thunderstore_mod_string) => Some(thunderstore_mod_string), - Err(_err) => None, - }; + // Get Thunderstore mod string if it exists + let thunderstore_mod_string = match parse_for_thunderstore_mod_string(directory_str) { + Ok(thunderstore_mod_string) => Some(thunderstore_mod_string), + Err(_err) => None, + }; // Get directory path let mod_directory = directory.to_str().unwrap().to_string(); @@ -185,6 +253,7 @@ fn parse_installed_mods(game_install: GameInstall) -> Result<Vec<NorthstarMod>, /// Gets list of installed mods and their properties /// - name /// - is enabled? +#[tauri::command] pub fn get_installed_mods_and_properties( game_install: GameInstall, ) -> Result<Vec<NorthstarMod>, String> { @@ -217,8 +286,19 @@ async fn get_ns_mod_download_url(thunderstore_mod_string: String) -> Result<Stri // TODO: This will crash the thread if not internet connection exist. `match` should be used instead let index = thermite::api::get_package_index().await.unwrap().to_vec(); - // String replace works but more care should be taken in the future - let ts_mod_string_url = thunderstore_mod_string.replace("-", "/"); + // 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.unwrap() + ); for ns_mod in index { // Iterate over all versions of a given mod @@ -233,32 +313,6 @@ async fn get_ns_mod_download_url(thunderstore_mod_string: String) -> Result<Stri Err("Could not find mod on Thunderstore".to_string()) } -/// Adds given Thunderstore mod string to the given `mod.json` -/// This way we can later check whether a mod is outdated based on the TS mod string -fn add_thunderstore_mod_string( - path_to_mod_json: String, - thunderstore_mod_string: String, -) -> Result<(), anyhow::Error> { - // Read file into string and parse it - let data = std::fs::read_to_string(path_to_mod_json.clone())?; - let parsed_json: serde_json::Value = json5::from_str(&data)?; - - // Insert the Thunderstore mod string - let mut parsed_json = parsed_json.as_object().unwrap().clone(); - parsed_json.insert( - "ThunderstoreModString".to_string(), - serde_json::Value::String(thunderstore_mod_string), - ); - - // And write back to disk - std::fs::write( - path_to_mod_json, - serde_json::to_string_pretty(&parsed_json)?, - )?; - - Ok(()) -} - /// Returns a vector of modstrings containing the dependencies of a given mod async fn get_mod_dependencies( thunderstore_mod_string: String, @@ -348,38 +402,123 @@ pub async fn fc_download_mod_and_install( ); // Download the mod - let f = match thermite::core::actions::download_file(&download_url, path.clone()).await { + let f = match thermite::core::manage::download_file(&download_url, path.clone()).await { Ok(f) => f, Err(e) => return Err(e.to_string()), }; + // Get Thunderstore mod author + let author = thunderstore_mod_string.split("-").next().unwrap(); + // Extract the mod to the mods directory - let pkg = match thermite::core::actions::install_mod(&f, std::path::Path::new(&mods_directory)) - { - Ok(pkg) => pkg, + match thermite::core::manage::install_mod(author, &f, std::path::Path::new(&mods_directory)) { + Ok(()) => (), Err(err) => return Err(err.to_string()), }; - dbg!(pkg.clone()); - - // Add Thunderstore mod string to `mod.json` of installed NorthstarMods - for nsmod in pkg.mods { - let path_to_current_mod_json = format!( - "{}/{}/mod.json", - mods_directory, - nsmod.path.to_string_lossy() - ); - match add_thunderstore_mod_string(path_to_current_mod_json, thunderstore_mod_string.clone()) + + // Delete downloaded zip file + std::fs::remove_file(path).unwrap(); + + Ok(()) +} + +/// Deletes a given Northstar mod folder +fn delete_mod_folder(ns_mod_directory: String) -> 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<String> = 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 { - Ok(()) => (), - Err(err) => { - println!("Failed setting modstring for {}", nsmod.name); - println!("{}", err); - } + // Add folder to list of folder to remove + mod_folders_to_remove.push(installed_ns_mod.directory); } } - // Delete downloaded zip file - std::fs::remove_file(path).unwrap(); + if !(mod_folders_to_remove.len() > 0) { + 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(()) } diff --git a/src-tauri/src/repair_and_verify/mod.rs b/src-tauri/src/repair_and_verify/mod.rs index 64278a25..fb108f37 100644 --- a/src-tauri/src/repair_and_verify/mod.rs +++ b/src-tauri/src/repair_and_verify/mod.rs @@ -1,9 +1,13 @@ -use crate::{mod_management::{set_mod_enabled_status, rebuild_enabled_mods_json}, northstar::CORE_MODS}; +use crate::{ + mod_management::{rebuild_enabled_mods_json, set_mod_enabled_status}, + northstar::CORE_MODS, +}; use anyhow::anyhow; /// Contains various functions to repair common issues and verifying installation use app::{get_enabled_mods, GameInstall}; /// Verifies Titanfall2 game files +#[tauri::command] pub fn verify_game_files(game_install: GameInstall) -> Result<String, String> { dbg!(game_install); Err("TODO, not yet implemented".to_string()) @@ -11,8 +15,8 @@ pub fn verify_game_files(game_install: GameInstall) -> Result<String, String> { /// Disables all mods except core ones /// Enables core mods if disabled +#[tauri::command] pub fn disable_all_but_core(game_install: GameInstall) -> Result<(), String> { - // Rebuild `enabledmods.json` first to ensure all mods are added rebuild_enabled_mods_json(game_install.clone())?; @@ -62,6 +66,8 @@ pub fn clean_up_download_folder( Ok(()) } +/// Get list of Northstar logs +#[tauri::command] pub fn get_log_list(game_install: GameInstall) -> Result<Vec<std::path::PathBuf>, String> { let ns_log_folder = format!("{}/R2Northstar/logs", game_install.game_path); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9dde0f14..ca5ec9c3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "FlightCore", - "version": "1.3.2" + "version": "1.6.0" }, "tauri": { "allowlist": { diff --git a/src-vue/src/App.vue b/src-vue/src/App.vue index fc6992b7..c28919e1 100644 --- a/src-vue/src/App.vue +++ b/src-vue/src/App.vue @@ -50,7 +50,7 @@ export default { <nav id="fc_menu-bar"> <!-- Navigation items --> <el-menu - default-active="/" + :default-active="$route.path" router mode="horizontal" id="fc__menu_items" @@ -59,7 +59,6 @@ export default { <el-menu-item index="/">Play</el-menu-item> <el-menu-item index="/changelog">Changelog</el-menu-item> <el-menu-item index="/mods">Mods</el-menu-item> - <el-menu-item index="/thunderstoreMods">Thunderstore</el-menu-item> <el-menu-item index="/settings">Settings</el-menu-item> <el-menu-item index="/dev" v-if="$store.state.developer_mode">Dev</el-menu-item> </el-menu> @@ -109,7 +108,7 @@ export default { border-color: white; } -.el-menu > .el-menu-item { +#fc__menu_items > .el-menu-item { text-transform: uppercase; border: none !important; font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; diff --git a/src-vue/src/components/ModsMenu.vue b/src-vue/src/components/ModsMenu.vue new file mode 100644 index 00000000..9b62fcfa --- /dev/null +++ b/src-vue/src/components/ModsMenu.vue @@ -0,0 +1,118 @@ +<template> + <nav class="fc_mods__menu"> + <el-menu + default-active="1" + text-color="#fff" + > + <h5>Mods</h5> + <el-menu-item index="1" @click="$emit('showLocalMods', true)"> + <el-icon><Folder /></el-icon> + <span>Local</span> + </el-menu-item> + <el-menu-item index="2" @click="$emit('showLocalMods', false)"> + <el-icon><Connection /></el-icon> + <span>Online</span> + </el-menu-item> + + <!-- Search inputs --> + <h5>Filter</h5> + <el-input v-model="$store.state.search.searchValue" placeholder="Search" clearable /> + <el-select + v-if="!showingLocalMods" + v-model="$store.state.search.sortValue" + placeholder="Sort mods" + > + <el-option + v-for="item of sortValues" + :key="item.value" + :label="item.label" + :value="item.value" + /> + </el-select> + <el-select + v-if="!showingLocalMods" + v-model="$store.state.search.selectedCategories" + multiple + placeholder="Select categories" + > + <el-option + v-for="item in $store.state.thunderstoreModsCategories" + :key="item" + :label="item" + :value="item" + /> + </el-select> + + </el-menu> + </nav> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import { SortOptions } from '../utils/SortOptions.d'; + +export default defineComponent({ + name: 'ModsMenu', + props: { + showingLocalMods: { + required: true, + type: Boolean + } + }, + mounted() { + this.$store.state.search.sortValue = this.sortValues[3].value; + }, + computed: { + sortValues(): {label: string, value: string}[] { + return Object.keys(SortOptions).map((key: string) => ({ + value: key, + label: Object.values(SortOptions)[Object.keys(SortOptions).indexOf(key)] + })); + } + } +}) +</script> + +<style scoped> +.fc_mods__menu { + display: flex; + max-width: 222px; + min-width: 222px; + padding: 10px; +} + +.fc_mods__menu h5 { + margin: 8px 0 16px 5px; +} + +.fc_mods__menu h5:not(:first-child){ + margin-top: 32px; +} + +.fc_mods__menu > .el-menu { + background-color: transparent; + border: none; + width: 100%; +} + +.fc_mods__menu > .el-menu > .el-menu-item { + height: 32px; + margin-bottom: 5px; + border-radius: 5px; + color: #e2e6e7; +} + +.fc_mods__menu > .el-menu > .el-menu-item:hover { + background-color: #4e4e4e3b; +} + +.fc_mods__menu > .el-menu > .el-menu-item.is-active { + color: white; + background-color: #4e4e4e7a; +} + +.el-select { + width: 100%; + margin-top: 5px; +} +</style> diff --git a/src-vue/src/components/ThunderstoreModCard.vue b/src-vue/src/components/ThunderstoreModCard.vue index 1e742fa2..a202aa50 100644 --- a/src-vue/src/components/ThunderstoreModCard.vue +++ b/src-vue/src/components/ThunderstoreModCard.vue @@ -35,11 +35,30 @@ > {{ modButtonText }} </el-button> - <el-button link type="info" class="infoBtn" @click="openURL(mod.package_url)"> + + <!-- Information dropdown menu --> + <el-button v-if="!modIsRemovable" + link type="info" class="infoBtn" @click="openURL(mod.package_url)"> <el-icon> <InfoFilled /> </el-icon> </el-button> + + <el-dropdown v-else> + <el-icon class="infoBtn moreBtn"> + <MoreFilled /> + </el-icon> + <template #dropdown> + <el-dropdown-menu> + <el-dropdown-item @click="openURL(mod.package_url)"> + More info + </el-dropdown-item> + <el-dropdown-item @click="deleteMod(mod)"> + Remove mod + </el-dropdown-item> + </el-dropdown-menu> + </template> + </el-dropdown> </span> </div> </el-card> @@ -54,6 +73,8 @@ import {ThunderstoreModStatus} from "../utils/thunderstore/ThunderstoreModStatus import {NorthstarMod} from "../utils/NorthstarMod"; import {GameInstall} from "../utils/GameInstall"; import {ElNotification} from "element-plus"; +import { NorthstarState } from "../utils/NorthstarState"; +import { ElMessageBox } from "element-plus"; export default defineComponent({ name: "ThunderstoreModCard", @@ -138,6 +159,15 @@ export default defineComponent({ }, /** + * Tells if a Thunderstore mod can be removed. + * This is used to tell if we should display the "Remove mod" option. + **/ + modIsRemovable(): boolean { + return [ThunderstoreModStatus.INSTALLED, ThunderstoreModStatus.OUTDATED] + .includes(this.modStatus); + }, + + /** * This computes the total count of downloads of a given mod, by adding * download count of each of its releases. */ @@ -166,6 +196,50 @@ export default defineComponent({ return `${dependencyStringMembers[0]}-${dependencyStringMembers[1]}`; }, + async deleteMod(mod: ThunderstoreMod) { + + // Show pop-up to confirm delete + ElMessageBox.confirm( + 'Delete Thunderstore mod?', + 'Warning', + { + confirmButtonText: 'OK', + cancelButtonText: 'Cancel', + type: 'warning', + } + ) + .then(async () => { // Deletion confirmed + let game_install = { + game_path: this.$store.state.game_path, + install_type: this.$store.state.install_type + } as GameInstall; + + await invoke("delete_thunderstore_mod", { gameInstall: game_install, thunderstoreModString: this.latestVersion.full_name }) + .then((message) => { + ElNotification({ + title: `Removed ${mod.name}`, + message: message as string, + type: 'success', + position: 'bottom-right' + }); + }) + .catch((error) => { + ElNotification({ + title: 'Error', + message: error, + type: 'error', + position: 'bottom-right' + }); + }) + .finally(() => { + this.$store.commit('loadInstalledMods'); + }); + }) + .catch(() => { // Deletion cancelled + console.log("Deleting Thunderstore mod cancelled.") + }) + }, + async installMod (mod: ThunderstoreMod) { let game_install = { game_path: this.$store.state.game_path, @@ -241,8 +315,13 @@ export default defineComponent({ .infoBtn { width: 20px; - padding: 0; + padding: 0 !important; font-size: 20px; border: none; } + +.moreBtn { + margin-left: 10px; + height: auto; +} </style> diff --git a/src-vue/src/main.ts b/src-vue/src/main.ts index 25f1b064..95bea7af 100644 --- a/src-vue/src/main.ts +++ b/src-vue/src/main.ts @@ -6,7 +6,6 @@ import { store } from './plugins/store'; import PlayView from "./views/PlayView.vue"; import ChangelogView from "./views/ChangelogView.vue"; import ModsView from "./views/ModsView.vue"; -import ThunderstoreModsView from "./views/ThunderstoreModsView.vue"; import SettingsView from "./views/SettingsView.vue"; import DeveloperView from "./views/DeveloperView.vue"; import {createRouter, createWebHashHistory} from "vue-router"; @@ -35,7 +34,6 @@ const routes = [ { path: '/', name: 'Main', component: async () => PlayView}, { path: '/changelog', name: 'Changelog', component: async () => ChangelogView}, { path: '/mods', name: 'Mods', component: async () => ModsView}, - { path: '/thunderstoreMods', name: 'Thunderstore mods', component: async () => ThunderstoreModsView}, { path: '/settings', name: 'Settings', component: async () => SettingsView}, { path: '/dev', name: 'Dev', component: async () => DeveloperView} ]; diff --git a/src-vue/src/plugins/modules/search.ts b/src-vue/src/plugins/modules/search.ts new file mode 100644 index 00000000..16260387 --- /dev/null +++ b/src-vue/src/plugins/modules/search.ts @@ -0,0 +1,19 @@ +interface SearchStoreState { + searchValue: string +} + +export const searchModule = { + state: () => ({ + // This is the treated value of search input + searchValue: '', + // Selected mod categories + selectedCategories: [], + sortValue: {label: '', value: ''} + }), + getters: { + searchWords(state: SearchStoreState): string { + return state.searchValue.toLowerCase(); + } + } + } +
\ No newline at end of file diff --git a/src-vue/src/plugins/store.ts b/src-vue/src/plugins/store.ts index c22479e0..2eae843a 100644 --- a/src-vue/src/plugins/store.ts +++ b/src-vue/src/plugins/store.ts @@ -5,15 +5,17 @@ import { InstallType } from "../utils/InstallType"; import { invoke } from "@tauri-apps/api"; import { GameInstall } from "../utils/GameInstall"; import { ReleaseCanal } from "../utils/ReleaseCanal"; +import { FlightCoreVersion } from "../utils/FlightCoreVersion"; import { ElNotification, NotificationHandle } from 'element-plus'; import { NorthstarState } from '../utils/NorthstarState'; import { appDir } from '@tauri-apps/api/path'; import { open } from '@tauri-apps/api/dialog'; import { Store } from 'tauri-plugin-store-api'; -import {router} from "../main"; +import { router } from "../main"; import ReleaseInfo from "../utils/ReleaseInfo"; import { ThunderstoreMod } from '../utils/thunderstore/ThunderstoreMod'; import { NorthstarMod } from "../utils/NorthstarMod"; +import { searchModule } from './modules/search'; const persistentStore = new Store('flight-core-settings.json'); @@ -32,15 +34,25 @@ export interface FlightCoreStore { releaseNotes: ReleaseInfo[], thunderstoreMods: ThunderstoreMod[], + thunderstoreModsCategories: string[], installed_mods: NorthstarMod[], northstar_is_running: boolean, - origin_is_running: boolean + origin_is_running: boolean, + + player_count: number, + server_count: number, + + // user custom settings + mods_per_page: number, } let notification_handle: NotificationHandle; export const store = createStore<FlightCoreStore>({ + modules: { + search: searchModule + }, state(): FlightCoreStore { return { developer_mode: false, @@ -56,10 +68,16 @@ export const store = createStore<FlightCoreStore>({ releaseNotes: [], thunderstoreMods: [], + thunderstoreModsCategories: [], installed_mods: [], northstar_is_running: false, - origin_is_running: false + origin_is_running: false, + + player_count: -1, + server_count: -1, + + mods_per_page: 20, } }, mutations: { @@ -80,7 +98,7 @@ export const store = createStore<FlightCoreStore>({ _initializeListeners(state); }, updateCurrentTab(state: any, newTab: Tabs) { - router.push({path: newTab}); + router.push({ path: newTab }); }, async updateGamePath(state: FlightCoreStore) { // Open a selection dialog for directories @@ -232,6 +250,16 @@ export const store = createStore<FlightCoreStore>({ state.thunderstoreMods = mods.filter((mod: ThunderstoreMod) => { return !removedMods.includes(mod.name) && !mod.is_deprecated; }); + + // Retrieve categories from mods + state.thunderstoreModsCategories = mods + .map((mod: ThunderstoreMod) => mod.categories) + .filter((modCategories: string[]) => modCategories.length !== 0) + .reduce((accumulator: string[], modCategories: string[]) => { + accumulator.push( ...modCategories.filter((cat: string) => !accumulator.includes(cat)) ); + return accumulator; + }, []) + .sort(); }, async loadInstalledMods(state: FlightCoreStore) { let game_install = { @@ -246,7 +274,7 @@ export const store = createStore<FlightCoreStore>({ } // Call back-end for installed mods - await invoke("get_installed_mods_caller", { gameInstall: game_install }) + await invoke("get_installed_mods_and_properties", { gameInstall: game_install }) .then((message) => { state.installed_mods = (message as NorthstarMod[]); }) @@ -314,11 +342,17 @@ async function _initializeApp(state: any) { } // Grab "Enable releases switching" setting from store if possible - const valueFromStore: {value: boolean} | null = await persistentStore.get('northstar-releases-switching'); + const valueFromStore: { value: boolean } | null = await persistentStore.get('northstar-releases-switching'); if (valueFromStore) { state.enableReleasesSwitch = valueFromStore.value; } + // Grab "Thunderstore mods per page" setting from store if possible + const perPageFromStore: { value: number } | null = await persistentStore.get('thunderstore-mods-per-page'); + if (perPageFromStore && perPageFromStore.value) { + state.mods_per_page = perPageFromStore.value; + } + // Get FlightCore version number state.flightcore_version = await invoke("get_flightcore_version_number"); @@ -370,6 +404,16 @@ async function _initializeApp(state: any) { // Check installed Northstar version if found await _get_northstar_version_number(state); } + + await invoke<[number, number]>("get_server_player_count") + .then((message) => { + state.player_count = message[0]; + state.server_count = message[1]; + }) + .catch((error) => { + console.warn("Failed getting player/server count"); + console.warn(error); + }); } async function _checkForFlightCoreUpdates(state: FlightCoreStore) { @@ -377,9 +421,10 @@ async function _checkForFlightCoreUpdates(state: FlightCoreStore) { let flightcore_is_outdated = await invoke("check_is_flightcore_outdated_caller") as boolean; if (flightcore_is_outdated) { + let newest_flightcore_version = await invoke("get_newest_flightcore_version") as FlightCoreVersion; ElNotification({ title: 'FlightCore outdated!', - message: `Please update FlightCore. Running outdated version ${state.flightcore_version}`, + message: `Please update FlightCore.\nRunning outdated version ${state.flightcore_version}.\nNewest is ${newest_flightcore_version.tag_name}!`, type: 'warning', position: 'bottom-right', duration: 0 // Duration `0` means the notification will not auto-vanish @@ -407,23 +452,23 @@ function _initializeListeners(state: any) { */ async function _get_northstar_version_number(state: any) { await invoke("get_northstar_version_number_caller", { gamePath: state.game_path }) - .then((message) => { - let northstar_version_number: string = message as string; - state.installed_northstar_version = northstar_version_number; - state.northstar_state = NorthstarState.READY_TO_PLAY; - - invoke("check_is_northstar_outdated", { gamePath: state.game_path, northstarPackageName: state.northstar_release_canal }) - .then((message) => { - if (message) { - state.northstar_state = NorthstarState.MUST_UPDATE; - } - }) - .catch((error) => { - console.error(error); - alert(error); - }); - }) - .catch((error) => { - state.northstar_state = NorthstarState.INSTALL; - }) + .then((message) => { + let northstar_version_number: string = message as string; + state.installed_northstar_version = northstar_version_number; + state.northstar_state = NorthstarState.READY_TO_PLAY; + + invoke("check_is_northstar_outdated", { gamePath: state.game_path, northstarPackageName: state.northstar_release_canal }) + .then((message) => { + if (message) { + state.northstar_state = NorthstarState.MUST_UPDATE; + } + }) + .catch((error) => { + console.error(error); + alert(error); + }); + }) + .catch((error) => { + state.northstar_state = NorthstarState.INSTALL; + }) } diff --git a/src-vue/src/utils/FlightCoreVersion.d.ts b/src-vue/src/utils/FlightCoreVersion.d.ts new file mode 100644 index 00000000..2516bf25 --- /dev/null +++ b/src-vue/src/utils/FlightCoreVersion.d.ts @@ -0,0 +1,5 @@ +// derived from release_notes.rs +export interface FlightCoreVersion { + tag_name: string, + published_at: string, +} diff --git a/src-vue/src/utils/SortOptions.d.ts b/src-vue/src/utils/SortOptions.d.ts new file mode 100644 index 00000000..6bdd0a4a --- /dev/null +++ b/src-vue/src/utils/SortOptions.d.ts @@ -0,0 +1,8 @@ +export enum SortOptions { + NAME_ASC = 'By name (A to Z)', + NAME_DESC = 'By name (Z to A)', + DATE_ASC = 'By date (from oldest)', + DATE_DESC = 'By date (from newest)', + MOST_DOWNLOADED = "Most downloaded", + TOP_RATED = "Top rated" +} diff --git a/src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts b/src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts index 6fddd06f..6387c47e 100644 --- a/src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts +++ b/src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts @@ -3,8 +3,10 @@ import { ThunderstoreModVersion } from "./ThunderstoreModVersion"; export interface ThunderstoreMod { name: string; owner: string; + date_updated: string; rating_score: number; package_url: string; is_deprecated: boolean; versions: ThunderstoreModVersion[]; + categories: string[]; } diff --git a/src-vue/src/views/DeveloperView.vue b/src-vue/src/views/DeveloperView.vue index 9f8881d8..6770caaa 100644 --- a/src-vue/src/views/DeveloperView.vue +++ b/src-vue/src/views/DeveloperView.vue @@ -111,7 +111,7 @@ export default defineComponent({ game_path: this.$store.state.game_path, install_type: this.$store.state.install_type } as GameInstall; - await invoke("disable_all_but_core_caller", { gameInstall: game_install }).then((message) => { + await invoke("disable_all_but_core", { gameInstall: game_install }).then((message) => { ElNotification({ title: 'Success', message: "Disabled all mods but core", @@ -133,7 +133,7 @@ export default defineComponent({ game_path: this.$store.state.game_path, install_type: this.$store.state.install_type } as GameInstall; - await invoke("get_installed_mods_caller", { gameInstall: game_install }).then((message) => { + await invoke("get_installed_mods_and_properties", { gameInstall: game_install }).then((message) => { // Simply console logging for now // In the future we should display the installed mods somewhere console.log(message); @@ -162,7 +162,7 @@ export default defineComponent({ } as GameInstall; let mod_to_install = this.mod_to_install_field_string; await invoke("install_mod_caller", { gameInstall: game_install, thunderstoreModString: mod_to_install }).then((message) => { - // Show user notificatio if mod install completed. + // Show user notification if mod install completed. ElNotification({ title: `Installed ${mod_to_install}`, message: message as string, @@ -185,7 +185,7 @@ export default defineComponent({ install_type: this.$store.state.install_type } as GameInstall; await invoke("clean_up_download_folder_caller", { gameInstall: game_install, force: true }).then((message) => { - // Show user notificatio if mod install completed. + // Show user notification if task completed. ElNotification({ title: `Done`, message: `Done`, diff --git a/src-vue/src/views/ModsView.vue b/src-vue/src/views/ModsView.vue index b1d1aeff..7c309832 100644 --- a/src-vue/src/views/ModsView.vue +++ b/src-vue/src/views/ModsView.vue @@ -1,78 +1,50 @@ <template> - <div class="fc-container"> - <el-scrollbar> - <h3>Installed Mods:</h3> - <div> - <p v-if="installedMods.length === 0">No mods were found.</p> - <el-card v-else shadow="hover" v-for="mod in installedMods" v-bind:key="mod.name"> - <el-switch style="--el-switch-on-color: #13ce66; --el-switch-off-color: #8957e5" v-model="mod.enabled" - :before-change="() => updateWhichModsEnabled(mod)" :loading="global_load_indicator" /> - {{mod.name}} - </el-card> - </div> - </el-scrollbar> + <div class="fc-container" style="display: flex"> + <!-- Local mods/Thunderstore mods menu --> + <mods-menu + :showingLocalMods="show_local_mods" + @showLocalMods="(v) => show_local_mods = v" + /> + + <!-- Mods content --> + <div class="fc_mods__container"> + <local-mods-view + v-if="show_local_mods" + /> + + <thunderstore-mods-view + v-else + clearable + /> + </div> </div> </template> <script lang="ts"> import { defineComponent } from "vue"; -import { ElNotification } from "element-plus"; -import { invoke } from '@tauri-apps/api/tauri'; -import { GameInstall } from "../utils/GameInstall"; -import { NorthstarMod } from "../utils/NorthstarMod" +import ThunderstoreModsView from "./mods/ThunderstoreModsView.vue"; +import LocalModsView from "./mods/LocalModsView.vue"; +import ModsMenu from "../components/ModsMenu.vue"; export default defineComponent({ name: "ModsView", + components: { + ModsMenu, + LocalModsView, + ThunderstoreModsView + }, data() { return { - global_load_indicator: false - } - }, - async mounted() { - this.$store.commit('loadInstalledMods'); - }, - computed: { - installedMods(): NorthstarMod[] { - return this.$store.state.installed_mods; - } - }, - methods: { - async updateWhichModsEnabled(mod: NorthstarMod) { - this.global_load_indicator = true; - - // Setup up struct - let game_install = { - game_path: this.$store.state.game_path, - install_type: this.$store.state.install_type - } as GameInstall; - - // enable/disable specific mod - try { - await invoke("set_mod_enabled_status_caller", { - gameInstall: game_install, - modName: mod.name, - // Need to set it to the opposite of current state, - // as current state is only updated after command is run - isEnabled: !mod.enabled, - }) - } - catch (error) { - ElNotification({ - title: 'Error', - message: `${error}`, - type: 'error', - position: 'bottom-right' - }); - this.global_load_indicator = false; - return false; - } - - this.global_load_indicator = false; - return true; + show_local_mods: true, } } }); </script> -<style> +<style scoped> +.fc_mods__container { + display: flex; + width: 100%; + flex-direction: column; +} </style> diff --git a/src-vue/src/views/SettingsView.vue b/src-vue/src/views/SettingsView.vue index ff87c394..1c03fe4f 100644 --- a/src-vue/src/views/SettingsView.vue +++ b/src-vue/src/views/SettingsView.vue @@ -14,6 +14,24 @@ <el-button icon="Folder" @click="updateGamePath"/> </template> </el-input> + + <!-- Thunderstore mods per page configuration --> + <div class="fc_parameter__panel"> + <h3>Number of Thunderstore mods per page</h3> + <h6> + This has an impact on display performances when browsing Thunderstore mods.<br> + Set this value to 0 to disable pagination. + </h6> + <el-input + v-model="modsPerPage" + type="number" + > + <template #append> + <el-button @click="modsPerPage = 20">Reset to default</el-button> + </template> + </el-input> + </div> + <h3>About:</h3> <div class="fc_northstar__version" @click="activateDeveloperMode"> FlightCore Version: {{ flightcoreVersion === '' ? 'Unknown version' : `${flightcoreVersion}` }} @@ -64,6 +82,15 @@ export default defineComponent({ this.$store.commit('toggleReleaseCandidate'); } } + }, + modsPerPage: { + get(): number { + return this.$store.state.mods_per_page; + }, + set(value: number) { + this.$store.state.mods_per_page = value; + persistentStore.set('thunderstore-mods-per-page', { value }); + } } }, methods: { @@ -86,6 +113,12 @@ export default defineComponent({ }, mounted() { document.querySelector('input')!.disabled = true; + }, + unmounted() { + if (('' + this.modsPerPage) === '') { + console.warn('Incorrect value for modsPerPage, resetting it to 20.'); + this.modsPerPage = 20; + } } }); </script> @@ -110,4 +143,19 @@ h3:first-of-type { .el-switch { margin-left: 50px; } + + +/* Parameter panel styles */ +.fc_parameter__panel { + margin: 30px 0; +} + +.fc_parameter__panel h3 { + margin-bottom: 5px; +} + +.fc_parameter__panel h6 { + margin-top: 0; + margin-bottom: 12px; +} </style> diff --git a/src-vue/src/views/ThunderstoreModsView.vue b/src-vue/src/views/ThunderstoreModsView.vue deleted file mode 100644 index 5b74bd1c..00000000 --- a/src-vue/src/views/ThunderstoreModsView.vue +++ /dev/null @@ -1,186 +0,0 @@ -<template> - <div class="fc-container"> - <div v-if="mods.length === 0" class="fc__changelog__container"> - <el-progress :show-text="false" :percentage="50" :indeterminate="true" /> - </div> - <el-scrollbar v-else class="container"> - <div class="card-container"> - <!-- Search filters --> - <div class="filter_container"> - <el-input v-model="input" placeholder="Search" clearable @input="onFilterTextChange" /> - <!-- Message displayed when user is typing in search bar --> - <div v-if="userIsTyping" class="modMessage search"> - Searching mods... - </div> - </div> - - <!-- Message displayed if no mod matched searched words --> - <div v-if="filteredMods.length === 0 && input.length !== 0 && !userIsTyping" class="modMessage"> - No matching mod has been found.<br/> - Try another search! - </div> - - <!-- Mod cards --> - <thunderstore-mod-card v-for="mod of modsList" v-bind:key="mod.name" :mod="mod" /> - </div> - </el-scrollbar> - </div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { ThunderstoreMod } from "../utils/thunderstore/ThunderstoreMod"; -import ThunderstoreModCard from "../components/ThunderstoreModCard.vue"; - -export default defineComponent({ - name: "ThunderstoreModsView", - components: {ThunderstoreModCard}, - async mounted() { - this.$store.commit('fetchThunderstoreMods'); - }, - computed: { - mods(): ThunderstoreMod[] { - return this.$store.state.thunderstoreMods; - }, - modsList(): ThunderstoreMod[] { - return this.input.length === 0 || this.userIsTyping ? this.mods : this.filteredMods; - } - }, - data() { - return { - input: '', - filteredMods: [] as ThunderstoreMod[], - modsBeingInstalled: [] as string[], - userIsTyping: false, - debouncedSearch: this.debounce((i: string) => this.filterMods(i)) - }; - }, - methods: { - /** - * This is a debounced version of the filterMods method, that calls - * filterMods when user has stopped typing in the search bar (i.e. - * waits 300ms). - * It allows not to trigger filtering method (which is costly) each - * time user inputs a character. - */ - onFilterTextChange (searchString: string) { - this.debouncedSearch(searchString); - }, - - /** - * This method is called each time search input is modified, and - * filters mods matching the input string. - * - * This converts research string and all researched fields to - * lower case, to match mods regardless of font case. - */ - filterMods(value: string) { - if (value === '') { - this.filteredMods = []; - return; - } - - const searchValue = value.toLowerCase(); - - this.filteredMods = this.mods.filter((mod: ThunderstoreMod) => { - return mod.name.toLowerCase().includes(searchValue) - || mod.owner.toLowerCase().includes(searchValue) - || mod.versions[0].description.toLowerCase().includes(searchValue); - }); - }, - - /** - * This debounces a method, i.e. it prevents input method from being called - * multiple times in a short period of time. - * Stolen from https://www.freecodecamp.org/news/javascript-debounce-example/ - */ - debounce (func: Function, timeout = 200) { - let timer: any; - return (...args: any) => { - this.userIsTyping = true; - clearTimeout(timer); - timer = setTimeout(() => { - this.userIsTyping = false; - func.apply(this, args); - }, timeout); - }; - } - } -}); -</script> - -<style scoped> -.fc__changelog__container { - padding: 20px 30px; -} - -.el-timeline-item__timestamp { - color: white !important; - user-select: none !important; -} - -.filter_container { - margin: 5px; -} - -.el-input { - max-width: 300px; -} - -.search { - display: inline-block; - margin: 0 0 0 10px !important; -} - -.modMessage { - color: white; - margin: 20px 5px; -} - -.card-container { - margin: 0 auto; -} - -/* Card container dynamic size */ -@media (max-width: 1000px) { - .card-container { - width: 752px; - } -} - -@media (max-width: 812px) { - .card-container { - width: 574px; - } -} - -@media (max-width: 624px) { - .card-container { - width: 376px; - } -} - -@media (min-width: 1000px) { - .card-container { - width: 940px; - } -} - -@media (min-width: 1188px) { - .card-container { - width: 1128px; - } -} - -@media (min-width: 1376px) { - .card-container { - width: 1316px; - } -} - -@media (min-width: 1565px) { - .card-container { - width: 1505px; - } -} -</style> diff --git a/src-vue/src/views/mods/LocalModsView.vue b/src-vue/src/views/mods/LocalModsView.vue new file mode 100644 index 00000000..470ab4f7 --- /dev/null +++ b/src-vue/src/views/mods/LocalModsView.vue @@ -0,0 +1,122 @@ +<template> + <el-scrollbar> + <div> + <p v-if="mods.length === 0">No mods were found.</p> + <el-card v-else shadow="hover" v-for="mod in mods" v-bind:key="mod.name"> + <el-switch style="--el-switch-on-color: #13ce66; --el-switch-off-color: #8957e5" v-model="mod.enabled" + :before-change="() => updateWhichModsEnabled(mod)" :loading="global_load_indicator" /> + <el-popconfirm + title="Are you sure to delete this mod?" + @confirm="deleteMod(mod)" + > + <template #reference> + <el-button type="danger">Delete</el-button> + </template> + </el-popconfirm> + {{ mod.name }} + </el-card> + </div> + </el-scrollbar> +</template> + +<script lang="ts"> +import { invoke } from '@tauri-apps/api'; +import { ElNotification } from 'element-plus'; +import { defineComponent } from 'vue'; +import { GameInstall } from '../../utils/GameInstall'; +import { NorthstarMod } from '../../utils/NorthstarMod'; + +export default defineComponent({ + name: 'LocalModsView', + computed: { + installedMods(): NorthstarMod[] { + return this.$store.state.installed_mods; + }, + searchValue(): string { + return this.$store.getters.searchWords; + }, + mods(): NorthstarMod[] { + if (this.searchValue.length === 0) { + return this.installedMods; + } + + return this.installedMods.filter((mod: NorthstarMod) => { + return mod.name.toLowerCase().includes(this.searchValue); + }); + } + }, + data() { + return { + global_load_indicator: false, + }; + }, + methods: { + async updateWhichModsEnabled(mod: NorthstarMod) { + this.global_load_indicator = true; + + // Setup up struct + let game_install = { + game_path: this.$store.state.game_path, + install_type: this.$store.state.install_type + } as GameInstall; + + // enable/disable specific mod + try { + await invoke("set_mod_enabled_status", { + gameInstall: game_install, + modName: mod.name, + // Need to set it to the opposite of current state, + // as current state is only updated after command is run + isEnabled: !mod.enabled, + }) + } + catch (error) { + ElNotification({ + title: 'Error', + message: `${error}`, + type: 'error', + position: 'bottom-right' + }); + this.global_load_indicator = false; + return false; + } + + this.global_load_indicator = false; + return true; + }, + async deleteMod(mod: NorthstarMod) { + let game_install = { + game_path: this.$store.state.game_path, + install_type: this.$store.state.install_type + } as GameInstall; + await invoke("delete_northstar_mod", { gameInstall: game_install, nsmodName: mod.name }) + .then((message) => { + // Just a visual indicator that it worked + ElNotification({ + title: `Success deleting ${mod.name}`, + type: 'success', + position: 'bottom-right' + }); + }) + .catch((error) => { + ElNotification({ + title: 'Error', + message: error, + type: 'error', + position: 'bottom-right' + }); + }) + .finally(() => { + this.$store.commit('loadInstalledMods'); + }); + }, + }, + mounted() { + this.$store.commit('loadInstalledMods'); + } +}) +</script> + +<style scoped> + +</style> diff --git a/src-vue/src/views/mods/ThunderstoreModsView.vue b/src-vue/src/views/mods/ThunderstoreModsView.vue new file mode 100644 index 00000000..aaf15220 --- /dev/null +++ b/src-vue/src/views/mods/ThunderstoreModsView.vue @@ -0,0 +1,272 @@ +<template>
+ <div class="fc-container" style="padding: 0">
+ <div v-if="mods.length === 0" class="fc__changelog__container">
+ <el-progress :show-text="false" :percentage="50" :indeterminate="true" />
+ </div>
+ <el-scrollbar v-else class="container" ref="scrollbar">
+ <div class="card-container">
+ <div class="pagination_container">
+ <el-pagination
+ v-if="shouldDisplayPagination"
+ :currentPage="currentPageIndex + 1"
+ layout="prev, pager, next"
+ :page-size="modsPerPage"
+ :total="modsList.length"
+ @current-change="(e: number) => currentPageIndex = e - 1"
+ />
+ </div>
+
+ <!-- Message displayed if no mod matched searched words -->
+ <div v-if="filteredMods.length === 0" class="modMessage">
+ No matching mod has been found.<br/>
+ Try another search!
+ </div>
+
+ <!-- Mod cards -->
+ <thunderstore-mod-card v-for="mod of currentPageMods" v-bind:key="mod.name" :mod="mod" />
+ </div>
+
+ <!-- Bottom pagination -->
+ <div class="card-container">
+ <div class="pagination_container">
+ <el-pagination
+ class="fc_bottom__pagination"
+ v-if="shouldDisplayPagination"
+ :currentPage="currentPageIndex + 1"
+ layout="prev, pager, next"
+ :page-size="modsPerPage"
+ :total="modsList.length"
+ @current-change="onBottomPaginationChange"
+ />
+ </div>
+ </div>
+ </el-scrollbar>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue';
+import { ThunderstoreMod } from "../../utils/thunderstore/ThunderstoreMod";
+import ThunderstoreModCard from "../../components/ThunderstoreModCard.vue";
+import { ElScrollbar, ScrollbarInstance } from "element-plus";
+import { SortOptions } from "../../utils/SortOptions.d";
+import { ThunderstoreModVersion } from '../../utils/thunderstore/ThunderstoreModVersion';
+
+export default defineComponent({
+ name: "ThunderstoreModsView",
+ components: {ThunderstoreModCard},
+ async mounted() {
+ this.$store.commit('fetchThunderstoreMods');
+ },
+ computed: {
+ searchValue(): string {
+ return this.$store.getters.searchWords;
+ },
+ selectedCategories(): Object[] {
+ return this.$store.state.search.selectedCategories;
+ },
+ modSorting(): SortOptions {
+ return Object.values(SortOptions)[Object.keys(SortOptions).indexOf(this.$store.state.search.sortValue)];
+ },
+ mods(): ThunderstoreMod[] {
+ return this.$store.state.thunderstoreMods;
+ },
+ filteredMods(): ThunderstoreMod[] {
+ if (this.searchValue.length === 0 && this.selectedCategories.length === 0) {
+ return this.mods;
+ }
+
+ return this.mods.filter((mod: ThunderstoreMod) => {
+ // Filter with search words (only if search field isn't empty)
+ const inputMatches: boolean = this.searchValue.length === 0
+ || (mod.name.toLowerCase().includes(this.searchValue)
+ || mod.owner.toLowerCase().includes(this.searchValue)
+ || mod.versions[0].description.toLowerCase().includes(this.searchValue));
+
+ // Filter with categories (only if some categories are selected)
+ const categoriesMatch: boolean = this.selectedCategories.length === 0
+ || mod.categories
+ .filter((category: string) => this.selectedCategories.includes(category))
+ .length === this.selectedCategories.length;
+
+ return inputMatches && categoriesMatch;
+ });
+ },
+ modsList(): ThunderstoreMod[] {
+ // Use filtered mods if user is searching, vanilla list otherwise.
+ const mods: ThunderstoreMod[] = this.searchValue.length !== 0 || this.selectedCategories.length !== 0
+ ? this.filteredMods
+ : this.mods;
+
+ // Sort mods regarding user selected algorithm.
+ let compare: (a: ThunderstoreMod, b: ThunderstoreMod) => number;
+ switch(this.modSorting) {
+ case SortOptions.NAME_ASC:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => a.name.localeCompare(b.name);
+ break;
+ case SortOptions.NAME_DESC:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => -1 * a.name.localeCompare(b.name);
+ break;
+ case SortOptions.DATE_ASC:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => a.date_updated.localeCompare(b.date_updated);
+ break;
+ case SortOptions.DATE_DESC:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => -1 * a.date_updated.localeCompare(b.date_updated);
+ break;
+ case SortOptions.MOST_DOWNLOADED:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => {
+ const aTotal = a.versions.reduce((prev, next) => {
+ return {downloads: prev.downloads + next.downloads} as ThunderstoreModVersion;
+ }).downloads;
+ const bTotal = b.versions.reduce((prev, next) => {
+ return {downloads: prev.downloads + next.downloads} as ThunderstoreModVersion;
+ }).downloads;
+ return -1 * (aTotal - bTotal);
+ };
+ break;
+ case SortOptions.TOP_RATED:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => -1 * (a.rating_score - b.rating_score);
+ break;
+ default:
+ throw new Error('Unknown mod sorting.');
+ }
+
+ return mods.sort(compare);
+ },
+ modsPerPage(): number {
+ return parseInt(this.$store.state.mods_per_page);
+ },
+ currentPageMods(): ThunderstoreMod[] {
+ // User might want to display all mods on one page.
+ const perPageValue = this.modsPerPage != 0 ? this.modsPerPage : this.modsList.length;
+
+ const startIndex = this.currentPageIndex * perPageValue;
+ const endIndexCandidate = startIndex + perPageValue;
+ const endIndex = endIndexCandidate > this.modsList.length ? this.modsList.length : endIndexCandidate;
+ return this.modsList.slice(startIndex, endIndex);
+ },
+ shouldDisplayPagination(): boolean {
+ return this.modsPerPage != 0 && this.modsList.length > this.modsPerPage;
+ }
+ },
+ data() {
+ return {
+ modsBeingInstalled: [] as string[],
+ currentPageIndex: 0
+ };
+ },
+ methods: {
+ /**
+ * This updates current pagination and scrolls view to the top.
+ */
+ onBottomPaginationChange(index: number) {
+ this.currentPageIndex = index - 1;
+ (this.$refs.scrollbar as ScrollbarInstance).scrollTo({ top: 0, behavior: 'smooth' });
+ }
+ },
+ watch: {
+ searchValue(_: string, __: string) {
+ if (this.currentPageIndex !== 0) {
+ this.currentPageIndex = 0;
+ }
+ },
+ selectedCategories(_: string[], __: string[]) {
+ if (this.currentPageIndex !== 0) {
+ this.currentPageIndex = 0;
+ }
+ }
+ }
+});
+</script>
+
+<style scoped>
+.fc__changelog__container {
+ padding: 20px 30px;
+}
+
+.fc-container:deep(.el-scrollbar__view) {
+ padding-left: 0;
+ padding-right: 0;
+}
+
+.el-timeline-item__timestamp {
+ color: white !important;
+ user-select: none !important;
+}
+
+.search {
+ display: inline-block;
+ margin: 0 0 0 10px !important;
+}
+
+.modMessage {
+ color: white;
+ margin: 20px 5px;
+}
+
+.card-container {
+ margin: 0 auto;
+}
+
+.pagination_container {
+ margin: 5px auto;
+ padding: 0 5px;
+ max-width: 1000px;
+ justify-content: center;
+ display: flex;
+}
+
+.el-pagination {
+ margin: 0;
+}
+
+.fc_bottom__pagination {
+ padding-bottom: 20px !important;
+ padding-right: 10px;
+}
+
+/* Card container dynamic size */
+
+.card-container {
+ --thunderstore-mod-card-width: 178px;
+ --thunderstore-mod-card-margin: 5px;
+ --thunderstore-mod-card-columns-count: 1;
+
+ width: calc(var(--thunderstore-mod-card-width) * var(--thunderstore-mod-card-columns-count) + var(--thunderstore-mod-card-margin) * 2 * var(--thunderstore-mod-card-columns-count));
+}
+@media (min-width: 628px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 2;
+ }
+}
+@media (min-width: 836px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 3;
+ }
+}
+@media (min-width: 1006px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 4;
+ }
+}
+@media (min-width: 1196px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 5;
+ }
+}
+@media (min-width: 1386px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 6;
+ }
+}
+@media (min-width: 1576px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 7;
+ }
+}
+@media (min-width: 1766px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 8;
+ }
+}
+</style>
diff --git a/src-vue/tsconfig.json b/src-vue/tsconfig.json index d4aefa2c..7f0f0012 100644 --- a/src-vue/tsconfig.json +++ b/src-vue/tsconfig.json @@ -10,7 +10,7 @@ "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true, - "lib": ["ESNext", "DOM"], + "lib": ["ESNext", "DOM", "ES2018"], "skipLibCheck": true }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], |