aboutsummaryrefslogtreecommitdiff
path: root/src-tauri/src
diff options
context:
space:
mode:
Diffstat (limited to 'src-tauri/src')
-rw-r--r--src-tauri/src/constants.rs6
-rw-r--r--src-tauri/src/github/mod.rs1
-rw-r--r--src-tauri/src/github/pull_requests.rs446
-rw-r--r--src-tauri/src/github/release_notes.rs2
-rw-r--r--src-tauri/src/main.rs4
5 files changed, 458 insertions, 1 deletions
diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs
index 73cb6fb3..57a10e02 100644
--- a/src-tauri/src/constants.rs
+++ b/src-tauri/src/constants.rs
@@ -28,3 +28,9 @@ pub const BLACKLISTED_MODS: [&str; 3] = [
// Titanfall2 game IDs on Origin/EA-App
pub const TITANFALL2_ORIGIN_IDS: [&str; 2] = ["Origin.OFR.50.0001452", "Origin.OFR.50.0001456"];
+
+// GitHub API endpoints for launcher/mods PRs
+pub const PULLS_API_ENDPOINT_LAUNCHER: &str =
+ "https://api.github.com/repos/R2Northstar/NorthstarLauncher/pulls";
+pub const PULLS_API_ENDPOINT_MODS: &str =
+ "https://api.github.com/repos/R2Northstar/NorthstarMods/pulls";
diff --git a/src-tauri/src/github/mod.rs b/src-tauri/src/github/mod.rs
index 80a1831a..942f0db2 100644
--- a/src-tauri/src/github/mod.rs
+++ b/src-tauri/src/github/mod.rs
@@ -1 +1,2 @@
+pub mod pull_requests;
pub mod release_notes;
diff --git a/src-tauri/src/github/pull_requests.rs b/src-tauri/src/github/pull_requests.rs
new file mode 100644
index 00000000..2b7be30b
--- /dev/null
+++ b/src-tauri/src/github/pull_requests.rs
@@ -0,0 +1,446 @@
+use crate::github::release_notes::fetch_github_releases_api;
+
+use anyhow::anyhow;
+use app::check_is_valid_game_path;
+use app::constants::{APP_USER_AGENT, PULLS_API_ENDPOINT_LAUNCHER, PULLS_API_ENDPOINT_MODS};
+use serde::{Deserialize, Serialize};
+use std::fs;
+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: i64,
+ title: String,
+ url: String,
+ head: CommitHead,
+ html_url: 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,
+ 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(url: String) -> Result<Vec<PullsApiResponseElement>, String> {
+ let json_response = match fetch_github_releases_api(&url).await {
+ Ok(result) => result,
+ Err(err) => return Err(err.to_string()),
+ };
+
+ let pulls_response: Vec<PullsApiResponseElement> = match serde_json::from_str(&json_response) {
+ Ok(res) => res,
+ Err(err) => return Err(err.to_string()),
+ };
+
+ Ok(pulls_response)
+}
+
+/// Gets either launcher or mods PRs
+#[tauri::command]
+pub async fn get_pull_requests_wrapper(
+ install_type: PullRequestType,
+) -> Result<Vec<PullsApiResponseElement>, String> {
+ let api_pr_url = match install_type {
+ PullRequestType::MODS => PULLS_API_ENDPOINT_MODS,
+ PullRequestType::LAUNCHER => PULLS_API_ENDPOINT_LAUNCHER,
+ };
+
+ get_pull_requests(api_pr_url.to_string()).await
+}
+
+fn unzip(zip_file_name: &str) -> String {
+ let fname = std::path::Path::new(zip_file_name);
+ let file = fs::File::open(fname).unwrap();
+
+ let mut archive = zip::ZipArchive::new(file).unwrap();
+
+ let mut folder_name = "".to_string();
+
+ for i in 0..archive.len() {
+ let mut file = archive.by_index(i).unwrap();
+ let outpath = match file.enclosed_name() {
+ Some(path) => path.to_owned(),
+ None => continue,
+ };
+
+ {
+ let comment = file.comment();
+ if !comment.is_empty() {
+ println!("File {} comment: {}", i, comment);
+ }
+ }
+
+ if i == 0 {
+ // Sanity check that it's a folder
+ assert!((*file.name()).ends_with('/'));
+
+ folder_name = format!("{}", outpath.display());
+ println!("{}", folder_name);
+ }
+
+ if (*file.name()).ends_with('/') {
+ fs::create_dir_all(&outpath).unwrap();
+ } else {
+ if let Some(p) = outpath.parent() {
+ if !p.exists() {
+ fs::create_dir_all(p).unwrap();
+ }
+ }
+ let mut outfile = fs::File::create(&outpath).unwrap();
+ io::copy(&mut file, &mut outfile).unwrap();
+ }
+
+ // Get and Set permissions
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+
+ if let Some(mode) = file.unix_mode() {
+ fs::set_permissions(&outpath, fs::Permissions::from_mode(mode)).unwrap();
+ }
+ }
+ }
+ folder_name
+}
+
+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
+async fn download_zip(download_url: String, location: String) -> Result<(), anyhow::Error> {
+ let client = reqwest::Client::new();
+ let resp = client
+ .get(download_url)
+ .header(reqwest::header::USER_AGENT, APP_USER_AGENT)
+ .send()
+ .await?;
+
+ // Error out earlier if non-successful response
+ if !resp.status().is_success() {
+ // Return error cause wrong game path
+ return Err(anyhow!(
+ "Couldn't download zip. Received error code \"{}\"",
+ resp.status()
+ ));
+ }
+
+ let mut out = fs::File::create(format!("{}/ns-dev-test-helper-temp-pr-files.zip", location))
+ .expect("failed to create file");
+ let bytes = resp.bytes().await?;
+ let mut cursor = std::io::Cursor::new(bytes);
+ std::io::copy(&mut cursor, &mut out)?;
+ Ok(())
+}
+
+fn unzip_launcher_zip(zip_file_name: &str) -> String {
+ let outfolder_name = "ns-dev-test-helper-temp-pr-files";
+ let fname = std::path::Path::new(zip_file_name);
+ let file = fs::File::open(fname).unwrap();
+
+ let mut archive = zip::ZipArchive::new(file).unwrap();
+
+ fs::create_dir_all(outfolder_name).unwrap();
+
+ for i in 0..archive.len() {
+ let mut file = archive.by_index(i).unwrap();
+ let outpath = match file.enclosed_name() {
+ Some(path) => path.to_owned(),
+ None => continue,
+ };
+
+ {
+ let comment = file.comment();
+ if !comment.is_empty() {
+ println!("File {} comment: {}", i, comment);
+ }
+ }
+
+ // Only extract two hardcoded files
+ if *file.name() == *"NorthstarLauncher.exe" || *file.name() == *"Northstar.dll" {
+ println!(
+ "File {} extracted to \"{}\" ({} bytes)",
+ i,
+ outpath.display(),
+ file.size()
+ );
+ if let Some(p) = outpath.parent() {
+ if !p.exists() {
+ fs::create_dir_all(p).unwrap();
+ }
+ }
+ let mut outfile =
+ fs::File::create(format!("{}/{}", outfolder_name, outpath.display())).unwrap();
+ std::io::copy(&mut file, &mut outfile).unwrap();
+ }
+
+ // Get and Set permissions
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+
+ if let Some(mode) = file.unix_mode() {
+ fs::set_permissions(&outpath, fs::Permissions::from_mode(mode)).unwrap();
+ }
+ }
+ }
+ outfolder_name.to_string()
+}
+
+/// Recursively copies files from one directory to another
+fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
+ fs::create_dir_all(&dst)?;
+ for entry in 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 {
+ fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
+ }
+ }
+ Ok(())
+}
+
+/// 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 PR
+async fn get_launcher_download_link(
+ pull_request: PullsApiResponseElement,
+) -> 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 PR head commit sha against workflow runs
+ for workflow_run in &runs_response.workflow_runs {
+ // If head commit sha of run and PR match, grab CI output
+ if workflow_run.head_sha == pull_request.head.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();
+
+ // Iterate over artifacts
+ for artifact in artifacts_response.artifacts {
+ // Make sure run is from PR head commit
+ if artifact.workflow_run.head_sha == workflow_run.head_sha {
+ dbg!(artifact.id);
+
+ // Download artifact
+ return Ok(format!("https://nightly.link/R2Northstar/NorthstarLauncher/actions/artifacts/{}.zip", artifact.id));
+ }
+ }
+ }
+ }
+ }
+
+ Err(format!(
+ "Couldn't grab download link for PR \"{}\". PR might be too old and therefore no CI build has been deleted. Maybe ask author to update?",
+ pull_request.number
+ ))
+}
+
+/// 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(_) => println!("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_path: &str,
+) -> Result<(), String> {
+ // Exit early if wrong game path
+ check_is_valid_game_path(game_install_path)?;
+
+ // get download link
+ let download_url = get_launcher_download_link(pull_request).await?;
+
+ // download
+ match download_zip(download_url, ".".to_string()).await {
+ Ok(_) => (),
+ Err(err) => return Err(err.to_string()),
+ };
+
+ // extract
+ let zip_extract_folder_name = unzip_launcher_zip("ns-dev-test-helper-temp-pr-files.zip");
+ fs::remove_file("ns-dev-test-helper-temp-pr-files.zip").unwrap();
+
+ // Copy downloaded folder to game install folder
+ match copy_dir_all(zip_extract_folder_name.clone(), game_install_path) {
+ Ok(_) => (),
+ Err(err) => {
+ return Err(format!("Failed copying files: {}", err));
+ }
+ }
+
+ // Delete old unzipped
+ fs::remove_dir_all(zip_extract_folder_name).unwrap();
+
+ println!("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_path: &str,
+) -> Result<(), String> {
+ // Exit early if wrong game path
+ check_is_valid_game_path(game_install_path)?;
+
+ let download_url = match get_mods_download_link(pull_request) {
+ Ok(url) => url,
+ Err(err) => return Err(err.to_string()),
+ };
+
+ match download_zip(download_url, ".".to_string()).await {
+ Ok(()) => (),
+ Err(err) => return Err(err.to_string()),
+ };
+
+ // Extract folder and delete zip
+ let zip_extract_folder_name = unzip("ns-dev-test-helper-temp-pr-files.zip");
+ fs::remove_file("ns-dev-test-helper-temp-pr-files.zip").unwrap();
+
+ // Delete previously managed folder
+ if std::fs::remove_dir_all(format!(
+ "{}/R2Northstar-PR-test-managed-folder",
+ game_install_path
+ ))
+ .is_err()
+ {
+ if std::path::Path::new(&format!(
+ "{}/R2Northstar-PR-test-managed-folder",
+ game_install_path
+ ))
+ .exists()
+ {
+ println!("Failed removing previous dir");
+ } else {
+ println!("Failed removing folder that doesn't exist. Probably cause first run");
+ }
+ };
+
+ // Copy downloaded folder to game install folder
+ copy_dir_all(
+ zip_extract_folder_name.clone(),
+ format!(
+ "{}/R2Northstar-PR-test-managed-folder/mods",
+ game_install_path
+ ),
+ )
+ .unwrap();
+
+ // Delete old copy
+ std::fs::remove_dir_all(zip_extract_folder_name).unwrap();
+
+ // Add batch file to launch right profile
+ add_batch_file(game_install_path);
+
+ println!("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 6dee4576..b59358ec 100644
--- a/src-tauri/src/github/release_notes.rs
+++ b/src-tauri/src/github/release_notes.rs
@@ -19,7 +19,7 @@ pub struct FlightCoreVersion {
}
// Fetches repo release API and returns response as string
-async fn fetch_github_releases_api(url: &str) -> Result<String, String> {
+pub async fn fetch_github_releases_api(url: &str) -> Result<String, String> {
println!("Fetching releases notes from GitHub API");
let client = reqwest::Client::new();
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index c0c8a186..c7451763 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -15,6 +15,7 @@ use app::{
};
mod github;
+use github::pull_requests::{apply_launcher_pr, apply_mods_pr, get_pull_requests_wrapper};
use github::release_notes::{
check_is_flightcore_outdated, get_newest_flightcore_version, get_northstar_release_notes,
};
@@ -115,6 +116,9 @@ fn main() {
get_server_player_count,
delete_thunderstore_mod,
query_thunderstore_packages_api,
+ get_pull_requests_wrapper,
+ apply_launcher_pr,
+ apply_mods_pr,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");