feat(races): ship client integration in Manager 0.6.9

This commit is contained in:
Дмитрий committed 2026-10-10 06:35:01 +05:00
1 parent d20d5a6410
commit 374a3f9c23
11 files changed
+118 -73

No files matched your search

Generated
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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"
+7
View File
@@ -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.
+93 -60
View File
@@ -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<PathBuf> = 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();
+4
View File
@@ -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}")]);
+2 -3
View File
@@ -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::<crate::registry::InstallMeta>(&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<bool> {
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+3 -1
View File
@@ -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);
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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 && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}