diff --git a/packages/oneclient_mc/src/install.rs b/packages/oneclient_mc/src/install.rs index 9540631d..1e427f6f 100644 --- a/packages/oneclient_mc/src/install.rs +++ b/packages/oneclient_mc/src/install.rs @@ -1,4 +1,6 @@ +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; +use std::sync::Mutex; use futures_util::{StreamExt, stream}; use interfrost::api::minecraft::{ @@ -700,6 +702,8 @@ pub async fn confirm_incomplete_install( } } +static VERSION_INFO_REFRESHED: Mutex> = Mutex::new(BTreeSet::new()); + #[tracing::instrument(skip(ctx, progress), level = "debug")] pub async fn download_version_info( ctx: &McCtx, @@ -713,22 +717,77 @@ pub async fn download_version_info( .map(|it| format!("{}-{}", version.id, it.id)) .unwrap_or_else(|| version.id.clone()); - let path = paths::versions_dir()? - .join(&version_id) - .join(version_info_file_name(&version_id, game_loader.get_format_version())); + let dir = paths::versions_dir()?.join(&version_id); + let format_version = game_loader.get_format_version(); + let stem = match loader { + Some(_) => format!("{version_id}.{}", game_loader.get_format_name()), + None => version_id.clone(), + }; + let path = dir.join(version_info_file_name(&stem, format_version)); - if path.exists() && !force { - match polyio::read_json::(&path).await { - Ok(cached) => return Ok(cached), - Err(err) => { - tracing::warn!( - path = %path.display(), - "cached version metadata is unusable, redownloading: {err}" - ); - } + let cached = if force { + None + } else { + let mut cached = read_cached_version_info(&path).await; + if cached.is_none() && loader.is_some() { + let legacy = version_info_file_name(&version_id, format_version); + cached = read_cached_version_info(&dir.join(legacy)) + .await + .filter(|info| profile_matches_loader(&info.libraries, game_loader)); } + cached + }; + + if let Some(cached) = cached { + if loader.is_some() + && oneclient_net::status::current().online + && VERSION_INFO_REFRESHED.lock().unwrap().insert(path.clone()) + { + let ctx = ctx.clone(); + let version = version.clone(); + let loader = loader.cloned(); + let installed = processor_inputs(&cached); + tokio::spawn(async move { + let refresh = async { + let info = + fetch_version_info(&ctx, None, &version, loader.as_ref(), &version_id) + .await?; + if processor_inputs(&info) != installed { + tracing::warn!( + version_id = %version_id, + "loader processors changed upstream, keeping cached profile until reinstall" + ); + return Ok(()); + } + polyio::write_json_atomic(&path, &info).await?; + McResult::Ok(()) + }; + if let Err(err) = refresh.await { + tracing::warn!( + version_id = %version_id, + "could not refresh version metadata, keeping cached copy: {err}" + ); + } + }); + } + return Ok(cached); } + let info = fetch_version_info(ctx, progress, version, loader, &version_id).await?; + polyio::write_json_atomic(&path, &info).await?; + if loader.is_some() { + VERSION_INFO_REFRESHED.lock().unwrap().insert(path); + } + Ok(info) +} + +async fn fetch_version_info( + ctx: &McCtx, + progress: Option<&GroupedProgressSession>, + version: &Version, + loader: Option<&LoaderVersion>, + version_id: &str, +) -> McResult { tracing::debug!( version_id = %version_id, "downloading Minecraft version metadata" @@ -806,9 +865,7 @@ pub async fn download_version_info( } } - info.id.clone_from(&version_id); - - polyio::write_json_atomic(&path, &info).await?; + info.id = version_id.to_owned(); Ok(info) } @@ -1541,11 +1598,60 @@ pub fn is_version_updated(version_index: usize, versions: &[Version]) -> bool { version_index <= versions.iter().position(|x| x.id == "22w16a").unwrap_or(0) } -fn version_info_file_name(version_id: &str, format_version: usize) -> String { +async fn read_cached_version_info(path: &Path) -> Option { + if !path.exists() { + return None; + } + + polyio::read_json::(path) + .await + .inspect_err(|err| { + tracing::warn!( + path = %path.display(), + "cached version metadata is unusable, redownloading: {err}" + ); + }) + .ok() +} + +fn processor_inputs(info: &VersionInfo) -> Option { + let processors = info.processors.as_deref().unwrap_or_default(); + let refs = processors + .iter() + .flat_map(|it| &it.args) + .chain(info.data.iter().flatten().flat_map(|(_, it)| [&it.client, &it.server])) + .filter_map(|it| it.strip_prefix('[')?.strip_suffix(']')); + let named: BTreeSet<&str> = processors + .iter() + .flat_map(|it| std::iter::once(&it.jar).chain(&it.classpath)) + .map(String::as_str) + .chain(refs) + .collect(); + let libraries: Vec<&Library> = info + .libraries + .iter() + .filter(|lib| named.contains(lib.name.as_str())) + .collect(); + serde_json::to_value((&info.processors, &info.data, libraries)).ok() +} + +fn profile_matches_loader(libraries: &[Library], loader: GameLoader) -> bool { + let has = |group: &str| libraries.iter().any(|lib| lib.name.starts_with(group)); + match loader { + GameLoader::Forge => !has("net.neoforged"), + GameLoader::NeoForge => has("net.neoforged"), + GameLoader::Fabric => !has("org.quiltmc") && !has("net.ornithemc"), + GameLoader::Quilt => has("org.quiltmc") && !has("net.ornithemc"), + GameLoader::Ornithe => has("net.ornithemc"), + GameLoader::Vanilla => true, + } +} + +fn version_info_file_name(stem: &str, format_version: usize) -> String { if format_version == 0 { - format!("{version_id}.json") + format!("{stem}.json") } else { - format!("{version_id}.v{format_version}.json") + format!("{stem}.v{format_version}.json") } } @@ -1556,7 +1662,78 @@ mod tests { #[test] fn a_format_bump_moves_the_cached_version_info() { assert_eq!(version_info_file_name("1.8.9", 0), "1.8.9.json"); - assert_eq!(version_info_file_name("1.8.9-0.19.5", 1), "1.8.9-0.19.5.v1.json"); + assert_eq!( + version_info_file_name("1.8.9-0.19.5", 1), + "1.8.9-0.19.5.v1.json" + ); + } + + #[test] + fn a_shared_legacy_profile_only_stands_in_for_its_own_loader() { + let libs = |names: &[&str]| -> Vec { + names + .iter() + .map(|name| serde_json::from_value(serde_json::json!({ "name": name })).unwrap()) + .collect() + }; + let forge = libs(&["net.minecraftforge:forge:1.20.1-47.1.106"]); + let neo = libs(&[ + "net.minecraftforge:eventbus:6.0.5", + "net.neoforged:forge:1.20.1-47.1.106", + ]); + let fabric = libs(&["net.fabricmc:fabric-loader:0.16.0"]); + let quilt = libs(&[ + "net.fabricmc:intermediary:1.20.1", + "org.quiltmc:quilt-loader:0.26.0", + ]); + + assert!(profile_matches_loader(&forge, GameLoader::Forge)); + assert!(!profile_matches_loader(&forge, GameLoader::NeoForge)); + assert!(profile_matches_loader(&neo, GameLoader::NeoForge)); + assert!(!profile_matches_loader(&neo, GameLoader::Forge)); + assert!(profile_matches_loader(&fabric, GameLoader::Fabric)); + assert!(!profile_matches_loader(&fabric, GameLoader::Quilt)); + assert!(profile_matches_loader(&quilt, GameLoader::Quilt)); + assert!(!profile_matches_loader(&quilt, GameLoader::Fabric)); + + let ornithe = libs(&[ + "net.fabricmc:fabric-loader:0.16.5", + "net.ornithemc:calamus-intermediary:1.14.4", + ]); + assert!(profile_matches_loader(&ornithe, GameLoader::Ornithe)); + assert!(!profile_matches_loader(&ornithe, GameLoader::Fabric)); + assert!(!profile_matches_loader(&fabric, GameLoader::Ornithe)); + } + + #[test] + fn a_changed_processor_library_counts_as_changed_input() { + let info = |installer: &str, lwjgl: &str| -> VersionInfo { + serde_json::from_value(serde_json::json!({ + "assetIndex": { "id": "1", "sha1": "", "size": 0, "totalSize": 0, "url": "" }, + "assets": "1", + "downloads": {}, + "id": "1.20.1-47.1.106", + "libraries": [ + { "name": "net.minecraftforge:installertools:1.3.0", "url": installer }, + { "name": "de.oceanlabs.mcp:mcp_config:1.20.1@zip", "url": installer }, + { "name": "org.lwjgl:lwjgl:3.3.1", "url": lwjgl }, + ], + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "minimumLauncherVersion": 0, + "releaseTime": "2023-06-12T00:00:00Z", + "time": "2023-06-12T00:00:00Z", + "type": "release", + "data": { "MCP": { "client": "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]", "server": "" } }, + "processors": [ + { "jar": "net.minecraftforge:installertools:1.3.0", "classpath": [], "args": ["{MCP}"] }, + ], + })) + .unwrap() + }; + + let installed = processor_inputs(&info("a", "a")); + assert_eq!(processor_inputs(&info("a", "b")), installed); + assert_ne!(processor_inputs(&info("b", "a")), installed); } fn scratch(tag: &str) -> PathBuf { diff --git a/packages/oneclient_mc/src/manifest.rs b/packages/oneclient_mc/src/manifest.rs index aabce86d..cebf3399 100644 --- a/packages/oneclient_mc/src/manifest.rs +++ b/packages/oneclient_mc/src/manifest.rs @@ -6,6 +6,7 @@ use interfrost::api::modded::Manifest as ModdedManifest; use reqwest::Method; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use tokio::sync::oneshot; use crate::McCtx; use crate::error::{McError, McResult}; @@ -17,9 +18,10 @@ pub struct MetadataStore { initialized: bool, inner: MetadataInner, version_loader_cache: HashMap>, + pending: Option>, } -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Serialize, Deserialize)] struct MetadataInner { minecraft: Option, forge: Option, @@ -42,9 +44,7 @@ impl MetadataStore { #[tracing::instrument(level = "debug", skip(self, ctx))] pub async fn get_vanilla_or_fetch(&mut self, ctx: &McCtx) -> McResult<&VanillaManifest> { - if !self.initialized() { - self.initialize(ctx).await?; - } + self.ensure_initialized(ctx).await?; if self.inner.minecraft.is_none() { self.refetch_errored(ctx).await; @@ -63,9 +63,7 @@ impl MetadataStore { return Err(McError::NotModdedManifest(loader)); } - if !self.initialized() { - self.initialize(ctx).await?; - } + self.ensure_initialized(ctx).await?; self.get_modded(loader) } @@ -96,16 +94,40 @@ impl MetadataStore { #[tracing::instrument(skip_all)] pub async fn initialize(&mut self, ctx: &McCtx) -> McResult<()> { let path = paths::caches_dir()?.join("metadata.json"); - let mut save_file = false; let mut metadata = Self::default(); match polyio::read_json::(&path).await { Ok(inner) => { metadata.inner = inner; - if metadata.refetch_errored(ctx).await > 0 { - save_file = true; + polyio::write_json_atomic(&path, &metadata.inner).await?; } + + let (tx, rx) = oneshot::channel(); + metadata.pending = Some(rx); + let ctx = ctx.clone(); + let mut saved = metadata.inner.clone(); + tokio::spawn(async move { + let mut status = oneclient_net::status::subscribe(); + let fresh = loop { + if status.wait_for(|status| status.online).await.is_err() { + return; + } + let mut fresh = MetadataInner::default(); + fresh.fetch_all(&ctx).await; + if fresh.has_any() { + break fresh; + } + if status.wait_for(|status| !status.online).await.is_err() { + return; + } + }; + saved.merge(fresh.clone()); + if let Err(err) = polyio::write_json_atomic(&path, &saved).await { + tracing::warn!("failed to save refreshed metadata manifest: {err}"); + } + let _ = tx.send(fresh); + }); } Err(err) => { if path.exists() { @@ -114,22 +136,37 @@ impl MetadataStore { "cached metadata manifest is unusable, refetching: {err}" ); } - - metadata.fetch_all(ctx).await; - save_file = true; + metadata.inner.fetch_all(ctx).await; + polyio::write_json_atomic(&path, &metadata.inner).await?; } } - if save_file { - polyio::write_json_atomic(&path, &metadata.inner).await?; - } - *self = metadata; self.initialized = true; Ok(()) } + async fn ensure_initialized(&mut self, ctx: &McCtx) -> McResult<()> { + if !self.initialized() { + self.initialize(ctx).await?; + } + + if let Some(pending) = &mut self.pending { + match pending.try_recv() { + Ok(fresh) => { + self.inner.merge(fresh); + self.version_loader_cache.clear(); + self.pending = None; + } + Err(oneshot::error::TryRecvError::Empty) => {} + Err(oneshot::error::TryRecvError::Closed) => self.pending = None, + } + } + + Ok(()) + } + #[tracing::instrument(level = "debug", skip_all)] pub async fn refetch_errored(&mut self, ctx: &McCtx) -> u8 { let mut changed: u8 = 0; @@ -170,21 +207,7 @@ impl MetadataStore { #[tracing::instrument(level = "debug", skip_all)] pub async fn fetch_all(&mut self, ctx: &McCtx) { - let (minecraft, forge, neo, fabric, quilt, ornithe) = tokio::join!( - fetch_vanilla_manifest(ctx), - fetch_modded_manifest(ctx, GameLoader::Forge), - fetch_modded_manifest(ctx, GameLoader::NeoForge), - fetch_modded_manifest(ctx, GameLoader::Fabric), - fetch_modded_manifest(ctx, GameLoader::Quilt), - fetch_modded_manifest(ctx, GameLoader::Ornithe), - ); - - keep_fetched(&mut self.inner.minecraft, minecraft); - keep_fetched(&mut self.inner.forge, forge); - keep_fetched(&mut self.inner.neo, neo); - keep_fetched(&mut self.inner.fabric, fabric); - keep_fetched(&mut self.inner.quilt, quilt); - keep_fetched(&mut self.inner.ornithe, ornithe); + self.inner.fetch_all(ctx).await; } #[tracing::instrument(level = "debug", skip(self, ctx))] @@ -193,9 +216,7 @@ impl MetadataStore { ctx: &McCtx, mc_version: &str, ) -> McResult> { - if !self.initialized() { - self.initialize(ctx).await?; - } + self.ensure_initialized(ctx).await?; if let Some(hit) = self.version_loader_cache.get(mc_version) { return Ok(hit.clone()); @@ -239,9 +260,7 @@ impl MetadataStore { ctx: &McCtx, loader: GameLoader, ) -> McResult>> { - if !self.initialized() { - self.initialize(ctx).await?; - } + self.ensure_initialized(ctx).await?; if loader == GameLoader::Vanilla { return Ok(None); @@ -255,6 +274,52 @@ impl MetadataStore { } } +impl MetadataInner { + async fn fetch_all(&mut self, ctx: &McCtx) { + let (minecraft, forge, neo, fabric, quilt, ornithe) = tokio::join!( + fetch_vanilla_manifest(ctx), + fetch_modded_manifest(ctx, GameLoader::Forge), + fetch_modded_manifest(ctx, GameLoader::NeoForge), + fetch_modded_manifest(ctx, GameLoader::Fabric), + fetch_modded_manifest(ctx, GameLoader::Quilt), + fetch_modded_manifest(ctx, GameLoader::Ornithe), + ); + + keep_fetched(&mut self.minecraft, minecraft); + keep_fetched(&mut self.forge, forge); + keep_fetched(&mut self.neo, neo); + keep_fetched(&mut self.fabric, fabric); + keep_fetched(&mut self.quilt, quilt); + keep_fetched(&mut self.ornithe, ornithe); + } + + const fn has_any(&self) -> bool { + self.minecraft.is_some() + || self.forge.is_some() + || self.neo.is_some() + || self.fabric.is_some() + || self.quilt.is_some() + || self.ornithe.is_some() + } + + fn merge(&mut self, fresh: Self) { + let Self { + minecraft, + forge, + neo, + fabric, + quilt, + ornithe, + } = fresh; + self.minecraft = minecraft.or(self.minecraft.take()); + self.forge = forge.or(self.forge.take()); + self.neo = neo.or(self.neo.take()); + self.fabric = fabric.or(self.fabric.take()); + self.quilt = quilt.or(self.quilt.take()); + self.ornithe = ornithe.or(self.ornithe.take()); + } +} + const LEGACY_DUMMY_REPLACE_STRING: &str = "${interpulse.gameVersion}"; #[must_use] @@ -355,7 +420,7 @@ async fn fetch_manifest(ctx: &McCtx, loader: GameLoader) -> #[cfg(test)] mod tests { - use super::{ModdedManifest, concrete_version_ids, manifest_supports_version}; + use super::{MetadataInner, ModdedManifest, concrete_version_ids, manifest_supports_version}; fn manifest(raw: &str) -> ModdedManifest { serde_json::from_str(raw).unwrap() @@ -401,4 +466,23 @@ mod tests { Some(vec!["1.21.1".to_string(), "1.14".to_string()]) ); } + + #[test] + fn a_failed_background_fetch_keeps_what_the_store_already_has() { + let mut store = MetadataInner { + forge: Some(fabric_shaped()), + fabric: Some(fabric_shaped()), + ..Default::default() + }; + let fresh = MetadataInner { + fabric: Some(manifest(r#"{"gameVersions": []}"#)), + ..Default::default() + }; + + store.merge(fresh); + + assert_eq!(store.forge.unwrap().game_versions.len(), 3); + assert!(store.fabric.unwrap().game_versions.is_empty()); + assert!(store.neo.is_none()); + } }