fix: verify and repair missing natives (#797)
This commit is contained in:
parent
612cd9e2a8
commit
0217a54518
9 changed files with 654 additions and 114 deletions
|
|
@ -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(|_| ())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<i64> {
|
||||
self.inner.lock().unwrap().keys().copied().collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StorageEntry>,
|
||||
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<StorageRepo
|
|||
clusters,
|
||||
unreferenced_cache,
|
||||
legacy_cluster_content: legacy_cluster_content(state).await?,
|
||||
unused_natives: unused_natives(state).await,
|
||||
})
|
||||
}
|
||||
|
||||
async fn natives_in_use(state: &LauncherState) -> Option<HashSet<String>> {
|
||||
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<PathBuf> {
|
||||
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<ReclaimableEntry> {
|
||||
|
|
@ -220,6 +309,27 @@ pub async fn clean_unreferenced_cache(state: &LauncherState) -> LauncherResult<u
|
|||
Ok(report.bytes_freed)
|
||||
}
|
||||
|
||||
pub async fn clean_unused_natives(state: &LauncherState) -> LauncherResult<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<StorageReport> {
|
|||
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<StorageReport> {
|
|||
],
|
||||
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<StorageReport> {
|
|||
bytes: 261_000_000,
|
||||
files: 439,
|
||||
},
|
||||
unused_natives: ReclaimableEntry {
|
||||
bytes: 4_200_000,
|
||||
files: 3,
|
||||
},
|
||||
},
|
||||
other => {
|
||||
tracing::warn!(
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Library>,
|
||||
pub libraries: Vec<PlannedLibrary>,
|
||||
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<String>,
|
||||
pub unsupported: Vec<String>,
|
||||
}
|
||||
|
||||
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<NativesReport> {
|
||||
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<JarVerdict>)> = 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<Vec<&'a Library>, 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<bool> {
|
||||
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<Library>,
|
||||
libraries: Vec<PlannedLibrary>,
|
||||
java_arch: &str,
|
||||
) -> McResult<usize> {
|
||||
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<LoaderVersion>) {
|
||||
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<Option<LoaderVersion>> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<std::path::Path>,
|
||||
) -> PolyIOResult<Vec<String>> {
|
||||
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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue