diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index 51a838c7d..3ad2d6e88 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -1,5 +1,6 @@ // This file stores various global constants values use const_format::concatcp; +use std::time::Duration; /// FlightCore user agent for web requests pub const APP_USER_AGENT: &str = concatcp!("FlightCore/", env!("CARGO_PKG_VERSION")); @@ -9,3 +10,51 @@ pub const MASTER_SERVER_URL: &str = "https://northstar.tf"; /// server list endpoint pub const SERVER_BROWSER_ENDPOINT: &str = "/client/servers"; + +/// List of core Northstar mods +pub const CORE_MODS: [&str; 3] = [ + "Northstar.Client", + "Northstar.Custom", + "Northstar.CustomServers", +]; + +/// List of Thunderstoremods that shouldn't be installable +/// as they behave different than common Squirrel mods +pub const BLACKLISTED_MODS: [&str; 3] = [ + "northstar-Northstar", + "northstar-NorthstarReleaseCandidate", + "ebkr-r2modman", +]; + +/// List of Thunderstoremods that have some specific install requirements that makes them different from standard mods +pub const MODS_WITH_SPECIAL_REQUIREMENTS: [&str; 1] = ["NanohmProtogen-VanillaPlus"]; + +/// Order in which the sections for release notes should be displayed +pub const SECTION_ORDER: [&str; 11] = [ + "feat", "fix", "docs", "style", "refactor", "build", "test", "i18n", "ci", "chore", "other", +]; + +/// Statistics (players and servers counts) refresh delay +pub const REFRESH_DELAY: Duration = Duration::from_secs(5 * 60); + +/// Flightcore repo name and org name on GitHub +pub const FLIGHTCORE_REPO_NAME: &str = "R2NorthstarTools/FlightCore"; + +/// Northstar release repo name and org name on GitHub +pub const NORTHSTAR_RELEASE_REPO_NAME: &str = "R2Northstar/Northstar"; + +/// NorthstarLauncher repo name on GitHub +pub const NORTHSTAR_LAUNCHER_REPO_NAME: &str = "NorthstarLauncher"; + +/// NorthstarMods repo name on GitHub +pub const NORTHSTAR_MODS_REPO_NAME: &str = "NorthstarMods"; + +/// URL to launcher commits API URL +pub const NS_LAUNCHER_COMMITS_API_URL: &str = + "https://api.github.com/repos/R2Northstar/NorthstarLauncher/commits"; + +/// Filename of DLL that Northstar uses +pub const NORTHSTAR_DLL: &str = "Northstar.dll"; + +/// Profile that Northstar defaults to and ships with +pub const NORTHSTAR_DEFAULT_PROFILE: &str = "R2Northstar"; diff --git a/src-tauri/src/development/mod.rs b/src-tauri/src/development/mod.rs new file mode 100644 index 000000000..7184904cd --- /dev/null +++ b/src-tauri/src/development/mod.rs @@ -0,0 +1,84 @@ +use crate::constants::NS_LAUNCHER_COMMITS_API_URL; +use crate::github::{ + pull_requests::{check_github_api, download_zip_into_memory, get_launcher_download_link}, + CommitInfo, +}; + +#[tauri::command] +pub async fn install_git_main(game_install_path: &str) -> Result { + // Get list of commits + let commits: Vec = serde_json::from_value( + check_github_api(NS_LAUNCHER_COMMITS_API_URL) + .await + .expect("Failed request"), + ) + .unwrap(); + + // Get latest commit... + let latest_commit_sha = commits[0].sha.clone(); + // ...and according artifact download URL + let download_url = get_launcher_download_link(latest_commit_sha.clone()).await?; + + let archive = match download_zip_into_memory(download_url).await { + Ok(archive) => archive, + Err(err) => return Err(err.to_string()), + }; + + let extract_directory = format!( + "{}/___flightcore-temp/download-dir/launcher-pr-{}", + game_install_path, latest_commit_sha + ); + match std::fs::create_dir_all(extract_directory.clone()) { + Ok(_) => (), + Err(err) => { + return Err(format!( + "Failed creating temporary download directory: {}", + err + )) + } + }; + + let target_dir = std::path::PathBuf::from(extract_directory.clone()); // Doesn't need to exist + match zip_extract::extract(std::io::Cursor::new(archive), &target_dir, true) { + Ok(()) => (), + Err(err) => { + return Err(format!("Failed unzip: {}", err)); + } + }; + + // Copy only necessary files from temp dir + // Copy: + // - NorthstarLauncher.exe + // - Northstar.dll + let files_to_copy = vec!["NorthstarLauncher.exe", "Northstar.dll"]; + for file_name in files_to_copy { + let source_file_path = format!("{}/{}", extract_directory, file_name); + let destination_file_path = format!("{}/{}", game_install_path, file_name); + match std::fs::copy(source_file_path, destination_file_path) { + Ok(_result) => (), + Err(err) => { + return Err(format!( + "Failed to copy necessary file {} from temp dir: {}", + file_name, err + )) + } + }; + } + + // delete extract directory + match std::fs::remove_dir_all(&extract_directory) { + Ok(()) => (), + Err(err) => { + return Err(format!( + "Failed to delete temporary download directory: {}", + err + )) + } + } + + log::info!( + "All done with installing launcher from {}", + latest_commit_sha + ); + Ok(latest_commit_sha) +} diff --git a/src-tauri/src/github/mod.rs b/src-tauri/src/github/mod.rs index 80a1831a9..9572d30c1 100644 --- a/src-tauri/src/github/mod.rs +++ b/src-tauri/src/github/mod.rs @@ -1 +1,163 @@ +pub mod pull_requests; pub mod release_notes; + +use crate::constants::{ + APP_USER_AGENT, FLIGHTCORE_REPO_NAME, NORTHSTAR_RELEASE_REPO_NAME, SECTION_ORDER, +}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +pub struct Tag { + name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[ts(export)] +pub enum Project { + FlightCore, + Northstar, +} + +/// Wrapper type needed for frontend +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +pub struct TagWrapper { + label: String, + value: Tag, +} + +#[derive(Debug, Deserialize)] +pub struct CommitInfo { + pub sha: String, + commit: Commit, + author: Option, +} + +#[derive(Debug, Deserialize)] +struct Commit { + message: String, +} + +#[derive(Debug, Deserialize)] +struct CommitAuthor { + login: String, +} + +#[derive(Debug, Deserialize)] +struct Comparison { + commits: Vec, +} + +/// Get a list of tags on the FlightCore repo +#[tauri::command] +pub fn get_list_of_tags(project: Project) -> Result, String> { + todo!() +} + +/// Use GitHub API to compare two tags of the same repo against each other and get the resulting changes +#[tauri::command] +pub fn compare_tags(project: Project, first_tag: Tag, second_tag: Tag) -> Result { + match project { + Project::FlightCore => compare_tags_flightcore(first_tag, second_tag), + Project::Northstar => compare_tags_northstar(first_tag, second_tag), + } +} + +pub fn compare_tags_flightcore(first_tag: Tag, second_tag: Tag) -> Result { + todo!() +} + +/// Generate release notes in the format used for FlightCore +fn generate_flightcore_release_notes(commits: Vec) -> String { + let grouped_commits = group_commits_by_type(commits); + let mut release_notes = String::new(); + + // Go over commit types and generate notes + for commit_type in SECTION_ORDER { + if let Some(commit_list) = grouped_commits.get(commit_type) { + if !commit_list.is_empty() { + let section_title = match commit_type { + "feat" => "**Features:**", + "fix" => "**Bug Fixes:**", + "docs" => "**Documentation:**", + "style" => "**Code style changes:**", + "refactor" => "**Code Refactoring:**", + "build" => "**Build:**", + "ci" => "**Continuous integration changes:**", + "test" => "**Tests:**", + "chore" => "**Chores:**", + "i18n" => "**Translations:**", + _ => "**Other:**", + }; + + release_notes.push_str(&format!("{}\n", section_title)); + + for commit_message in commit_list { + release_notes.push_str(&format!("- {}\n", commit_message)); + } + + release_notes.push('\n'); + } + } + } + + let release_notes = release_notes.trim_end_matches('\n').to_string(); + release_notes +} + +/// Group semantic commit messages by type +/// Commmit messages that are not formatted accordingly are marked as "other" +fn group_commits_by_type(commits: Vec) -> HashMap> { + let mut grouped_commits: HashMap> = HashMap::new(); + let mut other_commits: Vec = vec![]; + + for commit in commits { + let commit_parts: Vec<&str> = commit.splitn(2, ':').collect(); + if commit_parts.len() == 2 { + let commit_type = commit_parts[0].to_lowercase(); + let commit_description = commit_parts[1].trim().to_string(); + + // Check if known commit type + if SECTION_ORDER.contains(&commit_type.as_str()) { + let commit_list = grouped_commits.entry(commit_type.to_string()).or_default(); + commit_list.push(commit_description); + } else { + // otherwise add to list of "other" + other_commits.push(commit.to_string()); + } + } else { + other_commits.push(commit.to_string()); + } + } + grouped_commits.insert("other".to_string(), other_commits); + + grouped_commits +} + +/// Compares two tags on Northstar repo and generates release notes over the diff in tags +/// over the 3 major repos (Northstar, NorthstarLauncher, NorthstarMods) +pub fn compare_tags_northstar(first_tag: Tag, second_tag: Tag) -> Result { + todo!() +} + +/// Takes the commit title and repo slug and formats it as +/// `[commit title(SHORTENED_REPO#NUMBER)](LINK)` +fn turn_pr_number_into_link(input: &str, repo: &str) -> String { + // Extract `Mods/Launcher` from repo title + let last_line = repo + .split('/') + .next_back() + .unwrap() + .trim_start_matches("Northstar"); + // Extract PR number + let re = Regex::new(r"#(\d+)").unwrap(); + + // Generate pull request link + let pull_link = format!("https://github.com/{}/pull/", repo); + re.replace_all(input, format!("[{}#$1]({}$1)", last_line, pull_link)) + .to_string() +} diff --git a/src-tauri/src/github/pull_requests.rs b/src-tauri/src/github/pull_requests.rs new file mode 100644 index 000000000..de733feb3 --- /dev/null +++ b/src-tauri/src/github/pull_requests.rs @@ -0,0 +1,398 @@ +use crate::constants::{APP_USER_AGENT, NORTHSTAR_LAUNCHER_REPO_NAME, NORTHSTAR_MODS_REPO_NAME}; +use crate::repair_and_verify::check_is_valid_game_path; +use crate::GameInstall; +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io; +use std::io::prelude::*; +use std::path::Path; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +struct Repo { + full_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +struct CommitHead { + sha: String, + #[serde(rename = "ref")] + gh_ref: String, + repo: Repo, +} + +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +pub struct PullsApiResponseElement { + number: u64, + title: String, + url: String, + head: CommitHead, + html_url: String, + labels: Vec, +} + +// GitHub API response JSON elements as structs +#[derive(Debug, Deserialize, Clone)] +struct WorkflowRun { + id: u64, + head_sha: String, +} +#[derive(Debug, Deserialize, Clone)] +struct ActionsRunsResponse { + workflow_runs: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +struct Artifact { + id: u64, + name: String, + workflow_run: WorkflowRun, +} + +#[derive(Debug, Deserialize, Clone)] +struct ArtifactsResponse { + artifacts: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +pub enum PullRequestType { + Mods, + Launcher, +} + +/// Parse pull requests from specified URL +pub async fn get_pull_requests( + repo: PullRequestType, +) -> Result, anyhow::Error> { + let repo = match repo { + PullRequestType::Mods => NORTHSTAR_MODS_REPO_NAME, + PullRequestType::Launcher => NORTHSTAR_LAUNCHER_REPO_NAME, + }; + + // Grab list of PRs + let octocrab = octocrab::instance(); + let page = octocrab + .pulls("R2Northstar", repo) + .list() + .state(octocrab::params::State::Open) + .per_page(50) // Only grab 50 PRs + .page(1u32) + .send() + .await?; + + // Iterate over pull request elements and insert into struct + let mut all_pull_requests: Vec = vec![]; + for item in page.items { + let repo = Repo { + full_name: item + .head + .repo + .ok_or(anyhow!("repo not found"))? + .full_name + .ok_or(anyhow!("full_name not found"))?, + }; + + let head = CommitHead { + sha: item.head.sha, + gh_ref: item.head.ref_field, + repo, + }; + + // Get labels and their names and put the into vector + let label_names: Vec = item + .labels + .unwrap_or_else(Vec::new) + .into_iter() + .map(|label| label.name) + .collect(); + + // TODO there's probably a way to automatically serialize into the struct but I don't know yet how to + let elem = PullsApiResponseElement { + number: item.number, + title: item.title.ok_or(anyhow!("title not found"))?, + url: item.url, + head, + html_url: item + .html_url + .ok_or(anyhow!("html_url not found"))? + .to_string(), + labels: label_names, + }; + + all_pull_requests.push(elem); + } + + Ok(all_pull_requests) +} + +/// Gets either launcher or mods PRs +#[tauri::command] +pub async fn get_pull_requests_wrapper( + install_type: PullRequestType, +) -> Result, String> { + match get_pull_requests(install_type).await { + Ok(res) => Ok(res), + Err(err) => Err(err.to_string()), + } +} + +pub async fn check_github_api(url: &str) -> Result> { + 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 json: serde_json::Value = serde_json::from_str(&res).expect("JSON was not well-formatted"); + + Ok(json) +} + +/// Downloads a file from given URL into an array in memory +pub async fn download_zip_into_memory(download_url: String) -> Result, anyhow::Error> { + let client = reqwest::Client::builder() + .user_agent(APP_USER_AGENT) + .build()?; + + let response = client.get(download_url).send().await?; + + if !response.status().is_success() { + return Err(anyhow!("Request unsuccessful: {}", response.status())); + } + + let bytes = response.bytes().await?; + Ok(bytes.to_vec()) +} + +/// Gets GitHub download link of a mods PR +fn get_mods_download_link(pull_request: PullsApiResponseElement) -> Result { + // {pr object} -> number == pr_number + // -> head -> ref + // -> repo -> full_name + + // Use repo and branch name to get download link + let download_url = format!( + "https://github.com/{}/archive/refs/heads/{}.zip", + pull_request.head.repo.full_name, // repo name + pull_request.head.gh_ref, // branch name + ); + + Ok(download_url) +} + +/// Gets `nightly.link` artifact download link of a launcher commit +#[tauri::command] +pub async fn get_launcher_download_link(commit_sha: String) -> Result { + // Iterate over the first 10 pages of + for i in 1..=10 { + // Crossreference with runs API + let runs_response: ActionsRunsResponse = match check_github_api(&format!( + "https://api.github.com/repos/R2Northstar/NorthstarLauncher/actions/runs?page={}", + i + )) + .await + { + Ok(result) => serde_json::from_value(result).unwrap(), + Err(err) => return Err(format!("{}", err)), + }; + + // Cross-reference commit sha against workflow runs + for workflow_run in &runs_response.workflow_runs { + // If head commit sha of CI run matches the one passed to this function, grab CI output + if workflow_run.head_sha == commit_sha { + // Check artifacts + let api_url = format!("https://api.github.com/repos/R2Northstar/NorthstarLauncher/actions/runs/{}/artifacts", workflow_run.id); + let artifacts_response: ArtifactsResponse = serde_json::from_value( + check_github_api(&api_url).await.expect("Failed request"), + ) + .unwrap(); + + let multiple_artifacts = artifacts_response.artifacts.len() > 1; + + // Iterate over artifacts + for artifact in artifacts_response.artifacts { + if multiple_artifacts && !artifact.name.starts_with("NorthstarLauncher-MSVC") { + continue; + } + + // Make sure artifact and CI run commit head sha match + if artifact.workflow_run.head_sha == workflow_run.head_sha { + // Download artifact + return Ok(format!("https://nightly.link/R2Northstar/NorthstarLauncher/actions/artifacts/{}.zip", artifact.id)); + } + } + } + } + } + + Err(format!( + "Couldn't grab download link for \"{}\". Corresponding PR might be too old and therefore no CI build has been detected. Maybe ask author to update?", + commit_sha + )) +} + +/// Adds a batch file that allows for launching Northstar with mods PR profile +fn add_batch_file(game_install_path: &str) { + let batch_path = format!("{}/r2ns-launch-mod-pr-version.bat", game_install_path); + let path = Path::new(&batch_path); + let display = path.display(); + + // Open a file in write-only mode, returns `io::Result` + let mut file = match File::create(path) { + Err(why) => panic!("couldn't create {}: {}", display, why), + Ok(file) => file, + }; + + // Write the string to `file`, returns `io::Result<()>` + let batch_file_content = + "NorthstarLauncher.exe -profile=R2Northstar-PR-test-managed-folder\r\n"; + + match file.write_all(batch_file_content.as_bytes()) { + Err(why) => panic!("couldn't write to {}: {}", display, why), + Ok(_) => log::info!("successfully wrote to {}", display), + } +} + +/// Downloads selected launcher PR and extracts it into game install path +#[tauri::command] +pub async fn apply_launcher_pr( + pull_request: PullsApiResponseElement, + game_install: GameInstall, +) -> Result<(), String> { + // Exit early if wrong game path + check_is_valid_game_path(&game_install.game_path)?; + + // get download link + let download_url = match get_launcher_download_link(pull_request.head.sha.clone()).await { + Ok(res) => res, + Err(err) => { + return Err(format!( + "Couldn't grab download link for PR \"{}\". {}", + pull_request.number, err + )) + } + }; + + let archive = match download_zip_into_memory(download_url).await { + Ok(archive) => archive, + Err(err) => return Err(err.to_string()), + }; + + let extract_directory = format!( + "{}/___flightcore-temp/download-dir/launcher-pr-{}", + game_install.game_path, pull_request.number + ); + match std::fs::create_dir_all(extract_directory.clone()) { + Ok(_) => (), + Err(err) => { + return Err(format!( + "Failed creating temporary download directory: {}", + err + )) + } + }; + + let target_dir = std::path::PathBuf::from(extract_directory.clone()); // Doesn't need to exist + match zip_extract::extract(io::Cursor::new(archive), &target_dir, true) { + Ok(()) => (), + Err(err) => { + return Err(format!("Failed unzip: {}", err)); + } + }; + + // Copy only necessary files from temp dir + // Copy: + // - NorthstarLauncher.exe + // - Northstar.dll + let files_to_copy = vec!["NorthstarLauncher.exe", "Northstar.dll"]; + for file_name in files_to_copy { + let source_file_path = format!("{}/{}", extract_directory, file_name); + let destination_file_path = format!("{}/{}", game_install.game_path, file_name); + match std::fs::copy(source_file_path, destination_file_path) { + Ok(_result) => (), + Err(err) => { + return Err(format!( + "Failed to copy necessary file {} from temp dir: {}", + file_name, err + )) + } + }; + } + + // delete extract directory + match std::fs::remove_dir_all(&extract_directory) { + Ok(()) => (), + Err(err) => { + return Err(format!( + "Failed to delete temporary download directory: {}", + err + )) + } + } + + log::info!("All done with installing launcher PR"); + Ok(()) +} + +/// Downloads selected mods PR and extracts it into profile in game install path +#[tauri::command] +pub async fn apply_mods_pr( + pull_request: PullsApiResponseElement, + game_install: GameInstall, +) -> Result<(), String> { + // Exit early if wrong game path + check_is_valid_game_path(&game_install.game_path)?; + + let download_url = match get_mods_download_link(pull_request) { + Ok(url) => url, + Err(err) => return Err(err.to_string()), + }; + + let archive = match download_zip_into_memory(download_url).await { + Ok(archive) => archive, + Err(err) => return Err(err.to_string()), + }; + + let profile_folder = format!( + "{}/R2Northstar-PR-test-managed-folder", + game_install.game_path + ); + + // Delete previously managed folder + if std::fs::remove_dir_all(profile_folder.clone()).is_err() { + if std::path::Path::new(&profile_folder).exists() { + log::error!("Failed removing previous dir"); + } else { + log::warn!("Failed removing folder that doesn't exist. Probably cause first run"); + } + }; + + // Create profile folder + match std::fs::create_dir_all(profile_folder.clone()) { + Ok(()) => (), + Err(err) => return Err(err.to_string()), + } + + let target_dir = std::path::PathBuf::from(format!("{}/mods", profile_folder)); // Doesn't need to exist + match zip_extract::extract(io::Cursor::new(archive), &target_dir, true) { + Ok(()) => (), + Err(err) => { + return Err(format!("Failed unzip: {}", err)); + } + }; + // Add batch file to launch right profile + add_batch_file(&game_install.game_path); + + log::info!("All done with installing mods PR"); + Ok(()) +} diff --git a/src-tauri/src/github/release_notes.rs b/src-tauri/src/github/release_notes.rs index 3449df332..4adfb24b6 100644 --- a/src-tauri/src/github/release_notes.rs +++ b/src-tauri/src/github/release_notes.rs @@ -1,4 +1,6 @@ +use rand::prelude::SliceRandom; use serde::{Deserialize, Serialize}; +use std::vec::Vec; use ts_rs::TS; #[derive(Serialize, Deserialize, Debug, Clone, TS)] @@ -124,3 +126,119 @@ pub async fn get_northstar_release_notes() -> Result, String> { Ok(release_info_vector) } +/// Checks latest GitHub release and generates a announcement message for Discord based on it +#[tauri::command] +pub async fn generate_release_note_announcement() -> Result { + let octocrab = octocrab::instance(); + let page = octocrab + .repos("R2Northstar", "Northstar") + .releases() + .list() + // Optional Parameters + .per_page(1) + .page(1u32) + // Send the request + .send() + .await + .unwrap(); + + // Get newest element + let latest_release_item = &page.items[0]; + + // Extract the URL to the GitHub release note + let github_release_link = latest_release_item.html_url.clone(); + + // Extract release version number + let current_ns_version = &latest_release_item.tag_name; + + // Extract changelog and format it + let changelog = remove_markdown_links::remove_markdown_links( + latest_release_item + .body + .as_ref() + .unwrap() + .split("**Contributors:**") + .next() + .unwrap() + .trim(), + ); + + // Strings to insert for different sections + // Hardcoded for now + let general_info = "REPLACE ME"; + let modders_info = "Mod compatibility should not be impacted"; + let server_hosters_info = "REPLACE ME"; + + let mut rng = rand::thread_rng(); + let attributes = vec![ + "adorable", + "amazing", + "beautiful", + "blithsome", + "brilliant", + "compassionate", + "dazzling", + "delightful", + "distinguished", + "elegant", + "enigmatic", + "enthusiastic", + "fashionable", + "fortuitous", + "friendly", + "generous", + "gleeful", + "gorgeous", + "handsome", + "lively", + "lovely", + "lucky", + "lustrous", + "marvelous", + "merry", + "mirthful", + "phantasmagorical", + "pretty", + "propitious", + "ravishing", + "sincere", + "sophisticated fellow", + "stupendous", + "vivacious", + "wonderful", + "zestful", + ]; + + let selected_attribute = attributes.choose(&mut rng).unwrap(); + + // Build announcement string + let return_string = format!( + r"Hello {selected_attribute} people <3 +**Northstar `{current_ns_version}` is out!** + +{general_info} + +__**Modders:**__ + +{modders_info} + +__**Server hosters:**__ + +{server_hosters_info} + +__**Changelog:**__ +``` +{changelog} +``` +{github_release_link} + +Checkout #installation on how to install/update Northstar +(the process is the same for both, using a Northstar installer like FlightCore, Viper, or VTOL is recommended over manual installation) + +If you do notice any bugs, please open an issue on Github or drop a message in the thread below +" + ); + + // Return built announcement message + Ok(return_string.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3fe93da32..d608e3d80 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,13 +1,32 @@ +use std::{env, time::Duration}; + mod constants; +mod development; mod github; +mod mod_management; mod northstar; mod platform_specific; mod repair_and_verify; +mod thunderstore; mod util; use serde::{Deserialize, Serialize}; use ts_rs::TS; +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +struct NorthstarThunderstoreRelease { + package: String, + version: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +pub struct NorthstarThunderstoreReleaseWrapper { + label: String, + value: NorthstarThunderstoreRelease, +} + // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] fn greet(name: &str) -> String { @@ -21,14 +40,45 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ greet, + development::install_git_main, + github::compare_tags, + github::get_list_of_tags, + github::pull_requests::apply_launcher_pr, + github::pull_requests::apply_mods_pr, + github::pull_requests::get_launcher_download_link, + github::pull_requests::get_pull_requests_wrapper, github::release_notes::check_is_flightcore_outdated, + github::release_notes::generate_release_note_announcement, + github::release_notes::get_newest_flightcore_version, github::release_notes::get_northstar_release_notes, + mod_management::delete_northstar_mod, + mod_management::delete_thunderstore_mod, + mod_management::get_installed_mods_and_properties, + mod_management::install_mod_wrapper, + mod_management::set_mod_enabled_status, + northstar::check_is_northstar_outdated, + northstar::get_available_northstar_versions, + northstar::get_northstar_version_number, northstar::install::find_game_install_location, + northstar::install::install_northstar_wrapper, + northstar::install::update_northstar, + northstar::launch_northstar, + northstar::profile::clone_profile, + northstar::profile::delete_profile, + northstar::profile::fetch_profiles, + northstar::profile::validate_profile, + repair_and_verify::clean_up_download_folder_wrapper, + repair_and_verify::disable_all_but_core, + repair_and_verify::get_log_list, + repair_and_verify::verify_game_files, repair_and_verify::verify_install_location, + thunderstore::query_thunderstore_packages_api, + util::close_application, util::force_panic, util::get_flightcore_version_number, util::get_server_player_count, util::is_debug_mode, + util::kill_northstar, util::open_repair_window, ]) .run(tauri::generate_context!()) @@ -55,3 +105,14 @@ pub struct GameInstall { pub profile: String, pub install_type: InstallType, } + +/// Object holding various information about a Northstar mod +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +pub struct NorthstarMod { + pub name: String, + pub version: Option, + pub thunderstore_mod_string: Option, + pub enabled: bool, + pub directory: String, +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2abccd9e4..0f8552d4b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,7 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + fn main() { tauri_app_lib::run() } diff --git a/src-tauri/src/mod_management/legacy.rs b/src-tauri/src/mod_management/legacy.rs new file mode 100644 index 000000000..1e9f90f55 --- /dev/null +++ b/src-tauri/src/mod_management/legacy.rs @@ -0,0 +1,213 @@ +use crate::constants::BLACKLISTED_MODS; +use crate::mod_management::{ + delete_mod_folder, get_installed_mods_and_properties, ParsedThunderstoreModString, +}; +use crate::GameInstall; +use crate::NorthstarMod; +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::{io::Read, path::PathBuf}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ModJson { + #[serde(rename = "Name")] + name: String, + #[serde(rename = "ThunderstoreModString")] + thunderstore_mod_string: Option, + #[serde(rename = "Version")] + version: Option, +} + +/// Parses `manifest.json` for Thunderstore mod string +fn parse_for_thunderstore_mod_string(nsmod_path: &str) -> Result { + let manifest_json_path = format!("{}/manifest.json", nsmod_path); + let ts_author_txt_path = format!("{}/thunderstore_author.txt", nsmod_path); + + // Check if `manifest.json` exists and parse + let data = std::fs::read_to_string(manifest_json_path)?; + let thunderstore_manifest: super::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. +pub fn parse_installed_mods( + game_install: &GameInstall, +) -> Result, anyhow::Error> { + let ns_mods_folder = format!("{}/{}/mods/", game_install.game_path, game_install.profile); + + let paths = match std::fs::read_dir(ns_mods_folder) { + 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 { + log::info!("{path:?}"); + let my_path = path.unwrap().path(); + log::info!("{my_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; + } + + // Parse mod.json and get mod name + + // 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 Thunderstore mod string if it exists + let thunderstore_mod_string = match parsed_mod_json.thunderstore_mod_string { + // Attempt legacy method for getting Thunderstore string first + Some(ts_mod_string) => Some(ts_mod_string), + // Legacy method failed + None => 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(); + + let ns_mod = NorthstarMod { + name: parsed_mod_json.name, + version: parsed_mod_json.version, + thunderstore_mod_string, + enabled: false, // Placeholder + directory: mod_directory, + }; + + mods.push(ns_mod); + } + + // Return found mod names + Ok(mods) +} + +/// Deletes all legacy packages that match in author and mod name +/// regardless of version +/// +/// "legacy package" refers to a Thunderstore package installed into the `mods` folder +/// by extracting Northstar mods contained inside and then adding `manifest.json` and `thunderstore_author.txt` +/// to indicate which Thunderstore package they are part of +pub fn delete_legacy_package_install( + thunderstore_mod_string: &str, + game_install: &GameInstall, +) -> Result<(), String> { + let thunderstore_mod_string: ParsedThunderstoreModString = + thunderstore_mod_string.parse().unwrap(); + let found_installed_legacy_mods = match parse_installed_mods(game_install) { + Ok(res) => res, + Err(err) => return Err(err.to_string()), + }; + + for legacy_mod in found_installed_legacy_mods { + if legacy_mod.thunderstore_mod_string.is_none() { + continue; // Not a thunderstore mod + } + + let current_mod_ts_string: ParsedThunderstoreModString = legacy_mod + .clone() + .thunderstore_mod_string + .unwrap() + .parse() + .unwrap(); + + if thunderstore_mod_string.author_name == current_mod_ts_string.author_name + && thunderstore_mod_string.mod_name == current_mod_ts_string.mod_name + { + // They match, delete + delete_mod_folder(&legacy_mod.directory)?; + } + } + + Ok(()) +} + +/// Deletes all NorthstarMods related to a Thunderstore mod +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(()) +} diff --git a/src-tauri/src/mod_management/mod.rs b/src-tauri/src/mod_management/mod.rs new file mode 100644 index 000000000..52ef1180d --- /dev/null +++ b/src-tauri/src/mod_management/mod.rs @@ -0,0 +1,797 @@ +// This file contains various mod management functions + +use crate::constants::{BLACKLISTED_MODS, CORE_MODS, MODS_WITH_SPECIAL_REQUIREMENTS}; +use async_recursion::async_recursion; +use thermite::prelude::ThermiteError; + +use crate::NorthstarMod; +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::str::FromStr; +use std::string::ToString; +use std::{fs, path::PathBuf}; + +mod legacy; +mod plugins; +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 passes regex + 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 std::fmt::Display for ParsedThunderstoreModString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}-{}-{}", 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 + } +} + +/// Installs the specified mod +#[tauri::command] +pub async fn install_mod_wrapper( + game_install: GameInstall, + thunderstore_mod_string: String, +) -> Result<(), String> { + match fc_download_mod_and_install(&game_install, &thunderstore_mod_string).await { + Ok(()) => (), + Err(err) => { + log::warn!("{err}"); + return Err(err); + } + }; + match crate::repair_and_verify::clean_up_download_folder(&game_install, false) { + Ok(()) => Ok(()), + Err(err) => { + log::info!("Failed to delete download folder due to {}", err); + // Failure to delete download folder is not an error in mod install + // As such ignore. User can still force delete if need be + Ok(()) + } + } +} + +/// 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!( + "{}/{}/enabledmods.json", + game_install.game_path, game_install.profile + ); + + // 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!( + "{}/{}/enabledmods.json", + game_install.game_path, game_install.profile + ); + 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!( + "{}/{}/enabledmods.json", + game_install.game_path, game_install.profile + ); + + // 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?.path(); + let md = std::fs::metadata(my_path.clone())?; + 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!( + "{}/{}/packages/", + game_install.game_path, game_install.profile + ); + + 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) { + 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) { + return Ok(ns_mod.deps.clone()); + } + } + } + Ok(Vec::::new()) +} + +/// Deletes all versions of Thunderstore package except the specified one +fn delete_older_versions( + thunderstore_mod_string: &str, + game_install: &GameInstall, +) -> Result<(), String> { + let thunderstore_mod_string: ParsedThunderstoreModString = + thunderstore_mod_string.parse().unwrap(); + log::info!( + "Deleting other versions of {}", + thunderstore_mod_string.to_string() + ); + let packages_folder = format!( + "{}/{}/packages", + game_install.game_path, game_install.profile + ); + + // Get folders in packages dir + let paths = match std::fs::read_dir(&packages_folder) { + Ok(paths) => paths, + Err(_err) => return Err(format!("Failed to read directory {}", &packages_folder)), + }; + + let mut directories: 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); + } + } + + for directory in directories { + let folder_name = directory.file_name().unwrap().to_str().unwrap(); + let ts_mod_string_from_folder: ParsedThunderstoreModString = match folder_name.parse() { + Ok(res) => res, + Err(err) => { + // Failed parsing folder name as Thunderstore mod string + // This means it doesn't follow the `AUTHOR-MOD-VERSION` naming structure + // This folder could've been manually created by the user or another application + // As parsing failed we cannot determine the Thunderstore package it is part of hence we skip it + log::warn!("{err}"); + continue; + } + }; + // Check which match `AUTHOR-MOD` and do NOT match `AUTHOR-MOD-VERSION` + if ts_mod_string_from_folder.author_name == thunderstore_mod_string.author_name + && ts_mod_string_from_folder.mod_name == thunderstore_mod_string.mod_name + && ts_mod_string_from_folder.version != thunderstore_mod_string.version + { + delete_package_folder(&directory.display().to_string())?; + } + } + + Ok(()) +} + +/// Checks whether some mod is correctly formatted +/// Currently checks whether +/// - Some `mod.json` exists under `mods/*/mod.json` +fn fc_sanity_check(input: &&fs::File) -> Result<(), Box> { + let mut archive = match zip::read::ZipArchive::new(*input) { + Ok(archive) => archive, + Err(_) => { + return Err(Box::new(ThermiteError::UnknownError( + "Failed reading zip file".into(), + ))) + } + }; + + let mut has_mods = false; + let mut mod_json_exists = false; + + // Checks for `mods/*/mod.json` + for i in 0..archive.len() { + let file = match archive.by_index(i) { + Ok(file) => file, + Err(_) => continue, + }; + let file_path = file.mangled_name(); + if file_path.starts_with("mods/") { + has_mods = true; + if let Some(name) = file_path.file_name() { + if name == "mod.json" { + let parent_path = file_path.parent().unwrap(); + if parent_path.parent().unwrap().to_str().unwrap() == "mods" { + mod_json_exists = true; + } + } + } + } + + if file_path.starts_with("plugins/") { + if let Some(name) = file_path.file_name() { + if name.to_str().unwrap().contains(".dll") { + log::warn!("Plugin detected, prompting user"); + if !plugins::plugin_prompt() { + return Err(Box::new(ThermiteError::UnknownError( + "Plugin detected and install denied".into(), + ))); + } + } + } + } + } + + if has_mods && mod_json_exists { + Ok(()) + } else { + Err(Box::new(ThermiteError::UnknownError( + "Mod not correctly formatted".into(), + ))) + } +} + +// 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 + ); + + // 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()); + } + } + + // Prevent installing mods that have specific install requirements + for special_mod in MODS_WITH_SPECIAL_REQUIREMENTS { + if thunderstore_mod_string.contains(special_mod) { + return Err(format!( + "{} has special install requirements and cannot be installed with FlightCore", + thunderstore_mod_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 directory to install to made up of packages directory and Thunderstore mod string + let install_directory = format!( + "{}/{}/packages/", + game_install.game_path, game_install.profile + ); + + // Extract the mod to the mods directory + match thermite::core::manage::install_with_sanity( + thunderstore_mod_string, + temp_file.file(), + std::path::Path::new(&install_directory), + fc_sanity_check, + ) { + Ok(_) => (), + Err(err) => { + log::warn!("libthermite couldn't install mod {thunderstore_mod_string} due to {err:?}",); + return match err { + ThermiteError::SanityError(e) => Err( + format!("Mod failed sanity check during install. It's probably not correctly formatted. {}", e) + ), + _ => Err(err.to_string()), + }; + } + }; + + // Successful package install + match legacy::delete_legacy_package_install(thunderstore_mod_string, game_install) { + Ok(()) => (), + Err(err) => { + // Catch error but ignore + log::warn!("Failed deleting legacy versions due to: {}", err); + } + }; + + match delete_older_versions(thunderstore_mod_string, game_install) { + Ok(()) => (), + Err(err) => { + // Catch error but ignore + log::warn!("Failed deleting older versions due to: {}", err); + } + }; + + 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 a given Thunderstore package +fn delete_package_folder(ts_package_directory: &str) -> Result<(), String> { + let ns_mod_dir_path = std::path::Path::new(&ts_package_directory); + + // Safety check: Check whether `manifest.json` exists and exit early if not + // If it does not exist, we might not be dealing with a Thunderstore package + let mod_json_path = ns_mod_dir_path.join("manifest.json"); + if !mod_json_path.exists() { + // If it doesn't exist, return an error + return Err(format!( + "manifest.json does not exist in {}", + ts_package_directory + )); + } + + match std::fs::remove_dir_all(ts_package_directory) { + Ok(()) => Ok(()), + Err(err) => Err(format!("Failed deleting package: {err}")), + } +} + +/// Deletes all NorthstarMods related to a Thunderstore mod +#[tauri::command] +pub fn delete_thunderstore_mod( + game_install: GameInstall, + thunderstore_mod_string: String, +) -> Result<(), String> { + // Check packages + let packages_folder = format!( + "{}/{}/packages", + game_install.game_path, game_install.profile + ); + if std::path::Path::new(&packages_folder).exists() { + for entry in fs::read_dir(packages_folder).unwrap() { + let entry = entry.unwrap(); + + // Check if it's a folder and skip if otherwise + if !entry.file_type().unwrap().is_dir() { + log::warn!("Skipping \"{}\", not a file", entry.path().display()); + continue; + } + + let entry_path = entry.path(); + let package_folder_ts_string = entry_path.file_name().unwrap().to_string_lossy(); + + if package_folder_ts_string != thunderstore_mod_string { + // Not the mod folder we are looking for, try the next one\ + continue; + } + + // All checks passed, this is the matching mod + return delete_package_folder(&entry.path().display().to_string()); + } + } + + // Try legacy mod installs as fallback + legacy::delete_thunderstore_mod(game_install, thunderstore_mod_string) +} diff --git a/src-tauri/src/mod_management/plugins.rs b/src-tauri/src/mod_management/plugins.rs new file mode 100644 index 000000000..6e8aa6ae8 --- /dev/null +++ b/src-tauri/src/mod_management/plugins.rs @@ -0,0 +1,10 @@ +// use tauri::api::dialog::blocking::MessageDialogBuilder; +// use tauri::api::dialog::{MessageDialogButtons, MessageDialogKind}; + +/// Prompt on plugin +/// Returns: +/// - true: user accepted plugin install +/// - false: user denied plugin install +pub fn plugin_prompt() -> bool { + todo!() +} diff --git a/src-tauri/src/northstar/install.rs b/src-tauri/src/northstar/install.rs index 09e8ec92c..02db2c1de 100644 --- a/src-tauri/src/northstar/install.rs +++ b/src-tauri/src/northstar/install.rs @@ -1,10 +1,153 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use std::{cell::RefCell, time::Instant}; +use ts_rs::TS; + +use crate::constants::{CORE_MODS, NORTHSTAR_DEFAULT_PROFILE, NORTHSTAR_DLL}; use crate::{ + util::{extract, move_dir_all}, GameInstall, InstallType, }; #[cfg(target_os = "windows")] use crate::platform_specific::windows; +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +enum InstallState { + Downloading, + Extracting, + Done, +} + +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +struct InstallProgress { + current_downloaded: u64, + total_size: u64, + state: InstallState, +} + +/// Installs Northstar to the given path +#[tauri::command] +pub async fn install_northstar_wrapper( + window: tauri::Window, + game_install: GameInstall, + northstar_package_name: Option, + version_number: Option, +) -> Result { + log::info!("Running Northstar install"); + + // Get Northstar package name (`Northstar` vs `NorthstarReleaseCandidate`) + let northstar_package_name = northstar_package_name + .map(|name| { + if name.len() <= 1 { + "Northstar".to_string() + } else { + name + } + }) + .unwrap_or("Northstar".to_string()); + + match install_northstar(window, game_install, northstar_package_name, version_number).await { + Ok(_) => Ok(true), + Err(err) => { + log::error!("{}", err); + Err(err) + } + } +} + +/// Update Northstar install in the given path +#[tauri::command] +pub async fn update_northstar( + window: tauri::Window, + game_install: GameInstall, + northstar_package_name: Option, +) -> Result { + log::info!("Updating Northstar"); + + // Simply re-run install with up-to-date version for upate + install_northstar_wrapper(window, game_install, northstar_package_name, None).await +} + +/// Copied from `papa` source code and modified +///Install N* from the provided mod +/// +///Checks cache, else downloads the latest version +async fn do_install( + window: tauri::Window, + nmod: &thermite::model::ModVersion, + game_install: GameInstall, +) -> Result<()> { + let filename = format!("northstar-{}.zip", nmod.version); + let temp_dir = format!("{}/___flightcore-temp", game_install.game_path); + let download_directory = format!("{}/download-dir", temp_dir); + let extract_directory = format!("{}/extract-dir", temp_dir); + + log::info!("Attempting to create temporary directory {}", temp_dir); + std::fs::create_dir_all(download_directory.clone())?; + std::fs::create_dir_all(extract_directory.clone())?; + + let download_path = format!("{}/{}", download_directory, filename); + log::info!("Download path: {download_path}"); + + let last_emit = RefCell::new(Instant::now()); // Keep track of the last time a signal was emitted + let mut nfile = std::fs::File::options() + .read(true) + .write(true) + .truncate(true) + .create(true) + .open(download_path)?; + todo!() +} + +pub async fn install_northstar( + window: tauri::Window, + game_install: GameInstall, + northstar_package_name: String, + version_number: Option, +) -> Result { + let index = match thermite::api::get_package_index() { + Ok(res) => res.to_vec(), + Err(err) => { + log::warn!("Failed fetching package index due to: {err}"); + return Err("Failed to connect to Thunderstore.".to_string()); + } + }; + let nmod = index + .iter() + .find(|f| f.name.to_lowercase() == northstar_package_name.to_lowercase()) + .ok_or_else(|| panic!("Couldn't find Northstar on thunderstore???")) + .unwrap(); + + // Use passed version or latest if no version was passed + let version = version_number.as_ref().unwrap_or(&nmod.latest); + + let game_path = game_install.game_path.clone(); + log::info!("Install path \"{}\"", game_path); + + match do_install(window, nmod.versions.get(version).unwrap(), game_install).await { + Ok(_) => (), + Err(err) => { + if game_path + .to_lowercase() + .contains(&r"C:\Program Files\".to_lowercase()) + // default is `C:\Program Files\EA Games\Titanfall2` + { + return Err( + "Cannot install to default EA App install path, please move Titanfall2 to a different install location.".to_string(), + ); + } else { + return Err(err.to_string()); + } + } + } + + Ok(nmod.latest.clone()) +} + /// Attempts to find the game install location #[tauri::command] pub fn find_game_install_location() -> Result { diff --git a/src-tauri/src/northstar/mod.rs b/src-tauri/src/northstar/mod.rs index d1e03caa9..9953d742e 100644 --- a/src-tauri/src/northstar/mod.rs +++ b/src-tauri/src/northstar/mod.rs @@ -1,3 +1,276 @@ //! This module deals with handling things around Northstar such as //! - getting version number pub mod install; +pub mod profile; + +use crate::util::check_ea_app_or_origin_running; +use crate::{constants::CORE_MODS, platform_specific::get_host_os, GameInstall, InstallType}; +use crate::{NorthstarThunderstoreRelease, NorthstarThunderstoreReleaseWrapper}; +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[ts(export)] +pub struct NorthstarLaunchOptions { + launch_via_steam: bool, + bypass_checks: bool, +} + +/// Gets list of available Northstar versions from Thunderstore +#[tauri::command] +pub async fn get_available_northstar_versions( +) -> Result, ()> { + let northstar_package_name = "Northstar"; + let index = thermite::api::get_package_index().unwrap().to_vec(); + let nsmod = index + .iter() + .find(|f| f.name.to_lowercase() == northstar_package_name.to_lowercase()) + .ok_or_else(|| panic!("Couldn't find Northstar on thunderstore???")) + .unwrap(); + + let mut releases: Vec = vec![]; + for (_version_string, nsmod_version_obj) in nsmod.versions.iter() { + let current_elem = NorthstarThunderstoreRelease { + package: nsmod_version_obj.name.clone(), + version: nsmod_version_obj.version.clone(), + }; + let current_elem_wrapped = NorthstarThunderstoreReleaseWrapper { + label: format!( + "{} v{}", + nsmod_version_obj.name.clone(), + nsmod_version_obj.version.clone() + ), + value: current_elem, + }; + + releases.push(current_elem_wrapped); + } + + releases.sort_by(|a, b| { + // Parse version number + let a_ver = semver::Version::parse(&a.value.version).unwrap(); + let b_ver = semver::Version::parse(&b.value.version).unwrap(); + b_ver.partial_cmp(&a_ver).unwrap() // Sort newest first + }); + + Ok(releases) +} + +/// Checks if installed Northstar version is up-to-date +/// false -> Northstar install is up-to-date +/// true -> Northstar install is outdated +#[tauri::command] +pub async fn check_is_northstar_outdated( + game_install: GameInstall, + northstar_package_name: Option, +) -> Result { + let northstar_package_name = match northstar_package_name { + Some(northstar_package_name) => { + if northstar_package_name.len() <= 1 { + "Northstar".to_string() + } else { + northstar_package_name + } + } + None => "Northstar".to_string(), + }; + + let index = match thermite::api::get_package_index() { + Ok(res) => res.to_vec(), + Err(err) => return Err(format!("Couldn't check if Northstar up-to-date: {err}")), + }; + let nmod = index + .iter() + .find(|f| f.name.to_lowercase() == northstar_package_name.to_lowercase()) + .expect("Couldn't find Northstar on thunderstore???"); + // .ok_or_else(|| anyhow!("Couldn't find Northstar on thunderstore???"))?; + + let version_number = match get_northstar_version_number(game_install) { + Ok(version_number) => version_number, + Err(err) => { + log::warn!("{}", err); + // If we fail to get new version just assume we are up-to-date + return Err(err); + } + }; + + // Release candidate version numbers are different between `mods.json` and Thunderstore + let version_number = crate::util::convert_release_candidate_number(version_number); + + if version_number != nmod.latest { + log::info!("Installed Northstar version outdated"); + Ok(true) + } else { + log::info!("Installed Northstar version up-to-date"); + Ok(false) + } +} + +/// Check version number of a mod +pub fn check_mod_version_number(path_to_mod_folder: &str) -> Result { + let data = std::fs::read_to_string(format!("{path_to_mod_folder}/mod.json"))?; + let parsed_json: serde_json::Value = serde_json::from_str(&data)?; + + let mod_version_number = match parsed_json.get("Version").and_then(|value| value.as_str()) { + Some(version_number) => version_number, + None => return Err(anyhow!("No version number found")), + }; + + log::info!("{}", mod_version_number); + + Ok(mod_version_number.to_string()) +} + +/// Returns the current Northstar version number as a string +#[tauri::command] +pub fn get_northstar_version_number(game_install: GameInstall) -> Result { + log::info!("{}", game_install.game_path); + + // TODO: + // Check if NorthstarLauncher.exe exists and check its version number + let initial_version_number = match check_mod_version_number(&format!( + "{}/{}/mods/{}", + game_install.game_path, game_install.profile, CORE_MODS[0] + )) { + Ok(version_number) => version_number, + Err(err) => return Err(err.to_string()), + }; + + for core_mod in CORE_MODS { + let current_version_number = match check_mod_version_number(&format!( + "{}/{}/mods/{}", + game_install.game_path, game_install.profile, core_mod + )) { + Ok(version_number) => version_number, + Err(err) => return Err(err.to_string()), + }; + if current_version_number != initial_version_number { + // We have a version number mismatch + return Err("Found version number mismatch".to_string()); + } + } + log::info!("All mods same version"); + + Ok(initial_version_number) +} + +/// Launches Northstar +#[tauri::command] +pub fn launch_northstar( + game_install: GameInstall, + launch_options: NorthstarLaunchOptions, +) -> Result { + dbg!(game_install.clone()); + + if launch_options.launch_via_steam { + return launch_northstar_steam(game_install); + } + + let host_os = get_host_os(); + + // Explicitly fail early certain (currently) unsupported install setups + if host_os != "windows" { + if !matches!(game_install.install_type, InstallType::STEAM) { + return Err(format!( + "Not yet implemented for \"{}\" with Titanfall2 installed via \"{:?}\"", + get_host_os(), + game_install.install_type + )); + } + + return launch_northstar_steam(game_install); + } + + // Only check guards if bypassing checks is not enabled + if !launch_options.bypass_checks { + // Some safety checks before, should have more in the future + if get_northstar_version_number(game_install.clone()).is_err() { + return Err(anyhow!("Not all checks were met").to_string()); + } + + // Require EA App or Origin to be running to launch Northstar + let ea_app_is_running = check_ea_app_or_origin_running(); + if !ea_app_is_running { + return Err( + anyhow!("EA App not running, start EA App before launching Northstar").to_string(), + ); + } + } + + // Switch to Titanfall2 directory for launching + // NorthstarLauncher.exe expects to be run from that folder + if std::env::set_current_dir(game_install.game_path.clone()).is_err() { + // We failed to get to Titanfall2 directory + return Err(anyhow!("Couldn't access Titanfall2 directory").to_string()); + } + + // Only Windows with Steam or Origin are supported at the moment + if host_os == "windows" + && (matches!(game_install.install_type, InstallType::STEAM) + || matches!(game_install.install_type, InstallType::ORIGIN) + || matches!(game_install.install_type, InstallType::UNKNOWN)) + { + let ns_exe_path = format!("{}/NorthstarLauncher.exe", game_install.game_path); + let ns_profile_arg = format!("-profile={}", game_install.profile); + + let mut output = std::process::Command::new("C:\\Windows\\System32\\cmd.exe") + .args(["/C", "start", "", &ns_exe_path, &ns_profile_arg]) + .spawn() + .expect("failed to execute process"); + output.wait().expect("failed waiting on child process"); + return Ok("Launched game".to_string()); + } + + Err(format!( + "Not yet implemented for {:?} on {}", + game_install.install_type, + get_host_os() + )) +} + +/// Prepare Northstar and Launch through Steam using the Browser Protocol +pub fn launch_northstar_steam(game_install: GameInstall) -> Result { + if !matches!(game_install.install_type, InstallType::STEAM) { + return Err("Titanfall2 was not installed via Steam".to_string()); + } + + match steamlocate::SteamDir::locate() { + Ok(steamdir) => { + if get_host_os() != "windows" { + match steamdir.compat_tool_mapping() { + Ok(map) => match map.get(&thermite::TITANFALL2_STEAM_ID) { + Some(_) => {} + None => { + return Err( + "Titanfall2 was not configured to use a compatibility tool" + .to_string(), + ); + } + }, + Err(_) => { + return Err("Could not get compatibility tool mapping".to_string()); + } + } + } + } + Err(_) => { + return Err("Couldn't access Titanfall2 directory".to_string()); + } + } + + // Switch to Titanfall2 directory to set everything up + if std::env::set_current_dir(game_install.game_path).is_err() { + // We failed to get to Titanfall2 directory + return Err("Couldn't access Titanfall2 directory".to_string()); + } + + match open::that(format!( + "steam://run/{}//-profile={} --northstar/", + thermite::TITANFALL2_STEAM_ID, + game_install.profile + )) { + Ok(()) => Ok("Started game".to_string()), + Err(_err) => Err("Failed to launch Titanfall 2 via Steam".to_string()), + } +} diff --git a/src-tauri/src/northstar/profile.rs b/src-tauri/src/northstar/profile.rs new file mode 100644 index 000000000..26a32d6b5 --- /dev/null +++ b/src-tauri/src/northstar/profile.rs @@ -0,0 +1,121 @@ +use crate::util::copy_dir_all; +use crate::GameInstall; + +// These folders are part of Titanfall 2 and +// should NEVER be used as a Profile +const SKIP_PATHS: [&str; 8] = [ + "___flightcore-temp", + "__overlay", + "bin", + "Core", + "r2", + "vpk", + "platform", + "Support", +]; + +// A profile may have one of these to be detected +const MAY_CONTAIN: [&str; 10] = [ + "mods/", + "plugins/", + "packages/", + "logs/", + "runtime/", + "save_data/", + "Northstar.dll", + "enabledmods.json", + "placeholder.playerdata.pdata", + "LEGAL.txt", +]; + +/// Returns a list of Profile names +/// All the returned Profiles can be found relative to the game path +#[tauri::command] +pub fn fetch_profiles(game_install: GameInstall) -> Result, String> { + let mut profiles: Vec = Vec::new(); + + for content in MAY_CONTAIN { + let pattern = format!("{}/*/{}", game_install.game_path, content); + for e in glob::glob(&pattern).expect("Failed to read glob pattern") { + let path = e.unwrap(); + let mut ancestors = path.ancestors(); + + ancestors.next(); + + let profile_path = std::path::Path::new(ancestors.next().unwrap()); + let profile_name = profile_path + .file_name() + .unwrap() + .to_os_string() + .into_string() + .unwrap(); + + if !profiles.contains(&profile_name) { + profiles.push(profile_name); + } + } + } + + Ok(profiles) +} + +/// Validates if a given profile is actually a valid profile +#[tauri::command] +pub fn validate_profile(game_install: GameInstall, profile: String) -> bool { + // Game files are never a valid profile + // Prevent users with messed up installs from making it even worse + if SKIP_PATHS.contains(&profile.as_str()) { + return false; + } + + log::info!("Validating Profile {}", profile); + + let profile_path = format!("{}/{}", game_install.game_path, profile); + let profile_dir = std::path::Path::new(profile_path.as_str()); + + profile_dir.is_dir() +} + +#[tauri::command] +pub fn delete_profile(game_install: GameInstall, profile: String) -> Result<(), String> { + // Check if the Profile actually exists + if !validate_profile(game_install.clone(), profile.clone()) { + return Err(format!("{} is not a valid Profile", profile)); + } + + log::info!("Deleting Profile {}", profile); + + let profile_path = format!("{}/{}", game_install.game_path, profile); + + match std::fs::remove_dir_all(profile_path) { + Ok(()) => Ok(()), + Err(err) => Err(format!("Failed to delete Profile: {}", err)), + } +} + +/// Clones a profile by simply duplicating the folder under a new name +#[tauri::command] +pub fn clone_profile( + game_install: GameInstall, + old_profile: String, + new_profile: String, +) -> Result<(), String> { + // Check if the old Profile already exists + if !validate_profile(game_install.clone(), old_profile.clone()) { + return Err(format!("{} is not a valid Profile", old_profile)); + } + + // Check that new Profile does not already exist + if validate_profile(game_install.clone(), new_profile.clone()) { + return Err(format!("{} already exists", new_profile)); + } + + log::info!("Cloning Profile {} to {}", old_profile, new_profile); + + let old_profile_path = format!("{}/{}", game_install.game_path, old_profile); + let new_profile_path = format!("{}/{}", game_install.game_path, new_profile); + + copy_dir_all(old_profile_path, new_profile_path).unwrap(); + + Ok(()) +} diff --git a/src-tauri/src/platform_specific/linux.rs b/src-tauri/src/platform_specific/linux.rs new file mode 100644 index 000000000..fcac5b671 --- /dev/null +++ b/src-tauri/src/platform_specific/linux.rs @@ -0,0 +1,98 @@ +// Linux specific code + +fn get_proton_dir() -> Result { + let steam_dir = match steamlocate::SteamDir::locate() { + Ok(result) => result, + Err(_) => return Err("Unable to find Steam directory".to_string()), + }; + let compat_dir = format!("{}/compatibilitytools.d", steam_dir.path().display()); + + Ok(compat_dir) +} + +/// Downloads and installs NS proton +/// Assumes Steam install +pub fn install_ns_proton() -> Result<(), String> { + // Get latest NorthstarProton release + let latest = match thermite::core::latest_release() { + Ok(result) => result, + Err(_) => return Err("Failed to fetch latest NorthstarProton release".to_string()), + }; + + let temp_dir = std::env::temp_dir(); + let path = format!("{}/nsproton-{}.tar.gz", temp_dir.display(), latest); + let archive = match std::fs::File::create(path.clone()) { + Ok(result) => result, + Err(_) => return Err("Failed to allocate NorthstarProton archive on disk".to_string()), + }; + + // Download the latest Proton release + log::info!("Downloading NorthstarProton to {}", path); + match thermite::core::download_ns_proton(latest, archive) { + Ok(_) => {} + Err(_) => return Err("Failed to download NorthstarProton".to_string()), + } + + log::info!("Finished Download"); + + let compat_dir = get_proton_dir()?; + + match std::fs::create_dir_all(compat_dir.clone()) { + Ok(_) => {} + Err(_) => return Err("Failed to create compatibilitytools directory".to_string()), + } + + let finished = match std::fs::File::open(path.clone()) { + Ok(result) => result, + Err(_) => return Err("Failed to open NorthstarProton archive".to_string()), + }; + + // Extract to Proton dir + log::info!("Installing NorthstarProton to {}", compat_dir); + match thermite::core::install_ns_proton(&finished, compat_dir) { + Ok(_) => {} + Err(_) => return Err("Failed to create install NorthstarProton".to_string()), + } + log::info!("Finished Installation"); + drop(finished); + + // We installed NSProton, lets ignore this if it fails + let _ = std::fs::remove_file(path); + + Ok(()) +} + +/// Remove NS Proton +pub fn uninstall_ns_proton() -> Result<(), String> { + let compat_dir = get_proton_dir()?; + let pattern = format!("{}/NorthstarProton*", compat_dir); + for e in glob::glob(&pattern).expect("Failed to read glob pattern") { + match e { + Ok(path) => match std::fs::remove_dir_all(path.clone()) { + Ok(_) => {} + Err(_) => return Err(format!("Failed to remove {}", path.display())), + }, + Err(e) => return Err(format!("Found unprocessable entry {}", e)), + } + } + + Ok(()) +} + +/// Get the latest installed NS Proton version +pub fn get_local_ns_proton_version() -> Result { + let compat_dir = get_proton_dir().unwrap(); + let pattern = format!("{}/NorthstarProton*/version", compat_dir); + + if let Some(e) = glob::glob(&pattern) + .expect("Failed to read glob pattern") + .next() + { + let version_content = std::fs::read_to_string(e.unwrap()).unwrap(); + let version = version_content.split(' ').nth(1).unwrap().to_string(); + + return Ok(version); + } + + Err("Northstar Proton is not installed".to_string()) +} diff --git a/src-tauri/src/platform_specific/mod.rs b/src-tauri/src/platform_specific/mod.rs index 996f4556e..4e0514d4a 100644 --- a/src-tauri/src/platform_specific/mod.rs +++ b/src-tauri/src/platform_specific/mod.rs @@ -1,3 +1,50 @@ #[cfg(target_os = "windows")] pub mod windows; +#[cfg(target_os = "linux")] +pub mod linux; + +/// Returns identifier of host OS FlightCore is running on +#[tauri::command] +pub fn get_host_os() -> String { + std::env::consts::OS.to_string() +} + +/// On Linux attempts to install NorthstarProton +/// On Windows simply returns an error message +#[tauri::command] +pub async fn install_northstar_proton_wrapper() -> Result<(), String> { + #[cfg(target_os = "linux")] + return linux::install_ns_proton().map_err(|err| err.to_string()); + + #[cfg(target_os = "windows")] + Err("Not supported on Windows".to_string()) +} + +#[tauri::command] +pub async fn uninstall_northstar_proton_wrapper() -> Result<(), String> { + #[cfg(target_os = "linux")] + return linux::uninstall_ns_proton(); + + #[cfg(target_os = "windows")] + Err("Not supported on Windows".to_string()) +} + +#[tauri::command] +pub async fn get_local_northstar_proton_wrapper_version() -> Result { + #[cfg(target_os = "linux")] + return linux::get_local_ns_proton_version(); + + #[cfg(target_os = "windows")] + Err("Not supported on Windows".to_string()) +} + +/// Check whether the current device might be behind a CGNAT +#[tauri::command] +pub async fn check_cgnat() -> Result { + #[cfg(target_os = "linux")] + return Err("Not supported on Linux".to_string()); + + #[cfg(target_os = "windows")] + windows::check_cgnat().await +} diff --git a/src-tauri/src/platform_specific/windows.rs b/src-tauri/src/platform_specific/windows.rs index 678e5be54..fc6aab5d6 100644 --- a/src-tauri/src/platform_specific/windows.rs +++ b/src-tauri/src/platform_specific/windows.rs @@ -1,5 +1,6 @@ /// Windows specific code use anyhow::{anyhow, Result}; +use std::net::Ipv4Addr; #[cfg(target_os = "windows")] use winreg::{enums::HKEY_LOCAL_MACHINE, RegKey}; @@ -32,3 +33,72 @@ pub fn origin_install_location_detection() -> Result { Err(anyhow!("No Origin / EA App install path found")) } + +/// Check whether the current device might be behind a CGNAT +pub async fn check_cgnat() -> Result { + // Use external service to grap IP + let url = "https://api.ipify.org"; + let response = reqwest::get(url).await.unwrap().text().await.unwrap(); + + // Check if valid IPv4 address and return early if not + if response.parse::().is_err() { + return Err(format!("Not valid IPv4 address: {}", response)); + } + + let hops_count = run_tracert(&response)?; + Ok(format!("Counted {} hops to {}", hops_count, response)) +} + +/// Count number of hops in tracert output +fn count_hops(output: &str) -> usize { + // Split the output into lines + let lines: Vec<&str> = output.lines().collect(); + + // Filter lines that appear to represent hops + let hop_lines: Vec<&str> = lines + .iter() + .filter(|&line| line.contains("ms") || line.contains("*")) // TODO check if it contains just the `ms` surrounded by whitespace, otherwise it might falsely pick up some domain names as well + .cloned() + .collect(); + + // Return the number of hops + hop_lines.len() +} + +/// Run `tracert` +fn run_tracert(target_ip: &str) -> Result { + // Ensure valid IPv4 address to avoid prevent command injection + assert!(target_ip.parse::().is_ok()); + + // Execute the `tracert` command + let output = match std::process::Command::new("tracert") + .arg("-4") // Force IPv4 + .arg("-d") // Prevent resolving intermediate IP addresses + .arg("-w") // Set timeout to 1 second + .arg("1000") + .arg("-h") // Set max hop count + .arg("5") + .arg(target_ip) + .output() + { + Ok(res) => res, + Err(err) => return Err(format!("Failed running tracert: {}", err)), + }; + + // Check if the command was successful + if output.status.success() { + // Convert the output to a string + let stdout = + std::str::from_utf8(&output.stdout).expect("Invalid UTF-8 sequence in command output"); + println!("{}", stdout); + + // Count the number of hops + let hop_count = count_hops(stdout); + Ok(hop_count) + } else { + let stderr = std::str::from_utf8(&output.stderr) + .expect("Invalid UTF-8 sequence in command error output"); + println!("{}", stderr); + Err(format!("Failed collecting tracert output: {}", stderr)) + } +} diff --git a/src-tauri/src/repair_and_verify/mod.rs b/src-tauri/src/repair_and_verify/mod.rs index a511ae9ed..3c8616094 100644 --- a/src-tauri/src/repair_and_verify/mod.rs +++ b/src-tauri/src/repair_and_verify/mod.rs @@ -1,3 +1,6 @@ +use crate::mod_management::{get_enabled_mods, rebuild_enabled_mods_json, set_mod_enabled_status}; +/// Contains various functions to repair common issues and verifying installation +use crate::{constants::CORE_MODS, GameInstall}; /// Checks if is valid Titanfall2 install based on certain conditions #[tauri::command] @@ -23,3 +26,112 @@ pub fn check_is_valid_game_path(game_install_path: &str) -> Result<(), String> { } Ok(()) } + +/// Verifies Titanfall2 game files +#[tauri::command] +pub fn verify_game_files(game_install: GameInstall) -> Result { + dbg!(game_install); + Err("TODO, not yet implemented".to_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)?; + + let current_mods = get_enabled_mods(&game_install)?; + + // Disable all mods, set core mods to enabled + for (key, _value) in current_mods.as_object().unwrap() { + if CORE_MODS.contains(&key.as_str()) { + // This is a core mod, we do not want to disable it + set_mod_enabled_status(game_install.clone(), key.to_string(), true)?; + } else { + // Not a core mod + set_mod_enabled_status(game_install.clone(), key.to_string(), false)?; + } + } + + Ok(()) +} + +/// Installs the specified mod +#[tauri::command] +pub async fn clean_up_download_folder_wrapper( + game_install: GameInstall, + force: bool, +) -> Result<(), String> { + match clean_up_download_folder(&game_install, force) { + Ok(()) => Ok(()), + Err(err) => Err(err.to_string()), + } +} + +/// Deletes download folder +/// If `force` is FALSE, bails on non-empty folder +/// If `force` is TRUE, deletes folder even if non-empty +pub fn clean_up_download_folder( + game_install: &GameInstall, + force: bool, +) -> Result<(), anyhow::Error> { + const TEMPORARY_DIRECTORIES: [&str; 4] = [ + "___flightcore-temp-download-dir", + "___flightcore-temp/download-dir", + "___flightcore-temp/extract-dir", + "___flightcore-temp", + ]; + + for directory in TEMPORARY_DIRECTORIES { + // Get download directory + let download_directory = format!("{}/{}/", game_install.game_path, directory); + + // Check if files in folder + let download_dir_contents = match std::fs::read_dir(download_directory.clone()) { + Ok(contents) => contents, + Err(_) => continue, + }; + + let mut count = 0; + download_dir_contents.for_each(|_| count += 1); + + if count > 0 && !force { + // Skip folder if not empty + log::warn!("Folder not empty, not deleting: {directory}"); + continue; + } + + // Delete folder + std::fs::remove_dir_all(download_directory)?; + } + Ok(()) +} + +/// Get list of Northstar logs +#[tauri::command] +pub fn get_log_list(game_install: GameInstall) -> Result, String> { + let ns_log_folder = format!("{}/{}/logs", game_install.game_path, game_install.profile); + + // List files in logs folder + let paths = match std::fs::read_dir(ns_log_folder) { + Ok(paths) => paths, + Err(_err) => return Err("No logs folder found".to_string()), + }; + + // Stores paths of log files + let mut log_files: Vec = Vec::new(); + + for path in paths { + let path = path.unwrap().path(); + if path.display().to_string().contains("nslog") { + log_files.push(path); + } + } + + if !log_files.is_empty() { + Ok(log_files) + } else { + Err("No logs found".to_string()) + } +} diff --git a/src-tauri/src/thunderstore/mod.rs b/src-tauri/src/thunderstore/mod.rs new file mode 100644 index 000000000..fc2acb02a --- /dev/null +++ b/src-tauri/src/thunderstore/mod.rs @@ -0,0 +1,86 @@ +//! For interacting with Thunderstore API +use crate::constants::{APP_USER_AGENT, BLACKLISTED_MODS}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use ts_rs::TS; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ThunderstoreMod { + pub name: String, + pub full_name: String, + pub owner: String, + pub package_url: String, + pub date_created: String, + pub date_updated: String, + pub uuid4: String, + pub rating_score: i32, + pub is_pinned: bool, + pub is_deprecated: bool, + pub has_nsfw_content: bool, + pub categories: Vec, + pub versions: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ThunderstoreModVersion { + pub name: String, + pub full_name: String, + pub description: String, + pub icon: String, + pub version_number: String, + pub dependencies: Vec, + pub download_url: String, + pub downloads: i32, + pub date_created: String, + pub website_url: String, + pub is_active: bool, + pub uuid4: String, + pub file_size: i64, +} + +/// Performs actual fetch from Thunderstore and returns response +async fn fetch_thunderstore_packages() -> Result { + log::info!("Fetching Thunderstore API"); + + // Fetches + let url = "https://northstar.thunderstore.io/api/v1/package/"; + + let client = reqwest::Client::new(); + client + .get(url) + .header(reqwest::header::USER_AGENT, APP_USER_AGENT) + .send() + .await? + .text() + .await +} + +/// Queries Thunderstore packages API +#[tauri::command] +pub async fn query_thunderstore_packages_api() -> Result, String> { + let res = match fetch_thunderstore_packages().await { + Ok(res) => res, + Err(err) => { + let warn_response = format!("Couldn't fetch from Thunderstore: {err}"); + log::warn!("{warn_response}"); + return Err(warn_response); + } + }; + + // Parse response + let parsed_json: Vec = match serde_json::from_str(&res) { + Ok(res) => res, + Err(err) => return Err(err.to_string()), + }; + + // Remove some mods from listing + let to_remove_set: HashSet<&str> = BLACKLISTED_MODS.iter().copied().collect(); + let filtered_packages = parsed_json + .into_iter() + .filter(|package| !to_remove_set.contains(&package.full_name.as_ref())) + .collect::>(); + + Ok(filtered_packages) +} diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index a9387c361..d2234be50 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -1,6 +1,8 @@ //! This module contains various utility/helper functions that do not fit into any other module +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use zip::ZipArchive; use crate::constants::{APP_USER_AGENT, MASTER_SERVER_URL, SERVER_BROWSER_ENDPOINT}; @@ -59,6 +61,13 @@ pub async fn open_repair_window(handle: tauri::AppHandle) -> Result<(), String> Ok(()) } +/// Closes all windows and exits application +#[tauri::command] +pub async fn close_application(app: tauri::AppHandle) -> Result<(), String> { + app.exit(0); // Close application + Ok(()) +} + /// Fetches `/client/servers` endpoint from master server async fn fetch_server_list() -> Result { let url = format!("{MASTER_SERVER_URL}{SERVER_BROWSER_ENDPOINT}"); @@ -96,3 +105,220 @@ pub async fn get_server_player_count() -> Result<(i32, usize), String> { Ok((total_player_count, server_count)) } + +#[tauri::command] +pub async fn kill_northstar() -> Result<(), String> { + if !check_northstar_running() { + return Err("Northstar is not running".to_string()); + } + + let s = sysinfo::System::new_all(); + + for process in s.processes_by_exact_name("Titanfall2.exe") { + log::info!("Killing Process {}", process.pid()); + process.kill(); + } + + for process in s.processes_by_exact_name("NorthstarLauncher.exe") { + log::info!("Killing Process {}", process.pid()); + process.kill(); + } + + Ok(()) +} + +/// Copied from `papa` source code and modified +///Extract N* zip file to target game path +// fn extract(ctx: &Ctx, zip_file: File, target: &Path) -> Result<()> { +pub fn extract(zip_file: std::fs::File, target: &std::path::Path) -> Result<()> { + let mut archive = ZipArchive::new(&zip_file).context("Unable to open zip archive")?; + for i in 0..archive.len() { + let mut f = archive.by_index(i).unwrap(); + + //This should work fine for N* because the dir structure *should* always be the same + if f.enclosed_name().unwrap().starts_with("Northstar") { + let out = target.join( + f.enclosed_name() + .unwrap() + .strip_prefix("Northstar") + .unwrap(), + ); + + if (*f.name()).ends_with('/') { + log::info!("Create directory {}", f.name()); + std::fs::create_dir_all(target.join(f.name())) + .context("Unable to create directory")?; + continue; + } else if let Some(p) = out.parent() { + std::fs::create_dir_all(p).context("Unable to create directory")?; + } + + let mut outfile = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&out)?; + + log::info!("Write file {}", out.display()); + + std::io::copy(&mut f, &mut outfile).context("Unable to write to file")?; + } + } + + Ok(()) +} + +pub fn check_ea_app_or_origin_running() -> bool { + let s = sysinfo::System::new_all(); + let x = s.processes_by_name("Origin.exe").next().is_some() + || s.processes_by_name("EADesktop.exe").next().is_some(); + x +} + +/// Checks if Northstar process is running +pub fn check_northstar_running() -> bool { + let s = sysinfo::System::new_all(); + let x = s + .processes_by_name("NorthstarLauncher.exe") + .next() + .is_some() + || s.processes_by_name("Titanfall2.exe").next().is_some(); + x +} + +/// Copies a folder and all its contents to a new location +pub fn copy_dir_all( + src: impl AsRef, + dst: impl AsRef, +) -> std::io::Result<()> { + std::fs::create_dir_all(&dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?; + } else { + std::fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?; + } + } + Ok(()) +} + +/// Moves a folders file structure to a new location +/// Old folders are not removed +pub fn move_dir_all( + src: impl AsRef, + dst: impl AsRef, +) -> std::io::Result<()> { + std::fs::create_dir_all(&dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + move_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?; + std::fs::remove_dir(entry.path())?; + } else { + std::fs::rename(entry.path(), dst.as_ref().join(entry.file_name()))?; + } + } + Ok(()) +} + +/// Helps with converting release candidate numbers which are different on Thunderstore +/// due to restrictions imposed by the platform +pub fn convert_release_candidate_number(version_number: String) -> String { + let release_candidate_suffix = "-rc"; + + if !version_number.contains(release_candidate_suffix) { + // Not an release-candidate version number, nothing to do, return early + return version_number; + } + + // Version number is guaranteed to contain `-rc` + let re = regex::Regex::new(r"(\d+)\.(\d+)\.(\d+)-rc(\d+)").unwrap(); + if let Some(captures) = re.captures(&version_number) { + // Extract versions + let major_version: u32 = captures[1].parse().unwrap(); + let minor_version: u32 = captures[2].parse().unwrap(); + let patch_version: u32 = captures[3].parse().unwrap(); + let release_candidate: u32 = captures[4].parse().unwrap(); + + // Zero pad + let padded_release_candidate = format!("{:02}", release_candidate); + + // Combine + let combined_patch_version = format!("{}{}", patch_version, padded_release_candidate); + + // Strip leading zeroes + let trimmed_combined_patch_version = combined_patch_version.trim_start_matches('0'); + + // Combine all + let version_number = format!( + "{}.{}.{}", + major_version, minor_version, trimmed_combined_patch_version + ); + return version_number; + } + + // We should never end up here + panic!(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_not_release_candidate() { + let input = "1.2.3".to_string(); + let output = convert_release_candidate_number(input.clone()); + let expected_output = input; + assert_eq!(output, expected_output); + } + + #[test] + fn test_basic_release_candidate_number_conversion() { + let input = "1.2.3-rc4".to_string(); + let output = convert_release_candidate_number(input); + let expected_output = "1.2.304"; + assert_eq!(output, expected_output); + } + + #[test] + fn test_leading_zero_release_candidate_number_conversion() { + let input = "1.2.0-rc3".to_string(); + let output = convert_release_candidate_number(input); + let expected_output = "1.2.3"; + assert_eq!(output, expected_output); + } + + #[test] + fn test_double_patch_digit_release_candidate_number_conversion() { + // let input = "v1.2.34-rc5".to_string(); + // let output = convert_release_candidate_number(input); + // let expected_output = "v1.2.3405"; + let input = "1.19.10-rc1".to_string(); + let output = convert_release_candidate_number(input); + let expected_output = "1.19.1001"; + + assert_eq!(output, expected_output); + } + + #[test] + fn test_double_digit_release_candidate_number_conversion() { + let input = "1.2.3-rc45".to_string(); + let output = convert_release_candidate_number(input); + let expected_output = "1.2.345"; + + assert_eq!(output, expected_output); + } + + #[test] + fn test_double_digit_patch_and_rc_number_conversion() { + let input = "1.2.34-rc56".to_string(); + let output = convert_release_candidate_number(input); + let expected_output = "1.2.3456"; + + assert_eq!(output, expected_output); + } +}