Compare commits

...

16 Commits

Author SHA1 Message Date
Wyatt Verchere
1e8852b540 Bugs again (#703)
* initial

* more fixes

* logs

* more fixes

* working rescuer

* minor log display fix

* mac fixes

* minor fix

* libsselinux1

* linux error

* actions test

* more bugs. Modpack page! BIG changes

* changed minimum 64 -> 8

* removed modpack page moved to modal

* removed unnecessary css

* mac compile

* many revs

* Merge colorful logs (#725)

* make implementation not dumb

* run prettier

* null -> true

* Add line numbers & make errors more robust.

* improvments

* changes; virtual scroll

---------

Co-authored-by: qtchaos <72168435+qtchaos@users.noreply.github.com>

* omorphia colors, comments fix

* fixes; _JAVA_OPTIONS

* revs

* mac specific

* more mac

* some fixes

* quick fix

* add java reinstall option

---------

Co-authored-by: qtchaos <72168435+qtchaos@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
2023-09-12 09:27:03 -07:00
Modrinth Bot
bc02192d80 [no ci] synced file(s) with modrinth/.github (#731) 2023-09-10 12:42:05 -04:00
George Tsotsos
405f77e466 Rounded corner to improve UI continuity (#711)
* round top right corner

* remove conflicting shadow & more radius

* remove unnecessary css rule
2023-09-04 17:15:33 -04:00
Geometrically
2fad02df23 Fix some small things (#658) 2023-08-23 11:03:09 -04:00
chaos
1eb8998296 Update CSP to allow mclo.gs (#648)
* Update CSP to allow mclo.gs

* Fix semicolon location
2023-08-22 10:08:30 -04:00
Geometrically
2d3baff031 Neoforge support (#653) 2023-08-21 17:39:39 -04:00
Banzobotic
7bea362503 Update CSP to allow youtube embeds (#640) 2023-08-19 14:05:06 -04:00
chaos
abb02ad624 Fix being able to add mods to paired instances (#630)
* Fix being able to add mods to paired instances

* Remove debugging output.
2023-08-18 21:30:07 -04:00
Geometrically
f7f73b8163 Fix auth refresh (#631) 2023-08-18 21:27:42 -04:00
Wyatt Verchere
6d9d403e7b Hydra local (#594)
* initial commit

* merge fixes

* added sanitizing

* linter

* Improve sign in UI

* simple simple!

* bump version

---------

Co-authored-by: CodexAdrian <83074853+CodexAdrian@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
2023-08-17 20:26:21 -04:00
Adrian O.V
49bfb0637f Community requested enhancements (#556)
* Make images lazy and fix #198

* Fix console spam

* Fix bug with bad pagination impl

* Fixes #232

* Finalize more bug fixes

* run lint

* Improve minecraft sign in, improve onboarding

* Linter

* Added back button

* Implement #530

* run linter

* Address changes

* Bump version + run fmt

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
2023-08-14 14:02:22 -07:00
Wyatt Verchere
d6ee1ff25a Beta bugs (#562)
* fixed bugs

* added logging for atlauncher

* draft: improving imports time

* more improvements

* more

* prettier, etc

* small changes

* emma suggested change

* rev

* removed atlauncher debug
2023-08-14 13:23:42 -07:00
chaos
a1a5b8ed9c Fix z-index and add scrolling for AccountsCard (#585)
* Fix z-index and add scrolling for AccountsCard

* Run Prettier on code.
2023-08-14 13:18:37 -07:00
Geometrically
5f0d44a881 more bug fixes (#485)
* more bug fixes

* remove console log
2023-08-05 17:43:21 -07:00
Wyatt Verchere
d968ad383c Discord and playtime (#462)
* initial

* Fixed java thing

* fixes

* internet check change

* some fix/test commit

* Fix render issues on windows

* bump version

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Jai A <jai@modrinth.com>
2023-08-05 16:41:08 -07:00
Adrian O.V
5ee64f2705 New features (#477)
* Linking/Unlinking, Dir changing, CDN

* Fixes #435

* Progress bar

* Create splashscreen.html

* Run lint

* add rust part

* remove splashscreen code

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
2023-08-05 14:44:02 -07:00
124 changed files with 4777 additions and 1543 deletions

View File

@@ -25,9 +25,12 @@ body:
description: A clear and concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes: System information
description: Add any information about what OS you are on (like Windows or Mac), and what version of the app you are using.
- type: textarea
attributes:
label: Additional context
description: Add any other context about the problem here.
description: Add any other context about the problem here. This might include logs, screenshots, etc. The more the merrier!
validations:
required: false

View File

@@ -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

View File

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

2
.gitignore vendored
View File

@@ -110,3 +110,5 @@ fabric.properties
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
theseus.iml

99
Cargo.lock generated
View File

@@ -830,6 +830,30 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef"
dependencies = [
"cfg-if",
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7"
dependencies = [
"autocfg",
"cfg-if",
"crossbeam-utils",
"memoffset 0.9.0",
"scopeguard",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.16"
@@ -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.5.4"
dependencies = [
"async-recursion",
"async-tungstenite",
@@ -4620,6 +4696,7 @@ dependencies = [
"dirs 5.0.1",
"discord-rich-presence",
"dunce",
"flate2",
"futures",
"indicatif",
"lazy_static",
@@ -4634,6 +4711,7 @@ dependencies = [
"sha1 0.6.1",
"sha2 0.9.9",
"sys-info",
"sysinfo",
"tauri",
"tempfile",
"theseus_macros",
@@ -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.5.4"
dependencies = [
"argh",
"color-eyre",
@@ -4681,7 +4760,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "0.4.0"
version = "0.5.4"
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
View 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>

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus"
version = "0.4.0"
version = "0.5.4"
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"

View File

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

View File

@@ -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)
}
}
}

View 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::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 req = REQWEST_CLIENT.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
.header("Content-Type", "application/x-www-form-urlencoded").form(&params).send().await?;
match req.status() {
reqwest::StatusCode::OK => Ok(req.json().await?),
_ => {
let microsoft_error = req.json::<MicrosoftError>().await?;
Err(crate::ErrorKind::HydraError(format!(
"Error from Microsoft: {:?}",
microsoft_error.error_description
))
.into())
}
}
}

View 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>,
}

View File

@@ -0,0 +1,60 @@
use std::collections::HashMap;
use reqwest::StatusCode;
use serde::Deserialize;
use crate::{
hydra::{MicrosoftError, MICROSOFT_CLIENT_ID},
util::fetch::REQWEST_CLIENT,
};
#[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 = REQWEST_CLIENT
.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&params)
.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())
}
}
}

View File

@@ -0,0 +1,29 @@
use serde_json::json;
const MCSERVICES_AUTH_URL: &str =
"https://api.minecraftservices.com/launcher/login";
pub async fn fetch_bearer(token: &str, uhs: &str) -> crate::Result<String> {
let client = reqwest::Client::new();
let body = 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(),
)
}

View File

@@ -0,0 +1,7 @@
//! MSA authentication stages
pub mod bearer_token;
pub mod player_info;
pub mod poll_response;
pub mod xbl_signin;
pub mod xsts_token;

View File

@@ -0,0 +1,33 @@
//! Fetch player info for display
use serde::Deserialize;
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("???"),
}
}
}
pub async fn fetch_info(token: &str) -> crate::Result<PlayerInfo> {
let client = reqwest::Client::new();
let resp = client
.get(PROFILE_URL)
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}

View File

@@ -0,0 +1,91 @@
use std::collections::HashMap;
use reqwest::StatusCode;
use serde::Deserialize;
use crate::{
hydra::{MicrosoftError, MICROSOFT_CLIENT_ID},
util::fetch::REQWEST_CLIENT,
};
#[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 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 = REQWEST_CLIENT
.post(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
)
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&params)
.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());
}
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
use serde_json::json;
const XBL_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
// Deserialization
pub struct XBLLogin {
pub token: String,
pub uhs: String,
}
// Impl
pub async fn login_xbl(token: &str) -> crate::Result<XBLLogin> {
let client = reqwest::Client::new();
let body = 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?
.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 })
}

View File

@@ -0,0 +1,56 @@
use serde_json::json;
const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
pub enum XSTSResponse {
Unauthorized(String),
Success { token: String },
}
pub async fn fetch_token(token: &str) -> crate::Result<XSTSResponse> {
let client = reqwest::Client::new();
let resp = 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"),
},
))
}
}

View File

@@ -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()

View File

@@ -1,30 +1,70 @@
use std::io::{Read, SeekFrom};
use crate::{
prelude::Credentials,
util::io::{self, IOError},
{state::ProfilePathId, State},
};
use serde::{Deserialize, Serialize};
use futures::TryFutureExt;
use serde::Serialize;
use tokio::{
fs::File,
io::{AsyncReadExt, AsyncSeekExt},
};
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Debug)]
pub struct Logs {
pub datetime_string: String,
pub output: Option<String>,
pub filename: String,
pub output: Option<CensoredString>,
}
#[derive(Serialize, Debug)]
pub struct LatestLogCursor {
pub cursor: u64,
pub output: CensoredString,
pub new_file: bool,
}
#[derive(Serialize, Debug)] // Not deserialize
#[serde(transparent)]
pub struct CensoredString(String);
impl CensoredString {
pub fn censor(mut s: String, credentials_set: &Vec<Credentials>) -> Self {
let username = whoami::username();
s = s
.replace(&format!("/{}/", username), "/{COMPUTER_USERNAME}/")
.replace(&format!("\\{}\\", username), "\\{COMPUTER_USERNAME}\\");
for credentials in credentials_set {
s = s
.replace(&credentials.access_token, "{MINECRAFT_ACCESS_TOKEN}")
.replace(&credentials.username, "{MINECRAFT_USERNAME}")
.replace(
&credentials.id.as_simple().to_string(),
"{MINECRAFT_UUID}",
)
.replace(
&credentials.id.as_hyphenated().to_string(),
"{MINECRAFT_UUID}",
);
}
Self(s)
}
}
impl Logs {
async fn build(
profile_subpath: &ProfilePathId,
datetime_string: String,
filename: String,
clear_contents: Option<bool>,
) -> crate::Result<Self> {
Ok(Self {
output: if clear_contents.unwrap_or(false) {
None
} else {
Some(
get_output_by_datetime(profile_subpath, &datetime_string)
.await?,
)
Some(get_output_by_filename(profile_subpath, &filename).await?)
},
datetime_string,
filename,
})
}
}
@@ -51,33 +91,31 @@ pub async fn get_logs(
for entry in std::fs::read_dir(&logs_folder)
.map_err(|e| IOError::with_path(e, &logs_folder))?
{
let entry =
let entry: std::fs::DirEntry =
entry.map_err(|e| IOError::with_path(e, &logs_folder))?;
let path = entry.path();
if path.is_dir() {
if let Some(datetime_string) = path.file_name() {
logs.push(
Logs::build(
&profile_path,
datetime_string.to_string_lossy().to_string(),
clear_contents,
)
.await,
);
}
if !path.is_file() {
continue;
}
if let Some(file_name) = path.file_name() {
let file_name = file_name.to_string_lossy().to_string();
logs.push(
Logs::build(&profile_path, file_name, clear_contents).await,
);
}
}
}
let mut logs = logs.into_iter().collect::<crate::Result<Vec<Logs>>>()?;
logs.sort_by_key(|x| x.datetime_string.clone());
logs.sort_by_key(|x| x.filename.clone());
Ok(logs)
}
#[tracing::instrument]
pub async fn get_logs_by_datetime(
pub async fn get_logs_by_filename(
profile_path: ProfilePathId,
datetime_string: String,
filename: String,
) -> crate::Result<Logs> {
let profile_path =
if let Some(p) = crate::profile::get(&profile_path, None).await? {
@@ -89,23 +127,66 @@ pub async fn get_logs_by_datetime(
.into());
};
Ok(Logs {
output: Some(
get_output_by_datetime(&profile_path, &datetime_string).await?,
),
datetime_string,
output: Some(get_output_by_filename(&profile_path, &filename).await?),
filename,
})
}
#[tracing::instrument]
pub async fn get_output_by_datetime(
pub async fn get_output_by_filename(
profile_subpath: &ProfilePathId,
datetime_string: &str,
) -> crate::Result<String> {
file_name: &str,
) -> crate::Result<CensoredString> {
let state = State::get().await?;
let logs_folder =
state.directories.profile_logs_dir(profile_subpath).await?;
let path = logs_folder.join(datetime_string).join("stdout.log");
Ok(io::read_to_string(&path).await?)
let path = logs_folder.join(file_name);
let credentials: Vec<Credentials> =
state.users.read().await.clone().0.into_values().collect();
// Load .gz file into String
if let Some(ext) = path.extension() {
if ext == "gz" {
let file = std::fs::File::open(&path)
.map_err(|e| IOError::with_path(e, &path))?;
let mut contents = [0; 1024];
let mut result = String::new();
let mut gz =
flate2::read::GzDecoder::new(std::io::BufReader::new(file));
while gz
.read(&mut contents)
.map_err(|e| IOError::with_path(e, &path))?
> 0
{
result.push_str(&String::from_utf8_lossy(&contents));
contents = [0; 1024];
}
return Ok(CensoredString::censor(result, &credentials));
} else if ext == "log" {
let mut result = String::new();
let mut contents = [0; 1024];
let mut file = std::fs::File::open(&path)
.map_err(|e| IOError::with_path(e, &path))?;
// iteratively read the file to a String
while file
.read(&mut contents)
.map_err(|e| IOError::with_path(e, &path))?
> 0
{
result.push_str(&String::from_utf8_lossy(&contents));
contents = [0; 1024];
}
let result = CensoredString::censor(result, &credentials);
return Ok(result);
}
}
Err(crate::ErrorKind::OtherError(format!(
"File extension not supported: {}",
path.display()
))
.into())
}
#[tracing::instrument]
@@ -135,9 +216,9 @@ pub async fn delete_logs(profile_path: ProfilePathId) -> crate::Result<()> {
}
#[tracing::instrument]
pub async fn delete_logs_by_datetime(
pub async fn delete_logs_by_filename(
profile_path: ProfilePathId,
datetime_string: &str,
filename: &str,
) -> crate::Result<()> {
let profile_path =
if let Some(p) = crate::profile::get(&profile_path, None).await? {
@@ -151,7 +232,71 @@ pub async fn delete_logs_by_datetime(
let state = State::get().await?;
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
let path = logs_folder.join(datetime_string);
let path = logs_folder.join(filename);
io::remove_dir_all(&path).await?;
Ok(())
}
#[tracing::instrument]
pub async fn get_latest_log_cursor(
profile_path: ProfilePathId,
mut cursor: u64, // 0 to start at beginning of file
) -> crate::Result<LatestLogCursor> {
let profile_path =
if let Some(p) = crate::profile::get(&profile_path, None).await? {
p.profile_id()
} else {
return Err(crate::ErrorKind::UnmanagedProfileError(
profile_path.to_string(),
)
.into());
};
let state = State::get().await?;
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
let path = logs_folder.join("latest.log");
if !path.exists() {
// Allow silent failure if latest.log doesn't exist (as the instance may have been launched, but not yet created the file)
return Ok(LatestLogCursor {
cursor: 0,
new_file: false,
output: CensoredString("".to_string()),
});
}
let mut file = File::open(&path)
.await
.map_err(|e| IOError::with_path(e, &path))?;
let metadata = file
.metadata()
.await
.map_err(|e| IOError::with_path(e, &path))?;
let mut new_file = false;
if cursor > metadata.len() {
// Cursor is greater than file length, reset cursor to 0
// Likely cause is that the file was rotated while the log was being read
cursor = 0;
new_file = true;
}
let mut buffer = Vec::new();
file.seek(SeekFrom::Start(cursor))
.map_err(|e| IOError::with_path(e, &path))
.await?; // Seek to cursor
let bytes_read = file
.read_to_end(&mut buffer)
.map_err(|e| IOError::with_path(e, &path))
.await?; // Read to end of file
let output = String::from_utf8_lossy(&buffer).to_string(); // Convert to String
let cursor = cursor + bytes_read as u64; // Update cursor
let credentials: Vec<Credentials> =
state.users.read().await.clone().0.into_values().collect();
let output = CensoredString::censor(output, &credentials);
Ok(LatestLogCursor {
cursor,
new_file,
output,
})
}

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,28 @@ 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))
.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(())

View File

@@ -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,29 @@ 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))
.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?;
}

View File

@@ -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,28 @@ 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))
.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?;
}

View File

@@ -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,34 @@ 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))
.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(())

View File

@@ -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)
}

View File

@@ -65,10 +65,11 @@ pub enum EnvType {
Server,
}
#[derive(Serialize, Deserialize, Clone, Hash, PartialEq, Eq, Debug)]
#[derive(Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum PackDependency {
Forge,
NeoForge,
FabricLoader,
QuiltLoader,
Minecraft,
@@ -101,6 +102,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 +117,7 @@ impl Default for CreatePackProfile {
icon_url: None,
linked_data: None,
skip_install_profile: Some(true),
no_watch: Some(false),
}
}
}
@@ -150,6 +153,7 @@ pub fn get_profile_from_pack(
linked_data: Some(LinkedData {
project_id: Some(project_id),
version_id: Some(version_id),
locked: Some(true),
}),
..Default::default()
},
@@ -176,20 +180,29 @@ pub async fn generate_pack_from_version_id(
title: String,
icon_url: Option<String>,
profile_path: ProfilePathId,
// Existing loading bar. Unlike when existing_loading_bar is used, this one is pre-initialized with PackFileDownload
// For example, you might use this if multiple packs are being downloaded at once and you want to use the same loading bar
initialized_loading_bar: Option<LoadingBarId>,
) -> crate::Result<CreatePack> {
let state = State::get().await?;
let loading_bar = init_loading(
LoadingBarType::PackFileDownload {
profile_path: profile_path.get_full_path().await?,
pack_name: title,
icon: icon_url,
pack_version: version_id.clone(),
},
100.0,
"Downloading pack file",
)
.await?;
let loading_bar = if let Some(bar) = initialized_loading_bar {
emit_loading(&bar, 0.0, Some("Downloading pack file")).await?;
bar
} else {
init_loading(
LoadingBarType::PackFileDownload {
profile_path: profile_path.get_full_path().await?,
pack_name: title,
icon: icon_url,
pack_version: version_id.clone(),
},
100.0,
"Downloading pack file",
)
.await?
};
emit_loading(&loading_bar, 0.0, Some("Fetching version")).await?;
let creds = state.credentials.read().await;
@@ -310,6 +323,7 @@ pub async fn set_profile_information(
description: &CreatePackDescription,
backup_name: &str,
dependencies: &HashMap<PackDependency, String>,
ignore_lock: bool, // do not change locked status
) -> crate::Result<()> {
let mut game_version: Option<&String> = None;
let mut mod_loader = None;
@@ -321,6 +335,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);
@@ -363,6 +381,14 @@ pub async fn set_profile_information(
prof.metadata.linked_data = Some(LinkedData {
project_id: description.project_id.clone(),
version_id: description.version_id.clone(),
locked: if !ignore_lock {
Some(
description.project_id.is_some()
&& description.version_id.is_some(),
)
} else {
prof.metadata.linked_data.as_ref().and_then(|x| x.locked)
},
});
prof.metadata.icon = description.icon.clone();
prof.metadata.game_version = game_version.clone();

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
//! Theseus profile management interface
use crate::pack::install_from::CreatePackProfile;
use crate::prelude::ProfilePathId;
use crate::profile;
use crate::state::LinkedData;
use crate::util::io::{self, canonicalize};
use crate::{
event::{emit::emit_profile, ProfilePayloadType},
prelude::ModLoader,
};
use crate::{pack, profile, ErrorKind};
pub use crate::{
state::{JavaSettings, Profile},
State,
@@ -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,7 +119,9 @@ 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) {
@@ -146,10 +155,64 @@ 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> {
let profile = profile::get(&copy_from, None).await?.ok_or_else(|| {
ErrorKind::UnmanagedProfileError(copy_from.to_string())
})?;
let profile_path_id = profile_create(
profile.metadata.name.clone(),
profile.metadata.game_version.clone(),
profile.metadata.loader,
profile.metadata.loader_version.clone().map(|it| it.id),
profile.metadata.icon.clone(),
profile.metadata.icon_url.clone(),
profile.metadata.linked_data.clone(),
Some(true),
Some(true),
)
.await?;
// Copy it over using the import system (essentially importing from the same profile)
let state = State::get().await?;
let bar = pack::import::copy_dotminecraft(
profile_path_id.clone(),
copy_from.get_full_path().await?,
&state.io_semaphore,
None,
)
.await?;
crate::launcher::install_minecraft(&profile, Some(bar)).await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
}
// emit profile edited
emit_profile(
profile.uuid,
&profile.profile_id(),
&profile.metadata.name,
ProfilePayloadType::Edited,
)
.await?;
State::sync().await?;
Ok(profile_path_id)
}
#[tracing::instrument]
#[theseus_macros::debug_pin]
pub(crate) async fn get_loader_version_from_loader(
@@ -176,6 +239,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()

View File

@@ -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::{ProjectMetadata, SideType};
use crate::util::fetch;
use crate::util::io::{self, IOError};
use crate::{
auth::{self, refresh},
@@ -22,6 +23,7 @@ pub use crate::{
};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use serde_json::json;
use std::collections::HashMap;
@@ -107,6 +109,26 @@ pub async fn get_full_path(path: &ProfilePathId) -> crate::Result<PathBuf> {
Ok(full_path)
}
/// Get mod's full path in the filesystem
#[tracing::instrument]
pub async fn get_mod_full_path(
profile_path: &ProfilePathId,
project_path: &ProjectPathId,
) -> crate::Result<PathBuf> {
if get(profile_path, Some(true)).await?.is_some() {
let full_path = io::canonicalize(
project_path.get_full_path(profile_path.clone()).await?,
)?;
return Ok(full_path);
}
Err(crate::ErrorKind::OtherError(format!(
"Tried to get the full path of a nonexistent or unloaded project at path {}!",
project_path.get_full_path(profile_path.clone()).await?.display()
))
.into())
}
/// Edit a profile using a given asynchronous closure
pub async fn edit<Fut>(
path: &ProfilePathId,
@@ -541,23 +563,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)]
@@ -567,6 +572,8 @@ pub async fn export_mrpack(
export_path: PathBuf,
included_overrides: Vec<String>, // which folders to include in the overrides
version_id: Option<String>,
description: Option<String>,
_name: Option<String>,
) -> crate::Result<()> {
let state = State::get().await?;
let io_semaphore = state.io_semaphore.0.read().await;
@@ -600,7 +607,8 @@ pub async fn export_mrpack(
// Create mrpack json configuration file
let version_id = version_id.unwrap_or("1.0.0".to_string());
let packfile = create_mrpack_json(&profile, version_id).await?;
let packfile =
create_mrpack_json(&profile, version_id, description).await?;
let modrinth_path_list = get_modrinth_pack_list(&packfile);
// Build vec of all files in the folder
@@ -708,7 +716,7 @@ pub async fn get_potential_override_folders(
))
})?;
// dummy mrpack to get pack list
let mrpack = create_mrpack_json(&profile, "0".to_string()).await?;
let mrpack = create_mrpack_json(&profile, "0".to_string(), None).await?;
let mrpack_files = get_modrinth_pack_list(&mrpack);
let mut path_list: Vec<PathBuf> = Vec::new();
@@ -835,23 +843,12 @@ pub async fn run_credentials(
.unwrap_or(&settings.custom_env_args);
// Post post exit hooks
let post_exit_hook =
&profile.hooks.as_ref().unwrap_or(&settings.hooks).post_exit;
let post_exit_hook = if let Some(hook) = post_exit_hook {
let mut cmd = hook.split(' ');
if let Some(command) = cmd.next() {
let mut command = Command::new(command);
command
.args(&cmd.collect::<Vec<&str>>())
.current_dir(path.get_full_path().await?);
Some(command)
} else {
None
}
} else {
None
};
let post_exit_hook = profile
.hooks
.as_ref()
.unwrap_or(&settings.hooks)
.post_exit
.clone();
// Any options.txt settings that we want set, add here
let mut mc_set_options: Vec<(String, String)> = vec![];
@@ -878,6 +875,65 @@ pub async fn run_credentials(
Ok(mc_process)
}
/// 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
}
fn get_modrinth_pack_list(packfile: &PackFormat) -> Vec<String> {
packfile
.files
@@ -897,6 +953,7 @@ fn get_modrinth_pack_list(packfile: &PackFormat) -> Vec<String> {
pub async fn create_mrpack_json(
profile: &Profile,
version_id: String,
description: Option<String>,
) -> crate::Result<PackFormat> {
// Add loader version to dependencies
let mut dependencies = HashMap::new();
@@ -907,6 +964,9 @@ pub async fn create_mrpack_json(
(crate::prelude::ModLoader::Forge, Some(v)) => {
dependencies.insert(PackDependency::Forge, v.id)
}
(crate::prelude::ModLoader::NeoForge, Some(v)) => {
dependencies.insert(PackDependency::NeoForge, v.id)
}
(crate::prelude::ModLoader::Fabric, Some(v)) => {
dependencies.insert(PackDependency::FabricLoader, v.id)
}
@@ -930,25 +990,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: String = mod_path.get_inner_path_unix().ok()?;
// Only Modrinth projects have a modrinth metadata field for the modrinth.json
Some(Ok(match project.metadata {
crate::prelude::ProjectMetadata::Modrinth {
ref project,
ref version,
..
} => {
let mut env = HashMap::new();
env.insert(EnvType::Client, project.client_side.clone());
env.insert(EnvType::Server, project.server_side.clone());
// TODO: envtype should be a controllable option (in general or at least .mrpack exporting)
// For now, assume required.
// env.insert(EnvType::Client, project.client_side.clone());
// env.insert(EnvType::Server, project.server_side.clone());
env.insert(EnvType::Client, SideType::Required);
env.insert(EnvType::Server, SideType::Required);
let primary_file = if let Some(primary_file) =
version.files.first()
@@ -993,24 +1056,32 @@ 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 => {
let mut split: std::str::Split<'_, char> = s.split('-');
match split.next() {
Some(first) => match split.next() {
Some(second) => second,
None => first,
},
None => 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,
}
}
@@ -1037,5 +1108,5 @@ pub async fn build_folder(
}
pub fn sanitize_profile_name(input: &str) -> String {
input.replace(['/', '\\', ':'], "_")
input.replace(['/', '\\', '?', '*', ':', '\'', '\"', '|', '<', '>'], "_")
}

View File

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

View File

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

View File

@@ -140,11 +140,15 @@ impl Drop for LoadingBarId {
#[cfg(not(any(feature = "tauri", feature = "cli")))]
bars.remove(&loader_uuid);
}
let _ = SafeProcesses::complete(
crate::state::ProcessType::LoadingBar,
loader_uuid,
)
.await;
// complete calls state, and since a LoadingBarId is created in state initialization, we only complete if its already initializaed
// to avoid an infinite loop.
if crate::State::initialized() {
let _ = SafeProcesses::complete(
crate::state::ProcessType::LoadingBar,
loader_uuid,
)
.await;
}
});
}
}
@@ -185,6 +189,10 @@ pub enum LoadingBarType {
ConfigChange {
new_path: PathBuf,
},
CopyProfile {
import_location: PathBuf,
profile_name: String,
},
}
#[derive(Serialize, Clone)]

View File

@@ -268,8 +268,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")

View File

@@ -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)
}

View File

@@ -320,7 +320,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 {

View File

@@ -103,6 +103,7 @@ pub async fn install_minecraft(
profile: &Profile,
existing_loading_bar: Option<LoadingBarId>,
) -> crate::Result<()> {
let sync_projects = existing_loading_bar.is_some();
let loading_bar = init_or_edit_loading(
existing_loading_bar,
LoadingBarType::MinecraftDownload {
@@ -123,6 +124,10 @@ 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?)?;
@@ -307,7 +312,7 @@ pub async fn launch_minecraft(
memory: &st::MemorySettings,
resolution: &st::WindowSize,
credentials: &auth::Credentials,
post_exit_hook: Option<Command>,
post_exit_hook: Option<String>,
profile: &Profile,
) -> crate::Result<Arc<tokio::sync::RwLock<MinecraftChild>>> {
if profile.install_stage == ProfileInstallStage::PackInstalling
@@ -401,7 +406,6 @@ pub async fn launch_minecraft(
))
.as_error());
}
command
.args(
args::get_jvm_arguments(
@@ -442,57 +446,46 @@ pub async fn launch_minecraft(
.collect::<Vec<_>>(),
)
.current_dir(instance_path.clone())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
.stdout(Stdio::null())
.stderr(Stdio::null());
// CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground
#[cfg(target_os = "macos")]
if std::env::var("CARGO").is_ok() {
command.env_remove("DYLD_FALLBACK_LIBRARY_PATH");
}
// Java options should be set in instance options (the existence of _JAVA_OPTIONS overwites them)
command.env_remove("_JAVA_OPTIONS");
command.envs(env_args);
// Overwrites the minecraft options.txt file with the settings from the profile
// 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 +547,9 @@ pub async fn launch_minecraft(
// This also spawns the process and prepares the subsequent processes
let mut state_children = state.children.write().await;
state_children
.insert_process(
.insert_new_process(
Uuid::new_v4(),
profile.profile_id(),
stdout_log_path,
command,
post_exit_hook,
censor_strings,

View File

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

View File

@@ -1,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?;

View File

@@ -1,86 +1,278 @@
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::{collections::HashMap, sync::Arc};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use sysinfo::PidExt;
use tokio::process::Child;
use tokio::process::Command;
use tokio::process::{ChildStderr, ChildStdout};
use tokio::sync::RwLock;
use tracing::error;
use crate::event::emit::emit_process;
use crate::event::ProcessPayloadType;
use crate::util::fetch::read_json;
use crate::util::io::IOError;
use crate::{profile, ErrorKind};
use sysinfo::{ProcessExt, SystemExt};
use tokio::task::JoinHandle;
use uuid::Uuid;
const PROCESSES_JSON: &str = "processes.json";
// Child processes (instances of Minecraft)
// A wrapper over a Hashmap connecting PID -> MinecraftChild
pub struct Children(HashMap<Uuid, Arc<RwLock<MinecraftChild>>>);
// Minecraft Child, bundles together the PID, the actual Child, and the easily queryable stdout and stderr streams
#[derive(Debug)]
pub enum ChildType {
// A child process that is being managed by tokio
TokioChild(Child),
// A child process that was rescued from a cache (e.g. a process that was launched by theseus before the launcher was restarted)
// This may not have all the same functionality as a TokioChild
RescuedPID(u32),
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ProcessCache {
pub pid: u32,
pub uuid: Uuid,
pub start_time: u64,
pub name: String,
pub exe: String,
pub profile_relative_path: ProfilePathId,
pub post_command: Option<String>,
}
impl ChildType {
pub async fn try_wait(&mut self) -> crate::Result<Option<i32>> {
match self {
ChildType::TokioChild(child) => Ok(child
.try_wait()
.map_err(IOError::from)?
.map(|x| x.code().unwrap_or(0))),
ChildType::RescuedPID(pid) => {
let mut system = sysinfo::System::new();
if !system.refresh_process(sysinfo::Pid::from_u32(*pid)) {
return Ok(Some(0));
}
let process = system.process(sysinfo::Pid::from_u32(*pid));
if let Some(process) = process {
if process.status() == sysinfo::ProcessStatus::Run {
Ok(None)
} else {
Ok(Some(0))
}
} else {
Ok(Some(0))
}
}
}
}
pub async fn kill(&mut self) -> crate::Result<()> {
match self {
ChildType::TokioChild(child) => {
Ok(child.kill().await.map_err(IOError::from)?)
}
ChildType::RescuedPID(pid) => {
let mut system = sysinfo::System::new();
if system.refresh_process(sysinfo::Pid::from_u32(*pid)) {
let process = system.process(sysinfo::Pid::from_u32(*pid));
if let Some(process) = process {
process.kill();
}
}
Ok(())
}
}
}
pub fn id(&self) -> Option<u32> {
match self {
ChildType::TokioChild(child) => child.id(),
ChildType::RescuedPID(pid) => Some(*pid),
}
}
// Caches the process so that it can be restored if the launcher is restarted
// Stored in the caches/metadata/processes.json file
pub async fn cache_process(
&self,
uuid: uuid::Uuid,
profile_path_id: ProfilePathId,
post_command: Option<String>,
) -> crate::Result<()> {
let pid = match self {
ChildType::TokioChild(child) => child.id().unwrap_or(0),
ChildType::RescuedPID(pid) => *pid,
};
let state = crate::State::get().await?;
let mut system = sysinfo::System::new();
system.refresh_processes();
let process =
system.process(sysinfo::Pid::from_u32(pid)).ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Could not find process {}",
pid
))
})?;
let start_time = process.start_time();
let name = process.name().to_string();
let exe = process.exe().to_string_lossy().to_string();
let cached_process = ProcessCache {
pid,
start_time,
name,
exe,
post_command,
uuid,
profile_relative_path: profile_path_id,
};
let children_path = state
.directories
.caches_meta_dir()
.await
.join(PROCESSES_JSON);
let mut children_caches = if let Ok(children_json) =
read_json::<HashMap<uuid::Uuid, ProcessCache>>(
&children_path,
&state.io_semaphore,
)
.await
{
children_json
} else {
HashMap::new()
};
children_caches.insert(uuid, cached_process);
crate::util::fetch::write(
&children_path,
&serde_json::to_vec(&children_caches)?,
&state.io_semaphore,
)
.await?;
Ok(())
}
// Removes the process from the cache (ie: on process exit)
pub async fn remove_cache(&self, uuid: uuid::Uuid) -> crate::Result<()> {
let state = crate::State::get().await?;
let children_path = state
.directories
.caches_meta_dir()
.await
.join(PROCESSES_JSON);
let mut children_caches = if let Ok(children_json) =
read_json::<HashMap<uuid::Uuid, ProcessCache>>(
&children_path,
&state.io_semaphore,
)
.await
{
children_json
} else {
HashMap::new()
};
children_caches.remove(&uuid);
crate::util::fetch::write(
&children_path,
&serde_json::to_vec(&children_caches)?,
&state.io_semaphore,
)
.await?;
Ok(())
}
}
// Minecraft Child, bundles together the PID, the actual Child, and the easily queryable stdout and stderr streams (if needed)
#[derive(Debug)]
pub struct MinecraftChild {
pub uuid: Uuid,
pub profile_relative_path: ProfilePathId,
pub manager: Option<JoinHandle<crate::Result<ExitStatus>>>, // None when future has completed and been handled
pub current_child: Arc<RwLock<Child>>,
pub output: SharedOutput,
pub manager: Option<JoinHandle<crate::Result<i32>>>, // None when future has completed and been handled
pub current_child: Arc<RwLock<ChildType>>,
pub last_updated_playtime: DateTime<Utc>, // The last time we updated the playtime for the associated profile
}
impl Children {
pub fn new() -> Children {
pub fn new() -> Self {
Children(HashMap::new())
}
// Loads cached processes from the caches/metadata/processes.json file, re-inserts them into the hashmap, and removes them from the file
// This will only be called once, on startup. Only processes who match a cached process (name, time started, pid, etc) will be re-inserted
pub async fn rescue_cache(&mut self) -> crate::Result<()> {
let state = crate::State::get().await?;
let children_path = state
.directories
.caches_meta_dir()
.await
.join(PROCESSES_JSON);
let mut children_caches = if let Ok(children_json) =
read_json::<HashMap<uuid::Uuid, ProcessCache>>(
&children_path,
&state.io_semaphore,
)
.await
{
// Overwrite the file with an empty hashmap- we will re-insert the cached processes
let empty = HashMap::<uuid::Uuid, ProcessCache>::new();
crate::util::fetch::write(
&children_path,
&serde_json::to_vec(&empty)?,
&state.io_semaphore,
)
.await?;
// Return the cached processes
children_json
} else {
HashMap::new()
};
for (_, cache) in children_caches.drain() {
let uuid = cache.uuid;
match self.insert_cached_process(cache).await {
Ok(child) => {
self.0.insert(uuid, child);
}
Err(e) => tracing::warn!(
"Failed to rescue cached process {}: {}",
uuid,
e
),
}
}
Ok(())
}
// Runs the command in process, inserts a child process to keep track of, and returns a reference to the container struct MinecraftChild
// The threads for stdout and stderr are spawned here
// Unlike a Hashmap's 'insert', this directly returns the reference to the MinecraftChild rather than any previously stored MinecraftChild that may exist
#[tracing::instrument(skip(
self,
uuid,
log_path,
mc_command,
post_command,
censor_strings
))]
#[tracing::instrument(level = "trace", skip(self))]
#[theseus_macros::debug_pin]
pub async fn insert_process(
pub async fn insert_new_process(
&mut self,
uuid: Uuid,
profile_relative_path: ProfilePathId,
log_path: PathBuf,
mut mc_command: Command,
post_command: Option<Command>, // Command to run after minecraft.
post_command: Option<String>, // Command to run after minecraft.
censor_strings: HashMap<String, String>,
) -> crate::Result<Arc<RwLock<MinecraftChild>>> {
// Takes the first element of the commands vector and spawns it
let mut child = mc_command.spawn().map_err(IOError::from)?;
// Create std watcher threads for stdout and stderr
let shared_output =
SharedOutput::build(&log_path, censor_strings).await?;
if let Some(child_stdout) = child.stdout.take() {
let stdout_clone = shared_output.clone();
tokio::spawn(async move {
if let Err(e) = stdout_clone.read_stdout(child_stdout).await {
error!("Stdout process died with error: {}", e);
}
});
}
if let Some(child_stderr) = child.stderr.take() {
let stderr_clone = shared_output.clone();
tokio::spawn(async move {
if let Err(e) = stderr_clone.read_stderr(child_stderr).await {
error!("Stderr process died with error: {}", e);
}
});
}
let child = mc_command.spawn().map_err(IOError::from)?;
let child = ChildType::TokioChild(child);
// Slots child into manager
let pid = child.id().ok_or_else(|| {
@@ -88,12 +280,22 @@ impl Children {
"Process immediately failed, could not get PID".to_string(),
)
})?;
// Caches process so that it can be restored if the launcher is restarted
child
.cache_process(
uuid,
profile_relative_path.clone(),
post_command.clone(),
)
.await?;
let current_child = Arc::new(RwLock::new(child));
let manager = Some(tokio::spawn(Self::sequential_process_manager(
uuid,
post_command,
pid,
current_child.clone(),
profile_relative_path.clone(),
)));
emit_process(
@@ -104,13 +306,15 @@ impl Children {
)
.await?;
let last_updated_playtime = Utc::now();
// Create MinecraftChild
let mchild = MinecraftChild {
uuid,
profile_relative_path,
current_child,
output: shared_output,
manager,
last_updated_playtime,
};
let mchild = Arc::new(RwLock::new(mchild));
@@ -118,6 +322,96 @@ impl Children {
Ok(mchild)
}
// Rescues a cached process, inserts a child process to keep track of, and returns a reference to the container struct MinecraftChild
// Essentially 'reconnects' to a process that was launched by theseus before the launcher was restarted
// However, this may not have all the same functionality as a TokioChild, as we only have the PID and not the actual Child
// Only processes who match a cached process (name, time started, pid, etc) will be re-inserted. The function fails with an error if the process is notably different.
#[tracing::instrument(skip(self, cached_process,))]
#[tracing::instrument(level = "trace", skip(self))]
#[theseus_macros::debug_pin]
pub async fn insert_cached_process(
&mut self,
cached_process: ProcessCache,
) -> crate::Result<Arc<RwLock<MinecraftChild>>> {
let _state = crate::State::get().await?;
// Takes the first element of the commands vector and spawns it
// Checks processes, compares cached process to actual process
// Fails if notably different (meaning that the PID was reused, and we shouldn't reconnect to it)
{
let mut system = sysinfo::System::new();
system.refresh_processes();
let process = system
.process(sysinfo::Pid::from_u32(cached_process.pid))
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Could not find process {}",
cached_process.pid
))
})?;
if cached_process.start_time != process.start_time() {
return Err(ErrorKind::LauncherError(format!("Cached process {} has different start time than actual process {}", cached_process.pid, process.start_time())).into());
}
if cached_process.name != process.name() {
return Err(ErrorKind::LauncherError(format!("Cached process {} has different name than actual process {}", cached_process.pid, process.name())).into());
}
if cached_process.exe != process.exe().to_string_lossy() {
return Err(ErrorKind::LauncherError(format!("Cached process {} has different exe than actual process {}", cached_process.pid, process.exe().to_string_lossy())).into());
}
}
let child = ChildType::RescuedPID(cached_process.pid);
// Slots child into manager
let pid = child.id().ok_or_else(|| {
crate::ErrorKind::LauncherError(
"Process immediately failed, could not get PID".to_string(),
)
})?;
// Re-caches process so that it can be restored if the launcher is restarted
child
.cache_process(
cached_process.uuid,
cached_process.profile_relative_path.clone(),
cached_process.post_command.clone(),
)
.await?;
let current_child = Arc::new(RwLock::new(child));
let manager = Some(tokio::spawn(Self::sequential_process_manager(
cached_process.uuid,
cached_process.post_command,
pid,
current_child.clone(),
cached_process.profile_relative_path.clone(),
)));
emit_process(
cached_process.uuid,
pid,
ProcessPayloadType::Launched,
"Launched Minecraft",
)
.await?;
let last_updated_playtime = Utc::now();
// Create MinecraftChild
let mchild = MinecraftChild {
uuid: cached_process.uuid,
profile_relative_path: cached_process.profile_relative_path,
current_child,
manager,
last_updated_playtime,
};
let mchild = Arc::new(RwLock::new(mchild));
self.0.insert(cached_process.uuid, mchild.clone());
Ok(mchild)
}
// Spawns a new child process and inserts it into the hashmap
// Also, as the process ends, it spawns the follow-up process if it exists
// By convention, ExitStatus is last command's exit status, and we exit on the first non-zero exit status
@@ -125,28 +419,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 +508,12 @@ impl Children {
}
}
if !mc_exit_status.success() {
{
let current_child = current_child.write().await;
current_child.remove_cache(uuid).await?;
}
if !mc_exit_status == 0 {
emit_process(
uuid,
current_pid,
@@ -176,9 +526,28 @@ impl Children {
}
// If a post-command exist, switch to it and wait on it
// First, create the command by splitting arguments
let post_command = if let Some(hook) = post_command {
let mut cmd = hook.split(' ');
if let Some(command) = cmd.next() {
let mut command = Command::new(command);
command
.args(&cmd.collect::<Vec<&str>>())
.current_dir(associated_profile.get_full_path().await?);
Some(command)
} else {
None
}
} else {
None
};
if let Some(mut m_command) = post_command {
{
let mut current_child = current_child.write().await;
let mut current_child: tokio::sync::RwLockWriteGuard<
'_,
ChildType,
> = current_child.write().await;
let new_child = m_command.spawn().map_err(IOError::from)?;
current_pid = new_child.id().ok_or_else(|| {
crate::ErrorKind::LauncherError(
@@ -186,7 +555,7 @@ impl Children {
.to_string(),
)
})?;
*current_child = new_child;
*current_child = ChildType::TokioChild(new_child);
}
emit_process(
uuid,
@@ -197,12 +566,7 @@ impl Children {
.await?;
loop {
if let Some(t) = current_child
.write()
.await
.try_wait()
.map_err(IOError::from)?
{
if let Some(t) = current_child.write().await.try_wait().await? {
mc_exit_status = t;
break;
}
@@ -235,18 +599,10 @@ impl Children {
// Get exit status of a child by PID
// Returns None if the child is still running
pub async fn exit_status(
&self,
uuid: &Uuid,
) -> crate::Result<Option<std::process::ExitStatus>> {
pub async fn exit_status(&self, uuid: &Uuid) -> crate::Result<Option<i32>> {
if let Some(child) = self.get(uuid) {
let child = child.write().await;
let status = child
.current_child
.write()
.await
.try_wait()
.map_err(IOError::from)?;
let status = child.current_child.write().await.try_wait().await?;
Ok(status)
} else {
Ok(None)
@@ -265,7 +621,7 @@ impl Children {
.write()
.await
.try_wait()
.map_err(IOError::from)?
.await?
.is_none()
{
keys.push(key);
@@ -308,7 +664,7 @@ impl Children {
.write()
.await
.try_wait()
.map_err(IOError::from)?
.await?
.is_none()
{
profiles.push(child.profile_relative_path.clone());
@@ -331,7 +687,7 @@ impl Children {
.write()
.await
.try_wait()
.map_err(IOError::from)?
.await?
.is_none()
{
if let Some(prof) = crate::api::profile::get(
@@ -354,107 +710,3 @@ impl Default for Children {
Self::new()
}
}
// SharedOutput, a wrapper around a String that can be read from and written to concurrently
// Designed to be used with ChildStdout and ChildStderr in a tokio thread to have a simple String storage for the output of a child process
#[derive(Debug, Clone)]
pub struct SharedOutput {
output: Arc<RwLock<String>>,
log_file: Arc<RwLock<File>>,
censor_strings: HashMap<String, String>,
}
impl SharedOutput {
async fn build(
log_file_path: &Path,
censor_strings: HashMap<String, String>,
) -> crate::Result<Self> {
Ok(SharedOutput {
output: Arc::new(RwLock::new(String::new())),
log_file: Arc::new(RwLock::new(
File::create(log_file_path)
.await
.map_err(|e| IOError::with_path(e, log_file_path))?,
)),
censor_strings,
})
}
// Main entry function to a created SharedOutput, returns the log as a String
pub async fn get_output(&self) -> crate::Result<String> {
let output = self.output.read().await;
Ok(output.clone())
}
async fn read_stdout(
&self,
child_stdout: ChildStdout,
) -> crate::Result<()> {
let mut buf_reader = BufReader::new(child_stdout);
let mut line = String::new();
while buf_reader
.read_line(&mut line)
.await
.map_err(IOError::from)?
> 0
{
let val_line = self.censor_log(line.clone());
{
let mut output = self.output.write().await;
output.push_str(&val_line);
}
{
let mut log_file = self.log_file.write().await;
log_file
.write_all(val_line.as_bytes())
.await
.map_err(IOError::from)?;
}
line.clear();
}
Ok(())
}
async fn read_stderr(
&self,
child_stderr: ChildStderr,
) -> crate::Result<()> {
let mut buf_reader = BufReader::new(child_stderr);
let mut line = String::new();
while buf_reader
.read_line(&mut line)
.await
.map_err(IOError::from)?
> 0
{
let val_line = self.censor_log(line.clone());
{
let mut output = self.output.write().await;
output.push_str(&val_line);
}
{
let mut log_file = self.log_file.write().await;
log_file
.write_all(val_line.as_bytes())
.await
.map_err(IOError::from)?;
}
line.clear();
}
Ok(())
}
fn censor_log(&self, mut val: String) -> String {
for (find, replace) in &self.censor_strings {
val = val.replace(find, replace);
}
val
}
}

View File

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

View File

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

View File

@@ -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,

View File

@@ -127,6 +127,10 @@ impl State {
.await)
}
pub fn initialized() -> bool {
LAUNCHER_STATE.initialized()
}
#[tracing::instrument]
#[theseus_macros::debug_pin]
async fn initialize_state() -> crate::Result<RwLock<State>> {
@@ -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;
@@ -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(

View File

@@ -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,

View File

@@ -124,11 +124,22 @@ impl ProjectPathId {
&self,
profile: ProfilePathId,
) -> crate::Result<PathBuf> {
let _state = State::get().await?;
let profile_dir = profile.get_full_path().await?;
Ok(profile_dir.join(&self.0))
}
// Gets inner path in unix convention as a String
// ie: 'mods\myproj' -> 'mods/myproj'
// Used for exporting to mrpack, which should have a singular convention
pub fn get_inner_path_unix(&self) -> crate::Result<String> {
Ok(self
.0
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/"))
}
// Create a new ProjectPathId from a relative path
pub fn new(path: &Path) -> Self {
ProjectPathId(PathBuf::from(path))
@@ -183,12 +194,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 +225,7 @@ pub enum ModLoader {
Forge,
Fabric,
Quilt,
NeoForge,
}
impl std::fmt::Display for ModLoader {
@@ -210,6 +235,7 @@ impl std::fmt::Display for ModLoader {
Self::Forge => "Forge",
Self::Fabric => "Fabric",
Self::Quilt => "Quilt",
Self::NeoForge => "NeoForge",
})
}
}
@@ -221,6 +247,7 @@ impl ModLoader {
Self::Forge => "forge",
Self::Fabric => "fabric",
Self::Quilt => "quilt",
Self::NeoForge => "neoforge",
}
}
}
@@ -265,6 +292,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 +353,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 +742,15 @@ impl Profiles {
None
}
};
if let Some(profile) = prof {
// Clear out modrinth_logs of all files in profiles folder (these are legacy)
// TODO: should be removed in a future build
let modrinth_logs = path.join("modrinth_logs");
if modrinth_logs.exists() {
let _ = std::fs::remove_dir_all(modrinth_logs);
}
let path = io::canonicalize(path)?;
Profile::watch_fs(&path, file_watcher).await?;
profiles.insert(profile.profile_id(), profile);
@@ -832,12 +870,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 +914,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 +927,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 +1027,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>(())
}

View File

@@ -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());
}

View File

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

View File

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

View File

@@ -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>(

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_cli"
version = "0.4.0"
version = "0.5.4"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
edition = "2018"

View File

@@ -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}")),
}
}

View File

@@ -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(())
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "theseus_gui",
"private": true,
"version": "0.4.0",
"version": "0.5.4",
"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",

View File

@@ -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:

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_gui"
version = "0.4.0"
version = "0.5.4"
description = "A Tauri App"
authors = ["you"]
license = ""

View File

@@ -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?)
}

View File

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

View File

@@ -1,14 +1,14 @@
use crate::api::Result;
use theseus::{
logs::{self, Logs},
logs::{self, CensoredString, LatestLogCursor, Logs},
prelude::ProfilePathId,
};
/*
A log is a struct containing the datetime string, stdout, and stderr, as follows:
A log is a struct containing the filename string, stdout, and stderr, as follows:
pub struct Logs {
pub datetime_string: String,
pub filename: String,
pub stdout: String,
pub stderr: String,
}
@@ -18,15 +18,16 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("logs")
.invoke_handler(tauri::generate_handler![
logs_get_logs,
logs_get_logs_by_datetime,
logs_get_output_by_datetime,
logs_get_logs_by_filename,
logs_get_output_by_filename,
logs_delete_logs,
logs_delete_logs_by_datetime,
logs_delete_logs_by_filename,
logs_get_latest_log_cursor,
])
.build()
}
/// Get all Logs for a profile, sorted by datetime
/// Get all Logs for a profile, sorted by filename
#[tauri::command]
pub async fn logs_get_logs(
profile_path: ProfilePathId,
@@ -37,21 +38,21 @@ pub async fn logs_get_logs(
Ok(val)
}
/// Get a Log struct for a profile by profile id and datetime string
/// Get a Log struct for a profile by profile id and filename string
#[tauri::command]
pub async fn logs_get_logs_by_datetime(
pub async fn logs_get_logs_by_filename(
profile_path: ProfilePathId,
datetime_string: String,
filename: String,
) -> Result<Logs> {
Ok(logs::get_logs_by_datetime(profile_path, datetime_string).await?)
Ok(logs::get_logs_by_filename(profile_path, filename).await?)
}
/// Get the stdout for a profile by profile id and datetime string
/// Get the stdout for a profile by profile id and filename string
#[tauri::command]
pub async fn logs_get_output_by_datetime(
pub async fn logs_get_output_by_filename(
profile_path: ProfilePathId,
datetime_string: String,
) -> Result<String> {
filename: String,
) -> Result<CensoredString> {
let profile_path = if let Some(p) =
crate::profile::get(&profile_path, None).await?
{
@@ -63,7 +64,7 @@ pub async fn logs_get_output_by_datetime(
.into());
};
Ok(logs::get_output_by_datetime(&profile_path, &datetime_string).await?)
Ok(logs::get_output_by_filename(&profile_path, &filename).await?)
}
/// Delete all logs for a profile by profile id
@@ -72,11 +73,20 @@ pub async fn logs_delete_logs(profile_path: ProfilePathId) -> Result<()> {
Ok(logs::delete_logs(profile_path).await?)
}
/// Delete a log for a profile by profile id and datetime string
/// Delete a log for a profile by profile id and filename string
#[tauri::command]
pub async fn logs_delete_logs_by_datetime(
pub async fn logs_delete_logs_by_filename(
profile_path: ProfilePathId,
datetime_string: String,
filename: String,
) -> Result<()> {
Ok(logs::delete_logs_by_datetime(profile_path, &datetime_string).await?)
Ok(logs::delete_logs_by_filename(profile_path, &filename).await?)
}
/// Get live log from a cursor
#[tauri::command]
pub async fn logs_get_latest_log_cursor(
profile_path: ProfilePathId,
cursor: u64, // 0 to start at beginning of file
) -> Result<LatestLogCursor> {
Ok(logs::get_latest_log_cursor(profile_path, cursor).await?)
}

View File

@@ -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?)
}

View File

@@ -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,

View File

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

View File

@@ -13,6 +13,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_get,
profile_get_optimal_jre_key,
profile_get_full_path,
profile_get_mod_full_path,
profile_list,
profile_check_installed,
profile_install,
@@ -22,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,
@@ -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(
@@ -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,12 +213,16 @@ pub async fn profile_export_mrpack(
export_location: PathBuf,
included_overrides: Vec<String>,
version_id: Option<String>,
description: Option<String>,
name: Option<String>, // only used to cache
) -> Result<()> {
profile::export_mrpack(
&path,
export_location,
included_overrides,
version_id,
description,
name,
)
.await?;
Ok(())

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Modrinth App",
"version": "0.4.0"
"version": "0.5.4"
},
"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

View File

@@ -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

View File

@@ -7,9 +7,11 @@ import {
LibraryIcon,
PlusIcon,
SettingsIcon,
FileIcon,
Button,
Notifications,
XIcon,
Card,
} from 'omorphia'
import { useLoading, useTheming } from '@/store/state'
import AccountsCard from '@/components/ui/AccountsCard.vue'
@@ -19,12 +21,12 @@ import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import ModrinthLoadingIndicator from '@/components/modrinth-loading-indicator'
import { useNotifications } from '@/store/notifications.js'
import { handleError, useNotifications } from '@/store/notifications.js'
import { offline_listener, command_listener, warning_listener } from '@/helpers/events.js'
import { MinimizeIcon, MaximizeIcon } from '@/assets/icons'
import { MinimizeIcon, MaximizeIcon, ChatIcon } from '@/assets/icons'
import { type } from '@tauri-apps/api/os'
import { appWindow } from '@tauri-apps/api/window'
import { isDev, getOS, isOffline } from '@/helpers/utils.js'
import { isDev, getOS, isOffline, showLauncherLogsFolder } from '@/helpers/utils.js'
import {
mixpanel_track,
mixpanel_init,
@@ -40,6 +42,7 @@ import { confirm } from '@tauri-apps/api/dialog'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue'
import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue'
import { install_from_file } from './helpers/pack'
const themeStore = useTheming()
const urlModal = ref(null)
@@ -51,14 +54,17 @@ const showOnboarding = ref(false)
const onboardingVideo = ref()
const failureText = ref(null)
const os = ref('')
defineExpose({
initialize: async () => {
isLoading.value = false
const { theme, opt_out_analytics, collapsed_navigation, advanced_rendering, fully_onboarded } =
await get()
const os = await getOS()
// video should play if the user is not on linux, and has not onboarded
videoPlaying.value = !fully_onboarded && os !== 'Linux'
os.value = await getOS()
videoPlaying.value = !fully_onboarded && os.value !== 'Linux'
const dev = await isDev()
const version = await getVersion()
showOnboarding.value = !fully_onboarded
@@ -98,6 +104,11 @@ defineExpose({
onboardingVideo.value.play()
}
},
failure: async (e) => {
isLoading.value = false
failureText.value = e
os.value = await getOS()
},
})
const confirmClose = async () => {
@@ -112,6 +123,10 @@ const confirmClose = async () => {
}
const handleClose = async () => {
if (failureText.value != null) {
await TauriWindow.getCurrent().close()
return
}
// State should respond immeiately if it's safe to close
// If not, code is deadlocked or worse, so wait 2 seconds and then ask the user to confirm closing
// (Exception: if the user is changing config directory, which takes control of the state, and it's taking a significant amount of time for some reason)
@@ -129,6 +144,16 @@ const handleClose = async () => {
await TauriWindow.getCurrent().close()
}
const openSupport = async () => {
window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: 'https://discord.gg/modrinth',
},
})
}
TauriWindow.getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async () => {
await handleClose()
})
@@ -177,11 +202,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 +244,46 @@ command_listener((e) => {
autoplay
@ended="videoPlaying = false"
/>
<div v-if="failureText" class="failure dark-mode">
<div class="appbar-failure dark-mode">
<Button v-if="os != 'MacOS'" icon-only @click="TauriWindow.getCurrent().close()">
<XIcon />
</Button>
</div>
<div class="error-view dark-mode">
<Card class="error-text">
<div class="label">
<h3>
<span class="label__title size-card-header">Failed to initialize</span>
</h3>
</div>
<div class="error-div">
Modrinth App failed to load correctly. This may be because of a corrupted file, or because
the app is missing crucial files.
</div>
<div class="error-div">You may be able to fix it one of the following ways:</div>
<ul class="error-div">
<li>Ennsuring you are connected to the internet, then try restarting the app.</li>
<li>Redownloading the app.</li>
</ul>
<div class="error-div">
If it still does not work, you can seek support using the link below. You should provide
the following error, as well as any recent launcher logs in the folder below.
</div>
<div class="error-div">The following error was provided:</div>
<Card class="error-message">
{{ failureText.message }}
</Card>
<div class="button-row push-right">
<Button @click="showLauncherLogsFolder"><FileIcon />Open launcher logs</Button>
<Button @click="openSupport"><ChatIcon />Get support</Button>
</div>
</Card>
</div>
</div>
<SplashScreen v-else-if="!videoPlaying && isLoading" app-loading />
<OnboardingScreen v-else-if="showOnboarding" :finish="() => (showOnboarding = false)" />
<div v-else class="container">
@@ -204,10 +293,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 +306,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 +316,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 +324,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,7 +337,7 @@ command_listener((e) => {
</section>
<section class="mod-stats">
<Suspense>
<RunningAppBar data-tauri-drag-region />
<RunningAppBar />
</Suspense>
</section>
</div>
@@ -276,7 +367,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 +441,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 +463,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 +644,15 @@ command_listener((e) => {
object-fit: cover;
border-radius: var(--radius-md);
}
.button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: var(--gap-md);
.transparent {
padding: var(--gap-sm) 0;
}
}
</style>

View File

@@ -1,6 +1,5 @@
<svg
data-v-8c2610d6=""
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
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;"

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View 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

View File

@@ -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'

View 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

View File

@@ -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;
}

View File

@@ -20,7 +20,7 @@ import {
import ContextMenu from '@/components/ui/ContextMenu.vue'
import dayjs from 'dayjs'
import { useTheming } from '@/store/theme.js'
import { remove } from '@/helpers/profile.js'
import { duplicate, remove } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
const props = defineProps({
@@ -51,11 +51,17 @@ async function deleteProfile() {
}
}
const handleRightClick = (event, item) => {
async function duplicateProfile(p) {
await duplicate(p).catch(handleError)
}
const handleRightClick = (event, profilePathId) => {
const item = instanceComponents.value.find((x) => x.instance.path === profilePathId)
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open' },
{ name: 'copy' },
{ type: 'divider' },
@@ -100,6 +106,10 @@ const handleOptionsClick = async (args) => {
case 'edit':
await args.item.seeInstance()
break
case 'duplicate':
if (args.item.instance.install_stage == 'installed')
await duplicateProfile(args.item.instance.path)
break
case 'open':
await args.item.openFolder()
break
@@ -131,7 +141,7 @@ const filteredResults = computed(() => {
if (sortBy.value === 'Game version') {
instances.sort((a, b) => {
return a.metadata.name.localeCompare(b.metadata.game_version)
return a.metadata.game_version.localeCompare(b.metadata.game_version)
})
}
@@ -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>

View File

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

View File

@@ -2,6 +2,7 @@
<div
v-if="mode !== 'isolated'"
ref="button"
v-tooltip="'Minecraft accounts'"
class="button-base avatar-button"
:class="{ expanded: mode === 'expanded' }"
@click="showCard = !showCard"
@@ -11,18 +12,9 @@
:src="
selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://cdn.discordapp.com/attachments/817413688771608587/1129829843425570867/unnamed.png'
: '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
@@ -64,11 +56,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 +113,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 +128,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 +158,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 +215,12 @@ const handleClickOutside = (event) => {
}
}
const unlisten = await process_listener(async (e) => {
if (e.event === 'launched') {
await refreshValues()
}
})
onMounted(() => {
window.addEventListener('click', handleClickOutside)
})
@@ -177,6 +228,10 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
})
onUnmounted(() => {
unlisten()
})
</script>
<style scoped lang="scss">
@@ -212,7 +267,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 +275,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 +384,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>

View File

@@ -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 {

View File

@@ -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')

View File

@@ -1,5 +1,5 @@
<script setup>
import { Button, Checkbox, Modal, SendIcon, XIcon } from 'omorphia'
import { Button, Checkbox, Modal, XIcon, PlusIcon } from 'omorphia'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import { ref } from 'vue'
import { export_profile_mrpack, get_potential_override_folders } from '@/helpers/profile.js'
@@ -24,9 +24,11 @@ defineExpose({
const exportModal = ref(null)
const nameInput = ref(props.instance.metadata.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const folders = ref([])
const showingFiles = ref(false)
const themeStore = useTheming()
@@ -38,7 +40,17 @@ const initFiles = async () => {
.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>

View File

@@ -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>

View File

@@ -181,6 +181,10 @@
: 'Select profiles to import'
}}
</Button>
<ProgressBar
v-if="loading"
:progress="(importedProfiles / (totalProfiles + 0.0001)) * 100"
/>
</div>
</div>
</Modal>
@@ -212,6 +216,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 +229,7 @@ import {
get_importable_instances,
import_instance,
} from '@/helpers/import.js'
import ProgressBar from '@/components/ui/ProgressBar.vue'
const themeStore = useTheming()
@@ -288,21 +294,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 +328,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 +404,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 +439,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 +501,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 +516,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 +654,7 @@ const next = async () => {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--gap-md);
.transparent {

View File

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

View File

@@ -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,30 @@ 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 && !profile.installedMod
? 'Unpair an instance to add mods.'
: ''
"
>
<Button
:disabled="profile.installedMod || profile.installing || profile.metadata.linked_data"
@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
? 'Paired'
: 'Install'
}}
</Button>
</div>
</div>
</div>
<Card v-if="showCreation" class="creation-card">

View File

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

View File

@@ -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],
}"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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>

View File

@@ -176,7 +176,7 @@ defineProps({
</Card>
</aside>
<div ref="searchWrapper" class="search">
<Promotion class="promotion" />
<Promotion class="promotion" :external="false" query-param="?r=launcher" />
<Card class="project-type-container">
<NavRow :links="selectableProjectTypes" />
</Card>
@@ -220,7 +220,7 @@ defineProps({
<Card v-for="project in 20" :key="project" class="search-card button-base">
<div class="icon">
<Avatar
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
size="md"
class="search-icon"
/>

View File

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

View File

@@ -9,6 +9,8 @@ import QrcodeVue from 'qrcode.vue'
const loginUrl = ref(null)
const loginModal = ref()
const loginCode = ref(null)
const finalizedLogin = ref(false)
const props = defineProps({
nextPage: {
@@ -22,25 +24,28 @@ const props = defineProps({
})
async function login() {
const url = await authenticate_begin_flow().catch(handleError)
loginUrl.value = url
const loginSuccess = await authenticate_begin_flow().catch(handleError)
loginUrl.value = loginSuccess.verification_uri
loginCode.value = loginSuccess.user_code
loginModal.value.show()
await window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: url,
path: loginSuccess.verification_uri,
},
})
const loggedIn = await authenticate_await_completion().catch(handleError)
loginModal.value.hide()
props.nextPage(loggedIn[1])
const settings = await get().catch(handleError)
settings.default_user = loggedIn[0].id
settings.default_user = loggedIn.id
await set(settings).catch(handleError)
finalizedLogin.value = true
await mixpanel.track('AccountLogIn')
props.nextPage()
}
const openUrl = async () => {
@@ -52,12 +57,16 @@ const openUrl = async () => {
},
})
}
const clipboardWrite = async (a) => {
navigator.clipboard.writeText(a)
}
</script>
<template>
<div class="login-card">
<img
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
class="logo"
alt="Minecraft art"
/>
@@ -80,9 +89,6 @@ const openUrl = async () => {
<LogInIcon v-if="!finalizedLogin" />
{{ finalizedLogin ? 'Next' : 'Sign in' }}
</Button>
<Button v-if="loginUrl" class="transparent" @click="loginModal.show()">
Browser didn't open?
</Button>
</div>
<Button class="transparent" large @click="nextPage()"> Next </Button>
</div>
@@ -92,28 +98,28 @@ const openUrl = async () => {
<div class="modal-body">
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
<div class="modal-text">
<p>
Sign into Microsoft with your browser. If your browser didn't open, you can copy and open
the link below, or scan the QR code with your device.
</p>
<div class="iconified-input">
<LogInIcon />
<input type="text" :value="loginUrl" readonly />
<div class="label">Copy this code</div>
<div class="code-text">
<div class="code">
{{ loginCode }}
</div>
<Button
v-tooltip="'Copy link'"
v-tooltip="'Copy code'"
icon-only
large
color="raised"
@click="() => navigator.clipboard.writeText(loginUrl)"
@click="() => clipboardWrite(loginCode)"
>
<ClipboardCopyIcon />
</Button>
</div>
<div class="button-row">
<Button @click="openUrl">
<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="openUrl">
<GlobeIcon />
Open link
</Button>
<Button class="transparent" @click="loginModal.hide"> Cancel </Button>
</div>
</div>
</div>
@@ -196,6 +202,7 @@ const openUrl = async () => {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
width: 100%;
h2,
p {
@@ -204,6 +211,30 @@ const openUrl = async () => {
}
}
.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;
}
}
.sticker {
width: 100%;
max-width: 25rem;
@@ -217,4 +248,17 @@ const openUrl = async () => {
gap: var(--gap-sm);
align-items: center;
}
.code {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
.card {
background: var(--color-base);
color: var(--color-contrast);
padding: 0.5rem 1rem;
margin-top: 0.5rem;
}
}
</style>

View File

@@ -14,8 +14,6 @@ import {
login_2fa,
create_account,
login_pass,
get as getCreds,
login_minecraft,
} from '@/helpers/mr_auth.js'
import { handleError, useNotifications } from '@/store/state.js'
import { onMounted, ref } from 'vue'
@@ -33,10 +31,6 @@ const props = defineProps({
type: Boolean,
required: true,
},
flow: {
type: String,
default: null,
},
})
const loggingIn = ref(true)
@@ -118,15 +112,6 @@ async function createAccount() {
}
async function goToNextPage() {
const creds = await getCreds().catch(handleError)
if (!creds) {
try {
await login_minecraft(props.flow)
} catch {
/* empty */
}
}
props.nextPage()
}
@@ -211,7 +196,7 @@ onMounted(() => {
<div class="link-row">
<a v-if="loggingIn" class="button-base" @click="loggingIn = false"> Create account </a>
<a v-else class="button-base" @click="loggingIn = true">Sign in</a>
<a class="button-base" href="https://staging.modrinth.com/auth/reset-password">
<a class="button-base" href="https://modrinth.com/auth/reset-password">
Forgot password?
</a>
</div>
@@ -221,9 +206,7 @@ onMounted(() => {
<Button v-if="twoFactorCode" color="primary" large @click="signIn2fa"> Login </Button>
<Button v-else-if="loggingIn" color="primary" large @click="signIn"> Login </Button>
<Button v-else color="primary" large @click="createAccount"> Create account </Button>
<Button class="transparent" large @click="goToNextPage">
{{ modal ? 'Continue' : 'Next' }}
</Button>
<Button v-if="!modal" class="transparent" large @click="goToNextPage"> Next </Button>
</div>
</Card>
</template>

View File

@@ -8,7 +8,6 @@ import {
SettingsIcon,
XIcon,
Notifications,
LogOutIcon,
} from 'omorphia'
import { appWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from 'tauri-plugin-window-state-api'
@@ -83,15 +82,18 @@ const finishOnboarding = async () => {
async function fetchSettings() {
const fetchSettings = await get().catch(handleError)
if (!fetchSettings.java_globals) {
fetchSettings.java_globals = {}
}
if (!fetchSettings.java_globals.JAVA_17) {
const path = await auto_install_java(17).catch(handleError)
fetchSettings.java_globals.JAVA_17 = await get_jre(path).catch(handleError)
const path1 = await auto_install_java(17).catch(handleError)
fetchSettings.java_globals.JAVA_17 = await get_jre(path1).catch(handleError)
}
if (!fetchSettings.java_globals.JAVA_8) {
const path = await auto_install_java(8).catch(handleError)
fetchSettings.java_globals.JAVA_8 = await get_jre(path).catch(handleError)
const path2 = await auto_install_java(8).catch(handleError)
fetchSettings.java_globals.JAVA_8 = await get_jre(path2).catch(handleError)
}
await set(fetchSettings).catch(handleError)
@@ -157,7 +159,10 @@ onMounted(async () => {
<div class="btn icon-only" :class="{ active: phase < 4 }">
<HomeIcon />
</div>
<div class="btn icon-only" :class="{ active: phase === 4 || phase === 5 }">
<div
class="btn icon-only"
:class="{ active: phase === 4 || phase === 5, highlighted: phase === 4 }"
>
<SearchIcon />
</div>
<div
@@ -172,9 +177,6 @@ onMounted(async () => {
</div>
</div>
<div class="settings pages-list">
<Button class="active" icon-only @click="finishOnboarding">
<LogOutIcon />
</Button>
<Button class="sleek-primary" icon-only>
<PlusIcon />
</Button>
@@ -189,7 +191,11 @@ onMounted(async () => {
<Breadcrumbs data-tauri-drag-region />
</section>
<section class="mod-stats">
<FakeAppBar :show-running="phase === 7" :show-download="phase === 5">
<FakeAppBar
:show-running="phase === 7"
:show-download="phase === 5"
:exit="finishOnboarding"
>
<template #running>
<TutorialTip
:progress-function="nextPhase"
@@ -289,7 +295,7 @@ onMounted(async () => {
:previous-function="prevPhase"
:progress="phase"
title="Settings"
description="You can view and change the settings for the Modrinth App here. You can change the appearance, set and download new Java versions, and more."
description="You will be able to view and change the settings for the Modrinth App here. You can change the appearance, set and download new Java versions, and more."
/>
<TutorialTip
v-if="phase === 9"
@@ -427,12 +433,6 @@ onMounted(async () => {
background-color: var(--color-brand-highlight);
transition: all ease-in-out 0.1s;
}
&.sleek-exit {
background-color: var(--color-red);
color: var(--color-accent-contrast);
transition: all ease-in-out 0.1s;
}
}
}

Some files were not shown because too many files have changed in this diff Show More