feat: resource packs and shaders quiet reload (#752)

This commit is contained in:
Maciek 2026-09-04 18:01:53 +02:00 • committed by LynithDev
parent 72a9b63f24
commit 90fb354857
14 changed files with 410 additions and 82 deletions

View file

@ -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),

View file

@ -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,

View file

@ -180,9 +180,17 @@ pub struct PackageInstall {
pub dependencies: Vec<String>,
/// Unresolved or failed dependencies the package still installs
pub missing_dependencies: Vec<String>,
/// 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,
}
}

View file

@ -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,

View file

@ -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()),

View file

@ -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<Self> {
match name.to_lowercase().as_str() {
"mods" | "mod" => Some(Self::Mod),

View file

@ -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<bool> {
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

View file

@ -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,

View file

@ -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");

View file

@ -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<ManifestEntry>,
}
@ -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}")

View file

@ -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<ArtifactRow> {
) -> 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<bool> {
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<LiveSync> {
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<bool> {
) -> ContentResult<(bool, LiveSync)> {
Self::write_artifact_enabled(cluster_id, hash, None, ctx).await
}
@ -217,7 +243,9 @@ impl PackageStore {
enabled: bool,
ctx: &ContentCtx,
) -> ContentResult<bool> {
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<bool>,
ctx: &ContentCtx,
) -> ContentResult<bool> {
) -> 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))]

View file

@ -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,

View file

@ -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;
}

View file

@ -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<String> = 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());