diff --git a/packages/core/examples/download_minecraft.rs b/packages/core/examples/download_minecraft.rs new file mode 100644 index 00000000..f8b63e4a --- /dev/null +++ b/packages/core/examples/download_minecraft.rs @@ -0,0 +1,19 @@ +use anyhow::anyhow; +use onelauncher_core::{api::{game::metadata::{download_minecraft_ingressed, download_version_info}, proxy::ProxyDynamic}, error::LauncherResult, initialize_core, store::{CoreOptions, State}}; +use onelauncher_entity::loader::GameLoader; + +#[tokio::main] +async fn main() -> LauncherResult<()> { + initialize_core(CoreOptions::default(), ProxyDynamic::new()).await?; + + let state = State::get().await?; + let mut metadata = state.metadata.write().await; + let versions = metadata.get_vanilla(GameLoader::Vanilla).await?; + + let version = versions.versions.iter().find(|v| v.id == "1.8.9").ok_or_else(|| anyhow!("couldn't find 1.8.9 in version list"))?; + let version_info = download_version_info(version, None, None, Some(true)).await?; + + download_minecraft_ingressed(&version_info, Some(true)).await?; + + Ok(()) +} \ No newline at end of file diff --git a/packages/core/src/api/game/metadata.rs b/packages/core/src/api/game/metadata.rs new file mode 100644 index 00000000..12ff8bbe --- /dev/null +++ b/packages/core/src/api/game/metadata.rs @@ -0,0 +1,188 @@ +use futures::{StreamExt, stream}; +use interpulse::api::minecraft::{AssetsIndex, Version, VersionInfo}; +use interpulse::api::modded::LoaderVersion; +use reqwest::Method; + +use crate::api::ingress::{init_ingress, send_ingress_ref_opt}; +use crate::error::LauncherResult; +use crate::store::ingress::{IngressRef, IngressType}; +use crate::store::{Core, Dirs}; +use crate::utils::crypto::HashAlgorithm; +use crate::utils::http::{self, fetch_json_advanced}; +use crate::utils::io; + +pub async fn download_minecraft_ingressed(version: &VersionInfo, force: Option) -> LauncherResult<()> { + let id = init_ingress( + IngressType::MinecraftDownload, + &format!("Downloading Minecraft {}", version.id), + 100.0, + ) + .await?; + + let ingress_ref = IngressRef::new(&id, 100.0); + + download_minecraft(version, Some(&ingress_ref), force).await +} + +// MARK: Main +#[tracing::instrument(skip_all)] +pub async fn download_minecraft(version: &VersionInfo, ingress_ref: Option<&IngressRef<'_>>, force: Option) -> LauncherResult<()> { + + const TASKS: f64 = 2.0; + let ingress_ref = ingress_ref.map(|i| i.with_increment(i.increment_by / TASKS)); + let ingress_ref = ingress_ref.as_ref(); + + let asset_index = download_assets_index(version, ingress_ref, force).await?; + tokio::try_join! { + download_assets(version.assets == "legacy", &asset_index, ingress_ref, force) + }?; + + Ok(()) +} + +// MARK: Version Info +#[tracing::instrument(skip_all)] +pub async fn download_version_info( + version: &Version, + loader: Option<&LoaderVersion>, + ingress_ref: Option<&IngressRef<'_>>, + force: Option, +) -> LauncherResult { + let version_id = loader.map_or(version.id.clone(), |it| format!("{}-{}", version.id, it.id)); + + let ingress_ref = ingress_ref.map(|i| i.with_increment(i.increment_by / 3.33)); + let ingress_ref = ingress_ref.as_ref(); + + tracing::debug!("loading minecraft version info for minecraft version {version_id}"); + + let path = Dirs::get_versions_dir() + .await? + .join(&version_id) + .join(format!("{version_id}.json")); + + let result = if path.exists() && !force.unwrap_or(false) { + let data = io::read(path).await?; + serde_json::from_slice(&data)? + } else { + tracing::info!( + "downloading minecraft version info for minecraft version {}", + &version_id + ); + + let mut info = + fetch_json_advanced(Method::GET, &version.url, None, None, None, ingress_ref).await?; + + if let Some(loader) = loader { + let partial: interpulse::api::modded::PartialVersionInfo = + fetch_json_advanced(Method::GET, &loader.url, None, None, None, ingress_ref) + .await?; + + info = interpulse::api::modded::merge_partial_version(partial, info); + } + + info.id.clone_from(&version_id); + + io::create_dir_all(&path.parent().unwrap_or_else(|| panic!("couldn't get version path parent"))).await?; + io::write(&path, &serde_json::to_vec(&info)?).await?; + info + }; + + send_ingress_ref_opt(ingress_ref).await?; + + tracing::debug!("loaded minecraft version info for minecraft version {version_id}"); + Ok(result) +} + +// MARK: Assets Index +#[tracing::instrument(skip_all)] +pub async fn download_assets_index( + version: &VersionInfo, + ingress_ref: Option<&IngressRef<'_>>, + force: Option, +) -> LauncherResult { + let path = Dirs::get_assets_index_dir() + .await? + .join(version.asset_index.id.clone()); + + let ingress_ref = ingress_ref.map(|i| i.with_increment(i.increment_by / 2.0)); + let ingress_ref = ingress_ref.as_ref(); + send_ingress_ref_opt(ingress_ref).await?; + + if path.exists() && !force.unwrap_or(false) { + match io::read_json::(&path).await { + Ok(data) => { + send_ingress_ref_opt(ingress_ref).await?; + return Ok(data); + } + Err(err) => tracing::error!( + "failed to read assets index from cache: {err:?}, downloading again", + ), + } + } + + let data = serde_json::from_slice(&http::download( + Method::GET, + &version.asset_index.url, + path, + None, + ingress_ref, + ) + .await?)?; + + tracing::debug!("loaded assets index for version {}", version.id); + + Ok(data) +} + +// MARK: Assets +#[tracing::instrument(skip_all)] +pub async fn download_assets( + legacy: bool, + assets_index: &AssetsIndex, + ingress_ref: Option<&IngressRef<'_>>, + force: Option, +) -> LauncherResult<()> { + let len = assets_index.objects.len(); + + let dir = if legacy { + Dirs::get_legacy_assets_dir().await + } else { + Dirs::get_assets_object_dir().await + }?; + + io::create_dir_all(&dir).await?; + + let ingress_ref = ingress_ref.map(|i| i.with_increment(i.increment_by / len as f64)); + let ingress_ref = ingress_ref.as_ref(); + + let requests = stream::iter(assets_index.objects.iter().map(|(name, asset)| async { + let hash = &asset.hash; + let subhash = &hash[0..2]; + let path = if legacy { + dir.join(name.replace('/', std::path::MAIN_SEPARATOR_STR)) + } else { + dir.join(subhash).join(hash) + }; + + if !path.exists() || force.unwrap_or(false) { + http::download_advanced( + Method::GET, + &format!("https://resources.download.minecraft.net/{subhash}/{hash}"), + path, + None, + None, + Some((HashAlgorithm::Sha1, hash)), + ingress_ref, + ) + .await.map(|_| ()) + } else { + Ok(()) + } + })) + .buffer_unordered(Core::get().fetch_attempts.min(7)) + .collect::>(); + + requests.await; + + Ok(()) +} diff --git a/packages/core/src/game/mod.rs b/packages/core/src/api/game/mod.rs similarity index 100% rename from packages/core/src/game/mod.rs rename to packages/core/src/api/game/mod.rs diff --git a/packages/core/src/api/mod.rs b/packages/core/src/api/mod.rs index ef5fcc38..ba127915 100644 --- a/packages/core/src/api/mod.rs +++ b/packages/core/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod cluster; pub mod ingress; +pub mod game; pub mod proxy; pub mod java; \ No newline at end of file diff --git a/packages/core/src/game/metadata.rs b/packages/core/src/game/metadata.rs deleted file mode 100644 index 97ce154c..00000000 --- a/packages/core/src/game/metadata.rs +++ /dev/null @@ -1,73 +0,0 @@ -use interpulse::api::{minecraft::{Version, VersionInfo}, modded::LoaderVersion}; - -use crate::{api::ingress::{init_ingress, send_ingress_ref_opt}, error::LauncherResult, store::{ingress::{IngressRef, IngressType}, Dirs}, utils::{http::fetch_json_advanced, io}}; - -#[tracing::instrument] -pub async fn download_minecraft(version: &VersionInfo) -> LauncherResult<()> { - let id = init_ingress(IngressType::MinecraftDownload, "Downloading Minecraft", 100.0).await?; - - // tokio::try_join! { - - // }?; - - Ok(()) -} - -#[tracing::instrument] -pub async fn download_version_info( - version: &Version, - loader: Option<&LoaderVersion>, - force: Option, - ingress_ref: Option<&IngressRef<'_>>, -) -> LauncherResult { - let version_id = loader.map_or(version.id.clone(), |it| format!("{}-{}", version.id, it.id)); - - let ingress_ref = ingress_ref.map(|i| i.with_increment(i.increment_by / 3.33)); - let ingress_ref = ingress_ref.as_ref(); - - tracing::debug!("loading minecraft version info for minecraft version {version_id}"); - - let path = Dirs::get_version_infos_dir().await?.join(format!("{version_id}.json")); - - let result = if path.exists() && !force.unwrap_or(false) { - let data = io::read(path).await?; - serde_json::from_slice(&data)? - } else { - tracing::info!( - "downloading minecraft version info for minecraft version {}", - &version_id - ); - - let mut info = fetch_json_advanced( - reqwest::Method::GET, - &version.url, - None, - None, - None, - ingress_ref - ).await?; - - if let Some(loader) = loader { - let partial: interpulse::api::modded::PartialVersionInfo = fetch_json_advanced( - reqwest::Method::GET, - &loader.url, - None, - None, - None, - ingress_ref - ).await?; - - info = interpulse::api::modded::merge_partial_version(partial, info); - } - - info.id.clone_from(&version_id); - - io::write(&path, &serde_json::to_vec(&info)?).await?; - info - }; - - send_ingress_ref_opt(ingress_ref).await?; - - tracing::debug!("loaded minecraft version info for minecraft version {version_id}"); - Ok(result) -} \ No newline at end of file diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs index bcce3e2c..ad005a37 100644 --- a/packages/core/src/lib.rs +++ b/packages/core/src/lib.rs @@ -8,7 +8,6 @@ use logger::start_logger; pub mod api; pub mod constants; pub mod error; -pub mod game; pub mod store; pub mod utils; diff --git a/packages/core/src/logger.rs b/packages/core/src/logger.rs index b755b535..e7740b80 100644 --- a/packages/core/src/logger.rs +++ b/packages/core/src/logger.rs @@ -1,23 +1,27 @@ +use tracing_subscriber::{prelude::*, util::SubscriberInitExt}; +use crate::store::Core; + // Handling for the live development logging // This will log to the console, and will not log to a file #[cfg(debug_assertions)] pub async fn start_logger() { - use tracing_subscriber::{fmt::format::FmtSpan, prelude::*, util::SubscriberInitExt}; - let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(format!("{}=info", env!("CARGO_PKG_NAME")))); + let mut fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(true) + .with_file(true) + .with_line_number(true) + .with_level(true) + .with_thread_names(true) + .pretty(); + + if let Some(span) = Core::get().span_formatting.clone() { + fmt_layer = fmt_layer.with_span_events(span); + } + if let Err(err) = tracing_subscriber::registry() - .with( - tracing_subscriber::fmt::layer() - .with_ansi(true) - .with_file(true) - .with_line_number(true) - .with_level(true) - .with_thread_names(true) - .with_span_events(FmtSpan::FULL) - .pretty() - ) + .with(fmt_layer) .with(filter) .with(tracing_error::ErrorLayer::default()) .try_init() { @@ -32,7 +36,6 @@ pub async fn start_logger() { use chrono::Local; use std::fs::OpenOptions; use tracing_subscriber::fmt::time::ChronoLocal; - use tracing_subscriber::prelude::*; use crate::io::Dirs; @@ -61,14 +64,18 @@ pub async fn start_logger() { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(format!("{}=info", env!("CARGO_PKG_NAME")))); + let mut fmt_layer = tracing_subscriber::fmt::layer() + .compact() + .with_writer(file) + .with_ansi(false) + .with_timer(ChronoLocal::rfc_3339()); + + if let Some(span) = Core::get().span_formatting.clone() { + fmt_layer = fmt_layer.with_span_events(span); + } + tracing_subscriber::registry() - .with( - tracing_subscriber::fmt::layer() - .compact() - .with_writer(file) - .with_ansi(false) - .with_timer(ChronoLocal::rfc_3339()), - ) + .with(fmt_layer) .with(filter) .with(tracing_error::ErrorLayer::default()) .init(); diff --git a/packages/core/src/store/core_options.rs b/packages/core/src/store/core_options.rs index d266df81..ffa703af 100644 --- a/packages/core/src/store/core_options.rs +++ b/packages/core/src/store/core_options.rs @@ -1,6 +1,7 @@ use std::{ops::Deref, sync::Arc}; use tokio::sync::OnceCell; +use tracing_subscriber::fmt::format::FmtSpan; use crate::error::{LauncherError, LauncherResult}; @@ -14,7 +15,8 @@ pub struct CoreOptions { pub launcher_name: String, pub launcher_version: String, pub launcher_website: String, - pub fetch_attempts: u8, + pub fetch_attempts: usize, + pub span_formatting: Option, /// Default client id is the same as the one used by the official launcher pub msa_client_id: String, pub msa_redirect_uri: String, @@ -29,6 +31,7 @@ impl Default for CoreOptions { launcher_version: String::from(env!("CARGO_PKG_VERSION")), launcher_website: String::from("https://polyfrost.org/"), fetch_attempts: 3, + span_formatting: None, msa_client_id: String::from("00000000402b5328"), msa_redirect_uri: String::from("https://login.live.com/oauth20_desktop.srf"), curseforge_api_key: None, diff --git a/packages/core/src/store/dirs.rs b/packages/core/src/store/dirs.rs index 44cbecaf..ef2245e0 100644 --- a/packages/core/src/store/dirs.rs +++ b/packages/core/src/store/dirs.rs @@ -46,7 +46,7 @@ impl Dirs { } macro_rules! dirs_impl { - ($($name:ident => |$self:ident| $path:expr),*) => { + ($($name:ident => |$self:ident| $path:expr),*$(,)?) => { paste::paste! { impl Dirs { $( @@ -75,9 +75,14 @@ dirs_impl! { clusters_dir => |this| this.base_dir.join("clusters"), metadata_dir => |this| this.base_dir.join("metadata"), - version_infos_dir => |this| this.metadata_dir().join("version_infos"), + versions_dir => |this| this.metadata_dir().join("versions"), libraries_dir => |this| this.metadata_dir().join("libraries"), natives_dir => |this| this.metadata_dir().join("natives"), java_dir => |this| this.metadata_dir().join("java"), - caches_dir => |this| this.metadata_dir().join("caches") + caches_dir => |this| this.metadata_dir().join("caches"), + + assets_dir => |this| this.metadata_dir().join("assets"), + legacy_assets_dir => |this| this.metadata_dir().join("resources"), + assets_index_dir => |this| this.assets_dir().join("indexes"), + assets_object_dir => |this| this.assets_dir().join("objects"), } \ No newline at end of file diff --git a/packages/core/src/store/metadata.rs b/packages/core/src/store/metadata.rs index e6260f97..cdd33cd3 100644 --- a/packages/core/src/store/metadata.rs +++ b/packages/core/src/store/metadata.rs @@ -125,16 +125,19 @@ impl Metadata { macro_rules! check { ($var:tt, $is_modded:tt) => { if self.inner.$var.is_none() { - if let Ok(loader) = GameLoader::from_str(stringify!($var)) { - match check!(_fetch, loader, $is_modded) { - Ok(data) => { - self.inner.$var = Some(data); - changed += 1; + match GameLoader::from_str(stringify!($var)) { + Ok(loader) => { + match check!(_fetch, loader, $is_modded) { + Ok(data) => { + self.inner.$var = Some(data); + changed += 1; + } + Err(err) => { + tracing::error!("failed to fetch manifest for {}: {}", loader, err); + } } - Err(err) => { - tracing::error!("failed to fetch manifest for {}: {}", loader, err); - } - } + }, + Err(err) => tracing::error!("{err}") }; } }; diff --git a/packages/core/src/store/semaphore.rs b/packages/core/src/store/semaphore.rs index 03a92b76..3b60ee5d 100644 --- a/packages/core/src/store/semaphore.rs +++ b/packages/core/src/store/semaphore.rs @@ -5,7 +5,7 @@ use tokio::sync::{OnceCell, Semaphore}; static STORE_STATE: OnceCell> = OnceCell::const_new(); pub struct SemaphoreStore { - fetch: Arc + pub fetch: Arc } impl SemaphoreStore { diff --git a/packages/core/src/utils/http.rs b/packages/core/src/utils/http.rs index 9fdc62bb..8b62b48a 100644 --- a/packages/core/src/utils/http.rs +++ b/packages/core/src/utils/http.rs @@ -60,10 +60,13 @@ pub async fn fetch_advanced( ingress_ref: Option<&IngressRef<'_>>, ) -> LauncherResult { let semaphore = SemaphoreStore::fetch().await; - let _permit = semaphore.acquire().await?; + let permit = semaphore.acquire().await; + let client = &REQWEST_CLIENT; - for attempt in 0..Core::get().fetch_attempts { + let mut attempt = 0; + let res = loop { + attempt += 1; let mut req = client.request(method.clone(), url); if let Some(body) = body.clone() { @@ -76,67 +79,64 @@ pub async fn fetch_advanced( } } - let res = req.send().await; - - match res { - Err(_) if attempt <= 3 => {} - Err(err) => return Err(err.into()), - Ok(res) => { - let bytes = if let Some(ingress_ref) = ingress_ref { - let length = res.content_length(); - if let Some(total_size) = length { - let mut stream = res.bytes_stream(); - let mut bytes = Vec::new(); - - while let Some(item) = stream.next().await { - let chunk = - item.or(Err(anyhow::anyhow!("no value for fetch bytes")))?; - bytes.append(&mut chunk.to_vec()); - - send_ingress( - ingress_ref, - (chunk.len() as f64 / total_size as f64) * ingress_ref.increment_by, - ) - .await?; - } - - Ok(Bytes::from(bytes)) - } else { - res.bytes().await - } - } else { - res.bytes().await - }; - - if let Ok(bytes) = bytes { - if let Some((ref algorithm, expected_hash)) = hash { - let expected_hash = expected_hash.to_string(); - let calculated_hash = algorithm.hash(&bytes); - - if *calculated_hash != expected_hash { - if attempt <= 3 { - continue; - } - - return Err(CryptoError::InvalidHash { - algorithm: algorithm.clone(), - expected: expected_hash, - actual: calculated_hash, - } - .into()); - } - } - - tracing::debug!("finished downloading {url}"); - return Ok(bytes); - } else if let Err(err) = bytes { - return Err(err.into()); + match req.send().await { + Err(err) => { + if attempt <= Core::get().fetch_attempts { + tracing::error!("error occurred whilst fetching on attempt {attempt}: {err} retrying request..."); + continue; } + + return Err(err.into()) + }, + Ok(res) => break res + } + }; + + // Drop the fetch permit as we are no longer fetching + drop(permit); + + let bytes = if let Some(ingress_ref) = ingress_ref { + let length = res.content_length(); + if let Some(total_size) = length { + let mut stream = res.bytes_stream(); + let mut bytes = Vec::new(); + + while let Some(item) = stream.next().await { + let chunk = + item.or(Err(anyhow::anyhow!("no value for fetch bytes")))?; + bytes.append(&mut chunk.to_vec()); + + send_ingress( + ingress_ref, + (chunk.len() as f64 / total_size as f64) * ingress_ref.increment_by, + ) + .await?; } + + Ok(Bytes::from(bytes)) + } else { + res.bytes().await + } + } else { + res.bytes().await + }?; + + if let Some((ref algorithm, expected_hash)) = hash { + let expected_hash = expected_hash.to_string(); + let calculated_hash = algorithm.hash(&bytes); + + if *calculated_hash != expected_hash { + return Err(CryptoError::InvalidHash { + algorithm: algorithm.clone(), + expected: expected_hash, + actual: calculated_hash, + } + .into()); } } - unreachable!() + tracing::debug!("finished downloading {url}"); + Ok(bytes) } pub async fn fetch(method: Method, url: &str) -> LauncherResult { @@ -147,17 +147,17 @@ pub async fn fetch_json Deserialize<'de>>( method: Method, url: &str, body: Option, - headers: Option>, + ingress_ref: Option<&IngressRef<'_>>, ) -> LauncherResult { - fetch_json_advanced(method, url, body, None, headers, None).await + fetch_json_advanced(method, url, body, None, None, ingress_ref).await } pub async fn fetch_json_advanced Deserialize<'de>>( method: Method, url: &str, body: Option, - hash: Option<(HashAlgorithm, &str)>, headers: Option>, + hash: Option<(HashAlgorithm, &str)>, ingress: Option<&IngressRef<'_>>, ) -> LauncherResult { let bytes = fetch_advanced(method, url, body, hash, headers, ingress).await?; @@ -169,10 +169,10 @@ pub async fn download_advanced( url: &str, path: impl AsRef, body: Option, - hash: Option<(HashAlgorithm, &str)>, headers: Option>, + hash: Option<(HashAlgorithm, &str)>, ingress: Option<&IngressRef<'_>>, -) -> LauncherResult<()> { +) -> LauncherResult { let bytes = fetch_advanced( method, url, @@ -198,7 +198,7 @@ pub async fn download_advanced( send_ingress(ingress_ref, ingress_ref.increment_by / 4.0).await?; } - Ok(()) + Ok(bytes) } pub async fn download( @@ -206,6 +206,7 @@ pub async fn download( url: &str, path: impl AsRef, hash: Option<(HashAlgorithm, &str)>, -) -> LauncherResult<()> { - download_advanced(method, url, path, None, hash, None, None).await + ingress_ref: Option<&IngressRef<'_>> +) -> LauncherResult { + download_advanced(method, url, path, None, None, hash, ingress_ref).await } diff --git a/packages/entity/src/domain/loader.rs b/packages/entity/src/domain/loader.rs index 3b0619bf..92f83e2e 100644 --- a/packages/entity/src/domain/loader.rs +++ b/packages/entity/src/domain/loader.rs @@ -8,14 +8,13 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "lowercase")] #[sea_orm(rs_type = "u8", db_type = "Integer")] pub enum GameLoader { - Vanilla = 0, #[default] - Unknown = 1, - Forge = 2, - NeoForge = 3, - Quilt = 4, - Fabric = 5, - LegacyFabric = 6, + Vanilla = 0, + Forge = 1, + NeoForge = 2, + Quilt = 3, + Fabric = 4, + LegacyFabric = 5, } impl GameLoader { @@ -27,7 +26,7 @@ impl GameLoader { pub const fn get_format_version(&self) -> usize { match self { - Self::Vanilla | Self::Unknown => interpulse::api::minecraft::CURRENT_FORMAT_VERSION, + Self::Vanilla => interpulse::api::minecraft::CURRENT_FORMAT_VERSION, Self::Forge => interpulse::api::modded::CURRENT_FORGE_FORMAT_VERSION, Self::NeoForge => interpulse::api::modded::CURRENT_NEOFORGE_FORMAT_VERSION, Self::Quilt => interpulse::api::modded::CURRENT_QUILT_FORMAT_VERSION, @@ -38,7 +37,7 @@ impl GameLoader { pub fn get_format_name(&self) -> String { match self { - Self::Vanilla | Self::Unknown => String::from("minecraft"), + Self::Vanilla => String::from("minecraft"), Self::NeoForge => String::from("neo"), // TODO(metadata): change to neoforge _ => self.to_string().to_lowercase().replace(['_', '.', ' '], "") } @@ -46,25 +45,27 @@ impl GameLoader { } -impl From for GameLoader { - fn from(s: String) -> Self { - match s.to_lowercase().replace(['_', '.', ' '], "").as_str() { +impl TryFrom for GameLoader { + type Error = String; + + fn try_from(s: String) -> Result { + Ok(match s.to_lowercase().replace(['_', '.', ' '], "").as_str() { "vanilla" => Self::Vanilla, "forge" => Self::Forge, - "neoforge" => Self::NeoForge, + "neoforge" | "neo" => Self::NeoForge, "quilt" => Self::Quilt, "fabric" => Self::Fabric, "legacyfabric" => Self::LegacyFabric, - _ => Self::Unknown, - } + _ => return Err(format!("'{s}' is not a valid game loader")), + }) } } impl FromStr for GameLoader { - type Err = (); + type Err = String; fn from_str(s: &str) -> Result { - Ok(Self::from(s.to_string())) + Self::try_from(s.to_string()) } } @@ -78,7 +79,6 @@ impl Display for GameLoader { Self::Quilt => "Quilt", Self::Fabric => "Fabric", Self::LegacyFabric => "LegacyFabric", - Self::Unknown => "Unknown", } ) }