feat: Downloading game assets + download minecraft example

This commit is contained in:
LynithDev 2025-04-08 16:37:48 +02:00
parent 1f2d560983
commit 81ed2de0f5
13 changed files with 345 additions and 192 deletions

View file

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

View file

@ -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<bool>) -> 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<bool>) -> 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<bool>,
) -> LauncherResult<VersionInfo> {
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<bool>,
) -> LauncherResult<AssetsIndex> {
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::<AssetsIndex>(&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<bool>,
) -> 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::<Vec<_>>();
requests.await;
Ok(())
}

View file

@ -1,4 +1,5 @@
pub mod cluster;
pub mod ingress;
pub mod game;
pub mod proxy;
pub mod java;

View file

@ -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<bool>,
ingress_ref: Option<&IngressRef<'_>>,
) -> LauncherResult<VersionInfo> {
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)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@ use tokio::sync::{OnceCell, Semaphore};
static STORE_STATE: OnceCell<Arc<SemaphoreStore>> = OnceCell::const_new();
pub struct SemaphoreStore {
fetch: Arc<Semaphore>
pub fetch: Arc<Semaphore>
}
impl SemaphoreStore {

View file

@ -60,10 +60,13 @@ pub async fn fetch_advanced(
ingress_ref: Option<&IngressRef<'_>>,
) -> LauncherResult<Bytes> {
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<Bytes> {
@ -147,17 +147,17 @@ pub async fn fetch_json<T: for<'de> Deserialize<'de>>(
method: Method,
url: &str,
body: Option<serde_json::Value>,
headers: Option<HashMap<&str, &str>>,
ingress_ref: Option<&IngressRef<'_>>,
) -> LauncherResult<T> {
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<T: for<'de> Deserialize<'de>>(
method: Method,
url: &str,
body: Option<serde_json::Value>,
hash: Option<(HashAlgorithm, &str)>,
headers: Option<HashMap<&str, &str>>,
hash: Option<(HashAlgorithm, &str)>,
ingress: Option<&IngressRef<'_>>,
) -> LauncherResult<T> {
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<Path>,
body: Option<serde_json::Value>,
hash: Option<(HashAlgorithm, &str)>,
headers: Option<HashMap<&str, &str>>,
hash: Option<(HashAlgorithm, &str)>,
ingress: Option<&IngressRef<'_>>,
) -> LauncherResult<()> {
) -> LauncherResult<Bytes> {
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<Path>,
hash: Option<(HashAlgorithm, &str)>,
) -> LauncherResult<()> {
download_advanced(method, url, path, None, hash, None, None).await
ingress_ref: Option<&IngressRef<'_>>
) -> LauncherResult<Bytes> {
download_advanced(method, url, path, None, None, hash, ingress_ref).await
}

View file

@ -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<String> for GameLoader {
fn from(s: String) -> Self {
match s.to_lowercase().replace(['_', '.', ' '], "").as_str() {
impl TryFrom<String> for GameLoader {
type Error = String;
fn try_from(s: String) -> Result<GameLoader, String> {
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<Self, Self::Err> {
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",
}
)
}