diff --git a/packages/oneclient_app/src/hooks/queries/storage.rs b/packages/oneclient_app/src/hooks/queries/storage.rs index f26cbc35..658294dd 100644 --- a/packages/oneclient_app/src/hooks/queries/storage.rs +++ b/packages/oneclient_app/src/hooks/queries/storage.rs @@ -63,7 +63,7 @@ impl MutationCapability for StorageActionMutation { .map(|_| ()) } StorageAction::CleanLegacyClusterContent => { - oneclient_core::storage::clean_legacy_cluster_content(&state) + oneclient_core::storage::clean_cluster_leftovers(&state) .await .map(|_| ()) } diff --git a/packages/oneclient_app/src/view/app/settings/storage.rs b/packages/oneclient_app/src/view/app/settings/storage.rs index de5acd8d..85d6015f 100644 --- a/packages/oneclient_app/src/view/app/settings/storage.rs +++ b/packages/oneclient_app/src/view/app/settings/storage.rs @@ -49,9 +49,13 @@ impl Component for SettingsStorage { ReclaimRow { icon: IconType::Trash01, title: "Leftover cluster content", - description: legacy_content_description(&report.legacy_cluster_content), + description: legacy_content_description( + &report.legacy_cluster_content, + &report.unused_natives, + ), action: StorageAction::CleanLegacyClusterContent, - empty: report.legacy_cluster_content.is_empty(), + empty: report.legacy_cluster_content.is_empty() + && report.unused_natives.is_empty(), } .into_element(), ) @@ -73,7 +77,9 @@ impl Component for SettingsStorage { } fn hero(report: &StorageReport, refresh: Element) -> impl IntoElement { - let reclaimable = report.unreferenced_cache.bytes + report.legacy_cluster_content.bytes; + let reclaimable = report.unreferenced_cache.bytes + + report.legacy_cluster_content.bytes + + report.unused_natives.bytes; let subtitle = if reclaimable > 0 { format!("{} can be freed", format_bytes(reclaimable)) @@ -278,18 +284,43 @@ fn unused_cache_description(entry: &ReclaimableEntry) -> String { ) } -fn legacy_content_description(entry: &ReclaimableEntry) -> String { - if entry.is_empty() { - return "Nothing here - no cluster folder is holding old content.".to_string(); +fn legacy_content_description( + content: &ReclaimableEntry, + natives: &ReclaimableEntry, +) -> String { + if content.is_empty() && natives.is_empty() { + return "Nothing here - no old cluster content and no unused natives.".to_string(); } - format!( - "{} across {} file{} left in cluster folders by an older version. Already ignored when \ - you play; this just reclaims the space.", - format_bytes(entry.bytes), - entry.files, - plural(entry.files as i64) - ) + let mut parts = Vec::new(); + + if !content.is_empty() { + parts.push(format!( + "{} across {} file{} left in cluster folders by an older version", + format_bytes(content.bytes), + content.files, + plural(content.files as i64) + )); + } + + if !natives.is_empty() { + parts.push(format!( + "{} across {} native folder{} no cluster uses any more", + format_bytes(natives.bytes), + natives.files, + plural(natives.files as i64) + )); + } + + // Natives are not inert deleting them costs a re-download on the next launch + // so promising the space is free would be a lie + let tail = if natives.is_empty() { + "Already ignored when you play; this just reclaims the space." + } else { + "Nothing a cluster still needs is touched; anything else is fetched again on demand." + }; + + format!("{}. {tail}", parts.join(", plus ")) } fn empty_note(text: &'static str) -> impl IntoElement { diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 7e61aaaf..baa10624 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -234,6 +234,11 @@ async fn start( let _ = state.clusters.set_stage(cluster_id, ClusterStage::Ready).await; } Ok(false) => {} + Err(err @ oneclient_mc::McError::NoNativesForPlatform { .. }) => { + progress.finish(); + stage(LaunchStage::Exited); + return Err(err.into()); + } Err(err) => tracing::warn!(cluster_id, error = %err, "repair check failed"), } @@ -264,6 +269,7 @@ async fn start( .join(&version_name) .join(format!("{version_name}.jar")); let natives = paths::natives_dir()?.join(&version_name); + polyio::create_dir_all(&natives).await?; let libraries = paths::libraries_dir()?; let assets = paths::assets_dir()?; diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index b62609f3..37972182 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -29,9 +29,10 @@ pub use process::{ }; pub use reattach::recover_sessions; pub use oneclient_mc::{ - DownloadPlan, confirm_incomplete_install, download_minecraft, download_version_info, get_game_versions, + DownloadPlan, NativesReport, PlannedLibrary, check_natives, confirm_incomplete_install, + download_minecraft, download_version_info, get_game_versions, get_loader_version, get_loader_versions, get_loaders_for_version, is_version_updated, - game_files_missing, libraries_missing, plan_downloads, resolve_minecraft_version, + game_files_missing, libraries_missing, natives_missing, plan_downloads, resolve_minecraft_version, validate_rules, verify_game_files, }; diff --git a/packages/oneclient_core/src/game/process.rs b/packages/oneclient_core/src/game/process.rs index 40abb82f..0d4938bc 100644 --- a/packages/oneclient_core/src/game/process.rs +++ b/packages/oneclient_core/src/game/process.rs @@ -157,6 +157,9 @@ impl GameProcessManager { self.inner.lock().unwrap().get(&cluster_id).map(|p| p.stage) } + /// Every cluster the manager knows about not just the ones already running + /// a launch still resolving its files is as much a reason to leave its + /// natives alone as a live game pub fn active_ids(&self) -> Vec { self.inner.lock().unwrap().keys().copied().collect() } diff --git a/packages/oneclient_core/src/storage.rs b/packages/oneclient_core/src/storage.rs index 39830878..5da23d98 100644 --- a/packages/oneclient_core/src/storage.rs +++ b/packages/oneclient_core/src/storage.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -45,6 +46,7 @@ pub struct StorageReport { pub clusters: Vec, pub unreferenced_cache: ReclaimableEntry, pub legacy_cluster_content: ReclaimableEntry, + pub unused_natives: ReclaimableEntry, } #[tracing::instrument(skip(state))] @@ -97,9 +99,96 @@ pub async fn storage_report(state: &LauncherState) -> LauncherResult Option> { + let clusters = state.clusters.list().await.ok()?; + let metadata = state.metadata.lock().await; + + let mut in_use = HashSet::new(); + for cluster in clusters { + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + if cluster.mc_loader == oneclient_common::domain::GameLoader::Vanilla { + in_use.insert(mc_version); + continue; + } + + let resolved = oneclient_mc::get_loader_version_cached( + &metadata, + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ); + + match resolved { + Ok(Some(loader)) => { + in_use.insert(format!("{mc_version}-{}", loader.id)); + } + Ok(None) | Err(_) => { + tracing::info!( + cluster = %cluster.name, + loader = %cluster.mc_loader, + "cannot resolve a loader version from the cached manifest; \ + skipping the natives sweep" + ); + return None; + } + } + } + + Some(in_use) +} + +async fn unused_natives_dirs(state: &LauncherState) -> Vec { + if !state.games.active_ids().is_empty() { + return Vec::new(); + } + + let Ok(root) = paths::natives_dir() else { + return Vec::new(); + }; + let Some(in_use) = natives_in_use(state).await else { + return Vec::new(); + }; + + let mut unused = Vec::new(); + let Ok(mut entries) = polyio::read_dir(&root).await else { + return unused; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !in_use.contains(name) { + unused.push(path); + } + } + + unused +} + +async fn unused_natives(state: &LauncherState) -> ReclaimableEntry { + let dirs = unused_natives_dirs(state).await; + + let mut found = ReclaimableEntry::default(); + for dir in &dirs { + found.files += 1; + found.bytes += dir_size(dir).await; + } + found +} + /// Content is materialized from the cache now so anything in a cluster's own /// folder is an inert leftover from an older launcher space not correctness async fn legacy_cluster_content(state: &LauncherState) -> LauncherResult { @@ -220,6 +309,27 @@ pub async fn clean_unreferenced_cache(state: &LauncherState) -> LauncherResult LauncherResult { + if showing_fixture() { + tracing::info!("fixture storage report is active; skipping natives cleanup"); + return Ok(0); + } + + let dirs = unused_natives_dirs(state).await; + let mut removed = 0; + for dir in dirs { + match polyio::remove_dir_all(&dir).await { + Ok(()) => { + tracing::info!(path = %dir.display(), "removed unused natives"); + removed += 1; + } + Err(err) => tracing::warn!(path = %dir.display(), "could not remove natives: {err}"), + } + } + + Ok(removed) +} + pub async fn clean_legacy_cluster_content(state: &LauncherState) -> LauncherResult { if showing_fixture() { tracing::info!("fixture storage report is active; skipping leftover cleanup"); @@ -230,6 +340,12 @@ pub async fn clean_legacy_cluster_content(state: &LauncherState) -> LauncherResu Ok(report.removed) } +pub async fn clean_cluster_leftovers(state: &LauncherState) -> LauncherResult { + let content = clean_legacy_cluster_content(state).await?; + let natives = clean_unused_natives(state).await?; + Ok(content + natives) +} + /// Set `ONECLIENT_FAKE_STORAGE` to `empty` `clean` or `full` to return a fixture /// instead of scanning disk /// Read on every refresh so no rebuild is needed @@ -251,6 +367,7 @@ fn fixture_report() -> Option { clusters: Vec::new(), unreferenced_cache: ReclaimableEntry::default(), legacy_cluster_content: ReclaimableEntry::default(), + unused_natives: ReclaimableEntry::default(), }, "clean" => StorageReport { total_bytes: 1_284_000_000, @@ -271,6 +388,7 @@ fn fixture_report() -> Option { ], unreferenced_cache: ReclaimableEntry::default(), legacy_cluster_content: ReclaimableEntry::default(), + unused_natives: ReclaimableEntry::default(), }, "full" => StorageReport { total_bytes: 4_930_000_000, @@ -300,6 +418,10 @@ fn fixture_report() -> Option { bytes: 261_000_000, files: 439, }, + unused_natives: ReclaimableEntry { + bytes: 4_200_000, + files: 3, + }, }, other => { tracing::warn!( diff --git a/packages/oneclient_mc/src/error.rs b/packages/oneclient_mc/src/error.rs index 50ce8011..5c860db4 100644 --- a/packages/oneclient_mc/src/error.rs +++ b/packages/oneclient_mc/src/error.rs @@ -32,6 +32,12 @@ pub enum McError { #[error("no matching version found")] NoMatchingVersion, + #[error( + "{version} ships no native libraries for {platform}; this platform is not supported \ + by this version" + )] + NoNativesForPlatform { version: String, platform: String }, + #[error("cancelled: {failed} file(s) could not be downloaded")] IncompleteInstallCancelled { failed: usize }, diff --git a/packages/oneclient_mc/src/install.rs b/packages/oneclient_mc/src/install.rs index 83dec810..77924ce5 100644 --- a/packages/oneclient_mc/src/install.rs +++ b/packages/oneclient_mc/src/install.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use futures_util::{StreamExt, stream}; use interfrost::api::minecraft::{ - Asset, AssetsIndex, DownloadType, Library, Os, Version, VersionInfo, + Asset, AssetsIndex, DownloadType, Library, LibraryDownload, Os, Version, VersionInfo, }; use interfrost::api::modded::LoaderVersion; use reqwest::Method; @@ -24,13 +24,20 @@ const ASSET_DOWNLOAD_CONCURRENCY: usize = 32; /// Libraries are larger and fewer less fan-out is needed to saturate the link const LIBRARY_DOWNLOAD_CONCURRENCY: usize = 16; +#[derive(Debug, Clone)] +pub struct PlannedLibrary { + pub library: Library, + pub artifact: bool, + pub natives: bool, +} + /// Computed up-front so the pre-download size estimate and the progress-bar /// denominators come from the same numbers #[derive(Debug, Default)] pub struct DownloadPlan { pub assets: Vec<(String, Asset)>, pub asset_bytes: u64, - pub libraries: Vec, + pub libraries: Vec, pub library_bytes: u64, pub client: bool, pub client_bytes: u64, @@ -70,20 +77,72 @@ fn library_artifact_size(lib: &Library) -> u64 { .map_or(0, |artifact| u64::from(artifact.size)) } -fn native_download_size(lib: &Library, java_arch: &str) -> u64 { - let Some((os_key, classifiers)) = lib.natives.as_ref().and_then(|natives| { +fn native_download<'a>(lib: &'a Library, java_arch: &str) -> Option<&'a LibraryDownload> { + let (os_key, classifiers) = lib.natives.as_ref().and_then(|natives| { Some(( natives.get(&Os::native_arch(java_arch))?, lib.downloads.as_ref()?.classifiers.as_ref()?, )) - }) else { - return 0; + })?; + + classifiers.get(&os_key.replace("${arch}", oneclient_common::constants::ARCH_WIDTH)) +} + +fn native_download_size(lib: &Library, java_arch: &str) -> u64 { + native_download(lib, java_arch).map_or(0, |native| u64::from(native.size)) +} + +const NATIVES_RECEIPTS: &str = ".extracted"; + +fn natives_receipt(natives_dir: &Path, lib: &Library) -> PathBuf { + natives_dir + .join(NATIVES_RECEIPTS) + .join(polyio::sanitize_path(lib.name.replace(':', "_"))) +} + +fn natives_extracted(natives_dir: &Path, lib: &Library, java_arch: &str) -> bool { + let Some(native) = native_download(lib, java_arch) else { + return true; }; - let parsed = os_key.replace("${arch}", oneclient_common::constants::ARCH_WIDTH); - classifiers - .get(&parsed) - .map_or(0, |native| u64::from(native.size)) + match std::fs::read_to_string(natives_receipt(natives_dir, lib)) { + Ok(recorded) => native.sha1.is_empty() || recorded.trim() == native.sha1, + Err(_) => false, + } +} + +fn natives_classifier(lib: &Library) -> Option<&str> { + lib.name + .split(':') + .nth(3) + .filter(|classifier| classifier.starts_with("natives-")) +} + +/// Pre-LWJGL3 macOS natives jars ship `.jnilib` so a single suffix would read +/// an intact extraction as empty and repair it on every check +const NATIVE_SUFFIXES: &[&str] = if cfg!(windows) { + &[".dll"] +} else if cfg!(target_os = "macos") { + &[".dylib", ".jnilib"] +} else { + &[".so"] +}; + +fn is_native_file(name: &str) -> bool { + NATIVE_SUFFIXES.iter().any(|suffix| name.ends_with(suffix)) +} + +fn carries_natives(lib: &Library, java_arch: &str) -> bool { + native_download(lib, java_arch).is_some() || natives_classifier(lib).is_some() +} + +async fn record_natives(natives_dir: &Path, lib: &Library, sha1: &str) -> McResult<()> { + let path = natives_receipt(natives_dir, lib); + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await?; + } + polyio::write(&path, sha1.as_bytes()).await?; + Ok(()) } /// Stats thousands of files so it runs on the blocking pool and keeps the @@ -114,6 +173,7 @@ pub async fn plan_downloads( let libraries = version.libraries.clone(); let java_arch = java_arch.to_string(); + let natives_dir = paths::natives_dir()?.join(&version.id); tokio::task::spawn_blocking(move || { let mut plan = DownloadPlan::default(); @@ -140,19 +200,36 @@ pub async fn plan_downloads( // A library whose coordinates don't resolve is kept in the plan so the // download path still reports it as a failure rather than skipping it let Ok(artifact_path) = interfrost::utils::get_path_from_artifact(&lib.name) else { - plan.libraries.push(lib); + plan.libraries.push(PlannedLibrary { + library: lib, + artifact: true, + natives: true, + }); continue; }; - if !force - && matches_expected_size(&lib_dir.join(&artifact_path), library_artifact_size(&lib)) - { + let artifact = force + || !matches_expected_size( + &lib_dir.join(&artifact_path), + library_artifact_size(&lib), + ); + let natives = force || !natives_extracted(&natives_dir, &lib, &java_arch); + + if !artifact && !natives { continue; } - plan.library_bytes += library_artifact_size(&lib); - plan.library_bytes += native_download_size(&lib, &java_arch); - plan.libraries.push(lib); + if artifact { + plan.library_bytes += library_artifact_size(&lib); + } + if natives { + plan.library_bytes += native_download_size(&lib, &java_arch); + } + plan.libraries.push(PlannedLibrary { + library: lib, + artifact, + natives, + }); } if let Some((path, size)) = client { @@ -187,6 +264,156 @@ impl VerifyReport { } } +const ARCH_SEGMENTS: [&str; 4] = ["x64", "arm64", "x86", "arm32"]; + +fn arch_segment(java_arch: &str) -> &'static str { + match java_arch { + "aarch64" => "arm64", + "arm" => "arm32", + "x86" => "x86", + _ => "x64", + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct NativesReport { + pub repairable: Vec, + pub unsupported: Vec, +} + +impl NativesReport { + #[must_use] + pub fn is_empty(&self) -> bool { + self.repairable.is_empty() && self.unsupported.is_empty() + } +} + +enum JarVerdict { + Usable, + WrongArch, + Unreadable, +} + +async fn inspect_jar(path: &Path, java_arch: &str) -> JarVerdict { + let names = match polyio::zip_entry_names(path).await { + Ok(names) => names, + Err(err) => { + tracing::debug!(path = %path.display(), "could not read natives jar: {err}"); + return JarVerdict::Unreadable; + } + }; + + let natives: Vec<&String> = names + .iter() + .filter(|name| is_native_file(name)) + .collect(); + + if natives.is_empty() { + return JarVerdict::WrongArch; + } + + let wanted = arch_segment(java_arch); + if natives + .iter() + .any(|name| name.split('/').any(|part| part == wanted)) + { + return JarVerdict::Usable; + } + + if natives + .iter() + .all(|name| !name.split('/').any(|part| ARCH_SEGMENTS.contains(&part))) + { + return JarVerdict::Usable; + } + + JarVerdict::WrongArch +} + +fn natives_family(lib: &Library) -> String { + let parts: Vec<&str> = lib.name.split(':').collect(); + match parts.as_slice() { + [group, artifact, ..] => format!("{group}:{artifact}"), + _ => lib.name.clone(), + } +} + +async fn any_native_unpacked(natives_dir: &Path) -> bool { + let Ok(mut entries) = polyio::read_dir(natives_dir).await else { + return false; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + if is_native_file(&entry.file_name().to_string_lossy()) { + return true; + } + } + + false +} + +#[tracing::instrument(skip(version), fields(version_id = %version.id), level = "debug")] +pub async fn check_natives( + version: &VersionInfo, + java_arch: &str, + minecraft_updated: bool, +) -> McResult { + let natives_dir = paths::natives_dir()?.join(&version.id); + let lib_dir = paths::libraries_dir()?; + + let host_natives = natives_for_host(version, java_arch, minecraft_updated)?; + + // One scan for the whole sweep the directory is the same for every library + let unpacked = any_native_unpacked(&natives_dir).await; + + let mut families: Vec<(String, Vec)> = Vec::new(); + for lib in host_natives { + let verdict = if native_download(lib, java_arch).is_some() { + if unpacked && natives_extracted(&natives_dir, lib, java_arch) { + JarVerdict::Usable + } else { + JarVerdict::Unreadable + } + } else { + match interfrost::utils::get_path_from_artifact(&lib.name) { + Ok(rel) => inspect_jar(&lib_dir.join(&rel), java_arch).await, + Err(_) => continue, + } + }; + + let family = natives_family(lib); + match families.iter_mut().find(|(name, _)| name == &family) { + Some((_, verdicts)) => verdicts.push(verdict), + None => families.push((family, vec![verdict])), + } + } + + let mut report = NativesReport::default(); + for (family, verdicts) in families { + if verdicts.iter().any(|v| matches!(v, JarVerdict::Usable)) { + continue; + } + if verdicts.iter().any(|v| matches!(v, JarVerdict::Unreadable)) { + report.repairable.push(family); + } else { + report.unsupported.push(family); + } + } + + if !report.repairable.is_empty() { + tracing::warn!(libraries = ?report.repairable, "natives are missing or unreadable"); + } + if !report.unsupported.is_empty() { + tracing::error!( + libraries = ?report.unsupported, + platform = %format!("{}-{java_arch}", oneclient_common::constants::TARGET_OS), + "natives are installed but hold nothing loadable on this platform" + ); + } + + Ok(report) +} + /// Expensive reads every asset and library so it belongs behind an explicit /// user action not the launch path /// Nothing is re-downloaded here corrupt files are deleted so the next prepare @@ -254,7 +481,7 @@ pub async fn verify_game_files( // One blocking task for the whole sweep a spawn_blocking per small asset // would cost more than the hashing itself - let report = tokio::task::spawn_blocking(move || { + let mut report = tokio::task::spawn_blocking(move || { let mut report = VerifyReport::default(); for (index, (path, expected)) in targets.iter().enumerate() { @@ -303,10 +530,25 @@ pub async fn verify_game_files( child.finish(); + // A version that ships nothing for this platform is a fact about the version + // not a verification failure so the rest of the sweep still counts + let natives = match check_natives(version, java_arch, minecraft_updated).await { + Ok(natives) => natives, + Err(err @ McError::NoNativesForPlatform { .. }) => { + tracing::error!("{err}"); + NativesReport::default() + } + Err(err) => return Err(err), + }; + report.missing += natives.repairable.len(); + report.checked += natives.repairable.len() + natives.unsupported.len(); + tracing::info!( checked = report.checked, corrupt = report.corrupt, missing = report.missing, + natives_repairable = natives.repairable.len(), + natives_unsupported = natives.unsupported.len(), elapsed_ms = started.elapsed().as_millis() as u64, "verified game files" ); @@ -722,6 +964,86 @@ pub async fn download_client( Ok(path) } +fn natives_for_host<'a>( + version_info: &'a VersionInfo, + java_arch: &str, + minecraft_updated: bool, +) -> Result, McError> { + let mut selected = Vec::new(); + let mut any_declared = false; + + for lib in &version_info.libraries { + if natives_classifier(lib).is_some() || lib.natives.is_some() { + any_declared = true; + } + + if let Some(rules) = &lib.rules + && !validate_rules(rules, java_arch, minecraft_updated) + { + continue; + } + if !lib.downloadable { + continue; + } + if carries_natives(lib, java_arch) { + selected.push(lib); + } + } + + if any_declared && selected.is_empty() { + return Err(McError::NoNativesForPlatform { + version: version_info.id.clone(), + platform: format!("{}-{java_arch}", oneclient_common::constants::TARGET_OS), + }); + } + + Ok(selected) +} + +#[tracing::instrument(skip(version_info), level = "debug")] +pub fn natives_missing( + version_info: &VersionInfo, + java_arch: &str, + minecraft_updated: bool, +) -> McResult { + let host_natives = natives_for_host(version_info, java_arch, minecraft_updated)?; + let natives_dir = paths::natives_dir()?.join(&version_info.id); + + if !natives_dir.is_dir() { + tracing::warn!(path = %natives_dir.display(), "natives directory is missing; will repair"); + return Ok(true); + } + + let lib_dir = paths::libraries_dir()?; + + for lib in host_natives { + if native_download(lib, java_arch).is_some() { + if !natives_extracted(&natives_dir, lib, java_arch) { + tracing::warn!(library = %lib.name, "natives were never extracted; will repair"); + return Ok(true); + } + continue; + } + + if !lib.include_in_classpath { + tracing::error!( + library = %lib.name, + "a natives jar was dropped from the classpath; the game will not find it" + ); + } + + let Ok(rel) = interfrost::utils::get_path_from_artifact(&lib.name) else { + continue; + }; + if !matches_expected_size(&lib_dir.join(&rel), library_artifact_size(lib)) { + tracing::warn!(library = %lib.name, "missing or truncated natives jar; will repair"); + return Ok(true); + } + } + + Ok(false) +} + #[tracing::instrument(skip(version_info), level = "debug")] pub fn libraries_missing( version_info: &VersionInfo, @@ -746,7 +1068,8 @@ pub fn libraries_missing( return Ok(true); } } - Ok(false) + + natives_missing(version_info, java_arch, minecraft_updated) } /// Takes its paths rather than resolving them so it can be tested without the @@ -811,23 +1134,29 @@ pub async fn download_libraries( ctx: &McCtx, progress: &GroupedProgressSession, version: String, - libraries: Vec, + libraries: Vec, java_arch: &str, ) -> McResult { - if libraries.is_empty() { - return Ok(0); - } - let lib_dir = paths::libraries_dir()?; let natives_dest = paths::natives_dir()?.join(&version); let java_arch = java_arch.to_string(); - polyio::create_dir_all(&lib_dir).await?; polyio::create_dir_all(&natives_dest).await?; + if libraries.is_empty() { + return Ok(0); + } + + polyio::create_dir_all(&lib_dir).await?; + let started = std::time::Instant::now(); let count = libraries.len(); - let requests = stream::iter(libraries.into_iter().map(|lib| { + let requests = stream::iter(libraries.into_iter().map(|planned| { + let PlannedLibrary { + library: lib, + artifact: wants_artifact, + natives: wants_natives, + } = planned; let lib_dir = lib_dir.clone(); let natives_dest = natives_dest.clone(); let java_arch = java_arch.clone(); @@ -836,13 +1165,16 @@ pub async fn download_libraries( let progress = progress.clone(); async move { - // Rules and the on-disk check already ran in `plan_downloads` let artifact_path = interfrost::utils::get_path_from_artifact(&lib.name) .map_err(|_| McError::LibraryPath(lib.name.clone()))?; let path = lib_dir.join(&artifact_path); tokio::try_join!( async { + if !wants_artifact { + return Ok::<_, McError>(()); + } + if let Some(interfrost::api::minecraft::LibraryDownloads { artifact: Some(ref artifact), .. @@ -886,40 +1218,37 @@ pub async fn download_libraries( Ok(()) }, async { - if let Some((os_key, classifiers)) = lib.natives.as_ref().and_then(|natives| { - Some(( - natives.get(&Os::native_arch(&java_arch))?, - lib.downloads.as_ref()?.classifiers.as_ref()?, - )) - }) { - let parsed = os_key.replace("${arch}", oneclient_common::constants::ARCH_WIDTH); - if let Some(native) = classifiers.get(&parsed) { - let data = fetch_bytes_verified( - &requester, - &events, - &progress, - format!("Natives {}", lib_short(&lib.name)), - TaskCategory::Natives, - u64::from(native.size), - &native.url, - &native.sha1, - ) - .await?; + if !wants_natives { + return Ok(()); + } - let extract = progress.child( - format!("Natives {}", lib_short(&lib.name)), - 1, - TaskCategory::Natives, - ); - extract.set_phase(oneclient_events::TaskPhase::Extracting); - polyio::unzip_bytes_filtered( - data, - Some(|name: &str| !name.starts_with("META-INF")), - &natives_dest, - ) - .await?; - extract.finish(); - } + if let Some(native) = native_download(&lib, &java_arch) { + let data = fetch_bytes_verified( + &requester, + &events, + &progress, + format!("Natives {}", lib_short(&lib.name)), + TaskCategory::Natives, + u64::from(native.size), + &native.url, + &native.sha1, + ) + .await?; + + let extract = progress.child( + format!("Natives {}", lib_short(&lib.name)), + 1, + TaskCategory::Natives, + ); + extract.set_phase(oneclient_events::TaskPhase::Extracting); + polyio::unzip_bytes_filtered( + data, + Some(|name: &str| !name.starts_with("META-INF")), + &natives_dest, + ) + .await?; + record_natives(&natives_dest, &lib, &native.sha1).await?; + extract.finish(); } Ok(()) @@ -974,6 +1303,64 @@ pub async fn get_loader_versions( Ok(Vec::new()) } +fn resolve_loader_from_manifest( + manifest: &interfrost::api::modded::Manifest, + mc_version: &str, + loader_version: Option<&str>, +) -> (bool, Option) { + let mut saw_matching_game_version = false; + + for entry in &manifest.game_versions { + if entry + .id + .replace("${interpulse.gameVersion}", mc_version) + .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version) + != mc_version + { + continue; + } + + saw_matching_game_version = true; + + if let Some(requested) = loader_version { + if let Some(found) = entry + .loaders + .iter() + .find(|loader_entry| loader_entry.id == requested) + { + return (saw_matching_game_version, Some(found.clone())); + } + continue; + } + + if let Some(found) = entry + .loaders + .iter() + .find(|l| l.stable) + .or_else(|| entry.loaders.first()) + { + return (saw_matching_game_version, Some(found.clone())); + } + } + + (saw_matching_game_version, None) +} + +#[tracing::instrument(skip(metadata), level = "debug")] +pub fn get_loader_version_cached( + metadata: &MetadataStore, + mc_version: &str, + loader: GameLoader, + loader_version: Option<&str>, +) -> McResult> { + if loader == GameLoader::Vanilla { + return Ok(None); + } + + let manifest = metadata.get_modded(loader)?; + Ok(resolve_loader_from_manifest(manifest, mc_version, loader_version).1) +} + #[tracing::instrument(skip(metadata, ctx), level = "debug")] pub async fn get_loader_version( metadata: &mut MetadataStore, @@ -986,44 +1373,10 @@ pub async fn get_loader_version( return Ok(None); } - let resolve_from_manifest = |manifest: &interfrost::api::modded::Manifest| { - let mut saw_matching_game_version = false; - - for entry in &manifest.game_versions { - if entry - .id - .replace("${interpulse.gameVersion}", mc_version) - .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version) - != mc_version - { - continue; - } - - saw_matching_game_version = true; - - if let Some(requested) = loader_version { - if let Some(found) = entry - .loaders - .iter() - .find(|loader_entry| loader_entry.id == requested) - { - return (saw_matching_game_version, Some(found.clone())); - } - continue; - } - - if let Some(found) = entry - .loaders - .iter() - .find(|l| l.stable) - .or_else(|| entry.loaders.first()) - { - return (saw_matching_game_version, Some(found.clone())); - } - } - - (saw_matching_game_version, None) - }; + let resolve_from_manifest = + |manifest: &interfrost::api::modded::Manifest| { + resolve_loader_from_manifest(manifest, mc_version, loader_version) + }; let mut manifest = metadata.get_modded_or_fetch(ctx, loader).await?; let (mut saw_matching, mut resolved) = resolve_from_manifest(manifest); diff --git a/packages/polyio/src/archive.rs b/packages/polyio/src/archive.rs index e629e64a..fd716cca 100644 --- a/packages/polyio/src/archive.rs +++ b/packages/polyio/src/archive.rs @@ -286,6 +286,24 @@ pub async fn read_zip_file_entries( Ok(out) } +#[tracing::instrument( + level = "debug", + skip(zip_path), + fields(zip_path = %zip_path.as_ref().display()) +)] +pub async fn zip_entry_names( + zip_path: impl AsRef, +) -> PolyIOResult> { + let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path.as_ref()).await?; + + Ok(reader + .file() + .entries() + .iter() + .filter_map(|entry| entry.filename().as_str().ok().map(ToString::to_string)) + .collect()) +} + /// Returns a zip file entry's bytes without reading the entire file into memory #[tracing::instrument( level = "debug",