diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16759e4..c102960 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,9 +8,11 @@ on: description: 'Github Release Tag' required: true +run-name: Release ${{ inputs.tag }} + jobs: release: - name: release ${{ matrix.os }} + name: Release ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01eedf1..967641b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,5 +46,29 @@ pub async fn task_launch(&mut self, matches: &ArgMatches) { } } ``` - +### Errors +When handling errors, utilize the `BeansError` system in favor of other error instances. +Any necessary external errors should be nested inside of a new `BeansError`. +```rs +#[error("Failed to serialize provided AppVarData to JSON. ({error:})")] +AppVarDataSerializeFailure +{ + error: serde_json::Error, // <-- Outside Error + data: AppVarData +}, +#[error("Failed to read file attributes on {location} ({error:})")] +ReadFileAttributesError +{ + error: std::io::Error, + location: String, + backtrace: Backtrace +}, +#[error("Failed to open VPK file at {location} ({error:})")] +VpkOpenFailure +{ + location: String, + error: anyhow::Error, + backtrace: Backtrace +} +``` **Do not make any PRs to remove the embedded executables in favor of downloading.** Some users would like to use this application offline, or they may have unreliable internet. diff --git a/Cargo.toml b/Cargo.toml index 2910149..63f5a0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "beans-rs" -version = "1.7.3" +version = "1.7.4" edition = "2024" authors = [ "Kate Ward ", - "ToastXC " + "ToastXC ", + "Sour Dani ", + "dead_thing (https://github.com/rawra89)" ] description = "Open-Source Installer for Sourcemods" repository = "https://github.com/ktwrd/beans-rs" @@ -40,6 +42,8 @@ fltk = { version = "1.5.21" } fltk-theme = "0.7.9" dark-light = "2.0.0" image = { version = "0.25.8", features = ["png"] } +valve_pak = "0.1.0" +anyhow = "1.0.100" [build-dependencies] fl2rust = "0.7.1" diff --git a/docs/server-integration.md b/docs/server-integration.md index 443b410..783309a 100644 --- a/docs/server-integration.md +++ b/docs/server-integration.md @@ -15,7 +15,8 @@ In the `src` folder there is a file called `appvar.json` which determines the cu }, "remote": { "base_url": "https://of-proxy.kate.pet/", - "versions_url": "https://of-proxy.kate.pet/versions.json" + "versions_url": "https://of-proxy.kate.pet/versions.json", + "filemap_url": "" } } ``` @@ -25,6 +26,7 @@ In the `src` folder there is a file called `appvar.json` which determines the cu - `name_stylized`: The full name of the mod. - `base_url`: The file server address where the `beans-rs` related files will be stored. **Do not forget to include `/` at the end of the link.** - `versions_url`: The file server address where the `versions.json` file will be stored. +- `filemap_url`: The file server address where the `filemap.json` file will be stored. ## beans-rs @@ -96,6 +98,41 @@ The `versions` section is the data related to downloadable versions. `beans-rs` In the `patches` section, there is only a `url`, `file` and `tempreq` entry. The `.pwr` file is a diff generated by [butler](https://itch.io/docs/butler) and is used to upgrade the user to the latest version. As of `beans-rs` version 1.7.3, the `url` component is not yet optional and if the mod is not available via torrent, the `url` component should contain the same information as `file`. +### File Mapping Variables + +In `filemap.json` there are variables relating to migrating to the `.adastral` version system from a different version system. + +- `version_file`: This is the location for the file that your sourcemod uses to determine its current version. +- `pack_file`: This is the optional location of the pack file (e.g. `.vpk`) if your version file is stored inside of a pack file. +- `versions`: This dictionary stores your custom versions as the `key` and the `value` as the equivalent `.adastral` version number (e.g. `"version=0.7.4": "74"`). There is no maximum or minimum amount of version entries but any entries given will be converted when `beans-rs` successfully finds a remote `filemap.json`. + +### File Map Formatting + +Here is an example of how to format a `filemap.json` file. + +This example will be based off the [filemap.json](https://dl.prefortress.com/beans/filemap.json) file hosted by [Pre-Fortress 2](https://prefortress.com/). + +```json +{ + "files": { + "version_file": "version.txt", + "pack_file": "pf2_misc_dir.vpk" + }, + "versions": { + "version=0.7.4": "74", + "version=0.7.3": "73", + "version=0.7.2": "72", + "version=0.7.1": "71", + "version=0.7": "70", + "version=0.6 OPEN BETA": "60" + } +} +``` + +The `files` dictionary must include both an entry for `version_file` and `pack_file`, but the `pack_file` entry can be left empty as it is optional. + +For every entry in `versions`, the `key` must be the exact the content of your mods version file. In the example above, `version.txt` was used, but there is no set filename or content style. When `beans-rs` detects as valid version file it will convert it to a `.adastral` file with the appropriate version number. Keep in mind the Adastral version system is numeric only and cannot contain a zero at the front. For instance `0.7.4` would be converted to `74` and not `074`. + ## butler Install butler based off the instructions on the [itch.io docs](https://itch.io/docs/butler/installing.html). diff --git a/src/appvar.json b/src/appvar.json index daef045..57d00b7 100644 --- a/src/appvar.json +++ b/src/appvar.json @@ -1,11 +1,12 @@ { - "mod": { - "sm_name": "open_fortress", - "short_name": "of", - "name_stylized": "Open Fortress" - }, - "remote": { - "base_url": "https://of-proxy.kate.pet/", - "versions_url": "https://of-proxy.kate.pet/versions.json" - } -} \ No newline at end of file + "mod": { + "sm_name": "open_fortress", + "short_name": "of", + "name_stylized": "Open Fortress" + }, + "remote": { + "base_url": "https://of-proxy.kate.pet/", + "versions_url": "https://of-proxy.kate.pet/versions.json", + "filemap_url": "" + } +} diff --git a/src/appvar.rs b/src/appvar.rs index b3697ee..d9d199a 100644 --- a/src/appvar.rs +++ b/src/appvar.rs @@ -59,6 +59,7 @@ impl AppVarData .replace("$MOD_NAME", &self.mod_info.sourcemod_name) .replace("$URL_BASE", &self.remote_info.base_url) .replace("$URL_VERSIONS", &self.remote_info.versions_url) + .replace("$URL_FILEMAP", &self.remote_info.filemap_url) } /// Try and read the data from `AVD_INSTANCE` and return when some. @@ -174,5 +175,8 @@ pub struct AppVarRemote pub base_url: String, /// url where the version details are stored. /// e.g; `https://beans.adastral.net/versions.json` - pub versions_url: String + pub versions_url: String, + /// optional: url where the file mapping details are stored for upgrading + /// game versions from previous systems. e.g; `https://beans.adastral.net/filemap.json` + pub filemap_url: String } diff --git a/src/ctx.rs b/src/ctx.rs index 3398488..6414f7a 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -75,7 +75,8 @@ impl RunnerContext Ok(Self { sourcemod_path: parse_location(sourcemod_path.clone()), remote_version_list: version_list, - current_version: crate::version::get_current_version(Some(sourcemod_path.clone())), + current_version: crate::version::get_current_version(Some(sourcemod_path.clone())) + .await, appvar: AppVarData::get() }) } @@ -280,7 +281,6 @@ impl RunnerContext } /// Extract zstd_location to the detected sourcemods directory. - /// TODO replace unwrap/expect with match error handling pub fn extract_package( zstd_location: String, out_dir: String diff --git a/src/error.rs b/src/error.rs index 7a07642..00a0eeb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -267,6 +267,36 @@ pub enum BeansError hresult_msg: String, location: String, backtrace: Backtrace + }, + + #[error("Failed to open VPK file at {location} ({error:})")] + VpkOpenFailure + { + location: String, + error: anyhow::Error, + backtrace: Backtrace + }, + + #[error("Failed to read VPK file contents at {location}. ({error:})")] + VpkReadFailure + { + location: String, + error: anyhow::Error, + backtrace: Backtrace + }, + + #[error("Failed to read packed file inside of VPK file at {location}. ({error:})")] + VpkInternalFileReadFailure + { + location: String, + error: std::io::Error, + backtrace: Backtrace + }, + + #[error("Failed to find local version '{expected}' in remote filemap.")] + RemoteFileMapLocalVersionNotFound + { + expected: String } } #[derive(Debug)] diff --git a/src/helper/mod.rs b/src/helper/mod.rs index 9fb00a2..50a376c 100644 --- a/src/helper/mod.rs +++ b/src/helper/mod.rs @@ -165,6 +165,12 @@ pub fn dir_exists(location: String) -> bool file_exists(location.clone()) && is_directory(location.clone()) } +/// check if a path location exists +pub fn path_exists(path: String) -> bool +{ + std::path::Path::new(&path).exists() +} + pub fn is_directory(location: String) -> bool { let x = PathBuf::from(&location); diff --git a/src/helper/windows.rs b/src/helper/windows.rs index 38ccd23..0ca983a 100644 --- a/src/helper/windows.rs +++ b/src/helper/windows.rs @@ -13,7 +13,6 @@ use winreg::{RegKey, use crate::{BeansError, helper::format_directory_path}; -/// TODO use windows registry to get the SourceModInstallPath /// HKEY_CURRENT_USER\Software\Value\Steam /// Key: SourceModInstallPath pub fn find_sourcemod_path() -> Result diff --git a/src/lib.rs b/src/lib.rs index 1d78ce9..09cf39b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -225,10 +225,12 @@ pub fn get_user_agent() -> String pub fn staging_dir() -> String { let av = AppVarData::get(); - #[cfg(not(target_os = "windows"))] { + #[cfg(not(target_os = "windows"))] + { format!("/butler-staging-{}", av.mod_info.short_name) } - #[cfg(target_os = "windows")] { + #[cfg(target_os = "windows")] + { format!("\\butler-staging-{}", av.mod_info.short_name) } } diff --git a/src/version.rs b/src/version.rs index 1c06981..a9d1121 100644 --- a/src/version.rs +++ b/src/version.rs @@ -1,42 +1,127 @@ use std::{backtrace::Backtrace, collections::HashMap, - fs::{read_to_string, - File}, + fs::{File, + read_to_string}, io::{BufWriter, + Read, Write}}; use log::{debug, error, trace}; +use serde_json::{self}; +use valve_pak::{VPK, + VPKFile}; use crate::{BeansError, appvar::AppVarData, - helper, - helper::{InstallType, + helper::{self, + InstallType, find_sourcemod_path}}; /// get the current version installed via the .adastral file in the sourcemod /// mod folder. will parse the value of `version` as usize. -pub fn get_current_version(sourcemods_location: Option) -> Option +pub async fn get_current_version(sourcemods_location: Option) -> Option { + // TODO change function to return a BeansError let install_state = helper::install_state(sourcemods_location.clone()); - if install_state != InstallType::Adastral + + if install_state == InstallType::NotInstalled { return None; } - match get_mod_location(sourcemods_location) + + if install_state != InstallType::Adastral + { + // generate an .adastral file at this location + let data: AdastralVersionFile = match generate_version_file(sourcemods_location.clone()?) + .await + { + Ok(v) => v, + Err(e) => + { + trace!("{:#?}", e); + sentry::capture_error(&e); + panic!( + "[WizardContext::run] Failed to run version::generate_version_file. {:#?}", + e + ); + } + }; + let parsed = match data.version.parse::() + { + Ok(v) => v, + Err(e) => + { + let ex = BeansError::VersionFileParseFailure { + error: e, + old_location: sourcemods_location.clone()?, + old_content: data.version + }; + debug!("{:#?}", ex); + sentry::capture_error(&ex); + panic!( + "[version::get_current_version] Failed to get generated version file's usize. {:#?}", + ex + ); + } + }; + return Some(parsed); + } + match get_mod_location(sourcemods_location.clone()) { Some(smp_x) => { - // TODO generate BeansError instead of using panic let location = format!("{}.adastral", smp_x); - let content = - read_to_string(&location).unwrap_or_else(|_| panic!("Failed to open {}", location)); - let data: AdastralVersionFile = serde_json::from_str(&content) - .unwrap_or_else(|_| panic!("Failed to deserialize data at {}", location)); - let parsed = data.version.parse::().unwrap_or_else(|_| { - panic!("Failed to convert version to usize! ({})", data.version) - }); + let content = match read_to_string(&location) + { + Ok(v) => v, + Err(e) => + { + let ex = BeansError::VersionFileReadFailure { + error: e, + location: location.clone() + }; + debug!("{:#?}", ex); + sentry::capture_error(&ex); + panic!("Failed to open {} {:#?}", location, ex); + } + }; + let data: AdastralVersionFile = match serde_json::from_str(&content) + { + Ok(v) => v, + Err(e) => + { + let ex = BeansError::SerdeJson { + error: e, + backtrace: Backtrace::capture() + }; + debug!("{:#?}", ex); + sentry::capture_error(&ex); + panic!( + "[version::get_current_version] Failed to deserialize data at {} {:#?}", + location, ex + ) + } + }; + let parsed = match data.version.parse::() + { + Ok(v) => v, + Err(e) => + { + let ex = BeansError::VersionFileParseFailure { + error: e, + old_location: location.clone(), + old_content: data.version.clone() + }; + debug!("{:#?}", ex); + sentry::capture_error(&ex); + panic!( + "[version::get_current_version] Failed to convert version to usize! ({}) {:#?}", + data.version, ex + ) + } + }; Some(parsed) } @@ -44,6 +129,205 @@ pub fn get_current_version(sourcemods_location: Option) -> Option } } +/// Read a version file from either version file in mod folder or in specified +/// pack file. +async fn read_mod_version_file( + sourcemods_location: &str, + files: &RemoteFiles +) -> Result +{ + let mod_path = match get_mod_location(Some(sourcemods_location.to_owned())) + { + Some(x) => x, + None => return Err(BeansError::SourceModLocationNotFound) + }; + + // get the filename and pak directory from the remote json file + let mod_version_full_path = mod_path.clone() + &files.version_file; + let mod_pak_full_path = mod_path.clone() + &files.pack_file; + + // Check regular sourcemod directory + if helper::path_exists(mod_version_full_path.clone()) + { + let mut mod_version_file = File::open(mod_version_full_path.clone())?; + let version_content = &mut String::new(); + match mod_version_file.read_to_string(version_content) + { + Ok(v) => v, + Err(e) => + { + error!( + "[version::read_mod_verion_file] Failed to read {}. {:}", + mod_version_full_path.clone(), + e + ); + debug!("{:#?}", e); + return Err(BeansError::FileOpenFailure { + location: files.version_file.clone(), + error: e + }); + } + }; + + return Ok(version_content.trim().to_owned().clone()); + } + // else check inside vpk + else if helper::path_exists(mod_pak_full_path.clone()) + { + let mod_pack_file: VPK = match VPK::open(mod_pak_full_path.clone()) + { + Ok(v) => v, + Err(e) => + { + error!("[version::read_mod_version_file] VPK not found. {:}", e); + debug!("{:#?}", e); + return Err(BeansError::VpkOpenFailure { + location: files.version_file.clone(), + error: e, + backtrace: Backtrace::capture() + }); + } + }; + let mut mod_pack_file_in_vpk: VPKFile = match mod_pack_file + .get_file(files.version_file.as_str()) + { + Ok(v) => v, + Err(e) => + { + error!( + "[version::read_mod_version_file] {} not found in {}. {:}", + files.version_file, files.pack_file, e + ); + debug!("{:#?}", e); + return Err(BeansError::VpkReadFailure { + location: files.version_file.clone(), + error: e, + backtrace: Backtrace::capture() + }); + } + }; + let pak_version_content = &mut String::new(); + match mod_pack_file_in_vpk.read_to_string(pak_version_content) + { + Ok(v) => v, + Err(e) => + { + error!( + "[version::read_mod_version_file] Failed to open {}. {:}", + files.version_file, e + ); + debug!("{:#?}", e); + return Err(BeansError::VpkInternalFileReadFailure { + location: files.version_file.clone(), + error: e, + backtrace: Backtrace::capture() + }); + } + }; + return Ok(pak_version_content.trim().to_owned().clone()); + } + + // error out if we can't find the vpk file (last file we checked for) + Err(BeansError::FileNotFound { + location: mod_path.clone() + &files.pack_file, + backtrace: Backtrace::capture() + }) +} + +/// generate an .adastral file if the game was installed through other methods +/// based on the build's version file +async fn generate_version_file( + sourcemods_location: String +) -> Result +{ + let file_map_list = match get_file_map().await + { + Ok(v) => v, + Err(e) => + { + error!( + "[WizardContext::run] Failed to run version::get_file_map() {:#?}", + e + ); + trace!("{:#?}", e); + sentry::capture_error(&e); + return Err(e); + } + }; + + let mod_version_file_content = + match read_mod_version_file(sourcemods_location.as_str(), &file_map_list.files).await + { + Ok(v) => v, + Err(e) => + { + error!( + "[version::read_mod_version_file] Failed to read mod version file. {:#?}", + e + ); + trace!("{:#?}", e); + sentry::capture_error(&e); + return Err(e); + } + }; + // create a(n?) .adastral file with the version translation defined in the json + // I think it's an? it is "ah"-dastral.. right? -Dani + let mut adastral_value = String::new(); + + for (mod_version, adastral_version) in file_map_list.versions.iter() + { + if mod_version == &mod_version_file_content + { + adastral_value = String::from(adastral_version); + break; + } + } + + if adastral_value.is_empty() + { + let ex = BeansError::RemoteFileMapLocalVersionNotFound { + expected: mod_version_file_content.clone() + }; + debug!("{:#?}", ex); + error!( + "[version::generate_version_file] Local version not found in remote filemap. Remote FileMap potentially outdated. {:#?}", + ex + ); + sentry::capture_error(&ex); + return Err(ex); + } + + let mod_version_translation = AdastralVersionFile { + // If this blows something up, my bad -Dani + // Things no longer blow up :) -Dani + version: adastral_value.clone() + }; + match mod_version_translation.write(Some(sourcemods_location.clone())) + { + Ok(v) => v, + Err(e) => + { + debug!("{:#?}", e); + error!( + "[version::generate_version_file] Failed to set version to {} in .adastral {:#?}", + adastral_value.clone(), + e + ); + sentry::capture_error(&e); + return Err(e); + } + } + + let mod_path = match get_mod_location(Some(sourcemods_location.clone())) + { + Some(x) => x, + None => return Err(BeansError::SourceModLocationNotFound) + }; + + log::info!("Generated .adastral file at location {}", mod_path); + + Ok(mod_version_translation) +} /// set the version in the `.adastral` file in the sourcemod folder. /// will silently fail when install_state is not InstallType::Adastral, or the /// sourcemod isn't installed. @@ -196,7 +480,7 @@ pub fn update_version_file(sourcemods_location: Option) -> Result<(), Be Err(e) => { debug!( - "[update_version_file] failed to read {}. {:#?}", + "[version::update_version_file] failed to read {}. {:#?}", old_version_file_location, e ); sentry::capture_error(&e); @@ -212,7 +496,7 @@ pub fn update_version_file(sourcemods_location: Option) -> Result<(), Be Err(e) => { debug!( - "[update_version_file] Failed to parse content {} caused error {:}", + "[version::update_version_file] Failed to parse content {} caused error {:}", old_version_file_content, e ); sentry::capture_error(&e); @@ -294,6 +578,33 @@ pub async fn get_version_list() -> Result Ok(data) } +/// fetch the file map list from `{crate::SOURCE_URL}filemap.json` +pub async fn get_file_map() -> Result +{ + let av = AppVarData::get(); + let response = match reqwest::get(&av.remote_info.filemap_url).await + { + Ok(v) => v, + Err(e) => + { + error!( + "[version::get_file_map] Failed to get available versions! {:}", + e + ); + sentry::capture_error(&e); + return Err(BeansError::Reqwest { + error: e, + backtrace: Backtrace::capture() + }); + } + }; + let response_text = response.text().await?; + trace!("[version::get_file_map] response text: {}", response_text); + + let data: RemoteFileMapResponse = serde_json::from_str(&response_text)?; + Ok(data) +} + /// Version file that is used as `.adastral` in the sourcemod mod folder. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AdastralVersionFile @@ -375,3 +686,18 @@ pub struct RemotePatch /// in bytes. pub tempreq: usize } + +/// `filemap.json` response content from remote server. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RemoteFileMapResponse +{ + pub files: RemoteFiles, + pub versions: HashMap +} +/// Value of the `files` property in `RemoteFileMapResponse` +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RemoteFiles +{ + pub version_file: String, + pub pack_file: String +} diff --git a/src/wizard.rs b/src/wizard.rs index 53e0814..aba81e9 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -76,7 +76,7 @@ impl WizardContext let ctx = RunnerContext { sourcemod_path: sourcemod_path.clone(), remote_version_list: version_list, - current_version: crate::version::get_current_version(Some(sourcemod_path)), + current_version: crate::version::get_current_version(Some(sourcemod_path)).await, appvar: AppVarData::get() }; diff --git a/src/workflows/clean.rs b/src/workflows/clean.rs index 2878a95..f473a52 100644 --- a/src/workflows/clean.rs +++ b/src/workflows/clean.rs @@ -47,11 +47,13 @@ impl CleanWorkflow }); } - // clean up butler files if it was interrupted in previous install + // clean up butler files if it was interrupted in previous install if !helper::file_exists(staging_dir_location.clone()) { - debug!("[CleanWorkflow] Staging directory used by butler not found, nothing to clean.") - } else { + debug!("[CleanWorkflow] Staging directory used by butler not found, nothing to clean.") + } + else + { // delete temp butler directory and it's contents (and error handling) info!("[CleanWorkflow] Cleaning up {}", staging_dir_location); if let Err(e) = std::fs::remove_dir_all(&staging_dir_location) @@ -64,7 +66,7 @@ impl CleanWorkflow }); } } - + info!("[CleanWorkflow] Done!"); Ok(()) }