Speed up Custom Races patch moves and show switching feedback

This commit is contained in:
Дмитрий committed 2026-10-21 10:46:48 +05:00
1 parent 40213213bd
commit e27357d1b0
9 files changed
+93 -11

No files matched your search

@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: players
title: Switching Custom Races patches is faster and shows when changes are being applied
---
The Manager avoids copying large patches when switching them and prevents repeated clicks while the operation is running.
+66 -8
View File
@@ -325,6 +325,8 @@ pub fn client_patch_status(client_path: &Path) -> ClientPatchStatus {
struct SavedFile {
path: String,
existed: bool,
#[serde(default)]
moved_to: Option<String>,
}
struct PatchTransaction<'a> {
@@ -356,6 +358,7 @@ impl<'a> PatchTransaction<'a> {
self.entries.push(SavedFile {
path: relative,
existed: path.is_file(),
moved_to: None,
});
fsx::atomic_write_json(
&self.root.join(".coa-races-transaction/journal.json"),
@@ -382,19 +385,34 @@ impl<'a> PatchTransaction<'a> {
Ok(())
}
fn move_file(&mut self, source: &Path, target: &Path) -> Result<()> {
// Save the destination first so recovery restores the moved source before it.
self.save(target)?;
self.save(source)?;
let index = self
.entries
.iter()
.position(|entry| self.root.join(&entry.path) == source)
.unwrap();
// Copy from the journal so rollback retains the previous bytes.
let saved = self
.root
.join(".coa-races-transaction")
.join(index.to_string());
self.copy(&saved, target)
let relative = target
.strip_prefix(self.root)
.map_err(|_| Error::Invalid("Patch path escaped the client directory.".into()))?
.to_string_lossy()
.replace('\\', "/");
self.entries[index].moved_to = Some(relative);
fsx::atomic_write_json(
&self.root.join(".coa-races-transaction/journal.json"),
&self.entries,
)?;
fs::create_dir_all(target.parent().unwrap())?;
fs::rename(
self.root
.join(".coa-races-transaction")
.join(index.to_string()),
target,
)?;
Ok(())
}
fn finish(mut self) -> Result<()> {
fsx::atomic_write(
&self.root.join(".coa-races-transaction/committed"),
@@ -419,10 +437,21 @@ fn recover_patch_transaction(root: &Path) -> Result<()> {
return Ok(());
}
if !directory.join("committed").exists() && directory.join("journal.json").exists() {
let entries: Vec<SavedFile> = fsx::read_json(&directory.join("journal.json"))?;
for (index, entry) in entries.iter().enumerate().rev() {
let mut entries: Vec<SavedFile> = fsx::read_json(&directory.join("journal.json"))?;
for index in (0..entries.len()).rev() {
let entry = &entries[index];
let path = fsx::safe_join(root, &entry.path)?;
let saved = directory.join(index.to_string());
if let Some(target) = &entry.moved_to {
let target = fsx::safe_join(root, target)?;
if !saved.is_file() && target.is_file() {
fs::rename(target, &saved)?;
}
// Persist recovery progress before restoring the source, making retries safe.
entries[index].moved_to = None;
fsx::atomic_write_json(&directory.join("journal.json"), &entries)?;
}
let entry = &entries[index];
if saved.is_file() {
if path.is_file() {
fs::remove_file(&path)?;
@@ -1182,6 +1211,35 @@ mod tests {
assert_eq!(fs::read(&file).unwrap(), b"previous");
}
#[test]
fn interrupted_patch_moves_restore_source_and_destination() {
for existing in [false, true] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let source = root.join("patch.MPQ");
let target = root.join("patch.MPQ.disabled");
fs::write(&source, b"patch bytes").unwrap();
if existing {
fs::write(&target, b"previous destination").unwrap();
}
let mut tx = PatchTransaction::new(root).unwrap();
tx.move_file(&source, &target).unwrap();
assert!(!source.exists());
assert_eq!(fs::read(&target).unwrap(), b"patch bytes");
// The source is moved, not copied into both the journal and destination.
assert!(!root.join(".coa-races-transaction/1").exists());
std::mem::forget(tx);
recover_patch_transaction(root).unwrap();
recover_patch_transaction(root).unwrap();
assert_eq!(fs::read(&source).unwrap(), b"patch bytes");
if existing {
assert_eq!(fs::read(&target).unwrap(), b"previous destination");
} else {
assert!(!target.exists());
}
}
}
#[test]
fn existing_backups_and_foreign_disabled_files_are_retained() {
let temp = tempfile::tempdir().unwrap();
+1
View File
@@ -2,6 +2,7 @@ import type en from "./en";
/** Deutsch. Entwurf einer Übersetzung; vor der Veröffentlichung von Muttersprachlern zu prüfen. */
const de: Partial<Record<keyof typeof en, string>> = {
"mod.switching": "Änderungen werden angewendet… Bitte warten.",
"portable.experimental": "Experimentelle Funktion: Portable Charaktere befinden sich noch in aktiver Entwicklung. Fehler können auftreten.",
"remote.clientReady": "Dein Client ist bereit. Speichere die Hostadresse und klicke auf Spielen.",
"remote.clientIntro": "Wähle einen vorhandenen Spielclient oder lade ihn hier herunter. Ein lokaler Server ist nicht nötig.",
+1
View File
@@ -1,5 +1,6 @@
/** English is the source of truth: every key must exist here; other locales fall back to it. */
const en = {
"mod.switching": "Applying changes… Please wait.",
"remote.clientReady": "Your client is ready. Save the host’s address, then press Play.",
"remote.clientIntro": "Choose an existing game client or download one here. No local server is needed.",
"welcome.host.title": "Host a server",
+1
View File
@@ -2,6 +2,7 @@ import type en from "./en";
/** Español. Traducción provisional; debe revisarla un hablante nativo antes de publicarse. */
const es: Partial<Record<keyof typeof en, string>> = {
"mod.switching": "Aplicando cambios… Espera un momento.",
"portable.experimental": "Función experimental: los personajes portátiles siguen en desarrollo activo. Pueden producirse errores.",
"remote.clientReady": "Tu cliente está listo. Guarda la dirección del anfitrión y pulsa Jugar.",
"remote.clientIntro": "Elige un cliente existente o descárgalo aquí. No necesitas un servidor local.",
+1
View File
@@ -2,6 +2,7 @@ import type en from "./en";
/** Français. Traduction provisoire ; à faire relire par un locuteur natif avant la publication. */
const fr: Partial<Record<keyof typeof en, string>> = {
"mod.switching": "Application des modifications… Veuillez patienter.",
"portable.experimental": "Fonction expérimentale : les personnages portables sont encore en développement actif. Des bugs peuvent survenir.",
"remote.clientReady": "Votre client est prêt. Enregistrez l’adresse de l’hôte, puis cliquez sur Jouer.",
"remote.clientIntro": "Choisissez un client existant ou téléchargez-le ici. Aucun serveur local n’est nécessaire.",
+1
View File
@@ -2,6 +2,7 @@ import type en from "./en";
/** Русский. Черновой перевод; требует проверки носителем перед релизом. */
const ru: Partial<Record<keyof typeof en, string>> = {
"mod.switching": "Применяем изменения… Подождите.",
"portable.experimental": "Экспериментальная функция: портативные персонажи ещё в активной разработке. Возможны ошибки.",
"remote.clientReady": "Клиент готов. Сохраните адрес хоста и нажмите «Играть».",
"remote.clientIntro": "Выберите существующий клиент игры или скачайте его здесь. Локальный сервер не требуется.",
+1
View File
@@ -1,5 +1,6 @@
/** Simplified Chinese localization (zh-CN) */
const zh = {
"mod.switching": "正在应用更改…请稍候。",
"portable.experimental": "实验性功能:便携角色仍在积极开发中,可能存在错误。",
"remote.clientReady": "客户端已就绪。保存服主地址,然后点击开始游戏。",
"remote.clientIntro": "选择已有游戏客户端,或在此下载。无需安装本地服务器。",
+14 -3
View File
@@ -87,6 +87,8 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId
const [modules, setModules] = useState<ModuleView[] | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [error, setError] = useState<UiError | null>(null);
const toggleLock = useRef(false);
const [toggling, setToggling] = useState<string | null>(null);
const [changed, setChanged] = useState(false);
const [racesPatchStatus, setRacesPatchStatus] = useState<CustomRacesClientStatus | null>(null);
const [racesInstalling, setRacesInstalling] = useState(false);
@@ -156,6 +158,9 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId
}
async function toggle(m: ModuleView) {
if (toggleLock.current || racesInstalling) return;
toggleLock.current = true;
setToggling(m.id);
setError(null);
try {
await api.moduleSetEnabled(serverId, m.id, !m.enabled);
@@ -167,6 +172,10 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId
onChanged?.();
} catch (e) {
setError(asUiError(e));
await load();
} finally {
toggleLock.current = false;
setToggling(null);
}
}
@@ -205,7 +214,8 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId
role="switch"
aria-checked={m.enabled}
aria-label={t("mod.switch", { name: m.name })}
disabled={m.id === "custom-races" && racesInstalling}
aria-busy={toggling === m.id}
disabled={toggling !== null || (m.id === "custom-races" && racesInstalling)}
onClick={() => void toggle(m)}
className={cn("relative mt-1 h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors", m.enabled ? "bg-gold" : "bg-white/15", (m.id === "custom-races" && racesInstalling) && "opacity-50 cursor-not-allowed")}
>
@@ -215,6 +225,7 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId
{m.installed && !m.switchable && m.status !== "soon" && m.compatibility !== "unsupported" && <span className="mt-1.5 shrink-0 text-xs text-muted">{t("mod.alwaysOn")}</span>}
{!m.installed && m.status !== "soon" && <span className="mt-1.5 shrink-0 text-xs text-muted">{t("mod.notHere")}</span>}
</div>
{toggling === m.id && <p role="status" className="flex items-center gap-2 text-sm text-gold"><Loader2 className="h-4 w-4 animate-spin" aria-hidden />{t("mod.switching")}</p>}
{m.id === "custom-races" && (
<div className="w-full">
@@ -271,14 +282,14 @@ 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 || !racesPatchStatus?.clientPath}
disabled={toggling !== null || racesInstalling || !racesPatchStatus?.clientPath}
onClick={() => void installClientPatch()}
>
{racesInstalling && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{racesPatchStatus?.state === "not-installed" ? (locale === "ru" ? "Установить патч клиента" : "Install Client Patch") : (locale === "ru" ? "Восстановить патч клиента" : "Repair Client Patch")}
</Button>
)}
{racesPatchStatus && racesPatchStatus.state !== "not-installed" && <Button size="sm" variant="ghost" disabled={racesInstalling} onClick={() => void uninstallClientPatch()}>{locale === "ru" ? "Удалить патч клиента" : "Uninstall Client Patch"}</Button>}
{racesPatchStatus && racesPatchStatus.state !== "not-installed" && <Button size="sm" variant="ghost" disabled={toggling !== null || racesInstalling} onClick={() => void uninstallClientPatch()}>{locale === "ru" ? "Удалить патч клиента" : "Uninstall Client Patch"}</Button>}
<p className="text-xs text-muted">{locale === "ru" ? (m.enabled ? "После установки патч будет включён." : "После установки патч останется отключённым: модуль сервера выключен или отсутствует.") : (m.enabled ? "The patch will be enabled after installation." : "The patch will stay disabled after installation: the server module is off or missing.")}</p>
</div>
)}