Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25662d1402 | ||
|
|
01ab507e3a | ||
|
|
4491d50935 | ||
|
|
3c2889714a | ||
|
|
eb6e7d1491 | ||
|
|
a8eb561774 | ||
|
|
6152eeefe3 | ||
|
|
b8b1668fee | ||
|
|
aaf808477e | ||
|
|
8e3ddbcfaf | ||
|
|
a17e096d94 | ||
|
|
f5c7f90d19 | ||
|
|
bd18dbdbe8 | ||
|
|
696000546b | ||
|
|
dc5785c874 | ||
|
|
afaec4b1bf | ||
|
|
7fb8850071 | ||
|
|
8ccc7dfcd2 | ||
|
|
da07d7328d | ||
|
|
772597ce2a | ||
|
|
e76a7d57c0 | ||
|
|
ebc4da6c29 | ||
|
|
f73c112e07 | ||
|
|
7fbc9fa357 | ||
|
|
6f8ffcaf35 | ||
|
|
1e8852b540 | ||
|
|
bc02192d80 | ||
|
|
405f77e466 | ||
|
|
2fad02df23 | ||
|
|
1eb8998296 | ||
|
|
2d3baff031 | ||
|
|
7bea362503 | ||
|
|
abb02ad624 | ||
|
|
f7f73b8163 | ||
|
|
6d9d403e7b | ||
|
|
49bfb0637f | ||
|
|
d6ee1ff25a | ||
|
|
a1a5b8ed9c | ||
|
|
5f0d44a881 | ||
|
|
d968ad383c | ||
|
|
5ee64f2705 |
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -25,9 +25,13 @@ body:
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 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
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,7 +2,7 @@ blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discord
|
||||
about: Ask questions on our Discord Server.
|
||||
url: https://discord.gg/modrinth-734077874708938864
|
||||
url: https://discord.modrinth.com
|
||||
- name: Roadmap
|
||||
about: View our Roadmap. Please do not open issues for items on our roadmap.
|
||||
url: https://roadmap.modrinth.com
|
||||
|
||||
5
.github/workflows/tauri-build.yml
vendored
5
.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
|
||||
@@ -78,6 +78,7 @@ jobs:
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -110,3 +110,5 @@ fabric.properties
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
theseus.iml
|
||||
|
||||
|
||||
99
Cargo.lock
generated
99
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"
|
||||
@@ -888,9 +912,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "daedalus"
|
||||
version = "0.1.23"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d14d655650f5f0fd5b88946d4add0f1b02d866e40632304cfe1427240bfd2430"
|
||||
checksum = "247a958ffad4a2a44fb081a2b1ef39d6b4698c179949542b202236a460102762"
|
||||
dependencies = [
|
||||
"bincode 2.0.0-rc.3",
|
||||
"bytes",
|
||||
@@ -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.4.0"
|
||||
version = "0.6.1"
|
||||
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",
|
||||
@@ -4646,6 +4724,7 @@ dependencies = [
|
||||
"tracing-error 0.1.2",
|
||||
"tracing-subscriber 0.2.25",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid 1.4.0",
|
||||
"whoami",
|
||||
"winreg 0.50.0",
|
||||
@@ -4654,7 +4733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_cli"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"argh",
|
||||
"color-eyre",
|
||||
@@ -4681,7 +4760,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_gui"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cocoa",
|
||||
@@ -5216,6 +5295,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
|
||||
11
theseus.iml
Normal file
11
theseus.iml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/theseus/library" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
@@ -20,14 +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.23" }
|
||||
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,7 +1,10 @@
|
||||
//! Authentication flow interface
|
||||
use crate::{launcher::auth as inner, State};
|
||||
use crate::{
|
||||
hydra::{self, init::DeviceLoginSuccess},
|
||||
launcher::auth as inner,
|
||||
State,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::state::AuthTask;
|
||||
pub use inner::Credentials;
|
||||
@@ -11,7 +14,7 @@ pub use inner::Credentials;
|
||||
/// This can be used in conjunction with 'authenticate_await_complete_flow'
|
||||
/// to call authenticate and call the flow from the frontend.
|
||||
/// Visit the URL in a browser, then call and await 'authenticate_await_complete_flow'.
|
||||
pub async fn authenticate_begin_flow() -> crate::Result<url::Url> {
|
||||
pub async fn authenticate_begin_flow() -> crate::Result<DeviceLoginSuccess> {
|
||||
let url = AuthTask::begin_auth().await?;
|
||||
Ok(url)
|
||||
}
|
||||
@@ -20,8 +23,7 @@ pub async fn authenticate_begin_flow() -> crate::Result<url::Url> {
|
||||
/// This completes the authentication flow quasi-synchronously, returning the credentials
|
||||
/// This can be used in conjunction with 'authenticate_begin_flow'
|
||||
/// to call authenticate and call the flow from the frontend.
|
||||
pub async fn authenticate_await_complete_flow(
|
||||
) -> crate::Result<(Credentials, Option<String>)> {
|
||||
pub async fn authenticate_await_complete_flow() -> crate::Result<Credentials> {
|
||||
let credentials = AuthTask::await_auth_completion().await?;
|
||||
Ok(credentials)
|
||||
}
|
||||
@@ -31,39 +33,6 @@ pub async fn cancel_flow() -> crate::Result<()> {
|
||||
AuthTask::cancel().await
|
||||
}
|
||||
|
||||
/// Authenticate a user with Hydra
|
||||
/// To run this, you need to first spawn this function as a task, then
|
||||
/// open a browser to the given URL and finally wait on the spawned future
|
||||
/// with the ability to cancel in case the browser is closed before finishing
|
||||
#[tracing::instrument]
|
||||
#[theseus_macros::debug_pin]
|
||||
pub async fn authenticate(
|
||||
browser_url: oneshot::Sender<url::Url>,
|
||||
) -> crate::Result<(Credentials, Option<String>)> {
|
||||
let mut flow = inner::HydraAuthFlow::new().await?;
|
||||
let state = State::get().await?;
|
||||
|
||||
let url = flow.prepare_login_url().await?;
|
||||
browser_url.send(url).map_err(|url| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Error sending browser url to parent: {url}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let credentials = flow.extract_credentials(&state.fetch_semaphore).await?;
|
||||
{
|
||||
let mut users = state.users.write().await;
|
||||
users.insert(&credentials.0).await?;
|
||||
}
|
||||
|
||||
if state.settings.read().await.default_user.is_none() {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.default_user = Some(credentials.0.id);
|
||||
}
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// Refresh some credentials using Hydra, if needed
|
||||
/// This is the primary desired way to get credentials, as it will also refresh them.
|
||||
#[tracing::instrument]
|
||||
@@ -79,20 +48,38 @@ 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. Please try again or contact support in our Discord for help!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
credentials.username = player_info.name;
|
||||
users.insert(&credentials).await?;
|
||||
}
|
||||
users.insert(&credentials).await?;
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
85
theseus/src/api/hydra/complete.rs
Normal file
85
theseus/src/api/hydra/complete.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! Main authentication flow for Hydra
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::prelude::Credentials;
|
||||
|
||||
use super::stages::{
|
||||
bearer_token, player_info, poll_response, xbl_signin, xsts_token,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OauthFailure {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
pub struct SuccessfulLogin {
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires_after: i64,
|
||||
}
|
||||
|
||||
pub async fn wait_finish(device_code: String) -> crate::Result<Credentials> {
|
||||
// Loop, polling for response from Microsoft
|
||||
let oauth = poll_response::poll_response(device_code).await?;
|
||||
|
||||
// Get xbl token from oauth token
|
||||
let xbl_token = xbl_signin::login_xbl(&oauth.access_token).await?;
|
||||
|
||||
// Get xsts token from xbl token
|
||||
let xsts_response = xsts_token::fetch_token(&xbl_token.token).await?;
|
||||
|
||||
match xsts_response {
|
||||
xsts_token::XSTSResponse::Unauthorized(err) => {
|
||||
Err(crate::ErrorKind::HydraError(format!(
|
||||
"Error getting XBox Live token: {}",
|
||||
err
|
||||
))
|
||||
.as_error())
|
||||
}
|
||||
xsts_token::XSTSResponse::Success { token: xsts_token } => {
|
||||
// Get xsts bearer token from xsts token
|
||||
let bearer_token =
|
||||
bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Error getting bearer token: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
// Get player info from bearer token
|
||||
let player_info = player_info::fetch_info(&bearer_token).await.map_err(|_err| {
|
||||
crate::ErrorKind::HydraError("No Minecraft account for profile. Make sure you own the game and have set a username through the official Minecraft launcher."
|
||||
.to_string())
|
||||
})?;
|
||||
|
||||
// Create credentials
|
||||
let credentials = Credentials::new(
|
||||
uuid::Uuid::parse_str(&player_info.id)?, // get uuid from player_info.id which is a String
|
||||
player_info.name,
|
||||
bearer_token,
|
||||
oauth.refresh_token,
|
||||
chrono::Utc::now()
|
||||
+ chrono::Duration::seconds(oauth.expires_in),
|
||||
);
|
||||
|
||||
// Put credentials into state
|
||||
let state = crate::State::get().await?;
|
||||
{
|
||||
let mut users = state.users.write().await;
|
||||
users.insert(&credentials).await?;
|
||||
}
|
||||
|
||||
if state.settings.read().await.default_user.is_none() {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.default_user = Some(credentials.id);
|
||||
}
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
}
|
||||
}
|
||||
45
theseus/src/api/hydra/init.rs
Normal file
45
theseus/src/api/hydra/init.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! Login route for Hydra, redirects to the Microsoft login page before going to the redirect route
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{hydra::MicrosoftError, util::fetch::REQWEST_CLIENT};
|
||||
|
||||
use super::{stages::auth_retry, MICROSOFT_CLIENT_ID};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct DeviceLoginSuccess {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub async fn init() -> crate::Result<DeviceLoginSuccess> {
|
||||
// Get the initial URL
|
||||
let client_id = MICROSOFT_CLIENT_ID;
|
||||
|
||||
// Get device code
|
||||
// Define the parameters
|
||||
let mut params = HashMap::new();
|
||||
params.insert("client_id", client_id);
|
||||
params.insert("scope", "XboxLive.signin offline_access");
|
||||
|
||||
// urlencoding::encode("XboxLive.signin offline_access"));
|
||||
let resp = auth_retry(|| REQWEST_CLIENT.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded").form(¶ms).send()).await?;
|
||||
|
||||
match resp.status() {
|
||||
reqwest::StatusCode::OK => Ok(resp.json().await?),
|
||||
_ => {
|
||||
let microsoft_error = resp.json::<MicrosoftError>().await?;
|
||||
Err(crate::ErrorKind::HydraError(format!(
|
||||
"Error from Microsoft: {:?}",
|
||||
microsoft_error.error_description
|
||||
))
|
||||
.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
15
theseus/src/api/hydra/mod.rs
Normal file
15
theseus/src/api/hydra/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
pub mod complete;
|
||||
pub mod init;
|
||||
pub mod refresh;
|
||||
pub(crate) mod stages;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const MICROSOFT_CLIENT_ID: &str = "c4502edb-87c6-40cb-b595-64a280cf8906";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MicrosoftError {
|
||||
pub error: String,
|
||||
pub error_description: String,
|
||||
pub error_codes: Vec<u64>,
|
||||
}
|
||||
65
theseus/src/api/hydra/refresh.rs
Normal file
65
theseus/src/api/hydra/refresh.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
hydra::{MicrosoftError, MICROSOFT_CLIENT_ID},
|
||||
util::fetch::REQWEST_CLIENT,
|
||||
};
|
||||
|
||||
use super::stages::auth_retry;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OauthSuccess {
|
||||
pub token_type: String,
|
||||
pub scope: String,
|
||||
pub expires_in: i64,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
pub async fn refresh(refresh_token: String) -> crate::Result<OauthSuccess> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "refresh_token");
|
||||
params.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||
params.insert("refresh_token", &refresh_token);
|
||||
|
||||
// Poll the URL in a loop until we are successful.
|
||||
// On an authorization_pending response, wait 5 seconds and try again.
|
||||
let resp =
|
||||
auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
match resp.status() {
|
||||
StatusCode::OK => {
|
||||
let oauth = resp.json::<OauthSuccess>().await.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Could not decipher successful response: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
Ok(oauth)
|
||||
}
|
||||
_ => {
|
||||
let failure =
|
||||
resp.json::<MicrosoftError>().await.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Could not decipher failure response: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
Err(crate::ErrorKind::HydraError(format!(
|
||||
"Error refreshing token: {}",
|
||||
failure.error
|
||||
))
|
||||
.as_error())
|
||||
}
|
||||
}
|
||||
}
|
||||
34
theseus/src/api/hydra/stages/bearer_token.rs
Normal file
34
theseus/src/api/hydra/stages/bearer_token.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const MCSERVICES_AUTH_URL: &str =
|
||||
"https://api.minecraftservices.com/launcher/login";
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn fetch_bearer(token: &str, uhs: &str) -> crate::Result<String> {
|
||||
let body = auth_retry(|| {
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(MCSERVICES_AUTH_URL)
|
||||
.json(&json!({
|
||||
"xtoken": format!("XBL3.0 x={};{}", uhs, token),
|
||||
"platform": "PC_LAUNCHER"
|
||||
}))
|
||||
.send()
|
||||
})
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(&body)?
|
||||
.get("access_token")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(String::from)
|
||||
.ok_or(
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Response didn't contain valid bearer token. body: {body}"
|
||||
))
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
37
theseus/src/api/hydra/stages/mod.rs
Normal file
37
theseus/src/api/hydra/stages/mod.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! MSA authentication stages
|
||||
|
||||
use futures::Future;
|
||||
use reqwest::Response;
|
||||
|
||||
const RETRY_COUNT: usize = 2; // Does command 3 times
|
||||
const RETRY_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
pub mod bearer_token;
|
||||
pub mod player_info;
|
||||
pub mod poll_response;
|
||||
pub mod xbl_signin;
|
||||
pub mod xsts_token;
|
||||
|
||||
#[tracing::instrument(skip(reqwest_request))]
|
||||
pub async fn auth_retry<F>(
|
||||
reqwest_request: impl Fn() -> F,
|
||||
) -> crate::Result<reqwest::Response>
|
||||
where
|
||||
F: Future<Output = Result<Response, reqwest::Error>>,
|
||||
{
|
||||
let mut resp = reqwest_request().await?;
|
||||
for i in 0..RETRY_COUNT {
|
||||
if resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
tracing::debug!(
|
||||
"Request failed with status code {}, retrying...",
|
||||
resp.status()
|
||||
);
|
||||
if i < RETRY_COUNT - 1 {
|
||||
tokio::time::sleep(RETRY_WAIT).await;
|
||||
}
|
||||
resp = reqwest_request().await?;
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
38
theseus/src/api/hydra/stages/player_info.rs
Normal file
38
theseus/src/api/hydra/stages/player_info.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
//! Fetch player info for display
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::util::fetch::REQWEST_CLIENT;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PlayerInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Default for PlayerInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: "606e2ff0ed7748429d6ce1d3321c7838".to_string(),
|
||||
name: String::from("Unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn fetch_info(token: &str) -> crate::Result<PlayerInfo> {
|
||||
let response = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.get(PROFILE_URL)
|
||||
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let resp = response.error_for_status()?.json().await?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
96
theseus/src/api/hydra/stages/poll_response.rs
Normal file
96
theseus/src/api/hydra/stages/poll_response.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
hydra::{MicrosoftError, MICROSOFT_CLIENT_ID},
|
||||
util::fetch::REQWEST_CLIENT,
|
||||
};
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OauthSuccess {
|
||||
pub token_type: String,
|
||||
pub scope: String,
|
||||
pub expires_in: i64,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn poll_response(device_code: String) -> crate::Result<OauthSuccess> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
||||
params.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||
params.insert("device_code", &device_code);
|
||||
|
||||
// Poll the URL in a loop until we are successful.
|
||||
// On an authorization_pending response, wait 5 seconds and try again.
|
||||
loop {
|
||||
let resp = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post(
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
|
||||
)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
match resp.status() {
|
||||
StatusCode::OK => {
|
||||
let oauth =
|
||||
resp.json::<OauthSuccess>().await.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Could not decipher successful response: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
return Ok(oauth);
|
||||
}
|
||||
_ => {
|
||||
let failure =
|
||||
resp.json::<MicrosoftError>().await.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Could not decipher failure response: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
match failure.error.as_str() {
|
||||
"authorization_pending" => {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2))
|
||||
.await;
|
||||
}
|
||||
"authorization_declined" => {
|
||||
return Err(crate::ErrorKind::HydraError(
|
||||
"Authorization declined".to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
"expired_token" => {
|
||||
return Err(crate::ErrorKind::HydraError(
|
||||
"Device code expired".to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
"bad_verification_code" => {
|
||||
return Err(crate::ErrorKind::HydraError(
|
||||
"Invalid device code".to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
_ => {
|
||||
return Err(crate::ErrorKind::HydraError(format!(
|
||||
"Unknown error: {}",
|
||||
failure.error
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
theseus/src/api/hydra/stages/xbl_signin.rs
Normal file
60
theseus/src/api/hydra/stages/xbl_signin.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::util::fetch::REQWEST_CLIENT;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const XBL_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
|
||||
|
||||
// Deserialization
|
||||
pub struct XBLLogin {
|
||||
pub token: String,
|
||||
pub uhs: String,
|
||||
}
|
||||
|
||||
// Impl
|
||||
#[tracing::instrument]
|
||||
pub async fn login_xbl(token: &str) -> crate::Result<XBLLogin> {
|
||||
let response = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post(XBL_AUTH_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header("x-xbl-contract-version", "1")
|
||||
.json(&json!({
|
||||
"Properties": {
|
||||
"AuthMethod": "RPS",
|
||||
"SiteName": "user.auth.xboxlive.com",
|
||||
"RpsTicket": format!("d={token}")
|
||||
},
|
||||
"RelyingParty": "http://auth.xboxlive.com",
|
||||
"TokenType": "JWT"
|
||||
}))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let body = response.text().await?;
|
||||
|
||||
let json = serde_json::from_str::<serde_json::Value>(&body)?;
|
||||
let token = Some(&json)
|
||||
.and_then(|it| it.get("Token")?.as_str().map(String::from))
|
||||
.ok_or(crate::ErrorKind::HydraError(
|
||||
"XBL response didn't contain valid token".to_string(),
|
||||
))?;
|
||||
let uhs = Some(&json)
|
||||
.and_then(|it| {
|
||||
it.get("DisplayClaims")?
|
||||
.get("xui")?
|
||||
.get(0)?
|
||||
.get("uhs")?
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
})
|
||||
.ok_or(
|
||||
crate::ErrorKind::HydraError(
|
||||
"XBL response didn't contain valid user hash".to_string(),
|
||||
)
|
||||
.as_error(),
|
||||
)?;
|
||||
|
||||
Ok(XBLLogin { token, uhs })
|
||||
}
|
||||
62
theseus/src/api/hydra/stages/xsts_token.rs
Normal file
62
theseus/src/api/hydra/stages/xsts_token.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::util::fetch::REQWEST_CLIENT;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
|
||||
|
||||
pub enum XSTSResponse {
|
||||
Unauthorized(String),
|
||||
Success { token: String },
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn fetch_token(token: &str) -> crate::Result<XSTSResponse> {
|
||||
let resp = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post(XSTS_AUTH_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.json(&json!({
|
||||
"Properties": {
|
||||
"SandboxId": "RETAIL",
|
||||
"UserTokens": [
|
||||
token
|
||||
]
|
||||
},
|
||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||
"TokenType": "JWT"
|
||||
}))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
|
||||
let body = resp.text().await?;
|
||||
let json = serde_json::from_str::<serde_json::Value>(&body)?;
|
||||
|
||||
if status.is_success() {
|
||||
Ok(json
|
||||
.get("Token")
|
||||
.and_then(|x| x.as_str().map(String::from))
|
||||
.map(|it| XSTSResponse::Success { token: it })
|
||||
.unwrap_or(XSTSResponse::Unauthorized(
|
||||
"XSTS response didn't contain valid token!".to_string(),
|
||||
)))
|
||||
} else {
|
||||
Ok(XSTSResponse::Unauthorized(
|
||||
#[allow(clippy::unreadable_literal)]
|
||||
match json.get("XErr").and_then(|x| x.as_i64()) {
|
||||
Some(2148916238) => {
|
||||
String::from("This Microsoft account is underage and is not linked to a family.")
|
||||
},
|
||||
Some(2148916235) => {
|
||||
String::from("XBOX Live/Minecraft is not available in your country.")
|
||||
},
|
||||
Some(2148916233) => String::from("This account does not have a valid XBOX Live profile. Please buy Minecraft and try again!"),
|
||||
Some(2148916236) | Some(2148916237) => String::from("This account needs adult verification on Xbox page."),
|
||||
_ => String::from("Unknown error code"),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use std::path::PathBuf;
|
||||
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::{
|
||||
@@ -117,10 +118,6 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
|
||||
|
||||
let path = state.directories.java_versions_dir().await;
|
||||
|
||||
if path.exists() {
|
||||
io::remove_dir_all(&path).await?;
|
||||
}
|
||||
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(file))
|
||||
.map_err(|_| {
|
||||
crate::Error::from(crate::ErrorKind::InputError(
|
||||
@@ -128,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(
|
||||
@@ -184,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, DirectoryInfo},
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,7 +74,6 @@ pub async fn get_logs(
|
||||
profile_path: ProfilePathId,
|
||||
clear_contents: Option<bool>,
|
||||
) -> crate::Result<Vec<Logs>> {
|
||||
let state = State::get().await?;
|
||||
let profile_path =
|
||||
if let Some(p) = crate::profile::get(&profile_path, None).await? {
|
||||
p.profile_id()
|
||||
@@ -45,39 +84,37 @@ pub async fn get_logs(
|
||||
.into());
|
||||
};
|
||||
|
||||
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
|
||||
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
|
||||
let mut logs = Vec::new();
|
||||
if logs_folder.exists() {
|
||||
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 +126,65 @@ 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 logs_folder = DirectoryInfo::profile_logs_dir(profile_subpath).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]
|
||||
@@ -120,8 +199,7 @@ pub async fn delete_logs(profile_path: ProfilePathId) -> crate::Result<()> {
|
||||
.into());
|
||||
};
|
||||
|
||||
let state = State::get().await?;
|
||||
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
|
||||
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
|
||||
for entry in std::fs::read_dir(&logs_folder)
|
||||
.map_err(|e| IOError::with_path(e, &logs_folder))?
|
||||
{
|
||||
@@ -135,9 +213,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? {
|
||||
@@ -149,9 +227,89 @@ pub async fn delete_logs_by_datetime(
|
||||
.into());
|
||||
};
|
||||
|
||||
let state = State::get().await?;
|
||||
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
|
||||
let path = logs_folder.join(datetime_string);
|
||||
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
|
||||
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,
|
||||
cursor: u64, // 0 to start at beginning of file
|
||||
) -> crate::Result<LatestLogCursor> {
|
||||
get_generic_live_log_cursor(profile_path, "latest.log", cursor).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_std_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
cursor: u64, // 0 to start at beginning of file
|
||||
) -> crate::Result<LatestLogCursor> {
|
||||
get_generic_live_log_cursor(profile_path, "latest_stdout.log", cursor).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_generic_live_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
log_file_name: &str,
|
||||
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 = DirectoryInfo::profile_logs_dir(&profile_path).await?;
|
||||
let path = logs_folder.join(log_file_name);
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,3 +33,11 @@ pub async fn get_quilt_versions() -> crate::Result<Manifest> {
|
||||
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_neoforge_versions() -> crate::Result<Manifest> {
|
||||
let state = State::get().await?;
|
||||
let tags = state.metadata.read().await.neoforge.clone();
|
||||
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! API for interacting with Theseus
|
||||
pub mod auth;
|
||||
pub mod handler;
|
||||
pub mod hydra;
|
||||
pub mod jre;
|
||||
pub mod logs;
|
||||
pub mod metadata;
|
||||
|
||||
@@ -7,10 +7,13 @@ use crate::ErrorKind;
|
||||
pub async fn authenticate_begin_flow(provider: &str) -> crate::Result<String> {
|
||||
let state = crate::State::get().await?;
|
||||
|
||||
// Don't start an uncompleteable new flow if there's an existing locked one
|
||||
let mut write: tokio::sync::RwLockWriteGuard<'_, Option<ModrinthAuthFlow>> =
|
||||
state.modrinth_auth_flow.write().await;
|
||||
|
||||
let mut flow = ModrinthAuthFlow::new(provider).await?;
|
||||
let url = flow.prepare_login_url().await?;
|
||||
|
||||
let mut write = state.modrinth_auth_flow.write().await;
|
||||
*write = Some(flow);
|
||||
|
||||
Ok(url)
|
||||
@@ -87,22 +90,6 @@ pub async fn login_2fa(
|
||||
Ok(creds)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn login_minecraft(
|
||||
flow: &str,
|
||||
) -> crate::Result<ModrinthCredentialsResult> {
|
||||
let state = crate::State::get().await?;
|
||||
let creds =
|
||||
crate::state::login_minecraft(flow, &state.fetch_semaphore).await?;
|
||||
|
||||
if let ModrinthCredentialsResult::Credentials(creds) = &creds {
|
||||
let mut write = state.credentials.write().await;
|
||||
write.login(creds.clone()).await?;
|
||||
}
|
||||
|
||||
Ok(creds)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn create_account(
|
||||
username: &str,
|
||||
|
||||
@@ -3,13 +3,12 @@ use std::{collections::HashMap, path::PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
event::LoadingBarId,
|
||||
pack::{
|
||||
self,
|
||||
import::{self, copy_dotminecraft},
|
||||
install_from::CreatePackDescription,
|
||||
},
|
||||
prelude::{ModLoader, ProfilePathId},
|
||||
prelude::{ModLoader, Profile, ProfilePathId},
|
||||
state::{LinkedData, ProfileInstallStage},
|
||||
util::io,
|
||||
State,
|
||||
@@ -33,8 +32,6 @@ pub struct ATLauncher {
|
||||
pub modrinth_project: Option<ATLauncherModrinthProject>,
|
||||
pub modrinth_version: Option<ATLauncherModrinthVersion>,
|
||||
pub modrinth_manifest: Option<pack::install_from::PackFormat>,
|
||||
|
||||
pub mods: Vec<ATLauncherMod>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -57,13 +54,9 @@ pub struct ATLauncherModrinthProject {
|
||||
pub slug: String,
|
||||
pub project_type: String,
|
||||
pub team: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub body: String,
|
||||
pub client_side: Option<String>,
|
||||
pub server_side: Option<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub icon_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
@@ -110,7 +103,16 @@ pub async fn is_valid_atlauncher(instance_folder: PathBuf) -> bool {
|
||||
.unwrap_or("".to_string());
|
||||
let instance: Result<ATInstance, serde_json::Error> =
|
||||
serde_json::from_str::<ATInstance>(&instance);
|
||||
instance.is_ok()
|
||||
if let Err(e) = instance {
|
||||
tracing::warn!(
|
||||
"Could not parse instance.json at {}: {}",
|
||||
instance_folder.display(),
|
||||
e
|
||||
);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
@@ -169,7 +171,6 @@ pub async fn import_atlauncher(
|
||||
backup_name,
|
||||
description,
|
||||
atinstance,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -181,7 +182,6 @@ async fn import_atlauncher_unmanaged(
|
||||
backup_name: String,
|
||||
description: CreatePackDescription,
|
||||
atinstance: ATInstance,
|
||||
existing_loading_bar: Option<LoadingBarId>,
|
||||
) -> crate::Result<()> {
|
||||
let mod_loader = format!(
|
||||
"\"{}\"",
|
||||
@@ -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();
|
||||
@@ -230,19 +234,32 @@ async fn import_atlauncher_unmanaged(
|
||||
|
||||
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
|
||||
let state = State::get().await?;
|
||||
copy_dotminecraft(
|
||||
let loading_bar = copy_dotminecraft(
|
||||
profile_path.clone(),
|
||||
minecraft_folder,
|
||||
&state.io_semaphore,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(profile_val) =
|
||||
crate::api::profile::get(&profile_path, None).await?
|
||||
{
|
||||
crate::launcher::install_minecraft(&profile_val, existing_loading_bar)
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let state = State::get().await?;
|
||||
let mut file_watcher = state.file_watcher.write().await;
|
||||
Profile::watch_fs(
|
||||
&profile_val.get_profile_full_path().await?,
|
||||
&mut file_watcher,
|
||||
)
|
||||
.await?;
|
||||
|
||||
}
|
||||
State::sync().await?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::prelude::Profile;
|
||||
use crate::state::CredentialsStore;
|
||||
use crate::{
|
||||
prelude::{ModLoader, ProfilePathId},
|
||||
@@ -187,18 +188,33 @@ pub async fn import_curseforge(
|
||||
|
||||
// Copy in contained folders as overrides
|
||||
let state = State::get().await?;
|
||||
copy_dotminecraft(
|
||||
let loading_bar = copy_dotminecraft(
|
||||
profile_path.clone(),
|
||||
curseforge_instance_folder,
|
||||
&state.io_semaphore,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(profile_val) =
|
||||
crate::api::profile::get(&profile_path, None).await?
|
||||
{
|
||||
crate::launcher::install_minecraft(&profile_val, None).await?;
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let state = State::get().await?;
|
||||
let mut file_watcher = state.file_watcher.write().await;
|
||||
Profile::watch_fs(
|
||||
&profile_val.get_profile_full_path().await?,
|
||||
&mut file_watcher,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
State::sync().await?;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
prelude::{ModLoader, ProfilePathId},
|
||||
prelude::{ModLoader, Profile, ProfilePathId},
|
||||
state::ProfileInstallStage,
|
||||
util::io,
|
||||
State,
|
||||
@@ -101,18 +101,32 @@ pub async fn import_gdlauncher(
|
||||
|
||||
// Copy in contained folders as overrides
|
||||
let state = State::get().await?;
|
||||
copy_dotminecraft(
|
||||
let loading_bar = copy_dotminecraft(
|
||||
profile_path.clone(),
|
||||
gdlauncher_instance_folder,
|
||||
&state.io_semaphore,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(profile_val) =
|
||||
crate::api::profile::get(&profile_path, None).await?
|
||||
{
|
||||
crate::launcher::install_minecraft(&profile_val, None).await?;
|
||||
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let state = State::get().await?;
|
||||
let mut file_watcher = state.file_watcher.write().await;
|
||||
Profile::watch_fs(
|
||||
&profile_val.get_profile_full_path().await?,
|
||||
&mut file_watcher,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
State::sync().await?;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
import::{self, copy_dotminecraft},
|
||||
install_from::{self, CreatePackDescription, PackDependency},
|
||||
},
|
||||
prelude::ProfilePathId,
|
||||
prelude::{Profile, ProfilePathId},
|
||||
util::io,
|
||||
State,
|
||||
};
|
||||
@@ -119,6 +119,26 @@ pub struct MMCComponentRequirement {
|
||||
pub suggests: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
#[serde(untagged)]
|
||||
enum MMCLauncherEnum {
|
||||
General(MMCLauncherGeneral),
|
||||
Instance(MMCLauncher),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct MMCLauncherGeneral {
|
||||
pub general: MMCLauncher,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct MMCLauncher {
|
||||
instance_dir: String,
|
||||
}
|
||||
|
||||
// Checks if if its a folder, and the folder contains instance.cfg and mmc-pack.json, and they both parse
|
||||
#[tracing::instrument]
|
||||
pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool {
|
||||
@@ -134,9 +154,19 @@ pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool {
|
||||
&& serde_json::from_str::<MMCPack>(&mmc_pack).is_ok()
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_instances_subpath(config: PathBuf) -> Option<String> {
|
||||
let launcher = io::read_to_string(&config).await.ok()?;
|
||||
let launcher: MMCLauncherEnum = serde_ini::from_str(&launcher).ok()?;
|
||||
match launcher {
|
||||
MMCLauncherEnum::General(p) => Some(p.general.instance_dir),
|
||||
MMCLauncherEnum::Instance(p) => Some(p.instance_dir),
|
||||
}
|
||||
}
|
||||
|
||||
// Loading the INI (instance.cfg) file
|
||||
async fn load_instance_cfg(file_path: &Path) -> crate::Result<MMCInstance> {
|
||||
let instance_cfg = io::read_to_string(file_path).await?;
|
||||
let instance_cfg: String = io::read_to_string(file_path).await?;
|
||||
let instance_cfg_enum: MMCInstanceEnum =
|
||||
serde_ini::from_str::<MMCInstanceEnum>(&instance_cfg)?;
|
||||
match instance_cfg_enum {
|
||||
@@ -276,23 +306,38 @@ async fn import_mmc_unmanaged(
|
||||
&description,
|
||||
&backup_name,
|
||||
&dependencies,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
|
||||
let state = State::get().await?;
|
||||
copy_dotminecraft(
|
||||
let loading_bar = copy_dotminecraft(
|
||||
profile_path.clone(),
|
||||
minecraft_folder,
|
||||
&state.io_semaphore,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(profile_val) =
|
||||
crate::api::profile::get(&profile_path, None).await?
|
||||
{
|
||||
crate::launcher::install_minecraft(&profile_val, None).await?;
|
||||
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let state = State::get().await?;
|
||||
let mut file_watcher = state.file_watcher.write().await;
|
||||
Profile::watch_fs(
|
||||
&profile_val.get_profile_full_path().await?,
|
||||
&mut file_watcher,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
State::sync().await?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -7,6 +7,10 @@ use io::IOError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
event::{
|
||||
emit::{emit_loading, init_or_edit_loading},
|
||||
LoadingBarId,
|
||||
},
|
||||
prelude::ProfilePathId,
|
||||
state::Profiles,
|
||||
util::{
|
||||
@@ -51,11 +55,20 @@ pub async fn get_importable_instances(
|
||||
) -> crate::Result<Vec<String>> {
|
||||
// Some launchers have a different folder structure for instances
|
||||
let instances_subfolder = match launcher_type {
|
||||
ImportLauncherType::GDLauncher
|
||||
| ImportLauncherType::MultiMC
|
||||
| ImportLauncherType::PrismLauncher
|
||||
| ImportLauncherType::ATLauncher => "instances",
|
||||
ImportLauncherType::Curseforge => "Instances",
|
||||
ImportLauncherType::GDLauncher | ImportLauncherType::ATLauncher => {
|
||||
"instances".to_string()
|
||||
}
|
||||
ImportLauncherType::Curseforge => "Instances".to_string(),
|
||||
ImportLauncherType::MultiMC => {
|
||||
mmc::get_instances_subpath(base_path.clone().join("multimc.cfg"))
|
||||
.await
|
||||
.unwrap_or_else(|| "instances".to_string())
|
||||
}
|
||||
ImportLauncherType::PrismLauncher => mmc::get_instances_subpath(
|
||||
base_path.clone().join("prismlauncher.cfg"),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "instances".to_string()),
|
||||
ImportLauncherType::Unknown => {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Launcher type Unknown".to_string(),
|
||||
@@ -63,7 +76,8 @@ pub async fn get_importable_instances(
|
||||
.into())
|
||||
}
|
||||
};
|
||||
let instances_folder = base_path.join(instances_subfolder);
|
||||
|
||||
let instances_folder = base_path.join(&instances_subfolder);
|
||||
let mut instances = Vec::new();
|
||||
let mut dir = io::read_dir(&instances_folder).await.map_err(| _ | {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
@@ -237,56 +251,62 @@ pub async fn recache_icon(
|
||||
}
|
||||
}
|
||||
|
||||
async fn copy_dotminecraft(
|
||||
profile_path: ProfilePathId,
|
||||
pub async fn copy_dotminecraft(
|
||||
profile_path_id: ProfilePathId,
|
||||
dotminecraft: PathBuf,
|
||||
io_semaphore: &IoSemaphore,
|
||||
) -> crate::Result<()> {
|
||||
existing_loading_bar: Option<LoadingBarId>,
|
||||
) -> crate::Result<LoadingBarId> {
|
||||
// Get full path to profile
|
||||
let profile_path = profile_path.get_full_path().await?;
|
||||
let profile_path = profile_path_id.get_full_path().await?;
|
||||
|
||||
// std fs copy every file in dotminecraft to profile_path
|
||||
let mut dir = io::read_dir(&dotminecraft).await?;
|
||||
while let Some(entry) = dir
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &dotminecraft))?
|
||||
{
|
||||
let path = entry.path();
|
||||
copy_dir_to(
|
||||
&path,
|
||||
&profile_path.join(path.file_name().ok_or_else(|| {
|
||||
// Gets all subfiles recursively in src
|
||||
let subfiles = get_all_subfiles(&dotminecraft).await?;
|
||||
let total_subfiles = subfiles.len() as u64;
|
||||
|
||||
let loading_bar = init_or_edit_loading(
|
||||
existing_loading_bar,
|
||||
crate::LoadingBarType::CopyProfile {
|
||||
import_location: dotminecraft.clone(),
|
||||
profile_name: profile_path_id.to_string(),
|
||||
},
|
||||
total_subfiles as f64,
|
||||
"Copying files in profile",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Copy each file
|
||||
for src_child in subfiles {
|
||||
let dst_child =
|
||||
src_child.strip_prefix(&dotminecraft).map_err(|_| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Invalid file: {}",
|
||||
&path.display()
|
||||
&src_child.display()
|
||||
))
|
||||
})?),
|
||||
io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
})?;
|
||||
let dst_child = profile_path.join(dst_child);
|
||||
|
||||
// sleep for cpu for 1 millisecond
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
|
||||
|
||||
fetch::copy(&src_child, &dst_child, io_semaphore).await?;
|
||||
|
||||
emit_loading(&loading_bar, 1.0, None).await?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(loading_bar)
|
||||
}
|
||||
|
||||
/// Recursively fs::copy every file in src to dest
|
||||
/// Recursively get a list of all subfiles in src
|
||||
/// uses async recursion
|
||||
#[theseus_macros::debug_pin]
|
||||
#[async_recursion::async_recursion]
|
||||
#[tracing::instrument]
|
||||
async fn copy_dir_to(
|
||||
src: &Path,
|
||||
dst: &Path,
|
||||
io_semaphore: &IoSemaphore,
|
||||
) -> crate::Result<()> {
|
||||
async fn get_all_subfiles(src: &Path) -> crate::Result<Vec<PathBuf>> {
|
||||
if !src.is_dir() {
|
||||
fetch::copy(src, dst, io_semaphore).await?;
|
||||
return Ok(());
|
||||
return Ok(vec![src.to_path_buf()]);
|
||||
}
|
||||
|
||||
// Create the destination directory
|
||||
io::create_dir_all(&dst).await?;
|
||||
|
||||
// Iterate over the directory
|
||||
let mut files = Vec::new();
|
||||
let mut dir = io::read_dir(&src).await?;
|
||||
while let Some(child) = dir
|
||||
.next_entry()
|
||||
@@ -294,21 +314,7 @@ async fn copy_dir_to(
|
||||
.map_err(|e| IOError::with_path(e, src))?
|
||||
{
|
||||
let src_child = child.path();
|
||||
let dst_child = dst.join(src_child.file_name().ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Invalid file: {}",
|
||||
&src_child.display()
|
||||
))
|
||||
})?);
|
||||
|
||||
if src_child.is_dir() {
|
||||
// Recurse into sub-directory
|
||||
copy_dir_to(&src_child, &dst_child, io_semaphore).await?;
|
||||
} else {
|
||||
// Copy file
|
||||
fetch::copy(&src_child, &dst_child, io_semaphore).await?;
|
||||
}
|
||||
files.append(&mut get_all_subfiles(&src_child).await?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::util::fetch::{
|
||||
fetch, fetch_advanced, fetch_json, write_cached_icon,
|
||||
};
|
||||
use crate::util::io;
|
||||
use crate::State;
|
||||
use crate::{InnerProjectPathUnix, State};
|
||||
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -33,7 +33,7 @@ pub struct PackFormat {
|
||||
#[derive(Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackFile {
|
||||
pub path: String,
|
||||
pub path: InnerProjectPathUnix,
|
||||
pub hashes: HashMap<PackFileHash, String>,
|
||||
pub env: Option<HashMap<EnvType, SideType>>,
|
||||
pub downloads: Vec<String>,
|
||||
@@ -65,12 +65,22 @@ pub enum EnvType {
|
||||
Server,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Hash, PartialEq, Eq, Debug)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq, Debug)]
|
||||
pub enum PackDependency {
|
||||
#[serde(rename = "forge")]
|
||||
Forge,
|
||||
|
||||
#[serde(rename = "neoforge")]
|
||||
#[serde(alias = "neo-forge")]
|
||||
NeoForge,
|
||||
|
||||
#[serde(rename = "fabric-loader")]
|
||||
FabricLoader,
|
||||
|
||||
#[serde(rename = "quilt-loader")]
|
||||
QuiltLoader,
|
||||
|
||||
#[serde(rename = "minecraft")]
|
||||
Minecraft,
|
||||
}
|
||||
|
||||
@@ -101,6 +111,7 @@ pub struct CreatePackProfile {
|
||||
pub icon_url: Option<String>, // the URL icon for a profile (ONLY USED FOR TEMPORARY PROFILES)
|
||||
pub linked_data: Option<LinkedData>, // the linked project ID (mainly for modpacks)- used for updating
|
||||
pub skip_install_profile: Option<bool>,
|
||||
pub no_watch: Option<bool>,
|
||||
}
|
||||
|
||||
// default
|
||||
@@ -115,6 +126,7 @@ impl Default for CreatePackProfile {
|
||||
icon_url: None,
|
||||
linked_data: None,
|
||||
skip_install_profile: Some(true),
|
||||
no_watch: Some(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,6 +162,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()
|
||||
},
|
||||
@@ -176,20 +189,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;
|
||||
@@ -310,6 +332,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;
|
||||
@@ -321,6 +344,10 @@ pub async fn set_profile_information(
|
||||
mod_loader = Some(ModLoader::Forge);
|
||||
loader_version = Some(value);
|
||||
}
|
||||
PackDependency::NeoForge => {
|
||||
mod_loader = Some(ModLoader::NeoForge);
|
||||
loader_version = Some(value);
|
||||
}
|
||||
PackDependency::FabricLoader => {
|
||||
mod_loader = Some(ModLoader::Fabric);
|
||||
loader_version = Some(value);
|
||||
@@ -360,10 +387,26 @@ pub async fn set_profile_information(
|
||||
.clone()
|
||||
.unwrap_or_else(|| backup_name.to_string());
|
||||
prof.install_stage = ProfileInstallStage::PackInstalling;
|
||||
prof.metadata.linked_data = Some(LinkedData {
|
||||
project_id: description.project_id.clone(),
|
||||
version_id: description.version_id.clone(),
|
||||
});
|
||||
|
||||
let project_id = description.project_id.clone();
|
||||
let version_id = description.version_id.clone();
|
||||
|
||||
prof.metadata.linked_data = if project_id.is_some()
|
||||
&& version_id.is_some()
|
||||
{
|
||||
Some(LinkedData {
|
||||
project_id,
|
||||
version_id,
|
||||
locked: if !ignore_lock {
|
||||
Some(true)
|
||||
} else {
|
||||
prof.metadata.linked_data.as_ref().and_then(|x| x.locked)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
prof.metadata.icon = description.icon.clone();
|
||||
prof.metadata.game_version = game_version.clone();
|
||||
prof.metadata.loader_version = loader_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,17 @@ pub async fn install_zipped_mrpack_files(
|
||||
.await?;
|
||||
drop(creds);
|
||||
|
||||
let project_path = project.path.to_string();
|
||||
|
||||
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?;
|
||||
}
|
||||
@@ -273,11 +282,13 @@ pub async fn install_zipped_mrpack_files(
|
||||
profile::edit_icon(&profile_path, Some(&potential_icon)).await?;
|
||||
}
|
||||
|
||||
if let Some(profile_val) =
|
||||
crate::api::profile::get(&profile_path, None).await?
|
||||
{
|
||||
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
|
||||
.await?;
|
||||
if let Some(profile_val) = profile::get(&profile_path, None).await? {
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
State::sync().await?;
|
||||
}
|
||||
@@ -339,31 +350,68 @@ 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: PathBuf = profile_path
|
||||
.get_full_path()
|
||||
.await?
|
||||
.join(file.path.to_string());
|
||||
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,
|
||||
@@ -32,6 +32,7 @@ pub async fn profile_create(
|
||||
icon_url: Option<String>, // the URL icon for a profile (ONLY USED FOR TEMPORARY PROFILES)
|
||||
linked_data: Option<LinkedData>, // the linked project ID (mainly for modpacks)- used for updating
|
||||
skip_install_profile: Option<bool>,
|
||||
no_watch: Option<bool>,
|
||||
) -> crate::Result<ProfilePathId> {
|
||||
name = profile::sanitize_profile_name(&name);
|
||||
|
||||
@@ -101,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,
|
||||
@@ -112,11 +119,13 @@ pub async fn profile_create(
|
||||
|
||||
{
|
||||
let mut profiles = state.profiles.write().await;
|
||||
profiles.insert(profile.clone()).await?;
|
||||
profiles
|
||||
.insert(profile.clone(), no_watch.unwrap_or_default())
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !skip_install_profile.unwrap_or(false) {
|
||||
crate::launcher::install_minecraft(&profile, None).await?;
|
||||
crate::launcher::install_minecraft(&profile, None, false).await?;
|
||||
}
|
||||
State::sync().await?;
|
||||
|
||||
@@ -146,10 +155,71 @@ pub async fn profile_create_from_creator(
|
||||
profile.icon_url,
|
||||
profile.linked_data,
|
||||
profile.skip_install_profile,
|
||||
profile.no_watch,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn profile_create_from_duplicate(
|
||||
copy_from: ProfilePathId,
|
||||
) -> crate::Result<ProfilePathId> {
|
||||
// Original profile
|
||||
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?;
|
||||
|
||||
let duplicated_profile =
|
||||
profile::get(&profile_path_id, None).await?.ok_or_else(|| {
|
||||
ErrorKind::UnmanagedProfileError(profile_path_id.to_string())
|
||||
})?;
|
||||
|
||||
crate::launcher::install_minecraft(&duplicated_profile, Some(bar), false)
|
||||
.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(
|
||||
@@ -176,6 +246,7 @@ pub(crate) async fn get_loader_version_from_loader(
|
||||
ModLoader::Forge => &metadata.forge,
|
||||
ModLoader::Fabric => &metadata.fabric,
|
||||
ModLoader::Quilt => &metadata.quilt,
|
||||
ModLoader::NeoForge => &metadata.neoforge,
|
||||
_ => {
|
||||
return Err(
|
||||
ProfileCreationError::NoManifest(loader.to_string()).into()
|
||||
|
||||
@@ -8,8 +8,9 @@ use crate::pack::install_from::{
|
||||
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
|
||||
};
|
||||
use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId};
|
||||
use crate::state::ProjectMetadata;
|
||||
use crate::state::{InnerProjectPathUnix, ProjectMetadata, SideType};
|
||||
|
||||
use crate::util::fetch;
|
||||
use crate::util::io::{self, IOError};
|
||||
use crate::{
|
||||
auth::{self, refresh},
|
||||
@@ -22,9 +23,11 @@ pub use crate::{
|
||||
};
|
||||
use async_zip::tokio::write::ZipFileWriter;
|
||||
use async_zip::{Compression, ZipEntryBuilder};
|
||||
use serde_json::json;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use std::iter::FromIterator;
|
||||
use std::{
|
||||
future::Future,
|
||||
path::{Path, PathBuf},
|
||||
@@ -107,6 +110,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,
|
||||
@@ -258,9 +281,9 @@ pub async fn list(
|
||||
|
||||
/// Installs/Repairs a profile
|
||||
#[tracing::instrument]
|
||||
pub async fn install(path: &ProfilePathId) -> crate::Result<()> {
|
||||
pub async fn install(path: &ProfilePathId, force: bool) -> crate::Result<()> {
|
||||
if let Some(profile) = get(path, None).await? {
|
||||
crate::launcher::install_minecraft(&profile, None).await?;
|
||||
crate::launcher::install_minecraft(&profile, None, force).await?;
|
||||
} else {
|
||||
return Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
|
||||
.as_error());
|
||||
@@ -366,6 +389,10 @@ pub async fn update_project(
|
||||
.add_project_version(update_version.id.clone())
|
||||
.await?;
|
||||
|
||||
if project.disabled {
|
||||
profile.toggle_disable_project(&path).await?;
|
||||
}
|
||||
|
||||
if path != project_path.clone() {
|
||||
profile.remove_project(project_path, Some(true)).await?;
|
||||
}
|
||||
@@ -541,23 +568,6 @@ pub async fn remove_project(
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets whether project is a managed modrinth pack
|
||||
#[tracing::instrument]
|
||||
pub async fn is_managed_modrinth_pack(
|
||||
profile: &ProfilePathId,
|
||||
) -> crate::Result<bool> {
|
||||
if let Some(profile) = get(profile, None).await? {
|
||||
if let Some(linked_data) = profile.metadata.linked_data {
|
||||
return Ok(linked_data.project_id.is_some()
|
||||
&& linked_data.version_id.is_some());
|
||||
}
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(crate::ErrorKind::UnmanagedProfileError(profile.to_string())
|
||||
.as_error())
|
||||
}
|
||||
}
|
||||
|
||||
/// Exports the profile to a Modrinth-formatted .mrpack file
|
||||
// Version ID of uploaded version (ie 1.1.5), not the unique identifying ID of the version (nvrqJg44)
|
||||
#[tracing::instrument(skip_all)]
|
||||
@@ -565,8 +575,10 @@ pub async fn is_managed_modrinth_pack(
|
||||
pub async fn export_mrpack(
|
||||
profile_path: &ProfilePathId,
|
||||
export_path: PathBuf,
|
||||
included_overrides: Vec<String>, // which folders to include in the overrides
|
||||
included_export_candidates: Vec<String>, // which folders/files to include in the export
|
||||
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;
|
||||
@@ -578,8 +590,8 @@ pub async fn export_mrpack(
|
||||
))
|
||||
})?;
|
||||
|
||||
// remove .DS_Store files from included_overrides
|
||||
let included_overrides = included_overrides
|
||||
// remove .DS_Store files from included_export_candidates
|
||||
let included_export_candidates = included_export_candidates
|
||||
.into_iter()
|
||||
.filter(|x| {
|
||||
if let Some(f) = PathBuf::from(x).file_name() {
|
||||
@@ -600,12 +612,17 @@ 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 modrinth_path_list = get_modrinth_pack_list(&packfile);
|
||||
let mut packfile =
|
||||
create_mrpack_json(&profile, version_id, description).await?;
|
||||
let included_candidates_set =
|
||||
HashSet::<_>::from_iter(included_export_candidates.iter());
|
||||
packfile.files.retain(|f| {
|
||||
included_candidates_set.contains(&f.path.get_topmost_two_components())
|
||||
});
|
||||
|
||||
// Build vec of all files in the folder
|
||||
let mut path_list = Vec::new();
|
||||
build_folder(profile_base_path, &mut path_list).await?;
|
||||
add_all_recursive_folder_paths(profile_base_path, &mut path_list).await?;
|
||||
|
||||
// Initialize loading bar
|
||||
let loading_bar = init_loading(
|
||||
@@ -623,38 +640,13 @@ pub async fn export_mrpack(
|
||||
for path in path_list {
|
||||
emit_loading(&loading_bar, 1.0, None).await?;
|
||||
|
||||
// Get local path of file, relative to profile folder
|
||||
let relative_path = path.strip_prefix(profile_base_path)?;
|
||||
|
||||
// Get highest level folder pair ('a/b' in 'a/b/c', 'a' in 'a')
|
||||
// We only go one layer deep for the sake of not having a huge list of overrides
|
||||
let topmost_two = relative_path.iter().take(2).collect::<Vec<_>>();
|
||||
|
||||
// a,b => a/b
|
||||
// a => a
|
||||
let topmost = match topmost_two.len() {
|
||||
2 => PathBuf::from(topmost_two[0]).join(topmost_two[1]),
|
||||
1 => PathBuf::from(topmost_two[0]),
|
||||
_ => {
|
||||
return Err(crate::ErrorKind::OtherError(
|
||||
"No topmost folder found".to_string(),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
if !included_overrides.contains(&topmost) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative_path: std::borrow::Cow<str> =
|
||||
relative_path.to_string_lossy();
|
||||
let relative_path = relative_path.replace('\\', "/");
|
||||
let relative_path = relative_path.trim_start_matches('/').to_string();
|
||||
|
||||
if modrinth_path_list.contains(&relative_path) {
|
||||
let relative_path = ProjectPathId::from_fs_path(&path)
|
||||
.await?
|
||||
.get_inner_path_unix();
|
||||
if packfile.files.iter().any(|f| f.path == relative_path)
|
||||
|| !included_candidates_set
|
||||
.contains(&relative_path.get_topmost_two_components())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -688,30 +680,28 @@ pub async fn export_mrpack(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Given a folder path, populate a Vec of all the subfolders
|
||||
// Intended to be used for finding potential override folders
|
||||
// Given a folder path, populate a Vec of all the subfolders and files, at most 2 layers deep
|
||||
// profile
|
||||
// -- folder1
|
||||
// -- folder2
|
||||
// -- innerfolder
|
||||
// -- innerfile
|
||||
// -- folder2file
|
||||
// -- file1
|
||||
// => [folder1, folder2]
|
||||
// => [folder1, folder2/innerfolder, folder2/folder2file, file1]
|
||||
#[tracing::instrument]
|
||||
pub async fn get_potential_override_folders(
|
||||
profile_path: ProfilePathId,
|
||||
) -> crate::Result<Vec<PathBuf>> {
|
||||
pub async fn get_pack_export_candidates(
|
||||
profile_path: &ProfilePathId,
|
||||
) -> crate::Result<Vec<InnerProjectPathUnix>> {
|
||||
// First, get a dummy mrpack json for the files within
|
||||
let profile: Profile =
|
||||
get(&profile_path, None).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Tried to export a nonexistent or unloaded profile at path {}!",
|
||||
profile_path
|
||||
))
|
||||
})?;
|
||||
// dummy mrpack to get pack list
|
||||
let mrpack = create_mrpack_json(&profile, "0".to_string()).await?;
|
||||
let mrpack_files = get_modrinth_pack_list(&mrpack);
|
||||
let profile: Profile = get(profile_path, None).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Tried to export a nonexistent or unloaded profile at path {}!",
|
||||
profile_path
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut path_list: Vec<PathBuf> = Vec::new();
|
||||
let mut path_list: Vec<InnerProjectPathUnix> = Vec::new();
|
||||
|
||||
let profile_base_dir = profile.get_profile_full_path().await?;
|
||||
let mut read_dir = io::read_dir(&profile_base_dir).await?;
|
||||
@@ -730,16 +720,16 @@ pub async fn get_potential_override_folders(
|
||||
.map_err(|e| IOError::with_path(e, &profile_base_dir))?
|
||||
{
|
||||
let path: PathBuf = entry.path();
|
||||
let name = path.strip_prefix(&profile_base_dir)?.to_path_buf();
|
||||
if !mrpack_files.contains(&name.to_string_lossy().to_string()) {
|
||||
path_list.push(name);
|
||||
if let Ok(project_path) =
|
||||
ProjectPathId::from_fs_path(&path).await
|
||||
{
|
||||
path_list.push(project_path.get_inner_path_unix());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// One layer of files/folders if its a file
|
||||
let name = path.strip_prefix(&profile_base_dir)?.to_path_buf();
|
||||
if !mrpack_files.contains(&name.to_string_lossy().to_string()) {
|
||||
path_list.push(name);
|
||||
if let Ok(project_path) = ProjectPathId::from_fs_path(&path).await {
|
||||
path_list.push(project_path.get_inner_path_unix());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -835,23 +825,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![];
|
||||
@@ -878,17 +857,63 @@ pub async fn run_credentials(
|
||||
Ok(mc_process)
|
||||
}
|
||||
|
||||
fn get_modrinth_pack_list(packfile: &PackFormat) -> Vec<String> {
|
||||
packfile
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let path = PathBuf::from(f.path.clone());
|
||||
let name = path.to_string_lossy();
|
||||
let name = name.replace('\\', "/");
|
||||
name.trim_start_matches('/').to_string()
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
/// Update playtime- sending a request to the server to update the playtime
|
||||
#[tracing::instrument]
|
||||
#[theseus_macros::debug_pin]
|
||||
pub async fn try_update_playtime(path: &ProfilePathId) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
|
||||
let profile = get(path, None).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Tried to update playtime for a nonexistent or unloaded profile at path {}!",
|
||||
path
|
||||
))
|
||||
})?;
|
||||
let updated_recent_playtime = profile.metadata.recent_time_played;
|
||||
|
||||
let res = if updated_recent_playtime > 0 {
|
||||
// Create update struct to send to Labrinth
|
||||
let modrinth_pack_version_id =
|
||||
profile.metadata.linked_data.and_then(|l| l.version_id);
|
||||
let playtime_update_json = json!({
|
||||
"seconds": updated_recent_playtime,
|
||||
"loader": profile.metadata.loader.to_string(),
|
||||
"game_version": profile.metadata.game_version,
|
||||
"parent": modrinth_pack_version_id,
|
||||
});
|
||||
// Copy this struct for every Modrinth project in the profile
|
||||
let mut hashmap: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
for (_, project) in profile.projects {
|
||||
if let ProjectMetadata::Modrinth { version, .. } = project.metadata
|
||||
{
|
||||
hashmap.insert(version.id, playtime_update_json.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let creds = state.credentials.read().await;
|
||||
fetch::post_json(
|
||||
"https://api.modrinth.com/analytics/playtime",
|
||||
serde_json::to_value(hashmap)?,
|
||||
&state.fetch_semaphore,
|
||||
&creds,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// If successful, update the profile metadata to match submitted
|
||||
if res.is_ok() {
|
||||
let mut profiles = state.profiles.write().await;
|
||||
if let Some(profile) = profiles.0.get_mut(path) {
|
||||
profile.metadata.submitted_time_played += updated_recent_playtime;
|
||||
profile.metadata.recent_time_played = 0;
|
||||
}
|
||||
}
|
||||
// Sync either way
|
||||
State::sync().await?;
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
/// Creates a json configuration for a .mrpack zipped file
|
||||
@@ -897,6 +922,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();
|
||||
@@ -907,6 +933,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)
|
||||
}
|
||||
@@ -930,25 +959,28 @@ pub async fn create_mrpack_json(
|
||||
// But the values are sanitized to only include the version number
|
||||
let dependencies = dependencies
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, sanitize_loader_version_string(&v).to_string()))
|
||||
.map(|(k, v)| (k, sanitize_loader_version_string(&v, k).to_string()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let files: Result<Vec<PackFile>, crate::ErrorKind> = profile
|
||||
.projects
|
||||
.iter()
|
||||
.filter_map(|(mod_path, project)| {
|
||||
let path: String = mod_path.0.clone().to_string_lossy().to_string();
|
||||
let path = mod_path.get_inner_path_unix();
|
||||
|
||||
// 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()
|
||||
@@ -993,30 +1025,42 @@ pub async fn create_mrpack_json(
|
||||
format_version: 1,
|
||||
version_id,
|
||||
name: profile.metadata.name.clone(),
|
||||
summary: None,
|
||||
summary: description,
|
||||
files,
|
||||
dependencies,
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_loader_version_string(s: &str) -> &str {
|
||||
// Split on '-'
|
||||
// If two or more, take the second
|
||||
// If one, take the first
|
||||
// If none, take the whole thing
|
||||
let mut split: std::str::Split<'_, char> = s.split('-');
|
||||
match split.next() {
|
||||
Some(first) => match split.next() {
|
||||
Some(second) => second,
|
||||
None => first,
|
||||
},
|
||||
None => s,
|
||||
fn sanitize_loader_version_string(s: &str, loader: PackDependency) -> &str {
|
||||
match loader {
|
||||
// Split on '-'
|
||||
// If two or more, take the second
|
||||
// If one, take the first
|
||||
// If none, take the whole thing
|
||||
PackDependency::Forge | PackDependency::NeoForge => {
|
||||
if s.starts_with("1.") {
|
||||
let mut split: std::str::Split<'_, char> = s.split('-');
|
||||
match split.next() {
|
||||
Some(first) => match split.next() {
|
||||
Some(second) => second,
|
||||
None => first,
|
||||
},
|
||||
None => s,
|
||||
}
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
// For quilt, etc we take the whole thing, as it functions like: 0.20.0-beta.11 (and should not be split here)
|
||||
PackDependency::QuiltLoader
|
||||
| PackDependency::FabricLoader
|
||||
| PackDependency::Minecraft => s,
|
||||
}
|
||||
}
|
||||
|
||||
// Given a folder path, populate a Vec of all the files in the folder, recursively
|
||||
#[async_recursion::async_recursion]
|
||||
pub async fn build_folder(
|
||||
pub async fn add_all_recursive_folder_paths(
|
||||
path: &Path,
|
||||
path_list: &mut Vec<PathBuf>,
|
||||
) -> crate::Result<()> {
|
||||
@@ -1028,7 +1072,7 @@ pub async fn build_folder(
|
||||
{
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
build_folder(&path, path_list).await?;
|
||||
add_all_recursive_folder_paths(&path, path_list).await?;
|
||||
} else {
|
||||
path_list.push(path);
|
||||
}
|
||||
@@ -1037,5 +1081,5 @@ pub async fn build_folder(
|
||||
}
|
||||
|
||||
pub fn sanitize_profile_name(input: &str) -> String {
|
||||
input.replace(['/', '\\', ':'], "_")
|
||||
input.replace(['/', '\\', '?', '*', ':', '\'', '\"', '|', '<', '>'], "_")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ pub enum ErrorKind {
|
||||
#[error("I/O error: {0}")]
|
||||
IOError(#[from] util::io::IOError),
|
||||
|
||||
#[error("I/O (std) error: {0}")]
|
||||
StdIOError(#[from] std::io::Error),
|
||||
|
||||
#[error("Error launching Minecraft: {0}")]
|
||||
LauncherError(String),
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -185,6 +189,10 @@ pub enum LoadingBarType {
|
||||
ConfigChange {
|
||||
new_path: PathBuf,
|
||||
},
|
||||
CopyProfile {
|
||||
import_location: PathBuf,
|
||||
profile_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Minecraft CLI argument logic
|
||||
// TODO: Rafactor this section
|
||||
use super::{auth::Credentials, parse_rule};
|
||||
use super::auth::Credentials;
|
||||
use crate::launcher::parse_rules;
|
||||
use crate::{
|
||||
state::{MemorySettings, WindowSize},
|
||||
util::{io::IOError, platform::classpath_separator},
|
||||
@@ -11,6 +11,7 @@ use daedalus::{
|
||||
modded::SidedDataEntry,
|
||||
};
|
||||
use dunce::canonicalize;
|
||||
use std::collections::HashSet;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
use uuid::Uuid;
|
||||
@@ -23,12 +24,13 @@ pub fn get_class_paths(
|
||||
libraries: &[Library],
|
||||
client_path: &Path,
|
||||
java_arch: &str,
|
||||
minecraft_updated: bool,
|
||||
) -> crate::Result<String> {
|
||||
let mut cps = libraries
|
||||
.iter()
|
||||
.filter_map(|library| {
|
||||
if let Some(rules) = &library.rules {
|
||||
if !rules.iter().any(|x| parse_rule(x, java_arch)) {
|
||||
if !parse_rules(rules, java_arch, minecraft_updated) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -39,9 +41,9 @@ pub fn get_class_paths(
|
||||
|
||||
Some(get_lib_path(libraries_path, &library.name, false))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
.collect::<Result<HashSet<_>, _>>()?;
|
||||
|
||||
cps.push(
|
||||
cps.insert(
|
||||
canonicalize(client_path)
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
@@ -54,7 +56,10 @@ pub fn get_class_paths(
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
Ok(cps.join(classpath_separator(java_arch)))
|
||||
Ok(cps
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
.join(classpath_separator(java_arch)))
|
||||
}
|
||||
|
||||
pub fn get_class_paths_jar<T: AsRef<str>>(
|
||||
@@ -268,8 +273,8 @@ fn parse_minecraft_argument(
|
||||
.replace("${auth_player_name}", username)
|
||||
// TODO: add auth xuid eventually
|
||||
.replace("${auth_xuid}", "0")
|
||||
.replace("${auth_uuid}", &uuid.hyphenated().to_string())
|
||||
.replace("${uuid}", &uuid.hyphenated().to_string())
|
||||
.replace("${auth_uuid}", &uuid.simple().to_string())
|
||||
.replace("${uuid}", &uuid.simple().to_string())
|
||||
.replace("${clientid}", "c4502edb-87c6-40cb-b595-64a280cf8906")
|
||||
.replace("${user_properties}", "{}")
|
||||
.replace("${user_type}", "msa")
|
||||
@@ -335,7 +340,7 @@ where
|
||||
}
|
||||
}
|
||||
Argument::Ruled { rules, value } => {
|
||||
if rules.iter().any(|x| parse_rule(x, java_arch)) {
|
||||
if parse_rules(rules, java_arch, true) {
|
||||
match value {
|
||||
ArgumentValue::Single(arg) => {
|
||||
parsed_arguments.push(parse_function(
|
||||
|
||||
@@ -1,55 +1,13 @@
|
||||
//! Authentication flow based on Hydra
|
||||
use crate::config::MODRINTH_API_URL;
|
||||
use crate::state::CredentialsStore;
|
||||
use crate::util::fetch::{fetch_advanced, fetch_json, FetchSemaphore};
|
||||
use async_tungstenite as ws;
|
||||
|
||||
use crate::hydra;
|
||||
use crate::util::fetch::FetchSemaphore;
|
||||
|
||||
use chrono::{prelude::*, Duration};
|
||||
use futures::prelude::*;
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::Method;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
lazy_static! {
|
||||
static ref HYDRA_URL: Url =
|
||||
Url::parse(&format!("{MODRINTH_API_URL}auth/minecraft/"))
|
||||
.expect("Hydra URL parse failed");
|
||||
}
|
||||
|
||||
// Socket messages
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorJSON {
|
||||
error: String,
|
||||
}
|
||||
|
||||
impl ErrorJSON {
|
||||
pub fn unwrap<'a, T: Deserialize<'a>>(data: &'a [u8]) -> crate::Result<T> {
|
||||
if let Ok(err) = serde_json::from_slice::<Self>(data) {
|
||||
Err(crate::ErrorKind::HydraError(err.error).as_error())
|
||||
} else {
|
||||
Ok(serde_json::from_slice::<T>(data)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginCodeJSON {
|
||||
login_code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenJSON {
|
||||
token: String,
|
||||
refresh_token: String,
|
||||
expires_after: u32,
|
||||
flow: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProfileInfoJSON {
|
||||
id: uuid::Uuid,
|
||||
name: String,
|
||||
}
|
||||
use crate::api::hydra::stages::{bearer_token, xbl_signin, xsts_token};
|
||||
|
||||
// Login information
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
@@ -62,116 +20,66 @@ pub struct Credentials {
|
||||
_ctor_scope: std::marker::PhantomData<()>,
|
||||
}
|
||||
|
||||
// Implementation
|
||||
pub struct HydraAuthFlow<S: AsyncRead + AsyncWrite + Unpin> {
|
||||
socket: ws::WebSocketStream<S>,
|
||||
}
|
||||
|
||||
impl HydraAuthFlow<ws::tokio::ConnectStream> {
|
||||
pub async fn new() -> crate::Result<Self> {
|
||||
let (socket, _) = ws::tokio::connect_async(
|
||||
"wss://api.modrinth.com/v2/auth/minecraft/ws",
|
||||
)
|
||||
.await?;
|
||||
Ok(Self { socket })
|
||||
impl Credentials {
|
||||
pub fn new(
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
expires: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
username,
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires,
|
||||
_ctor_scope: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn prepare_login_url(&mut self) -> crate::Result<Url> {
|
||||
let code_resp = self
|
||||
.socket
|
||||
.try_next()
|
||||
.await?
|
||||
.ok_or(
|
||||
crate::ErrorKind::WSClosedError(String::from(
|
||||
"login socket ID",
|
||||
))
|
||||
.as_error(),
|
||||
)?
|
||||
.into_data();
|
||||
let code = ErrorJSON::unwrap::<LoginCodeJSON>(&code_resp)?;
|
||||
Ok(wrap_ref_builder!(
|
||||
it = HYDRA_URL.join("init")? =>
|
||||
{ it.query_pairs_mut().append_pair("id", &code.login_code); }
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn extract_credentials(
|
||||
&mut self,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<(Credentials, Option<String>)> {
|
||||
// Minecraft bearer token
|
||||
let token_resp = self
|
||||
.socket
|
||||
.try_next()
|
||||
.await?
|
||||
.ok_or(
|
||||
crate::ErrorKind::WSClosedError(String::from(
|
||||
"login socket ID",
|
||||
))
|
||||
.as_error(),
|
||||
)?
|
||||
.into_data();
|
||||
let token = ErrorJSON::unwrap::<TokenJSON>(&token_resp)?;
|
||||
let expires =
|
||||
Utc::now() + Duration::seconds(token.expires_after.into());
|
||||
|
||||
// Get account credentials
|
||||
let info = fetch_info(&token.token, semaphore).await?;
|
||||
|
||||
// Return structure from response
|
||||
Ok((
|
||||
Credentials {
|
||||
username: info.name,
|
||||
id: info.id,
|
||||
refresh_token: token.refresh_token,
|
||||
access_token: token.token,
|
||||
expires,
|
||||
_ctor_scope: std::marker::PhantomData,
|
||||
},
|
||||
token.flow,
|
||||
))
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires < Utc::now()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_credentials(
|
||||
credentials: &mut Credentials,
|
||||
semaphore: &FetchSemaphore,
|
||||
_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<()> {
|
||||
let resp = fetch_json::<TokenJSON>(
|
||||
Method::POST,
|
||||
&format!("{MODRINTH_API_URL}auth/minecraft/refresh"),
|
||||
None,
|
||||
Some(serde_json::json!({ "refresh_token": credentials.refresh_token })),
|
||||
semaphore,
|
||||
&CredentialsStore(None),
|
||||
)
|
||||
.await?;
|
||||
let oauth =
|
||||
hydra::refresh::refresh(credentials.refresh_token.clone()).await?;
|
||||
|
||||
credentials.access_token = resp.token;
|
||||
credentials.refresh_token = resp.refresh_token;
|
||||
credentials.expires =
|
||||
Utc::now() + Duration::seconds(resp.expires_after.into());
|
||||
let xbl_token = xbl_signin::login_xbl(&oauth.access_token).await?;
|
||||
|
||||
// Get xsts token from xbl token
|
||||
let xsts_response = xsts_token::fetch_token(&xbl_token.token).await?;
|
||||
|
||||
match xsts_response {
|
||||
xsts_token::XSTSResponse::Unauthorized(err) => {
|
||||
return Err(crate::ErrorKind::HydraError(format!(
|
||||
"Error getting XBox Live token: {}",
|
||||
err
|
||||
))
|
||||
.as_error())
|
||||
}
|
||||
xsts_token::XSTSResponse::Success { token: xsts_token } => {
|
||||
let bearer_token =
|
||||
bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Error getting bearer token: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
credentials.access_token = bearer_token;
|
||||
credentials.refresh_token = oauth.refresh_token;
|
||||
credentials.expires =
|
||||
Utc::now() + Duration::seconds(oauth.expires_in);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helpers
|
||||
async fn fetch_info(
|
||||
token: &str,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<ProfileInfoJSON> {
|
||||
let result = fetch_advanced(
|
||||
Method::GET,
|
||||
"https://api.minecraftservices.com/minecraft/profile",
|
||||
None,
|
||||
None,
|
||||
Some(("Authorization", &format!("Bearer {token}"))),
|
||||
None,
|
||||
semaphore,
|
||||
&CredentialsStore(None),
|
||||
)
|
||||
.await?;
|
||||
let value = serde_json::from_slice(&result)?;
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Downloader for Minecraft data
|
||||
|
||||
use crate::launcher::parse_rules;
|
||||
use crate::state::CredentialsStore;
|
||||
use crate::{
|
||||
event::{
|
||||
@@ -26,11 +27,13 @@ pub async fn download_minecraft(
|
||||
version: &GameVersionInfo,
|
||||
loading_bar: &LoadingBarId,
|
||||
java_arch: &str,
|
||||
force: bool,
|
||||
minecraft_updated: bool,
|
||||
) -> crate::Result<()> {
|
||||
tracing::info!("Downloading Minecraft version {}", version.id);
|
||||
// 5
|
||||
let assets_index =
|
||||
download_assets_index(st, version, Some(loading_bar)).await?;
|
||||
download_assets_index(st, version, Some(loading_bar), force).await?;
|
||||
|
||||
let amount = if version
|
||||
.processors
|
||||
@@ -45,9 +48,9 @@ pub async fn download_minecraft(
|
||||
|
||||
tokio::try_join! {
|
||||
// Total loading sums to 90/60
|
||||
download_client(st, version, Some(loading_bar)), // 10
|
||||
download_assets(st, version.assets == "legacy", &assets_index, Some(loading_bar), amount), // 40
|
||||
download_libraries(st, version.libraries.as_slice(), &version.id, Some(loading_bar), amount, java_arch) // 40
|
||||
download_client(st, version, Some(loading_bar), force), // 10
|
||||
download_assets(st, version.assets == "legacy", &assets_index, Some(loading_bar), amount, force), // 40
|
||||
download_libraries(st, version.libraries.as_slice(), &version.id, Some(loading_bar), amount, java_arch, force, minecraft_updated) // 40
|
||||
}?;
|
||||
|
||||
tracing::info!("Done downloading Minecraft!");
|
||||
@@ -105,6 +108,7 @@ pub async fn download_client(
|
||||
st: &State,
|
||||
version_info: &GameVersionInfo,
|
||||
loading_bar: Option<&LoadingBarId>,
|
||||
force: bool,
|
||||
) -> crate::Result<()> {
|
||||
let version = &version_info.id;
|
||||
tracing::debug!("Locating client for version {version}");
|
||||
@@ -123,7 +127,7 @@ pub async fn download_client(
|
||||
.await
|
||||
.join(format!("{version}.jar"));
|
||||
|
||||
if !path.exists() {
|
||||
if !path.exists() || force {
|
||||
let bytes = fetch(
|
||||
&client_download.url,
|
||||
Some(&client_download.sha1),
|
||||
@@ -148,6 +152,7 @@ pub async fn download_assets_index(
|
||||
st: &State,
|
||||
version: &GameVersionInfo,
|
||||
loading_bar: Option<&LoadingBarId>,
|
||||
force: bool,
|
||||
) -> crate::Result<AssetsIndex> {
|
||||
tracing::debug!("Loading assets index");
|
||||
let path = st
|
||||
@@ -156,7 +161,7 @@ pub async fn download_assets_index(
|
||||
.await
|
||||
.join(format!("{}.json", &version.asset_index.id));
|
||||
|
||||
let res = if path.exists() {
|
||||
let res = if path.exists() && !force {
|
||||
io::read(path)
|
||||
.err_into::<crate::Error>()
|
||||
.await
|
||||
@@ -183,6 +188,7 @@ pub async fn download_assets(
|
||||
index: &AssetsIndex,
|
||||
loading_bar: Option<&LoadingBarId>,
|
||||
loading_amount: f64,
|
||||
force: bool,
|
||||
) -> crate::Result<()> {
|
||||
tracing::debug!("Loading assets");
|
||||
let num_futs = index.objects.len();
|
||||
@@ -206,7 +212,7 @@ pub async fn download_assets(
|
||||
let fetch_cell = OnceCell::<bytes::Bytes>::new();
|
||||
tokio::try_join! {
|
||||
async {
|
||||
if !resource_path.exists() {
|
||||
if !resource_path.exists() || force {
|
||||
let resource = fetch_cell
|
||||
.get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore, &CredentialsStore(None)))
|
||||
.await?;
|
||||
@@ -216,13 +222,14 @@ pub async fn download_assets(
|
||||
Ok::<_, crate::Error>(())
|
||||
},
|
||||
async {
|
||||
if with_legacy {
|
||||
let resource_path = st.directories.legacy_assets_dir().await.join(
|
||||
name.replace('/', &String::from(std::path::MAIN_SEPARATOR))
|
||||
);
|
||||
|
||||
if with_legacy && !resource_path.exists() || force {
|
||||
let resource = fetch_cell
|
||||
.get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore, &CredentialsStore(None)))
|
||||
.await?;
|
||||
let resource_path = st.directories.legacy_assets_dir().await.join(
|
||||
name.replace('/', &String::from(std::path::MAIN_SEPARATOR))
|
||||
);
|
||||
write(&resource_path, resource, &st.io_semaphore).await?;
|
||||
tracing::trace!("Fetched legacy asset with hash {hash}");
|
||||
}
|
||||
@@ -239,6 +246,7 @@ pub async fn download_assets(
|
||||
|
||||
#[tracing::instrument(skip(st, libraries))]
|
||||
#[theseus_macros::debug_pin]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn download_libraries(
|
||||
st: &State,
|
||||
libraries: &[Library],
|
||||
@@ -246,6 +254,8 @@ pub async fn download_libraries(
|
||||
loading_bar: Option<&LoadingBarId>,
|
||||
loading_amount: f64,
|
||||
java_arch: &str,
|
||||
force: bool,
|
||||
minecraft_updated: bool,
|
||||
) -> crate::Result<()> {
|
||||
tracing::debug!("Loading libraries");
|
||||
|
||||
@@ -258,7 +268,7 @@ pub async fn download_libraries(
|
||||
stream::iter(libraries.iter())
|
||||
.map(Ok::<&Library, crate::Error>), None, loading_bar,loading_amount,num_files, None,|library| async move {
|
||||
if let Some(rules) = &library.rules {
|
||||
if !rules.iter().any(|x| super::parse_rule(x, java_arch)) {
|
||||
if !parse_rules(rules, java_arch, minecraft_updated) {
|
||||
tracing::trace!("Skipped library {}", &library.name);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -270,7 +280,7 @@ pub async fn download_libraries(
|
||||
let path = st.directories.libraries_dir().await.join(&artifact_path);
|
||||
|
||||
match library.downloads {
|
||||
_ if path.exists() => Ok(()),
|
||||
_ if path.exists() && !force => Ok(()),
|
||||
Some(d::minecraft::LibraryDownloads {
|
||||
artifact: Some(ref artifact),
|
||||
..
|
||||
@@ -320,7 +330,7 @@ pub async fn download_libraries(
|
||||
let reader = std::io::Cursor::new(&data);
|
||||
if let Ok(mut archive) = zip::ZipArchive::new(reader) {
|
||||
match archive.extract(st.directories.version_natives_dir(version).await) {
|
||||
Ok(_) => tracing::info!("Fetched native {}", &library.name),
|
||||
Ok(_) => tracing::debug!("Fetched native {}", &library.name),
|
||||
Err(err) => tracing::error!("Failed extracting native {}. err: {}", &library.name, err)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
};
|
||||
use chrono::Utc;
|
||||
use daedalus as d;
|
||||
use daedalus::minecraft::VersionInfo;
|
||||
use daedalus::minecraft::{RuleAction, VersionInfo};
|
||||
use st::Profile;
|
||||
use std::collections::HashMap;
|
||||
use std::{process::Stdio, sync::Arc};
|
||||
@@ -25,14 +25,48 @@ mod args;
|
||||
pub mod auth;
|
||||
pub mod download;
|
||||
|
||||
// All nones -> disallowed
|
||||
// 1+ true -> allowed
|
||||
// 1+ false -> disallowed
|
||||
#[tracing::instrument]
|
||||
pub fn parse_rule(rule: &d::minecraft::Rule, java_version: &str) -> bool {
|
||||
pub fn parse_rules(
|
||||
rules: &[d::minecraft::Rule],
|
||||
java_version: &str,
|
||||
minecraft_updated: bool,
|
||||
) -> bool {
|
||||
let mut x = rules
|
||||
.iter()
|
||||
.map(|x| parse_rule(x, java_version, minecraft_updated))
|
||||
.collect::<Vec<Option<bool>>>();
|
||||
|
||||
if rules
|
||||
.iter()
|
||||
.all(|x| matches!(x.action, RuleAction::Disallow))
|
||||
{
|
||||
x.push(Some(true))
|
||||
}
|
||||
|
||||
!(x.iter().any(|x| x == &Some(false)) || x.iter().all(|x| x.is_none()))
|
||||
}
|
||||
|
||||
// if anything is disallowed, it should NOT be included
|
||||
// if anything is not disallowed, it shouldn't factor in final result
|
||||
// if anything is not allowed, it shouldn't factor in final result
|
||||
// if anything is allowed, it should be included
|
||||
#[tracing::instrument]
|
||||
pub fn parse_rule(
|
||||
rule: &d::minecraft::Rule,
|
||||
java_version: &str,
|
||||
minecraft_updated: bool,
|
||||
) -> Option<bool> {
|
||||
use d::minecraft::{Rule, RuleAction};
|
||||
|
||||
let res = match rule {
|
||||
Rule {
|
||||
os: Some(ref os), ..
|
||||
} => crate::util::platform::os_rule(os, java_version),
|
||||
} => {
|
||||
crate::util::platform::os_rule(os, java_version, minecraft_updated)
|
||||
}
|
||||
Rule {
|
||||
features: Some(ref features),
|
||||
..
|
||||
@@ -44,12 +78,24 @@ pub fn parse_rule(rule: &d::minecraft::Rule, java_version: &str) -> bool {
|
||||
|| !features.is_quick_play_realms.unwrap_or(true)
|
||||
|| !features.is_quick_play_singleplayer.unwrap_or(true)
|
||||
}
|
||||
_ => false,
|
||||
_ => return Some(true),
|
||||
};
|
||||
|
||||
match rule.action {
|
||||
RuleAction::Allow => res,
|
||||
RuleAction::Disallow => !res,
|
||||
RuleAction::Allow => {
|
||||
if res {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
RuleAction::Disallow => {
|
||||
if res {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +148,9 @@ pub async fn get_java_version_from_profile(
|
||||
pub async fn install_minecraft(
|
||||
profile: &Profile,
|
||||
existing_loading_bar: Option<LoadingBarId>,
|
||||
repairing: bool,
|
||||
) -> crate::Result<()> {
|
||||
let sync_projects = existing_loading_bar.is_some();
|
||||
let loading_bar = init_or_edit_loading(
|
||||
existing_loading_bar,
|
||||
LoadingBarType::MinecraftDownload {
|
||||
@@ -123,20 +171,32 @@ pub async fn install_minecraft(
|
||||
.await?;
|
||||
State::sync().await?;
|
||||
|
||||
if sync_projects {
|
||||
Profile::sync_projects_task(profile.profile_id(), true);
|
||||
}
|
||||
|
||||
let state = State::get().await?;
|
||||
let instance_path =
|
||||
&io::canonicalize(&profile.get_profile_full_path().await?)?;
|
||||
let metadata = state.metadata.read().await;
|
||||
|
||||
let version = metadata
|
||||
let version_index = metadata
|
||||
.minecraft
|
||||
.versions
|
||||
.iter()
|
||||
.find(|it| it.id == profile.metadata.game_version)
|
||||
.position(|it| it.id == profile.metadata.game_version)
|
||||
.ok_or(crate::ErrorKind::LauncherError(format!(
|
||||
"Invalid game version: {}",
|
||||
profile.metadata.game_version
|
||||
)))?;
|
||||
let version = &metadata.minecraft.versions[version_index];
|
||||
let minecraft_updated = version_index
|
||||
<= metadata
|
||||
.minecraft
|
||||
.versions
|
||||
.iter()
|
||||
.position(|x| x.id == "22w16a")
|
||||
.unwrap_or(0);
|
||||
|
||||
let version_jar = profile
|
||||
.metadata
|
||||
@@ -151,7 +211,7 @@ pub async fn install_minecraft(
|
||||
&state,
|
||||
version,
|
||||
profile.metadata.loader_version.as_ref(),
|
||||
None,
|
||||
Some(repairing),
|
||||
Some(&loading_bar),
|
||||
)
|
||||
.await?;
|
||||
@@ -180,6 +240,8 @@ pub async fn install_minecraft(
|
||||
&version_info,
|
||||
&loading_bar,
|
||||
&java_version.architecture,
|
||||
repairing,
|
||||
minecraft_updated,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -307,7 +369,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
|
||||
@@ -320,7 +382,7 @@ pub async fn launch_minecraft(
|
||||
}
|
||||
|
||||
if profile.install_stage != ProfileInstallStage::Installed {
|
||||
install_minecraft(profile, None).await?;
|
||||
install_minecraft(profile, None, false).await?;
|
||||
}
|
||||
|
||||
let state = State::get().await?;
|
||||
@@ -329,15 +391,23 @@ pub async fn launch_minecraft(
|
||||
let instance_path = profile.get_profile_full_path().await?;
|
||||
let instance_path = &io::canonicalize(instance_path)?;
|
||||
|
||||
let version = metadata
|
||||
let version_index = metadata
|
||||
.minecraft
|
||||
.versions
|
||||
.iter()
|
||||
.find(|it| it.id == profile.metadata.game_version)
|
||||
.position(|it| it.id == profile.metadata.game_version)
|
||||
.ok_or(crate::ErrorKind::LauncherError(format!(
|
||||
"Invalid game version: {}",
|
||||
profile.metadata.game_version
|
||||
)))?;
|
||||
let version = &metadata.minecraft.versions[version_index];
|
||||
let minecraft_updated = version_index
|
||||
<= metadata
|
||||
.minecraft
|
||||
.versions
|
||||
.iter()
|
||||
.position(|x| x.id == "22w16a")
|
||||
.unwrap_or(0);
|
||||
|
||||
let version_jar = profile
|
||||
.metadata
|
||||
@@ -401,7 +471,6 @@ pub async fn launch_minecraft(
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
|
||||
command
|
||||
.args(
|
||||
args::get_jvm_arguments(
|
||||
@@ -414,6 +483,7 @@ pub async fn launch_minecraft(
|
||||
version_info.libraries.as_slice(),
|
||||
&client_path,
|
||||
&java_version.architecture,
|
||||
minecraft_updated,
|
||||
)?,
|
||||
&version_jar,
|
||||
*memory,
|
||||
@@ -450,49 +520,38 @@ pub async fn launch_minecraft(
|
||||
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
|
||||
// Uses 'a:b' syntax which is not quite yaml
|
||||
use regex::Regex;
|
||||
|
||||
let options_path = instance_path.join("options.txt");
|
||||
let mut options_string = String::new();
|
||||
|
||||
if options_path.exists() {
|
||||
options_string = io::read_to_string(&options_path).await?;
|
||||
}
|
||||
|
||||
for (key, value) in mc_set_options {
|
||||
let re = Regex::new(&format!(r"(?m)^{}:.*$", regex::escape(key)))?;
|
||||
// check if the regex exists in the file
|
||||
if !re.is_match(&options_string) {
|
||||
// The key was not found in the file, so append it
|
||||
options_string.push_str(&format!("\n{}:{}", key, value));
|
||||
} else {
|
||||
let replaced_string = re
|
||||
.replace_all(&options_string, &format!("{}:{}", key, value))
|
||||
.to_string();
|
||||
options_string = replaced_string;
|
||||
if !mc_set_options.is_empty() {
|
||||
let options_path = instance_path.join("options.txt");
|
||||
let mut options_string = String::new();
|
||||
if options_path.exists() {
|
||||
options_string = io::read_to_string(&options_path).await?;
|
||||
}
|
||||
for (key, value) in mc_set_options {
|
||||
let re = Regex::new(&format!(r"(?m)^{}:.*$", regex::escape(key)))?;
|
||||
// check if the regex exists in the file
|
||||
if !re.is_match(&options_string) {
|
||||
// The key was not found in the file, so append it
|
||||
options_string.push_str(&format!("\n{}:{}", key, value));
|
||||
} else {
|
||||
let replaced_string = re
|
||||
.replace_all(&options_string, &format!("{}:{}", key, value))
|
||||
.to_string();
|
||||
options_string = replaced_string;
|
||||
}
|
||||
}
|
||||
|
||||
io::write(&options_path, options_string).await?;
|
||||
}
|
||||
|
||||
io::write(&options_path, options_string).await?;
|
||||
|
||||
// Get Modrinth logs directories
|
||||
let datetime_string =
|
||||
chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let logs_dir = {
|
||||
let st = State::get().await?;
|
||||
st.directories
|
||||
.profile_logs_dir(&profile.profile_id())
|
||||
.await?
|
||||
.join(&datetime_string)
|
||||
};
|
||||
io::create_dir_all(&logs_dir).await?;
|
||||
|
||||
let stdout_log_path = logs_dir.join("stdout.log");
|
||||
|
||||
crate::api::profile::edit(&profile.profile_id(), |prof| {
|
||||
prof.metadata.last_played = Some(Utc::now());
|
||||
|
||||
@@ -554,10 +613,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,
|
||||
|
||||
@@ -22,4 +22,5 @@ pub use api::*;
|
||||
pub use error::*;
|
||||
pub use event::{EventState, LoadingBar, LoadingBarType};
|
||||
pub use logger::start_logger;
|
||||
pub use state::InnerProjectPathUnix;
|
||||
pub use state::State;
|
||||
|
||||
@@ -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,4 +1,7 @@
|
||||
use crate::launcher::auth::Credentials;
|
||||
use crate::{
|
||||
hydra::{self, init::DeviceLoginSuccess},
|
||||
launcher::auth::Credentials,
|
||||
};
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
@@ -8,7 +11,7 @@ use tokio::task::JoinHandle;
|
||||
|
||||
pub struct AuthTask(
|
||||
#[allow(clippy::type_complexity)]
|
||||
Option<JoinHandle<crate::Result<(Credentials, Option<String>)>>>,
|
||||
Option<JoinHandle<crate::Result<Credentials>>>,
|
||||
);
|
||||
|
||||
impl AuthTask {
|
||||
@@ -16,32 +19,24 @@ impl AuthTask {
|
||||
AuthTask(None)
|
||||
}
|
||||
|
||||
pub async fn begin_auth() -> crate::Result<url::Url> {
|
||||
pub async fn begin_auth() -> crate::Result<DeviceLoginSuccess> {
|
||||
let state = crate::State::get().await?;
|
||||
// Init task, get url
|
||||
let login = hydra::init::init().await?;
|
||||
|
||||
// Creates a channel to receive the URL
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<url::Url>();
|
||||
let task = tokio::spawn(crate::auth::authenticate(tx));
|
||||
|
||||
// If receiver is dropped, try to get Hydra error
|
||||
let url = rx.await;
|
||||
let url = match url {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
task.await??;
|
||||
return Err(e.into()); // truly a dropped receiver
|
||||
}
|
||||
};
|
||||
// Await completion
|
||||
let task = tokio::spawn(hydra::complete::wait_finish(
|
||||
login.device_code.clone(),
|
||||
));
|
||||
|
||||
// Flow is going, store in state and return
|
||||
let mut write = state.auth_flow.write().await;
|
||||
write.0 = Some(task);
|
||||
|
||||
Ok(url)
|
||||
Ok(login)
|
||||
}
|
||||
|
||||
pub async fn await_auth_completion(
|
||||
) -> crate::Result<(Credentials, Option<String>)> {
|
||||
pub async fn await_auth_completion() -> crate::Result<Credentials> {
|
||||
// Gets the task handle from the state, replacing with None
|
||||
let task = {
|
||||
let state = crate::State::get().await?;
|
||||
|
||||
@@ -1,68 +1,292 @@
|
||||
use super::DirectoryInfo;
|
||||
use super::{Profile, ProfilePathId};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitStatus;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use sysinfo::PidExt;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::ChildStderr;
|
||||
use tokio::process::ChildStdout;
|
||||
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::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 output: Option<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 log_path = DirectoryInfo::profile_logs_dir(&profile_relative_path)
|
||||
.await?
|
||||
.join("latest_stdout.log");
|
||||
let shared_output =
|
||||
SharedOutput::build(&log_path, censor_strings).await?;
|
||||
if let Some(child_stdout) = child.stdout.take() {
|
||||
@@ -70,6 +294,12 @@ impl Children {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = stdout_clone.read_stdout(child_stdout).await {
|
||||
error!("Stdout process died with error: {}", e);
|
||||
let _ = stdout_clone
|
||||
.push_line(format!(
|
||||
"Stdout process died with error: {}",
|
||||
e
|
||||
))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -78,22 +308,40 @@ impl Children {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = stderr_clone.read_stderr(child_stderr).await {
|
||||
error!("Stderr process died with error: {}", e);
|
||||
let _ = stderr_clone
|
||||
.push_line(format!(
|
||||
"Stderr process died with error: {}",
|
||||
e
|
||||
))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let child = ChildType::TokioChild(child);
|
||||
|
||||
// Slots child into manager
|
||||
let pid = child.id().ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(
|
||||
"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,
|
||||
post_command,
|
||||
pid,
|
||||
current_child.clone(),
|
||||
profile_relative_path.clone(),
|
||||
)));
|
||||
|
||||
emit_process(
|
||||
@@ -104,13 +352,16 @@ impl Children {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let last_updated_playtime = Utc::now();
|
||||
|
||||
// Create MinecraftChild
|
||||
let mchild = MinecraftChild {
|
||||
uuid,
|
||||
profile_relative_path,
|
||||
output: Some(shared_output),
|
||||
current_child,
|
||||
output: shared_output,
|
||||
manager,
|
||||
last_updated_playtime,
|
||||
};
|
||||
|
||||
let mchild = Arc::new(RwLock::new(mchild));
|
||||
@@ -118,6 +369,97 @@ 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,
|
||||
output: None, // No output for cached/rescued processes
|
||||
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
|
||||
@@ -125,28 +467,79 @@ 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>>,
|
||||
) -> crate::Result<ExitStatus> {
|
||||
current_child: Arc<RwLock<ChildType>>,
|
||||
associated_profile: ProfilePathId,
|
||||
) -> 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()
|
||||
.signed_duration_since(last_updated_playtime)
|
||||
.num_seconds();
|
||||
if diff >= 60 {
|
||||
if let Err(e) = profile::edit(&associated_profile, |prof| {
|
||||
prof.metadata.recent_time_played += diff as u64;
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to update playtime for profile {}: {}",
|
||||
&associated_profile,
|
||||
e
|
||||
);
|
||||
}
|
||||
last_updated_playtime = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Now fully complete- update playtime one last time
|
||||
let diff = Utc::now()
|
||||
.signed_duration_since(last_updated_playtime)
|
||||
.num_seconds();
|
||||
if let Err(e) = profile::edit(&associated_profile, |prof| {
|
||||
prof.metadata.recent_time_played += diff as u64;
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to update playtime for profile {}: {}",
|
||||
&associated_profile,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// 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_clone.clone())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to update playtime for profile {}: {}",
|
||||
&associated_profile_clone,
|
||||
e
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
// Clear game played for Discord RPC
|
||||
// May have other active processes, so we clear to the next running process
|
||||
@@ -163,7 +556,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,
|
||||
@@ -176,9 +574,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(
|
||||
@@ -186,7 +603,7 @@ impl Children {
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
*current_child = new_child;
|
||||
*current_child = ChildType::TokioChild(new_child);
|
||||
}
|
||||
emit_process(
|
||||
uuid,
|
||||
@@ -197,12 +614,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;
|
||||
}
|
||||
@@ -235,18 +647,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)
|
||||
@@ -265,7 +669,7 @@ impl Children {
|
||||
.write()
|
||||
.await
|
||||
.try_wait()
|
||||
.map_err(IOError::from)?
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
keys.push(key);
|
||||
@@ -308,7 +712,7 @@ impl Children {
|
||||
.write()
|
||||
.await
|
||||
.try_wait()
|
||||
.map_err(IOError::from)?
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
profiles.push(child.profile_relative_path.clone());
|
||||
@@ -331,7 +735,7 @@ impl Children {
|
||||
.write()
|
||||
.await
|
||||
.try_wait()
|
||||
.map_err(IOError::from)?
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
if let Some(prof) = crate::api::profile::get(
|
||||
@@ -359,18 +763,28 @@ impl Default for Children {
|
||||
// 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 {
|
||||
#[tracing::instrument(skip(censor_strings))]
|
||||
async fn build(
|
||||
log_file_path: &Path,
|
||||
censor_strings: HashMap<String, String>,
|
||||
) -> crate::Result<Self> {
|
||||
// create log_file_path parent if it doesn't exist
|
||||
let parent_folder = log_file_path.parent().ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Could not get parent folder of {:?}",
|
||||
log_file_path
|
||||
))
|
||||
})?;
|
||||
tokio::fs::create_dir_all(parent_folder)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, parent_folder))?;
|
||||
|
||||
Ok(SharedOutput {
|
||||
output: Arc::new(RwLock::new(String::new())),
|
||||
log_file: Arc::new(RwLock::new(
|
||||
File::create(log_file_path)
|
||||
.await
|
||||
@@ -380,31 +794,21 @@ impl SharedOutput {
|
||||
})
|
||||
}
|
||||
|
||||
// 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();
|
||||
let mut buf = Vec::new();
|
||||
|
||||
while buf_reader
|
||||
.read_line(&mut line)
|
||||
.read_until(b'\n', &mut buf)
|
||||
.await
|
||||
.map_err(IOError::from)?
|
||||
> 0
|
||||
{
|
||||
let line = String::from_utf8_lossy(&buf).into_owned();
|
||||
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
|
||||
@@ -413,7 +817,7 @@ impl SharedOutput {
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
line.clear();
|
||||
buf.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -423,20 +827,17 @@ impl SharedOutput {
|
||||
child_stderr: ChildStderr,
|
||||
) -> crate::Result<()> {
|
||||
let mut buf_reader = BufReader::new(child_stderr);
|
||||
let mut line = String::new();
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// TODO: these can be asbtracted into noe function
|
||||
while buf_reader
|
||||
.read_line(&mut line)
|
||||
.read_until(b'\n', &mut buf)
|
||||
.await
|
||||
.map_err(IOError::from)?
|
||||
> 0
|
||||
{
|
||||
let line = String::from_utf8_lossy(&buf).into_owned();
|
||||
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
|
||||
@@ -445,11 +846,24 @@ impl SharedOutput {
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
line.clear();
|
||||
buf.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn push_line(&self, line: String) -> crate::Result<()> {
|
||||
let val_line = self.censor_log(line.clone());
|
||||
{
|
||||
let mut log_file = self.log_file.write().await;
|
||||
log_file
|
||||
.write_all(val_line.as_bytes())
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn censor_log(&self, mut val: String) -> String {
|
||||
for (find, replace) in &self.censor_strings {
|
||||
val = val.replace(find, replace);
|
||||
|
||||
@@ -159,10 +159,9 @@ impl DirectoryInfo {
|
||||
/// Gets the logs dir for a given profile
|
||||
#[inline]
|
||||
pub async fn profile_logs_dir(
|
||||
&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,13 +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(());
|
||||
}
|
||||
|
||||
@@ -146,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()
|
||||
@@ -160,7 +209,7 @@ impl DiscordGuard {
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
self.clear_activity(reconnect_if_fail).await?;
|
||||
self.set_activity("Idling...", reconnect_if_fail).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct Metadata {
|
||||
pub forge: LoaderManifest,
|
||||
pub fabric: LoaderManifest,
|
||||
pub quilt: LoaderManifest,
|
||||
pub neoforge: LoaderManifest,
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
@@ -26,7 +27,7 @@ impl Metadata {
|
||||
}
|
||||
|
||||
pub async fn fetch() -> crate::Result<Self> {
|
||||
let (minecraft, forge, fabric, quilt) = tokio::try_join! {
|
||||
let (minecraft, forge, fabric, quilt, neoforge) = tokio::try_join! {
|
||||
async {
|
||||
let url = Self::get_manifest("minecraft");
|
||||
fetch_version_manifest(Some(&url)).await
|
||||
@@ -42,6 +43,10 @@ impl Metadata {
|
||||
async {
|
||||
let url = Self::get_manifest("quilt");
|
||||
fetch_loader_manifest(&url).await
|
||||
},
|
||||
async {
|
||||
let url = Self::get_manifest("neo");
|
||||
fetch_loader_manifest(&url).await
|
||||
}
|
||||
}?;
|
||||
|
||||
@@ -50,6 +55,7 @@ impl Metadata {
|
||||
forge,
|
||||
fabric,
|
||||
quilt,
|
||||
neoforge,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,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
|
||||
@@ -79,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>(())
|
||||
}
|
||||
@@ -90,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 {
|
||||
@@ -112,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>> {
|
||||
@@ -156,7 +160,7 @@ impl State {
|
||||
)));
|
||||
emit_loading(&loading_bar, 10.0, None).await?;
|
||||
|
||||
let is_offline = !fetch::check_internet(&fetch_semaphore, 3).await;
|
||||
let is_offline = !fetch::check_internet(3).await;
|
||||
|
||||
let metadata_fut =
|
||||
Metadata::init(&directories, !is_offline, &io_semaphore);
|
||||
@@ -180,11 +184,17 @@ 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
|
||||
// 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();
|
||||
@@ -234,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 {
|
||||
@@ -248,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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -323,7 +329,7 @@ impl State {
|
||||
|
||||
/// Refreshes whether or not the launcher should be offline, by whether or not there is an internet connection
|
||||
pub async fn refresh_offline(&self) -> crate::Result<()> {
|
||||
let is_online = fetch::check_internet(&self.fetch_semaphore, 3).await;
|
||||
let is_online = fetch::check_internet(3).await;
|
||||
|
||||
let mut offline = self.offline.write().await;
|
||||
|
||||
@@ -341,7 +347,7 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
|
||||
let (mut tx, mut rx) = channel(1);
|
||||
|
||||
let file_watcher = new_debouncer(
|
||||
Duration::from_secs_f32(0.25),
|
||||
Duration::from_secs_f32(2.0),
|
||||
None,
|
||||
move |res: DebounceEventResult| {
|
||||
futures::executor::block_on(async {
|
||||
@@ -349,19 +355,17 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
let span = tracing::span!(tracing::Level::INFO, "init_watcher");
|
||||
tracing::info!(parent: &span, "Initting watcher");
|
||||
while let Some(res) = rx.next().await {
|
||||
let _span = span.enter();
|
||||
match res {
|
||||
Ok(mut events) => {
|
||||
let mut visited_paths = Vec::new();
|
||||
// sort events by e.path
|
||||
events.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
events.iter().for_each(|e| {
|
||||
tracing::debug!(
|
||||
"File watcher event: {:?}",
|
||||
serde_json::to_string(&e.path).unwrap()
|
||||
);
|
||||
let mut new_path = PathBuf::new();
|
||||
let mut components_iterator = e.path.components();
|
||||
let mut found = false;
|
||||
@@ -379,7 +383,7 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
|
||||
|
||||
// At this point, new_path is the path to the profile, and subfile is whether it's a subfile of the profile or not
|
||||
let profile_path_id =
|
||||
ProfilePathId::new(&PathBuf::from(
|
||||
ProfilePathId::new(PathBuf::from(
|
||||
new_path.file_name().unwrap_or_default(),
|
||||
));
|
||||
|
||||
@@ -394,7 +398,10 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
|
||||
Profile::crash_task(profile_path_id);
|
||||
} else if !visited_paths.contains(&new_path) {
|
||||
if subfile {
|
||||
Profile::sync_projects_task(profile_path_id);
|
||||
Profile::sync_projects_task(
|
||||
profile_path_id,
|
||||
false,
|
||||
);
|
||||
visited_paths.push(new_path);
|
||||
} else {
|
||||
Profiles::sync_available_profiles_task(
|
||||
|
||||
@@ -222,7 +222,7 @@ pub async fn login_password(
|
||||
) -> crate::Result<ModrinthCredentialsResult> {
|
||||
let resp = fetch_advanced(
|
||||
Method::POST,
|
||||
&format!("https://{MODRINTH_API_URL}auth/login"),
|
||||
&format!("{MODRINTH_API_URL}auth/login"),
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"username": username,
|
||||
@@ -322,29 +322,6 @@ pub async fn create_account(
|
||||
get_creds_from_res(response, semaphore).await
|
||||
}
|
||||
|
||||
pub async fn login_minecraft(
|
||||
flow: &str,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<ModrinthCredentialsResult> {
|
||||
let resp = fetch_advanced(
|
||||
Method::POST,
|
||||
&format!("{MODRINTH_API_URL}auth/login/minecraft"),
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"flow": flow,
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
semaphore,
|
||||
&CredentialsStore(None),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response = serde_json::from_slice::<HashMap<String, Value>>(&resp)?;
|
||||
|
||||
get_result_from_res("session", response, semaphore).await
|
||||
}
|
||||
|
||||
pub async fn refresh_credentials(
|
||||
credentials_store: &mut CredentialsStore,
|
||||
semaphore: &FetchSemaphore,
|
||||
|
||||
@@ -72,8 +72,8 @@ impl ProfilePathId {
|
||||
}
|
||||
|
||||
// Create a new ProfilePathId from a relative path
|
||||
pub fn new(path: &Path) -> Self {
|
||||
ProfilePathId(PathBuf::from(path))
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
ProfilePathId(path.into())
|
||||
}
|
||||
|
||||
pub async fn get_full_path(&self) -> crate::Result<PathBuf> {
|
||||
@@ -95,6 +95,45 @@ impl std::fmt::Display for ProfilePathId {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
|
||||
#[serde(into = "RawProjectPath", from = "RawProjectPath")]
|
||||
pub struct InnerProjectPathUnix(pub String);
|
||||
|
||||
impl InnerProjectPathUnix {
|
||||
pub fn get_topmost_two_components(&self) -> String {
|
||||
self.to_string()
|
||||
.split('/')
|
||||
.take(2)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InnerProjectPathUnix {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RawProjectPath> for InnerProjectPathUnix {
|
||||
fn from(value: RawProjectPath) -> Self {
|
||||
// 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
|
||||
InnerProjectPathUnix(value.0.replace('\\', "/"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
struct RawProjectPath(pub String);
|
||||
|
||||
impl From<InnerProjectPathUnix> for RawProjectPath {
|
||||
fn from(value: InnerProjectPathUnix) -> Self {
|
||||
RawProjectPath(value.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// newtype wrapper over a Profile path, to be usable as a clear identifier for the kind of path used
|
||||
/// eg: for "a/b/c/profiles/My Mod/mods/myproj", the ProjectPathId would be "mods/myproj"
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
|
||||
@@ -102,11 +141,14 @@ impl std::fmt::Display for ProfilePathId {
|
||||
pub struct ProjectPathId(pub PathBuf);
|
||||
impl ProjectPathId {
|
||||
// Create a new ProjectPathId from a full file path
|
||||
pub async fn from_fs_path(path: PathBuf) -> crate::Result<Self> {
|
||||
let path: PathBuf = io::canonicalize(path)?;
|
||||
let profiles_dir: PathBuf = io::canonicalize(
|
||||
pub async fn from_fs_path(path: &PathBuf) -> crate::Result<Self> {
|
||||
// This is avoiding dunce::canonicalize deliberately. On Windows, paths will always be convert to UNC,
|
||||
// but this is ok because we are stripping that with the prefix. Using std::fs avoids different behaviors with dunce that
|
||||
// come with too-long paths
|
||||
let profiles_dir: PathBuf = std::fs::canonicalize(
|
||||
State::get().await?.directories.profiles_dir().await,
|
||||
)?;
|
||||
let path: PathBuf = std::fs::canonicalize(path)?;
|
||||
let path = path
|
||||
.strip_prefix(profiles_dir)
|
||||
.ok()
|
||||
@@ -124,11 +166,23 @@ 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) -> InnerProjectPathUnix {
|
||||
InnerProjectPathUnix(
|
||||
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))
|
||||
@@ -183,12 +237,25 @@ pub struct ProfileMetadata {
|
||||
pub date_modified: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_played: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub submitted_time_played: u64,
|
||||
#[serde(default)]
|
||||
pub recent_time_played: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
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(
|
||||
@@ -201,6 +268,7 @@ pub enum ModLoader {
|
||||
Forge,
|
||||
Fabric,
|
||||
Quilt,
|
||||
NeoForge,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModLoader {
|
||||
@@ -210,6 +278,7 @@ impl std::fmt::Display for ModLoader {
|
||||
Self::Forge => "Forge",
|
||||
Self::Fabric => "Fabric",
|
||||
Self::Quilt => "Quilt",
|
||||
Self::NeoForge => "NeoForge",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -221,6 +290,7 @@ impl ModLoader {
|
||||
Self::Forge => "forge",
|
||||
Self::Fabric => "fabric",
|
||||
Self::Quilt => "quilt",
|
||||
Self::NeoForge => "neoforge",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,6 +335,8 @@ impl Profile {
|
||||
date_created: Utc::now(),
|
||||
date_modified: Utc::now(),
|
||||
last_played: None,
|
||||
submitted_time_played: 0,
|
||||
recent_time_played: 0,
|
||||
},
|
||||
projects: HashMap::new(),
|
||||
java: None,
|
||||
@@ -324,47 +396,48 @@ impl Profile {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn sync_projects_task(profile_path_id: ProfilePathId) {
|
||||
pub fn sync_projects_task(profile_path_id: ProfilePathId, force: bool) {
|
||||
let span = tracing::span!(
|
||||
tracing::Level::INFO,
|
||||
"sync_projects_task",
|
||||
?profile_path_id,
|
||||
?force
|
||||
);
|
||||
tokio::task::spawn(async move {
|
||||
let span =
|
||||
tracing::span!(tracing::Level::INFO, "sync_projects_task");
|
||||
tracing::debug!(
|
||||
parent: &span,
|
||||
"Syncing projects for profile {}",
|
||||
profile_path_id
|
||||
);
|
||||
let res = async {
|
||||
let _span = span.enter();
|
||||
let state = State::get().await?;
|
||||
let profile = crate::api::profile::get(&profile_path_id, None).await?;
|
||||
|
||||
if let Some(profile) = profile {
|
||||
let paths = profile.get_profile_full_project_paths().await?;
|
||||
if profile.install_stage != ProfileInstallStage::PackInstalling || force {
|
||||
let paths = profile.get_profile_full_project_paths().await?;
|
||||
|
||||
let caches_dir = state.directories.caches_dir();
|
||||
let creds = state.credentials.read().await;
|
||||
let projects = crate::state::infer_data_from_files(
|
||||
profile.clone(),
|
||||
paths,
|
||||
caches_dir,
|
||||
&state.io_semaphore,
|
||||
&state.fetch_semaphore,
|
||||
&creds,
|
||||
)
|
||||
.await?;
|
||||
drop(creds);
|
||||
let caches_dir = state.directories.caches_dir();
|
||||
let creds = state.credentials.read().await;
|
||||
let projects = crate::state::infer_data_from_files(
|
||||
profile.clone(),
|
||||
paths,
|
||||
caches_dir,
|
||||
&state.io_semaphore,
|
||||
&state.fetch_semaphore,
|
||||
&creds,
|
||||
)
|
||||
.await?;
|
||||
drop(creds);
|
||||
|
||||
let mut new_profiles = state.profiles.write().await;
|
||||
if let Some(profile) = new_profiles.0.get_mut(&profile_path_id) {
|
||||
profile.projects = projects;
|
||||
let mut new_profiles = state.profiles.write().await;
|
||||
if let Some(profile) = new_profiles.0.get_mut(&profile_path_id) {
|
||||
profile.projects = projects;
|
||||
}
|
||||
emit_profile(
|
||||
profile.uuid,
|
||||
&profile_path_id,
|
||||
&profile.metadata.name,
|
||||
ProfilePayloadType::Synced,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
emit_profile(
|
||||
profile.uuid,
|
||||
&profile_path_id,
|
||||
&profile.metadata.name,
|
||||
ProfilePayloadType::Synced,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Unable to fetch single profile projects: path {profile_path_id} invalid",
|
||||
@@ -712,7 +785,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);
|
||||
@@ -811,8 +892,6 @@ impl Profiles {
|
||||
// Fetch online from Modrinth each latest version
|
||||
future::try_join_all(modrinth_updatables.into_iter().map(
|
||||
|(profile_path, linked_project)| {
|
||||
let profile_path = profile_path;
|
||||
let linked_project = linked_project;
|
||||
let state = state.clone();
|
||||
async move {
|
||||
let creds = state.credentials.read().await;
|
||||
@@ -832,12 +911,17 @@ impl Profiles {
|
||||
drop(creds);
|
||||
|
||||
// Versions are pre-sorted in labrinth (by versions.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published));)
|
||||
// so we can just take the first one
|
||||
// so we can just take the first one for which the loader matches
|
||||
let mut new_profiles = state.profiles.write().await;
|
||||
if let Some(profile) =
|
||||
new_profiles.0.get_mut(&profile_path)
|
||||
{
|
||||
if let Some(recent_version) = versions.get(0) {
|
||||
let loader = profile.metadata.loader;
|
||||
let recent_version = versions.iter().find(|x| {
|
||||
x.loaders
|
||||
.contains(&loader.as_api_str().to_string())
|
||||
});
|
||||
if let Some(recent_version) = recent_version {
|
||||
profile.modrinth_update_version =
|
||||
Some(recent_version.id.clone());
|
||||
} else {
|
||||
@@ -871,7 +955,11 @@ impl Profiles {
|
||||
|
||||
#[tracing::instrument(skip(self, profile))]
|
||||
#[theseus_macros::debug_pin]
|
||||
pub async fn insert(&mut self, profile: Profile) -> crate::Result<&Self> {
|
||||
pub async fn insert(
|
||||
&mut self,
|
||||
profile: Profile,
|
||||
no_watch: bool,
|
||||
) -> crate::Result<&Self> {
|
||||
emit_profile(
|
||||
profile.uuid,
|
||||
&profile.profile_id(),
|
||||
@@ -880,13 +968,15 @@ impl Profiles {
|
||||
)
|
||||
.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?;
|
||||
if !no_watch {
|
||||
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?;
|
||||
}
|
||||
|
||||
let profile_name = profile.profile_id();
|
||||
profile_name.check_valid_utf()?;
|
||||
@@ -978,9 +1068,10 @@ impl Profiles {
|
||||
dirs,
|
||||
)
|
||||
.await?,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
Profile::sync_projects_task(profile_path_id);
|
||||
Profile::sync_projects_task(profile_path_id, false);
|
||||
}
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
|
||||
@@ -281,7 +281,6 @@ pub async fn infer_data_from_files(
|
||||
) -> crate::Result<HashMap<ProjectPathId, Project>> {
|
||||
let mut file_path_hashes = HashMap::new();
|
||||
|
||||
// TODO: Make this concurrent and use progressive hashing to avoid loading each JAR in memory
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
@@ -297,10 +296,19 @@ pub async fn infer_data_from_files(
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &path))?;
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
file.read_to_end(&mut buffer).await.map_err(IOError::from)?;
|
||||
let mut buffer = [0u8; 4096]; // Buffer to read chunks
|
||||
let mut hasher = sha2::Sha512::new(); // Hasher
|
||||
|
||||
let hash = format!("{:x}", sha2::Sha512::digest(&buffer));
|
||||
loop {
|
||||
let bytes_read =
|
||||
file.read(&mut buffer).await.map_err(IOError::from)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
file_path_hashes.insert(hash, path.clone());
|
||||
}
|
||||
|
||||
@@ -807,7 +815,7 @@ pub async fn infer_data_from_files(
|
||||
let mut corrected_hashmap = HashMap::new();
|
||||
let mut stream = tokio_stream::iter(return_projects);
|
||||
while let Some((h, v)) = stream.next().await {
|
||||
let h = ProjectPathId::from_fs_path(h).await?;
|
||||
let h = ProjectPathId::from_fs_path(&h).await?;
|
||||
corrected_hashmap.insert(h, v);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,12 @@ 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 native_decorations: bool,
|
||||
#[serde(default)]
|
||||
pub default_page: DefaultPage,
|
||||
#[serde(default)]
|
||||
pub developer_mode: bool,
|
||||
@@ -49,8 +53,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 +67,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 +99,22 @@ impl Settings {
|
||||
max_concurrent_writes: 10,
|
||||
version: CURRENT_FORMAT_VERSION,
|
||||
collapsed_navigation: false,
|
||||
disable_discord_rpc: false,
|
||||
hide_on_process: false,
|
||||
native_decorations: 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 +152,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,
|
||||
|
||||
@@ -20,7 +20,7 @@ pub struct IoSemaphore(pub RwLock<Semaphore>);
|
||||
pub struct FetchSemaphore(pub RwLock<Semaphore>);
|
||||
|
||||
lazy_static! {
|
||||
static ref REQWEST_CLIENT: reqwest::Client = {
|
||||
pub static ref REQWEST_CLIENT: reqwest::Client = {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
let header = reqwest::header::HeaderValue::from_str(&format!(
|
||||
"modrinth/theseus/{} (support@modrinth.com)",
|
||||
@@ -213,18 +213,41 @@ pub async fn fetch_mirrors(
|
||||
}
|
||||
|
||||
/// Using labrinth API, checks if an internet response can be found, with a timeout in seconds
|
||||
#[tracing::instrument(skip(semaphore))]
|
||||
#[tracing::instrument]
|
||||
#[theseus_macros::debug_pin]
|
||||
pub async fn check_internet(semaphore: &FetchSemaphore, timeout: u64) -> bool {
|
||||
let result = fetch(
|
||||
"https://api.modrinth.com",
|
||||
None,
|
||||
semaphore,
|
||||
&CredentialsStore(None),
|
||||
);
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(timeout), result).await;
|
||||
matches!(result, Ok(Ok(_)))
|
||||
pub async fn check_internet(timeout: u64) -> bool {
|
||||
REQWEST_CLIENT
|
||||
.get("https://launcher-files.modrinth.com/detect.txt")
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.send()
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Posts a JSON to a URL
|
||||
#[tracing::instrument(skip(json_body, semaphore))]
|
||||
#[theseus_macros::debug_pin]
|
||||
pub async fn post_json<T>(
|
||||
url: &str,
|
||||
json_body: serde_json::Value,
|
||||
semaphore: &FetchSemaphore,
|
||||
credentials: &CredentialsStore,
|
||||
) -> crate::Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let io_semaphore = semaphore.0.read().await;
|
||||
let _permit = io_semaphore.acquire().await?;
|
||||
|
||||
let mut req = REQWEST_CLIENT.post(url).json(&json_body);
|
||||
if let Some(creds) = &credentials.0 {
|
||||
req = req.header("Authorization", &creds.session);
|
||||
}
|
||||
|
||||
let result = req.send().await?.error_for_status()?;
|
||||
|
||||
let value = result.json().await?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub async fn read_json<T>(
|
||||
|
||||
@@ -43,7 +43,9 @@ pub async fn get_all_jre() -> Result<Vec<JavaVersion>, JREError> {
|
||||
r"C:\Program Files (x86)\Eclipse Adoptium",
|
||||
];
|
||||
for java_path in java_paths {
|
||||
let Ok(java_subpaths) = std::fs::read_dir(java_path) else {continue };
|
||||
let Ok(java_subpaths) = std::fs::read_dir(java_path) else {
|
||||
continue;
|
||||
};
|
||||
for java_subpath in java_subpaths.flatten() {
|
||||
let path = java_subpath.path();
|
||||
jre_paths.insert(path.join("bin"));
|
||||
@@ -97,7 +99,7 @@ pub fn get_paths_from_jre_winregkey(jre_key: RegKey) -> HashSet<PathBuf> {
|
||||
for subkey_value in subkey_value_names {
|
||||
let path: Result<String, std::io::Error> =
|
||||
subkey.get_value(subkey_value);
|
||||
let Ok(path) = path else {continue};
|
||||
let Ok(path) = path else { continue };
|
||||
|
||||
jre_paths.insert(PathBuf::from(path).join("bin"));
|
||||
}
|
||||
@@ -264,7 +266,9 @@ pub async fn check_java_at_filepaths(
|
||||
pub async fn check_java_at_filepath(path: &Path) -> Option<JavaVersion> {
|
||||
// Attempt to canonicalize the potential java filepath
|
||||
// If it fails, this path does not exist and None is returned (no Java here)
|
||||
let Ok(path) = io::canonicalize(path) else { return None };
|
||||
let Ok(path) = io::canonicalize(path) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Checks for existence of Java at this filepath
|
||||
// Adds JAVA_BIN to the end of the path if it is not already there
|
||||
|
||||
@@ -56,7 +56,12 @@ pub const ARCH_WIDTH: &str = "64";
|
||||
pub const ARCH_WIDTH: &str = "32";
|
||||
|
||||
// Platform rule handling
|
||||
pub fn os_rule(rule: &OsRule, java_arch: &str) -> bool {
|
||||
pub fn os_rule(
|
||||
rule: &OsRule,
|
||||
java_arch: &str,
|
||||
// Minecraft updated over 1.18.2 (supports MacOS Natively)
|
||||
minecraft_updated: bool,
|
||||
) -> bool {
|
||||
let mut rule_match = true;
|
||||
|
||||
if let Some(ref arch) = rule.arch {
|
||||
@@ -64,8 +69,14 @@ pub fn os_rule(rule: &OsRule, java_arch: &str) -> bool {
|
||||
}
|
||||
|
||||
if let Some(name) = &rule.name {
|
||||
rule_match &=
|
||||
&Os::native() == name || &Os::native_arch(java_arch) == name;
|
||||
if minecraft_updated
|
||||
&& (name != &Os::LinuxArm64 || name != &Os::LinuxArm32)
|
||||
{
|
||||
rule_match &=
|
||||
&Os::native() == name || &Os::native_arch(java_arch) == name;
|
||||
} else {
|
||||
rule_match &= &Os::native_arch(java_arch) == name;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(version) = &rule.version {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus_cli"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@ impl ProfileInit {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -363,6 +364,8 @@ fn modloader_from_str(it: &str) -> core::result::Result<ModLoader, String> {
|
||||
"vanilla" => Ok(ModLoader::Vanilla),
|
||||
"forge" => Ok(ModLoader::Forge),
|
||||
"fabric" => Ok(ModLoader::Fabric),
|
||||
"quilt" => Ok(ModLoader::Quilt),
|
||||
"neoforge" => Ok(ModLoader::NeoForge),
|
||||
_ => Err(String::from("Invalid modloader: {it}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ use eyre::Result;
|
||||
use paris::*;
|
||||
use tabled::Tabled;
|
||||
use theseus::prelude::*;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
#[argh(subcommand, name = "user")]
|
||||
@@ -41,18 +40,23 @@ impl UserAdd {
|
||||
info!("Adding new user account to Theseus");
|
||||
info!("A browser window will now open, follow the login flow there.");
|
||||
|
||||
let (tx, rx) = oneshot::channel::<url::Url>();
|
||||
let flow = tokio::spawn(auth::authenticate(tx));
|
||||
let login = auth::authenticate_begin_flow().await?;
|
||||
let flow = tokio::spawn(auth::authenticate_await_complete_flow());
|
||||
|
||||
info!("Opening browser window at {}", login.verification_uri);
|
||||
info!("Your code is {}", login.user_code);
|
||||
|
||||
let url = rx.await?;
|
||||
match self.browser {
|
||||
Some(browser) => webbrowser::open_browser(browser, url.as_str()),
|
||||
None => webbrowser::open(url.as_str()),
|
||||
Some(browser) => webbrowser::open_browser(
|
||||
browser,
|
||||
login.verification_uri.as_str(),
|
||||
),
|
||||
None => webbrowser::open(login.verification_uri.as_str()),
|
||||
}?;
|
||||
|
||||
let credentials = flow.await??;
|
||||
State::sync().await?;
|
||||
success!("Logged in user {}.", credentials.0.username);
|
||||
success!("Logged in user {}.", credentials.username);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "theseus_gui",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -18,14 +18,15 @@
|
||||
"floating-vue": "^2.0.0-beta.20",
|
||||
"mixpanel-browser": "^2.47.0",
|
||||
"ofetch": "^1.0.1",
|
||||
"omorphia": "^0.4.34",
|
||||
"omorphia": "^0.4.38",
|
||||
"pinia": "^2.1.3",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"tauri-plugin-window-state-api": "github:tauri-apps/tauri-plugin-window-state#v1",
|
||||
"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",
|
||||
|
||||
34
theseus_gui/pnpm-lock.yaml
generated
34
theseus_gui/pnpm-lock.yaml
generated
@@ -21,8 +21,8 @@ dependencies:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
omorphia:
|
||||
specifier: ^0.4.34
|
||||
version: 0.4.34
|
||||
specifier: ^0.4.38
|
||||
version: 0.4.38
|
||||
pinia:
|
||||
specifier: ^2.1.3
|
||||
version: 2.1.3(vue@3.3.4)
|
||||
@@ -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
|
||||
@@ -1348,8 +1355,8 @@ packages:
|
||||
ufo: 1.1.2
|
||||
dev: false
|
||||
|
||||
/omorphia@0.4.34:
|
||||
resolution: {integrity: sha512-6uAH1kgzbYYmJDM41Vy4/MhzT9kRj+s1t8IknHKeOQqmVft+wPtv/pbA7pqTMfCzBOarLKKO5s4sNlz8TeMmaQ==}
|
||||
/omorphia@0.4.38:
|
||||
resolution: {integrity: sha512-V0vEarmAart6Gf5WuPUZ58TuIiQf7rI5HJpmYU7FVbtdvZ3q08VqyKZflCddbeBSFQ4/N+A+sNr/ELf/jz+Cug==}
|
||||
dependencies:
|
||||
dayjs: 1.11.7
|
||||
floating-vue: 2.0.0-beta.20(vue@3.3.4)
|
||||
@@ -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>
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
@@ -57,6 +57,6 @@ objc = "0.2.7"
|
||||
# by default Tauri runs in production mode
|
||||
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||
default = ["custom-protocol"]
|
||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||
# this feature is used for production builds where `devPath` points to the filesystem
|
||||
# DO NOT remove this
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::api::Result;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use theseus::prelude::*;
|
||||
use theseus::{hydra::init::DeviceLoginSuccess, prelude::*};
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("auth")
|
||||
@@ -20,7 +20,7 @@ pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||
/// Authenticate a user with Hydra - part 1
|
||||
/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
|
||||
#[tauri::command]
|
||||
pub async fn auth_authenticate_begin_flow() -> Result<url::Url> {
|
||||
pub async fn auth_authenticate_begin_flow() -> Result<DeviceLoginSuccess> {
|
||||
Ok(auth::authenticate_begin_flow().await?)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,7 @@ pub async fn auth_authenticate_begin_flow() -> Result<url::Url> {
|
||||
/// This completes the authentication flow quasi-synchronously, returning the sign-in credentials
|
||||
/// (and also adding the credentials to the state)
|
||||
#[tauri::command]
|
||||
pub async fn auth_authenticate_await_completion(
|
||||
) -> Result<(Credentials, Option<String>)> {
|
||||
pub async fn auth_authenticate_await_completion() -> Result<Credentials> {
|
||||
Ok(auth::authenticate_await_complete_flow().await?)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,17 @@ 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,
|
||||
logs_get_std_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 +39,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 +65,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 +74,29 @@ 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?)
|
||||
}
|
||||
|
||||
/// Get live stdout log from a cursor
|
||||
#[tauri::command]
|
||||
pub async fn logs_get_std_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
cursor: u64, // 0 to start at beginning of file
|
||||
) -> Result<LatestLogCursor> {
|
||||
Ok(logs::get_std_log_cursor(profile_path, cursor).await?)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
metadata_get_fabric_versions,
|
||||
metadata_get_forge_versions,
|
||||
metadata_get_quilt_versions,
|
||||
metadata_get_neoforge_versions,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -36,3 +37,9 @@ pub async fn metadata_get_forge_versions() -> Result<Manifest> {
|
||||
pub async fn metadata_get_quilt_versions() -> Result<Manifest> {
|
||||
Ok(theseus::metadata::get_quilt_versions().await?)
|
||||
}
|
||||
|
||||
/// Gets the quilt versions from daedalus
|
||||
#[tauri::command]
|
||||
pub async fn metadata_get_neoforge_versions() -> Result<Manifest> {
|
||||
Ok(theseus::metadata::get_neoforge_versions().await?)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||
cancel_flow,
|
||||
login_pass,
|
||||
login_2fa,
|
||||
login_minecraft,
|
||||
create_account,
|
||||
refresh,
|
||||
logout,
|
||||
@@ -49,11 +48,6 @@ pub async fn login_2fa(code: &str, flow: &str) -> Result<ModrinthCredentials> {
|
||||
Ok(theseus::mr_auth::login_2fa(code, flow).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login_minecraft(flow: &str) -> Result<ModrinthCredentialsResult> {
|
||||
Ok(theseus::mr_auth::login_minecraft(flow).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_account(
|
||||
username: &str,
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -3,7 +3,7 @@ use daedalus::modded::LoaderVersion;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use theseus::prelude::*;
|
||||
use theseus::{prelude::*, InnerProjectPathUnix};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
@@ -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,9 +23,8 @@ 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_is_managed_modrinth,
|
||||
profile_run,
|
||||
profile_run_wait,
|
||||
profile_run_credentials,
|
||||
@@ -32,7 +32,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
profile_edit,
|
||||
profile_edit_icon,
|
||||
profile_export_mrpack,
|
||||
profile_get_potential_override_folders,
|
||||
profile_get_pack_export_candidates,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -64,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(
|
||||
@@ -106,8 +117,8 @@ pub async fn profile_check_installed(
|
||||
/// Installs/Repairs a profile
|
||||
/// invoke('plugin:profile|profile_install')
|
||||
#[tauri::command]
|
||||
pub async fn profile_install(path: ProfilePathId) -> Result<()> {
|
||||
profile::install(&path).await?;
|
||||
pub async fn profile_install(path: ProfilePathId, force: bool) -> Result<()> {
|
||||
profile::install(&path, force).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -174,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
|
||||
@@ -190,12 +205,6 @@ pub async fn profile_repair_managed_modrinth(
|
||||
Ok(profile::update::repair_managed_modrinth(&path).await?)
|
||||
}
|
||||
|
||||
// Gets if a profile is managed by Modrinth
|
||||
#[tauri::command]
|
||||
pub async fn profile_is_managed_modrinth(path: ProfilePathId) -> Result<bool> {
|
||||
Ok(profile::is_managed_modrinth_pack(&path).await?)
|
||||
}
|
||||
|
||||
// Exports a profile to a .mrpack file (export_location should end in .mrpack)
|
||||
// invoke('profile_export_mrpack')
|
||||
#[tauri::command]
|
||||
@@ -204,31 +213,28 @@ 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(())
|
||||
}
|
||||
|
||||
// Given a folder path, populate a Vec of all the subfolders
|
||||
// Intended to be used for finding potential override folders
|
||||
// profile
|
||||
// -- folder1
|
||||
// -- folder2
|
||||
// -- file1
|
||||
// => [folder1, folder2]
|
||||
/// See [`profile::get_pack_export_candidates`]
|
||||
#[tauri::command]
|
||||
pub async fn profile_get_potential_override_folders(
|
||||
pub async fn profile_get_pack_export_candidates(
|
||||
profile_path: ProfilePathId,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
let overrides =
|
||||
profile::get_potential_override_folders(profile_path).await?;
|
||||
Ok(overrides)
|
||||
) -> Result<Vec<InnerProjectPathUnix>> {
|
||||
let candidates = profile::get_pack_export_candidates(&profile_path).await?;
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
// Run minecraft using a profile using the default credentials
|
||||
|
||||
@@ -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
|
||||
@@ -17,6 +20,7 @@ pub async fn profile_create(
|
||||
modloader: ModLoader, // the modloader to use
|
||||
loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
|
||||
icon: Option<PathBuf>, // the icon for the profile
|
||||
no_watch: Option<bool>,
|
||||
) -> Result<ProfilePathId> {
|
||||
let res = profile::create::profile_create(
|
||||
name,
|
||||
@@ -27,7 +31,16 @@ pub async fn profile_create(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
no_watch,
|
||||
)
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -142,7 +145,7 @@ fn main() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
initialize_state,
|
||||
is_dev,
|
||||
toggle_decorations
|
||||
toggle_decorations,
|
||||
]);
|
||||
|
||||
builder
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Modrinth App",
|
||||
"version": "0.4.0"
|
||||
"version": "0.6.1"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"identifier": "com.modrinth.theseus",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"entitlements": "App.entitlements",
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
@@ -83,7 +83,7 @@
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src https://modrinth.com https://*.modrinth.com https://mixpanel.com https://*.mixpanel.com; font-src https://cdn-raw.modrinth.com/fonts/inter/; img-src tauri: https: data: blob: 'unsafe-inline' asset: https://asset.localhost"
|
||||
"csp": "default-src 'self'; connect-src https://modrinth.com https://*.modrinth.com https://mixpanel.com https://*.mixpanel.com https://*.cloudflare.com https://api.mclo.gs; font-src https://cdn-raw.modrinth.com/fonts/inter/; img-src tauri: https: data: blob: 'unsafe-inline' asset: https://asset.localhost; script-src https://*.cloudflare.com 'self'; frame-src https://*.cloudflare.com https://www.youtube.com 'self'; style-src unsafe-inline 'self'"
|
||||
},
|
||||
"updater": {
|
||||
"active": true,
|
||||
@@ -96,11 +96,11 @@
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"fullscreen": false,
|
||||
"height": 650,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"title": "Modrinth App",
|
||||
"width": 1280,
|
||||
"minHeight": 630,
|
||||
"minHeight": 700,
|
||||
"minWidth": 1100,
|
||||
"visible": false,
|
||||
"decorations": false
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"fullscreen": false,
|
||||
"height": 650,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"title": "Modrinth App",
|
||||
"width": 1280,
|
||||
"minHeight": 630,
|
||||
"minHeight": 700,
|
||||
"minWidth": 1100,
|
||||
"visible": false,
|
||||
"decorations": true
|
||||
|
||||
@@ -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)
|
||||
@@ -48,21 +51,34 @@ const isLoading = ref(true)
|
||||
const videoPlaying = ref(false)
|
||||
const offline = ref(false)
|
||||
const showOnboarding = ref(false)
|
||||
const nativeDecorations = 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()
|
||||
const {
|
||||
native_decorations,
|
||||
theme,
|
||||
opt_out_analytics,
|
||||
collapsed_navigation,
|
||||
advanced_rendering,
|
||||
fully_onboarded,
|
||||
} = await get()
|
||||
// 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
|
||||
|
||||
nativeDecorations.value = native_decorations
|
||||
if (os.value !== 'MacOS') appWindow.setDecorations(native_decorations)
|
||||
|
||||
themeStore.setThemeState(theme)
|
||||
themeStore.collapsedNavigation = collapsed_navigation
|
||||
themeStore.advancedRendering = advanced_rendering
|
||||
@@ -98,6 +114,11 @@ defineExpose({
|
||||
onboardingVideo.value.play()
|
||||
}
|
||||
},
|
||||
failure: async (e) => {
|
||||
isLoading.value = false
|
||||
failureText.value = e
|
||||
os.value = await getOS()
|
||||
},
|
||||
})
|
||||
|
||||
const confirmClose = async () => {
|
||||
@@ -112,6 +133,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 +154,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()
|
||||
})
|
||||
@@ -177,11 +212,35 @@ document.querySelector('body').addEventListener('click', function (e) {
|
||||
}
|
||||
})
|
||||
|
||||
document.querySelector('body').addEventListener('auxclick', function (e) {
|
||||
// disables middle click -> new tab
|
||||
if (e.button === 1) {
|
||||
e.preventDefault()
|
||||
// instead do a left click
|
||||
const event = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
e.target.dispatchEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
@@ -195,6 +254,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">
|
||||
@@ -204,10 +303,11 @@ command_listener((e) => {
|
||||
<AccountsCard ref="accounts" mode="small" />
|
||||
</suspense>
|
||||
<div class="pages-list">
|
||||
<RouterLink to="/" class="btn icon-only collapsed-button">
|
||||
<RouterLink v-tooltip="'Home'" to="/" class="btn icon-only collapsed-button">
|
||||
<HomeIcon />
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-tooltip="'Browse'"
|
||||
to="/browse/modpack"
|
||||
class="btn icon-only collapsed-button"
|
||||
:class="{
|
||||
@@ -216,7 +316,7 @@ command_listener((e) => {
|
||||
>
|
||||
<SearchIcon />
|
||||
</RouterLink>
|
||||
<RouterLink to="/library" class="btn icon-only collapsed-button">
|
||||
<RouterLink v-tooltip="'Library'" to="/library" class="btn icon-only collapsed-button">
|
||||
<LibraryIcon />
|
||||
</RouterLink>
|
||||
<Suspense>
|
||||
@@ -226,6 +326,7 @@ command_listener((e) => {
|
||||
</div>
|
||||
<div class="settings pages-list">
|
||||
<Button
|
||||
v-tooltip="'Create profile'"
|
||||
class="sleek-primary collapsed-button"
|
||||
icon-only
|
||||
:disabled="offline"
|
||||
@@ -233,7 +334,7 @@ command_listener((e) => {
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<RouterLink to="/settings" class="btn icon-only collapsed-button">
|
||||
<RouterLink v-tooltip="'Settings'" to="/settings" class="btn icon-only collapsed-button">
|
||||
<SettingsIcon />
|
||||
</RouterLink>
|
||||
</div>
|
||||
@@ -246,11 +347,11 @@ command_listener((e) => {
|
||||
</section>
|
||||
<section class="mod-stats">
|
||||
<Suspense>
|
||||
<RunningAppBar data-tauri-drag-region />
|
||||
<RunningAppBar />
|
||||
</Suspense>
|
||||
</section>
|
||||
</div>
|
||||
<section class="window-controls">
|
||||
<section v-if="!nativeDecorations" class="window-controls">
|
||||
<Button class="titlebar-button" icon-only @click="() => appWindow.minimize()">
|
||||
<MinimizeIcon />
|
||||
</Button>
|
||||
@@ -276,7 +377,7 @@ command_listener((e) => {
|
||||
offset-height="var(--appbar-height)"
|
||||
offset-width="var(--sidebar-width)"
|
||||
/>
|
||||
<RouterView v-slot="{ Component }" class="main-view">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<template v-if="Component">
|
||||
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()">
|
||||
<component :is="Component"></component>
|
||||
@@ -350,13 +451,13 @@ command_listener((e) => {
|
||||
|
||||
.view {
|
||||
width: calc(100% - var(--sidebar-width));
|
||||
background-color: var(--color-raised-bg);
|
||||
|
||||
.appbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
background: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
|
||||
text-align: center;
|
||||
padding: var(--gap-md);
|
||||
height: 3.25rem;
|
||||
@@ -372,6 +473,54 @@ command_listener((e) => {
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--color-bg);
|
||||
border-top-left-radius: var(--radius-xl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,4 +654,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>
|
||||
|
||||
25
theseus_gui/src/assets/external/google.svg
vendored
25
theseus_gui/src/assets/external/google.svg
vendored
@@ -5,17 +5,18 @@
|
||||
viewBox="0 0 100 100"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2;"><circle cx="50" cy="50" r="50" style="fill:#fff;"
|
||||
/>
|
||||
<g transform="translate(14.39 14.302) scale(.09916)"><clipPath id="a"><path d="M0 0h705.6v720H0z"/></clipPath>
|
||||
<g clip-path="url(#a)"><path d="M-4117.16-2597.44v139.42h193.74c-8.51 44.84-34.04 82.8-72.33 108.33l116.84 90.66c68.07-62.84 107.35-155.13 107.35-264.77 0-25.53-2.29-50.07-6.55-73.63l-339.05-.01Z" style="fill:#4285f4;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="m-4318.92-2463.46-26.35 20.17-93.28 72.65c59.24 117.49 180.65 198.66 321.38 198.66 97.2 0 178.69-32.07 238.25-87.05l-116.83-90.66c-32.08 21.6-72.99 34.69-121.42 34.69-93.6 0-173.13-63.16-201.6-148.25l-.15-.21Z"
|
||||
style="fill:#34a853;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="M-4438.55-2693.33c-24.54 48.44-38.61 103.09-38.61 161.34 0 58.26 14.07 112.91 38.61 161.35 0 .32 119.79-92.95 119.79-92.95-7.2-21.6-11.46-44.5-11.46-68.4 0-23.89 4.26-46.8 11.46-68.4l-119.79-92.94Z"
|
||||
style="fill:#fbbc05;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="M-4117.16-2748.64c53.02 0 100.14 18.33 137.78 53.67l103.09-103.09c-62.51-58.25-143.67-93.93-240.87-93.93-140.73 0-262.15 80.84-321.39 198.66l119.79 92.95c28.47-85.09 108-148.26 201.6-148.26Z"
|
||||
style="fill:#ea4335;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
</g>
|
||||
<g transform="translate(14.39 14.302) scale(.09916)">
|
||||
<path
|
||||
d="M-4117.16-2597.44v139.42h193.74c-8.51 44.84-34.04 82.8-72.33 108.33l116.84 90.66c68.07-62.84 107.35-155.13 107.35-264.77 0-25.53-2.29-50.07-6.55-73.63l-339.05-.01Z"
|
||||
style="fill:#4285f4;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="m-4318.92-2463.46-26.35 20.17-93.28 72.65c59.24 117.49 180.65 198.66 321.38 198.66 97.2 0 178.69-32.07 238.25-87.05l-116.83-90.66c-32.08 21.6-72.99 34.69-121.42 34.69-93.6 0-173.13-63.16-201.6-148.25l-.15-.21Z"
|
||||
style="fill:#34a853;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="M-4438.55-2693.33c-24.54 48.44-38.61 103.09-38.61 161.34 0 58.26 14.07 112.91 38.61 161.35 0 .32 119.79-92.95 119.79-92.95-7.2-21.6-11.46-44.5-11.46-68.4 0-23.89 4.26-46.8 11.46-68.4l-119.79-92.94Z"
|
||||
style="fill:#fbbc05;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="M-4117.16-2748.64c53.02 0 100.14 18.33 137.78 53.67l103.09-103.09c-62.51-58.25-143.67-93.93-240.87-93.93-140.73 0-262.15 80.84-321.39 198.66l119.79 92.95c28.47-85.09 108-148.26 201.6-148.26Z"
|
||||
style="fill:#ea4335;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
1
theseus_gui/src/assets/icons/bug.svg
Normal file
1
theseus_gui/src/assets/icons/bug.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bug"><rect width="8" height="14" x="8" y="6" rx="4"/><path d="m19 7-3 2"/><path d="m5 7 3 2"/><path d="m19 19-3-2"/><path d="m5 19 3-2"/><path d="M20 13h-4"/><path d="M4 13h4"/><path d="m10 4 1 2"/><path d="m14 4-1 2"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -10,3 +10,5 @@ export { default as TextInputIcon } from './text-cursor-input.svg'
|
||||
export { default as AddProjectImage } from './add-project.svg'
|
||||
export { default as NewInstanceImage } from './new-instance.svg'
|
||||
export { default as MenuIcon } from './menu.svg'
|
||||
export { default as BugIcon } from './bug.svg'
|
||||
export { default as ChatIcon } from './messages-square.svg'
|
||||
|
||||
1
theseus_gui/src/assets/icons/messages-square.svg
Normal file
1
theseus_gui/src/assets/icons/messages-square.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-messages-square"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2v5Z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@@ -128,3 +128,10 @@ input {
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
img {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -200,6 +210,25 @@ const filteredResults = computed(() => {
|
||||
return instanceMap.set('None', instances)
|
||||
}
|
||||
|
||||
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
|
||||
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A
|
||||
if (sortBy.value === 'Name') {
|
||||
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
||||
// None should always be first
|
||||
if (a[0] === 'None' && b[0] !== 'None') {
|
||||
return -1
|
||||
}
|
||||
if (a[0] !== 'None' && b[0] === 'None') {
|
||||
return 1
|
||||
}
|
||||
return a[0].localeCompare(b[0])
|
||||
})
|
||||
instanceMap.clear()
|
||||
sortedEntries.forEach((entry) => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
|
||||
return instanceMap
|
||||
})
|
||||
</script>
|
||||
@@ -226,6 +255,7 @@ const filteredResults = computed(() => {
|
||||
<DropdownSelect
|
||||
v-model="sortBy"
|
||||
class="sort-dropdown"
|
||||
name="Sort Dropdown"
|
||||
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
@@ -235,6 +265,7 @@ const filteredResults = computed(() => {
|
||||
<DropdownSelect
|
||||
v-model="filters"
|
||||
class="filter-dropdown"
|
||||
name="Filter Dropdown"
|
||||
:options="['All profiles', 'Custom instances', 'Downloaded modpacks']"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
@@ -244,6 +275,7 @@ const filteredResults = computed(() => {
|
||||
<DropdownSelect
|
||||
v-model="group"
|
||||
class="group-dropdown"
|
||||
name="Group Dropdown"
|
||||
:options="['Category', 'Loader', 'Game version', 'None']"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
@@ -263,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.id"
|
||||
: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>
|
||||
@@ -276,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()
|
||||
@@ -195,8 +203,8 @@ const handleOptionsClick = async (args) => {
|
||||
}
|
||||
}
|
||||
|
||||
const maxInstancesPerRow = ref(0)
|
||||
const maxProjectsPerRow = ref(0)
|
||||
const maxInstancesPerRow = ref(1)
|
||||
const maxProjectsPerRow = ref(1)
|
||||
|
||||
const calculateCardsPerRow = () => {
|
||||
// Calculate how many cards fit in one row
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div
|
||||
v-if="mode !== 'isolated'"
|
||||
ref="button"
|
||||
v-tooltip.right="'Minecraft accounts'"
|
||||
class="button-base avatar-button"
|
||||
:class="{ expanded: mode === 'expanded' }"
|
||||
@click="showCard = !showCard"
|
||||
@@ -10,19 +11,10 @@
|
||||
:size="mode === 'expanded' ? 'xs' : 'sm'"
|
||||
:src="
|
||||
selectedAccount
|
||||
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
|
||||
: 'https://cdn.discordapp.com/attachments/817413688771608587/1129829843425570867/unnamed.png'
|
||||
? `https://crafatar.com/avatars/${selectedAccount.id}?size=128&overlay`
|
||||
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
"
|
||||
/>
|
||||
<div v-show="mode === 'expanded'" class="avatar-text">
|
||||
<div class="text no-select">
|
||||
{{ selectedAccount ? selectedAccount.username : 'Offline' }}
|
||||
</div>
|
||||
<p class="accounts-text no-select">
|
||||
<UsersIcon />
|
||||
Accounts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<Card
|
||||
@@ -32,7 +24,10 @@
|
||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
||||
>
|
||||
<div v-if="selectedAccount" class="selected account">
|
||||
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
|
||||
<Avatar
|
||||
size="xs"
|
||||
:src="`https://crafatar.com/avatars/${selectedAccount.id}?size=128&overlay`"
|
||||
/>
|
||||
<div>
|
||||
<h4>{{ selectedAccount.username }}</h4>
|
||||
<p>Selected</p>
|
||||
@@ -50,7 +45,10 @@
|
||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
||||
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
|
||||
<Button class="option account" @click="setAccount(account)">
|
||||
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
|
||||
<Avatar
|
||||
:src="`https://crafatar.com/avatars/${selectedAccount.id}?size=128&overlay`"
|
||||
class="icon"
|
||||
/>
|
||||
<p>{{ account.username }}</p>
|
||||
</Button>
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
|
||||
@@ -64,11 +62,56 @@
|
||||
</Button>
|
||||
</Card>
|
||||
</transition>
|
||||
<Modal ref="loginModal" class="modal" header="Signing in">
|
||||
<div class="modal-body">
|
||||
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
|
||||
<div class="modal-text">
|
||||
<div class="label">Copy this code</div>
|
||||
<div class="code-text">
|
||||
<div class="code">
|
||||
{{ loginCode }}
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="'Copy code'"
|
||||
icon-only
|
||||
large
|
||||
color="raised"
|
||||
@click="() => clipboardWrite(loginCode)"
|
||||
>
|
||||
<ClipboardCopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div>And enter it on Microsoft's website to sign in.</div>
|
||||
<div class="iconified-input">
|
||||
<LogInIcon />
|
||||
<input type="text" :value="loginUrl" readonly />
|
||||
<Button
|
||||
v-tooltip="'Open link'"
|
||||
icon-only
|
||||
color="raised"
|
||||
@click="() => clipboardWrite(loginUrl)"
|
||||
>
|
||||
<GlobeIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Avatar, Button, Card, PlusIcon, TrashIcon, UsersIcon, LogInIcon } from 'omorphia'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
LogInIcon,
|
||||
Modal,
|
||||
GlobeIcon,
|
||||
ClipboardCopyIcon,
|
||||
} from 'omorphia'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import {
|
||||
users,
|
||||
remove_user,
|
||||
@@ -76,10 +119,10 @@ import {
|
||||
authenticate_await_completion,
|
||||
} from '@/helpers/auth'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { WebviewWindow } from '@tauri-apps/api/window'
|
||||
import { handleError } from '@/store/state.js'
|
||||
import { get as getCreds, login_minecraft } from '@/helpers/mr_auth'
|
||||
import { mixpanel_track } from '@/helpers/mixpanel'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
|
||||
defineProps({
|
||||
mode: {
|
||||
@@ -91,8 +134,13 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const loginCode = ref(null)
|
||||
|
||||
const settings = ref({})
|
||||
const accounts = ref([])
|
||||
const loginUrl = ref('')
|
||||
const loginModal = ref(null)
|
||||
|
||||
async function refreshValues() {
|
||||
settings.value = await get().catch(handleError)
|
||||
accounts.value = await users().catch(handleError)
|
||||
@@ -116,30 +164,33 @@ async function setAccount(account) {
|
||||
emit('change')
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const url = await authenticate_begin_flow().catch(handleError)
|
||||
const clipboardWrite = async (a) => {
|
||||
navigator.clipboard.writeText(a)
|
||||
}
|
||||
|
||||
const window = new WebviewWindow('loginWindow', {
|
||||
title: 'Modrinth App',
|
||||
url: url,
|
||||
async function login() {
|
||||
const loginSuccess = await authenticate_begin_flow().catch(handleError)
|
||||
|
||||
loginModal.value.show()
|
||||
loginCode.value = loginSuccess.user_code
|
||||
loginUrl.value = loginSuccess.verification_uri
|
||||
await window.__TAURI_INVOKE__('tauri', {
|
||||
__tauriModule: 'Shell',
|
||||
message: {
|
||||
cmd: 'open',
|
||||
path: loginSuccess.verification_uri,
|
||||
},
|
||||
})
|
||||
|
||||
const loggedIn = await authenticate_await_completion().catch(handleError)
|
||||
loginModal.value.hide()
|
||||
|
||||
if (loggedIn && loggedIn[0]) {
|
||||
await setAccount(loggedIn[0])
|
||||
if (loggedIn) {
|
||||
await setAccount(loggedIn)
|
||||
await refreshValues()
|
||||
|
||||
const creds = await getCreds().catch(handleError)
|
||||
if (!creds) {
|
||||
try {
|
||||
await login_minecraft(loggedIn[1])
|
||||
} catch (err) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
await window.close()
|
||||
|
||||
loginModal.value.hide()
|
||||
mixpanel_track('AccountLogIn')
|
||||
}
|
||||
|
||||
@@ -170,6 +221,12 @@ const handleClickOutside = (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const unlisten = await process_listener(async (e) => {
|
||||
if (e.event === 'launched') {
|
||||
await refreshValues()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
@@ -177,6 +234,10 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -212,7 +273,7 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
top: 0.5rem;
|
||||
left: 5.5rem;
|
||||
z-index: 9;
|
||||
z-index: 11;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-button-bg);
|
||||
@@ -220,6 +281,18 @@ onBeforeUnmount(() => {
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
max-height: 98vh;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
border-top-right-radius: 1rem;
|
||||
border-bottom-right-radius: 1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
border-top-right-radius: 1rem;
|
||||
border-bottom-right-radius: 1rem;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
@@ -317,4 +390,75 @@ onBeforeUnmount(() => {
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
background-color: white !important;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-lg);
|
||||
align-items: center;
|
||||
padding: var(--gap-xl);
|
||||
|
||||
.modal-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
width: 100%;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-text {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
align-items: center;
|
||||
|
||||
.code {
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
border: solid 1px var(--color-button-bg);
|
||||
font-family: var(--mono-font);
|
||||
letter-spacing: var(--gap-md);
|
||||
color: var(--color-contrast);
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
padding: var(--gap-sm) 0 var(--gap-sm) var(--gap-md);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.code {
|
||||
color: var(--color-brand);
|
||||
padding: 0.05rem 0.1rem;
|
||||
// row not column
|
||||
display: flex;
|
||||
|
||||
.card {
|
||||
background: var(--color-base);
|
||||
color: var(--color-contrast);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div class="breadcrumbs">
|
||||
<Button class="breadcrumbs__back transparent" icon-only @click="$router.back()">
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<Button class="breadcrumbs__forward transparent" icon-only @click="$router.forward()">
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
{{ breadcrumbData.resetToNames(breadcrumbs) }}
|
||||
<div v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name" class="breadcrumbs__item">
|
||||
<router-link
|
||||
@@ -25,7 +31,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ChevronRightIcon } from 'omorphia'
|
||||
import { ChevronRightIcon, Button, ChevronLeftIcon } from 'omorphia'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
@@ -60,6 +66,18 @@ const breadcrumbs = computed(() => {
|
||||
margin: auto 0;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumbs__back,
|
||||
.breadcrumbs__forward {
|
||||
margin: auto 0;
|
||||
color: var(--color-base);
|
||||
height: unset;
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.breadcrumbs__forward {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
>
|
||||
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
|
||||
<hr v-if="option.type === 'divider'" class="divider" />
|
||||
<div v-else class="item clickable" :class="[option.color ?? 'base']">
|
||||
<div
|
||||
v-else-if="!(isLinkedData(item) && option.name === `add_content`)"
|
||||
class="item clickable"
|
||||
:class="[option.color ?? 'base']"
|
||||
>
|
||||
<slot :name="option.name" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,6 +59,15 @@ defineExpose({
|
||||
},
|
||||
})
|
||||
|
||||
const isLinkedData = (item) => {
|
||||
if (item.instance != undefined && item.instance.metadata.linked_data) {
|
||||
return true
|
||||
} else if (item.metadata != undefined && item.metadata.linked_data) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const hideContextMenu = () => {
|
||||
shown.value = false
|
||||
emit('menu-closed')
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<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'
|
||||
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
|
||||
import { open } from '@tauri-apps/api/dialog'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { useTheming } from '@/store/theme'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -24,21 +23,34 @@ 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()
|
||||
|
||||
const initFiles = async () => {
|
||||
const newFolders = new Map()
|
||||
const sep = '/'
|
||||
files.value = []
|
||||
await get_potential_override_folders(props.instance.path).then((filePaths) =>
|
||||
await get_pack_export_candidates(props.instance.path).then((filePaths) =>
|
||||
filePaths
|
||||
.map((folder) => ({
|
||||
path: folder,
|
||||
name: folder.split(sep).pop(),
|
||||
selected: false,
|
||||
selected:
|
||||
folder.startsWith('mods') ||
|
||||
folder.startsWith('datapacks') ||
|
||||
folder.startsWith('resourcepacks') ||
|
||||
folder.startsWith('shaderpacks') ||
|
||||
folder.startsWith('config'),
|
||||
disabled:
|
||||
folder === 'profile.json' ||
|
||||
folder.startsWith('modrinth_logs') ||
|
||||
folder.startsWith('.fabric') ||
|
||||
folder.includes('.DS_Store'),
|
||||
}))
|
||||
.forEach((pathData) => {
|
||||
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
|
||||
@@ -83,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()
|
||||
}
|
||||
@@ -113,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 as overrides</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">
|
||||
@@ -125,6 +159,7 @@ const exportPack = async () => {
|
||||
:model-value="children.every((child) => child.selected)"
|
||||
:label="path.name"
|
||||
class="select-checkbox"
|
||||
:disabled="children.every((x) => x.disabled)"
|
||||
@update:model-value="
|
||||
(newValue) => children.forEach((child) => (child.selected = newValue))
|
||||
"
|
||||
@@ -137,7 +172,12 @@ const exportPack = async () => {
|
||||
</div>
|
||||
<div v-if="path.showingMore" class="file-secondary">
|
||||
<div v-for="child in children" :key="child.path" class="file-secondary-row">
|
||||
<Checkbox v-model="child.selected" :label="child.name" class="select-checkbox" />
|
||||
<Checkbox
|
||||
v-model="child.selected"
|
||||
:label="child.name"
|
||||
class="select-checkbox"
|
||||
:disabled="child.disabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,7 +185,12 @@ const exportPack = async () => {
|
||||
<div v-for="file in files" :key="file.path" class="table-row">
|
||||
<div class="table-cell file-entry">
|
||||
<div class="file-primary">
|
||||
<Checkbox v-model="file.selected" :label="file.name" class="select-checkbox" />
|
||||
<Checkbox
|
||||
v-model="file.selected"
|
||||
:label="file.name"
|
||||
:disabled="file.disabled"
|
||||
class="select-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,10 +201,6 @@ const exportPack = async () => {
|
||||
<XIcon />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled>
|
||||
<SendIcon />
|
||||
Share
|
||||
</Button>
|
||||
<Button color="primary" @click="exportPack">
|
||||
<PackageIcon />
|
||||
Export
|
||||
@@ -188,6 +229,8 @@ const exportPack = async () => {
|
||||
}
|
||||
|
||||
.select-checkbox {
|
||||
gap: var(--gap-sm);
|
||||
|
||||
button.checkbox {
|
||||
border: none;
|
||||
}
|
||||
@@ -238,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>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
v-model="selectedVersion"
|
||||
:options="versions"
|
||||
placeholder="Select version"
|
||||
name="Version select"
|
||||
:display-name="
|
||||
(version) =>
|
||||
`${version?.name} (${version?.loaders
|
||||
@@ -165,5 +166,9 @@ td:first-child {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
|
||||
:deep(.animated-dropdown .options) {
|
||||
max-height: 13.375rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -187,7 +187,7 @@ onUnmounted(() => unlisten())
|
||||
<div class="instance">
|
||||
<Card class="instance-card-item button-base" @click="seeInstance" @mouseenter="checkProcess">
|
||||
<Avatar
|
||||
size="sm"
|
||||
size="lg"
|
||||
:src="
|
||||
props.instance.metadata
|
||||
? !props.instance.metadata.icon ||
|
||||
|
||||
@@ -20,7 +20,13 @@
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<p class="input-label">Name</p>
|
||||
<input v-model="profile_name" autocomplete="off" class="text-input" type="text" />
|
||||
<input
|
||||
v-model="profile_name"
|
||||
autocomplete="off"
|
||||
class="text-input"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<p class="input-label">Loader</p>
|
||||
@@ -181,6 +187,10 @@
|
||||
: 'Select profiles to import'
|
||||
}}
|
||||
</Button>
|
||||
<ProgressBar
|
||||
v-if="loading"
|
||||
:progress="(importedProfiles / (totalProfiles + 0.0001)) * 100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -212,6 +222,7 @@ import {
|
||||
get_fabric_versions,
|
||||
get_forge_versions,
|
||||
get_quilt_versions,
|
||||
get_neoforge_versions,
|
||||
} from '@/helpers/metadata'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import Multiselect from 'vue-multiselect'
|
||||
@@ -224,6 +235,7 @@ import {
|
||||
get_importable_instances,
|
||||
import_instance,
|
||||
} from '@/helpers/import.js'
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -288,21 +300,28 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const [fabric_versions, forge_versions, quilt_versions, all_game_versions, loaders] =
|
||||
await Promise.all([
|
||||
get_fabric_versions().then(shallowRef).catch(handleError),
|
||||
get_forge_versions().then(shallowRef).catch(handleError),
|
||||
get_quilt_versions().then(shallowRef).catch(handleError),
|
||||
get_game_versions().then(shallowRef).catch(handleError),
|
||||
get_loaders()
|
||||
.then((value) =>
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||
.map((item) => item.name.toLowerCase())
|
||||
)
|
||||
.then(ref)
|
||||
.catch(handleError),
|
||||
])
|
||||
const [
|
||||
fabric_versions,
|
||||
forge_versions,
|
||||
quilt_versions,
|
||||
neoforge_versions,
|
||||
all_game_versions,
|
||||
loaders,
|
||||
] = await Promise.all([
|
||||
get_fabric_versions().then(shallowRef).catch(handleError),
|
||||
get_forge_versions().then(shallowRef).catch(handleError),
|
||||
get_quilt_versions().then(shallowRef).catch(handleError),
|
||||
get_neoforge_versions().then(shallowRef).catch(handleError),
|
||||
get_game_versions().then(shallowRef).catch(handleError),
|
||||
get_loaders()
|
||||
.then((value) =>
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||
.map((item) => item.name.toLowerCase())
|
||||
)
|
||||
.then(ref)
|
||||
.catch(handleError),
|
||||
])
|
||||
loaders.value.unshift('vanilla')
|
||||
|
||||
const game_versions = computed(() => {
|
||||
@@ -315,6 +334,8 @@ const game_versions = computed(() => {
|
||||
defaultVal &= forge_versions.value.gameVersions.some((x) => item.id === x.id)
|
||||
} else if (loader.value === 'quilt') {
|
||||
defaultVal &= quilt_versions.value.gameVersions.some((x) => item.id === x.id)
|
||||
} else if (loader.value === 'neoforge') {
|
||||
defaultVal &= neoforge_versions.value.gameVersions.some((x) => item.id === x.id)
|
||||
}
|
||||
|
||||
return defaultVal
|
||||
@@ -389,6 +410,10 @@ const selectable_versions = computed(() => {
|
||||
.loaders.map((item) => item.id)
|
||||
} else if (loader.value === 'quilt') {
|
||||
return quilt_versions.value.gameVersions[0].loaders.map((item) => item.id)
|
||||
} else if (loader.value === 'neoforge') {
|
||||
return neoforge_versions.value.gameVersions
|
||||
.find((item) => item.id === game_version.value)
|
||||
.loaders.map((item) => item.id)
|
||||
}
|
||||
}
|
||||
return []
|
||||
@@ -420,6 +445,8 @@ const profiles = ref(
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
const importedProfiles = ref(0)
|
||||
const totalProfiles = ref(0)
|
||||
|
||||
const selectedProfileType = ref('MultiMC')
|
||||
const profileOptions = ref([
|
||||
@@ -480,6 +507,10 @@ const setPath = () => {
|
||||
}
|
||||
|
||||
const next = async () => {
|
||||
importedProfiles.value = 0
|
||||
totalProfiles.value = Array.from(profiles.value.values())
|
||||
.map((profiles) => profiles.filter((profile) => profile.selected).length)
|
||||
.reduce((a, b) => a + b, 0)
|
||||
loading.value = true
|
||||
for (const launcher of Array.from(profiles.value.entries()).map(([launcher, profiles]) => ({
|
||||
launcher,
|
||||
@@ -491,6 +522,7 @@ const next = async () => {
|
||||
.catch(handleError)
|
||||
.then(() => console.log(`Successfully Imported ${profile.name} from ${launcher.launcher}`))
|
||||
profile.selected = false
|
||||
importedProfiles.value++
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
@@ -628,6 +660,7 @@ const next = async () => {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
|
||||
.transparent {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -183,7 +183,7 @@ const createInstance = async () => {
|
||||
await router.push(`/instance/${encodeURIComponent(id)}/`)
|
||||
|
||||
const instance = await get(id, true)
|
||||
await installVersionDependencies(instance, versions.value)
|
||||
await installVersionDependencies(instance, versions.value[0])
|
||||
|
||||
mixpanel_track('InstanceCreate', {
|
||||
profile_name: name.value,
|
||||
@@ -204,7 +204,7 @@ const createInstance = async () => {
|
||||
source: 'ProjectInstallModal',
|
||||
})
|
||||
|
||||
installModal.value.hide()
|
||||
if (installModal.value) installModal.value.hide()
|
||||
creatingInstance.value = false
|
||||
}
|
||||
|
||||
@@ -245,13 +245,32 @@ const check_valid = computed(() => {
|
||||
/>
|
||||
{{ profile.metadata.name }}
|
||||
</Button>
|
||||
<Button :disabled="profile.installedMod || profile.installing" @click="install(profile)">
|
||||
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
|
||||
<CheckIcon v-else-if="profile.installedMod" />
|
||||
{{
|
||||
profile.installing ? 'Installing...' : profile.installedMod ? 'Installed' : 'Install'
|
||||
}}
|
||||
</Button>
|
||||
<div
|
||||
v-tooltip="
|
||||
profile.metadata.linked_data?.locked && !profile.installedMod
|
||||
? 'Unpair or unlock an instance to add mods.'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<Button
|
||||
:disabled="
|
||||
profile.installedMod || profile.installing || profile.metadata.linked_data?.locked
|
||||
"
|
||||
@click="install(profile)"
|
||||
>
|
||||
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
|
||||
<CheckIcon v-else-if="profile.installedMod" />
|
||||
{{
|
||||
profile.installing
|
||||
? 'Installing...'
|
||||
: profile.installedMod
|
||||
? 'Installed'
|
||||
: profile.metadata.linked_data && profile.metadata.linked_data.locked
|
||||
? 'Paired'
|
||||
: 'Install'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Card v-if="showCreation" class="creation-card">
|
||||
|
||||
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>
|
||||
@@ -119,7 +119,7 @@ const install = async (e) => {
|
||||
'background-image': `url(${
|
||||
project.featured_gallery ??
|
||||
project.gallery[0] ??
|
||||
'https://cdn.discordapp.com/attachments/817413688771608587/1119143634319724564/image.png'
|
||||
'https://launcher-files.modrinth.com/assets/maze-bg.png'
|
||||
})`,
|
||||
'no-image': !project.featured_gallery && !project.gallery[0],
|
||||
}"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div class="action-groups">
|
||||
<a href="https://discord.modrinth.com" class="link">
|
||||
<ChatIcon />
|
||||
<span> Get support </span>
|
||||
</a>
|
||||
<Button
|
||||
v-if="currentLoadingBars.length > 0"
|
||||
ref="infoButton"
|
||||
@@ -120,6 +124,7 @@ import { refreshOffline, isOffline } from '@/helpers/utils.js'
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { mixpanel_track } from '@/helpers/mixpanel'
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
|
||||
const router = useRouter()
|
||||
const card = ref(null)
|
||||
@@ -266,7 +271,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
@@ -452,4 +457,14 @@ onBeforeUnmount(() => {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
<template>
|
||||
<div ref="button" class="button-base avatar-button" :class="{ highlighted: showDemo }">
|
||||
<Avatar
|
||||
src="https://cdn.discordapp.com/attachments/817413688771608587/1129829843425570867/unnamed.png"
|
||||
/>
|
||||
<Avatar src="https://launcher-files.modrinth.com/assets/steve_head.png" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="showDemo" class="card-section">
|
||||
<Card ref="card" class="fake-account-card expanded highlighted">
|
||||
<div class="selected account">
|
||||
<Avatar
|
||||
size="xs"
|
||||
src="https://cdn.discordapp.com/attachments/817413688771608587/1129829843425570867/unnamed.png"
|
||||
/>
|
||||
<Avatar size="xs" src="https://launcher-files.modrinth.com/assets/steve_head.png" />
|
||||
<div>
|
||||
<h4>Modrinth</h4>
|
||||
<p>Selected</p>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div class="action-groups">
|
||||
<Button color="danger" outline @click="exit">
|
||||
<LogOutIcon />
|
||||
Exit tutorial
|
||||
</Button>
|
||||
<Button v-if="showDownload" ref="infoButton" icon-only class="icon-button show-card-icon">
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
@@ -36,7 +40,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, DownloadIcon, Card, StopCircleIcon, TerminalSquareIcon } from 'omorphia'
|
||||
import {
|
||||
Button,
|
||||
DownloadIcon,
|
||||
Card,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
LogOutIcon,
|
||||
} from 'omorphia'
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
|
||||
defineProps({
|
||||
@@ -48,6 +59,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
exit: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ defineProps({
|
||||
<DropdownSelect
|
||||
v-model="sortBy"
|
||||
class="sort-dropdown"
|
||||
name="Sort Dropdown"
|
||||
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
@@ -41,6 +42,7 @@ defineProps({
|
||||
<DropdownSelect
|
||||
v-model="filters"
|
||||
class="filter-dropdown"
|
||||
name="Filter Dropdown"
|
||||
:options="['All profiles', 'Custom instances', 'Downloaded modpacks']"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
@@ -50,6 +52,7 @@ defineProps({
|
||||
<DropdownSelect
|
||||
v-model="group"
|
||||
class="group-dropdown"
|
||||
name="Group dropdown"
|
||||
:options="['Category', 'Loader', 'Game version', 'None']"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
@@ -65,7 +68,7 @@ defineProps({
|
||||
>
|
||||
<Avatar
|
||||
size="sm"
|
||||
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
|
||||
src="https://launcher-files.modrinth.com/assets/default_profile.png"
|
||||
alt="Mod card"
|
||||
class="mod-image"
|
||||
/>
|
||||
|
||||
@@ -63,7 +63,7 @@ defineProps({
|
||||
>
|
||||
<Avatar
|
||||
size="sm"
|
||||
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
|
||||
src="https://launcher-files.modrinth.com/assets/default_profile.png"
|
||||
alt="Mod card"
|
||||
class="mod-image"
|
||||
/>
|
||||
@@ -79,7 +79,7 @@ defineProps({
|
||||
<div
|
||||
class="banner no-image"
|
||||
:style="{
|
||||
'background-image': `url(https://cdn.discordapp.com/attachments/817413688771608587/1119143634319724564/image.png)`,
|
||||
'background-image': `url(https://launcher-files.modrinth.com/assets/maze-bg.png)`,
|
||||
}"
|
||||
>
|
||||
<div class="badges">
|
||||
@@ -111,7 +111,7 @@ defineProps({
|
||||
<Avatar
|
||||
class="icon"
|
||||
size="sm"
|
||||
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
|
||||
src="https://launcher-files.modrinth.com/assets/default_profile.png"
|
||||
/>
|
||||
<div class="title">
|
||||
<div class="title-text">Example Project</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user