aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGeckoEidechse <gecko.eidechse+git@pm.me>2025-01-03 17:10:16 +0100
committerGeckoEidechse <gecko.eidechse+git@pm.me>2025-01-03 17:10:16 +0100
commit7ae9254ff6b2bca6560abd0713c8c6ea446f692d (patch)
treec35159b4393258088cf91c991b4b5797e24814cb
parentd8c220f0de759eb209b35567faf1618b77ea9ba6 (diff)
downloadFlightCore-7ae9254ff6b2bca6560abd0713c8c6ea446f692d.tar.gz
FlightCore-7ae9254ff6b2bca6560abd0713c8c6ea446f692d.zip
feat: Re-implement majority of backend
-rw-r--r--src-tauri/src/constants.rs49
-rw-r--r--src-tauri/src/development/mod.rs84
-rw-r--r--src-tauri/src/github/mod.rs162
-rw-r--r--src-tauri/src/github/pull_requests.rs398
-rw-r--r--src-tauri/src/github/release_notes.rs118
-rw-r--r--src-tauri/src/lib.rs61
-rw-r--r--src-tauri/src/main.rs1
-rw-r--r--src-tauri/src/mod_management/legacy.rs213
-rw-r--r--src-tauri/src/mod_management/mod.rs797
-rw-r--r--src-tauri/src/mod_management/plugins.rs10
-rw-r--r--src-tauri/src/northstar/install.rs143
-rw-r--r--src-tauri/src/northstar/mod.rs273
-rw-r--r--src-tauri/src/northstar/profile.rs121
-rw-r--r--src-tauri/src/platform_specific/linux.rs98
-rw-r--r--src-tauri/src/platform_specific/mod.rs47
-rw-r--r--src-tauri/src/platform_specific/windows.rs70
-rw-r--r--src-tauri/src/repair_and_verify/mod.rs112
-rw-r--r--src-tauri/src/thunderstore/mod.rs86
-rw-r--r--src-tauri/src/util.rs226
19 files changed, 3069 insertions, 0 deletions
diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs
index 51a838c7..3ad2d6e8 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 00000000..7184904c
--- /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<String, String> {
+ // Get list of commits
+ let commits: Vec<CommitInfo> = 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 80a1831a..9572d30c 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<CommitAuthor>,
+}
+
+#[derive(Debug, Deserialize)]
+struct Commit {
+ message: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct CommitAuthor {
+ login: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct Comparison {
+ commits: Vec<CommitInfo>,
+}
+
+/// Get a list of tags on the FlightCore repo
+#[tauri::command]
+pub fn get_list_of_tags(project: Project) -> Result<Vec<TagWrapper>, 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<String, String> {
+ 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<String, String> {
+ todo!()
+}
+
+/// Generate release notes in the format used for FlightCore
+fn generate_flightcore_release_notes(commits: Vec<String>) -> 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<String>) -> HashMap<String, Vec<String>> {
+ let mut grouped_commits: HashMap<String, Vec<String>> = HashMap::new();
+ let mut other_commits: Vec<String> = 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<String, String> {
+ 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 00000000..de733feb
--- /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<String>,
+}
+
+// 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<WorkflowRun>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+struct Artifact {
+ id: u64,
+ name: String,
+ workflow_run: WorkflowRun,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+struct ArtifactsResponse {
+ artifacts: Vec<Artifact>,
+}
+
+#[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<Vec<PullsApiResponseElement>, 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<PullsApiResponseElement> = 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<String> = 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<Vec<PullsApiResponseElement>, 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<serde_json::Value, Box<dyn std::error::Error>> {
+ 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<Vec<u8>, 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<String, anyhow::Error> {
+ // {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<String, String> {
+ // 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<File>`
+ 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 3449df33..4adfb24b 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<Vec<ReleaseInfo>, 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<String, String> {
+ 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 3fe93da3..d608e3d8 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<String>,
+ pub thunderstore_mod_string: Option<String>,
+ pub enabled: bool,
+ pub directory: String,
+}
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 2abccd9e..0f8552d4 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 00000000..1e9f90f5
--- /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<String>,
+ #[serde(rename = "Version")]
+ version: Option<String>,
+}
+
+/// Parses `manifest.json` for Thunderstore mod string
+fn parse_for_thunderstore_mod_string(nsmod_path: &str) -> Result<String, anyhow::Error> {
+ 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<Vec<NorthstarMod>, 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<PathBuf> = Vec::new();
+ let mut mods: Vec<NorthstarMod> = 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<String> = Vec::new();
+
+ // Get folder name based on Thundestore mod string
+ for installed_ns_mod in installed_ns_mods {
+ if installed_ns_mod.thunderstore_mod_string.is_none() {
+ // Not a Thunderstore mod
+ continue;
+ }
+
+ let installed_ns_mod_ts_string: ParsedThunderstoreModString = installed_ns_mod
+ .thunderstore_mod_string
+ .unwrap()
+ .parse()
+ .unwrap();
+
+ // Installed mod matches specified Thunderstore mod string
+ if parsed_ts_mod_string.author_name == installed_ns_mod_ts_string.author_name
+ && parsed_ts_mod_string.mod_name == installed_ns_mod_ts_string.mod_name
+ {
+ // 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 00000000..52ef1180
--- /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<Self, Self::Err> {
+ // 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<serde_json::value::Value, String> {
+ 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<String>,
+}
+
+/// Parse `mods` folder for installed mods.
+pub fn parse_mods_in_package(
+ package_mods_path: PathBuf,
+ thunderstore_mod_string: ParsedThunderstoreModString,
+) -> Result<Vec<NorthstarMod>, 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<PathBuf> = Vec::new();
+ let mut mods: Vec<NorthstarMod> = 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<Vec<NorthstarMod>, anyhow::Error> {
+ let mut collected_mods: Vec<NorthstarMod> = 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<Vec<NorthstarMod>, 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(&current_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<String, String> {
+ // 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<Vec<String>, 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::<String>::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<PathBuf> = 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<dyn Error + Send + Sync + 'static>> {
+ 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 00000000..6e8aa6ae
--- /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 09e8ec92..02db2c1d 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<String>,
+ version_number: Option<String>,
+) -> Result<bool, String> {
+ 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<String>,
+) -> Result<bool, String> {
+ 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<String>,
+) -> Result<String, String> {
+ 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<GameInstall, String> {
diff --git a/src-tauri/src/northstar/mod.rs b/src-tauri/src/northstar/mod.rs
index d1e03caa..9953d742 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<Vec<NorthstarThunderstoreReleaseWrapper>, ()> {
+ 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<NorthstarThunderstoreReleaseWrapper> = 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<String>,
+) -> Result<bool, String> {
+ 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<String, anyhow::Error> {
+ 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<String, String> {
+ 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<String, String> {
+ 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<String, String> {
+ 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 00000000..26a32d6b
--- /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<Vec<String>, String> {
+ let mut profiles: Vec<String> = 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 00000000..fcac5b67
--- /dev/null
+++ b/src-tauri/src/platform_specific/linux.rs
@@ -0,0 +1,98 @@
+// Linux specific code
+
+fn get_proton_dir() -> Result<String, String> {
+ 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<String, String> {
+ 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 996f4556..4e0514d4 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<String, String> {
+ #[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<String, String> {
+ #[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 678e5be5..fc6aab5d 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<String, anyhow::Error> {
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<String, String> {
+ // 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::<Ipv4Addr>().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<usize, String> {
+ // Ensure valid IPv4 address to avoid prevent command injection
+ assert!(target_ip.parse::<Ipv4Addr>().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 a511ae9e..3c861609 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<String, String> {
+ 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<Vec<std::path::PathBuf>, 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<std::path::PathBuf> = 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 00000000..fc2acb02
--- /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<String>,
+ pub versions: Vec<ThunderstoreModVersion>,
+}
+
+#[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<String>,
+ 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<String, reqwest::Error> {
+ 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<Vec<ThunderstoreMod>, 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<ThunderstoreMod> = 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::<Vec<ThunderstoreMod>>();
+
+ Ok(filtered_packages)
+}
diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs
index a9387c36..d2234be5 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<R: tauri::Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
+ app.exit(0); // Close application
+ Ok(())
+}
+
/// Fetches `/client/servers` endpoint from master server
async fn fetch_server_list() -> Result<String, anyhow::Error> {
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<std::path::Path>,
+ dst: impl AsRef<std::path::Path>,
+) -> 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<std::path::Path>,
+ dst: impl AsRef<std::path::Path>,
+) -> 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);
+ }
+}