feat: resource packs and shaders quiet reload (#752)
This commit is contained in:
parent
72a9b63f24
commit
90fb354857
14 changed files with 410 additions and 82 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue