mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
feat(races): ship client integration in Manager 0.6.9
This commit is contained in:
1 parent
d20d5a6410
commit
374a3f9c23
11 files changed
+118
-73
No files matched your search
Generated
+3
-3
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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}")]);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
Reference in new issue
Block a user