From 90fb3548579784ce08b4ef36ba6422c496ae01db Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:01:53 +0200 Subject: [PATCH] feat: resource packs and shaders quiet reload (#752) --- packages/oneclient_app/src/hooks/actions.rs | 23 +- .../src/hooks/queries/mutations.rs | 32 +-- packages/oneclient_app/src/install.rs | 25 +- .../view/app/cluster/package_manager/mod.rs | 3 +- .../view/app/cluster/package_manager/views.rs | 19 +- packages/oneclient_common/src/domain.rs | 5 + .../oneclient_content/src/bundles/install.rs | 10 +- .../oneclient_content/src/packages/mod.rs | 2 +- .../src/packages/store/link.rs | 254 +++++++++++++++++- .../src/packages/store/manifest.rs | 8 +- .../src/packages/store/mod.rs | 77 ++++-- .../oneclient_content/src/packages/updates.rs | 2 +- .../src/clusters/unlink_legacy.rs | 2 + .../oneclient_core/src/game/shared_dir.rs | 30 ++- 14 files changed, 410 insertions(+), 82 deletions(-) diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 4399c2ee..995ae80e 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -715,10 +715,24 @@ impl Actions { .await { Ok(row) => { - events - .notify("Imported") - .body(format!("Added {}", row.file_name)) - .send(); + let live = oneclient_content::packages::PackageStore::sync_live_content( + cluster_id, + &row, + &state.services.content(), + ) + .await + .unwrap_or(oneclient_content::packages::LiveSync::Skipped); + + let mut body = format!("Added {}", row.file_name); + if live == oneclient_content::packages::LiveSync::Deferred + && state.games.is_active(cluster_id) + { + body.push_str( + ". Minecraft is running, so it will be there at the next launch", + ); + } + + events.notify("Imported").body(body).send(); super::invalidate_cluster_queries().await; } Err(err) => events @@ -779,6 +793,7 @@ impl Actions { name, &install.dependencies, &install.missing_dependencies, + install.live_deferred, ), level: Level::Info, icon: Some(IconType::Download01), diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index 53748ec2..7c40b207 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -1,7 +1,5 @@ -use std::path::PathBuf; - use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; -use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_content::packages::LiveSync; use oneclient_db::models::ClusterId; use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery}; @@ -78,11 +76,6 @@ pub enum ClusterAction { /// writes `Enabled` / `Disabled` manifest_default: bool, }, - ImportLocalFile { - cluster_id: ClusterId, - content_type: ContentType, - path: PathBuf, - }, SetDedicatedDir { cluster_id: ClusterId, dedicated: bool, @@ -104,11 +97,17 @@ impl MutationCapability for ClusterMutation { let content = &state.services.content(); let result = match keys { ClusterAction::ToggleArtifact { cluster_id, hash } => { - // Applied to the game folder at next launch never mid-session - // Minecraft reads its mods once at startup oneclient_core::toggle_artifact_enabled(*cluster_id, hash, content) .await - .map(|_| ()) + .map(|(_, live)| { + if live == LiveSync::Deferred && state.games.is_active(*cluster_id) { + services + .events + .notify("Saved for the next launch") + .body("Minecraft is running, but this could not be added to the open game.") + .send(); + } + }) } ClusterAction::RemoveArtifact { cluster_id, hash } => { oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await @@ -133,17 +132,6 @@ impl MutationCapability for ClusterMutation { ) .await } - ClusterAction::ImportLocalFile { - cluster_id, - content_type, - path, - } => PackageStore::import_local_file(path, *content_type, *cluster_id, content) - .await - .map(|row| { - services - .events - .notify("Imported").body(format!("Added {}", row.file_name)).send(); - }), ClusterAction::SetDedicatedDir { cluster_id, dedicated, diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index 16310ea5..47306749 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -180,9 +180,17 @@ pub struct PackageInstall { pub dependencies: Vec, /// Unresolved or failed dependencies the package still installs pub missing_dependencies: Vec, + /// The game is open but could not take the package, so the toast must not + /// claim it is there now + pub live_deferred: bool, } -pub fn install_body(name: &str, dependencies: &[String], missing: &[String]) -> String { +pub fn install_body( + name: &str, + dependencies: &[String], + missing: &[String], + live_deferred: bool, +) -> String { let mut body = format!("Added {name}"); if !dependencies.is_empty() { @@ -198,6 +206,10 @@ pub fn install_body(name: &str, dependencies: &[String], missing: &[String]) -> body.push_str(&format!(" Could not add: {}.", missing.join(", "))); } + if live_deferred { + body.push_str(" Minecraft is running, so it will be there at the next launch."); + } + body } @@ -208,6 +220,7 @@ impl PackageInstall { result: Err(err), dependencies: Vec::new(), missing_dependencies: Vec::new(), + live_deferred: false, } } } @@ -301,7 +314,7 @@ pub async fn install_package( child.finish(); match result { - Ok(artifact) => { + Ok((artifact, _)) => { mark_new(cluster_id, &artifact.hash, state).await; installed_dependencies.push(dependency.project.name.clone()); } @@ -336,7 +349,12 @@ pub async fn install_package( child.finish(); - if let Ok(artifact) = &result { + let live_deferred = matches!( + result, + Ok((_, oneclient_content::packages::LiveSync::Deferred)) + ) && state.games.is_active(cluster_id); + + if let Ok((artifact, _)) = &result { mark_new(cluster_id, &artifact.hash, state).await; } @@ -345,6 +363,7 @@ pub async fn install_package( result: result.map(|_| project.name).map_err(anyhow::Error::from), dependencies: installed_dependencies, missing_dependencies, + live_deferred, } } diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 17dd153a..d1481c16 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -399,7 +399,6 @@ impl Component for PackageManager { }); }); - // Minecraft reads its content once at startup so a toggle now cannot reach the running session let session_live = use_game_snapshot().is_active(cluster_id); let active = use_state(|| 0usize); @@ -466,7 +465,7 @@ impl Component for PackageManager { cluster_id, package_type, )) - .maybe_child(session_live.then(|| views::running_notice(noun_plural))) + .maybe_child(session_live.then(|| views::running_notice(noun_plural, content_type))) .child(ContentBox::new( filtered, noun_plural, diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 00eaa42b..1f6e2535 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -218,8 +218,19 @@ pub(super) fn toolbar_bar( .into_element() } -/// Enabling still stores and applies at the next launch but the running session cannot pick it up -pub(super) fn running_notice(noun_plural: &'static str) -> Element { +pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentType) -> Element { + let text = match content_type { + ContentType::ResourcePack => format!( + "Minecraft is running. New {noun_plural} usually go in right away, open Options → Resource Packs in game to turn them on. OneClient tells you when one has to wait for the next launch." + ), + ContentType::Shader => format!( + "Minecraft is running. New {noun_plural} usually go in right away, open the shader pack screen in game to turn them on. OneClient tells you when one has to wait for the next launch." + ), + _ => format!( + "Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version." + ), + }; + rect() .horizontal() .width(Size::fill()) @@ -238,9 +249,7 @@ pub(super) fn running_notice(noun_plural: &'static str) -> Element { ) .child( label() - .text(format!( - "Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version." - )) + .text(text) .font_size(12.) .width(Size::flex(1.0)) .color(colors::fg_secondary()), diff --git a/packages/oneclient_common/src/domain.rs b/packages/oneclient_common/src/domain.rs index 045fe085..bf25db06 100644 --- a/packages/oneclient_common/src/domain.rs +++ b/packages/oneclient_common/src/domain.rs @@ -56,6 +56,11 @@ impl ContentType { } } + #[must_use] + pub const fn reloads_in_game(self) -> bool { + matches!(self, Self::ResourcePack | Self::Shader) + } + pub fn from_folder_name(name: &str) -> Option { match name.to_lowercase().as_str() { "mods" | "mod" => Some(Self::Mod), diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index 8fac85da..dea46745 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -12,7 +12,7 @@ use crate::bundles::overrides; use crate::bundles::types::{BundleArchive, BundleFile, BundleFileKind}; use oneclient_events::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; use oneclient_common::domain::{ContentType, GameLoader}; -use crate::packages::store::{PackageStore, evict_if_unused, try_unlink_materialized}; +use crate::packages::store::{LiveSync, PackageStore, evict_if_unused, try_unlink_materialized}; use crate::packages::types::ExternalFile; use crate::ctx::ContentCtx; @@ -109,7 +109,7 @@ pub async fn install_package_from_bundle( .await? }; - let artifact = PackageStore::install_to_cluster( + let (artifact, _) = PackageStore::install_to_cluster( *provider, &project, &version, @@ -577,8 +577,8 @@ pub async fn toggle_artifact_enabled( cluster_id: i64, hash: &str, ctx: &ContentCtx, -) -> ContentResult { - let enabled = PackageStore::set_artifact_enabled(cluster_id, hash, ctx).await?; +) -> ContentResult<(bool, LiveSync)> { + let (enabled, live) = PackageStore::set_artifact_enabled(cluster_id, hash, ctx).await?; if enabled { on_user_enable_artifact(cluster_id, hash, ctx).await?; @@ -586,7 +586,7 @@ pub async fn toggle_artifact_enabled( on_user_disable_artifact(cluster_id, hash, ctx).await?; } - Ok(enabled) + Ok((enabled, live)) } /// Prefer this over [`toggle_artifact_enabled`] unless the current value is diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index 74ee1cb6..9f8d4962 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -23,7 +23,7 @@ pub use dependencies::{ pub use file_identity::{curseforge_fingerprint, FileIdentity}; pub use error::{PackageError, PackageResult}; pub use provider::{PackageProvider, PackageProviderRegistry}; -pub use store::PackageStore; +pub use store::{LiveSync, PackageStore}; pub use types::*; pub use updates::{ BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update, diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index 045d3595..c223c3e3 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -1,30 +1,83 @@ -use std::path::Path; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; -use oneclient_db::models::ClusterRow; +use oneclient_db::models::{ArtifactRow, ClusterRow}; use oneclient_common::domain::ContentType; use oneclient_common::paths; use crate::error::ContentResult; use super::manifest; +use super::paths::artifact_absolute_path; +const STAGING_SUFFIX: &str = ".oneclient-tmp"; + +/// What a live add actually did, so a caller can say so instead of promising +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LiveSync { + /// The file is in the running game's folder now + Applied, + /// It could not be put there; it arrives at the next launch instead + Deferred, + /// Nothing to do: this content type never reloads, or no session owns the folder + Skipped, +} + +/// so a rerun that died mid-way leaves atleast one stale file, that next rerun clears anyways +fn staging_path(dest: &Path) -> PathBuf { + let mut name = OsString::from("."); + name.push(dest.file_name().unwrap_or_else(|| "entry".as_ref())); + name.push(STAGING_SUFFIX); + + dest.with_file_name(name) +} + +/// A launcher killed between the copy and the rename leaves one of these behind +/// and every other pass over these folders skips dotfiles +#[tracing::instrument(level = "debug")] +pub async fn sweep_staging_files(dir: &Path) { + let Ok(mut entries) = polyio::read_dir(dir).await else { + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + if !name.starts_with('.') || !name.ends_with(STAGING_SUFFIX) { + continue; + } + + if let Err(err) = remove_entry(&path).await { + tracing::debug!(file = name, error = %err, "failed to clear a stale staging file"); + } + } +} + +/// written to a staging name and renamed into place so the destination is never missing even for a moment #[tracing::instrument(level = "debug")] pub async fn link_or_copy(src: &Path, dest: &Path) -> ContentResult<()> { if let Some(parent) = dest.parent() { polyio::create_dir_all(parent).await?; } - remove_entry(dest).await?; + let staging = staging_path(dest); + remove_entry(&staging).await?; - if polyio::symlink_file(src, dest).await.is_ok() { - return Ok(()); + if polyio::symlink_file(src, &staging).await.is_err() { + polyio::copy(src, &staging).await?; + } + + if let Err(err) = polyio::rename(&staging, dest).await { + remove_entry(&staging).await.ok(); + return Err(err.into()); } - polyio::copy(src, dest).await?; Ok(()) } -/// `Path::exists` resolves symlinks so a link to an evicted artifact reads as absent and survives every unlink pub async fn remove_entry(path: &Path) -> ContentResult<()> { if polyio::symlink_metadata(path).await.is_err() { return Ok(()); @@ -34,8 +87,6 @@ pub async fn remove_entry(path: &Path) -> ContentResult<()> { Ok(()) } -/// Best-effort only the folder is reconciled at the next launch regardless -/// A running game holds its jars open which on Windows blocks deletion so failure here is expected #[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] pub async fn try_unlink_materialized( cluster: &ClusterRow, @@ -46,6 +97,8 @@ pub async fn try_unlink_materialized( return false; }; + let _guard = manifest::lock().await; + let Some(mut loaded) = manifest::load(&game_dir).await else { return false; }; @@ -74,10 +127,138 @@ pub async fn try_unlink_materialized( true } +#[tracing::instrument(level = "debug", skip(cluster, artifact), fields(cluster_id = cluster.id, hash = %artifact.hash))] +pub async fn try_link_materialized( + cluster: &ClusterRow, + artifact: &ArtifactRow, + file_name: &str, +) -> LiveSync { + let Some(content_type) = ContentType::from_repr(artifact.content_type as u8) else { + return LiveSync::Skipped; + }; + + if !content_type.reloads_in_game() { + return LiveSync::Skipped; + } + + let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { + return LiveSync::Deferred; + }; + + let Ok(src) = artifact_absolute_path(&artifact.path) else { + return LiveSync::Deferred; + }; + + if !polyio::try_exists(&src).await.unwrap_or(false) { + tracing::warn!(hash = %artifact.hash, "cached artifact missing; leaving it to the next launch"); + return LiveSync::Deferred; + } + + let _guard = manifest::lock().await; + + // No manifest means nothing is playing out of this folder right now + let Some(mut loaded) = manifest::load(&game_dir).await else { + return LiveSync::Skipped; + }; + + if loaded.cluster_id != cluster.id { + return LiveSync::Deferred; + } + + let relative = manifest::entry_path(content_type.folder_name(), file_name); + let dest = game_dir.join(content_type.folder_name()).join(file_name); + + // Same rule as the unlink path: never write over a file we did not place + if !loaded.owns(cluster.id, &relative) && polyio::symlink_metadata(&dest).await.is_ok() { + tracing::debug!( + file = file_name, + "a file we do not own already sits in the game folder; leaving it to the next launch" + ); + return LiveSync::Deferred; + } + + if let Err(err) = link_or_copy(&src, &dest).await { + tracing::debug!( + file = file_name, + error = %err, + "could not add the pack to the running game; it goes in at the next launch" + ); + return LiveSync::Deferred; + } + + loaded.entries.retain(|entry| entry.path != relative); + loaded.entries.push(manifest::ManifestEntry { + path: relative, + hash: artifact.hash.clone(), + }); + manifest::save(&game_dir, &loaded).await; + + LiveSync::Applied +} + #[cfg(test)] mod tests { use super::*; + fn cluster() -> ClusterRow { + ClusterRow { + id: 1, + name: "Test".into(), + folder_name: "test".into(), + setting_profile_name: None, + mc_version: "1.21.1".into(), + mc_loader: 0, + stage: 0, + mc_loader_version: None, + created_at: None, + last_played: None, + overall_played: None, + linked_modpack_hash: None, + } + } + + fn artifact(content_type: ContentType) -> ArtifactRow { + ArtifactRow { + hash: "abc".into(), + content_type: content_type as i64, + path: "packages/whatever".into(), + file_name: "thing".into(), + size_bytes: None, + } + } + + #[tokio::test] + async fn a_mod_is_never_added_to_a_running_game() { + assert_eq!( + try_link_materialized(&cluster(), &artifact(ContentType::Mod), "sodium.jar").await, + LiveSync::Skipped + ); + assert_eq!( + try_link_materialized(&cluster(), &artifact(ContentType::World), "world.zip").await, + LiveSync::Skipped + ); + } + + #[tokio::test] + async fn a_stale_staging_file_is_swept() { + let root = polyio::testing::ScratchDir::new("sweep_staging"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + let orphan = staging_path(&dir.join("pack.zip")); + polyio::write(&orphan, b"half a copy".as_slice()).await.unwrap(); + polyio::write(dir.join("keep.zip"), b"pack".as_slice()) + .await + .unwrap(); + + sweep_staging_files(dir).await; + + assert!(polyio::symlink_metadata(&orphan).await.is_err()); + assert!(polyio::symlink_metadata(dir.join("keep.zip")).await.is_ok()); + + std::fs::remove_dir_all(root.path()).ok(); + } + #[tokio::test] async fn remove_entry_clears_a_dangling_link() { let root = polyio::testing::ScratchDir::new("dangling_link"); @@ -102,6 +283,61 @@ mod tests { std::fs::remove_dir_all(root.path()).ok(); } + #[tokio::test] + async fn replacing_a_pack_leaves_only_the_pack() { + let root = polyio::testing::ScratchDir::new("atomic_replace"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + let old = dir.join("old.zip"); + let new = dir.join("new.zip"); + polyio::write(&old, b"old".as_slice()).await.unwrap(); + polyio::write(&new, b"new".as_slice()).await.unwrap(); + + let packs = dir.join("resourcepacks"); + let dest = packs.join("pack.zip"); + + link_or_copy(&old, &dest).await.unwrap(); + link_or_copy(&new, &dest).await.unwrap(); + + assert_eq!(polyio::read_to_string(&dest).await.unwrap(), "new"); + + let mut names = Vec::new(); + let mut entries = polyio::read_dir(&packs).await.unwrap(); + while let Ok(Some(entry)) = entries.next_entry().await { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + + assert_eq!(names, vec!["pack.zip".to_string()]); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// A crash between the write and the rename must not wedge the next attempt + #[tokio::test] + async fn a_stale_staging_file_is_cleared() { + let root = polyio::testing::ScratchDir::new("stale_staging"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + let src = dir.join("src.zip"); + let dest = dir.join("pack.zip"); + polyio::write(&src, b"real".as_slice()).await.unwrap(); + polyio::write(staging_path(&dest), b"junk".as_slice()) + .await + .unwrap(); + + link_or_copy(&src, &dest).await.unwrap(); + + assert_eq!(polyio::read_to_string(&dest).await.unwrap(), "real"); + assert!( + polyio::symlink_metadata(staging_path(&dest)).await.is_err(), + "the staging file is consumed by the rename" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + #[tokio::test] async fn remove_entry_is_fine_with_nothing_there() { let root = polyio::testing::ScratchDir::new("remove_missing"); diff --git a/packages/oneclient_content/src/packages/store/manifest.rs b/packages/oneclient_content/src/packages/store/manifest.rs index 34d5ee59..99b12a64 100644 --- a/packages/oneclient_content/src/packages/store/manifest.rs +++ b/packages/oneclient_content/src/packages/store/manifest.rs @@ -9,7 +9,6 @@ const MANIFEST_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestEntry { - /// Slash-separated relative to the game directory e.g. `mods/sodium.jar` pub path: String, pub hash: String, } @@ -17,7 +16,6 @@ pub struct ManifestEntry { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MaterializedManifest { pub version: u32, - /// The shared `.minecraft` is used by every non-dedicated cluster in turn so this often is not us pub cluster_id: i64, pub entries: Vec, } @@ -93,6 +91,12 @@ pub async fn clear(game_dir: &Path) { polyio::remove_file(manifest_path(game_dir)).await.ok(); } +static MANIFEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +pub async fn lock() -> tokio::sync::MutexGuard<'static, ()> { + MANIFEST_LOCK.lock().await +} + #[must_use] pub fn entry_path(content_folder: &str, file_name: &str) -> String { format!("{content_folder}/{file_name}") diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 6c519084..4217086e 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -9,7 +9,10 @@ pub use gc::{ GcReport, collect_unused_artifacts, evict_if_unused, find_unreferenced_files, remove_unreferenced_files, }; -pub use link::{link_or_copy, remove_entry, try_unlink_materialized}; +pub use link::{ + LiveSync, link_or_copy, remove_entry, sweep_staging_files, try_link_materialized, + try_unlink_materialized, +}; pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; use oneclient_db::dao::{artifact as artifact_dao, cluster as cluster_dao}; @@ -84,7 +87,7 @@ impl PackageStore { force_download: bool, child: Option<&GroupedProgressChild>, ctx: &ContentCtx, - ) -> ContentResult { + ) -> ContentResult<(ArtifactRow, LiveSync)> { tracing::info!("installing package to cluster"); let cluster = Self::get_cluster(cluster_id, ctx).await?; @@ -102,25 +105,51 @@ impl PackageStore { ) .await?; - Self::link_artifact(&artifact, &cluster, None, ctx).await?; - Ok(artifact) + let enabled = Self::link_artifact(&artifact, &cluster, None, ctx).await?; + + let live = if enabled { + link::try_link_materialized(&cluster, &artifact, &artifact.file_name).await + } else { + LiveSync::Skipped + }; + + Ok((artifact, live)) } - /// Writes nothing to disk - /// the artifact is materialized into the game directory at launch the only - /// moment no game is holding the files open #[tracing::instrument(level = "debug", skip(artifact, cluster, ctx))] pub async fn link_artifact( artifact: &ArtifactRow, cluster: &ClusterRow, cluster_file_name: Option<&str>, ctx: &ContentCtx, - ) -> ContentResult<()> { + ) -> ContentResult { let name = cluster_file_name.unwrap_or(&artifact.file_name); - artifact_dao::link_cluster_artifact(&ctx.db, cluster.id, &artifact.hash, name).await?; + let link = + artifact_dao::link_cluster_artifact(&ctx.db, cluster.id, &artifact.hash, name).await?; - Ok(()) + Ok(link.enabled != 0) + } + + #[tracing::instrument(level = "debug", skip(artifact, ctx), fields(hash = %artifact.hash))] + pub async fn sync_live_content( + cluster_id: i64, + artifact: &ArtifactRow, + ctx: &ContentCtx, + ) -> ContentResult { + let Some(link) = + artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, &artifact.hash).await? + else { + return Ok(LiveSync::Skipped); + }; + + if link.enabled == 0 { + return Ok(LiveSync::Skipped); + } + + let cluster = Self::get_cluster(cluster_id, ctx).await?; + + Ok(link::try_link_materialized(&cluster, artifact, &link.cluster_file_name).await) } #[tracing::instrument(level = "debug", skip(ctx))] @@ -136,7 +165,9 @@ impl PackageStore { let cluster = Self::get_cluster(cluster_id, ctx).await?; - Self::link_artifact(&artifact, &cluster, cluster_file_name, ctx).await + Self::link_artifact(&artifact, &cluster, cluster_file_name, ctx) + .await + .map(|_| ()) } #[tracing::instrument(level = "debug", skip(ctx))] @@ -196,16 +227,11 @@ impl PackageStore { } #[tracing::instrument(level = "debug", skip(ctx))] - /// Only the flag is recorded - /// the folder is rewritten at the next launch since a running game holds - /// its jars open - /// Storage only bundle override bookkeeping lives in - /// [`crate::bundles::toggle_artifact_enabled`] to avoid mutual recursion pub async fn set_artifact_enabled( cluster_id: i64, hash: &str, ctx: &ContentCtx, - ) -> ContentResult { + ) -> ContentResult<(bool, LiveSync)> { Self::write_artifact_enabled(cluster_id, hash, None, ctx).await } @@ -217,7 +243,9 @@ impl PackageStore { enabled: bool, ctx: &ContentCtx, ) -> ContentResult { - Self::write_artifact_enabled(cluster_id, hash, Some(enabled), ctx).await + Self::write_artifact_enabled(cluster_id, hash, Some(enabled), ctx) + .await + .map(|(enabled, _)| enabled) } /// `target` of `None` means "the opposite of whatever it is now" @@ -226,7 +254,7 @@ impl PackageStore { hash: &str, target: Option, ctx: &ContentCtx, - ) -> ContentResult { + ) -> ContentResult<(bool, LiveSync)> { let cluster = Self::get_cluster(cluster_id, ctx).await?; let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, hash) .await? @@ -256,14 +284,19 @@ impl PackageStore { ) .await?; - if !enabled { + // Only the enable side has an outcome to report; a pack that is not in + // the running folder needs no removing from it + let live = if enabled { + link::try_link_materialized(&cluster, &artifact, &file_name).await + } else { link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; if link.cluster_file_name != file_name { link::try_unlink_materialized(&cluster, content_type, &file_name).await; } - } + LiveSync::Skipped + }; - Ok(enabled) + Ok((enabled, live)) } #[tracing::instrument(level = "debug", skip(ctx))] diff --git a/packages/oneclient_content/src/packages/updates.rs b/packages/oneclient_content/src/packages/updates.rs index e41e0465..201312ae 100644 --- a/packages/oneclient_content/src/packages/updates.rs +++ b/packages/oneclient_content/src/packages/updates.rs @@ -320,7 +320,7 @@ pub async fn apply_browser_package_update( // Compatibility is not re-checked // a provider disagreeing at install time would strand the user on a build // they cannot move off - let installed = PackageStore::install_to_cluster( + let (installed, _) = PackageStore::install_to_cluster( update.provider, &project, &version, diff --git a/packages/oneclient_core/src/clusters/unlink_legacy.rs b/packages/oneclient_core/src/clusters/unlink_legacy.rs index 297b1462..1387f98e 100644 --- a/packages/oneclient_core/src/clusters/unlink_legacy.rs +++ b/packages/oneclient_core/src/clusters/unlink_legacy.rs @@ -107,6 +107,8 @@ async fn adopt_dedicated( cluster_root: &Path, report: &mut SweepReport, ) { + let _manifest = manifest::lock().await; + if manifest::load(cluster_root).await.is_some() { return; } diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index f53bdb5f..6fba1653 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -10,7 +10,9 @@ use oneclient_common::domain::ContentType; use oneclient_content::packages::store::manifest::{ self, ManifestEntry, MaterializedManifest, }; -use oneclient_content::packages::store::{artifact_absolute_path, link_or_copy, remove_entry}; +use oneclient_content::packages::store::{ + artifact_absolute_path, link_or_copy, remove_entry, sweep_staging_files, +}; use oneclient_content::packages::PackageStore; use crate::state::LauncherServices; @@ -49,10 +51,6 @@ pub async fn materialize_content( let dedicated = cluster.uses_dedicated_dir(); polyio::create_dir_all(game_dir).await.ok(); - // In the shared directory this often belongs to another cluster so every - // use of it checks the id - let previous = manifest::load(game_dir).await; - import_manual_content(services, cluster, game_dir).await; // Before the folder is built not after handing the game several enabled @@ -67,9 +65,22 @@ pub async fn materialize_content( tracing::warn!(cluster_id = cluster.id, %err, "failed to resolve duplicate package versions"); } + // Held from the database snapshot through the save so a package removed + // mid-launch is not resurrected by our own write; the two calls above take + // it themselves so it cannot be taken any earlier + let _manifest = manifest::lock().await; + + // In the shared directory this often belongs to another cluster so every + // use of it checks the id + let previous = manifest::load(game_dir).await; + let desired = desired_content(services, cluster).await?; let desired_paths: HashSet = desired.iter().map(Desired::relative_path).collect(); + for content_type in SWAP_TYPES { + sweep_staging_files(&game_dir.join(content_type.folder_name())).await; + } + // While the game is still closed this is what lands a package removed // mid-session and clears another cluster's content from the shared dir prune_previous(game_dir, previous.as_ref(), &desired_paths).await; @@ -111,8 +122,10 @@ pub async fn dematerialize_content( ) -> LauncherResult<()> { // Runs first so anything dropped in during the session is a tracked artifact // by now and gets dropped rather than stashed as a loose file + // It loads the manifest itself so the lock comes after it import_manual_content(services, cluster, game_dir).await; + let _manifest = manifest::lock().await; let current = manifest::load(game_dir).await; let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) .await @@ -125,6 +138,7 @@ pub async fn dematerialize_content( let ours = ours_in_folder(content_type, &linked, current.as_ref()); stash_content_files(&dir, &stash, &ours).await; + sweep_staging_files(&dir).await; ensure_note(&dir, content_type).await; } @@ -261,7 +275,11 @@ pub async fn import_manual_content( } }; - let manifest = manifest::load(game_dir).await; + // Not held across the import loop below, which is long and does not need it + let manifest = { + let _guard = manifest::lock().await; + manifest::load(game_dir).await + }; for content_type in SWAP_TYPES { let dir = game_dir.join(content_type.folder_name());