From f48959a8160cefcb4c5c3c801916f00dbe3307d6 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 31 Mar 2023 11:00:43 -0700 Subject: [PATCH] Profile bindings (#55) * basic framework. still has errors * added functionality for main endpoints + some structuring * formatting * unused code * mimicked CLI function with wait_for process * made PR changes, added playground * cargo fmt * removed missed println * misc tests fixes * cargo fmt * added windows support * cargo fmt * all OS use dunce * restructured profile slightly; fixed mac bug * profile changes, new main.rs * fixed requested pr + canonicaliation bug * fixed regressed bug in ui * fixed regressed bugs * fixed git error * typo * ran prettier * clippy * playground clippy * ported profile loading fix * profile change for real, url println and clippy * PR changes --------- Co-authored-by: Wyatt --- .gitignore | 3 + Cargo.lock | 27 +++ Cargo.toml | 1 + theseus/Cargo.toml | 4 +- theseus/src/api/mod.rs | 3 +- theseus/src/api/profile.rs | 24 ++- theseus/src/api/profile_create.rs | 165 ++++++++++++++++++ theseus/src/error.rs | 8 + theseus/src/launcher/args.rs | 44 ++--- theseus/src/launcher/auth.rs | 4 +- theseus/src/launcher/download.rs | 5 +- theseus/src/launcher/mod.rs | 64 ++++--- theseus/src/state/children.rs | 37 ++++ theseus/src/state/dirs.rs | 6 + theseus/src/state/mod.rs | 8 + theseus/src/state/profiles.rs | 20 ++- theseus/src/util/jre.rs | 7 +- theseus_cli/Cargo.toml | 6 +- theseus_cli/src/subcommands/profile.rs | 12 +- theseus_gui/.gitignore | 2 + theseus_gui/src-tauri/Cargo.toml | 8 + theseus_gui/src-tauri/src/api/mod.rs | 66 +++++++ theseus_gui/src-tauri/src/api/profile.rs | 124 +++++++++++++ .../src-tauri/src/api/profile_create.rs | 34 ++++ theseus_gui/src-tauri/src/main.rs | 25 +++ theseus_gui/src/helpers/profile.js | 73 ++++++++ theseus_gui/src/helpers/state.js | 12 ++ theseus_playground/Cargo.toml | 22 +++ theseus_playground/src/main.rs | 119 +++++++++++++ yarn.lock | 4 + 30 files changed, 857 insertions(+), 80 deletions(-) create mode 100644 theseus/src/api/profile_create.rs create mode 100644 theseus/src/state/children.rs create mode 100644 theseus_gui/src-tauri/src/api/mod.rs create mode 100644 theseus_gui/src-tauri/src/api/profile.rs create mode 100644 theseus_gui/src-tauri/src/api/profile_create.rs create mode 100644 theseus_gui/src/helpers/profile.js create mode 100644 theseus_gui/src/helpers/state.js create mode 100644 theseus_playground/Cargo.toml create mode 100644 theseus_playground/src/main.rs create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore index 499bff711..d9fcee736 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ WixTools .DS_Store .pnpm-debug.log +minecraft +config + [#]*[#] # TEMPORARY: ignore my test instance and metadata diff --git a/Cargo.lock b/Cargo.lock index b3b1347a4..6f9668071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3531,6 +3531,7 @@ dependencies = [ "sys-info", "thiserror", "tokio", + "tokio-stream", "toml 0.7.3", "tracing", "tracing-error", @@ -3549,6 +3550,7 @@ dependencies = [ "daedalus", "dialoguer", "dirs", + "dunce", "eyre", "futures", "paris", @@ -3563,16 +3565,41 @@ dependencies = [ "url", "uuid 1.3.0", "webbrowser", + "winreg 0.11.0", ] [[package]] name = "theseus_gui" version = "0.0.0" dependencies = [ + "daedalus", + "futures", "serde", "serde_json", "tauri", "tauri-build", + "theseus", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "theseus_playground" +version = "0.1.0" +dependencies = [ + "daedalus", + "dunce", + "futures", + "serde", + "serde_json", + "tauri", + "theseus", + "thiserror", + "tokio", + "tokio-stream", + "url", + "webbrowser", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 93e03389b..af7b943af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "theseus", "theseus_cli", + "theseus_playground", "theseus_gui/src-tauri" ] diff --git a/theseus/Cargo.toml b/theseus/Cargo.toml index ee4585210..e92b97b56 100644 --- a/theseus/Cargo.toml +++ b/theseus/Cargo.toml @@ -36,11 +36,13 @@ futures = "0.3" once_cell = "1.9.0" reqwest = { version = "0.11", features = ["json"] } tokio = { version = "1", features = ["full"] } +tokio-stream = { version = "0.1", features = ["fs"] } + lazy_static = "1.4.0" +dunce = "1.0.3" [target.'cfg(windows)'.dependencies] winreg = "0.11.0" -dunce = "1.0.3" [dev-dependencies] argh = "0.1.6" diff --git a/theseus/src/api/mod.rs b/theseus/src/api/mod.rs index 0d572b3b5..064c04a33 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 profile; +pub mod profile_create; pub mod data { pub use crate::state::{ @@ -14,6 +15,6 @@ pub mod prelude { auth::{self, Credentials}, data::*, profile::{self, Profile}, - State, + profile_create, State, }; } diff --git a/theseus/src/api/profile.rs b/theseus/src/api/profile.rs index de4820e84..4a9d87fc1 100644 --- a/theseus/src/api/profile.rs +++ b/theseus/src/api/profile.rs @@ -7,8 +7,12 @@ use daedalus as d; use std::{ future::Future, path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::{ + process::{Child, Command}, + sync::RwLock, }; -use tokio::process::{Child, Command}; /// Add a profile to the in-memory state #[tracing::instrument] @@ -105,11 +109,12 @@ pub async fn list( } /// Run Minecraft using a profile +/// Returns Arc pointer to RwLock to Child #[tracing::instrument(skip_all)] pub async fn run( path: &Path, credentials: &crate::auth::Credentials, -) -> crate::Result { +) -> crate::Result>> { let state = State::get().await.unwrap(); let settings = state.settings.read().await; let profile = get(path).await?.ok_or_else(|| { @@ -199,7 +204,7 @@ pub async fn run( let memory = profile.memory.unwrap_or(settings.memory); let resolution = profile.resolution.unwrap_or(settings.game_resolution); - crate::launcher::launch_minecraft( + let mc_process = crate::launcher::launch_minecraft( &profile.metadata.game_version, &profile.metadata.loader_version, &profile.path, @@ -210,7 +215,18 @@ pub async fn run( &resolution, credentials, ) - .await + .await?; + + // Insert child into state + let mut state_children = state.children.write().await; + let pid = mc_process.id().ok_or_else(|| { + crate::ErrorKind::LauncherError( + "Process failed to stay open.".to_string(), + ) + })?; + let child_arc = state_children.insert(pid, mc_process); + + Ok(child_arc) } #[tracing::instrument] diff --git a/theseus/src/api/profile_create.rs b/theseus/src/api/profile_create.rs new file mode 100644 index 000000000..2046f2b89 --- /dev/null +++ b/theseus/src/api/profile_create.rs @@ -0,0 +1,165 @@ +//! Theseus profile management interface +use crate::{prelude::ModLoader, profile}; +pub use crate::{ + state::{JavaSettings, Profile}, + State, +}; +use daedalus::modded::LoaderVersion; +use dunce::canonicalize; +use futures::prelude::*; +use std::path::PathBuf; +use tokio::fs; +use tokio_stream::wrappers::ReadDirStream; +use uuid::Uuid; + +const DEFAULT_NAME: &str = "Untitled Instance"; + +// Generic basic profile creation tool. +// Creates an essentially empty dummy profile with profile_create +#[tracing::instrument] +pub async fn profile_create_empty() -> crate::Result { + profile_create( + String::from(DEFAULT_NAME), // the name/path of the profile + String::from("1.19.2"), // the game version of the profile + ModLoader::Vanilla, // the modloader to use + String::from("stable"), // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader + None, // the icon for the profile + ) + .await +} + +// Creates a profile at the given filepath and adds it to the in-memory state +// Returns filepath at which it can be accessed in the State +#[tracing::instrument] +pub async fn profile_create( + name: String, // the name of the profile, and relative path + game_version: String, // the game version of the profile + modloader: ModLoader, // the modloader to use + loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader + icon: Option, // the icon for the profile +) -> crate::Result { + let state = State::get().await?; + + let uuid = Uuid::new_v4(); + let path = state.directories.profiles_dir().join(uuid.to_string()); + + if path.exists() { + if !path.is_dir() { + return Err(ProfileCreationError::NotFolder.into()); + } + if path.join("profile.json").exists() { + return Err(ProfileCreationError::ProfileExistsError( + path.join("profile.json"), + ) + .into()); + } + + if ReadDirStream::new(fs::read_dir(&path).await?) + .next() + .await + .is_some() + { + return Err(ProfileCreationError::NotEmptyFolder.into()); + } + } else { + fs::create_dir_all(&path).await?; + } + println!( + "Creating profile at path {}", + &canonicalize(&path)?.display() + ); + + let loader = modloader; + let loader = if loader != ModLoader::Vanilla { + let version = loader_version; + + let filter = |it: &LoaderVersion| match version.as_str() { + "latest" => true, + "stable" => it.stable, + id => it.id == *id, + }; + + let loader_data = match loader { + ModLoader::Forge => &state.metadata.forge, + ModLoader::Fabric => &state.metadata.fabric, + _ => { + return Err(ProfileCreationError::NoManifest( + loader.to_string(), + ) + .into()) + } + }; + + let loaders = &loader_data + .game_versions + .iter() + .find(|it| it.id == game_version) + .ok_or_else(|| { + ProfileCreationError::ModloaderUnsupported( + loader.to_string(), + game_version.clone(), + ) + })? + .loaders; + + let loader_version = loaders + .iter() + .cloned() + .find(filter) + .or( + // If stable was searched for but not found, return latest by default + if version == "stable" { + loaders.iter().next().cloned() + } else { + None + }, + ) + .ok_or_else(|| { + ProfileCreationError::InvalidVersionModloader( + version, + loader.to_string(), + ) + })?; + + Some((loader_version, loader)) + } else { + None + }; + + // Fully canonicalize now that its created for storing purposes + let path = canonicalize(&path)?; + let mut profile = Profile::new(name, game_version, path.clone()).await?; + if let Some(ref icon) = icon { + profile.with_icon(icon).await?; + } + if let Some((loader_version, loader)) = loader { + profile.with_loader(loader, Some(loader_version)); + } + + profile::add(profile).await?; + State::sync().await?; + + Ok(path) +} + +#[derive(thiserror::Error, Debug)] +pub enum ProfileCreationError { + #[error("Profile .json exists: {0}")] + ProfileExistsError(PathBuf), + #[error("Modloader {0} unsupported for Minecraft version {1}")] + ModloaderUnsupported(String, String), + #[error("Invalid version {0} for modloader {1}")] + InvalidVersionModloader(String, String), + #[error("Could not get manifest for loader {0}. This is a bug in the GUI")] + NoManifest(String), + #[error("Could not get State.")] + NoState, + + #[error("Attempted to create project in something other than a folder.")] + NotFolder, + #[error("You are trying to create a profile in a non-empty directory")] + NotEmptyFolder, + + #[error("IO error: {0}")] + IOError(#[from] std::io::Error), +} diff --git a/theseus/src/error.rs b/theseus/src/error.rs index f97822f0b..5509b877c 100644 --- a/theseus/src/error.rs +++ b/theseus/src/error.rs @@ -1,6 +1,8 @@ //! Theseus error type use tracing_error::InstrumentError; +use crate::profile_create; + #[derive(thiserror::Error, Debug)] pub enum ErrorKind { #[error("Filesystem error: {0}")] @@ -57,6 +59,9 @@ pub enum ErrorKind { #[error("Invalid input: {0}")] InputError(String), + #[error("Recv error: {0}")] + RecvError(#[from] tokio::sync::oneshot::error::RecvError), + #[error( "Tried to access unloaded profile {0}, loading it probably failed" )] @@ -65,6 +70,9 @@ pub enum ErrorKind { #[error("Profile {0} is not managed by Theseus!")] UnmanagedProfileError(String), + #[error("Could not create profile: {0}")] + ProfileCreationError(#[from] profile_create::ProfileCreationError), + #[error("Error: {0}")] OtherError(String), } diff --git a/theseus/src/launcher/args.rs b/theseus/src/launcher/args.rs index 9724adf5b..fdfab7105 100644 --- a/theseus/src/launcher/args.rs +++ b/theseus/src/launcher/args.rs @@ -10,10 +10,14 @@ use daedalus::{ minecraft::{Argument, ArgumentValue, Library, VersionType}, modded::SidedDataEntry, }; +use dunce::canonicalize; use std::io::{BufRead, BufReader}; use std::{collections::HashMap, path::Path}; use uuid::Uuid; +// Replaces the space separator with a newline character, as to not split the arguments +const TEMPORARY_REPLACE_CHAR: &str = "\n"; + pub fn get_class_paths( libraries_path: &Path, libraries: &[Library], @@ -37,8 +41,7 @@ pub fn get_class_paths( .collect::, _>>()?; cps.push( - client_path - .canonicalize() + canonicalize(client_path) .map_err(|_| { crate::ErrorKind::LauncherError(format!( "Specified class path {} does not exist", @@ -70,7 +73,7 @@ pub fn get_lib_path(libraries_path: &Path, lib: &str) -> crate::Result { path.push(get_path_from_artifact(lib)?); - let path = &path.canonicalize().map_err(|_| { + let path = &canonicalize(&path).map_err(|_| { crate::ErrorKind::LauncherError(format!( "Library file at path {} does not exist", path.to_string_lossy() @@ -104,15 +107,13 @@ pub fn get_jvm_arguments( } else { parsed_arguments.push(format!( "-Djava.library.path={}", - &natives_path - .canonicalize() + canonicalize(natives_path) .map_err(|_| crate::ErrorKind::LauncherError(format!( "Specified natives path {} does not exist", natives_path.to_string_lossy() )) .as_error())? .to_string_lossy() - .to_string() )); parsed_arguments.push("-cp".to_string()); parsed_arguments.push(class_paths.to_string()); @@ -142,8 +143,7 @@ fn parse_jvm_argument( Ok(argument .replace( "${natives_directory}", - &natives_path - .canonicalize() + &canonicalize(natives_path) .map_err(|_| { crate::ErrorKind::LauncherError(format!( "Specified natives path {} does not exist", @@ -155,8 +155,7 @@ fn parse_jvm_argument( ) .replace( "${library_directory}", - &libraries_path - .canonicalize() + &canonicalize(libraries_path) .map_err(|_| { crate::ErrorKind::LauncherError(format!( "Specified libraries path {} does not exist", @@ -206,7 +205,7 @@ pub fn get_minecraft_arguments( Ok(parsed_arguments) } else if let Some(legacy_arguments) = legacy_arguments { Ok(parse_minecraft_argument( - legacy_arguments, + &legacy_arguments.replace(' ', TEMPORARY_REPLACE_CHAR), &credentials.access_token, &credentials.username, &credentials.id, @@ -249,8 +248,7 @@ fn parse_minecraft_argument( .replace("${assets_index_name}", asset_index_name) .replace( "${game_directory}", - &game_directory - .canonicalize() + &canonicalize(game_directory) .map_err(|_| { crate::ErrorKind::LauncherError(format!( "Specified game directory {} does not exist", @@ -262,8 +260,7 @@ fn parse_minecraft_argument( ) .replace( "${assets_root}", - &assets_directory - .canonicalize() + &canonicalize(assets_directory) .map_err(|_| { crate::ErrorKind::LauncherError(format!( "Specified assets directory {} does not exist", @@ -275,8 +272,7 @@ fn parse_minecraft_argument( ) .replace( "${game_assets}", - &assets_directory - .canonicalize() + &canonicalize(assets_directory) .map_err(|_| { crate::ErrorKind::LauncherError(format!( "Specified assets directory {} does not exist", @@ -302,9 +298,9 @@ where for argument in arguments { match argument { Argument::Normal(arg) => { - let parsed = parse_function(arg)?; - - for arg in parsed.split(' ') { + let parsed = + parse_function(&arg.replace(' ', TEMPORARY_REPLACE_CHAR))?; + for arg in parsed.split(TEMPORARY_REPLACE_CHAR) { parsed_arguments.push(arg.to_string()); } } @@ -312,11 +308,15 @@ where if rules.iter().all(parse_rule) { match value { ArgumentValue::Single(arg) => { - parsed_arguments.push(parse_function(arg)?); + parsed_arguments.push(parse_function( + &arg.replace(' ', TEMPORARY_REPLACE_CHAR), + )?); } ArgumentValue::Many(args) => { for arg in args { - parsed_arguments.push(parse_function(arg)?); + parsed_arguments.push(parse_function( + &arg.replace(' ', TEMPORARY_REPLACE_CHAR), + )?); } } } diff --git a/theseus/src/launcher/auth.rs b/theseus/src/launcher/auth.rs index c7410ee77..aba2a4539 100644 --- a/theseus/src/launcher/auth.rs +++ b/theseus/src/launcher/auth.rs @@ -4,7 +4,7 @@ use bincode::{Decode, Encode}; use chrono::{prelude::*, Duration}; use futures::prelude::*; use lazy_static::lazy_static; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use url::Url; lazy_static! { @@ -47,7 +47,7 @@ struct ProfileInfoJSON { } // Login information -#[derive(Encode, Decode)] +#[derive(Encode, Decode, Serialize, Deserialize)] pub struct Credentials { #[bincode(with_serde)] pub id: uuid::Uuid, diff --git a/theseus/src/launcher/download.rs b/theseus/src/launcher/download.rs index 601b610dc..f1092dc55 100644 --- a/theseus/src/launcher/download.rs +++ b/theseus/src/launcher/download.rs @@ -139,10 +139,9 @@ pub async fn download_assets( index: &AssetsIndex, ) -> crate::Result<()> { log::debug!("Loading assets"); - stream::iter(index.objects.iter()) .map(Ok::<(&String, &Asset), crate::Error>) - .try_for_each_concurrent(None, |(name, asset)| async move { + .try_for_each_concurrent(Some(st.settings.read().await.max_concurrent_downloads), |(name, asset)| async move { let hash = &asset.hash; let resource_path = st.directories.object_dir(hash); let url = format!( @@ -202,7 +201,7 @@ pub async fn download_libraries( stream::iter(libraries.iter()) .map(Ok::<&Library, crate::Error>) - .try_for_each_concurrent(None, |library| async move { + .try_for_each_concurrent(Some(st.settings.read().await.max_concurrent_downloads), |library| async move { if let Some(rules) = &library.rules { if !rules.iter().all(super::parse_rule) { return Ok(()); diff --git a/theseus/src/launcher/mod.rs b/theseus/src/launcher/mod.rs index 49c1b1d02..bbb7e6a29 100644 --- a/theseus/src/launcher/mod.rs +++ b/theseus/src/launcher/mod.rs @@ -1,6 +1,7 @@ //! Logic for launching Minecraft use crate::state as st; use daedalus as d; +use dunce::canonicalize; use std::{path::Path, process::Stdio}; use tokio::process::{Child, Command}; @@ -58,7 +59,7 @@ pub async fn launch_minecraft( credentials: &auth::Credentials, ) -> crate::Result { let state = st::State::get().await?; - let instance_path = instance_path.canonicalize()?; + let instance_path = &canonicalize(instance_path)?; let version = state .metadata @@ -173,34 +174,45 @@ pub async fn launch_minecraft( }; command - .args(args::get_jvm_arguments( - args.get(&d::minecraft::ArgumentType::Jvm) - .map(|x| x.as_slice()), - &state.directories.version_natives_dir(&version.id), - &state.directories.libraries_dir(), - &args::get_class_paths( + .args( + args::get_jvm_arguments( + args.get(&d::minecraft::ArgumentType::Jvm) + .map(|x| x.as_slice()), + &state.directories.version_natives_dir(&version.id), &state.directories.libraries_dir(), - version_info.libraries.as_slice(), - &client_path, - )?, - &version_jar, - *memory, - Vec::from(java_args), - )?) + &args::get_class_paths( + &state.directories.libraries_dir(), + version_info.libraries.as_slice(), + &client_path, + )?, + &version_jar, + *memory, + Vec::from(java_args), + )? + .into_iter() + .map(|r| r.replace(' ', r"\ ")) + .collect::>(), + ) .arg(version_info.main_class.clone()) - .args(args::get_minecraft_arguments( - args.get(&d::minecraft::ArgumentType::Game) - .map(|x| x.as_slice()), - version_info.minecraft_arguments.as_deref(), - credentials, - &version.id, - &version_info.asset_index.id, - &instance_path, - &state.directories.assets_dir(), - &version.type_, - *resolution, - )?) + .args( + args::get_minecraft_arguments( + args.get(&d::minecraft::ArgumentType::Game) + .map(|x| x.as_slice()), + version_info.minecraft_arguments.as_deref(), + credentials, + &version.id, + &version_info.asset_index.id, + instance_path, + &state.directories.assets_dir(), + &version.type_, + *resolution, + )? + .into_iter() + .map(|r| r.replace(' ', r"\ ")) + .collect::>(), + ) .current_dir(instance_path.clone()) + .env_clear() .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); diff --git a/theseus/src/state/children.rs b/theseus/src/state/children.rs new file mode 100644 index 000000000..a16983015 --- /dev/null +++ b/theseus/src/state/children.rs @@ -0,0 +1,37 @@ +use std::{collections::HashMap, sync::Arc}; +use tokio::process::Child; +use tokio::sync::RwLock; + +// Child processes (instances of Minecraft) +// A wrapper over a Hashmap connecting PID -> Child +// Left open for future functionality re: polling children +pub struct Children(HashMap>>); + +impl Children { + pub fn new() -> Children { + Children(HashMap::new()) + } + + // Inserts and returns a ref to the child + // Unlike a Hashmap, this directly returns the reference to the Child rather than any previously stored Child that may exist + pub fn insert( + &mut self, + pid: u32, + child: tokio::process::Child, + ) -> Arc> { + let child = Arc::new(RwLock::new(child)); + self.0.insert(pid, child.clone()); + child + } + + // Returns a ref to the child + pub fn get(&self, pid: &u32) -> Option>> { + self.0.get(pid).cloned() + } +} + +impl Default for Children { + fn default() -> Self { + Self::new() + } +} diff --git a/theseus/src/state/dirs.rs b/theseus/src/state/dirs.rs index 5fd2be7dd..1d94ab388 100644 --- a/theseus/src/state/dirs.rs +++ b/theseus/src/state/dirs.rs @@ -110,6 +110,12 @@ impl DirectoryInfo { self.config_dir.join("icons") } + /// Get the profiles directory for created profiles + #[inline] + pub fn profiles_dir(&self) -> PathBuf { + self.config_dir.join("profiles") + } + /// Get the file containing the global database #[inline] pub fn database_file(&self) -> PathBuf { diff --git a/theseus/src/state/mod.rs b/theseus/src/state/mod.rs index 4b1bd9570..21635f43e 100644 --- a/theseus/src/state/mod.rs +++ b/theseus/src/state/mod.rs @@ -22,6 +22,9 @@ pub use self::projects::*; mod users; pub use self::users::*; +mod children; +pub use self::children::*; + // Global state static LAUNCHER_STATE: OnceCell> = OnceCell::const_new(); pub struct State { @@ -36,6 +39,8 @@ pub struct State { // TODO: settings API /// Launcher configuration pub settings: RwLock, + /// Reference to process children + pub children: RwLock, /// Launcher profile metadata pub(crate) profiles: RwLock, /// Launcher user account info @@ -73,6 +78,8 @@ impl State { let io_semaphore = Semaphore::new(settings.max_concurrent_downloads); + let children = Children::new(); + Ok(Arc::new(Self { database, directories, @@ -81,6 +88,7 @@ impl State { settings: RwLock::new(settings), profiles: RwLock::new(profiles), users: RwLock::new(users), + children: RwLock::new(children), })) } }) diff --git a/theseus/src/state/profiles.rs b/theseus/src/state/profiles.rs index ce8049d48..f9e1570f0 100644 --- a/theseus/src/state/profiles.rs +++ b/theseus/src/state/profiles.rs @@ -3,6 +3,7 @@ use crate::config::BINCODE_CONFIG; use crate::data::DirectoryInfo; use crate::state::projects::Project; use daedalus::modded::LoaderVersion; +use dunce::canonicalize; use futures::prelude::*; use serde::{Deserialize, Serialize}; use std::{ @@ -98,7 +99,7 @@ impl Profile { } Ok(Self { - path: path.canonicalize()?, + path: canonicalize(path)?, metadata: ProfileMetadata { name, icon: None, @@ -236,10 +237,13 @@ impl Profiles { { for (profile_path, _profile_opt) in profiles.iter() { let mut read_paths = |path: &str| { - for path in std::fs::read_dir(profile_path.join(path))? { - files.insert(path?.path(), profile_path.clone()); + let new_path = profile_path.join(path); + if new_path.exists() { + for path in std::fs::read_dir(profile_path.join(path))? + { + files.insert(path?.path(), profile_path.clone()); + } } - Ok::<(), crate::Error>(()) }; read_paths("mods")?; @@ -268,9 +272,7 @@ impl Profiles { #[tracing::instrument(skip(self))] pub fn insert(&mut self, profile: Profile) -> crate::Result<&Self> { self.0.insert( - profile - .path - .canonicalize()? + canonicalize(&profile.path)? .to_str() .ok_or( crate::ErrorKind::UTFError(profile.path.clone()).as_error(), @@ -286,12 +288,12 @@ impl Profiles { &'a mut self, path: &'a Path, ) -> crate::Result<&Self> { - self.insert(Self::read_profile_from_dir(&path.canonicalize()?).await?) + self.insert(Self::read_profile_from_dir(&canonicalize(path)?).await?) } #[tracing::instrument(skip(self))] pub fn remove(&mut self, path: &Path) -> crate::Result<&Self> { - let path = PathBuf::from(path.canonicalize()?.to_str().unwrap()); + let path = PathBuf::from(&canonicalize(path)?.to_str().unwrap()); self.0.remove(&path); Ok(self) } diff --git a/theseus/src/util/jre.rs b/theseus/src/util/jre.rs index d433696c3..d74a46392 100644 --- a/theseus/src/util/jre.rs +++ b/theseus/src/util/jre.rs @@ -1,3 +1,4 @@ +use dunce::canonicalize; use lazy_static::lazy_static; use regex::Regex; use std::collections::HashSet; @@ -11,12 +12,6 @@ use winreg::{ RegKey, }; -// Uses dunce canonicalization to resolve symlinks without UNC prefixes -#[cfg(target_os = "windows")] -use dunce::canonicalize; -#[cfg(not(target_os = "windows"))] -use std::fs::canonicalize; - #[derive(Debug, PartialEq, Eq, Hash)] pub struct JavaVersion { pub path: String, diff --git a/theseus_cli/Cargo.toml b/theseus_cli/Cargo.toml index 149288044..2e966d69f 100644 --- a/theseus_cli/Cargo.toml +++ b/theseus_cli/Cargo.toml @@ -26,5 +26,9 @@ tracing = "0.1" tracing-error = "0.2" tracing-futures = "0.2" tracing-subscriber = {version = "0.3", features = ["env-filter"]} +dunce = "1.0.3" -webbrowser = "0.7" \ No newline at end of file +webbrowser = "0.7" + +[target.'cfg(windows)'.dependencies] +winreg = "0.11.0" diff --git a/theseus_cli/src/subcommands/profile.rs b/theseus_cli/src/subcommands/profile.rs index 44f88977a..8d3de74fe 100644 --- a/theseus_cli/src/subcommands/profile.rs +++ b/theseus_cli/src/subcommands/profile.rs @@ -3,6 +3,7 @@ use crate::util::{ confirm_async, prompt_async, select_async, table, table_path_display, }; use daedalus::modded::LoaderVersion; +use dunce::canonicalize; use eyre::{ensure, Result}; use futures::prelude::*; use paris::*; @@ -50,7 +51,7 @@ impl ProfileAdd { self.profile.display() ); - let profile = self.profile.canonicalize()?; + let profile = canonicalize(&self.profile)?; let json_path = profile.join("profile.json"); ensure!( @@ -137,7 +138,7 @@ impl ProfileInit { } info!( "Creating profile at path {}", - &self.path.canonicalize()?.display() + &canonicalize(&self.path)?.display() ); // TODO: abstract default prompting @@ -343,7 +344,7 @@ impl ProfileRemove { _args: &crate::Args, _largs: &ProfileCommand, ) -> Result<()> { - let profile = self.profile.canonicalize()?; + let profile = canonicalize(&self.profile)?; info!("Removing profile {} from Theseus", self.profile.display()); if confirm_async(String::from("Do you wish to continue"), true).await? { @@ -382,7 +383,7 @@ impl ProfileRun { _largs: &ProfileCommand, ) -> Result<()> { info!("Starting profile at path {}...", self.profile.display()); - let path = self.profile.canonicalize()?; + let path = canonicalize(&self.profile)?; ensure!( profile::is_managed(&path).await?, @@ -402,7 +403,8 @@ impl ProfileRun { .await?; let credentials = auth::refresh(id, false).await?; - let mut proc = profile::run(&path, &credentials).await?; + let proc_lock = profile::run(&path, &credentials).await?; + let mut proc = proc_lock.write().await; profile::wait_for(&mut proc).await?; success!("Process exited successfully!"); diff --git a/theseus_gui/.gitignore b/theseus_gui/.gitignore index a547bf36d..ad4f821fb 100644 --- a/theseus_gui/.gitignore +++ b/theseus_gui/.gitignore @@ -1,3 +1,5 @@ +.minecraft + # Logs logs *.log diff --git a/theseus_gui/src-tauri/Cargo.toml b/theseus_gui/src-tauri/Cargo.toml index ce286804d..1868c7041 100644 --- a/theseus_gui/src-tauri/Cargo.toml +++ b/theseus_gui/src-tauri/Cargo.toml @@ -13,9 +13,17 @@ edition = "2021" tauri-build = { version = "1.2", features = [] } [dependencies] +theseus = { path = "../../theseus" } + serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } tauri = { version = "1.2", features = ["shell-open"] } +tokio = { version = "1", features = ["full"] } +thiserror = "1.0" + +tokio-stream = { version = "0.1", features = ["fs"] } +futures = "0.3" +daedalus = {version = "0.1.15", features = ["bincode"] } [features] # by default Tauri runs in production mode diff --git a/theseus_gui/src-tauri/src/api/mod.rs b/theseus_gui/src-tauri/src/api/mod.rs new file mode 100644 index 000000000..5c900b631 --- /dev/null +++ b/theseus_gui/src-tauri/src/api/mod.rs @@ -0,0 +1,66 @@ +use serde::ser::SerializeStruct; +use serde::{Serialize, Serializer}; +use thiserror::Error; + +pub mod profile; +pub mod profile_create; + +pub type Result = std::result::Result; + +// Main returnable Theseus GUI error +// Needs to be Serializable to be returned to the JavaScript side +#[derive(Error, Debug, Serialize)] +pub enum TheseusGuiError { + #[error(transparent)] + Serializable(TheseusSerializableError), +} + +// Serializable error intermediary, so TheseusGuiError can be Serializable (eg: so that we can return theseus::Errors in Tauri directly) +#[derive(Error, Debug)] +pub enum TheseusSerializableError { + #[error("Theseus API error: {0}")] + Theseus(#[from] theseus::Error), + + #[error("IO error: {0}")] + IO(#[from] std::io::Error), +} + +// Generic implementation of From for ErrorTypeA +impl From for TheseusGuiError +where + TheseusSerializableError: From, +{ + fn from(error: T) -> Self { + TheseusGuiError::Serializable(TheseusSerializableError::from(error)) + } +} + +// This is a very simple macro that implements a very basic Serializable for each variant of TheseusSerializableError, +// where the field is the string. (This allows easy extension to errors without many match arms) +macro_rules! impl_serialize { + ($($variant:ident),* $(,)?) => { + impl Serialize for TheseusSerializableError { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + match self { + $( + TheseusSerializableError::$variant(message) => { + let mut state = serializer.serialize_struct(stringify!($variant), 2)?; + state.serialize_field("field_name", stringify!($variant))?; + state.serialize_field("message", &message.to_string())?; + state.end() + }, + )* + } + } + } + }; +} + +// Use the macro to implement Serialize for TheseusSerializableError +impl_serialize! { + Theseus, + IO, +} diff --git a/theseus_gui/src-tauri/src/api/profile.rs b/theseus_gui/src-tauri/src/api/profile.rs new file mode 100644 index 000000000..b0f19c880 --- /dev/null +++ b/theseus_gui/src-tauri/src/api/profile.rs @@ -0,0 +1,124 @@ +use crate::api::Result; +use std::path::{Path, PathBuf}; +use theseus::prelude::*; + +// Add a profile to the in-memory state +// invoke('profile_add',profile) +#[tauri::command] +pub async fn profile_add(profile: Profile) -> Result<()> { + let res = profile::add(profile).await?; + State::sync().await?; + Ok(res) +} + +// Add a path as a profile in-memory +// invoke('profile_add_path',path) +#[tauri::command] +pub async fn profile_add_path(path: &Path) -> Result<()> { + let res = profile::add_path(path).await?; + State::sync().await?; + Ok(res) +} + +// Remove a profile +// invoke('profile_add_path',path) +#[tauri::command] +pub async fn profile_remove(path: &Path) -> Result<()> { + let res = profile::remove(path).await?; + State::sync().await?; + Ok(res) +} + +// Get a profile by path +// invoke('profile_add_path',path) +#[tauri::command] +pub async fn profile_get(path: &Path) -> Result> { + let res = profile::get(path).await?; + State::sync().await?; + Ok(res) +} + +// Check if a profile is already managed by Theseus +// invoke('profile_is_managed',profile) +#[tauri::command] +pub async fn profile_is_managed(profile: &Path) -> Result { + let res = profile::is_managed(profile).await?; + State::sync().await?; + Ok(res) +} + +// Check if a profile is loaded +// invoke('profile_is_loaded',profile) +#[tauri::command] +pub async fn profile_is_loaded(profile: &Path) -> Result { + let res = profile::is_loaded(profile).await?; + State::sync().await?; + Ok(res) +} + +// Get a copy of the profile set +// invoke('profile_list') +#[tauri::command] +pub async fn profile_list( +) -> Result>> { + let res = profile::list().await?; + State::sync().await?; + Ok(res) +} + +// Run Minecraft using a profile +// Returns a u32 representing the PID, which can be used to poll +// for the actual Child in the state. +// invoke('profile_run') +#[tauri::command] +pub async fn profile_run( + path: &Path, + credentials: theseus::auth::Credentials, +) -> Result { + let proc_lock = profile::run(path, &credentials).await?; + let pid = proc_lock.read().await.id().ok_or_else(|| { + theseus::Error::from(theseus::ErrorKind::LauncherError(format!( + "Process failed to stay open." + ))) + })?; + Ok(pid) +} + +// Run Minecraft using a profile, and wait for the result +// invoke('profile_wait_for', path, credentials) +#[tauri::command] +pub async fn profile_run_wait( + path: &Path, + credentials: theseus::auth::Credentials, +) -> Result<()> { + let proc_lock = profile::run(path, &credentials).await?; + let mut proc = proc_lock.write().await; + Ok(profile::wait_for(&mut proc).await?) +} + +// Wait for a running minecraft process (a Child) +// invoke('profile_wait_for', pid) +#[tauri::command] +pub async fn profile_wait_for(pid: u32) -> Result<()> { + let st = State::get().await?; + if let Some(proc_lock) = st.children.blocking_read().get(&pid) { + let mut proc = proc_lock.write().await; + return Ok(profile::wait_for(&mut proc).await?); + } + // If child is gone from state, it's not tracked or already finished + Ok(()) +} + +// Tries to kill a running minecraft process (if PID is still stored) +// invoke('profile_kill', pid) +#[tauri::command] +pub async fn profile_kill(pid: u32) -> Result<()> { + let st = State::get().await?; + let st = State::get().await?; + if let Some(proc_lock) = st.children.blocking_read().get(&pid) { + let mut proc = proc_lock.write().await; + return Ok(profile::kill(&mut proc).await?); + } + // If child is gone from state, it's not tracked or already finished + Ok(()) +} diff --git a/theseus_gui/src-tauri/src/api/profile_create.rs b/theseus_gui/src-tauri/src/api/profile_create.rs new file mode 100644 index 000000000..52b735c66 --- /dev/null +++ b/theseus_gui/src-tauri/src/api/profile_create.rs @@ -0,0 +1,34 @@ +use crate::api::Result; +use std::path::PathBuf; +use theseus::prelude::*; + +// Generic basic profile creation tool. +// Creates an essentially empty dummy profile with profile_create +#[tauri::command] +pub async fn profile_create_empty() -> Result { + let res = profile_create::profile_create_empty().await?; + State::sync().await?; + Ok(res) +} + +// Creates a profile at the given filepath and adds it to the in-memory state +// invoke('profile_add',profile) +#[tauri::command] +pub async fn profile_create( + name: String, // the name of the profile, and relative path + game_version: String, // the game version of the profile + modloader: ModLoader, // the modloader to use + loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader + icon: Option, // the icon for the profile +) -> Result { + let res = profile_create::profile_create( + name, + game_version, + modloader, + loader_version, + icon, + ) + .await?; + State::sync().await?; + Ok(res) +} diff --git a/theseus_gui/src-tauri/src/main.rs b/theseus_gui/src-tauri/src/main.rs index 860b10d66..d8482b3a7 100644 --- a/theseus_gui/src-tauri/src/main.rs +++ b/theseus_gui/src-tauri/src/main.rs @@ -3,8 +3,33 @@ windows_subsystem = "windows" )] +use theseus::prelude::*; + +mod api; + +// Should be called in launcher initialization +#[tauri::command] +async fn initialize_state() -> api::Result<()> { + State::get().await?; + Ok(()) +} + fn main() { tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + initialize_state, + api::profile_create::profile_create_empty, + api::profile_create::profile_create, + api::profile::profile_add, + api::profile::profile_add_path, + api::profile::profile_remove, + api::profile::profile_get, + api::profile::profile_is_managed, + api::profile::profile_is_loaded, + api::profile::profile_list, + api::profile::profile_run, + api::profile::profile_run_wait, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/theseus_gui/src/helpers/profile.js b/theseus_gui/src/helpers/profile.js new file mode 100644 index 000000000..51376a3a4 --- /dev/null +++ b/theseus_gui/src/helpers/profile.js @@ -0,0 +1,73 @@ +/** + * 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' + +// Add empty default instance +export async function addDefaultInstance() { + return await invoke('profile_create_empty') +} + +// Add empty default instance +export async function create() { + return await invoke('profile_create') +} + +// Add a profile to the in-memory state +export async function add(profile) { + return await invoke('profile_add', profile) +} + +// Add a path as a profile in-memory +export async function add_path(path) { + return await invoke('profile_add_path', path) +} + +// Remove a profile +export async function remove(path) { + return await invoke('profile_remove', path) +} + +// Get a profile by path +export async function get(path) { + return await invoke('profile_get', path) +} + +// Check if a pathed profile is already managed by Theseus +export async function is_managed(path) { + return await invoke('profile_is_managed', path) +} + +// Check if a pathed profile is loaded +export async function is_loaded(path) { + return await invoke('profile_is_loaded', path) +} + +// Get a copy of the profile set +export async function list() { + return await invoke('profile_list') +} + +// Run Minecraft using a pathed profile +// Returns PID of child +export async function run(path, credentials) { + return await invoke('profile_run', path, credentials) +} + +// Run Minecraft using a pathed profile +// Waits for end +export async function run_wait(path, credentials) { + return await invoke('run_wait', path, credentials) +} + +// Tries to kill a running minecraft process (if PID is still stored) +export async function kill(child_pid) { + return await invoke('profile_kill', child_pid) +} + +// Wait for a running minecraft process (a Child) +export async function wait_for(child_pid) { + return await invoke('profile_wait_for', child_pid) +} diff --git a/theseus_gui/src/helpers/state.js b/theseus_gui/src/helpers/state.js new file mode 100644 index 000000000..1b65402a1 --- /dev/null +++ b/theseus_gui/src/helpers/state.js @@ -0,0 +1,12 @@ +/** + * 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' + +// Initialize the theseus API state +// This should be called during the initializion/opening of the launcher +export async function initialize_state() { + return await invoke('initialize_state') +} diff --git a/theseus_playground/Cargo.toml b/theseus_playground/Cargo.toml new file mode 100644 index 000000000..4a0c6637c --- /dev/null +++ b/theseus_playground/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "theseus_playground" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +theseus = { path = "../theseus" } + +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +tauri = { version = "1.2", features = ["shell-open"] } +tokio = { version = "1", features = ["full"] } +thiserror = "1.0" +url = "2.2" +webbrowser = "0.7" +dunce = "1.0.3" + +tokio-stream = { version = "0.1", features = ["fs"] } +futures = "0.3" +daedalus = {version = "0.1.15", features = ["bincode"] } diff --git a/theseus_playground/src/main.rs b/theseus_playground/src/main.rs new file mode 100644 index 000000000..18b561e5e --- /dev/null +++ b/theseus_playground/src/main.rs @@ -0,0 +1,119 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +use dunce::canonicalize; +use std::path::Path; +use theseus::{prelude::*, profile_create::profile_create}; +use tokio::process::Child; +use tokio::sync::{oneshot, RwLockWriteGuard}; + +// We use this function directly to call authentication procedure +// Note: "let url = match url" logic is handled differently, so that if there is a rate limit in the other set causing that one to end early, +// we can see the error message in this thread rather than a Recv error on 'rx' when the receiver is mysteriously droppped +pub async fn authenticate_run() -> theseus::Result { + println!("Adding new user account to Theseus"); + println!("A browser window will now open, follow the login flow there."); + + let (tx, rx) = oneshot::channel::(); + let flow = tokio::spawn(auth::authenticate(tx)); + + let url = rx.await; + let url = match url { + Ok(url) => url, + Err(e) => { + flow.await.unwrap()?; + return Err(e.into()); + } + }; + println!("URL {}", url.as_str()); + webbrowser::open(url.as_str())?; + let credentials = flow.await.unwrap()?; + State::sync().await?; + println!("Logged in user {}.", credentials.username); + Ok(credentials) +} + +#[tokio::main] +async fn main() -> theseus::Result<()> { + println!("Starting."); + + // Initialize state + let st = State::get().await?; + + // Set max concurrent downloads to 10 + st.settings.write().await.max_concurrent_downloads = 10; + + // Example variables for simple project case + let name = "Example".to_string(); + let game_version = "1.19.2".to_string(); + let modloader = ModLoader::Vanilla; + let loader_version = "stable".to_string(); + + // let icon = Some( + // Path::new("../icon_test.png") + // .canonicalize() + // .expect("Icon could be not be found. If not using, set to None"), + // ); + let icon = None; + + // Clear profiles + println!("Clearing profiles."); + let h = profile::list().await?; + for (path, _) in h.into_iter() { + profile::remove(&path).await?; + } + + println!("Creating/adding profile."); + // Attempt to create a profile. If that fails, try adding one from the same path. + // TODO: actually do a nested error check for the correct profile error. + let profile_path = profile_create( + name.clone(), + game_version, + modloader, + loader_version, + icon, + ) + .await?; + State::sync().await?; + + // async closure for testing any desired edits + // (ie: changing the java runtime of an added profile) + println!("Editing."); + profile::edit(&profile_path, |profile| { + // Eg: Java. TODO: hook up to jre.rs class to pick optimal java + profile.java = Some(JavaSettings { + install: Some(Path::new("/usr/bin/java").to_path_buf()), + extra_arguments: None, + }); + async { Ok(()) } + }) + .await?; + State::sync().await?; + + println!("Authenticating."); + // Attempt to create credentials and run. + let proc_lock = match authenticate_run().await { + Ok(credentials) => { + println!("Running."); + profile::run(&canonicalize(&profile_path)?, &credentials).await + } + Err(e) => { + println!("Could not authenticate: {}.\nAttempting stored authentication.",e); + // Attempt to load credentials if Hydra is down/rate limit hit + let users = auth::users().await.unwrap(); + let credentials = users.first().unwrap(); + + println!("Running."); + profile::run(&canonicalize(&profile_path)?, credentials).await + } + }?; + + println!("Started. Waiting..."); + let mut proc: RwLockWriteGuard = proc_lock.write().await; + profile::wait_for(&mut proc).await?; + + // Run MC + Ok(()) +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +