You've already forked AstralRinth
forked from didirus/AstralRinth
Initial draft of profile metadata format & CLI (#17)
* Initial draft of profile metadata format * Remove records, add Clippy to Nix, fix Clippy error * Work on profile definition * BREAKING: Make global settings consistent with profile settings * Add builder methods & format * Integrate launching with profiles * Add profile loading * Launching via profile, API tweaks, and yak shaving * Incremental update, committing everything due to personal system maintainance * Prepare for review cycle * Remove reminents of experimental work * CLI: allow people to override the non-empty directory check * Fix mistake in previous commit * Handle trailing whitespace and newlines in prompts * Revamp prompt to use dialoguer and support defaults * Make requested changes
This commit is contained in:
502
theseus/src/data/profiles.rs
Normal file
502
theseus/src/data/profiles.rs
Normal file
@@ -0,0 +1,502 @@
|
||||
use super::DataError;
|
||||
use crate::launcher::ModLoader;
|
||||
use daedalus::modded::LoaderVersion;
|
||||
use futures::*;
|
||||
use once_cell::sync::OnceCell;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::{
|
||||
fs,
|
||||
process::{Child, Command},
|
||||
sync::{Mutex, RwLock, RwLockReadGuard},
|
||||
};
|
||||
|
||||
static PROFILES: OnceCell<RwLock<Profiles>> = OnceCell::new();
|
||||
pub const PROFILE_JSON_PATH: &str = "profile.json";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Profiles(pub HashMap<PathBuf, Profile>);
|
||||
|
||||
// TODO: possibly add defaults to some of these values
|
||||
pub const CURRENT_FORMAT_VERSION: u32 = 1;
|
||||
pub const SUPPORTED_ICON_FORMATS: &[&'static str] = &[
|
||||
"bmp", "gif", "jpeg", "jpg", "jpe", "png", "svg", "svgz", "webp", "rgb",
|
||||
"mp4",
|
||||
];
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Profile {
|
||||
#[serde(skip)]
|
||||
pub path: PathBuf,
|
||||
pub metadata: Metadata,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub java: Option<JavaSettings>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory: Option<MemorySettings>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<WindowSize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hooks: Option<ProfileHooks>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Metadata {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<PathBuf>,
|
||||
pub game_version: String,
|
||||
#[serde(default)]
|
||||
pub loader: ModLoader,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub loader_version: Option<LoaderVersion>,
|
||||
pub format_version: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct JavaSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub install: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra_arguments: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
pub struct MemorySettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub minimum: Option<u32>,
|
||||
pub maximum: u32,
|
||||
}
|
||||
|
||||
impl Default for MemorySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
minimum: None,
|
||||
maximum: 2048,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
pub struct WindowSize(pub u16, pub u16);
|
||||
|
||||
impl Default for WindowSize {
|
||||
fn default() -> Self {
|
||||
Self(854, 480)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ProfileHooks {
|
||||
#[serde(skip_serializing_if = "HashSet::is_empty", default)]
|
||||
pub pre_launch: HashSet<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub wrapper: Option<String>,
|
||||
#[serde(skip_serializing_if = "HashSet::is_empty", default)]
|
||||
pub post_exit: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Default for ProfileHooks {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pre_launch: HashSet::<String>::new(),
|
||||
wrapper: None,
|
||||
post_exit: HashSet::<String>::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub async fn new(
|
||||
name: String,
|
||||
version: String,
|
||||
path: PathBuf,
|
||||
) -> Result<Self, DataError> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(DataError::FormatError(String::from(
|
||||
"Empty name for instance!",
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
path: path.canonicalize()?,
|
||||
metadata: Metadata {
|
||||
name,
|
||||
icon: None,
|
||||
game_version: version,
|
||||
loader: ModLoader::Vanilla,
|
||||
loader_version: None,
|
||||
format_version: CURRENT_FORMAT_VERSION,
|
||||
},
|
||||
java: None,
|
||||
memory: None,
|
||||
resolution: None,
|
||||
hooks: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
&self,
|
||||
credentials: &crate::launcher::Credentials,
|
||||
) -> Result<Child, crate::launcher::LauncherError> {
|
||||
use crate::launcher::LauncherError;
|
||||
let (settings, version_info) = tokio::try_join! {
|
||||
super::Settings::get(),
|
||||
super::Metadata::get()
|
||||
.and_then(|manifest| async move {
|
||||
let version = manifest
|
||||
.minecraft
|
||||
.versions
|
||||
.iter()
|
||||
.find(|it| it.id == self.metadata.game_version.as_ref())
|
||||
.ok_or_else(|| DataError::FormatError(format!(
|
||||
"invalid or unknown version: {}",
|
||||
self.metadata.game_version
|
||||
)))?;
|
||||
|
||||
Ok(daedalus::minecraft::fetch_version_info(version)
|
||||
.await?)
|
||||
})
|
||||
}?;
|
||||
|
||||
let ref pre_launch_hooks =
|
||||
self.hooks.as_ref().unwrap_or(&settings.hooks).pre_launch;
|
||||
for hook in pre_launch_hooks.iter() {
|
||||
// TODO: hook parameters
|
||||
let mut cmd = hook.split(' ');
|
||||
let result = Command::new(cmd.next().unwrap())
|
||||
.args(&cmd.collect::<Vec<&str>>())
|
||||
.current_dir(&self.path)
|
||||
.spawn()?
|
||||
.wait()
|
||||
.await?;
|
||||
|
||||
if !result.success() {
|
||||
return Err(LauncherError::ExitError(
|
||||
result.code().unwrap_or(-1),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let java_install = match self.java {
|
||||
Some(JavaSettings {
|
||||
install: Some(ref install),
|
||||
..
|
||||
}) => install,
|
||||
_ => if version_info
|
||||
.java_version
|
||||
.as_ref()
|
||||
.filter(|it| it.major_version >= 16)
|
||||
.is_some()
|
||||
{
|
||||
settings.java_17_path.as_ref()
|
||||
} else {
|
||||
settings.java_8_path.as_ref()
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
LauncherError::JavaError(format!(
|
||||
"No Java installed for version {}",
|
||||
version_info.java_version.map_or(8, |it| it.major_version),
|
||||
))
|
||||
})?,
|
||||
};
|
||||
|
||||
if !java_install.exists() {
|
||||
return Err(LauncherError::JavaError(format!(
|
||||
"Could not find java install: {}",
|
||||
java_install.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let java_args = &self
|
||||
.java
|
||||
.as_ref()
|
||||
.and_then(|it| it.extra_arguments.as_ref())
|
||||
.unwrap_or(&settings.custom_java_args);
|
||||
|
||||
let wrapper = self
|
||||
.hooks
|
||||
.as_ref()
|
||||
.map_or(&settings.hooks.wrapper, |it| &it.wrapper);
|
||||
|
||||
let ref memory = self.memory.unwrap_or(settings.memory);
|
||||
let ref resolution =
|
||||
self.resolution.unwrap_or(settings.game_resolution);
|
||||
|
||||
crate::launcher::launch_minecraft(
|
||||
&self.metadata.game_version,
|
||||
&self.metadata.loader_version,
|
||||
&self.path,
|
||||
&java_install,
|
||||
&java_args,
|
||||
&wrapper,
|
||||
memory,
|
||||
resolution,
|
||||
credentials,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn kill(
|
||||
&self,
|
||||
running: &mut Child,
|
||||
) -> Result<(), crate::launcher::LauncherError> {
|
||||
running.kill().await?;
|
||||
self.wait_for(running).await
|
||||
}
|
||||
|
||||
pub async fn wait_for(
|
||||
&self,
|
||||
running: &mut Child,
|
||||
) -> Result<(), crate::launcher::LauncherError> {
|
||||
let result = running.wait().await.map_err(|err| {
|
||||
crate::launcher::LauncherError::ProcessError {
|
||||
inner: err,
|
||||
process: String::from("minecraft"),
|
||||
}
|
||||
})?;
|
||||
|
||||
match result.success() {
|
||||
false => Err(crate::launcher::LauncherError::ExitError(
|
||||
result.code().unwrap_or(-1),
|
||||
)),
|
||||
true => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: deduplicate these builder methods
|
||||
// They are flat like this in order to allow builder-style usage
|
||||
pub fn with_name(&mut self, name: String) -> &mut Self {
|
||||
self.metadata.name = name;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn with_icon(
|
||||
&mut self,
|
||||
icon: &Path,
|
||||
) -> Result<&mut Self, DataError> {
|
||||
let ext = icon
|
||||
.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.unwrap_or("");
|
||||
|
||||
if SUPPORTED_ICON_FORMATS.contains(&ext) {
|
||||
let file_name = format!("icon.{ext}");
|
||||
fs::copy(icon, &self.path.join(&file_name)).await?;
|
||||
self.metadata.icon =
|
||||
Some(Path::new(&format!("./{file_name}")).to_owned());
|
||||
|
||||
Ok(self)
|
||||
} else {
|
||||
Err(DataError::FormatError(format!(
|
||||
"Unsupported image type: {ext}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_game_version(&mut self, version: String) -> &mut Self {
|
||||
self.metadata.game_version = version;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_loader(
|
||||
&mut self,
|
||||
loader: ModLoader,
|
||||
version: Option<LoaderVersion>,
|
||||
) -> &mut Self {
|
||||
self.metadata.loader = loader;
|
||||
self.metadata.loader_version = version;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_java_settings(
|
||||
&mut self,
|
||||
settings: Option<JavaSettings>,
|
||||
) -> &mut Self {
|
||||
self.java = settings;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_memory(
|
||||
&mut self,
|
||||
settings: Option<MemorySettings>,
|
||||
) -> &mut Self {
|
||||
self.memory = settings;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_resolution(
|
||||
&mut self,
|
||||
resolution: Option<WindowSize>,
|
||||
) -> &mut Self {
|
||||
self.resolution = resolution;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_hooks(&mut self, hooks: Option<ProfileHooks>) -> &mut Self {
|
||||
self.hooks = hooks;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Profiles {
|
||||
pub async fn init() -> Result<(), DataError> {
|
||||
let settings = super::Settings::get().await?;
|
||||
let profiles = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let futures = settings.profiles.clone().into_iter().map(|path| async {
|
||||
let profiles = Arc::clone(&profiles);
|
||||
tokio::spawn(async move {
|
||||
// TODO: handle missing profiles
|
||||
let mut profiles = profiles.lock().await;
|
||||
let profile = Self::read_profile_from_dir(path.clone()).await?;
|
||||
|
||||
profiles.insert(path, profile);
|
||||
Ok(()) as Result<_, DataError>
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
futures::future::try_join_all(futures).await?;
|
||||
|
||||
PROFILES.get_or_init(|| {
|
||||
RwLock::new(Profiles(
|
||||
Arc::try_unwrap(profiles).unwrap().into_inner(),
|
||||
))
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert(profile: Profile) -> Result<(), DataError> {
|
||||
let mut profiles = PROFILES
|
||||
.get()
|
||||
.ok_or_else(|| {
|
||||
DataError::InitializedError(String::from("profiles"))
|
||||
})?
|
||||
.write()
|
||||
.await;
|
||||
|
||||
super::Settings::get_mut()
|
||||
.await?
|
||||
.profiles
|
||||
.insert(profile.path.clone());
|
||||
profiles.0.insert(profile.path.clone(), profile);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert_from(path: PathBuf) -> Result<(), DataError> {
|
||||
Self::read_profile_from_dir(path)
|
||||
.and_then(Self::insert)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove(path: &Path) -> Result<Option<Profile>, DataError> {
|
||||
let path = path.canonicalize()?;
|
||||
let mut profiles = PROFILES.get().unwrap().write().await;
|
||||
super::Settings::get_mut().await?.profiles.remove(&path);
|
||||
Ok(profiles.0.remove(&path))
|
||||
}
|
||||
|
||||
pub async fn save() -> Result<(), DataError> {
|
||||
let profiles = Self::get().await?;
|
||||
|
||||
let futures = profiles.0.clone().into_iter().map(|(path, profile)| {
|
||||
tokio::spawn(async move {
|
||||
let json = tokio::task::spawn_blocking(move || {
|
||||
serde_json::to_vec_pretty(&profile)
|
||||
})
|
||||
.await
|
||||
.unwrap()?;
|
||||
|
||||
let profile_json_path = path.join(PROFILE_JSON_PATH);
|
||||
fs::write(profile_json_path, json).await?;
|
||||
Ok(()) as Result<(), DataError>
|
||||
})
|
||||
});
|
||||
futures::future::try_join_all(futures)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect::<Result<_, DataError>>()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get<'a>() -> Result<RwLockReadGuard<'a, Self>, DataError> {
|
||||
Ok(PROFILES
|
||||
.get()
|
||||
.ok_or_else(|| DataError::InitializedError("profiles".to_string()))?
|
||||
.read()
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn read_profile_from_dir(
|
||||
path: PathBuf,
|
||||
) -> Result<Profile, DataError> {
|
||||
let json = fs::read(path.join(PROFILE_JSON_PATH)).await?;
|
||||
let mut profile = serde_json::from_slice::<Profile>(&json)?;
|
||||
profile.path = path.clone();
|
||||
Ok(profile)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::{assert_eq, assert_str_eq};
|
||||
|
||||
#[test]
|
||||
fn profile_test() -> Result<(), serde_json::Error> {
|
||||
let profile = Profile {
|
||||
path: PathBuf::from("/tmp/nunya/beeswax"),
|
||||
metadata: Metadata {
|
||||
name: String::from("Example Pack"),
|
||||
icon: None,
|
||||
game_version: String::from("1.18.2"),
|
||||
loader: ModLoader::Vanilla,
|
||||
loader_version: None,
|
||||
format_version: CURRENT_FORMAT_VERSION,
|
||||
},
|
||||
java: JavaSettings {
|
||||
install: PathBuf::from("/usr/bin/java"),
|
||||
extra_arguments: Vec::new(),
|
||||
},
|
||||
memory: MemorySettings {
|
||||
minimum: None,
|
||||
maximum: 8192,
|
||||
},
|
||||
resolution: WindowSize(1920, 1080),
|
||||
hooks: ProfileHooks {
|
||||
pre_launch: HashSet::new(),
|
||||
wrapper: None,
|
||||
post_exit: HashSet::new(),
|
||||
},
|
||||
};
|
||||
let json = serde_json::json!({
|
||||
"path": "/tmp/nunya/beeswax",
|
||||
"metadata": {
|
||||
"name": "Example Pack",
|
||||
"game_version": "1.18.2",
|
||||
"format_version": 1u32,
|
||||
},
|
||||
"java": {
|
||||
"install": "/usr/bin/java",
|
||||
},
|
||||
"memory": {
|
||||
"maximum": 8192u32,
|
||||
},
|
||||
"resolution": (1920u16, 1080u16),
|
||||
"hooks": {},
|
||||
});
|
||||
|
||||
assert_eq!(serde_json::to_value(profile.clone())?, json.clone());
|
||||
assert_str_eq!(
|
||||
format!("{:?}", serde_json::from_value::<Profile>(json)?),
|
||||
format!("{:?}", profile),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user