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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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.
validations:
required: false
- type: textarea
attributes: System information
description: Add any information about what OS you are on (like Windows or Mac), and what version of the app you are using.
- type: textarea
attributes:
label: Additional context
description: Add any other context about the problem here.
description: Add any other context about the problem here. This might include logs, screenshots, etc. The more the merrier!
validations:
required: false

View File

@ -8,7 +8,7 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest, ubuntu-20.04]
platform: [macos-latest, windows-latest, ubuntu-20.04, ubuntu-22.04]
runs-on: ${{ matrix.platform }}
defaults:
@ -62,7 +62,7 @@ jobs:
if: startsWith(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf libselinux1
- name: Install frontend dependencies
run: pnpm install

88
Cargo.lock generated
View File

@ -830,6 +830,30 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef"
dependencies = [
"cfg-if",
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7"
dependencies = [
"autocfg",
"cfg-if",
"crossbeam-utils",
"memoffset 0.9.0",
"scopeguard",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.16"
@ -1146,6 +1170,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b"
[[package]]
name = "either"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
[[package]]
name = "embed-resource"
version = "2.1.1"
@ -1309,9 +1339,9 @@ dependencies = [
[[package]]
name = "flate2"
version = "1.0.26"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743"
checksum = "c6c98ee8095e9d1dcbf2fcc6d95acccb90d1c81db1e44725c6a984b1dbdfb010"
dependencies = [
"crc32fast",
"miniz_oxide",
@ -2703,6 +2733,15 @@ dependencies = [
"notify",
]
[[package]]
name = "ntapi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@ -3440,6 +3479,28 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9"
[[package]]
name = "rayon"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d"
dependencies = [
"crossbeam-channel",
"crossbeam-deque",
"crossbeam-utils",
"num_cpus",
]
[[package]]
name = "redox_syscall"
version = "0.2.16"
@ -4223,6 +4284,21 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "sysinfo"
version = "0.29.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d0e9cc2273cc8d31377bdd638d72e3ac3e5607b18621062b169d02787f1bab"
dependencies = [
"cfg-if",
"core-foundation-sys",
"libc",
"ntapi",
"once_cell",
"rayon",
"winapi",
]
[[package]]
name = "system-deps"
version = "5.0.0"
@ -4609,7 +4685,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "0.5.3"
version = "0.5.4"
dependencies = [
"async-recursion",
"async-tungstenite",
@ -4620,6 +4696,7 @@ dependencies = [
"dirs 5.0.1",
"discord-rich-presence",
"dunce",
"flate2",
"futures",
"indicatif",
"lazy_static",
@ -4634,6 +4711,7 @@ dependencies = [
"sha1 0.6.1",
"sha2 0.9.9",
"sys-info",
"sysinfo",
"tauri",
"tempfile",
"theseus_macros",
@ -4655,7 +4733,7 @@ dependencies = [
[[package]]
name = "theseus_cli"
version = "0.5.3"
version = "0.5.4"
dependencies = [
"argh",
"color-eyre",
@ -4682,7 +4760,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "0.5.3"
version = "0.5.4"
dependencies = [
"chrono",
"cocoa",

View File

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

View File

@ -1,5 +1,9 @@
//! Authentication flow interface
use crate::{hydra::init::DeviceLoginSuccess, launcher::auth as inner, State};
use crate::{
hydra::{self, init::DeviceLoginSuccess},
launcher::auth as inner,
State,
};
use chrono::Utc;
use crate::state::AuthTask;
@ -44,20 +48,34 @@ pub async fn refresh(user: uuid::Uuid) -> crate::Result<Credentials> {
.as_error()
})?;
let fetch_semaphore = &state.fetch_semaphore;
if Utc::now() > credentials.expires
&& inner::refresh_credentials(&mut credentials, fetch_semaphore)
.await
.is_err()
{
users.remove(credentials.id).await?;
let offline = *state.offline.read().await;
return Err(crate::ErrorKind::OtherError(
"Please re-authenticate with your Minecraft account!".to_string(),
)
.as_error());
if !offline {
let fetch_semaphore: &crate::util::fetch::FetchSemaphore =
&state.fetch_semaphore;
if Utc::now() > credentials.expires
&& inner::refresh_credentials(&mut credentials, fetch_semaphore)
.await
.is_err()
{
users.remove(credentials.id).await?;
return Err(crate::ErrorKind::OtherError(
"Please re-authenticate with your Minecraft account!"
.to_string(),
)
.as_error());
}
// Update player info from bearer token
let player_info = hydra::stages::player_info::fetch_info(&credentials.access_token).await.map_err(|_err| {
crate::ErrorKind::HydraError("No Minecraft account for your profile. Make sure you own the game and have set a username through the official Minecraft launcher."
.to_string())
})?;
credentials.username = player_info.name;
users.insert(&credentials).await?;
}
users.insert(&credentials).await?;
Ok(credentials)
}

View File

@ -7,6 +7,7 @@ use crate::event::emit::{emit_loading, init_loading};
use crate::state::CredentialsStore;
use crate::util::fetch::{fetch_advanced, fetch_json};
use crate::util::io;
use crate::util::jre::extract_java_majorminor_version;
use crate::{
state::JavaGlobals,
@ -92,7 +93,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
let packages = fetch_json::<Vec<Package>>(
Method::GET,
&format!(
"https://api.azul.com/metadata/v1/zulu/packages?arch={}&java_version={}&os={}&archive_type=zip&javafx_bundled=false&java_package_type=jdk&page_size=1",
"https://api.azul.com/metadata/v1/zulu/packages?arch={}&java_version={}&os={}&archive_type=zip&javafx_bundled=false&java_package_type=jre&page_size=1",
std::env::consts::ARCH, java_version, std::env::consts::OS
),
None,
@ -124,6 +125,17 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<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?;
archive.extract(&path).map_err(|_| {
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)
}
// 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.
pub async fn get_max_memory() -> crate::Result<u64> {
Ok(sys_info::mem_info()

View File

@ -1,30 +1,70 @@
use std::io::{Read, SeekFrom};
use crate::{
prelude::Credentials,
util::io::{self, IOError},
{state::ProfilePathId, State},
};
use serde::{Deserialize, Serialize};
use futures::TryFutureExt;
use serde::Serialize;
use tokio::{
fs::File,
io::{AsyncReadExt, AsyncSeekExt},
};
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Debug)]
pub struct Logs {
pub datetime_string: String,
pub output: Option<String>,
pub filename: 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 {
async fn build(
profile_subpath: &ProfilePathId,
datetime_string: String,
filename: String,
clear_contents: Option<bool>,
) -> crate::Result<Self> {
Ok(Self {
output: if clear_contents.unwrap_or(false) {
None
} else {
Some(
get_output_by_datetime(profile_subpath, &datetime_string)
.await?,
)
Some(get_output_by_filename(profile_subpath, &filename).await?)
},
datetime_string,
filename,
})
}
}
@ -51,33 +91,31 @@ pub async fn get_logs(
for entry in std::fs::read_dir(&logs_folder)
.map_err(|e| IOError::with_path(e, &logs_folder))?
{
let entry =
let entry: std::fs::DirEntry =
entry.map_err(|e| IOError::with_path(e, &logs_folder))?;
let path = entry.path();
if path.is_dir() {
if let Some(datetime_string) = path.file_name() {
logs.push(
Logs::build(
&profile_path,
datetime_string.to_string_lossy().to_string(),
clear_contents,
)
.await,
);
}
if !path.is_file() {
continue;
}
if let Some(file_name) = path.file_name() {
let file_name = file_name.to_string_lossy().to_string();
logs.push(
Logs::build(&profile_path, file_name, clear_contents).await,
);
}
}
}
let mut logs = logs.into_iter().collect::<crate::Result<Vec<Logs>>>()?;
logs.sort_by_key(|x| x.datetime_string.clone());
logs.sort_by_key(|x| x.filename.clone());
Ok(logs)
}
#[tracing::instrument]
pub async fn get_logs_by_datetime(
pub async fn get_logs_by_filename(
profile_path: ProfilePathId,
datetime_string: String,
filename: String,
) -> crate::Result<Logs> {
let profile_path =
if let Some(p) = crate::profile::get(&profile_path, None).await? {
@ -89,23 +127,66 @@ pub async fn get_logs_by_datetime(
.into());
};
Ok(Logs {
output: Some(
get_output_by_datetime(&profile_path, &datetime_string).await?,
),
datetime_string,
output: Some(get_output_by_filename(&profile_path, &filename).await?),
filename,
})
}
#[tracing::instrument]
pub async fn get_output_by_datetime(
pub async fn get_output_by_filename(
profile_subpath: &ProfilePathId,
datetime_string: &str,
) -> crate::Result<String> {
file_name: &str,
) -> crate::Result<CensoredString> {
let state = State::get().await?;
let logs_folder =
state.directories.profile_logs_dir(profile_subpath).await?;
let path = logs_folder.join(datetime_string).join("stdout.log");
Ok(io::read_to_string(&path).await?)
let path = logs_folder.join(file_name);
let credentials: Vec<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]
@ -135,9 +216,9 @@ pub async fn delete_logs(profile_path: ProfilePathId) -> crate::Result<()> {
}
#[tracing::instrument]
pub async fn delete_logs_by_datetime(
pub async fn delete_logs_by_filename(
profile_path: ProfilePathId,
datetime_string: &str,
filename: &str,
) -> crate::Result<()> {
let profile_path =
if let Some(p) = crate::profile::get(&profile_path, None).await? {
@ -151,7 +232,71 @@ pub async fn delete_logs_by_datetime(
let state = State::get().await?;
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
let path = logs_folder.join(datetime_string);
let path = logs_folder.join(filename);
io::remove_dir_all(&path).await?;
Ok(())
}
#[tracing::instrument]
pub async fn get_latest_log_cursor(
profile_path: ProfilePathId,
mut cursor: u64, // 0 to start at beginning of file
) -> crate::Result<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 {
project_id: description.project_id.clone(),
version_id: description.version_id.clone(),
locked: Some(
description.project_id.is_some()
&& description.version_id.is_some(),
),
});
prof.metadata.icon = description.icon.clone();
prof.metadata.game_version = game_version.clone();

View File

@ -306,6 +306,7 @@ async fn import_mmc_unmanaged(
&description,
&backup_name,
&dependencies,
false,
)
.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,
dotminecraft: PathBuf,
io_semaphore: &IoSemaphore,

View File

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

View File

@ -1,3 +1,4 @@
use crate::config::MODRINTH_API_URL;
use crate::event::emit::{
emit_loading, init_or_edit_loading, loading_try_for_each_concurrent,
};
@ -5,13 +6,16 @@ use crate::event::LoadingBarType;
use crate::pack::install_from::{
set_profile_information, EnvType, PackFile, PackFileHash,
};
use crate::prelude::ProfilePathId;
use crate::prelude::{ModrinthVersion, ProfilePathId, ProjectMetadata};
use crate::state::{ProfileInstallStage, Profiles, SideType};
use crate::util::fetch::{fetch_mirrors, write};
use crate::util::fetch::{fetch_json, fetch_mirrors, write};
use crate::util::io;
use crate::{profile, State};
use async_zip::tokio::read::seek::ZipFileReader;
use reqwest::Method;
use serde_json::json;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::{Component, PathBuf};
@ -43,6 +47,7 @@ pub async fn install_zipped_mrpack(
title,
icon_url,
profile_path.clone(),
None,
)
.await?
}
@ -52,7 +57,7 @@ pub async fn install_zipped_mrpack(
};
// Install pack files, and if it fails, fail safely by removing the profile
let result = install_zipped_mrpack_files(create_pack).await;
let result = install_zipped_mrpack_files(create_pack, false).await;
// Check existing managed packs for potential updates
tokio::task::spawn(Profiles::update_modrinth_versions());
@ -72,6 +77,7 @@ pub async fn install_zipped_mrpack(
#[theseus_macros::debug_pin]
pub async fn install_zipped_mrpack_files(
create_pack: CreatePack,
ignore_lock: bool,
) -> crate::Result<ProfilePathId> {
let state = &State::get().await?;
@ -126,6 +132,7 @@ pub async fn install_zipped_mrpack_files(
&description,
&pack.name,
&pack.dependencies,
ignore_lock,
)
.await?;
@ -182,15 +189,20 @@ pub async fn install_zipped_mrpack_files(
.await?;
drop(creds);
// Convert windows path to unix path.
// .mrpacks no longer generate windows paths, but this is here for backwards compatibility before this was fixed
// https://github.com/modrinth/theseus/issues/595
let project_path = project.path.replace('\\', "/");
let path =
std::path::Path::new(&project.path).components().next();
std::path::Path::new(&project_path).components().next();
if let Some(path) = path {
match path {
Component::CurDir | Component::Normal(_) => {
let path = profile_path
.get_full_path()
.await?
.join(&project.path);
.join(&project_path);
write(&path, &file, &state.io_semaphore)
.await?;
}
@ -337,31 +349,65 @@ pub async fn remove_all_related_files(
})
.await?;
let num_files = pack.files.len();
use futures::StreamExt;
loading_try_for_each_concurrent(
futures::stream::iter(pack.files.into_iter())
.map(Ok::<PackFile, crate::Error>),
None,
None,
0.0,
num_files,
None,
|project| {
let profile_path = profile_path.clone();
async move {
// Remove this file if a corresponding one exists in the filesystem
let existing_file =
profile_path.get_full_path().await?.join(&project.path);
if existing_file.exists() {
io::remove_file(&existing_file).await?;
}
// First, remove all modrinth projects by their version hashes
// Remove all modrinth projects by their version hashes
// We need to do a fetch to get the project ids from Modrinth
let state = State::get().await?;
let all_hashes = pack
.files
.iter()
.filter_map(|f| Some(f.hashes.get(&PackFileHash::Sha512)?.clone()))
.collect::<Vec<_>>();
let creds = state.credentials.read().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?;
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
for index in 0..zip_reader.file().entries().len() {

View File

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

View File

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

View File

@ -1,21 +1,22 @@
use crate::{
event::{
emit::{emit_profile, loading_try_for_each_concurrent},
emit::{emit_profile, init_loading, loading_try_for_each_concurrent},
ProfilePayloadType,
},
pack::{self, install_from::generate_pack_from_version_id},
prelude::{ProfilePathId, ProjectPathId},
profile::get,
state::Project,
State,
state::{ProfileInstallStage, Project},
LoadingBarType, State,
};
use futures::try_join;
/// Updates a managed modrinth pack to the cached latest version found in 'modrinth_update_version'
/// Updates a managed modrinth pack to the version specified by new_version_id
#[tracing::instrument]
#[theseus_macros::debug_pin]
pub async fn update_managed_modrinth(
pub async fn update_managed_modrinth_version(
profile_path: &ProfilePathId,
new_version_id: &String,
) -> crate::Result<()> {
let profile = get(profile_path, None).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(profile_path.to_string())
@ -39,19 +40,14 @@ pub async fn update_managed_modrinth(
let version_id =
linked_data.version_id.as_ref().ok_or_else(unmanaged_err)?;
// extract modrinth_update_version, returning Ok(()) if it is none
let modrinth_update_version = match profile.modrinth_update_version {
Some(ref x) if x != version_id => x,
_ => return Ok(()), // No update version, or no update needed, return Ok(())
};
// Replace the pack with the new version
replace_managed_modrinth(
profile_path,
&profile,
project_id,
version_id,
Some(modrinth_update_version),
Some(new_version_id),
true, // switching versions should ignore the lock
)
.await?;
@ -128,6 +124,7 @@ pub async fn repair_managed_modrinth(
project_id,
version_id,
None,
false, // do not ignore lock, as repairing can reset the lock
)
.await?;
@ -153,32 +150,61 @@ async fn replace_managed_modrinth(
project_id: &String,
version_id: &String,
new_version_id: Option<&String>,
ignore_lock: bool,
) -> crate::Result<()> {
crate::profile::edit(profile_path, |profile| {
profile.install_stage = ProfileInstallStage::Installing;
async { Ok(()) }
})
.await?;
// Fetch .mrpacks for both old and new versions
// TODO: this will need to be updated if we revert the hacky pack method we needed for compiler speed
let old_pack_creator = generate_pack_from_version_id(
project_id.clone(),
version_id.clone(),
profile.metadata.name.clone(),
None,
profile_path.clone(),
);
// download in parallel, then join. If new_version_id is None, we don't need to download the new pack, so we clone the old one
let (old_pack_creator, new_pack_creator) =
if let Some(new_version_id) = new_version_id {
let shared_loading_bar = init_loading(
LoadingBarType::PackFileDownload {
profile_path: profile_path.get_full_path().await?,
pack_name: profile.metadata.name.clone(),
icon: None,
pack_version: version_id.clone(),
},
200.0, // These two downloads will share the same loading bar
"Downloading pack file",
)
.await?;
// download in parallel, then join.
try_join!(
old_pack_creator,
generate_pack_from_version_id(
project_id.clone(),
version_id.clone(),
profile.metadata.name.clone(),
None,
profile_path.clone(),
Some(shared_loading_bar.clone())
),
generate_pack_from_version_id(
project_id.clone(),
new_version_id.clone(),
profile.metadata.name.clone(),
None,
profile_path.clone()
profile_path.clone(),
Some(shared_loading_bar)
)
)?
} else {
let mut old_pack_creator = old_pack_creator.await?;
// If new_version_id is None, we don't need to download the new pack, so we clone the old one
let mut old_pack_creator = generate_pack_from_version_id(
project_id.clone(),
version_id.clone(),
profile.metadata.name.clone(),
None,
profile_path.clone(),
None,
)
.await?;
old_pack_creator.description.existing_loading_bar = None;
(old_pack_creator.clone(), old_pack_creator)
};
@ -197,7 +223,11 @@ async fn replace_managed_modrinth(
// - install all overrides
// - edits the profile to update the new data
// - (functionals almost identically to rteinstalling the pack 'in-place')
pack::install_mrpack::install_zipped_mrpack_files(new_pack_creator).await?;
pack::install_mrpack::install_zipped_mrpack_files(
new_pack_creator,
ignore_lock,
)
.await?;
Ok(())
}

View File

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

View File

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

View File

@ -312,7 +312,7 @@ pub async fn launch_minecraft(
memory: &st::MemorySettings,
resolution: &st::WindowSize,
credentials: &auth::Credentials,
post_exit_hook: Option<Command>,
post_exit_hook: Option<String>,
profile: &Profile,
) -> crate::Result<Arc<tokio::sync::RwLock<MinecraftChild>>> {
if profile.install_stage == ProfileInstallStage::PackInstalling
@ -406,7 +406,6 @@ pub async fn launch_minecraft(
))
.as_error());
}
command
.args(
args::get_jvm_arguments(
@ -447,14 +446,17 @@ pub async fn launch_minecraft(
.collect::<Vec<_>>(),
)
.current_dir(instance_path.clone())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
.stdout(Stdio::null())
.stderr(Stdio::null());
// CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground
#[cfg(target_os = "macos")]
if std::env::var("CARGO").is_ok() {
command.env_remove("DYLD_FALLBACK_LIBRARY_PATH");
}
// Java options should be set in instance options (the existence of _JAVA_OPTIONS overwites them)
command.env_remove("_JAVA_OPTIONS");
command.envs(env_args);
// Overwrites the minecraft options.txt file with the settings from the profile
@ -484,20 +486,6 @@ pub async fn launch_minecraft(
io::write(&options_path, options_string).await?;
}
// Get Modrinth logs directories
let datetime_string =
chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
let logs_dir = {
let st = State::get().await?;
st.directories
.profile_logs_dir(&profile.profile_id())
.await?
.join(&datetime_string)
};
io::create_dir_all(&logs_dir).await?;
let stdout_log_path = logs_dir.join("stdout.log");
crate::api::profile::edit(&profile.profile_id(), |prof| {
prof.metadata.last_played = Some(Utc::now());
@ -559,10 +547,9 @@ pub async fn launch_minecraft(
// This also spawns the process and prepares the subsequent processes
let mut state_children = state.children.write().await;
state_children
.insert_process(
.insert_new_process(
Uuid::new_v4(),
profile.profile_id(),
stdout_log_path,
command,
post_exit_hook,
censor_strings,

View File

@ -24,7 +24,9 @@ pub fn start_logger() -> Option<WorkerGuard> {
use tracing_subscriber::prelude::*;
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("theseus=info"));
.unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info")
});
let subscriber = tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(filter)

View File

@ -1,89 +1,278 @@
use super::{Profile, ProfilePathId};
use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use serde::Deserialize;
use serde::Serialize;
use std::{collections::HashMap, sync::Arc};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use sysinfo::PidExt;
use tokio::process::Child;
use tokio::process::Command;
use tokio::process::{ChildStderr, ChildStdout};
use tokio::sync::RwLock;
use tracing::error;
use crate::event::emit::emit_process;
use crate::event::ProcessPayloadType;
use crate::profile;
use crate::util::fetch::read_json;
use crate::util::io::IOError;
use crate::{profile, ErrorKind};
use sysinfo::{ProcessExt, SystemExt};
use tokio::task::JoinHandle;
use uuid::Uuid;
const PROCESSES_JSON: &str = "processes.json";
// Child processes (instances of Minecraft)
// A wrapper over a Hashmap connecting PID -> MinecraftChild
pub struct Children(HashMap<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)]
pub struct MinecraftChild {
pub uuid: Uuid,
pub profile_relative_path: ProfilePathId,
pub manager: Option<JoinHandle<crate::Result<ExitStatus>>>, // None when future has completed and been handled
pub current_child: Arc<RwLock<Child>>,
pub output: SharedOutput,
pub manager: Option<JoinHandle<crate::Result<i32>>>, // None when future has completed and been handled
pub current_child: Arc<RwLock<ChildType>>,
pub last_updated_playtime: DateTime<Utc>, // The last time we updated the playtime for the associated profile
}
impl Children {
pub fn new() -> Children {
pub fn new() -> Self {
Children(HashMap::new())
}
// Loads cached processes from the caches/metadata/processes.json file, re-inserts them into the hashmap, and removes them from the file
// This will only be called once, on startup. Only processes who match a cached process (name, time started, pid, etc) will be re-inserted
pub async fn rescue_cache(&mut self) -> crate::Result<()> {
let state = crate::State::get().await?;
let children_path = state
.directories
.caches_meta_dir()
.await
.join(PROCESSES_JSON);
let mut children_caches = if let Ok(children_json) =
read_json::<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
// The threads for stdout and stderr are spawned here
// Unlike a Hashmap's 'insert', this directly returns the reference to the MinecraftChild rather than any previously stored MinecraftChild that may exist
#[tracing::instrument(skip(
self,
uuid,
log_path,
mc_command,
post_command,
censor_strings
))]
#[tracing::instrument(level = "trace", skip(self))]
#[theseus_macros::debug_pin]
pub async fn insert_process(
pub async fn insert_new_process(
&mut self,
uuid: Uuid,
profile_relative_path: ProfilePathId,
log_path: PathBuf,
mut mc_command: Command,
post_command: Option<Command>, // Command to run after minecraft.
post_command: Option<String>, // Command to run after minecraft.
censor_strings: HashMap<String, String>,
) -> crate::Result<Arc<RwLock<MinecraftChild>>> {
// Takes the first element of the commands vector and spawns it
let mut child = mc_command.spawn().map_err(IOError::from)?;
// Create std watcher threads for stdout and stderr
let shared_output =
SharedOutput::build(&log_path, censor_strings).await?;
if let Some(child_stdout) = child.stdout.take() {
let stdout_clone = shared_output.clone();
tokio::spawn(async move {
if let Err(e) = stdout_clone.read_stdout(child_stdout).await {
error!("Stdout process died with error: {}", e);
}
});
}
if let Some(child_stderr) = child.stderr.take() {
let stderr_clone = shared_output.clone();
tokio::spawn(async move {
if let Err(e) = stderr_clone.read_stderr(child_stderr).await {
error!("Stderr process died with error: {}", e);
}
});
}
let child = mc_command.spawn().map_err(IOError::from)?;
let child = ChildType::TokioChild(child);
// Slots child into manager
let pid = child.id().ok_or_else(|| {
@ -91,6 +280,15 @@ impl Children {
"Process immediately failed, could not get PID".to_string(),
)
})?;
// Caches process so that it can be restored if the launcher is restarted
child
.cache_process(
uuid,
profile_relative_path.clone(),
post_command.clone(),
)
.await?;
let current_child = Arc::new(RwLock::new(child));
let manager = Some(tokio::spawn(Self::sequential_process_manager(
uuid,
@ -115,7 +313,6 @@ impl Children {
uuid,
profile_relative_path,
current_child,
output: shared_output,
manager,
last_updated_playtime,
};
@ -125,6 +322,96 @@ impl Children {
Ok(mchild)
}
// Rescues a cached process, inserts a child process to keep track of, and returns a reference to the container struct MinecraftChild
// Essentially 'reconnects' to a process that was launched by theseus before the launcher was restarted
// However, this may not have all the same functionality as a TokioChild, as we only have the PID and not the actual Child
// Only processes who match a cached process (name, time started, pid, etc) will be re-inserted. The function fails with an error if the process is notably different.
#[tracing::instrument(skip(self, cached_process,))]
#[tracing::instrument(level = "trace", skip(self))]
#[theseus_macros::debug_pin]
pub async fn insert_cached_process(
&mut self,
cached_process: ProcessCache,
) -> crate::Result<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
// Also, as the process ends, it spawns the follow-up process if it exists
// By convention, ExitStatus is last command's exit status, and we exit on the first non-zero exit status
@ -132,28 +419,23 @@ impl Children {
#[theseus_macros::debug_pin]
async fn sequential_process_manager(
uuid: Uuid,
post_command: Option<Command>,
post_command: Option<String>,
mut current_pid: u32,
current_child: Arc<RwLock<Child>>,
current_child: Arc<RwLock<ChildType>>,
associated_profile: ProfilePathId,
) -> crate::Result<ExitStatus> {
) -> crate::Result<i32> {
let current_child = current_child.clone();
// Wait on current Minecraft Child
let mut mc_exit_status;
let mut last_updated_playtime = Utc::now();
loop {
if let Some(t) = current_child
.write()
.await
.try_wait()
.map_err(IOError::from)?
{
if let Some(t) = current_child.write().await.try_wait().await? {
mc_exit_status = t;
break;
}
// sleep for 10ms
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
// Auto-update playtime every minute
let diff = Utc::now()
@ -168,7 +450,7 @@ impl Children {
{
tracing::warn!(
"Failed to update playtime for profile {}: {}",
associated_profile,
&associated_profile,
e
);
}
@ -188,7 +470,7 @@ impl Children {
{
tracing::warn!(
"Failed to update playtime for profile {}: {}",
associated_profile,
&associated_profile,
e
);
}
@ -196,13 +478,15 @@ impl Children {
// Publish play time update
// Allow failure, it will be stored locally and sent next time
// Sent in another thread as first call may take a couple seconds and hold up process ending
let associated_profile_clone = associated_profile.clone();
tokio::spawn(async move {
if let Err(e) =
profile::try_update_playtime(&associated_profile).await
profile::try_update_playtime(&associated_profile_clone.clone())
.await
{
tracing::warn!(
"Failed to update playtime for profile {}: {}",
associated_profile,
&associated_profile_clone,
e
);
}
@ -224,7 +508,12 @@ impl Children {
}
}
if !mc_exit_status.success() {
{
let current_child = current_child.write().await;
current_child.remove_cache(uuid).await?;
}
if !mc_exit_status == 0 {
emit_process(
uuid,
current_pid,
@ -237,9 +526,28 @@ impl Children {
}
// If a post-command exist, switch to it and wait on it
// First, create the command by splitting arguments
let post_command = if let Some(hook) = post_command {
let mut cmd = hook.split(' ');
if let Some(command) = cmd.next() {
let mut command = Command::new(command);
command
.args(&cmd.collect::<Vec<&str>>())
.current_dir(associated_profile.get_full_path().await?);
Some(command)
} else {
None
}
} else {
None
};
if let Some(mut m_command) = post_command {
{
let mut current_child = current_child.write().await;
let mut current_child: tokio::sync::RwLockWriteGuard<
'_,
ChildType,
> = current_child.write().await;
let new_child = m_command.spawn().map_err(IOError::from)?;
current_pid = new_child.id().ok_or_else(|| {
crate::ErrorKind::LauncherError(
@ -247,7 +555,7 @@ impl Children {
.to_string(),
)
})?;
*current_child = new_child;
*current_child = ChildType::TokioChild(new_child);
}
emit_process(
uuid,
@ -258,12 +566,7 @@ impl Children {
.await?;
loop {
if let Some(t) = current_child
.write()
.await
.try_wait()
.map_err(IOError::from)?
{
if let Some(t) = current_child.write().await.try_wait().await? {
mc_exit_status = t;
break;
}
@ -296,18 +599,10 @@ impl Children {
// Get exit status of a child by PID
// Returns None if the child is still running
pub async fn exit_status(
&self,
uuid: &Uuid,
) -> crate::Result<Option<std::process::ExitStatus>> {
pub async fn exit_status(&self, uuid: &Uuid) -> crate::Result<Option<i32>> {
if let Some(child) = self.get(uuid) {
let child = child.write().await;
let status = child
.current_child
.write()
.await
.try_wait()
.map_err(IOError::from)?;
let status = child.current_child.write().await.try_wait().await?;
Ok(status)
} else {
Ok(None)
@ -326,7 +621,7 @@ impl Children {
.write()
.await
.try_wait()
.map_err(IOError::from)?
.await?
.is_none()
{
keys.push(key);
@ -369,7 +664,7 @@ impl Children {
.write()
.await
.try_wait()
.map_err(IOError::from)?
.await?
.is_none()
{
profiles.push(child.profile_relative_path.clone());
@ -392,7 +687,7 @@ impl Children {
.write()
.await
.try_wait()
.map_err(IOError::from)?
.await?
.is_none()
{
if let Some(prof) = crate::api::profile::get(
@ -415,107 +710,3 @@ impl Default for Children {
Self::new()
}
}
// SharedOutput, a wrapper around a String that can be read from and written to concurrently
// Designed to be used with ChildStdout and ChildStderr in a tokio thread to have a simple String storage for the output of a child process
#[derive(Debug, Clone)]
pub struct SharedOutput {
output: Arc<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,
profile_id: &ProfilePathId,
) -> crate::Result<PathBuf> {
Ok(profile_id.get_full_path().await?.join("modrinth_logs"))
Ok(profile_id.get_full_path().await?.join("logs"))
}
#[inline]

View File

@ -16,17 +16,22 @@ pub struct DiscordGuard {
impl DiscordGuard {
/// Initialize discord IPC client, and attempt to connect to it
/// If it fails, it will still return a DiscordGuard, but the client will be unconnected
pub async fn init() -> crate::Result<DiscordGuard> {
pub async fn init(is_offline: bool) -> crate::Result<DiscordGuard> {
let mut dipc =
DiscordIpcClient::new("1084015525241311292").map_err(|e| {
DiscordIpcClient::new("1123683254248148992").map_err(|e| {
crate::ErrorKind::OtherError(format!(
"Could not create Discord client {}",
e,
))
})?;
let res = dipc.connect(); // Do not need to connect to Discord to use app
let connected = if res.is_ok() {
Arc::new(AtomicBool::new(true))
let connected = if !is_offline {
let res = dipc.connect(); // Do not need to connect to Discord to use app
if res.is_ok() {
Arc::new(AtomicBool::new(true))
} else {
Arc::new(AtomicBool::new(false))
}
} else {
Arc::new(AtomicBool::new(false))
};
@ -51,11 +56,46 @@ impl DiscordGuard {
true
}
// check online
pub async fn check_online(&self) -> bool {
let state = match State::get().await {
Ok(s) => s,
Err(_) => return false,
};
let offline = state.offline.read().await;
if *offline {
return false;
}
true
}
/// Set the activity to the given message
/// First checks if discord is disabled, and if so, clear the activity instead
pub async fn set_activity(
&self,
msg: &str,
reconnect_if_fail: bool,
) -> crate::Result<()> {
if !self.check_online().await {
return Ok(());
}
// Check if discord is disabled, and if so, clear the activity instead
let state = State::get().await?;
let settings = state.settings.read().await;
if settings.disable_discord_rpc {
Ok(self.clear_activity(true).await?)
} else {
Ok(self.force_set_activity(msg, reconnect_if_fail).await?)
}
}
/// Sets the activity to the given message, regardless of if discord is disabled or offline
/// Should not be used except for in the above method, or if it is already known that discord is enabled (specifically for state initialization) and we are connected to the internet
pub async fn force_set_activity(
&self,
msg: &str,
reconnect_if_fail: bool,
) -> crate::Result<()> {
// Attempt to connect if not connected. Do not continue if it fails, as the client.set_activity can panic if it never was connected
if !self.retry_if_not_ready().await {
@ -99,14 +139,13 @@ impl DiscordGuard {
Ok(())
}
/*
/// Clear the activity
/// Clear the activity entirely ('disabling' the RPC until the next set_activity)
pub async fn clear_activity(
&self,
reconnect_if_fail: bool,
) -> crate::Result<()> {
// Attempt to connect if not connected. Do not continue if it fails, as the client.clear_activity can panic if it never was connected
if !self.retry_if_not_ready().await {
if !self.check_online().await || !self.retry_if_not_ready().await {
return Ok(());
}
@ -138,7 +177,7 @@ impl DiscordGuard {
res.map_err(could_not_clear_err)?;
}
Ok(())
}*/
}
/// Clear the activity, but if there is a running profile, set the activity to that instead
pub async fn clear_to_default(
@ -147,6 +186,15 @@ impl DiscordGuard {
) -> crate::Result<()> {
let state: Arc<tokio::sync::RwLockReadGuard<'_, State>> =
State::get().await?;
{
let settings = state.settings.read().await;
if settings.disable_discord_rpc {
println!("Discord is disabled, clearing activity");
return self.clear_activity(true).await;
}
}
if let Some(existing_child) = state
.children
.read()

View File

@ -69,6 +69,8 @@ impl Metadata {
) -> crate::Result<Self> {
let mut metadata = None;
let metadata_path = dirs.caches_meta_dir().await.join("metadata.json");
let metadata_backup_path =
dirs.caches_meta_dir().await.join("metadata.json.bak");
if let Ok(metadata_json) =
read_json::<Metadata>(&metadata_path, io_semaphore).await
@ -85,6 +87,13 @@ impl Metadata {
)
.await?;
write(
&metadata_backup_path,
&serde_json::to_vec(&metadata_fetch).unwrap_or_default(),
io_semaphore,
)
.await?;
metadata = Some(metadata_fetch);
Ok::<(), crate::Error>(())
}
@ -96,6 +105,18 @@ impl Metadata {
tracing::warn!("Unable to fetch launcher metadata: {err}")
}
}
} else if let Ok(metadata_json) =
read_json::<Metadata>(&metadata_backup_path, io_semaphore).await
{
metadata = Some(metadata_json);
std::fs::copy(&metadata_backup_path, &metadata_path).map_err(
|err| {
crate::ErrorKind::FSError(format!(
"Error restoring metadata backup: {err}"
))
.as_error()
},
)?;
}
if let Some(meta) = metadata {
@ -118,6 +139,15 @@ impl Metadata {
.caches_meta_dir()
.await
.join("metadata.json");
let metadata_backup_path = state
.directories
.caches_meta_dir()
.await
.join("metadata.json.bak");
if metadata_path.exists() {
std::fs::copy(&metadata_path, &metadata_backup_path).unwrap();
}
write(
&metadata_path,

View File

@ -127,6 +127,10 @@ impl State {
.await)
}
pub fn initialized() -> bool {
LAUNCHER_STATE.initialized()
}
#[tracing::instrument]
#[theseus_macros::debug_pin]
async fn initialize_state() -> crate::Result<RwLock<State>> {
@ -180,16 +184,18 @@ impl State {
creds_fut,
}?;
let children = Children::new();
let auth_flow = AuthTask::new();
let safety_processes = SafeProcesses::new();
let discord_rpc = DiscordGuard::init().await?;
{
let discord_rpc = DiscordGuard::init(is_offline).await?;
if !settings.disable_discord_rpc && !is_offline {
// Add default Idling to discord rich presence
let _ = discord_rpc.set_activity("Idling...", true).await;
// Force add to avoid recursion
let _ = discord_rpc.force_set_activity("Idling...", true).await;
}
let children = Children::new();
// Starts a loop of checking if we are online, and updating
Self::offine_check_loop();
@ -238,11 +244,6 @@ impl State {
/// Updates state with data from the web, if we are online
pub fn update() {
tokio::task::spawn(Metadata::update());
tokio::task::spawn(Tags::update());
tokio::task::spawn(Profiles::update_projects());
tokio::task::spawn(Profiles::update_modrinth_versions());
tokio::task::spawn(CredentialsStore::update_creds());
tokio::task::spawn(async {
if let Ok(state) = crate::State::get().await {
if !*state.offline.read().await {
@ -252,8 +253,9 @@ impl State {
let res4 = Profiles::update_projects();
let res5 = Settings::update_java();
let res6 = CredentialsStore::update_creds();
let res7 = Settings::update_default_user();
let _ = join!(res1, res2, res3, res4, res5, res6);
let _ = join!(res1, res2, res3, res4, res5, res6, res7);
}
}
});

View File

@ -124,11 +124,22 @@ impl ProjectPathId {
&self,
profile: ProfilePathId,
) -> crate::Result<PathBuf> {
let _state = State::get().await?;
let profile_dir = profile.get_full_path().await?;
Ok(profile_dir.join(&self.0))
}
// Gets inner path in unix convention as a String
// ie: 'mods\myproj' -> 'mods/myproj'
// Used for exporting to mrpack, which should have a singular convention
pub fn get_inner_path_unix(&self) -> crate::Result<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
pub fn new(path: &Path) -> Self {
ProjectPathId(PathBuf::from(path))
@ -193,6 +204,15 @@ pub struct ProfileMetadata {
pub struct LinkedData {
pub project_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(
@ -722,7 +742,15 @@ impl Profiles {
None
}
};
if let Some(profile) = prof {
// Clear out modrinth_logs of all files in profiles folder (these are legacy)
// TODO: should be removed in a future build
let modrinth_logs = path.join("modrinth_logs");
if modrinth_logs.exists() {
let _ = std::fs::remove_dir_all(modrinth_logs);
}
let path = io::canonicalize(path)?;
Profile::watch_fs(&path, file_watcher).await?;
profiles.insert(profile.profile_id(), profile);

View File

@ -31,6 +31,8 @@ pub struct Settings {
pub version: u32,
pub collapsed_navigation: bool,
#[serde(default)]
pub disable_discord_rpc: bool,
#[serde(default)]
pub hide_on_process: bool,
#[serde(default)]
pub default_page: DefaultPage,
@ -49,8 +51,10 @@ pub struct Settings {
impl Settings {
#[tracing::instrument]
pub async fn init(file: &Path) -> crate::Result<Self> {
if file.exists() {
fs::read(&file)
let mut rescued = false;
let settings = if file.exists() {
let loaded_settings = fs::read(&file)
.await
.map_err(|err| {
crate::ErrorKind::FSError(format!(
@ -61,9 +65,25 @@ impl Settings {
.and_then(|it| {
serde_json::from_slice::<Settings>(&it)
.map_err(crate::Error::from)
})
});
// settings is corrupted. Back up the file and create a new one
if let Err(ref err) = loaded_settings {
tracing::error!("Failed to load settings file: {err}. ");
let backup_file = file.with_extension("json.bak");
tracing::error!("Corrupted settings file will be backed up as {}, and a new settings file will be created.", backup_file.display());
let _ = fs::rename(file, backup_file).await;
rescued = true;
}
loaded_settings.ok()
} else {
Ok(Self {
None
};
if let Some(settings) = settings {
Ok(settings)
} else {
// Create new settings file
let settings = Self {
theme: Theme::Dark,
memory: MemorySettings::default(),
force_fullscreen: false,
@ -77,16 +97,21 @@ impl Settings {
max_concurrent_writes: 10,
version: CURRENT_FORMAT_VERSION,
collapsed_navigation: false,
disable_discord_rpc: false,
hide_on_process: false,
default_page: DefaultPage::Home,
developer_mode: false,
opt_out_analytics: false,
advanced_rendering: true,
fully_onboarded: false,
fully_onboarded: rescued, // If we rescued the settings file, we should consider the user fully onboarded
// By default, the config directory is the same as the settings directory
loaded_config_dir: DirectoryInfo::get_initial_settings_dir(),
})
};
if rescued {
settings.sync(file).await?;
}
Ok(settings)
}
}
@ -124,6 +149,32 @@ impl Settings {
};
}
#[tracing::instrument]
#[theseus_macros::debug_pin]
pub async fn update_default_user() {
let res = async {
let state = State::get().await?;
let settings_read = state.settings.read().await;
if settings_read.default_user.is_none() {
drop(settings_read);
let users = state.users.read().await;
let user = users.0.iter().next().map(|(id, _)| *id);
state.settings.write().await.default_user = user;
}
Ok::<(), crate::Error>(())
}
.await;
match res {
Ok(()) => {}
Err(err) => {
tracing::warn!("Unable to update default user: {err}")
}
};
}
#[tracing::instrument(skip(self))]
pub async fn sync(&self, to: &Path) -> crate::Result<()> {
fs::write(to, serde_json::to_vec(self)?)

View File

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

View File

@ -25,7 +25,8 @@
"vite-svg-loader": "^4.0.0",
"vue": "^3.3.4",
"vue-multiselect": "^3.0.0-beta.2",
"vue-router": "4.2.1"
"vue-router": "4.2.1",
"vue-virtual-scroller": "2.0.0-beta.8"
},
"devDependencies": {
"@rollup/plugin-alias": "^4.0.4",

View File

@ -44,6 +44,9 @@ dependencies:
vue-router:
specifier: 4.2.1
version: 4.2.1(vue@3.3.4)
vue-virtual-scroller:
specifier: 2.0.0-beta.8
version: 2.0.0-beta.8(vue@3.3.4)
devDependencies:
'@rollup/plugin-alias':
@ -1309,6 +1312,10 @@ packages:
brace-expansion: 1.1.11
dev: true
/mitt@2.1.0:
resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==}
dev: false
/mixpanel-browser@2.47.0:
resolution: {integrity: sha512-Ldrva0fRBEIFWmEibBQO1PulfpJVF3pf28Guk09lDirDaSQqqU/xs9zQLwN2rL5VwVtsP1aD3JaCgaa98EjojQ==}
dev: false
@ -1738,6 +1745,14 @@ packages:
engines: {node: '>= 4.0.0', npm: '>= 3.0.0'}
dev: false
/vue-observe-visibility@2.0.0-alpha.1(vue@3.3.4):
resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==}
peerDependencies:
vue: ^3.0.0
dependencies:
vue: 3.3.4
dev: false
/vue-resize@2.0.0-alpha.1(vue@3.3.4):
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
peerDependencies:
@ -1763,6 +1778,17 @@ packages:
vue: 3.3.4
dev: false
/vue-virtual-scroller@2.0.0-beta.8(vue@3.3.4):
resolution: {integrity: sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==}
peerDependencies:
vue: ^3.2.0
dependencies:
mitt: 2.1.0
vue: 3.3.4
vue-observe-visibility: 2.0.0-alpha.1(vue@3.3.4)
vue-resize: 2.0.0-alpha.1(vue@3.3.4)
dev: false
/vue@3.3.4:
resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==}
dependencies:

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_validate_globals,
jre_get_jre,
jre_test_jre,
jre_auto_install_java,
jre_get_max_memory,
])
@ -61,6 +62,16 @@ pub async fn jre_get_jre(path: PathBuf) -> Result<Option<JavaVersion>> {
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
#[tauri::command]
pub async fn jre_auto_install_java(java_version: u32) -> Result<PathBuf> {

View File

@ -1,14 +1,14 @@
use crate::api::Result;
use theseus::{
logs::{self, Logs},
logs::{self, CensoredString, LatestLogCursor, Logs},
prelude::ProfilePathId,
};
/*
A log is a struct containing the datetime string, stdout, and stderr, as follows:
A log is a struct containing the filename string, stdout, and stderr, as follows:
pub struct Logs {
pub datetime_string: String,
pub filename: String,
pub stdout: String,
pub stderr: String,
}
@ -18,15 +18,16 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("logs")
.invoke_handler(tauri::generate_handler![
logs_get_logs,
logs_get_logs_by_datetime,
logs_get_output_by_datetime,
logs_get_logs_by_filename,
logs_get_output_by_filename,
logs_delete_logs,
logs_delete_logs_by_datetime,
logs_delete_logs_by_filename,
logs_get_latest_log_cursor,
])
.build()
}
/// Get all Logs for a profile, sorted by datetime
/// Get all Logs for a profile, sorted by filename
#[tauri::command]
pub async fn logs_get_logs(
profile_path: ProfilePathId,
@ -37,21 +38,21 @@ pub async fn logs_get_logs(
Ok(val)
}
/// Get a Log struct for a profile by profile id and datetime string
/// Get a Log struct for a profile by profile id and filename string
#[tauri::command]
pub async fn logs_get_logs_by_datetime(
pub async fn logs_get_logs_by_filename(
profile_path: ProfilePathId,
datetime_string: String,
filename: String,
) -> Result<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]
pub async fn logs_get_output_by_datetime(
pub async fn logs_get_output_by_filename(
profile_path: ProfilePathId,
datetime_string: String,
) -> Result<String> {
filename: String,
) -> Result<CensoredString> {
let profile_path = if let Some(p) =
crate::profile::get(&profile_path, None).await?
{
@ -63,7 +64,7 @@ pub async fn logs_get_output_by_datetime(
.into());
};
Ok(logs::get_output_by_datetime(&profile_path, &datetime_string).await?)
Ok(logs::get_output_by_filename(&profile_path, &filename).await?)
}
/// Delete all logs for a profile by profile id
@ -72,11 +73,20 @@ pub async fn logs_delete_logs(profile_path: ProfilePathId) -> Result<()> {
Ok(logs::delete_logs(profile_path).await?)
}
/// Delete a log for a profile by profile id and datetime string
/// Delete a log for a profile by profile id and filename string
#[tauri::command]
pub async fn logs_delete_logs_by_datetime(
pub async fn logs_delete_logs_by_filename(
profile_path: ProfilePathId,
datetime_string: String,
filename: String,
) -> Result<()> {
Ok(logs::delete_logs_by_datetime(profile_path, &datetime_string).await?)
Ok(logs::delete_logs_by_filename(profile_path, &filename).await?)
}
/// Get live log from a cursor
#[tauri::command]
pub async fn logs_get_latest_log_cursor(
profile_path: ProfilePathId,
cursor: u64, // 0 to start at beginning of file
) -> Result<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_all_running_profile_paths,
process_get_all_running_profiles,
process_get_output_by_uuid,
process_kill_by_uuid,
process_wait_for_by_uuid,
])
@ -66,12 +65,6 @@ pub async fn process_get_all_running_profiles() -> Result<Vec<Profile>> {
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
#[tauri::command]
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_optimal_jre_key,
profile_get_full_path,
profile_get_mod_full_path,
profile_list,
profile_check_installed,
profile_install,
@ -22,7 +23,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_add_project_from_path,
profile_toggle_disable_project,
profile_remove_project,
profile_update_managed_modrinth,
profile_update_managed_modrinth_version,
profile_repair_managed_modrinth,
profile_run,
profile_run_wait,
@ -63,6 +64,17 @@ pub async fn profile_get_full_path(path: ProfilePathId) -> Result<PathBuf> {
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
#[tauri::command]
pub async fn profile_get_optimal_jre_key(
@ -173,12 +185,16 @@ pub async fn profile_remove_project(
Ok(())
}
// Updates a managed Modrinth profile
// Updates a managed Modrinth profile to a version of version_id
#[tauri::command]
pub async fn profile_update_managed_modrinth(
pub async fn profile_update_managed_modrinth_version(
path: ProfilePathId,
version_id: String,
) -> Result<()> {
Ok(profile::update::update_managed_modrinth(&path).await?)
Ok(
profile::update::update_managed_modrinth_version(&path, &version_id)
.await?,
)
}
// Repairs a managed Modrinth profile by updating it to the current version
@ -197,12 +213,16 @@ pub async fn profile_export_mrpack(
export_location: PathBuf,
included_overrides: Vec<String>,
version_id: Option<String>,
description: Option<String>,
name: Option<String>, // only used to cache
) -> Result<()> {
profile::export_mrpack(
&path,
export_location,
included_overrides,
version_id,
description,
name,
)
.await?;
Ok(())

View File

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

View File

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

View File

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

View File

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

View File

@ -7,9 +7,11 @@ import {
LibraryIcon,
PlusIcon,
SettingsIcon,
FileIcon,
Button,
Notifications,
XIcon,
Card,
} from 'omorphia'
import { useLoading, useTheming } from '@/store/state'
import AccountsCard from '@/components/ui/AccountsCard.vue'
@ -19,12 +21,12 @@ import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import ModrinthLoadingIndicator from '@/components/modrinth-loading-indicator'
import { useNotifications } from '@/store/notifications.js'
import { handleError, useNotifications } from '@/store/notifications.js'
import { offline_listener, command_listener, warning_listener } from '@/helpers/events.js'
import { MinimizeIcon, MaximizeIcon } from '@/assets/icons'
import { MinimizeIcon, MaximizeIcon, ChatIcon } from '@/assets/icons'
import { type } from '@tauri-apps/api/os'
import { appWindow } from '@tauri-apps/api/window'
import { isDev, getOS, isOffline } from '@/helpers/utils.js'
import { isDev, getOS, isOffline, showLauncherLogsFolder } from '@/helpers/utils.js'
import {
mixpanel_track,
mixpanel_init,
@ -40,6 +42,7 @@ import { confirm } from '@tauri-apps/api/dialog'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue'
import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue'
import { install_from_file } from './helpers/pack'
const themeStore = useTheming()
const urlModal = ref(null)
@ -51,14 +54,17 @@ const showOnboarding = ref(false)
const onboardingVideo = ref()
const failureText = ref(null)
const os = ref('')
defineExpose({
initialize: async () => {
isLoading.value = false
const { theme, opt_out_analytics, collapsed_navigation, advanced_rendering, fully_onboarded } =
await get()
const os = await getOS()
// video should play if the user is not on linux, and has not onboarded
videoPlaying.value = !fully_onboarded && os !== 'Linux'
os.value = await getOS()
videoPlaying.value = !fully_onboarded && os.value !== 'Linux'
const dev = await isDev()
const version = await getVersion()
showOnboarding.value = !fully_onboarded
@ -98,6 +104,11 @@ defineExpose({
onboardingVideo.value.play()
}
},
failure: async (e) => {
isLoading.value = false
failureText.value = e
os.value = await getOS()
},
})
const confirmClose = async () => {
@ -112,6 +123,10 @@ const confirmClose = async () => {
}
const handleClose = async () => {
if (failureText.value != null) {
await TauriWindow.getCurrent().close()
return
}
// State should respond immeiately if it's safe to close
// If not, code is deadlocked or worse, so wait 2 seconds and then ask the user to confirm closing
// (Exception: if the user is changing config directory, which takes control of the state, and it's taking a significant amount of time for some reason)
@ -129,6 +144,16 @@ const handleClose = async () => {
await TauriWindow.getCurrent().close()
}
const openSupport = async () => {
window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: 'https://discord.gg/modrinth',
},
})
}
TauriWindow.getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async () => {
await handleClose()
})
@ -193,9 +218,19 @@ document.querySelector('body').addEventListener('auxclick', function (e) {
const accounts = ref(null)
command_listener((e) => {
console.log(e)
urlModal.value.show(e)
command_listener(async (e) => {
if (e.event === 'RunMRPack') {
// RunMRPack should directly install a local mrpack given a path
if (e.path.endsWith('.mrpack')) {
await install_from_file(e.path).catch(handleError)
mixpanel_track('InstanceCreate', {
source: 'CreationModalFileDrop',
})
}
} else {
// Other commands are URL-based (deep linking)
urlModal.value.show(e)
}
})
</script>
@ -209,6 +244,46 @@ command_listener((e) => {
autoplay
@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 />
<OnboardingScreen v-else-if="showOnboarding" :finish="() => (showOnboarding = false)" />
<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 {
display: flex;
flex-direction: column;
@ -522,4 +644,15 @@ command_listener((e) => {
object-fit: cover;
border-radius: var(--radius-md);
}
.button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: var(--gap-md);
.transparent {
padding: var(--gap-sm) 0;
}
}
</style>

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<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 { ref } from 'vue'
import { export_profile_mrpack, get_potential_override_folders } from '@/helpers/profile.js'
@ -24,9 +24,11 @@ defineExpose({
const exportModal = ref(null)
const nameInput = ref(props.instance.metadata.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const folders = ref([])
const showingFiles = ref(false)
const themeStore = useTheming()
@ -93,7 +95,9 @@ const exportPack = async () => {
props.instance.path,
outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`,
filesToExport,
versionInput.value
versionInput.value,
exportDescription.value,
nameInput.value
).catch((err) => handleError(err))
exportModal.value.hide()
}
@ -123,11 +127,31 @@ const exportPack = async () => {
</Button>
</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-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 class="table-content">
<div v-if="showingFiles" class="table-content">
<div v-for="[path, children] of folders" :key="path.name" class="table-row">
<div class="table-cell file-entry">
<div class="file-primary">
@ -177,10 +201,6 @@ const exportPack = async () => {
<XIcon />
Cancel
</Button>
<Button disabled>
<SendIcon />
Share
</Button>
<Button color="primary" @click="exportPack">
<PackageIcon />
Export
@ -261,4 +281,22 @@ const exportPack = async () => {
gap: var(--gap-sm);
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>

View File

@ -18,6 +18,14 @@
"
/>
<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">
<SearchIcon />
Auto detect
@ -44,8 +52,22 @@
</template>
<script setup>
import { Button, SearchIcon, PlayIcon, CheckIcon, XIcon, FolderSearchIcon } from 'omorphia'
import { find_jre_17_jres, get_jre } from '@/helpers/jre.js'
import {
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 { open } from '@tauri-apps/api/dialog'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
@ -82,15 +104,21 @@ const emit = defineEmits(['update:modelValue'])
const testingJava = ref(false)
const testingJavaSuccess = ref(null)
const installingJava = ref(false)
async function testJava() {
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
testingJavaSuccess.value = !!result
mixpanel_track('JavaTest', {
path: props.modelValue ? props.modelValue.path : '',
success: !!result,
success: testingJavaSuccess.value,
})
setTimeout(() => {
@ -109,13 +137,13 @@ async function handleJavaFileInput() {
version: props.version.toString(),
architecture: 'x86',
}
mixpanel_track('JavaManualSelect', {
path: filePath,
version: props.version,
})
}
mixpanel_track('JavaManualSelect', {
path: filePath,
version: props.version,
})
emit('update:modelValue', result)
}
}
@ -125,12 +153,43 @@ async function autoDetect() {
if (!props.compact) {
detectJavaModal.value.show(props.version, props.modelValue)
} else {
let versions = await find_jre_17_jres().catch(handleError)
if (versions.length > 0) {
emit('update:modelValue', versions[0])
if (props.version == 8) {
let versions = await find_jre_8_jres().catch(handleError)
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>
<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>
</aside>
<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">
<NavRow :links="selectableProjectTypes" />
</Card>

View File

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

View File

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

View File

@ -295,7 +295,7 @@ onMounted(async () => {
:previous-function="prevPhase"
:progress="phase"
title="Settings"
description="You can view and change the settings for the Modrinth App here. You can change the appearance, set and download new Java versions, and more."
description="You will be able to view and change the settings for the Modrinth App here. You can change the appearance, set and download new Java versions, and more."
/>
<TutorialTip
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 })
}
// 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.
// Returns a *NEW* JavaGlobals that can be put into Settings
export async function autodetect_java_globals() {

View File

@ -6,37 +6,50 @@
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 datetime_string: String,
pub filename: String,
pub stdout: String,
pub stderr: String,
}
*/
/// 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) {
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)
export async function get_logs_by_datetime(profilePath, datetimeString) {
return await invoke('plugin:logs|logs_get_logs_by_datetime', { profilePath, datetimeString })
/// Get a profile's log by filename
export async function get_logs_by_filename(profilePath, filename) {
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)
export async function get_output_by_datetime(profilePath, datetimeString) {
return await invoke('plugin:logs|logs_get_output_by_datetime', { profilePath, datetimeString })
/// Get a profile's log text only by filename
export async function get_output_by_filename(profilePath, filename) {
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)
export async function delete_logs_by_datetime(profilePath, datetimeString) {
return await invoke('plugin:logs|logs_delete_logs_by_datetime', { profilePath, datetimeString })
/// Delete a profile's log by filename
export async function delete_logs_by_filename(profilePath, filename) {
return await invoke('plugin:logs|logs_delete_logs_by_filename', { profilePath, filename })
}
/// Delete all logs for a given profile
export async function 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')
}
/// 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
export async function 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
export async function 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 })
}
// 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
// Returns a java version
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 })
}
// Update a managed Modrinth profile
export async function update_managed_modrinth(path) {
return await invoke('plugin:profile|profile_update_managed_modrinth', { path })
// Update a managed Modrinth profile to a specific version
export async function update_managed_modrinth_version(path, versionId) {
return await invoke('plugin:profile|profile_update_managed_modrinth_version', { path, versionId })
}
// Repair a managed Modrinth profile
@ -114,12 +125,21 @@ export async function update_repair_modrinth(path) {
// Export a profile to .mrpack
/// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
// 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', {
path,
exportLocation,
includedOverrides,
versionId,
description,
name,
})
}

View File

@ -2,6 +2,7 @@ import {
add_project_from_version as installMod,
check_installed,
get_full_path,
get_mod_full_path,
} from '@/helpers/profile'
import { useFetch } from '@/helpers/fetch.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 })
}
export async function showLauncherLogsFolder() {
return await invoke('plugin:utils|show_launcher_logs_folder', {})
}
// Opens a profile's folder in the OS file explorer
export async function showProfileInFolder(path) {
const fullPath = await get_full_path(path)
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) => {
switch (releaseType) {
case 'release':

View File

@ -59,5 +59,6 @@ initialize_state()
})
})
.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"
:noblur="!themeStore.advancedRendering"
>
<ModrinthLoginScreen
:modal="true"
:prev-page="$refs.loginScreenModal.show()"
:next-page="signInAfter"
/>
<ModrinthLoginScreen :modal="true" :prev-page="signInAfter" :next-page="signInAfter" />
</Modal>
<div class="adjacent-input">
<label for="theme">
@ -323,6 +319,21 @@ async function refreshDir() {
"
/>
</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>
<div class="label">
@ -372,7 +383,7 @@ async function refreshDir() {
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="256"
:min="8"
:max="maxMemory"
:step="1"
unit="mb"

View File

@ -75,7 +75,7 @@
</Card>
</div>
<div class="content">
<Promotion query-param="?r=launcher" />
<Promotion :external="false" query-param="?r=launcher" />
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loadingBar.startLoading()" @resolve="loadingBar.stopLoading()">
@ -84,6 +84,9 @@
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
></component>
</Suspense>
</template>
@ -149,6 +152,7 @@ import { isOffline, showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { mixpanel_track } from '@/helpers/mixpanel'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import { useFetch } from '@/helpers/fetch'
const route = useRoute()
@ -197,6 +201,15 @@ const checkProcess = async () => {
uuid.value = null
}
// Get information on associated modrinth versions, if any
const modrinthVersions = ref([])
if (!(await isOffline()) && instance.value.metadata.linked_data) {
modrinthVersions.value = await useFetch(
`https://api.modrinth.com/v2/project/${instance.value.metadata.linked_data.project_id}/version`,
'project'
)
}
await checkProcess()
const stopInstance = async (context) => {

View File

@ -20,6 +20,15 @@
Share
</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"
color="danger"
@click="deleteLog()"
@ -29,14 +38,43 @@
</Button>
</div>
</div>
<div ref="logContainer" class="log-text">
<span
v-for="(line, index) in logs[selectedLogIndex]?.stdout.split('\n')"
:key="index"
class="no-wrap"
<div class="button-row">
<input
id="text-filter"
v-model="searchFilter"
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 />
</span>
<div class="user no-wrap">
<span :style="{ color: item.prefixColor, 'font-weight': item.weight }">{{
item.prefix
}}</span>
<span :style="{ color: item.textColor }">{{ item.text }}</span>
</div>
</RecycleScroller>
</div>
<ShareModal
ref="shareModal"
@ -56,20 +94,31 @@ import {
ClipboardCopyIcon,
DropdownSelect,
ShareIcon,
Checkbox,
TrashIcon,
ShareModal,
} from 'omorphia'
import { delete_logs_by_datetime, get_logs, get_output_by_datetime } from '@/helpers/logs.js'
import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import {
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 calendar from 'dayjs/plugin/calendar'
import { get_output_by_uuid, get_uuids_by_profile_path } from '@/helpers/process.js'
import isToday from 'dayjs/plugin/isToday'
import isYesterday from 'dayjs/plugin/isYesterday'
import { get_uuids_by_profile_path } from '@/helpers/process.js'
import { useRoute } from 'vue-router'
import { process_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js'
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()
@ -82,11 +131,21 @@ const props = defineProps({
type: Boolean,
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([])
await setLogs()
const logsColored = true
const selectedLogIndex = ref(0)
const copied = ref(false)
const logContainer = ref(null)
@ -95,16 +154,86 @@ const userScrolled = ref(false)
const isAutoScrolling = ref(false)
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() {
if (route.params.id) {
const uuids = await get_uuids_by_profile_path(route.params.id).catch(handleError)
let returnValue
if (uuids.length === 0) {
returnValue = 'No live game detected. \nStart your game to proceed'
returnValue = emptyText.join('\n')
} else {
returnValue = await get_output_by_uuid(uuids[0]).catch(handleError)
const logCursor = await get_latest_log_cursor(
props.instance.path,
currentLiveLogCursor.value
).catch(handleError)
if (logCursor.new_file) {
currentLiveLog.value = ''
}
currentLiveLog.value = currentLiveLog.value + logCursor.output
currentLiveLogCursor.value = logCursor.cursor
returnValue = currentLiveLog.value
}
return { name: 'Live Log', stdout: returnValue, live: true }
}
return null
@ -112,9 +241,25 @@ async function getLiveLog() {
async function getLogs() {
return (await get_logs(props.instance.path, true).catch(handleError)).reverse().map((log) => {
log.name = dayjs(
log.datetime_string.slice(0, 8) + 'T' + log.datetime_string.slice(9)
).calendar()
if (log.filename == 'latest.log') {
log.name = 'Latest Log'
} else {
let filename = log.filename.split('.')[0]
let day = dayjs(filename.slice(0, 10))
if (day.isValid()) {
if (day.isToday()) {
log.name = 'Today'
} else if (day.isYesterday()) {
log.name = 'Yesterday'
} else {
log.name = day.format('MMMM D, YYYY')
}
// Displays as "Today-1", "Today-2", etc, matching minecraft log naming but with the date
log.name = log.name + filename.slice(10)
} else {
log.name = filename
}
}
log.stdout = 'Loading...'
return log
})
@ -152,29 +297,127 @@ watch(selectedLogIndex, async (newIndex) => {
if (logs.value.length > 1 && newIndex !== 0) {
logs.value[newIndex].stdout = 'Loading...'
logs.value[newIndex].stdout = await get_output_by_datetime(
logs.value[newIndex].stdout = await get_output_by_filename(
props.instance.path,
logs.value[newIndex].datetime_string
logs.value[newIndex].filename
).catch(handleError)
}
})
if (logs.value.length >= 1) {
if (logs.value.length > 1 && !props.playing) {
selectedLogIndex.value = 1
} else {
selectedLogIndex.value = 0
}
const deleteLog = async () => {
if (logs.value[selectedLogIndex.value] && selectedLogIndex.value !== 0) {
let deleteIndex = selectedLogIndex.value
selectedLogIndex.value = deleteIndex - 1
await delete_logs_by_datetime(
props.instance.path,
logs.value[deleteIndex].datetime_string
).catch(handleError)
await delete_logs_by_filename(props.instance.path, logs.value[deleteIndex].filename).catch(
handleError
)
await setLogs()
}
}
const clearLiveLog = async () => {
currentLiveLog.value = ''
// does not reset cursor
}
const isLineLevel = (text, level) => {
if ((text.includes('/INFO') || text.includes('[System] [CHAT]')) && level === 'info') {
return true
}
if (text.includes('/WARN') && level === 'warn') {
return true
}
if (text.includes('/DEBUG') && level === 'debug') {
return true
}
if (text.includes('/TRACE') && level === 'trace') {
return true
}
const errorTriggers = ['/ERROR', 'Exception:', ':?]', 'Error', '[thread', ' at']
if (level === 'error') {
for (const trigger of errorTriggers) {
if (text.includes(trigger)) return true
}
}
if (text.trim()[0] === '#' && level === 'comment') {
return true
}
return false
}
const getLineWeight = (text) => {
if (
!logsColored ||
isLineLevel(text, 'info') ||
isLineLevel(text, 'debug') ||
isLineLevel(text, 'trace')
) {
return 'normal'
}
if (isLineLevel(text, 'error') || isLineLevel(text, 'warn')) {
return 'bold'
}
}
const getLineLevel = (text) => {
for (const level of levels) {
if (isLineLevel(text, level.toLowerCase())) {
return level
}
}
}
const getLineColor = (text, prefix) => {
if (isLineLevel(text, 'comment')) {
return 'var(--color-green)'
}
if (!logsColored || text.includes('[System] [CHAT]')) {
return 'var(--color-white)'
}
if (
(isLineLevel(text, 'info') || isLineLevel(text, 'debug') || isLineLevel(text, 'trace')) &&
prefix
) {
return 'var(--color-blue)'
}
if (isLineLevel(text, 'warn')) {
return 'var(--color-orange)'
}
if (isLineLevel(text, 'error')) {
return 'var(--color-red)'
}
}
const getLinePrefix = (text) => {
if (text.includes(']:')) {
return text.split(']:')[0] + ']:'
}
}
const getLineText = (text) => {
if (text.includes(']:')) {
if (text.split(']:').length > 2) {
return text.split(']:').slice(1).join(']:')
}
return text.split(']:')[1]
} else {
return text
}
}
function handleUserScroll() {
if (!isAutoScrolling.value) {
userScrolled.value = true
@ -185,19 +428,14 @@ interval.value = setInterval(async () => {
if (logs.value.length > 0) {
logs.value[0] = await getLiveLog()
const scroll = logContainer.value.getScroll()
// Allow resetting of userScrolled if the user scrolls to the bottom
if (selectedLogIndex.value === 0) {
if (
logContainer.value.scrollTop + logContainer.value.offsetHeight >=
logContainer.value.scrollHeight - 10
)
userScrolled.value = false
if (scroll.end >= logContainer.value.$el.scrollHeight - 10) userScrolled.value = false
if (!userScrolled.value) {
await nextTick()
isAutoScrolling.value = true
logContainer.value.scrollTop =
logContainer.value.scrollHeight - logContainer.value.offsetHeight
logContainer.value.scrollToItem(displayProcessedLogs.value.length - 1)
setTimeout(() => (isAutoScrolling.value = false), 50)
}
}
@ -206,9 +444,13 @@ interval.value = setInterval(async () => {
const unlistenProcesses = await process_listener(async (e) => {
if (e.event === 'launched') {
currentLiveLog.value = ''
currentLiveLogCursor.value = 0
selectedLogIndex.value = 0
}
if (e.event === 'finished') {
currentLiveLog.value = ''
currentLiveLogCursor.value = 0
userScrolled.value = false
await setLogs()
selectedLogIndex.value = 1
@ -216,11 +458,11 @@ const unlistenProcesses = await process_listener(async (e) => {
})
onMounted(() => {
logContainer.value.addEventListener('scroll', handleUserScroll)
logContainer.value.$el.addEventListener('scroll', handleUserScroll)
})
onBeforeUnmount(() => {
logContainer.value.removeEventListener('scroll', handleUserScroll)
logContainer.value.$el.removeEventListener('scroll', handleUserScroll)
})
onUnmounted(() => {
clearInterval(interval.value)
@ -257,7 +499,9 @@ onUnmounted(() => {
color: var(--color-contrast);
border-radius: var(--radius-lg);
padding: 1.5rem;
overflow: auto;
overflow-x: auto; /* Enables horizontal scrolling */
overflow-y: hidden; /* Disables vertical scrolling on this wrapper */
white-space: nowrap; /* Keeps content on a single line */
white-space: normal;
color-scheme: dark;
@ -265,4 +509,37 @@ onUnmounted(() => {
white-space: pre;
}
}
.filter-checkbox {
margin-bottom: 0.3rem;
font-size: 1rem;
svg {
display: flex;
align-self: center;
justify-self: center;
}
}
.filter-group {
display: flex;
padding: 0.6rem;
flex-direction: row;
gap: 0.5rem;
}
:deep(.vue-recycle-scroller__item-wrapper) {
overflow: visible; /* Enables horizontal scrolling */
}
.scroller {
height: 100%;
}
.user {
height: 32%;
padding: 0 12px;
display: flex;
align-items: center;
}
</style>

View File

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

View File

@ -8,6 +8,56 @@
:noblur="!themeStore.advancedRendering"
@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
ref="changeVersionsModal"
header="Change instance versions"
@ -191,7 +241,7 @@
<Slider
v-model="memory.maximum"
:disabled="!overrideMemorySettings"
:min="256"
:min="8"
:max="maxMemory"
:step="1"
unit="mb"
@ -298,22 +348,110 @@
/>
</div>
</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>
<div class="label">
<h3>
<span class="label__title size-card-header">Instance management</span>
</h3>
</div>
<div v-if="instance.metadata.linked_data" class="adjacent-input">
<label for="repair-profile">
<span class="label__title">Unpair instance</span>
<div v-if="instance.install_stage == 'installed'" class="adjacent-input">
<label for="duplicate-profile">
<span class="label__title">Duplicate instance</span>
<span class="label__description">
Removes the link to an external 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.
Creates another copy of the instance, including saves, configs, mods, and everything.
</span>
</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 class="adjacent-input">
<label for="repair-profile">
@ -326,29 +464,12 @@
<Button
id="repair-profile"
color="highlight"
:disabled="repairing || offline"
:disabled="installing || inProgress || repairing || offline"
@click="repairProfile"
>
<HammerIcon /> Repair
</Button>
</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">
<label for="delete-profile">
<span class="label__title">Delete instance</span>
@ -367,6 +488,12 @@
</Button>
</div>
</Card>
<ModpackVersionModal
v-if="instance.metadata.linked_data"
ref="modpackVersionModal"
:instance="instance"
:versions="props.versions"
/>
</template>
<script setup>
@ -383,14 +510,19 @@ import {
DropdownSelect,
XIcon,
SaveIcon,
LockIcon,
HammerIcon,
DownloadIcon,
ModalConfirm,
DownloadIcon,
ClipboardCopyIcon,
Button,
} from 'omorphia'
import { SwapIcon } from '@/assets/icons'
import { Multiselect } from 'vue-multiselect'
import { useRouter } from 'vue-router'
import {
duplicate,
edit,
edit_icon,
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 { mixpanel_track } from '@/helpers/mixpanel'
import { useTheming } from '@/store/theme.js'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
const router = useRouter()
@ -427,6 +560,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
versions: {
type: Array,
required: true,
},
})
const themeStore = useTheming()
@ -435,6 +572,8 @@ const title = ref(props.instance.metadata.name)
const icon = ref(props.instance.metadata.icon)
const groups = ref(props.instance.metadata.groups)
const modpackVersionModal = ref(null)
const instancesList = Object.values(await list(true))
const availableGroups = ref([
...instancesList.reduce((acc, obj) => {
@ -469,6 +608,9 @@ async function setIcon() {
const globalSettings = await get().catch(handleError)
const modalConfirmUnlock = ref(null)
const modalConfirmUnpair = ref(null)
const javaSettings = props.instance.java ?? {}
const overrideJavaInstall = ref(!!javaSettings.override_version)
@ -496,6 +638,13 @@ const fullscreenSetting = ref(!!props.instance.fullscreen)
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(
[
title,
@ -517,71 +666,78 @@ watch(
unlinkModpack,
],
async () => {
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
}
await edit(props.instance.path, editProfile)
await edit(props.instance.path, editProfileObject.value)
},
{ 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)
async function unpairProfile() {
unlinkModpack.value = true
async function duplicateProfile() {
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() {
@ -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() {
repairing.value = true
inProgress.value = true
await update_repair_modrinth(props.instance.path).catch(handleError)
repairing.value = false
inProgress.value = false
mixpanel_track('InstanceRepair', {
loader: props.instance.metadata.loader,
@ -711,12 +887,9 @@ const editing = ref(false)
async function saveGvLoaderEdits() {
editing.value = true
const editProfile = {
metadata: {
game_version: gameVersion.value,
loader: loader.value,
},
}
let editProfile = editProfileObject.value
editProfile.metadata.loader = loader.value
editProfile.metadata.game_version = gameVersion.value
if (loader.value !== 'vanilla') {
editProfile.metadata.loader_version = selectableLoaderVersions.value[loaderVersionIndex.value]
@ -772,4 +945,39 @@ async function saveGvLoaderEdits() {
:deep(button.checkbox) {
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>

View File

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

View File

@ -7,7 +7,6 @@ use theseus::jre::autodetect_java_globals;
use theseus::prelude::*;
use theseus::profile::create::profile_create;
use tokio::time::{sleep, Duration};
// 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)
@ -107,12 +106,6 @@ async fn main() -> theseus::Result<()> {
println!("Minecraft UUID: {}", uuid);
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!(
"All running process UUID {:?}",
process::get_all_running_uuids().await?