diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index ed79325..cfc0063 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -15,12 +15,35 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + - os: ubuntu-20.04 filename: 'beans-rs' + target: x86_64-unknown-linux-musl - os: windows-latest + target: x86_64-pc-windows-msvc filename: 'beans-rs.exe' runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - name: Build - run: cargo build --verbose \ No newline at end of file + + - name: Install Build Dependencies (ubuntu) + if: ${{ matrix.os == 'ubuntu-20.04' }} + run: | + sudo apt-get update; + sudo apt-get install -y \ + libssl-dev \ + musl-tools + + - uses: actions-rs/toolchain@v1 + with: + toolchain: nightly-2024-05-24 + target: ${{ matrix.target }} + + - uses: actions-rs/cargo@v1 + with: + command: build + args: --verbose --all-features --target ${{ matrix.target }} + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: binary-${{ matrix.os }}-${{ matrix.target }} + path: target/${{ matrix.target }}/debug/${{ matrix.filename }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62508a3..c5165a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,23 +15,39 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + - os: ubuntu-20.04 filename: 'beans-rs' + target: x86_64-unknown-linux-musl + - os: windows-latest + target: x86_64-pc-windows-msvc filename: 'beans-rs.exe' + steps: - uses: actions/checkout@master + + - name: Install Build Dependencies (ubuntu) + if: ${{ matrix.os == 'ubuntu-20.04' }} + run: | + sudo apt-get update; + sudo apt-get install -y \ + libssl-dev \ + musl-tools + - uses: actions-rs/toolchain@v1 with: - toolchain: stable + toolchain: nightly-2024-05-24 + target: ${{ matrix.target }} + - uses: actions-rs/cargo@v1 with: command: build - args: --release --all-features + args: --release --all-features --target ${{ matrix.target }} + - name: Upload binaries to release uses: softprops/action-gh-release@v1 with: - files: target/release/${{ matrix.filename }} + files: target/${{ matrix.target }}/release/${{ matrix.filename }} tag_name: ${{ github.event.inputs.tag }} draft: false prerelease: true diff --git a/Cargo.toml b/Cargo.toml index 4b09bf0..f25b284 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "beans-rs" -version = "1.4.5" +version = "1.5.1" edition = "2021" authors = [ "Kate Ward " @@ -30,7 +30,6 @@ clap = { version = "4.5.4", features = ["cargo"] } bitflags = "2.5.0" log = "0.4.21" native-dialog = "0.7.0" -sentry = "0.34.0" lazy_static = "1.4.0" thread-id = "4.2.1" colored = "2.1.0" @@ -42,6 +41,20 @@ winconsole = { version = "0.11.1", features = ["window"] } winreg = "0.52.0" dunce = "1.0.4" +[dependencies.sentry] +version = "0.34.0" +default-features = false +features = [ + "backtrace", + "contexts", + "debug-images", + "panic", + + "reqwest", + "rustls" +] + + [dependencies.tokio] version = "1.37.0" features = [ @@ -54,8 +67,14 @@ version = "0.12.4" features = [ "multipart", "stream", - "json" + "json", + + "rustls-tls", + "charset", + "http2", + "macos-system-configuration" ] +default-features = false [target.'cfg(target_os = "windows")'.build-dependencies] winres = "0.1.12" diff --git a/README.md b/README.md index 6524010..e5c8963 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ This is a complete rewrite of the original [beans](https://github.com/int-72h/of `beans-rs` is licensed under `GPLv3-only`, so please respect it! +**Note** Releases for Linux v1.5.0 and later are built with Ubuntu 20.04 (using `libssl v1.1`) + ## Developing Requirements - Rust Toolchain (nightly, only for building) diff --git a/src/error.rs b/src/error.rs index 85f0e38..f67ecf2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,6 +26,11 @@ pub enum BeansError location: String, error: std::io::Error }, + #[error("Failed to create directory {location} ({error:})")] + DirectoryCreateFailure { + location: String, + error: std::io::Error + }, #[error("Failed to extract {src_file} to directory {target_dir} ({error:})")] TarExtractFailure { src_file: String, @@ -154,6 +159,12 @@ pub enum BeansError #[error("Failed to backup gameinfo.txt, {reason:}")] GameinfoBackupFailure { reason: GameinfoBackupFailureReason + }, + + #[error("Failed to remove files in {location} ({error:})")] + CleanTempFailure { + location: String, + error: std::io::Error } } #[derive(Debug)] diff --git a/src/helper/mod.rs b/src/helper/mod.rs index 6793184..421b659 100644 --- a/src/helper/mod.rs +++ b/src/helper/mod.rs @@ -20,6 +20,7 @@ use crate::{BeansError, DownloadFailureReason, GameinfoBackupCreateDirectoryFail use rand::{distributions::Alphanumeric, Rng}; use reqwest::header::USER_AGENT; use crate::appvar::AppVarData; +use std::collections::HashMap; #[derive(Clone, Debug)] pub enum InstallType @@ -127,6 +128,11 @@ pub fn file_exists(location: String) -> bool { std::path::Path::new(&location).exists() } +/// Check if the location provided exists and it's a directory. +pub fn dir_exists(location: String) -> bool +{ + file_exists(location.clone()) && is_directory(location.clone()) +} pub fn is_directory(location: String) -> bool { let x = PathBuf::from(&location); @@ -164,6 +170,16 @@ pub fn join_path(tail: String, head: String) -> String format!("{}{}", format_directory_path(tail), h) } +pub fn remove_path_head(location: String) -> String +{ + let p = std::path::Path::new(&location); + if let Some(x) = p.parent() { + if let Some(m) = x.to_str() { + return m.to_string(); + } + } + return String::new(); +} /// Make sure that the location provided is formatted as a directory (ends with `crate::PATH_SEP`). pub fn format_directory_path(location: String) -> String { @@ -226,17 +242,25 @@ pub fn parse_location(location: String) -> String /// Get the amount of free space on the drive in the location provided. pub fn get_free_space(location: String) -> Result { - let real_location = parse_location(location); + let mut data: HashMap = HashMap::new(); for disk in sysinfo::Disks::new_with_refreshed_list().list() { if let Some(mp) = disk.mount_point().to_str() { - if real_location.clone().starts_with(&mp) { - return Ok(disk.available_space()) - } + debug!("[get_free_space] space: {} {}", mp, disk.available_space()); + data.insert(mp.to_string(), disk.available_space()); + } + } + + let mut l = parse_location(location.clone()); + while l.len() >= 2 { + debug!("[get_free_space] Checking if {} is in data", l); + if let Some(x) = data.get(&l) { + return Ok(x.clone()); } + l = remove_path_head(l.clone()); } Err(BeansError::FreeSpaceCheckFailure { - location: real_location + location: parse_location(location.clone()) }) } /// Check if the location provided has enough free space. @@ -356,17 +380,42 @@ pub fn format_size(i: usize) -> String { pub fn get_tmp_dir() -> String { let mut dir = std::env::temp_dir().to_str().unwrap_or("").to_string(); - if cfg!(target_os = "android") { + if is_steamdeck() { + trace!("[helper::get_tmp_dir] Detected that we are running on a steam deck. Using ~/.tmp/beans-rs"); + match simple_home_dir::home_dir() { + Some(v) => { + match v.to_str() { + Some(k) => { + dir = format_directory_path(k.to_string()); + dir = join_path(dir, String::from(".tmp")); + }, + None => { + trace!("[helper::get_tmp_dir] Failed to convert PathBuf to &str"); + } + } + }, + None => { + trace!("[helper::get_tmp_dir] Failed to get home directory."); + } + }; + } else if cfg!(target_os = "android") { dir = String::from("/data/var/tmp"); } else if cfg!(not(target_os = "windows")) { dir = String::from("/var/tmp"); } dir = format_directory_path(dir); + if !dir_exists(dir.clone()) { + if let Err(e) = std::fs::create_dir(&dir) { + trace!("[helper::get_tmp_dir] {:#?}", e); + warn!("[helper::get_tmp_dir] failed to make tmp directory at {} ({:})", dir, e); + } + } dir = join_path(dir, String::from("beans-rs")); dir = format_directory_path(dir); - if !file_exists(dir.clone()) { + if !dir_exists(dir.clone()) { if let Err(e) = std::fs::create_dir(&dir) { + trace!("[helper::get_tmp_dir] {:#?}", e); warn!("[helper::get_tmp_dir] failed to make tmp directory at {} ({:})", dir, e); sentry::capture_error(&e); } else { @@ -376,6 +425,52 @@ pub fn get_tmp_dir() -> String return dir; } +/// Check if the content of `uname -r` contains `valve` (Linux Only) +/// +/// ## Returns +/// - `true` when; +/// - The output of `uname -r` contains `valve` +/// - `false` when; +/// - `target_os` is not `linux` +/// - Failed to run `uname -r` +/// - Failed to parse the stdout of `uname -r` as a String. +/// +/// ## Note +/// Will always return `false` when `cfg!(not(target_os = "linux"))`. +/// +/// This function will write to `log::trace` with the full error details before writing it to `log::warn` or `log::error`. Since errors from this +/// aren't significant, `sentry::capture_error` will not be called. +pub fn is_steamdeck() -> bool { + if cfg!(not(target_os = "linux")) { + return false; + } + + match std::process::Command::new("uname").arg("-r").output() { + Ok(cmd) => { + trace!("[helper::is_steamdeck] exit status: {}", &cmd.status); + let stdout = &cmd.stdout.to_vec(); + let stderr = &cmd.stderr.to_vec(); + if let Ok(x) = String::from_utf8(stderr.clone()) { + trace!("[helper::is_steamdeck] stderr: {}", x); + } + match String::from_utf8(stdout.clone()) { + Ok(x) => { + trace!("[helper::is_steamdeck] stdout: {}", x); + x.contains("valve") + }, + Err(e) => { + trace!("[helper::is_steamdeck] Failed to parse as utf8 {:#?}", e); + false + } + } + }, + Err(e) => { + trace!("[helper::is_steamdeck] {:#?}", e); + warn!("[helper::is_steamdeck] Failed to detect {:}", e); + return false; + } + } +} /// Generate a full file location for a temporary file. pub fn get_tmp_file(filename: String) -> String { diff --git a/src/lib.rs b/src/lib.rs index 1fd11c0..77e85ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,8 @@ pub const PANIC_MSG_CONTENT: &str = include_str!("text/msgbox_panic_text.txt"); /// /// just like the `pause` thing in batch. pub static mut PAUSE_ONCE_DONE: bool = false; +/// When `true`, everything that prompts the user for Y/N should use the default option. +pub static mut PROMPT_DO_WHATEVER: bool = false; // ------------------------------------------------------------------------ diff --git a/src/main.rs b/src/main.rs index d609842..45ef310 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use beans_rs::{flags, helper, PANIC_MSG_CONTENT, RunnerContext, wizard}; use beans_rs::flags::LaunchFlag; use beans_rs::helper::parse_location; use beans_rs::SourceModDirectoryParam; -use beans_rs::workflows::{InstallWorkflow, UpdateWorkflow, VerifyWorkflow}; +use beans_rs::workflows::{CleanWorkflow, InstallWorkflow, UpdateWorkflow, VerifyWorkflow}; pub const DEFAULT_LOG_LEVEL_RELEASE: LevelFilter = LevelFilter::Info; #[cfg(debug_assertions)] @@ -130,6 +130,14 @@ impl Launcher .help("Manually specify sourcemods directory. When not provided, beans-rs will automatically detect the sourcemods directory.") .required(false) } + fn create_confirm_arg() -> Arg + { + Arg::new("confirm") + .long("confirm") + .help("When prompted to do something (as a multi-choice option), the default option will be automatically chosen when this switch is provided, and there is a default multi-choice option available.") + .required(false) + .action(ArgAction::SetTrue) + } pub async fn run() { let cmd = Command::new("beans-rs") @@ -149,13 +157,16 @@ impl Launcher Arg::new("target-version") .long("target-version") .help("Specify the version to install. Ignored when [--from] is used.") - .required(false)])) + .required(false), + Self::create_confirm_arg()])) .subcommand(Command::new("verify") .about("Verify your current installation") .arg(Launcher::create_location_arg())) .subcommand(Command::new("update") .about("Update your installation") .arg(Launcher::create_location_arg())) + .subcommand(Command::new("clean-tmp") + .about("Clean up temporary files used by beans")) .args([ Arg::new("debug") .long("debug") @@ -169,7 +180,8 @@ impl Launcher .long("no-pause") .help("When provided, beans-rs will not wait for user input before exiting. It is suggested that server owners use this for any of their scripts.") .action(ArgAction::SetTrue), - Launcher::create_location_arg() + Self::create_location_arg(), + Self::create_confirm_arg() ]); let mut i = Self::new(&cmd.get_matches()); @@ -188,6 +200,7 @@ impl Launcher }; i.set_debug(); i.set_no_pause(); + i.set_prompt_do_whatever(); i.to_location = Launcher::find_arg_sourcemods_location(&i.root_matches); return i; @@ -243,12 +256,24 @@ impl Launcher self.to_location = Launcher::find_arg_sourcemods_location(wz_matches); self.task_wizard().await; }, + Some(("clean-tmp", _)) => { + self.task_clean_tmp().await; + }, _ => { self.task_wizard().await; } } } + pub fn set_prompt_do_whatever(&mut self) + { + if self.root_matches.get_flag("confirm") { + unsafe { + beans_rs::PROMPT_DO_WHATEVER = true; + } + } + } + /// Try and get `SourceModDirectoryParam`. /// Returns SourceModDirectoryParam::default() when `to_location` is `None`. fn try_get_smdp(&mut self) -> SourceModDirectoryParam @@ -279,6 +304,12 @@ impl Launcher pub async fn task_install(&mut self, matches: &ArgMatches) { self.to_location = Launcher::find_arg_sourcemods_location(&matches); + if matches.get_flag("confirm") { + unsafe { + beans_rs::PROMPT_DO_WHATEVER = true; + } + } + let mut ctx = self.try_create_context().await; // call install_version when target-version is found. @@ -371,6 +402,20 @@ impl Launcher } } + /// Handler for the `clean-tmp` subcommand. + /// + /// NOTE this function uses `panic!` when `CleanWorkflow::wizard` fails. panics are handled + /// and are reported via sentry. + pub async fn task_clean_tmp(&mut self) + { + let mut ctx = self.try_create_context().await; + if let Err(e) = CleanWorkflow::wizard(&mut ctx) { + panic!("Failed to run CleanWorkflow {:#?}", e); + } else { + logic_done(); + } + } + /// try and create an instance of `RunnerContext` via the `create_auto` method while setting /// the `sml_via` parameter to the output of `self.try_get_smdp()` /// diff --git a/src/wizard.rs b/src/wizard.rs index 2f5ba01..aa2a162 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -4,7 +4,7 @@ use async_recursion::async_recursion; use log::{debug, error, info, trace}; use std::backtrace::Backtrace; use crate::flags::LaunchFlag; -use crate::workflows::{InstallWorkflow, UpdateWorkflow, VerifyWorkflow}; +use crate::workflows::{CleanWorkflow, InstallWorkflow, UpdateWorkflow, VerifyWorkflow}; #[derive(Debug, Clone)] pub struct WizardContext @@ -82,14 +82,18 @@ impl WizardContext println!("1 - Install or reinstall the game"); println!("2 - Check for and apply any available updates"); println!("3 - Verify and repair game files"); + println!("c - Clean up temporary files used by beans."); println!(); println!("q - Quit"); let user_input = helper::get_input("-- Enter option below --"); match user_input.to_lowercase().as_str() { - "1" => WizardContext::menu_error_catch(self.task_install().await), - "2" => WizardContext::menu_error_catch(self.task_update().await), - "3" => WizardContext::menu_error_catch(self.task_verify().await), - "d" => { + "1" | "install" => WizardContext::menu_error_catch(self.task_install().await), + "2" | "update" => WizardContext::menu_error_catch(self.task_update().await), + "3" | "verify" => WizardContext::menu_error_catch(self.task_verify().await), + "c" | "clean" => { + Self::menu_error_catch(CleanWorkflow::wizard(&mut self.context)) + }, + "d" | "debug" => { flags::add_flag(LaunchFlag::DEBUG_MODE); info!("Debug mode enabled!"); self.menu().await; diff --git a/src/workflows/clean.rs b/src/workflows/clean.rs index dda4479..3d2553c 100644 --- a/src/workflows/clean.rs +++ b/src/workflows/clean.rs @@ -1,12 +1,38 @@ -use crate::{BeansError, RunnerContext}; +use log::{info, warn}; +use crate::{BeansError, helper, RunnerContext}; #[derive(Debug, Clone)] pub struct CleanWorkflow { pub context: RunnerContext } impl CleanWorkflow { - pub async fn wizard(_ctx: &mut RunnerContext) -> Result<(), BeansError> + pub fn wizard(_ctx: &mut RunnerContext) -> Result<(), BeansError> { - todo!("please implement. clean action deletes temporary files") + let target_directory = helper::get_tmp_dir(); + + info!("[CleanWorkflow] Cleaning up {}", target_directory); + if helper::file_exists(target_directory.clone()) == false { + warn!("[CleanWorkflow] Temporary directory not found, nothing to clean.") + } + + + // delete directory and it's contents (and error handling) + if let Err(e) = std::fs::remove_dir_all(&target_directory) { + return Err(BeansError::CleanTempFailure { + location: target_directory, + error: e + }); + } + + // re-creating the temporary directory (and error handling) + if let Err(e) = std::fs::create_dir(&target_directory) { + return Err(BeansError::DirectoryCreateFailure { + location: target_directory, + error: e + }); + } + + info!("[CleanWorkflow] Done!"); + return Ok(()); } } \ No newline at end of file diff --git a/src/workflows/install.rs b/src/workflows/install.rs index b5177d4..adf030b 100644 --- a/src/workflows/install.rs +++ b/src/workflows/install.rs @@ -1,5 +1,6 @@ -use log::{debug, error, warn}; +use log::{debug, error, info, warn}; use crate::{DownloadFailureReason, helper, RunnerContext}; +use crate::appvar::AppVarData; use crate::BeansError; use crate::version::{AdastralVersionFile, RemoteVersion}; @@ -18,6 +19,44 @@ impl InstallWorkflow { Self::install_with_remote_version(ctx, latest_remote_id, latest_remote).await } + /// Prompt the user to confirm if they want to reinstall (when parameter `current_version` is Some) + /// + /// Will always return `true` when `crate::PROMPT_DO_WHATEVER` is `true`. + /// + /// Returns: `true` when the installation should continue, `false` when we should silently abort. + pub fn prompt_confirm(current_version: Option) -> bool + { + unsafe { + if crate::PROMPT_DO_WHATEVER { + info!("[InstallWorkflow::prompt_confirm] skipping since PROMPT_DO_WHATEVER is true"); + return true; + } + } + let av = AppVarData::get(); + if let Some(v) = current_version { + println!("[InstallWorkflow::prompt_confirm] Seems like {} is already installed (v{})", v, av.mod_info.name_stylized); + + println!("Are you sure that you want to reinstall?"); + println!("Yes/Y (default)"); + println!("No/N"); + let user_input = helper::get_input("-- Enter option below --"); + match user_input.to_lowercase().as_str() { + "y" | "yes" | "" => { + true + }, + "n" | "no" => { + false + }, + _ => { + println!("Unknown option \"{}\"", user_input.to_lowercase()); + Self::prompt_confirm(current_version) + } + } + } else { + true + } + } + /// Install the specified version by its ID to the output directory. pub async fn install_version(&mut self, version_id: usize) -> Result<(), BeansError> { @@ -34,9 +73,18 @@ impl InstallWorkflow { InstallWorkflow::install_with_remote_version(&mut ctx, version_id, target_version.clone()).await } + /// Install with a specific remote version. + /// + /// Note: Will call Self::prompt_confirm, so set `crate::PROMPT_DO_WHATEVER` to `true` before you call + /// this function if you don't want to wait for a newline from stdin. pub async fn install_with_remote_version(ctx: &mut RunnerContext, version_id: usize, version: RemoteVersion) -> Result<(), BeansError> { + if Self::prompt_confirm(ctx.current_version) == false { + info!("[InstallWorkflow] Operation aborted by user"); + return Ok(()); + } + println!("{:=>60}\nInstalling version {} to {}\n{0:=>60}", "=", version_id, &ctx.sourcemod_path); let presz_loc = RunnerContext::download_package(version).await?; Self::install_from(presz_loc.clone(), ctx.sourcemod_path.clone(), Some(version_id)).await?;