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/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/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/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 new file mode 100644 index 0000000..6244b5c --- /dev/null +++ b/changelog.d/20261008-start-after-update-options.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: A stopped server stays stopped after an update, unless you ask it to start +--- +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/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..047234f --- /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 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 new file mode 100644 index 0000000..613d852 --- /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" 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. 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/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] diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 8e21adb..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) } @@ -163,7 +248,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); @@ -176,6 +262,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"], @@ -490,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/dashboard.rs b/crates/coa-core/src/dashboard.rs new file mode 100644 index 0000000..5a4b76b --- /dev/null +++ b/crates/coa-core/src/dashboard.rs @@ -0,0 +1,382 @@ +//! 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://127.0.0.1:{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())) +} + +/// 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); + 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); + 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)] + { + 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 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")); + } + + /// 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); + } + + /// 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(); + 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/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/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/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/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>, 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/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 a048010..ab83667 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) { @@ -259,10 +266,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)?; @@ -277,7 +303,16 @@ 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_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(), false, Some(error.to_string())) + } + }; Ok(Preview { from_version: meta.core.version.clone(), to_version: m.version.clone(), @@ -286,6 +321,8 @@ 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, }) } @@ -422,7 +459,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 @@ -455,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); + } } } @@ -565,7 +606,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."))); } @@ -735,8 +776,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 }); } @@ -746,7 +792,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)) { @@ -1131,6 +1181,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/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-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3592012..fa60d83 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -353,6 +353,51 @@ 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] +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_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)?; + 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)?; @@ -684,12 +729,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 } @@ -1557,6 +1604,13 @@ pub fn run() { list_config_snapshots, restore_config_snapshot, list_backups, + dashboard_status, + dashboard_install, + dashboard_start, + dashboard_open, + dashboard_stop, + backup_location, + set_backup_location, create_backup, verify_backup, delete_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 c13b43a..ac023bd 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.", @@ -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 @@ -827,6 +827,36 @@ 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}).", + "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", + "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", + "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", "fr.lan.address": "Netzwerkadresse", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 03db274..4354787 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.", @@ -51,7 +53,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 +65,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 +202,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.", @@ -528,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 @@ -848,6 +850,36 @@ const en = { "set.searchAll": "Search all settings by name or description…", "set.search": "Search settings…", "upd.current": "You are up to date (version {v}).", + "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", + "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", + "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", "fr.lan.address": "Network address", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 3bd4e70..15177b3 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.", @@ -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 @@ -827,6 +827,36 @@ 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}).", + "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", + "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", + "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", "fr.lan.address": "Dirección de red", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index beb10a4..6e37e65 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.", @@ -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 @@ -827,6 +827,36 @@ 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}).", + "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", + "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", + "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", "fr.lan.address": "Adresse réseau", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 7d24c29..71d3ed7 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 и не добавляет колонки наугад. Экспортируйте диагностику с этим списком для исправляющего обновления сервера.", @@ -51,7 +53,7 @@ const ru: Partial> = { "nav.overview": "Обзор", "nav.bots": "Боты", "nav.server": "Сервер", - "nav.players": "Игроки", + "nav.players": "Аккаунты", "nav.friends": "Игра с друзьями", "nav.backups": "Резервные копии", "nav.console": "Консоль", @@ -63,7 +65,7 @@ const ru: Partial> = { "q.overview": "Мой сервер работает?", "q.bots": "Как настроены мои боты?", "q.server": "Как ведёт себя мой сервер?", - "q.players": "Кто сейчас играет?", + "q.players": "Кто может войти в игру?", "q.friends": "Как подключиться другу?", "q.backups": "Мои данные в безопасности?", "q.console": "Что делает сервер?", @@ -198,7 +200,7 @@ const ru: Partial> = { "install.open": "ОТКРЫТЬ МОЙ СЕРВЕР", // Players - "players.title": "Игроки", + "players.title": "Аккаунты", "players.createTitle": "Создайте свою учётную запись", "players.createText": "Это имя и пароль, с которыми вы входите в игру.", "players.startFirst": "Сначала запустите сервер — учётные записи создаются, пока он работает.", @@ -526,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 @@ -846,6 +848,36 @@ const ru: Partial> = { "set.searchAll": "Поиск по всем настройкам: название или описание…", "set.search": "Поиск по настройкам…", "upd.current": "У вас последняя версия ({v}).", + "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": "по умолчанию", + "bk.folderNote": "Новые копии сохраняются сюда, например на другой диск. Прежние копии остаются на месте и по-прежнему в списке. Папка должна быть вне папки сервера.", + "bk.folderChange": "Выбрать папку…", + "bk.folderReset": "Папка по умолчанию", + "bk.folderChanged": "Папка резервных копий изменена.", + "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": "Обновление клиента", "fr.lan.address": "Сетевой адрес", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index fe83254..72e8d16 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": "请先启动服务器 —— 账号需要在服务器运行时创建。", @@ -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 @@ -827,6 +827,36 @@ const zh = { "set.searchAll": "按名称或描述搜索全部设置…", "set.search": "搜索设置…", "upd.current": "当前已是最新版本 ({v})。", + "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": "默认", + "bk.folderNote": "新备份将保存在这里,例如另一个驱动器上。之前的备份保留在原处,仍会列出。该文件夹必须位于服务器文件夹之外。", + "bk.folderChange": "更改文件夹…", + "bk.folderReset": "使用默认文件夹", + "bk.folderChanged": "备份文件夹已更改。", + "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": "正在更新游戏客户端", "err.docker_unavailable.title": "Docker 不可用", 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/api.ts b/src/lib/api.ts index 19f0539..b580709 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -78,6 +78,22 @@ 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; + is_default: boolean; +} + export interface ServerSummary { id: string; name: string; @@ -217,7 +233,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 { @@ -285,6 +301,10 @@ 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; } export interface UpdateTxn { @@ -421,6 +441,14 @@ 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 }), + 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 }), + /** `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 }), @@ -449,7 +477,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/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/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/lib/serverUpdate.ts b/src/lib/serverUpdate.ts index cc74040..a89e878 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, @@ -34,12 +35,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 +52,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); } @@ -85,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 { @@ -95,6 +99,7 @@ export async function applyServerUpdate(id: string, opts?: { choices: Record undefined); return true; } catch (e) { const ui = asUiError(e); diff --git a/src/lib/serverUpdateStatus.ts b/src/lib/serverUpdateStatus.ts index be84a75..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.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/lib/updatePrefs.ts b/src/lib/updatePrefs.ts new file mode 100644 index 0000000..ba2caa3 --- /dev/null +++ b/src/lib/updatePrefs.ts @@ -0,0 +1,28 @@ +import { useState } from "react"; + +/** + * 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) => false; + +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/BackupsPage.tsx b/src/screens/BackupsPage.tsx index 84144f5..35b5104 100644 --- a/src/screens/BackupsPage.tsx +++ b/src/screens/BackupsPage.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import { Loader2, ShieldCheck } from "lucide-react"; -import { api, asUiError, type BackupKind, type RecoveryPoint, type UiError } from "@/lib/api"; +import { open } from "@tauri-apps/plugin-dialog"; +import { api, asUiError, type BackupKind, type BackupLocation, type RecoveryPoint, type UiError } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { useHuman, useI18n, type Key, type Locale } from "@/i18n"; @@ -45,9 +46,11 @@ export function BackupsPage({ serverId }: { serverId: string }) { const [notice, setNotice] = useState(null); const [restoring, setRestoring] = useState(null); const [running, setRunning] = useState(false); + const [location, setLocation] = useState(null); const load = useCallback(async () => { setPoints(await api.backups(serverId)); + setLocation(await api.backupLocation(serverId)); const s = await api.status(serverId); setRunning(s.observed.world.state !== "stopped" || s.observed.auth.state !== "stopped"); }, [serverId]); @@ -113,6 +116,23 @@ export function BackupsPage({ serverId }: { serverId: string }) { + {location && ( + +

{t("bk.folder")}

+

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

+

{t("bk.folderNote")}

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

{notice}

} {error && ( diff --git a/src/screens/ClientCard.tsx b/src/screens/ClientCard.tsx index a2c2b7f..4103e6b 100644 --- a/src/screens/ClientCard.tsx +++ b/src/screens/ClientCard.tsx @@ -6,6 +6,7 @@ import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { useHuman, useT } from "@/i18n"; +import { useStartAfterUpdate } from "@/lib/updatePrefs"; export function ClientCard({ serverId, remote = false }: { serverId: string; remote?: boolean }) { const t = useT(); @@ -16,6 +17,7 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); const [dialog, setDialog] = useState(null); + const [playAfter, setPlayAfter] = useStartAfterUpdate("client", serverId); const refresh = () => { void api.clientStatus(serverId).then(setStatus).catch(() => setStatus(null)); @@ -86,6 +88,12 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem
{t("client.otherAddons")}
{t("client.leftAsIs", { n: info.other_addons })}
+ {!remote && ( + + )}
{!remote && !pointsLocal && (
-
+
{missing ?

{missing}

: lines.length === 0 ?

{t("con.empty")}

: lines.map((l, i) => (
{l.level !== "info" && {l.level}: } diff --git a/src/screens/DashboardPage.tsx b/src/screens/DashboardPage.tsx new file mode 100644 index 0000000..e41a763 --- /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")}

+ + + ) : ( + <> +

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

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

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

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

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

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

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

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