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:
Danielle
2022-03-28 18:41:35 -07:00
committed by GitHub
parent 98aa66f9d8
commit d1070ca213
27 changed files with 1825 additions and 334 deletions

View File

@@ -1,3 +1,4 @@
use crate::data::profiles::*;
use crate::launcher::auth::provider::Credentials;
use crate::launcher::rules::parse_rules;
use crate::launcher::LauncherError;
@@ -21,19 +22,22 @@ pub fn get_class_paths(
libraries: &[Library],
client_path: &Path,
) -> Result<String, LauncherError> {
let mut class_paths = libraries.iter().filter_map(|library| {
if let Some(rules) = &library.rules {
if !super::rules::parse_rules(rules.as_slice()) {
let mut class_paths = libraries
.iter()
.filter_map(|library| {
if let Some(rules) = &library.rules {
if !super::rules::parse_rules(rules.as_slice()) {
return None;
}
}
if !library.include_in_classpath {
return None;
}
}
if !library.include_in_classpath {
return None;
}
Some(get_lib_path(libraries_path, &library.name))
}).collect::<Result<Vec<_>, _>>()?;
Some(get_lib_path(libraries_path, &library.name))
})
.collect::<Result<Vec<_>, _>>()?;
class_paths.push(
crate::util::absolute_path(&client_path)
@@ -54,9 +58,10 @@ pub fn get_class_paths_jar<T: AsRef<str>>(
libraries_path: &Path,
libraries: &[T],
) -> Result<String, LauncherError> {
let class_paths = libraries.iter().map(|library| {
get_lib_path(libraries_path, library.as_ref())
}).collect::<Result<Vec<_>, _>>()?;
let class_paths = libraries
.iter()
.map(|library| get_lib_path(libraries_path, library.as_ref()))
.collect::<Result<Vec<_>, _>>()?;
Ok(class_paths.join(get_cp_separator()))
}
@@ -90,7 +95,7 @@ pub fn get_jvm_arguments(
libraries_path: &Path,
class_paths: &str,
version_name: &str,
memory: i32,
memory: MemorySettings,
custom_args: Vec<String>,
) -> Result<Vec<String>, LauncherError> {
let mut parsed_arguments = Vec::new();
@@ -120,7 +125,10 @@ pub fn get_jvm_arguments(
parsed_arguments.push(class_paths.to_string());
}
parsed_arguments.push(format!("-Xmx{}M", memory));
if let Some(minimum) = memory.minimum {
parsed_arguments.push(format!("-Xms{minimum}M"));
}
parsed_arguments.push(format!("-Xmx{}M", memory.maximum));
for arg in custom_args {
if !arg.is_empty() {
parsed_arguments.push(arg);
@@ -148,8 +156,7 @@ fn parse_jvm_argument(
natives_path.to_string_lossy()
))
})?
.to_string_lossy()
.to_string(),
.to_string_lossy(),
)
.replace(
"${library_directory}",
@@ -180,7 +187,7 @@ pub fn get_minecraft_arguments(
game_directory: &Path,
assets_directory: &Path,
version_type: &VersionType,
resolution: (i32, i32),
resolution: WindowSize,
) -> Result<Vec<String>, LauncherError> {
if let Some(arguments) = arguments {
let mut parsed_arguments = Vec::new();
@@ -234,7 +241,7 @@ fn parse_minecraft_argument(
game_directory: &Path,
assets_directory: &Path,
version_type: &VersionType,
resolution: (i32, i32),
resolution: WindowSize,
) -> Result<String, LauncherError> {
Ok(argument
.replace("${auth_access_token}", access_token)
@@ -255,7 +262,7 @@ fn parse_minecraft_argument(
))
})?
.to_string_lossy()
.to_string(),
.to_owned(),
)
.replace(
"${assets_root}",
@@ -267,7 +274,7 @@ fn parse_minecraft_argument(
))
})?
.to_string_lossy()
.to_string(),
.to_owned(),
)
.replace(
"${game_assets}",
@@ -279,7 +286,7 @@ fn parse_minecraft_argument(
))
})?
.to_string_lossy()
.to_string(),
.to_owned(),
)
.replace("${version_type}", version_type.as_str())
.replace("${resolution_width}", &resolution.0.to_string())

View File

@@ -1,15 +1,38 @@
use crate::launcher::LauncherError;
use crate::{
data::{DataError, Settings},
launcher::LauncherError,
};
use daedalus::get_path_from_artifact;
use daedalus::minecraft::{
fetch_assets_index, fetch_version_info, Asset, AssetsIndex, DownloadType, Library, Os, Version,
VersionInfo,
fetch_assets_index, fetch_version_info, Asset, AssetsIndex, DownloadType,
Library, Os, Version, VersionInfo,
};
use daedalus::modded::{
fetch_partial_version, merge_partial_version, LoaderVersion,
};
use daedalus::modded::{fetch_partial_version, merge_partial_version, LoaderVersion};
use futures::future;
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::time::Duration;
use tokio::{
fs::File,
io::AsyncWriteExt,
sync::{OnceCell, Semaphore},
};
static DOWNLOADS_SEMAPHORE: OnceCell<Semaphore> = OnceCell::const_new();
pub async fn init() -> Result<(), DataError> {
DOWNLOADS_SEMAPHORE
.get_or_try_init(|| async {
let settings = Settings::get().await?;
Ok::<_, DataError>(Semaphore::new(
settings.max_concurrent_downloads,
))
})
.await?;
Ok(())
}
pub async fn download_version_info(
client_path: &Path,
@@ -22,8 +45,7 @@ pub async fn download_version_info(
};
let mut path = client_path.join(id);
path.push(id);
path.set_extension("json");
path.push(&format!("{id}.json"));
if path.exists() {
let contents = std::fs::read_to_string(path)?;
@@ -37,7 +59,7 @@ pub async fn download_version_info(
info.id = loader_version.id.clone();
}
let info_s = serde_json::to_string(&info)?;
save_file(&path, &bytes::Bytes::from(info_s))?;
save_file(&path, &bytes::Bytes::from(info_s)).await?;
Ok(info)
}
@@ -58,10 +80,14 @@ pub async fn download_client(
})?;
let mut path = client_path.join(version);
path.push(version);
path.set_extension("jar");
path.push(&format!("{version}.jar"));
save_and_download_file(&path, &client_download.url, Some(&client_download.sha1)).await?;
save_and_download_file(
&path,
&client_download.url,
Some(&client_download.sha1),
)
.await?;
Ok(())
}
@@ -69,7 +95,8 @@ pub async fn download_assets_index(
assets_path: &Path,
version: &VersionInfo,
) -> Result<AssetsIndex, LauncherError> {
let path = assets_path.join(format!("indexes/{}.json", &version.asset_index.id));
let path =
assets_path.join(format!("indexes/{}.json", &version.asset_index.id));
if path.exists() {
let content = std::fs::read_to_string(path)?;
@@ -77,7 +104,8 @@ pub async fn download_assets_index(
} else {
let index = fetch_assets_index(version).await?;
save_file(&path, &bytes::Bytes::from(serde_json::to_string(&index)?))?;
save_file(&path, &bytes::Bytes::from(serde_json::to_string(&index)?))
.await?;
Ok(index)
}
@@ -88,12 +116,9 @@ pub async fn download_assets(
legacy_path: Option<&Path>,
index: &AssetsIndex,
) -> Result<(), LauncherError> {
future::join_all(
index
.objects
.iter()
.map(|(name, asset)| download_asset(assets_path, legacy_path, name, asset)),
)
future::join_all(index.objects.iter().map(|(name, asset)| {
download_asset(assets_path, legacy_path, name, asset)
}))
.await
.into_iter()
.collect::<Result<Vec<()>, LauncherError>>()?;
@@ -114,14 +139,16 @@ async fn download_asset(
resource_path.push(sub_hash);
resource_path.push(hash);
let url = format!("https://resources.download.minecraft.net/{sub_hash}/{hash}");
let url =
format!("https://resources.download.minecraft.net/{sub_hash}/{hash}");
let resource = save_and_download_file(&resource_path, &url, Some(hash)).await?;
let resource =
save_and_download_file(&resource_path, &url, Some(hash)).await?;
if let Some(legacy_path) = legacy_path {
let resource_path =
legacy_path.join(name.replace('/', &std::path::MAIN_SEPARATOR.to_string()));
save_file(resource_path.as_path(), &resource)?;
let resource_path = legacy_path
.join(name.replace('/', &std::path::MAIN_SEPARATOR.to_string()));
save_file(resource_path.as_path(), &resource).await?;
}
Ok(())
@@ -132,11 +159,9 @@ pub async fn download_libraries(
natives_path: &Path,
libraries: &[Library],
) -> Result<(), LauncherError> {
future::join_all(
libraries
.iter()
.map(|library| download_library(libraries_path, natives_path, library)),
)
future::join_all(libraries.iter().map(|library| {
download_library(libraries_path, natives_path, library)
}))
.await
.into_iter()
.collect::<Result<Vec<()>, LauncherError>>()?;
@@ -173,7 +198,8 @@ async fn download_library_jar(
if let Some(downloads) = &library.downloads {
if let Some(library) = &downloads.artifact {
save_and_download_file(&path, &library.url, Some(&library.sha1)).await?;
save_and_download_file(&path, &library.url, Some(&library.sha1))
.await?;
}
} else {
let url = format!(
@@ -189,16 +215,21 @@ async fn download_library_jar(
Ok(())
}
async fn download_native(natives_path: &Path, library: &Library) -> Result<(), LauncherError> {
async fn download_native(
natives_path: &Path,
library: &Library,
) -> Result<(), LauncherError> {
use daedalus::minecraft::LibraryDownload;
use std::collections::HashMap;
// Try blocks in stable Rust when?
let optional_cascade = || -> Option<(&String, &HashMap<String, LibraryDownload>)> {
let os_key = library.natives.as_ref()?.get(&get_os())?;
let classifiers = library.downloads.as_ref()?.classifiers.as_ref()?;
Some((os_key, classifiers))
};
let optional_cascade =
|| -> Option<(&String, &HashMap<String, LibraryDownload>)> {
let os_key = library.natives.as_ref()?.get(&get_os())?;
let classifiers =
library.downloads.as_ref()?.classifiers.as_ref()?;
Some((os_key, classifiers))
};
if let Some((os_key, classifiers)) = optional_cascade() {
#[cfg(target_pointer_width = "64")]
@@ -227,19 +258,25 @@ async fn save_and_download_file(
Ok(bytes) => Ok(bytes::Bytes::from(bytes)),
Err(_) => {
let file = download_file(url, sha1).await?;
save_file(path, &file)?;
save_file(path, &file).await?;
Ok(file)
}
}
}
fn save_file(path: &Path, bytes: &bytes::Bytes) -> std::io::Result<()> {
async fn save_file(path: &Path, bytes: &bytes::Bytes) -> std::io::Result<()> {
let _save_permit = DOWNLOADS_SEMAPHORE
.get()
.expect("File operation semaphore not initialized!")
.acquire()
.await
.unwrap();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
tokio::fs::create_dir_all(parent).await?;
}
let mut file = File::create(path)?;
file.write_all(bytes)?;
let mut file = File::create(path).await?;
file.write_all(bytes).await?;
Ok(())
}
@@ -252,7 +289,17 @@ pub fn get_os() -> Os {
}
}
pub async fn download_file(url: &str, sha1: Option<&str>) -> Result<bytes::Bytes, LauncherError> {
pub async fn download_file(
url: &str,
sha1: Option<&str>,
) -> Result<bytes::Bytes, LauncherError> {
let _download_permit = DOWNLOADS_SEMAPHORE
.get()
.expect("File operation semaphore not initialized!")
.acquire()
.await
.unwrap();
let client = reqwest::Client::builder()
.tcp_keepalive(Some(Duration::from_secs(10)))
.build()
@@ -307,7 +354,9 @@ pub async fn download_file(url: &str, sha1: Option<&str>) -> Result<bytes::Bytes
/// Computes a checksum of the input bytes
async fn get_hash(bytes: bytes::Bytes) -> Result<String, LauncherError> {
let hash = tokio::task::spawn_blocking(|| sha1::Sha1::from(bytes).hexdigest()).await?;
let hash =
tokio::task::spawn_blocking(|| sha1::Sha1::from(bytes).hexdigest())
.await?;
Ok(hash)
}

View File

@@ -1,17 +0,0 @@
use crate::launcher::LauncherError;
use std::process::Command;
pub fn check_java() -> Result<String, LauncherError> {
let child = Command::new("java")
.arg("-version")
.output()
.map_err(|inner| LauncherError::ProcessError {
inner,
process: "java".into(),
})?;
let output = String::from_utf8_lossy(&child.stderr);
let output = output.trim_matches('\"');
Ok(output.into())
}

View File

@@ -1,20 +1,22 @@
use daedalus::minecraft::{ArgumentType, VersionInfo};
use daedalus::modded::LoaderVersion;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::{Command, Stdio};
use std::{path::Path, process::Stdio};
use thiserror::Error;
use tokio::process::{Child, Command};
pub use crate::launcher::auth::provider::Credentials;
mod args;
mod auth;
pub mod auth;
mod download;
mod java;
mod rules;
pub(crate) use download::init as init_download_semaphore;
#[derive(Error, Debug)]
pub enum LauncherError {
#[error("Failed to violate file checksum at url {url} with hash {hash} after {tries} tries")]
#[error("Failed to validate file checksum at url {url} with hash {hash} after {tries} tries")]
ChecksumFailure {
hash: String,
url: String,
@@ -56,8 +58,12 @@ pub enum LauncherError {
#[error("Java error: {0}")]
JavaError(String),
#[error("Command exited with non-zero exit code: {0}")]
ExitError(i32),
}
// TODO: this probably should be in crate::data
#[derive(Debug, Eq, PartialEq, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ModLoader {
@@ -72,80 +78,64 @@ impl Default for ModLoader {
}
}
pub async fn launch_minecraft(
version_name: &str,
mod_loader: Option<ModLoader>,
root_dir: &Path,
credentials: &Credentials,
) -> Result<(), LauncherError> {
let metadata = crate::data::Metadata::get().await?;
let settings = crate::data::Settings::get().await?;
impl std::fmt::Display for ModLoader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let repr = match self {
&Self::Vanilla => "Vanilla",
&Self::Forge => "Forge",
&Self::Fabric => "Fabric",
};
let versions_path = crate::util::absolute_path(root_dir.join("versions"))?;
let libraries_path = crate::util::absolute_path(root_dir.join("libraries"))?;
let assets_path = crate::util::absolute_path(root_dir.join("assets"))?;
let legacy_assets_path = crate::util::absolute_path(root_dir.join("resources"))?;
f.write_str(repr)
}
}
pub async fn launch_minecraft(
game_version: &str,
loader_version: &Option<LoaderVersion>,
root_dir: &Path,
java: &Path,
java_args: &Vec<String>,
wrapper: &Option<String>,
memory: &crate::data::profiles::MemorySettings,
resolution: &crate::data::profiles::WindowSize,
credentials: &Credentials,
) -> Result<Child, LauncherError> {
let (metadata, settings) = futures::try_join! {
crate::data::Metadata::get(),
crate::data::Settings::get(),
}?;
let root_dir = root_dir.canonicalize()?;
let metadata_dir = &settings.metadata_dir;
let (
versions_path,
libraries_path,
assets_path,
legacy_assets_path,
natives_path,
) = (
metadata_dir.join("versions"),
metadata_dir.join("libraries"),
metadata_dir.join("assets"),
metadata_dir.join("resources"),
metadata_dir.join("natives"),
);
let version = metadata
.minecraft
.versions
.iter()
.find(|x| x.id == version_name)
.find(|it| it.id == game_version)
.ok_or_else(|| {
LauncherError::InvalidInput(format!("Version {} does not exist", version_name))
LauncherError::InvalidInput(format!(
"Invalid game version: {game_version}",
))
})?;
let loader_version = match mod_loader.unwrap_or_default() {
ModLoader::Vanilla => None,
ModLoader::Forge | ModLoader::Fabric => {
let loaders = if mod_loader.unwrap_or_default() == ModLoader::Forge {
&metadata
.forge
.game_versions
.iter()
.find(|x| x.id == version_name)
.ok_or_else(|| {
LauncherError::InvalidInput(format!(
"Version {} for mod loader Forge does not exist",
version_name
))
})?
.loaders
} else {
&metadata
.fabric
.game_versions
.iter()
.find(|x| x.id == version_name)
.ok_or_else(|| {
LauncherError::InvalidInput(format!(
"Version {} for mod loader Fabric does not exist",
version_name
))
})?
.loaders
};
let loader = if let Some(version) = loaders.iter().find(|x| x.stable) {
Some(version.clone())
} else {
loaders.first().cloned()
};
Some(loader.ok_or_else(|| {
LauncherError::InvalidInput(format!(
"No mod loader version found for version {}",
version_name
))
})?)
}
};
let version_jar_name = if let Some(loader) = &loader_version {
loader.id.clone()
} else {
version.id.clone()
};
let version_jar = loader_version
.as_ref()
.map_or(version.id.clone(), |it| it.id.clone());
let mut version = download::download_version_info(
&versions_path,
@@ -154,23 +144,10 @@ pub async fn launch_minecraft(
)
.await?;
let java_path = if let Some(java) = &version.java_version {
if java.major_version == 17 || java.major_version == 16 {
settings.java_17_path.as_deref().ok_or_else(|| LauncherError::JavaError("Please install Java 17 or select your Java 17 installation settings before launching this version!".to_string()))?
} else {
&settings.java_8_path.as_deref().ok_or_else(|| LauncherError::JavaError("Please install Java 8 or select your Java 8 installation settings before launching this version!".to_string()))?
}
} else {
&settings.java_8_path.as_deref().ok_or_else(|| LauncherError::JavaError("Please install Java 8 or select your Java 8 installation settings before launching this version!".to_string()))?
};
let client_path = crate::util::absolute_path(
root_dir
.join("versions")
.join(&version.id)
.join(format!("{}.jar", &version.id)),
)?;
let natives_path = crate::util::absolute_path(root_dir.join("natives").join(&version.id))?;
let client_path = versions_path
.join(&version.id)
.join(format!("{}.jar", &version_jar));
let version_natives_path = natives_path.join(&version.id);
download_minecraft(
&version,
@@ -178,7 +155,7 @@ pub async fn launch_minecraft(
&assets_path,
&legacy_assets_path,
&libraries_path,
&natives_path,
&version_natives_path,
)
.await?;
@@ -201,7 +178,7 @@ pub async fn launch_minecraft(
data.insert(
"MINECRAFT_VERSION".to_string(),
daedalus::modded::SidedDataEntry {
client: version_name.to_string(),
client: game_version.to_string(),
server: "".to_string(),
},
);
@@ -252,6 +229,7 @@ pub async fn launch_minecraft(
data,
)?)
.output()
.await
.map_err(|err| LauncherError::ProcessError {
inner: err,
process: "java".to_string(),
@@ -266,60 +244,50 @@ pub async fn launch_minecraft(
}
}
let arguments = version.arguments.unwrap_or_default();
let mut command = Command::new(if let Some(wrapper) = &settings.wrapper_command {
wrapper.clone()
} else {
java_path.to_string()
});
if settings.wrapper_command.is_some() {
command.arg(java_path);
}
let arguments = version.arguments.clone().unwrap_or_default();
let mut command = match wrapper {
Some(hook) => {
let mut cmd = Command::new(hook);
cmd.arg(java);
cmd
}
None => Command::new(java.to_string_lossy().to_string()),
};
command
.args(args::get_jvm_arguments(
arguments.get(&ArgumentType::Jvm).map(|x| x.as_slice()),
&natives_path,
&version_natives_path,
&libraries_path,
&args::get_class_paths(&libraries_path, version.libraries.as_slice(), &client_path)?,
&version_jar_name,
settings.memory,
settings
.custom_java_args
.split(" ")
.into_iter()
.map(|x| x.to_string())
.collect(),
&args::get_class_paths(
&libraries_path,
version.libraries.as_slice(),
&client_path,
)?,
&version_jar,
*memory,
java_args.clone(),
)?)
.arg(version.main_class)
.arg(version.main_class.clone())
.args(args::get_minecraft_arguments(
arguments.get(&ArgumentType::Game).map(|x| x.as_slice()),
version.minecraft_arguments.as_deref(),
credentials,
&version.id,
&version.asset_index.id,
root_dir,
&root_dir,
&assets_path,
&version.type_,
settings.game_resolution,
*resolution,
)?)
.current_dir(root_dir)
.current_dir(root_dir.clone())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let mut child = command.spawn().map_err(|err| LauncherError::ProcessError {
command.spawn().map_err(|err| LauncherError::ProcessError {
inner: err,
process: "minecraft".to_string(),
})?;
child.wait().map_err(|err| LauncherError::ProcessError {
inner: err,
process: "minecraft".to_string(),
})?;
Ok(())
process: format!("minecraft-{} @ {}", &version.id, root_dir.display()),
})
}
pub async fn download_minecraft(
@@ -330,7 +298,8 @@ pub async fn download_minecraft(
libraries_dir: &Path,
natives_dir: &Path,
) -> Result<(), LauncherError> {
let assets_index = download::download_assets_index(assets_dir, version).await?;
let assets_index =
download::download_assets_index(assets_dir, version).await?;
let (a, b, c) = futures::future::join3(
download::download_client(versions_dir, version),
@@ -343,7 +312,11 @@ pub async fn download_minecraft(
},
&assets_index,
),
download::download_libraries(libraries_dir, natives_dir, version.libraries.as_slice()),
download::download_libraries(
libraries_dir,
natives_dir,
version.libraries.as_slice(),
),
)
.await;