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
30 changed files with 857 additions and 80 deletions

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,