From c6e8a3450924e693620694fd1778fb4ba1856366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 06:56:03 +0500 Subject: [PATCH 01/12] Make the update check survive damaged launcher state and an unavailable database A crash during a launcher write can leave .state/*.json zero-filled, which made every launcher start (and so the update check) fail with "Something went wrong". Zero-filled state files are now discarded before each launcher verb, the check degrades to a warning when the database cannot be inspected, and database start failures carry the launcher's own output. Co-Authored-By: Claude Sonnet 5.5 --- ...008-update-check-survives-damaged-state.md | 7 +++ crates/coa-core/src/backup.rs | 3 +- crates/coa-core/src/driver.rs | 43 +++++++++++++++++++ crates/coa-core/src/update.rs | 15 ++++++- src/i18n/locales/de.ts | 1 + src/i18n/locales/en.ts | 1 + src/i18n/locales/es.ts | 1 + src/i18n/locales/fr.ts | 1 + src/i18n/locales/ru.ts | 1 + src/i18n/locales/zh.ts | 1 + src/lib/api.ts | 2 + src/lib/serverUpdateStatus.ts | 2 +- src/screens/SettingsHome.tsx | 6 ++- 13 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 changelog.d/20261008-update-check-survives-damaged-state.md diff --git a/changelog.d/20261008-update-check-survives-damaged-state.md b/changelog.d/20261008-update-check-survives-damaged-state.md new file mode 100644 index 0000000..b01e708 --- /dev/null +++ b/changelog.d/20261008-update-check-survives-damaged-state.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Checking for updates no longer fails with "Something went wrong" after an interrupted start +--- +A crash or power loss could leave a damaged status file that made every database start fail. The Manager now removes such files itself, still compares the files when the database is unavailable, and shows the real reason. diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 8e21adb..c3094ea 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -163,7 +163,8 @@ pub fn with_database(root: &Path, f: impl FnOnce(&Db) -> Result) -> Result if !mysql_was_up { let out = driver::run(root, Verb::StartMysql)?; if !out.ok { - return Err(Error::Invalid(out.human.map(|h| h.title.to_string()).unwrap_or_else(|| "The database could not be started.".into()))); + // Keep the launcher's own last lines: the title alone ("Something went wrong") hides the cause. + return Err(Error::Invalid(format!("The database could not be started. {}", driver::startup_failure(root, &out)))); } } let result = f(&db); diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index b3c8a1e..930ed7b 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -136,6 +136,7 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result Result = redacted.lines().rev().take(12).collect(); + tracing::warn!(?verb, exit_code, output = %tail.into_iter().rev().collect::>().join("\n"), "driver: launcher verb failed"); + } if ok && verb == Verb::StartAll { crate::multiworld::start(&root)?; } Ok(DriverOutcome { ok, exit_code, code, human: code.map(ErrorCode::human), output }) } +/// The launcher keeps one JSON record per service in `.state`. A crash or power loss while it writes one can leave +/// a file of the right length that holds only zero bytes (NTFS keeps the length but not the data), and the launcher +/// then dies on every start with an unreadable record. Such a file carries no information, so remove it: the +/// launcher treats a missing record as "not running". Only empty or all-zero `*.json` files are touched. +pub(crate) fn discard_damaged_launcher_state(root: &Path) -> Vec { + let mut removed = Vec::new(); + let Ok(entries) = std::fs::read_dir(root.join(".state")) else { return removed; }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_none_or(|e| !e.eq_ignore_ascii_case("json")) || !path.is_file() { continue; } + let Ok(bytes) = std::fs::read(&path) else { continue; }; + if bytes.iter().all(|b| *b == 0) && std::fs::remove_file(&path).is_ok() { + tracing::warn!(file = %path.display(), bytes = bytes.len(), "driver: removed a launcher state file damaged by an interrupted write"); + removed.push(path); + } + } + removed +} + const LAUNCH_SCRIPT: &str = "import runpy,sys;from pathlib import Path;script=sys.argv[1];sys.path.insert(0,str(Path(script).resolve().parent));sys.argv=sys.argv[1:];runpy.run_path(script,run_name='__main__')"; pub(crate) fn patch_launcher_imports(text: &str) -> Result { @@ -251,6 +276,24 @@ mod tests { assert_eq!(Verb::StartWorld.timeout(folder.path()), Duration::from_secs(420)); } + #[test] + fn zero_filled_launcher_state_is_discarded_and_valid_state_is_kept() { + let folder = tempfile::tempdir().unwrap(); + let state = folder.path().join(".state"); + std::fs::create_dir_all(&state).unwrap(); + std::fs::write(state.join("mysql.json"), vec![0u8; 116]).unwrap(); + std::fs::write(state.join("configuration.json"), Vec::::new()).unwrap(); + std::fs::write(state.join("world.json"), "{\"pid\": 4}\n").unwrap(); + std::fs::write(state.join("stop-relay"), Vec::::new()).unwrap(); + std::fs::write(state.join("auth.json"), "{\"pid\": 0}").unwrap(); + let mut removed: Vec<_> = discard_damaged_launcher_state(folder.path()).iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect(); + removed.sort(); + assert_eq!(removed, ["configuration.json", "mysql.json"]); + assert!(state.join("world.json").exists() && state.join("auth.json").exists() && state.join("stop-relay").exists()); + assert!(discard_damaged_launcher_state(folder.path()).is_empty()); + assert!(discard_damaged_launcher_state(&folder.path().join("missing")).is_empty()); + } + #[test] fn translates_known_launcher_messages() { assert_eq!(translate("RuntimeError: Port 8085 is already used. Stop the other server"), ErrorCode::PortInUse); diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index a048010..7ff06ea 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -259,6 +259,10 @@ pub struct Preview { pub migrations: usize, pub pending_migrations: usize, pub download_bytes: u64, + /// Why the database could not be inspected. The check still answers about the files; applying the update starts + /// the database itself and reports its own failure. + #[serde(skip_serializing_if = "Option::is_none")] + pub database_check_error: Option, } /// Describe the update; a managed launcher may need a one-time authenticated payload check. @@ -277,7 +281,15 @@ pub fn preview(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &s item.reason = Some("already up to date with Manager integration".into()); } } - let pending_migrations = (RepackEnv { root, meta_dir: &meta_dir }).pending_migrations(&m)?; + // Counting pending SQL needs the database. If it cannot be started the files can still be compared, so report + // that instead of failing the whole check. + let (pending_migrations, database_check_error) = match (RepackEnv { root, meta_dir: &meta_dir }).pending_migrations(&m) { + Ok(count) => (count, None), + Err(error) => { + tracing::warn!(%error, "update check: the database could not be inspected"); + (m.migrations.len(), Some(error.to_string())) + } + }; Ok(Preview { from_version: meta.core.version.clone(), to_version: m.version.clone(), @@ -286,6 +298,7 @@ pub fn preview(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &s migrations: m.migrations.len(), pending_migrations, download_bytes: m.archive.as_ref().map(|a| a.parts.iter().map(|p| p.size).sum()).unwrap_or(0), + database_check_error, }) } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index c13b43a..7fc74bd 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -827,6 +827,7 @@ const de: Partial> = { "set.searchAll": "Alle Einstellungen nach Name oder Beschreibung durchsuchen…", "set.search": "Einstellungen durchsuchen…", "upd.current": "Du bist auf dem neuesten Stand (Version {v}).", + "upd.dbUnchecked": "Die Datenbank konnte nicht geprüft werden, daher sind offene Datenbankänderungen unbekannt: {why}", "client.job.checking": "Client wird geprüft…", "client.job.updating": "Client wird aktualisiert", "fr.lan.address": "Netzwerkadresse", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 03db274..af81ef5 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -848,6 +848,7 @@ const en = { "set.searchAll": "Search all settings by name or description…", "set.search": "Search settings…", "upd.current": "You are up to date (version {v}).", + "upd.dbUnchecked": "The database could not be checked, so pending database changes are unknown: {why}", "client.job.checking": "Checking the client…", "client.job.updating": "Updating the client", "fr.lan.address": "Network address", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 3bd4e70..4336f40 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -827,6 +827,7 @@ const es: Partial> = { "set.searchAll": "Buscar en todos los ajustes por nombre o descripción…", "set.search": "Buscar ajustes…", "upd.current": "Estás al día (versión {v}).", + "upd.dbUnchecked": "No se pudo comprobar la base de datos, así que se desconocen los cambios pendientes: {why}", "client.job.checking": "Comprobando el cliente…", "client.job.updating": "Actualizando el cliente", "fr.lan.address": "Dirección de red", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index beb10a4..83b52a2 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -827,6 +827,7 @@ const fr: Partial> = { "set.searchAll": "Rechercher dans tous les réglages par nom ou description…", "set.search": "Rechercher un réglage…", "upd.current": "Vous êtes à jour (version {v}).", + "upd.dbUnchecked": "La base de données n'a pas pu être vérifiée ; les modifications en attente sont donc inconnues : {why}", "client.job.checking": "Vérification du client…", "client.job.updating": "Mise à jour du client", "fr.lan.address": "Adresse réseau", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 7d24c29..a6ae392 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -846,6 +846,7 @@ const ru: Partial> = { "set.searchAll": "Поиск по всем настройкам: название или описание…", "set.search": "Поиск по настройкам…", "upd.current": "У вас последняя версия ({v}).", + "upd.dbUnchecked": "Не удалось проверить базу данных, поэтому неизвестно, нужны ли ей изменения: {why}", "client.job.checking": "Проверка клиента…", "client.job.updating": "Обновление клиента", "fr.lan.address": "Сетевой адрес", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index fe83254..9b21111 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -827,6 +827,7 @@ const zh = { "set.searchAll": "按名称或描述搜索全部设置…", "set.search": "搜索设置…", "upd.current": "当前已是最新版本 ({v})。", + "upd.dbUnchecked": "无法检查数据库,因此不清楚是否有待应用的数据库更改:{why}", "client.job.checking": "正在检查游戏客户端…", "client.job.updating": "正在更新游戏客户端", "err.docker_unavailable.title": "Docker 不可用", diff --git a/src/lib/api.ts b/src/lib/api.ts index 19f0539..5d2ddb4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -285,6 +285,8 @@ export interface UpdatePreview { migrations: number; pending_migrations?: number; download_bytes: number; + /** Set when the database could not be inspected; the file comparison is still valid. */ + database_check_error?: string; } export interface UpdateTxn { diff --git a/src/lib/serverUpdateStatus.ts b/src/lib/serverUpdateStatus.ts index be84a75..dd5a76d 100644 --- a/src/lib/serverUpdateStatus.ts +++ b/src/lib/serverUpdateStatus.ts @@ -3,5 +3,5 @@ import type { UpdatePreview } from "./api"; export function isUpdateCurrent(preview: UpdatePreview): boolean { return preview.from_version === preview.to_version && preview.items.every((item) => item.action === "skip") - && (preview.pending_migrations ?? (preview.migrations === 0 ? 0 : undefined)) === 0; + && (preview.database_check_error !== undefined || (preview.pending_migrations ?? (preview.migrations === 0 ? 0 : undefined)) === 0); } diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx index 1f5574f..60ab99a 100644 --- a/src/screens/SettingsHome.tsx +++ b/src/screens/SettingsHome.tsx @@ -202,6 +202,10 @@ export function SettingsHome({ serverId }: { serverId: string }) {

)} + {preview?.database_check_error && ( +

{t("upd.dbUnchecked", { why: preview.database_check_error })}

+ )} + {preview && isUpdateCurrent(preview) && (

{t("upd.current", { v: preview.to_version })}

@@ -214,7 +218,7 @@ export function SettingsHome({ serverId }: { serverId: string }) { {preview.from_version ? t("upd.availableFrom", { to: preview.to_version, from: preview.from_version }) : t("upd.available", { to: preview.to_version })}

- {(preview.pending_migrations ?? preview.migrations) > 0 + {!preview.database_check_error && (preview.pending_migrations ?? preview.migrations) > 0 ? t("upd.summaryDb", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length, db: preview.pending_migrations ?? preview.migrations }) : t("upd.summary", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length })}

From 072a657649f80d3b0202344b960817fd73106681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 07:01:20 +0500 Subject: [PATCH 02/12] Remove-server card, Accounts tab, quiet background update check, safer install cleanup - Settings: a card removes a stopped server from the Manager (no files deleted). - The Players tab is now Accounts in all languages. - The five-minute background check no longer starts and stops MySQL on a stopped server; only an explicit check may start it. - A failed install keeps its marker until the folder is complete, cleans up after a failure that happens after the move, and reports the launcher's output; install and update errors have a Copy button. Co-Authored-By: Claude Sonnet 5.5 --- crates/coa-core/src/backup.rs | 8 +++++++ crates/coa-core/src/install.rs | 34 ++++++++++++++++++++-------- crates/coa-core/src/update.rs | 38 ++++++++++++++++++++++++++----- src-tauri/src/lib.rs | 6 +++-- src/i18n/locales/de.ts | 11 ++++++--- src/i18n/locales/en.ts | 11 ++++++--- src/i18n/locales/es.ts | 11 ++++++--- src/i18n/locales/fr.ts | 11 ++++++--- src/i18n/locales/ru.ts | 11 ++++++--- src/i18n/locales/zh.ts | 11 ++++++--- src/lib/api.ts | 5 +++- src/lib/serverUpdate.ts | 8 +++---- src/lib/serverUpdateStatus.ts | 2 +- src/screens/InstallServer.tsx | 11 ++++++--- src/screens/RemoveServerCard.tsx | 39 ++++++++++++++++++++++++++++++++ src/screens/SettingsHome.tsx | 16 +++++++++---- src/screens/Shell.tsx | 2 +- 17 files changed, 185 insertions(+), 50 deletions(-) create mode 100644 src/screens/RemoveServerCard.tsx diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index c3094ea..41be4b6 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -177,6 +177,14 @@ pub fn with_database(root: &Path, f: impl FnOnce(&Db) -> Result) -> Result result } +/// Run `f` only if the database is already up; `None` (and nothing started) otherwise. +pub fn with_running_database(root: &Path, f: impl FnOnce(&Db) -> Result) -> Result> { + let db = Db::from_repack(root, Account::Admin)?; + let observed = process::observe(root, &read_ports(root)); + if observed.mysql.state != ServiceState::Running || !db.ping() { return Ok(None); } + f(&db).map(Some) +} + fn wanted_databases(kind: Kind) -> &'static [&'static str] { match kind { Kind::Quick | Kind::Database => &["characters", "auth"], diff --git a/crates/coa-core/src/install.rs b/crates/coa-core/src/install.rs index 2e88aa4..2d88ee2 100644 --- a/crates/coa-core/src/install.rs +++ b/crates/coa-core/src/install.rs @@ -183,7 +183,7 @@ fn bootstrap_database_inner(root: &Path) -> Result<()> { let started = driver::run(root, Verb::StartMysql)?; if !started.ok { - return Err(Error::Invalid(started.human.map(|h| h.message.to_string()).unwrap_or_else(|| "The database could not be started.".into()))); + return Err(Error::Invalid(format!("The database could not be started. {}", driver::startup_failure(root, &started)))); } let result = (|| -> Result<()> { let db = Db::from_repack(root, Account::Admin)?; @@ -210,8 +210,14 @@ fn bootstrap_database_inner(root: &Path) -> Result<()> { } Ok(()) })(); - let _ = driver::run(root, Verb::StopAll); + let stopped = driver::run(root, Verb::StopAll); result?; + // A database that keeps running locks its files, and the folder could not be moved into place afterwards. + match stopped { + Ok(out) if out.ok => {} + Ok(out) => return Err(Error::Invalid(format!("The database could not be stopped after preparing it. {}", driver::startup_failure(root, &out)))), + Err(e) => return Err(e), + } fs::remove_file(&boot)?; Ok(()) } @@ -250,7 +256,7 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result { if staging_root.join(MARKER).is_file() { fs::remove_dir_all(&staging_root)?; // leftover of an earlier failed attempt that we created } else { - return Err(Error::Invalid(format!("{} already exists and was not created by the Manager.", staging_root.display()))); + return Err(Error::Invalid(format!("{} is left over from an earlier installation that did not finish, and the Manager cannot tell whether it may be deleted. Delete that folder yourself, then try again.", staging_root.display()))); } } fs::create_dir_all(&staging_root)?; @@ -268,9 +274,8 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result { } Ok(()) })?; - fs::remove_file(staging_root.join(MARKER))?; - - // 4. Commit: the only moment the destination changes. + // 4. Commit: the only moment the destination changes. The marker stays until everything is done, so an + // unfinished install can always be recognised as ours (and cleaned up), wherever it stopped. say("Finishing", 95, None); if dest.exists() { fs::remove_dir(&dest)?; // only succeeds for an empty folder (preflight verified) @@ -288,15 +293,24 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result { } let md = MetaDir::create(&dest, &meta)?; fsx::atomic_write(&md.root.join("manifests").join("base.json"), &manifest_bytes)?; + // Complete from here on: the marker goes just before the server is listed. + fs::remove_file(dest.join(MARKER))?; p.registry.register(&meta.id, &dest)?; say("Ready", 100, None); Ok(Installed { id: meta.id, path: dest.to_string_lossy().into_owned(), version: m.version.clone() }) })(); - if result.is_err() && staging_root.join(MARKER).exists() { - // Unfinished work of ours; the destination was never touched. - let _ = driver::run(&staging_root, Verb::StopAll); - let _ = fs::remove_dir_all(&staging_root); + if result.is_err() { + if staging_root.join(MARKER).exists() { + // Unfinished work of ours; the destination was never touched. + let _ = driver::run(&staging_root, Verb::StopAll); + let _ = fs::remove_dir_all(&staging_root); + } else if dest.join(MARKER).is_file() { + // Failed after the folder was moved into place. The destination was empty or absent before (preflight), + // so what carries our marker is only our unfinished install. + let _ = driver::run(&dest, Verb::StopAll); + let _ = fs::remove_dir_all(&dest); + } } result } diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 7ff06ea..26a9986 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -259,14 +259,29 @@ pub struct Preview { pub migrations: usize, pub pending_migrations: usize, pub download_bytes: u64, + /// False when the pending SQL was not counted (database stopped during a background check, or an error). + pub database_checked: bool, /// Why the database could not be inspected. The check still answers about the files; applying the update starts /// the database itself and reports its own failure. #[serde(skip_serializing_if = "Option::is_none")] pub database_check_error: Option, } +/// Whether looking for an update may start the database to count pending SQL. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DatabaseAccess { + /// An explicit check: start the database if it is stopped (and stop it again afterwards). + Start, + /// The background check that repeats every few minutes must not start and stop MySQL on a stopped server. + OnlyIfRunning, +} + /// Describe the update; a managed launcher may need a one-time authenticated payload check. pub fn preview(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &str, resolutions: &BTreeMap) -> Result { + preview_with(root, meta, source, trusted_key, resolutions, DatabaseAccess::Start) +} + +pub fn preview_with(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &str, resolutions: &BTreeMap, access: DatabaseAccess) -> Result { let meta_dir = crate::registry::metadata_dir_for(root)?; let _lock = operation_lock(&meta_dir)?; ensure_recovered(&meta_dir)?; @@ -283,11 +298,12 @@ pub fn preview(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &s } // Counting pending SQL needs the database. If it cannot be started the files can still be compared, so report // that instead of failing the whole check. - let (pending_migrations, database_check_error) = match (RepackEnv { root, meta_dir: &meta_dir }).pending_migrations(&m) { - Ok(count) => (count, None), + let (pending_migrations, database_checked, database_check_error) = match (RepackEnv { root, meta_dir: &meta_dir }).pending_migrations_with(&m, access) { + Ok(Some(count)) => (count, true, None), + Ok(None) => (m.migrations.len(), false, None), Err(error) => { tracing::warn!(%error, "update check: the database could not be inspected"); - (m.migrations.len(), Some(error.to_string())) + (m.migrations.len(), false, Some(error.to_string())) } }; Ok(Preview { @@ -298,6 +314,7 @@ pub fn preview(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &s migrations: m.migrations.len(), pending_migrations, download_bytes: m.archive.as_ref().map(|a| a.parts.iter().map(|p| p.size).sum()).unwrap_or(0), + database_checked, database_check_error, }) } @@ -748,8 +765,13 @@ pub struct RepackEnv<'a> { impl RepackEnv<'_> { pub fn pending_migrations(&self, manifest: &Manifest) -> Result { - if manifest.migrations.is_empty() { return Ok(0); } - crate::backup::with_database(self.root, |db| { + Ok(self.pending_migrations_with(manifest, DatabaseAccess::Start)?.unwrap_or(0)) + } + + /// `None` when the database is not running and `access` forbids starting it. + pub fn pending_migrations_with(&self, manifest: &Manifest, access: DatabaseAccess) -> Result> { + if manifest.migrations.is_empty() { return Ok(Some(0)); } + let count = |db: &crate::db::Db| -> Result { let realms = crate::realms::state(self.root)?; let mut modes = vec![realms.active]; if realms.wildcard_created { modes.push(if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }); } @@ -759,7 +781,11 @@ impl RepackEnv<'_> { count += crate::migrations::pending_count(&db.clone().for_realm(mode), &list)?; } Ok(count) - }) + }; + match access { + DatabaseAccess::Start => crate::backup::with_database(self.root, count).map(Some), + DatabaseAccess::OnlyIfRunning => crate::backup::with_running_database(self.root, count), + } } pub(crate) fn verify_recovery_point(&self, point: &crate::backup::RecoveryPoint) -> Result<()> { if point.kind != crate::backup::Kind::Full || !["characters", "auth", "world", "configs"].iter().all(|name| point.components.iter().any(|c| c.name == *name)) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3592012..08c3133 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -684,12 +684,14 @@ fn install_meta(root: &std::path::Path) -> Result<(PathBuf, InstallMeta)> { } #[tauri::command] -async fn check_update(state: State<'_, AppState>, id: String, source: Option) -> std::result::Result { +async fn check_update(state: State<'_, AppState>, id: String, source: Option, background: Option) -> std::result::Result { let root = path_of(&state, &id)?; let src = update_source(source); + // The check that repeats every few minutes must not start and stop the database of a stopped server. + let access = if background.unwrap_or(false) { update::DatabaseAccess::OnlyIfRunning } else { update::DatabaseAccess::Start }; blocking(move || { let (_, meta) = install_meta(&root)?; - update::preview(&root, &meta, &src, coa_core::signing::EMBEDDED_PUBLIC_KEY, &Default::default()) + update::preview_with(&root, &meta, &src, coa_core::signing::EMBEDDED_PUBLIC_KEY, &Default::default(), access) }) .await } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 7fc74bd..fc9f56b 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -20,7 +20,7 @@ const de: Partial> = { "nav.overview": "Übersicht", "nav.bots": "Bots", "nav.server": "Server", - "nav.players": "Spieler", + "nav.players": "Konten", "nav.friends": "Mit Freunden spielen", "nav.backups": "Sicherungen", "nav.console": "Konsole", @@ -32,7 +32,7 @@ const de: Partial> = { "q.overview": "Läuft mein Server?", "q.bots": "Wie sind meine Bots eingestellt?", "q.server": "Wie verhält sich mein Server?", - "q.players": "Wer spielt gerade?", + "q.players": "Wer kann sich im Spiel anmelden?", "q.friends": "Wie kann mein Freund beitreten?", "q.backups": "Sind meine Daten sicher?", "q.console": "Was macht der Server gerade?", @@ -167,7 +167,7 @@ const de: Partial> = { "install.open": "MEINEN SERVER ÖFFNEN", // Players - "players.title": "Spieler", + "players.title": "Konten", "players.createTitle": "Erstelle dein Konto", "players.createText": "Das sind der Name und das Passwort, mit denen du dich im Spiel anmeldest.", "players.startFirst": "Starte zuerst den Server – Konten werden erstellt, solange er läuft.", @@ -827,6 +827,11 @@ const de: Partial> = { "set.searchAll": "Alle Einstellungen nach Name oder Beschreibung durchsuchen…", "set.search": "Einstellungen durchsuchen…", "upd.current": "Du bist auf dem neuesten Stand (Version {v}).", + "remove.title": "Diesen Server aus dem Manager entfernen", + "remove.text": "Der Manager zeigt diesen Server nicht mehr an. Es werden keine Dateien, Datenbanken oder Sicherungen gelöscht; den Ordner kannst du später über „Weiteren Server verwalten“ wieder hinzufügen.", + "remove.button": "Aus dem Manager entfernen", + "remove.stopFirst": "Halte zuerst den Server an.", + "remove.confirm": "Diesen Server aus dem Manager entfernen?\n\n{path}\n\nEs werden keine Dateien gelöscht.", "upd.dbUnchecked": "Die Datenbank konnte nicht geprüft werden, daher sind offene Datenbankänderungen unbekannt: {why}", "client.job.checking": "Client wird geprüft…", "client.job.updating": "Client wird aktualisiert", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index af81ef5..c293a94 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -51,7 +51,7 @@ const en = { "nav.overview": "Overview", "nav.bots": "Bots", "nav.server": "Server", - "nav.players": "Players", + "nav.players": "Accounts", "nav.friends": "Play with Friends", "nav.backups": "Backups", "nav.console": "Console", @@ -63,7 +63,7 @@ const en = { "q.overview": "Is my server running?", "q.bots": "How are my bots configured?", "q.server": "How does my server behave?", - "q.players": "Who is playing right now?", + "q.players": "Who can log in to the game?", "q.friends": "How can my friend join?", "q.backups": "Is my data safe?", "q.console": "What is the server doing?", @@ -200,7 +200,7 @@ const en = { "install.open": "OPEN MY SERVER", // Players - "players.title": "Players", + "players.title": "Accounts", "players.createTitle": "Create your account", "players.createText": "This is the name and password you use to log in to the game.", "players.startFirst": "Start the server first — accounts are created while it is running.", @@ -848,6 +848,11 @@ const en = { "set.searchAll": "Search all settings by name or description…", "set.search": "Search settings…", "upd.current": "You are up to date (version {v}).", + "remove.title": "Remove this server from the Manager", + "remove.text": "The Manager stops listing this server. No files, databases or backups are deleted, and you can add the folder again later with “Manage another server”.", + "remove.button": "Remove from Manager", + "remove.stopFirst": "Stop the server first.", + "remove.confirm": "Remove this server from the Manager?\n\n{path}\n\nNo files are deleted.", "upd.dbUnchecked": "The database could not be checked, so pending database changes are unknown: {why}", "client.job.checking": "Checking the client…", "client.job.updating": "Updating the client", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 4336f40..d6432ba 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -20,7 +20,7 @@ const es: Partial> = { "nav.overview": "Resumen", "nav.bots": "Bots", "nav.server": "Servidor", - "nav.players": "Jugadores", + "nav.players": "Cuentas", "nav.friends": "Jugar con amigos", "nav.backups": "Copias de seguridad", "nav.console": "Consola", @@ -32,7 +32,7 @@ const es: Partial> = { "q.overview": "¿Está funcionando mi servidor?", "q.bots": "¿Cómo están configurados mis bots?", "q.server": "¿Cómo se comporta mi servidor?", - "q.players": "¿Quién está jugando ahora?", + "q.players": "¿Quién puede entrar al juego?", "q.friends": "¿Cómo puede unirse mi amigo?", "q.backups": "¿Están seguros mis datos?", "q.console": "¿Qué está haciendo el servidor?", @@ -167,7 +167,7 @@ const es: Partial> = { "install.open": "ABRIR MI SERVIDOR", // Players - "players.title": "Jugadores", + "players.title": "Cuentas", "players.createTitle": "Crea tu cuenta", "players.createText": "Este es el nombre y la contraseña con los que inicias sesión en el juego.", "players.startFirst": "Inicia primero el servidor: las cuentas se crean mientras está en marcha.", @@ -827,6 +827,11 @@ const es: Partial> = { "set.searchAll": "Buscar en todos los ajustes por nombre o descripción…", "set.search": "Buscar ajustes…", "upd.current": "Estás al día (versión {v}).", + "remove.title": "Quitar este servidor del Manager", + "remove.text": "El Manager deja de mostrar este servidor. No se borran archivos, bases de datos ni copias de seguridad, y puedes volver a añadir la carpeta con «Gestionar otro servidor».", + "remove.button": "Quitar del Manager", + "remove.stopFirst": "Detén primero el servidor.", + "remove.confirm": "¿Quitar este servidor del Manager?\n\n{path}\n\nNo se borra ningún archivo.", "upd.dbUnchecked": "No se pudo comprobar la base de datos, así que se desconocen los cambios pendientes: {why}", "client.job.checking": "Comprobando el cliente…", "client.job.updating": "Actualizando el cliente", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 83b52a2..0aa1d4e 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -20,7 +20,7 @@ const fr: Partial> = { "nav.overview": "Vue d’ensemble", "nav.bots": "Bots", "nav.server": "Serveur", - "nav.players": "Joueurs", + "nav.players": "Comptes", "nav.friends": "Jouer avec des amis", "nav.backups": "Sauvegardes", "nav.console": "Console", @@ -32,7 +32,7 @@ const fr: Partial> = { "q.overview": "Mon serveur fonctionne-t-il ?", "q.bots": "Comment mes bots sont-ils configurés ?", "q.server": "Comment se comporte mon serveur ?", - "q.players": "Qui joue en ce moment ?", + "q.players": "Qui peut se connecter au jeu ?", "q.friends": "Comment mon ami peut-il se connecter ?", "q.backups": "Mes données sont-elles en sécurité ?", "q.console": "Que fait le serveur ?", @@ -167,7 +167,7 @@ const fr: Partial> = { "install.open": "OUVRIR MON SERVEUR", // Players - "players.title": "Joueurs", + "players.title": "Comptes", "players.createTitle": "Créez votre compte", "players.createText": "C’est le nom et le mot de passe que vous utilisez pour vous connecter au jeu.", "players.startFirst": "Démarrez d’abord le serveur : les comptes sont créés pendant qu’il fonctionne.", @@ -827,6 +827,11 @@ const fr: Partial> = { "set.searchAll": "Rechercher dans tous les réglages par nom ou description…", "set.search": "Rechercher un réglage…", "upd.current": "Vous êtes à jour (version {v}).", + "remove.title": "Retirer ce serveur du Manager", + "remove.text": "Le Manager n’affiche plus ce serveur. Aucun fichier, base de données ni sauvegarde n’est supprimé, et vous pourrez rajouter le dossier plus tard avec « Gérer un autre serveur ».", + "remove.button": "Retirer du Manager", + "remove.stopFirst": "Arrêtez d’abord le serveur.", + "remove.confirm": "Retirer ce serveur du Manager ?\n\n{path}\n\nAucun fichier n’est supprimé.", "upd.dbUnchecked": "La base de données n'a pas pu être vérifiée ; les modifications en attente sont donc inconnues : {why}", "client.job.checking": "Vérification du client…", "client.job.updating": "Mise à jour du client", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index a6ae392..8a23d89 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -51,7 +51,7 @@ const ru: Partial> = { "nav.overview": "Обзор", "nav.bots": "Боты", "nav.server": "Сервер", - "nav.players": "Игроки", + "nav.players": "Аккаунты", "nav.friends": "Игра с друзьями", "nav.backups": "Резервные копии", "nav.console": "Консоль", @@ -63,7 +63,7 @@ const ru: Partial> = { "q.overview": "Мой сервер работает?", "q.bots": "Как настроены мои боты?", "q.server": "Как ведёт себя мой сервер?", - "q.players": "Кто сейчас играет?", + "q.players": "Кто может войти в игру?", "q.friends": "Как подключиться другу?", "q.backups": "Мои данные в безопасности?", "q.console": "Что делает сервер?", @@ -198,7 +198,7 @@ const ru: Partial> = { "install.open": "ОТКРЫТЬ МОЙ СЕРВЕР", // Players - "players.title": "Игроки", + "players.title": "Аккаунты", "players.createTitle": "Создайте свою учётную запись", "players.createText": "Это имя и пароль, с которыми вы входите в игру.", "players.startFirst": "Сначала запустите сервер — учётные записи создаются, пока он работает.", @@ -846,6 +846,11 @@ const ru: Partial> = { "set.searchAll": "Поиск по всем настройкам: название или описание…", "set.search": "Поиск по настройкам…", "upd.current": "У вас последняя версия ({v}).", + "remove.title": "Убрать сервер из Manager", + "remove.text": "Manager перестанет показывать этот сервер. Файлы, базы данных и резервные копии не удаляются, папку можно добавить снова через «Управлять другим сервером».", + "remove.button": "Убрать из Manager", + "remove.stopFirst": "Сначала остановите сервер.", + "remove.confirm": "Убрать этот сервер из Manager?\n\n{path}\n\nФайлы не удаляются.", "upd.dbUnchecked": "Не удалось проверить базу данных, поэтому неизвестно, нужны ли ей изменения: {why}", "client.job.checking": "Проверка клиента…", "client.job.updating": "Обновление клиента", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 9b21111..5386709 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -32,7 +32,7 @@ const zh = { "nav.overview": "概览", "nav.bots": "机器人", "nav.server": "服务器", - "nav.players": "玩家", + "nav.players": "账号", "nav.friends": "好友联机", "nav.backups": "备份", "nav.console": "控制台", @@ -44,7 +44,7 @@ const zh = { "q.overview": "服务器当前是否正在运行?", "q.bots": "机器人目前是如何配置的?", "q.server": "服务器机制与规则是如何运行的?", - "q.players": "当前有哪些玩家在线?", + "q.players": "谁可以登录游戏?", "q.friends": "好友如何加入我的服务器?", "q.backups": "我的数据是否安全?", "q.console": "服务器正在输出什么日志?", @@ -179,7 +179,7 @@ const zh = { "install.open": "打开我的服务器", // Players - "players.title": "玩家", + "players.title": "账号", "players.createTitle": "创建游戏账号", "players.createText": "用于登录游戏的账号名称和密码。", "players.startFirst": "请先启动服务器 —— 账号需要在服务器运行时创建。", @@ -827,6 +827,11 @@ const zh = { "set.searchAll": "按名称或描述搜索全部设置…", "set.search": "搜索设置…", "upd.current": "当前已是最新版本 ({v})。", + "remove.title": "从 Manager 中移除此服务器", + "remove.text": "Manager 将不再列出此服务器。不会删除任何文件、数据库或备份,之后可通过“管理其他服务器”重新添加该文件夹。", + "remove.button": "从 Manager 中移除", + "remove.stopFirst": "请先停止服务器。", + "remove.confirm": "要从 Manager 中移除此服务器吗?\n\n{path}\n\n不会删除任何文件。", "upd.dbUnchecked": "无法检查数据库,因此不清楚是否有待应用的数据库更改:{why}", "client.job.checking": "正在检查游戏客户端…", "client.job.updating": "正在更新游戏客户端", diff --git a/src/lib/api.ts b/src/lib/api.ts index 5d2ddb4..0713057 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -285,6 +285,8 @@ export interface UpdatePreview { migrations: number; pending_migrations?: number; download_bytes: number; + /** False when pending database changes were not counted (stopped server during a background check, or an error). */ + database_checked?: boolean; /** Set when the database could not be inspected; the file comparison is still valid. */ database_check_error?: string; } @@ -451,7 +453,8 @@ export const api = { cancelInstall: () => invoke("cancel_install"), createAccount: (id: string, username: string, password: string, administrator: boolean) => invoke("create_account", { id, username, password, administrator }), - checkUpdate: (id: string, source?: string) => invoke("check_update", { id, source: source ?? null }), + /** `background`: the repeating check, which must not start the database of a stopped server. */ + checkUpdate: (id: string, source?: string, background?: boolean) => invoke("check_update", { id, source: source ?? null, background: background ?? false }), pendingUpdate: (id: string) => invoke("pending_update", { id }), applyUpdate: (id: string, resolutions: Record, source?: string) => invoke<{ txn: UpdateTxn }>("apply_update", { id, source: source ?? null, resolutions }), diff --git a/src/lib/serverUpdate.ts b/src/lib/serverUpdate.ts index cc74040..eeab33e 100644 --- a/src/lib/serverUpdate.ts +++ b/src/lib/serverUpdate.ts @@ -34,12 +34,12 @@ function set(id: string, next: Partial) { listeners.forEach((l) => l()); } -export async function checkServerUpdate(id: string): Promise { +export async function checkServerUpdate(id: string, background = false): Promise { const cur = get(id); if (cur.applying || cur.checking) return; set(id, { checking: true }); try { - const p = await api.checkUpdate(id); + const p = await api.checkUpdate(id, undefined, background); set(id, { preview: p, available: !isUpdateCurrent(p), error: null }); } catch (e) { // A cached offer is no longer safe after an update or failed recheck. @@ -51,8 +51,8 @@ export async function checkServerUpdate(id: string): Promise { /** Check now and then every five minutes. Returns the function that stops it. */ export function startServerUpdatePolling(id: string): () => void { - void checkServerUpdate(id); - const t = setInterval(() => void checkServerUpdate(id), CHECK_EVERY_MS); + void checkServerUpdate(id, true); + const t = setInterval(() => void checkServerUpdate(id, true), CHECK_EVERY_MS); return () => clearInterval(t); } diff --git a/src/lib/serverUpdateStatus.ts b/src/lib/serverUpdateStatus.ts index dd5a76d..f32b52c 100644 --- a/src/lib/serverUpdateStatus.ts +++ b/src/lib/serverUpdateStatus.ts @@ -3,5 +3,5 @@ import type { UpdatePreview } from "./api"; export function isUpdateCurrent(preview: UpdatePreview): boolean { return preview.from_version === preview.to_version && preview.items.every((item) => item.action === "skip") - && (preview.database_check_error !== undefined || (preview.pending_migrations ?? (preview.migrations === 0 ? 0 : undefined)) === 0); + && (preview.database_check_error !== undefined || preview.database_checked === false || (preview.pending_migrations ?? (preview.migrations === 0 ? 0 : undefined)) === 0); } diff --git a/src/screens/InstallServer.tsx b/src/screens/InstallServer.tsx index 32a9c42..c9f92f8 100644 --- a/src/screens/InstallServer.tsx +++ b/src/screens/InstallServer.tsx @@ -158,9 +158,14 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;

{error.human.code === "unknown" ? t("install.failedTitle") : human(error.human).title}

{error.human.code === "unknown" ? error.technical : human(error.human).message}

{t("install.nothingInstalled")}

- +
+ + +
{showDetails &&
{error.technical}
} )} diff --git a/src/screens/RemoveServerCard.tsx b/src/screens/RemoveServerCard.tsx new file mode 100644 index 0000000..f8edfcc --- /dev/null +++ b/src/screens/RemoveServerCard.tsx @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; +import { api, asUiError } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { useT } from "@/i18n"; + +/** Takes a server out of the Manager's list. Nothing on disk is deleted, so the folder can be added again later. */ +export function RemoveServerCard({ serverId, path, onForget }: { serverId: string; path: string; onForget: () => Promise }) { + const t = useT(); + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let active = true; + void api.status(serverId).then((s) => { + if (!active) return; + const all = [s.observed.mysql, s.observed.auth, s.observed.world, ...(s.observed.secondary_world ? [s.observed.secondary_world] : [])]; + setRunning(all.some((x) => x.state === "running" || x.state === "starting" || x.state === "stopping")); + }).catch(() => { /* an unreadable status must not hide the button */ }); + return () => { active = false; }; + }, [serverId]); + + async function remove() { + if (!window.confirm(t("remove.confirm", { path }))) return; + setBusy(true); setError(null); + try { await onForget(); } + catch (e) { setError(asUiError(e).technical); setBusy(false); } + } + + return +

{t("remove.title")}

+

{t("remove.text")}

+

{path}

+ + {running &&

{t("remove.stopFirst")}

} + {error &&

{error}

} +
; +} diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx index 60ab99a..f613a13 100644 --- a/src/screens/SettingsHome.tsx +++ b/src/screens/SettingsHome.tsx @@ -9,6 +9,7 @@ import { Card } from "@/components/ui/card"; import { ClientCard } from "@/screens/ClientCard"; import { DiagnosticsCard } from "@/screens/DiagnosticsCard"; import { AboutCard } from "@/screens/AboutCard"; +import { RemoveServerCard } from "@/screens/RemoveServerCard"; import { hasKey, useHuman, useI18n, type Key } from "@/i18n"; import { LanguagePicker } from "@/components/LanguagePicker"; import { RealmStartupSettings } from "@/screens/RealmStartupSettings"; @@ -18,7 +19,7 @@ function mb(bytes: number) { return bytes >= 1 << 20 ? `${(bytes / (1 << 20)).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`; } -export function SettingsHome({ serverId }: { serverId: string }) { +export function SettingsHome({ serverId, path, onForget }: { serverId: string; path: string; onForget: () => Promise }) { const { t } = useI18n(); const upd = useServerUpdate(serverId); const human = useHuman(); @@ -197,9 +198,14 @@ export function SettingsHome({ serverId }: { serverId: string }) { {done && pendingChecked && !pendingStateError &&

{done}

} {error && ( -

- {error.human.code === "unknown" ? error.technical : human(error.human).message} -

+
+

+ {error.human.code === "unknown" ? error.technical : human(error.human).message} +

+ +
)} {preview?.database_check_error && ( @@ -264,6 +270,8 @@ export function SettingsHome({ serverId }: { serverId: string }) {
)} + + ); } diff --git a/src/screens/Shell.tsx b/src/screens/Shell.tsx index aa4a804..4a6b134 100644 --- a/src/screens/Shell.tsx +++ b/src/screens/Shell.tsx @@ -139,7 +139,7 @@ export function Shell(props: { ) : page === "friends" ? ( ) : page === "settings" ? ( - + props.onForget(server.id)} /> ) : page === "players" ? ( ) : page === "backups" ? ( From f920ff8a59909ff7771ddc0db90dc95100ab6268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 07:04:25 +0500 Subject: [PATCH 03/12] Fix the stray window scrollbar, the Overview button overflow and show one card per realm - The main area and the console log are now positioning contexts, so screen-reader-only text inside them can no longer extend the whole window (the second scrollbar in the update dialog and the window scroll on the Console database tab). - The client button group on the server card wraps instead of leaving the card. - With both worlds starting together, each realm gets its own card. Co-Authored-By: Claude Sonnet 5.5 --- src/screens/ConsolePage.tsx | 2 +- src/screens/Overview.tsx | 40 ++++++++++++++++++++++++++++++++++--- src/screens/Shell.tsx | 2 +- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/screens/ConsolePage.tsx b/src/screens/ConsolePage.tsx index dfcef3d..945930d 100644 --- a/src/screens/ConsolePage.tsx +++ b/src/screens/ConsolePage.tsx @@ -103,7 +103,7 @@ export function ConsolePage({ serverId }: { serverId: string }) { -
+
{missing ?

{missing}

: lines.length === 0 ?

{t("con.empty")}

: lines.map((l, i) => (
{l.level !== "info" && {l.level}: } diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index a288bb7..ee3ebac 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -68,6 +68,19 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o const t = useT(); const human = useHuman(); const [status, setStatus] = useState(null); + // With both worlds started together, each realm gets its own card; the first realm owns the database and auth. + const [realms, setRealms] = useState<{ first: string; second: string } | null>(null); + const bothWorlds = !!status?.observed.secondary_world; + useEffect(() => { + if (!bothWorlds) { setRealms(null); return; } + let alive = true; + void api.realmProfiles(server.id).then((r) => { + if (!alive) return; + const name = (m: "coa" | "wildcard") => (m === "coa" ? "Conquest of Azeroth" : "Wildcard"); + setRealms({ first: name(r.active), second: name(r.active === "coa" ? "wildcard" : "coa") }); + }).catch(() => { if (alive) setRealms(null); }); + return () => { alive = false; }; + }, [bothWorlds, server.id]); const [action, setAction] = useState(null); const [realmBusy, setRealmBusy] = useState(false); const [failure, setFailure] = useState(null); @@ -220,7 +233,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o { onRealmChanged(); void poll(); }} /> -
{t("overview.server")}
+
{realms ? `${realms.first} · ${t("overview.server")}` : t("overview.server")}
- {secondary_world && }
@@ -301,7 +313,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o {t("btn.stopSmall")} )} -
+
{client && } {client && jobBusy ? (
+ {secondary_world && ( + +
{realms ? `${realms.second} · ${t("overview.server")}` : t("realm.secondWorld")}
+
+ + {secondary_world.state === "running" ? t("status.online") : secondary_world.state === "starting" ? t("status.starting") : t("status.offline")} +
+
+ +
+
+
{t("overview.uptime")}
+
{secondary_world.state === "running" ? formatUptime(secondary_world.uptime_secs) : "—"}
+
+

{t("realm.bothRunning")}

+
+ )} + {conflict && !anyUp && (

{t("overview.portTitle")}

diff --git a/src/screens/Shell.tsx b/src/screens/Shell.tsx index 4a6b134..cf3fcfb 100644 --- a/src/screens/Shell.tsx +++ b/src/screens/Shell.tsx @@ -125,7 +125,7 @@ export function Shell(props: {
-
+
{page === "overview" ? ( props.onForget(server.id)} onOpenUpdates={() => setPage("settings")} onRealmChanged={reloadModules} onReport={() => setPage("report")} /> ) : page === "bots" || page === "server" ? ( From 337418afe0d2345ac0916a90e5de18d36edbf522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 07:08:09 +0500 Subject: [PATCH 04/12] Add a configurable backup folder and drop-down bot settings Backups can be stored in a chosen folder (new backups go there, older ones stay listed), and a backups folder that is a link to another drive no longer fails the update's safety backup check. SQUID settings that declare a fixed list of choices are edited as a drop-down and validated against it. Co-Authored-By: Claude Sonnet 5.5 --- .../20261008-backup-folder-and-bot-choices.md | 7 + .../20261008-bot-settings-drop-down.md | 7 + crates/coa-core/src/backup.rs | 123 +++++++++++++++++- crates/coa-core/src/squid.rs | 34 ++++- crates/coa-core/src/update.rs | 4 +- src-tauri/src/lib.rs | 14 ++ src/i18n/locales/de.ts | 6 + src/i18n/locales/en.ts | 6 + src/i18n/locales/es.ts | 6 + src/i18n/locales/fr.ts | 6 + src/i18n/locales/ru.ts | 6 + src/i18n/locales/zh.ts | 6 + src/lib/api.ts | 11 +- src/lib/moduleSettings.ts | 6 +- src/screens/BackupsPage.tsx | 22 +++- 15 files changed, 253 insertions(+), 11 deletions(-) create mode 100644 changelog.d/20261008-backup-folder-and-bot-choices.md create mode 100644 changelog.d/20261008-bot-settings-drop-down.md diff --git a/changelog.d/20261008-backup-folder-and-bot-choices.md b/changelog.d/20261008-backup-folder-and-bot-choices.md new file mode 100644 index 0000000..1cccc99 --- /dev/null +++ b/changelog.d/20261008-backup-folder-and-bot-choices.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Choose where backups are stored, for example on another drive +--- +Backups can now live in any folder outside the server folder. Existing backups stay listed. A backups folder that you moved and linked from the old place also works again for updates. diff --git a/changelog.d/20261008-bot-settings-drop-down.md b/changelog.d/20261008-bot-settings-drop-down.md new file mode 100644 index 0000000..898aaf3 --- /dev/null +++ b/changelog.d/20261008-bot-settings-drop-down.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Bot settings with a fixed list of values show a drop-down +--- +SQUID bots 1.9 describes the chat language as a list of languages. The Manager shows it as a drop-down with names instead of a bare number. diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 41be4b6..3914f23 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -69,10 +69,82 @@ pub struct RecoveryPoint { pub realm: crate::realms::Mode, } -fn backups_dir(meta: &Path) -> PathBuf { +const LOCATION_FILE: &str = "backup-location.json"; + +#[derive(Serialize, Deserialize)] +struct StoredLocation { + path: String, +} + +fn default_dir(meta: &Path) -> PathBuf { meta.join("backups") } +/// The folder the owner chose for backups (for example on another drive), if any. +fn custom_dir(meta: &Path) -> Option { + let stored: StoredLocation = fsx::read_json(&meta.join(LOCATION_FILE)).ok()?; + let path = PathBuf::from(stored.path.trim()); + path.is_absolute().then_some(path) +} + +/// Where new backups go. +fn backups_dir(meta: &Path) -> PathBuf { + custom_dir(meta).unwrap_or_else(|| default_dir(meta)) +} + +/// Every folder that may hold backups: the chosen one first, then the default one, so backups made before the +/// folder was changed stay visible and restorable. +fn backup_roots(meta: &Path) -> Vec { + let mut roots: Vec = custom_dir(meta).into_iter().collect(); + let default = default_dir(meta); + if !roots.contains(&default) { roots.push(default); } + roots +} + +#[derive(Debug, Serialize)] +pub struct BackupLocation { + pub path: String, + pub default_path: String, + pub is_default: bool, +} + +pub fn location(meta: &Path) -> BackupLocation { + let default = default_dir(meta); + let current = backups_dir(meta); + BackupLocation { is_default: current == default, path: current.to_string_lossy().into_owned(), default_path: default.to_string_lossy().into_owned() } +} + +/// Choose where new backups are stored (`None` = the default folder next to the server). Existing backups stay where +/// they are and remain listed. +pub fn set_location(root: &Path, meta: &Path, path: Option<&str>) -> Result { + let _lock = crate::update::operation_lock(meta)?; + crate::update::ensure_recovered(meta)?; + match path.map(str::trim).filter(|p| !p.is_empty()) { + None => { + let file = meta.join(LOCATION_FILE); + if file.exists() { fs::remove_file(file)?; } + } + Some(chosen) => { + let dir = PathBuf::from(chosen); + if !dir.is_absolute() { return Err(Error::Invalid("Choose a full folder path, for example D:\\CoA backups.".into())); } + if fsx::ensure_within(root, &dir).is_ok() { + return Err(Error::Invalid("Backups cannot be stored inside the server folder: they would be part of what is being backed up.".into())); + } + fs::create_dir_all(&dir).map_err(|e| Error::Invalid(format!("The folder cannot be created: {e}")))?; + let probe = dir.join(format!(".coa-write-test-{}", uuid::Uuid::new_v4().simple())); + fs::write(&probe, b"x").map_err(|e| Error::Invalid(format!("The folder is not writable: {e}")))?; + let _ = fs::remove_file(&probe); + fsx::atomic_write_json(&meta.join(LOCATION_FILE), &StoredLocation { path: dir.to_string_lossy().into_owned() })?; + } + } + Ok(location(meta)) +} + +/// The `backup.json` of a recovery point, wherever its folder is. +pub(crate) fn point_json(meta: &Path, id: &str) -> Result { + Ok(point_dir(meta, id)?.join("backup.json")) +} + fn id_ok(id: &str) -> bool { !id.is_empty() && id.len() < 100 && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && !id.contains("..") } @@ -81,19 +153,32 @@ fn point_dir(meta: &Path, id: &str) -> Result { if !id_ok(id) { return Err(Error::PathRejected(format!("bad backup id {id:?}"))); } + if let Some(found) = backup_roots(meta).into_iter().map(|root| root.join(id)).find(|dir| dir.is_dir()) { + return Ok(found); + } Ok(backups_dir(meta).join(id)) } pub fn list(meta: &Path) -> Vec { - let mut out: Vec = fs::read_dir(backups_dir(meta)) - .map(|rd| rd.filter_map(|e| e.ok()).filter_map(|e| fsx::read_json(&e.path().join("backup.json")).ok()).collect()) - .unwrap_or_default(); + let mut out: Vec = Vec::new(); + for root in backup_roots(meta) { + let found: Vec = fs::read_dir(&root) + .map(|rd| rd.filter_map(|e| e.ok()).filter_map(|e| fsx::read_json(&e.path().join("backup.json")).ok()).collect()) + .unwrap_or_default(); + for point in found { + if !out.iter().any(|p| p.id == point.id) { out.push(point); } + } + } out.sort_by(|a, b| b.id.cmp(&a.id)); out } pub fn get(meta: &Path, id: &str) -> Result { - let point: RecoveryPoint = fsx::read_json(&fsx::ensure_within(meta, &point_dir(meta, id)?.join("backup.json"))?).map_err(|_| Error::Invalid(format!("backup {id} was not found or its metadata is damaged")))?; + let dir = point_dir(meta, id)?; + // The folder holding the backups may itself be a junction or symlink to another drive (the owner moved it there); + // what must hold is that the recovery point stays inside that folder. + let holder = dir.parent().ok_or_else(|| Error::PathRejected(format!("bad backup folder for {id}")))?; + let point: RecoveryPoint = fsx::read_json(&fsx::ensure_within(holder, &dir.join("backup.json"))?).map_err(|_| Error::Invalid(format!("backup {id} was not found or its metadata is damaged")))?; if point.id != id || point.schema != 1 { return Err(Error::Invalid(format!("Backup {id} has inconsistent identity or an unsupported schema."))); } Ok(point) } @@ -499,6 +584,34 @@ mod tests { assert!(list(&meta).len() >= 2); } + #[test] + fn backups_can_live_in_a_chosen_folder_and_old_ones_stay_visible() { + let (d, root, meta) = setup(); + let before = create(&root, &meta, Kind::Config, Trigger::Manual, Some("default folder".into()), &|_| {}).unwrap(); + assert!(location(&meta).is_default); + + // inside the server folder is refused, a relative path is refused + assert!(set_location(&root, &meta, Some(root.join("backups").to_str().unwrap())).is_err()); + assert!(set_location(&root, &meta, Some("backups")).is_err()); + + let elsewhere = d.path().join("other drive").join("CoA backups"); + let loc = set_location(&root, &meta, Some(elsewhere.to_str().unwrap())).unwrap(); + assert!(!loc.is_default && elsewhere.is_dir()); + let after = create(&root, &meta, Kind::Config, Trigger::Manual, Some("chosen folder".into()), &|_| {}).unwrap(); + assert!(elsewhere.join(&after.id).join("backup.json").is_file(), "new backups go to the chosen folder"); + assert!(!default_dir(&meta).join(&after.id).exists()); + let ids: Vec<_> = list(&meta).into_iter().map(|p| p.id).collect(); + assert!(ids.contains(&before.id) && ids.contains(&after.id), "the old backup is still listed"); + assert!(verify(&meta, &before.id).unwrap().ok && verify(&meta, &after.id).unwrap().ok); + assert_eq!(point_json(&meta, &after.id).unwrap(), elsewhere.join(&after.id).join("backup.json")); + restore_configs(&root, &meta, &after.id).unwrap(); + delete(&meta, &after.id).unwrap(); + assert!(!elsewhere.join(&after.id).exists()); + + assert!(set_location(&root, &meta, None).unwrap().is_default); + assert!(list(&meta).iter().any(|p| p.id == before.id)); + } + #[test] fn verify_detects_tampering_and_restore_refuses_damaged_backup() { let (_d, root, meta) = setup(); diff --git a/crates/coa-core/src/squid.rs b/crates/coa-core/src/squid.rs index 5ae6897..67222b0 100644 --- a/crates/coa-core/src/squid.rs +++ b/crates/coa-core/src/squid.rs @@ -44,6 +44,15 @@ pub struct Field { pub default_if_missing: Option, pub min: Option, pub max: Option, + /// A fixed list of allowed values with their labels (SQUID 1.9+); the editor shows it as a drop-down. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub choices: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct Choice { + pub value: serde_json::Value, + pub label: String, } #[derive(Deserialize)] @@ -71,12 +80,20 @@ pub fn fields(root: &Path) -> crate::Result max) || !default_type(&field.kind, &field.default) || field.default_if_missing.as_ref().is_some_and(|value| !default_type(&field.kind, value)) + || !valid_choices(&field) || fields.contains_key(&field.key) { continue; } fields.insert(field.key.clone(), field); } Ok(fields) } +/// A list of choices must be small, labelled, free of repeats and made of values of the field's own type. +fn valid_choices(field: &Field) -> bool { + field.choices.len() <= 64 + && field.choices.iter().all(|c| !c.label.trim().is_empty() && c.label.len() <= 80 && default_type(&field.kind, &c.value)) + && field.choices.iter().enumerate().all(|(i, c)| field.choices[..i].iter().all(|earlier| scalar(&earlier.value) != scalar(&c.value))) +} + fn default_type(kind: &str, value: &serde_json::Value) -> bool { match kind { "bool" => value.is_boolean(), @@ -104,13 +121,28 @@ pub fn validate(field: &Field, value: &str) -> crate::Result<()> { && field.min.is_none_or(|min| number >= min) && field.max.is_none_or(|max| number <= max)), "string" => true, _ => false, - }; + } && (field.choices.is_empty() || field.choices.iter().any(|c| scalar(&c.value).as_deref() == Some(value))); if valid { Ok(()) } else { Err(crate::Error::Invalid(format!("{} must match its documented SquidBots type and range.", field.key))) } } #[cfg(test)] mod tests { use super::*; + #[test] + fn a_setting_with_choices_only_accepts_one_of_them() { + let root = tempfile::tempdir().unwrap(); + crate::fsx::atomic_write(&root.path().join("Core/configs/modules/playerbots.conf.settings.json"), br#"{"format":1,"settings":[ + {"key":"AiPlayerbot.BotTextLocale","type":"int","group":"chat","title":"Language","description":"x","default":-1,"min":-1,"max":8, + "choices":[{"value":-1,"label":"Auto"},{"value":0,"label":"English"},{"value":8,"label":"Russian"}]}, + {"key":"AiPlayerbot.Broken","type":"int","group":"chat","title":"b","description":"x","default":0,"choices":[{"value":"a","label":"wrong type"}]}]}"#).unwrap(); + let fields = fields(root.path()).unwrap(); + let locale = &fields["AiPlayerbot.BotTextLocale"]; + assert_eq!(locale.choices.len(), 3); + for ok in ["-1", "0", "8"] { assert!(validate(locale, ok).is_ok(), "{ok}"); } + for bad in ["1", "7", "x"] { assert!(validate(locale, bad).is_err(), "{bad}"); } + assert!(!fields.contains_key("AiPlayerbot.Broken"), "a choice of the wrong type invalidates the setting"); + } + #[test] fn upstream_settings_preserve_types_ranges_and_distinct_defaults() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 26a9986..9c7b7bf 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -452,7 +452,7 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { p.env.ensure_stopped()?; step(report, "Saving a recovery point", 52); txn.recovery_point = Some(p.env.snapshot().map_err(|e| Error::Invalid(format!("The update was not applied because the safety backup failed: {e}")))?); - let point_meta = meta_dir.join("backups").join(txn.recovery_point.as_deref().unwrap()).join("backup.json"); + let point_meta = crate::backup::point_json(meta_dir, txn.recovery_point.as_deref().unwrap())?; if point_meta.is_file() { txn.recovery_hashes.insert("@point".into(), fsx::sha256_file(&point_meta)?); } txn.ops = items @@ -595,7 +595,7 @@ fn restore_transaction(root: &Path, meta: &Path, before: &Path, txn: &mut Txn, e for (rel, expected) in &txn.recovery_hashes { let saved = match rel.as_str() { "@install" => before.join("manager-install.json"), - "@point" => meta.join("backups").join(txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The recovery point identity is missing.".into()))?).join("backup.json"), + "@point" => crate::backup::point_json(meta, txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The recovery point identity is missing.".into()))?)?, _ => fsx::safe_join(before, rel)?, }; if fsx::sha256_file(&saved)? != *expected { return Err(Error::Invalid(format!("Recovery copy {rel} is damaged; no restoration was started."))); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 08c3133..36a5bb8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -353,6 +353,18 @@ impl Drop for BusyGuard<'_> { } } +#[tauri::command] +fn backup_location(state: State<'_, AppState>, id: String) -> std::result::Result { + let root = path_of(&state, &id)?; + Ok(backup::location(&meta_dir(&root)?)) +} + +#[tauri::command] +async fn set_backup_location(state: State<'_, AppState>, id: String, path: Option) -> std::result::Result { + let root = path_of(&state, &id)?; + blocking(move || backup::set_location(&root, &meta_dir(&root)?, path.as_deref())).await +} + #[tauri::command] fn list_backups(state: State<'_, AppState>, id: String) -> std::result::Result, UiError> { let root = path_of(&state, &id)?; @@ -1559,6 +1571,8 @@ pub fn run() { list_config_snapshots, restore_config_snapshot, list_backups, + backup_location, + set_backup_location, create_backup, verify_backup, delete_backup, diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index fc9f56b..fa81270 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -827,6 +827,12 @@ const de: Partial> = { "set.searchAll": "Alle Einstellungen nach Name oder Beschreibung durchsuchen…", "set.search": "Einstellungen durchsuchen…", "upd.current": "Du bist auf dem neuesten Stand (Version {v}).", + "bk.folder": "Sicherungsordner", + "bk.folderDefault": "Standard", + "bk.folderNote": "Neue Sicherungen werden hier gespeichert, zum Beispiel auf einem anderen Laufwerk. Frühere Sicherungen bleiben, wo sie sind, und werden weiter aufgelistet. Der Ordner muss außerhalb des Serverordners liegen.", + "bk.folderChange": "Ordner ändern…", + "bk.folderReset": "Standardordner verwenden", + "bk.folderChanged": "Sicherungsordner geändert.", "remove.title": "Diesen Server aus dem Manager entfernen", "remove.text": "Der Manager zeigt diesen Server nicht mehr an. Es werden keine Dateien, Datenbanken oder Sicherungen gelöscht; den Ordner kannst du später über „Weiteren Server verwalten“ wieder hinzufügen.", "remove.button": "Aus dem Manager entfernen", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c293a94..16677cd 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -848,6 +848,12 @@ const en = { "set.searchAll": "Search all settings by name or description…", "set.search": "Search settings…", "upd.current": "You are up to date (version {v}).", + "bk.folder": "Backup folder", + "bk.folderDefault": "default", + "bk.folderNote": "New backups are saved here, for example on another drive. Backups made earlier stay where they are and are still listed. The folder must be outside the server folder.", + "bk.folderChange": "Change folder…", + "bk.folderReset": "Use the default folder", + "bk.folderChanged": "Backup folder changed.", "remove.title": "Remove this server from the Manager", "remove.text": "The Manager stops listing this server. No files, databases or backups are deleted, and you can add the folder again later with “Manage another server”.", "remove.button": "Remove from Manager", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index d6432ba..656a55f 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -827,6 +827,12 @@ const es: Partial> = { "set.searchAll": "Buscar en todos los ajustes por nombre o descripción…", "set.search": "Buscar ajustes…", "upd.current": "Estás al día (versión {v}).", + "bk.folder": "Carpeta de copias de seguridad", + "bk.folderDefault": "predeterminada", + "bk.folderNote": "Las copias nuevas se guardan aquí, por ejemplo en otra unidad. Las anteriores se quedan donde están y siguen en la lista. La carpeta debe estar fuera de la carpeta del servidor.", + "bk.folderChange": "Cambiar carpeta…", + "bk.folderReset": "Usar la carpeta predeterminada", + "bk.folderChanged": "Carpeta de copias cambiada.", "remove.title": "Quitar este servidor del Manager", "remove.text": "El Manager deja de mostrar este servidor. No se borran archivos, bases de datos ni copias de seguridad, y puedes volver a añadir la carpeta con «Gestionar otro servidor».", "remove.button": "Quitar del Manager", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 0aa1d4e..13e5d12 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -827,6 +827,12 @@ const fr: Partial> = { "set.searchAll": "Rechercher dans tous les réglages par nom ou description…", "set.search": "Rechercher un réglage…", "upd.current": "Vous êtes à jour (version {v}).", + "bk.folder": "Dossier des sauvegardes", + "bk.folderDefault": "par défaut", + "bk.folderNote": "Les nouvelles sauvegardes sont enregistrées ici, par exemple sur un autre disque. Les précédentes restent où elles sont et figurent toujours dans la liste. Le dossier doit se trouver hors du dossier du serveur.", + "bk.folderChange": "Changer de dossier…", + "bk.folderReset": "Utiliser le dossier par défaut", + "bk.folderChanged": "Dossier des sauvegardes modifié.", "remove.title": "Retirer ce serveur du Manager", "remove.text": "Le Manager n’affiche plus ce serveur. Aucun fichier, base de données ni sauvegarde n’est supprimé, et vous pourrez rajouter le dossier plus tard avec « Gérer un autre serveur ».", "remove.button": "Retirer du Manager", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 8a23d89..319f4b0 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -846,6 +846,12 @@ const ru: Partial> = { "set.searchAll": "Поиск по всем настройкам: название или описание…", "set.search": "Поиск по настройкам…", "upd.current": "У вас последняя версия ({v}).", + "bk.folder": "Папка резервных копий", + "bk.folderDefault": "по умолчанию", + "bk.folderNote": "Новые копии сохраняются сюда, например на другой диск. Прежние копии остаются на месте и по-прежнему в списке. Папка должна быть вне папки сервера.", + "bk.folderChange": "Выбрать папку…", + "bk.folderReset": "Папка по умолчанию", + "bk.folderChanged": "Папка резервных копий изменена.", "remove.title": "Убрать сервер из Manager", "remove.text": "Manager перестанет показывать этот сервер. Файлы, базы данных и резервные копии не удаляются, папку можно добавить снова через «Управлять другим сервером».", "remove.button": "Убрать из Manager", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 5386709..9dc3888 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -827,6 +827,12 @@ const zh = { "set.searchAll": "按名称或描述搜索全部设置…", "set.search": "搜索设置…", "upd.current": "当前已是最新版本 ({v})。", + "bk.folder": "备份文件夹", + "bk.folderDefault": "默认", + "bk.folderNote": "新备份将保存在这里,例如另一个驱动器上。之前的备份保留在原处,仍会列出。该文件夹必须位于服务器文件夹之外。", + "bk.folderChange": "更改文件夹…", + "bk.folderReset": "使用默认文件夹", + "bk.folderChanged": "备份文件夹已更改。", "remove.title": "从 Manager 中移除此服务器", "remove.text": "Manager 将不再列出此服务器。不会删除任何文件、数据库或备份,之后可通过“管理其他服务器”重新添加该文件夹。", "remove.button": "从 Manager 中移除", diff --git a/src/lib/api.ts b/src/lib/api.ts index 0713057..4acf7b2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -78,6 +78,12 @@ export interface DriverOutcome { output: string; } +export interface BackupLocation { + path: string; + default_path: string; + is_default: boolean; +} + export interface ServerSummary { id: string; name: string; @@ -217,7 +223,7 @@ export interface ModuleSetting { value: string; default: string | null; doc: string; - field?: { key: string; type: string; title: string; description: string; group: string; group_title?: string | null; min?: number | null; max?: number | null } | null; + field?: { key: string; type: string; title: string; description: string; group: string; group_title?: string | null; min?: number | null; max?: number | null; choices?: { value: string | number | boolean; label: string }[] | null } | null; } export interface AllSetting { @@ -425,6 +431,9 @@ export const api = { save: (id: string, scope: Scope, changes: Record) => invoke("save_settings", { id, scope, changes }), presets: (scope: Scope) => invoke("list_presets", { scope }), backups: (id: string) => invoke("list_backups", { id }), + backupLocation: (id: string) => invoke("backup_location", { id }), + /** `null` goes back to the default folder next to the server. */ + setBackupLocation: (id: string, path: string | null) => invoke("set_backup_location", { id, path }), createBackup: (id: string, kind: BackupKind, label?: string) => invoke("create_backup", { id, kind, label: label ?? null }), verifyBackup: (id: string, backupId: string) => invoke<{ ok: boolean; problems: string[] }>("verify_backup", { id, backupId }), deleteBackup: (id: string, backupId: string) => invoke("delete_backup", { id, backupId }), diff --git a/src/lib/moduleSettings.ts b/src/lib/moduleSettings.ts index b82b6b2..3a71d16 100644 --- a/src/lib/moduleSettings.ts +++ b/src/lib/moduleSettings.ts @@ -175,7 +175,11 @@ for (const [category, categoryNames] of Object.entries(auctionCategories)) { } export function moduleField(key: string, locale: Locale, metadata?: ModuleSetting["field"]): ModuleField | undefined { const translated = locale !== "en" ? fields.find(f => f.key === key) : undefined; - if (metadata) return { title: translated ? text(translated.title, locale) : metadata.title, description: translated ? text(translated.description, locale) : metadata.description, type: metadata.type, category: metadata.group, advanced: false, min: metadata.min ?? undefined, max: metadata.max ?? undefined }; + if (metadata) { + // A fixed list of values is edited as a drop-down; the values stay strings, as they are written to the config. + const options = metadata.choices?.length ? metadata.choices.map(c => ({ value: String(c.value), label: c.label })) : undefined; + return { title: translated ? text(translated.title, locale) : metadata.title, description: translated ? text(translated.description, locale) : metadata.description, type: options ? "enum" : metadata.type, category: metadata.group, advanced: false, min: metadata.min ?? undefined, max: metadata.max ?? undefined, ...(options ? { options } : {}) } as ModuleField; + } const field = fields.find(f => f.key === key); if (field) return { ...field, title: text(field.title, locale), description: text(field.description, locale), category: "general", advanced: field.advanced ?? false, step: field.type === "range" ? 0.05 : undefined, options: field.options?.map(o => ({ value: o.value, label: text(o.label, locale) })) }; const existing = [...bots.settings, ...server.settings].find(s => s.key === key); diff --git a/src/screens/BackupsPage.tsx b/src/screens/BackupsPage.tsx index 84144f5..35b5104 100644 --- a/src/screens/BackupsPage.tsx +++ b/src/screens/BackupsPage.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import { Loader2, ShieldCheck } from "lucide-react"; -import { api, asUiError, type BackupKind, type RecoveryPoint, type UiError } from "@/lib/api"; +import { open } from "@tauri-apps/plugin-dialog"; +import { api, asUiError, type BackupKind, type BackupLocation, type RecoveryPoint, type UiError } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { useHuman, useI18n, type Key, type Locale } from "@/i18n"; @@ -45,9 +46,11 @@ export function BackupsPage({ serverId }: { serverId: string }) { const [notice, setNotice] = useState(null); const [restoring, setRestoring] = useState(null); const [running, setRunning] = useState(false); + const [location, setLocation] = useState(null); const load = useCallback(async () => { setPoints(await api.backups(serverId)); + setLocation(await api.backupLocation(serverId)); const s = await api.status(serverId); setRunning(s.observed.world.state !== "stopped" || s.observed.auth.state !== "stopped"); }, [serverId]); @@ -113,6 +116,23 @@ export function BackupsPage({ serverId }: { serverId: string }) {
+ {location && ( + +

{t("bk.folder")}

+

{location.path}{location.is_default ? ` (${t("bk.folderDefault")})` : ""}

+

{t("bk.folderNote")}

+
+ + {!location.is_default && ( + + )} +
+
+ )} + {notice &&

{notice}

} {error && ( From 017b977bfa09a9a859c6f2ea6fd05a2818125744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 07:09:33 +0500 Subject: [PATCH 05/12] Same version is never an update; Repair lists the files that differ An update is decided by version: files the owner edited at the same version no longer open a keep/replace decision. Repair shows which files differ or are missing. Co-Authored-By: Claude Sonnet 5.5 --- crates/coa-core/src/update.rs | 12 ++++++++++++ src/i18n/locales/en.ts | 2 ++ src/i18n/locales/ru.ts | 2 ++ src/screens/RepairCard.tsx | 8 +++++++- 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 9c7b7bf..96c3ba9 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -197,8 +197,15 @@ pub struct PlanItem { /// What an update would do to this installation. Read-only. pub fn plan(root: &Path, meta: &InstallMeta, manifest: &Manifest, resolutions: &BTreeMap, staged: Option<&Path>) -> Result> { let mut out = Vec::new(); + // An update is decided by version. The same version has the same files, so a file the owner edited (or removed) + // is not an update: Repair lists such files and restores them on request. + let same_version = meta.core.version.as_deref().is_some_and(|installed| installed == manifest.version); for f in manifest.files.iter().filter(|f| !f.path.starts_with(STAGED_PREFIX)) { let target = fsx::ensure_within(root, &fsx::safe_join(root, &f.path)?)?; + if same_version { + out.push(PlanItem { path: f.path.clone(), action: Action::Skip, reason: Some("same version; Repair restores changed files".into()) }); + continue; + } let cur = sha_if_exists(&target); let recorded = meta.original_hashes.get(&f.path); let (action, reason) = match (f.policy, &cur) { @@ -1170,6 +1177,11 @@ mod tests { changed.files.iter_mut().find(|file| file.path == "Scripts/manage.py").unwrap().sha256 = fsx::sha256_bytes(b"new launcher"); assert!(launcher_already_integrated(&w.root, &w.meta, &meta, &changed, &Source::Dir(w.pkg.clone())).is_err()); write(&w.root, "Scripts/manage.py", format!("{integrated}\n# user edit\n").as_bytes()); + // Same version: the owner's edit is not an update (Repair lists and restores it). + let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap(); + assert!(p.items.iter().all(|item| item.action == Action::Skip) && p.conflicts.is_empty()); + // An older installed build with the same edit does need a decision. + meta.core.version = Some("1.0.0".into()); let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap(); assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Conflict); meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_file(&w.root.join("Scripts/manage.py")).unwrap()); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 16677cd..12f3538 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -32,6 +32,8 @@ const en = { "repair.result": "Restored files: {files}. Applied database updates: {sql}.", "repair.backup": "Backup: {id}", "repair.fileCount": "Files that differ or are missing: {n}", + "repair.fileMissing": "missing", + "repair.fileChanged": "changed", "repair.dbCount": "Schema/start-data problems: {columns}. Pending or failed SQL updates: {sql}.", "repair.limited": "This older package has no full schema contract. Only character-save columns and starting data are checked.", "repair.unresolved": "Database problems remain. Repair does not replay already applied SQL or invent column changes. Export diagnostics with the listed problems for a corrective server update.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 319f4b0..0880437 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -34,6 +34,8 @@ const ru: Partial> = { "repair.result": "Восстановлено файлов: {files}. Применено обновлений БД: {sql}.", "repair.backup": "Резервная копия: {id}", "repair.fileCount": "Изменённых или отсутствующих файлов: {n}", + "repair.fileMissing": "нет файла", + "repair.fileChanged": "изменён", "repair.dbCount": "Проблем структуры БД/стартовых данных: {columns}. Ожидающих или неудачных SQL: {sql}.", "repair.limited": "В старом пакете нет полного описания схемы. Проверяются только колонки сохранения персонажей и стартовые данные.", "repair.unresolved": "Проблемы БД остались. Repair не повторяет уже применённые SQL и не добавляет колонки наугад. Экспортируйте диагностику с этим списком для исправляющего обновления сервера.", diff --git a/src/screens/RepairCard.tsx b/src/screens/RepairCard.tsx index 3368c44..9d3f23d 100644 --- a/src/screens/RepairCard.tsx +++ b/src/screens/RepairCard.tsx @@ -10,7 +10,7 @@ export function RepairCard({ serverId }: { serverId: string }) { const job = useRepair(serverId); const [checking, setChecking] = useState(false); const [database, setDatabase] = useState(null); - const [files, setFiles] = useState<{ path: string; kind: string }[] | null>(null); + const [files, setFiles] = useState<{ path: string; kind: "missing" | "changed" | string }[] | null>(null); const [error, setError] = useState(null); async function check() { clearRepairResult(serverId); @@ -36,6 +36,12 @@ export function RepairCard({ serverId }: { serverId: string }) { {job.result.error &&

{job.result.error}

}
} {files && !job.result &&

{t("repair.fileCount", { n: files.length })}

} + {files && !job.result && files.length > 0 && ( +
    + {files.slice(0, 200).map((f) =>
  • {t(f.kind === "missing" ? "repair.fileMissing" : "repair.fileChanged")} {f.path}
  • )} + {files.length > 200 &&
  • …
  • } +
+ )} {checks?.map(c => { const pending = c.migrations.filter(m => m.status !== "applied"); return
From 0a050a40a27269a4cc1d1bf5ccd4472452b35e85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 07:11:08 +0500 Subject: [PATCH 06/12] Add start-after-update options and clarify the always-max-skills setting Co-Authored-By: Claude Sonnet 5.5 --- .../20261008-start-after-update-options.md | 7 +++++ schemas/server.json | 2 +- src/i18n/locales/de.ts | 2 ++ src/i18n/locales/en.ts | 2 ++ src/i18n/locales/es.ts | 2 ++ src/i18n/locales/fr.ts | 2 ++ src/i18n/locales/ru.ts | 2 ++ src/i18n/locales/zh.ts | 2 ++ src/i18n/schema/de.json | 2 +- src/i18n/schema/es.json | 2 +- src/i18n/schema/fr.json | 2 +- src/i18n/schema/ru.json | 2 +- src/i18n/schema/zh.json | 2 +- src/lib/clientJob.ts | 2 ++ src/lib/serverUpdate.ts | 3 ++ src/lib/updatePrefs.ts | 28 +++++++++++++++++++ src/screens/ClientCard.tsx | 8 ++++++ src/screens/SettingsHome.tsx | 6 ++++ 18 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 changelog.d/20261008-start-after-update-options.md create mode 100644 src/lib/updatePrefs.ts diff --git a/changelog.d/20261008-start-after-update-options.md b/changelog.d/20261008-start-after-update-options.md new file mode 100644 index 0000000..10144fa --- /dev/null +++ b/changelog.d/20261008-start-after-update-options.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Choose whether the server keeps running after an update and whether the game starts after a client update +--- +Settings has a switch for stopping the server again once its update has finished, and the client card has one for starting the game after the client has been updated. diff --git a/schemas/server.json b/schemas/server.json index e0762de..b8bc720 100644 --- a/schemas/server.json +++ b/schemas/server.json @@ -770,7 +770,7 @@ "type": "bool", "category": "gameplay", "title": "Skills always at maximum for level", - "description": "Weapon and other skills are always at their maximum for the character's level, no training needed.", + "description": "Weapon and other skills are always at their maximum for the character's level, no training needed. The Wildcard realm always turns this on for itself.", "default": false, "advanced": false, "restartRequired": "world", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index fa81270..831611a 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -827,6 +827,8 @@ const de: Partial> = { "set.searchAll": "Alle Einstellungen nach Name oder Beschreibung durchsuchen…", "set.search": "Einstellungen durchsuchen…", "upd.current": "Du bist auf dem neuesten Stand (Version {v}).", + "upd.startAfter": "Server nach dem Update starten (ausschalten, damit er nach dem Update wieder angehalten wird)", + "client.startAfter": "Spiel nach dem Client-Update starten", "bk.folder": "Sicherungsordner", "bk.folderDefault": "Standard", "bk.folderNote": "Neue Sicherungen werden hier gespeichert, zum Beispiel auf einem anderen Laufwerk. Frühere Sicherungen bleiben, wo sie sind, und werden weiter aufgelistet. Der Ordner muss außerhalb des Serverordners liegen.", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 12f3538..a7aa2d8 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -850,6 +850,8 @@ const en = { "set.searchAll": "Search all settings by name or description…", "set.search": "Search settings…", "upd.current": "You are up to date (version {v}).", + "upd.startAfter": "Start the server after updating it (turn off to stop it again once the update is done)", + "client.startAfter": "Start the game after the client has been updated", "bk.folder": "Backup folder", "bk.folderDefault": "default", "bk.folderNote": "New backups are saved here, for example on another drive. Backups made earlier stay where they are and are still listed. The folder must be outside the server folder.", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 656a55f..af95035 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -827,6 +827,8 @@ const es: Partial> = { "set.searchAll": "Buscar en todos los ajustes por nombre o descripción…", "set.search": "Buscar ajustes…", "upd.current": "Estás al día (versión {v}).", + "upd.startAfter": "Iniciar el servidor tras actualizarlo (desactívalo para detenerlo de nuevo al terminar)", + "client.startAfter": "Iniciar el juego tras actualizar el cliente", "bk.folder": "Carpeta de copias de seguridad", "bk.folderDefault": "predeterminada", "bk.folderNote": "Las copias nuevas se guardan aquí, por ejemplo en otra unidad. Las anteriores se quedan donde están y siguen en la lista. La carpeta debe estar fuera de la carpeta del servidor.", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 13e5d12..c3a7ab3 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -827,6 +827,8 @@ const fr: Partial> = { "set.searchAll": "Rechercher dans tous les réglages par nom ou description…", "set.search": "Rechercher un réglage…", "upd.current": "Vous êtes à jour (version {v}).", + "upd.startAfter": "Démarrer le serveur après la mise à jour (désactivez pour qu’il soit arrêté à nouveau ensuite)", + "client.startAfter": "Lancer le jeu après la mise à jour du client", "bk.folder": "Dossier des sauvegardes", "bk.folderDefault": "par défaut", "bk.folderNote": "Les nouvelles sauvegardes sont enregistrées ici, par exemple sur un autre disque. Les précédentes restent où elles sont et figurent toujours dans la liste. Le dossier doit se trouver hors du dossier du serveur.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 0880437..a38477c 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -848,6 +848,8 @@ const ru: Partial> = { "set.searchAll": "Поиск по всем настройкам: название или описание…", "set.search": "Поиск по настройкам…", "upd.current": "У вас последняя версия ({v}).", + "upd.startAfter": "Запускать сервер после обновления (выключите, чтобы он снова остановился после обновления)", + "client.startAfter": "Запускать игру после обновления клиента", "bk.folder": "Папка резервных копий", "bk.folderDefault": "по умолчанию", "bk.folderNote": "Новые копии сохраняются сюда, например на другой диск. Прежние копии остаются на месте и по-прежнему в списке. Папка должна быть вне папки сервера.", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 9dc3888..1b88128 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -827,6 +827,8 @@ const zh = { "set.searchAll": "按名称或描述搜索全部设置…", "set.search": "搜索设置…", "upd.current": "当前已是最新版本 ({v})。", + "upd.startAfter": "更新后启动服务器(关闭则更新完成后再次停止)", + "client.startAfter": "更新客户端后启动游戏", "bk.folder": "备份文件夹", "bk.folderDefault": "默认", "bk.folderNote": "新备份将保存在这里,例如另一个驱动器上。之前的备份保留在原处,仍会列出。该文件夹必须位于服务器文件夹之外。", diff --git a/src/i18n/schema/de.json b/src/i18n/schema/de.json index b32e60e..075054e 100644 --- a/src/i18n/schema/de.json +++ b/src/i18n/schema/de.json @@ -525,7 +525,7 @@ }, "AlwaysMaxSkillForLevel": { "t": "Fertigkeiten immer auf Maximum der Stufe", - "d": "Waffen- und andere Fertigkeiten sind immer auf dem Maximum der Charakterstufe, Training ist nicht nötig." + "d": "Waffen- und andere Fertigkeiten sind immer auf dem Maximum der Charakterstufe, Training ist nicht nötig. Der Wildcard-Realm schaltet dies für sich immer ein." }, "ActivateWeather": { "t": "Wetter", diff --git a/src/i18n/schema/es.json b/src/i18n/schema/es.json index dd2c1a4..11dab15 100644 --- a/src/i18n/schema/es.json +++ b/src/i18n/schema/es.json @@ -525,7 +525,7 @@ }, "AlwaysMaxSkillForLevel": { "t": "Habilidades siempre al máximo del nivel", - "d": "Las habilidades de armas y otras están siempre al máximo para el nivel del personaje, sin entrenar." + "d": "Las habilidades de armas y otras están siempre al máximo para el nivel del personaje, sin entrenar. El reino Wildcard lo activa siempre para sí mismo." }, "ActivateWeather": { "t": "Clima", diff --git a/src/i18n/schema/fr.json b/src/i18n/schema/fr.json index 85365d2..b399d7b 100644 --- a/src/i18n/schema/fr.json +++ b/src/i18n/schema/fr.json @@ -525,7 +525,7 @@ }, "AlwaysMaxSkillForLevel": { "t": "Compétences toujours au maximum du niveau", - "d": "Les compétences d'armes et autres sont toujours au maximum du niveau du personnage, sans entraînement." + "d": "Les compétences d'armes et autres sont toujours au maximum du niveau du personnage, sans entraînement. Le royaume Wildcard l’active toujours pour lui-même." }, "ActivateWeather": { "t": "Météo", diff --git a/src/i18n/schema/ru.json b/src/i18n/schema/ru.json index 15fe228..fbd248c 100644 --- a/src/i18n/schema/ru.json +++ b/src/i18n/schema/ru.json @@ -525,7 +525,7 @@ }, "AlwaysMaxSkillForLevel": { "t": "Навыки всегда на максимуме для уровня", - "d": "Навыки оружия и другие навыки всегда на максимуме для уровня персонажа, учиться не нужно." + "d": "Навыки оружия и другие навыки всегда на максимуме для уровня персонажа, учиться не нужно. Реалм Wildcard всегда включает это для себя." }, "ActivateWeather": { "t": "Погода", diff --git a/src/i18n/schema/zh.json b/src/i18n/schema/zh.json index 130548b..c38a478 100644 --- a/src/i18n/schema/zh.json +++ b/src/i18n/schema/zh.json @@ -525,7 +525,7 @@ }, "AlwaysMaxSkillForLevel": { "t": "武器技能自动升满", - "d": "武器及其他熟练度技能自动保持在当前角色等级上限,无需反复练习。" + "d": "武器及其他熟练度技能自动保持在当前角色等级上限,无需反复练习。 Wildcard 领域会始终为自己开启此项。" }, "ActivateWeather": { "t": "天气系统", diff --git a/src/lib/clientJob.ts b/src/lib/clientJob.ts index 9f961f2..3c8393e 100644 --- a/src/lib/clientJob.ts +++ b/src/lib/clientJob.ts @@ -1,3 +1,4 @@ +import { startAfterUpdate } from "./updatePrefs"; import { useSyncExternalStore } from "react"; import { listen } from "@tauri-apps/api/event"; import { api, asUiError, type ClientPlan, type ClientStep, type UiError } from "@/lib/api"; @@ -85,6 +86,7 @@ export async function syncClient(id: string, keepModified: boolean): Promise undefined); void refresh(id); setTimeout(() => { if (get(id).phase === "done") set(id, { phase: "idle" }); diff --git a/src/lib/serverUpdate.ts b/src/lib/serverUpdate.ts index eeab33e..295faf8 100644 --- a/src/lib/serverUpdate.ts +++ b/src/lib/serverUpdate.ts @@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react"; import { listen } from "@tauri-apps/api/event"; import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from "@/lib/api"; import { isUpdateCurrent } from "./serverUpdateStatus"; +import { startAfterUpdate } from "./updatePrefs"; /** * Server update status for the main screen and the navigation: checked when the app starts and every five minutes, @@ -95,6 +96,8 @@ export async function applyServerUpdate(id: string, opts?: { choices: Record undefined); return true; } catch (e) { const ui = asUiError(e); diff --git a/src/lib/updatePrefs.ts b/src/lib/updatePrefs.ts new file mode 100644 index 0000000..0b5950c --- /dev/null +++ b/src/lib/updatePrefs.ts @@ -0,0 +1,28 @@ +import { useState } from "react"; + +/** + * What to do when an update has finished: leave the server running (the default, as the update proves the new build + * starts) or stop it again, and whether to open the game after a client update (off by default). + */ +export type AfterUpdate = "server" | "client"; + +const key = (kind: AfterUpdate, id: string) => `coa-start-after-${kind}-update:${id}`; +const fallback = (kind: AfterUpdate) => kind === "server"; + +export function startAfterUpdate(kind: AfterUpdate, id: string): boolean { + try { + const value = localStorage.getItem(key(kind, id)); + return value === null ? fallback(kind) : value === "1"; + } catch { + return fallback(kind); + } +} + +export function setStartAfterUpdate(kind: AfterUpdate, id: string, on: boolean) { + try { localStorage.setItem(key(kind, id), on ? "1" : "0"); } catch { /* storage unavailable: the default applies */ } +} + +export function useStartAfterUpdate(kind: AfterUpdate, id: string): [boolean, (on: boolean) => void] { + const [on, setOn] = useState(() => startAfterUpdate(kind, id)); + return [on, (next) => { setStartAfterUpdate(kind, id, next); setOn(next); }]; +} diff --git a/src/screens/ClientCard.tsx b/src/screens/ClientCard.tsx index a2c2b7f..4103e6b 100644 --- a/src/screens/ClientCard.tsx +++ b/src/screens/ClientCard.tsx @@ -6,6 +6,7 @@ import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { useHuman, useT } from "@/i18n"; +import { useStartAfterUpdate } from "@/lib/updatePrefs"; export function ClientCard({ serverId, remote = false }: { serverId: string; remote?: boolean }) { const t = useT(); @@ -16,6 +17,7 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); const [dialog, setDialog] = useState(null); + const [playAfter, setPlayAfter] = useStartAfterUpdate("client", serverId); const refresh = () => { void api.clientStatus(serverId).then(setStatus).catch(() => setStatus(null)); @@ -86,6 +88,12 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem
{t("client.otherAddons")}
{t("client.leftAsIs", { n: info.other_addons })}
+ {!remote && ( + + )}
{!remote && !pointsLocal && ( + + ) : ( + <> +

+ {t("dash.versions", { dash: status.tag ?? "?", bots: status.squid_tag ?? "?" })} + {" · "} + {status.running ? t("dash.running") : t("dash.stopped")} +

+ {!status.matches && status.squid_tag && ( +

{t("dash.mismatch", { bots: status.squid_tag })}

+ )} +
+ {status.running ? ( + <> + + + + ) : ( + + )} + {!status.matches && status.squid_tag && ( + + )} +
+ + )} + {busy === "install" &&

{step ?? t("bk.working")}

} + + )} + + {error && ( + +

{error.human.code === "unknown" ? error.technical : human(error.human).message}

+ +
+ )} + + {status?.running && ( +