You've already forked AstralRinth
forked from didirus/AstralRinth
Auth retrying, std logs (#879)
This commit is contained in:
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::{hydra::MicrosoftError, util::fetch::REQWEST_CLIENT};
|
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)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct DeviceLoginSuccess {
|
pub struct DeviceLoginSuccess {
|
||||||
@@ -28,13 +28,13 @@ pub async fn init() -> crate::Result<DeviceLoginSuccess> {
|
|||||||
params.insert("scope", "XboxLive.signin offline_access");
|
params.insert("scope", "XboxLive.signin offline_access");
|
||||||
|
|
||||||
// urlencoding::encode("XboxLive.signin offline_access"));
|
// urlencoding::encode("XboxLive.signin offline_access"));
|
||||||
let req = REQWEST_CLIENT.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
|
let resp = auth_retry(|| REQWEST_CLIENT.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded").form(¶ms).send().await?;
|
.header("Content-Type", "application/x-www-form-urlencoded").form(¶ms).send()).await?;
|
||||||
|
|
||||||
match req.status() {
|
match resp.status() {
|
||||||
reqwest::StatusCode::OK => Ok(req.json().await?),
|
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!(
|
Err(crate::ErrorKind::HydraError(format!(
|
||||||
"Error from Microsoft: {:?}",
|
"Error from Microsoft: {:?}",
|
||||||
microsoft_error.error_description
|
microsoft_error.error_description
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use crate::{
|
|||||||
util::fetch::REQWEST_CLIENT,
|
util::fetch::REQWEST_CLIENT,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::stages::auth_retry;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct OauthSuccess {
|
pub struct OauthSuccess {
|
||||||
pub token_type: String,
|
pub token_type: String,
|
||||||
@@ -25,11 +27,14 @@ pub async fn refresh(refresh_token: String) -> crate::Result<OauthSuccess> {
|
|||||||
|
|
||||||
// Poll the URL in a loop until we are successful.
|
// Poll the URL in a loop until we are successful.
|
||||||
// On an authorization_pending response, wait 5 seconds and try again.
|
// 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")
|
.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
.form(¶ms)
|
.form(¶ms)
|
||||||
.send()
|
.send()
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
match resp.status() {
|
match resp.status() {
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::auth_retry;
|
||||||
|
|
||||||
const MCSERVICES_AUTH_URL: &str =
|
const MCSERVICES_AUTH_URL: &str =
|
||||||
"https://api.minecraftservices.com/launcher/login";
|
"https://api.minecraftservices.com/launcher/login";
|
||||||
|
|
||||||
|
#[tracing::instrument]
|
||||||
pub async fn fetch_bearer(token: &str, uhs: &str) -> crate::Result<String> {
|
pub async fn fetch_bearer(token: &str, uhs: &str) -> crate::Result<String> {
|
||||||
let client = reqwest::Client::new();
|
let body = auth_retry(|| {
|
||||||
let body = client
|
let client = reqwest::Client::new();
|
||||||
.post(MCSERVICES_AUTH_URL)
|
client
|
||||||
.json(&json!({
|
.post(MCSERVICES_AUTH_URL)
|
||||||
"xtoken": format!("XBL3.0 x={};{}", uhs, token),
|
.json(&json!({
|
||||||
"platform": "PC_LAUNCHER"
|
"xtoken": format!("XBL3.0 x={};{}", uhs, token),
|
||||||
}))
|
"platform": "PC_LAUNCHER"
|
||||||
.send()
|
}))
|
||||||
.await?
|
.send()
|
||||||
.text()
|
})
|
||||||
.await?;
|
.await?
|
||||||
|
.text()
|
||||||
|
.await?;
|
||||||
|
|
||||||
serde_json::from_str::<serde_json::Value>(&body)?
|
serde_json::from_str::<serde_json::Value>(&body)?
|
||||||
.get("access_token")
|
.get("access_token")
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
//! MSA authentication stages
|
//! MSA authentication stages
|
||||||
|
|
||||||
|
use futures::Future;
|
||||||
|
use reqwest::Response;
|
||||||
|
|
||||||
|
const RETRY_COUNT: usize = 2; // Does command 3 times
|
||||||
|
const RETRY_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
pub mod bearer_token;
|
pub mod bearer_token;
|
||||||
pub mod player_info;
|
pub mod player_info;
|
||||||
pub mod poll_response;
|
pub mod poll_response;
|
||||||
pub mod xbl_signin;
|
pub mod xbl_signin;
|
||||||
pub mod xsts_token;
|
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
|
//! Fetch player info for display
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::util::fetch::REQWEST_CLIENT;
|
||||||
|
|
||||||
|
use super::auth_retry;
|
||||||
|
|
||||||
const PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
|
const PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -18,16 +22,17 @@ impl Default for PlayerInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument]
|
||||||
pub async fn fetch_info(token: &str) -> crate::Result<PlayerInfo> {
|
pub async fn fetch_info(token: &str) -> crate::Result<PlayerInfo> {
|
||||||
let client = reqwest::Client::new();
|
let response = auth_retry(|| {
|
||||||
let resp = client
|
REQWEST_CLIENT
|
||||||
.get(PROFILE_URL)
|
.get(PROFILE_URL)
|
||||||
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
|
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
|
||||||
.send()
|
.send()
|
||||||
.await?
|
})
|
||||||
.error_for_status()?
|
.await?;
|
||||||
.json()
|
|
||||||
.await?;
|
let resp = response.error_for_status()?.json().await?;
|
||||||
|
|
||||||
Ok(resp)
|
Ok(resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use crate::{
|
|||||||
util::fetch::REQWEST_CLIENT,
|
util::fetch::REQWEST_CLIENT,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::auth_retry;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct OauthSuccess {
|
pub struct OauthSuccess {
|
||||||
pub token_type: String,
|
pub token_type: String,
|
||||||
@@ -17,6 +19,7 @@ pub struct OauthSuccess {
|
|||||||
pub refresh_token: String,
|
pub refresh_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument]
|
||||||
pub async fn poll_response(device_code: String) -> crate::Result<OauthSuccess> {
|
pub async fn poll_response(device_code: String) -> crate::Result<OauthSuccess> {
|
||||||
let mut params = HashMap::new();
|
let mut params = HashMap::new();
|
||||||
params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
||||||
@@ -26,14 +29,16 @@ pub async fn poll_response(device_code: String) -> crate::Result<OauthSuccess> {
|
|||||||
// Poll the URL in a loop until we are successful.
|
// Poll the URL in a loop until we are successful.
|
||||||
// On an authorization_pending response, wait 5 seconds and try again.
|
// On an authorization_pending response, wait 5 seconds and try again.
|
||||||
loop {
|
loop {
|
||||||
let resp = REQWEST_CLIENT
|
let resp = auth_retry(|| {
|
||||||
|
REQWEST_CLIENT
|
||||||
.post(
|
.post(
|
||||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
|
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
|
||||||
)
|
)
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
.form(¶ms)
|
.form(¶ms)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
match resp.status() {
|
match resp.status() {
|
||||||
StatusCode::OK => {
|
StatusCode::OK => {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
use serde_json::json;
|
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";
|
const XBL_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
|
||||||
|
|
||||||
// Deserialization
|
// Deserialization
|
||||||
@@ -9,25 +13,26 @@ pub struct XBLLogin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Impl
|
// Impl
|
||||||
|
#[tracing::instrument]
|
||||||
pub async fn login_xbl(token: &str) -> crate::Result<XBLLogin> {
|
pub async fn login_xbl(token: &str) -> crate::Result<XBLLogin> {
|
||||||
let client = reqwest::Client::new();
|
let response = auth_retry(|| {
|
||||||
let body = client
|
REQWEST_CLIENT
|
||||||
.post(XBL_AUTH_URL)
|
.post(XBL_AUTH_URL)
|
||||||
.header(reqwest::header::ACCEPT, "application/json")
|
.header(reqwest::header::ACCEPT, "application/json")
|
||||||
.header("x-xbl-contract-version", "1")
|
.header("x-xbl-contract-version", "1")
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"Properties": {
|
"Properties": {
|
||||||
"AuthMethod": "RPS",
|
"AuthMethod": "RPS",
|
||||||
"SiteName": "user.auth.xboxlive.com",
|
"SiteName": "user.auth.xboxlive.com",
|
||||||
"RpsTicket": format!("d={token}")
|
"RpsTicket": format!("d={token}")
|
||||||
},
|
},
|
||||||
"RelyingParty": "http://auth.xboxlive.com",
|
"RelyingParty": "http://auth.xboxlive.com",
|
||||||
"TokenType": "JWT"
|
"TokenType": "JWT"
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await?
|
})
|
||||||
.text()
|
.await?;
|
||||||
.await?;
|
let body = response.text().await?;
|
||||||
|
|
||||||
let json = serde_json::from_str::<serde_json::Value>(&body)?;
|
let json = serde_json::from_str::<serde_json::Value>(&body)?;
|
||||||
let token = Some(&json)
|
let token = Some(&json)
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
use serde_json::json;
|
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";
|
const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
|
||||||
|
|
||||||
pub enum XSTSResponse {
|
pub enum XSTSResponse {
|
||||||
@@ -7,23 +11,25 @@ pub enum XSTSResponse {
|
|||||||
Success { token: String },
|
Success { token: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument]
|
||||||
pub async fn fetch_token(token: &str) -> crate::Result<XSTSResponse> {
|
pub async fn fetch_token(token: &str) -> crate::Result<XSTSResponse> {
|
||||||
let client = reqwest::Client::new();
|
let resp = auth_retry(|| {
|
||||||
let resp = client
|
REQWEST_CLIENT
|
||||||
.post(XSTS_AUTH_URL)
|
.post(XSTS_AUTH_URL)
|
||||||
.header(reqwest::header::ACCEPT, "application/json")
|
.header(reqwest::header::ACCEPT, "application/json")
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"Properties": {
|
"Properties": {
|
||||||
"SandboxId": "RETAIL",
|
"SandboxId": "RETAIL",
|
||||||
"UserTokens": [
|
"UserTokens": [
|
||||||
token
|
token
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||||
"TokenType": "JWT"
|
"TokenType": "JWT"
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
})
|
||||||
|
.await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
|
|
||||||
let body = resp.text().await?;
|
let body = resp.text().await?;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::io::{Read, SeekFrom};
|
use std::io::{Read, SeekFrom};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
prelude::Credentials,
|
prelude::{Credentials, DirectoryInfo},
|
||||||
util::io::{self, IOError},
|
util::io::{self, IOError},
|
||||||
{state::ProfilePathId, State},
|
{state::ProfilePathId, State},
|
||||||
};
|
};
|
||||||
@@ -74,7 +74,6 @@ pub async fn get_logs(
|
|||||||
profile_path: ProfilePathId,
|
profile_path: ProfilePathId,
|
||||||
clear_contents: Option<bool>,
|
clear_contents: Option<bool>,
|
||||||
) -> crate::Result<Vec<Logs>> {
|
) -> crate::Result<Vec<Logs>> {
|
||||||
let state = State::get().await?;
|
|
||||||
let profile_path =
|
let profile_path =
|
||||||
if let Some(p) = crate::profile::get(&profile_path, None).await? {
|
if let Some(p) = crate::profile::get(&profile_path, None).await? {
|
||||||
p.profile_id()
|
p.profile_id()
|
||||||
@@ -85,7 +84,7 @@ pub async fn get_logs(
|
|||||||
.into());
|
.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();
|
let mut logs = Vec::new();
|
||||||
if logs_folder.exists() {
|
if logs_folder.exists() {
|
||||||
for entry in std::fs::read_dir(&logs_folder)
|
for entry in std::fs::read_dir(&logs_folder)
|
||||||
@@ -138,8 +137,7 @@ pub async fn get_output_by_filename(
|
|||||||
file_name: &str,
|
file_name: &str,
|
||||||
) -> crate::Result<CensoredString> {
|
) -> crate::Result<CensoredString> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
let logs_folder =
|
let logs_folder = DirectoryInfo::profile_logs_dir(profile_subpath).await?;
|
||||||
state.directories.profile_logs_dir(profile_subpath).await?;
|
|
||||||
let path = logs_folder.join(file_name);
|
let path = logs_folder.join(file_name);
|
||||||
|
|
||||||
let credentials: Vec<Credentials> =
|
let credentials: Vec<Credentials> =
|
||||||
@@ -201,8 +199,7 @@ pub async fn delete_logs(profile_path: ProfilePathId) -> crate::Result<()> {
|
|||||||
.into());
|
.into());
|
||||||
};
|
};
|
||||||
|
|
||||||
let state = State::get().await?;
|
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
|
||||||
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
|
|
||||||
for entry in std::fs::read_dir(&logs_folder)
|
for entry in std::fs::read_dir(&logs_folder)
|
||||||
.map_err(|e| IOError::with_path(e, &logs_folder))?
|
.map_err(|e| IOError::with_path(e, &logs_folder))?
|
||||||
{
|
{
|
||||||
@@ -230,8 +227,7 @@ pub async fn delete_logs_by_filename(
|
|||||||
.into());
|
.into());
|
||||||
};
|
};
|
||||||
|
|
||||||
let state = State::get().await?;
|
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
|
||||||
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
|
|
||||||
let path = logs_folder.join(filename);
|
let path = logs_folder.join(filename);
|
||||||
io::remove_dir_all(&path).await?;
|
io::remove_dir_all(&path).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -240,6 +236,23 @@ pub async fn delete_logs_by_filename(
|
|||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn get_latest_log_cursor(
|
pub async fn get_latest_log_cursor(
|
||||||
profile_path: ProfilePathId,
|
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
|
mut cursor: u64, // 0 to start at beginning of file
|
||||||
) -> crate::Result<LatestLogCursor> {
|
) -> crate::Result<LatestLogCursor> {
|
||||||
let profile_path =
|
let profile_path =
|
||||||
@@ -253,8 +266,8 @@ pub async fn get_latest_log_cursor(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let state = State::get().await?;
|
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("latest.log");
|
let path = logs_folder.join(log_file_name);
|
||||||
if !path.exists() {
|
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)
|
// 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 {
|
return Ok(LatestLogCursor {
|
||||||
|
|||||||
@@ -512,8 +512,8 @@ pub async fn launch_minecraft(
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
)
|
)
|
||||||
.current_dir(instance_path.clone())
|
.current_dir(instance_path.clone())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::null());
|
.stderr(Stdio::piped());
|
||||||
|
|
||||||
// CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground
|
// CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
use super::DirectoryInfo;
|
||||||
use super::{Profile, ProfilePathId};
|
use super::{Profile, ProfilePathId};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
use sysinfo::PidExt;
|
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::Child;
|
||||||
|
use tokio::process::ChildStderr;
|
||||||
|
use tokio::process::ChildStdout;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
use crate::event::emit::emit_process;
|
use crate::event::emit::emit_process;
|
||||||
use crate::event::ProcessPayloadType;
|
use crate::event::ProcessPayloadType;
|
||||||
@@ -192,6 +201,7 @@ impl ChildType {
|
|||||||
pub struct MinecraftChild {
|
pub struct MinecraftChild {
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
pub profile_relative_path: ProfilePathId,
|
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 manager: Option<JoinHandle<crate::Result<i32>>>, // None when future has completed and been handled
|
||||||
pub current_child: Arc<RwLock<ChildType>>,
|
pub current_child: Arc<RwLock<ChildType>>,
|
||||||
pub last_updated_playtime: DateTime<Utc>, // The last time we updated the playtime for the associated profile
|
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>,
|
censor_strings: HashMap<String, String>,
|
||||||
) -> crate::Result<Arc<RwLock<MinecraftChild>>> {
|
) -> crate::Result<Arc<RwLock<MinecraftChild>>> {
|
||||||
// Takes the first element of the commands vector and spawns it
|
// 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);
|
let child = ChildType::TokioChild(child);
|
||||||
|
|
||||||
// Slots child into manager
|
// Slots child into manager
|
||||||
@@ -312,6 +358,7 @@ impl Children {
|
|||||||
let mchild = MinecraftChild {
|
let mchild = MinecraftChild {
|
||||||
uuid,
|
uuid,
|
||||||
profile_relative_path,
|
profile_relative_path,
|
||||||
|
output: Some(shared_output),
|
||||||
current_child,
|
current_child,
|
||||||
manager,
|
manager,
|
||||||
last_updated_playtime,
|
last_updated_playtime,
|
||||||
@@ -402,6 +449,7 @@ impl Children {
|
|||||||
let mchild = MinecraftChild {
|
let mchild = MinecraftChild {
|
||||||
uuid: cached_process.uuid,
|
uuid: cached_process.uuid,
|
||||||
profile_relative_path: cached_process.profile_relative_path,
|
profile_relative_path: cached_process.profile_relative_path,
|
||||||
|
output: None, // No output for cached/rescued processes
|
||||||
current_child,
|
current_child,
|
||||||
manager,
|
manager,
|
||||||
last_updated_playtime,
|
last_updated_playtime,
|
||||||
@@ -710,3 +758,117 @@ impl Default for Children {
|
|||||||
Self::new()
|
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
|
/// Gets the logs dir for a given profile
|
||||||
#[inline]
|
#[inline]
|
||||||
pub async fn profile_logs_dir(
|
pub async fn profile_logs_dir(
|
||||||
&self,
|
|
||||||
profile_id: &ProfilePathId,
|
profile_id: &ProfilePathId,
|
||||||
) -> crate::Result<PathBuf> {
|
) -> crate::Result<PathBuf> {
|
||||||
Ok(profile_id.get_full_path().await?.join("logs"))
|
Ok(profile_id.get_full_path().await?.join("logs"))
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
logs_delete_logs,
|
logs_delete_logs,
|
||||||
logs_delete_logs_by_filename,
|
logs_delete_logs_by_filename,
|
||||||
logs_get_latest_log_cursor,
|
logs_get_latest_log_cursor,
|
||||||
|
logs_get_std_log_cursor,
|
||||||
])
|
])
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
@@ -90,3 +91,12 @@ pub async fn logs_get_latest_log_cursor(
|
|||||||
) -> Result<LatestLogCursor> {
|
) -> Result<LatestLogCursor> {
|
||||||
Ok(logs::get_latest_log_cursor(profile_path, cursor).await?)
|
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?)
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,8 +61,14 @@ const os = ref('')
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
initialize: async () => {
|
initialize: async () => {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
const { native_decorations, theme, opt_out_analytics, collapsed_navigation, advanced_rendering, fully_onboarded } =
|
const {
|
||||||
await get()
|
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
|
// video should play if the user is not on linux, and has not onboarded
|
||||||
os.value = await getOS()
|
os.value = await getOS()
|
||||||
videoPlaying.value = !fully_onboarded && os.value !== 'Linux'
|
videoPlaying.value = !fully_onboarded && os.value !== 'Linux'
|
||||||
@@ -71,7 +77,7 @@ defineExpose({
|
|||||||
showOnboarding.value = !fully_onboarded
|
showOnboarding.value = !fully_onboarded
|
||||||
|
|
||||||
nativeDecorations.value = native_decorations
|
nativeDecorations.value = native_decorations
|
||||||
if (os !== "MacOS") appWindow.setDecorations(native_decorations)
|
if (os.value !== 'MacOS') appWindow.setDecorations(native_decorations)
|
||||||
|
|
||||||
themeStore.setThemeState(theme)
|
themeStore.setThemeState(theme)
|
||||||
themeStore.collapsedNavigation = collapsed_navigation
|
themeStore.collapsedNavigation = collapsed_navigation
|
||||||
|
|||||||
@@ -24,7 +24,10 @@
|
|||||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
||||||
>
|
>
|
||||||
<div v-if="selectedAccount" class="selected account">
|
<div v-if="selectedAccount" class="selected account">
|
||||||
<Avatar size="xs" :src="`https://crafatar.com/avatars/${selectedAccount.id}?size=128&overlay`" />
|
<Avatar
|
||||||
|
size="xs"
|
||||||
|
:src="`https://crafatar.com/avatars/${selectedAccount.id}?size=128&overlay`"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h4>{{ selectedAccount.username }}</h4>
|
<h4>{{ selectedAccount.username }}</h4>
|
||||||
<p>Selected</p>
|
<p>Selected</p>
|
||||||
|
|||||||
@@ -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.
|
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) {
|
export async function get_latest_log_cursor(profilePath, cursor) {
|
||||||
return await invoke('plugin:logs|logs_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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { invoke } from '@tauri-apps/api/tauri'
|
|||||||
|
|
||||||
export async function create(name, gameVersion, modloader, loaderVersion, icon, noWatch) {
|
export async function create(name, gameVersion, modloader, loaderVersion, icon, noWatch) {
|
||||||
//Trim string name to avoid "Unable to find directory"
|
//Trim string name to avoid "Unable to find directory"
|
||||||
name = name.trim();
|
name = name.trim()
|
||||||
return await invoke('plugin:profile_create|profile_create', {
|
return await invoke('plugin:profile_create|profile_create', {
|
||||||
name,
|
name,
|
||||||
gameVersion,
|
gameVersion,
|
||||||
|
|||||||
@@ -246,9 +246,7 @@ async function refreshDir() {
|
|||||||
<div v-if="getOS() != 'MacOS'" class="adjacent-input">
|
<div v-if="getOS() != 'MacOS'" class="adjacent-input">
|
||||||
<label for="native-decorations">
|
<label for="native-decorations">
|
||||||
<span class="label__title">Native decorations</span>
|
<span class="label__title">Native decorations</span>
|
||||||
<span class="label__description"
|
<span class="label__description">Use system window frame (app restart required).</span>
|
||||||
>Use system window frame (app restart required).</span
|
|
||||||
>
|
|
||||||
</label>
|
</label>
|
||||||
<Toggle
|
<Toggle
|
||||||
id="native-decorations"
|
id="native-decorations"
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ import {
|
|||||||
delete_logs_by_filename,
|
delete_logs_by_filename,
|
||||||
get_logs,
|
get_logs,
|
||||||
get_output_by_filename,
|
get_output_by_filename,
|
||||||
get_latest_log_cursor,
|
get_std_log_cursor,
|
||||||
} from '@/helpers/logs.js'
|
} from '@/helpers/logs.js'
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -216,14 +216,14 @@ const processedLogs = computed(() => {
|
|||||||
return processed
|
return processed
|
||||||
})
|
})
|
||||||
|
|
||||||
async function getLiveLog() {
|
async function getLiveStdLog() {
|
||||||
if (route.params.id) {
|
if (route.params.id) {
|
||||||
const uuids = await get_uuids_by_profile_path(route.params.id).catch(handleError)
|
const uuids = await get_uuids_by_profile_path(route.params.id).catch(handleError)
|
||||||
let returnValue
|
let returnValue
|
||||||
if (uuids.length === 0) {
|
if (uuids.length === 0) {
|
||||||
returnValue = emptyText.join('\n')
|
returnValue = emptyText.join('\n')
|
||||||
} else {
|
} else {
|
||||||
const logCursor = await get_latest_log_cursor(
|
const logCursor = await get_std_log_cursor(
|
||||||
props.instance.path,
|
props.instance.path,
|
||||||
currentLiveLogCursor.value
|
currentLiveLogCursor.value
|
||||||
).catch(handleError)
|
).catch(handleError)
|
||||||
@@ -240,34 +240,42 @@ async function getLiveLog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getLogs() {
|
async function getLogs() {
|
||||||
return (await get_logs(props.instance.path, true).catch(handleError)).reverse().map((log) => {
|
return (await get_logs(props.instance.path, true).catch(handleError))
|
||||||
if (log.filename == 'latest.log') {
|
.reverse()
|
||||||
log.name = 'Latest Log'
|
.filter(
|
||||||
} else {
|
(log) =>
|
||||||
let filename = log.filename.split('.')[0]
|
log.filename !== 'latest_stdout.log' &&
|
||||||
let day = dayjs(filename.slice(0, 10))
|
log.filename !== 'latest_stdout' &&
|
||||||
if (day.isValid()) {
|
log.stdout !== ''
|
||||||
if (day.isToday()) {
|
)
|
||||||
log.name = 'Today'
|
.map((log) => {
|
||||||
} else if (day.isYesterday()) {
|
if (log.filename == 'latest.log') {
|
||||||
log.name = 'Yesterday'
|
log.name = 'Latest Log'
|
||||||
} 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 {
|
} 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...'
|
||||||
log.stdout = 'Loading...'
|
return log
|
||||||
return log
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setLogs() {
|
async function setLogs() {
|
||||||
const [liveLog, allLogs] = await Promise.all([getLiveLog(), getLogs()])
|
const [liveStd, allLogs] = await Promise.all([getLiveStdLog(), getLogs()])
|
||||||
logs.value = [liveLog, ...allLogs]
|
logs.value = [liveStd, ...allLogs]
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyLog = () => {
|
const copyLog = () => {
|
||||||
@@ -426,7 +434,7 @@ function handleUserScroll() {
|
|||||||
|
|
||||||
interval.value = setInterval(async () => {
|
interval.value = setInterval(async () => {
|
||||||
if (logs.value.length > 0) {
|
if (logs.value.length > 0) {
|
||||||
logs.value[0] = await getLiveLog()
|
logs.value[0] = await getLiveStdLog()
|
||||||
|
|
||||||
const scroll = logContainer.value.getScroll()
|
const scroll = logContainer.value.getScroll()
|
||||||
// Allow resetting of userScrolled if the user scrolls to the bottom
|
// Allow resetting of userScrolled if the user scrolls to the bottom
|
||||||
|
|||||||
@@ -542,7 +542,7 @@ const ascending = ref(true)
|
|||||||
const sortColumn = ref('Name')
|
const sortColumn = ref('Name')
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
|
||||||
watch(searchFilter, () => currentPage.value = 1)
|
watch(searchFilter, () => (currentPage.value = 1))
|
||||||
|
|
||||||
const selected = computed(() =>
|
const selected = computed(() =>
|
||||||
Array.from(selectionMap.value)
|
Array.from(selectionMap.value)
|
||||||
|
|||||||
Reference in New Issue
Block a user