diff --git a/packages/oneclient_content/src/packages/dependencies.rs b/packages/oneclient_content/src/packages/dependencies.rs index 64644499..27f30b3a 100644 --- a/packages/oneclient_content/src/packages/dependencies.rs +++ b/packages/oneclient_content/src/packages/dependencies.rs @@ -156,7 +156,10 @@ async fn resolve_one( /// Picks the newest release of `project_id` that fits the cluster, falling back /// to the newest prerelease when that's all there is. -async fn pick_version( +/// +/// Shared with the browser update check, which asks the same question of an +/// already-installed package: "what would we install for this cluster today?". +pub(crate) async fn pick_version( provider: &dyn PackageProvider, project_id: &str, cluster: &ClusterRow, diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index 06a7a2bd..e33fd556 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -5,6 +5,7 @@ pub mod modpack; pub mod provider; pub mod store; pub mod types; +pub mod updates; mod file_identity; @@ -23,3 +24,8 @@ pub use error::{PackageError, PackageResult}; pub use provider::{PackageProvider, PackageProviderRegistry}; pub use store::PackageStore; pub use types::*; +pub use updates::{ + BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update, + cached_browser_package_updates, check_browser_package_updates, + refresh_browser_package_updates, skip_browser_package_update, +}; diff --git a/packages/oneclient_content/src/packages/updates.rs b/packages/oneclient_content/src/packages/updates.rs new file mode 100644 index 00000000..f7f0298a --- /dev/null +++ b/packages/oneclient_content/src/packages/updates.rs @@ -0,0 +1,531 @@ +//! Update checking for packages the user installed from the browser. +//! +//! "Browser installed" means a remote provider release that no bundle claims. +//! Bundles have their own update flow in [`crate::bundles::updates`] and own the +//! files they ship; touching one from here would have the two fight over the +//! same artifact, so anything with a bundle tracking row is skipped outright. +//! +//! The check answers the same question the installer asks — "what would we +//! install for this cluster today?" — by reusing +//! [`crate::packages::dependencies::pick_version`], and calls the package stale +//! when the answer is not the version that is already there. +//! +//! Results are cached in `browser_package_updates` so the package manager can +//! mark a row out of date without a round trip, and still knows after a +//! restart. The cache is only ever *narrowed* by a check that actually reached +//! the provider: a package whose lookup failed keeps whatever the last +//! successful run recorded, which is what makes an offline launch a no-op +//! rather than a mass "everything is up to date". + +use std::collections::HashSet; + +use futures_util::StreamExt; + +use oneclient_common::domain::ProviderId; +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::browser_package_update as update_dao; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::models::{BrowserPackageUpdateRow, ClusterRow}; +use oneclient_events::GroupedProgressChild; + +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; +use crate::packages::dependencies::pick_version; +use crate::packages::store::{PackageStore, evict_if_unused, try_unlink_materialized}; +use crate::packages::types::LinkedArtifactInfo; + +/// How many projects to ask about at once. Matches the bundle installer's +/// fan-out; the providers are the bottleneck, not us. +const UPDATE_CHECK_CONCURRENCY: usize = 6; + +/// A browser-installed package with a newer version available. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserPackageUpdate { + pub cluster_id: i64, + /// The artifact currently linked into the cluster. + pub hash: String, + pub provider: ProviderId, + pub project_id: String, + pub installed_version_id: String, + pub installed_version_name: String, + pub latest_version_id: String, + pub latest_version_name: String, + /// Best display name known locally, for a UI that has no meta cache entry. + pub display_name: String, + /// The user has already declined this exact newer version. + pub skipped: bool, +} + +impl BrowserPackageUpdate { + fn from_row(row: BrowserPackageUpdateRow) -> Self { + Self { + cluster_id: row.cluster_id, + hash: row.hash, + provider: ProviderId::from_repr(row.provider as u8).unwrap_or(ProviderId::Modrinth), + project_id: row.project_id, + installed_version_id: row.installed_version_id, + installed_version_name: row.installed_version_name, + latest_version_id: row.latest_version_id, + latest_version_name: row.latest_version_name, + display_name: row.display_name, + skipped: row.skipped != 0, + } + } +} + +/// One pass of the check over a cluster. +#[derive(Debug, Clone, Default)] +pub struct BrowserUpdateCheck { + pub cluster_id: i64, + pub updates: Vec, + /// Artifacts the providers could not be asked about this run. Their cached + /// rows are preserved rather than treated as "no longer stale". + pub unchecked: Vec, +} + +impl BrowserUpdateCheck { + /// Updates the user has not already declined — what the modal shows. + #[must_use] + pub fn pending(&self) -> Vec { + self.updates.iter().filter(|u| !u.skipped).cloned().collect() + } +} + +/// A browser-installed package, i.e. one candidate for this check. +struct Candidate { + hash: String, + provider: ProviderId, + project_id: String, + version_id: String, + display_name: String, + display_version: String, +} + +/// Every artifact in the cluster that came from a remote provider and that no +/// bundle claims. +/// +/// A missing bundle tracking row is the data-layer marker for "the user added +/// this themselves"; the package manager's `External` tab is the same rule +/// spelled out in the UI. +fn browser_installed( + linked: &[LinkedArtifactInfo], + bundle_hashes: &HashSet, +) -> Vec { + linked + .iter() + .filter(|info| !bundle_hashes.contains(&info.hash)) + .filter_map(|info| { + let provider = info.provider?; + if provider == ProviderId::Local { + return None; + } + Some(Candidate { + hash: info.hash.clone(), + provider, + project_id: info.project_id.clone()?, + version_id: info.version_id.clone()?, + display_name: info + .display_name + .clone() + .unwrap_or_else(|| info.file_name.clone()), + display_version: info.display_version.clone().unwrap_or_default(), + }) + }) + .collect() +} + +/// Asks every provider what the newest compatible version of each +/// browser-installed package is. +/// +/// Never fails because one project could not be reached: that project lands in +/// [`BrowserUpdateCheck::unchecked`] and the run continues. The whole call only +/// errors when the cluster itself cannot be read. +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn check_browser_package_updates( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + + let bundle_hashes: HashSet = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id) + .await? + .into_iter() + .map(|row| row.hash) + .collect(); + + let candidates = browser_installed(&linked, &bundle_hashes); + if candidates.is_empty() { + return Ok(BrowserUpdateCheck { + cluster_id, + ..Default::default() + }); + } + + let skipped: HashSet = update_dao::list_for_cluster(&ctx.db, cluster_id) + .await? + .into_iter() + .filter(|row| row.skipped != 0) + .map(|row| format!("{}:{}", row.hash, row.latest_version_id)) + .collect(); + + let results = futures_util::stream::iter(candidates.into_iter().map(|candidate| { + let cluster = &cluster; + async move { + let outcome = latest_for(&candidate, cluster, ctx).await; + (candidate, outcome) + } + })) + .buffer_unordered(UPDATE_CHECK_CONCURRENCY) + .collect::>() + .await; + + let mut check = BrowserUpdateCheck { + cluster_id, + ..Default::default() + }; + + for (candidate, outcome) in results { + match outcome { + Ok(Some((latest_version_id, latest_version_name))) => { + let key = format!("{}:{latest_version_id}", candidate.hash); + check.updates.push(BrowserPackageUpdate { + cluster_id, + hash: candidate.hash, + provider: candidate.provider, + project_id: candidate.project_id, + installed_version_id: candidate.version_id, + installed_version_name: candidate.display_version, + latest_version_id, + latest_version_name, + display_name: candidate.display_name, + skipped: skipped.contains(&key), + }); + } + // Up to date, or the provider has nothing that fits this cluster any + // more. Either way there is nothing to offer. + Ok(None) => {} + Err(err) => { + // Offline is the common case and says nothing about the + // launcher, so it stays at debug; anything else is worth seeing. + if err.is_transient() { + tracing::debug!( + project_id = %candidate.project_id, + error = %err, + "update check skipped, provider unreachable" + ); + } else { + tracing::warn!( + project_id = %candidate.project_id, + error = %err, + "update check failed for package" + ); + } + check.unchecked.push(candidate.hash); + } + } + } + + Ok(check) +} + +/// The newest compatible version id and name, or `None` when the installed one +/// is already it. +async fn latest_for( + candidate: &Candidate, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ContentResult> { + let provider = ctx.providers.get(candidate.provider)?; + let Some(latest) = pick_version(provider, &candidate.project_id, cluster, ctx).await? else { + return Ok(None); + }; + + if latest.version_id == candidate.version_id { + return Ok(None); + } + + let name = if latest.version_number.is_empty() { + latest.name + } else { + latest.version_number + }; + + Ok(Some((latest.version_id, name))) +} + +/// Runs the check and writes what it found into the cache table. +/// +/// Rows the check did not report are dropped, so a package that was updated or +/// removed stops being marked stale — except for the ones it could not reach, +/// which keep their last known state. +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn refresh_browser_package_updates( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult { + let check = check_browser_package_updates(cluster_id, ctx).await?; + + let mut keep: Vec = check.unchecked.clone(); + let mut stored = Vec::with_capacity(check.updates.len()); + + for update in &check.updates { + let row = update_dao::upsert( + &ctx.db, + cluster_id, + &update.hash, + update.provider as i64, + &update.project_id, + &update.installed_version_id, + &update.installed_version_name, + &update.latest_version_id, + &update.latest_version_name, + &update.display_name, + ) + .await?; + keep.push(row.hash.clone()); + // The row is the authority on `skipped`: it knows whether the user has + // already answered for this exact version. + stored.push(BrowserPackageUpdate::from_row(row)); + } + + update_dao::retain_hashes(&ctx.db, cluster_id, &keep).await?; + + Ok(BrowserUpdateCheck { + cluster_id, + updates: stored, + unchecked: check.unchecked, + }) +} + +/// What the last check recorded for one cluster. Never touches the network. +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn cached_browser_package_updates( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + Ok(update_dao::list_for_cluster(&ctx.db, cluster_id) + .await? + .into_iter() + .map(BrowserPackageUpdate::from_row) + .collect()) +} + +/// Records that the user does not want this particular newer version. +/// +/// The row stays: the package is still out of date and the package manager +/// still says so. Only the modal stops asking, and only until a version newer +/// than the declined one appears. +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn skip_browser_package_update( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + update_dao::set_skipped(&ctx.db, cluster_id, hash, true).await?; + Ok(()) +} + +/// Installs the newer version and drops the one it supersedes. +/// +/// Download first, unlink second: a failed download leaves the cluster exactly +/// as it was rather than with nothing installed. +#[tracing::instrument(level = "debug", skip(update, child, ctx), fields(cluster_id = update.cluster_id, project_id = %update.project_id))] +pub async fn apply_browser_package_update( + update: &BrowserPackageUpdate, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + // Refuse to touch anything a bundle owns, however this was called. The + // check already filters these out; this is the invariant, not a retry. + if bundle_dao::get_bundle_tracked(&ctx.db, update.cluster_id, &update.hash) + .await? + .is_some() + { + return Err(ContentError::InvalidData { + reason: format!( + "{} is provided by a bundle and is not updated by the browser flow", + update.display_name + ), + }); + } + + let provider = ctx.providers.get(update.provider)?; + let project = provider.get_project(&update.project_id, ctx).await?; + let version = provider + .get_version(&update.project_id, &update.latest_version_id, ctx) + .await?; + + // The check already established this version fits the cluster, and a + // provider that disagrees at install time would strand the user on a build + // they cannot move off. Compatibility is not re-litigated here. + let installed = PackageStore::install_to_cluster( + update.provider, + &project, + &version, + update.cluster_id, + true, + false, + child, + ctx, + ) + .await?; + + let enabled_before = artifact_dao::get_cluster_artifact(&ctx.db, update.cluster_id, &update.hash) + .await? + .map(|link| link.enabled != 0); + + // Nothing moved: the provider handed back the file that is already linked. + // Leave the link, and its enabled flag, exactly as they were. + if installed.hash == update.hash { + update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; + return Ok(installed.hash); + } + + unlink_superseded(update.cluster_id, &update.hash, ctx).await?; + + // A package the user had switched off stays off across an update; being out + // of date is not a reason to turn it back on. The fresh link starts enabled, + // so this is a toggle rather than a set. + if enabled_before == Some(false) { + PackageStore::set_artifact_enabled(update.cluster_id, &installed.hash, ctx).await?; + } + + update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; + + Ok(installed.hash) +} + +/// Drops the artifact an applied update replaced. +/// +/// Deliberately not `bundles::remove_artifact_from_cluster`: that one also +/// reconciles bundle overrides, which cannot apply to a package no bundle +/// provides, and `packages` must not depend on `bundles`. +#[tracing::instrument(level = "debug", skip(ctx))] +async fn unlink_superseded(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let content_type = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| { + oneclient_common::domain::ContentType::from_repr(artifact.content_type as u8) + }); + let link = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, hash).await?; + + artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + + if let (Some(content_type), Some(link)) = (content_type, link) { + try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; + } + + if let Err(err) = evict_if_unused(hash, ctx).await { + tracing::warn!(hash, error = %err, "failed to evict the superseded artifact"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use oneclient_common::domain::ContentType; + + fn linked( + hash: &str, + provider: Option, + project_id: Option<&str>, + version_id: Option<&str>, + ) -> LinkedArtifactInfo { + LinkedArtifactInfo { + hash: hash.into(), + cluster_file_name: format!("{hash}.jar"), + enabled: true, + content_type: ContentType::Mod, + file_name: format!("{hash}.jar"), + project_id: project_id.map(Into::into), + version_id: version_id.map(Into::into), + display_name: None, + display_version: None, + provider, + } + } + + #[test] + fn bundle_provided_packages_are_never_candidates() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + Some("v1"), + )]; + let bundles: HashSet = ["a".to_string()].into_iter().collect(); + + assert!( + browser_installed(&linked, &bundles).is_empty(), + "a bundle-tracked artifact must stay out of the browser update flow" + ); + } + + #[test] + fn browser_installed_remote_packages_are_candidates() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + Some("v1"), + )]; + + let found = browser_installed(&linked, &HashSet::new()); + assert_eq!(found.len(), 1); + assert_eq!(found[0].project_id, "sodium"); + assert_eq!(found[0].version_id, "v1"); + } + + #[test] + fn local_imports_are_not_candidates() { + let linked = vec![ + linked("a", Some(ProviderId::Local), Some("x"), Some("v1")), + linked("b", None, None, None), + ]; + + assert!( + browser_installed(&linked, &HashSet::new()).is_empty(), + "a file with no provider release has no version to compare" + ); + } + + #[test] + fn a_remote_package_with_no_recorded_version_is_not_a_candidate() { + let linked = vec![linked("a", Some(ProviderId::Modrinth), Some("sodium"), None)]; + + assert!( + browser_installed(&linked, &HashSet::new()).is_empty(), + "without an installed version id there is nothing to compare against" + ); + } + + #[test] + fn pending_hides_versions_the_user_declined() { + let make = |hash: &str, skipped: bool| BrowserPackageUpdate { + cluster_id: 1, + hash: hash.into(), + provider: ProviderId::Modrinth, + project_id: "sodium".into(), + installed_version_id: "v1".into(), + installed_version_name: "1.0".into(), + latest_version_id: "v2".into(), + latest_version_name: "2.0".into(), + display_name: "Sodium".into(), + skipped, + }; + + let check = BrowserUpdateCheck { + cluster_id: 1, + updates: vec![make("a", true), make("b", false)], + unchecked: Vec::new(), + }; + + let pending = check.pending(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].hash, "b"); + } +} diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 8e2bdbca..223a2376 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -54,6 +54,11 @@ pub use oneclient_cluster::screenshots::{ ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot, }; pub use oneclient_content::packages::LinkedArtifactInfo; +pub use oneclient_content::packages::updates::{ + BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update, + cached_browser_package_updates, check_browser_package_updates, + refresh_browser_package_updates, skip_browser_package_update, +}; pub use settings::ProfileUpdate; pub use state::LauncherServices; pub use state::LauncherState;