From 773b158409a7da249339152e5f880fee83c2e221 Mon Sep 17 00:00:00 2001 From: rozwader Date: Wed, 19 Aug 2026 18:27:05 +0200 Subject: [PATCH 1/3] feat: added shaders and resourcepacks refresh while in game (not working) --- packages/oneclient_app/src/hooks/actions.rs | 8 ++ .../src/hooks/queries/mutations.rs | 16 ++- .../view/app/cluster/package_manager/mod.rs | 3 +- .../view/app/cluster/package_manager/views.rs | 19 +++- packages/oneclient_common/src/domain.rs | 5 + .../src/packages/store/link.rs | 99 ++++++++++++++++++- .../src/packages/store/manifest.rs | 8 +- .../src/packages/store/mod.rs | 53 +++++++--- 8 files changed, 181 insertions(+), 30 deletions(-) diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index aee585fc..3bcef980 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -669,6 +669,14 @@ impl Actions { .await { Ok(row) => { + oneclient_content::packages::PackageStore::sync_live_content( + cluster_id, + &row, + &state.services.content(), + ) + .await + .ok(); + events .notify("Imported") .body(format!("Added {}", row.file_name)) diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index cc685c5e..35bc9466 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -104,8 +104,6 @@ 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(|_| ()) @@ -137,13 +135,21 @@ impl MutationCapability for ClusterMutation { cluster_id, content_type, path, - } => PackageStore::import_local_file(path, *content_type, *cluster_id, content) + } => match PackageStore::import_local_file(path, *content_type, *cluster_id, content) .await - .map(|row| { + { + Ok(row) => { + PackageStore::sync_live_content(*cluster_id, &row, content) + .await + .ok(); + services .events .notify("Imported").body(format!("Added {}", row.file_name)).send(); - }), + Ok(()) + } + Err(err) => Err(err), + }, ClusterAction::SetDedicatedDir { cluster_id, dedicated, 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 22587ce6..f3dd6dee 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 @@ -376,7 +376,6 @@ impl Component for PackageManager { let cluster_id = self.cluster_id; let content_type = self.content_type; - // 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); @@ -441,7 +440,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 056c9fe2..c9290416 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 @@ -213,8 +213,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. Your {noun_plural} are added right away — open Options → Resource Packs in game to turn them on." + ), + ContentType::Shader => format!( + "Minecraft is running. Your {noun_plural} are added right away — open the shader pack screen in game to turn them on." + ), + _ => 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()) @@ -233,9 +244,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/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index 045d3595..ceb01e14 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -1,12 +1,13 @@ use std::path::Path; -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; #[tracing::instrument(level = "debug")] pub async fn link_or_copy(src: &Path, dest: &Path) -> ContentResult<()> { @@ -46,6 +47,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 +77,104 @@ 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, +) -> bool { + let Some(content_type) = ContentType::from_repr(artifact.content_type as u8) else { + return false; + }; + + if !content_type.reloads_in_game() { + return false; + } + + let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { + return false; + }; + + let Ok(src) = artifact_absolute_path(&artifact.path) else { + return false; + }; + + if !polyio::try_exists(&src).await.unwrap_or(false) { + tracing::warn!(hash = %artifact.hash, "cached artifact missing; leaving it to the next launch"); + return false; + } + + let _guard = manifest::lock().await; + + let Some(mut loaded) = manifest::load(&game_dir).await else { + return false; + }; + + if loaded.cluster_id != cluster.id { + return false; + } + + let relative = manifest::entry_path(content_type.folder_name(), file_name); + let dest = game_dir.join(content_type.folder_name()).join(file_name); + + 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 false; + } + + loaded.entries.retain(|entry| entry.path != relative); + loaded.entries.push(manifest::ManifestEntry { + path: relative, + hash: artifact.hash.clone(), + }); + manifest::save(&game_dir, &loaded).await; + + true +} + #[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!(!try_link_materialized(&cluster(), &artifact(ContentType::Mod), "sodium.jar").await); + assert!( + !try_link_materialized(&cluster(), &artifact(ContentType::World), "world.zip").await + ); + } + #[tokio::test] async fn remove_entry_clears_a_dangling_link() { let root = polyio::testing::ScratchDir::new("dangling_link"); 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 5362f5b7..665d1de7 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -9,7 +9,7 @@ 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::{link_or_copy, remove_entry, 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}; @@ -102,25 +102,49 @@ impl PackageStore { ) .await?; - Self::link_artifact(&artifact, &cluster, None, ctx).await?; + let enabled = Self::link_artifact(&artifact, &cluster, None, ctx).await?; + + if enabled { + link::try_link_materialized(&cluster, &artifact, &artifact.file_name).await; + } + Ok(artifact) } - /// 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(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(false); + }; + + if link.enabled == 0 { + return Ok(false); + } + + let cluster = Self::get_cluster(cluster_id, ctx).await?; - Ok(()) + Ok(link::try_link_materialized(&cluster, artifact, &link.cluster_file_name).await) } #[tracing::instrument(level = "debug", skip(ctx))] @@ -136,7 +160,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))] @@ -179,11 +205,6 @@ 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, @@ -239,7 +260,9 @@ impl PackageStore { ) .await?; - if !enabled { + 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; From d45c6bed71d4f5464a3930d334a505bf76eb7ffa Mon Sep 17 00:00:00 2001 From: rozwader Date: Thu, 20 Aug 2026 12:35:05 +0200 Subject: [PATCH 2/3] fix: patched a window where a file would not exist, it could have broke 50% of the time (sometimes would, sometimes not) --- .../src/packages/store/link.rs | 84 +++++++++++++++++-- 1 file changed, 76 insertions(+), 8 deletions(-) diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index ceb01e14..ce65ccc4 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -1,4 +1,5 @@ -use std::path::Path; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; use oneclient_db::models::{ArtifactRow, ClusterRow}; @@ -9,23 +10,37 @@ use crate::error::ContentResult; use super::manifest; use super::paths::artifact_absolute_path; +/// 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(".oneclient-tmp"); + + dest.with_file_name(name) +} + +/// 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(()); @@ -35,8 +50,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, @@ -199,6 +212,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"); From fd524ca42cd26e97d7ebbe6a4dc32a1f0f6a5870 Mon Sep 17 00:00:00 2001 From: rozwader Date: Thu, 20 Aug 2026 13:21:35 +0200 Subject: [PATCH 3/3] chore: 'them dashes' --- .../src/view/app/cluster/package_manager/views.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 c9290416..b9c81356 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 @@ -216,10 +216,10 @@ pub(super) fn toolbar_bar( pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentType) -> Element { let text = match content_type { ContentType::ResourcePack => format!( - "Minecraft is running. Your {noun_plural} are added right away — open Options → Resource Packs in game to turn them on." + "Minecraft is running. Your {noun_plural} are added right away, open Options → Resource Packs in game to turn them on." ), ContentType::Shader => format!( - "Minecraft is running. Your {noun_plural} are added right away — open the shader pack screen in game to turn them on." + "Minecraft is running. Your {noun_plural} are added right away, open the shader pack screen in game to turn them on." ), _ => format!( "Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version."