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 <jaiagr+gpg@pm.me>
This commit is contained in:
Wyatt Verchere
2023-09-12 09:27:03 -07:00
committed by GitHub
parent bc02192d80
commit 1e8852b540
63 changed files with 2677 additions and 719 deletions

View File

@@ -25,9 +25,12 @@ body:
description: A clear and concise description of what you expected to happen. description: A clear and concise description of what you expected to happen.
validations: validations:
required: false 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 - type: textarea
attributes: attributes:
label: Additional context 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: validations:
required: false required: false

View File

@@ -8,7 +8,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
platform: [macos-latest, windows-latest, ubuntu-20.04] platform: [macos-latest, windows-latest, ubuntu-20.04, ubuntu-22.04]
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
defaults: defaults:
@@ -62,7 +62,7 @@ jobs:
if: startsWith(matrix.platform, 'ubuntu') if: startsWith(matrix.platform, 'ubuntu')
run: | run: |
sudo apt-get update 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 - name: Install frontend dependencies
run: pnpm install run: pnpm install

88
Cargo.lock generated
View File

@@ -830,6 +830,30 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.16" version = "0.8.16"
@@ -1146,6 +1170,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b"
[[package]]
name = "either"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "2.1.1" version = "2.1.1"
@@ -1309,9 +1339,9 @@ dependencies = [
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.0.26" version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" checksum = "c6c98ee8095e9d1dcbf2fcc6d95acccb90d1c81db1e44725c6a984b1dbdfb010"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"miniz_oxide", "miniz_oxide",
@@ -2703,6 +2733,15 @@ dependencies = [
"notify", "notify",
] ]
[[package]]
name = "ntapi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.46.0"
@@ -3440,6 +3479,28 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" 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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.2.16" version = "0.2.16"
@@ -4223,6 +4284,21 @@ dependencies = [
"windows-sys 0.45.0", "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]] [[package]]
name = "system-deps" name = "system-deps"
version = "5.0.0" version = "5.0.0"
@@ -4609,7 +4685,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus" name = "theseus"
version = "0.5.3" version = "0.5.4"
dependencies = [ dependencies = [
"async-recursion", "async-recursion",
"async-tungstenite", "async-tungstenite",
@@ -4620,6 +4696,7 @@ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"discord-rich-presence", "discord-rich-presence",
"dunce", "dunce",
"flate2",
"futures", "futures",
"indicatif", "indicatif",
"lazy_static", "lazy_static",
@@ -4634,6 +4711,7 @@ dependencies = [
"sha1 0.6.1", "sha1 0.6.1",
"sha2 0.9.9", "sha2 0.9.9",
"sys-info", "sys-info",
"sysinfo",
"tauri", "tauri",
"tempfile", "tempfile",
"theseus_macros", "theseus_macros",
@@ -4655,7 +4733,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_cli" name = "theseus_cli"
version = "0.5.3" version = "0.5.4"
dependencies = [ dependencies = [
"argh", "argh",
"color-eyre", "color-eyre",
@@ -4682,7 +4760,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_gui" name = "theseus_gui"
version = "0.5.3" version = "0.5.4"
dependencies = [ dependencies = [
"chrono", "chrono",
"cocoa", "cocoa",

View File

@@ -20,15 +20,18 @@ url = "2.2"
uuid = { version = "1.1", features = ["serde", "v4"] } uuid = { version = "1.1", features = ["serde", "v4"] }
zip = "0.6.5" zip = "0.6.5"
async_zip = { version = "0.0.13", features = ["full"] } async_zip = { version = "0.0.13", features = ["full"] }
flate2 = "1.0.27"
tempfile = "3.5.0" tempfile = "3.5.0"
urlencoding = "2.1.3" urlencoding = "2.1.3"
chrono = { version = "0.4.19", features = ["serde"] } chrono = { version = "0.4.19", features = ["serde"] }
daedalus = { version = "0.1.25" } daedalus = { version = "0.1.25" }
dirs = "5.0.1" dirs = "5.0.1"
regex = "1.5" regex = "1.5"
sys-info = "0.9.0" sys-info = "0.9.0"
sysinfo = "0.29.9"
thiserror = "1.0" thiserror = "1.0"
tracing = "0.1.37" tracing = "0.1.37"

View File

@@ -1,5 +1,9 @@
//! Authentication flow interface //! 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 chrono::Utc;
use crate::state::AuthTask; use crate::state::AuthTask;
@@ -44,20 +48,34 @@ pub async fn refresh(user: uuid::Uuid) -> crate::Result<Credentials> {
.as_error() .as_error()
})?; })?;
let fetch_semaphore = &state.fetch_semaphore; let offline = *state.offline.read().await;
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( if !offline {
"Please re-authenticate with your Minecraft account!".to_string(), let fetch_semaphore: &crate::util::fetch::FetchSemaphore =
) &state.fetch_semaphore;
.as_error()); 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) Ok(credentials)
} }

View File

@@ -7,6 +7,7 @@ use crate::event::emit::{emit_loading, init_loading};
use crate::state::CredentialsStore; use crate::state::CredentialsStore;
use crate::util::fetch::{fetch_advanced, fetch_json}; use crate::util::fetch::{fetch_advanced, fetch_json};
use crate::util::io;
use crate::util::jre::extract_java_majorminor_version; use crate::util::jre::extract_java_majorminor_version;
use crate::{ use crate::{
state::JavaGlobals, state::JavaGlobals,
@@ -92,7 +93,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
let packages = fetch_json::<Vec<Package>>( let packages = fetch_json::<Vec<Package>>(
Method::GET, Method::GET,
&format!( &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 std::env::consts::ARCH, java_version, std::env::consts::OS
), ),
None, None,
@@ -124,6 +125,17 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
)) ))
})?; })?;
// 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?; emit_loading(&loading_bar, 0.0, Some("Extracting java")).await?;
archive.extract(&path).map_err(|_| { archive.extract(&path).map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError( crate::Error::from(crate::ErrorKind::InputError(
@@ -180,6 +192,20 @@ pub async fn check_jre(path: PathBuf) -> crate::Result<Option<JavaVersion>> {
Ok(jre::check_java_at_filepath(&path).await) 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<bool> {
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. // Gets maximum memory in KiB.
pub async fn get_max_memory() -> crate::Result<u64> { pub async fn get_max_memory() -> crate::Result<u64> {
Ok(sys_info::mem_info() Ok(sys_info::mem_info()

View File

@@ -1,30 +1,70 @@
use std::io::{Read, SeekFrom};
use crate::{ use crate::{
prelude::Credentials,
util::io::{self, IOError}, util::io::{self, IOError},
{state::ProfilePathId, State}, {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 struct Logs {
pub datetime_string: String, pub filename: String,
pub output: Option<String>, pub output: Option<CensoredString>,
} }
#[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<Credentials>) -> 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 { impl Logs {
async fn build( async fn build(
profile_subpath: &ProfilePathId, profile_subpath: &ProfilePathId,
datetime_string: String, filename: String,
clear_contents: Option<bool>, clear_contents: Option<bool>,
) -> crate::Result<Self> { ) -> crate::Result<Self> {
Ok(Self { Ok(Self {
output: if clear_contents.unwrap_or(false) { output: if clear_contents.unwrap_or(false) {
None None
} else { } else {
Some( Some(get_output_by_filename(profile_subpath, &filename).await?)
get_output_by_datetime(profile_subpath, &datetime_string)
.await?,
)
}, },
datetime_string, filename,
}) })
} }
} }
@@ -51,33 +91,31 @@ pub async fn get_logs(
for entry in std::fs::read_dir(&logs_folder) for entry in std::fs::read_dir(&logs_folder)
.map_err(|e| IOError::with_path(e, &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))?; entry.map_err(|e| IOError::with_path(e, &logs_folder))?;
let path = entry.path(); let path = entry.path();
if path.is_dir() { if !path.is_file() {
if let Some(datetime_string) = path.file_name() { continue;
logs.push( }
Logs::build( if let Some(file_name) = path.file_name() {
&profile_path, let file_name = file_name.to_string_lossy().to_string();
datetime_string.to_string_lossy().to_string(),
clear_contents, logs.push(
) Logs::build(&profile_path, file_name, clear_contents).await,
.await, );
);
}
} }
} }
} }
let mut logs = logs.into_iter().collect::<crate::Result<Vec<Logs>>>()?; let mut logs = logs.into_iter().collect::<crate::Result<Vec<Logs>>>()?;
logs.sort_by_key(|x| x.datetime_string.clone()); logs.sort_by_key(|x| x.filename.clone());
Ok(logs) Ok(logs)
} }
#[tracing::instrument] #[tracing::instrument]
pub async fn get_logs_by_datetime( pub async fn get_logs_by_filename(
profile_path: ProfilePathId, profile_path: ProfilePathId,
datetime_string: String, filename: String,
) -> crate::Result<Logs> { ) -> crate::Result<Logs> {
let profile_path = let profile_path =
if let Some(p) = crate::profile::get(&profile_path, None).await? { if let Some(p) = crate::profile::get(&profile_path, None).await? {
@@ -89,23 +127,66 @@ pub async fn get_logs_by_datetime(
.into()); .into());
}; };
Ok(Logs { Ok(Logs {
output: Some( output: Some(get_output_by_filename(&profile_path, &filename).await?),
get_output_by_datetime(&profile_path, &datetime_string).await?, filename,
),
datetime_string,
}) })
} }
#[tracing::instrument] #[tracing::instrument]
pub async fn get_output_by_datetime( pub async fn get_output_by_filename(
profile_subpath: &ProfilePathId, profile_subpath: &ProfilePathId,
datetime_string: &str, file_name: &str,
) -> crate::Result<String> { ) -> crate::Result<CensoredString> {
let state = State::get().await?; let state = State::get().await?;
let logs_folder = let logs_folder =
state.directories.profile_logs_dir(profile_subpath).await?; state.directories.profile_logs_dir(profile_subpath).await?;
let path = logs_folder.join(datetime_string).join("stdout.log"); let path = logs_folder.join(file_name);
Ok(io::read_to_string(&path).await?)
let credentials: Vec<Credentials> =
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] #[tracing::instrument]
@@ -135,9 +216,9 @@ pub async fn delete_logs(profile_path: ProfilePathId) -> crate::Result<()> {
} }
#[tracing::instrument] #[tracing::instrument]
pub async fn delete_logs_by_datetime( pub async fn delete_logs_by_filename(
profile_path: ProfilePathId, profile_path: ProfilePathId,
datetime_string: &str, filename: &str,
) -> crate::Result<()> { ) -> crate::Result<()> {
let profile_path = let profile_path =
if let Some(p) = crate::profile::get(&profile_path, None).await? { 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 state = State::get().await?;
let logs_folder = state.directories.profile_logs_dir(&profile_path).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?; io::remove_dir_all(&path).await?;
Ok(()) 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<LatestLogCursor> {
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<Credentials> =
state.users.read().await.clone().0.into_values().collect();
let output = CensoredString::censor(output, &credentials);
Ok(LatestLogCursor {
cursor,
new_file,
output,
})
}

View File

@@ -218,6 +218,10 @@ async fn import_atlauncher_unmanaged(
prof.metadata.linked_data = Some(LinkedData { prof.metadata.linked_data = Some(LinkedData {
project_id: description.project_id.clone(), project_id: description.project_id.clone(),
version_id: description.version_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.icon = description.icon.clone();
prof.metadata.game_version = game_version.clone(); prof.metadata.game_version = game_version.clone();

View File

@@ -306,6 +306,7 @@ async fn import_mmc_unmanaged(
&description, &description,
&backup_name, &backup_name,
&dependencies, &dependencies,
false,
) )
.await?; .await?;

View File

@@ -251,7 +251,7 @@ pub async fn recache_icon(
} }
} }
async fn copy_dotminecraft( pub async fn copy_dotminecraft(
profile_path_id: ProfilePathId, profile_path_id: ProfilePathId,
dotminecraft: PathBuf, dotminecraft: PathBuf,
io_semaphore: &IoSemaphore, io_semaphore: &IoSemaphore,

View File

@@ -153,6 +153,7 @@ pub fn get_profile_from_pack(
linked_data: Some(LinkedData { linked_data: Some(LinkedData {
project_id: Some(project_id), project_id: Some(project_id),
version_id: Some(version_id), version_id: Some(version_id),
locked: Some(true),
}), }),
..Default::default() ..Default::default()
}, },
@@ -179,20 +180,29 @@ pub async fn generate_pack_from_version_id(
title: String, title: String,
icon_url: Option<String>, icon_url: Option<String>,
profile_path: ProfilePathId, 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<LoadingBarId>,
) -> crate::Result<CreatePack> { ) -> crate::Result<CreatePack> {
let state = State::get().await?; let state = State::get().await?;
let loading_bar = init_loading( let loading_bar = if let Some(bar) = initialized_loading_bar {
LoadingBarType::PackFileDownload { emit_loading(&bar, 0.0, Some("Downloading pack file")).await?;
profile_path: profile_path.get_full_path().await?, bar
pack_name: title, } else {
icon: icon_url, init_loading(
pack_version: version_id.clone(), LoadingBarType::PackFileDownload {
}, profile_path: profile_path.get_full_path().await?,
100.0, pack_name: title,
"Downloading pack file", icon: icon_url,
) pack_version: version_id.clone(),
.await?; },
100.0,
"Downloading pack file",
)
.await?
};
emit_loading(&loading_bar, 0.0, Some("Fetching version")).await?; emit_loading(&loading_bar, 0.0, Some("Fetching version")).await?;
let creds = state.credentials.read().await; let creds = state.credentials.read().await;
@@ -313,6 +323,7 @@ pub async fn set_profile_information(
description: &CreatePackDescription, description: &CreatePackDescription,
backup_name: &str, backup_name: &str,
dependencies: &HashMap<PackDependency, String>, dependencies: &HashMap<PackDependency, String>,
ignore_lock: bool, // do not change locked status
) -> crate::Result<()> { ) -> crate::Result<()> {
let mut game_version: Option<&String> = None; let mut game_version: Option<&String> = None;
let mut mod_loader = None; let mut mod_loader = None;
@@ -370,6 +381,14 @@ pub async fn set_profile_information(
prof.metadata.linked_data = Some(LinkedData { prof.metadata.linked_data = Some(LinkedData {
project_id: description.project_id.clone(), project_id: description.project_id.clone(),
version_id: description.version_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.icon = description.icon.clone();
prof.metadata.game_version = game_version.clone(); prof.metadata.game_version = game_version.clone();

View File

@@ -1,3 +1,4 @@
use crate::config::MODRINTH_API_URL;
use crate::event::emit::{ use crate::event::emit::{
emit_loading, init_or_edit_loading, loading_try_for_each_concurrent, emit_loading, init_or_edit_loading, loading_try_for_each_concurrent,
}; };
@@ -5,13 +6,16 @@ use crate::event::LoadingBarType;
use crate::pack::install_from::{ use crate::pack::install_from::{
set_profile_information, EnvType, PackFile, PackFileHash, set_profile_information, EnvType, PackFile, PackFileHash,
}; };
use crate::prelude::ProfilePathId; use crate::prelude::{ModrinthVersion, ProfilePathId, ProjectMetadata};
use crate::state::{ProfileInstallStage, Profiles, SideType}; 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::util::io;
use crate::{profile, State}; use crate::{profile, State};
use async_zip::tokio::read::seek::ZipFileReader; use async_zip::tokio::read::seek::ZipFileReader;
use reqwest::Method;
use serde_json::json;
use std::collections::HashMap;
use std::io::Cursor; use std::io::Cursor;
use std::path::{Component, PathBuf}; use std::path::{Component, PathBuf};
@@ -43,6 +47,7 @@ pub async fn install_zipped_mrpack(
title, title,
icon_url, icon_url,
profile_path.clone(), profile_path.clone(),
None,
) )
.await? .await?
} }
@@ -52,7 +57,7 @@ pub async fn install_zipped_mrpack(
}; };
// Install pack files, and if it fails, fail safely by removing the profile // 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 // Check existing managed packs for potential updates
tokio::task::spawn(Profiles::update_modrinth_versions()); tokio::task::spawn(Profiles::update_modrinth_versions());
@@ -72,6 +77,7 @@ pub async fn install_zipped_mrpack(
#[theseus_macros::debug_pin] #[theseus_macros::debug_pin]
pub async fn install_zipped_mrpack_files( pub async fn install_zipped_mrpack_files(
create_pack: CreatePack, create_pack: CreatePack,
ignore_lock: bool,
) -> crate::Result<ProfilePathId> { ) -> crate::Result<ProfilePathId> {
let state = &State::get().await?; let state = &State::get().await?;
@@ -126,6 +132,7 @@ pub async fn install_zipped_mrpack_files(
&description, &description,
&pack.name, &pack.name,
&pack.dependencies, &pack.dependencies,
ignore_lock,
) )
.await?; .await?;
@@ -182,15 +189,20 @@ pub async fn install_zipped_mrpack_files(
.await?; .await?;
drop(creds); 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 = let path =
std::path::Path::new(&project.path).components().next(); std::path::Path::new(&project_path).components().next();
if let Some(path) = path { if let Some(path) = path {
match path { match path {
Component::CurDir | Component::Normal(_) => { Component::CurDir | Component::Normal(_) => {
let path = profile_path let path = profile_path
.get_full_path() .get_full_path()
.await? .await?
.join(&project.path); .join(&project_path);
write(&path, &file, &state.io_semaphore) write(&path, &file, &state.io_semaphore)
.await?; .await?;
} }
@@ -337,31 +349,65 @@ pub async fn remove_all_related_files(
}) })
.await?; .await?;
let num_files = pack.files.len(); // First, remove all modrinth projects by their version hashes
use futures::StreamExt; // Remove all modrinth projects by their version hashes
loading_try_for_each_concurrent( // We need to do a fetch to get the project ids from Modrinth
futures::stream::iter(pack.files.into_iter()) let state = State::get().await?;
.map(Ok::<PackFile, crate::Error>), let all_hashes = pack
None, .files
None, .iter()
0.0, .filter_map(|f| Some(f.hashes.get(&PackFileHash::Sha512)?.clone()))
num_files, .collect::<Vec<_>>();
None, let creds = state.credentials.read().await;
|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(()) // First, get project info by hash
} let files_url = format!("{}version_files", MODRINTH_API_URL);
},
let hash_projects = fetch_json::<HashMap<String, ModrinthVersion>>(
Method::POST,
&files_url,
None,
Some(json!({
"hashes": all_hashes,
"algorithm": "sha512",
})),
&state.fetch_semaphore,
&creds,
) )
.await?; .await?;
let to_remove = hash_projects
.into_values()
.map(|p| p.project_id)
.collect::<Vec<_>>();
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 // Iterate over each 'overrides' file and remove it
for index in 0..zip_reader.file().entries().len() { for index in 0..zip_reader.file().entries().len() {

View File

@@ -2,16 +2,13 @@
use uuid::Uuid; use uuid::Uuid;
use crate::state::{MinecraftChild, ProfilePathId};
pub use crate::{ pub use crate::{
state::{ state::{
Hooks, JavaSettings, MemorySettings, Profile, Settings, WindowSize, Hooks, JavaSettings, MemorySettings, Profile, Settings, WindowSize,
}, },
State, State,
}; };
use crate::{
state::{MinecraftChild, ProfilePathId},
util::io::IOError,
};
// Gets whether a child process stored in the state by UUID has finished // Gets whether a child process stored in the state by UUID has finished
#[tracing::instrument] #[tracing::instrument]
@@ -26,7 +23,7 @@ pub async fn get_exit_status_by_uuid(
) -> crate::Result<Option<i32>> { ) -> crate::Result<Option<i32>> {
let state = State::get().await?; let state = State::get().await?;
let children = state.children.read().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 // 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 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<String> {
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 // Kill a child process stored in the state by UUID, as a string
#[tracing::instrument] #[tracing::instrument]
pub async fn kill_by_uuid(uuid: &Uuid) -> crate::Result<()> { 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 // Kill a running child process directly
#[tracing::instrument(skip(running))] #[tracing::instrument(skip(running))]
pub async fn kill(running: &mut MinecraftChild) -> crate::Result<()> { pub async fn kill(running: &mut MinecraftChild) -> crate::Result<()> {
running running.current_child.write().await.kill().await?;
.current_child
.write()
.await
.kill()
.await
.map_err(IOError::from)?;
Ok(()) Ok(())
} }

View File

@@ -1,13 +1,13 @@
//! Theseus profile management interface //! Theseus profile management interface
use crate::pack::install_from::CreatePackProfile; use crate::pack::install_from::CreatePackProfile;
use crate::prelude::ProfilePathId; use crate::prelude::ProfilePathId;
use crate::profile;
use crate::state::LinkedData; use crate::state::LinkedData;
use crate::util::io::{self, canonicalize}; use crate::util::io::{self, canonicalize};
use crate::{ use crate::{
event::{emit::emit_profile, ProfilePayloadType}, event::{emit::emit_profile, ProfilePayloadType},
prelude::ModLoader, prelude::ModLoader,
}; };
use crate::{pack, profile, ErrorKind};
pub use crate::{ pub use crate::{
state::{JavaSettings, Profile}, state::{JavaSettings, Profile},
State, State,
@@ -102,6 +102,12 @@ pub async fn profile_create(
} }
profile.metadata.linked_data = linked_data; 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( emit_profile(
uuid, uuid,
@@ -154,6 +160,59 @@ pub async fn profile_create_from_creator(
.await .await
} }
pub async fn profile_create_from_duplicate(
copy_from: ProfilePathId,
) -> crate::Result<ProfilePathId> {
let profile = profile::get(&copy_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] #[tracing::instrument]
#[theseus_macros::debug_pin] #[theseus_macros::debug_pin]
pub(crate) async fn get_loader_version_from_loader( pub(crate) async fn get_loader_version_from_loader(

View File

@@ -8,7 +8,7 @@ use crate::pack::install_from::{
EnvType, PackDependency, PackFile, PackFileHash, PackFormat, EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
}; };
use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId}; use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId};
use crate::state::ProjectMetadata; use crate::state::{ProjectMetadata, SideType};
use crate::util::fetch; use crate::util::fetch;
use crate::util::io::{self, IOError}; use crate::util::io::{self, IOError};
@@ -109,6 +109,26 @@ pub async fn get_full_path(path: &ProfilePathId) -> crate::Result<PathBuf> {
Ok(full_path) 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<PathBuf> {
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 /// Edit a profile using a given asynchronous closure
pub async fn edit<Fut>( pub async fn edit<Fut>(
path: &ProfilePathId, path: &ProfilePathId,
@@ -552,6 +572,8 @@ pub async fn export_mrpack(
export_path: PathBuf, export_path: PathBuf,
included_overrides: Vec<String>, // which folders to include in the overrides included_overrides: Vec<String>, // which folders to include in the overrides
version_id: Option<String>, version_id: Option<String>,
description: Option<String>,
_name: Option<String>,
) -> crate::Result<()> { ) -> crate::Result<()> {
let state = State::get().await?; let state = State::get().await?;
let io_semaphore = state.io_semaphore.0.read().await; let io_semaphore = state.io_semaphore.0.read().await;
@@ -585,7 +607,8 @@ pub async fn export_mrpack(
// Create mrpack json configuration file // Create mrpack json configuration file
let version_id = version_id.unwrap_or("1.0.0".to_string()); 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); let modrinth_path_list = get_modrinth_pack_list(&packfile);
// Build vec of all files in the folder // 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 // 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 mrpack_files = get_modrinth_pack_list(&mrpack);
let mut path_list: Vec<PathBuf> = Vec::new(); let mut path_list: Vec<PathBuf> = Vec::new();
@@ -820,23 +843,12 @@ pub async fn run_credentials(
.unwrap_or(&settings.custom_env_args); .unwrap_or(&settings.custom_env_args);
// Post post exit hooks // Post post exit hooks
let post_exit_hook = let post_exit_hook = profile
&profile.hooks.as_ref().unwrap_or(&settings.hooks).post_exit; .hooks
.as_ref()
let post_exit_hook = if let Some(hook) = post_exit_hook { .unwrap_or(&settings.hooks)
let mut cmd = hook.split(' '); .post_exit
if let Some(command) = cmd.next() { .clone();
let mut command = Command::new(command);
command
.args(&cmd.collect::<Vec<&str>>())
.current_dir(path.get_full_path().await?);
Some(command)
} else {
None
}
} else {
None
};
// Any options.txt settings that we want set, add here // Any options.txt settings that we want set, add here
let mut mc_set_options: Vec<(String, String)> = vec![]; let mut mc_set_options: Vec<(String, String)> = vec![];
@@ -941,6 +953,7 @@ fn get_modrinth_pack_list(packfile: &PackFormat) -> Vec<String> {
pub async fn create_mrpack_json( pub async fn create_mrpack_json(
profile: &Profile, profile: &Profile,
version_id: String, version_id: String,
description: Option<String>,
) -> crate::Result<PackFormat> { ) -> crate::Result<PackFormat> {
// Add loader version to dependencies // Add loader version to dependencies
let mut dependencies = HashMap::new(); let mut dependencies = HashMap::new();
@@ -951,6 +964,9 @@ pub async fn create_mrpack_json(
(crate::prelude::ModLoader::Forge, Some(v)) => { (crate::prelude::ModLoader::Forge, Some(v)) => {
dependencies.insert(PackDependency::Forge, v.id) dependencies.insert(PackDependency::Forge, v.id)
} }
(crate::prelude::ModLoader::NeoForge, Some(v)) => {
dependencies.insert(PackDependency::NeoForge, v.id)
}
(crate::prelude::ModLoader::Fabric, Some(v)) => { (crate::prelude::ModLoader::Fabric, Some(v)) => {
dependencies.insert(PackDependency::FabricLoader, v.id) dependencies.insert(PackDependency::FabricLoader, v.id)
} }
@@ -981,18 +997,21 @@ pub async fn create_mrpack_json(
.projects .projects
.iter() .iter()
.filter_map(|(mod_path, project)| { .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 // Only Modrinth projects have a modrinth metadata field for the modrinth.json
Some(Ok(match project.metadata { Some(Ok(match project.metadata {
crate::prelude::ProjectMetadata::Modrinth { crate::prelude::ProjectMetadata::Modrinth {
ref project,
ref version, ref version,
.. ..
} => { } => {
let mut env = HashMap::new(); let mut env = HashMap::new();
env.insert(EnvType::Client, project.client_side.clone()); // TODO: envtype should be a controllable option (in general or at least .mrpack exporting)
env.insert(EnvType::Server, project.server_side.clone()); // 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) = let primary_file = if let Some(primary_file) =
version.files.first() version.files.first()
@@ -1037,7 +1056,7 @@ pub async fn create_mrpack_json(
format_version: 1, format_version: 1,
version_id, version_id,
name: profile.metadata.name.clone(), name: profile.metadata.name.clone(),
summary: None, summary: description,
files, files,
dependencies, dependencies,
}) })

View File

@@ -1,21 +1,22 @@
use crate::{ use crate::{
event::{ event::{
emit::{emit_profile, loading_try_for_each_concurrent}, emit::{emit_profile, init_loading, loading_try_for_each_concurrent},
ProfilePayloadType, ProfilePayloadType,
}, },
pack::{self, install_from::generate_pack_from_version_id}, pack::{self, install_from::generate_pack_from_version_id},
prelude::{ProfilePathId, ProjectPathId}, prelude::{ProfilePathId, ProjectPathId},
profile::get, profile::get,
state::Project, state::{ProfileInstallStage, Project},
State, LoadingBarType, State,
}; };
use futures::try_join; 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] #[tracing::instrument]
#[theseus_macros::debug_pin] #[theseus_macros::debug_pin]
pub async fn update_managed_modrinth( pub async fn update_managed_modrinth_version(
profile_path: &ProfilePathId, profile_path: &ProfilePathId,
new_version_id: &String,
) -> crate::Result<()> { ) -> crate::Result<()> {
let profile = get(profile_path, None).await?.ok_or_else(|| { let profile = get(profile_path, None).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(profile_path.to_string()) crate::ErrorKind::UnmanagedProfileError(profile_path.to_string())
@@ -39,19 +40,14 @@ pub async fn update_managed_modrinth(
let version_id = let version_id =
linked_data.version_id.as_ref().ok_or_else(unmanaged_err)?; 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 the pack with the new version
replace_managed_modrinth( replace_managed_modrinth(
profile_path, profile_path,
&profile, &profile,
project_id, project_id,
version_id, version_id,
Some(modrinth_update_version), Some(new_version_id),
true, // switching versions should ignore the lock
) )
.await?; .await?;
@@ -128,6 +124,7 @@ pub async fn repair_managed_modrinth(
project_id, project_id,
version_id, version_id,
None, None,
false, // do not ignore lock, as repairing can reset the lock
) )
.await?; .await?;
@@ -153,32 +150,61 @@ async fn replace_managed_modrinth(
project_id: &String, project_id: &String,
version_id: &String, version_id: &String,
new_version_id: Option<&String>, new_version_id: Option<&String>,
ignore_lock: bool,
) -> crate::Result<()> { ) -> crate::Result<()> {
crate::profile::edit(profile_path, |profile| {
profile.install_stage = ProfileInstallStage::Installing;
async { Ok(()) }
})
.await?;
// Fetch .mrpacks for both old and new versions // 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 // 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) = let (old_pack_creator, new_pack_creator) =
if let Some(new_version_id) = new_version_id { 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!( 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( generate_pack_from_version_id(
project_id.clone(), project_id.clone(),
new_version_id.clone(), new_version_id.clone(),
profile.metadata.name.clone(), profile.metadata.name.clone(),
None, None,
profile_path.clone() profile_path.clone(),
Some(shared_loading_bar)
) )
)? )?
} else { } 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.description.existing_loading_bar = None;
(old_pack_creator.clone(), old_pack_creator) (old_pack_creator.clone(), old_pack_creator)
}; };
@@ -197,7 +223,11 @@ async fn replace_managed_modrinth(
// - install all overrides // - install all overrides
// - edits the profile to update the new data // - edits the profile to update the new data
// - (functionals almost identically to rteinstalling the pack 'in-place') // - (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(()) Ok(())
} }

View File

@@ -49,10 +49,19 @@ pub async fn set(settings: Settings) -> crate::Result<()> {
} }
.await; .await;
let updated_discord_rpc = {
let read = state.settings.read().await;
settings.disable_discord_rpc != read.disable_discord_rpc
};
{ {
*state.settings.write().await = settings; *state.settings.write().await = settings;
} }
if updated_discord_rpc {
state.discord_rpc.clear_to_default(true).await?;
}
if reset_io { if reset_io {
state.reset_io_semaphore().await; state.reset_io_semaphore().await;
} }

View File

@@ -140,11 +140,15 @@ impl Drop for LoadingBarId {
#[cfg(not(any(feature = "tauri", feature = "cli")))] #[cfg(not(any(feature = "tauri", feature = "cli")))]
bars.remove(&loader_uuid); bars.remove(&loader_uuid);
} }
let _ = SafeProcesses::complete( // complete calls state, and since a LoadingBarId is created in state initialization, we only complete if its already initializaed
crate::state::ProcessType::LoadingBar, // to avoid an infinite loop.
loader_uuid, if crate::State::initialized() {
) let _ = SafeProcesses::complete(
.await; crate::state::ProcessType::LoadingBar,
loader_uuid,
)
.await;
}
}); });
} }
} }

View File

@@ -312,7 +312,7 @@ pub async fn launch_minecraft(
memory: &st::MemorySettings, memory: &st::MemorySettings,
resolution: &st::WindowSize, resolution: &st::WindowSize,
credentials: &auth::Credentials, credentials: &auth::Credentials,
post_exit_hook: Option<Command>, post_exit_hook: Option<String>,
profile: &Profile, profile: &Profile,
) -> crate::Result<Arc<tokio::sync::RwLock<MinecraftChild>>> { ) -> crate::Result<Arc<tokio::sync::RwLock<MinecraftChild>>> {
if profile.install_stage == ProfileInstallStage::PackInstalling if profile.install_stage == ProfileInstallStage::PackInstalling
@@ -406,7 +406,6 @@ pub async fn launch_minecraft(
)) ))
.as_error()); .as_error());
} }
command command
.args( .args(
args::get_jvm_arguments( args::get_jvm_arguments(
@@ -447,14 +446,17 @@ pub async fn launch_minecraft(
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.current_dir(instance_path.clone()) .current_dir(instance_path.clone())
.stdout(Stdio::piped()) .stdout(Stdio::null())
.stderr(Stdio::piped()); .stderr(Stdio::null());
// CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground // CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if std::env::var("CARGO").is_ok() { if std::env::var("CARGO").is_ok() {
command.env_remove("DYLD_FALLBACK_LIBRARY_PATH"); 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); command.envs(env_args);
// Overwrites the minecraft options.txt file with the settings from the profile // 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?; 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| { crate::api::profile::edit(&profile.profile_id(), |prof| {
prof.metadata.last_played = Some(Utc::now()); 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 // This also spawns the process and prepares the subsequent processes
let mut state_children = state.children.write().await; let mut state_children = state.children.write().await;
state_children state_children
.insert_process( .insert_new_process(
Uuid::new_v4(), Uuid::new_v4(),
profile.profile_id(), profile.profile_id(),
stdout_log_path,
command, command,
post_exit_hook, post_exit_hook,
censor_strings, censor_strings,

View File

@@ -24,7 +24,9 @@ pub fn start_logger() -> Option<WorkerGuard> {
use tracing_subscriber::prelude::*; use tracing_subscriber::prelude::*;
let filter = tracing_subscriber::EnvFilter::try_from_default_env() 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() let subscriber = tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.with(filter) .with(filter)

View File

@@ -1,89 +1,278 @@
use super::{Profile, ProfilePathId}; use super::{Profile, ProfilePathId};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf}; use serde::Deserialize;
use std::process::ExitStatus; use serde::Serialize;
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use tokio::fs::File; use sysinfo::PidExt;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Child; use tokio::process::Child;
use tokio::process::Command; use tokio::process::Command;
use tokio::process::{ChildStderr, ChildStdout};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing::error;
use crate::event::emit::emit_process; use crate::event::emit::emit_process;
use crate::event::ProcessPayloadType; use crate::event::ProcessPayloadType;
use crate::profile; use crate::util::fetch::read_json;
use crate::util::io::IOError; use crate::util::io::IOError;
use crate::{profile, ErrorKind};
use sysinfo::{ProcessExt, SystemExt};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use uuid::Uuid; use uuid::Uuid;
const PROCESSES_JSON: &str = "processes.json";
// Child processes (instances of Minecraft) // Child processes (instances of Minecraft)
// A wrapper over a Hashmap connecting PID -> MinecraftChild // A wrapper over a Hashmap connecting PID -> MinecraftChild
pub struct Children(HashMap<Uuid, Arc<RwLock<MinecraftChild>>>); pub struct Children(HashMap<Uuid, Arc<RwLock<MinecraftChild>>>);
// 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<String>,
}
impl ChildType {
pub async fn try_wait(&mut self) -> crate::Result<Option<i32>> {
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<u32> {
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<String>,
) -> 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::<HashMap<uuid::Uuid, ProcessCache>>(
&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::<HashMap<uuid::Uuid, ProcessCache>>(
&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)] #[derive(Debug)]
pub struct MinecraftChild { pub struct MinecraftChild {
pub uuid: Uuid, pub uuid: Uuid,
pub profile_relative_path: ProfilePathId, pub profile_relative_path: ProfilePathId,
pub manager: Option<JoinHandle<crate::Result<ExitStatus>>>, // None when future has completed and been handled pub manager: Option<JoinHandle<crate::Result<i32>>>, // None when future has completed and been handled
pub current_child: Arc<RwLock<Child>>, pub current_child: Arc<RwLock<ChildType>>,
pub output: SharedOutput,
pub last_updated_playtime: DateTime<Utc>, // The last time we updated the playtime for the associated profile pub last_updated_playtime: DateTime<Utc>, // The last time we updated the playtime for the associated profile
} }
impl Children { impl Children {
pub fn new() -> Children { pub fn new() -> Self {
Children(HashMap::new()) 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::<HashMap<uuid::Uuid, ProcessCache>>(
&children_path,
&state.io_semaphore,
)
.await
{
// Overwrite the file with an empty hashmap- we will re-insert the cached processes
let empty = HashMap::<uuid::Uuid, ProcessCache>::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 // 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 // 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 // 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( #[tracing::instrument(skip(
self, self,
uuid, uuid,
log_path,
mc_command, mc_command,
post_command, post_command,
censor_strings censor_strings
))] ))]
#[tracing::instrument(level = "trace", skip(self))] #[tracing::instrument(level = "trace", skip(self))]
#[theseus_macros::debug_pin] #[theseus_macros::debug_pin]
pub async fn insert_process( pub async fn insert_new_process(
&mut self, &mut self,
uuid: Uuid, uuid: Uuid,
profile_relative_path: ProfilePathId, profile_relative_path: ProfilePathId,
log_path: PathBuf,
mut mc_command: Command, mut mc_command: Command,
post_command: Option<Command>, // Command to run after minecraft. post_command: Option<String>, // Command to run after minecraft.
censor_strings: HashMap<String, String>, censor_strings: HashMap<String, String>,
) -> crate::Result<Arc<RwLock<MinecraftChild>>> { ) -> crate::Result<Arc<RwLock<MinecraftChild>>> {
// Takes the first element of the commands vector and spawns it // Takes the first element of the commands vector and spawns it
let mut child = mc_command.spawn().map_err(IOError::from)?; let child = mc_command.spawn().map_err(IOError::from)?;
let child = ChildType::TokioChild(child);
// 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);
}
});
}
// Slots child into manager // Slots child into manager
let pid = child.id().ok_or_else(|| { let pid = child.id().ok_or_else(|| {
@@ -91,6 +280,15 @@ impl Children {
"Process immediately failed, could not get PID".to_string(), "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 current_child = Arc::new(RwLock::new(child));
let manager = Some(tokio::spawn(Self::sequential_process_manager( let manager = Some(tokio::spawn(Self::sequential_process_manager(
uuid, uuid,
@@ -115,7 +313,6 @@ impl Children {
uuid, uuid,
profile_relative_path, profile_relative_path,
current_child, current_child,
output: shared_output,
manager, manager,
last_updated_playtime, last_updated_playtime,
}; };
@@ -125,6 +322,96 @@ impl Children {
Ok(mchild) 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<Arc<RwLock<MinecraftChild>>> {
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 // 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 // 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 // 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] #[theseus_macros::debug_pin]
async fn sequential_process_manager( async fn sequential_process_manager(
uuid: Uuid, uuid: Uuid,
post_command: Option<Command>, post_command: Option<String>,
mut current_pid: u32, mut current_pid: u32,
current_child: Arc<RwLock<Child>>, current_child: Arc<RwLock<ChildType>>,
associated_profile: ProfilePathId, associated_profile: ProfilePathId,
) -> crate::Result<ExitStatus> { ) -> crate::Result<i32> {
let current_child = current_child.clone(); let current_child = current_child.clone();
// Wait on current Minecraft Child // Wait on current Minecraft Child
let mut mc_exit_status; let mut mc_exit_status;
let mut last_updated_playtime = Utc::now(); let mut last_updated_playtime = Utc::now();
loop { loop {
if let Some(t) = current_child if let Some(t) = current_child.write().await.try_wait().await? {
.write()
.await
.try_wait()
.map_err(IOError::from)?
{
mc_exit_status = t; mc_exit_status = t;
break; break;
} }
// sleep for 10ms // 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 // Auto-update playtime every minute
let diff = Utc::now() let diff = Utc::now()
@@ -168,7 +450,7 @@ impl Children {
{ {
tracing::warn!( tracing::warn!(
"Failed to update playtime for profile {}: {}", "Failed to update playtime for profile {}: {}",
associated_profile, &associated_profile,
e e
); );
} }
@@ -188,7 +470,7 @@ impl Children {
{ {
tracing::warn!( tracing::warn!(
"Failed to update playtime for profile {}: {}", "Failed to update playtime for profile {}: {}",
associated_profile, &associated_profile,
e e
); );
} }
@@ -196,13 +478,15 @@ impl Children {
// Publish play time update // Publish play time update
// Allow failure, it will be stored locally and sent next time // 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 // 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 { tokio::spawn(async move {
if let Err(e) = if let Err(e) =
profile::try_update_playtime(&associated_profile).await profile::try_update_playtime(&associated_profile_clone.clone())
.await
{ {
tracing::warn!( tracing::warn!(
"Failed to update playtime for profile {}: {}", "Failed to update playtime for profile {}: {}",
associated_profile, &associated_profile_clone,
e 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( emit_process(
uuid, uuid,
current_pid, current_pid,
@@ -237,9 +526,28 @@ impl Children {
} }
// If a post-command exist, switch to it and wait on it // 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::<Vec<&str>>())
.current_dir(associated_profile.get_full_path().await?);
Some(command)
} else {
None
}
} else {
None
};
if let Some(mut m_command) = post_command { 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)?; let new_child = m_command.spawn().map_err(IOError::from)?;
current_pid = new_child.id().ok_or_else(|| { current_pid = new_child.id().ok_or_else(|| {
crate::ErrorKind::LauncherError( crate::ErrorKind::LauncherError(
@@ -247,7 +555,7 @@ impl Children {
.to_string(), .to_string(),
) )
})?; })?;
*current_child = new_child; *current_child = ChildType::TokioChild(new_child);
} }
emit_process( emit_process(
uuid, uuid,
@@ -258,12 +566,7 @@ impl Children {
.await?; .await?;
loop { loop {
if let Some(t) = current_child if let Some(t) = current_child.write().await.try_wait().await? {
.write()
.await
.try_wait()
.map_err(IOError::from)?
{
mc_exit_status = t; mc_exit_status = t;
break; break;
} }
@@ -296,18 +599,10 @@ impl Children {
// Get exit status of a child by PID // Get exit status of a child by PID
// Returns None if the child is still running // Returns None if the child is still running
pub async fn exit_status( pub async fn exit_status(&self, uuid: &Uuid) -> crate::Result<Option<i32>> {
&self,
uuid: &Uuid,
) -> crate::Result<Option<std::process::ExitStatus>> {
if let Some(child) = self.get(uuid) { if let Some(child) = self.get(uuid) {
let child = child.write().await; let child = child.write().await;
let status = child let status = child.current_child.write().await.try_wait().await?;
.current_child
.write()
.await
.try_wait()
.map_err(IOError::from)?;
Ok(status) Ok(status)
} else { } else {
Ok(None) Ok(None)
@@ -326,7 +621,7 @@ impl Children {
.write() .write()
.await .await
.try_wait() .try_wait()
.map_err(IOError::from)? .await?
.is_none() .is_none()
{ {
keys.push(key); keys.push(key);
@@ -369,7 +664,7 @@ impl Children {
.write() .write()
.await .await
.try_wait() .try_wait()
.map_err(IOError::from)? .await?
.is_none() .is_none()
{ {
profiles.push(child.profile_relative_path.clone()); profiles.push(child.profile_relative_path.clone());
@@ -392,7 +687,7 @@ impl Children {
.write() .write()
.await .await
.try_wait() .try_wait()
.map_err(IOError::from)? .await?
.is_none() .is_none()
{ {
if let Some(prof) = crate::api::profile::get( if let Some(prof) = crate::api::profile::get(
@@ -415,107 +710,3 @@ impl Default for Children {
Self::new() 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<RwLock<String>>,
log_file: Arc<RwLock<File>>,
censor_strings: HashMap<String, String>,
}
impl SharedOutput {
async fn build(
log_file_path: &Path,
censor_strings: HashMap<String, String>,
) -> crate::Result<Self> {
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<String> {
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
}
}

View File

@@ -162,7 +162,7 @@ impl DirectoryInfo {
&self, &self,
profile_id: &ProfilePathId, profile_id: &ProfilePathId,
) -> crate::Result<PathBuf> { ) -> crate::Result<PathBuf> {
Ok(profile_id.get_full_path().await?.join("modrinth_logs")) Ok(profile_id.get_full_path().await?.join("logs"))
} }
#[inline] #[inline]

View File

@@ -16,17 +16,22 @@ pub struct DiscordGuard {
impl DiscordGuard { impl DiscordGuard {
/// Initialize discord IPC client, and attempt to connect to it /// 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 /// If it fails, it will still return a DiscordGuard, but the client will be unconnected
pub async fn init() -> crate::Result<DiscordGuard> { pub async fn init(is_offline: bool) -> crate::Result<DiscordGuard> {
let mut dipc = let mut dipc =
DiscordIpcClient::new("1084015525241311292").map_err(|e| { DiscordIpcClient::new("1123683254248148992").map_err(|e| {
crate::ErrorKind::OtherError(format!( crate::ErrorKind::OtherError(format!(
"Could not create Discord client {}", "Could not create Discord client {}",
e, e,
)) ))
})?; })?;
let res = dipc.connect(); // Do not need to connect to Discord to use app
let connected = if res.is_ok() { let connected = if !is_offline {
Arc::new(AtomicBool::new(true)) 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 { } else {
Arc::new(AtomicBool::new(false)) Arc::new(AtomicBool::new(false))
}; };
@@ -51,11 +56,46 @@ impl DiscordGuard {
true 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 /// 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( pub async fn set_activity(
&self, &self,
msg: &str, msg: &str,
reconnect_if_fail: bool, 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<()> { ) -> 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 // 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 { if !self.retry_if_not_ready().await {
@@ -99,14 +139,13 @@ impl DiscordGuard {
Ok(()) Ok(())
} }
/* /// Clear the activity entirely ('disabling' the RPC until the next set_activity)
/// Clear the activity
pub async fn clear_activity( pub async fn clear_activity(
&self, &self,
reconnect_if_fail: bool, reconnect_if_fail: bool,
) -> crate::Result<()> { ) -> 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 // 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(()); return Ok(());
} }
@@ -138,7 +177,7 @@ impl DiscordGuard {
res.map_err(could_not_clear_err)?; res.map_err(could_not_clear_err)?;
} }
Ok(()) Ok(())
}*/ }
/// Clear the activity, but if there is a running profile, set the activity to that instead /// Clear the activity, but if there is a running profile, set the activity to that instead
pub async fn clear_to_default( pub async fn clear_to_default(
@@ -147,6 +186,15 @@ impl DiscordGuard {
) -> crate::Result<()> { ) -> crate::Result<()> {
let state: Arc<tokio::sync::RwLockReadGuard<'_, State>> = let state: Arc<tokio::sync::RwLockReadGuard<'_, State>> =
State::get().await?; 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 if let Some(existing_child) = state
.children .children
.read() .read()

View File

@@ -69,6 +69,8 @@ impl Metadata {
) -> crate::Result<Self> { ) -> crate::Result<Self> {
let mut metadata = None; let mut metadata = None;
let metadata_path = dirs.caches_meta_dir().await.join("metadata.json"); 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) = if let Ok(metadata_json) =
read_json::<Metadata>(&metadata_path, io_semaphore).await read_json::<Metadata>(&metadata_path, io_semaphore).await
@@ -85,6 +87,13 @@ impl Metadata {
) )
.await?; .await?;
write(
&metadata_backup_path,
&serde_json::to_vec(&metadata_fetch).unwrap_or_default(),
io_semaphore,
)
.await?;
metadata = Some(metadata_fetch); metadata = Some(metadata_fetch);
Ok::<(), crate::Error>(()) Ok::<(), crate::Error>(())
} }
@@ -96,6 +105,18 @@ impl Metadata {
tracing::warn!("Unable to fetch launcher metadata: {err}") tracing::warn!("Unable to fetch launcher metadata: {err}")
} }
} }
} else if let Ok(metadata_json) =
read_json::<Metadata>(&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 { if let Some(meta) = metadata {
@@ -118,6 +139,15 @@ impl Metadata {
.caches_meta_dir() .caches_meta_dir()
.await .await
.join("metadata.json"); .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( write(
&metadata_path, &metadata_path,

View File

@@ -127,6 +127,10 @@ impl State {
.await) .await)
} }
pub fn initialized() -> bool {
LAUNCHER_STATE.initialized()
}
#[tracing::instrument] #[tracing::instrument]
#[theseus_macros::debug_pin] #[theseus_macros::debug_pin]
async fn initialize_state() -> crate::Result<RwLock<State>> { async fn initialize_state() -> crate::Result<RwLock<State>> {
@@ -180,16 +184,18 @@ impl State {
creds_fut, creds_fut,
}?; }?;
let children = Children::new();
let auth_flow = AuthTask::new(); let auth_flow = AuthTask::new();
let safety_processes = SafeProcesses::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 // 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 // Starts a loop of checking if we are online, and updating
Self::offine_check_loop(); Self::offine_check_loop();
@@ -238,11 +244,6 @@ impl State {
/// Updates state with data from the web, if we are online /// Updates state with data from the web, if we are online
pub fn update() { 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 { tokio::task::spawn(async {
if let Ok(state) = crate::State::get().await { if let Ok(state) = crate::State::get().await {
if !*state.offline.read().await { if !*state.offline.read().await {
@@ -252,8 +253,9 @@ impl State {
let res4 = Profiles::update_projects(); let res4 = Profiles::update_projects();
let res5 = Settings::update_java(); let res5 = Settings::update_java();
let res6 = CredentialsStore::update_creds(); 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);
} }
} }
}); });

View File

@@ -124,11 +124,22 @@ impl ProjectPathId {
&self, &self,
profile: ProfilePathId, profile: ProfilePathId,
) -> crate::Result<PathBuf> { ) -> crate::Result<PathBuf> {
let _state = State::get().await?;
let profile_dir = profile.get_full_path().await?; let profile_dir = profile.get_full_path().await?;
Ok(profile_dir.join(&self.0)) 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<String> {
Ok(self
.0
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/"))
}
// Create a new ProjectPathId from a relative path // Create a new ProjectPathId from a relative path
pub fn new(path: &Path) -> Self { pub fn new(path: &Path) -> Self {
ProjectPathId(PathBuf::from(path)) ProjectPathId(PathBuf::from(path))
@@ -193,6 +204,15 @@ pub struct ProfileMetadata {
pub struct LinkedData { pub struct LinkedData {
pub project_id: Option<String>, pub project_id: Option<String>,
pub version_id: Option<String>, pub version_id: Option<String>,
#[serde(default = "default_locked")]
pub locked: Option<bool>,
}
// 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<bool> {
Some(true)
} }
#[derive( #[derive(
@@ -722,7 +742,15 @@ impl Profiles {
None None
} }
}; };
if let Some(profile) = prof { 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)?; let path = io::canonicalize(path)?;
Profile::watch_fs(&path, file_watcher).await?; Profile::watch_fs(&path, file_watcher).await?;
profiles.insert(profile.profile_id(), profile); profiles.insert(profile.profile_id(), profile);

View File

@@ -31,6 +31,8 @@ pub struct Settings {
pub version: u32, pub version: u32,
pub collapsed_navigation: bool, pub collapsed_navigation: bool,
#[serde(default)] #[serde(default)]
pub disable_discord_rpc: bool,
#[serde(default)]
pub hide_on_process: bool, pub hide_on_process: bool,
#[serde(default)] #[serde(default)]
pub default_page: DefaultPage, pub default_page: DefaultPage,
@@ -49,8 +51,10 @@ pub struct Settings {
impl Settings { impl Settings {
#[tracing::instrument] #[tracing::instrument]
pub async fn init(file: &Path) -> crate::Result<Self> { pub async fn init(file: &Path) -> crate::Result<Self> {
if file.exists() { let mut rescued = false;
fs::read(&file)
let settings = if file.exists() {
let loaded_settings = fs::read(&file)
.await .await
.map_err(|err| { .map_err(|err| {
crate::ErrorKind::FSError(format!( crate::ErrorKind::FSError(format!(
@@ -61,9 +65,25 @@ impl Settings {
.and_then(|it| { .and_then(|it| {
serde_json::from_slice::<Settings>(&it) serde_json::from_slice::<Settings>(&it)
.map_err(crate::Error::from) .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 { } else {
Ok(Self { None
};
if let Some(settings) = settings {
Ok(settings)
} else {
// Create new settings file
let settings = Self {
theme: Theme::Dark, theme: Theme::Dark,
memory: MemorySettings::default(), memory: MemorySettings::default(),
force_fullscreen: false, force_fullscreen: false,
@@ -77,16 +97,21 @@ impl Settings {
max_concurrent_writes: 10, max_concurrent_writes: 10,
version: CURRENT_FORMAT_VERSION, version: CURRENT_FORMAT_VERSION,
collapsed_navigation: false, collapsed_navigation: false,
disable_discord_rpc: false,
hide_on_process: false, hide_on_process: false,
default_page: DefaultPage::Home, default_page: DefaultPage::Home,
developer_mode: false, developer_mode: false,
opt_out_analytics: false, opt_out_analytics: false,
advanced_rendering: true, 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 // By default, the config directory is the same as the settings directory
loaded_config_dir: DirectoryInfo::get_initial_settings_dir(), 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))] #[tracing::instrument(skip(self))]
pub async fn sync(&self, to: &Path) -> crate::Result<()> { pub async fn sync(&self, to: &Path) -> crate::Result<()> {
fs::write(to, serde_json::to_vec(self)?) fs::write(to, serde_json::to_vec(self)?)

View File

@@ -32,6 +32,8 @@ impl Tags {
) -> crate::Result<Self> { ) -> crate::Result<Self> {
let mut tags = None; let mut tags = None;
let tags_path = dirs.caches_meta_dir().await.join("tags.json"); 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::<Self>(&tags_path, io_semaphore).await if let Ok(tags_json) = read_json::<Self>(&tags_path, io_semaphore).await
{ {
@@ -43,11 +45,28 @@ impl Tags {
tracing::warn!("Unable to fetch launcher tags: {err}") tracing::warn!("Unable to fetch launcher tags: {err}")
} }
} }
} else if let Ok(tags_json) =
read_json::<Self>(&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 { if let Some(tags_data) = tags {
write(&tags_path, &serde_json::to_vec(&tags_data)?, io_semaphore) write(&tags_path, &serde_json::to_vec(&tags_data)?, io_semaphore)
.await?; .await?;
write(
&tags_path_backup,
&serde_json::to_vec(&tags_data)?,
io_semaphore,
)
.await?;
Ok(tags_data) Ok(tags_data)
} else { } else {
Err(crate::ErrorKind::NoValueFor(String::from("launcher tags")) Err(crate::ErrorKind::NoValueFor(String::from("launcher tags"))
@@ -68,6 +87,14 @@ impl Tags {
let tags_path = let tags_path =
state.directories.caches_meta_dir().await.join("tags.json"); 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( write(
&tags_path, &tags_path,

View File

@@ -25,7 +25,8 @@
"vite-svg-loader": "^4.0.0", "vite-svg-loader": "^4.0.0",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-multiselect": "^3.0.0-beta.2", "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": { "devDependencies": {
"@rollup/plugin-alias": "^4.0.4", "@rollup/plugin-alias": "^4.0.4",

View File

@@ -44,6 +44,9 @@ dependencies:
vue-router: vue-router:
specifier: 4.2.1 specifier: 4.2.1
version: 4.2.1(vue@3.3.4) 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: devDependencies:
'@rollup/plugin-alias': '@rollup/plugin-alias':
@@ -1309,6 +1312,10 @@ packages:
brace-expansion: 1.1.11 brace-expansion: 1.1.11
dev: true dev: true
/mitt@2.1.0:
resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==}
dev: false
/mixpanel-browser@2.47.0: /mixpanel-browser@2.47.0:
resolution: {integrity: sha512-Ldrva0fRBEIFWmEibBQO1PulfpJVF3pf28Guk09lDirDaSQqqU/xs9zQLwN2rL5VwVtsP1aD3JaCgaa98EjojQ==} resolution: {integrity: sha512-Ldrva0fRBEIFWmEibBQO1PulfpJVF3pf28Guk09lDirDaSQqqU/xs9zQLwN2rL5VwVtsP1aD3JaCgaa98EjojQ==}
dev: false dev: false
@@ -1738,6 +1745,14 @@ packages:
engines: {node: '>= 4.0.0', npm: '>= 3.0.0'} engines: {node: '>= 4.0.0', npm: '>= 3.0.0'}
dev: false 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): /vue-resize@2.0.0-alpha.1(vue@3.3.4):
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==} resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
peerDependencies: peerDependencies:
@@ -1763,6 +1778,17 @@ packages:
vue: 3.3.4 vue: 3.3.4
dev: false 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: /vue@3.3.4:
resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==} resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==}
dependencies: dependencies:

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

View File

@@ -13,6 +13,7 @@ pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
jre_autodetect_java_globals, jre_autodetect_java_globals,
jre_validate_globals, jre_validate_globals,
jre_get_jre, jre_get_jre,
jre_test_jre,
jre_auto_install_java, jre_auto_install_java,
jre_get_max_memory, jre_get_max_memory,
]) ])
@@ -61,6 +62,16 @@ pub async fn jre_get_jre(path: PathBuf) -> Result<Option<JavaVersion>> {
jre::check_jre(path).await.map_err(|e| e.into()) 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<bool> {
Ok(jre::test_jre(path, major_version, minor_version).await?)
}
// Auto installs java for the given java version // Auto installs java for the given java version
#[tauri::command] #[tauri::command]
pub async fn jre_auto_install_java(java_version: u32) -> Result<PathBuf> { pub async fn jre_auto_install_java(java_version: u32) -> Result<PathBuf> {

View File

@@ -1,14 +1,14 @@
use crate::api::Result; use crate::api::Result;
use theseus::{ use theseus::{
logs::{self, Logs}, logs::{self, CensoredString, LatestLogCursor, Logs},
prelude::ProfilePathId, 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 struct Logs {
pub datetime_string: String, pub filename: String,
pub stdout: String, pub stdout: String,
pub stderr: String, pub stderr: String,
} }
@@ -18,15 +18,16 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("logs") tauri::plugin::Builder::new("logs")
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
logs_get_logs, logs_get_logs,
logs_get_logs_by_datetime, logs_get_logs_by_filename,
logs_get_output_by_datetime, logs_get_output_by_filename,
logs_delete_logs, logs_delete_logs,
logs_delete_logs_by_datetime, logs_delete_logs_by_filename,
logs_get_latest_log_cursor,
]) ])
.build() .build()
} }
/// Get all Logs for a profile, sorted by datetime /// Get all Logs for a profile, sorted by filename
#[tauri::command] #[tauri::command]
pub async fn logs_get_logs( pub async fn logs_get_logs(
profile_path: ProfilePathId, profile_path: ProfilePathId,
@@ -37,21 +38,21 @@ pub async fn logs_get_logs(
Ok(val) 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] #[tauri::command]
pub async fn logs_get_logs_by_datetime( pub async fn logs_get_logs_by_filename(
profile_path: ProfilePathId, profile_path: ProfilePathId,
datetime_string: String, filename: String,
) -> Result<Logs> { ) -> Result<Logs> {
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] #[tauri::command]
pub async fn logs_get_output_by_datetime( pub async fn logs_get_output_by_filename(
profile_path: ProfilePathId, profile_path: ProfilePathId,
datetime_string: String, filename: String,
) -> Result<String> { ) -> Result<CensoredString> {
let profile_path = if let Some(p) = let profile_path = if let Some(p) =
crate::profile::get(&profile_path, None).await? crate::profile::get(&profile_path, None).await?
{ {
@@ -63,7 +64,7 @@ pub async fn logs_get_output_by_datetime(
.into()); .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 /// 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?) 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] #[tauri::command]
pub async fn logs_delete_logs_by_datetime( pub async fn logs_delete_logs_by_filename(
profile_path: ProfilePathId, profile_path: ProfilePathId,
datetime_string: String, filename: String,
) -> Result<()> { ) -> 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<LatestLogCursor> {
Ok(logs::get_latest_log_cursor(profile_path, cursor).await?)
} }

View File

@@ -12,7 +12,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
process_get_uuids_by_profile_path, process_get_uuids_by_profile_path,
process_get_all_running_profile_paths, process_get_all_running_profile_paths,
process_get_all_running_profiles, process_get_all_running_profiles,
process_get_output_by_uuid,
process_kill_by_uuid, process_kill_by_uuid,
process_wait_for_by_uuid, process_wait_for_by_uuid,
]) ])
@@ -66,12 +65,6 @@ pub async fn process_get_all_running_profiles() -> Result<Vec<Profile>> {
Ok(process::get_all_running_profiles().await?) 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<String> {
Ok(process::get_output_by_uuid(&uuid).await?)
}
// Kill a process by process UUID // Kill a process by process UUID
#[tauri::command] #[tauri::command]
pub async fn process_kill_by_uuid(uuid: Uuid) -> Result<()> { pub async fn process_kill_by_uuid(uuid: Uuid) -> Result<()> {

View File

@@ -13,6 +13,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_get, profile_get,
profile_get_optimal_jre_key, profile_get_optimal_jre_key,
profile_get_full_path, profile_get_full_path,
profile_get_mod_full_path,
profile_list, profile_list,
profile_check_installed, profile_check_installed,
profile_install, profile_install,
@@ -22,7 +23,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_add_project_from_path, profile_add_project_from_path,
profile_toggle_disable_project, profile_toggle_disable_project,
profile_remove_project, profile_remove_project,
profile_update_managed_modrinth, profile_update_managed_modrinth_version,
profile_repair_managed_modrinth, profile_repair_managed_modrinth,
profile_run, profile_run,
profile_run_wait, profile_run_wait,
@@ -63,6 +64,17 @@ pub async fn profile_get_full_path(path: ProfilePathId) -> Result<PathBuf> {
Ok(res) 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<PathBuf> {
let res = profile::get_mod_full_path(&path, &project_path).await?;
Ok(res)
}
// Get optimal java version from profile // Get optimal java version from profile
#[tauri::command] #[tauri::command]
pub async fn profile_get_optimal_jre_key( pub async fn profile_get_optimal_jre_key(
@@ -173,12 +185,16 @@ pub async fn profile_remove_project(
Ok(()) Ok(())
} }
// Updates a managed Modrinth profile // Updates a managed Modrinth profile to a version of version_id
#[tauri::command] #[tauri::command]
pub async fn profile_update_managed_modrinth( pub async fn profile_update_managed_modrinth_version(
path: ProfilePathId, path: ProfilePathId,
version_id: String,
) -> Result<()> { ) -> 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 // 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, export_location: PathBuf,
included_overrides: Vec<String>, included_overrides: Vec<String>,
version_id: Option<String>, version_id: Option<String>,
description: Option<String>,
name: Option<String>, // only used to cache
) -> Result<()> { ) -> Result<()> {
profile::export_mrpack( profile::export_mrpack(
&path, &path,
export_location, export_location,
included_overrides, included_overrides,
version_id, version_id,
description,
name,
) )
.await?; .await?;
Ok(()) Ok(())

View File

@@ -4,12 +4,15 @@ use theseus::prelude::*;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> { pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("profile_create") tauri::plugin::Builder::new("profile_create")
.invoke_handler(tauri::generate_handler![profile_create,]) .invoke_handler(tauri::generate_handler![
profile_create,
profile_duplicate
])
.build() .build()
} }
// Creates a profile at the given filepath and adds it to the in-memory state // 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] #[tauri::command]
pub async fn profile_create( pub async fn profile_create(
name: String, // the name of the profile, and relative path name: String, // the name of the profile, and relative path
@@ -33,3 +36,11 @@ pub async fn profile_create(
.await?; .await?;
Ok(res) 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<ProfilePathId> {
let res = profile::create::profile_create_from_duplicate(path).await?;
Ok(res)
}

View File

@@ -1,8 +1,12 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use theseus::{handler, prelude::CommandPayload, State}; use theseus::{
handler,
prelude::{CommandPayload, DirectoryInfo},
State,
};
use crate::api::Result; use crate::api::Result;
use std::{env, process::Command}; use std::{env, path::PathBuf, process::Command};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> { pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("utils") tauri::plugin::Builder::new("utils")
@@ -10,6 +14,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
get_os, get_os,
should_disable_mouseover, should_disable_mouseover,
show_in_folder, show_in_folder,
show_launcher_logs_folder,
progress_bars_list, progress_bars_list,
safety_check_safe_loading_bars, safety_check_safe_loading_bars,
get_opening_command, get_opening_command,
@@ -76,13 +81,19 @@ pub async fn should_disable_mouseover() -> bool {
} }
#[tauri::command] #[tauri::command]
pub fn show_in_folder(path: String) -> Result<()> { pub fn show_in_folder(path: PathBuf) -> Result<()> {
{ {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
Command::new("explorer") if path.is_dir() {
.args([&path]) // The comma after select is not a typo Command::new("explorer")
.spawn()?; .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")] #[cfg(target_os = "linux")]
@@ -90,14 +101,14 @@ pub fn show_in_folder(path: String) -> Result<()> {
use std::fs::metadata; use std::fs::metadata;
use std::path::PathBuf; use std::path::PathBuf;
if path.contains(',') { if path.to_string_lossy().to_string().contains(',') {
// see https://gitlab.freedesktop.org/dbus/dbus/-/issues/76 // see https://gitlab.freedesktop.org/dbus/dbus/-/issues/76
let new_path = match metadata(&path)?.is_dir() { let new_path = match metadata(&path)?.is_dir() {
true => path, true => path,
false => { false => {
let mut path2 = PathBuf::from(path); let mut path2 = PathBuf::from(path);
path2.pop(); path2.pop();
path2.to_string_lossy().to_string() path2
} }
}; };
Command::new("xdg-open").arg(&new_path).spawn()?; Command::new("xdg-open").arg(&new_path).spawn()?;
@@ -108,7 +119,13 @@ pub fn show_in_folder(path: String) -> Result<()> {
#[cfg(target_os = "macos")] #[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>(()) Ok::<(), theseus::Error>(())
@@ -117,6 +134,14 @@ pub fn show_in_folder(path: String) -> Result<()> {
Ok(()) 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 // Get opening command
// For example, if a user clicks on an .mrpack to open the app. // 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 // This should be called once and only when the app is done booting up and ready to receive a command

View File

@@ -13,11 +13,14 @@ mod error;
mod macos; mod macos;
// Should be called in launcher initialization // Should be called in launcher initialization
#[tracing::instrument(skip_all)]
#[tauri::command] #[tauri::command]
async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> { async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
theseus::EventState::init(app).await?; theseus::EventState::init(app).await?;
State::get().await?; let s = State::get().await?;
State::update(); State::update();
s.children.write().await.rescue_cache().await?;
Ok(()) Ok(())
} }

View File

@@ -64,7 +64,7 @@
"identifier": "com.modrinth.theseus", "identifier": "com.modrinth.theseus",
"longDescription": "", "longDescription": "",
"macOS": { "macOS": {
"entitlements": null, "entitlements": "App.entitlements",
"exceptionDomain": "", "exceptionDomain": "",
"frameworks": [], "frameworks": [],
"providerShortName": null, "providerShortName": null,

View File

@@ -7,9 +7,11 @@ import {
LibraryIcon, LibraryIcon,
PlusIcon, PlusIcon,
SettingsIcon, SettingsIcon,
FileIcon,
Button, Button,
Notifications, Notifications,
XIcon, XIcon,
Card,
} from 'omorphia' } from 'omorphia'
import { useLoading, useTheming } from '@/store/state' import { useLoading, useTheming } from '@/store/state'
import AccountsCard from '@/components/ui/AccountsCard.vue' 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 RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue' import SplashScreen from '@/components/ui/SplashScreen.vue'
import ModrinthLoadingIndicator from '@/components/modrinth-loading-indicator' 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 { 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 { type } from '@tauri-apps/api/os'
import { appWindow } from '@tauri-apps/api/window' 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 { import {
mixpanel_track, mixpanel_track,
mixpanel_init, mixpanel_init,
@@ -40,6 +42,7 @@ import { confirm } from '@tauri-apps/api/dialog'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue' import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue' import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue'
import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue' import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue'
import { install_from_file } from './helpers/pack'
const themeStore = useTheming() const themeStore = useTheming()
const urlModal = ref(null) const urlModal = ref(null)
@@ -51,14 +54,17 @@ const showOnboarding = ref(false)
const onboardingVideo = ref() const onboardingVideo = ref()
const failureText = ref(null)
const os = ref('')
defineExpose({ defineExpose({
initialize: async () => { initialize: async () => {
isLoading.value = false isLoading.value = false
const { theme, opt_out_analytics, collapsed_navigation, advanced_rendering, fully_onboarded } = const { theme, opt_out_analytics, collapsed_navigation, advanced_rendering, fully_onboarded } =
await get() await get()
const os = await getOS()
// video should play if the user is not on linux, and has not onboarded // 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 dev = await isDev()
const version = await getVersion() const version = await getVersion()
showOnboarding.value = !fully_onboarded showOnboarding.value = !fully_onboarded
@@ -98,6 +104,11 @@ defineExpose({
onboardingVideo.value.play() onboardingVideo.value.play()
} }
}, },
failure: async (e) => {
isLoading.value = false
failureText.value = e
os.value = await getOS()
},
}) })
const confirmClose = async () => { const confirmClose = async () => {
@@ -112,6 +123,10 @@ const confirmClose = async () => {
} }
const handleClose = async () => { const handleClose = async () => {
if (failureText.value != null) {
await TauriWindow.getCurrent().close()
return
}
// State should respond immeiately if it's safe to close // 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 // 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) // (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() 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 () => { TauriWindow.getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async () => {
await handleClose() await handleClose()
}) })
@@ -193,9 +218,19 @@ document.querySelector('body').addEventListener('auxclick', function (e) {
const accounts = ref(null) const accounts = ref(null)
command_listener((e) => { command_listener(async (e) => {
console.log(e) if (e.event === 'RunMRPack') {
urlModal.value.show(e) // 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)
}
}) })
</script> </script>
@@ -209,6 +244,46 @@ command_listener((e) => {
autoplay autoplay
@ended="videoPlaying = false" @ended="videoPlaying = false"
/> />
<div v-if="failureText" class="failure dark-mode">
<div class="appbar-failure dark-mode">
<Button v-if="os != 'MacOS'" icon-only @click="TauriWindow.getCurrent().close()">
<XIcon />
</Button>
</div>
<div class="error-view dark-mode">
<Card class="error-text">
<div class="label">
<h3>
<span class="label__title size-card-header">Failed to initialize</span>
</h3>
</div>
<div class="error-div">
Modrinth App failed to load correctly. This may be because of a corrupted file, or because
the app is missing crucial files.
</div>
<div class="error-div">You may be able to fix it one of the following ways:</div>
<ul class="error-div">
<li>Ennsuring you are connected to the internet, then try restarting the app.</li>
<li>Redownloading the app.</li>
</ul>
<div class="error-div">
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.
</div>
<div class="error-div">The following error was provided:</div>
<Card class="error-message">
{{ failureText.message }}
</Card>
<div class="button-row push-right">
<Button @click="showLauncherLogsFolder"><FileIcon />Open launcher logs</Button>
<Button @click="openSupport"><ChatIcon />Get support</Button>
</div>
</Card>
</div>
</div>
<SplashScreen v-else-if="!videoPlaying && isLoading" app-loading /> <SplashScreen v-else-if="!videoPlaying && isLoading" app-loading />
<OnboardingScreen v-else-if="showOnboarding" :finish="() => (showOnboarding = false)" /> <OnboardingScreen v-else-if="showOnboarding" :finish="() => (showOnboarding = false)" />
<div v-else class="container"> <div v-else class="container">
@@ -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 { .nav-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -522,4 +644,15 @@ command_listener((e) => {
object-fit: cover; object-fit: cover;
border-radius: var(--radius-md); 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;
}
}
</style> </style>

View File

@@ -20,7 +20,7 @@ import {
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useTheming } from '@/store/theme.js' 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' import { handleError } from '@/store/notifications.js'
const props = defineProps({ 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 = [ const baseOptions = [
{ name: 'add_content' }, { name: 'add_content' },
{ type: 'divider' }, { type: 'divider' },
{ name: 'edit' }, { name: 'edit' },
{ name: 'duplicate' },
{ name: 'open' }, { name: 'open' },
{ name: 'copy' }, { name: 'copy' },
{ type: 'divider' }, { type: 'divider' },
@@ -100,6 +106,10 @@ const handleOptionsClick = async (args) => {
case 'edit': case 'edit':
await args.item.seeInstance() await args.item.seeInstance()
break break
case 'duplicate':
if (args.item.instance.install_stage == 'installed')
await duplicateProfile(args.item.instance.path)
break
case 'open': case 'open':
await args.item.openFolder() await args.item.openFolder()
break break
@@ -131,7 +141,7 @@ const filteredResults = computed(() => {
if (sortBy.value === 'Game version') { if (sortBy.value === 'Game version') {
instances.sort((a, b) => { 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(() => {
</div> </div>
<section class="instances"> <section class="instances">
<Instance <Instance
v-for="(instance, index) in instanceSection.value" v-for="instance in instanceSection.value"
ref="instanceComponents" ref="instanceComponents"
:key="instance.path" :key="instance.path + instance.install_stage"
:instance="instance" :instance="instance"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instanceComponents[index])" @contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
/> />
</section> </section>
</div> </div>
@@ -298,6 +308,7 @@ const filteredResults = computed(() => {
<template #stop> <StopCircleIcon /> Stop </template> <template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template> <template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template> <template #edit> <EyeIcon /> View instance </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #delete> <TrashIcon /> Delete </template> <template #delete> <TrashIcon /> Delete </template>
<template #open> <FolderOpenIcon /> Open folder </template> <template #open> <FolderOpenIcon /> Open folder </template>
<template #copy> <ClipboardCopyIcon /> Copy path </template> <template #copy> <ClipboardCopyIcon /> Copy path </template>

View File

@@ -25,7 +25,7 @@ import {
kill_by_uuid, kill_by_uuid,
} from '@/helpers/process.js' } from '@/helpers/process.js'
import { handleError } from '@/store/notifications.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 { useRouter } from 'vue-router'
import { showProfileInFolder } from '@/helpers/utils.js' import { showProfileInFolder } from '@/helpers/utils.js'
import { useFetch } from '@/helpers/fetch.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 handleInstanceRightClick = async (event, passedInstance) => {
const baseOptions = [ const baseOptions = [
{ name: 'add_content' }, { name: 'add_content' },
{ type: 'divider' }, { type: 'divider' },
{ name: 'edit' }, { name: 'edit' },
{ name: 'duplicate' },
{ name: 'open_folder' }, { name: 'open_folder' },
{ name: 'copy_path' }, { name: 'copy_path' },
{ type: 'divider' }, { type: 'divider' },
@@ -150,6 +155,9 @@ const handleOptionsClick = async (args) => {
path: `/instance/${encodeURIComponent(args.item.path)}/`, path: `/instance/${encodeURIComponent(args.item.path)}/`,
}) })
break break
case 'duplicate':
if (args.item.install_stage == 'installed') await duplicateProfile(args.item.path)
break
case 'delete': case 'delete':
currentDeleteInstance.value = args.item.path currentDeleteInstance.value = args.item.path
deleteConfirmModal.value.show() deleteConfirmModal.value.show()
@@ -237,7 +245,7 @@ onUnmounted(() => {
<section v-if="row.instances[0].metadata" ref="modsRow" class="instances"> <section v-if="row.instances[0].metadata" ref="modsRow" class="instances">
<Instance <Instance
v-for="instance in row.instances.slice(0, maxInstancesPerRow)" v-for="instance in row.instances.slice(0, maxInstancesPerRow)"
:key="instance?.project_id || instance?.id" :key="(instance?.project_id || instance?.id) + instance.install_stage"
:instance="instance" :instance="instance"
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)" @contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
/> />
@@ -263,6 +271,7 @@ onUnmounted(() => {
<template #edit> <EyeIcon /> View instance </template> <template #edit> <EyeIcon /> View instance </template>
<template #delete> <TrashIcon /> Delete </template> <template #delete> <TrashIcon /> Delete </template>
<template #open_folder> <FolderOpenIcon /> Open folder </template> <template #open_folder> <FolderOpenIcon /> Open folder </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template> <template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #install> <DownloadIcon /> Install </template> <template #install> <DownloadIcon /> Install </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template> <template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>

View File

@@ -105,7 +105,7 @@ import {
GlobeIcon, GlobeIcon,
ClipboardCopyIcon, ClipboardCopyIcon,
} from 'omorphia' } from 'omorphia'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { import {
users, users,
remove_user, remove_user,
@@ -116,6 +116,7 @@ import { get, set } from '@/helpers/settings'
import { handleError } from '@/store/state.js' import { handleError } from '@/store/state.js'
import { mixpanel_track } from '@/helpers/mixpanel' import { mixpanel_track } from '@/helpers/mixpanel'
import QrcodeVue from 'qrcode.vue' import QrcodeVue from 'qrcode.vue'
import { process_listener } from '@/helpers/events'
defineProps({ defineProps({
mode: { mode: {
@@ -214,6 +215,12 @@ const handleClickOutside = (event) => {
} }
} }
const unlisten = await process_listener(async (e) => {
if (e.event === 'launched') {
await refreshValues()
}
})
onMounted(() => { onMounted(() => {
window.addEventListener('click', handleClickOutside) window.addEventListener('click', handleClickOutside)
}) })
@@ -221,6 +228,10 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside) window.removeEventListener('click', handleClickOutside)
}) })
onUnmounted(() => {
unlisten()
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { Button, Checkbox, Modal, SendIcon, XIcon } from 'omorphia' import { Button, Checkbox, Modal, XIcon, PlusIcon } from 'omorphia'
import { PackageIcon, VersionIcon } from '@/assets/icons' import { PackageIcon, VersionIcon } from '@/assets/icons'
import { ref } from 'vue' import { ref } from 'vue'
import { export_profile_mrpack, get_potential_override_folders } from '@/helpers/profile.js' import { export_profile_mrpack, get_potential_override_folders } from '@/helpers/profile.js'
@@ -24,9 +24,11 @@ defineExpose({
const exportModal = ref(null) const exportModal = ref(null)
const nameInput = ref(props.instance.metadata.name) const nameInput = ref(props.instance.metadata.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0') const versionInput = ref('1.0.0')
const files = ref([]) const files = ref([])
const folders = ref([]) const folders = ref([])
const showingFiles = ref(false)
const themeStore = useTheming() const themeStore = useTheming()
@@ -93,7 +95,9 @@ const exportPack = async () => {
props.instance.path, props.instance.path,
outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`, outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`,
filesToExport, filesToExport,
versionInput.value versionInput.value,
exportDescription.value,
nameInput.value
).catch((err) => handleError(err)) ).catch((err) => handleError(err))
exportModal.value.hide() exportModal.value.hide()
} }
@@ -123,11 +127,31 @@ const exportPack = async () => {
</Button> </Button>
</div> </div>
</div> </div>
<div class="adjacent-input">
<div class="labeled_input">
<p>Description</p>
<div class="textarea-wrapper">
<textarea v-model="exportDescription" placeholder="Enter modpack description..." />
</div>
</div>
</div>
<div class="table"> <div class="table">
<div class="table-head"> <div class="table-head">
<div class="table-cell">Select files and folders to include in pack</div> <div class="table-cell row-wise">
Select files and folders to include in pack
<Button
class="sleek-primary collapsed-button"
icon-only
@click="() => (showingFiles = !showingFiles)"
>
<PlusIcon v-if="!showingFiles" />
<XIcon v-else />
</Button>
</div>
</div> </div>
<div class="table-content"> <div v-if="showingFiles" class="table-content">
<div v-for="[path, children] of folders" :key="path.name" class="table-row"> <div v-for="[path, children] of folders" :key="path.name" class="table-row">
<div class="table-cell file-entry"> <div class="table-cell file-entry">
<div class="file-primary"> <div class="file-primary">
@@ -177,10 +201,6 @@ const exportPack = async () => {
<XIcon /> <XIcon />
Cancel Cancel
</Button> </Button>
<Button disabled>
<SendIcon />
Share
</Button>
<Button color="primary" @click="exportPack"> <Button color="primary" @click="exportPack">
<PackageIcon /> <PackageIcon />
Export Export
@@ -261,4 +281,22 @@ const exportPack = async () => {
gap: var(--gap-sm); gap: var(--gap-sm);
align-items: center; align-items: center;
} }
.row-wise {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.textarea-wrapper {
// margin-top: 1rem;
height: 12rem;
textarea {
max-height: 12rem;
}
.preview {
overflow-y: auto;
}
}
</style> </style>

View File

@@ -18,6 +18,14 @@
" "
/> />
<span class="installation-buttons"> <span class="installation-buttons">
<Button
v-if="props.version"
:disabled="props.disabled || installingJava"
@click="reinstallJava"
>
<DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }}
</Button>
<Button :disabled="props.disabled" @click="autoDetect"> <Button :disabled="props.disabled" @click="autoDetect">
<SearchIcon /> <SearchIcon />
Auto detect Auto detect
@@ -44,8 +52,22 @@
</template> </template>
<script setup> <script setup>
import { Button, SearchIcon, PlayIcon, CheckIcon, XIcon, FolderSearchIcon } from 'omorphia' import {
import { find_jre_17_jres, get_jre } from '@/helpers/jre.js' Button,
SearchIcon,
PlayIcon,
CheckIcon,
XIcon,
FolderSearchIcon,
DownloadIcon,
} from 'omorphia'
import {
auto_install_java,
find_jre_17_jres,
find_jre_8_jres,
get_jre,
test_jre,
} from '@/helpers/jre.js'
import { ref } from 'vue' import { ref } from 'vue'
import { open } from '@tauri-apps/api/dialog' import { open } from '@tauri-apps/api/dialog'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue' import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
@@ -82,15 +104,21 @@ const emit = defineEmits(['update:modelValue'])
const testingJava = ref(false) const testingJava = ref(false)
const testingJavaSuccess = ref(null) const testingJavaSuccess = ref(null)
const installingJava = ref(false)
async function testJava() { async function testJava() {
testingJava.value = true testingJava.value = true
let result = await get_jre(props.modelValue ? props.modelValue.path : '') testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '',
1,
props.version
)
testingJava.value = false testingJava.value = false
testingJavaSuccess.value = !!result
mixpanel_track('JavaTest', { mixpanel_track('JavaTest', {
path: props.modelValue ? props.modelValue.path : '', path: props.modelValue ? props.modelValue.path : '',
success: !!result, success: testingJavaSuccess.value,
}) })
setTimeout(() => { setTimeout(() => {
@@ -109,13 +137,13 @@ async function handleJavaFileInput() {
version: props.version.toString(), version: props.version.toString(),
architecture: 'x86', architecture: 'x86',
} }
mixpanel_track('JavaManualSelect', {
path: filePath,
version: props.version,
})
} }
mixpanel_track('JavaManualSelect', {
path: filePath,
version: props.version,
})
emit('update:modelValue', result) emit('update:modelValue', result)
} }
} }
@@ -125,12 +153,43 @@ async function autoDetect() {
if (!props.compact) { if (!props.compact) {
detectJavaModal.value.show(props.version, props.modelValue) detectJavaModal.value.show(props.version, props.modelValue)
} else { } else {
let versions = await find_jre_17_jres().catch(handleError) if (props.version == 8) {
if (versions.length > 0) { let versions = await find_jre_8_jres().catch(handleError)
emit('update:modelValue', versions[0]) if (versions.length > 0) {
emit('update:modelValue', versions[0])
}
} else {
let versions = await find_jre_17_jres().catch(handleError)
if (versions.length > 0) {
emit('update:modelValue', versions[0])
}
} }
} }
} }
async function reinstallJava() {
installingJava.value = true
const path = await auto_install_java(props.version).catch(handleError)
console.log('java path: ' + path)
let result = await get_jre(path)
console.log('java result ' + result)
if (!result) {
result = {
path: path,
version: props.version.toString(),
architecture: 'x86',
}
}
mixpanel_track('JavaReInstall', {
path: path,
version: props.version,
})
emit('update:modelValue', result)
installingJava.value = false
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -0,0 +1,187 @@
<script setup>
import { Button, Modal, CheckIcon, Badge } from 'omorphia'
import { computed, ref } from 'vue'
import { useTheming } from '@/store/theme'
import { update_managed_modrinth_version } from '@/helpers/profile'
import { releaseColor } from '@/helpers/utils'
import { SwapIcon } from '@/assets/icons/index.js'
const props = defineProps({
versions: {
type: Array,
required: true,
},
instance: {
type: Object,
default: null,
},
})
defineExpose({
show: () => {
modpackVersionModal.value.show()
},
})
const filteredVersions = computed(() => {
return props.versions
})
const modpackVersionModal = ref(null)
const installedVersion = computed(() => props.instance?.metadata?.linked_data?.version_id)
const installing = computed(() => props.instance.install_stage !== 'installed')
const inProgress = ref(false)
const themeStore = useTheming()
const switchVersion = async (versionId) => {
inProgress.value = true
await update_managed_modrinth_version(props.instance.path, versionId)
inProgress.value = false
}
</script>
<template>
<Modal
ref="modpackVersionModal"
class="modpack-version-modal"
header="Change modpack version"
:noblur="!themeStore.advancedRendering"
>
<div class="modal-body">
<Card v-if="instance.metadata.linked_data" class="mod-card">
<div class="table">
<div class="table-row with-columns table-head">
<div class="table-cell table-text download-cell" />
<div class="name-cell table-cell table-text">Name</div>
<div class="table-cell table-text">Supports</div>
</div>
<div class="scrollable">
<div
v-for="version in filteredVersions"
:key="version.id"
class="table-row with-columns selectable"
@click="$router.push(`/project/${$route.params.id}/version/${version.id}`)"
>
<div class="table-cell table-text">
<Button
:color="version.id === installedVersion ? '' : 'primary'"
icon-only
:disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)"
>
<SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else />
</Button>
</div>
<div class="name-cell table-cell table-text">
<div class="version-link">
{{ version.name.charAt(0).toUpperCase() + version.name.slice(1) }}
<div class="version-badge">
<div class="channel-indicator">
<Badge
:color="releaseColor(version.version_type)"
:type="
version.version_type.charAt(0).toUpperCase() +
version.version_type.slice(1)
"
/>
</div>
<div>
{{ version.version_number }}
</div>
</div>
</div>
</div>
<div class="table-cell table-text stacked-text">
<span>
{{
version.loaders
.map((str) => str.charAt(0).toUpperCase() + str.slice(1))
.join(', ')
}}
</span>
<span>
{{ version.game_versions.join(', ') }}
</span>
</div>
</div>
</div>
</div>
</Card>
</div>
</Modal>
</template>
<style scoped lang="scss">
.filter-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.with-columns {
grid-template-columns: min-content 1fr 1fr;
}
.scrollable {
overflow-y: auto;
max-height: 25rem;
}
.card-row {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-raised-bg);
}
.mod-card {
display: flex;
flex-direction: column;
gap: 1rem;
overflow: hidden;
margin-top: 0.5rem;
}
.version-link {
display: flex;
flex-direction: column;
gap: 0.25rem;
.version-badge {
display: flex;
flex-wrap: wrap;
.channel-indicator {
margin-right: 0.5rem;
}
}
}
.stacked-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: flex-start;
}
.download-cell {
width: 4rem;
padding: 1rem;
}
.modal-body {
padding: var(--gap-xl);
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.table {
border: 1px solid var(--color-bg);
}
</style>

View File

@@ -176,7 +176,7 @@ defineProps({
</Card> </Card>
</aside> </aside>
<div ref="searchWrapper" class="search"> <div ref="searchWrapper" class="search">
<Promotion class="promotion" query-param="?r=launcher" /> <Promotion class="promotion" :external="false" query-param="?r=launcher" />
<Card class="project-type-container"> <Card class="project-type-container">
<NavRow :links="selectableProjectTypes" /> <NavRow :links="selectableProjectTypes" />
</Card> </Card>

View File

@@ -22,8 +22,9 @@ const pageOptions = ['Home', 'Library']
id="theme" id="theme"
name="Theme dropdown" name="Theme dropdown"
:options="['Dark']" :options="['Dark']"
:disabled="true"
:default-value="'dark'" :default-value="'dark'"
class="theme-dropdown" class="theme-dropdown disable-children"
/> />
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
@@ -33,7 +34,7 @@ const pageOptions = ['Home', 'Library']
>Change the style of the side navigation bar to a compact version.</span >Change the style of the side navigation bar to a compact version.</span
> >
</label> </label>
<Toggle id="collapsed-nav" :checked="false" /> <Toggle id="collapsed-nav" :checked="false" :disabled="true" />
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label for="advanced-rendering"> <label for="advanced-rendering">
@@ -43,7 +44,7 @@ const pageOptions = ['Home', 'Library']
without hardware-accelerated rendering. without hardware-accelerated rendering.
</span> </span>
</label> </label>
<Toggle id="advanced-rendering" :checked="true" /> <Toggle id="advanced-rendering" :checked="true" :disabled="true" />
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label for="minimize-launcher"> <label for="minimize-launcher">
@@ -52,7 +53,7 @@ const pageOptions = ['Home', 'Library']
>Minimize the launcher when a Minecraft process starts.</span >Minimize the launcher when a Minecraft process starts.</span
> >
</label> </label>
<Toggle id="minimize-launcher" :checked="false" /> <Toggle id="minimize-launcher" :checked="false" :disabled="true" />
</div> </div>
<div class="opening-page"> <div class="opening-page">
<label for="opening-page"> <label for="opening-page">
@@ -65,6 +66,7 @@ const pageOptions = ['Home', 'Library']
:options="pageOptions" :options="pageOptions"
default-value="Home" default-value="Home"
class="opening-page" class="opening-page"
:disabled="true"
/> />
</div> </div>
</Card> </Card>
@@ -82,7 +84,7 @@ const pageOptions = ['Home', 'Library']
lower value if you have a poor internet connection.</span lower value if you have a poor internet connection.</span
> >
</label> </label>
<Slider id="max-downloads" :min="1" :max="10" :step="1" /> <Slider id="max-downloads" :min="1" :max="10" :step="1" :disabled="true" />
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
@@ -93,7 +95,7 @@ const pageOptions = ['Home', 'Library']
lower value if you are frequently getting I/O errors.</span lower value if you are frequently getting I/O errors.</span
> >
</label> </label>
<Slider id="max-writes" :min="1" :max="50" :step="1" /> <Slider id="max-writes" :min="1" :max="50" :step="1" :disabled="true" />
</div> </div>
</Card> </Card>
<Card> <Card>
@@ -110,7 +112,7 @@ const pageOptions = ['Home', 'Library']
customize your experience. Opting out will disable this data collection. customize your experience. Opting out will disable this data collection.
</span> </span>
</label> </label>
<Toggle id="opt-out-analytics" /> <Toggle id="opt-out-analytics" :disabled="true" />
</div> </div>
</Card> </Card>
<Card> <Card>
@@ -122,11 +124,11 @@ const pageOptions = ['Home', 'Library']
<label for="java-17"> <label for="java-17">
<span class="label__title">Java 17 location</span> <span class="label__title">Java 17 location</span>
</label> </label>
<JavaSelector id="java-17" :version="17" model-value="" /> <JavaSelector id="java-17" :version="17" model-value="" :disabled="true" />
<label for="java-8"> <label for="java-8">
<span class="label__title">Java 8 location</span> <span class="label__title">Java 8 location</span>
</label> </label>
<JavaSelector id="java-8" :version="8" model-value="" /> <JavaSelector id="java-8" :version="8" model-value="" :disabled="true" />
<hr class="card-divider" /> <hr class="card-divider" />
<label for="java-args"> <label for="java-args">
<span class="label__title">Java arguments</span> <span class="label__title">Java arguments</span>
@@ -137,6 +139,7 @@ const pageOptions = ['Home', 'Library']
type="text" type="text"
class="installation-input" class="installation-input"
placeholder="Enter java arguments..." placeholder="Enter java arguments..."
:disabled="true"
/> />
<label for="env-vars"> <label for="env-vars">
<span class="label__title">Environmental variables</span> <span class="label__title">Environmental variables</span>
@@ -147,6 +150,7 @@ const pageOptions = ['Home', 'Library']
type="text" type="text"
class="installation-input" class="installation-input"
placeholder="Enter environmental variables..." placeholder="Enter environmental variables..."
:disabled="true"
/> />
<hr class="card-divider" /> <hr class="card-divider" />
<div class="adjacent-input"> <div class="adjacent-input">
@@ -156,7 +160,7 @@ const pageOptions = ['Home', 'Library']
The memory allocated to each instance when it is ran. The memory allocated to each instance when it is ran.
</span> </span>
</label> </label>
<Slider id="max-memory" :min="256" :max="10256" :step="1" unit="mb" /> <Slider id="max-memory" :min="256" :max="10256" :step="1" unit="mb" :disabled="true" />
</div> </div>
</Card> </Card>
<Card> <Card>
@@ -175,6 +179,7 @@ const pageOptions = ['Home', 'Library']
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter pre-launch command..." placeholder="Enter pre-launch command..."
:disabled="true"
/> />
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
@@ -182,7 +187,13 @@ const pageOptions = ['Home', 'Library']
<span class="label__title">Wrapper</span> <span class="label__title">Wrapper</span>
<span class="label__description"> Wrapper command for launching Minecraft. </span> <span class="label__description"> Wrapper command for launching Minecraft. </span>
</label> </label>
<input id="wrapper" autocomplete="off" type="text" placeholder="Enter wrapper command..." /> <input
id="wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
:disabled="true"
/>
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label for="post-exit"> <label for="post-exit">
@@ -194,6 +205,7 @@ const pageOptions = ['Home', 'Library']
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter post-exit command..." placeholder="Enter post-exit command..."
:disabled="true"
/> />
</div> </div>
</Card> </Card>
@@ -208,7 +220,13 @@ const pageOptions = ['Home', 'Library']
<span class="label__title">Width</span> <span class="label__title">Width</span>
<span class="label__description"> The width of the game window when launched. </span> <span class="label__description"> The width of the game window when launched. </span>
</label> </label>
<input id="width" autocomplete="off" type="number" placeholder="Enter width..." /> <input
id="width"
autocomplete="off"
type="number"
placeholder="Enter width..."
:disabled="true"
/>
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label for="height"> <label for="height">
@@ -221,6 +239,7 @@ const pageOptions = ['Home', 'Library']
type="number" type="number"
class="input" class="input"
placeholder="Enter height..." placeholder="Enter height..."
:disabled="true"
/> />
</div> </div>
</Card> </Card>
@@ -244,4 +263,8 @@ const pageOptions = ['Home', 'Library']
.card-divider { .card-divider {
margin: 1rem 0; margin: 1rem 0;
} }
.disable-children * {
pointer-events: none;
}
</style> </style>

View File

@@ -206,9 +206,7 @@ onMounted(() => {
<Button v-if="twoFactorCode" color="primary" large @click="signIn2fa"> Login </Button> <Button v-if="twoFactorCode" color="primary" large @click="signIn2fa"> Login </Button>
<Button v-else-if="loggingIn" color="primary" large @click="signIn"> Login </Button> <Button v-else-if="loggingIn" color="primary" large @click="signIn"> Login </Button>
<Button v-else color="primary" large @click="createAccount"> Create account </Button> <Button v-else color="primary" large @click="createAccount"> Create account </Button>
<Button class="transparent" large @click="goToNextPage"> <Button v-if="!modal" class="transparent" large @click="goToNextPage"> Next </Button>
{{ modal ? 'Continue' : 'Next' }}
</Button>
</div> </div>
</Card> </Card>
</template> </template>

View File

@@ -295,7 +295,7 @@ onMounted(async () => {
:previous-function="prevPhase" :previous-function="prevPhase"
:progress="phase" :progress="phase"
title="Settings" 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."
/> />
<TutorialTip <TutorialTip
v-if="phase === 9" v-if="phase === 9"

View File

@@ -59,6 +59,12 @@ export async function get_jre(path) {
return await invoke('plugin:jre|jre_get_jre', { path }) return await invoke('plugin:jre|jre_get_jre', { path })
} }
// Tests JRE version by running 'java -version' on it.
// Returns true if the version is valid, and matches given (after extraction)
export async function test_jre(path, majorVersion, minorVersion) {
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion })
}
// Autodetect Java globals, by searching the users computer. // Autodetect Java globals, by searching the users computer.
// Returns a *NEW* JavaGlobals that can be put into Settings // Returns a *NEW* JavaGlobals that can be put into Settings
export async function autodetect_java_globals() { export async function autodetect_java_globals() {

View File

@@ -6,37 +6,50 @@
import { invoke } from '@tauri-apps/api/tauri' import { invoke } from '@tauri-apps/api/tauri'
/* /*
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 struct Logs {
pub datetime_string: String, pub filename: String,
pub stdout: String, pub stdout: String,
pub stderr: String, pub stderr: String,
} }
*/ */
/// Get all logs that exist for a given profile /// Get all logs that exist for a given profile
/// This is returned as an array of Log objects, sorted by datetime_string (the folder name, when the log was created) /// This is returned as an array of Log objects, sorted by filename (the folder name, when the log was created)
export async function get_logs(profilePath, clearContents) { export async function get_logs(profilePath, clearContents) {
return await invoke('plugin:logs|logs_get_logs', { profilePath, clearContents }) return await invoke('plugin:logs|logs_get_logs', { profilePath, clearContents })
} }
/// Get a profile's log by datetime_string (the folder name, when the log was created) /// Get a profile's log by filename
export async function get_logs_by_datetime(profilePath, datetimeString) { export async function get_logs_by_filename(profilePath, filename) {
return await invoke('plugin:logs|logs_get_logs_by_datetime', { profilePath, datetimeString }) return await invoke('plugin:logs|logs_get_logs_by_filename', { profilePath, filename })
} }
/// Get a profile's stdout only by datetime_string (the folder name, when the log was created) /// Get a profile's log text only by filename
export async function get_output_by_datetime(profilePath, datetimeString) { export async function get_output_by_filename(profilePath, filename) {
return await invoke('plugin:logs|logs_get_output_by_datetime', { profilePath, datetimeString }) return await invoke('plugin:logs|logs_get_output_by_filename', { profilePath, filename })
} }
/// Delete a profile's log by datetime_string (the folder name, when the log was created) /// Delete a profile's log by filename
export async function delete_logs_by_datetime(profilePath, datetimeString) { export async function delete_logs_by_filename(profilePath, filename) {
return await invoke('plugin:logs|logs_delete_logs_by_datetime', { profilePath, datetimeString }) return await invoke('plugin:logs|logs_delete_logs_by_filename', { profilePath, filename })
} }
/// Delete all logs for a given profile /// Delete all logs for a given profile
export async function delete_logs(profilePath) { export async function delete_logs(profilePath) {
return await invoke('plugin:logs|logs_delete_logs', { profilePath }) return await invoke('plugin:logs|logs_delete_logs', { profilePath })
} }
/// Get the latest log for a given profile and cursor (startpoint to read withi nthe file)
/// Returns:
/*
{
cursor: u64
output: String
new_file: bool <- the cursor was too far, meaning that the file was likely rotated/reset. This signals to the frontend to clear the log and start over with this struct.
}
*/
export async function get_latest_log_cursor(profilePath, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
}

View File

@@ -47,12 +47,6 @@ export async function get_all_running_profiles() {
return await invoke('plugin:process|process_get_all_running_profiles') return await invoke('plugin:process|process_get_all_running_profiles')
} }
/// Gets process stdout by UUID
/// Returns String
export async function get_output_by_uuid(uuid) {
return await invoke('plugin:process|process_get_output_by_uuid', { uuid })
}
/// Kills a process by UUID /// Kills a process by UUID
export async function kill_by_uuid(uuid) { export async function kill_by_uuid(uuid) {
return await invoke('plugin:process|process_kill_by_uuid', { uuid }) return await invoke('plugin:process|process_kill_by_uuid', { uuid })

View File

@@ -27,6 +27,11 @@ export async function create(name, gameVersion, modloader, loaderVersion, icon,
}) })
} }
// duplicate a profile
export async function duplicate(path) {
return await invoke('plugin:profile_create|profile_duplicate', { path })
}
// Remove a profile // Remove a profile
export async function remove(path) { export async function remove(path) {
return await invoke('plugin:profile|profile_remove', { path }) return await invoke('plugin:profile|profile_remove', { path })
@@ -44,6 +49,12 @@ export async function get_full_path(path) {
return await invoke('plugin:profile|profile_get_full_path', { path }) return await invoke('plugin:profile|profile_get_full_path', { path })
} }
// Get's a mod's full fs path
// Returns a path
export async function get_mod_full_path(path, projectPath) {
return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
}
// Get optimal java version from profile // Get optimal java version from profile
// Returns a java version // Returns a java version
export async function get_optimal_jre_key(path) { export async function get_optimal_jre_key(path) {
@@ -101,9 +112,9 @@ export async function remove_project(path, projectPath) {
return await invoke('plugin:profile|profile_remove_project', { path, projectPath }) return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
} }
// Update a managed Modrinth profile // Update a managed Modrinth profile to a specific version
export async function update_managed_modrinth(path) { export async function update_managed_modrinth_version(path, versionId) {
return await invoke('plugin:profile|profile_update_managed_modrinth', { path }) return await invoke('plugin:profile|profile_update_managed_modrinth_version', { path, versionId })
} }
// Repair a managed Modrinth profile // Repair a managed Modrinth profile
@@ -114,12 +125,21 @@ export async function update_repair_modrinth(path) {
// Export a profile to .mrpack // Export a profile to .mrpack
/// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs') /// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
// Version id is optional (ie: 1.1.5) // Version id is optional (ie: 1.1.5)
export async function export_profile_mrpack(path, exportLocation, includedOverrides, versionId) { export async function export_profile_mrpack(
path,
exportLocation,
includedOverrides,
versionId,
description,
name
) {
return await invoke('plugin:profile|profile_export_mrpack', { return await invoke('plugin:profile|profile_export_mrpack', {
path, path,
exportLocation, exportLocation,
includedOverrides, includedOverrides,
versionId, versionId,
description,
name,
}) })
} }

View File

@@ -2,6 +2,7 @@ import {
add_project_from_version as installMod, add_project_from_version as installMod,
check_installed, check_installed,
get_full_path, get_full_path,
get_mod_full_path,
} from '@/helpers/profile' } from '@/helpers/profile'
import { useFetch } from '@/helpers/fetch.js' import { useFetch } from '@/helpers/fetch.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
@@ -20,12 +21,21 @@ export async function showInFolder(path) {
return await invoke('plugin:utils|show_in_folder', { path }) return await invoke('plugin:utils|show_in_folder', { path })
} }
export async function showLauncherLogsFolder() {
return await invoke('plugin:utils|show_launcher_logs_folder', {})
}
// Opens a profile's folder in the OS file explorer // Opens a profile's folder in the OS file explorer
export async function showProfileInFolder(path) { export async function showProfileInFolder(path) {
const fullPath = await get_full_path(path) const fullPath = await get_full_path(path)
return await showInFolder(fullPath) return await showInFolder(fullPath)
} }
export async function highlightModInProfile(profilePath, projectPath) {
const fullPath = await get_mod_full_path(profilePath, projectPath)
return await showInFolder(fullPath)
}
export const releaseColor = (releaseType) => { export const releaseColor = (releaseType) => {
switch (releaseType) { switch (releaseType) {
case 'release': case 'release':

View File

@@ -59,5 +59,6 @@ initialize_state()
}) })
}) })
.catch((err) => { .catch((err) => {
console.error(err) console.error('Failed to initialize app', err)
mountedApp.failure(err)
}) })

View File

@@ -133,11 +133,7 @@ async function refreshDir() {
class="login-screen-modal" class="login-screen-modal"
:noblur="!themeStore.advancedRendering" :noblur="!themeStore.advancedRendering"
> >
<ModrinthLoginScreen <ModrinthLoginScreen :modal="true" :prev-page="signInAfter" :next-page="signInAfter" />
:modal="true"
:prev-page="$refs.loginScreenModal.show()"
:next-page="signInAfter"
/>
</Modal> </Modal>
<div class="adjacent-input"> <div class="adjacent-input">
<label for="theme"> <label for="theme">
@@ -323,6 +319,21 @@ async function refreshDir() {
" "
/> />
</div> </div>
<div class="adjacent-input">
<label for="disable-discord-rpc">
<span class="label__title">Disable Discord RPC</span>
<span class="label__description">
Disables the Discord Rich Presence integration. 'Modrinth' will no longer show up as a
game or app you are using on your Discord profile. This does not disable any
instance-specific Discord Rich Presence integrations, such as those added by mods.
</span>
</label>
<Toggle
id="disable-discord-rpc"
v-model="settings.disable_discord_rpc"
:checked="settings.disable_discord_rpc"
/>
</div>
</Card> </Card>
<Card> <Card>
<div class="label"> <div class="label">
@@ -372,7 +383,7 @@ async function refreshDir() {
<Slider <Slider
id="max-memory" id="max-memory"
v-model="settings.memory.maximum" v-model="settings.memory.maximum"
:min="256" :min="8"
:max="maxMemory" :max="maxMemory"
:step="1" :step="1"
unit="mb" unit="mb"

View File

@@ -75,7 +75,7 @@
</Card> </Card>
</div> </div>
<div class="content"> <div class="content">
<Promotion query-param="?r=launcher" /> <Promotion :external="false" query-param="?r=launcher" />
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
<template v-if="Component"> <template v-if="Component">
<Suspense @pending="loadingBar.startLoading()" @resolve="loadingBar.stopLoading()"> <Suspense @pending="loadingBar.startLoading()" @resolve="loadingBar.stopLoading()">
@@ -84,6 +84,9 @@
:instance="instance" :instance="instance"
:options="options" :options="options"
:offline="offline" :offline="offline"
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
></component> ></component>
</Suspense> </Suspense>
</template> </template>
@@ -149,6 +152,7 @@ import { isOffline, showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import { mixpanel_track } from '@/helpers/mixpanel' import { mixpanel_track } from '@/helpers/mixpanel'
import { convertFileSrc } from '@tauri-apps/api/tauri' import { convertFileSrc } from '@tauri-apps/api/tauri'
import { useFetch } from '@/helpers/fetch'
const route = useRoute() const route = useRoute()
@@ -197,6 +201,15 @@ const checkProcess = async () => {
uuid.value = null 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() await checkProcess()
const stopInstance = async (context) => { const stopInstance = async (context) => {

View File

@@ -20,6 +20,15 @@
Share Share
</Button> </Button>
<Button <Button
v-if="logs[selectedLogIndex] && logs[selectedLogIndex].live === true"
@click="clearLiveLog()"
>
<TrashIcon />
Clear
</Button>
<Button
v-else
:disabled="!logs[selectedLogIndex] || logs[selectedLogIndex].live === true" :disabled="!logs[selectedLogIndex] || logs[selectedLogIndex].live === true"
color="danger" color="danger"
@click="deleteLog()" @click="deleteLog()"
@@ -29,14 +38,43 @@
</Button> </Button>
</div> </div>
</div> </div>
<div ref="logContainer" class="log-text"> <div class="button-row">
<span <input
v-for="(line, index) in logs[selectedLogIndex]?.stdout.split('\n')" id="text-filter"
:key="index" v-model="searchFilter"
class="no-wrap" autocomplete="off"
type="text"
class="text-filter"
placeholder="Type to filter logs..."
/>
<div class="filter-group">
<Checkbox
v-for="level in levels"
:key="level.toLowerCase()"
v-model="levelFilters[level.toLowerCase()]"
class="filter-checkbox"
>
{{ level }}</Checkbox
>
</div>
</div>
<div class="log-text">
<RecycleScroller
v-slot="{ item }"
ref="logContainer"
class="scroller"
:items="displayProcessedLogs"
direction="vertical"
:item-size="20"
key-field="id"
> >
{{ line }} <br /> <div class="user no-wrap">
</span> <span :style="{ color: item.prefixColor, 'font-weight': item.weight }">{{
item.prefix
}}</span>
<span :style="{ color: item.textColor }">{{ item.text }}</span>
</div>
</RecycleScroller>
</div> </div>
<ShareModal <ShareModal
ref="shareModal" ref="shareModal"
@@ -56,20 +94,31 @@ import {
ClipboardCopyIcon, ClipboardCopyIcon,
DropdownSelect, DropdownSelect,
ShareIcon, ShareIcon,
Checkbox,
TrashIcon, TrashIcon,
ShareModal, ShareModal,
} from 'omorphia' } from 'omorphia'
import { delete_logs_by_datetime, get_logs, get_output_by_datetime } from '@/helpers/logs.js' import {
import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue' delete_logs_by_filename,
get_logs,
get_output_by_filename,
get_latest_log_cursor,
} from '@/helpers/logs.js'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import calendar from 'dayjs/plugin/calendar' import isToday from 'dayjs/plugin/isToday'
import { get_output_by_uuid, get_uuids_by_profile_path } from '@/helpers/process.js' import isYesterday from 'dayjs/plugin/isYesterday'
import { get_uuids_by_profile_path } from '@/helpers/process.js'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { process_listener } from '@/helpers/events.js' import { process_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { ofetch } from 'ofetch' import { ofetch } from 'ofetch'
dayjs.extend(calendar) import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
dayjs.extend(isToday)
dayjs.extend(isYesterday)
const route = useRoute() const route = useRoute()
@@ -82,11 +131,21 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
playing: {
type: Boolean,
default: false,
},
}) })
const currentLiveLog = ref(null)
const currentLiveLogCursor = ref(0)
const emptyText = ['No live game detected.', 'Start your game to proceed']
const logs = ref([]) const logs = ref([])
await setLogs() await setLogs()
const logsColored = true
const selectedLogIndex = ref(0) const selectedLogIndex = ref(0)
const copied = ref(false) const copied = ref(false)
const logContainer = ref(null) const logContainer = ref(null)
@@ -95,16 +154,86 @@ const userScrolled = ref(false)
const isAutoScrolling = ref(false) const isAutoScrolling = ref(false)
const shareModal = ref(null) const shareModal = ref(null)
const levels = ['Comment', 'Error', 'Warn', 'Info', 'Debug', 'Trace']
const levelFilters = ref({})
levels.forEach((level) => {
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() { async function getLiveLog() {
if (route.params.id) { if (route.params.id) {
const uuids = await get_uuids_by_profile_path(route.params.id).catch(handleError) const uuids = await get_uuids_by_profile_path(route.params.id).catch(handleError)
let returnValue let returnValue
if (uuids.length === 0) { if (uuids.length === 0) {
returnValue = 'No live game detected. \nStart your game to proceed' returnValue = emptyText.join('\n')
} else { } 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 { name: 'Live Log', stdout: returnValue, live: true }
} }
return null return null
@@ -112,9 +241,25 @@ async function getLiveLog() {
async function getLogs() { async function getLogs() {
return (await get_logs(props.instance.path, true).catch(handleError)).reverse().map((log) => { return (await get_logs(props.instance.path, true).catch(handleError)).reverse().map((log) => {
log.name = dayjs( if (log.filename == 'latest.log') {
log.datetime_string.slice(0, 8) + 'T' + log.datetime_string.slice(9) log.name = 'Latest Log'
).calendar() } 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...' log.stdout = 'Loading...'
return log return log
}) })
@@ -152,29 +297,127 @@ watch(selectedLogIndex, async (newIndex) => {
if (logs.value.length > 1 && newIndex !== 0) { if (logs.value.length > 1 && newIndex !== 0) {
logs.value[newIndex].stdout = 'Loading...' 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, props.instance.path,
logs.value[newIndex].datetime_string logs.value[newIndex].filename
).catch(handleError) ).catch(handleError)
} }
}) })
if (logs.value.length >= 1) { if (logs.value.length > 1 && !props.playing) {
selectedLogIndex.value = 1 selectedLogIndex.value = 1
} else {
selectedLogIndex.value = 0
} }
const deleteLog = async () => { const deleteLog = async () => {
if (logs.value[selectedLogIndex.value] && selectedLogIndex.value !== 0) { if (logs.value[selectedLogIndex.value] && selectedLogIndex.value !== 0) {
let deleteIndex = selectedLogIndex.value let deleteIndex = selectedLogIndex.value
selectedLogIndex.value = deleteIndex - 1 selectedLogIndex.value = deleteIndex - 1
await delete_logs_by_datetime( await delete_logs_by_filename(props.instance.path, logs.value[deleteIndex].filename).catch(
props.instance.path, handleError
logs.value[deleteIndex].datetime_string )
).catch(handleError)
await setLogs() 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() { function handleUserScroll() {
if (!isAutoScrolling.value) { if (!isAutoScrolling.value) {
userScrolled.value = true userScrolled.value = true
@@ -185,19 +428,14 @@ interval.value = setInterval(async () => {
if (logs.value.length > 0) { if (logs.value.length > 0) {
logs.value[0] = await getLiveLog() logs.value[0] = await getLiveLog()
const scroll = logContainer.value.getScroll()
// Allow resetting of userScrolled if the user scrolls to the bottom // Allow resetting of userScrolled if the user scrolls to the bottom
if (selectedLogIndex.value === 0) { if (selectedLogIndex.value === 0) {
if ( if (scroll.end >= logContainer.value.$el.scrollHeight - 10) userScrolled.value = false
logContainer.value.scrollTop + logContainer.value.offsetHeight >=
logContainer.value.scrollHeight - 10
)
userScrolled.value = false
if (!userScrolled.value) { if (!userScrolled.value) {
await nextTick() await nextTick()
isAutoScrolling.value = true isAutoScrolling.value = true
logContainer.value.scrollTop = logContainer.value.scrollToItem(displayProcessedLogs.value.length - 1)
logContainer.value.scrollHeight - logContainer.value.offsetHeight
setTimeout(() => (isAutoScrolling.value = false), 50) setTimeout(() => (isAutoScrolling.value = false), 50)
} }
} }
@@ -206,9 +444,13 @@ interval.value = setInterval(async () => {
const unlistenProcesses = await process_listener(async (e) => { const unlistenProcesses = await process_listener(async (e) => {
if (e.event === 'launched') { if (e.event === 'launched') {
currentLiveLog.value = ''
currentLiveLogCursor.value = 0
selectedLogIndex.value = 0 selectedLogIndex.value = 0
} }
if (e.event === 'finished') { if (e.event === 'finished') {
currentLiveLog.value = ''
currentLiveLogCursor.value = 0
userScrolled.value = false userScrolled.value = false
await setLogs() await setLogs()
selectedLogIndex.value = 1 selectedLogIndex.value = 1
@@ -216,11 +458,11 @@ const unlistenProcesses = await process_listener(async (e) => {
}) })
onMounted(() => { onMounted(() => {
logContainer.value.addEventListener('scroll', handleUserScroll) logContainer.value.$el.addEventListener('scroll', handleUserScroll)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
logContainer.value.removeEventListener('scroll', handleUserScroll) logContainer.value.$el.removeEventListener('scroll', handleUserScroll)
}) })
onUnmounted(() => { onUnmounted(() => {
clearInterval(interval.value) clearInterval(interval.value)
@@ -257,7 +499,9 @@ onUnmounted(() => {
color: var(--color-contrast); color: var(--color-contrast);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; 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; white-space: normal;
color-scheme: dark; color-scheme: dark;
@@ -265,4 +509,37 @@ onUnmounted(() => {
white-space: pre; 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;
}
</style> </style>

View File

@@ -26,21 +26,25 @@
</div> </div>
</div> </div>
<Button <Button
v-if="isPackLinked" v-if="canUpdatePack"
v-tooltip="'Modpack is up to date'" :disabled="installing"
:disabled="updatingModpack || !canUpdatePack"
color="secondary" color="secondary"
@click="updateModpack" @click="modpackVersionModal.show()"
> >
<UpdatedIcon /> <UpdatedIcon />
{{ updatingModpack ? 'Updating' : 'Update modpack' }} {{ installing ? 'Updating' : 'Update modpack' }}
</Button> </Button>
<Button v-else @click="exportModal.show()"> <Button v-else-if="!isPackLocked" @click="exportModal.show()">
<PackageIcon /> <PackageIcon />
Export modpack Export modpack
</Button> </Button>
<Button v-if="!isPackLocked && projects.some((m) => m.outdated)" @click="updateAll">
<UpdatedIcon />
Update all
</Button>
<DropdownButton <DropdownButton
v-if="!isPackLinked" v-if="!isPackLocked"
:options="['search', 'from_file']" :options="['search', 'from_file']"
default-value="search" default-value="search"
name="add-content-dropdown" name="add-content-dropdown"
@@ -107,9 +111,9 @@
<ShareIcon /> <ShareIcon />
Share Share
</Button> </Button>
<div v-tooltip="isPackLinked ? 'Unpair this instance to remove mods' : ''"> <div v-tooltip="isPackLocked ? 'Unlock this instance to remove mods' : ''">
<Button <Button
:disabled="isPackLinked" :disabled="isPackLocked"
class="transparent trash" class="transparent trash"
@click="deleteWarning.show()" @click="deleteWarning.show()"
@mouseover="selectedOption = 'Delete'" @mouseover="selectedOption = 'Delete'"
@@ -118,20 +122,20 @@
Delete Delete
</Button> </Button>
</div> </div>
<div v-tooltip="isPackLinked ? 'Unpair this instance to update mods' : ''"> <div v-tooltip="isPackLocked ? 'Unlock this instance to update mods' : ''">
<Button <Button
:disabled="isPackLinked || offline" :disabled="isPackLocked || offline"
class="transparent update" class="transparent update"
@click="updateAll()" @click="updateSelected()"
@mouseover="selectedOption = 'Update'" @mouseover="selectedOption = 'Update'"
> >
<UpdatedIcon /> <UpdatedIcon />
Update Update
</Button> </Button>
</div> </div>
<div v-tooltip="isPackLinked ? 'Unpair this instance to toggle mods' : ''"> <div v-tooltip="isPackLocked ? 'Unlock this instance to toggle mods' : ''">
<Button <Button
:disabled="isPackLinked" :disabled="isPackLocked"
class="transparent" class="transparent"
@click="toggleSelected()" @click="toggleSelected()"
@mouseover="selectedOption = 'Toggle'" @mouseover="selectedOption = 'Toggle'"
@@ -232,21 +236,18 @@
<span v-tooltip="`${mod.version}`">{{ mod.version }}</span> <span v-tooltip="`${mod.version}`">{{ mod.version }}</span>
</div> </div>
<div class="table-cell table-text manage"> <div class="table-cell table-text manage">
<div v-tooltip="isPackLinked ? 'Unpair this instance to remove mods.' : ''"> <div v-tooltip="isPackLocked ? 'Unlock this instance to remove mods.' : 'Remove project'">
<Button <Button :disabled="isPackLocked" icon-only @click="removeMod(mod)">
v-tooltip="'Remove project'"
:disabled="isPackLinked"
icon-only
@click="removeMod(mod)"
>
<TrashIcon /> <TrashIcon />
</Button> </Button>
</div> </div>
<AnimatedLogo v-if="mod.updating" class="btn icon-only updating-indicator"></AnimatedLogo> <AnimatedLogo v-if="mod.updating" class="btn icon-only updating-indicator"></AnimatedLogo>
<div v-tooltip="isPackLinked ? 'Unpair this instance to update mods.' : ''"> <div
v-else
v-tooltip="isPackLocked ? 'Unlock this instance to update mods.' : 'Update project'"
>
<Button <Button
v-tooltip="'Update project'" :disabled="!mod.outdated || offline || isPackLocked"
:disabled="!mod.outdated || offline || isPackLinked"
icon-only icon-only
@click="updateProject(mod)" @click="updateProject(mod)"
> >
@@ -254,10 +255,10 @@
<CheckIcon v-else /> <CheckIcon v-else />
</Button> </Button>
</div> </div>
<div v-tooltip="isPackLinked ? 'Unpair this instance to toggle mods.' : ''"> <div v-tooltip="isPackLocked ? 'Unlock this instance to toggle mods.' : ''">
<input <input
id="switch-1" id="switch-1"
:disabled="isPackLinked" :disabled="isPackLocked"
autocomplete="off" autocomplete="off"
type="checkbox" type="checkbox"
class="switch stylized-toggle" class="switch stylized-toggle"
@@ -268,7 +269,7 @@
<Button <Button
v-tooltip="`Show ${mod.file_name}`" v-tooltip="`Show ${mod.file_name}`"
icon-only icon-only
@click="showProfileInFolder(mod.path)" @click="highlightModInProfile(instance.path, mod.path)"
> >
<FolderOpenIcon /> <FolderOpenIcon />
</Button> </Button>
@@ -301,6 +302,14 @@
</DropdownButton> </DropdownButton>
</div> </div>
</div> </div>
<Pagination
v-if="projects.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
class="pagination-after"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<Modal ref="deleteWarning" header="Are you sure?"> <Modal ref="deleteWarning" header="Are you sure?">
<div class="modal-body"> <div class="modal-body">
<div class="markdown-body"> <div class="markdown-body">
@@ -349,6 +358,12 @@
share-text="Check out the projects I'm using in my modpack!" share-text="Check out the projects I'm using in my modpack!"
/> />
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" /> <ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ModpackVersionModal
v-if="instance.metadata.linked_data"
ref="modpackVersionModal"
:instance="instance"
:versions="props.versions"
/>
</template> </template>
<script setup> <script setup>
import { import {
@@ -385,7 +400,6 @@ import {
remove_project, remove_project,
toggle_disable_project, toggle_disable_project,
update_all, update_all,
update_managed_modrinth,
update_project, update_project,
} from '@/helpers/profile.js' } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
@@ -393,9 +407,10 @@ import { mixpanel_track } from '@/helpers/mixpanel'
import { open } from '@tauri-apps/api/dialog' import { open } from '@tauri-apps/api/dialog'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import { convertFileSrc } from '@tauri-apps/api/tauri' import { convertFileSrc } from '@tauri-apps/api/tauri'
import { showProfileInFolder } from '@/helpers/utils.js' import { highlightModInProfile } from '@/helpers/utils.js'
import { MenuIcon, ToggleIcon, TextInputIcon, AddProjectImage, PackageIcon } from '@/assets/icons' import { MenuIcon, ToggleIcon, TextInputIcon, AddProjectImage, PackageIcon } from '@/assets/icons'
import ExportModal from '@/components/ui/ExportModal.vue' import ExportModal from '@/components/ui/ExportModal.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
const router = useRouter() const router = useRouter()
@@ -418,20 +433,24 @@ const props = defineProps({
return false return false
}, },
}, },
versions: {
type: Array,
required: true,
},
}) })
const projects = ref([]) const projects = ref([])
const selectionMap = ref(new Map()) const selectionMap = ref(new Map())
const showingOptions = ref(false) const showingOptions = ref(false)
const isPackLinked = computed(() => { const isPackLocked = computed(() => {
return props.instance.metadata.linked_data return props.instance.metadata.linked_data && props.instance.metadata.linked_data.locked
}) })
const canUpdatePack = computed(() => { const canUpdatePack = computed(() => {
if (!props.instance.metadata.linked_data) return false
return props.instance.metadata.linked_data.version_id !== props.instance.modrinth_update_version return props.instance.metadata.linked_data.version_id !== props.instance.modrinth_update_version
}) })
const exportModal = ref(null) const exportModal = ref(null)
console.log(props.instance)
const initProjects = (initInstance) => { const initProjects = (initInstance) => {
projects.value = [] projects.value = []
if (!initInstance || !initInstance.projects) return if (!initInstance || !initInstance.projects) return
@@ -508,6 +527,9 @@ watch(
} }
) )
const modpackVersionModal = ref(null)
const installing = computed(() => props.instance.install_stage !== 'installed')
const searchFilter = ref('') const searchFilter = ref('')
const selectAll = ref(false) const selectAll = ref(false)
const selectedProjectType = ref('All') const selectedProjectType = ref('All')
@@ -661,6 +683,7 @@ const selectUpdatable = () => {
const updateProject = async (mod) => { const updateProject = async (mod) => {
mod.updating = true mod.updating = true
await new Promise((resolve) => setTimeout(resolve, 0))
mod.path = await update_project(props.instance.path, mod.path).catch(handleError) mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
mod.updating = false mod.updating = false
@@ -779,6 +802,14 @@ const toggleSelected = async () => {
} }
} }
const updateSelected = async () => {
const promises = []
for (const project of functionValues.value) {
if (project.outdated) promises.push(updateProject(project))
}
await Promise.all(promises).catch(handleError)
}
const enableAll = async () => { const enableAll = async () => {
for (const project of functionValues.value) { for (const project of functionValues.value) {
if (project.disabled) { if (project.disabled) {
@@ -828,13 +859,6 @@ const handleContentOptionClick = async (args) => {
} }
} }
const updatingModpack = ref(false)
const updateModpack = async () => {
updatingModpack.value = true
await update_managed_modrinth(props.instance.path).catch(handleError)
updatingModpack.value = false
}
watch(selectAll, () => { watch(selectAll, () => {
for (const [key, value] of Array.from(selectionMap.value)) { for (const [key, value] of Array.from(selectionMap.value)) {
if (value !== selectAll.value) { if (value !== selectAll.value) {
@@ -1152,4 +1176,8 @@ onUnmounted(() => {
height: 2.5rem; height: 2.5rem;
} }
} }
.pagination-after {
margin-bottom: 5rem;
}
</style> </style>

View File

@@ -8,6 +8,56 @@
:noblur="!themeStore.advancedRendering" :noblur="!themeStore.advancedRendering"
@proceed="removeProfile" @proceed="removeProfile"
/> />
<Modal
ref="modalConfirmUnlock"
header="Are you sure you want to unlock this instance?"
:noblur="!themeStore.advancedRendering"
>
<div class="modal-delete">
<div
class="markdown-body"
v-html="
'If you proceed, you will not be able to re-lock it without using the `Reinstall modpack` button.'
"
/>
<div class="input-group push-right">
<button class="btn" @click="$refs.modalConfirmUnlock.hide()">
<XIcon />
Cancel
</button>
<button class="btn btn-danger" :disabled="action_disabled" @click="unlockProfile">
<LockIcon />
Unlock
</button>
</div>
</div>
</Modal>
<Modal
ref="modalConfirmUnpair"
header="Are you sure you want to unpair this instance?"
:noblur="!themeStore.advancedRendering"
>
<div class="modal-delete">
<div
class="markdown-body"
v-html="
'If you proceed, you will not be able to re-pair it without creating an entirely new instance.'
"
/>
<div class="input-group push-right">
<button class="btn" @click="$refs.modalConfirmUnpair.hide()">
<XIcon />
Cancel
</button>
<button class="btn btn-danger" :disabled="action_disabled" @click="unpairProfile">
<XIcon />
Unpair
</button>
</div>
</div>
</Modal>
<Modal <Modal
ref="changeVersionsModal" ref="changeVersionsModal"
header="Change instance versions" header="Change instance versions"
@@ -191,7 +241,7 @@
<Slider <Slider
v-model="memory.maximum" v-model="memory.maximum"
:disabled="!overrideMemorySettings" :disabled="!overrideMemorySettings"
:min="256" :min="8"
:max="maxMemory" :max="maxMemory"
:step="1" :step="1"
unit="mb" unit="mb"
@@ -298,22 +348,110 @@
/> />
</div> </div>
</Card> </Card>
<Card v-if="instance.metadata.linked_data">
<div class="label">
<h3>
<span class="label__title size-card-header">Modpack</span>
</h3>
</div>
<div class="adjacent-input">
<label for="general-modpack-info">
<span class="label__description">
<strong>Modpack: </strong> {{ instance.metadata.name }}
</span>
<span class="label__description">
<strong>Version: </strong>
{{
installedVersionData.name.charAt(0).toUpperCase() + installedVersionData.name.slice(1)
}}
</span>
</label>
</div>
<div v-if="!isPackLocked" class="adjacent-input">
<Card class="unlocked-instance">
This is an unlocked instance. There may be unexpected behaviour unintended by the modpack
creator.
</Card>
</div>
<div v-else class="adjacent-input">
<label for="unlock-profile">
<span class="label__title">Unlock instance</span>
<span class="label__description">
Allows modifications to the instance, which allows you to add projects to the modpack. The
pack will remain linked, and you can still change versions. Only mods listed in the
modpack will be modified on version changes.
</span>
</label>
<Button id="unlock-profile" @click="$refs.modalConfirmUnlock.show()">
<LockIcon /> Unlock
</Button>
</div>
<div class="adjacent-input">
<label for="unpair-profile">
<span class="label__title">Unpair instance</span>
<span class="label__description">
Removes the link to an external Modrinth modpack on the instance. This allows you to edit
modpacks you download through the browse page but you will not be able to update the
instance from a new version of a modpack if you do this.
</span>
</label>
<Button id="unpair-profile" @click="$refs.modalConfirmUnpair.show()">
<XIcon /> Unpair
</Button>
</div>
<div class="adjacent-input">
<label for="change-modpack-version">
<span class="label__title">Change modpack version</span>
<span class="label__description">
Changes to another version of the modpack, allowing upgrading or downgrading. This will
replace all files marked as relevant to the modpack.
</span>
</label>
<Button
id="change-modpack-version"
:disabled="inProgress || installing"
@click="modpackVersionModal.show()"
>
<SwapIcon />
Change modpack version
</Button>
</div>
<div class="adjacent-input">
<label for="repair-modpack">
<span class="label__title">Reinstall modpack</span>
<span class="label__description">
Removes all projects and reinstalls Modrinth modpack. Use this to fix unexpected behaviour
if your instance is diverging from the Modrinth modpack. This also re-locks the instance.
</span>
</label>
<Button id="repair-modpack" color="highlight" :disabled="offline" @click="repairModpack">
<DownloadIcon /> Reinstall
</Button>
</div>
</Card>
<Card> <Card>
<div class="label"> <div class="label">
<h3> <h3>
<span class="label__title size-card-header">Instance management</span> <span class="label__title size-card-header">Instance management</span>
</h3> </h3>
</div> </div>
<div v-if="instance.metadata.linked_data" class="adjacent-input"> <div v-if="instance.install_stage == 'installed'" class="adjacent-input">
<label for="repair-profile"> <label for="duplicate-profile">
<span class="label__title">Unpair instance</span> <span class="label__title">Duplicate instance</span>
<span class="label__description"> <span class="label__description">
Removes the link to an external modpack on the instance. This allows you to edit modpacks Creates another copy of the instance, including saves, configs, mods, and everything.
you download through the browse page but you will not be able to update the instance from
a new version of a modpack if you do this.
</span> </span>
</label> </label>
<Button id="repair-profile" @click="unpairProfile"> <XIcon /> Unpair </Button> <Button
id="repair-profile"
:disabled:="installing || inProgress || offline"
@click="duplicateProfile"
>
<ClipboardCopyIcon /> Duplicate
</Button>
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label for="repair-profile"> <label for="repair-profile">
@@ -326,29 +464,12 @@
<Button <Button
id="repair-profile" id="repair-profile"
color="highlight" color="highlight"
:disabled="repairing || offline" :disabled="installing || inProgress || repairing || offline"
@click="repairProfile" @click="repairProfile"
> >
<HammerIcon /> Repair <HammerIcon /> Repair
</Button> </Button>
</div> </div>
<div v-if="props.instance.modrinth_update_version" class="adjacent-input">
<label for="repair-profile">
<span class="label__title">Reinstall modpack</span>
<span class="label__description">
Reinstalls Modrinth modpack and checks for corruption. Use this if your game is not
launching due to your instance diverging from the Modrinth modpack.
</span>
</label>
<Button
id="repair-profile"
color="highlight"
:disabled="repairing || offline"
@click="repairModpack"
>
<DownloadIcon /> Reinstall
</Button>
</div>
<div class="adjacent-input"> <div class="adjacent-input">
<label for="delete-profile"> <label for="delete-profile">
<span class="label__title">Delete instance</span> <span class="label__title">Delete instance</span>
@@ -367,6 +488,12 @@
</Button> </Button>
</div> </div>
</Card> </Card>
<ModpackVersionModal
v-if="instance.metadata.linked_data"
ref="modpackVersionModal"
:instance="instance"
:versions="props.versions"
/>
</template> </template>
<script setup> <script setup>
@@ -383,14 +510,19 @@ import {
DropdownSelect, DropdownSelect,
XIcon, XIcon,
SaveIcon, SaveIcon,
LockIcon,
HammerIcon, HammerIcon,
DownloadIcon,
ModalConfirm, ModalConfirm,
DownloadIcon,
ClipboardCopyIcon,
Button, Button,
} from 'omorphia' } from 'omorphia'
import { SwapIcon } from '@/assets/icons'
import { Multiselect } from 'vue-multiselect' import { Multiselect } from 'vue-multiselect'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import {
duplicate,
edit, edit,
edit_icon, edit_icon,
get_optimal_jre_key, get_optimal_jre_key,
@@ -415,6 +547,7 @@ import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { mixpanel_track } from '@/helpers/mixpanel' import { mixpanel_track } from '@/helpers/mixpanel'
import { useTheming } from '@/store/theme.js' import { useTheming } from '@/store/theme.js'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
const router = useRouter() const router = useRouter()
@@ -427,6 +560,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
versions: {
type: Array,
required: true,
},
}) })
const themeStore = useTheming() const themeStore = useTheming()
@@ -435,6 +572,8 @@ const title = ref(props.instance.metadata.name)
const icon = ref(props.instance.metadata.icon) const icon = ref(props.instance.metadata.icon)
const groups = ref(props.instance.metadata.groups) const groups = ref(props.instance.metadata.groups)
const modpackVersionModal = ref(null)
const instancesList = Object.values(await list(true)) const instancesList = Object.values(await list(true))
const availableGroups = ref([ const availableGroups = ref([
...instancesList.reduce((acc, obj) => { ...instancesList.reduce((acc, obj) => {
@@ -469,6 +608,9 @@ async function setIcon() {
const globalSettings = await get().catch(handleError) const globalSettings = await get().catch(handleError)
const modalConfirmUnlock = ref(null)
const modalConfirmUnpair = ref(null)
const javaSettings = props.instance.java ?? {} const javaSettings = props.instance.java ?? {}
const overrideJavaInstall = ref(!!javaSettings.override_version) const overrideJavaInstall = ref(!!javaSettings.override_version)
@@ -496,6 +638,13 @@ const fullscreenSetting = ref(!!props.instance.fullscreen)
const unlinkModpack = ref(false) const unlinkModpack = ref(false)
const inProgress = ref(false)
const installing = computed(() => props.instance.install_stage !== 'installed')
const installedVersion = computed(() => props.instance?.metadata?.linked_data?.version_id)
const installedVersionData = computed(() =>
props.versions.find((version) => version.id === installedVersion.value)
)
watch( watch(
[ [
title, title,
@@ -517,71 +666,78 @@ watch(
unlinkModpack, unlinkModpack,
], ],
async () => { async () => {
const editProfile = { await edit(props.instance.path, editProfileObject.value)
metadata: {
name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
loader_version: props.instance.metadata.loader_version,
linked_data: props.instance.metadata.linked_data,
},
java: {},
}
if (overrideJavaInstall.value) {
if (javaInstall.value.path !== '') {
editProfile.java.override_version = javaInstall.value
editProfile.java.override_version.path = editProfile.java.override_version.path.replace(
'java.exe',
'javaw.exe'
)
}
}
if (overrideJavaArgs.value) {
if (javaArgs.value !== '') {
editProfile.java.extra_arguments = javaArgs.value.trim().split(/\s+/).filter(Boolean)
}
}
if (overrideEnvVars.value) {
if (envVars.value !== '') {
editProfile.java.custom_env_args = envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
}
}
if (overrideMemorySettings.value) {
editProfile.memory = memory.value
}
if (overrideWindowSettings.value) {
editProfile.fullscreen = fullscreenSetting.value
if (!fullscreenSetting.value) {
editProfile.resolution = resolution.value
}
}
if (overrideHooks.value) {
editProfile.hooks = hooks.value
}
if (unlinkModpack.value) {
editProfile.metadata.linked_data = null
}
await edit(props.instance.path, editProfile)
}, },
{ deep: true } { deep: true }
) )
const editProfileObject = computed(() => {
const editProfile = {
metadata: {
name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
loader_version: props.instance.metadata.loader_version,
linked_data: props.instance.metadata.linked_data,
},
java: {},
}
if (overrideJavaInstall.value) {
if (javaInstall.value.path !== '') {
editProfile.java.override_version = javaInstall.value
editProfile.java.override_version.path = editProfile.java.override_version.path.replace(
'java.exe',
'javaw.exe'
)
}
}
if (overrideJavaArgs.value) {
if (javaArgs.value !== '') {
editProfile.java.extra_arguments = javaArgs.value.trim().split(/\s+/).filter(Boolean)
}
}
if (overrideEnvVars.value) {
if (envVars.value !== '') {
editProfile.java.custom_env_args = envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
}
}
if (overrideMemorySettings.value) {
editProfile.memory = memory.value
}
if (overrideWindowSettings.value) {
editProfile.fullscreen = fullscreenSetting.value
if (!fullscreenSetting.value) {
editProfile.resolution = resolution.value
}
}
if (overrideHooks.value) {
editProfile.hooks = hooks.value
}
if (unlinkModpack.value) {
editProfile.metadata.linked_data = null
}
return editProfile
})
const repairing = ref(false) const repairing = ref(false)
async function unpairProfile() { async function duplicateProfile() {
unlinkModpack.value = true await duplicate(props.instance.path).catch(handleError)
mixpanel_track('InstanceDuplicate', {
loader: props.instance.metadata.loader,
game_version: props.instance.metadata.game_version,
})
} }
async function repairProfile() { async function repairProfile() {
@@ -595,10 +751,30 @@ async function repairProfile() {
}) })
} }
async function unpairProfile() {
const editProfile = props.instance
editProfile.metadata.linked_data = null
await edit(props.instance.path, editProfile)
installedVersion.value = null
installedVersionData.value = null
modalConfirmUnpair.value.hide()
}
async function unlockProfile() {
const editProfile = props.instance
editProfile.metadata.linked_data.locked = false
await edit(props.instance.path, editProfile)
modalConfirmUnlock.value.hide()
}
const isPackLocked = computed(() => {
return props.instance.metadata.linked_data && props.instance.metadata.linked_data.locked
})
async function repairModpack() { async function repairModpack() {
repairing.value = true inProgress.value = true
await update_repair_modrinth(props.instance.path).catch(handleError) await update_repair_modrinth(props.instance.path).catch(handleError)
repairing.value = false inProgress.value = false
mixpanel_track('InstanceRepair', { mixpanel_track('InstanceRepair', {
loader: props.instance.metadata.loader, loader: props.instance.metadata.loader,
@@ -711,12 +887,9 @@ const editing = ref(false)
async function saveGvLoaderEdits() { async function saveGvLoaderEdits() {
editing.value = true editing.value = true
const editProfile = { let editProfile = editProfileObject.value
metadata: { editProfile.metadata.loader = loader.value
game_version: gameVersion.value, editProfile.metadata.game_version = gameVersion.value
loader: loader.value,
},
}
if (loader.value !== 'vanilla') { if (loader.value !== 'vanilla') {
editProfile.metadata.loader_version = selectableLoaderVersions.value[loaderVersionIndex.value] editProfile.metadata.loader_version = selectableLoaderVersions.value[loaderVersionIndex.value]
@@ -772,4 +945,39 @@ async function saveGvLoaderEdits() {
:deep(button.checkbox) { :deep(button.checkbox) {
border: none; border: none;
} }
.unlocked-instance {
background-color: var(--color-bg);
}
.modal-delete {
padding: var(--gap-lg);
display: flex;
flex-direction: column;
.markdown-body {
margin-bottom: 1rem;
}
.confirmation-label {
margin-bottom: 0.5rem;
}
.confirmation-text {
padding-right: 0.25ch;
margin: 0 0.25rem;
}
.confirmation-input {
input {
width: 20rem;
max-width: 100%;
}
}
.button-group {
margin-left: auto;
margin-top: 1.5rem;
}
}
</style> </style>

View File

@@ -168,7 +168,7 @@
</Card> </Card>
</div> </div>
<div v-if="data" class="content-container"> <div v-if="data" class="content-container">
<Promotion query-param="?r=launcher" /> <Promotion :external="false" query-param="?r=launcher" />
<Card class="tabs"> <Card class="tabs">
<NavRow <NavRow
v-if="data.gallery.length > 0" v-if="data.gallery.length > 0"

View File

@@ -7,7 +7,6 @@ use theseus::jre::autodetect_java_globals;
use theseus::prelude::*; use theseus::prelude::*;
use theseus::profile::create::profile_create; use theseus::profile::create::profile_create;
use tokio::time::{sleep, Duration};
// A simple Rust implementation of the authentication run // A simple Rust implementation of the authentication run
// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend) // 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend)
@@ -107,12 +106,6 @@ async fn main() -> theseus::Result<()> {
println!("Minecraft UUID: {}", uuid); println!("Minecraft UUID: {}", uuid);
println!("Minecraft PID: {:?}", pid); println!("Minecraft PID: {:?}", pid);
// Wait 5 seconds
println!("Waiting 5 seconds to gather logs...");
sleep(Duration::from_secs(5)).await;
let stdout = process::get_output_by_uuid(&uuid).await?;
println!("Logs after 5sec <<< {stdout} >>> end stdout");
println!( println!(
"All running process UUID {:?}", "All running process UUID {:?}",
process::get_all_running_uuids().await? process::get_all_running_uuids().await?