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 <wyatt@modrinth.com>
This commit is contained in:
Wyatt Verchere 2023-03-31 11:00:43 -07:00 committed by GitHub
parent 24ba986cf1
commit f48959a816
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 857 additions and 80 deletions

3
.gitignore vendored
View File

@ -8,6 +8,9 @@ WixTools
.DS_Store
.pnpm-debug.log
minecraft
config
[#]*[#]
# TEMPORARY: ignore my test instance and metadata

27
Cargo.lock generated
View File

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

View File

@ -3,6 +3,7 @@
members = [
"theseus",
"theseus_cli",
"theseus_playground",
"theseus_gui/src-tauri"
]

View File

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

View File

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

View File

@ -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<Child> {
) -> crate::Result<Arc<RwLock<Child>>> {
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]

View File

@ -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<PathBuf> {
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<PathBuf>, // the icon for the profile
) -> crate::Result<PathBuf> {
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),
}

View File

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

View File

@ -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::<Result<Vec<_>, _>>()?;
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<String> {
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),
)?);
}
}
}

View File

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

View File

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

View File

@ -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<Child> {
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::<Vec<_>>(),
)
.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::<Vec<_>>(),
)
.current_dir(instance_path.clone())
.env_clear()
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());

View File

@ -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<u32, Arc<RwLock<Child>>>);
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<RwLock<Child>> {
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<Arc<RwLock<Child>>> {
self.0.get(pid).cloned()
}
}
impl Default for Children {
fn default() -> Self {
Self::new()
}
}

View File

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

View File

@ -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<Arc<State>> = OnceCell::const_new();
pub struct State {
@ -36,6 +39,8 @@ pub struct State {
// TODO: settings API
/// Launcher configuration
pub settings: RwLock<Settings>,
/// Reference to process children
pub children: RwLock<Children>,
/// Launcher profile metadata
pub(crate) profiles: RwLock<Profiles>,
/// 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),
}))
}
})

View File

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

View File

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

View File

@ -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"
webbrowser = "0.7"
[target.'cfg(windows)'.dependencies]
winreg = "0.11.0"

View File

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

View File

@ -1,3 +1,5 @@
.minecraft
# Logs
logs
*.log

View File

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

View File

@ -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<T> = std::result::Result<T, TheseusGuiError>;
// 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<T> for ErrorTypeA
impl<T> From<T> for TheseusGuiError
where
TheseusSerializableError: From<T>,
{
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<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
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,
}

View File

@ -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<Option<Profile>> {
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<bool> {
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<bool> {
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<std::collections::HashMap<PathBuf, Option<Profile>>> {
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<u32> {
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(())
}

View File

@ -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<PathBuf> {
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<PathBuf>, // the icon for the profile
) -> Result<PathBuf> {
let res = profile_create::profile_create(
name,
game_version,
modloader,
loader_version,
icon,
)
.await?;
State::sync().await?;
Ok(res)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Credentials> {
println!("Adding new user account to Theseus");
println!("A browser window will now open, follow the login flow there.");
let (tx, rx) = oneshot::channel::<url::Url>();
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<Child> = proc_lock.write().await;
profile::wait_for(&mut proc).await?;
// Run MC
Ok(())
}

4
yarn.lock Normal file
View File

@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1