Auth retrying, std logs (#879)

This commit is contained in:
Wyatt Verchere
2023-11-17 20:49:32 -08:00
committed by GitHub
parent 01ab507e3a
commit 25662d1402
20 changed files with 379 additions and 114 deletions

View File

@@ -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(&params).send().await?; .header("Content-Type", "application/x-www-form-urlencoded").form(&params).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

View File

@@ -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(&params) .form(&params)
.send() .send()
})
.await?; .await?;
match resp.status() { match resp.status() {

View File

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

View File

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

View File

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

View File

@@ -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(&params) .form(&params)
.send() .send()
.await?; })
.await?;
match resp.status() { match resp.status() {
StatusCode::OK => { StatusCode::OK => {

View File

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

View File

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

View File

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

View File

@@ -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")]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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