Merge pull request #70 from lostmind84/feat/linux-b4-parity

feat: Docker server parity on Linux (realm online, game data folder, settings, sharing)
This commit is contained in:
Corfirean authored and GitHub committed 2026-10-13 06:50:56 +05:00
commit fd0eaafda2
24 files changed
+692 -15

No files matched your search

@@ -0,0 +1,7 @@
---
area: manager
type: added
audience: admins
title: Linux Docker servers follow Settings, sharing and backups (experimental)
---
Experimental. Settings the Docker backend fixes are hidden, friends sharing and logs work, the game data folder can be changed, and the realm no longer shows offline after a start.
@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: admins
title: Docker friend mode requests a restart when remote client support changes
---
Enabling remote CoA clients now requests a world restart even when the game ports were already open. Repeating the same unchanged setting does not request another restart.
+1 -1
View File
@@ -212,7 +212,7 @@ pub fn config_files(root: &Path) -> Vec<String> {
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().to_string();
// database.json holds generated database passwords; it is launcher state, not configuration.
if e.path().is_file() && (n.contains(".template") || n == "repack.json") {
if e.path().is_file() && (n.contains(".template") || n == "repack.json" || n == "docker.json") {
out.push(format!("Settings/{n}"));
}
}
+35
View File
@@ -206,6 +206,10 @@ pub fn drift(root: &Path) -> Result<Vec<String>> {
}
pub fn load(root: &Path, scope: Scope) -> Result<SettingsView> {
// A Docker server that was added but never started has no active world configuration yet; create it from its template.
if scope == Scope::Server && crate::docker::is_docker(root) {
let _ = crate::docker::ensure_main_configs(root);
}
let schema = scope.schema();
let t = targets(root, scope)?;
let conf = load_conf(&t.read)?;
@@ -213,9 +217,12 @@ pub fn load(root: &Path, scope: Scope) -> Result<SettingsView> {
let known: BTreeSet<&str> = schema.settings.iter().map(|s| s.key.as_str()).collect();
let unknown_keys = conf.entries().filter(|(k, _)| !known.contains(k)).count();
// On a Docker server some settings are fixed by the backend (environment beats the file): not offered.
let docker = scope == Scope::Server && crate::docker::is_docker(root);
let settings = schema
.settings
.iter()
.filter(|s| !(docker && crate::docker::managed_setting(&s.key)))
.map(|s| {
let raw = conf.get(&s.key);
let (value, problem) = match raw {
@@ -286,11 +293,19 @@ pub fn validate(root: &Path, scope: Scope, changes: &BTreeMap<String, Value>) ->
let schema = scope.schema();
let mut errors = Vec::new();
let mut raws = BTreeMap::new();
let docker = scope == Scope::Server && crate::docker::is_docker(root);
for (key, value) in changes {
match schema.get(key) {
None => errors.push(FieldError { key: key.clone(), message: "is not a setting the Manager can change".into() }),
Some(_) if docker && crate::docker::managed_setting(key) => {
errors.push(FieldError { key: key.clone(), message: "is set by the Docker backend; changing it in the file would have no effect".into() })
}
Some(s) => match s.to_raw(value) {
Ok(raw) => {
if let Some(problem) = crate::docker::setting_problem(key, &raw).filter(|_| crate::docker::is_docker(root)) {
errors.push(FieldError { key: key.clone(), message: problem.into() });
continue;
}
raws.insert(key.clone(), raw);
}
Err(m) => errors.push(FieldError { key: key.clone(), message: m }),
@@ -736,6 +751,26 @@ Spellbook.New = 5
assert!(list_snapshots(&meta).is_empty(), "no snapshot for a rejected save");
}
#[test]
fn a_docker_server_is_not_offered_the_settings_its_backend_fixes() {
let (_d, root, meta) = fixture();
let keys = |root: &Path| load(root, Scope::Server).unwrap().settings.into_iter().map(|s| s.meta.key).collect::<Vec<_>>();
for k in ["Ra.Enable", "Ra.IP", "Updates.EnableDatabases", "PlayerLimit"] {
assert!(keys(&root).iter().any(|x| x == k), "a repack offers {k}");
}
fs::write(root.join("Settings/docker.json"), br#"{"project":"t1"}"#).unwrap();
let docker = keys(&root);
for k in ["Ra.Enable", "Ra.IP", "Updates.EnableDatabases"] {
assert!(!docker.iter().any(|x| x == k), "a Docker server does not offer {k}: the containers' environment beats the file");
}
assert!(docker.iter().any(|x| x == "PlayerLimit"), "everything else is still offered");
// Forcing a change through is refused and says why, instead of being saved and ignored.
let err = save(&root, &meta, Scope::Server, &set(&[("Ra.Enable", json!(0))])).unwrap_err().to_string();
assert!(err.contains("Docker backend"), "{err}");
assert!(!fs::read_to_string(root.join("Core/configs/worldserver.conf")).unwrap().contains("Ra.Enable = 0"));
save(&root, &meta, Scope::Server, &set(&[("PlayerLimit", json!(77))])).unwrap();
}
#[test]
fn missing_key_is_appended_and_noop_saves_do_nothing() {
let (_d, root, meta) = fixture();
+13
View File
@@ -51,6 +51,19 @@ fn classify(l: &str) -> Level {
}
}
/// The last `max_lines` lines of some text (after filtering), classified and redacted like the lines of a log file.
pub fn lines_from_text(text: &str, filter: Option<&str>, max_lines: usize) -> Vec<Line> {
let needle = filter.map(str::to_lowercase).filter(|n| !n.is_empty());
let out: Vec<Line> = text
.lines()
.filter(|l| !l.trim().is_empty())
.filter(|l| needle.as_ref().map(|n| l.to_lowercase().contains(n)).unwrap_or(true))
.map(|l| Line { level: classify(l), text: crate::diag::redact(l) })
.collect();
let skip = out.len().saturating_sub(max_lines);
out.into_iter().skip(skip).collect()
}
/// The last `max_lines` lines (after filtering) from the last ~512 KB of a log. Works on multi-gigabyte files.
pub fn tail(path: &Path, filter: Option<&str>, max_lines: usize) -> Result<Vec<Line>> {
const WINDOW: u64 = 512 * 1024;
+39 -1
View File
@@ -109,7 +109,8 @@ pub fn run(root: &Path, meta: &InstallMeta) -> Report {
c.push(if crate::client::detect(Path::new(path), None).is_some() { check("client", "Game client", Level::Ok, path.clone()) } else { check("client", "Game client", Level::Warn, "The saved game folder was not found; choose it again in Settings.") });
}
let exposed: Vec<&str> = crate::net::exposure(&ports).iter().filter(|e| (e.what == "database" || e.what == "server console") && e.reachable_from_network).map(|e| e.what).collect();
let exposure = if crate::docker::is_docker(root) { crate::docker::exposure(root, &ports) } else { crate::net::exposure(&ports) };
let exposed: Vec<&str> = exposure.iter().filter(|e| (e.what == "database" || e.what == "server console") && e.reachable_from_network).map(|e| e.what).collect();
c.push(if exposed.is_empty() { check("exposure", "Private services", Level::Ok, "Database and server console are not reachable from the network.") } else { check("exposure", "Private services", Level::Fail, format!("Reachable from the network: {}.", exposed.join(", "))) });
let problems = c.iter().filter(|x| x.level != Level::Ok).count();
@@ -473,16 +474,53 @@ pub fn stamp() -> String {
chrono::Local::now().format("%Y%m%d-%H%M%S").to_string()
}
#[cfg(windows)]
pub fn desktop_or_temp() -> PathBuf {
let d = std::env::var_os("USERPROFILE").map(PathBuf::from).unwrap_or_else(std::env::temp_dir).join("Desktop");
if d.is_dir() { d } else { std::env::temp_dir() }
}
/// Where a file meant for the person goes on Linux: the desktop folder they have (the XDG user directories file names it), else
/// `~/Desktop`, else `~/Downloads`, else the temporary folder.
#[cfg(not(windows))]
pub fn desktop_or_temp() -> PathBuf {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { return std::env::temp_dir() };
let dirs = std::fs::read_to_string(home.join(".config/user-dirs.dirs")).ok();
pick_desktop(&home, dirs.as_deref(), &|p| p.is_dir())
}
#[cfg(not(windows))]
fn pick_desktop(home: &Path, user_dirs: Option<&str>, exists: &dyn Fn(&Path) -> bool) -> PathBuf {
let named = user_dirs.and_then(|t| {
t.lines().find_map(|l| {
let v = l.trim().strip_prefix("XDG_DESKTOP_DIR=")?.trim_matches('"');
Some(PathBuf::from(v.replace("$HOME", &home.to_string_lossy())))
})
});
// A user without a desktop sets XDG_DESKTOP_DIR to the home folder itself: that is not a place to drop files into.
[named, Some(home.join("Desktop")), Some(home.join("Downloads"))].into_iter().flatten().filter(|p| p != home).find(|p| exists(p)).unwrap_or_else(std::env::temp_dir)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::{InstallKind, InstallMeta};
#[cfg(not(windows))]
#[test]
fn the_desktop_folder_on_linux_follows_the_users_own_directories() {
let home = Path::new("/home/ana");
let only = |wanted: &'static [&'static str]| move |p: &Path| wanted.contains(&p.to_str().unwrap());
let dirs = "# comment\nXDG_DOWNLOAD_DIR=\"$HOME/Downloads\"\nXDG_DESKTOP_DIR=\"$HOME/Bureau\"\n";
assert_eq!(pick_desktop(home, Some(dirs), &only(&["/home/ana/Bureau", "/home/ana/Desktop"])), PathBuf::from("/home/ana/Bureau"));
assert_eq!(pick_desktop(home, None, &only(&["/home/ana/Desktop"])), PathBuf::from("/home/ana/Desktop"));
assert_eq!(pick_desktop(home, Some(dirs), &only(&["/home/ana/Downloads"])), PathBuf::from("/home/ana/Downloads"));
assert_eq!(pick_desktop(home, None, &only(&[])), std::env::temp_dir());
// No desktop: the user directories file names the home folder itself.
let none = "XDG_DESKTOP_DIR=\"$HOME/\"\n";
assert_eq!(pick_desktop(home, Some(none), &only(&["/home/ana", "/home/ana/Downloads"])), PathBuf::from("/home/ana/Downloads"));
}
#[test]
fn repeated_config_warnings_are_folded() {
let t = "start
+1 -1
View File
@@ -112,7 +112,7 @@ pub fn preflight(dest: &Path, data_dir: &Path, needed_bytes: u64, registry: &Reg
Preflight { ok: problems.is_empty(), problems, free_bytes }
}
fn data_problems(data_dir: &Path) -> Vec<crate::install::Problem> {
pub(crate) fn data_problems(data_dir: &Path) -> Vec<crate::install::Problem> {
let s = data_dir.to_string_lossy();
if !data_dir.is_absolute() || s.contains(':') {
return vec![problem("data_path", "Choose the game data folder with its full path, without a colon in it.")];
+57
View File
@@ -242,6 +242,10 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L
run_container(d, &call, &n.world, log)?;
}
wait_listening(d, cfg, &n.world, ports.world, WORLD_WAIT, log)?;
// The core leaves with code 2 for `server restart` and expects a supervisor to start it again (the repack has one). Docker
// does that for any non-zero exit, but only once the server has come up: a start that fails must stay failed, not loop.
// A stop by `docker stop` or `server shutdown` (code 0) is never restarted.
let _ = docker(d, &["update", "--restart", "on-failure:3", &n.world], QUICK);
if !state.get(&n.auth).is_some_and(Container::running) {
remove(d, &n.auth);
@@ -251,9 +255,26 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L
run_container(d, &call, &n.auth, log)?;
}
wait_listening(d, cfg, &n.auth, ports.auth, AUTH_WAIT, log)?;
mark_realm_online(d, &n, &secrets, realm, log);
Ok(())
}
/// The auth server marks every realm offline when it starts, and the world server (already up, see the order above) only
/// puts itself back after its next uptime update, ten minutes later by default: until then the client shows "Realm is
/// Offline". The world is known to be listening here, so say so for the realm it serves. The other realm of a Wildcard
/// install keeps the offline flag it is given on purpose (`realms::setup_realmlist`).
fn mark_realm_online(d: &dyn Docker, n: &Names, secrets: &Secrets, realm: crate::realms::Mode, log: &mut Log) {
let sql = format!("UPDATE acore_auth.realmlist SET flag = flag & ~2 WHERE id = {};", realm.realm_id());
let mut call = Call::new(&["exec", "-i", "-e", "MYSQL_PWD", &n.db, "mysql", "--user=root", "--protocol=tcp", "--host=127.0.0.1"], QUICK);
call.env = vec![("MYSQL_PWD".into(), secrets.root.clone())];
call.stdin = Some(sql.as_bytes());
match d.run(&call) {
Ok(o) if o.ok() => {}
Ok(o) => log.say(format!("the realm could not be marked online: {}", o.text().trim())),
Err(e) => log.say(format!("the realm could not be marked online: {e}")),
}
}
fn remove(d: &dyn Docker, name: &str) {
// A stopped container of the same name is replaced, so its settings always match the files on disk.
let _ = d.run(&Call::new(&["rm", "-f", name], QUICK));
@@ -388,6 +409,21 @@ fn database_env(s: &Secrets, world: bool, realm: crate::realms::Mode) -> Vec<(St
env
}
/// Every setting variable (`AC_...`) the containers get, for the check that the screens know which settings are fixed.
#[cfg(test)]
pub(crate) fn forced_variables() -> Vec<String> {
let cfg = Config { project: "t".into(), bind_address: "127.0.0.1".into(), mysql_image: "mysql:8.4".into(), data_dir: None, mysql_data: None };
let n = cfg.names();
let mut found = Vec::new();
for kind in [GameKind::World, GameKind::Auth] {
let args = game_args(&cfg, &n, kind, Path::new("/s"), Path::new("/d"), 1, 2, None);
found.extend(args.iter().filter_map(|a| a.strip_prefix("AC_").map(|rest| format!("AC_{}", rest.split('=').next().unwrap()))));
}
found.sort();
found.dedup();
found
}
/// Realm profiles are only in play once a profile has been chosen on this server (`Settings/realm-profile.json`).
fn prepare_realm(root: &Path, log: &mut Log) -> Step<crate::realms::Mode> {
let fail_with = |e: crate::error::Error| Failure { code: ErrorCode::StartupFailed, output: e.to_string() };
@@ -670,6 +706,8 @@ mod tests {
}
out(0, "containerid\n", "")
}
["update", "--restart", _, _] => out(0, "", ""),
["exec", ..] => out(0, "", ""),
["stop", "--time", _, name] => {
self.running.borrow_mut().remove(*name);
self.exited.borrow_mut().insert(name.to_string(), (0, false));
@@ -712,6 +750,10 @@ mod tests {
assert!(at("network create") < at("run --detach"), "database comes first");
let runs: Vec<String> = sim.calls_of("run").iter().map(|c| c.args[c.args.iter().position(|x| x == "--name").unwrap() + 1].clone()).collect();
assert_eq!(runs, ["coa-t1-db", "coa-t1-world", "coa-t1-auth"], "database, then world, then auth");
let exec = sim.calls_of("exec");
assert_eq!(exec.len(), 1, "the realm is marked online once the auth server is up");
assert!(exec[0].stdin && exec[0].env == [("MYSQL_PWD".to_string(), ROOT_PW.to_string())]);
assert!(!exec[0].args.iter().any(|a| a.contains(ROOT_PW)), "the password is not on the command line");
assert!(at("build") < verbs.iter().rposition(|v| v.starts_with("run")).unwrap(), "the runtime image exists before the game servers");
assert!(sim.calls_of("build")[0].stdin, "the Dockerfile travels on stdin");
}
@@ -757,6 +799,21 @@ mod tests {
assert!(!plain.iter().any(|a| a == "--lower-case-table-names=1" || a == "--user"));
}
#[test]
fn the_world_server_is_started_again_when_it_restarts_itself_but_only_once_it_is_up() {
let (_d, root) = server("t1");
let sim = Sim::new();
assert!(start_all(&sim, &root).ok);
let world_run = sim.calls_of("run").into_iter().find(|c| c.args.iter().any(|a| a == "coa-t1-world")).unwrap();
assert!(!world_run.args.iter().any(|a| a == "--restart"), "no restart while it is still starting");
let updates = sim.calls_of("update");
assert_eq!(updates.len(), 1);
assert_eq!(updates[0].args, ["update", "--restart", "on-failure:3", "coa-t1-world"]);
let verbs = sim.verbs();
let pos = |p: &str| verbs.iter().position(|v| v.starts_with(p)).unwrap();
assert!(pos("run --detach") < pos("update"), "after the container exists and listens");
}
#[test]
fn the_containers_use_the_databases_of_the_selected_realm() {
let s = Secrets { root: "r".into(), app: "apppw".into() };
+72
View File
@@ -0,0 +1,72 @@
//! The database of a Docker server has no log file in the server folder: MySQL writes to its container's output.
use super::cli::{Call, Docker, SystemDocker};
use super::Config;
use crate::console::Line;
use crate::error::{Error, Result};
use std::path::Path;
use std::time::Duration;
/// The last lines MySQL wrote (what `docker logs` returns), filtered and classified like the lines of a log file.
pub fn database_log(root: &Path, filter: Option<&str>, max_lines: usize) -> Result<Vec<Line>> {
database_log_with(&SystemDocker, root, filter, max_lines)
}
pub fn database_log_with(d: &dyn Docker, root: &Path, filter: Option<&str>, max_lines: usize) -> Result<Vec<Line>> {
let name = Config::load(root)?.names().db;
// Ask for more than wanted: the filter may drop most of them.
let tail = (max_lines * 4).clamp(200, 8000).to_string();
let out = d.run(&Call::new(&["logs", "--tail", &tail, &name], Duration::from_secs(20)))?;
if !out.ok() {
return Err(Error::Invalid("This log does not exist yet.".into()));
}
// MySQL writes to the error stream.
Ok(crate::console::lines_from_text(&format!("{}\n{}", out.stderr, out.stdout), filter, max_lines))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::docker::cli::Output;
use std::cell::RefCell;
struct Fake {
out: Output,
calls: RefCell<Vec<Vec<String>>>,
}
impl Docker for Fake {
fn run(&self, call: &Call) -> Result<Output> {
self.calls.borrow_mut().push(call.args.clone());
Ok(self.out.clone())
}
}
fn root() -> (tempfile::TempDir, std::path::PathBuf) {
let d = tempfile::tempdir().unwrap();
let root = d.path().join("srv");
std::fs::create_dir_all(root.join("Settings")).unwrap();
std::fs::write(root.join("Settings/docker.json"), r#"{"project":"t1"}"#).unwrap();
(d, root)
}
#[test]
fn the_database_log_is_what_the_container_printed() {
let (_d, root) = root();
let fake = Fake { out: Output { code: Some(0), stdout: "2026-10-04T10:00:00Z [System] ready\n".into(), stderr: "2026-10-04T10:00:01Z [ERROR] [MY-010] boom\n2026-10-04T10:00:02Z [Warning] slow\n".into() }, calls: Default::default() };
let lines = database_log_with(&fake, &root, None, 100).unwrap();
assert_eq!(fake.calls.borrow()[0][..2], ["logs", "--tail"]);
assert_eq!(fake.calls.borrow()[0].last().unwrap(), "coa-t1-db");
assert_eq!(lines.len(), 3);
assert!(lines.iter().any(|l| l.text.contains("boom") && l.level == crate::console::Level::Error));
let only = database_log_with(&fake, &root, Some("slow"), 100).unwrap();
assert_eq!(only.len(), 1);
}
#[test]
fn a_database_that_never_ran_has_no_log_yet() {
let (_d, root) = root();
let fake = Fake { out: Output { code: Some(1), stdout: String::new(), stderr: "Error: No such container: coa-t1-db".into() }, calls: Default::default() };
assert!(database_log_with(&fake, &root, None, 10).unwrap_err().to_string().contains("does not exist yet"));
}
}
+141
View File
@@ -18,9 +18,12 @@ mod cli;
pub mod fixture;
pub mod install;
mod lifecycle;
pub mod logs;
pub mod sharing;
pub use cli::{Call, Docker, Output, SystemDocker};
pub use lifecycle::{check_docker, observe, observe_with, run, run_with};
pub use sharing::{bind_is_open, exposure, set_open};
pub(crate) use lifecycle::destroy;
use std::net::IpAddr;
@@ -140,6 +143,84 @@ pub fn ensure_main_configs(root: &Path) -> Result<Vec<String>> {
Ok(created)
}
/// Settings the Docker backend gives the containers as environment variables. The core reads `AC_<KEY>` before the
/// configuration file, so a value written to the file for one of these keys is silently ignored. They are what makes the
/// containers work (where things are inside them, listening on every interface, the console, no built-in database
/// updater, the databases); the owner has no use for them and the screens do not offer them.
pub(crate) const FORCED_KEYS: [&str; 12] = [
"BindIP",
"WorldServerPort",
"RealmServerPort",
"LogsDir",
"DataDir",
"Updates.EnableDatabases",
"Ra.Enable",
"Ra.IP",
"Ra.Port",
"LoginDatabaseInfo",
"WorldDatabaseInfo",
"CharacterDatabaseInfo",
];
/// Is this setting fixed by the Docker backend (see `FORCED_KEYS`)?
pub fn managed_setting(key: &str) -> bool {
FORCED_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key))
}
/// The environment variable the core reads for a setting: `AC_` and the key in upper snake case, a `_` between a lower-case
/// letter and a capital, and at letter / digit boundaries (the core's `IniKeyToEnvVarKey`).
#[cfg(test)]
pub(crate) fn env_name(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
let mut out = String::from("AC_");
for (i, &c) in chars.iter().enumerate() {
if matches!(c, ' ' | '.' | '-') {
out.push('_');
continue;
}
out.push(c.to_ascii_uppercase());
if let Some(&next) = chars.get(i + 1) {
let split = (!c.is_ascii_uppercase() && next.is_ascii_uppercase()) || (!c.is_ascii_digit() && next.is_ascii_digit()) || (c.is_ascii_digit() && !next.is_ascii_digit() && !matches!(next, ' ' | '.' | '-'));
if split {
out.push('_');
}
}
}
out
}
/// The folder mounted as the game data of this Docker server.
pub fn game_data_dir(root: &Path) -> Result<std::path::PathBuf> {
Ok(Config::load(root)?.data_path(root))
}
/// Point a Docker server at another game data folder (used in place, read-only). The containers are recreated at the next
/// start, so the servers must be stopped: a running one keeps the folder it has.
pub fn set_game_data(root: &Path, path: &Path) -> Result<()> {
let problems = install::data_problems(path);
if !problems.is_empty() {
return Err(Error::Invalid(problems.iter().map(|p| p.message.clone()).collect::<Vec<_>>().join(" ")));
}
let observed = crate::process::observe(root, &crate::layout::read_ports(root));
let busy = |s: &crate::process::ServiceStatus| matches!(s.state, crate::process::ServiceState::Running | crate::process::ServiceState::Starting | crate::process::ServiceState::Stopping);
if busy(&observed.world) || busy(&observed.auth) {
return Err(Error::Invalid("Stop the server before changing the game data folder.".into()));
}
let mut cfg = Config::load(root)?;
cfg.data_dir = Some(path.to_string_lossy().into_owned());
cfg.validate()?;
fsx::atomic_write_json(&root.join(MARKER), &cfg)
}
/// A reason a value cannot work on a Docker server, for settings that name a path: the server runs in a container that sees only
/// its own `Core` folder (as `/srv/core`, its working directory), so a path elsewhere on this computer does not exist for it.
pub fn setting_problem(key: &str, raw: &str) -> Option<&'static str> {
let raw = raw.trim().trim_matches('"');
let outside = Path::new(raw).is_absolute() && !raw.starts_with("/srv/core/");
(key.eq_ignore_ascii_case("CoaBots.TalentBuildsPath") && !raw.is_empty() && outside)
.then_some("must be a path inside the server's Core folder (for example reference/ascensionsidekick-level-builds.json), because the server runs in a container that cannot see other folders")
}
/// Name of the runtime image: it follows the content of its Dockerfile, so changing the libraries builds a new image.
pub(crate) fn runtime_image() -> String {
format!("coa-runtime:{}", &fsx::sha256_bytes(RUNTIME_DOCKERFILE.as_bytes())[..12])
@@ -206,6 +287,66 @@ mod tests {
use super::*;
use std::fs;
#[cfg(unix)]
#[test]
fn the_game_data_folder_can_be_changed_when_the_servers_are_stopped() {
let d = tempfile::tempdir().unwrap();
let root = d.path().join("srv");
std::fs::create_dir_all(root.join("Settings")).unwrap();
std::fs::write(root.join(MARKER), r#"{"project":"t1","dataDir":"/old/data"}"#).unwrap();
let good = d.path().join("data");
std::fs::create_dir_all(good.join("dbc")).unwrap();
std::fs::create_dir_all(good.join("maps")).unwrap();
assert_eq!(game_data_dir(&root).unwrap(), std::path::PathBuf::from("/old/data"));
set_game_data(&root, &good).unwrap();
assert_eq!(game_data_dir(&root).unwrap(), good);
assert_eq!(Config::load(&root).unwrap().project, "t1", "the other settings are kept");
// A folder that does not look like game data is refused and nothing changes.
let empty = d.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
assert!(set_game_data(&root, &empty).is_err());
assert_eq!(game_data_dir(&root).unwrap(), good);
assert!(set_game_data(&root, std::path::Path::new("relative/dir")).is_err());
}
#[cfg(unix)]
#[test]
fn a_path_outside_the_core_folder_is_refused_for_a_docker_server() {
let key = "CoaBots.TalentBuildsPath";
assert!(setting_problem(key, "reference/builds.json").is_none());
assert!(setting_problem(key, "").is_none());
assert!(setting_problem(key, "\"/srv/core/reference/builds.json\"").is_none());
assert!(setting_problem(key, "/home/ana/builds.json").is_some());
assert!(setting_problem("Rate.XP.Kill", "/home/ana/x").is_none(), "only settings that name a path");
}
#[test]
fn setting_names_become_the_variables_the_core_reads() {
for (key, var) in [
("BindIP", "AC_BIND_IP"),
("Ra.Enable", "AC_RA_ENABLE"),
("Ra.IP", "AC_RA_IP"),
("Updates.EnableDatabases", "AC_UPDATES_ENABLE_DATABASES"),
("WorldServerPort", "AC_WORLD_SERVER_PORT"),
("LoginDatabaseInfo", "AC_LOGIN_DATABASE_INFO"),
("DataDir", "AC_DATA_DIR"),
("Dynamic.XP.Reminder.Interval", "AC_DYNAMIC_XP_REMINDER_INTERVAL"),
("EtherealBazaar.Enable", "AC_ETHEREAL_BAZAAR_ENABLE"),
] {
assert_eq!(env_name(key), var, "{key}");
}
}
#[test]
fn the_settings_called_managed_are_exactly_the_variables_the_containers_get() {
// If a variable is added to the containers without being listed here (or the other way round), a screen would
// offer a setting that does nothing, or hide one that works.
let given: std::collections::BTreeSet<String> = lifecycle::forced_variables().into_iter().collect();
let listed: std::collections::BTreeSet<String> = FORCED_KEYS.iter().map(|k| env_name(k)).collect();
assert_eq!(given, listed);
assert!(managed_setting("ra.enable") && managed_setting("Updates.EnableDatabases") && !managed_setting("PlayerLimit"));
}
#[test]
fn the_main_configs_are_created_from_their_templates_and_never_overwritten() {
let d = tempfile::tempdir().unwrap();
+149
View File
@@ -0,0 +1,149 @@
//! Sharing a Docker server with friends. Who can reach the game is decided by the address the container ports are
//! published on (`bindAddress` in `Settings/docker.json`): `127.0.0.1` is this computer only, anything else is the network.
//! The `BindIP` of the configuration files is forced to `0.0.0.0` inside the containers and changes nothing, so the
//! repack's way of opening the servers (editing `BindIP`) is replaced here. The database is never published and the server
//! console is only ever published on the loopback address.
use std::fs;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use super::{Config, MARKER};
use crate::config::parser::ConfFile;
use crate::config::{take_snapshot, Scope};
use crate::error::Result;
use crate::fsx;
use crate::layout::Ports;
use crate::net::Exposure;
use crate::process::{ServiceState, ServiceStatus};
fn is_open(cfg: &Config) -> bool {
cfg.bind_address.parse::<IpAddr>().map(|ip| !ip.is_loopback()).unwrap_or(false)
}
/// Whether the login and world servers are published beyond this computer.
pub fn bind_is_open(root: &Path) -> bool {
Config::load(root).map(|c| is_open(&c)).unwrap_or(false)
}
/// Publish the login and world servers beyond this computer (`0.0.0.0`) or only on it (`127.0.0.1`). An address the owner chose
/// by hand (for example one network card) is kept when opening. Returns true if anything changed, which needs a restart of
/// the containers. The old files are snapshotted.
pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result<bool> {
let mut cfg = Config::load(root)?;
let mut originals: Vec<(PathBuf, Vec<u8>)> = Vec::new();
let mut changed = false;
if is_open(&cfg) != open {
originals.push((root.join(MARKER), fs::read(root.join(MARKER))?));
cfg.bind_address = if open { "0.0.0.0" } else { "127.0.0.1" }.into();
changed = true;
}
// Friends connect from other addresses, and the server only applies the CoA client protocol to loopback unless remote clients
// are allowed; opening switches that on (closing leaves it, it only matters while the servers listen beyond this computer).
let coa_conf = root.join("Core/configs/modules/coa.conf");
let mut coa_edit = None;
if open {
if let Ok(bytes) = fs::read(&coa_conf) {
let mut conf = ConfFile::parse_bytes(&bytes)?;
if conf.get("CoA.AllowRemoteClients").map(str::trim) != Some("1") {
conf.set("CoA.AllowRemoteClients", "1", &["Set by CoA Server Manager together with the friends mode"]);
originals.push((coa_conf.clone(), bytes));
coa_edit = Some(conf.to_text());
}
}
}
if !changed && coa_edit.is_none() {
return Ok(false);
}
take_snapshot(meta, Scope::Server, if open { "before opening the servers to friends" } else { "before closing the servers to friends" }, &originals)?;
if changed {
fsx::atomic_write_json(&root.join(MARKER), &cfg)?;
}
let remote_changed = coa_edit.is_some();
if let Some(text) = coa_edit {
fsx::atomic_write(&coa_conf, text.as_bytes())?;
}
Ok(changed || remote_changed)
}
/// What each service can be reached from, computed from how the containers publish their ports: the database is never published,
/// the console only on the loopback address, the game servers on `bindAddress`.
pub fn exposure(root: &Path, ports: &Ports) -> Vec<Exposure> {
let open = bind_is_open(root);
let observed = crate::process::observe(root, ports);
let up = |s: &ServiceStatus| matches!(s.state, ServiceState::Running | ServiceState::Starting);
vec![
Exposure { port: ports.mysql, what: "database", listening: up(&observed.mysql), reachable_from_network: false },
Exposure { port: ports.ra, what: "server console", listening: up(&observed.world), reachable_from_network: false },
Exposure { port: ports.auth, what: "login server", listening: up(&observed.auth), reachable_from_network: open && up(&observed.auth) },
Exposure { port: ports.world, what: "game world", listening: up(&observed.world), reachable_from_network: open && up(&observed.world) },
]
}
#[cfg(test)]
mod tests {
use super::*;
fn server(bind: Option<&str>) -> (tempfile::TempDir, PathBuf, PathBuf) {
let d = tempfile::tempdir().unwrap();
let root = d.path().join("srv");
let meta = d.path().join("srv.manager");
fs::create_dir_all(root.join("Settings")).unwrap();
fs::create_dir_all(root.join("Core/configs/modules")).unwrap();
fs::create_dir_all(&meta).unwrap();
let extra = bind.map(|b| format!(r#","bindAddress":"{b}""#)).unwrap_or_default();
fs::write(root.join(MARKER), format!(r#"{{"project":"t1"{extra}}}"#)).unwrap();
fs::write(root.join("Core/configs/modules/coa.conf"), "CoA.Enable = 1\n").unwrap();
(d, root, meta)
}
#[test]
fn opening_and_closing_changes_the_published_address_and_allows_remote_clients() {
let (_d, root, meta) = server(None);
assert!(!bind_is_open(&root));
assert!(set_open(&root, &meta, true).unwrap(), "a restart is needed");
assert!(bind_is_open(&root));
assert_eq!(Config::load(&root).unwrap().bind_address, "0.0.0.0");
assert!(fs::read_to_string(root.join("Core/configs/modules/coa.conf")).unwrap().contains("CoA.AllowRemoteClients = 1"));
assert!(!set_open(&root, &meta, true).unwrap(), "nothing left to change");
assert!(set_open(&root, &meta, false).unwrap());
assert!(!bind_is_open(&root));
assert_eq!(Config::load(&root).unwrap().bind_address, "127.0.0.1");
}
#[test]
fn enabling_remote_client_protocol_requires_restart_even_when_ports_are_open() {
let (_d, root, meta) = server(Some("0.0.0.0"));
fs::write(root.join("Core/configs/modules/coa.conf"), "CoA.Enable = 1\nCoA.AllowRemoteClients = 0\n").unwrap();
assert!(set_open(&root, &meta, true).unwrap());
assert_eq!(Config::load(&root).unwrap().bind_address, "0.0.0.0");
assert!(!set_open(&root, &meta, true).unwrap());
}
#[test]
fn an_address_chosen_by_hand_is_kept_when_opening_and_other_settings_survive() {
let (_d, root, meta) = server(Some("192.168.1.20"));
assert!(bind_is_open(&root), "any address but the loopback is the network");
set_open(&root, &meta, true).unwrap();
let cfg = Config::load(&root).unwrap();
assert_eq!(cfg.bind_address, "192.168.1.20");
assert_eq!(cfg.project, "t1");
}
#[test]
fn the_old_settings_are_snapshotted_before_a_change() {
let (_d, root, meta) = server(None);
set_open(&root, &meta, true).unwrap();
assert_eq!(crate::config::list_snapshots(&meta).len(), 1);
}
#[test]
fn the_database_and_the_console_are_never_reported_reachable() {
let (_d, root, _meta) = server(Some("0.0.0.0"));
let rows = exposure(&root, &Ports::default());
let by = |what: &str| rows.iter().find(|e| e.what == what).unwrap();
assert!(!by("database").reachable_from_network && !by("server console").reachable_from_network);
// Nothing runs in this test (docker is not asked to start anything), so nothing is reachable yet.
assert!(rows.iter().all(|e| !e.reachable_from_network));
}
}
+6
View File
@@ -55,6 +55,7 @@ pub enum ErrorCode {
OperationInProgress,
StartupFailed,
DockerUnavailable,
GameDataMismatch,
HashMismatch,
PathRejected,
PackageNotPublished,
@@ -138,6 +139,11 @@ impl ErrorCode {
"This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.",
&[Retry, ShowDetails],
),
ErrorCode::GameDataMismatch => (
"The game data folder is not the CoA set",
"The server stopped because the DBC files in the game data folder are not the ones of the Conquest of Azeroth client. Choose the folder that holds the CoA data (Settings > Game data folder), then start again.",
&[ShowDetails],
),
ErrorCode::HashMismatch => (
"A downloaded file is damaged",
"The file did not match its checksum and was not used.",
+6
View File
@@ -80,6 +80,9 @@ fn bind_targets(root: &Path) -> Vec<PathBuf> {
/// Whether the configuration currently lets other computers reach the login and world servers.
pub fn bind_is_open(root: &Path) -> bool {
if crate::docker::is_docker(root) {
return crate::docker::bind_is_open(root);
}
let files = bind_targets(root);
!files.is_empty()
&& files
@@ -90,6 +93,9 @@ pub fn bind_is_open(root: &Path) -> bool {
/// Open (`0.0.0.0`) or close (`127.0.0.1`) the login and world servers to other computers. The database and the server
/// console are never touched. Returns true if anything changed (a restart is then needed). The old files are snapshotted.
pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result<bool> {
if crate::docker::is_docker(root) {
return crate::docker::set_open(root, meta, open);
}
let want = if open { "\"0.0.0.0\"" } else { "\"127.0.0.1\"" };
let mut originals = Vec::new();
let mut edits = Vec::new();
+9
View File
@@ -25,6 +25,8 @@ pub fn diagnose(text: &str) -> Option<ErrorCode> {
Some(ErrorCode::DatabaseNotRunning)
} else if l.contains("address already in use") || l.contains("could not bind") || l.contains("only one usage of each socket") {
Some(ErrorCode::PortInUse)
} else if l.contains("does not hold the coa client dbc set") || l.contains("_outdated_ dbc data") {
Some(ErrorCode::GameDataMismatch)
} else if l.contains("config::loadfile") && l.contains("failed open file") && l.contains("worldserver.conf") {
Some(ErrorCode::ServerFilesIncomplete)
} else {
@@ -57,6 +59,13 @@ pub fn diagnose_installation(root: &Path) -> Option<ErrorCode> {
mod tests {
use super::*;
#[test]
fn a_game_data_folder_without_the_coa_dbc_set_is_named_as_such() {
let log = "Using DataDir /srv/data/\nSpellAffect.dbc not found in /srv/data/dbc/\nDataDir does not hold the CoA client DBC set. Install it with apps/coa-dbc/client_dbc.py.";
assert_eq!(diagnose(log), Some(ErrorCode::GameDataMismatch));
assert_eq!(diagnose("You have _outdated_ DBC data. Please extract correct versions from current using client."), Some(ErrorCode::GameDataMismatch));
}
#[test]
fn recognises_missing_migrations() {
let log = "ERROR [sql.sql] [1146] Table 'acore_world.coa_boss' doesn't exist\nFATAL [sql.sql] Your database structure is not up to date.";
+19 -1
View File
@@ -71,7 +71,15 @@ pub fn hardware() -> Hardware {
#[cfg(not(windows))]
pub fn hardware() -> Hardware {
Hardware { cores: std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(2), ram_gb: 8.0, free_ram_gb: 4.0 }
let (total, free) = std::fs::read_to_string("/proc/meminfo").ok().and_then(|t| parse_meminfo(&t)).unwrap_or((8.0, 4.0));
Hardware { cores: std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(2), ram_gb: total, free_ram_gb: free }
}
/// (total, available) memory in GB from the text of `/proc/meminfo`.
#[cfg(not(windows))]
fn parse_meminfo(text: &str) -> Option<(f64, f64)> {
let kb = |key: &str| -> Option<f64> { text.lines().find_map(|l| l.strip_prefix(key)?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()) };
Some((kb("MemTotal:")? / (1u64 << 20) as f64, kb("MemAvailable:")? / (1u64 << 20) as f64))
}
#[derive(Debug, Clone, Serialize)]
@@ -105,6 +113,16 @@ pub fn sizes(h: &Hardware) -> Vec<SizeOption> {
mod tests {
use super::*;
#[cfg(not(windows))]
#[test]
fn memory_is_read_from_proc_meminfo() {
let t = "MemTotal: 65536000 kB\nMemFree: 1000000 kB\nMemAvailable: 16777216 kB\nBuffers: 1 kB\n";
let (total, free) = parse_meminfo(t).unwrap();
assert!((total - 62.5).abs() < 0.01 && (free - 16.0).abs() < 0.01, "{total} {free}");
assert!(parse_meminfo("nothing useful").is_none());
assert!(hardware().ram_gb > 0.0);
}
#[test]
fn parses_counts_and_never_goes_negative() {
let p = parse_counts("12\t9\t400").unwrap();
+12
View File
@@ -422,6 +422,18 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop,
DivxTac.dll deadlocks the world loading screen under wine-mono. `COA_PROTONPATH`, `COA_WINEPREFIX` or
`client-launch.json` in the Manager's folder (`proton_path`, `prefix`, `runner`) override the detection. Whether the client
runs is read from `/proc` (under Wine the command line holds the Windows form of the path). Output goes to `client.log`.
* **Sharing with friends** (`docker/sharing.rs`): who can reach the game is decided by the address the container ports are
published on, `bindAddress` in `docker.json`; the `BindIP` of the configuration files is forced to `0.0.0.0` inside the
containers and changes nothing. Opening and closing therefore edit `bindAddress` (and set `CoA.AllowRemoteClients`), "servers
open" and the exposure check are computed from it (the database is never published, the console only on `127.0.0.1`), and
there is no firewall row on Linux. Settings the backend fixes (`docker::FORCED_KEYS`) are not offered; a test keeps that list
equal to the variables the containers get. A world that restarts itself (exit code 2) is restarted by Docker once it has come
up. The database console shows `docker logs`, and the file-based screens read Linux locations (the desktop folder from the
user directories file, memory from `/proc/meminfo`, the distribution name for the problem report).
* **Game data folder** (`docker::game_data_dir` / `set_game_data`, Settings card): `dataDir` in `docker.json` can be changed
after the install, only while the world and auth are stopped, and only to a folder that passes the install checks. A world
that stops with "does not hold the CoA client dbc set" is reported as `GameDataMismatch` (wrong data set: the standard one,
not Conquest of Azeroth's) instead of a bare failure.
* **Not done yet**: downloading the game data, updates, port conflict detection, the firewall and exposure checks, the
Wildcard realm profiles, and the client under Wine/Proton.
+51 -6
View File
@@ -110,6 +110,31 @@ fn summary(id: String, path: PathBuf) -> ServerSummary {
ServerSummary { id, name, path: path.to_string_lossy().into_owned() }
}
/// The game data folder of a Docker server; none for a repack, whose data is inside its own folder.
#[tauri::command]
fn game_data_folder(state: State<'_, AppState>, id: String) -> std::result::Result<Option<String>, UiError> {
let root = path_of(&state, &id)?;
if !coa_core::docker::is_docker(&root) {
return Ok(None);
}
Ok(Some(coa_core::docker::game_data_dir(&root)?.to_string_lossy().into_owned()))
}
/// Point a Docker server at another game data folder. The servers must be stopped.
#[tauri::command]
async fn set_game_data_folder(state: State<'_, AppState>, id: String, path: String) -> std::result::Result<String, UiError> {
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
blocking(move || {
if !coa_core::docker::is_docker(&root) {
return Err(Error::Invalid("The game data of this server is inside its folder.".into()));
}
coa_core::docker::set_game_data(&root, std::path::Path::new(&path))?;
Ok(path)
})
.await
}
#[tauri::command]
fn default_install_dir() -> String {
if platform::flavor() == Flavor::Docker {
@@ -156,7 +181,9 @@ async fn add_server(state: State<'_, AppState>, path: String) -> std::result::Re
meta.core.version = report.banner_revision.clone();
meta.database.port = Some(report.ports.mysql);
meta.database.schemas = report.database_schemas.clone();
for (rel, exe) in [("Core/worldserver.exe", &report.worldserver), ("Core/authserver.exe", &report.authserver)] {
// The executables are `worldserver.exe` in a repack and `worldserver` in a Docker server; the file check reads these names.
let exe_names = if coa_core::docker::is_docker(std::path::Path::new(&report.path)) { ("Core/worldserver", "Core/authserver") } else { ("Core/worldserver.exe", "Core/authserver.exe") };
for (rel, exe) in [(exe_names.0, &report.worldserver), (exe_names.1, &report.authserver)] {
if let Some(e) = exe {
meta.original_hashes.insert(rel.into(), e.sha256.clone());
}
@@ -1381,7 +1408,7 @@ struct FriendsStatus {
exposure: Vec<coa_core::net::Exposure>,
/// The configuration lets other computers reach the login and world servers.
servers_open: bool,
firewall: coa_core::firewall::Status,
firewall: Option<coa_core::firewall::Status>,
tailscale: coa_core::net::Tailscale,
server_running: bool,
auth_port: u16,
@@ -1426,7 +1453,18 @@ fn report_targets() -> Vec<coa_core::report::Target> {
coa_core::report::targets()
}
/// The distribution on Linux ("Linux (Arch Linux)"), from `/etc/os-release`; empty if it cannot be read.
#[cfg(not(windows))]
fn windows_version() -> String {
std::fs::read_to_string("/etc/os-release")
.ok()
.and_then(|t| t.lines().find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string())))
.map(|name| format!("Linux ({name})"))
.unwrap_or_default()
}
/// "Windows 11 (build 26200)" from `ver`; empty if it cannot be read.
#[cfg(windows)]
fn windows_version() -> String {
let mut cmd = std::process::Command::new("cmd.exe");
cmd.args(["/C", "ver"]);
@@ -1475,9 +1513,10 @@ async fn friends_status(state: State<'_, AppState>, id: String) -> std::result::
settings: coa_core::friends::load(&meta),
lan_ip: automatic.map(|a| a.to_string()),
lan_addresses: coa_core::net::lan_addresses(automatic).unwrap_or_default(),
exposure: coa_core::net::exposure(&ports),
exposure: if coa_core::docker::is_docker(&root) { coa_core::docker::exposure(&root, &ports) } else { coa_core::net::exposure(&ports) },
servers_open: coa_core::friends::bind_is_open(&root),
firewall: coa_core::firewall::status(),
// The Windows firewall exists only on Windows; on Linux Docker publishes the ports itself.
firewall: (platform::flavor() == Flavor::Repack).then(coa_core::firewall::status),
tailscale: coa_core::net::tailscale(),
server_running: coa_core::process::observe(&root, &ports).world.state == coa_core::process::ServiceState::Running,
auth_port: ports.auth,
@@ -1547,7 +1586,7 @@ async fn friends_enable(
};
let open = mode != Mode::Local;
let changed = friends::set_open(&root, &meta, open)?;
if open {
if open && platform::flavor() == Flavor::Repack {
coa_core::firewall::ensure_rules_with_secondary(&ports, secondary)?;
}
if mode == Mode::Direct && use_upnp {
@@ -1580,7 +1619,7 @@ async fn friends_package(state: State<'_, AppState>, id: String) -> std::result:
blocking(move || {
let meta = meta_dir(&root)?;
let host = coa_core::friends::load(&meta).host.ok_or_else(|| Error::Invalid("Choose how friends connect first.".into()))?;
let desktop = std::env::var_os("USERPROFILE").map(PathBuf::from).unwrap_or_else(std::env::temp_dir).join("Desktop");
let desktop = if platform::flavor() == Flavor::Docker { coa_core::diag::desktop_or_temp() } else { std::env::var_os("USERPROFILE").map(PathBuf::from).unwrap_or_else(std::env::temp_dir).join("Desktop") };
let out = if desktop.is_dir() { desktop } else { std::env::temp_dir() }.join("CoA-Friend-Setup.zip");
coa_core::friends::make_friend_package(&root, &host, true, &out)?;
Ok(out.to_string_lossy().into_owned())
@@ -1641,6 +1680,10 @@ async fn console_tail(
) -> std::result::Result<Vec<coa_core::console::Line>, UiError> {
let root = path_of(&state, &id)?;
blocking(move || {
// A Docker server's database writes to its container's output, not to a file.
if coa_core::docker::is_docker(&root) && matches!(source, coa_core::console::Source::Database) {
return coa_core::docker::logs::database_log(&root, filter.as_deref(), lines.unwrap_or(300).min(2000));
}
let path = coa_core::console::log_path(&root, &data_dir().join("logs").join("manager.log"), source);
coa_core::console::tail(&path, filter.as_deref(), lines.unwrap_or(300).min(2000))
})
@@ -1677,6 +1720,8 @@ pub fn run() {
.manage(AppState { registry: Registry::at(dir.join("installs.json")), busy: Mutex::new(HashSet::new()), install_cancel: Mutex::new(None), client_cancel: Mutex::new(None) })
.invoke_handler(tauri::generate_handler![
default_install_dir,
game_data_folder,
set_game_data_folder,
install_environment,
scan_server,
add_server,
+2
View File
@@ -92,6 +92,8 @@ export {};
if(cmd==="open_link"){(window as any).__lastLink=args.url;return null;}
if(cmd==="export_diagnostics") return "C:\\Users\\you\\Desktop\\CoA-Diagnostics-20261001-140000.zip";
if(cmd==="list_crashes") return [];
if(cmd==="game_data_folder") return new URLSearchParams(location.search).get("flavor")==="docker"?"/srv/coa/server-data":null;
if(cmd==="set_game_data_folder") return args.path;
if(cmd==="install_environment"){const docker=new URLSearchParams(location.search).get("flavor")==="docker";return docker?{flavor:"docker",default_dir:"/home/you/CoaServer",docker_problem:new URLSearchParams(location.search).has("nodocker")?"permission denied while trying to connect to the Docker daemon socket":null}:{flavor:"repack",default_dir:"C:\\Games\\CoA Server",docker_problem:null};}
if(cmd==="install_preflight") return {ok:true,problems:[],free_bytes:210*2**30};
if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.2.0"};
+8
View File
@@ -101,6 +101,7 @@ const en = {
"status.attention": "Needs attention",
"status.running": "Running",
"status.stopped": "Stopped",
"status.crashed": "Stopped unexpectedly",
"btn.start": "START SERVER",
"btn.stop": "STOP SERVER",
@@ -143,6 +144,8 @@ const en = {
"err.operation_in_progress.message": "The server is busy starting or stopping. Wait a moment and try again.",
"err.startup_failed.title": "Server could not start",
"err.startup_failed.message": "A service stopped while starting. Check the details for the cause.",
"err.game_data_mismatch.title": "The game data folder is not the CoA set",
"err.game_data_mismatch.message": "The server stopped because the DBC files in the game data folder are not the ones of the Conquest of Azeroth client. Choose the folder that holds the CoA data (Settings > Game data folder), then start again.",
"err.docker_unavailable.title": "Docker is not available",
"err.docker_unavailable.message": "This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.",
"err.hash_mismatch.title": "A downloaded file is damaged",
@@ -190,6 +193,11 @@ const en = {
"install.localPkg": "Install from a local package folder instead of downloading",
"install.localPkgHint": "(leave empty to download)",
"install.button": "Install",
"gamedata.title": "Game data folder",
"gamedata.text": "The folder with the game's dbc, maps, vmaps and mmaps that this server reads. It is used where it is and never changed. It must be the Conquest of Azeroth set, not the standard one. Stop the server before changing it.",
"gamedata.change": "Change…",
"gamedata.dialog": "Choose the game data folder",
"gamedata.saved": "Saved. It is used the next time the server starts.",
"install.docker.title": "Docker is needed",
"install.docker.text": "On Linux the server runs in Docker. Install Docker and start it, or allow your user to use it, then check again.",
"install.docker.retry": "Check again",
+5 -1
View File
@@ -421,7 +421,8 @@ export interface FriendsStatus {
lan_addresses: LanAddress[];
exposure: { port: number; what: string; reachable_from_network: boolean; listening: boolean }[];
servers_open: boolean;
firewall: { auth: boolean; world: boolean };
/** null where there is no Windows firewall to ask about (Linux). */
firewall: { auth: boolean; world: boolean } | null;
tailscale: { installed: boolean; ip: string | null; connected: boolean };
server_running: boolean;
auth_port: number;
@@ -498,6 +499,9 @@ export const api = {
restoreConfigs: (id: string, backupId: string) => invoke<RecoveryPoint>("restore_backup_configs", { id, backupId }),
restoreDatabase: (id: string, backupId: string, database: string) => invoke<DbRestore>("restore_backup_database", { id, backupId, database }),
installEnvironment: () => invoke<InstallEnvironment>("install_environment"),
/** The game data folder of a Docker server; null for a repack. */
gameDataFolder: (id: string) => invoke<string | null>("game_data_folder", { id }),
setGameDataFolder: (id: string, path: string) => invoke<string>("set_game_data_folder", { id, path }),
installPreflight: (dest: string, needed?: number, gameData?: string) => invoke<Preflight>("install_preflight", { dest, needed: needed ?? null, gameData: gameData ?? null }),
installRequirements: (pkg?: string) => invoke<InstallRequirements>("install_requirements", { package: pkg ?? null }),
realmlistProfiles: (id: string) => invoke<{ profiles: RealmProfile[]; active: string | null }>("realmlist_profiles", { id }),
+1 -1
View File
@@ -170,7 +170,7 @@ export function FriendsPage({ serverId }: { serverId: string }) {
<ul className="mt-2 divide-y divide-line">
<Line ok={st.server_running}>{t("fr.st.running")}</Line>
<Line ok={st.servers_open}>{t("fr.st.open")}</Line>
<Line ok={st.firewall.auth && st.firewall.world}>{t("fr.st.firewall")}</Line>
{st.firewall && <Line ok={st.firewall.auth && st.firewall.world}>{t("fr.st.firewall")}</Line>}
<Line ok={st.exposure.filter((e) => e.what === "database" || e.what === "server console").every((e) => !e.reachable_from_network)}>
{t("fr.st.private")}
</Line>
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { api, asUiError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useT } from "@/i18n";
/** Where a Docker server reads the game data from. Not shown for a repack, which keeps its data in its own folder. */
export function GameDataCard({ serverId }: { serverId: string }) {
const t = useT();
const [folder, setFolder] = useState<string | null>(null);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
useEffect(() => {
let alive = true;
void api.gameDataFolder(serverId).then((f) => alive && setFolder(f)).catch(() => alive && setFolder(null));
return () => { alive = false; };
}, [serverId]);
if (folder === null) return null;
async function change() {
const picked = await open({ directory: true, multiple: false, title: t("gamedata.dialog"), defaultPath: folder ?? undefined });
if (typeof picked !== "string") return;
try {
setFolder(await api.setGameDataFolder(serverId, picked));
setMessage({ ok: true, text: t("gamedata.saved") });
} catch (e) {
setMessage({ ok: false, text: asUiError(e).technical });
}
}
return (
<Card className="mt-6 p-6">
<h2 className="font-semibold">{t("gamedata.title")}</h2>
<p className="mt-1 text-sm text-muted">{t("gamedata.text")}</p>
<div className="mt-3 flex items-center gap-3">
<code className="selectable min-w-0 flex-1 truncate rounded-md border border-line bg-card px-3 py-2 text-sm" title={folder}>{folder}</code>
<Button onClick={() => void change()}>{t("gamedata.change")}</Button>
</div>
{message && <p className={message.ok ? "mt-2 text-sm text-ok" : "mt-2 text-sm text-bad"} role="status">{message.text}</p>}
</Card>
);
}
+4 -3
View File
@@ -46,21 +46,22 @@ function Row({ label, s }: { label: string; s: ServiceStatus }) {
const t = useT();
const running = s.state === "running";
const starting = s.state === "starting";
const crashed = s.state === "crashed";
return (
<div className="flex items-center justify-between py-2.5">
<span>{label}</span>
<span
className={cn("flex items-center gap-2 text-sm", running ? "text-ok" : starting ? "text-warn" : "text-muted")}
className={cn("flex items-center gap-2 text-sm", running ? "text-ok" : starting || crashed ? "text-warn" : "text-muted")}
role="status"
>
<span
aria-hidden
className={cn(
"h-2 w-2 rounded-full",
running ? "bg-ok" : starting ? "animate-[pulse-dot_1.2s_ease-in-out_infinite] bg-warn" : "bg-muted/50",
running ? "bg-ok" : starting ? "animate-[pulse-dot_1.2s_ease-in-out_infinite] bg-warn" : crashed ? "bg-warn" : "bg-muted/50",
)}
/>
{running ? t("status.running") : starting ? t("status.starting") : t("status.stopped")}
{running ? t("status.running") : starting ? t("status.starting") : crashed ? t("status.crashed") : t("status.stopped")}
</span>
</div>
);
+3
View File
@@ -7,6 +7,7 @@ import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ClientCard } from "@/screens/ClientCard";
import { GameDataCard } from "@/screens/GameDataCard";
import { DiagnosticsCard } from "@/screens/DiagnosticsCard";
import { AboutCard } from "@/screens/AboutCard";
import { RemoveServerCard } from "@/screens/RemoveServerCard";
@@ -174,6 +175,8 @@ export function SettingsHome({ serverId, path, onForget }: { serverId: string; p
<RealmStartupSettings serverId={serverId} />
<AutoRestartSettings serverId={serverId} />
<GameDataCard serverId={serverId} />
<DiagnosticsCard serverId={serverId} />
<RepairCard key={serverId} serverId={serverId} />