feat(ui): very basic bundle overrides updating
This commit is contained in:
parent
44897b3e7f
commit
8cd617a78b
4 changed files with 562 additions and 94 deletions
|
|
@ -108,7 +108,9 @@ pub async fn extract_bundle_overrides_for_cluster(
|
|||
services: &LauncherServices,
|
||||
) -> LauncherResult<()> {
|
||||
let cluster = PackageStore::get_cluster(cluster_id, services).await?;
|
||||
overrides::extract_bundle_overrides(&archive.bundle.path, &cluster).await
|
||||
overrides::sync_bundle_overrides(&archive.bundle.path, &archive.manifest.name, &cluster, None)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn install_bundle(
|
||||
|
|
|
|||
|
|
@ -1,86 +1,521 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use oneclient_db::models::ClusterRow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::LauncherResult;
|
||||
use crate::crypto::{sha1_bytes, sha1_file};
|
||||
use crate::notification::NotificationService;
|
||||
|
||||
pub async fn extract_bundle_overrides(
|
||||
archive_path: &Path,
|
||||
cluster: &ClusterRow,
|
||||
) -> LauncherResult<()> {
|
||||
extract_overrides(archive_path, &cluster_root(cluster)?, false).await
|
||||
}
|
||||
const ALWAYS_UPDATE_GLOBS: &[&str] = &["config/fabric_loader_dependencies.json"];
|
||||
|
||||
pub async fn extract_bundle_overrides_no_overwrite(
|
||||
archive_path: &Path,
|
||||
cluster: &ClusterRow,
|
||||
) -> LauncherResult<()> {
|
||||
extract_overrides(archive_path, &cluster_root(cluster)?, true).await
|
||||
}
|
||||
const OVERRIDES_PREFIX: &str = "overrides/";
|
||||
const LOCK_REL_PATH: &str = ".oneclient/bundle_overrides.json";
|
||||
|
||||
fn cluster_root(cluster: &ClusterRow) -> LauncherResult<PathBuf> {
|
||||
Ok(crate::paths::clusters_dir()?.join(&cluster.folder_name))
|
||||
}
|
||||
|
||||
async fn extract_overrides(
|
||||
archive_path: &Path,
|
||||
dest_path: &Path,
|
||||
skip_existing: bool,
|
||||
) -> LauncherResult<()> {
|
||||
if skip_existing {
|
||||
extract_overrides_no_overwrite(archive_path, dest_path).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
polyio::extract_zip_filtered(
|
||||
archive_path,
|
||||
dest_path,
|
||||
Some(|entry: &async_zip::StoredZipEntry| {
|
||||
entry
|
||||
.filename()
|
||||
.as_str()
|
||||
.is_ok_and(|name| name.starts_with("overrides/"))
|
||||
}),
|
||||
Some(|name: &str| name.trim_start_matches("overrides/").to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
struct OverrideLock {
|
||||
#[serde(default)]
|
||||
bundles: HashMap<String, HashMap<String, String>>,
|
||||
}
|
||||
|
||||
async fn extract_overrides_no_overwrite(
|
||||
impl OverrideLock {
|
||||
async fn load(root: &Path) -> Self {
|
||||
let path = root.join(LOCK_REL_PATH);
|
||||
match polyio::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|err| {
|
||||
tracing::warn!(error = %err, "corrupt bundle override lock; starting fresh");
|
||||
Self::default()
|
||||
}),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(&self, root: &Path) -> LauncherResult<()> {
|
||||
let path = root.join(LOCK_REL_PATH);
|
||||
if let Some(parent) = path.parent() {
|
||||
polyio::create_dir_all(parent).await.ok();
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?;
|
||||
polyio::write(&path, bytes).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OverrideSyncReport {
|
||||
pub written: Vec<String>,
|
||||
pub conflicts: Vec<String>,
|
||||
pub deleted: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn sync_bundle_overrides(
|
||||
archive_path: &Path,
|
||||
dest_path: &Path,
|
||||
) -> LauncherResult<()> {
|
||||
polyio::extract_zip_filtered(
|
||||
archive_path,
|
||||
dest_path,
|
||||
Some(|entry: &async_zip::StoredZipEntry| {
|
||||
if let Ok(name) = entry.filename().as_str() {
|
||||
if !name.starts_with("overrides/") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let stripped = name.trim_start_matches("overrides/");
|
||||
if stripped.is_empty() {
|
||||
return false;
|
||||
}
|
||||
bundle_name: &str,
|
||||
cluster: &ClusterRow,
|
||||
notifier: Option<&NotificationService>,
|
||||
) -> LauncherResult<OverrideSyncReport> {
|
||||
let root = cluster_root(cluster)?;
|
||||
sync_bundle_overrides_at(archive_path, bundle_name, &root, notifier).await
|
||||
}
|
||||
|
||||
let target_path = dest_path.join(polyio::sanitize_path(stripped));
|
||||
if target_path.exists() {
|
||||
return false;
|
||||
}
|
||||
async fn sync_bundle_overrides_at(
|
||||
archive_path: &Path,
|
||||
bundle_name: &str,
|
||||
root: &Path,
|
||||
notifier: Option<&NotificationService>,
|
||||
) -> LauncherResult<OverrideSyncReport> {
|
||||
let entries =
|
||||
polyio::read_zip_file_entries(archive_path, |name| name.starts_with(OVERRIDES_PREFIX))
|
||||
.await?;
|
||||
|
||||
if let Ok(is_dir) = entry.dir() {
|
||||
return !is_dir;
|
||||
let mut lock = OverrideLock::load(root).await;
|
||||
let previous = lock.bundles.remove(bundle_name).unwrap_or_default();
|
||||
let mut next: HashMap<String, String> = HashMap::new();
|
||||
let mut report = OverrideSyncReport::default();
|
||||
|
||||
for (name, bytes) in entries {
|
||||
let rel = name.trim_start_matches(OVERRIDES_PREFIX);
|
||||
if rel.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let new_sha1 = sha1_bytes(&bytes);
|
||||
let dest = root.join(polyio::sanitize_path(rel));
|
||||
let disk_sha1 = current_sha1(&dest).await;
|
||||
let base = previous.get(rel).cloned();
|
||||
|
||||
if matches_always_update(rel) {
|
||||
let wrote = write_override(&dest, &bytes, rel).await;
|
||||
if wrote {
|
||||
if disk_sha1.as_deref() != Some(new_sha1.as_str()) {
|
||||
report.written.push(rel.to_string());
|
||||
}
|
||||
next.insert(rel.to_string(), new_sha1);
|
||||
} else if let Some(b) = base {
|
||||
next.insert(rel.to_string(), b);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match disk_sha1 {
|
||||
// Nothing on disk -> copy from bundle
|
||||
None => {
|
||||
if write_override(&dest, &bytes, rel).await {
|
||||
report.written.push(rel.to_string());
|
||||
next.insert(rel.to_string(), new_sha1);
|
||||
}
|
||||
}
|
||||
// Already matches the bundle -> so just record it in the lock
|
||||
Some(disk) if disk == new_sha1 => {
|
||||
next.insert(rel.to_string(), new_sha1);
|
||||
}
|
||||
// Untouched -< safe to update
|
||||
Some(disk) if base.as_deref() == Some(disk.as_str()) => {
|
||||
if write_override(&dest, &bytes, rel).await {
|
||||
report.written.push(rel.to_string());
|
||||
next.insert(rel.to_string(), new_sha1);
|
||||
} else {
|
||||
next.insert(rel.to_string(), disk);
|
||||
}
|
||||
}
|
||||
// Differs from both the bundle and the base -> conflict
|
||||
Some(_) => {
|
||||
if let Some(b) = base {
|
||||
if b != new_sha1 {
|
||||
report.conflicts.push(rel.to_string());
|
||||
}
|
||||
next.insert(rel.to_string(), b);
|
||||
}
|
||||
// untracked and untouched
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delete files the bundle no longer ships, but only if they were unmodified locally
|
||||
for (rel, base_sha1) in &previous {
|
||||
if next.contains_key(rel) {
|
||||
continue;
|
||||
}
|
||||
let dest = root.join(polyio::sanitize_path(rel));
|
||||
|
||||
if current_sha1(&dest).await.as_deref() == Some(base_sha1.as_str())
|
||||
&& polyio::remove_file(&dest).await.is_ok()
|
||||
{
|
||||
report.deleted.push(rel.clone());
|
||||
}
|
||||
}
|
||||
|
||||
lock.bundles.insert(bundle_name.to_string(), next);
|
||||
if let Err(err) = lock.save(root).await {
|
||||
tracing::warn!(error = %err, "failed to persist bundle override lock");
|
||||
}
|
||||
|
||||
if !report.conflicts.is_empty() {
|
||||
notify_conflicts(notifier, bundle_name, &report.conflicts);
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn write_override(dest: &Path, bytes: &[u8], rel: &str) -> bool {
|
||||
if let Some(parent) = dest.parent() {
|
||||
polyio::create_dir_all(parent).await.ok();
|
||||
}
|
||||
match polyio::write(dest, bytes).await {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
tracing::warn!(path = rel, error = %err, "failed to write bundle override");
|
||||
false
|
||||
}),
|
||||
Some(|old_name: &str| {
|
||||
old_name.trim_start_matches("overrides/").to_string()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn current_sha1(path: &Path) -> Option<String> {
|
||||
if polyio::try_exists(path).await.unwrap_or(false) {
|
||||
sha1_file(path).await.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_conflicts(
|
||||
notifier: Option<&NotificationService>,
|
||||
bundle_name: &str,
|
||||
conflicts: &[String],
|
||||
) {
|
||||
let Some(notifier) = notifier else {
|
||||
return;
|
||||
};
|
||||
|
||||
const MAX_LISTED: usize = 5;
|
||||
let listed = conflicts
|
||||
.iter()
|
||||
.take(MAX_LISTED)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let extra = conflicts.len().saturating_sub(MAX_LISTED);
|
||||
let suffix = if extra > 0 {
|
||||
format!(" (+{extra} more)")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
notifier.send_info(
|
||||
"Kept your config edits",
|
||||
&format!(
|
||||
"{bundle_name}: {} config file(s) you edited were left untouched by the update: {listed}{suffix}",
|
||||
conflicts.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn matches_always_update(rel: &str) -> bool {
|
||||
ALWAYS_UPDATE_GLOBS
|
||||
.iter()
|
||||
.any(|pattern| glob_match(pattern, rel))
|
||||
}
|
||||
|
||||
fn glob_match(pattern: &str, path: &str) -> bool {
|
||||
let pat: Vec<&str> = pattern.split('/').collect();
|
||||
let seg: Vec<&str> = path.split('/').collect();
|
||||
glob_segments(&pat, &seg)
|
||||
}
|
||||
|
||||
fn glob_segments(pat: &[&str], path: &[&str]) -> bool {
|
||||
match pat.split_first() {
|
||||
None => path.is_empty(),
|
||||
Some((&"**", rest)) => (0..=path.len()).any(|i| glob_segments(rest, &path[i..])),
|
||||
Some((first, rest)) => {
|
||||
if let Some((head, tail)) = path.split_first() {
|
||||
segment_match(first, head) && glob_segments(rest, tail)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_match(pattern: &str, segment: &str) -> bool {
|
||||
if !pattern.contains('*') {
|
||||
return pattern == segment;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = pattern.split('*').collect();
|
||||
let mut pos = 0usize;
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if index == 0 {
|
||||
if !segment[pos..].starts_with(part) {
|
||||
return false;
|
||||
}
|
||||
pos += part.len();
|
||||
} else if index == parts.len() - 1 {
|
||||
if !segment[pos..].ends_with(part) {
|
||||
return false;
|
||||
}
|
||||
} else if let Some(found) = segment[pos..].find(part) {
|
||||
pos += found + part.len();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn glob_exact_and_wildcards() {
|
||||
assert!(glob_match(
|
||||
"config/fabric_loader_dependencies.json",
|
||||
"config/fabric_loader_dependencies.json"
|
||||
));
|
||||
assert!(!glob_match(
|
||||
"config/fabric_loader_dependencies.json",
|
||||
"config/other.json"
|
||||
));
|
||||
assert!(glob_match("config/*.json", "config/anything.json"));
|
||||
assert!(!glob_match("config/*.json", "config/nested/anything.json"));
|
||||
assert!(glob_match("config/**", "config/nested/deep/file.toml"));
|
||||
assert!(glob_match("**/options.txt", "a/b/options.txt"));
|
||||
assert!(glob_match("**/options.txt", "options.txt"));
|
||||
assert!(glob_match("config/mod-*.cfg", "config/mod-foo.cfg"));
|
||||
assert!(!glob_match("config/mod-*.cfg", "config/other-foo.cfg"));
|
||||
}
|
||||
|
||||
fn tmp_root(tag: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("oneclient_ovr_{}_{tag}_{n}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
async fn write_bundle(path: &Path, entries: &[(&str, &[u8])]) {
|
||||
let file = tokio::fs::File::create(path).await.unwrap();
|
||||
let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(file);
|
||||
for (name, data) in entries {
|
||||
let builder = async_zip::ZipEntryBuilder::new(
|
||||
(*name).to_string().into(),
|
||||
async_zip::Compression::Stored,
|
||||
);
|
||||
writer.write_entry_whole(builder, data).await.unwrap();
|
||||
}
|
||||
writer.close().await.unwrap();
|
||||
}
|
||||
|
||||
async fn read(path: &Path) -> Option<Vec<u8>> {
|
||||
tokio::fs::read(path).await.ok()
|
||||
}
|
||||
|
||||
async fn sync(zip: &Path, root: &Path) -> OverrideSyncReport {
|
||||
sync_bundle_overrides_at(zip, "test-bundle", root, None)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_install_writes_all() {
|
||||
let root = tmp_root("fresh");
|
||||
let zip = root.join("v1.mrpack");
|
||||
write_bundle(
|
||||
&zip,
|
||||
&[
|
||||
("overrides/config/a.toml", b"alpha"),
|
||||
("overrides/config/nested/b.cfg", b"beta"),
|
||||
("overrides/", b""),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let report = sync(&zip, &root).await;
|
||||
assert_eq!(report.written.len(), 2);
|
||||
assert!(report.conflicts.is_empty());
|
||||
assert_eq!(read(&root.join("config/a.toml")).await.unwrap(), b"alpha");
|
||||
assert_eq!(
|
||||
read(&root.join("config/nested/b.cfg")).await.unwrap(),
|
||||
b"beta"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rerun_same_version_is_noop() {
|
||||
let root = tmp_root("noop");
|
||||
let zip = root.join("v1.mrpack");
|
||||
write_bundle(&zip, &[("overrides/config/a.toml", b"alpha")]).await;
|
||||
sync(&zip, &root).await;
|
||||
|
||||
let report = sync(&zip, &root).await;
|
||||
assert!(report.written.is_empty());
|
||||
assert!(report.conflicts.is_empty());
|
||||
assert!(report.deleted.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn untouched_file_updates() {
|
||||
let root = tmp_root("update");
|
||||
let v1 = root.join("v1.mrpack");
|
||||
write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await;
|
||||
sync(&v1, &root).await;
|
||||
|
||||
let v2 = root.join("v2.mrpack");
|
||||
write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await;
|
||||
let report = sync(&v2, &root).await;
|
||||
|
||||
assert_eq!(report.written, vec!["config/a.toml".to_string()]);
|
||||
assert!(report.conflicts.is_empty());
|
||||
assert_eq!(
|
||||
read(&root.join("config/a.toml")).await.unwrap(),
|
||||
b"alpha-v2"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_edit_with_bundle_unchanged_is_kept_silently() {
|
||||
let root = tmp_root("edit_noconflict");
|
||||
let v1 = root.join("v1.mrpack");
|
||||
write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await;
|
||||
sync(&v1, &root).await;
|
||||
|
||||
tokio::fs::write(root.join("config/a.toml"), b"user-edit")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = sync(&v1, &root).await;
|
||||
assert!(report.written.is_empty());
|
||||
assert!(report.conflicts.is_empty());
|
||||
assert_eq!(
|
||||
read(&root.join("config/a.toml")).await.unwrap(),
|
||||
b"user-edit"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_edit_and_bundle_changed_is_conflict_kept() {
|
||||
let root = tmp_root("conflict");
|
||||
let v1 = root.join("v1.mrpack");
|
||||
write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await;
|
||||
sync(&v1, &root).await;
|
||||
|
||||
tokio::fs::write(root.join("config/a.toml"), b"user-edit")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let v2 = root.join("v2.mrpack");
|
||||
write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await;
|
||||
let report = sync(&v2, &root).await;
|
||||
|
||||
assert_eq!(report.conflicts, vec!["config/a.toml".to_string()]);
|
||||
assert!(report.written.is_empty());
|
||||
|
||||
assert_eq!(
|
||||
read(&root.join("config/a.toml")).await.unwrap(),
|
||||
b"user-edit"
|
||||
);
|
||||
|
||||
let report2 = sync(&v2, &root).await;
|
||||
assert_eq!(
|
||||
read(&root.join("config/a.toml")).await.unwrap(),
|
||||
b"user-edit"
|
||||
);
|
||||
assert_eq!(report2.conflicts, vec!["config/a.toml".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn always_update_overwrites_user_edit() {
|
||||
let root = tmp_root("always");
|
||||
let path = "config/fabric_loader_dependencies.json";
|
||||
let v1 = root.join("v1.mrpack");
|
||||
write_bundle(&v1, &[(&format!("overrides/{path}"), b"deps-v1")]).await;
|
||||
sync(&v1, &root).await;
|
||||
|
||||
tokio::fs::write(root.join(path), b"runtime-mutated")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let v2 = root.join("v2.mrpack");
|
||||
write_bundle(&v2, &[(&format!("overrides/{path}"), b"deps-v2")]).await;
|
||||
let report = sync(&v2, &root).await;
|
||||
|
||||
assert!(report.conflicts.is_empty());
|
||||
assert_eq!(read(&root.join(path)).await.unwrap(), b"deps-v2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_file_deleted_only_when_unmodified() {
|
||||
let root = tmp_root("removed");
|
||||
let v1 = root.join("v1.mrpack");
|
||||
write_bundle(
|
||||
&v1,
|
||||
&[
|
||||
("overrides/config/gone.toml", b"g"),
|
||||
("overrides/config/kept.toml", b"k"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
sync(&v1, &root).await;
|
||||
|
||||
// User modified kept.toml; gone.toml untouched.
|
||||
tokio::fs::write(root.join("config/kept.toml"), b"edited")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let v2 = root.join("v2.mrpack");
|
||||
write_bundle(&v2, &[("overrides/config/a.toml", b"a")]).await;
|
||||
let report = sync(&v2, &root).await;
|
||||
|
||||
assert_eq!(report.deleted, vec!["config/gone.toml".to_string()]);
|
||||
assert!(read(&root.join("config/gone.toml")).await.is_none());
|
||||
|
||||
assert_eq!(
|
||||
read(&root.join("config/kept.toml")).await.unwrap(),
|
||||
b"edited"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bootstrap_pre_existing_edit_is_never_overwritten() {
|
||||
let root = tmp_root("bootstrap");
|
||||
|
||||
tokio::fs::create_dir_all(root.join("config"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(root.join("config/a.toml"), b"pre-existing")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let v1 = root.join("v1.mrpack");
|
||||
write_bundle(&v1, &[("overrides/config/a.toml", b"bundle-version")]).await;
|
||||
let report = sync(&v1, &root).await;
|
||||
|
||||
assert!(report.written.is_empty());
|
||||
assert!(report.conflicts.is_empty());
|
||||
assert_eq!(
|
||||
read(&root.join("config/a.toml")).await.unwrap(),
|
||||
b"pre-existing"
|
||||
);
|
||||
|
||||
let report2 = sync(&v1, &root).await;
|
||||
assert!(report2.written.is_empty());
|
||||
assert_eq!(
|
||||
read(&root.join("config/a.toml")).await.unwrap(),
|
||||
b"pre-existing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,22 +365,7 @@ pub async fn apply_bundle_updates(
|
|||
session.finish();
|
||||
}
|
||||
|
||||
let has_changes = !result.updates_applied.is_empty()
|
||||
|| !result.removals_applied.is_empty()
|
||||
|| !result.additions_applied.is_empty();
|
||||
|
||||
if has_changes {
|
||||
let mut affected = HashSet::new();
|
||||
for u in &result.updates_applied {
|
||||
affected.insert(u.bundle_name.clone());
|
||||
}
|
||||
for r in &result.removals_applied {
|
||||
affected.insert(r.bundle_name.clone());
|
||||
}
|
||||
for a in &result.additions_applied {
|
||||
affected.insert(a.bundle_name.clone());
|
||||
}
|
||||
|
||||
{
|
||||
let cluster = PackageStore::get_cluster(cluster_id, services).await?;
|
||||
let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric);
|
||||
if let Ok(archives) = bundles
|
||||
|
|
@ -388,12 +373,19 @@ pub async fn apply_bundle_updates(
|
|||
.await
|
||||
{
|
||||
for archive in archives {
|
||||
if affected.contains(&archive.manifest.name) {
|
||||
let _ = overrides::extract_bundle_overrides_no_overwrite(
|
||||
&archive.bundle.path,
|
||||
&cluster,
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = overrides::sync_bundle_overrides(
|
||||
&archive.bundle.path,
|
||||
&archive.manifest.name,
|
||||
&cluster,
|
||||
Some(&services.notifier),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
bundle = %archive.manifest.name,
|
||||
error = %err,
|
||||
"failed to sync bundle overrides during update"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ pub fn stream_zip_entries_bytes(
|
|||
skip(data, dest_path),
|
||||
fields(
|
||||
dest_path = %dest_path.as_ref().display()
|
||||
)
|
||||
)
|
||||
)]
|
||||
pub async fn unzip_bytes(
|
||||
data: Vec<u8>,
|
||||
|
|
@ -86,7 +86,7 @@ pub async fn unzip_bytes(
|
|||
skip(data, filter_entries, dest_path),
|
||||
fields(
|
||||
dest_path = %dest_path.as_ref().display()
|
||||
)
|
||||
)
|
||||
)]
|
||||
pub async fn unzip_bytes_filtered(
|
||||
data: Vec<u8>,
|
||||
|
|
@ -134,7 +134,7 @@ pub async fn unzip_bytes_filtered(
|
|||
fields(
|
||||
zip_path = %zip_path.as_ref().display(),
|
||||
dest_path = %dest_path.as_ref().display()
|
||||
)
|
||||
)
|
||||
)]
|
||||
pub async fn extract_zip(
|
||||
zip_path: impl AsRef<std::path::Path>,
|
||||
|
|
@ -156,7 +156,7 @@ pub async fn extract_zip(
|
|||
fields(
|
||||
zip_path = %zip_path.as_ref().display(),
|
||||
dest_path = %dest_path.as_ref().display()
|
||||
)
|
||||
)
|
||||
)]
|
||||
pub async fn extract_zip_filtered(
|
||||
zip_path: impl AsRef<std::path::Path>,
|
||||
|
|
@ -207,6 +207,45 @@ pub async fn extract_zip_filtered(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads the bytes of every non-directory zip entry whose name passes `filter`.
|
||||
///
|
||||
/// Returns `(entry_name, bytes)` pairs. Each matching entry is read fully into
|
||||
/// memory, so this is meant for small files (e.g. bundle config overrides), not
|
||||
/// large archives.
|
||||
#[instrument(
|
||||
level = "debug",
|
||||
skip(zip_path, filter),
|
||||
fields(zip_path = %zip_path.as_ref().display())
|
||||
)]
|
||||
pub async fn read_zip_file_entries(
|
||||
zip_path: impl AsRef<std::path::Path>,
|
||||
filter: impl Fn(&str) -> bool,
|
||||
) -> PolyIOResult<Vec<(String, Vec<u8>)>> {
|
||||
let zip_path = zip_path.as_ref();
|
||||
let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path).await?;
|
||||
let entries = reader.file().entries();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for index in 0..entries.len() {
|
||||
let entry = entries.get(index).expect("expected more zip entries");
|
||||
let Ok(name) = entry.filename().as_str() else {
|
||||
continue;
|
||||
};
|
||||
let name = name.to_string();
|
||||
|
||||
if entry.dir().unwrap_or(false) || !filter(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut entry_reader = reader.reader_without_entry(index).await?;
|
||||
let mut data = Vec::new();
|
||||
futures_lite::AsyncReadExt::read_to_end(&mut entry_reader, &mut data).await?;
|
||||
out.push((name, data));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Returns a zip file entry's bytes without reading the entire file into memory.
|
||||
#[instrument(
|
||||
level = "debug",
|
||||
|
|
@ -253,7 +292,7 @@ pub async fn extract_tar_gz(archive: impl AsRef<std::path::Path>, dest: impl AsR
|
|||
let file = tokio::fs::File::open(archive).await?;
|
||||
let buf_reader = tokio::io::BufReader::new(file);
|
||||
let gzip_decoder = async_compression::tokio::bufread::GzipDecoder::new(buf_reader);
|
||||
|
||||
|
||||
let mut tar_archive = tokio_tar::Archive::new(gzip_decoder);
|
||||
tar_archive.unpack(dest).await?;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue