Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/oneclient_app/src/hooks/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 11 additions & 5 deletions packages/oneclient_app/src/hooks/queries/mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(|_| ())
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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()),
Expand Down
5 changes: 5 additions & 0 deletions packages/oneclient_common/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
183 changes: 174 additions & 9 deletions packages/oneclient_content/src/packages/store/link.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
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;

/// 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(());
Expand All @@ -34,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,
Expand All @@ -46,6 +60,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;
};
Expand Down Expand Up @@ -74,10 +90,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");
Expand All @@ -102,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");
Expand Down
8 changes: 6 additions & 2 deletions packages/oneclient_content/src/packages/store/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@ 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,
}

#[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>,
}
Expand Down Expand Up @@ -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}")
Expand Down
Loading