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")}
- setShowDetails((v) => !v)}>
- {showDetails ? t("install.hideTech") : t("install.showTech")}
-
+
+ setShowDetails((v) => !v)}>
+ {showDetails ? t("install.hideTech") : t("install.showTech")}
+
+ void navigator.clipboard.writeText(error.technical)}>
+ {t("common.copyError")}
+
+
{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}
+ void remove()}>{t("remove.button")}
+ {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}
+
+
void navigator.clipboard.writeText(error.technical)}>
+ {t("common.copyError")}
+
+
)}
{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")}
+
+ void run("folder", async () => {
+ const dir = await open({ directory: true, multiple: false, title: t("bk.folder") });
+ if (typeof dir === "string") { await api.setBackupLocation(serverId, dir); return t("bk.folderChanged"); }
+ })}>{t("bk.folderChange")}
+ {!location.is_default && (
+ void run("folder", async () => { await api.setBackupLocation(serverId, null); return t("bk.folderChanged"); })}>{t("bk.folderReset")}
+ )}
+
+
+ )}
+
{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 && (
+
+ setPlayAfter(e.target.checked)} className="h-4 w-4 accent-[#c9a24a]" />
+ {t("client.startAfter")}
+
+ )}
{!remote && !pointsLocal && (
void run(async () => { await api.setRealmlist(serverId, "127.0.0.1"); return t("client.pointedNote"); })}>
diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx
index f613a13..b41c09a 100644
--- a/src/screens/SettingsHome.tsx
+++ b/src/screens/SettingsHome.tsx
@@ -10,6 +10,7 @@ import { ClientCard } from "@/screens/ClientCard";
import { DiagnosticsCard } from "@/screens/DiagnosticsCard";
import { AboutCard } from "@/screens/AboutCard";
import { RemoveServerCard } from "@/screens/RemoveServerCard";
+import { useStartAfterUpdate } from "@/lib/updatePrefs";
import { hasKey, useHuman, useI18n, type Key } from "@/i18n";
import { LanguagePicker } from "@/components/LanguagePicker";
import { RealmStartupSettings } from "@/screens/RealmStartupSettings";
@@ -38,6 +39,7 @@ export function SettingsHome({ serverId, path, onForget }: { serverId: string; p
const [choices, setChoices] = useState>({});
const [advanced, setAdvanced] = useState(false);
const [source, setSource] = useState("");
+ const [startAfter, setStartAfter] = useStartAfterUpdate("server", serverId);
useEffect(() => {
let active = true;
@@ -178,6 +180,10 @@ export function SettingsHome({ serverId, path, onForget }: { serverId: string; p
{t("upd.title")}
{t("upd.text")}
+
+ setStartAfter(e.target.checked)} className="h-4 w-4 accent-[#c9a24a]" />
+ {t("upd.startAfter")}
+
void check()}>
From d85d86a3dce941cb2d55c11c4eb22d2aff36843f 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:12:01 +0500
Subject: [PATCH 07/12] Recognise the coa-bots world server and explain the
undo step
Co-Authored-By: Claude Sonnet 5.5
---
...261008-world-server-from-coa-bots-folder.md | 7 +++++++
crates/coa-core/src/process.rs | 18 +++++++++++++++---
crates/coa-core/src/update.rs | 6 +++++-
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 +-
9 files changed, 33 insertions(+), 10 deletions(-)
create mode 100644 changelog.d/20261008-world-server-from-coa-bots-folder.md
diff --git a/changelog.d/20261008-world-server-from-coa-bots-folder.md b/changelog.d/20261008-world-server-from-coa-bots-folder.md
new file mode 100644
index 0000000..a6e3f1e
--- /dev/null
+++ b/changelog.d/20261008-world-server-from-coa-bots-folder.md
@@ -0,0 +1,7 @@
+---
+area: manager
+type: fixed
+audience: admins
+title: A world server started from the coa-bots folder is recognised
+---
+The Manager no longer reports a port conflict when the world server runs from the coa-bots folder inside the server folder, and a failed update now says that undoing it can take several minutes.
diff --git a/crates/coa-core/src/process.rs b/crates/coa-core/src/process.rs
index 646eead..3e38392 100644
--- a/crates/coa-core/src/process.rs
+++ b/crates/coa-core/src/process.rs
@@ -278,12 +278,24 @@ fn uptime_secs(p: &ProcessIdentity) -> Option {
(now >= p.created).then(|| (now - p.created) / 10_000_000)
}
+/// The world server of the `coa-bots` repack folder lives inside the server folder and is started by its own
+/// launcher (`coa-bots\Core\worldserver.exe`). It serves the same realm, so it counts as this server's world.
+fn world_alternatives(root: &Path) -> Vec {
+ vec![root.join("coa-bots").join("Core").join("worldserver.exe")]
+}
+
fn service(root: &Path, name: &'static str, exe: PathBuf, port: u16, listen: &[(u16, u32)]) -> ServiceStatus {
+ service_with(root, name, exe, &[], port, listen)
+}
+
+fn service_with(root: &Path, name: &'static str, exe: PathBuf, alternatives: &[PathBuf], port: u16, listen: &[(u16, u32)]) -> ServiceStatus {
// Prefer the repack's own record when it still matches a live process; otherwise discover by exact exe path.
+ let norm = |p: &Path| p.to_string_lossy().replace('/', "\\");
+ let expected: Vec = std::iter::once(&exe).chain(alternatives).map(|p| norm(p)).collect();
let proc = read_state_record(root, name)
.filter(is_alive)
- .filter(|r| r.exe.eq_ignore_ascii_case(&exe.to_string_lossy().replace('/', "\\")))
- .or_else(|| find_by_exe(&exe).into_iter().next());
+ .filter(|r| expected.iter().any(|e| r.exe.eq_ignore_ascii_case(e)))
+ .or_else(|| std::iter::once(&exe).chain(alternatives).find_map(|p| find_by_exe(p).into_iter().next()));
let owner = listen.iter().find(|(p, _)| *p == port).map(|(_, pid)| *pid);
let (state, port_ready, conflict) = match (&proc, owner) {
@@ -319,7 +331,7 @@ pub fn observe(root: &Path, ports: &Ports) -> Observed {
Observed {
mysql: service(root, "mysql", mysql, ports.mysql, &listen),
auth: service(root, "auth", auth, ports.auth, &listen),
- world: service(root, "world", world, ports.world, &listen),
+ world: service_with(root, "world", world, &world_alternatives(root), ports.world, &listen),
secondary_world,
}
}
diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs
index 96c3ba9..ab83667 100644
--- a/crates/coa-core/src/update.rs
+++ b/crates/coa-core/src/update.rs
@@ -492,9 +492,13 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result {
Ok(r) if r.failed.is_none() => migrated = Some(r),
Ok(r) => {
let (mid, why) = r.failed.clone().unwrap();
+ step(report, "Undoing the update", 76);
return fail_after_apply(p, &mut txn, &before, &tree, format!("Database update {mid} failed: {why}"), Some(r));
}
- Err(e) => return fail_after_apply(p, &mut txn, &before, &tree, e.to_string(), None),
+ Err(e) => {
+ step(report, "Undoing the update", 76);
+ return fail_after_apply(p, &mut txn, &before, &tree, e.to_string(), None);
+ }
}
}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 831611a..7eee400 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -495,7 +495,7 @@ const de: Partial> = {
"ustep.Updating the database": "Datenbank wird aktualisiert",
"ustep.Starting the updated server": "Aktualisierter Server wird gestartet",
"ustep.Finishing": "Abschluss",
- "ustep.Undoing the update": "Update wird rückgängig gemacht",
+ "ustep.Undoing the update": "Update wird rückgängig gemacht (das Wiederherstellen der Datenbanken kann mehrere Minuten dauern)",
"ustep.Done": "Fertig",
// Companion sizes
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index a7aa2d8..4c058c4 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -530,7 +530,7 @@ const en = {
"ustep.Updating the database": "Updating the database",
"ustep.Starting the updated server": "Starting the updated server",
"ustep.Finishing": "Finishing",
- "ustep.Undoing the update": "Undoing the update",
+ "ustep.Undoing the update": "Undoing the update (restoring the databases can take several minutes)",
"ustep.Done": "Done",
// Companion sizes
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index af95035..418d13e 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -495,7 +495,7 @@ const es: Partial> = {
"ustep.Updating the database": "Actualizando la base de datos",
"ustep.Starting the updated server": "Iniciando el servidor actualizado",
"ustep.Finishing": "Finalizando",
- "ustep.Undoing the update": "Deshaciendo la actualización",
+ "ustep.Undoing the update": "Deshaciendo la actualización (restaurar las bases de datos puede tardar varios minutos)",
"ustep.Done": "Hecho",
// Companion sizes
diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts
index c3a7ab3..cee9ef5 100644
--- a/src/i18n/locales/fr.ts
+++ b/src/i18n/locales/fr.ts
@@ -495,7 +495,7 @@ const fr: Partial> = {
"ustep.Updating the database": "Mise à jour de la base de données",
"ustep.Starting the updated server": "Démarrage du serveur mis à jour",
"ustep.Finishing": "Finalisation",
- "ustep.Undoing the update": "Annulation de la mise à jour",
+ "ustep.Undoing the update": "Annulation de la mise à jour (la restauration des bases de données peut prendre plusieurs minutes)",
"ustep.Done": "Terminé",
// Companion sizes
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index a38477c..a8eb78b 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -528,7 +528,7 @@ const ru: Partial> = {
"ustep.Updating the database": "Обновление базы данных",
"ustep.Starting the updated server": "Запуск обновлённого сервера",
"ustep.Finishing": "Завершение",
- "ustep.Undoing the update": "Откат обновления",
+ "ustep.Undoing the update": "Отмена обновления (восстановление баз данных может занять несколько минут)",
"ustep.Done": "Готово",
// Companion sizes
diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts
index 1b88128..d0d0ecf 100644
--- a/src/i18n/locales/zh.ts
+++ b/src/i18n/locales/zh.ts
@@ -507,7 +507,7 @@ const zh = {
"ustep.Updating the database": "正在更新数据库结构",
"ustep.Starting the updated server": "正在启动更新后的服务器",
"ustep.Finishing": "正在收尾完成",
- "ustep.Undoing the update": "正在撤销更新并回滚",
+ "ustep.Undoing the update": "正在撤销更新(恢复数据库可能需要几分钟)",
"ustep.Done": "已完成",
// Companion sizes
From 4c4f8bbb3eb00ed89c4f72ae5d6919dea47c3469 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:12:51 +0500
Subject: [PATCH 08/12] Accept both published versions of the Wildcard table
repair
The migration manager_repair__20261005_missing_wildcard_tables was published with two
different checksums; a server that applied one refused the update carrying the other.
Both versions have the same effect, so the Manager treats them as interchangeable.
Co-Authored-By: Claude Sonnet 5.5
---
.../20261008-wildcard-repair-checksum.md | 7 +++++
crates/coa-core/src/migrations.rs | 29 +++++++++++++++++++
2 files changed, 36 insertions(+)
create mode 100644 changelog.d/20261008-wildcard-repair-checksum.md
diff --git a/changelog.d/20261008-wildcard-repair-checksum.md b/changelog.d/20261008-wildcard-repair-checksum.md
new file mode 100644
index 0000000..c346118
--- /dev/null
+++ b/changelog.d/20261008-wildcard-repair-checksum.md
@@ -0,0 +1,7 @@
+---
+area: manager
+type: fixed
+audience: admins
+title: Server updates no longer stop with "has a different checksum" for the Wildcard table repair
+---
+Servers that applied one of the two published versions of the Wildcard table repair can now update to the other. Nothing in the database changes.
diff --git a/crates/coa-core/src/migrations.rs b/crates/coa-core/src/migrations.rs
index 7341bc4..7d95645 100644
--- a/crates/coa-core/src/migrations.rs
+++ b/crates/coa-core/src/migrations.rs
@@ -154,9 +154,26 @@ fn recorded<'a>(rows: &'a [LedgerRow], m: &Migration) -> Option<&'a LedgerRow> {
rows.iter().rev().find(|r| r.db == m.db && r.id == m.id)
}
+/// Migrations that were published with different bytes but have the same effect, so a server that applied either
+/// version accepts the other. The Wildcard table repair went out as `c1d8…` (0.261005.0, 0.261006.1) and as `9e4a…`
+/// (0.261005.22, 0.261007.24); the packages carry no list of compatible versions, so the Manager knows this pair.
+const SAME_EFFECT: &[(&str, &[&str])] = &[(
+ "manager_repair__20261005_missing_wildcard_tables",
+ &[
+ "c1d8dbf2271234283a48a39e4b4aea1106b83b70581f1e3c4aa9fff438e8e4d2",
+ "9e4a36245c3f83255415c122c01f08ceb3e3b83898d3d427ac4e2fbcf61e9638",
+ ],
+)];
+
+fn same_effect(id: &str, a: &str, b: &str) -> bool {
+ SAME_EFFECT.iter().any(|(known, hashes)| *known == id
+ && hashes.iter().any(|h| h.eq_ignore_ascii_case(a)) && hashes.iter().any(|h| h.eq_ignore_ascii_case(b)))
+}
+
fn hash_changed(row: &LedgerRow, m: &Migration) -> bool {
row.status == Status::Applied && row.sha256 != "0".repeat(64) && !row.sha256.eq_ignore_ascii_case(&m.sha256)
&& !m.compatible_sha256.iter().any(|hash| hash.eq_ignore_ascii_case(&row.sha256))
+ && !same_effect(&m.id, &row.sha256, &m.sha256)
}
/// Inspect recorded history without creating a ledger or executing migrations.
@@ -275,6 +292,18 @@ mod tests {
use super::*;
use std::cell::RefCell;
+ #[test]
+ fn the_two_published_versions_of_the_wildcard_table_repair_are_interchangeable() {
+ let (old, new) = ("c1d8dbf2271234283a48a39e4b4aea1106b83b70581f1e3c4aa9fff438e8e4d2", "9e4a36245c3f83255415c122c01f08ceb3e3b83898d3d427ac4e2fbcf61e9638");
+ let id = "manager_repair__20261005_missing_wildcard_tables";
+ let row = |id: &str, sha: &str| LedgerRow { db: "characters".into(), id: id.into(), sha256: sha.into(), status: Status::Applied, error: None, baseline: false };
+ let migration = |id: &str, sha: &str| Migration { compatible_sha256: vec![], id: id.into(), db: "characters".into(), sha256: sha.into(), destructive: false };
+ assert!(!hash_changed(&row(id, old), &migration(id, new)), "applied as c1d8, offered as 9e4a");
+ assert!(!hash_changed(&row(id, new), &migration(id, old)), "applied as 9e4a, offered as c1d8");
+ assert!(hash_changed(&row(id, &"a".repeat(64)), &migration(id, new)), "an unknown version is still a change");
+ assert!(hash_changed(&row("other_migration", old), &migration("other_migration", new)), "only the known pair is exempt");
+ }
+
#[derive(Default)]
struct Mem {
rows: RefCell>,
From 838cc07e738c5d6d842c1669aced98a7fc5068d5 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:33:07 +0500
Subject: [PATCH 09/12] Add the SQUID dashboard tab and make a stopped server
stay stopped after updating
The Dashboard tab (shown with SQUID Playerbots) downloads the dashboard release of the
bots' release series, starts it on a free local port and shows it in the window. After an
update, a server that was stopped stays stopped unless the owner asked for it to start.
Co-Authored-By: Claude Sonnet 5.5
---
changelog.d/20261008-squid-dashboard-tab.md | 7 +
.../20261008-start-after-update-options.md | 4 +-
crates/coa-core/src/dashboard.rs | 286 ++++++++++++++++++
crates/coa-core/src/lib.rs | 1 +
src-tauri/src/lib.rs | 28 ++
src-tauri/tauri.conf.json | 2 +-
src/i18n/locales/de.ts | 18 +-
src/i18n/locales/en.ts | 18 +-
src/i18n/locales/es.ts | 18 +-
src/i18n/locales/fr.ts | 18 +-
src/i18n/locales/ru.ts | 18 +-
src/i18n/locales/zh.ts | 18 +-
src/lib/api.ts | 14 +
src/lib/serverUpdate.ts | 6 +-
src/lib/updatePrefs.ts | 6 +-
src/screens/DashboardPage.tsx | 109 +++++++
src/screens/Shell.tsx | 10 +-
17 files changed, 564 insertions(+), 17 deletions(-)
create mode 100644 changelog.d/20261008-squid-dashboard-tab.md
create mode 100644 crates/coa-core/src/dashboard.rs
create mode 100644 src/screens/DashboardPage.tsx
diff --git a/changelog.d/20261008-squid-dashboard-tab.md b/changelog.d/20261008-squid-dashboard-tab.md
new file mode 100644
index 0000000..3459f42
--- /dev/null
+++ b/changelog.d/20261008-squid-dashboard-tab.md
@@ -0,0 +1,7 @@
+---
+area: manager
+type: added
+audience: admins
+title: A Dashboard tab shows what the SQUID bots are doing
+---
+With SQUID Playerbots on, the Dashboard tab downloads the dashboard release that matches your bots, starts it on this computer and shows it inside the Manager.
diff --git a/changelog.d/20261008-start-after-update-options.md b/changelog.d/20261008-start-after-update-options.md
index 10144fa..6244b5c 100644
--- a/changelog.d/20261008-start-after-update-options.md
+++ b/changelog.d/20261008-start-after-update-options.md
@@ -2,6 +2,6 @@
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
+title: A stopped server stays stopped after an update, unless you ask it to start
---
-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.
+Updating no longer leaves a server running that was stopped before. Settings has a switch to start it afterwards, and the client card has one to open the game after a client update.
diff --git a/crates/coa-core/src/dashboard.rs b/crates/coa-core/src/dashboard.rs
new file mode 100644
index 0000000..15b6b14
--- /dev/null
+++ b/crates/coa-core/src/dashboard.rs
@@ -0,0 +1,286 @@
+//! SQUID's bot dashboard (): a small web page, written in Python with
+//! no dependencies, that shows where the bots are and what they do. It is kept in `Extras/SquidDashboard` of the server
+//! folder and always comes from the release that matches the installed SQUID bots (same major and minor version), so the
+//! page and the bots agree on what the database and the logs contain.
+//!
+//! The dashboard finds the repack by itself (`Settings/database.json` and `Settings/repack.json` two folders up), so the
+//! Manager only has to download it and start it on a free port. Only the process the Manager started and recorded is ever
+//! stopped, identified by PID and creation time.
+
+use std::io::Read;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use crate::error::{Error, Result};
+use crate::fsx;
+use crate::process::{self, ProcessIdentity};
+
+const REPO: &str = "Zyth45/squidbots-dashboard";
+const DIR: &str = "Extras/SquidDashboard";
+const MARKER: &str = ".manager-dashboard.json";
+const PROCESS: &str = ".manager-dashboard-process.json";
+const FIRST_PORT: u16 = 8088;
+const MAX_ARCHIVE: u64 = 60 * 1024 * 1024;
+const MAX_UNPACKED: u64 = 150 * 1024 * 1024;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct Marker {
+ /// The dashboard release that is installed.
+ tag: String,
+ /// The SQUID release it was chosen for.
+ squid_tag: String,
+ port: u16,
+}
+
+#[derive(Debug, Serialize)]
+pub struct Status {
+ pub installed: bool,
+ /// Dashboard release installed.
+ pub tag: Option,
+ /// SQUID bots release found in the server.
+ pub squid_tag: Option,
+ /// The installed dashboard belongs to the installed bots' release series.
+ pub matches: bool,
+ pub running: bool,
+ pub port: u16,
+ pub url: String,
+}
+
+fn dir(root: &Path) -> PathBuf {
+ root.join(DIR)
+}
+
+fn read_marker(root: &Path) -> Option {
+ fsx::read_json(&dir(root).join(MARKER)).ok()
+}
+
+/// `v1.9.1` -> `[1, 9, 1]`.
+fn parse_tag(tag: &str) -> Option> {
+ let parts: Vec = tag.trim().trim_start_matches('v').split('.').map(|p| p.parse().ok()).collect::>()?;
+ (parts.len() >= 2).then_some(parts)
+}
+
+/// Same major and minor version.
+fn same_series(a: &str, b: &str) -> bool {
+ matches!((parse_tag(a), parse_tag(b)), (Some(a), Some(b)) if a[..2] == b[..2])
+}
+
+/// The newest dashboard release of the bots' series (`v1.9` serves bots `v1.9` and `v1.9.1`).
+fn pick_tag(squid_tag: &str, available: &[String]) -> Option {
+ available.iter()
+ .filter(|tag| same_series(tag, squid_tag))
+ .max_by_key(|tag| parse_tag(tag).unwrap_or_default())
+ .cloned()
+}
+
+fn safe_tag(tag: &str) -> bool {
+ !tag.is_empty() && tag.len() <= 40 && tag.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
+}
+
+fn squid_tag(root: &Path) -> Option {
+ crate::squid::release(root).and_then(|r| r.tag).filter(|t| safe_tag(t))
+}
+
+fn record_alive(root: &Path) -> Option {
+ let record: ProcessIdentity = fsx::read_json(&dir(root).join(PROCESS)).ok()?;
+ let inside = Path::new(&record.exe).starts_with(root);
+ (inside && process::is_alive(&record)).then_some(record)
+}
+
+pub fn status(root: &Path) -> Status {
+ let marker = read_marker(root);
+ let installed = dir(root).join("squidbots.py").is_file() && marker.is_some();
+ let squid = squid_tag(root);
+ let port = marker.as_ref().map(|m| m.port).unwrap_or(FIRST_PORT);
+ let running = installed && record_alive(root).is_some_and(|r| process::listeners().iter().any(|(p, pid)| *p == port && *pid == r.pid));
+ Status {
+ installed,
+ matches: matches!((&marker, &squid), (Some(m), Some(s)) if same_series(&m.tag, s)),
+ tag: marker.map(|m| m.tag),
+ squid_tag: squid,
+ running,
+ port,
+ url: format!("http://localhost:{port}"),
+ }
+}
+
+fn http() -> Result {
+ reqwest::blocking::Client::builder()
+ .user_agent(concat!("CoA-Server-Manager/", env!("CARGO_PKG_VERSION")))
+ .timeout(std::time::Duration::from_secs(120))
+ .build()
+ .map_err(|e| Error::Invalid(format!("Cannot prepare the download: {e}")))
+}
+
+fn download_error(e: impl std::fmt::Display) -> Error {
+ Error::NetworkUnreachable(e.to_string())
+}
+
+fn available_tags(client: &reqwest::blocking::Client) -> Result> {
+ #[derive(Deserialize)]
+ struct Tag { name: String }
+ let response = client.get(format!("https://api.github.com/repos/{REPO}/tags?per_page=100")).send().map_err(download_error)?;
+ if !response.status().is_success() { return Err(download_error(format!("GitHub answered {}", response.status()))); }
+ let tags: Vec = serde_json::from_str(&response.text().map_err(download_error)?).map_err(download_error)?;
+ Ok(tags.into_iter().map(|t| t.name).filter(|t| safe_tag(t)).collect())
+}
+
+/// Download the dashboard release that matches the installed bots and unpack it. A running dashboard is stopped first;
+/// maps the owner extracted and other files of theirs are left alone.
+pub fn install(root: &Path, report: &dyn Fn(&str)) -> Result {
+ let squid = squid_tag(root).ok_or_else(|| Error::Invalid("The version of the SQUID bots could not be read, so the matching dashboard cannot be chosen.".into()))?;
+ report("Looking for the matching dashboard");
+ let client = http()?;
+ let tag = pick_tag(&squid, &available_tags(&client)?)
+ .ok_or_else(|| Error::Invalid(format!("There is no dashboard release for SQUID bots {squid} yet.")))?;
+ report("Downloading the dashboard");
+ let response = client.get(format!("https://codeload.github.com/{REPO}/zip/refs/tags/{tag}")).send().map_err(download_error)?;
+ if !response.status().is_success() { return Err(download_error(format!("GitHub answered {}", response.status()))); }
+ let mut bytes = Vec::new();
+ response.take(MAX_ARCHIVE + 1).read_to_end(&mut bytes).map_err(download_error)?;
+ if bytes.len() as u64 > MAX_ARCHIVE { return Err(Error::Invalid("The dashboard download is larger than expected and was rejected.".into())); }
+
+ stop(root)?;
+ report("Unpacking the dashboard");
+ let target = dir(root);
+ std::fs::create_dir_all(&target)?;
+ let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| Error::Invalid(format!("The dashboard download is damaged: {e}")))?;
+ let mut total = 0u64;
+ let mut wrote_main = false;
+ for index in 0..archive.len() {
+ let mut file = archive.by_index(index).map_err(|e| Error::Invalid(format!("The dashboard download is damaged: {e}")))?;
+ if file.is_dir() { continue; }
+ // GitHub wraps everything in one folder named after the repository and release.
+ let Some(relative) = file.name().split_once('/').map(|(_, rest)| rest.to_string()) else { continue; };
+ if relative.is_empty() || relative.starts_with(".github/") || relative.starts_with("docs/") { continue; }
+ total += file.size();
+ if total > MAX_UNPACKED { return Err(Error::Invalid("The dashboard download unpacks to more than expected and was rejected.".into())); }
+ let destination = fsx::safe_join(&target, &relative)?;
+ let mut content = Vec::with_capacity(file.size() as usize);
+ file.read_to_end(&mut content)?;
+ fsx::atomic_write(&destination, &content)?;
+ wrote_main |= relative == "squidbots.py";
+ }
+ if !wrote_main { return Err(Error::Invalid("The dashboard download did not contain the dashboard.".into())); }
+ let port = read_marker(root).map(|m| m.port).unwrap_or(FIRST_PORT);
+ fsx::atomic_write_json(&target.join(MARKER), &Marker { tag, squid_tag: squid, port })?;
+ Ok(status(root))
+}
+
+fn free_port(preferred: u16) -> Result {
+ let used: Vec = process::listeners().into_iter().map(|(port, _)| port).collect();
+ std::iter::once(preferred).chain(FIRST_PORT..FIRST_PORT + 40).find(|p| !used.contains(p))
+ .ok_or_else(|| Error::Invalid("No free port was found for the dashboard.".into()))
+}
+
+/// Start the dashboard with the repack's own Python. Returns when it answers on its port.
+pub fn start(root: &Path) -> Result {
+ let current = status(root);
+ if !current.installed { return Err(Error::Invalid("The dashboard is not installed.".into())); }
+ if current.running { return Ok(current); }
+ let python = root.join("Runtime/python/python.exe");
+ if !python.is_file() { return Err(Error::Invalid("This server has no bundled Python, so the dashboard cannot run.".into())); }
+ let folder = dir(root);
+ let mut marker = read_marker(root).ok_or_else(|| Error::Invalid("The dashboard installation is incomplete.".into()))?;
+ marker.port = free_port(marker.port)?;
+ fsx::atomic_write_json(&folder.join(MARKER), &marker)?;
+
+ let log_path = folder.join("dashboard.log");
+ let log = std::fs::File::create(&log_path)?;
+ let mut command = std::process::Command::new(&python);
+ command.arg("-B").arg("squidbots.py").current_dir(&folder).env("COA_DASHBOARD_PORT", marker.port.to_string())
+ .stdin(std::process::Stdio::null()).stdout(log.try_clone()?).stderr(log);
+ #[cfg(windows)]
+ {
+ use std::os::windows::process::CommandExt;
+ command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
+ }
+ let mut child = command.spawn()?;
+ let identity = process::identity(child.id()).ok_or_else(|| Error::Invalid("The dashboard exited at once.".into()))?;
+ fsx::atomic_write_json(&folder.join(PROCESS), &identity)?;
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
+ loop {
+ if process::listeners().iter().any(|(port, pid)| *port == marker.port && *pid == identity.pid) { return Ok(status(root)); }
+ if let Ok(Some(_)) = child.try_wait() {
+ let _ = std::fs::remove_file(folder.join(PROCESS));
+ let tail = std::fs::read_to_string(&log_path).unwrap_or_default();
+ let lines: Vec<&str> = tail.lines().rev().take(8).collect();
+ return Err(Error::Invalid(format!("The dashboard stopped right after starting. {}", lines.into_iter().rev().collect::>().join("\n"))));
+ }
+ if std::time::Instant::now() > deadline {
+ let _ = stop(root);
+ return Err(Error::Invalid("The dashboard did not start in time. See Extras/SquidDashboard/dashboard.log.".into()));
+ }
+ std::thread::sleep(std::time::Duration::from_millis(300));
+ }
+}
+
+/// Stop the dashboard this Manager started (by recorded PID and creation time, never by name).
+pub fn stop(root: &Path) -> Result<()> {
+ let record_file = dir(root).join(PROCESS);
+ if let Some(record) = record_alive(root) {
+ let mut command = std::process::Command::new("taskkill");
+ command.args(["/PID", &record.pid.to_string(), "/T", "/F"]).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
+ #[cfg(windows)]
+ {
+ use std::os::windows::process::CommandExt;
+ command.creation_flags(0x0800_0000);
+ }
+ let _ = command.status();
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
+ while process::is_alive(&record) && std::time::Instant::now() < deadline {
+ std::thread::sleep(std::time::Duration::from_millis(200));
+ }
+ if process::is_alive(&record) { return Err(Error::Invalid("The dashboard could not be stopped.".into())); }
+ }
+ let _ = std::fs::remove_file(record_file);
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn tags(list: &[&str]) -> Vec { list.iter().map(|s| s.to_string()).collect() }
+
+ #[test]
+ fn the_dashboard_follows_the_bots_release_series() {
+ let available = tags(&["v1.8", "v1.9", "v1.9.2", "v2.0"]);
+ assert_eq!(pick_tag("v1.9", &available).as_deref(), Some("v1.9.2"), "the newest of the same series");
+ assert_eq!(pick_tag("v1.9.1", &tags(&["v1.8", "v1.9"])).as_deref(), Some("v1.9"), "bots 1.9.1 use dashboard 1.9");
+ assert_eq!(pick_tag("v1.8.1", &available).as_deref(), Some("v1.8"));
+ assert_eq!(pick_tag("v1.10", &available), None, "a later series has none yet");
+ assert_eq!(pick_tag("main", &available), None);
+ assert!(same_series("v1.9", "v1.9.1") && !same_series("v1.9", "v1.8.9"));
+ }
+
+ #[test]
+ fn tags_are_checked_before_they_reach_a_url() {
+ assert!(safe_tag("v1.9.1") && !safe_tag("v1.9/../x") && !safe_tag("") && !safe_tag("a b"));
+ }
+
+ /// Needs the network: `cargo test -p coa-core dashboard -- --ignored`.
+ #[test]
+ #[ignore]
+ fn the_matching_release_is_downloaded_and_unpacked() {
+ let root = tempfile::tempdir().unwrap();
+ let settings = root.path().join("Core/configs/modules/playerbots.conf.settings.json");
+ fsx::atomic_write(&settings, br#"{"format":1,"tag":"v1.9.1","settings":[]}"#).unwrap();
+ let installed = install(root.path(), &|step| println!("{step}")).unwrap();
+ assert!(installed.installed && installed.matches);
+ assert_eq!(installed.tag.as_deref(), Some("v1.9"));
+ assert!(root.path().join("Extras/SquidDashboard/squidbots.py").is_file());
+ assert!(!installed.running);
+ }
+
+ #[test]
+ fn a_folder_without_the_dashboard_is_not_installed_or_running() {
+ let root = tempfile::tempdir().unwrap();
+ let s = status(root.path());
+ assert!(!s.installed && !s.running && !s.matches);
+ assert!(start(root.path()).is_err());
+ assert!(stop(root.path()).is_ok());
+ }
+}
diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs
index 968a16d..b105692 100644
--- a/crates/coa-core/src/lib.rs
+++ b/crates/coa-core/src/lib.rs
@@ -7,6 +7,7 @@ pub mod remote_client;
pub mod clientdl;
pub mod config;
pub mod console;
+pub mod dashboard;
pub mod db;
pub mod diag;
pub mod docker;
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 36a5bb8..06cfeff 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -365,6 +365,30 @@ async fn set_backup_location(state: State<'_, AppState>, id: String, path: Optio
blocking(move || backup::set_location(&root, &meta_dir(&root)?, path.as_deref())).await
}
+#[tauri::command]
+async fn dashboard_status(state: State<'_, AppState>, id: String) -> std::result::Result {
+ let root = path_of(&state, &id)?;
+ blocking(move || Ok(coa_core::dashboard::status(&root))).await
+}
+
+#[tauri::command]
+async fn dashboard_install(app: AppHandle, state: State<'_, AppState>, id: String) -> std::result::Result {
+ let root = path_of(&state, &id)?;
+ blocking(move || coa_core::dashboard::install(&root, &|step| { let _ = app.emit("dashboard-progress", step); })).await
+}
+
+#[tauri::command]
+async fn dashboard_start(state: State<'_, AppState>, id: String) -> std::result::Result {
+ let root = path_of(&state, &id)?;
+ blocking(move || coa_core::dashboard::start(&root)).await
+}
+
+#[tauri::command]
+async fn dashboard_stop(state: State<'_, AppState>, id: String) -> std::result::Result {
+ let root = path_of(&state, &id)?;
+ blocking(move || { coa_core::dashboard::stop(&root)?; Ok(coa_core::dashboard::status(&root)) }).await
+}
+
#[tauri::command]
fn list_backups(state: State<'_, AppState>, id: String) -> std::result::Result, UiError> {
let root = path_of(&state, &id)?;
@@ -1571,6 +1595,10 @@ pub fn run() {
list_config_snapshots,
restore_config_snapshot,
list_backups,
+ dashboard_status,
+ dashboard_install,
+ dashboard_start,
+ dashboard_stop,
backup_location,
set_backup_location,
create_backup,
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index eff0631..743c799 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -21,7 +21,7 @@
}
],
"security": {
- "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
+ "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; frame-src http://localhost:* http://127.0.0.1:*"
}
},
"bundle": {
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 7eee400..ac023bd 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -827,7 +827,23 @@ 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)",
+ "nav.dashboard": "Dashboard",
+ "q.dashboard": "Was tun die Bots gerade?",
+ "dash.title": "Bot-Dashboard",
+ "dash.intro": "SQUIDs Live-Seite zu den Bots: wo sie sind, was sie tun, was sie finden. Läuft nur auf diesem Computer.",
+ "dash.notInstalled": "Das Dashboard ist noch nicht installiert.",
+ "dash.willInstall": "Die zu deinen Bots {v} passende Version wird heruntergeladen.",
+ "dash.noSquid": "Die Version deiner SQUID-Bots konnte nicht gelesen werden.",
+ "dash.install": "Dashboard herunterladen",
+ "dash.versions": "Dashboard {dash} für Bots {bots}",
+ "dash.running": "läuft",
+ "dash.stopped": "angehalten",
+ "dash.mismatch": "Dieses Dashboard gehört nicht zu deinen Bots {bots}. Aktualisiere es, damit die Versionen passen.",
+ "dash.start": "Dashboard starten",
+ "dash.stop": "Dashboard anhalten",
+ "dash.openBrowser": "Im Browser öffnen",
+ "dash.update": "Dashboard aktualisieren",
+ "upd.startAfter": "Server nach dem Update starten (sonst bleibt ein angehaltener Server angehalten)",
"client.startAfter": "Spiel nach dem Client-Update starten",
"bk.folder": "Sicherungsordner",
"bk.folderDefault": "Standard",
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 4c058c4..4354787 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -850,7 +850,23 @@ 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)",
+ "nav.dashboard": "Dashboard",
+ "q.dashboard": "What are the bots doing right now?",
+ "dash.title": "Bot dashboard",
+ "dash.intro": "SQUID's live page for the bots: where they are, what they do, what they find. It runs on this computer only.",
+ "dash.notInstalled": "The dashboard is not installed yet.",
+ "dash.willInstall": "The release that matches your bots {v} will be downloaded.",
+ "dash.noSquid": "The version of your SQUID bots could not be read.",
+ "dash.install": "Download the dashboard",
+ "dash.versions": "Dashboard {dash} for bots {bots}",
+ "dash.running": "running",
+ "dash.stopped": "stopped",
+ "dash.mismatch": "This dashboard does not belong to your bots {bots}. Update it so that they match.",
+ "dash.start": "Start the dashboard",
+ "dash.stop": "Stop the dashboard",
+ "dash.openBrowser": "Open in the browser",
+ "dash.update": "Update the dashboard",
+ "upd.startAfter": "Start the server after updating it (otherwise a stopped server stays stopped)",
"client.startAfter": "Start the game after the client has been updated",
"bk.folder": "Backup folder",
"bk.folderDefault": "default",
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index 418d13e..15177b3 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -827,7 +827,23 @@ 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)",
+ "nav.dashboard": "Panel",
+ "q.dashboard": "¿Qué están haciendo los bots ahora?",
+ "dash.title": "Panel de bots",
+ "dash.intro": "La página en vivo de SQUID sobre los bots: dónde están, qué hacen, qué encuentran. Funciona solo en este equipo.",
+ "dash.notInstalled": "El panel aún no está instalado.",
+ "dash.willInstall": "Se descargará la versión que corresponde a tus bots {v}.",
+ "dash.noSquid": "No se pudo leer la versión de tus bots SQUID.",
+ "dash.install": "Descargar el panel",
+ "dash.versions": "Panel {dash} para bots {bots}",
+ "dash.running": "en marcha",
+ "dash.stopped": "detenido",
+ "dash.mismatch": "Este panel no corresponde a tus bots {bots}. Actualízalo para que coincidan.",
+ "dash.start": "Iniciar el panel",
+ "dash.stop": "Detener el panel",
+ "dash.openBrowser": "Abrir en el navegador",
+ "dash.update": "Actualizar el panel",
+ "upd.startAfter": "Iniciar el servidor tras actualizarlo (si no, un servidor detenido sigue detenido)",
"client.startAfter": "Iniciar el juego tras actualizar el cliente",
"bk.folder": "Carpeta de copias de seguridad",
"bk.folderDefault": "predeterminada",
diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts
index cee9ef5..6e37e65 100644
--- a/src/i18n/locales/fr.ts
+++ b/src/i18n/locales/fr.ts
@@ -827,7 +827,23 @@ 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)",
+ "nav.dashboard": "Tableau de bord",
+ "q.dashboard": "Que font les bots en ce moment ?",
+ "dash.title": "Tableau de bord des bots",
+ "dash.intro": "La page en direct de SQUID sur les bots : où ils sont, ce qu’ils font, ce qu’ils trouvent. Elle ne tourne que sur cet ordinateur.",
+ "dash.notInstalled": "Le tableau de bord n’est pas encore installé.",
+ "dash.willInstall": "La version qui correspond à vos bots {v} sera téléchargée.",
+ "dash.noSquid": "La version de vos bots SQUID n’a pas pu être lue.",
+ "dash.install": "Télécharger le tableau de bord",
+ "dash.versions": "Tableau de bord {dash} pour les bots {bots}",
+ "dash.running": "en cours",
+ "dash.stopped": "arrêté",
+ "dash.mismatch": "Ce tableau de bord ne correspond pas à vos bots {bots}. Mettez-le à jour pour que les versions concordent.",
+ "dash.start": "Démarrer le tableau de bord",
+ "dash.stop": "Arrêter le tableau de bord",
+ "dash.openBrowser": "Ouvrir dans le navigateur",
+ "dash.update": "Mettre à jour le tableau de bord",
+ "upd.startAfter": "Démarrer le serveur après la mise à jour (sinon un serveur arrêté reste arrêté)",
"client.startAfter": "Lancer le jeu après la mise à jour du client",
"bk.folder": "Dossier des sauvegardes",
"bk.folderDefault": "par défaut",
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index a8eb78b..71d3ed7 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -848,7 +848,23 @@ const ru: Partial> = {
"set.searchAll": "Поиск по всем настройкам: название или описание…",
"set.search": "Поиск по настройкам…",
"upd.current": "У вас последняя версия ({v}).",
- "upd.startAfter": "Запускать сервер после обновления (выключите, чтобы он снова остановился после обновления)",
+ "nav.dashboard": "Панель",
+ "q.dashboard": "Что сейчас делают боты?",
+ "dash.title": "Панель ботов",
+ "dash.intro": "Страница SQUID о ботах в реальном времени: где они, что делают, что находят. Работает только на этом компьютере.",
+ "dash.notInstalled": "Панель ещё не установлена.",
+ "dash.willInstall": "Будет скачан выпуск, подходящий к вашим ботам {v}.",
+ "dash.noSquid": "Не удалось определить версию ботов SQUID.",
+ "dash.install": "Скачать панель",
+ "dash.versions": "Панель {dash} для ботов {bots}",
+ "dash.running": "работает",
+ "dash.stopped": "остановлена",
+ "dash.mismatch": "Эта панель не соответствует вашим ботам {bots}. Обновите её, чтобы версии совпадали.",
+ "dash.start": "Запустить панель",
+ "dash.stop": "Остановить панель",
+ "dash.openBrowser": "Открыть в браузере",
+ "dash.update": "Обновить панель",
+ "upd.startAfter": "Запускать сервер после обновления (иначе выключенный сервер останется выключенным)",
"client.startAfter": "Запускать игру после обновления клиента",
"bk.folder": "Папка резервных копий",
"bk.folderDefault": "по умолчанию",
diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts
index d0d0ecf..72e8d16 100644
--- a/src/i18n/locales/zh.ts
+++ b/src/i18n/locales/zh.ts
@@ -827,7 +827,23 @@ const zh = {
"set.searchAll": "按名称或描述搜索全部设置…",
"set.search": "搜索设置…",
"upd.current": "当前已是最新版本 ({v})。",
- "upd.startAfter": "更新后启动服务器(关闭则更新完成后再次停止)",
+ "nav.dashboard": "仪表盘",
+ "q.dashboard": "机器人现在在做什么?",
+ "dash.title": "机器人仪表盘",
+ "dash.intro": "SQUID 的机器人实时页面:它们在哪里、在做什么、发现了什么。仅在本机运行。",
+ "dash.notInstalled": "尚未安装仪表盘。",
+ "dash.willInstall": "将下载与你的机器人 {v} 对应的版本。",
+ "dash.noSquid": "无法读取你的 SQUID 机器人版本。",
+ "dash.install": "下载仪表盘",
+ "dash.versions": "适用于机器人 {bots} 的仪表盘 {dash}",
+ "dash.running": "运行中",
+ "dash.stopped": "已停止",
+ "dash.mismatch": "此仪表盘与你的机器人 {bots} 不匹配。请更新使版本一致。",
+ "dash.start": "启动仪表盘",
+ "dash.stop": "停止仪表盘",
+ "dash.openBrowser": "在浏览器中打开",
+ "dash.update": "更新仪表盘",
+ "upd.startAfter": "更新后启动服务器(否则已停止的服务器保持停止)",
"client.startAfter": "更新客户端后启动游戏",
"bk.folder": "备份文件夹",
"bk.folderDefault": "默认",
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 4acf7b2..d79fe75 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -78,6 +78,16 @@ export interface DriverOutcome {
output: string;
}
+export interface DashboardStatus {
+ installed: boolean;
+ tag: string | null;
+ squid_tag: string | null;
+ matches: boolean;
+ running: boolean;
+ port: number;
+ url: string;
+}
+
export interface BackupLocation {
path: string;
default_path: string;
@@ -431,6 +441,10 @@ 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 }),
+ dashboardStatus: (id: string) => invoke("dashboard_status", { id }),
+ dashboardInstall: (id: string) => invoke("dashboard_install", { id }),
+ dashboardStart: (id: string) => invoke("dashboard_start", { id }),
+ dashboardStop: (id: string) => invoke("dashboard_stop", { 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 }),
diff --git a/src/lib/serverUpdate.ts b/src/lib/serverUpdate.ts
index 295faf8..a89e878 100644
--- a/src/lib/serverUpdate.ts
+++ b/src/lib/serverUpdate.ts
@@ -86,6 +86,9 @@ export async function applyServerUpdate(id: string, opts?: { choices: Record 0)) return false;
+ // The update stops a running server and proves the new build by starting it; a server that was stopped stays stopped
+ // unless the owner asked for it to start.
+ const wasRunning = await api.status(id).then((s) => [s.observed.mysql, s.observed.auth, s.observed.world].some((x) => x.state === "running" || x.state === "starting")).catch(() => true);
set(id, { applying: true, step: null, percent: 0, error: null, uiError: null, result: null });
let un: (() => void) | undefined;
try {
@@ -96,8 +99,7 @@ export async function applyServerUpdate(id: string, opts?: { choices: Record undefined);
+ if (out.txn.state === "committed" && !wasRunning && !startAfterUpdate("server", id)) await api.stop(id).catch(() => undefined);
return true;
} catch (e) {
const ui = asUiError(e);
diff --git a/src/lib/updatePrefs.ts b/src/lib/updatePrefs.ts
index 0b5950c..ba2caa3 100644
--- a/src/lib/updatePrefs.ts
+++ b/src/lib/updatePrefs.ts
@@ -1,13 +1,13 @@
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).
+ * Whether a finished update starts things. Off by default for both: updating a stopped server leaves it stopped (a
+ * server that was running is started again, as the update had to stop it) and a client update does not open the game.
*/
export type AfterUpdate = "server" | "client";
const key = (kind: AfterUpdate, id: string) => `coa-start-after-${kind}-update:${id}`;
-const fallback = (kind: AfterUpdate) => kind === "server";
+const fallback = (_kind: AfterUpdate) => false;
export function startAfterUpdate(kind: AfterUpdate, id: string): boolean {
try {
diff --git a/src/screens/DashboardPage.tsx b/src/screens/DashboardPage.tsx
new file mode 100644
index 0000000..ee94e0f
--- /dev/null
+++ b/src/screens/DashboardPage.tsx
@@ -0,0 +1,109 @@
+import { useCallback, useEffect, useState } from "react";
+import { listen } from "@tauri-apps/api/event";
+import { Loader2 } from "lucide-react";
+import { api, asUiError, type DashboardStatus, type UiError } from "@/lib/api";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { useHuman, useT } from "@/i18n";
+
+/** SQUID's bot dashboard: installed from the release that matches the bots, started on a free port, shown here. */
+export function DashboardPage({ serverId }: { serverId: string }) {
+ const t = useT();
+ const human = useHuman();
+ const [status, setStatus] = useState(null);
+ const [busy, setBusy] = useState<"install" | "start" | "stop" | null>(null);
+ const [step, setStep] = useState(null);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ try { setStatus(await api.dashboardStatus(serverId)); } catch (e) { setError(asUiError(e)); }
+ }, [serverId]);
+
+ useEffect(() => {
+ void load();
+ const un = listen("dashboard-progress", (e) => setStep(e.payload));
+ return () => { void un.then((f) => f()); };
+ }, [load]);
+
+ async function run(kind: "install" | "start" | "stop", fn: () => Promise) {
+ setBusy(kind); setError(null); setStep(null);
+ try { setStatus(await fn()); } catch (e) { setError(asUiError(e)); await load(); }
+ finally { setBusy(null); setStep(null); }
+ }
+
+ const install = () => run("install", () => api.dashboardInstall(serverId));
+
+ return (
+
+
{t("dash.title")}
+
{t("dash.intro")}
+
+ {!status && !error &&
{t("overview.checking")}
}
+
+ {status && (
+
+ {!status.installed ? (
+ <>
+ {t("dash.notInstalled")}
+ {status.squid_tag ? t("dash.willInstall", { v: status.squid_tag }) : t("dash.noSquid")}
+ void install()}>
+ {busy === "install" && }
+ {t("dash.install")}
+
+ >
+ ) : (
+ <>
+
+ {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 ? (
+ <>
+ void run("stop", () => api.dashboardStop(serverId))}>
+ {busy === "stop" && }
+ {t("dash.stop")}
+
+ void api.openLink(status.url)}>{t("dash.openBrowser")}
+ >
+ ) : (
+ void run("start", () => api.dashboardStart(serverId))}>
+ {busy === "start" && }
+ {t("dash.start")}
+
+ )}
+ {!status.matches && status.squid_tag && (
+ void install()}>
+ {busy === "install" && }
+ {t("dash.update")}
+
+ )}
+
+ >
+ )}
+ {busy === "install" && {step ?? t("bk.working")}
}
+
+ )}
+
+ {error && (
+
+ {error.human.code === "unknown" ? error.technical : human(error.human).message}
+ void navigator.clipboard.writeText(error.technical)}>{t("common.copyError")}
+
+ )}
+
+ {status?.running && (
+
+ )}
+
+ );
+}
diff --git a/src/screens/Shell.tsx b/src/screens/Shell.tsx
index cf3fcfb..dd6d310 100644
--- a/src/screens/Shell.tsx
+++ b/src/screens/Shell.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
-import { Archive, Bot, Bug, Gauge, Globe, Puzzle, Server, Settings, Terminal, Users, type LucideIcon } from "lucide-react";
+import { Archive, BarChart3, Bot, Bug, Gauge, Globe, Puzzle, Server, Settings, Terminal, Users, type LucideIcon } from "lucide-react";
import { api, type ServerSummary } from "@/lib/api";
import { useT, type Key } from "@/i18n";
import logo from "@/assets/logo.png";
@@ -14,15 +14,17 @@ import { FriendsPage } from "@/screens/FriendsPage";
import { ReportPage } from "@/screens/ReportPage";
import { ModulesPage } from "@/screens/ModulesPage";
import { ConsolePage } from "@/screens/ConsolePage";
+import { DashboardPage } from "@/screens/DashboardPage";
import { Button } from "@/components/ui/button";
import { startServerUpdatePolling, useServerUpdate } from "@/lib/serverUpdate";
import { startClientPolling } from "@/lib/clientUpdate";
-type Page = "overview" | "bots" | "server" | "modules" | "players" | "friends" | "backups" | "console" | "report" | "settings";
+type Page = "overview" | "bots" | "dashboard" | "server" | "modules" | "players" | "friends" | "backups" | "console" | "report" | "settings";
const NAV: { id: Page; label: Key; icon: LucideIcon; question: Key }[] = [
{ id: "overview", label: "nav.overview", icon: Gauge, question: "q.overview" },
{ id: "bots", label: "nav.bots", icon: Bot, question: "q.bots" },
+ { id: "dashboard", label: "nav.dashboard", icon: BarChart3, question: "q.dashboard" },
{ id: "server", label: "nav.server", icon: Server, question: "q.server" },
{ id: "modules", label: "nav.modules", icon: Puzzle, question: "q.modules" },
{ id: "players", label: "nav.players", icon: Users, question: "q.players" },
@@ -84,7 +86,7 @@ export function Shell(props: {
{t("app.name")}
- {NAV.filter((n) => !hiddenPages.includes(n.id)).map(({ id, label, icon: Icon }) => (
+ {NAV.filter((n) => !hiddenPages.includes(n.id) && (n.id !== "dashboard" || botModule === "playerbots")).map(({ id, label, icon: Icon }) => (
setPage(id)}
@@ -130,6 +132,8 @@ export function Shell(props: {
props.onForget(server.id)} onOpenUpdates={() => setPage("settings")} onRealmChanged={reloadModules} onReport={() => setPage("report")} />
) : page === "bots" || page === "server" ? (
+ ) : page === "dashboard" ? (
+
) : page === "console" ? (
) : page === "modules" ? (
From 1acbcc2bb5fb71ca0e4c03c673f1de7a5aeee503 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 08:33:36 +0500
Subject: [PATCH 10/12] Fix the dashboard tab: large answers, damaged history
files, browser link and realm drop-down
The dashboard now runs through a small launcher that avoids the immediate socket shutdown that
cut answers above 64 KB on Windows, supplies the missing level-builds file, removes history
files zeroed by a crash, is addressed as 127.0.0.1 and opens in the browser through its own
command. The realm drop-down is hidden while both worlds start together.
Co-Authored-By: Claude Sonnet 5.5
---
changelog.d/20261008-dashboard-delivery.md | 7 ++
crates/coa-core/src/dashboard.rs | 100 ++++++++++++++++++++-
src-tauri/src/lib.rs | 10 +++
src/lib/api.ts | 1 +
src/screens/DashboardPage.tsx | 2 +-
src/screens/RealmPicker.tsx | 8 +-
6 files changed, 121 insertions(+), 7 deletions(-)
create mode 100644 changelog.d/20261008-dashboard-delivery.md
diff --git a/changelog.d/20261008-dashboard-delivery.md b/changelog.d/20261008-dashboard-delivery.md
new file mode 100644
index 0000000..5caa410
--- /dev/null
+++ b/changelog.d/20261008-dashboard-delivery.md
@@ -0,0 +1,7 @@
+---
+area: manager
+type: fixed
+audience: admins
+title: The bot dashboard loads completely and can be opened in the browser
+---
+The dashboard no longer fails on large pages, starts again after a crash left its history files empty, and its Open in the browser button works. With both realms started together the realm drop-down is hidden.
diff --git a/crates/coa-core/src/dashboard.rs b/crates/coa-core/src/dashboard.rs
index 15b6b14..5a4b76b 100644
--- a/crates/coa-core/src/dashboard.rs
+++ b/crates/coa-core/src/dashboard.rs
@@ -101,7 +101,7 @@ pub fn status(root: &Path) -> Status {
squid_tag: squid,
running,
port,
- url: format!("http://localhost:{port}"),
+ url: format!("http://127.0.0.1:{port}"),
}
}
@@ -174,6 +174,61 @@ fn free_port(preferred: u16) -> Result {
.ok_or_else(|| Error::Invalid("No free port was found for the dashboard.".into()))
}
+/// A crash or power loss while the dashboard saves its history can leave a file of the right length that holds only
+/// zero bytes, and the dashboard then fails to start on it. Those files carry nothing, and the dashboard recreates them,
+/// so they are removed. The files that come with the release are never touched here (reinstalling restores them).
+fn discard_damaged_data(folder: &Path) -> Vec {
+ const SHIPPED: &[&str] = &["worldmap.json", "dashboard.example.json"];
+ let mut removed = Vec::new();
+ let Ok(entries) = std::fs::read_dir(folder) else { return removed; };
+ for entry in entries.flatten() {
+ let name = entry.file_name().to_string_lossy().into_owned();
+ let path = entry.path();
+ if !name.ends_with(".json") || name.starts_with('.') || SHIPPED.contains(&name.as_str()) || !path.is_file() { continue; }
+ let Ok(bytes) = std::fs::read(&path) else { continue; };
+ if !bytes.is_empty() && bytes.iter().all(|b| *b == 0) && std::fs::remove_file(&path).is_ok() {
+ tracing::warn!(file = %path.display(), "dashboard: removed a data file damaged by an interrupted write");
+ removed.push(name);
+ }
+ }
+ removed
+}
+
+/// The dashboard reads `coa-level-builds.json` (class and specialization names) from its own folder; the CoA Bots repack
+/// ships it, the GitHub release does not. The server's own reference file has the same content and shape.
+fn ensure_builds_file(root: &Path) {
+ let target = dir(root).join("coa-level-builds.json");
+ if target.is_file() { return; }
+ let reference = root.join("Core/reference/ascensionsidekick-level-builds.json");
+ let content = std::fs::read(&reference).ok().filter(|bytes| serde_json::from_slice::(bytes).is_ok_and(|v| v.is_object()));
+ let _ = fsx::atomic_write(&target, content.as_deref().unwrap_or(b"{}"));
+}
+
+/// Python's HTTP server closes each connection with `shutdown(SHUT_WR)` straight after the answer. On Windows that can
+/// throw away what is still queued: any answer above about 64 KB (the dashboard's statistics are 400 KB) arrives cut
+/// off, and the page fails to load. This small launcher waits for the client to finish reading and close instead, queues
+/// more waiting connections than Python's five, and then runs the dashboard unchanged.
+const LAUNCHER: &str = r#"import runpy, socketserver, sys, http.server
+
+http.server.ThreadingHTTPServer.request_queue_size = 256
+
+
+def shutdown_request(self, request):
+ try:
+ request.settimeout(5)
+ while request.recv(4096):
+ pass
+ except OSError:
+ pass
+ self.close_request(request)
+
+
+socketserver.TCPServer.shutdown_request = shutdown_request
+script = sys.argv[1]
+sys.argv = [script]
+runpy.run_path(script, run_name="__main__")
+"#;
+
/// Start the dashboard with the repack's own Python. Returns when it answers on its port.
pub fn start(root: &Path) -> Result {
let current = status(root);
@@ -189,7 +244,12 @@ pub fn start(root: &Path) -> Result {
let log_path = folder.join("dashboard.log");
let log = std::fs::File::create(&log_path)?;
let mut command = std::process::Command::new(&python);
- command.arg("-B").arg("squidbots.py").current_dir(&folder).env("COA_DASHBOARD_PORT", marker.port.to_string())
+ ensure_builds_file(root);
+ discard_damaged_data(&folder);
+ let script = folder.join("squidbots.py");
+ let launcher = folder.join("manager_launch.py");
+ fsx::atomic_write(&launcher, LAUNCHER.as_bytes())?;
+ command.arg("-B").arg(&launcher).arg(&script).current_dir(&folder).env("COA_DASHBOARD_PORT", marker.port.to_string())
.stdin(std::process::Stdio::null()).stdout(log.try_clone()?).stderr(log);
#[cfg(windows)]
{
@@ -256,6 +316,20 @@ mod tests {
assert!(same_series("v1.9", "v1.9.1") && !same_series("v1.9", "v1.8.9"));
}
+ #[test]
+ fn zero_filled_history_files_are_removed_but_shipped_and_valid_files_stay() {
+ let folder = tempfile::tempdir().unwrap();
+ std::fs::write(folder.path().join("history.json"), vec![0u8; 358]).unwrap();
+ std::fs::write(folder.path().join("levels.json"), vec![0u8; 100]).unwrap();
+ std::fs::write(folder.path().join("xp-history.json"), "[1, 2]").unwrap();
+ std::fs::write(folder.path().join("worldmap.json"), vec![0u8; 50]).unwrap();
+ std::fs::write(folder.path().join("empty.json"), "").unwrap();
+ let mut removed = discard_damaged_data(folder.path());
+ removed.sort();
+ assert_eq!(removed, ["history.json", "levels.json"]);
+ assert!(folder.path().join("xp-history.json").exists() && folder.path().join("worldmap.json").exists() && folder.path().join("empty.json").exists());
+ }
+
#[test]
fn tags_are_checked_before_they_reach_a_url() {
assert!(safe_tag("v1.9.1") && !safe_tag("v1.9/../x") && !safe_tag("") && !safe_tag("a b"));
@@ -275,6 +349,28 @@ mod tests {
assert!(!installed.running);
}
+ /// Needs a real server folder (`COA_TEST_ROOT`) whose bots are installed and whose database runs:
+ /// `COA_TEST_ROOT=... cargo test -p coa-core dashboard -- --ignored --nocapture`.
+ #[test]
+ #[ignore]
+ fn the_started_dashboard_delivers_its_large_statistics() {
+ let root = PathBuf::from(std::env::var("COA_TEST_ROOT").expect("COA_TEST_ROOT"));
+ let started = start(&root).unwrap();
+ assert!(started.running, "{started:?}");
+ let client = reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(90)).build().unwrap();
+ let body = client.get(format!("{}/api/stats", started.url)).send().unwrap().bytes().unwrap();
+ println!("stats: {} bytes: {}", body.len(), String::from_utf8_lossy(&body[..body.len().min(300)]));
+ let live = client.get(format!("{}/api/live", started.url)).send().unwrap().bytes().unwrap();
+ stop(&root).unwrap();
+ assert!(!status(&root).running);
+ if String::from_utf8_lossy(&body).starts_with("{\"error\"") {
+ println!("the database of this server is not running, so the large answer could not be checked");
+ } else {
+ assert!(body.len() > 100_000, "the statistics must arrive whole, got {} bytes", body.len());
+ }
+ assert!(!live.is_empty());
+ }
+
#[test]
fn a_folder_without_the_dashboard_is_not_installed_or_running() {
let root = tempfile::tempdir().unwrap();
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 06cfeff..fa60d83 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -377,6 +377,15 @@ async fn dashboard_install(app: AppHandle, state: State<'_, AppState>, id: Strin
blocking(move || coa_core::dashboard::install(&root, &|step| { let _ = app.emit("dashboard-progress", step); })).await
}
+#[tauri::command]
+async fn dashboard_open(state: State<'_, AppState>, id: String) -> std::result::Result<(), UiError> {
+ let root = path_of(&state, &id)?;
+ let status = blocking(move || Ok(coa_core::dashboard::status(&root))).await?;
+ if !status.running { return Err(Error::Invalid("The dashboard is not running.".into()).into()); }
+ tauri_plugin_opener::open_url(&status.url, None::<&str>).map_err(|e| Error::Invalid(e.to_string()))?;
+ Ok(())
+}
+
#[tauri::command]
async fn dashboard_start(state: State<'_, AppState>, id: String) -> std::result::Result {
let root = path_of(&state, &id)?;
@@ -1598,6 +1607,7 @@ pub fn run() {
dashboard_status,
dashboard_install,
dashboard_start,
+ dashboard_open,
dashboard_stop,
backup_location,
set_backup_location,
diff --git a/src/lib/api.ts b/src/lib/api.ts
index d79fe75..b580709 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -443,6 +443,7 @@ export const api = {
backups: (id: string) => invoke("list_backups", { id }),
dashboardStatus: (id: string) => invoke("dashboard_status", { id }),
dashboardInstall: (id: string) => invoke("dashboard_install", { id }),
+ dashboardOpen: (id: string) => invoke("dashboard_open", { id }),
dashboardStart: (id: string) => invoke("dashboard_start", { id }),
dashboardStop: (id: string) => invoke("dashboard_stop", { id }),
backupLocation: (id: string) => invoke("backup_location", { id }),
diff --git a/src/screens/DashboardPage.tsx b/src/screens/DashboardPage.tsx
index ee94e0f..e41a763 100644
--- a/src/screens/DashboardPage.tsx
+++ b/src/screens/DashboardPage.tsx
@@ -68,7 +68,7 @@ export function DashboardPage({ serverId }: { serverId: string }) {
{busy === "stop" && }
{t("dash.stop")}
- void api.openLink(status.url)}>{t("dash.openBrowser")}
+ void api.dashboardOpen(serverId).catch((e) => setError(asUiError(e)))}>{t("dash.openBrowser")}
>
) : (
void run("start", () => api.dashboardStart(serverId))}>
diff --git a/src/screens/RealmPicker.tsx b/src/screens/RealmPicker.tsx
index bab28d3..5cde7cf 100644
--- a/src/screens/RealmPicker.tsx
+++ b/src/screens/RealmPicker.tsx
@@ -34,15 +34,15 @@ export function RealmPicker({ serverId, running, disabled, onChanged, onBusy }:
{t("realm.profileLabel")}
- setSelected(e.target.value as RealmMode)} className="min-w-52 rounded-md border border-line bg-card-2 px-3 py-2 text-ink">
+ {view?.simultaneous ? Conquest of Azeroth + Wildcard : setSelected(e.target.value as RealmMode)} className="min-w-52 rounded-md border border-line bg-card-2 px-3 py-2 text-ink">
Conquest of Azeroth
Wildcard · Darkmoon
-
- {view && (selected !== view.active || view.recovery_pending) && void select()}>
+ }
+ {view && !view.simultaneous && (selected !== view.active || view.recovery_pending) && void select()}>
{busy && }
{busy ? t("realm.switching") : selected === "wildcard" && !view.wildcard_created ? t("realm.create") : running ? t("realm.restart") : t("realm.select")}
}
- {view && {t("realm.active", { name: view.active === "coa" ? "CoA" : "Wildcard" })} }
+ {view && !view.simultaneous && {t("realm.active", { name: view.active === "coa" ? "CoA" : "Wildcard" })} }
{t(view?.simultaneous ? "realm.bothRunning" : "realm.separate")}
{selected === "wildcard" && {t("realm.modules")}
}
From c3791f80f18c04a9298ab2712c1ed47fac4a840c 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 08:58:25 +0500
Subject: [PATCH 11/12] Shorten two changelog titles to the 90 character limit
Co-Authored-By: Claude Sonnet 5.5
---
changelog.d/20261008-update-check-survives-damaged-state.md | 2 +-
changelog.d/20261008-wildcard-repair-checksum.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/changelog.d/20261008-update-check-survives-damaged-state.md b/changelog.d/20261008-update-check-survives-damaged-state.md
index b01e708..047234f 100644
--- a/changelog.d/20261008-update-check-survives-damaged-state.md
+++ b/changelog.d/20261008-update-check-survives-damaged-state.md
@@ -2,6 +2,6 @@
area: manager
type: fixed
audience: admins
-title: Checking for updates no longer fails with "Something went wrong" after an interrupted start
+title: Checking for updates no longer fails with "Something went wrong" after a crash
---
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/changelog.d/20261008-wildcard-repair-checksum.md b/changelog.d/20261008-wildcard-repair-checksum.md
index c346118..613d852 100644
--- a/changelog.d/20261008-wildcard-repair-checksum.md
+++ b/changelog.d/20261008-wildcard-repair-checksum.md
@@ -2,6 +2,6 @@
area: manager
type: fixed
audience: admins
-title: Server updates no longer stop with "has a different checksum" for the Wildcard table repair
+title: Server updates no longer stop with "has a different checksum" on the Wildcard repair
---
Servers that applied one of the two published versions of the Wildcard table repair can now update to the other. Nothing in the database changes.
From c8a0f6872f0561860c51b5df6987befd86d938ac 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 09:33:46 +0500
Subject: [PATCH 12/12] Hide the SQUID bot accounts from the Accounts list
Accounts named with AiPlayerbot.RandomBotAccountPrefix (default rndbot) belong to the bot
system like the companions' accounts: they are not listed and cannot be renamed or deleted.
Co-Authored-By: Claude Sonnet 5.5
---
.../20261008-hide-squid-bot-accounts.md | 7 ++++
crates/coa-core/src/accounts.rs | 36 ++++++++++++++++---
2 files changed, 38 insertions(+), 5 deletions(-)
create mode 100644 changelog.d/20261008-hide-squid-bot-accounts.md
diff --git a/changelog.d/20261008-hide-squid-bot-accounts.md b/changelog.d/20261008-hide-squid-bot-accounts.md
new file mode 100644
index 0000000..7030e43
--- /dev/null
+++ b/changelog.d/20261008-hide-squid-bot-accounts.md
@@ -0,0 +1,7 @@
+---
+area: manager
+type: fixed
+audience: admins
+title: Accounts of the SQUID bots are hidden from the Accounts list
+---
+The RNDBOT accounts that hold the random bots no longer fill the Accounts tab, and they cannot be renamed or deleted from there.
diff --git a/crates/coa-core/src/accounts.rs b/crates/coa-core/src/accounts.rs
index 5aaa361..3e72835 100644
--- a/crates/coa-core/src/accounts.rs
+++ b/crates/coa-core/src/accounts.rs
@@ -27,7 +27,18 @@ const LIST_SQL: &str = "SELECT a.id, a.username, \
a.online, IFNULL(a.last_login, ''), \
(SELECT COUNT(*) FROM acore_characters.characters c WHERE c.account = a.id) \
FROM acore_auth.account a \
- WHERE a.username NOT LIKE 'COABOTHOST%' AND a.username <> 'COAMANAGER' ORDER BY a.id;";
+ WHERE a.username NOT LIKE 'COABOTHOST%' AND a.username <> 'COAMANAGER' AND a.username NOT LIKE '{SQUID}%' ORDER BY a.id;";
+
+/// The accounts of SQUID's random bots share a name prefix (`AiPlayerbot.RandomBotAccountPrefix`, default `rndbot`).
+/// They belong to the bot system like the companions' accounts and are never listed or touched here.
+pub fn squid_bot_prefix(root: &Path) -> String {
+ std::fs::read(root.join("Core/configs/modules/playerbots.conf"))
+ .ok()
+ .and_then(|bytes| crate::config::parser::ConfFile::parse_bytes(&bytes).ok())
+ .and_then(|conf| conf.get("AiPlayerbot.RandomBotAccountPrefix").map(|v| crate::config::parser::unquote(v).to_string()))
+ .filter(|prefix| !prefix.is_empty() && prefix.len() <= 32 && prefix.chars().all(|c| c.is_ascii_alphanumeric()))
+ .unwrap_or_else(|| "rndbot".into())
+}
/// Rows of tab-separated columns as the `mysql` client prints them.
pub fn parse_list(out: &str) -> Vec {
@@ -51,7 +62,7 @@ pub fn parse_list(out: &str) -> Vec {
pub fn list(root: &Path) -> Result> {
let db = Db::from_repack(root, Account::Admin)?;
- Ok(parse_list(&db.query(LIST_SQL)?))
+ Ok(parse_list(&db.query(&LIST_SQL.replace("{SQUID}", &squid_bot_prefix(root).to_ascii_uppercase()))?))
}
fn upper(name: &str) -> Result {
@@ -64,6 +75,9 @@ fn upper(name: &str) -> Result {
pub fn rename(root: &Path, ra: &mut Ra, old: &str, new: &str, password: &str) -> Result<()> {
let (old, new) = (upper(old)?, upper(new)?);
validate_account(&new, password)?;
+ if is_reserved(&old) || old.starts_with(&squid_bot_prefix(root).to_ascii_uppercase()) {
+ return Err(Error::Invalid("This account belongs to the Manager or the bots and cannot be renamed.".into()));
+ }
if old == new {
return Err(Error::Invalid("That is already the name of this account.".into()));
}
@@ -93,8 +107,8 @@ fn is_reserved(upper_name: &str) -> bool {
/// companions' own accounts, an account that is not there and one that is logged in right now.
pub fn delete(root: &Path, ra: &mut Ra, name: &str) -> Result<()> {
let name = upper(name)?;
- if is_reserved(&name) {
- return Err(Error::Invalid("This account belongs to the Manager or the companions and cannot be deleted.".into()));
+ if is_reserved(&name) || name.starts_with(&squid_bot_prefix(root).to_ascii_uppercase()) {
+ return Err(Error::Invalid("This account belongs to the Manager or the bots and cannot be deleted.".into()));
}
let db = Db::from_repack(root, Account::Admin)?;
match db.query(&format!("SELECT online FROM acore_auth.account WHERE username = '{name}';"))?.trim() {
@@ -125,7 +139,19 @@ mod tests {
#[test]
fn the_internal_accounts_are_filtered_out_by_the_query() {
- assert!(LIST_SQL.contains("COABOTHOST%") && LIST_SQL.contains("COAMANAGER"));
+ assert!(LIST_SQL.contains("COABOTHOST%") && LIST_SQL.contains("COAMANAGER") && LIST_SQL.contains("{SQUID}%"));
+ }
+
+ #[test]
+ fn the_squid_bot_prefix_comes_from_the_bot_settings_and_defaults_to_rndbot() {
+ let root = tempfile::tempdir().unwrap();
+ assert_eq!(squid_bot_prefix(root.path()), "rndbot");
+ let conf = root.path().join("Core/configs/modules/playerbots.conf");
+ std::fs::create_dir_all(conf.parent().unwrap()).unwrap();
+ std::fs::write(&conf, "AiPlayerbot.RandomBotAccountPrefix = \"MyBots\"\n").unwrap();
+ assert_eq!(squid_bot_prefix(root.path()), "MyBots");
+ std::fs::write(&conf, "AiPlayerbot.RandomBotAccountPrefix = \"x' OR 1=1 --\"\n").unwrap();
+ assert_eq!(squid_bot_prefix(root.path()), "rndbot", "an unsafe prefix is ignored");
}
#[test]