diff --git a/Cargo.lock b/Cargo.lock index e056253..cc2cb7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.8" +version = "0.6.9" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.8" +version = "0.6.9" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.8" +version = "0.6.9" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 24bf338..108ecec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/coa-core", "crates/coa-release", "src-tauri"] [workspace.package] -version = "0.6.8" +version = "0.6.9" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261010-custom-races.md b/changelog.d/20261010-custom-races.md new file mode 100644 index 0000000..aee3bad --- /dev/null +++ b/changelog.d/20261010-custom-races.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Custom races can be enabled and their client patches installed from Modules +--- +Install the custom race client assets from the module card, then enable the module and restart the server. diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index a0f3a96..38b8945 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -15,9 +15,6 @@ use crate::error::{Error, Result}; use crate::fsx; pub const MANIFEST_FILENAME: &str = "custom_races_patch_manifest.txt"; -pub const GITHUB_RELEASE_BASE: &str = - "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.4"; - pub const ARCHIVES: &[(&str, &str)] = &[ ( "CoA-Custom-1.4-main-20261007-b46a130e.zip", @@ -78,10 +75,10 @@ pub fn client_patch_status(client_path: &Path) -> ClientPatchStatus { let mut disabled_count = 0; for mpq in RACE_MPQS { - if data_dir.join(mpq).is_file() { - enabled_count += 1; - } else if data_dir.join(format!("{mpq}.disabled")).is_file() { + if data_dir.join(format!("{mpq}.disabled")).is_file() { disabled_count += 1; + } else if data_dir.join(mpq).is_file() { + enabled_count += 1; } } @@ -90,9 +87,9 @@ pub fn client_patch_status(client_path: &Path) -> ClientPatchStatus { || client_path.join("dinput8.dll.disabled").is_file(); let total = RACE_MPQS.len(); - let state = if enabled_count >= total - 2 { + let state = if enabled_count == total && client_path.join("dinput8.dll").is_file() { ClientPatchState::Enabled - } else if disabled_count >= total - 2 { + } else if disabled_count == total && client_path.join("dinput8.dll.disabled").is_file() { ClientPatchState::Disabled } else if (enabled_count + disabled_count) > 0 { ClientPatchState::PartiallyInstalled @@ -115,67 +112,56 @@ pub fn set_client_patch_enabled(client_path: &Path, enabled: bool) -> Result<()> return Err(Error::Invalid("Game client directory not found.".into())); } - let data_dir = client_path.join("Data"); - let manifest_path = client_path.join(MANIFEST_FILENAME); - - // 1. Process files from manifest if it exists - if manifest_path.is_file() { - if let Ok(manifest_content) = fs::read_to_string(&manifest_path) { - for line in manifest_content.lines() { - let line = line.trim(); - if let Some(mpq) = line.strip_prefix("MPQ:") { - let mpq = mpq.trim(); - let active = data_dir.join(mpq); - let disabled = data_dir.join(format!("{mpq}.disabled")); - if enabled { - if disabled.is_file() { - let _ = fs::rename(&disabled, &active); - } - } else if active.is_file() { - let _ = fs::rename(&active, &disabled); - } - } else if line == "ROOT:dinput8.dll" { - let active = client_path.join("dinput8.dll"); - let disabled = client_path.join("dinput8.dll.disabled"); - if enabled { - if disabled.is_file() { - let _ = fs::rename(&disabled, &active); - } - } else if active.is_file() { - let _ = fs::rename(&active, &disabled); - } + if crate::client::is_running(client_path) { + return Err(Error::Invalid("Close the game before changing custom races patches.".into())); + } + if !client_path.join(MANIFEST_FILENAME).is_file() + && !RACE_MPQS.iter().skip(1).any(|name| client_path.join("Data").join(name).is_file() + || client_path.join("Data").join(format!("{name}.disabled")).is_file()) { + return Ok(()); + } + let mut paths: Vec = RACE_MPQS.iter().map(|name| client_path.join("Data").join(name)).collect(); + paths.push(client_path.join("dinput8.dll")); + paths.push(client_path.join("Ascension.exe")); + for active in paths { + let disabled = active.with_extension(format!("{}.disabled", active.extension().unwrap_or_default().to_string_lossy())); + let original = active.with_extension(format!("{}.original", active.extension().unwrap_or_default().to_string_lossy())); + if enabled && disabled.is_file() { + if active.is_file() { + if !original.is_file() { + return Err(Error::Invalid(format!("Cannot replace existing {} without an original backup.", active.display()))); } + fs::remove_file(&active)?; + } + fs::rename(&disabled, &active)?; + } else if !enabled && active.is_file() && !disabled.is_file() { + if active.file_name().is_some_and(|name| name == "Ascension.exe") && !original.is_file() { + continue; + } + fs::rename(&active, &disabled)?; + if original.is_file() { + fs::copy(&original, &active)?; } } } - // 2. Also ensure all known RACE_MPQS and dinput8.dll are toggled even if manifest is missing - for mpq in RACE_MPQS { - let active = data_dir.join(mpq); - let disabled = data_dir.join(format!("{mpq}.disabled")); - if enabled { - if disabled.is_file() && !active.is_file() { - let _ = fs::rename(&disabled, &active); - } - } else if active.is_file() { - let _ = fs::rename(&active, &disabled); - } - } - - let dll_active = client_path.join("dinput8.dll"); - let dll_disabled = client_path.join("dinput8.dll.disabled"); - if enabled { - if dll_disabled.is_file() && !dll_active.is_file() { - let _ = fs::rename(&dll_disabled, &dll_active); - } - } else if dll_active.is_file() { - let _ = fs::rename(&dll_active, &dll_disabled); - } - tracing::info!(enabled, "custom races client patches toggled"); Ok(()) } +fn verify_archive(path: &Path, filename: &str) -> Result<()> { + let expected = match filename { + "CoA-Custom-1.4-main-20261007-b46a130e.zip" => "eaf934b5283476aa7b5d6eca7200ceae016e866ddbdab726bd47f39739c9c802", + "CoA-Custom-1.4-client-part1.zip" => "669f8396e82f396125e0be2070b2bad782f0104c695939a88924ec97f4fc4d21", + "CoA-Custom-1.4-client-part2.zip" => "088b3ddcf292abccbc84453c0ccf4cd74ff6b20c848d5902dc83854553aecc8b", + _ => return Err(Error::Invalid("Unknown custom races archive.".into())), + }; + if fsx::sha256_file(path)? != expected { + return Err(Error::Invalid(format!("Custom races archive {filename} failed verification. Remove it and try again."))); + } + Ok(()) +} + /// Locate an archive locally in Downloads or cache, or download it from GitHub. fn resolve_or_download_archive( filename: &str, @@ -194,6 +180,7 @@ fn resolve_or_download_archive( if let Ok(meta) = candidate.metadata() { if meta.len() > 1000 { tracing::info!(path = %candidate.display(), "using existing archive in Downloads"); + verify_archive(&candidate, filename)?; return Ok(candidate); } } @@ -207,6 +194,7 @@ fn resolve_or_download_archive( if target.is_file() { if let Ok(meta) = target.metadata() { if meta.len() > 1000 { + verify_archive(&target, filename)?; return Ok(target); } } @@ -272,6 +260,7 @@ fn resolve_or_download_archive( dst.flush()?; drop(dst); + verify_archive(&part_path, filename)?; fs::rename(&part_path, &target)?; Ok(target) } @@ -286,6 +275,9 @@ pub fn install_client_patches( return Err(Error::Invalid("Game client directory does not exist.".into())); } + if crate::client::is_running(client_path) { + return Err(Error::Invalid("Close the game before installing custom races patches.".into())); + } let data_dir = client_path.join("Data"); fs::create_dir_all(&data_dir)?; @@ -304,6 +296,12 @@ pub fn install_client_patches( fs::copy(&t_mpq, &t_mpq_bak)?; } + let dll = client_path.join("dinput8.dll"); + let dll_backup = client_path.join("dinput8.dll.original"); + if dll.is_file() && !dll_backup.is_file() { + fs::copy(&dll, &dll_backup)?; + } + // 2. Resolve/download archives (0.05 .. 0.50) let http = reqwest::blocking::Client::builder() .user_agent(concat!("CoA-Server-Manager/", env!("CARGO_PKG_VERSION"))) @@ -425,6 +423,41 @@ pub fn install_client_patches( mod tests { use super::*; + #[test] + fn disabling_restores_original_client_files_and_enabling_restores_custom_files() { + let temp = tempfile::tempdir().unwrap(); + let data = temp.path().join("Data"); + fs::create_dir_all(&data).unwrap(); + for path in [data.join("patch-T.MPQ"), temp.path().join("Ascension.exe"), temp.path().join("dinput8.dll")] { + fs::write(&path, b"custom").unwrap(); + let backup = path.with_extension(format!("{}.original", path.extension().unwrap().to_string_lossy())); + fs::write(&backup, b"original").unwrap(); + } + fs::write(temp.path().join(MANIFEST_FILENAME), "MPQ:patch-T.MPQ\n").unwrap(); + set_client_patch_enabled(temp.path(), false).unwrap(); + assert_eq!(fs::read(data.join("patch-T.MPQ")).unwrap(), b"original"); + assert_eq!(fs::read(temp.path().join("Ascension.exe")).unwrap(), b"original"); + set_client_patch_enabled(temp.path(), true).unwrap(); + assert_eq!(fs::read(data.join("patch-T.MPQ")).unwrap(), b"custom"); + assert_eq!(fs::read(temp.path().join("Ascension.exe")).unwrap(), b"custom"); + } + + #[test] + fn an_unpatched_client_is_left_untouched() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("Data")).unwrap(); + fs::write(temp.path().join("Data/patch-T.MPQ"), b"original").unwrap(); + set_client_patch_enabled(temp.path(), false).unwrap(); + assert_eq!(fs::read(temp.path().join("Data/patch-T.MPQ")).unwrap(), b"original"); + } + + #[test] + fn corrupt_downloads_are_rejected() { + let temp = tempfile::NamedTempFile::new().unwrap(); + fs::write(temp.path(), b"corrupt archive").unwrap(); + assert!(verify_archive(temp.path(), ARCHIVES[0].0).is_err()); + } + #[test] fn test_patch_status_and_toggle() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index c7bde2b..642ed36 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -428,6 +428,10 @@ fn game_args(cfg: &Config, n: &Names, kind: GameKind, host: &Path, data: &Path, a.extend(env("AC_UPDATES_ENABLE_DATABASES", "0")); if kind == GameKind::World { a.extend(["--volume".into(), format!("{}:{DATA}:ro", data.display())]); + let races = host.join("Data/dbc_races"); + if races.is_dir() && races != data.join("dbc_races") { + a.extend(["--volume".into(), format!("{}:{DATA}/dbc_races:ro", races.display())]); + } a.extend(env("AC_DATA_DIR", DATA)); // The remote console is only ever reachable from this computer. a.extend(["--publish".into(), format!("127.0.0.1:{ra_port}:{RA_PORT}")]); diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index 250a311..ba7894b 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -203,20 +203,19 @@ pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> { Some(lock) } else { None }; if on { crate::realms::guard_module(root, id)?; } - set_enabled_in(&catalog(), root, meta, id, on)?; if id == "custom-races" { if let Ok(meta_content) = fs::read_to_string(meta.join("install.json")) { if let Ok(install_meta) = serde_json::from_str::(&meta_content) { if let Some(cp) = install_meta.client_path { let client_path = PathBuf::from(cp); if client_path.is_dir() { - let _ = crate::custom_races::set_client_patch_enabled(&client_path, on); + crate::custom_races::set_client_patch_enabled(&client_path, on)?; } } } } } - Ok(()) + set_enabled_in(&catalog(), root, meta, id, on) } fn bot_enabled(root: &Path, e: &Entry) -> Result { diff --git a/package-lock.json b/package-lock.json index 0b88e6d..f08d81b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.8", + "version": "0.6.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.8", + "version": "0.6.9", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 267edad..a580101 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.8", + "version": "0.6.9", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc1f599..1d9ade2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -686,7 +686,9 @@ async fn custom_races_install(app: AppHandle, state: State<'_, AppState>, id: St "step": step, "percent": percent, })); - }) + })?; + let enabled = coa_core::modules::list(&root).iter().any(|module| module.id == "custom-races" && module.enabled); + coa_core::custom_races::set_client_patch_enabled(&client, enabled) }) .await; end_client_job(&state); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index dae2b4a..8116dd4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CoA Server Manager", - "version": "0.6.8", + "version": "0.6.9", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/screens/ModulesPage.tsx b/src/screens/ModulesPage.tsx index 6808284..5431518 100644 --- a/src/screens/ModulesPage.tsx +++ b/src/screens/ModulesPage.tsx @@ -257,7 +257,7 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId size="sm" variant="secondary" className="h-7 px-3 text-xs border border-gold/40 hover:border-gold" - disabled={racesInstalling} + disabled={racesInstalling || !racesPatchStatus?.clientPath} onClick={() => void installClientPatch()} > {racesInstalling && }