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:
parent
bc02192d80
commit
1e8852b540
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -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
|
||||
|
4
.github/workflows/tauri-build.yml
vendored
4
.github/workflows/tauri-build.yml
vendored
@ -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
88
Cargo.lock
generated
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -306,6 +306,7 @@ async fn import_mmc_unmanaged(
|
||||
&description,
|
||||
&backup_name,
|
||||
&dependencies,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -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() {
|
||||
|
@ -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(())
|
||||
}
|
||||
|
||||
|
@ -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(©_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(
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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(())
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -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)?)
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
26
theseus_gui/pnpm-lock.yaml
generated
26
theseus_gui/pnpm-lock.yaml
generated
@ -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:
|
||||
|
14
theseus_gui/src-tauri/App.entitlements
Normal file
14
theseus_gui/src-tauri/App.entitlements
Normal 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>
|
@ -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> {
|
||||
|
@ -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?)
|
||||
}
|
||||
|
@ -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<()> {
|
||||
|
@ -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(())
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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(())
|
||||
}
|
||||
|
||||
|
@ -64,7 +64,7 @@
|
||||
"identifier": "com.modrinth.theseus",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"entitlements": "App.entitlements",
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
187
theseus_gui/src/components/ui/ModpackVersionModal.vue
Normal file
187
theseus_gui/src/components/ui/ModpackVersionModal.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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() {
|
||||
|
@ -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 })
|
||||
}
|
||||
|
@ -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 })
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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':
|
||||
|
@ -59,5 +59,6 @@ initialize_state()
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
console.error('Failed to initialize app', err)
|
||||
mountedApp.failure(err)
|
||||
})
|
||||
|
@ -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"
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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?
|
||||
|
Loading…
x
Reference in New Issue
Block a user