mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Speed up Custom Races patch moves and show switching feedback
This commit is contained in:
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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
/** Simplified Chinese localization (zh-CN) */
|
||||
const zh = {
|
||||
"mod.switching": "正在应用更改…请稍候。",
|
||||
"portable.experimental": "实验性功能:便携角色仍在积极开发中,可能存在错误。",
|
||||
"remote.clientReady": "客户端已就绪。保存服主地址,然后点击开始游戏。",
|
||||
"remote.clientIntro": "选择已有游戏客户端,或在此下载。无需安装本地服务器。",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user