feat(ui): very basic bundle overrides updating

This commit is contained in:
LynithDev 2026-07-13 18:01:41 +02:00
parent 44897b3e7f
commit 8cd617a78b
4 changed files with 562 additions and 94 deletions

View file

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

View file

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

View file

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

View file

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