Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef8b525376 | ||
|
|
e39635c75b | ||
|
|
260744c8af | ||
|
|
54114e6e94 | ||
|
|
1bd721d523 | ||
|
|
c1518c52f3 | ||
|
|
531b38e562 | ||
|
|
fd299aabe8 | ||
|
|
4b1a3eb41e | ||
|
|
a5739fa7e2 | ||
|
|
25662d1402 | ||
|
|
01ab507e3a | ||
|
|
4491d50935 | ||
|
|
3c2889714a | ||
|
|
eb6e7d1491 | ||
|
|
a8eb561774 | ||
|
|
6152eeefe3 | ||
|
|
b8b1668fee | ||
|
|
aaf808477e | ||
|
|
8e3ddbcfaf | ||
|
|
a17e096d94 | ||
|
|
f5c7f90d19 | ||
|
|
bd18dbdbe8 | ||
|
|
696000546b | ||
|
|
dc5785c874 | ||
|
|
afaec4b1bf | ||
|
|
7fb8850071 | ||
|
|
8ccc7dfcd2 | ||
|
|
da07d7328d | ||
|
|
772597ce2a | ||
|
|
e76a7d57c0 | ||
|
|
ebc4da6c29 | ||
|
|
f73c112e07 | ||
|
|
7fbc9fa357 | ||
|
|
6f8ffcaf35 |
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -26,7 +26,8 @@ body:
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes: System information
|
||||
attributes:
|
||||
label: System information
|
||||
description: Add any information about what OS you are on (like Windows or Mac), and what version of the app you are using.
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
1
.github/workflows/tauri-build.yml
vendored
1
.github/workflows/tauri-build.yml
vendored
@@ -78,6 +78,7 @@ jobs:
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -4685,7 +4685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"async-tungstenite",
|
||||
@@ -4733,7 +4733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_cli"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"argh",
|
||||
"color-eyre",
|
||||
@@ -4760,7 +4760,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_gui"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cocoa",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
|
||||
@@ -68,10 +68,14 @@ pub async fn refresh(user: uuid::Uuid) -> crate::Result<Credentials> {
|
||||
}
|
||||
|
||||
// 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())
|
||||
})?;
|
||||
let player_info =
|
||||
hydra::stages::player_info::fetch_info(&credentials.access_token)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
crate::ErrorKind::HydraError(
|
||||
"No Minecraft account for your profile. Please try again or contact support in our Discord for help!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
credentials.username = player_info.name;
|
||||
users.insert(&credentials).await?;
|
||||
|
||||
@@ -41,7 +41,7 @@ pub async fn wait_finish(device_code: String) -> crate::Result<Credentials> {
|
||||
}
|
||||
xsts_token::XSTSResponse::Success { token: xsts_token } => {
|
||||
// Get xsts bearer token from xsts token
|
||||
let bearer_token =
|
||||
let (bearer_token, expires_in) =
|
||||
bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
@@ -63,8 +63,7 @@ pub async fn wait_finish(device_code: String) -> crate::Result<Credentials> {
|
||||
player_info.name,
|
||||
bearer_token,
|
||||
oauth.refresh_token,
|
||||
chrono::Utc::now()
|
||||
+ chrono::Duration::seconds(oauth.expires_in),
|
||||
chrono::Utc::now() + chrono::Duration::seconds(expires_in),
|
||||
);
|
||||
|
||||
// Put credentials into state
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
//! 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;
|
||||
use super::{stages::auth_retry, MICROSOFT_CLIENT_ID};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct DeviceLoginSuccess {
|
||||
@@ -19,22 +17,26 @@ pub struct DeviceLoginSuccess {
|
||||
|
||||
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(¶ms).send().await?;
|
||||
let resp = auth_retry(|| REQWEST_CLIENT.get("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
|
||||
.header("Content-Length", "0")
|
||||
.query(&[
|
||||
("client_id", MICROSOFT_CLIENT_ID),
|
||||
(
|
||||
"scope",
|
||||
"XboxLive.signin XboxLive.offline_access profile openid email",
|
||||
),
|
||||
])
|
||||
.send()
|
||||
).await?;
|
||||
|
||||
match req.status() {
|
||||
reqwest::StatusCode::OK => Ok(req.json().await?),
|
||||
match resp.status() {
|
||||
reqwest::StatusCode::OK => Ok(resp.json().await?),
|
||||
_ => {
|
||||
let microsoft_error = req.json::<MicrosoftError>().await?;
|
||||
let microsoft_error = resp.json::<MicrosoftError>().await?;
|
||||
Err(crate::ErrorKind::HydraError(format!(
|
||||
"Error from Microsoft: {:?}",
|
||||
microsoft_error.error_description
|
||||
|
||||
@@ -8,6 +8,8 @@ use crate::{
|
||||
util::fetch::REQWEST_CLIENT,
|
||||
};
|
||||
|
||||
use super::stages::auth_retry;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OauthSuccess {
|
||||
pub token_type: String,
|
||||
@@ -22,14 +24,21 @@ pub async fn refresh(refresh_token: String) -> crate::Result<OauthSuccess> {
|
||||
params.insert("grant_type", "refresh_token");
|
||||
params.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||
params.insert("refresh_token", &refresh_token);
|
||||
params.insert(
|
||||
"redirect_uri",
|
||||
"https://login.microsoftonline.com/common/oauth2/nativeclient",
|
||||
);
|
||||
|
||||
// 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
|
||||
let resp =
|
||||
auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
match resp.status() {
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const MCSERVICES_AUTH_URL: &str =
|
||||
"https://api.minecraftservices.com/launcher/login";
|
||||
"https://api.minecraftservices.com/authentication/login_with_xbox";
|
||||
|
||||
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?;
|
||||
#[derive(Deserialize)]
|
||||
pub struct BearerTokenResponse {
|
||||
access_token: String,
|
||||
expires_in: i64,
|
||||
}
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(&body)?
|
||||
.get("access_token")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(String::from)
|
||||
.ok_or(
|
||||
#[tracing::instrument]
|
||||
pub async fn fetch_bearer(
|
||||
token: &str,
|
||||
uhs: &str,
|
||||
) -> crate::Result<(String, i64)> {
|
||||
let body = auth_retry(|| {
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(MCSERVICES_AUTH_URL)
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({
|
||||
"identityToken": format!("XBL3.0 x={};{}", uhs, token),
|
||||
}))
|
||||
.send()
|
||||
})
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
serde_json::from_str::<BearerTokenResponse>(&body)
|
||||
.map(|x| (x.access_token, x.expires_in))
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Response didn't contain valid bearer token. body: {body}"
|
||||
))
|
||||
.into(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
//! MSA authentication stages
|
||||
|
||||
use futures::Future;
|
||||
use reqwest::Response;
|
||||
|
||||
const RETRY_COUNT: usize = 9; // Does command 3 times
|
||||
const RETRY_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
pub mod bearer_token;
|
||||
pub mod player_info;
|
||||
pub mod poll_response;
|
||||
pub mod xbl_signin;
|
||||
pub mod xsts_token;
|
||||
|
||||
#[tracing::instrument(skip(reqwest_request))]
|
||||
pub async fn auth_retry<F>(
|
||||
reqwest_request: impl Fn() -> F,
|
||||
) -> crate::Result<reqwest::Response>
|
||||
where
|
||||
F: Future<Output = Result<Response, reqwest::Error>>,
|
||||
{
|
||||
let mut resp = reqwest_request().await?;
|
||||
for i in 0..RETRY_COUNT {
|
||||
if resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
tracing::debug!(
|
||||
"Request failed with status code {}, retrying...",
|
||||
resp.status()
|
||||
);
|
||||
if i < RETRY_COUNT - 1 {
|
||||
tokio::time::sleep(RETRY_WAIT).await;
|
||||
}
|
||||
resp = reqwest_request().await?;
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Fetch player info for display
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::util::fetch::REQWEST_CLIENT;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -13,21 +17,30 @@ impl Default for PlayerInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: "606e2ff0ed7748429d6ce1d3321c7838".to_string(),
|
||||
name: String::from("???"),
|
||||
name: String::from("Unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
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?;
|
||||
auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.get("https://api.minecraftservices.com/entitlements/mcstore")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.get(PROFILE_URL)
|
||||
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let resp = response.error_for_status()?.json().await?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use crate::{
|
||||
util::fetch::REQWEST_CLIENT,
|
||||
};
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OauthSuccess {
|
||||
pub token_type: String,
|
||||
@@ -17,23 +19,29 @@ pub struct OauthSuccess {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn poll_response(device_code: String) -> crate::Result<OauthSuccess> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
||||
params.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||
params.insert("device_code", &device_code);
|
||||
params.insert(
|
||||
"scope",
|
||||
"XboxLive.signin XboxLive.offline_access profile openid email",
|
||||
);
|
||||
|
||||
// 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
|
||||
let resp = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post(
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
|
||||
)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
})
|
||||
.await?;
|
||||
|
||||
match resp.status() {
|
||||
StatusCode::OK => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::util::fetch::REQWEST_CLIENT;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const XBL_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
|
||||
|
||||
// Deserialization
|
||||
@@ -9,25 +13,25 @@ pub struct XBLLogin {
|
||||
}
|
||||
|
||||
// Impl
|
||||
#[tracing::instrument]
|
||||
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 response = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post(XBL_AUTH_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.json(&json!({
|
||||
"Properties": {
|
||||
"AuthMethod": "RPS",
|
||||
"SiteName": "user.auth.xboxlive.com",
|
||||
"RpsTicket": format!("d={token}")
|
||||
},
|
||||
"RelyingParty": "http://auth.xboxlive.com",
|
||||
"TokenType": "JWT"
|
||||
}))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let body = response.text().await?;
|
||||
|
||||
let json = serde_json::from_str::<serde_json::Value>(&body)?;
|
||||
let token = Some(&json)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::util::fetch::REQWEST_CLIENT;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
|
||||
|
||||
pub enum XSTSResponse {
|
||||
@@ -7,23 +11,25 @@ pub enum XSTSResponse {
|
||||
Success { token: String },
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
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 resp = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post(XSTS_AUTH_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.json(&json!({
|
||||
"Properties": {
|
||||
"SandboxId": "RETAIL",
|
||||
"UserTokens": [
|
||||
token
|
||||
]
|
||||
},
|
||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||
"TokenType": "JWT"
|
||||
}))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
|
||||
let body = resp.text().await?;
|
||||
|
||||
@@ -127,7 +127,7 @@ 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() {
|
||||
if let Some(dir) = file.split('/').next() {
|
||||
let path = path.join(dir);
|
||||
|
||||
if path.exists() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::io::{Read, SeekFrom};
|
||||
|
||||
use crate::{
|
||||
prelude::Credentials,
|
||||
prelude::{Credentials, DirectoryInfo},
|
||||
util::io::{self, IOError},
|
||||
{state::ProfilePathId, State},
|
||||
};
|
||||
@@ -74,7 +74,6 @@ pub async fn get_logs(
|
||||
profile_path: ProfilePathId,
|
||||
clear_contents: Option<bool>,
|
||||
) -> crate::Result<Vec<Logs>> {
|
||||
let state = State::get().await?;
|
||||
let profile_path =
|
||||
if let Some(p) = crate::profile::get(&profile_path, None).await? {
|
||||
p.profile_id()
|
||||
@@ -85,7 +84,7 @@ pub async fn get_logs(
|
||||
.into());
|
||||
};
|
||||
|
||||
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
|
||||
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
|
||||
let mut logs = Vec::new();
|
||||
if logs_folder.exists() {
|
||||
for entry in std::fs::read_dir(&logs_folder)
|
||||
@@ -138,8 +137,7 @@ pub async fn get_output_by_filename(
|
||||
file_name: &str,
|
||||
) -> crate::Result<CensoredString> {
|
||||
let state = State::get().await?;
|
||||
let logs_folder =
|
||||
state.directories.profile_logs_dir(profile_subpath).await?;
|
||||
let logs_folder = DirectoryInfo::profile_logs_dir(profile_subpath).await?;
|
||||
let path = logs_folder.join(file_name);
|
||||
|
||||
let credentials: Vec<Credentials> =
|
||||
@@ -201,8 +199,7 @@ pub async fn delete_logs(profile_path: ProfilePathId) -> crate::Result<()> {
|
||||
.into());
|
||||
};
|
||||
|
||||
let state = State::get().await?;
|
||||
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
|
||||
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
|
||||
for entry in std::fs::read_dir(&logs_folder)
|
||||
.map_err(|e| IOError::with_path(e, &logs_folder))?
|
||||
{
|
||||
@@ -230,8 +227,7 @@ pub async fn delete_logs_by_filename(
|
||||
.into());
|
||||
};
|
||||
|
||||
let state = State::get().await?;
|
||||
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
|
||||
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
|
||||
let path = logs_folder.join(filename);
|
||||
io::remove_dir_all(&path).await?;
|
||||
Ok(())
|
||||
@@ -240,6 +236,23 @@ pub async fn delete_logs_by_filename(
|
||||
#[tracing::instrument]
|
||||
pub async fn get_latest_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
cursor: u64, // 0 to start at beginning of file
|
||||
) -> crate::Result<LatestLogCursor> {
|
||||
get_generic_live_log_cursor(profile_path, "latest.log", cursor).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_std_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
cursor: u64, // 0 to start at beginning of file
|
||||
) -> crate::Result<LatestLogCursor> {
|
||||
get_generic_live_log_cursor(profile_path, "latest_stdout.log", cursor).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_generic_live_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
log_file_name: &str,
|
||||
mut cursor: u64, // 0 to start at beginning of file
|
||||
) -> crate::Result<LatestLogCursor> {
|
||||
let profile_path =
|
||||
@@ -253,8 +266,8 @@ pub async fn get_latest_log_cursor(
|
||||
};
|
||||
|
||||
let state = State::get().await?;
|
||||
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
|
||||
let path = logs_folder.join("latest.log");
|
||||
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
|
||||
let path = logs_folder.join(log_file_name);
|
||||
if !path.exists() {
|
||||
// Allow silent failure if latest.log doesn't exist (as the instance may have been launched, but not yet created the file)
|
||||
return Ok(LatestLogCursor {
|
||||
|
||||
@@ -245,8 +245,12 @@ async fn import_atlauncher_unmanaged(
|
||||
if let Some(profile_val) =
|
||||
crate::api::profile::get(&profile_path, None).await?
|
||||
{
|
||||
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
|
||||
.await?;
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let state = State::get().await?;
|
||||
let mut file_watcher = state.file_watcher.write().await;
|
||||
|
||||
@@ -16,37 +16,22 @@ use crate::{
|
||||
|
||||
use super::{copy_dotminecraft, recache_icon};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlameManifest {
|
||||
pub manifest_version: u8,
|
||||
pub name: String,
|
||||
pub minecraft: FlameMinecraft,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlameMinecraft {
|
||||
pub version: String,
|
||||
pub mod_loaders: Vec<FlameModLoader>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlameModLoader {
|
||||
pub id: String,
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MinecraftInstance {
|
||||
pub name: Option<String>,
|
||||
pub base_mod_loader: Option<MinecraftInstanceModLoader>,
|
||||
pub profile_image_path: Option<PathBuf>,
|
||||
pub installed_modpack: Option<InstalledModpack>,
|
||||
pub game_version: String, // Minecraft game version. Non-prioritized, use this if Vanilla
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
pub struct MinecraftInstanceModLoader {
|
||||
pub name: String,
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstalledModpack {
|
||||
pub thumbnail_url: Option<String>,
|
||||
}
|
||||
@@ -113,35 +98,26 @@ pub async fn import_curseforge(
|
||||
}
|
||||
}
|
||||
|
||||
// Curseforge vanilla profile may not have a manifest.json, so we allow it to not exist
|
||||
if curseforge_instance_folder.join("manifest.json").exists() {
|
||||
// Load manifest.json
|
||||
let cf_manifest: String = io::read_to_string(
|
||||
&curseforge_instance_folder.join("manifest.json"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cf_manifest: FlameManifest =
|
||||
serde_json::from_str::<FlameManifest>(&cf_manifest)?;
|
||||
|
||||
let game_version = cf_manifest.minecraft.version;
|
||||
// base mod loader is always None for vanilla
|
||||
if let Some(instance_mod_loader) = minecraft_instance.base_mod_loader {
|
||||
let game_version = minecraft_instance.game_version;
|
||||
|
||||
// CF allows Forge, Fabric, and Vanilla
|
||||
let mut mod_loader = None;
|
||||
let mut loader_version = None;
|
||||
for loader in cf_manifest.minecraft.mod_loaders {
|
||||
match loader.id.split_once('-') {
|
||||
Some(("forge", version)) => {
|
||||
mod_loader = Some(ModLoader::Forge);
|
||||
loader_version = Some(version.to_string());
|
||||
}
|
||||
Some(("fabric", version)) => {
|
||||
mod_loader = Some(ModLoader::Fabric);
|
||||
loader_version = Some(version.to_string());
|
||||
}
|
||||
_ => {}
|
||||
|
||||
match instance_mod_loader.name.split('-').collect::<Vec<&str>>()[..] {
|
||||
["forge", version] => {
|
||||
mod_loader = Some(ModLoader::Forge);
|
||||
loader_version = Some(version.to_string());
|
||||
}
|
||||
["fabric", version, _game_version] => {
|
||||
mod_loader = Some(ModLoader::Fabric);
|
||||
loader_version = Some(version.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let mod_loader = mod_loader.unwrap_or(ModLoader::Vanilla);
|
||||
|
||||
let loader_version = if mod_loader != ModLoader::Vanilla {
|
||||
@@ -170,7 +146,7 @@ pub async fn import_curseforge(
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
// If no manifest is found, it's a vanilla profile
|
||||
// create a vanilla profile
|
||||
crate::api::profile::edit(&profile_path, |prof| {
|
||||
prof.metadata.name = override_title
|
||||
.clone()
|
||||
@@ -199,8 +175,12 @@ pub async fn import_curseforge(
|
||||
if let Some(profile_val) =
|
||||
crate::api::profile::get(&profile_path, None).await?
|
||||
{
|
||||
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
|
||||
.await?;
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let state = State::get().await?;
|
||||
|
||||
@@ -112,8 +112,12 @@ pub async fn import_gdlauncher(
|
||||
if let Some(profile_val) =
|
||||
crate::api::profile::get(&profile_path, None).await?
|
||||
{
|
||||
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
|
||||
.await?;
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let state = State::get().await?;
|
||||
let mut file_watcher = state.file_watcher.write().await;
|
||||
|
||||
@@ -323,8 +323,12 @@ async fn import_mmc_unmanaged(
|
||||
if let Some(profile_val) =
|
||||
crate::api::profile::get(&profile_path, None).await?
|
||||
{
|
||||
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
|
||||
.await?;
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let state = State::get().await?;
|
||||
let mut file_watcher = state.file_watcher.write().await;
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::util::fetch::{
|
||||
fetch, fetch_advanced, fetch_json, write_cached_icon,
|
||||
};
|
||||
use crate::util::io;
|
||||
use crate::State;
|
||||
use crate::{InnerProjectPathUnix, State};
|
||||
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -33,7 +33,7 @@ pub struct PackFormat {
|
||||
#[derive(Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackFile {
|
||||
pub path: String,
|
||||
pub path: InnerProjectPathUnix,
|
||||
pub hashes: HashMap<PackFileHash, String>,
|
||||
pub env: Option<HashMap<EnvType, SideType>>,
|
||||
pub downloads: Vec<String>,
|
||||
@@ -66,12 +66,21 @@ pub enum EnvType {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq, Debug)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PackDependency {
|
||||
#[serde(rename = "forge")]
|
||||
Forge,
|
||||
|
||||
#[serde(rename = "neoforge")]
|
||||
#[serde(alias = "neo-forge")]
|
||||
NeoForge,
|
||||
|
||||
#[serde(rename = "fabric-loader")]
|
||||
FabricLoader,
|
||||
|
||||
#[serde(rename = "quilt-loader")]
|
||||
QuiltLoader,
|
||||
|
||||
#[serde(rename = "minecraft")]
|
||||
Minecraft,
|
||||
}
|
||||
|
||||
@@ -378,18 +387,26 @@ pub async fn set_profile_information(
|
||||
.clone()
|
||||
.unwrap_or_else(|| backup_name.to_string());
|
||||
prof.install_stage = ProfileInstallStage::PackInstalling;
|
||||
prof.metadata.linked_data = Some(LinkedData {
|
||||
project_id: description.project_id.clone(),
|
||||
version_id: description.version_id.clone(),
|
||||
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)
|
||||
},
|
||||
});
|
||||
|
||||
let project_id = description.project_id.clone();
|
||||
let version_id = description.version_id.clone();
|
||||
|
||||
prof.metadata.linked_data = if project_id.is_some()
|
||||
&& version_id.is_some()
|
||||
{
|
||||
Some(LinkedData {
|
||||
project_id,
|
||||
version_id,
|
||||
locked: if !ignore_lock {
|
||||
Some(true)
|
||||
} else {
|
||||
prof.metadata.linked_data.as_ref().and_then(|x| x.locked)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
prof.metadata.icon = description.icon.clone();
|
||||
prof.metadata.game_version = game_version.clone();
|
||||
prof.metadata.loader_version = loader_version.clone();
|
||||
|
||||
@@ -189,10 +189,7 @@ 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 project_path = project.path.to_string();
|
||||
|
||||
let path =
|
||||
std::path::Path::new(&project_path).components().next();
|
||||
@@ -286,8 +283,12 @@ pub async fn install_zipped_mrpack_files(
|
||||
}
|
||||
|
||||
if let Some(profile_val) = profile::get(&profile_path, None).await? {
|
||||
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
|
||||
.await?;
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
State::sync().await?;
|
||||
}
|
||||
@@ -403,7 +404,10 @@ pub async fn remove_all_related_files(
|
||||
// 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);
|
||||
let path: PathBuf = profile_path
|
||||
.get_full_path()
|
||||
.await?
|
||||
.join(file.path.to_string());
|
||||
if path.exists() {
|
||||
io::remove_file(&path).await?;
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ pub async fn profile_create(
|
||||
}
|
||||
|
||||
if !skip_install_profile.unwrap_or(false) {
|
||||
crate::launcher::install_minecraft(&profile, None).await?;
|
||||
crate::launcher::install_minecraft(&profile, None, false).await?;
|
||||
}
|
||||
State::sync().await?;
|
||||
|
||||
@@ -163,6 +163,7 @@ pub async fn profile_create_from_creator(
|
||||
pub async fn profile_create_from_duplicate(
|
||||
copy_from: ProfilePathId,
|
||||
) -> crate::Result<ProfilePathId> {
|
||||
// Original profile
|
||||
let profile = profile::get(©_from, None).await?.ok_or_else(|| {
|
||||
ErrorKind::UnmanagedProfileError(copy_from.to_string())
|
||||
})?;
|
||||
@@ -190,7 +191,13 @@ pub async fn profile_create_from_duplicate(
|
||||
)
|
||||
.await?;
|
||||
|
||||
crate::launcher::install_minecraft(&profile, Some(bar)).await?;
|
||||
let duplicated_profile =
|
||||
profile::get(&profile_path_id, None).await?.ok_or_else(|| {
|
||||
ErrorKind::UnmanagedProfileError(profile_path_id.to_string())
|
||||
})?;
|
||||
|
||||
crate::launcher::install_minecraft(&duplicated_profile, Some(bar), false)
|
||||
.await?;
|
||||
{
|
||||
let state = State::get().await?;
|
||||
let mut file_watcher = state.file_watcher.write().await;
|
||||
@@ -265,8 +272,8 @@ pub(crate) async fn get_loader_version_from_loader(
|
||||
|
||||
let loader_version = loaders
|
||||
.iter()
|
||||
.find(|&x| filter(x))
|
||||
.cloned()
|
||||
.find(filter)
|
||||
.or(
|
||||
// If stable was searched for but not found, return latest by default
|
||||
if version == "stable" {
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::pack::install_from::{
|
||||
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
|
||||
};
|
||||
use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId};
|
||||
use crate::state::{ProjectMetadata, SideType};
|
||||
use crate::state::{InnerProjectPathUnix, ProjectMetadata, SideType};
|
||||
|
||||
use crate::util::fetch;
|
||||
use crate::util::io::{self, IOError};
|
||||
@@ -25,8 +25,9 @@ use async_zip::tokio::write::ZipFileWriter;
|
||||
use async_zip::{Compression, ZipEntryBuilder};
|
||||
use serde_json::json;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use std::iter::FromIterator;
|
||||
use std::{
|
||||
future::Future,
|
||||
path::{Path, PathBuf},
|
||||
@@ -280,9 +281,9 @@ pub async fn list(
|
||||
|
||||
/// Installs/Repairs a profile
|
||||
#[tracing::instrument]
|
||||
pub async fn install(path: &ProfilePathId) -> crate::Result<()> {
|
||||
pub async fn install(path: &ProfilePathId, force: bool) -> crate::Result<()> {
|
||||
if let Some(profile) = get(path, None).await? {
|
||||
crate::launcher::install_minecraft(&profile, None).await?;
|
||||
crate::launcher::install_minecraft(&profile, None, force).await?;
|
||||
} else {
|
||||
return Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
|
||||
.as_error());
|
||||
@@ -388,6 +389,10 @@ pub async fn update_project(
|
||||
.add_project_version(update_version.id.clone())
|
||||
.await?;
|
||||
|
||||
if project.disabled {
|
||||
profile.toggle_disable_project(&path).await?;
|
||||
}
|
||||
|
||||
if path != project_path.clone() {
|
||||
profile.remove_project(project_path, Some(true)).await?;
|
||||
}
|
||||
@@ -570,7 +575,7 @@ pub async fn remove_project(
|
||||
pub async fn export_mrpack(
|
||||
profile_path: &ProfilePathId,
|
||||
export_path: PathBuf,
|
||||
included_overrides: Vec<String>, // which folders to include in the overrides
|
||||
included_export_candidates: Vec<String>, // which folders/files to include in the export
|
||||
version_id: Option<String>,
|
||||
description: Option<String>,
|
||||
_name: Option<String>,
|
||||
@@ -585,8 +590,8 @@ pub async fn export_mrpack(
|
||||
))
|
||||
})?;
|
||||
|
||||
// remove .DS_Store files from included_overrides
|
||||
let included_overrides = included_overrides
|
||||
// remove .DS_Store files from included_export_candidates
|
||||
let included_export_candidates = included_export_candidates
|
||||
.into_iter()
|
||||
.filter(|x| {
|
||||
if let Some(f) = PathBuf::from(x).file_name() {
|
||||
@@ -607,13 +612,17 @@ pub async fn export_mrpack(
|
||||
|
||||
// Create mrpack json configuration file
|
||||
let version_id = version_id.unwrap_or("1.0.0".to_string());
|
||||
let packfile =
|
||||
let mut packfile =
|
||||
create_mrpack_json(&profile, version_id, description).await?;
|
||||
let modrinth_path_list = get_modrinth_pack_list(&packfile);
|
||||
let included_candidates_set =
|
||||
HashSet::<_>::from_iter(included_export_candidates.iter());
|
||||
packfile.files.retain(|f| {
|
||||
included_candidates_set.contains(&f.path.get_topmost_two_components())
|
||||
});
|
||||
|
||||
// Build vec of all files in the folder
|
||||
let mut path_list = Vec::new();
|
||||
build_folder(profile_base_path, &mut path_list).await?;
|
||||
add_all_recursive_folder_paths(profile_base_path, &mut path_list).await?;
|
||||
|
||||
// Initialize loading bar
|
||||
let loading_bar = init_loading(
|
||||
@@ -631,38 +640,13 @@ pub async fn export_mrpack(
|
||||
for path in path_list {
|
||||
emit_loading(&loading_bar, 1.0, None).await?;
|
||||
|
||||
// Get local path of file, relative to profile folder
|
||||
let relative_path = path.strip_prefix(profile_base_path)?;
|
||||
|
||||
// Get highest level folder pair ('a/b' in 'a/b/c', 'a' in 'a')
|
||||
// We only go one layer deep for the sake of not having a huge list of overrides
|
||||
let topmost_two = relative_path.iter().take(2).collect::<Vec<_>>();
|
||||
|
||||
// a,b => a/b
|
||||
// a => a
|
||||
let topmost = match topmost_two.len() {
|
||||
2 => PathBuf::from(topmost_two[0]).join(topmost_two[1]),
|
||||
1 => PathBuf::from(topmost_two[0]),
|
||||
_ => {
|
||||
return Err(crate::ErrorKind::OtherError(
|
||||
"No topmost folder found".to_string(),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
if !included_overrides.contains(&topmost) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative_path: std::borrow::Cow<str> =
|
||||
relative_path.to_string_lossy();
|
||||
let relative_path = relative_path.replace('\\', "/");
|
||||
let relative_path = relative_path.trim_start_matches('/').to_string();
|
||||
|
||||
if modrinth_path_list.contains(&relative_path) {
|
||||
let relative_path = ProjectPathId::from_fs_path(&path)
|
||||
.await?
|
||||
.get_inner_path_unix();
|
||||
if packfile.files.iter().any(|f| f.path == relative_path)
|
||||
|| !included_candidates_set
|
||||
.contains(&relative_path.get_topmost_two_components())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -696,30 +680,28 @@ pub async fn export_mrpack(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Given a folder path, populate a Vec of all the subfolders
|
||||
// Intended to be used for finding potential override folders
|
||||
// Given a folder path, populate a Vec of all the subfolders and files, at most 2 layers deep
|
||||
// profile
|
||||
// -- folder1
|
||||
// -- folder2
|
||||
// -- innerfolder
|
||||
// -- innerfile
|
||||
// -- folder2file
|
||||
// -- file1
|
||||
// => [folder1, folder2]
|
||||
// => [folder1, folder2/innerfolder, folder2/folder2file, file1]
|
||||
#[tracing::instrument]
|
||||
pub async fn get_potential_override_folders(
|
||||
profile_path: ProfilePathId,
|
||||
) -> crate::Result<Vec<PathBuf>> {
|
||||
pub async fn get_pack_export_candidates(
|
||||
profile_path: &ProfilePathId,
|
||||
) -> crate::Result<Vec<InnerProjectPathUnix>> {
|
||||
// First, get a dummy mrpack json for the files within
|
||||
let profile: Profile =
|
||||
get(&profile_path, None).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Tried to export a nonexistent or unloaded profile at path {}!",
|
||||
profile_path
|
||||
))
|
||||
})?;
|
||||
// dummy mrpack to get pack list
|
||||
let mrpack = create_mrpack_json(&profile, "0".to_string(), None).await?;
|
||||
let mrpack_files = get_modrinth_pack_list(&mrpack);
|
||||
let profile: Profile = get(profile_path, None).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Tried to export a nonexistent or unloaded profile at path {}!",
|
||||
profile_path
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut path_list: Vec<PathBuf> = Vec::new();
|
||||
let mut path_list: Vec<InnerProjectPathUnix> = Vec::new();
|
||||
|
||||
let profile_base_dir = profile.get_profile_full_path().await?;
|
||||
let mut read_dir = io::read_dir(&profile_base_dir).await?;
|
||||
@@ -738,16 +720,16 @@ pub async fn get_potential_override_folders(
|
||||
.map_err(|e| IOError::with_path(e, &profile_base_dir))?
|
||||
{
|
||||
let path: PathBuf = entry.path();
|
||||
let name = path.strip_prefix(&profile_base_dir)?.to_path_buf();
|
||||
if !mrpack_files.contains(&name.to_string_lossy().to_string()) {
|
||||
path_list.push(name);
|
||||
if let Ok(project_path) =
|
||||
ProjectPathId::from_fs_path(&path).await
|
||||
{
|
||||
path_list.push(project_path.get_inner_path_unix());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// One layer of files/folders if its a file
|
||||
let name = path.strip_prefix(&profile_base_dir)?.to_path_buf();
|
||||
if !mrpack_files.contains(&name.to_string_lossy().to_string()) {
|
||||
path_list.push(name);
|
||||
if let Ok(project_path) = ProjectPathId::from_fs_path(&path).await {
|
||||
path_list.push(project_path.get_inner_path_unix());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -934,19 +916,6 @@ pub async fn try_update_playtime(path: &ProfilePathId) -> crate::Result<()> {
|
||||
res
|
||||
}
|
||||
|
||||
fn get_modrinth_pack_list(packfile: &PackFormat) -> Vec<String> {
|
||||
packfile
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let path = PathBuf::from(f.path.clone());
|
||||
let name = path.to_string_lossy();
|
||||
let name = name.replace('\\', "/");
|
||||
name.trim_start_matches('/').to_string()
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
}
|
||||
|
||||
/// Creates a json configuration for a .mrpack zipped file
|
||||
// Version ID of uploaded version (ie 1.1.5), not the unique identifying ID of the version (nvrqJg44)
|
||||
#[tracing::instrument(skip_all)]
|
||||
@@ -997,7 +966,7 @@ pub async fn create_mrpack_json(
|
||||
.projects
|
||||
.iter()
|
||||
.filter_map(|(mod_path, project)| {
|
||||
let path: String = mod_path.get_inner_path_unix().ok()?;
|
||||
let path = mod_path.get_inner_path_unix();
|
||||
|
||||
// Only Modrinth projects have a modrinth metadata field for the modrinth.json
|
||||
Some(Ok(match project.metadata {
|
||||
@@ -1069,13 +1038,17 @@ fn sanitize_loader_version_string(s: &str, loader: PackDependency) -> &str {
|
||||
// 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,
|
||||
if s.starts_with("1.") {
|
||||
let mut split: std::str::Split<'_, char> = s.split('-');
|
||||
match split.next() {
|
||||
Some(first) => match split.next() {
|
||||
Some(second) => second,
|
||||
None => first,
|
||||
},
|
||||
None => s,
|
||||
}
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
// For quilt, etc we take the whole thing, as it functions like: 0.20.0-beta.11 (and should not be split here)
|
||||
@@ -1087,7 +1060,7 @@ fn sanitize_loader_version_string(s: &str, loader: PackDependency) -> &str {
|
||||
|
||||
// Given a folder path, populate a Vec of all the files in the folder, recursively
|
||||
#[async_recursion::async_recursion]
|
||||
pub async fn build_folder(
|
||||
pub async fn add_all_recursive_folder_paths(
|
||||
path: &Path,
|
||||
path_list: &mut Vec<PathBuf>,
|
||||
) -> crate::Result<()> {
|
||||
@@ -1099,7 +1072,7 @@ pub async fn build_folder(
|
||||
{
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
build_folder(&path, path_list).await?;
|
||||
add_all_recursive_folder_paths(&path, path_list).await?;
|
||||
} else {
|
||||
path_list.push(path);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Theseus profile management interface
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
use io::IOError;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -188,3 +189,17 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn is_dir_writeable(new_config_dir: PathBuf) -> crate::Result<bool> {
|
||||
let temp_path = new_config_dir.join(".tmp");
|
||||
match fs::write(temp_path.clone(), "test").await {
|
||||
Ok(_) => {
|
||||
fs::remove_file(temp_path).await?;
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error writing to new config dir: {}", e);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ pub enum ErrorKind {
|
||||
#[error("I/O error: {0}")]
|
||||
IOError(#[from] util::io::IOError),
|
||||
|
||||
#[error("I/O (std) error: {0}")]
|
||||
StdIOError(#[from] std::io::Error),
|
||||
|
||||
#[error("Error launching Minecraft: {0}")]
|
||||
LauncherError(String),
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Minecraft CLI argument logic
|
||||
// TODO: Rafactor this section
|
||||
use super::{auth::Credentials, parse_rule};
|
||||
use super::auth::Credentials;
|
||||
use crate::launcher::parse_rules;
|
||||
use crate::{
|
||||
state::{MemorySettings, WindowSize},
|
||||
util::{io::IOError, platform::classpath_separator},
|
||||
@@ -11,6 +11,7 @@ use daedalus::{
|
||||
modded::SidedDataEntry,
|
||||
};
|
||||
use dunce::canonicalize;
|
||||
use std::collections::HashSet;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
use uuid::Uuid;
|
||||
@@ -23,12 +24,13 @@ pub fn get_class_paths(
|
||||
libraries: &[Library],
|
||||
client_path: &Path,
|
||||
java_arch: &str,
|
||||
minecraft_updated: bool,
|
||||
) -> crate::Result<String> {
|
||||
let mut cps = libraries
|
||||
.iter()
|
||||
.filter_map(|library| {
|
||||
if let Some(rules) = &library.rules {
|
||||
if !rules.iter().any(|x| parse_rule(x, java_arch)) {
|
||||
if !parse_rules(rules, java_arch, minecraft_updated) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -39,9 +41,9 @@ pub fn get_class_paths(
|
||||
|
||||
Some(get_lib_path(libraries_path, &library.name, false))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
.collect::<Result<HashSet<_>, _>>()?;
|
||||
|
||||
cps.push(
|
||||
cps.insert(
|
||||
canonicalize(client_path)
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
@@ -54,7 +56,10 @@ pub fn get_class_paths(
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
Ok(cps.join(classpath_separator(java_arch)))
|
||||
Ok(cps
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
.join(classpath_separator(java_arch)))
|
||||
}
|
||||
|
||||
pub fn get_class_paths_jar<T: AsRef<str>>(
|
||||
@@ -335,7 +340,7 @@ where
|
||||
}
|
||||
}
|
||||
Argument::Ruled { rules, value } => {
|
||||
if rules.iter().any(|x| parse_rule(x, java_arch)) {
|
||||
if parse_rules(rules, java_arch, true) {
|
||||
match value {
|
||||
ArgumentValue::Single(arg) => {
|
||||
parsed_arguments.push(parse_function(
|
||||
|
||||
@@ -64,7 +64,7 @@ pub async fn refresh_credentials(
|
||||
.as_error())
|
||||
}
|
||||
xsts_token::XSTSResponse::Success { token: xsts_token } => {
|
||||
let bearer_token =
|
||||
let (bearer_token, expires_in) =
|
||||
bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
@@ -76,8 +76,7 @@ pub async fn refresh_credentials(
|
||||
|
||||
credentials.access_token = bearer_token;
|
||||
credentials.refresh_token = oauth.refresh_token;
|
||||
credentials.expires =
|
||||
Utc::now() + Duration::seconds(oauth.expires_in);
|
||||
credentials.expires = Utc::now() + Duration::seconds(expires_in);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Downloader for Minecraft data
|
||||
|
||||
use crate::launcher::parse_rules;
|
||||
use crate::state::CredentialsStore;
|
||||
use crate::{
|
||||
event::{
|
||||
@@ -26,11 +27,13 @@ pub async fn download_minecraft(
|
||||
version: &GameVersionInfo,
|
||||
loading_bar: &LoadingBarId,
|
||||
java_arch: &str,
|
||||
force: bool,
|
||||
minecraft_updated: bool,
|
||||
) -> crate::Result<()> {
|
||||
tracing::info!("Downloading Minecraft version {}", version.id);
|
||||
// 5
|
||||
let assets_index =
|
||||
download_assets_index(st, version, Some(loading_bar)).await?;
|
||||
download_assets_index(st, version, Some(loading_bar), force).await?;
|
||||
|
||||
let amount = if version
|
||||
.processors
|
||||
@@ -45,9 +48,9 @@ pub async fn download_minecraft(
|
||||
|
||||
tokio::try_join! {
|
||||
// Total loading sums to 90/60
|
||||
download_client(st, version, Some(loading_bar)), // 10
|
||||
download_assets(st, version.assets == "legacy", &assets_index, Some(loading_bar), amount), // 40
|
||||
download_libraries(st, version.libraries.as_slice(), &version.id, Some(loading_bar), amount, java_arch) // 40
|
||||
download_client(st, version, Some(loading_bar), force), // 10
|
||||
download_assets(st, version.assets == "legacy", &assets_index, Some(loading_bar), amount, force), // 40
|
||||
download_libraries(st, version.libraries.as_slice(), &version.id, Some(loading_bar), amount, java_arch, force, minecraft_updated) // 40
|
||||
}?;
|
||||
|
||||
tracing::info!("Done downloading Minecraft!");
|
||||
@@ -105,6 +108,7 @@ pub async fn download_client(
|
||||
st: &State,
|
||||
version_info: &GameVersionInfo,
|
||||
loading_bar: Option<&LoadingBarId>,
|
||||
force: bool,
|
||||
) -> crate::Result<()> {
|
||||
let version = &version_info.id;
|
||||
tracing::debug!("Locating client for version {version}");
|
||||
@@ -123,7 +127,7 @@ pub async fn download_client(
|
||||
.await
|
||||
.join(format!("{version}.jar"));
|
||||
|
||||
if !path.exists() {
|
||||
if !path.exists() || force {
|
||||
let bytes = fetch(
|
||||
&client_download.url,
|
||||
Some(&client_download.sha1),
|
||||
@@ -148,6 +152,7 @@ pub async fn download_assets_index(
|
||||
st: &State,
|
||||
version: &GameVersionInfo,
|
||||
loading_bar: Option<&LoadingBarId>,
|
||||
force: bool,
|
||||
) -> crate::Result<AssetsIndex> {
|
||||
tracing::debug!("Loading assets index");
|
||||
let path = st
|
||||
@@ -156,7 +161,7 @@ pub async fn download_assets_index(
|
||||
.await
|
||||
.join(format!("{}.json", &version.asset_index.id));
|
||||
|
||||
let res = if path.exists() {
|
||||
let res = if path.exists() && !force {
|
||||
io::read(path)
|
||||
.err_into::<crate::Error>()
|
||||
.await
|
||||
@@ -183,6 +188,7 @@ pub async fn download_assets(
|
||||
index: &AssetsIndex,
|
||||
loading_bar: Option<&LoadingBarId>,
|
||||
loading_amount: f64,
|
||||
force: bool,
|
||||
) -> crate::Result<()> {
|
||||
tracing::debug!("Loading assets");
|
||||
let num_futs = index.objects.len();
|
||||
@@ -206,7 +212,7 @@ pub async fn download_assets(
|
||||
let fetch_cell = OnceCell::<bytes::Bytes>::new();
|
||||
tokio::try_join! {
|
||||
async {
|
||||
if !resource_path.exists() {
|
||||
if !resource_path.exists() || force {
|
||||
let resource = fetch_cell
|
||||
.get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore, &CredentialsStore(None)))
|
||||
.await?;
|
||||
@@ -216,13 +222,14 @@ pub async fn download_assets(
|
||||
Ok::<_, crate::Error>(())
|
||||
},
|
||||
async {
|
||||
if with_legacy {
|
||||
let resource_path = st.directories.legacy_assets_dir().await.join(
|
||||
name.replace('/', &String::from(std::path::MAIN_SEPARATOR))
|
||||
);
|
||||
|
||||
if with_legacy && !resource_path.exists() || force {
|
||||
let resource = fetch_cell
|
||||
.get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore, &CredentialsStore(None)))
|
||||
.await?;
|
||||
let resource_path = st.directories.legacy_assets_dir().await.join(
|
||||
name.replace('/', &String::from(std::path::MAIN_SEPARATOR))
|
||||
);
|
||||
write(&resource_path, resource, &st.io_semaphore).await?;
|
||||
tracing::trace!("Fetched legacy asset with hash {hash}");
|
||||
}
|
||||
@@ -239,6 +246,7 @@ pub async fn download_assets(
|
||||
|
||||
#[tracing::instrument(skip(st, libraries))]
|
||||
#[theseus_macros::debug_pin]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn download_libraries(
|
||||
st: &State,
|
||||
libraries: &[Library],
|
||||
@@ -246,6 +254,8 @@ pub async fn download_libraries(
|
||||
loading_bar: Option<&LoadingBarId>,
|
||||
loading_amount: f64,
|
||||
java_arch: &str,
|
||||
force: bool,
|
||||
minecraft_updated: bool,
|
||||
) -> crate::Result<()> {
|
||||
tracing::debug!("Loading libraries");
|
||||
|
||||
@@ -258,7 +268,7 @@ pub async fn download_libraries(
|
||||
stream::iter(libraries.iter())
|
||||
.map(Ok::<&Library, crate::Error>), None, loading_bar,loading_amount,num_files, None,|library| async move {
|
||||
if let Some(rules) = &library.rules {
|
||||
if !rules.iter().any(|x| super::parse_rule(x, java_arch)) {
|
||||
if !parse_rules(rules, java_arch, minecraft_updated) {
|
||||
tracing::trace!("Skipped library {}", &library.name);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -270,7 +280,7 @@ pub async fn download_libraries(
|
||||
let path = st.directories.libraries_dir().await.join(&artifact_path);
|
||||
|
||||
match library.downloads {
|
||||
_ if path.exists() => Ok(()),
|
||||
_ if path.exists() && !force => Ok(()),
|
||||
Some(d::minecraft::LibraryDownloads {
|
||||
artifact: Some(ref artifact),
|
||||
..
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
};
|
||||
use chrono::Utc;
|
||||
use daedalus as d;
|
||||
use daedalus::minecraft::VersionInfo;
|
||||
use daedalus::minecraft::{RuleAction, VersionInfo};
|
||||
use st::Profile;
|
||||
use std::collections::HashMap;
|
||||
use std::{process::Stdio, sync::Arc};
|
||||
@@ -25,14 +25,48 @@ mod args;
|
||||
pub mod auth;
|
||||
pub mod download;
|
||||
|
||||
// All nones -> disallowed
|
||||
// 1+ true -> allowed
|
||||
// 1+ false -> disallowed
|
||||
#[tracing::instrument]
|
||||
pub fn parse_rule(rule: &d::minecraft::Rule, java_version: &str) -> bool {
|
||||
pub fn parse_rules(
|
||||
rules: &[d::minecraft::Rule],
|
||||
java_version: &str,
|
||||
minecraft_updated: bool,
|
||||
) -> bool {
|
||||
let mut x = rules
|
||||
.iter()
|
||||
.map(|x| parse_rule(x, java_version, minecraft_updated))
|
||||
.collect::<Vec<Option<bool>>>();
|
||||
|
||||
if rules
|
||||
.iter()
|
||||
.all(|x| matches!(x.action, RuleAction::Disallow))
|
||||
{
|
||||
x.push(Some(true))
|
||||
}
|
||||
|
||||
!(x.iter().any(|x| x == &Some(false)) || x.iter().all(|x| x.is_none()))
|
||||
}
|
||||
|
||||
// if anything is disallowed, it should NOT be included
|
||||
// if anything is not disallowed, it shouldn't factor in final result
|
||||
// if anything is not allowed, it shouldn't factor in final result
|
||||
// if anything is allowed, it should be included
|
||||
#[tracing::instrument]
|
||||
pub fn parse_rule(
|
||||
rule: &d::minecraft::Rule,
|
||||
java_version: &str,
|
||||
minecraft_updated: bool,
|
||||
) -> Option<bool> {
|
||||
use d::minecraft::{Rule, RuleAction};
|
||||
|
||||
let res = match rule {
|
||||
Rule {
|
||||
os: Some(ref os), ..
|
||||
} => crate::util::platform::os_rule(os, java_version),
|
||||
} => {
|
||||
crate::util::platform::os_rule(os, java_version, minecraft_updated)
|
||||
}
|
||||
Rule {
|
||||
features: Some(ref features),
|
||||
..
|
||||
@@ -44,12 +78,24 @@ pub fn parse_rule(rule: &d::minecraft::Rule, java_version: &str) -> bool {
|
||||
|| !features.is_quick_play_realms.unwrap_or(true)
|
||||
|| !features.is_quick_play_singleplayer.unwrap_or(true)
|
||||
}
|
||||
_ => false,
|
||||
_ => return Some(true),
|
||||
};
|
||||
|
||||
match rule.action {
|
||||
RuleAction::Allow => res,
|
||||
RuleAction::Disallow => !res,
|
||||
RuleAction::Allow => {
|
||||
if res {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
RuleAction::Disallow => {
|
||||
if res {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +148,7 @@ pub async fn get_java_version_from_profile(
|
||||
pub async fn install_minecraft(
|
||||
profile: &Profile,
|
||||
existing_loading_bar: Option<LoadingBarId>,
|
||||
repairing: bool,
|
||||
) -> crate::Result<()> {
|
||||
let sync_projects = existing_loading_bar.is_some();
|
||||
let loading_bar = init_or_edit_loading(
|
||||
@@ -130,18 +177,26 @@ pub async fn install_minecraft(
|
||||
|
||||
let state = State::get().await?;
|
||||
let instance_path =
|
||||
&io::canonicalize(&profile.get_profile_full_path().await?)?;
|
||||
&io::canonicalize(profile.get_profile_full_path().await?)?;
|
||||
let metadata = state.metadata.read().await;
|
||||
|
||||
let version = metadata
|
||||
let version_index = metadata
|
||||
.minecraft
|
||||
.versions
|
||||
.iter()
|
||||
.find(|it| it.id == profile.metadata.game_version)
|
||||
.position(|it| it.id == profile.metadata.game_version)
|
||||
.ok_or(crate::ErrorKind::LauncherError(format!(
|
||||
"Invalid game version: {}",
|
||||
profile.metadata.game_version
|
||||
)))?;
|
||||
let version = &metadata.minecraft.versions[version_index];
|
||||
let minecraft_updated = version_index
|
||||
<= metadata
|
||||
.minecraft
|
||||
.versions
|
||||
.iter()
|
||||
.position(|x| x.id == "22w16a")
|
||||
.unwrap_or(0);
|
||||
|
||||
let version_jar = profile
|
||||
.metadata
|
||||
@@ -156,7 +211,7 @@ pub async fn install_minecraft(
|
||||
&state,
|
||||
version,
|
||||
profile.metadata.loader_version.as_ref(),
|
||||
None,
|
||||
Some(repairing),
|
||||
Some(&loading_bar),
|
||||
)
|
||||
.await?;
|
||||
@@ -185,6 +240,8 @@ pub async fn install_minecraft(
|
||||
&version_info,
|
||||
&loading_bar,
|
||||
&java_version.architecture,
|
||||
repairing,
|
||||
minecraft_updated,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -325,7 +382,7 @@ pub async fn launch_minecraft(
|
||||
}
|
||||
|
||||
if profile.install_stage != ProfileInstallStage::Installed {
|
||||
install_minecraft(profile, None).await?;
|
||||
install_minecraft(profile, None, false).await?;
|
||||
}
|
||||
|
||||
let state = State::get().await?;
|
||||
@@ -334,15 +391,23 @@ pub async fn launch_minecraft(
|
||||
let instance_path = profile.get_profile_full_path().await?;
|
||||
let instance_path = &io::canonicalize(instance_path)?;
|
||||
|
||||
let version = metadata
|
||||
let version_index = metadata
|
||||
.minecraft
|
||||
.versions
|
||||
.iter()
|
||||
.find(|it| it.id == profile.metadata.game_version)
|
||||
.position(|it| it.id == profile.metadata.game_version)
|
||||
.ok_or(crate::ErrorKind::LauncherError(format!(
|
||||
"Invalid game version: {}",
|
||||
profile.metadata.game_version
|
||||
)))?;
|
||||
let version = &metadata.minecraft.versions[version_index];
|
||||
let minecraft_updated = version_index
|
||||
<= metadata
|
||||
.minecraft
|
||||
.versions
|
||||
.iter()
|
||||
.position(|x| x.id == "22w16a")
|
||||
.unwrap_or(0);
|
||||
|
||||
let version_jar = profile
|
||||
.metadata
|
||||
@@ -418,6 +483,7 @@ pub async fn launch_minecraft(
|
||||
version_info.libraries.as_slice(),
|
||||
&client_path,
|
||||
&java_version.architecture,
|
||||
minecraft_updated,
|
||||
)?,
|
||||
&version_jar,
|
||||
*memory,
|
||||
@@ -446,8 +512,8 @@ pub async fn launch_minecraft(
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.current_dir(instance_path.clone())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -22,4 +22,5 @@ pub use api::*;
|
||||
pub use error::*;
|
||||
pub use event::{EventState, LoadingBar, LoadingBarType};
|
||||
pub use logger::start_logger;
|
||||
pub use state::InnerProjectPathUnix;
|
||||
pub use state::State;
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
use super::DirectoryInfo;
|
||||
use super::{Profile, ProfilePathId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use sysinfo::PidExt;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::ChildStderr;
|
||||
use tokio::process::ChildStdout;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::error;
|
||||
|
||||
use crate::event::emit::emit_process;
|
||||
use crate::event::ProcessPayloadType;
|
||||
@@ -192,6 +201,7 @@ impl ChildType {
|
||||
pub struct MinecraftChild {
|
||||
pub uuid: Uuid,
|
||||
pub profile_relative_path: ProfilePathId,
|
||||
pub output: Option<SharedOutput>,
|
||||
pub manager: Option<JoinHandle<crate::Result<i32>>>, // None when future has completed and been handled
|
||||
pub current_child: Arc<RwLock<ChildType>>,
|
||||
pub last_updated_playtime: DateTime<Utc>, // The last time we updated the playtime for the associated profile
|
||||
@@ -271,7 +281,43 @@ impl Children {
|
||||
censor_strings: HashMap<String, String>,
|
||||
) -> crate::Result<Arc<RwLock<MinecraftChild>>> {
|
||||
// Takes the first element of the commands vector and spawns it
|
||||
let child = mc_command.spawn().map_err(IOError::from)?;
|
||||
let mut child = mc_command.spawn().map_err(IOError::from)?;
|
||||
|
||||
// Create std watcher threads for stdout and stderr
|
||||
let log_path = DirectoryInfo::profile_logs_dir(&profile_relative_path)
|
||||
.await?
|
||||
.join("latest_stdout.log");
|
||||
let shared_output =
|
||||
SharedOutput::build(&log_path, censor_strings).await?;
|
||||
if let Some(child_stdout) = child.stdout.take() {
|
||||
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);
|
||||
let _ = stdout_clone
|
||||
.push_line(format!(
|
||||
"Stdout process died with error: {}",
|
||||
e
|
||||
))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
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 _ = stderr_clone
|
||||
.push_line(format!(
|
||||
"Stderr process died with error: {}",
|
||||
e
|
||||
))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let child = ChildType::TokioChild(child);
|
||||
|
||||
// Slots child into manager
|
||||
@@ -312,6 +358,7 @@ impl Children {
|
||||
let mchild = MinecraftChild {
|
||||
uuid,
|
||||
profile_relative_path,
|
||||
output: Some(shared_output),
|
||||
current_child,
|
||||
manager,
|
||||
last_updated_playtime,
|
||||
@@ -402,6 +449,7 @@ impl Children {
|
||||
let mchild = MinecraftChild {
|
||||
uuid: cached_process.uuid,
|
||||
profile_relative_path: cached_process.profile_relative_path,
|
||||
output: None, // No output for cached/rescued processes
|
||||
current_child,
|
||||
manager,
|
||||
last_updated_playtime,
|
||||
@@ -710,3 +758,117 @@ 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 {
|
||||
log_file: Arc<RwLock<File>>,
|
||||
censor_strings: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl SharedOutput {
|
||||
#[tracing::instrument(skip(censor_strings))]
|
||||
async fn build(
|
||||
log_file_path: &Path,
|
||||
censor_strings: HashMap<String, String>,
|
||||
) -> crate::Result<Self> {
|
||||
// create log_file_path parent if it doesn't exist
|
||||
let parent_folder = log_file_path.parent().ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Could not get parent folder of {:?}",
|
||||
log_file_path
|
||||
))
|
||||
})?;
|
||||
tokio::fs::create_dir_all(parent_folder)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, parent_folder))?;
|
||||
|
||||
Ok(SharedOutput {
|
||||
log_file: Arc::new(RwLock::new(
|
||||
File::create(log_file_path)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, log_file_path))?,
|
||||
)),
|
||||
censor_strings,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_stdout(
|
||||
&self,
|
||||
child_stdout: ChildStdout,
|
||||
) -> crate::Result<()> {
|
||||
let mut buf_reader = BufReader::new(child_stdout);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
while buf_reader
|
||||
.read_until(b'\n', &mut buf)
|
||||
.await
|
||||
.map_err(IOError::from)?
|
||||
> 0
|
||||
{
|
||||
let line = String::from_utf8_lossy(&buf).into_owned();
|
||||
let val_line = self.censor_log(line.clone());
|
||||
{
|
||||
let mut log_file = self.log_file.write().await;
|
||||
log_file
|
||||
.write_all(val_line.as_bytes())
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_stderr(
|
||||
&self,
|
||||
child_stderr: ChildStderr,
|
||||
) -> crate::Result<()> {
|
||||
let mut buf_reader = BufReader::new(child_stderr);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// TODO: these can be asbtracted into noe function
|
||||
while buf_reader
|
||||
.read_until(b'\n', &mut buf)
|
||||
.await
|
||||
.map_err(IOError::from)?
|
||||
> 0
|
||||
{
|
||||
let line = String::from_utf8_lossy(&buf).into_owned();
|
||||
let val_line = self.censor_log(line.clone());
|
||||
{
|
||||
let mut log_file = self.log_file.write().await;
|
||||
log_file
|
||||
.write_all(val_line.as_bytes())
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn push_line(&self, line: String) -> crate::Result<()> {
|
||||
let val_line = self.censor_log(line.clone());
|
||||
{
|
||||
let mut log_file = self.log_file.write().await;
|
||||
log_file
|
||||
.write_all(val_line.as_bytes())
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn censor_log(&self, mut val: String) -> String {
|
||||
for (find, replace) in &self.censor_strings {
|
||||
val = val.replace(find, replace);
|
||||
}
|
||||
|
||||
val
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,6 @@ impl DirectoryInfo {
|
||||
/// Gets the logs dir for a given profile
|
||||
#[inline]
|
||||
pub async fn profile_logs_dir(
|
||||
&self,
|
||||
profile_id: &ProfilePathId,
|
||||
) -> crate::Result<PathBuf> {
|
||||
Ok(profile_id.get_full_path().await?.join("logs"))
|
||||
|
||||
@@ -383,7 +383,7 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
|
||||
|
||||
// At this point, new_path is the path to the profile, and subfile is whether it's a subfile of the profile or not
|
||||
let profile_path_id =
|
||||
ProfilePathId::new(&PathBuf::from(
|
||||
ProfilePathId::new(PathBuf::from(
|
||||
new_path.file_name().unwrap_or_default(),
|
||||
));
|
||||
|
||||
|
||||
@@ -322,29 +322,6 @@ pub async fn create_account(
|
||||
get_creds_from_res(response, semaphore).await
|
||||
}
|
||||
|
||||
pub async fn login_minecraft(
|
||||
flow: &str,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<ModrinthCredentialsResult> {
|
||||
let resp = fetch_advanced(
|
||||
Method::POST,
|
||||
&format!("{MODRINTH_API_URL}auth/login/minecraft"),
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"flow": flow,
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
semaphore,
|
||||
&CredentialsStore(None),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response = serde_json::from_slice::<HashMap<String, Value>>(&resp)?;
|
||||
|
||||
get_result_from_res("session", response, semaphore).await
|
||||
}
|
||||
|
||||
pub async fn refresh_credentials(
|
||||
credentials_store: &mut CredentialsStore,
|
||||
semaphore: &FetchSemaphore,
|
||||
|
||||
@@ -72,8 +72,8 @@ impl ProfilePathId {
|
||||
}
|
||||
|
||||
// Create a new ProfilePathId from a relative path
|
||||
pub fn new(path: &Path) -> Self {
|
||||
ProfilePathId(PathBuf::from(path))
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
ProfilePathId(path.into())
|
||||
}
|
||||
|
||||
pub async fn get_full_path(&self) -> crate::Result<PathBuf> {
|
||||
@@ -95,6 +95,45 @@ impl std::fmt::Display for ProfilePathId {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
|
||||
#[serde(into = "RawProjectPath", from = "RawProjectPath")]
|
||||
pub struct InnerProjectPathUnix(pub String);
|
||||
|
||||
impl InnerProjectPathUnix {
|
||||
pub fn get_topmost_two_components(&self) -> String {
|
||||
self.to_string()
|
||||
.split('/')
|
||||
.take(2)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InnerProjectPathUnix {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RawProjectPath> for InnerProjectPathUnix {
|
||||
fn from(value: RawProjectPath) -> Self {
|
||||
// Convert windows path to unix path.
|
||||
// .mrpacks no longer generate windows paths, but this is here for backwards compatibility before this was fixed
|
||||
// https://github.com/modrinth/theseus/issues/595
|
||||
InnerProjectPathUnix(value.0.replace('\\', "/"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
struct RawProjectPath(pub String);
|
||||
|
||||
impl From<InnerProjectPathUnix> for RawProjectPath {
|
||||
fn from(value: InnerProjectPathUnix) -> Self {
|
||||
RawProjectPath(value.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// newtype wrapper over a Profile path, to be usable as a clear identifier for the kind of path used
|
||||
/// eg: for "a/b/c/profiles/My Mod/mods/myproj", the ProjectPathId would be "mods/myproj"
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
|
||||
@@ -102,11 +141,14 @@ impl std::fmt::Display for ProfilePathId {
|
||||
pub struct ProjectPathId(pub PathBuf);
|
||||
impl ProjectPathId {
|
||||
// Create a new ProjectPathId from a full file path
|
||||
pub async fn from_fs_path(path: PathBuf) -> crate::Result<Self> {
|
||||
let path: PathBuf = io::canonicalize(path)?;
|
||||
let profiles_dir: PathBuf = io::canonicalize(
|
||||
pub async fn from_fs_path(path: &PathBuf) -> crate::Result<Self> {
|
||||
// This is avoiding dunce::canonicalize deliberately. On Windows, paths will always be convert to UNC,
|
||||
// but this is ok because we are stripping that with the prefix. Using std::fs avoids different behaviors with dunce that
|
||||
// come with too-long paths
|
||||
let profiles_dir: PathBuf = std::fs::canonicalize(
|
||||
State::get().await?.directories.profiles_dir().await,
|
||||
)?;
|
||||
let path: PathBuf = std::fs::canonicalize(path)?;
|
||||
let path = path
|
||||
.strip_prefix(profiles_dir)
|
||||
.ok()
|
||||
@@ -131,13 +173,14 @@ impl ProjectPathId {
|
||||
// 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("/"))
|
||||
pub fn get_inner_path_unix(&self) -> InnerProjectPathUnix {
|
||||
InnerProjectPathUnix(
|
||||
self.0
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/"),
|
||||
)
|
||||
}
|
||||
|
||||
// Create a new ProjectPathId from a relative path
|
||||
@@ -849,8 +892,6 @@ impl Profiles {
|
||||
// Fetch online from Modrinth each latest version
|
||||
future::try_join_all(modrinth_updatables.into_iter().map(
|
||||
|(profile_path, linked_project)| {
|
||||
let profile_path = profile_path;
|
||||
let linked_project = linked_project;
|
||||
let state = state.clone();
|
||||
async move {
|
||||
let creds = state.credentials.read().await;
|
||||
|
||||
@@ -815,7 +815,7 @@ pub async fn infer_data_from_files(
|
||||
let mut corrected_hashmap = HashMap::new();
|
||||
let mut stream = tokio_stream::iter(return_projects);
|
||||
while let Some((h, v)) = stream.next().await {
|
||||
let h = ProjectPathId::from_fs_path(h).await?;
|
||||
let h = ProjectPathId::from_fs_path(&h).await?;
|
||||
corrected_hashmap.insert(h, v);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub hide_on_process: bool,
|
||||
#[serde(default)]
|
||||
pub native_decorations: bool,
|
||||
#[serde(default)]
|
||||
pub default_page: DefaultPage,
|
||||
#[serde(default)]
|
||||
pub developer_mode: bool,
|
||||
@@ -99,6 +101,7 @@ impl Settings {
|
||||
collapsed_navigation: false,
|
||||
disable_discord_rpc: false,
|
||||
hide_on_process: false,
|
||||
native_decorations: false,
|
||||
default_page: DefaultPage::Home,
|
||||
developer_mode: false,
|
||||
opt_out_analytics: false,
|
||||
|
||||
@@ -43,7 +43,9 @@ pub async fn get_all_jre() -> Result<Vec<JavaVersion>, JREError> {
|
||||
r"C:\Program Files (x86)\Eclipse Adoptium",
|
||||
];
|
||||
for java_path in java_paths {
|
||||
let Ok(java_subpaths) = std::fs::read_dir(java_path) else {continue };
|
||||
let Ok(java_subpaths) = std::fs::read_dir(java_path) else {
|
||||
continue;
|
||||
};
|
||||
for java_subpath in java_subpaths.flatten() {
|
||||
let path = java_subpath.path();
|
||||
jre_paths.insert(path.join("bin"));
|
||||
@@ -97,7 +99,7 @@ pub fn get_paths_from_jre_winregkey(jre_key: RegKey) -> HashSet<PathBuf> {
|
||||
for subkey_value in subkey_value_names {
|
||||
let path: Result<String, std::io::Error> =
|
||||
subkey.get_value(subkey_value);
|
||||
let Ok(path) = path else {continue};
|
||||
let Ok(path) = path else { continue };
|
||||
|
||||
jre_paths.insert(PathBuf::from(path).join("bin"));
|
||||
}
|
||||
@@ -264,7 +266,9 @@ pub async fn check_java_at_filepaths(
|
||||
pub async fn check_java_at_filepath(path: &Path) -> Option<JavaVersion> {
|
||||
// Attempt to canonicalize the potential java filepath
|
||||
// If it fails, this path does not exist and None is returned (no Java here)
|
||||
let Ok(path) = io::canonicalize(path) else { return None };
|
||||
let Ok(path) = io::canonicalize(path) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Checks for existence of Java at this filepath
|
||||
// Adds JAVA_BIN to the end of the path if it is not already there
|
||||
|
||||
@@ -56,7 +56,12 @@ pub const ARCH_WIDTH: &str = "64";
|
||||
pub const ARCH_WIDTH: &str = "32";
|
||||
|
||||
// Platform rule handling
|
||||
pub fn os_rule(rule: &OsRule, java_arch: &str) -> bool {
|
||||
pub fn os_rule(
|
||||
rule: &OsRule,
|
||||
java_arch: &str,
|
||||
// Minecraft updated over 1.18.2 (supports MacOS Natively)
|
||||
minecraft_updated: bool,
|
||||
) -> bool {
|
||||
let mut rule_match = true;
|
||||
|
||||
if let Some(ref arch) = rule.arch {
|
||||
@@ -64,8 +69,14 @@ pub fn os_rule(rule: &OsRule, java_arch: &str) -> bool {
|
||||
}
|
||||
|
||||
if let Some(name) = &rule.name {
|
||||
rule_match &=
|
||||
&Os::native() == name || &Os::native_arch(java_arch) == name;
|
||||
if minecraft_updated
|
||||
&& (name != &Os::LinuxArm64 || name != &Os::LinuxArm32)
|
||||
{
|
||||
rule_match &=
|
||||
&Os::native() == name || &Os::native_arch(java_arch) == name;
|
||||
} else {
|
||||
rule_match &= &Os::native_arch(java_arch) == name;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(version) = &rule.version {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus_cli"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
|
||||
@@ -175,12 +175,13 @@ impl ProfileInit {
|
||||
.ok_or_else(|| eyre::eyre!("Modloader {loader} unsupported for Minecraft version {game_version}"))?
|
||||
.loaders;
|
||||
|
||||
let loader_version =
|
||||
loaders.iter().cloned().find(filter).ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"Invalid version {version} for modloader {loader}"
|
||||
)
|
||||
})?;
|
||||
let loader_version = loaders
|
||||
.iter()
|
||||
.find(|&x| filter(x))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
eyre::eyre!("Invalid version {version} for modloader {loader}")
|
||||
})?;
|
||||
|
||||
Some((loader_version, loader))
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "theseus_gui",
|
||||
"private": true,
|
||||
"version": "0.5.4",
|
||||
"version": "0.6.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
@@ -57,6 +57,6 @@ objc = "0.2.7"
|
||||
# by default Tauri runs in production mode
|
||||
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||
default = ["custom-protocol"]
|
||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||
# this feature is used for production builds where `devPath` points to the filesystem
|
||||
# DO NOT remove this
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
@@ -23,6 +23,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
logs_delete_logs,
|
||||
logs_delete_logs_by_filename,
|
||||
logs_get_latest_log_cursor,
|
||||
logs_get_std_log_cursor,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -90,3 +91,12 @@ pub async fn logs_get_latest_log_cursor(
|
||||
) -> Result<LatestLogCursor> {
|
||||
Ok(logs::get_latest_log_cursor(profile_path, cursor).await?)
|
||||
}
|
||||
|
||||
/// Get live stdout log from a cursor
|
||||
#[tauri::command]
|
||||
pub async fn logs_get_std_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
cursor: u64, // 0 to start at beginning of file
|
||||
) -> Result<LatestLogCursor> {
|
||||
Ok(logs::get_std_log_cursor(profile_path, cursor).await?)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use daedalus::modded::LoaderVersion;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use theseus::prelude::*;
|
||||
use theseus::{prelude::*, InnerProjectPathUnix};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
@@ -32,7 +32,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
profile_edit,
|
||||
profile_edit_icon,
|
||||
profile_export_mrpack,
|
||||
profile_get_potential_override_folders,
|
||||
profile_get_pack_export_candidates,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -117,8 +117,8 @@ pub async fn profile_check_installed(
|
||||
/// Installs/Repairs a profile
|
||||
/// invoke('plugin:profile|profile_install')
|
||||
#[tauri::command]
|
||||
pub async fn profile_install(path: ProfilePathId) -> Result<()> {
|
||||
profile::install(&path).await?;
|
||||
pub async fn profile_install(path: ProfilePathId, force: bool) -> Result<()> {
|
||||
profile::install(&path, force).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -228,20 +228,13 @@ pub async fn profile_export_mrpack(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Given a folder path, populate a Vec of all the subfolders
|
||||
// Intended to be used for finding potential override folders
|
||||
// profile
|
||||
// -- folder1
|
||||
// -- folder2
|
||||
// -- file1
|
||||
// => [folder1, folder2]
|
||||
/// See [`profile::get_pack_export_candidates`]
|
||||
#[tauri::command]
|
||||
pub async fn profile_get_potential_override_folders(
|
||||
pub async fn profile_get_pack_export_candidates(
|
||||
profile_path: ProfilePathId,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
let overrides =
|
||||
profile::get_potential_override_folders(profile_path).await?;
|
||||
Ok(overrides)
|
||||
) -> Result<Vec<InnerProjectPathUnix>> {
|
||||
let candidates = profile::get_pack_export_candidates(&profile_path).await?;
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
// Run minecraft using a profile using the default credentials
|
||||
|
||||
@@ -8,7 +8,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
settings_get,
|
||||
settings_set,
|
||||
settings_change_config_dir
|
||||
settings_change_config_dir,
|
||||
settings_is_dir_writeable
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -37,3 +38,11 @@ pub async fn settings_change_config_dir(new_config_dir: PathBuf) -> Result<()> {
|
||||
settings::set_config_dir(new_config_dir).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn settings_is_dir_writeable(
|
||||
new_config_dir: PathBuf,
|
||||
) -> Result<bool> {
|
||||
let res = settings::is_dir_writeable(new_config_dir).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Modrinth App",
|
||||
"version": "0.5.4"
|
||||
"version": "0.6.3"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
@@ -83,7 +83,7 @@
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"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'"
|
||||
"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 https://www.youtube-nocookie.com https://discord.com 'self'; style-src unsafe-inline 'self'"
|
||||
},
|
||||
"updater": {
|
||||
"active": true,
|
||||
|
||||
@@ -51,6 +51,7 @@ const isLoading = ref(true)
|
||||
const videoPlaying = ref(false)
|
||||
const offline = ref(false)
|
||||
const showOnboarding = ref(false)
|
||||
const nativeDecorations = ref(false)
|
||||
|
||||
const onboardingVideo = ref()
|
||||
|
||||
@@ -60,8 +61,14 @@ const os = ref('')
|
||||
defineExpose({
|
||||
initialize: async () => {
|
||||
isLoading.value = false
|
||||
const { theme, opt_out_analytics, collapsed_navigation, advanced_rendering, fully_onboarded } =
|
||||
await get()
|
||||
const {
|
||||
native_decorations,
|
||||
theme,
|
||||
opt_out_analytics,
|
||||
collapsed_navigation,
|
||||
advanced_rendering,
|
||||
fully_onboarded,
|
||||
} = await get()
|
||||
// video should play if the user is not on linux, and has not onboarded
|
||||
os.value = await getOS()
|
||||
videoPlaying.value = !fully_onboarded && os.value !== 'Linux'
|
||||
@@ -69,6 +76,9 @@ defineExpose({
|
||||
const version = await getVersion()
|
||||
showOnboarding.value = !fully_onboarded
|
||||
|
||||
nativeDecorations.value = native_decorations
|
||||
if (os.value !== 'MacOS') appWindow.setDecorations(native_decorations)
|
||||
|
||||
themeStore.setThemeState(theme)
|
||||
themeStore.collapsedNavigation = collapsed_navigation
|
||||
themeStore.advancedRendering = advanced_rendering
|
||||
@@ -341,7 +351,7 @@ command_listener(async (e) => {
|
||||
</Suspense>
|
||||
</section>
|
||||
</div>
|
||||
<section class="window-controls">
|
||||
<section v-if="!nativeDecorations" class="window-controls">
|
||||
<Button class="titlebar-button" icon-only @click="() => appWindow.minimize()">
|
||||
<MinimizeIcon />
|
||||
</Button>
|
||||
|
||||
26
theseus_gui/src/assets/external/google.svg
vendored
26
theseus_gui/src/assets/external/google.svg
vendored
@@ -1,20 +1,22 @@
|
||||
<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;"
|
||||
/>
|
||||
<g transform="translate(14.39 14.302) scale(.09916)"><clipPath id="a"><path d="M0 0h705.6v720H0z"/></clipPath>
|
||||
<g clip-path="url(#a)"><path d="M-4117.16-2597.44v139.42h193.74c-8.51 44.84-34.04 82.8-72.33 108.33l116.84 90.66c68.07-62.84 107.35-155.13 107.35-264.77 0-25.53-2.29-50.07-6.55-73.63l-339.05-.01Z" style="fill:#4285f4;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="m-4318.92-2463.46-26.35 20.17-93.28 72.65c59.24 117.49 180.65 198.66 321.38 198.66 97.2 0 178.69-32.07 238.25-87.05l-116.83-90.66c-32.08 21.6-72.99 34.69-121.42 34.69-93.6 0-173.13-63.16-201.6-148.25l-.15-.21Z"
|
||||
style="fill:#34a853;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="M-4438.55-2693.33c-24.54 48.44-38.61 103.09-38.61 161.34 0 58.26 14.07 112.91 38.61 161.35 0 .32 119.79-92.95 119.79-92.95-7.2-21.6-11.46-44.5-11.46-68.4 0-23.89 4.26-46.8 11.46-68.4l-119.79-92.94Z"
|
||||
style="fill:#fbbc05;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="M-4117.16-2748.64c53.02 0 100.14 18.33 137.78 53.67l103.09-103.09c-62.51-58.25-143.67-93.93-240.87-93.93-140.73 0-262.15 80.84-321.39 198.66l119.79 92.95c28.47-85.09 108-148.26 201.6-148.26Z"
|
||||
style="fill:#ea4335;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
</g>
|
||||
<g transform="translate(14.39 14.302) scale(.09916)">
|
||||
<path
|
||||
d="M-4117.16-2597.44v139.42h193.74c-8.51 44.84-34.04 82.8-72.33 108.33l116.84 90.66c68.07-62.84 107.35-155.13 107.35-264.77 0-25.53-2.29-50.07-6.55-73.63l-339.05-.01Z"
|
||||
style="fill:#4285f4;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="m-4318.92-2463.46-26.35 20.17-93.28 72.65c59.24 117.49 180.65 198.66 321.38 198.66 97.2 0 178.69-32.07 238.25-87.05l-116.83-90.66c-32.08 21.6-72.99 34.69-121.42 34.69-93.6 0-173.13-63.16-201.6-148.25l-.15-.21Z"
|
||||
style="fill:#34a853;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="M-4438.55-2693.33c-24.54 48.44-38.61 103.09-38.61 161.34 0 58.26 14.07 112.91 38.61 161.35 0 .32 119.79-92.95 119.79-92.95-7.2-21.6-11.46-44.5-11.46-68.4 0-23.89 4.26-46.8 11.46-68.4l-119.79-92.94Z"
|
||||
style="fill:#fbbc05;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
<path
|
||||
d="M-4117.16-2748.64c53.02 0 100.14 18.33 137.78 53.67l103.09-103.09c-62.51-58.25-143.67-93.93-240.87-93.93-140.73 0-262.15 80.84-321.39 198.66l119.79 92.95c28.47-85.09 108-148.26 201.6-148.26Z"
|
||||
style="fill:#ea4335;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -203,8 +203,8 @@ const handleOptionsClick = async (args) => {
|
||||
}
|
||||
}
|
||||
|
||||
const maxInstancesPerRow = ref(0)
|
||||
const maxProjectsPerRow = ref(0)
|
||||
const maxInstancesPerRow = ref(1)
|
||||
const maxProjectsPerRow = ref(1)
|
||||
|
||||
const calculateCardsPerRow = () => {
|
||||
// Calculate how many cards fit in one row
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
v-if="mode !== 'isolated'"
|
||||
ref="button"
|
||||
v-tooltip="'Minecraft accounts'"
|
||||
v-tooltip.right="'Minecraft accounts'"
|
||||
class="button-base avatar-button"
|
||||
:class="{ expanded: mode === 'expanded' }"
|
||||
@click="showCard = !showCard"
|
||||
@@ -56,7 +56,7 @@
|
||||
</Button>
|
||||
</Card>
|
||||
</transition>
|
||||
<Modal ref="loginModal" class="modal" header="Signing in">
|
||||
<Modal ref="loginModal" class="modal" header="Signing in" :noblur="!themeStore.advancedRendering">
|
||||
<div class="modal-body">
|
||||
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
|
||||
<div class="modal-text">
|
||||
@@ -114,6 +114,7 @@ import {
|
||||
} from '@/helpers/auth'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { handleError } from '@/store/state.js'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
import { mixpanel_track } from '@/helpers/mixpanel'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
@@ -130,6 +131,7 @@ const emit = defineEmits(['change'])
|
||||
|
||||
const loginCode = ref(null)
|
||||
|
||||
const themeStore = useTheming()
|
||||
const settings = ref({})
|
||||
const accounts = ref([])
|
||||
const loginUrl = ref('')
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
import { Button, Checkbox, Modal, XIcon, PlusIcon } from 'omorphia'
|
||||
import { PackageIcon, VersionIcon } from '@/assets/icons'
|
||||
import { ref } from 'vue'
|
||||
import { export_profile_mrpack, get_potential_override_folders } from '@/helpers/profile.js'
|
||||
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
|
||||
import { open } from '@tauri-apps/api/dialog'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { useTheming } from '@/store/theme'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -34,8 +33,9 @@ const themeStore = useTheming()
|
||||
|
||||
const initFiles = async () => {
|
||||
const newFolders = new Map()
|
||||
const sep = '/'
|
||||
files.value = []
|
||||
await get_potential_override_folders(props.instance.path).then((filePaths) =>
|
||||
await get_pack_export_candidates(props.instance.path).then((filePaths) =>
|
||||
filePaths
|
||||
.map((folder) => ({
|
||||
path: folder,
|
||||
@@ -292,9 +292,11 @@ const exportPack = async () => {
|
||||
.textarea-wrapper {
|
||||
// margin-top: 1rem;
|
||||
height: 12rem;
|
||||
|
||||
textarea {
|
||||
max-height: 12rem;
|
||||
}
|
||||
|
||||
.preview {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ onUnmounted(() => unlisten())
|
||||
<div class="instance">
|
||||
<Card class="instance-card-item button-base" @click="seeInstance" @mouseenter="checkProcess">
|
||||
<Avatar
|
||||
size="sm"
|
||||
size="lg"
|
||||
:src="
|
||||
props.instance.metadata
|
||||
? !props.instance.metadata.icon ||
|
||||
|
||||
@@ -20,7 +20,13 @@
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<p class="input-label">Name</p>
|
||||
<input v-model="profile_name" autocomplete="off" class="text-input" type="text" />
|
||||
<input
|
||||
v-model="profile_name"
|
||||
autocomplete="off"
|
||||
class="text-input"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<p class="input-label">Loader</p>
|
||||
|
||||
@@ -247,13 +247,15 @@ const check_valid = computed(() => {
|
||||
</Button>
|
||||
<div
|
||||
v-tooltip="
|
||||
profile.metadata.linked_data && !profile.installedMod
|
||||
? 'Unpair an instance to add mods.'
|
||||
profile.metadata.linked_data?.locked && !profile.installedMod
|
||||
? 'Unpair or unlock an instance to add mods.'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<Button
|
||||
:disabled="profile.installedMod || profile.installing || profile.metadata.linked_data"
|
||||
:disabled="
|
||||
profile.installedMod || profile.installing || profile.metadata.linked_data?.locked
|
||||
"
|
||||
@click="install(profile)"
|
||||
>
|
||||
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
|
||||
@@ -263,7 +265,7 @@ const check_valid = computed(() => {
|
||||
? 'Installing...'
|
||||
: profile.installedMod
|
||||
? 'Installed'
|
||||
: profile.metadata.linked_data
|
||||
: profile.metadata.linked_data && profile.metadata.linked_data.locked
|
||||
? 'Paired'
|
||||
: 'Install'
|
||||
}}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { Button, LogInIcon, Modal, ClipboardCopyIcon, GlobeIcon, Card } from 'omorphia'
|
||||
import { authenticate_await_completion, authenticate_begin_flow } from '@/helpers/auth.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
import mixpanel from 'mixpanel-browser'
|
||||
import { get, set } from '@/helpers/settings.js'
|
||||
import { ref } from 'vue'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
|
||||
const themeStore = useTheming()
|
||||
const loginUrl = ref(null)
|
||||
const loginModal = ref()
|
||||
const loginCode = ref(null)
|
||||
@@ -94,7 +96,7 @@ const clipboardWrite = async (a) => {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Modal ref="loginModal" header="Signing in">
|
||||
<Modal ref="loginModal" header="Signing in" :noblur="!themeStore.advancedRendering">
|
||||
<div class="modal-body">
|
||||
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
|
||||
<div class="modal-text">
|
||||
|
||||
@@ -50,6 +50,11 @@ export async function delete_logs(profilePath) {
|
||||
new_file: bool <- the cursor was too far, meaning that the file was likely rotated/reset. This signals to the frontend to clear the log and start over with this struct.
|
||||
}
|
||||
*/
|
||||
// From latest.log directly
|
||||
export async function get_latest_log_cursor(profilePath, cursor) {
|
||||
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
|
||||
}
|
||||
// For std log (from modrinth app written latest_stdout.log, contains stdout and stderr)
|
||||
export async function get_std_log_cursor(profilePath, cursor) {
|
||||
return await invoke('plugin:logs|logs_get_std_log_cursor', { profilePath, cursor })
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { invoke } from '@tauri-apps/api/tauri'
|
||||
*/
|
||||
|
||||
export async function create(name, gameVersion, modloader, loaderVersion, icon, noWatch) {
|
||||
//Trim string name to avoid "Unable to find directory"
|
||||
name = name.trim()
|
||||
return await invoke('plugin:profile_create|profile_create', {
|
||||
name,
|
||||
gameVersion,
|
||||
@@ -72,8 +74,8 @@ export async function check_installed(path, projectId) {
|
||||
}
|
||||
|
||||
// Installs/Repairs a profile
|
||||
export async function install(path) {
|
||||
return await invoke('plugin:profile|profile_install', { path })
|
||||
export async function install(path, force) {
|
||||
return await invoke('plugin:profile|profile_install', { path, force })
|
||||
}
|
||||
|
||||
// Updates all of a profile's projects
|
||||
@@ -151,8 +153,8 @@ export async function export_profile_mrpack(
|
||||
// -- file1
|
||||
// => [mods, resourcepacks]
|
||||
// allows selection for 'included_overrides' in export_profile_mrpack
|
||||
export async function get_potential_override_folders(profilePath) {
|
||||
return await invoke('plugin:profile|profile_get_potential_override_folders', { profilePath })
|
||||
export async function get_pack_export_candidates(profilePath) {
|
||||
return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
|
||||
}
|
||||
|
||||
// Run Minecraft using a pathed profile
|
||||
|
||||
@@ -43,3 +43,7 @@ export async function set(settings) {
|
||||
export async function change_config_dir(newConfigDir) {
|
||||
return await invoke('plugin:settings|settings_change_config_dir', { newConfigDir })
|
||||
}
|
||||
|
||||
export async function is_dir_writeable(newConfigDir) {
|
||||
return await invoke('plugin:settings|settings_is_dir_writeable', { newConfigDir })
|
||||
}
|
||||
|
||||
@@ -549,7 +549,11 @@ onUnmounted(() => unlistenOffline())
|
||||
size="sm"
|
||||
/>
|
||||
<div class="small-instance_info">
|
||||
<span class="title">{{ instanceContext.metadata.name }}</span>
|
||||
<span class="title">{{
|
||||
instanceContext.metadata.name.length > 20
|
||||
? instanceContext.metadata.name.substring(0, 20) + '...'
|
||||
: instanceContext.metadata.name
|
||||
}}</span>
|
||||
<span>
|
||||
{{
|
||||
instanceContext.metadata.loader.charAt(0).toUpperCase() +
|
||||
|
||||
@@ -14,27 +14,35 @@ import {
|
||||
UpdatedIcon,
|
||||
} from 'omorphia'
|
||||
import { handleError, useTheming } from '@/store/state'
|
||||
import { change_config_dir, get, set } from '@/helpers/settings'
|
||||
import { is_dir_writeable, change_config_dir, get, set } from '@/helpers/settings'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { get as getCreds, logout } from '@/helpers/mr_auth.js'
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
import ModrinthLoginScreen from '@/components/ui/tutorial/ModrinthLoginScreen.vue'
|
||||
import { mixpanel_opt_out_tracking, mixpanel_opt_in_tracking } from '@/helpers/mixpanel'
|
||||
import { open } from '@tauri-apps/api/dialog'
|
||||
import { getOS } from '@/helpers/utils.js'
|
||||
import { version } from '../../package.json'
|
||||
|
||||
const pageOptions = ['Home', 'Library']
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
const fetchSettings = await get().catch(handleError)
|
||||
const accessSettings = async () => {
|
||||
const settings = await get()
|
||||
|
||||
if (!fetchSettings.java_globals.JAVA_8)
|
||||
fetchSettings.java_globals.JAVA_8 = { path: '', version: '' }
|
||||
if (!fetchSettings.java_globals.JAVA_17)
|
||||
fetchSettings.java_globals.JAVA_17 = { path: '', version: '' }
|
||||
if (!settings.java_globals.JAVA_8) settings.java_globals.JAVA_8 = { path: '', version: '' }
|
||||
if (!settings.java_globals.JAVA_17) settings.java_globals.JAVA_17 = { path: '', version: '' }
|
||||
|
||||
fetchSettings.javaArgs = fetchSettings.custom_java_args.join(' ')
|
||||
fetchSettings.envArgs = fetchSettings.custom_env_args.map((x) => x.join('=')).join(' ')
|
||||
settings.javaArgs = settings.custom_java_args.join(' ')
|
||||
settings.envArgs = settings.custom_env_args.map((x) => x.join('=')).join(' ')
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
// const launcherVersion = await get_launcher_version().catch(handleError)
|
||||
|
||||
const fetchSettings = await accessSettings().catch(handleError)
|
||||
|
||||
const settings = ref(fetchSettings)
|
||||
const settingsDir = ref(settings.value.loaded_config_dir)
|
||||
@@ -43,6 +51,10 @@ const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1
|
||||
watch(
|
||||
settings,
|
||||
async (oldSettings, newSettings) => {
|
||||
if (oldSettings.loaded_config_dir !== newSettings.loaded_config_dir) {
|
||||
return
|
||||
}
|
||||
|
||||
const setSettings = JSON.parse(JSON.stringify(newSettings))
|
||||
|
||||
if (setSettings.opt_out_analytics) {
|
||||
@@ -107,7 +119,18 @@ async function signInAfter() {
|
||||
}
|
||||
|
||||
async function findLauncherDir() {
|
||||
const newDir = await open({ multiple: false, directory: true })
|
||||
const newDir = await open({
|
||||
multiple: false,
|
||||
directory: true,
|
||||
title: 'Select a new app directory',
|
||||
})
|
||||
|
||||
const writeable = await is_dir_writeable(newDir)
|
||||
|
||||
if (!writeable) {
|
||||
handleError('The selected directory does not have proper permissions for write access.')
|
||||
return
|
||||
}
|
||||
|
||||
if (newDir) {
|
||||
settingsDir.value = newDir
|
||||
@@ -117,6 +140,8 @@ async function findLauncherDir() {
|
||||
|
||||
async function refreshDir() {
|
||||
await change_config_dir(settingsDir.value)
|
||||
settings.value = await accessSettings().catch(handleError)
|
||||
settingsDir.value = settings.value.loaded_config_dir
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -143,9 +168,13 @@ async function refreshDir() {
|
||||
</span>
|
||||
<span v-else> Sign in to your Modrinth account. </span>
|
||||
</label>
|
||||
<button v-if="credentials" class="btn" @click="logOut"><LogOutIcon /> Sign out</button>
|
||||
<button v-if="credentials" class="btn" @click="logOut">
|
||||
<LogOutIcon />
|
||||
Sign out
|
||||
</button>
|
||||
<button v-else class="btn" @click="$refs.loginScreenModal.show()">
|
||||
<LogInIcon /> Sign in
|
||||
<LogInIcon />
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
<label for="theme">
|
||||
@@ -232,6 +261,22 @@ async function refreshDir() {
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="getOS() != 'MacOS'" class="adjacent-input">
|
||||
<label for="native-decorations">
|
||||
<span class="label__title">Native decorations</span>
|
||||
<span class="label__description">Use system window frame (app restart required).</span>
|
||||
</label>
|
||||
<Toggle
|
||||
id="native-decorations"
|
||||
:model-value="settings.native_decorations"
|
||||
:checked="settings.native_decorations"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.native_decorations = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="opening-page">
|
||||
<span class="label__title">Default landing page</span>
|
||||
@@ -385,7 +430,7 @@ async function refreshDir() {
|
||||
v-model="settings.memory.maximum"
|
||||
:min="8"
|
||||
:max="maxMemory"
|
||||
:step="1"
|
||||
:step="64"
|
||||
unit="mb"
|
||||
/>
|
||||
</div>
|
||||
@@ -490,6 +535,19 @@ async function refreshDir() {
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">About</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<span class="label__title">App version</span>
|
||||
<span class="label__description">Theseus v{{ version }} </span>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -161,7 +161,13 @@ const breadcrumbs = useBreadcrumbs()
|
||||
|
||||
const instance = ref(await get(route.params.id).catch(handleError))
|
||||
|
||||
breadcrumbs.setName('Instance', instance.value.metadata.name)
|
||||
breadcrumbs.setName(
|
||||
'Instance',
|
||||
instance.value.metadata.name.length > 40
|
||||
? instance.value.metadata.name.substring(0, 40) + '...'
|
||||
: instance.value.metadata.name
|
||||
)
|
||||
|
||||
breadcrumbs.setContext({
|
||||
name: instance.value.metadata.name,
|
||||
link: route.path,
|
||||
@@ -203,7 +209,7 @@ const checkProcess = async () => {
|
||||
|
||||
// Get information on associated modrinth versions, if any
|
||||
const modrinthVersions = ref([])
|
||||
if (!(await isOffline()) && instance.value.metadata.linked_data) {
|
||||
if (!(await isOffline()) && instance.value.metadata.linked_data?.project_id) {
|
||||
modrinthVersions.value = await useFetch(
|
||||
`https://api.modrinth.com/v2/project/${instance.value.metadata.linked_data.project_id}/version`,
|
||||
'project'
|
||||
@@ -366,6 +372,8 @@ Button {
|
||||
.name {
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-contrast);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
|
||||
@@ -102,7 +102,7 @@ import {
|
||||
delete_logs_by_filename,
|
||||
get_logs,
|
||||
get_output_by_filename,
|
||||
get_latest_log_cursor,
|
||||
get_std_log_cursor,
|
||||
} from '@/helpers/logs.js'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -216,14 +216,14 @@ const processedLogs = computed(() => {
|
||||
return processed
|
||||
})
|
||||
|
||||
async function getLiveLog() {
|
||||
async function getLiveStdLog() {
|
||||
if (route.params.id) {
|
||||
const uuids = await get_uuids_by_profile_path(route.params.id).catch(handleError)
|
||||
let returnValue
|
||||
if (uuids.length === 0) {
|
||||
returnValue = emptyText.join('\n')
|
||||
} else {
|
||||
const logCursor = await get_latest_log_cursor(
|
||||
const logCursor = await get_std_log_cursor(
|
||||
props.instance.path,
|
||||
currentLiveLogCursor.value
|
||||
).catch(handleError)
|
||||
@@ -240,34 +240,42 @@ async function getLiveLog() {
|
||||
}
|
||||
|
||||
async function getLogs() {
|
||||
return (await get_logs(props.instance.path, true).catch(handleError)).reverse().map((log) => {
|
||||
if (log.filename == 'latest.log') {
|
||||
log.name = 'Latest Log'
|
||||
} else {
|
||||
let filename = log.filename.split('.')[0]
|
||||
let day = dayjs(filename.slice(0, 10))
|
||||
if (day.isValid()) {
|
||||
if (day.isToday()) {
|
||||
log.name = 'Today'
|
||||
} else if (day.isYesterday()) {
|
||||
log.name = 'Yesterday'
|
||||
} else {
|
||||
log.name = day.format('MMMM D, YYYY')
|
||||
}
|
||||
// Displays as "Today-1", "Today-2", etc, matching minecraft log naming but with the date
|
||||
log.name = log.name + filename.slice(10)
|
||||
return (await get_logs(props.instance.path, true).catch(handleError))
|
||||
.reverse()
|
||||
.filter(
|
||||
(log) =>
|
||||
log.filename !== 'latest_stdout.log' &&
|
||||
log.filename !== 'latest_stdout' &&
|
||||
log.stdout !== ''
|
||||
)
|
||||
.map((log) => {
|
||||
if (log.filename == 'latest.log') {
|
||||
log.name = 'Latest Log'
|
||||
} else {
|
||||
log.name = filename
|
||||
let filename = log.filename.split('.')[0]
|
||||
let day = dayjs(filename.slice(0, 10))
|
||||
if (day.isValid()) {
|
||||
if (day.isToday()) {
|
||||
log.name = 'Today'
|
||||
} else if (day.isYesterday()) {
|
||||
log.name = 'Yesterday'
|
||||
} else {
|
||||
log.name = day.format('MMMM D, YYYY')
|
||||
}
|
||||
// Displays as "Today-1", "Today-2", etc, matching minecraft log naming but with the date
|
||||
log.name = log.name + filename.slice(10)
|
||||
} else {
|
||||
log.name = filename
|
||||
}
|
||||
}
|
||||
}
|
||||
log.stdout = 'Loading...'
|
||||
return log
|
||||
})
|
||||
log.stdout = 'Loading...'
|
||||
return log
|
||||
})
|
||||
}
|
||||
|
||||
async function setLogs() {
|
||||
const [liveLog, allLogs] = await Promise.all([getLiveLog(), getLogs()])
|
||||
logs.value = [liveLog, ...allLogs]
|
||||
const [liveStd, allLogs] = await Promise.all([getLiveStdLog(), getLogs()])
|
||||
logs.value = [liveStd, ...allLogs]
|
||||
}
|
||||
|
||||
const copyLog = () => {
|
||||
@@ -426,7 +434,7 @@ function handleUserScroll() {
|
||||
|
||||
interval.value = setInterval(async () => {
|
||||
if (logs.value.length > 0) {
|
||||
logs.value[0] = await getLiveLog()
|
||||
logs.value[0] = await getLiveStdLog()
|
||||
|
||||
const scroll = logContainer.value.getScroll()
|
||||
// Allow resetting of userScrolled if the user scrolls to the bottom
|
||||
@@ -524,13 +532,26 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
padding: 0.6rem;
|
||||
flex-direction: row;
|
||||
overflow: auto;
|
||||
gap: 0.5rem;
|
||||
|
||||
&::-webkit-scrollbar-track,
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.vue-recycle-scroller__item-wrapper) {
|
||||
overflow: visible; /* Enables horizontal scrolling */
|
||||
}
|
||||
|
||||
:deep(.vue-recycle-scroller) {
|
||||
&::-webkit-scrollbar-corner {
|
||||
background-color: var(--color-bg);
|
||||
border-radius: 0 0 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.scroller {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -542,6 +542,8 @@ const ascending = ref(true)
|
||||
const sortColumn = ref('Name')
|
||||
const currentPage = ref(1)
|
||||
|
||||
watch(searchFilter, () => (currentPage.value = 1))
|
||||
|
||||
const selected = computed(() =>
|
||||
Array.from(selectionMap.value)
|
||||
.filter((args) => {
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
:disabled="!overrideMemorySettings"
|
||||
:min="8"
|
||||
:max="maxMemory"
|
||||
:step="1"
|
||||
:step="64"
|
||||
unit="mb"
|
||||
/>
|
||||
</div>
|
||||
@@ -264,7 +264,17 @@
|
||||
Make the game start in full screen when launched (using options.txt).
|
||||
</span>
|
||||
</label>
|
||||
<Checkbox id="fullscreen" v-model="fullscreenSetting" :disabled="!overrideWindowSettings" />
|
||||
<Toggle
|
||||
id="fullscreen"
|
||||
:model-value="fullscreenSetting"
|
||||
:checked="fullscreenSetting"
|
||||
:disabled="!overrideWindowSettings"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
fullscreenSetting = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="width">
|
||||
@@ -362,7 +372,10 @@
|
||||
<span class="label__description">
|
||||
<strong>Version: </strong>
|
||||
{{
|
||||
installedVersionData.name.charAt(0).toUpperCase() + installedVersionData.name.slice(1)
|
||||
installedVersionData?.name != null
|
||||
? installedVersionData.name.charAt(0).toUpperCase() +
|
||||
installedVersionData.name.slice(1)
|
||||
: getLocalVersion(props.instance.path)
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
@@ -401,7 +414,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="adjacent-input">
|
||||
<div v-if="props.instance.metadata.linked_data.project_id" class="adjacent-input">
|
||||
<label for="change-modpack-version">
|
||||
<span class="label__title">Change modpack version</span>
|
||||
<span class="label__description">
|
||||
@@ -465,7 +478,7 @@
|
||||
id="repair-profile"
|
||||
color="highlight"
|
||||
:disabled="installing || inProgress || repairing || offline"
|
||||
@click="repairProfile"
|
||||
@click="repairProfile(true)"
|
||||
>
|
||||
<HammerIcon /> Repair
|
||||
</Button>
|
||||
@@ -516,6 +529,7 @@ import {
|
||||
DownloadIcon,
|
||||
ClipboardCopyIcon,
|
||||
Button,
|
||||
Toggle,
|
||||
} from 'omorphia'
|
||||
import { SwapIcon } from '@/assets/icons'
|
||||
|
||||
@@ -547,8 +561,11 @@ import { get_game_versions, get_loaders } from '@/helpers/tags.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { mixpanel_track } from '@/helpers/mixpanel'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
@@ -576,9 +593,11 @@ const modpackVersionModal = ref(null)
|
||||
|
||||
const instancesList = Object.values(await list(true))
|
||||
const availableGroups = ref([
|
||||
...instancesList.reduce((acc, obj) => {
|
||||
return acc.concat(obj.metadata.groups)
|
||||
}, []),
|
||||
...new Set(
|
||||
instancesList.reduce((acc, obj) => {
|
||||
return acc.concat(obj.metadata.groups)
|
||||
}, [])
|
||||
),
|
||||
])
|
||||
|
||||
async function resetIcon() {
|
||||
@@ -641,9 +660,10 @@ const unlinkModpack = ref(false)
|
||||
const inProgress = ref(false)
|
||||
const installing = computed(() => props.instance.install_stage !== 'installed')
|
||||
const installedVersion = computed(() => props.instance?.metadata?.linked_data?.version_id)
|
||||
const installedVersionData = computed(() =>
|
||||
props.versions.find((version) => version.id === installedVersion.value)
|
||||
)
|
||||
const installedVersionData = computed(() => {
|
||||
if (!installedVersion.value) return null
|
||||
return props.versions.find((version) => version.id === installedVersion.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
@@ -671,6 +691,15 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const getLocalVersion = (path) => {
|
||||
const pathSlice = path.split(' ').slice(-1).toString()
|
||||
// If the path ends in (1), (2), etc. it's a duplicate instance and no version can be obtained.
|
||||
if (/^\(\d\)/.test(pathSlice)) {
|
||||
return 'Unknown'
|
||||
}
|
||||
return pathSlice
|
||||
}
|
||||
|
||||
const editProfileObject = computed(() => {
|
||||
const editProfile = {
|
||||
metadata: {
|
||||
@@ -727,6 +756,9 @@ const editProfileObject = computed(() => {
|
||||
if (unlinkModpack.value) {
|
||||
editProfile.metadata.linked_data = null
|
||||
}
|
||||
|
||||
breadcrumbs.setName('Instance', editProfile.metadata.name)
|
||||
|
||||
return editProfile
|
||||
})
|
||||
|
||||
@@ -740,9 +772,9 @@ async function duplicateProfile() {
|
||||
})
|
||||
}
|
||||
|
||||
async function repairProfile() {
|
||||
async function repairProfile(force) {
|
||||
repairing.value = true
|
||||
await install(props.instance.path).catch(handleError)
|
||||
await install(props.instance.path, force).catch(handleError)
|
||||
repairing.value = false
|
||||
|
||||
mixpanel_track('InstanceRepair', {
|
||||
@@ -895,7 +927,7 @@ async function saveGvLoaderEdits() {
|
||||
editProfile.metadata.loader_version = selectableLoaderVersions.value[loaderVersionIndex.value]
|
||||
}
|
||||
await edit(props.instance.path, editProfile).catch(handleError)
|
||||
await repairProfile()
|
||||
await repairProfile(false)
|
||||
|
||||
editing.value = false
|
||||
changeVersionsModal.value.hide()
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
size="sm"
|
||||
/>
|
||||
<div class="small-instance_info">
|
||||
<span class="title">{{ instance.metadata.name }}</span>
|
||||
<span class="title">{{
|
||||
instance.metadata.name.length > 20
|
||||
? instance.metadata.name.substring(0, 20) + '...'
|
||||
: instance.metadata.name
|
||||
}}</span>
|
||||
<span>
|
||||
{{
|
||||
instance.metadata.loader.charAt(0).toUpperCase() + instance.metadata.loader.slice(1)
|
||||
|
||||
@@ -283,6 +283,7 @@ watch([filterVersions, filterLoader, filterGameVersions], () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
text-wrap: wrap;
|
||||
|
||||
.version-badge {
|
||||
display: flex;
|
||||
|
||||
@@ -2,10 +2,9 @@ import { defineStore } from 'pinia'
|
||||
|
||||
export const useTheming = defineStore('themeStore', {
|
||||
state: () => ({
|
||||
themeOptions: ['dark'],
|
||||
themeOptions: ['dark', 'light', 'oled'],
|
||||
advancedRendering: true,
|
||||
selectedTheme: 'dark',
|
||||
darkTheme: true,
|
||||
}),
|
||||
actions: {
|
||||
setThemeState(newTheme) {
|
||||
@@ -15,8 +14,9 @@ export const useTheming = defineStore('themeStore', {
|
||||
this.setThemeClass()
|
||||
},
|
||||
setThemeClass() {
|
||||
document.getElementsByTagName('html')[0].classList.remove('dark-mode')
|
||||
document.getElementsByTagName('html')[0].classList.remove('light-mode')
|
||||
for (const theme of this.themeOptions) {
|
||||
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
|
||||
}
|
||||
document.getElementsByTagName('html')[0].classList.add(`${this.selectedTheme}-mode`)
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user