diff --git a/.vscode/settings.json b/.vscode/settings.json index d5fbd58db..526b84890 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -53,4 +53,7 @@ "svelte.ask-to-enable-ts-plugin": false, "svelte.plugin.css.diagnostics.enable": false, "svelte.plugin.svelte.diagnostics.enable": false, + "rust-analyzer.linkedProjects": [ + "./theseus/Cargo.toml" + ], } \ No newline at end of file diff --git a/theseus/src/api/logs.rs b/theseus/src/api/logs.rs new file mode 100644 index 000000000..536b5bd10 --- /dev/null +++ b/theseus/src/api/logs.rs @@ -0,0 +1,112 @@ +use crate::State; +use serde::{Deserialize, Serialize}; +use tokio::fs::read_to_string; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Logs { + pub datetime_string: String, + pub stdout: String, + pub stderr: String, +} +impl Logs { + async fn build( + profile_uuid: uuid::Uuid, + datetime_string: String, + ) -> crate::Result { + Ok(Self { + stdout: get_stdout_by_datetime(profile_uuid, &datetime_string) + .await?, + stderr: get_stderr_by_datetime(profile_uuid, &datetime_string) + .await?, + datetime_string, + }) + } +} + +#[tracing::instrument] +pub async fn get_logs(profile_uuid: uuid::Uuid) -> crate::Result> { + let state = State::get().await?; + let logs_folder = state.directories.profile_logs_dir(profile_uuid); + let mut logs = Vec::new(); + for entry in std::fs::read_dir(logs_folder)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if let Some(datetime_string) = path.file_name() { + logs.push( + Logs::build( + profile_uuid, + datetime_string.to_string_lossy().to_string(), + ) + .await, + ); + } + } + } + let mut logs = logs.into_iter().collect::>>()?; + logs.sort_by_key(|x| x.datetime_string.clone()); + Ok(logs) +} + +#[tracing::instrument] +pub async fn get_logs_by_datetime( + profile_uuid: uuid::Uuid, + datetime_string: String, +) -> crate::Result { + Ok(Logs { + stdout: get_stdout_by_datetime(profile_uuid, &datetime_string).await?, + stderr: get_stderr_by_datetime(profile_uuid, &datetime_string).await?, + datetime_string, + }) +} + +#[tracing::instrument] +pub async fn get_stdout_by_datetime( + profile_uuid: uuid::Uuid, + datetime_string: &str, +) -> crate::Result { + let state = State::get().await?; + let logs_folder = state.directories.profile_logs_dir(profile_uuid); + Ok( + read_to_string(logs_folder.join(datetime_string).join("stdout.log")) + .await?, + ) +} + +#[tracing::instrument] +pub async fn get_stderr_by_datetime( + profile_uuid: uuid::Uuid, + datetime_string: &str, +) -> crate::Result { + let state = State::get().await?; + let logs_folder = state.directories.profile_logs_dir(profile_uuid); + Ok( + read_to_string(logs_folder.join(datetime_string).join("stderr.log")) + .await?, + ) +} + +#[tracing::instrument] +pub async fn delete_logs(profile_uuid: uuid::Uuid) -> crate::Result<()> { + let state = State::get().await?; + let logs_folder = state.directories.profile_logs_dir(profile_uuid); + for entry in std::fs::read_dir(logs_folder)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + std::fs::remove_dir_all(path)?; + } + } + Ok(()) +} + +#[tracing::instrument] +pub async fn delete_logs_by_datetime( + profile_uuid: uuid::Uuid, + datetime_string: &str, +) -> crate::Result<()> { + let state = State::get().await?; + let logs_folder = state.directories.profile_logs_dir(profile_uuid); + std::fs::remove_dir_all(logs_folder.join(datetime_string))?; + Ok(()) +} diff --git a/theseus/src/api/mod.rs b/theseus/src/api/mod.rs index 3ee31ffe7..b9d4c4969 100644 --- a/theseus/src/api/mod.rs +++ b/theseus/src/api/mod.rs @@ -1,6 +1,7 @@ //! API for interacting with Theseus pub mod auth; pub mod jre; +pub mod logs; pub mod metadata; pub mod pack; pub mod process; diff --git a/theseus/src/launcher/mod.rs b/theseus/src/launcher/mod.rs index bf1c4cb40..4659546b1 100644 --- a/theseus/src/launcher/mod.rs +++ b/theseus/src/launcher/mod.rs @@ -9,6 +9,7 @@ use crate::{ use daedalus as d; use dunce::canonicalize; use st::Profile; +use std::fs; use std::{path::Path, process::Stdio, sync::Arc}; use tokio::process::Command; use uuid::Uuid; @@ -322,6 +323,20 @@ pub async fn launch_minecraft( // Clear cargo-added env varaibles for debugging, and add settings env vars clear_cargo_env_vals(&mut command).envs(env_args); + // Get Modrinth logs directories + let datetime_string = + chrono::Local::now().format("%Y%m%y_%H%M%S").to_string(); + let logs_dir = { + let st = State::get().await?; + st.directories + .profile_logs_dir(profile.uuid) + .join(&datetime_string) + }; + fs::create_dir_all(&logs_dir)?; + + let stdout_log_path = logs_dir.join("stdout.log"); + let stderr_log_path = logs_dir.join("stderr.log"); + // Create Minecraft child by inserting it into the state // This also spawns the process and prepares the subsequent processes let mut state_children = state.children.write().await; @@ -329,6 +344,8 @@ pub async fn launch_minecraft( .insert_process( Uuid::new_v4(), instance_path.to_path_buf(), + stdout_log_path, + stderr_log_path, command, post_exit_hook, ) diff --git a/theseus/src/state/children.rs b/theseus/src/state/children.rs index 42cb46a06..7443e444e 100644 --- a/theseus/src/state/children.rs +++ b/theseus/src/state/children.rs @@ -2,7 +2,8 @@ use super::Profile; use std::path::{Path, PathBuf}; use std::process::ExitStatus; use std::{collections::HashMap, sync::Arc}; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::fs::File; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Child; use tokio::process::Command; use tokio::process::{ChildStderr, ChildStdout}; @@ -40,6 +41,8 @@ impl Children { &mut self, uuid: Uuid, profile_path: PathBuf, + stdout_log_path: PathBuf, + stderr_log_path: PathBuf, mut mc_command: Command, post_command: Option, // Command to run after minecraft. ) -> crate::Result>> { @@ -47,7 +50,7 @@ impl Children { let mut child = mc_command.spawn()?; // Create std watcher threads for stdout and stderr - let stdout = SharedOutput::new(); + let stdout = SharedOutput::build(&stdout_log_path).await?; if let Some(child_stdout) = child.stdout.take() { let stdout_clone = stdout.clone(); tokio::spawn(async move { @@ -56,7 +59,7 @@ impl Children { } }); } - let stderr = SharedOutput::new(); + let stderr = SharedOutput::build(&stderr_log_path).await?; if let Some(child_stderr) = child.stderr.take() { let stderr_clone = stderr.clone(); tokio::spawn(async move { @@ -270,16 +273,18 @@ impl Default for Children { // 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(Clone, Debug)] +#[derive(Debug, Clone)] pub struct SharedOutput { output: Arc>, + log_file: Arc>, } impl SharedOutput { - fn new() -> Self { - SharedOutput { + async fn build(log_file_path: &Path) -> crate::Result { + Ok(SharedOutput { output: Arc::new(RwLock::new(String::new())), - } + log_file: Arc::new(RwLock::new(File::create(log_file_path).await?)), + }) } // Main entry function to a created SharedOutput, returns the log as a String @@ -300,6 +305,10 @@ impl SharedOutput { let mut output = self.output.write().await; output.push_str(&line); } + { + let mut log_file = self.log_file.write().await; + log_file.write_all(line.as_bytes()).await?; + } line.clear(); } Ok(()) diff --git a/theseus/src/state/dirs.rs b/theseus/src/state/dirs.rs index b91aafcea..68b81f3e7 100644 --- a/theseus/src/state/dirs.rs +++ b/theseus/src/state/dirs.rs @@ -116,6 +116,14 @@ impl DirectoryInfo { self.config_dir.join("profiles") } + /// Gets the logs dir for a given profile + #[inline] + pub fn profile_logs_dir(&self, profile: uuid::Uuid) -> PathBuf { + self.profiles_dir() + .join(profile.to_string()) + .join("modrinth_logs") + } + /// Get the file containing the global database #[inline] pub fn database_file(&self) -> PathBuf { diff --git a/theseus/src/state/projects.rs b/theseus/src/state/projects.rs index 0db6f15ee..3925bbec6 100644 --- a/theseus/src/state/projects.rs +++ b/theseus/src/state/projects.rs @@ -358,7 +358,9 @@ pub async fn infer_data_from_files( .filter(|x| x.team_id == project.team) .cloned() .collect::>(), - update_version: update_versions.get(&hash).map(|val| Box::new(val.clone())), + update_version: update_versions + .get(&hash) + .map(|val| Box::new(val.clone())), incompatible: !version.loaders.contains( &profile diff --git a/theseus_gui/src-tauri/src/api/logs.rs b/theseus_gui/src-tauri/src/api/logs.rs new file mode 100644 index 000000000..22bfb3379 --- /dev/null +++ b/theseus_gui/src-tauri/src/api/logs.rs @@ -0,0 +1,61 @@ +use crate::api::Result; +use theseus::logs::{self, Logs}; +use uuid::Uuid; + +/* +A log is a struct containing the datetime string, stdout, and stderr, as follows: + +pub struct Logs { + pub datetime_string: String, + pub stdout: String, + pub stderr: String, +} +*/ + +/// Get all Logs for a profile, sorted by datetime +#[tauri::command] +pub async fn logs_get_logs(profile_uuid: Uuid) -> Result> { + Ok(logs::get_logs(profile_uuid).await?) +} + +/// Get a Log struct for a profile by profile id and datetime string +#[tauri::command] +pub async fn logs_get_logs_by_datetime( + profile_uuid: Uuid, + datetime_string: String, +) -> Result { + Ok(logs::get_logs_by_datetime(profile_uuid, datetime_string).await?) +} + +/// Get the stdout for a profile by profile id and datetime string +#[tauri::command] +pub async fn logs_get_stdout_by_datetime( + profile_uuid: Uuid, + datetime_string: String, +) -> Result { + Ok(logs::get_stdout_by_datetime(profile_uuid, &datetime_string).await?) +} + +/// Get the stderr for a profile by profile id and datetime string +#[tauri::command] +pub async fn logs_get_stderr_by_datetime( + profile_uuid: Uuid, + datetime_string: String, +) -> Result { + Ok(logs::get_stderr_by_datetime(profile_uuid, &datetime_string).await?) +} + +/// Delete all logs for a profile by profile id +#[tauri::command] +pub async fn logs_delete_logs(profile_uuid: Uuid) -> Result<()> { + Ok(logs::delete_logs(profile_uuid).await?) +} + +/// Delete a log for a profile by profile id and datetime string +#[tauri::command] +pub async fn logs_delete_logs_by_datetime( + profile_uuid: Uuid, + datetime_string: String, +) -> Result<()> { + Ok(logs::delete_logs_by_datetime(profile_uuid, &datetime_string).await?) +} diff --git a/theseus_gui/src-tauri/src/api/mod.rs b/theseus_gui/src-tauri/src/api/mod.rs index 8e693f871..a1b5a33f9 100644 --- a/theseus_gui/src-tauri/src/api/mod.rs +++ b/theseus_gui/src-tauri/src/api/mod.rs @@ -4,7 +4,7 @@ use thiserror::Error; pub mod auth; pub mod jre; - +pub mod logs; pub mod metadata; pub mod pack; pub mod process; diff --git a/theseus_gui/src-tauri/src/main.rs b/theseus_gui/src-tauri/src/main.rs index e63d9c750..7d5065869 100644 --- a/theseus_gui/src-tauri/src/main.rs +++ b/theseus_gui/src-tauri/src/main.rs @@ -101,6 +101,12 @@ fn main() { api::metadata::metadata_get_game_versions, api::metadata::metadata_get_fabric_versions, api::metadata::metadata_get_forge_versions, + api::logs::logs_get_logs, + api::logs::logs_get_logs_by_datetime, + api::logs::logs_get_stdout_by_datetime, + api::logs::logs_get_stderr_by_datetime, + api::logs::logs_delete_logs, + api::logs::logs_delete_logs_by_datetime, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/theseus_gui/src/helpers/logs.js b/theseus_gui/src/helpers/logs.js new file mode 100644 index 000000000..ddff16a7f --- /dev/null +++ b/theseus_gui/src/helpers/logs.js @@ -0,0 +1,47 @@ +/** + * All theseus API calls return serialized values (both return values and errors); + * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized, + * and deserialized into a usable JS object. + */ +import { invoke } from '@tauri-apps/api/tauri' + +/* +A log is a struct containing the datetime string, stdout, and stderr, as follows: + +pub struct Logs { + pub datetime_string: String, + pub stdout: String, + pub stderr: String, +} +*/ + +/// Get all logs that exist for a given profile +/// This is returned as an array of Log objects, sorted by datetime_string (the folder name, when the log was created) +export async function get_logs(profileUuid) { + return await invoke('logs_get_logs', { profileUuid }) +} + +/// Get a profile's log by datetime_string (the folder name, when the log was created) +export async function get_logs_by_datetime(profileUuid, datetimeString) { + return await invoke('logs_get_logs_by_datetime', { profileUuid, datetimeString }) +} + +/// Get a profile's stdout only by datetime_string (the folder name, when the log was created) +export async function get_stdout_by_datetime(profileUuid, datetimeString) { + return await invoke('logs_get_stdout_by_datetime', { profileUuid, datetimeString }) +} + +/// Get a profile's stderr only by datetime_string (the folder name, when the log was created) +export async function get_stderr_by_datetime(profileUuid, datetimeString) { + return await invoke('logs_get_stderr_by_datetime', { profileUuid, datetimeString }) +} + +/// Delete a profile's log by datetime_string (the folder name, when the log was created) +export async function delete_logs_by_datetime(profileUuid, datetimeString) { + return await invoke('logs_delete_logs_by_datetime', { profileUuid, datetimeString }) +} + +/// Delete all logs for a given profile +export async function delete_logs(profileUuid) { + return await invoke('logs_delete_logs', { profileUuid }) +}