diff --git a/theseus/src/api/hydra/init.rs b/theseus/src/api/hydra/init.rs index e27996aa1..324bb727a 100644 --- a/theseus/src/api/hydra/init.rs +++ b/theseus/src/api/hydra/init.rs @@ -5,7 +5,7 @@ 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 { @@ -28,13 +28,13 @@ pub async fn init() -> crate::Result { 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.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode") + .header("Content-Type", "application/x-www-form-urlencoded").form(¶ms).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::().await?; + let microsoft_error = resp.json::().await?; Err(crate::ErrorKind::HydraError(format!( "Error from Microsoft: {:?}", microsoft_error.error_description diff --git a/theseus/src/api/hydra/refresh.rs b/theseus/src/api/hydra/refresh.rs index 4caf19837..9fb3c8fc4 100644 --- a/theseus/src/api/hydra/refresh.rs +++ b/theseus/src/api/hydra/refresh.rs @@ -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, @@ -25,11 +27,14 @@ pub async fn refresh(refresh_token: String) -> crate::Result { // 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() { diff --git a/theseus/src/api/hydra/stages/bearer_token.rs b/theseus/src/api/hydra/stages/bearer_token.rs index 2edcd6493..68ef855c4 100644 --- a/theseus/src/api/hydra/stages/bearer_token.rs +++ b/theseus/src/api/hydra/stages/bearer_token.rs @@ -1,20 +1,25 @@ use serde_json::json; +use super::auth_retry; + const MCSERVICES_AUTH_URL: &str = "https://api.minecraftservices.com/launcher/login"; +#[tracing::instrument] pub async fn fetch_bearer(token: &str, uhs: &str) -> crate::Result { - 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?; + let body = auth_retry(|| { + let client = reqwest::Client::new(); + client + .post(MCSERVICES_AUTH_URL) + .json(&json!({ + "xtoken": format!("XBL3.0 x={};{}", uhs, token), + "platform": "PC_LAUNCHER" + })) + .send() + }) + .await? + .text() + .await?; serde_json::from_str::(&body)? .get("access_token") diff --git a/theseus/src/api/hydra/stages/mod.rs b/theseus/src/api/hydra/stages/mod.rs index 9ccf23715..5bb58dcc6 100644 --- a/theseus/src/api/hydra/stages/mod.rs +++ b/theseus/src/api/hydra/stages/mod.rs @@ -1,7 +1,37 @@ //! MSA authentication stages +use futures::Future; +use reqwest::Response; + +const RETRY_COUNT: usize = 2; // Does command 3 times +const RETRY_WAIT: std::time::Duration = std::time::Duration::from_secs(2); + pub mod bearer_token; pub mod player_info; pub mod poll_response; pub mod xbl_signin; pub mod xsts_token; + +#[tracing::instrument(skip(reqwest_request))] +pub async fn auth_retry( + reqwest_request: impl Fn() -> F, +) -> crate::Result +where + F: Future>, +{ + 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) +} diff --git a/theseus/src/api/hydra/stages/player_info.rs b/theseus/src/api/hydra/stages/player_info.rs index 15923efb8..1383cd25c 100644 --- a/theseus/src/api/hydra/stages/player_info.rs +++ b/theseus/src/api/hydra/stages/player_info.rs @@ -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)] @@ -18,16 +22,17 @@ impl Default for PlayerInfo { } } +#[tracing::instrument] pub async fn fetch_info(token: &str) -> crate::Result { - 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?; + 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) } diff --git a/theseus/src/api/hydra/stages/poll_response.rs b/theseus/src/api/hydra/stages/poll_response.rs index b38435600..84c59d57e 100644 --- a/theseus/src/api/hydra/stages/poll_response.rs +++ b/theseus/src/api/hydra/stages/poll_response.rs @@ -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,6 +19,7 @@ pub struct OauthSuccess { pub refresh_token: String, } +#[tracing::instrument] pub async fn poll_response(device_code: String) -> crate::Result { let mut params = HashMap::new(); 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 { // 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 => { diff --git a/theseus/src/api/hydra/stages/xbl_signin.rs b/theseus/src/api/hydra/stages/xbl_signin.rs index 60b432da1..7f4c048e6 100644 --- a/theseus/src/api/hydra/stages/xbl_signin.rs +++ b/theseus/src/api/hydra/stages/xbl_signin.rs @@ -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,26 @@ pub struct XBLLogin { } // Impl +#[tracing::instrument] pub async fn login_xbl(token: &str) -> crate::Result { - 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") + .header("x-xbl-contract-version", "1") + .json(&json!({ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": format!("d={token}") + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" + })) + .send() + }) + .await?; + let body = response.text().await?; let json = serde_json::from_str::(&body)?; let token = Some(&json) diff --git a/theseus/src/api/hydra/stages/xsts_token.rs b/theseus/src/api/hydra/stages/xsts_token.rs index 4e1497707..80fbd892c 100644 --- a/theseus/src/api/hydra/stages/xsts_token.rs +++ b/theseus/src/api/hydra/stages/xsts_token.rs @@ -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 { - 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?; diff --git a/theseus/src/api/logs.rs b/theseus/src/api/logs.rs index 831786354..c3ab4cc92 100644 --- a/theseus/src/api/logs.rs +++ b/theseus/src/api/logs.rs @@ -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, ) -> crate::Result> { - 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 { 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 = @@ -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 { + 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 { + 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 { 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 { diff --git a/theseus/src/launcher/mod.rs b/theseus/src/launcher/mod.rs index 1a4167e00..08ab2e877 100644 --- a/theseus/src/launcher/mod.rs +++ b/theseus/src/launcher/mod.rs @@ -512,8 +512,8 @@ pub async fn launch_minecraft( .collect::>(), ) .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")] diff --git a/theseus/src/state/children.rs b/theseus/src/state/children.rs index d7975f02d..0b474567f 100644 --- a/theseus/src/state/children.rs +++ b/theseus/src/state/children.rs @@ -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, pub manager: Option>>, // None when future has completed and been handled pub current_child: Arc>, pub last_updated_playtime: DateTime, // The last time we updated the playtime for the associated profile @@ -271,7 +281,43 @@ impl Children { censor_strings: HashMap, ) -> crate::Result>> { // 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>, + censor_strings: HashMap, +} + +impl SharedOutput { + #[tracing::instrument(skip(censor_strings))] + async fn build( + log_file_path: &Path, + censor_strings: HashMap, + ) -> crate::Result { + // 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 + } +} diff --git a/theseus/src/state/dirs.rs b/theseus/src/state/dirs.rs index adfc15eee..4d3a64608 100644 --- a/theseus/src/state/dirs.rs +++ b/theseus/src/state/dirs.rs @@ -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 { Ok(profile_id.get_full_path().await?.join("logs")) diff --git a/theseus_gui/src-tauri/src/api/logs.rs b/theseus_gui/src-tauri/src/api/logs.rs index d328aac4e..cfea2efb3 100644 --- a/theseus_gui/src-tauri/src/api/logs.rs +++ b/theseus_gui/src-tauri/src/api/logs.rs @@ -23,6 +23,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { 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 { 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 { + Ok(logs::get_std_log_cursor(profile_path, cursor).await?) +} diff --git a/theseus_gui/src/App.vue b/theseus_gui/src/App.vue index 957c527dc..a93e3c66d 100644 --- a/theseus_gui/src/App.vue +++ b/theseus_gui/src/App.vue @@ -61,8 +61,14 @@ const os = ref('') defineExpose({ initialize: async () => { isLoading.value = false - const { native_decorations, 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' @@ -71,7 +77,7 @@ defineExpose({ showOnboarding.value = !fully_onboarded nativeDecorations.value = native_decorations - if (os !== "MacOS") appWindow.setDecorations(native_decorations) + if (os.value !== 'MacOS') appWindow.setDecorations(native_decorations) themeStore.setThemeState(theme) themeStore.collapsedNavigation = collapsed_navigation diff --git a/theseus_gui/src/components/ui/AccountsCard.vue b/theseus_gui/src/components/ui/AccountsCard.vue index 27a6fe5a2..c4ece1e86 100644 --- a/theseus_gui/src/components/ui/AccountsCard.vue +++ b/theseus_gui/src/components/ui/AccountsCard.vue @@ -24,7 +24,10 @@ :class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }" >