diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6c64bf7b0..edf798e8f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -97,6 +97,7 @@ dependencies = [ "json5", "libthermite", "log", + "once_cell", "open", "pretty_env_logger", "regex", @@ -2788,9 +2789,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.51" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d727cae5b39d21da60fa540906919ad737832fe0b1c165da3a34d6548c849d6" +checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435" dependencies = [ "unicode-ident", ] @@ -2876,9 +2877,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.23" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" +checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" dependencies = [ "proc-macro2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 76315faef..c69c34e17 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -64,6 +64,8 @@ log = "0.4.17" zip-extract = "0.1.2" # open urls open = "3.2.0" +# for statics +once_cell = "1.17.1" [features] # by default Tauri runs in production mode diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3aa174599..a0eefe7d1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,6 +3,8 @@ windows_subsystem = "windows" )] +use once_cell::sync::OnceCell; + use std::{ env, sync::{Arc, Mutex}, @@ -34,18 +36,23 @@ use mod_management::{ get_installed_mods_and_properties, set_mod_enabled_status, }; +mod plugin_management; +use crate::plugin_management::download::{receive_install_status, InstallStatusSender}; + mod northstar; use northstar::get_northstar_version_number; mod thunderstore; use thunderstore::query_thunderstore_packages_api; -use tauri::{Manager, Runtime}; +use tauri::{AppHandle, Manager, Runtime}; use tokio::time::sleep; #[derive(Default)] struct Counter(Arc>); +pub static APP_HANDLE: OnceCell = OnceCell::new(); + fn main() { // Setup logger let mut log_builder = pretty_env_logger::formatted_builder(); @@ -107,9 +114,14 @@ fn main() { } }); + APP_HANDLE + .set(app.app_handle()) + .expect("failed to set a app handle"); + Ok(()) }) .manage(Counter(Default::default())) + .manage(InstallStatusSender::new()) .invoke_handler(tauri::generate_handler![ force_panic, find_game_install_location_caller, @@ -146,6 +158,7 @@ fn main() { apply_mods_pr, get_launcher_download_link, close_application, + receive_install_status, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -340,8 +353,10 @@ async fn launch_northstar_steam_caller( async fn install_mod_caller( game_install: GameInstall, thunderstore_mod_string: String, + can_install_plugins: bool, ) -> Result<(), String> { - fc_download_mod_and_install(&game_install, &thunderstore_mod_string).await?; + fc_download_mod_and_install(&game_install, &thunderstore_mod_string, can_install_plugins) + .await?; match clean_up_download_folder(&game_install, false) { Ok(()) => Ok(()), Err(err) => { diff --git a/src-tauri/src/mod_management/mod.rs b/src-tauri/src/mod_management/mod.rs index 728e72c03..9ec365e22 100644 --- a/src-tauri/src/mod_management/mod.rs +++ b/src-tauri/src/mod_management/mod.rs @@ -8,15 +8,18 @@ use app::NorthstarMod; use serde::{Deserialize, Serialize}; use std::io::Read; use std::path::PathBuf; +use thermite::prelude::ThermiteError; use app::get_enabled_mods; use app::GameInstall; +use crate::plugin_management::{download::install_plugin,detection::{find_installed_plugins,installed_plugins_to_mod}}; + #[derive(Debug, Clone)] -struct ParsedThunderstoreModString { - author_name: String, - mod_name: String, - version: Option, +pub struct ParsedThunderstoreModString { + pub author_name: String, + pub mod_name: String, + pub version: Option, } impl std::str::FromStr for ParsedThunderstoreModString { @@ -39,8 +42,8 @@ impl std::str::FromStr for ParsedThunderstoreModString { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ThunderstoreManifest { - name: String, - version_number: String, + pub name: String, + pub version_number: String, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -250,6 +253,12 @@ pub fn get_installed_mods_and_properties( installed_mods.push(current_mod); } + // push plugins into this list + // plugins should probably have there own tab but I hate frontend + installed_mods.extend(installed_plugins_to_mod( + &find_installed_plugins(&game_install).map_err(|err| err.to_string())?, + )); + Ok(installed_mods) } @@ -314,6 +323,7 @@ async fn get_mod_dependencies(thunderstore_mod_string: &str) -> Result Result<(), String> { // Get mods and download directories let download_directory = format!( @@ -335,7 +345,7 @@ pub async fn fc_download_mod_and_install( // Recursively install dependencies for dep in deps { - match fc_download_mod_and_install(game_install, &dep).await { + match fc_download_mod_and_install(game_install, &dep, can_install_plugins).await { Ok(()) => (), Err(err) => { if err == "Cannot install Northstar as a mod!" { @@ -379,15 +389,41 @@ pub async fn fc_download_mod_and_install( let author = thunderstore_mod_string.split('-').next().unwrap(); // Extract the mod to the mods directory - match thermite::core::manage::install_mod(author, &f, std::path::Path::new(&mods_directory)) { - Ok(()) => (), - Err(err) => return Err(err.to_string()), + let result_mod = match thermite::core::manage::install_mod( + author, + &f, + std::path::Path::new(&mods_directory), + ) { + Ok(()) => Ok(()), + err if matches!(err, Err(ThermiteError::PrefixError(_))) => err, // probably happens when there is not mod folder found + Err(err) => Err(err.to_string())?, + }; + + // Injected plugin install + + let result_plugin = match install_plugin( + game_install, + &f, + thunderstore_mod_string, + can_install_plugins, + ) + .await + { + err if matches!(err, Err(ThermiteError::MissingFile(_))) => err, + Err(err) => Err(err.to_string())?, + r => r, }; // Delete downloaded zip file std::fs::remove_file(path).unwrap(); - Ok(()) + // Because of the match expression only errors that can indicate missing mod/plugins folder + // we can say that it worked if the plugin install worked + if result_plugin.is_ok() { + Ok(()) + } else { + result_mod.map_err(|e| e.to_string()) + } } /// Deletes a given Northstar mod folder diff --git a/src-tauri/src/plugin_management/detection.rs b/src-tauri/src/plugin_management/detection.rs new file mode 100644 index 000000000..3b241d00a --- /dev/null +++ b/src-tauri/src/plugin_management/detection.rs @@ -0,0 +1,68 @@ +use crate::{mod_management::ThunderstoreManifest, GameInstall, NorthstarMod}; +use std::{ffi::OsStr, path::{PathBuf, Path}}; +use thermite::prelude::ThermiteError; + +pub fn installed_plugins_to_mod( + manifests: &[(ThunderstoreManifest, PathBuf)], +) -> Vec { + manifests + .iter() + .map(|(m, path)| NorthstarMod { + name: m.name.clone(), + version: None, // assume None + thunderstore_mod_string: Some(m.name.clone()), + enabled: true, // assume it is enabled + directory: path.display().to_string(), + }) + .collect() +} + +pub fn find_installed_plugins( + game_install: &GameInstall, +) -> Result, ThermiteError> { + let plugins_directory = PathBuf::new() + .join(&game_install.game_path) + .join("R2Northstar") + .join("plugins"); + + Ok(plugins_directory + .read_dir() + .map_err(|_| ThermiteError::MissingFile(Box::new(plugins_directory)))? + .filter_map(|f| f.ok()) + .map(|e| e.path()) + .filter_map(|p| find_manifest(p.as_path()).or_else(|| find_plugin_in_root(p.as_path()))) + .collect()) +} + +fn find_plugin_in_root(file: &Path) -> Option<(ThunderstoreManifest, PathBuf)> { + if file.extension()? == "dll" { + Some(( + ThunderstoreManifest { + name: file.file_name()?.to_str()?.to_string(), + version_number: "0.0.0".to_string(), // TODO: peak the dll to find it's version + }, + file.to_owned(), + )) + } else { + None + } +} + +// this can't be async :( +fn find_manifest(dir: &Path) -> Option<(ThunderstoreManifest, PathBuf)> { + pasre_manifest_path( + dir.read_dir() + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|path| path.file_name() == Some(OsStr::new("manifest.json"))) + .last()?, + ) +} + +fn pasre_manifest_path(path: PathBuf) -> Option<(ThunderstoreManifest, PathBuf)> { + Some(( + json5::from_str(&std::fs::read_to_string(&path).ok()?).ok()?, + path, + )) +} diff --git a/src-tauri/src/plugin_management/download.rs b/src-tauri/src/plugin_management/download.rs new file mode 100644 index 000000000..a0ff8ecf8 --- /dev/null +++ b/src-tauri/src/plugin_management/download.rs @@ -0,0 +1,162 @@ +use app::GameInstall; +use once_cell::sync::OnceCell; +use std::{ + fs::{self, File, OpenOptions}, + io, + path::PathBuf, + str::FromStr, +}; +use tauri::{ + async_runtime::{block_on, channel, Mutex, Receiver, Sender}, + Manager, State, +}; +use thermite::{core::utils::TempDir, prelude::ThermiteError}; +use zip::ZipArchive; + +use crate::{mod_management::ParsedThunderstoreModString, APP_HANDLE}; + +static INSTALL_STATUS_RECV: OnceCell>> = OnceCell::new(); + +pub struct InstallStatusSender(Mutex>); + +impl InstallStatusSender { + pub fn new() -> Self { + let (send, recv) = channel(1); + + INSTALL_STATUS_RECV + .set(Mutex::new(recv)) + .expect("failed to set INSTALL_STATUS_RECV"); + + Self(Mutex::new(send)) + } +} + +/// Tries to install plugins from a thunderstore zip +pub async fn install_plugin( + game_install: &GameInstall, + zip_file: &File, + thunderstore_mod_string: &str, + can_install_plugins: bool, +) -> Result<(), ThermiteError> { + let plugins_directory = PathBuf::new() + .join(&game_install.game_path) + .join("R2Northstar") + .join("plugins"); + let temp_dir = TempDir::create(plugins_directory.join("___flightcore-temp-plugin-dir"))?; + let manifest_path = temp_dir.join("manifest.json"); + let mut archive = ZipArchive::new(zip_file)?; + + let parsed_mod_string = ParsedThunderstoreModString::from_str(thunderstore_mod_string).map_err(|_|ThermiteError::MiscError("Gecko why is this returning nothing? anyway the error is failed to parse thunderstore string lmao".into()))?; + let package_name = parsed_mod_string.mod_name.to_owned(); + let folder_name = format!( + "{}-{}-{}", + parsed_mod_string.author_name, + package_name, + parsed_mod_string.version.unwrap_or_else(|| "0.0.0".into()) + ); + + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + + if file.enclosed_name().is_none() || file.enclosed_name().unwrap().starts_with(".") { + continue; + } + + let out = temp_dir.join(file.enclosed_name().unwrap()); + + if (*file.name()).ends_with('/') { + fs::create_dir_all(&out)?; + continue; + } else if let Some(p) = out.parent() { + fs::create_dir_all(p)?; + } + let mut outfile = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&out)?; + io::copy(&mut file, &mut outfile)?; + } + + let this_plugin_dir = plugins_directory.join(folder_name); + + let plugins: Vec = temp_dir + .join("plugins") + .read_dir() + .map_err(|_| ThermiteError::MissingFile(Box::new(temp_dir.join("plugins"))))? + .filter_map(|f| f.ok()) // ignore any errors + .filter(|f| f.path().extension().map(|e| e == "dll").unwrap_or(false)) // check for dll extension + .collect(); + + // warn user + if !plugins.is_empty() { + // check here instead if we can install plugins so people don't get broken mods without plugins + if !can_install_plugins { + Err(ThermiteError::MiscError( + "plugin installing is disabled; this mod contains a plugin; plugins can be enabled in the dev menu".to_string(), + ))? + } + + APP_HANDLE + .wait() + .emit_all("display-plugin-warning", ()) + .map_err(|err| ThermiteError::MiscError(err.to_string()))?; + + if !INSTALL_STATUS_RECV + .wait() + .lock() + .await + .recv() + .await + .unwrap_or(false) + { + Err(ThermiteError::MiscError( + "user denided plugin installing".to_string(), + ))? + } + } else { + Err(ThermiteError::MissingFile(Box::new( + temp_dir.join("plugins/anyplugins.dll"), + )))?; + } + + // nuke previous version if it exists + for (_, path) in plugins_directory + .read_dir() + .map_err(|_| ThermiteError::MissingFile(Box::new(temp_dir.join("plugins"))))? + .filter_map(|f| f.ok()) // ignore any errors + .map(|e| e.path()) + .filter(|path| path.is_dir()) + .filter_map(|path| Some((path.clone().file_name()?.to_str()?.to_owned(), path))) + .filter_map(|(name, path)| Some((name.parse::().ok()?, path))) + .filter(|(p, _)| p.mod_name == package_name) + .inspect(|(_, path)| println!("removing {}", path.display())) + { + fs::remove_dir_all(path)? + } + + // create the plugin subdir + if !this_plugin_dir.exists() { + fs::create_dir(&this_plugin_dir)?; + } + + fs::copy( + &manifest_path, + this_plugin_dir.join(manifest_path.file_name().unwrap_or_default()), + )?; + + for file in plugins { + fs::copy(file.path(), this_plugin_dir.join(file.file_name()))?; + } + + Ok(()) +} + +#[tauri::command] +pub fn receive_install_status( + sender: State<'_, InstallStatusSender>, + comfirmed_install: bool, +) -> Result<(), String> { + block_on(async { sender.0.lock().await.send(comfirmed_install).await }) + .map_err(|err| err.to_string()) +} diff --git a/src-tauri/src/plugin_management/mod.rs b/src-tauri/src/plugin_management/mod.rs new file mode 100644 index 000000000..a42ee9feb --- /dev/null +++ b/src-tauri/src/plugin_management/mod.rs @@ -0,0 +1,2 @@ +pub mod detection; +pub mod download; diff --git a/src-vue/src/components/ThunderstoreModCard.vue b/src-vue/src/components/ThunderstoreModCard.vue index fec95f147..1fc5cdbfc 100644 --- a/src-vue/src/components/ThunderstoreModCard.vue +++ b/src-vue/src/components/ThunderstoreModCard.vue @@ -243,7 +243,7 @@ export default defineComponent({ this.isBeingInstalled = true; } - await invoke("install_mod_caller", { gameInstall: game_install, thunderstoreModString: this.latestVersion.full_name }).then((message) => { + await invoke("install_mod_caller", { gameInstall: game_install, thunderstoreModString: this.latestVersion.full_name, canInstallPlugins: this.$store.state.can_install_plugins }).then((message) => { showNotification(this.$t('mods.card.install_success', {modName: mod.name}), message); }) .catch((error) => { diff --git a/src-vue/src/i18n/lang/en.json b/src-vue/src/i18n/lang/en.json index 407b69d1b..18957182f 100644 --- a/src-vue/src/i18n/lang/en.json +++ b/src-vue/src/i18n/lang/en.json @@ -16,7 +16,8 @@ "downloading": "Downloading", "extracting": "Extracting", "done": "Done", - "success": "Success" + "success": "Success", + "warning": "Warning" }, "play": { @@ -160,5 +161,12 @@ "Northstar": "Northstar", "NorthstarReleaseCandidate": "Northstar release candidate" } + }, + + "plugins": { + "warning_dialog": { + "warning_text": "This mod contains a plugin. Plugins CAN BE REALLY DANGEROURS since they have to access to your system!", + "comfirm_text": "Do you still want to install this mod with a plugin?" + } } } diff --git a/src-vue/src/plugins/store.ts b/src-vue/src/plugins/store.ts index 00b8f35a1..9794c2176 100644 --- a/src-vue/src/plugins/store.ts +++ b/src-vue/src/plugins/store.ts @@ -19,6 +19,7 @@ import { searchModule } from './modules/search'; import { i18n } from '../main'; import { pullRequestModule } from './modules/pull_requests'; import { showErrorNotification, showNotification } from '../utils/ui'; +import { ElMessageBox } from "element-plus"; const persistentStore = new Store('flight-core-settings.json'); @@ -48,6 +49,8 @@ export interface FlightCoreStore { // user custom settings mods_per_page: number, + + can_install_plugins: boolean, } let notification_handle: NotificationHandle; @@ -83,6 +86,8 @@ export const store = createStore({ server_count: -1, mods_per_page: 20, + + can_install_plugins: false, } }, mutations: { @@ -474,6 +479,10 @@ function _initializeListeners(state: any) { state.player_count = evt.payload.Ok[0]; state.server_count = evt.payload.Ok[1]; }); + + listen("display-plugin-warning", async function (evt: TauriEvent) { + await display_plugin_warning() // could also display the names of the plugins + }); } /** @@ -502,3 +511,29 @@ async function _get_northstar_version_number(state: any) { state.northstar_state = NorthstarState.INSTALL; }) } + +async function display_plugin_warning() { + let warning_title = i18n.global.tc('generic.warning'); + + // just wait for the end user to click ok button + await ElMessageBox.alert(i18n.global.tc('plugins.warning_dialog.warning_text'), warning_title); + + + // I switched comfirm and cancel button so end users don't spam throught the popups + // without reading and install a plugin by accident + await ElMessageBox.confirm( + i18n.global.tc('plugins.warning_dialog.comfirm_text'), + warning_title, + { + confirmButtonText: i18n.global.tc('generic.no'), + cancelButtonText: i18n.global.tc('generic.yes'), + type: "warning", + } + ) + .then(() => { + invoke("receive_install_status", { comfirmedInstall: false }) + }) + .catch(() => { + invoke("receive_install_status", { comfirmedInstall: true }) + }) +} diff --git a/src-vue/src/views/DeveloperView.vue b/src-vue/src/views/DeveloperView.vue index 7e11bd117..b1bad4f36 100644 --- a/src-vue/src/views/DeveloperView.vue +++ b/src-vue/src/views/DeveloperView.vue @@ -15,6 +15,14 @@ Panic button +

Installing Plugins:

+ + + Plugin Install Toggle + + + +

Linux:

@@ -106,6 +114,7 @@ export default defineComponent({ first_tag: { label: '', value: {name: ''} }, second_tag: { label: '', value: {name: ''} }, ns_release_tags: [] as TagWrapper[], + can_install_plugins_state : this.$store.state.can_install_plugins ? "Enabled" : "Disabled", } }, computed: { @@ -134,6 +143,11 @@ export default defineComponent({ await invoke("force_panic"); showErrorNotification("Never should have been able to get here!"); }, + togglePluginsInstalling() { + let new_state = !this.$store.state.can_install_plugins; + this.$store.state.can_install_plugins = new_state; + this.can_install_plugins_state = new_state ? "Enabled" : "Disabled"; + }, async checkLinuxCompatibility() { await invoke("linux_checks") .then(() => { @@ -173,7 +187,7 @@ export default defineComponent({ install_type: this.$store.state.install_type } as GameInstall; let mod_to_install = this.mod_to_install_field_string; - await invoke("install_mod_caller", { gameInstall: game_install, thunderstoreModString: mod_to_install }).then((message) => { + await invoke("install_mod_caller", { gameInstall: game_install, thunderstoreModString: mod_to_install, canInstallPlugins : this.$store.state.can_install_plugins }).then((message) => { // Show user notification if mod install completed. showNotification(`Installed ${mod_to_install}`, message); })