diff --git a/changelog.d/20261021-custom-races-switching.md b/changelog.d/20261021-custom-races-switching.md new file mode 100644 index 0000000..1afe131 --- /dev/null +++ b/changelog.d/20261021-custom-races-switching.md @@ -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. diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index 6398f67..e221b6d 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -325,6 +325,8 @@ pub fn client_patch_status(client_path: &Path) -> ClientPatchStatus { struct SavedFile { path: String, existed: bool, + #[serde(default)] + moved_to: Option, } 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 = fsx::read_json(&directory.join("journal.json"))?; - for (index, entry) in entries.iter().enumerate().rev() { + let mut entries: Vec = 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(); diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 956e1b2..1eeb37b 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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> = { + "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.", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9079937..165d573 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 3ea42a7..df78ed5 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -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> = { + "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.", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 623f905..e004640 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -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> = { + "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.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index f07e602..71f73e0 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -2,6 +2,7 @@ import type en from "./en"; /** Русский. Черновой перевод; требует проверки носителем перед релизом. */ const ru: Partial> = { + "mod.switching": "Применяем изменения… Подождите.", "portable.experimental": "Экспериментальная функция: портативные персонажи ещё в активной разработке. Возможны ошибки.", "remote.clientReady": "Клиент готов. Сохраните адрес хоста и нажмите «Играть».", "remote.clientIntro": "Выберите существующий клиент игры или скачайте его здесь. Локальный сервер не требуется.", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 664b582..266fc8b 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -1,5 +1,6 @@ /** Simplified Chinese localization (zh-CN) */ const zh = { + "mod.switching": "正在应用更改…请稍候。", "portable.experimental": "实验性功能:便携角色仍在积极开发中,可能存在错误。", "remote.clientReady": "客户端已就绪。保存服主地址,然后点击开始游戏。", "remote.clientIntro": "选择已有游戏客户端,或在此下载。无需安装本地服务器。", diff --git a/src/screens/ModulesPage.tsx b/src/screens/ModulesPage.tsx index 5cba9dd..475ff0c 100644 --- a/src/screens/ModulesPage.tsx +++ b/src/screens/ModulesPage.tsx @@ -87,6 +87,8 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId const [modules, setModules] = useState(null); const [open, setOpen] = useState(null); const [error, setError] = useState(null); + const toggleLock = useRef(false); + const [toggling, setToggling] = useState(null); const [changed, setChanged] = useState(false); const [racesPatchStatus, setRacesPatchStatus] = useState(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" && {t("mod.alwaysOn")}} {!m.installed && m.status !== "soon" && {t("mod.notHere")}} + {toggling === m.id &&

{t("mod.switching")}

} {m.id === "custom-races" && (
@@ -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 && } {racesPatchStatus?.state === "not-installed" ? (locale === "ru" ? "Установить патч клиента" : "Install Client Patch") : (locale === "ru" ? "Восстановить патч клиента" : "Repair Client Patch")} )} - {racesPatchStatus && racesPatchStatus.state !== "not-installed" && } + {racesPatchStatus && racesPatchStatus.state !== "not-installed" && }

{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.")}

)}