From 1e8852b54007f8739d94c4593aa369ac512b30fc Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Tue, 12 Sep 2023 09:27:03 -0700 Subject: [PATCH] Bugs again (#703) * initial * more fixes * logs * more fixes * working rescuer * minor log display fix * mac fixes * minor fix * libsselinux1 * linux error * actions test * more bugs. Modpack page! BIG changes * changed minimum 64 -> 8 * removed modpack page moved to modal * removed unnecessary css * mac compile * many revs * Merge colorful logs (#725) * make implementation not dumb * run prettier * null -> true * Add line numbers & make errors more robust. * improvments * changes; virtual scroll --------- Co-authored-by: qtchaos <72168435+qtchaos@users.noreply.github.com> * omorphia colors, comments fix * fixes; _JAVA_OPTIONS * revs * mac specific * more mac * some fixes * quick fix * add java reinstall option --------- Co-authored-by: qtchaos <72168435+qtchaos@users.noreply.github.com> Co-authored-by: Jai A --- .github/ISSUE_TEMPLATE/bug_report.yml | 5 +- .github/workflows/tauri-build.yml | 4 +- Cargo.lock | 88 ++- theseus/Cargo.toml | 3 + theseus/src/api/auth.rs | 46 +- theseus/src/api/jre.rs | 28 +- theseus/src/api/logs.rs | 219 +++++-- theseus/src/api/pack/import/atlauncher.rs | 4 + theseus/src/api/pack/import/mmc.rs | 1 + theseus/src/api/pack/import/mod.rs | 2 +- theseus/src/api/pack/install_from.rs | 41 +- theseus/src/api/pack/install_mrpack.rs | 100 +++- theseus/src/api/process.rs | 35 +- theseus/src/api/profile/create.rs | 61 +- theseus/src/api/profile/mod.rs | 69 ++- theseus/src/api/profile/update.rs | 78 ++- theseus/src/api/settings.rs | 9 + theseus/src/event/mod.rs | 14 +- theseus/src/launcher/mod.rs | 27 +- theseus/src/logger.rs | 4 +- theseus/src/state/children.rs | 549 ++++++++++++------ theseus/src/state/dirs.rs | 2 +- theseus/src/state/discord.rs | 66 ++- theseus/src/state/metadata.rs | 30 + theseus/src/state/mod.rs | 22 +- theseus/src/state/profiles.rs | 30 +- theseus/src/state/settings.rs | 63 +- theseus/src/state/tags.rs | 27 + theseus_gui/package.json | 3 +- theseus_gui/pnpm-lock.yaml | 26 + theseus_gui/src-tauri/App.entitlements | 14 + theseus_gui/src-tauri/src/api/jre.rs | 11 + theseus_gui/src-tauri/src/api/logs.rs | 50 +- theseus_gui/src-tauri/src/api/process.rs | 7 - theseus_gui/src-tauri/src/api/profile.rs | 28 +- .../src-tauri/src/api/profile_create.rs | 15 +- theseus_gui/src-tauri/src/api/utils.rs | 43 +- theseus_gui/src-tauri/src/main.rs | 5 +- theseus_gui/src-tauri/tauri.conf.json | 2 +- theseus_gui/src/App.vue | 149 ++++- theseus_gui/src/components/GridDisplay.vue | 23 +- theseus_gui/src/components/RowDisplay.vue | 13 +- .../src/components/ui/AccountsCard.vue | 13 +- theseus_gui/src/components/ui/ExportModal.vue | 54 +- .../src/components/ui/JavaSelector.vue | 85 ++- .../src/components/ui/ModpackVersionModal.vue | 187 ++++++ .../src/components/ui/tutorial/FakeSearch.vue | 2 +- .../components/ui/tutorial/FakeSettings.vue | 47 +- .../ui/tutorial/ModrinthLoginScreen.vue | 4 +- .../ui/tutorial/OnboardingScreen.vue | 2 +- theseus_gui/src/helpers/jre.js | 6 + theseus_gui/src/helpers/logs.js | 37 +- theseus_gui/src/helpers/process.js | 6 - theseus_gui/src/helpers/profile.js | 28 +- theseus_gui/src/helpers/utils.js | 10 + theseus_gui/src/main.js | 3 +- theseus_gui/src/pages/Settings.vue | 23 +- theseus_gui/src/pages/instance/Index.vue | 15 +- theseus_gui/src/pages/instance/Logs.vue | 349 +++++++++-- theseus_gui/src/pages/instance/Mods.vue | 106 ++-- theseus_gui/src/pages/instance/Options.vue | 372 +++++++++--- theseus_gui/src/pages/project/Index.vue | 2 +- theseus_playground/src/main.rs | 7 - 63 files changed, 2666 insertions(+), 708 deletions(-) create mode 100644 theseus_gui/src-tauri/App.entitlements create mode 100644 theseus_gui/src/components/ui/ModpackVersionModal.vue diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c744593da..045d8c1cf 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,9 +25,12 @@ body: description: A clear and concise description of what you expected to happen. validations: required: false + - type: textarea + attributes: System information + description: Add any information about what OS you are on (like Windows or Mac), and what version of the app you are using. - type: textarea attributes: label: Additional context - description: Add any other context about the problem here. + description: Add any other context about the problem here. This might include logs, screenshots, etc. The more the merrier! validations: required: false diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 29ecff368..6eb65719a 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [macos-latest, windows-latest, ubuntu-20.04] + platform: [macos-latest, windows-latest, ubuntu-20.04, ubuntu-22.04] runs-on: ${{ matrix.platform }} defaults: @@ -62,7 +62,7 @@ jobs: if: startsWith(matrix.platform, 'ubuntu') run: | sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf libselinux1 - name: Install frontend dependencies run: pnpm install diff --git a/Cargo.lock b/Cargo.lock index 928d4200f..30c8aaf38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -830,6 +830,30 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7" +dependencies = [ + "autocfg", + "cfg-if", + "crossbeam-utils", + "memoffset 0.9.0", + "scopeguard", +] + [[package]] name = "crossbeam-utils" version = "0.8.16" @@ -1146,6 +1170,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "embed-resource" version = "2.1.1" @@ -1309,9 +1339,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" +checksum = "c6c98ee8095e9d1dcbf2fcc6d95acccb90d1c81db1e44725c6a984b1dbdfb010" dependencies = [ "crc32fast", "miniz_oxide", @@ -2703,6 +2733,15 @@ dependencies = [ "notify", ] +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -3440,6 +3479,28 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" +[[package]] +name = "rayon" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-utils", + "num_cpus", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -4223,6 +4284,21 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "sysinfo" +version = "0.29.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d0e9cc2273cc8d31377bdd638d72e3ac3e5607b18621062b169d02787f1bab" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "winapi", +] + [[package]] name = "system-deps" version = "5.0.0" @@ -4609,7 +4685,7 @@ dependencies = [ [[package]] name = "theseus" -version = "0.5.3" +version = "0.5.4" dependencies = [ "async-recursion", "async-tungstenite", @@ -4620,6 +4696,7 @@ dependencies = [ "dirs 5.0.1", "discord-rich-presence", "dunce", + "flate2", "futures", "indicatif", "lazy_static", @@ -4634,6 +4711,7 @@ dependencies = [ "sha1 0.6.1", "sha2 0.9.9", "sys-info", + "sysinfo", "tauri", "tempfile", "theseus_macros", @@ -4655,7 +4733,7 @@ dependencies = [ [[package]] name = "theseus_cli" -version = "0.5.3" +version = "0.5.4" dependencies = [ "argh", "color-eyre", @@ -4682,7 +4760,7 @@ dependencies = [ [[package]] name = "theseus_gui" -version = "0.5.3" +version = "0.5.4" dependencies = [ "chrono", "cocoa", diff --git a/theseus/Cargo.toml b/theseus/Cargo.toml index 55469b69e..fd558a0f4 100644 --- a/theseus/Cargo.toml +++ b/theseus/Cargo.toml @@ -20,15 +20,18 @@ url = "2.2" uuid = { version = "1.1", features = ["serde", "v4"] } zip = "0.6.5" async_zip = { version = "0.0.13", features = ["full"] } +flate2 = "1.0.27" tempfile = "3.5.0" urlencoding = "2.1.3" + chrono = { version = "0.4.19", features = ["serde"] } daedalus = { version = "0.1.25" } dirs = "5.0.1" regex = "1.5" sys-info = "0.9.0" +sysinfo = "0.29.9" thiserror = "1.0" tracing = "0.1.37" diff --git a/theseus/src/api/auth.rs b/theseus/src/api/auth.rs index d907feaf0..99cdea414 100644 --- a/theseus/src/api/auth.rs +++ b/theseus/src/api/auth.rs @@ -1,5 +1,9 @@ //! Authentication flow interface -use crate::{hydra::init::DeviceLoginSuccess, launcher::auth as inner, State}; +use crate::{ + hydra::{self, init::DeviceLoginSuccess}, + launcher::auth as inner, + State, +}; use chrono::Utc; use crate::state::AuthTask; @@ -44,20 +48,34 @@ pub async fn refresh(user: uuid::Uuid) -> crate::Result { .as_error() })?; - let fetch_semaphore = &state.fetch_semaphore; - if Utc::now() > credentials.expires - && inner::refresh_credentials(&mut credentials, fetch_semaphore) - .await - .is_err() - { - users.remove(credentials.id).await?; - - return Err(crate::ErrorKind::OtherError( - "Please re-authenticate with your Minecraft account!".to_string(), - ) - .as_error()); + let offline = *state.offline.read().await; + + if !offline { + let fetch_semaphore: &crate::util::fetch::FetchSemaphore = + &state.fetch_semaphore; + if Utc::now() > credentials.expires + && inner::refresh_credentials(&mut credentials, fetch_semaphore) + .await + .is_err() + { + users.remove(credentials.id).await?; + + return Err(crate::ErrorKind::OtherError( + "Please re-authenticate with your Minecraft account!" + .to_string(), + ) + .as_error()); + } + + // Update player info from bearer token + let player_info = hydra::stages::player_info::fetch_info(&credentials.access_token).await.map_err(|_err| { + crate::ErrorKind::HydraError("No Minecraft account for your profile. Make sure you own the game and have set a username through the official Minecraft launcher." + .to_string()) + })?; + + credentials.username = player_info.name; + users.insert(&credentials).await?; } - users.insert(&credentials).await?; Ok(credentials) } diff --git a/theseus/src/api/jre.rs b/theseus/src/api/jre.rs index 4d1f44889..bc81f70c1 100644 --- a/theseus/src/api/jre.rs +++ b/theseus/src/api/jre.rs @@ -7,6 +7,7 @@ use crate::event::emit::{emit_loading, init_loading}; use crate::state::CredentialsStore; use crate::util::fetch::{fetch_advanced, fetch_json}; +use crate::util::io; use crate::util::jre::extract_java_majorminor_version; use crate::{ state::JavaGlobals, @@ -92,7 +93,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result { let packages = fetch_json::>( Method::GET, &format!( - "https://api.azul.com/metadata/v1/zulu/packages?arch={}&java_version={}&os={}&archive_type=zip&javafx_bundled=false&java_package_type=jdk&page_size=1", + "https://api.azul.com/metadata/v1/zulu/packages?arch={}&java_version={}&os={}&archive_type=zip&javafx_bundled=false&java_package_type=jre&page_size=1", std::env::consts::ARCH, java_version, std::env::consts::OS ), None, @@ -124,6 +125,17 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result { )) })?; + // removes the old installation of java + if let Some(file) = archive.file_names().next() { + if let Some(dir) = file.split("/").next() { + let path = path.join(dir); + + if path.exists() { + io::remove_dir_all(path).await?; + } + } + } + emit_loading(&loading_bar, 0.0, Some("Extracting java")).await?; archive.extract(&path).map_err(|_| { crate::Error::from(crate::ErrorKind::InputError( @@ -180,6 +192,20 @@ pub async fn check_jre(path: PathBuf) -> crate::Result> { Ok(jre::check_java_at_filepath(&path).await) } +// Test JRE at a given path +pub async fn test_jre( + path: PathBuf, + major_version: u32, + minor_version: u32, +) -> crate::Result { + let jre = match jre::check_java_at_filepath(&path).await { + Some(jre) => jre, + None => return Ok(false), + }; + let (major, minor) = extract_java_majorminor_version(&jre.version)?; + Ok(major == major_version && minor == minor_version) +} + // Gets maximum memory in KiB. pub async fn get_max_memory() -> crate::Result { Ok(sys_info::mem_info() diff --git a/theseus/src/api/logs.rs b/theseus/src/api/logs.rs index 694ce0e85..831786354 100644 --- a/theseus/src/api/logs.rs +++ b/theseus/src/api/logs.rs @@ -1,30 +1,70 @@ +use std::io::{Read, SeekFrom}; + use crate::{ + prelude::Credentials, util::io::{self, IOError}, {state::ProfilePathId, State}, }; -use serde::{Deserialize, Serialize}; +use futures::TryFutureExt; +use serde::Serialize; +use tokio::{ + fs::File, + io::{AsyncReadExt, AsyncSeekExt}, +}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Debug)] pub struct Logs { - pub datetime_string: String, - pub output: Option, + pub filename: String, + pub output: Option, +} + +#[derive(Serialize, Debug)] +pub struct LatestLogCursor { + pub cursor: u64, + pub output: CensoredString, + pub new_file: bool, } + +#[derive(Serialize, Debug)] // Not deserialize +#[serde(transparent)] +pub struct CensoredString(String); +impl CensoredString { + pub fn censor(mut s: String, credentials_set: &Vec) -> Self { + let username = whoami::username(); + s = s + .replace(&format!("/{}/", username), "/{COMPUTER_USERNAME}/") + .replace(&format!("\\{}\\", username), "\\{COMPUTER_USERNAME}\\"); + for credentials in credentials_set { + s = s + .replace(&credentials.access_token, "{MINECRAFT_ACCESS_TOKEN}") + .replace(&credentials.username, "{MINECRAFT_USERNAME}") + .replace( + &credentials.id.as_simple().to_string(), + "{MINECRAFT_UUID}", + ) + .replace( + &credentials.id.as_hyphenated().to_string(), + "{MINECRAFT_UUID}", + ); + } + + Self(s) + } +} + impl Logs { async fn build( profile_subpath: &ProfilePathId, - datetime_string: String, + filename: String, clear_contents: Option, ) -> crate::Result { Ok(Self { output: if clear_contents.unwrap_or(false) { None } else { - Some( - get_output_by_datetime(profile_subpath, &datetime_string) - .await?, - ) + Some(get_output_by_filename(profile_subpath, &filename).await?) }, - datetime_string, + filename, }) } } @@ -51,33 +91,31 @@ pub async fn get_logs( for entry in std::fs::read_dir(&logs_folder) .map_err(|e| IOError::with_path(e, &logs_folder))? { - let entry = + let entry: std::fs::DirEntry = entry.map_err(|e| IOError::with_path(e, &logs_folder))?; let path = entry.path(); - if path.is_dir() { - if let Some(datetime_string) = path.file_name() { - logs.push( - Logs::build( - &profile_path, - datetime_string.to_string_lossy().to_string(), - clear_contents, - ) - .await, - ); - } + if !path.is_file() { + continue; + } + if let Some(file_name) = path.file_name() { + let file_name = file_name.to_string_lossy().to_string(); + + logs.push( + Logs::build(&profile_path, file_name, clear_contents).await, + ); } } } let mut logs = logs.into_iter().collect::>>()?; - logs.sort_by_key(|x| x.datetime_string.clone()); + logs.sort_by_key(|x| x.filename.clone()); Ok(logs) } #[tracing::instrument] -pub async fn get_logs_by_datetime( +pub async fn get_logs_by_filename( profile_path: ProfilePathId, - datetime_string: String, + filename: String, ) -> crate::Result { let profile_path = if let Some(p) = crate::profile::get(&profile_path, None).await? { @@ -89,23 +127,66 @@ pub async fn get_logs_by_datetime( .into()); }; Ok(Logs { - output: Some( - get_output_by_datetime(&profile_path, &datetime_string).await?, - ), - datetime_string, + output: Some(get_output_by_filename(&profile_path, &filename).await?), + filename, }) } #[tracing::instrument] -pub async fn get_output_by_datetime( +pub async fn get_output_by_filename( profile_subpath: &ProfilePathId, - datetime_string: &str, -) -> crate::Result { + file_name: &str, +) -> crate::Result { let state = State::get().await?; let logs_folder = state.directories.profile_logs_dir(profile_subpath).await?; - let path = logs_folder.join(datetime_string).join("stdout.log"); - Ok(io::read_to_string(&path).await?) + let path = logs_folder.join(file_name); + + let credentials: Vec = + state.users.read().await.clone().0.into_values().collect(); + + // Load .gz file into String + if let Some(ext) = path.extension() { + if ext == "gz" { + let file = std::fs::File::open(&path) + .map_err(|e| IOError::with_path(e, &path))?; + let mut contents = [0; 1024]; + let mut result = String::new(); + let mut gz = + flate2::read::GzDecoder::new(std::io::BufReader::new(file)); + + while gz + .read(&mut contents) + .map_err(|e| IOError::with_path(e, &path))? + > 0 + { + result.push_str(&String::from_utf8_lossy(&contents)); + contents = [0; 1024]; + } + return Ok(CensoredString::censor(result, &credentials)); + } else if ext == "log" { + let mut result = String::new(); + let mut contents = [0; 1024]; + let mut file = std::fs::File::open(&path) + .map_err(|e| IOError::with_path(e, &path))?; + // iteratively read the file to a String + while file + .read(&mut contents) + .map_err(|e| IOError::with_path(e, &path))? + > 0 + { + result.push_str(&String::from_utf8_lossy(&contents)); + contents = [0; 1024]; + } + let result = CensoredString::censor(result, &credentials); + return Ok(result); + } + } + Err(crate::ErrorKind::OtherError(format!( + "File extension not supported: {}", + path.display() + )) + .into()) } #[tracing::instrument] @@ -135,9 +216,9 @@ pub async fn delete_logs(profile_path: ProfilePathId) -> crate::Result<()> { } #[tracing::instrument] -pub async fn delete_logs_by_datetime( +pub async fn delete_logs_by_filename( profile_path: ProfilePathId, - datetime_string: &str, + filename: &str, ) -> crate::Result<()> { let profile_path = if let Some(p) = crate::profile::get(&profile_path, None).await? { @@ -151,7 +232,71 @@ pub async fn delete_logs_by_datetime( let state = State::get().await?; let logs_folder = state.directories.profile_logs_dir(&profile_path).await?; - let path = logs_folder.join(datetime_string); + let path = logs_folder.join(filename); io::remove_dir_all(&path).await?; Ok(()) } + +#[tracing::instrument] +pub async fn get_latest_log_cursor( + profile_path: ProfilePathId, + mut cursor: u64, // 0 to start at beginning of file +) -> crate::Result { + let profile_path = + if let Some(p) = crate::profile::get(&profile_path, None).await? { + p.profile_id() + } else { + return Err(crate::ErrorKind::UnmanagedProfileError( + profile_path.to_string(), + ) + .into()); + }; + + let state = State::get().await?; + let logs_folder = state.directories.profile_logs_dir(&profile_path).await?; + let path = logs_folder.join("latest.log"); + if !path.exists() { + // Allow silent failure if latest.log doesn't exist (as the instance may have been launched, but not yet created the file) + return Ok(LatestLogCursor { + cursor: 0, + new_file: false, + output: CensoredString("".to_string()), + }); + } + + let mut file = File::open(&path) + .await + .map_err(|e| IOError::with_path(e, &path))?; + let metadata = file + .metadata() + .await + .map_err(|e| IOError::with_path(e, &path))?; + + let mut new_file = false; + if cursor > metadata.len() { + // Cursor is greater than file length, reset cursor to 0 + // Likely cause is that the file was rotated while the log was being read + cursor = 0; + new_file = true; + } + + let mut buffer = Vec::new(); + file.seek(SeekFrom::Start(cursor)) + .map_err(|e| IOError::with_path(e, &path)) + .await?; // Seek to cursor + let bytes_read = file + .read_to_end(&mut buffer) + .map_err(|e| IOError::with_path(e, &path)) + .await?; // Read to end of file + let output = String::from_utf8_lossy(&buffer).to_string(); // Convert to String + let cursor = cursor + bytes_read as u64; // Update cursor + + let credentials: Vec = + state.users.read().await.clone().0.into_values().collect(); + let output = CensoredString::censor(output, &credentials); + Ok(LatestLogCursor { + cursor, + new_file, + output, + }) +} diff --git a/theseus/src/api/pack/import/atlauncher.rs b/theseus/src/api/pack/import/atlauncher.rs index a63374756..b4341b37a 100644 --- a/theseus/src/api/pack/import/atlauncher.rs +++ b/theseus/src/api/pack/import/atlauncher.rs @@ -218,6 +218,10 @@ async fn import_atlauncher_unmanaged( prof.metadata.linked_data = Some(LinkedData { project_id: description.project_id.clone(), version_id: description.version_id.clone(), + locked: Some( + description.project_id.is_some() + && description.version_id.is_some(), + ), }); prof.metadata.icon = description.icon.clone(); prof.metadata.game_version = game_version.clone(); diff --git a/theseus/src/api/pack/import/mmc.rs b/theseus/src/api/pack/import/mmc.rs index 438776f75..bdd06439c 100644 --- a/theseus/src/api/pack/import/mmc.rs +++ b/theseus/src/api/pack/import/mmc.rs @@ -306,6 +306,7 @@ async fn import_mmc_unmanaged( &description, &backup_name, &dependencies, + false, ) .await?; diff --git a/theseus/src/api/pack/import/mod.rs b/theseus/src/api/pack/import/mod.rs index 96563acad..1d3c71605 100644 --- a/theseus/src/api/pack/import/mod.rs +++ b/theseus/src/api/pack/import/mod.rs @@ -251,7 +251,7 @@ pub async fn recache_icon( } } -async fn copy_dotminecraft( +pub async fn copy_dotminecraft( profile_path_id: ProfilePathId, dotminecraft: PathBuf, io_semaphore: &IoSemaphore, diff --git a/theseus/src/api/pack/install_from.rs b/theseus/src/api/pack/install_from.rs index 805f782b6..995201e26 100644 --- a/theseus/src/api/pack/install_from.rs +++ b/theseus/src/api/pack/install_from.rs @@ -153,6 +153,7 @@ pub fn get_profile_from_pack( linked_data: Some(LinkedData { project_id: Some(project_id), version_id: Some(version_id), + locked: Some(true), }), ..Default::default() }, @@ -179,20 +180,29 @@ pub async fn generate_pack_from_version_id( title: String, icon_url: Option, profile_path: ProfilePathId, + + // Existing loading bar. Unlike when existing_loading_bar is used, this one is pre-initialized with PackFileDownload + // For example, you might use this if multiple packs are being downloaded at once and you want to use the same loading bar + initialized_loading_bar: Option, ) -> crate::Result { let state = State::get().await?; - let loading_bar = init_loading( - LoadingBarType::PackFileDownload { - profile_path: profile_path.get_full_path().await?, - pack_name: title, - icon: icon_url, - pack_version: version_id.clone(), - }, - 100.0, - "Downloading pack file", - ) - .await?; + let loading_bar = if let Some(bar) = initialized_loading_bar { + emit_loading(&bar, 0.0, Some("Downloading pack file")).await?; + bar + } else { + init_loading( + LoadingBarType::PackFileDownload { + profile_path: profile_path.get_full_path().await?, + pack_name: title, + icon: icon_url, + pack_version: version_id.clone(), + }, + 100.0, + "Downloading pack file", + ) + .await? + }; emit_loading(&loading_bar, 0.0, Some("Fetching version")).await?; let creds = state.credentials.read().await; @@ -313,6 +323,7 @@ pub async fn set_profile_information( description: &CreatePackDescription, backup_name: &str, dependencies: &HashMap, + ignore_lock: bool, // do not change locked status ) -> crate::Result<()> { let mut game_version: Option<&String> = None; let mut mod_loader = None; @@ -370,6 +381,14 @@ pub async fn set_profile_information( prof.metadata.linked_data = Some(LinkedData { project_id: description.project_id.clone(), version_id: description.version_id.clone(), + locked: if !ignore_lock { + Some( + description.project_id.is_some() + && description.version_id.is_some(), + ) + } else { + prof.metadata.linked_data.as_ref().and_then(|x| x.locked) + }, }); prof.metadata.icon = description.icon.clone(); prof.metadata.game_version = game_version.clone(); diff --git a/theseus/src/api/pack/install_mrpack.rs b/theseus/src/api/pack/install_mrpack.rs index f9b2e57ac..b28f41603 100644 --- a/theseus/src/api/pack/install_mrpack.rs +++ b/theseus/src/api/pack/install_mrpack.rs @@ -1,3 +1,4 @@ +use crate::config::MODRINTH_API_URL; use crate::event::emit::{ emit_loading, init_or_edit_loading, loading_try_for_each_concurrent, }; @@ -5,13 +6,16 @@ use crate::event::LoadingBarType; use crate::pack::install_from::{ set_profile_information, EnvType, PackFile, PackFileHash, }; -use crate::prelude::ProfilePathId; +use crate::prelude::{ModrinthVersion, ProfilePathId, ProjectMetadata}; use crate::state::{ProfileInstallStage, Profiles, SideType}; -use crate::util::fetch::{fetch_mirrors, write}; +use crate::util::fetch::{fetch_json, fetch_mirrors, write}; use crate::util::io; use crate::{profile, State}; use async_zip::tokio::read::seek::ZipFileReader; +use reqwest::Method; +use serde_json::json; +use std::collections::HashMap; use std::io::Cursor; use std::path::{Component, PathBuf}; @@ -43,6 +47,7 @@ pub async fn install_zipped_mrpack( title, icon_url, profile_path.clone(), + None, ) .await? } @@ -52,7 +57,7 @@ pub async fn install_zipped_mrpack( }; // Install pack files, and if it fails, fail safely by removing the profile - let result = install_zipped_mrpack_files(create_pack).await; + let result = install_zipped_mrpack_files(create_pack, false).await; // Check existing managed packs for potential updates tokio::task::spawn(Profiles::update_modrinth_versions()); @@ -72,6 +77,7 @@ pub async fn install_zipped_mrpack( #[theseus_macros::debug_pin] pub async fn install_zipped_mrpack_files( create_pack: CreatePack, + ignore_lock: bool, ) -> crate::Result { let state = &State::get().await?; @@ -126,6 +132,7 @@ pub async fn install_zipped_mrpack_files( &description, &pack.name, &pack.dependencies, + ignore_lock, ) .await?; @@ -182,15 +189,20 @@ pub async fn install_zipped_mrpack_files( .await?; drop(creds); + // Convert windows path to unix path. + // .mrpacks no longer generate windows paths, but this is here for backwards compatibility before this was fixed + // https://github.com/modrinth/theseus/issues/595 + let project_path = project.path.replace('\\', "/"); + let path = - std::path::Path::new(&project.path).components().next(); + std::path::Path::new(&project_path).components().next(); if let Some(path) = path { match path { Component::CurDir | Component::Normal(_) => { let path = profile_path .get_full_path() .await? - .join(&project.path); + .join(&project_path); write(&path, &file, &state.io_semaphore) .await?; } @@ -337,31 +349,65 @@ pub async fn remove_all_related_files( }) .await?; - let num_files = pack.files.len(); - use futures::StreamExt; - loading_try_for_each_concurrent( - futures::stream::iter(pack.files.into_iter()) - .map(Ok::), - None, + // First, remove all modrinth projects by their version hashes + // Remove all modrinth projects by their version hashes + // We need to do a fetch to get the project ids from Modrinth + let state = State::get().await?; + let all_hashes = pack + .files + .iter() + .filter_map(|f| Some(f.hashes.get(&PackFileHash::Sha512)?.clone())) + .collect::>(); + let creds = state.credentials.read().await; + + // First, get project info by hash + let files_url = format!("{}version_files", MODRINTH_API_URL); + + let hash_projects = fetch_json::>( + Method::POST, + &files_url, None, - 0.0, - num_files, - None, - |project| { - let profile_path = profile_path.clone(); - async move { - // Remove this file if a corresponding one exists in the filesystem - let existing_file = - profile_path.get_full_path().await?.join(&project.path); - if existing_file.exists() { - io::remove_file(&existing_file).await?; - } - - Ok(()) - } - }, + Some(json!({ + "hashes": all_hashes, + "algorithm": "sha512", + })), + &state.fetch_semaphore, + &creds, ) .await?; + let to_remove = hash_projects + .into_values() + .map(|p| p.project_id) + .collect::>(); + let profile = + profile::get(&profile_path, None).await?.ok_or_else(|| { + crate::ErrorKind::UnmanagedProfileError( + profile_path.to_string(), + ) + })?; + for (project_id, project) in &profile.projects { + if let ProjectMetadata::Modrinth { project, .. } = &project.metadata + { + if to_remove.contains(&project.id) { + let path = profile + .get_profile_full_path() + .await? + .join(project_id.0.clone()); + if path.exists() { + io::remove_file(&path).await?; + } + } + } + } + + // Iterate over all Modrinth project file paths in the json, and remove them + // (There should be few, but this removes any files the .mrpack intended as Modrinth projects but were unrecognized) + for file in pack.files { + let path = profile_path.get_full_path().await?.join(file.path); + if path.exists() { + io::remove_file(&path).await?; + } + } // Iterate over each 'overrides' file and remove it for index in 0..zip_reader.file().entries().len() { diff --git a/theseus/src/api/process.rs b/theseus/src/api/process.rs index c9ae281ba..3fb3bc6f1 100644 --- a/theseus/src/api/process.rs +++ b/theseus/src/api/process.rs @@ -2,16 +2,13 @@ use uuid::Uuid; +use crate::state::{MinecraftChild, ProfilePathId}; pub use crate::{ state::{ Hooks, JavaSettings, MemorySettings, Profile, Settings, WindowSize, }, State, }; -use crate::{ - state::{MinecraftChild, ProfilePathId}, - util::io::IOError, -}; // Gets whether a child process stored in the state by UUID has finished #[tracing::instrument] @@ -26,7 +23,7 @@ pub async fn get_exit_status_by_uuid( ) -> crate::Result> { let state = State::get().await?; let children = state.children.read().await; - Ok(children.exit_status(uuid).await?.and_then(|f| f.code())) + children.exit_status(uuid).await } // Gets the UUID of each stored process in the state @@ -72,26 +69,6 @@ pub async fn get_uuids_by_profile_path( children.running_keys_with_profile(profile_path).await } -// Gets output of a child process stored in the state by UUID, as a string -#[tracing::instrument] -pub async fn get_output_by_uuid(uuid: &Uuid) -> crate::Result { - let state = State::get().await?; - // Get stdout from child - let children = state.children.read().await; - - // Extract child or return crate::Error - if let Some(child) = children.get(uuid) { - let child = child.read().await; - Ok(child.output.get_output().await?) - } else { - Err(crate::ErrorKind::LauncherError(format!( - "No child process by UUID {}", - uuid - )) - .as_error()) - } -} - // Kill a child process stored in the state by UUID, as a string #[tracing::instrument] pub async fn kill_by_uuid(uuid: &Uuid) -> crate::Result<()> { @@ -124,13 +101,7 @@ pub async fn wait_for_by_uuid(uuid: &Uuid) -> crate::Result<()> { // Kill a running child process directly #[tracing::instrument(skip(running))] pub async fn kill(running: &mut MinecraftChild) -> crate::Result<()> { - running - .current_child - .write() - .await - .kill() - .await - .map_err(IOError::from)?; + running.current_child.write().await.kill().await?; Ok(()) } diff --git a/theseus/src/api/profile/create.rs b/theseus/src/api/profile/create.rs index 1cd7e9ce0..6a3e1b294 100644 --- a/theseus/src/api/profile/create.rs +++ b/theseus/src/api/profile/create.rs @@ -1,13 +1,13 @@ //! Theseus profile management interface use crate::pack::install_from::CreatePackProfile; use crate::prelude::ProfilePathId; -use crate::profile; use crate::state::LinkedData; use crate::util::io::{self, canonicalize}; use crate::{ event::{emit::emit_profile, ProfilePayloadType}, prelude::ModLoader, }; +use crate::{pack, profile, ErrorKind}; pub use crate::{ state::{JavaSettings, Profile}, State, @@ -102,6 +102,12 @@ pub async fn profile_create( } profile.metadata.linked_data = linked_data; + if let Some(linked_data) = &mut profile.metadata.linked_data { + linked_data.locked = Some( + linked_data.project_id.is_some() + && linked_data.version_id.is_some(), + ); + } emit_profile( uuid, @@ -154,6 +160,59 @@ pub async fn profile_create_from_creator( .await } +pub async fn profile_create_from_duplicate( + copy_from: ProfilePathId, +) -> crate::Result { + let profile = profile::get(©_from, None).await?.ok_or_else(|| { + ErrorKind::UnmanagedProfileError(copy_from.to_string()) + })?; + + let profile_path_id = profile_create( + profile.metadata.name.clone(), + profile.metadata.game_version.clone(), + profile.metadata.loader, + profile.metadata.loader_version.clone().map(|it| it.id), + profile.metadata.icon.clone(), + profile.metadata.icon_url.clone(), + profile.metadata.linked_data.clone(), + Some(true), + Some(true), + ) + .await?; + + // Copy it over using the import system (essentially importing from the same profile) + let state = State::get().await?; + let bar = pack::import::copy_dotminecraft( + profile_path_id.clone(), + copy_from.get_full_path().await?, + &state.io_semaphore, + None, + ) + .await?; + + crate::launcher::install_minecraft(&profile, Some(bar)).await?; + { + let state = State::get().await?; + let mut file_watcher = state.file_watcher.write().await; + Profile::watch_fs( + &profile.get_profile_full_path().await?, + &mut file_watcher, + ) + .await?; + } + + // emit profile edited + emit_profile( + profile.uuid, + &profile.profile_id(), + &profile.metadata.name, + ProfilePayloadType::Edited, + ) + .await?; + State::sync().await?; + Ok(profile_path_id) +} + #[tracing::instrument] #[theseus_macros::debug_pin] pub(crate) async fn get_loader_version_from_loader( diff --git a/theseus/src/api/profile/mod.rs b/theseus/src/api/profile/mod.rs index ff1c66b4f..e410f21e4 100644 --- a/theseus/src/api/profile/mod.rs +++ b/theseus/src/api/profile/mod.rs @@ -8,7 +8,7 @@ use crate::pack::install_from::{ EnvType, PackDependency, PackFile, PackFileHash, PackFormat, }; use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId}; -use crate::state::ProjectMetadata; +use crate::state::{ProjectMetadata, SideType}; use crate::util::fetch; use crate::util::io::{self, IOError}; @@ -109,6 +109,26 @@ pub async fn get_full_path(path: &ProfilePathId) -> crate::Result { Ok(full_path) } +/// Get mod's full path in the filesystem +#[tracing::instrument] +pub async fn get_mod_full_path( + profile_path: &ProfilePathId, + project_path: &ProjectPathId, +) -> crate::Result { + if get(profile_path, Some(true)).await?.is_some() { + let full_path = io::canonicalize( + project_path.get_full_path(profile_path.clone()).await?, + )?; + return Ok(full_path); + } + + Err(crate::ErrorKind::OtherError(format!( + "Tried to get the full path of a nonexistent or unloaded project at path {}!", + project_path.get_full_path(profile_path.clone()).await?.display() + )) + .into()) +} + /// Edit a profile using a given asynchronous closure pub async fn edit( path: &ProfilePathId, @@ -552,6 +572,8 @@ pub async fn export_mrpack( export_path: PathBuf, included_overrides: Vec, // which folders to include in the overrides version_id: Option, + description: Option, + _name: Option, ) -> crate::Result<()> { let state = State::get().await?; let io_semaphore = state.io_semaphore.0.read().await; @@ -585,7 +607,8 @@ pub async fn export_mrpack( // Create mrpack json configuration file let version_id = version_id.unwrap_or("1.0.0".to_string()); - let packfile = create_mrpack_json(&profile, version_id).await?; + let packfile = + create_mrpack_json(&profile, version_id, description).await?; let modrinth_path_list = get_modrinth_pack_list(&packfile); // Build vec of all files in the folder @@ -693,7 +716,7 @@ pub async fn get_potential_override_folders( )) })?; // dummy mrpack to get pack list - let mrpack = create_mrpack_json(&profile, "0".to_string()).await?; + let mrpack = create_mrpack_json(&profile, "0".to_string(), None).await?; let mrpack_files = get_modrinth_pack_list(&mrpack); let mut path_list: Vec = Vec::new(); @@ -820,23 +843,12 @@ pub async fn run_credentials( .unwrap_or(&settings.custom_env_args); // Post post exit hooks - let post_exit_hook = - &profile.hooks.as_ref().unwrap_or(&settings.hooks).post_exit; - - let post_exit_hook = if let Some(hook) = post_exit_hook { - let mut cmd = hook.split(' '); - if let Some(command) = cmd.next() { - let mut command = Command::new(command); - command - .args(&cmd.collect::>()) - .current_dir(path.get_full_path().await?); - Some(command) - } else { - None - } - } else { - None - }; + let post_exit_hook = profile + .hooks + .as_ref() + .unwrap_or(&settings.hooks) + .post_exit + .clone(); // Any options.txt settings that we want set, add here let mut mc_set_options: Vec<(String, String)> = vec![]; @@ -941,6 +953,7 @@ fn get_modrinth_pack_list(packfile: &PackFormat) -> Vec { pub async fn create_mrpack_json( profile: &Profile, version_id: String, + description: Option, ) -> crate::Result { // Add loader version to dependencies let mut dependencies = HashMap::new(); @@ -951,6 +964,9 @@ pub async fn create_mrpack_json( (crate::prelude::ModLoader::Forge, Some(v)) => { dependencies.insert(PackDependency::Forge, v.id) } + (crate::prelude::ModLoader::NeoForge, Some(v)) => { + dependencies.insert(PackDependency::NeoForge, v.id) + } (crate::prelude::ModLoader::Fabric, Some(v)) => { dependencies.insert(PackDependency::FabricLoader, v.id) } @@ -981,18 +997,21 @@ pub async fn create_mrpack_json( .projects .iter() .filter_map(|(mod_path, project)| { - let path: String = mod_path.0.clone().to_string_lossy().to_string(); + let path: String = mod_path.get_inner_path_unix().ok()?; // Only Modrinth projects have a modrinth metadata field for the modrinth.json Some(Ok(match project.metadata { crate::prelude::ProjectMetadata::Modrinth { - ref project, ref version, .. } => { let mut env = HashMap::new(); - env.insert(EnvType::Client, project.client_side.clone()); - env.insert(EnvType::Server, project.server_side.clone()); + // TODO: envtype should be a controllable option (in general or at least .mrpack exporting) + // For now, assume required. + // env.insert(EnvType::Client, project.client_side.clone()); + // env.insert(EnvType::Server, project.server_side.clone()); + env.insert(EnvType::Client, SideType::Required); + env.insert(EnvType::Server, SideType::Required); let primary_file = if let Some(primary_file) = version.files.first() @@ -1037,7 +1056,7 @@ pub async fn create_mrpack_json( format_version: 1, version_id, name: profile.metadata.name.clone(), - summary: None, + summary: description, files, dependencies, }) diff --git a/theseus/src/api/profile/update.rs b/theseus/src/api/profile/update.rs index bc39fc563..e46ceb068 100644 --- a/theseus/src/api/profile/update.rs +++ b/theseus/src/api/profile/update.rs @@ -1,21 +1,22 @@ use crate::{ event::{ - emit::{emit_profile, loading_try_for_each_concurrent}, + emit::{emit_profile, init_loading, loading_try_for_each_concurrent}, ProfilePayloadType, }, pack::{self, install_from::generate_pack_from_version_id}, prelude::{ProfilePathId, ProjectPathId}, profile::get, - state::Project, - State, + state::{ProfileInstallStage, Project}, + LoadingBarType, State, }; use futures::try_join; -/// Updates a managed modrinth pack to the cached latest version found in 'modrinth_update_version' +/// Updates a managed modrinth pack to the version specified by new_version_id #[tracing::instrument] #[theseus_macros::debug_pin] -pub async fn update_managed_modrinth( +pub async fn update_managed_modrinth_version( profile_path: &ProfilePathId, + new_version_id: &String, ) -> crate::Result<()> { let profile = get(profile_path, None).await?.ok_or_else(|| { crate::ErrorKind::UnmanagedProfileError(profile_path.to_string()) @@ -39,19 +40,14 @@ pub async fn update_managed_modrinth( let version_id = linked_data.version_id.as_ref().ok_or_else(unmanaged_err)?; - // extract modrinth_update_version, returning Ok(()) if it is none - let modrinth_update_version = match profile.modrinth_update_version { - Some(ref x) if x != version_id => x, - _ => return Ok(()), // No update version, or no update needed, return Ok(()) - }; - // Replace the pack with the new version replace_managed_modrinth( profile_path, &profile, project_id, version_id, - Some(modrinth_update_version), + Some(new_version_id), + true, // switching versions should ignore the lock ) .await?; @@ -128,6 +124,7 @@ pub async fn repair_managed_modrinth( project_id, version_id, None, + false, // do not ignore lock, as repairing can reset the lock ) .await?; @@ -153,32 +150,61 @@ async fn replace_managed_modrinth( project_id: &String, version_id: &String, new_version_id: Option<&String>, + ignore_lock: bool, ) -> crate::Result<()> { + crate::profile::edit(profile_path, |profile| { + profile.install_stage = ProfileInstallStage::Installing; + async { Ok(()) } + }) + .await?; + // Fetch .mrpacks for both old and new versions // TODO: this will need to be updated if we revert the hacky pack method we needed for compiler speed - let old_pack_creator = generate_pack_from_version_id( - project_id.clone(), - version_id.clone(), - profile.metadata.name.clone(), - None, - profile_path.clone(), - ); - // download in parallel, then join. If new_version_id is None, we don't need to download the new pack, so we clone the old one let (old_pack_creator, new_pack_creator) = if let Some(new_version_id) = new_version_id { + let shared_loading_bar = init_loading( + LoadingBarType::PackFileDownload { + profile_path: profile_path.get_full_path().await?, + pack_name: profile.metadata.name.clone(), + icon: None, + pack_version: version_id.clone(), + }, + 200.0, // These two downloads will share the same loading bar + "Downloading pack file", + ) + .await?; + + // download in parallel, then join. try_join!( - old_pack_creator, + generate_pack_from_version_id( + project_id.clone(), + version_id.clone(), + profile.metadata.name.clone(), + None, + profile_path.clone(), + Some(shared_loading_bar.clone()) + ), generate_pack_from_version_id( project_id.clone(), new_version_id.clone(), profile.metadata.name.clone(), None, - profile_path.clone() + profile_path.clone(), + Some(shared_loading_bar) ) )? } else { - let mut old_pack_creator = old_pack_creator.await?; + // If new_version_id is None, we don't need to download the new pack, so we clone the old one + let mut old_pack_creator = generate_pack_from_version_id( + project_id.clone(), + version_id.clone(), + profile.metadata.name.clone(), + None, + profile_path.clone(), + None, + ) + .await?; old_pack_creator.description.existing_loading_bar = None; (old_pack_creator.clone(), old_pack_creator) }; @@ -197,7 +223,11 @@ async fn replace_managed_modrinth( // - install all overrides // - edits the profile to update the new data // - (functionals almost identically to rteinstalling the pack 'in-place') - pack::install_mrpack::install_zipped_mrpack_files(new_pack_creator).await?; + pack::install_mrpack::install_zipped_mrpack_files( + new_pack_creator, + ignore_lock, + ) + .await?; Ok(()) } diff --git a/theseus/src/api/settings.rs b/theseus/src/api/settings.rs index aa33dcc06..59bca9a87 100644 --- a/theseus/src/api/settings.rs +++ b/theseus/src/api/settings.rs @@ -49,10 +49,19 @@ pub async fn set(settings: Settings) -> crate::Result<()> { } .await; + let updated_discord_rpc = { + let read = state.settings.read().await; + settings.disable_discord_rpc != read.disable_discord_rpc + }; + { *state.settings.write().await = settings; } + if updated_discord_rpc { + state.discord_rpc.clear_to_default(true).await?; + } + if reset_io { state.reset_io_semaphore().await; } diff --git a/theseus/src/event/mod.rs b/theseus/src/event/mod.rs index b0dd2f010..aa3c92fd2 100644 --- a/theseus/src/event/mod.rs +++ b/theseus/src/event/mod.rs @@ -140,11 +140,15 @@ impl Drop for LoadingBarId { #[cfg(not(any(feature = "tauri", feature = "cli")))] bars.remove(&loader_uuid); } - let _ = SafeProcesses::complete( - crate::state::ProcessType::LoadingBar, - loader_uuid, - ) - .await; + // complete calls state, and since a LoadingBarId is created in state initialization, we only complete if its already initializaed + // to avoid an infinite loop. + if crate::State::initialized() { + let _ = SafeProcesses::complete( + crate::state::ProcessType::LoadingBar, + loader_uuid, + ) + .await; + } }); } } diff --git a/theseus/src/launcher/mod.rs b/theseus/src/launcher/mod.rs index 50aa7592c..4b0290a28 100644 --- a/theseus/src/launcher/mod.rs +++ b/theseus/src/launcher/mod.rs @@ -312,7 +312,7 @@ pub async fn launch_minecraft( memory: &st::MemorySettings, resolution: &st::WindowSize, credentials: &auth::Credentials, - post_exit_hook: Option, + post_exit_hook: Option, profile: &Profile, ) -> crate::Result>> { if profile.install_stage == ProfileInstallStage::PackInstalling @@ -406,7 +406,6 @@ pub async fn launch_minecraft( )) .as_error()); } - command .args( args::get_jvm_arguments( @@ -447,14 +446,17 @@ pub async fn launch_minecraft( .collect::>(), ) .current_dir(instance_path.clone()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + .stdout(Stdio::null()) + .stderr(Stdio::null()); // CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground #[cfg(target_os = "macos")] if std::env::var("CARGO").is_ok() { command.env_remove("DYLD_FALLBACK_LIBRARY_PATH"); } + // Java options should be set in instance options (the existence of _JAVA_OPTIONS overwites them) + command.env_remove("_JAVA_OPTIONS"); + command.envs(env_args); // Overwrites the minecraft options.txt file with the settings from the profile @@ -484,20 +486,6 @@ pub async fn launch_minecraft( io::write(&options_path, options_string).await?; } - // Get Modrinth logs directories - let datetime_string = - chrono::Local::now().format("%Y%m%d_%H%M%S").to_string(); - let logs_dir = { - let st = State::get().await?; - st.directories - .profile_logs_dir(&profile.profile_id()) - .await? - .join(&datetime_string) - }; - io::create_dir_all(&logs_dir).await?; - - let stdout_log_path = logs_dir.join("stdout.log"); - crate::api::profile::edit(&profile.profile_id(), |prof| { prof.metadata.last_played = Some(Utc::now()); @@ -559,10 +547,9 @@ pub async fn launch_minecraft( // This also spawns the process and prepares the subsequent processes let mut state_children = state.children.write().await; state_children - .insert_process( + .insert_new_process( Uuid::new_v4(), profile.profile_id(), - stdout_log_path, command, post_exit_hook, censor_strings, diff --git a/theseus/src/logger.rs b/theseus/src/logger.rs index b715c0ccf..30b37365f 100644 --- a/theseus/src/logger.rs +++ b/theseus/src/logger.rs @@ -24,7 +24,9 @@ pub fn start_logger() -> Option { use tracing_subscriber::prelude::*; let filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("theseus=info")); + .unwrap_or_else(|_| { + tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info") + }); let subscriber = tracing_subscriber::registry() .with(tracing_subscriber::fmt::layer()) .with(filter) diff --git a/theseus/src/state/children.rs b/theseus/src/state/children.rs index 189a8eab3..d7975f02d 100644 --- a/theseus/src/state/children.rs +++ b/theseus/src/state/children.rs @@ -1,89 +1,278 @@ use super::{Profile, ProfilePathId}; use chrono::{DateTime, Utc}; -use std::path::{Path, PathBuf}; -use std::process::ExitStatus; +use serde::Deserialize; +use serde::Serialize; use std::{collections::HashMap, sync::Arc}; -use tokio::fs::File; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use sysinfo::PidExt; use tokio::process::Child; use tokio::process::Command; -use tokio::process::{ChildStderr, ChildStdout}; use tokio::sync::RwLock; -use tracing::error; use crate::event::emit::emit_process; use crate::event::ProcessPayloadType; -use crate::profile; +use crate::util::fetch::read_json; use crate::util::io::IOError; +use crate::{profile, ErrorKind}; +use sysinfo::{ProcessExt, SystemExt}; use tokio::task::JoinHandle; use uuid::Uuid; +const PROCESSES_JSON: &str = "processes.json"; + // Child processes (instances of Minecraft) // A wrapper over a Hashmap connecting PID -> MinecraftChild pub struct Children(HashMap>>); -// Minecraft Child, bundles together the PID, the actual Child, and the easily queryable stdout and stderr streams +#[derive(Debug)] +pub enum ChildType { + // A child process that is being managed by tokio + TokioChild(Child), + // A child process that was rescued from a cache (e.g. a process that was launched by theseus before the launcher was restarted) + // This may not have all the same functionality as a TokioChild + RescuedPID(u32), +} +#[derive(Serialize, Deserialize, Debug)] +pub struct ProcessCache { + pub pid: u32, + pub uuid: Uuid, + pub start_time: u64, + pub name: String, + pub exe: String, + pub profile_relative_path: ProfilePathId, + pub post_command: Option, +} +impl ChildType { + pub async fn try_wait(&mut self) -> crate::Result> { + match self { + ChildType::TokioChild(child) => Ok(child + .try_wait() + .map_err(IOError::from)? + .map(|x| x.code().unwrap_or(0))), + ChildType::RescuedPID(pid) => { + let mut system = sysinfo::System::new(); + if !system.refresh_process(sysinfo::Pid::from_u32(*pid)) { + return Ok(Some(0)); + } + let process = system.process(sysinfo::Pid::from_u32(*pid)); + if let Some(process) = process { + if process.status() == sysinfo::ProcessStatus::Run { + Ok(None) + } else { + Ok(Some(0)) + } + } else { + Ok(Some(0)) + } + } + } + } + pub async fn kill(&mut self) -> crate::Result<()> { + match self { + ChildType::TokioChild(child) => { + Ok(child.kill().await.map_err(IOError::from)?) + } + ChildType::RescuedPID(pid) => { + let mut system = sysinfo::System::new(); + if system.refresh_process(sysinfo::Pid::from_u32(*pid)) { + let process = system.process(sysinfo::Pid::from_u32(*pid)); + if let Some(process) = process { + process.kill(); + } + } + Ok(()) + } + } + } + pub fn id(&self) -> Option { + match self { + ChildType::TokioChild(child) => child.id(), + ChildType::RescuedPID(pid) => Some(*pid), + } + } + + // Caches the process so that it can be restored if the launcher is restarted + // Stored in the caches/metadata/processes.json file + pub async fn cache_process( + &self, + uuid: uuid::Uuid, + profile_path_id: ProfilePathId, + post_command: Option, + ) -> crate::Result<()> { + let pid = match self { + ChildType::TokioChild(child) => child.id().unwrap_or(0), + ChildType::RescuedPID(pid) => *pid, + }; + + let state = crate::State::get().await?; + + let mut system = sysinfo::System::new(); + system.refresh_processes(); + let process = + system.process(sysinfo::Pid::from_u32(pid)).ok_or_else(|| { + crate::ErrorKind::LauncherError(format!( + "Could not find process {}", + pid + )) + })?; + let start_time = process.start_time(); + let name = process.name().to_string(); + let exe = process.exe().to_string_lossy().to_string(); + + let cached_process = ProcessCache { + pid, + start_time, + name, + exe, + post_command, + uuid, + profile_relative_path: profile_path_id, + }; + + let children_path = state + .directories + .caches_meta_dir() + .await + .join(PROCESSES_JSON); + let mut children_caches = if let Ok(children_json) = + read_json::>( + &children_path, + &state.io_semaphore, + ) + .await + { + children_json + } else { + HashMap::new() + }; + children_caches.insert(uuid, cached_process); + crate::util::fetch::write( + &children_path, + &serde_json::to_vec(&children_caches)?, + &state.io_semaphore, + ) + .await?; + + Ok(()) + } + + // Removes the process from the cache (ie: on process exit) + pub async fn remove_cache(&self, uuid: uuid::Uuid) -> crate::Result<()> { + let state = crate::State::get().await?; + let children_path = state + .directories + .caches_meta_dir() + .await + .join(PROCESSES_JSON); + let mut children_caches = if let Ok(children_json) = + read_json::>( + &children_path, + &state.io_semaphore, + ) + .await + { + children_json + } else { + HashMap::new() + }; + children_caches.remove(&uuid); + crate::util::fetch::write( + &children_path, + &serde_json::to_vec(&children_caches)?, + &state.io_semaphore, + ) + .await?; + + Ok(()) + } +} + +// Minecraft Child, bundles together the PID, the actual Child, and the easily queryable stdout and stderr streams (if needed) #[derive(Debug)] pub struct MinecraftChild { pub uuid: Uuid, pub profile_relative_path: ProfilePathId, - pub manager: Option>>, // None when future has completed and been handled - pub current_child: Arc>, - pub output: SharedOutput, + pub manager: Option>>, // None when future has completed and been handled + pub current_child: Arc>, pub last_updated_playtime: DateTime, // The last time we updated the playtime for the associated profile } impl Children { - pub fn new() -> Children { + pub fn new() -> Self { Children(HashMap::new()) } + // Loads cached processes from the caches/metadata/processes.json file, re-inserts them into the hashmap, and removes them from the file + // This will only be called once, on startup. Only processes who match a cached process (name, time started, pid, etc) will be re-inserted + pub async fn rescue_cache(&mut self) -> crate::Result<()> { + let state = crate::State::get().await?; + let children_path = state + .directories + .caches_meta_dir() + .await + .join(PROCESSES_JSON); + + let mut children_caches = if let Ok(children_json) = + read_json::>( + &children_path, + &state.io_semaphore, + ) + .await + { + // Overwrite the file with an empty hashmap- we will re-insert the cached processes + let empty = HashMap::::new(); + crate::util::fetch::write( + &children_path, + &serde_json::to_vec(&empty)?, + &state.io_semaphore, + ) + .await?; + + // Return the cached processes + children_json + } else { + HashMap::new() + }; + + for (_, cache) in children_caches.drain() { + let uuid = cache.uuid; + match self.insert_cached_process(cache).await { + Ok(child) => { + self.0.insert(uuid, child); + } + Err(e) => tracing::warn!( + "Failed to rescue cached process {}: {}", + uuid, + e + ), + } + } + Ok(()) + } + // Runs the command in process, inserts a child process to keep track of, and returns a reference to the container struct MinecraftChild // The threads for stdout and stderr are spawned here // Unlike a Hashmap's 'insert', this directly returns the reference to the MinecraftChild rather than any previously stored MinecraftChild that may exist - #[tracing::instrument(skip( self, uuid, - log_path, mc_command, post_command, censor_strings ))] #[tracing::instrument(level = "trace", skip(self))] #[theseus_macros::debug_pin] - pub async fn insert_process( + pub async fn insert_new_process( &mut self, uuid: Uuid, profile_relative_path: ProfilePathId, - log_path: PathBuf, mut mc_command: Command, - post_command: Option, // Command to run after minecraft. + post_command: Option, // Command to run after minecraft. censor_strings: HashMap, ) -> crate::Result>> { // Takes the first element of the commands vector and spawns it - let mut child = mc_command.spawn().map_err(IOError::from)?; - - // Create std watcher threads for stdout and stderr - let shared_output = - SharedOutput::build(&log_path, censor_strings).await?; - if let Some(child_stdout) = child.stdout.take() { - let stdout_clone = shared_output.clone(); - tokio::spawn(async move { - if let Err(e) = stdout_clone.read_stdout(child_stdout).await { - error!("Stdout process died with error: {}", e); - } - }); - } - if let Some(child_stderr) = child.stderr.take() { - let stderr_clone = shared_output.clone(); - tokio::spawn(async move { - if let Err(e) = stderr_clone.read_stderr(child_stderr).await { - error!("Stderr process died with error: {}", e); - } - }); - } + let child = mc_command.spawn().map_err(IOError::from)?; + let child = ChildType::TokioChild(child); // Slots child into manager let pid = child.id().ok_or_else(|| { @@ -91,6 +280,15 @@ impl Children { "Process immediately failed, could not get PID".to_string(), ) })?; + + // Caches process so that it can be restored if the launcher is restarted + child + .cache_process( + uuid, + profile_relative_path.clone(), + post_command.clone(), + ) + .await?; let current_child = Arc::new(RwLock::new(child)); let manager = Some(tokio::spawn(Self::sequential_process_manager( uuid, @@ -115,7 +313,6 @@ impl Children { uuid, profile_relative_path, current_child, - output: shared_output, manager, last_updated_playtime, }; @@ -125,6 +322,96 @@ impl Children { Ok(mchild) } + // Rescues a cached process, inserts a child process to keep track of, and returns a reference to the container struct MinecraftChild + // Essentially 'reconnects' to a process that was launched by theseus before the launcher was restarted + // However, this may not have all the same functionality as a TokioChild, as we only have the PID and not the actual Child + // Only processes who match a cached process (name, time started, pid, etc) will be re-inserted. The function fails with an error if the process is notably different. + #[tracing::instrument(skip(self, cached_process,))] + #[tracing::instrument(level = "trace", skip(self))] + #[theseus_macros::debug_pin] + pub async fn insert_cached_process( + &mut self, + cached_process: ProcessCache, + ) -> crate::Result>> { + let _state = crate::State::get().await?; + + // Takes the first element of the commands vector and spawns it + // Checks processes, compares cached process to actual process + // Fails if notably different (meaning that the PID was reused, and we shouldn't reconnect to it) + { + let mut system = sysinfo::System::new(); + system.refresh_processes(); + let process = system + .process(sysinfo::Pid::from_u32(cached_process.pid)) + .ok_or_else(|| { + crate::ErrorKind::LauncherError(format!( + "Could not find process {}", + cached_process.pid + )) + })?; + + if cached_process.start_time != process.start_time() { + return Err(ErrorKind::LauncherError(format!("Cached process {} has different start time than actual process {}", cached_process.pid, process.start_time())).into()); + } + if cached_process.name != process.name() { + return Err(ErrorKind::LauncherError(format!("Cached process {} has different name than actual process {}", cached_process.pid, process.name())).into()); + } + if cached_process.exe != process.exe().to_string_lossy() { + return Err(ErrorKind::LauncherError(format!("Cached process {} has different exe than actual process {}", cached_process.pid, process.exe().to_string_lossy())).into()); + } + } + + let child = ChildType::RescuedPID(cached_process.pid); + + // Slots child into manager + let pid = child.id().ok_or_else(|| { + crate::ErrorKind::LauncherError( + "Process immediately failed, could not get PID".to_string(), + ) + })?; + + // Re-caches process so that it can be restored if the launcher is restarted + child + .cache_process( + cached_process.uuid, + cached_process.profile_relative_path.clone(), + cached_process.post_command.clone(), + ) + .await?; + + let current_child = Arc::new(RwLock::new(child)); + let manager = Some(tokio::spawn(Self::sequential_process_manager( + cached_process.uuid, + cached_process.post_command, + pid, + current_child.clone(), + cached_process.profile_relative_path.clone(), + ))); + + emit_process( + cached_process.uuid, + pid, + ProcessPayloadType::Launched, + "Launched Minecraft", + ) + .await?; + + let last_updated_playtime = Utc::now(); + + // Create MinecraftChild + let mchild = MinecraftChild { + uuid: cached_process.uuid, + profile_relative_path: cached_process.profile_relative_path, + current_child, + manager, + last_updated_playtime, + }; + + let mchild = Arc::new(RwLock::new(mchild)); + self.0.insert(cached_process.uuid, mchild.clone()); + Ok(mchild) + } + // Spawns a new child process and inserts it into the hashmap // Also, as the process ends, it spawns the follow-up process if it exists // By convention, ExitStatus is last command's exit status, and we exit on the first non-zero exit status @@ -132,28 +419,23 @@ impl Children { #[theseus_macros::debug_pin] async fn sequential_process_manager( uuid: Uuid, - post_command: Option, + post_command: Option, mut current_pid: u32, - current_child: Arc>, + current_child: Arc>, associated_profile: ProfilePathId, - ) -> crate::Result { + ) -> crate::Result { let current_child = current_child.clone(); // Wait on current Minecraft Child let mut mc_exit_status; let mut last_updated_playtime = Utc::now(); loop { - if let Some(t) = current_child - .write() - .await - .try_wait() - .map_err(IOError::from)? - { + if let Some(t) = current_child.write().await.try_wait().await? { mc_exit_status = t; break; } // sleep for 10ms - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; // Auto-update playtime every minute let diff = Utc::now() @@ -168,7 +450,7 @@ impl Children { { tracing::warn!( "Failed to update playtime for profile {}: {}", - associated_profile, + &associated_profile, e ); } @@ -188,7 +470,7 @@ impl Children { { tracing::warn!( "Failed to update playtime for profile {}: {}", - associated_profile, + &associated_profile, e ); } @@ -196,13 +478,15 @@ impl Children { // Publish play time update // Allow failure, it will be stored locally and sent next time // Sent in another thread as first call may take a couple seconds and hold up process ending + let associated_profile_clone = associated_profile.clone(); tokio::spawn(async move { if let Err(e) = - profile::try_update_playtime(&associated_profile).await + profile::try_update_playtime(&associated_profile_clone.clone()) + .await { tracing::warn!( "Failed to update playtime for profile {}: {}", - associated_profile, + &associated_profile_clone, e ); } @@ -224,7 +508,12 @@ impl Children { } } - if !mc_exit_status.success() { + { + let current_child = current_child.write().await; + current_child.remove_cache(uuid).await?; + } + + if !mc_exit_status == 0 { emit_process( uuid, current_pid, @@ -237,9 +526,28 @@ impl Children { } // If a post-command exist, switch to it and wait on it + // First, create the command by splitting arguments + let post_command = if let Some(hook) = post_command { + let mut cmd = hook.split(' '); + if let Some(command) = cmd.next() { + let mut command = Command::new(command); + command + .args(&cmd.collect::>()) + .current_dir(associated_profile.get_full_path().await?); + Some(command) + } else { + None + } + } else { + None + }; + if let Some(mut m_command) = post_command { { - let mut current_child = current_child.write().await; + let mut current_child: tokio::sync::RwLockWriteGuard< + '_, + ChildType, + > = current_child.write().await; let new_child = m_command.spawn().map_err(IOError::from)?; current_pid = new_child.id().ok_or_else(|| { crate::ErrorKind::LauncherError( @@ -247,7 +555,7 @@ impl Children { .to_string(), ) })?; - *current_child = new_child; + *current_child = ChildType::TokioChild(new_child); } emit_process( uuid, @@ -258,12 +566,7 @@ impl Children { .await?; loop { - if let Some(t) = current_child - .write() - .await - .try_wait() - .map_err(IOError::from)? - { + if let Some(t) = current_child.write().await.try_wait().await? { mc_exit_status = t; break; } @@ -296,18 +599,10 @@ impl Children { // Get exit status of a child by PID // Returns None if the child is still running - pub async fn exit_status( - &self, - uuid: &Uuid, - ) -> crate::Result> { + pub async fn exit_status(&self, uuid: &Uuid) -> crate::Result> { if let Some(child) = self.get(uuid) { let child = child.write().await; - let status = child - .current_child - .write() - .await - .try_wait() - .map_err(IOError::from)?; + let status = child.current_child.write().await.try_wait().await?; Ok(status) } else { Ok(None) @@ -326,7 +621,7 @@ impl Children { .write() .await .try_wait() - .map_err(IOError::from)? + .await? .is_none() { keys.push(key); @@ -369,7 +664,7 @@ impl Children { .write() .await .try_wait() - .map_err(IOError::from)? + .await? .is_none() { profiles.push(child.profile_relative_path.clone()); @@ -392,7 +687,7 @@ impl Children { .write() .await .try_wait() - .map_err(IOError::from)? + .await? .is_none() { if let Some(prof) = crate::api::profile::get( @@ -415,107 +710,3 @@ impl Default for Children { Self::new() } } - -// SharedOutput, a wrapper around a String that can be read from and written to concurrently -// Designed to be used with ChildStdout and ChildStderr in a tokio thread to have a simple String storage for the output of a child process -#[derive(Debug, Clone)] -pub struct SharedOutput { - output: Arc>, - log_file: Arc>, - censor_strings: HashMap, -} - -impl SharedOutput { - async fn build( - log_file_path: &Path, - censor_strings: HashMap, - ) -> crate::Result { - Ok(SharedOutput { - output: Arc::new(RwLock::new(String::new())), - log_file: Arc::new(RwLock::new( - File::create(log_file_path) - .await - .map_err(|e| IOError::with_path(e, log_file_path))?, - )), - censor_strings, - }) - } - - // Main entry function to a created SharedOutput, returns the log as a String - pub async fn get_output(&self) -> crate::Result { - let output = self.output.read().await; - Ok(output.clone()) - } - - async fn read_stdout( - &self, - child_stdout: ChildStdout, - ) -> crate::Result<()> { - let mut buf_reader = BufReader::new(child_stdout); - let mut line = String::new(); - - while buf_reader - .read_line(&mut line) - .await - .map_err(IOError::from)? - > 0 - { - let val_line = self.censor_log(line.clone()); - - { - let mut output = self.output.write().await; - output.push_str(&val_line); - } - { - let mut log_file = self.log_file.write().await; - log_file - .write_all(val_line.as_bytes()) - .await - .map_err(IOError::from)?; - } - - line.clear(); - } - Ok(()) - } - - async fn read_stderr( - &self, - child_stderr: ChildStderr, - ) -> crate::Result<()> { - let mut buf_reader = BufReader::new(child_stderr); - let mut line = String::new(); - - while buf_reader - .read_line(&mut line) - .await - .map_err(IOError::from)? - > 0 - { - let val_line = self.censor_log(line.clone()); - - { - let mut output = self.output.write().await; - output.push_str(&val_line); - } - { - let mut log_file = self.log_file.write().await; - log_file - .write_all(val_line.as_bytes()) - .await - .map_err(IOError::from)?; - } - - line.clear(); - } - Ok(()) - } - - fn censor_log(&self, mut val: String) -> String { - for (find, replace) in &self.censor_strings { - val = val.replace(find, replace); - } - - val - } -} diff --git a/theseus/src/state/dirs.rs b/theseus/src/state/dirs.rs index 9a626606d..adfc15eee 100644 --- a/theseus/src/state/dirs.rs +++ b/theseus/src/state/dirs.rs @@ -162,7 +162,7 @@ impl DirectoryInfo { &self, profile_id: &ProfilePathId, ) -> crate::Result { - Ok(profile_id.get_full_path().await?.join("modrinth_logs")) + Ok(profile_id.get_full_path().await?.join("logs")) } #[inline] diff --git a/theseus/src/state/discord.rs b/theseus/src/state/discord.rs index a9d2001f9..9f17c4bfb 100644 --- a/theseus/src/state/discord.rs +++ b/theseus/src/state/discord.rs @@ -16,17 +16,22 @@ pub struct DiscordGuard { impl DiscordGuard { /// Initialize discord IPC client, and attempt to connect to it /// If it fails, it will still return a DiscordGuard, but the client will be unconnected - pub async fn init() -> crate::Result { + pub async fn init(is_offline: bool) -> crate::Result { let mut dipc = - DiscordIpcClient::new("1084015525241311292").map_err(|e| { + DiscordIpcClient::new("1123683254248148992").map_err(|e| { crate::ErrorKind::OtherError(format!( "Could not create Discord client {}", e, )) })?; - let res = dipc.connect(); // Do not need to connect to Discord to use app - let connected = if res.is_ok() { - Arc::new(AtomicBool::new(true)) + + let connected = if !is_offline { + let res = dipc.connect(); // Do not need to connect to Discord to use app + if res.is_ok() { + Arc::new(AtomicBool::new(true)) + } else { + Arc::new(AtomicBool::new(false)) + } } else { Arc::new(AtomicBool::new(false)) }; @@ -51,11 +56,46 @@ impl DiscordGuard { true } + // check online + pub async fn check_online(&self) -> bool { + let state = match State::get().await { + Ok(s) => s, + Err(_) => return false, + }; + let offline = state.offline.read().await; + if *offline { + return false; + } + true + } + /// Set the activity to the given message + /// First checks if discord is disabled, and if so, clear the activity instead pub async fn set_activity( &self, msg: &str, reconnect_if_fail: bool, + ) -> crate::Result<()> { + if !self.check_online().await { + return Ok(()); + } + + // Check if discord is disabled, and if so, clear the activity instead + let state = State::get().await?; + let settings = state.settings.read().await; + if settings.disable_discord_rpc { + Ok(self.clear_activity(true).await?) + } else { + Ok(self.force_set_activity(msg, reconnect_if_fail).await?) + } + } + + /// Sets the activity to the given message, regardless of if discord is disabled or offline + /// Should not be used except for in the above method, or if it is already known that discord is enabled (specifically for state initialization) and we are connected to the internet + pub async fn force_set_activity( + &self, + msg: &str, + reconnect_if_fail: bool, ) -> crate::Result<()> { // Attempt to connect if not connected. Do not continue if it fails, as the client.set_activity can panic if it never was connected if !self.retry_if_not_ready().await { @@ -99,14 +139,13 @@ impl DiscordGuard { Ok(()) } - /* - /// Clear the activity + /// Clear the activity entirely ('disabling' the RPC until the next set_activity) pub async fn clear_activity( &self, reconnect_if_fail: bool, ) -> crate::Result<()> { // Attempt to connect if not connected. Do not continue if it fails, as the client.clear_activity can panic if it never was connected - if !self.retry_if_not_ready().await { + if !self.check_online().await || !self.retry_if_not_ready().await { return Ok(()); } @@ -138,7 +177,7 @@ impl DiscordGuard { res.map_err(could_not_clear_err)?; } Ok(()) - }*/ + } /// Clear the activity, but if there is a running profile, set the activity to that instead pub async fn clear_to_default( @@ -147,6 +186,15 @@ impl DiscordGuard { ) -> crate::Result<()> { let state: Arc> = State::get().await?; + + { + let settings = state.settings.read().await; + if settings.disable_discord_rpc { + println!("Discord is disabled, clearing activity"); + return self.clear_activity(true).await; + } + } + if let Some(existing_child) = state .children .read() diff --git a/theseus/src/state/metadata.rs b/theseus/src/state/metadata.rs index e7da01398..7d67e3097 100644 --- a/theseus/src/state/metadata.rs +++ b/theseus/src/state/metadata.rs @@ -69,6 +69,8 @@ impl Metadata { ) -> crate::Result { let mut metadata = None; let metadata_path = dirs.caches_meta_dir().await.join("metadata.json"); + let metadata_backup_path = + dirs.caches_meta_dir().await.join("metadata.json.bak"); if let Ok(metadata_json) = read_json::(&metadata_path, io_semaphore).await @@ -85,6 +87,13 @@ impl Metadata { ) .await?; + write( + &metadata_backup_path, + &serde_json::to_vec(&metadata_fetch).unwrap_or_default(), + io_semaphore, + ) + .await?; + metadata = Some(metadata_fetch); Ok::<(), crate::Error>(()) } @@ -96,6 +105,18 @@ impl Metadata { tracing::warn!("Unable to fetch launcher metadata: {err}") } } + } else if let Ok(metadata_json) = + read_json::(&metadata_backup_path, io_semaphore).await + { + metadata = Some(metadata_json); + std::fs::copy(&metadata_backup_path, &metadata_path).map_err( + |err| { + crate::ErrorKind::FSError(format!( + "Error restoring metadata backup: {err}" + )) + .as_error() + }, + )?; } if let Some(meta) = metadata { @@ -118,6 +139,15 @@ impl Metadata { .caches_meta_dir() .await .join("metadata.json"); + let metadata_backup_path = state + .directories + .caches_meta_dir() + .await + .join("metadata.json.bak"); + + if metadata_path.exists() { + std::fs::copy(&metadata_path, &metadata_backup_path).unwrap(); + } write( &metadata_path, diff --git a/theseus/src/state/mod.rs b/theseus/src/state/mod.rs index 53db032c4..86f07feee 100644 --- a/theseus/src/state/mod.rs +++ b/theseus/src/state/mod.rs @@ -127,6 +127,10 @@ impl State { .await) } + pub fn initialized() -> bool { + LAUNCHER_STATE.initialized() + } + #[tracing::instrument] #[theseus_macros::debug_pin] async fn initialize_state() -> crate::Result> { @@ -180,16 +184,18 @@ impl State { creds_fut, }?; - let children = Children::new(); let auth_flow = AuthTask::new(); let safety_processes = SafeProcesses::new(); - let discord_rpc = DiscordGuard::init().await?; - { + let discord_rpc = DiscordGuard::init(is_offline).await?; + if !settings.disable_discord_rpc && !is_offline { // Add default Idling to discord rich presence - let _ = discord_rpc.set_activity("Idling...", true).await; + // Force add to avoid recursion + let _ = discord_rpc.force_set_activity("Idling...", true).await; } + let children = Children::new(); + // Starts a loop of checking if we are online, and updating Self::offine_check_loop(); @@ -238,11 +244,6 @@ impl State { /// Updates state with data from the web, if we are online pub fn update() { - tokio::task::spawn(Metadata::update()); - tokio::task::spawn(Tags::update()); - tokio::task::spawn(Profiles::update_projects()); - tokio::task::spawn(Profiles::update_modrinth_versions()); - tokio::task::spawn(CredentialsStore::update_creds()); tokio::task::spawn(async { if let Ok(state) = crate::State::get().await { if !*state.offline.read().await { @@ -252,8 +253,9 @@ impl State { let res4 = Profiles::update_projects(); let res5 = Settings::update_java(); let res6 = CredentialsStore::update_creds(); + let res7 = Settings::update_default_user(); - let _ = join!(res1, res2, res3, res4, res5, res6); + let _ = join!(res1, res2, res3, res4, res5, res6, res7); } } }); diff --git a/theseus/src/state/profiles.rs b/theseus/src/state/profiles.rs index 9bcaeef25..50ffeecb4 100644 --- a/theseus/src/state/profiles.rs +++ b/theseus/src/state/profiles.rs @@ -124,11 +124,22 @@ impl ProjectPathId { &self, profile: ProfilePathId, ) -> crate::Result { - let _state = State::get().await?; let profile_dir = profile.get_full_path().await?; Ok(profile_dir.join(&self.0)) } + // Gets inner path in unix convention as a String + // ie: 'mods\myproj' -> 'mods/myproj' + // Used for exporting to mrpack, which should have a singular convention + pub fn get_inner_path_unix(&self) -> crate::Result { + Ok(self + .0 + .components() + .map(|c| c.as_os_str().to_string_lossy().to_string()) + .collect::>() + .join("/")) + } + // Create a new ProjectPathId from a relative path pub fn new(path: &Path) -> Self { ProjectPathId(PathBuf::from(path)) @@ -193,6 +204,15 @@ pub struct ProfileMetadata { pub struct LinkedData { pub project_id: Option, pub version_id: Option, + + #[serde(default = "default_locked")] + pub locked: Option, +} + +// Called if linked_data is present but locked is not +// Meaning this is a legacy profile, and we should consider it locked +pub fn default_locked() -> Option { + Some(true) } #[derive( @@ -722,7 +742,15 @@ impl Profiles { None } }; + if let Some(profile) = prof { + // Clear out modrinth_logs of all files in profiles folder (these are legacy) + // TODO: should be removed in a future build + let modrinth_logs = path.join("modrinth_logs"); + if modrinth_logs.exists() { + let _ = std::fs::remove_dir_all(modrinth_logs); + } + let path = io::canonicalize(path)?; Profile::watch_fs(&path, file_watcher).await?; profiles.insert(profile.profile_id(), profile); diff --git a/theseus/src/state/settings.rs b/theseus/src/state/settings.rs index 526e60e7b..92f20a6f5 100644 --- a/theseus/src/state/settings.rs +++ b/theseus/src/state/settings.rs @@ -31,6 +31,8 @@ pub struct Settings { pub version: u32, pub collapsed_navigation: bool, #[serde(default)] + pub disable_discord_rpc: bool, + #[serde(default)] pub hide_on_process: bool, #[serde(default)] pub default_page: DefaultPage, @@ -49,8 +51,10 @@ pub struct Settings { impl Settings { #[tracing::instrument] pub async fn init(file: &Path) -> crate::Result { - if file.exists() { - fs::read(&file) + let mut rescued = false; + + let settings = if file.exists() { + let loaded_settings = fs::read(&file) .await .map_err(|err| { crate::ErrorKind::FSError(format!( @@ -61,9 +65,25 @@ impl Settings { .and_then(|it| { serde_json::from_slice::(&it) .map_err(crate::Error::from) - }) + }); + // settings is corrupted. Back up the file and create a new one + if let Err(ref err) = loaded_settings { + tracing::error!("Failed to load settings file: {err}. "); + let backup_file = file.with_extension("json.bak"); + tracing::error!("Corrupted settings file will be backed up as {}, and a new settings file will be created.", backup_file.display()); + let _ = fs::rename(file, backup_file).await; + rescued = true; + } + loaded_settings.ok() + } else { + None + }; + + if let Some(settings) = settings { + Ok(settings) } else { - Ok(Self { + // Create new settings file + let settings = Self { theme: Theme::Dark, memory: MemorySettings::default(), force_fullscreen: false, @@ -77,16 +97,21 @@ impl Settings { max_concurrent_writes: 10, version: CURRENT_FORMAT_VERSION, collapsed_navigation: false, + disable_discord_rpc: false, hide_on_process: false, default_page: DefaultPage::Home, developer_mode: false, opt_out_analytics: false, advanced_rendering: true, - fully_onboarded: false, + fully_onboarded: rescued, // If we rescued the settings file, we should consider the user fully onboarded // By default, the config directory is the same as the settings directory loaded_config_dir: DirectoryInfo::get_initial_settings_dir(), - }) + }; + if rescued { + settings.sync(file).await?; + } + Ok(settings) } } @@ -124,6 +149,32 @@ impl Settings { }; } + #[tracing::instrument] + #[theseus_macros::debug_pin] + pub async fn update_default_user() { + let res = async { + let state = State::get().await?; + let settings_read = state.settings.read().await; + + if settings_read.default_user.is_none() { + drop(settings_read); + let users = state.users.read().await; + let user = users.0.iter().next().map(|(id, _)| *id); + state.settings.write().await.default_user = user; + } + + Ok::<(), crate::Error>(()) + } + .await; + + match res { + Ok(()) => {} + Err(err) => { + tracing::warn!("Unable to update default user: {err}") + } + }; + } + #[tracing::instrument(skip(self))] pub async fn sync(&self, to: &Path) -> crate::Result<()> { fs::write(to, serde_json::to_vec(self)?) diff --git a/theseus/src/state/tags.rs b/theseus/src/state/tags.rs index d28d935d2..c30d7a002 100644 --- a/theseus/src/state/tags.rs +++ b/theseus/src/state/tags.rs @@ -32,6 +32,8 @@ impl Tags { ) -> crate::Result { let mut tags = None; let tags_path = dirs.caches_meta_dir().await.join("tags.json"); + let tags_path_backup = + dirs.caches_meta_dir().await.join("tags.json.bak"); if let Ok(tags_json) = read_json::(&tags_path, io_semaphore).await { @@ -43,11 +45,28 @@ impl Tags { tracing::warn!("Unable to fetch launcher tags: {err}") } } + } else if let Ok(tags_json) = + read_json::(&tags_path_backup, io_semaphore).await + { + tags = Some(tags_json); + std::fs::copy(&tags_path_backup, &tags_path).map_err(|err| { + crate::ErrorKind::FSError(format!( + "Error restoring tags backup: {err}" + )) + .as_error() + })?; } if let Some(tags_data) = tags { write(&tags_path, &serde_json::to_vec(&tags_data)?, io_semaphore) .await?; + write( + &tags_path_backup, + &serde_json::to_vec(&tags_data)?, + io_semaphore, + ) + .await?; + Ok(tags_data) } else { Err(crate::ErrorKind::NoValueFor(String::from("launcher tags")) @@ -68,6 +87,14 @@ impl Tags { let tags_path = state.directories.caches_meta_dir().await.join("tags.json"); + let tags_path_backup = state + .directories + .caches_meta_dir() + .await + .join("tags.json.bak"); + if tags_path.exists() { + std::fs::copy(&tags_path, &tags_path_backup).unwrap(); + } write( &tags_path, diff --git a/theseus_gui/package.json b/theseus_gui/package.json index 4c766697b..41ebe09ae 100644 --- a/theseus_gui/package.json +++ b/theseus_gui/package.json @@ -25,7 +25,8 @@ "vite-svg-loader": "^4.0.0", "vue": "^3.3.4", "vue-multiselect": "^3.0.0-beta.2", - "vue-router": "4.2.1" + "vue-router": "4.2.1", + "vue-virtual-scroller": "2.0.0-beta.8" }, "devDependencies": { "@rollup/plugin-alias": "^4.0.4", diff --git a/theseus_gui/pnpm-lock.yaml b/theseus_gui/pnpm-lock.yaml index 145740cf2..34af6ce8f 100644 --- a/theseus_gui/pnpm-lock.yaml +++ b/theseus_gui/pnpm-lock.yaml @@ -44,6 +44,9 @@ dependencies: vue-router: specifier: 4.2.1 version: 4.2.1(vue@3.3.4) + vue-virtual-scroller: + specifier: 2.0.0-beta.8 + version: 2.0.0-beta.8(vue@3.3.4) devDependencies: '@rollup/plugin-alias': @@ -1309,6 +1312,10 @@ packages: brace-expansion: 1.1.11 dev: true + /mitt@2.1.0: + resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==} + dev: false + /mixpanel-browser@2.47.0: resolution: {integrity: sha512-Ldrva0fRBEIFWmEibBQO1PulfpJVF3pf28Guk09lDirDaSQqqU/xs9zQLwN2rL5VwVtsP1aD3JaCgaa98EjojQ==} dev: false @@ -1738,6 +1745,14 @@ packages: engines: {node: '>= 4.0.0', npm: '>= 3.0.0'} dev: false + /vue-observe-visibility@2.0.0-alpha.1(vue@3.3.4): + resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==} + peerDependencies: + vue: ^3.0.0 + dependencies: + vue: 3.3.4 + dev: false + /vue-resize@2.0.0-alpha.1(vue@3.3.4): resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==} peerDependencies: @@ -1763,6 +1778,17 @@ packages: vue: 3.3.4 dev: false + /vue-virtual-scroller@2.0.0-beta.8(vue@3.3.4): + resolution: {integrity: sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==} + peerDependencies: + vue: ^3.2.0 + dependencies: + mitt: 2.1.0 + vue: 3.3.4 + vue-observe-visibility: 2.0.0-alpha.1(vue@3.3.4) + vue-resize: 2.0.0-alpha.1(vue@3.3.4) + dev: false + /vue@3.3.4: resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==} dependencies: diff --git a/theseus_gui/src-tauri/App.entitlements b/theseus_gui/src-tauri/App.entitlements new file mode 100644 index 000000000..eb4313258 --- /dev/null +++ b/theseus_gui/src-tauri/App.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + + \ No newline at end of file diff --git a/theseus_gui/src-tauri/src/api/jre.rs b/theseus_gui/src-tauri/src/api/jre.rs index 0d1999bf8..3514e294b 100644 --- a/theseus_gui/src-tauri/src/api/jre.rs +++ b/theseus_gui/src-tauri/src/api/jre.rs @@ -13,6 +13,7 @@ pub fn init() -> TauriPlugin { jre_autodetect_java_globals, jre_validate_globals, jre_get_jre, + jre_test_jre, jre_auto_install_java, jre_get_max_memory, ]) @@ -61,6 +62,16 @@ pub async fn jre_get_jre(path: PathBuf) -> Result> { jre::check_jre(path).await.map_err(|e| e.into()) } +// Tests JRE of a certain version +#[tauri::command] +pub async fn jre_test_jre( + path: PathBuf, + major_version: u32, + minor_version: u32, +) -> Result { + Ok(jre::test_jre(path, major_version, minor_version).await?) +} + // Auto installs java for the given java version #[tauri::command] pub async fn jre_auto_install_java(java_version: u32) -> Result { diff --git a/theseus_gui/src-tauri/src/api/logs.rs b/theseus_gui/src-tauri/src/api/logs.rs index 7e253ab90..d328aac4e 100644 --- a/theseus_gui/src-tauri/src/api/logs.rs +++ b/theseus_gui/src-tauri/src/api/logs.rs @@ -1,14 +1,14 @@ use crate::api::Result; use theseus::{ - logs::{self, Logs}, + logs::{self, CensoredString, LatestLogCursor, Logs}, prelude::ProfilePathId, }; /* -A log is a struct containing the datetime string, stdout, and stderr, as follows: +A log is a struct containing the filename string, stdout, and stderr, as follows: pub struct Logs { - pub datetime_string: String, + pub filename: String, pub stdout: String, pub stderr: String, } @@ -18,15 +18,16 @@ pub fn init() -> tauri::plugin::TauriPlugin { tauri::plugin::Builder::new("logs") .invoke_handler(tauri::generate_handler![ logs_get_logs, - logs_get_logs_by_datetime, - logs_get_output_by_datetime, + logs_get_logs_by_filename, + logs_get_output_by_filename, logs_delete_logs, - logs_delete_logs_by_datetime, + logs_delete_logs_by_filename, + logs_get_latest_log_cursor, ]) .build() } -/// Get all Logs for a profile, sorted by datetime +/// Get all Logs for a profile, sorted by filename #[tauri::command] pub async fn logs_get_logs( profile_path: ProfilePathId, @@ -37,21 +38,21 @@ pub async fn logs_get_logs( Ok(val) } -/// Get a Log struct for a profile by profile id and datetime string +/// Get a Log struct for a profile by profile id and filename string #[tauri::command] -pub async fn logs_get_logs_by_datetime( +pub async fn logs_get_logs_by_filename( profile_path: ProfilePathId, - datetime_string: String, + filename: String, ) -> Result { - Ok(logs::get_logs_by_datetime(profile_path, datetime_string).await?) + Ok(logs::get_logs_by_filename(profile_path, filename).await?) } -/// Get the stdout for a profile by profile id and datetime string +/// Get the stdout for a profile by profile id and filename string #[tauri::command] -pub async fn logs_get_output_by_datetime( +pub async fn logs_get_output_by_filename( profile_path: ProfilePathId, - datetime_string: String, -) -> Result { + filename: String, +) -> Result { let profile_path = if let Some(p) = crate::profile::get(&profile_path, None).await? { @@ -63,7 +64,7 @@ pub async fn logs_get_output_by_datetime( .into()); }; - Ok(logs::get_output_by_datetime(&profile_path, &datetime_string).await?) + Ok(logs::get_output_by_filename(&profile_path, &filename).await?) } /// Delete all logs for a profile by profile id @@ -72,11 +73,20 @@ pub async fn logs_delete_logs(profile_path: ProfilePathId) -> Result<()> { Ok(logs::delete_logs(profile_path).await?) } -/// Delete a log for a profile by profile id and datetime string +/// Delete a log for a profile by profile id and filename string #[tauri::command] -pub async fn logs_delete_logs_by_datetime( +pub async fn logs_delete_logs_by_filename( profile_path: ProfilePathId, - datetime_string: String, + filename: String, ) -> Result<()> { - Ok(logs::delete_logs_by_datetime(profile_path, &datetime_string).await?) + Ok(logs::delete_logs_by_filename(profile_path, &filename).await?) +} + +/// Get live log from a cursor +#[tauri::command] +pub async fn logs_get_latest_log_cursor( + profile_path: ProfilePathId, + cursor: u64, // 0 to start at beginning of file +) -> Result { + Ok(logs::get_latest_log_cursor(profile_path, cursor).await?) } diff --git a/theseus_gui/src-tauri/src/api/process.rs b/theseus_gui/src-tauri/src/api/process.rs index 714855ff6..d6dbc676d 100644 --- a/theseus_gui/src-tauri/src/api/process.rs +++ b/theseus_gui/src-tauri/src/api/process.rs @@ -12,7 +12,6 @@ pub fn init() -> tauri::plugin::TauriPlugin { process_get_uuids_by_profile_path, process_get_all_running_profile_paths, process_get_all_running_profiles, - process_get_output_by_uuid, process_kill_by_uuid, process_wait_for_by_uuid, ]) @@ -66,12 +65,6 @@ pub async fn process_get_all_running_profiles() -> Result> { Ok(process::get_all_running_profiles().await?) } -// Gets process stderr by process UUID -#[tauri::command] -pub async fn process_get_output_by_uuid(uuid: Uuid) -> Result { - Ok(process::get_output_by_uuid(&uuid).await?) -} - // Kill a process by process UUID #[tauri::command] pub async fn process_kill_by_uuid(uuid: Uuid) -> Result<()> { diff --git a/theseus_gui/src-tauri/src/api/profile.rs b/theseus_gui/src-tauri/src/api/profile.rs index f620ccde0..51480883d 100644 --- a/theseus_gui/src-tauri/src/api/profile.rs +++ b/theseus_gui/src-tauri/src/api/profile.rs @@ -13,6 +13,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { profile_get, profile_get_optimal_jre_key, profile_get_full_path, + profile_get_mod_full_path, profile_list, profile_check_installed, profile_install, @@ -22,7 +23,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { profile_add_project_from_path, profile_toggle_disable_project, profile_remove_project, - profile_update_managed_modrinth, + profile_update_managed_modrinth_version, profile_repair_managed_modrinth, profile_run, profile_run_wait, @@ -63,6 +64,17 @@ pub async fn profile_get_full_path(path: ProfilePathId) -> Result { Ok(res) } +// Get's a mod's full path +// invoke('plugin:profile|profile_get_mod_full_path',path) +#[tauri::command] +pub async fn profile_get_mod_full_path( + path: ProfilePathId, + project_path: ProjectPathId, +) -> Result { + let res = profile::get_mod_full_path(&path, &project_path).await?; + Ok(res) +} + // Get optimal java version from profile #[tauri::command] pub async fn profile_get_optimal_jre_key( @@ -173,12 +185,16 @@ pub async fn profile_remove_project( Ok(()) } -// Updates a managed Modrinth profile +// Updates a managed Modrinth profile to a version of version_id #[tauri::command] -pub async fn profile_update_managed_modrinth( +pub async fn profile_update_managed_modrinth_version( path: ProfilePathId, + version_id: String, ) -> Result<()> { - Ok(profile::update::update_managed_modrinth(&path).await?) + Ok( + profile::update::update_managed_modrinth_version(&path, &version_id) + .await?, + ) } // Repairs a managed Modrinth profile by updating it to the current version @@ -197,12 +213,16 @@ pub async fn profile_export_mrpack( export_location: PathBuf, included_overrides: Vec, version_id: Option, + description: Option, + name: Option, // only used to cache ) -> Result<()> { profile::export_mrpack( &path, export_location, included_overrides, version_id, + description, + name, ) .await?; Ok(()) diff --git a/theseus_gui/src-tauri/src/api/profile_create.rs b/theseus_gui/src-tauri/src/api/profile_create.rs index 2cbf6548a..fff7a7e84 100644 --- a/theseus_gui/src-tauri/src/api/profile_create.rs +++ b/theseus_gui/src-tauri/src/api/profile_create.rs @@ -4,12 +4,15 @@ use theseus::prelude::*; pub fn init() -> tauri::plugin::TauriPlugin { tauri::plugin::Builder::new("profile_create") - .invoke_handler(tauri::generate_handler![profile_create,]) + .invoke_handler(tauri::generate_handler![ + profile_create, + profile_duplicate + ]) .build() } // Creates a profile at the given filepath and adds it to the in-memory state -// invoke('plugin:profile|profile_add',profile) +// invoke('plugin:profile_create|profile_add',profile) #[tauri::command] pub async fn profile_create( name: String, // the name of the profile, and relative path @@ -33,3 +36,11 @@ pub async fn profile_create( .await?; Ok(res) } + +// Creates a profile from a duplicate +// invoke('plugin:profile_create|profile_duplicate',profile) +#[tauri::command] +pub async fn profile_duplicate(path: ProfilePathId) -> Result { + let res = profile::create::profile_create_from_duplicate(path).await?; + Ok(res) +} diff --git a/theseus_gui/src-tauri/src/api/utils.rs b/theseus_gui/src-tauri/src/api/utils.rs index b7e6aef23..e7e7543df 100644 --- a/theseus_gui/src-tauri/src/api/utils.rs +++ b/theseus_gui/src-tauri/src/api/utils.rs @@ -1,8 +1,12 @@ use serde::{Deserialize, Serialize}; -use theseus::{handler, prelude::CommandPayload, State}; +use theseus::{ + handler, + prelude::{CommandPayload, DirectoryInfo}, + State, +}; use crate::api::Result; -use std::{env, process::Command}; +use std::{env, path::PathBuf, process::Command}; pub fn init() -> tauri::plugin::TauriPlugin { tauri::plugin::Builder::new("utils") @@ -10,6 +14,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { get_os, should_disable_mouseover, show_in_folder, + show_launcher_logs_folder, progress_bars_list, safety_check_safe_loading_bars, get_opening_command, @@ -76,13 +81,19 @@ pub async fn should_disable_mouseover() -> bool { } #[tauri::command] -pub fn show_in_folder(path: String) -> Result<()> { +pub fn show_in_folder(path: PathBuf) -> Result<()> { { #[cfg(target_os = "windows")] { - Command::new("explorer") - .args([&path]) // The comma after select is not a typo - .spawn()?; + if path.is_dir() { + Command::new("explorer") + .args([&path]) // The comma after select is not a typo + .spawn()?; + } else { + Command::new("explorer") + .args(["/select,", &path.to_string_lossy()]) // The comma after select is not a typo + .spawn()?; + } } #[cfg(target_os = "linux")] @@ -90,14 +101,14 @@ pub fn show_in_folder(path: String) -> Result<()> { use std::fs::metadata; use std::path::PathBuf; - if path.contains(',') { + if path.to_string_lossy().to_string().contains(',') { // see https://gitlab.freedesktop.org/dbus/dbus/-/issues/76 let new_path = match metadata(&path)?.is_dir() { true => path, false => { let mut path2 = PathBuf::from(path); path2.pop(); - path2.to_string_lossy().to_string() + path2 } }; Command::new("xdg-open").arg(&new_path).spawn()?; @@ -108,7 +119,13 @@ pub fn show_in_folder(path: String) -> Result<()> { #[cfg(target_os = "macos")] { - Command::new("open").args([&path]).spawn()?; + if path.is_dir() { + Command::new("open").args([&path]).spawn()?; + } else { + Command::new("open") + .args(["-R", &path.as_os_str().to_string_lossy()]) + .spawn()?; + } } Ok::<(), theseus::Error>(()) @@ -117,6 +134,14 @@ pub fn show_in_folder(path: String) -> Result<()> { Ok(()) } +#[tauri::command] +pub fn show_launcher_logs_folder() -> Result<()> { + let path = DirectoryInfo::launcher_logs_dir().unwrap_or_default(); + // failure to get folder just opens filesystem + // (ie: if in debug mode only and launcher_logs never created) + show_in_folder(path) +} + // Get opening command // For example, if a user clicks on an .mrpack to open the app. // This should be called once and only when the app is done booting up and ready to receive a command diff --git a/theseus_gui/src-tauri/src/main.rs b/theseus_gui/src-tauri/src/main.rs index ded8f9aa0..3a3fa0240 100644 --- a/theseus_gui/src-tauri/src/main.rs +++ b/theseus_gui/src-tauri/src/main.rs @@ -13,11 +13,14 @@ mod error; mod macos; // Should be called in launcher initialization +#[tracing::instrument(skip_all)] #[tauri::command] async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> { theseus::EventState::init(app).await?; - State::get().await?; + let s = State::get().await?; State::update(); + + s.children.write().await.rescue_cache().await?; Ok(()) } diff --git a/theseus_gui/src-tauri/tauri.conf.json b/theseus_gui/src-tauri/tauri.conf.json index 374c8501c..c690b4e19 100644 --- a/theseus_gui/src-tauri/tauri.conf.json +++ b/theseus_gui/src-tauri/tauri.conf.json @@ -64,7 +64,7 @@ "identifier": "com.modrinth.theseus", "longDescription": "", "macOS": { - "entitlements": null, + "entitlements": "App.entitlements", "exceptionDomain": "", "frameworks": [], "providerShortName": null, diff --git a/theseus_gui/src/App.vue b/theseus_gui/src/App.vue index f2550a788..601a69d91 100644 --- a/theseus_gui/src/App.vue +++ b/theseus_gui/src/App.vue @@ -7,9 +7,11 @@ import { LibraryIcon, PlusIcon, SettingsIcon, + FileIcon, Button, Notifications, XIcon, + Card, } from 'omorphia' import { useLoading, useTheming } from '@/store/state' import AccountsCard from '@/components/ui/AccountsCard.vue' @@ -19,12 +21,12 @@ import Breadcrumbs from '@/components/ui/Breadcrumbs.vue' import RunningAppBar from '@/components/ui/RunningAppBar.vue' import SplashScreen from '@/components/ui/SplashScreen.vue' import ModrinthLoadingIndicator from '@/components/modrinth-loading-indicator' -import { useNotifications } from '@/store/notifications.js' +import { handleError, useNotifications } from '@/store/notifications.js' import { offline_listener, command_listener, warning_listener } from '@/helpers/events.js' -import { MinimizeIcon, MaximizeIcon } from '@/assets/icons' +import { MinimizeIcon, MaximizeIcon, ChatIcon } from '@/assets/icons' import { type } from '@tauri-apps/api/os' import { appWindow } from '@tauri-apps/api/window' -import { isDev, getOS, isOffline } from '@/helpers/utils.js' +import { isDev, getOS, isOffline, showLauncherLogsFolder } from '@/helpers/utils.js' import { mixpanel_track, mixpanel_init, @@ -40,6 +42,7 @@ import { confirm } from '@tauri-apps/api/dialog' import URLConfirmModal from '@/components/ui/URLConfirmModal.vue' import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue' import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue' +import { install_from_file } from './helpers/pack' const themeStore = useTheming() const urlModal = ref(null) @@ -51,14 +54,17 @@ const showOnboarding = ref(false) const onboardingVideo = ref() +const failureText = ref(null) +const os = ref('') + defineExpose({ initialize: async () => { isLoading.value = false const { theme, opt_out_analytics, collapsed_navigation, advanced_rendering, fully_onboarded } = await get() - const os = await getOS() // video should play if the user is not on linux, and has not onboarded - videoPlaying.value = !fully_onboarded && os !== 'Linux' + os.value = await getOS() + videoPlaying.value = !fully_onboarded && os.value !== 'Linux' const dev = await isDev() const version = await getVersion() showOnboarding.value = !fully_onboarded @@ -98,6 +104,11 @@ defineExpose({ onboardingVideo.value.play() } }, + failure: async (e) => { + isLoading.value = false + failureText.value = e + os.value = await getOS() + }, }) const confirmClose = async () => { @@ -112,6 +123,10 @@ const confirmClose = async () => { } const handleClose = async () => { + if (failureText.value != null) { + await TauriWindow.getCurrent().close() + return + } // State should respond immeiately if it's safe to close // If not, code is deadlocked or worse, so wait 2 seconds and then ask the user to confirm closing // (Exception: if the user is changing config directory, which takes control of the state, and it's taking a significant amount of time for some reason) @@ -129,6 +144,16 @@ const handleClose = async () => { await TauriWindow.getCurrent().close() } +const openSupport = async () => { + window.__TAURI_INVOKE__('tauri', { + __tauriModule: 'Shell', + message: { + cmd: 'open', + path: 'https://discord.gg/modrinth', + }, + }) +} + TauriWindow.getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async () => { await handleClose() }) @@ -193,9 +218,19 @@ document.querySelector('body').addEventListener('auxclick', function (e) { const accounts = ref(null) -command_listener((e) => { - console.log(e) - urlModal.value.show(e) +command_listener(async (e) => { + if (e.event === 'RunMRPack') { + // RunMRPack should directly install a local mrpack given a path + if (e.path.endsWith('.mrpack')) { + await install_from_file(e.path).catch(handleError) + mixpanel_track('InstanceCreate', { + source: 'CreationModalFileDrop', + }) + } + } else { + // Other commands are URL-based (deep linking) + urlModal.value.show(e) + } }) @@ -209,6 +244,46 @@ command_listener((e) => { autoplay @ended="videoPlaying = false" /> +
+
+ +
+
+ +
+

+ Failed to initialize +

+
+
+ Modrinth App failed to load correctly. This may be because of a corrupted file, or because + the app is missing crucial files. +
+
You may be able to fix it one of the following ways:
+
    +
  • Ennsuring you are connected to the internet, then try restarting the app.
  • +
  • Redownloading the app.
  • +
+
+ If it still does not work, you can seek support using the link below. You should provide + the following error, as well as any recent launcher logs in the folder below. +
+
The following error was provided:
+ + + {{ failureText.message }} + + +
+ + + +
+
+
+
@@ -393,6 +468,53 @@ command_listener((e) => { } } +.failure { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + background-color: var(--color-bg); + + .appbar-failure { + display: flex; /* Change to flex to align items horizontally */ + justify-content: flex-end; /* Align items to the right */ + height: 3.25rem; + //no select + user-select: none; + -webkit-user-select: none; + } + + .error-view { + display: flex; /* Change to flex to align items horizontally */ + justify-content: center; + width: 100%; + background-color: var(--color-bg); + + color: var(--color-base); + + .card { + background-color: var(--color-raised-bg); + } + + .error-text { + display: flex; + max-width: 60%; + gap: 0.25rem; + flex-direction: column; + + .error-div { + // spaced out + margin: 0.5rem; + } + + .error-message { + margin: 0.5rem; + background-color: var(--color-button-bg); + } + } + } +} + .nav-container { display: flex; flex-direction: column; @@ -522,4 +644,15 @@ command_listener((e) => { object-fit: cover; border-radius: var(--radius-md); } + +.button-row { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: var(--gap-md); + + .transparent { + padding: var(--gap-sm) 0; + } +} diff --git a/theseus_gui/src/components/GridDisplay.vue b/theseus_gui/src/components/GridDisplay.vue index b182d7982..615ff5e08 100644 --- a/theseus_gui/src/components/GridDisplay.vue +++ b/theseus_gui/src/components/GridDisplay.vue @@ -20,7 +20,7 @@ import { import ContextMenu from '@/components/ui/ContextMenu.vue' import dayjs from 'dayjs' import { useTheming } from '@/store/theme.js' -import { remove } from '@/helpers/profile.js' +import { duplicate, remove } from '@/helpers/profile.js' import { handleError } from '@/store/notifications.js' const props = defineProps({ @@ -51,11 +51,17 @@ async function deleteProfile() { } } -const handleRightClick = (event, item) => { +async function duplicateProfile(p) { + await duplicate(p).catch(handleError) +} + +const handleRightClick = (event, profilePathId) => { + const item = instanceComponents.value.find((x) => x.instance.path === profilePathId) const baseOptions = [ { name: 'add_content' }, { type: 'divider' }, { name: 'edit' }, + { name: 'duplicate' }, { name: 'open' }, { name: 'copy' }, { type: 'divider' }, @@ -100,6 +106,10 @@ const handleOptionsClick = async (args) => { case 'edit': await args.item.seeInstance() break + case 'duplicate': + if (args.item.instance.install_stage == 'installed') + await duplicateProfile(args.item.instance.path) + break case 'open': await args.item.openFolder() break @@ -131,7 +141,7 @@ const filteredResults = computed(() => { if (sortBy.value === 'Game version') { instances.sort((a, b) => { - return a.metadata.name.localeCompare(b.metadata.game_version) + return a.metadata.game_version.localeCompare(b.metadata.game_version) }) } @@ -285,11 +295,11 @@ const filteredResults = computed(() => {
@@ -298,6 +308,7 @@ const filteredResults = computed(() => { + diff --git a/theseus_gui/src/components/RowDisplay.vue b/theseus_gui/src/components/RowDisplay.vue index 54e9ce923..d260f6e54 100644 --- a/theseus_gui/src/components/RowDisplay.vue +++ b/theseus_gui/src/components/RowDisplay.vue @@ -25,7 +25,7 @@ import { kill_by_uuid, } from '@/helpers/process.js' import { handleError } from '@/store/notifications.js' -import { remove, run } from '@/helpers/profile.js' +import { duplicate, remove, run } from '@/helpers/profile.js' import { useRouter } from 'vue-router' import { showProfileInFolder } from '@/helpers/utils.js' import { useFetch } from '@/helpers/fetch.js' @@ -70,11 +70,16 @@ async function deleteProfile() { } } +async function duplicateProfile(p) { + await duplicate(p).catch(handleError) +} + const handleInstanceRightClick = async (event, passedInstance) => { const baseOptions = [ { name: 'add_content' }, { type: 'divider' }, { name: 'edit' }, + { name: 'duplicate' }, { name: 'open_folder' }, { name: 'copy_path' }, { type: 'divider' }, @@ -150,6 +155,9 @@ const handleOptionsClick = async (args) => { path: `/instance/${encodeURIComponent(args.item.path)}/`, }) break + case 'duplicate': + if (args.item.install_stage == 'installed') await duplicateProfile(args.item.path) + break case 'delete': currentDeleteInstance.value = args.item.path deleteConfirmModal.value.show() @@ -237,7 +245,7 @@ onUnmounted(() => {
@@ -263,6 +271,7 @@ onUnmounted(() => { + diff --git a/theseus_gui/src/components/ui/AccountsCard.vue b/theseus_gui/src/components/ui/AccountsCard.vue index 5c58558bc..473c63e07 100644 --- a/theseus_gui/src/components/ui/AccountsCard.vue +++ b/theseus_gui/src/components/ui/AccountsCard.vue @@ -105,7 +105,7 @@ import { GlobeIcon, ClipboardCopyIcon, } from 'omorphia' -import { ref, computed, onMounted, onBeforeUnmount } from 'vue' +import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue' import { users, remove_user, @@ -116,6 +116,7 @@ import { get, set } from '@/helpers/settings' import { handleError } from '@/store/state.js' import { mixpanel_track } from '@/helpers/mixpanel' import QrcodeVue from 'qrcode.vue' +import { process_listener } from '@/helpers/events' defineProps({ mode: { @@ -214,6 +215,12 @@ const handleClickOutside = (event) => { } } +const unlisten = await process_listener(async (e) => { + if (e.event === 'launched') { + await refreshValues() + } +}) + onMounted(() => { window.addEventListener('click', handleClickOutside) }) @@ -221,6 +228,10 @@ onMounted(() => { onBeforeUnmount(() => { window.removeEventListener('click', handleClickOutside) }) + +onUnmounted(() => { + unlisten() +}) diff --git a/theseus_gui/src/components/ui/JavaSelector.vue b/theseus_gui/src/components/ui/JavaSelector.vue index aad0c0034..cdecdda44 100644 --- a/theseus_gui/src/components/ui/JavaSelector.vue +++ b/theseus_gui/src/components/ui/JavaSelector.vue @@ -18,6 +18,14 @@ " /> + - + diff --git a/theseus_gui/src/components/ui/tutorial/OnboardingScreen.vue b/theseus_gui/src/components/ui/tutorial/OnboardingScreen.vue index 8b5df5392..d75ef5206 100644 --- a/theseus_gui/src/components/ui/tutorial/OnboardingScreen.vue +++ b/theseus_gui/src/components/ui/tutorial/OnboardingScreen.vue @@ -295,7 +295,7 @@ onMounted(async () => { :previous-function="prevPhase" :progress="phase" title="Settings" - description="You can view and change the settings for the Modrinth App here. You can change the appearance, set and download new Java versions, and more." + description="You will be able to view and change the settings for the Modrinth App here. You can change the appearance, set and download new Java versions, and more." /> { switch (releaseType) { case 'release': diff --git a/theseus_gui/src/main.js b/theseus_gui/src/main.js index 2ccaa748d..6086d334a 100644 --- a/theseus_gui/src/main.js +++ b/theseus_gui/src/main.js @@ -59,5 +59,6 @@ initialize_state() }) }) .catch((err) => { - console.error(err) + console.error('Failed to initialize app', err) + mountedApp.failure(err) }) diff --git a/theseus_gui/src/pages/Settings.vue b/theseus_gui/src/pages/Settings.vue index 5e4ea94f4..4ed6d2ecf 100644 --- a/theseus_gui/src/pages/Settings.vue +++ b/theseus_gui/src/pages/Settings.vue @@ -133,11 +133,7 @@ async function refreshDir() { class="login-screen-modal" :noblur="!themeStore.advancedRendering" > - +
+
+ + +
@@ -372,7 +383,7 @@ async function refreshDir() {
- + @@ -149,6 +152,7 @@ import { isOffline, showProfileInFolder } from '@/helpers/utils.js' import ContextMenu from '@/components/ui/ContextMenu.vue' import { mixpanel_track } from '@/helpers/mixpanel' import { convertFileSrc } from '@tauri-apps/api/tauri' +import { useFetch } from '@/helpers/fetch' const route = useRoute() @@ -197,6 +201,15 @@ const checkProcess = async () => { uuid.value = null } +// Get information on associated modrinth versions, if any +const modrinthVersions = ref([]) +if (!(await isOffline()) && instance.value.metadata.linked_data) { + modrinthVersions.value = await useFetch( + `https://api.modrinth.com/v2/project/${instance.value.metadata.linked_data.project_id}/version`, + 'project' + ) +} + await checkProcess() const stopInstance = async (context) => { diff --git a/theseus_gui/src/pages/instance/Logs.vue b/theseus_gui/src/pages/instance/Logs.vue index 4756ce66b..6aa854c1c 100644 --- a/theseus_gui/src/pages/instance/Logs.vue +++ b/theseus_gui/src/pages/instance/Logs.vue @@ -20,6 +20,15 @@ Share + +
-
- + +
+ + {{ level }} +
+
+
+ - {{ line }}
- +
+ {{ + item.prefix + }} + {{ item.text }} +
+
{ + levelFilters.value[level.toLowerCase()] = true +}) +const searchFilter = ref('') + +function shouldDisplay(processedLine) { + if (!processedLine.level) { + return true + } + + if (!levelFilters.value[processedLine.level.toLowerCase()]) { + return false + } + if (searchFilter.value !== '') { + if (!processedLine.text.toLowerCase().includes(searchFilter.value.toLowerCase())) { + return false + } + } + return true +} + +// Selects from the processed logs which ones should be displayed (shouldDisplay) +// In addition, splits each line by \n. Each split line is given the same properties as the original line +const displayProcessedLogs = computed(() => { + return processedLogs.value.filter((l) => shouldDisplay(l)) +}) + +const processedLogs = computed(() => { + // split based on newline and timestamp lookahead + // (not just newline because of multiline messages) + const splitPattern = /\n(?=(?:#|\[\d\d:\d\d:\d\d\]))/ + + const lines = logs.value[selectedLogIndex.value]?.stdout.split(splitPattern) || [] + const processed = [] + let id = 0 + for (let i = 0; i < lines.length; i++) { + // Then split off of \n. + // Lines that are not the first have prefix = null + const text = getLineText(lines[i]) + const prefix = getLinePrefix(lines[i]) + const prefixColor = getLineColor(lines[i], true) + const textColor = getLineColor(lines[i], false) + const weight = getLineWeight(lines[i]) + const level = getLineLevel(lines[i]) + text.split('\n').forEach((line, index) => { + processed.push({ + id: id, + text: line, + prefix: index === 0 ? prefix : null, + prefixColor: prefixColor, + textColor: textColor, + weight: weight, + level: level, + }) + id += 1 + }) + } + return processed +}) + async function getLiveLog() { if (route.params.id) { const uuids = await get_uuids_by_profile_path(route.params.id).catch(handleError) let returnValue if (uuids.length === 0) { - returnValue = 'No live game detected. \nStart your game to proceed' + returnValue = emptyText.join('\n') } else { - returnValue = await get_output_by_uuid(uuids[0]).catch(handleError) + const logCursor = await get_latest_log_cursor( + props.instance.path, + currentLiveLogCursor.value + ).catch(handleError) + if (logCursor.new_file) { + currentLiveLog.value = '' + } + currentLiveLog.value = currentLiveLog.value + logCursor.output + currentLiveLogCursor.value = logCursor.cursor + returnValue = currentLiveLog.value } - return { name: 'Live Log', stdout: returnValue, live: true } } return null @@ -112,9 +241,25 @@ async function getLiveLog() { async function getLogs() { return (await get_logs(props.instance.path, true).catch(handleError)).reverse().map((log) => { - log.name = dayjs( - log.datetime_string.slice(0, 8) + 'T' + log.datetime_string.slice(9) - ).calendar() + if (log.filename == 'latest.log') { + log.name = 'Latest Log' + } else { + let filename = log.filename.split('.')[0] + let day = dayjs(filename.slice(0, 10)) + if (day.isValid()) { + if (day.isToday()) { + log.name = 'Today' + } else if (day.isYesterday()) { + log.name = 'Yesterday' + } else { + log.name = day.format('MMMM D, YYYY') + } + // Displays as "Today-1", "Today-2", etc, matching minecraft log naming but with the date + log.name = log.name + filename.slice(10) + } else { + log.name = filename + } + } log.stdout = 'Loading...' return log }) @@ -152,29 +297,127 @@ watch(selectedLogIndex, async (newIndex) => { if (logs.value.length > 1 && newIndex !== 0) { logs.value[newIndex].stdout = 'Loading...' - logs.value[newIndex].stdout = await get_output_by_datetime( + logs.value[newIndex].stdout = await get_output_by_filename( props.instance.path, - logs.value[newIndex].datetime_string + logs.value[newIndex].filename ).catch(handleError) } }) -if (logs.value.length >= 1) { +if (logs.value.length > 1 && !props.playing) { selectedLogIndex.value = 1 +} else { + selectedLogIndex.value = 0 } const deleteLog = async () => { if (logs.value[selectedLogIndex.value] && selectedLogIndex.value !== 0) { let deleteIndex = selectedLogIndex.value selectedLogIndex.value = deleteIndex - 1 - await delete_logs_by_datetime( - props.instance.path, - logs.value[deleteIndex].datetime_string - ).catch(handleError) + await delete_logs_by_filename(props.instance.path, logs.value[deleteIndex].filename).catch( + handleError + ) await setLogs() } } +const clearLiveLog = async () => { + currentLiveLog.value = '' + // does not reset cursor +} + +const isLineLevel = (text, level) => { + if ((text.includes('/INFO') || text.includes('[System] [CHAT]')) && level === 'info') { + return true + } + + if (text.includes('/WARN') && level === 'warn') { + return true + } + + if (text.includes('/DEBUG') && level === 'debug') { + return true + } + + if (text.includes('/TRACE') && level === 'trace') { + return true + } + + const errorTriggers = ['/ERROR', 'Exception:', ':?]', 'Error', '[thread', ' at'] + if (level === 'error') { + for (const trigger of errorTriggers) { + if (text.includes(trigger)) return true + } + } + + if (text.trim()[0] === '#' && level === 'comment') { + return true + } + return false +} + +const getLineWeight = (text) => { + if ( + !logsColored || + isLineLevel(text, 'info') || + isLineLevel(text, 'debug') || + isLineLevel(text, 'trace') + ) { + return 'normal' + } + + if (isLineLevel(text, 'error') || isLineLevel(text, 'warn')) { + return 'bold' + } +} + +const getLineLevel = (text) => { + for (const level of levels) { + if (isLineLevel(text, level.toLowerCase())) { + return level + } + } +} + +const getLineColor = (text, prefix) => { + if (isLineLevel(text, 'comment')) { + return 'var(--color-green)' + } + + if (!logsColored || text.includes('[System] [CHAT]')) { + return 'var(--color-white)' + } + if ( + (isLineLevel(text, 'info') || isLineLevel(text, 'debug') || isLineLevel(text, 'trace')) && + prefix + ) { + return 'var(--color-blue)' + } + if (isLineLevel(text, 'warn')) { + return 'var(--color-orange)' + } + if (isLineLevel(text, 'error')) { + return 'var(--color-red)' + } +} + +const getLinePrefix = (text) => { + if (text.includes(']:')) { + return text.split(']:')[0] + ']:' + } +} + +const getLineText = (text) => { + if (text.includes(']:')) { + if (text.split(']:').length > 2) { + return text.split(']:').slice(1).join(']:') + } + return text.split(']:')[1] + } else { + return text + } +} + function handleUserScroll() { if (!isAutoScrolling.value) { userScrolled.value = true @@ -185,19 +428,14 @@ interval.value = setInterval(async () => { if (logs.value.length > 0) { logs.value[0] = await getLiveLog() + const scroll = logContainer.value.getScroll() // Allow resetting of userScrolled if the user scrolls to the bottom if (selectedLogIndex.value === 0) { - if ( - logContainer.value.scrollTop + logContainer.value.offsetHeight >= - logContainer.value.scrollHeight - 10 - ) - userScrolled.value = false - + if (scroll.end >= logContainer.value.$el.scrollHeight - 10) userScrolled.value = false if (!userScrolled.value) { await nextTick() isAutoScrolling.value = true - logContainer.value.scrollTop = - logContainer.value.scrollHeight - logContainer.value.offsetHeight + logContainer.value.scrollToItem(displayProcessedLogs.value.length - 1) setTimeout(() => (isAutoScrolling.value = false), 50) } } @@ -206,9 +444,13 @@ interval.value = setInterval(async () => { const unlistenProcesses = await process_listener(async (e) => { if (e.event === 'launched') { + currentLiveLog.value = '' + currentLiveLogCursor.value = 0 selectedLogIndex.value = 0 } if (e.event === 'finished') { + currentLiveLog.value = '' + currentLiveLogCursor.value = 0 userScrolled.value = false await setLogs() selectedLogIndex.value = 1 @@ -216,11 +458,11 @@ const unlistenProcesses = await process_listener(async (e) => { }) onMounted(() => { - logContainer.value.addEventListener('scroll', handleUserScroll) + logContainer.value.$el.addEventListener('scroll', handleUserScroll) }) onBeforeUnmount(() => { - logContainer.value.removeEventListener('scroll', handleUserScroll) + logContainer.value.$el.removeEventListener('scroll', handleUserScroll) }) onUnmounted(() => { clearInterval(interval.value) @@ -257,7 +499,9 @@ onUnmounted(() => { color: var(--color-contrast); border-radius: var(--radius-lg); padding: 1.5rem; - overflow: auto; + overflow-x: auto; /* Enables horizontal scrolling */ + overflow-y: hidden; /* Disables vertical scrolling on this wrapper */ + white-space: nowrap; /* Keeps content on a single line */ white-space: normal; color-scheme: dark; @@ -265,4 +509,37 @@ onUnmounted(() => { white-space: pre; } } + +.filter-checkbox { + margin-bottom: 0.3rem; + font-size: 1rem; + + svg { + display: flex; + align-self: center; + justify-self: center; + } +} +.filter-group { + display: flex; + padding: 0.6rem; + flex-direction: row; + gap: 0.5rem; +} + +:deep(.vue-recycle-scroller__item-wrapper) { + overflow: visible; /* Enables horizontal scrolling */ +} + +.scroller { + height: 100%; +} + +.user { + height: 32%; + padding: 0 12px; + display: flex; + + align-items: center; +} diff --git a/theseus_gui/src/pages/instance/Mods.vue b/theseus_gui/src/pages/instance/Mods.vue index 3501fba2e..da04ebd24 100644 --- a/theseus_gui/src/pages/instance/Mods.vue +++ b/theseus_gui/src/pages/instance/Mods.vue @@ -26,21 +26,25 @@ - + + Share -
+
-
+
-
+
-
-
-
+
-
+
@@ -301,6 +302,14 @@
+