Merge pull request #38 from lostmind84/feat/linux-b3b-install-screen

feat: install screen, Wine/Proton client and realm profiles for a Docker server (Linux)
This commit is contained in:
Corfirean authored and GitHub committed 2026-10-10 05:46:36 +05:00
commit c46edd2feb
15 files changed
+805 -22

No files matched your search

@@ -0,0 +1,7 @@
---
area: manager
type: added
audience: admins
title: Linux: install from the install screen, play with Wine or Proton (experimental)
---
Experimental. The install screen offers Docker installs on Linux, realm profiles and the Play button work on a Docker server, and updates are not offered for it.
+6
View File
@@ -17,6 +17,9 @@ pub const ADDON_NAME: &str = "CoABotUI";
const EXECUTABLES: [&str; 3] = ["Ascension.exe", "Wow.exe", "WoW.exe"];
pub fn is_running(client: &Path) -> bool {
if crate::platform::flavor() == crate::platform::Flavor::Docker {
return crate::wine::is_running(client, &EXECUTABLES);
}
EXECUTABLES.iter().any(|exe| {
let path = client.join(exe);
let path = dunce::canonicalize(&path).unwrap_or(path);
@@ -307,6 +310,9 @@ pub fn install_addon(client: &Path, meta: &Path, source: &Path) -> Result<()> {
/// Start the game client. The caller is responsible for making sure the server is ready first.
pub fn launch(client: &Path) -> Result<u32> {
let exe = find_exe(client).ok_or_else(|| Error::Invalid("The game client was not found.".into()))?;
if crate::platform::flavor() == crate::platform::Flavor::Docker {
return crate::wine::launch(client, exe);
}
let child = Command::new(client.join(exe)).current_dir(client).spawn()?;
Ok(child.id())
}
+41 -5
View File
@@ -223,6 +223,10 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L
return Ok(());
}
// Realm profiles (CoA / Wildcard): the database is running and healthy here (started and waited for above), which the
// realm list update below needs. Finish an interrupted switch, then make the realm list say what is selected. The repack's
// launcher did this when it started; here nothing else would.
let realm = prepare_realm(root, log)?;
ensure_image(d, log)?;
super::ensure_main_configs(root).map_err(|e| Failure { code: ErrorCode::ServerFilesIncomplete, output: e.to_string() })?;
std::fs::create_dir_all(root.join("Core/Logs")).map_err(|e| Failure { code: ErrorCode::ServerFilesIncomplete, output: e.to_string() })?;
@@ -234,7 +238,7 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L
remove(d, &n.world);
let mut call = Call::new(&[], Duration::from_secs(60));
call.args = game_args(cfg, &n, GameKind::World, &host, &cfg.data_path(&host), ports.world, ports.ra, owner.as_deref());
call.env = database_env(&secrets, true);
call.env = database_env(&secrets, true, realm);
run_container(d, &call, &n.world, log)?;
}
wait_listening(d, cfg, &n.world, ports.world, WORLD_WAIT, log)?;
@@ -243,7 +247,7 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L
remove(d, &n.auth);
let mut call = Call::new(&[], Duration::from_secs(60));
call.args = game_args(cfg, &n, GameKind::Auth, &host, &cfg.data_path(&host), ports.auth, 0, owner.as_deref());
call.env = database_env(&secrets, false);
call.env = database_env(&secrets, false, realm);
run_container(d, &call, &n.auth, log)?;
}
wait_listening(d, cfg, &n.auth, ports.auth, AUTH_WAIT, log)?;
@@ -372,16 +376,32 @@ enum GameKind {
}
/// Connection strings: host;port;user;password;database. They hold the password, so they travel by environment.
fn database_env(s: &Secrets, world: bool) -> Vec<(String, String)> {
fn database_env(s: &Secrets, world: bool, realm: crate::realms::Mode) -> Vec<(String, String)> {
let info = |db: &str| format!("db;3306;acore;{};{db}", s.app);
let mut env = vec![("AC_LOGIN_DATABASE_INFO".to_string(), info("acore_auth"))];
if world {
env.push(("AC_WORLD_DATABASE_INFO".into(), info("acore_world")));
env.push(("AC_CHARACTER_DATABASE_INFO".into(), info("acore_characters")));
// The databases of the selected realm (the Wildcard realm has its own world and characters databases).
let schema = |kind: &str| realm.schema(kind).unwrap_or("acore_world");
env.push(("AC_WORLD_DATABASE_INFO".into(), info(schema("world"))));
env.push(("AC_CHARACTER_DATABASE_INFO".into(), info(schema("characters"))));
}
env
}
/// 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() };
crate::realms::before_start(root).map_err(fail_with)?;
let active = crate::realms::state(root).map_err(fail_with)?.active;
if root.join("Settings/realm-profile.json").exists() {
crate::realms::setup_realmlist(root).map_err(fail_with)?;
let db = crate::db::Db::from_repack(root, crate::db::Account::Admin).map_err(fail_with)?;
db.query("UPDATE acore_auth.realmlist SET name='Conquest of Azeroth' WHERE id=1; UPDATE acore_auth.realmlist SET name='Wildcard' WHERE id=2;").map_err(fail_with)?;
log.say(format!("realm {}", active.name()));
}
Ok(active)
}
fn game_args(cfg: &Config, n: &Names, kind: GameKind, host: &Path, data: &Path, port: u16, ra_port: u16, owner: Option<&str>) -> Vec<String> {
let (name, alias, binary, conf, inner_port) = match kind {
GameKind::World => (&n.world, "world", "./worldserver", "configs/worldserver.conf", WORLD_PORT),
@@ -733,6 +753,22 @@ mod tests {
assert!(!plain.iter().any(|a| a == "--lower-case-table-names=1" || a == "--user"));
}
#[test]
fn the_containers_use_the_databases_of_the_selected_realm() {
let s = Secrets { root: "r".into(), app: "apppw".into() };
let get = |env: &[(String, String)], k: &str| env.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
let coa = database_env(&s, true, crate::realms::Mode::Coa);
assert_eq!(get(&coa, "AC_WORLD_DATABASE_INFO").unwrap(), "db;3306;acore;apppw;acore_world");
assert_eq!(get(&coa, "AC_CHARACTER_DATABASE_INFO").unwrap(), "db;3306;acore;apppw;acore_characters");
let wild = database_env(&s, true, crate::realms::Mode::Wildcard);
assert_eq!(get(&wild, "AC_WORLD_DATABASE_INFO").unwrap(), "db;3306;acore;apppw;acore_world_wildcard");
assert_eq!(get(&wild, "AC_CHARACTER_DATABASE_INFO").unwrap(), "db;3306;acore;apppw;acore_characters_wildcard");
// Accounts are shared by both realms; the auth server never sees the others.
assert_eq!(get(&wild, "AC_LOGIN_DATABASE_INFO").unwrap(), "db;3306;acore;apppw;acore_auth");
let auth = database_env(&s, false, crate::realms::Mode::Wildcard);
assert_eq!(auth.len(), 1);
}
#[test]
fn the_database_is_only_healthy_once_it_takes_tcp_connections() {
let (_d, root) = server("t1");
+2
View File
@@ -31,6 +31,7 @@ pub mod repair;
pub mod schema_check;
pub mod net;
pub mod package;
pub mod platform;
pub mod ra;
pub mod allsettings;
pub mod modules;
@@ -41,6 +42,7 @@ pub mod release;
pub mod report;
pub mod release_schema;
pub mod update;
pub mod wine;
pub mod upnp;
pub mod signing;
pub mod srp6;
+41
View File
@@ -0,0 +1,41 @@
//! Which kind of server a new installation is on this computer. This is the one place where the host decides: a Windows
//! computer installs a repack, anything else installs a Docker server. The screens and the commands ask here and then
//! call the code of that kind, so no platform check is scattered through shared code.
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Flavor {
/// The CoA Repack: Windows executables supervised by the repack's launcher.
Repack,
/// MySQL, the auth server and the world server in Docker containers.
Docker,
}
pub fn flavor() -> Flavor {
if cfg!(windows) {
Flavor::Repack
} else {
Flavor::Docker
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_host_decides_once() {
#[cfg(windows)]
assert_eq!(flavor(), Flavor::Repack);
#[cfg(not(windows))]
assert_eq!(flavor(), Flavor::Docker);
}
#[test]
fn the_screens_read_the_flavor_as_a_plain_word() {
assert_eq!(serde_json::to_string(&Flavor::Repack).unwrap(), "\"repack\"");
assert_eq!(serde_json::to_string(&Flavor::Docker).unwrap(), "\"docker\"");
}
}
+34 -1
View File
@@ -65,10 +65,16 @@ pub fn state(root: &Path) -> Result<RealmState> {
fsx::read_json(&root.join(STATE))
}
/// The world server executable of this folder: `worldserver.exe` in a repack, `worldserver` in a Docker server.
fn world_binary(root: &Path) -> std::path::PathBuf {
let exe = root.join("Core/worldserver.exe");
if exe.exists() { exe } else { root.join("Core/worldserver") }
}
pub fn view(root: &Path) -> Result<View> {
let s = state(root)?;
// Inspect the binary itself; templates or an old log cannot prove installed support.
let supported = fs::read(root.join("Core/worldserver.exe"))?
let supported = fs::read(world_binary(root))?
.windows(b"Wildcard synergy settings".len()).any(|w| w == b"Wildcard synergy settings");
Ok(View { active: s.active, wildcard_created: s.wildcard_created, supported, recovery_pending: root.join(JOURNAL).exists(), simultaneous: s.simultaneous, secondary_world_port: s.secondary_world_port, secondary_running: crate::multiworld::is_running(root) })
}
@@ -269,6 +275,8 @@ pub fn select(root: &Path, mode: Mode) -> Result<View> {
/// Adapt the bundled launcher's canonical schema and realm choices without changing its supervision logic.
pub fn prepare_launcher(root: &Path) -> Result<()> {
// A Docker server has no launcher to adapt: the Docker backend reads the active realm itself.
if crate::docker::is_docker(root) { return Ok(()); }
let path = root.join("Scripts/manage.py");
let source = fs::read_to_string(&path)?;
let patched = patch_launcher(&source)?;
@@ -353,6 +361,31 @@ pub fn setup_realmlist(root: &Path) -> Result<()> {
mod tests {
use super::*;
#[test]
fn a_docker_server_is_checked_for_wildcard_support_in_its_own_binary() {
let d = tempfile::tempdir().unwrap();
fs::create_dir_all(d.path().join("Core")).unwrap();
fs::create_dir_all(d.path().join("Settings")).unwrap();
fs::write(d.path().join("Settings/docker.json"), br#"{"project":"t1"}"#).unwrap();
fs::write(d.path().join("Core/worldserver"), b"ELF...Wildcard synergy settings...").unwrap();
assert!(view(d.path()).unwrap().supported, "worldserver without .exe is read too");
fs::write(d.path().join("Core/worldserver"), b"ELF...an older build").unwrap();
assert!(!view(d.path()).unwrap().supported);
// The repack's executable still wins when both exist, so a repack is read as before.
fs::write(d.path().join("Core/worldserver.exe"), b"MZ...Wildcard synergy settings").unwrap();
assert!(view(d.path()).unwrap().supported);
}
#[test]
fn a_docker_server_has_no_launcher_to_adapt() {
let d = tempfile::tempdir().unwrap();
fs::create_dir_all(d.path().join("Settings")).unwrap();
// No Scripts/manage.py anywhere: on a repack this fails, on a Docker server there is nothing to do.
assert!(prepare_launcher(d.path()).is_err());
fs::write(d.path().join("Settings/docker.json"), br#"{"project":"t1"}"#).unwrap();
prepare_launcher(d.path()).unwrap();
}
#[test]
fn fresh_wildcard_disables_bots_and_preserves_scaling() {
let mut f = Files::new();
+33
View File
@@ -283,12 +283,22 @@ pub enum DatabaseAccess {
OnlyIfRunning,
}
/// The update packages are those of the Windows repack (executables, a bundled MySQL, a launcher): applying one to a Docker
/// server would put Windows files in a Linux server folder. Updating a Docker server is separate work.
fn refuse_for_docker(root: &Path) -> Result<()> {
if crate::docker::is_docker(root) {
return Err(Error::Invalid("Updates are not available for Docker servers yet.".into()));
}
Ok(())
}
/// 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<String, Resolution>) -> Result<Preview> {
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<String, Resolution>, access: DatabaseAccess) -> Result<Preview> {
refuse_for_docker(root)?;
let meta_dir = crate::registry::metadata_dir_for(root)?;
let _lock = operation_lock(&meta_dir)?;
ensure_recovered(&meta_dir)?;
@@ -396,6 +406,7 @@ fn step(report: &dyn Fn(&str, u8), name: &str, pct: u8) {
/// Run the whole update. On failure before the new build is validated the files are rolled back automatically;
/// a build that is applied but unhealthy is left in `NeedsDecision` so the owner chooses.
pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result<Outcome> {
refuse_for_docker(p.root)?;
let (root, meta_dir) = (p.root, p.meta_dir);
let _lock = operation_lock(meta_dir)?;
ensure_recovered(meta_dir)?;
@@ -700,6 +711,7 @@ fn restore_files(root: &Path, before: &Path, txn: &mut Txn) -> Result<()> {
/// Restore the database recovery point first, then restore binaries and configuration.
pub fn rollback(root: &Path, meta_dir: &Path, id: &str, env: &dyn Env) -> Result<Txn> {
refuse_for_docker(root)?;
let _lock = operation_lock(meta_dir)?;
let mut txn = load(meta_dir, id)?;
if matches!(txn.state, State::Committed | State::RolledBack) {
@@ -1142,6 +1154,27 @@ mod tests {
assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1");
}
#[test]
fn a_docker_server_is_never_given_the_repack_files() {
// Found by trying the installer: the update check of a Docker server read the Windows channel and offered its files.
let w = world(&[], false);
write(&w.root, "Settings/docker.json", br#"{"project":"t1"}"#);
let before = read(&w, "Core/worldserver.exe");
let meta = InstallMeta::new(InstallKind::New, &w.root);
let err = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap_err();
assert!(err.to_string().contains("Docker"), "{err}");
let err = run(&w, &Fake::ok(), BTreeMap::new(), None).unwrap_err();
assert!(err.to_string().contains("Docker"), "{err}");
assert!(rollback(&w.root, &w.meta, "any", &Fake::ok()).is_err());
assert_eq!(read(&w, "Core/worldserver.exe"), before, "nothing was touched");
assert!(!w.root.join("Core/newfile.dll").exists());
// The same package is applied to the same folder once it is not a Docker server.
fs::remove_file(w.root.join("Settings/docker.json")).unwrap();
assert_eq!(run(&w, &Fake::ok(), BTreeMap::new(), None).unwrap().txn.state, State::Committed);
}
#[test]
fn happy_path_replaces_creates_merges_and_records_ownership() {
let w = world(&[], false);
+393
View File
@@ -0,0 +1,393 @@
//! Starting the game client on Linux. The client is a Windows program, so it runs under umu-launcher with a Proton build
//! (what the project's own guide uses), or under plain Wine when umu is not installed. This is the Linux counterpart of
//! starting `Ascension.exe` directly; `client::launch` hands over to it on a Linux host and Windows never comes here.
//!
//! The settings are found on their own and can all be overridden, from strongest to weakest: the environment
//! (`COA_PROTONPATH`, `COA_WINEPREFIX`), then `client-launch.json` in the Manager's folder, then what is installed.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use serde::Deserialize;
use crate::error::{Error, Result};
/// Prefix used when nothing else is said: the folder umu-launcher itself suggests for a game.
const DEFAULT_PREFIX: &str = "Games/umu/coa-client";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Runner {
/// umu-launcher runs the game with a Proton build. `proton` is `None` when umu should pick and download one itself.
Umu { program: PathBuf, proton: Option<PathBuf> },
Wine { program: PathBuf },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Launcher {
pub runner: Runner,
pub prefix: PathBuf,
}
/// What to run, in a form that can be checked without running anything.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Plan {
pub program: PathBuf,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
pub cwd: PathBuf,
}
/// `client-launch.json`: every key is optional.
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Overrides {
pub proton_path: Option<String>,
pub prefix: Option<String>,
/// "umu" or "wine": use this one even if the other is installed.
pub runner: Option<String>,
}
/// Everything detection looks at, so it can be tested without a real computer.
pub struct Host<'a> {
pub var: &'a dyn Fn(&str) -> Option<String>,
pub which: &'a dyn Fn(&str) -> Option<PathBuf>,
/// Names of the entries of a folder (empty when it does not exist).
pub list: &'a dyn Fn(&Path) -> Vec<String>,
pub is_file: &'a dyn Fn(&Path) -> bool,
}
fn proton_folders(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/share/Steam/compatibilitytools.d"),
home.join(".steam/root/compatibilitytools.d"),
home.join(".steam/steam/compatibilitytools.d"),
home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d"),
PathBuf::from("/usr/share/steam/compatibilitytools.d"),
]
}
/// Numbers of a name in order, so that GE-Proton10-9 sorts before GE-Proton10-34.
fn version_key(name: &str) -> Vec<u64> {
let mut out = Vec::new();
let mut cur = String::new();
for c in name.chars().chain(std::iter::once(' ')) {
if c.is_ascii_digit() {
cur.push(c);
} else if !cur.is_empty() {
out.push(cur.parse().unwrap_or(0));
cur.clear();
}
}
out
}
/// The newest GE-Proton build installed for Steam, if any. A folder only counts when it holds the `proton` script.
fn newest_proton(host: &Host, home: &Path) -> Option<PathBuf> {
proton_folders(home)
.into_iter()
.flat_map(|dir| (host.list)(&dir).into_iter().filter(|n| n.starts_with("GE-Proton")).map(move |n| (n.clone(), dir.join(n))))
.filter(|(_, path)| (host.is_file)(&path.join("proton")))
.max_by_key(|(name, _)| version_key(name))
.map(|(_, path)| path)
}
pub fn detect_with(host: &Host, overrides: &Overrides) -> Result<Launcher> {
let home = (host.var)("HOME").map(PathBuf::from).ok_or_else(|| Error::Invalid("The home folder of this user is not known.".into()))?;
let prefix = (host.var)("COA_WINEPREFIX").or_else(|| overrides.prefix.clone()).map(PathBuf::from).unwrap_or_else(|| home.join(DEFAULT_PREFIX));
let proton = (host.var)("COA_PROTONPATH").or_else(|| overrides.proton_path.clone()).map(PathBuf::from).or_else(|| newest_proton(host, &home));
let (umu, wine) = ((host.which)("umu-run"), (host.which)("wine"));
let runner = match (overrides.runner.as_deref(), umu, wine) {
(Some("wine"), _, Some(program)) => Runner::Wine { program },
(Some("umu"), Some(program), _) | (None, Some(program), _) => Runner::Umu { program, proton },
(_, None, Some(program)) => Runner::Wine { program },
_ => {
return Err(Error::Invalid(
"To play on Linux the game client needs umu-launcher (with a Proton build such as GE-Proton) or Wine. Install one of them and press Play again.".into(),
))
}
};
Ok(Launcher { runner, prefix })
}
/// The command line that starts `exe` from the client folder.
pub fn plan(launcher: &Launcher, client: &Path, exe: &str) -> Plan {
let mut env = vec![("WINEPREFIX".to_string(), launcher.prefix.to_string_lossy().into_owned())];
// DivxTac.dll is a mixed-mode .NET assembly: loaded through wine-mono it deadlocks the client on the world loading screen
// (100%). Disabling it has the same effect as removing the file.
env.push(("WINEDLLOVERRIDES".into(), "divxtac=d".into()));
let program = match &launcher.runner {
Runner::Umu { program, proton } => {
env.push(("GAMEID".into(), "0".into()));
if let Some(p) = proton {
env.push(("PROTONPATH".into(), p.to_string_lossy().into_owned()));
}
program.clone()
}
Runner::Wine { program } => program.clone(),
};
// The client reads Data/ relative to the working directory.
Plan { program, args: vec![exe.to_string()], env, cwd: client.to_path_buf() }
}
fn system_var(k: &str) -> Option<String> {
std::env::var(k).ok().filter(|v| !v.trim().is_empty())
}
fn find_on_path(program: &str) -> Option<PathBuf> {
std::env::split_paths(&std::env::var_os("PATH")?).map(|d| d.join(program)).find(|p| p.is_file())
}
fn list_names(dir: &Path) -> Vec<String> {
fs::read_dir(dir).map(|rd| rd.filter_map(|e| e.ok()).filter_map(|e| e.file_name().into_string().ok()).collect()).unwrap_or_default()
}
fn data_home() -> Option<PathBuf> {
system_var("XDG_DATA_HOME").map(PathBuf::from).filter(|p| p.is_absolute()).or_else(|| system_var("HOME").map(|h| PathBuf::from(h).join(".local/share")))
}
fn overrides_file() -> Option<PathBuf> {
data_home().map(|d| d.join("CoAServerManager/client-launch.json"))
}
pub fn detect() -> Result<Launcher> {
let overrides: Overrides = overrides_file().and_then(|p| crate::fsx::read_json(&p).ok()).unwrap_or_default();
detect_with(&Host { var: &system_var, which: &find_on_path, list: &list_names, is_file: &|p| p.is_file() }, &overrides)
}
/// Where what the client prints goes: the Manager's own log folder.
pub fn log_path() -> Option<PathBuf> {
data_home().map(|d| d.join("CoAServerManager/logs/client.log"))
}
/// Start the client and return at once; it keeps running when the Manager closes. Returns the process id.
pub fn launch(client: &Path, exe: &str) -> Result<u32> {
let launcher = detect()?;
let plan = plan(&launcher, client, exe);
let log = match log_path() {
Some(p) => {
if let Some(dir) = p.parent() {
fs::create_dir_all(dir)?;
}
let mut f = OpenOptions::new().create(true).append(true).open(&p)?;
writeln!(f, "=== {} {:?} in {}", plan.program.display(), plan.args, plan.cwd.display())?;
for (k, v) in &plan.env {
writeln!(f, " {k}={v}")?;
}
Some(f)
}
None => None,
};
let mut cmd = Command::new(&plan.program);
cmd.args(&plan.args).current_dir(&plan.cwd).envs(plan.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
match log {
Some(f) => {
cmd.stderr(f.try_clone()?).stdout(f);
}
None => {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0); // its own group: closing the Manager does not take the game with it
}
let mut child = cmd.spawn().map_err(|e| Error::Invalid(format!("The game client could not be started ({}): {e}", plan.program.display())))?;
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait(); // reap it when the game closes
});
Ok(pid)
}
/// Is a process running this client? Under Wine its command line carries the Windows form of the path
/// (`Z:\home\you\client\Ascension.exe`), which is the only trace of it that names the folder.
pub fn command_line_runs(cmdline: &str, client: &Path, exe: &str) -> bool {
let wanted = format!("{}/{}", client.to_string_lossy().trim_end_matches('/'), exe).to_lowercase();
cmdline.replace('\0', " ").replace('\\', "/").to_lowercase().contains(&wanted)
}
/// The folder as it was given and as the system resolves it. On an immutable distribution (SteamOS, Bazzite) `/home` is a
/// link to `/var/home`: Wine writes the path it was given, the resolved one differs, and either may be what the command
/// line of the running client holds.
fn spellings(client: &Path) -> Vec<PathBuf> {
let mut all = vec![client.to_path_buf()];
if let Ok(real) = fs::canonicalize(client) {
if real != client {
all.push(real);
}
}
all
}
pub fn is_running(client: &Path, exes: &[&str]) -> bool {
let folders = spellings(client);
let Ok(rd) = fs::read_dir("/proc") else { return false };
rd.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().bytes().all(|b| b.is_ascii_digit()))
.filter_map(|e| fs::read(e.path().join("cmdline")).ok())
.any(|raw| {
let line = String::from_utf8_lossy(&raw);
folders.iter().any(|folder| exes.iter().any(|exe| command_line_runs(&line, folder, exe)))
})
}
// Linux only: the folders are written as Linux path strings.
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::collections::{BTreeMap, BTreeSet};
#[test]
fn a_client_folder_behind_a_link_is_looked_for_under_both_names() {
// SteamOS and Bazzite: /home is a link to /var/home.
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("var/home/u/client")).unwrap();
std::os::unix::fs::symlink("var/home", dir.path().join("home")).unwrap();
let given = dir.path().join("home/u/client");
let both = spellings(&given);
assert_eq!(both.len(), 2);
assert_eq!(both[0], given);
assert!(both[1].starts_with(dir.path().canonicalize().unwrap().join("var/home")));
// Wine kept the name it was given, so the line holds the first one and not the resolved one.
let line = format!("Z:\\{}\\Ascension.exe", given.to_string_lossy().replace('/', "\\"));
assert!(!command_line_runs(&line, &both[1], "Ascension.exe"));
assert!(both.iter().any(|f| command_line_runs(&line, f, "Ascension.exe")));
}
/// A computer described in a few lines: variables, programs on the path, folders with their entries, and `proton` scripts.
struct Fake {
vars: BTreeMap<&'static str, &'static str>,
programs: BTreeSet<&'static str>,
folders: BTreeMap<&'static str, Vec<&'static str>>,
}
impl Fake {
fn new() -> Fake {
Fake { vars: BTreeMap::from([("HOME", "/home/ana")]), programs: BTreeSet::new(), folders: BTreeMap::new() }
}
fn detect(&self, overrides: &Overrides) -> Result<Launcher> {
let var = |k: &str| self.vars.get(k).map(|v| v.to_string());
let which = |p: &str| self.programs.contains(p).then(|| PathBuf::from(format!("/usr/bin/{p}")));
let list = |d: &Path| self.folders.get(d.to_str().unwrap()).map(|v| v.iter().map(|s| s.to_string()).collect()).unwrap_or_default();
// every listed build has its `proton` script
let is_file = |p: &Path| p.file_name().is_some_and(|n| n == "proton");
detect_with(&Host { var: &var, which: &which, list: &list, is_file: &is_file }, overrides)
}
}
const STEAM: &str = "/home/ana/.local/share/Steam/compatibilitytools.d";
#[test]
fn umu_with_the_newest_ge_proton_is_the_default() {
let mut c = Fake::new();
c.programs.extend(["umu-run", "wine"]);
c.folders.insert(STEAM, vec!["GE-Proton9-27", "GE-Proton10-9", "GE-Proton10-34", "Proton-Experimental", "notes.txt"]);
let l = c.detect(&Overrides::default()).unwrap();
assert_eq!(l.prefix, PathBuf::from("/home/ana/Games/umu/coa-client"));
match l.runner {
Runner::Umu { proton, program } => {
assert_eq!(program, PathBuf::from("/usr/bin/umu-run"));
assert_eq!(proton, Some(PathBuf::from(format!("{STEAM}/GE-Proton10-34"))), "10-34 is newer than 10-9");
}
other => panic!("{other:?}"),
}
}
#[test]
fn without_a_proton_build_umu_is_left_to_choose_one() {
let mut c = Fake::new();
c.programs.insert("umu-run");
let Runner::Umu { proton, .. } = c.detect(&Overrides::default()).unwrap().runner else { panic!() };
assert_eq!(proton, None);
}
#[test]
fn plain_wine_is_used_when_umu_is_not_installed() {
let mut c = Fake::new();
c.programs.insert("wine");
assert!(matches!(c.detect(&Overrides::default()).unwrap().runner, Runner::Wine { .. }));
}
#[test]
fn without_umu_or_wine_the_person_is_told_what_to_install() {
let err = Fake::new().detect(&Overrides::default()).unwrap_err().to_string();
assert!(err.contains("umu-launcher") && err.contains("Wine"), "{err}");
}
#[test]
fn settings_override_what_is_installed_and_the_environment_overrides_the_file() {
let mut c = Fake::new();
c.programs.extend(["umu-run", "wine"]);
c.folders.insert(STEAM, vec!["GE-Proton10-34"]);
let file = Overrides { proton_path: Some("/opt/proton-a".into()), prefix: Some("/data/prefix-a".into()), runner: None };
let l = c.detect(&file).unwrap();
assert_eq!(l.prefix, PathBuf::from("/data/prefix-a"));
assert!(matches!(&l.runner, Runner::Umu { proton: Some(p), .. } if p == Path::new("/opt/proton-a")));
c.vars.insert("COA_PROTONPATH", "/opt/proton-b");
c.vars.insert("COA_WINEPREFIX", "/data/prefix-b");
let l = c.detect(&file).unwrap();
assert_eq!(l.prefix, PathBuf::from("/data/prefix-b"));
assert!(matches!(&l.runner, Runner::Umu { proton: Some(p), .. } if p == Path::new("/opt/proton-b")));
// Forcing Wine works when it is installed, and falls back to umu when it is not.
let wine = Overrides { runner: Some("wine".into()), ..Default::default() };
assert!(matches!(c.detect(&wine).unwrap().runner, Runner::Wine { .. }));
}
#[test]
fn the_command_is_the_one_of_the_projects_own_launcher_script() {
let l = Launcher { runner: Runner::Umu { program: "/usr/bin/umu-run".into(), proton: Some("/p/GE-Proton11-6".into()) }, prefix: "/home/ana/Games/umu/coa-client".into() };
let p = plan(&l, Path::new("/home/ana/CoaServer/client/ascension-live"), "Ascension.exe");
assert_eq!(p.program, PathBuf::from("/usr/bin/umu-run"));
assert_eq!(p.args, ["Ascension.exe"]);
assert_eq!(p.cwd, PathBuf::from("/home/ana/CoaServer/client/ascension-live"), "the client reads Data/ from its working directory");
let env: BTreeMap<_, _> = p.env.iter().cloned().collect();
assert_eq!(env["GAMEID"], "0");
assert_eq!(env["PROTONPATH"], "/p/GE-Proton11-6");
assert_eq!(env["WINEPREFIX"], "/home/ana/Games/umu/coa-client");
assert_eq!(env["WINEDLLOVERRIDES"], "divxtac=d", "DivxTac.dll deadlocks the world loading screen under wine-mono");
// Wine has no GAMEID / PROTONPATH.
let w = plan(&Launcher { runner: Runner::Wine { program: "/usr/bin/wine".into() }, prefix: "/pre".into() }, Path::new("/c"), "Wow.exe");
assert!(w.env.iter().all(|(k, _)| k != "GAMEID" && k != "PROTONPATH") && w.args == ["Wow.exe"]);
}
#[test]
fn a_wine_command_line_names_the_client_folder_in_windows_form() {
let client = Path::new("/home/ana/CoaServer/client/ascension-live");
assert!(command_line_runs("Z:\\home\\ana\\CoaServer\\client\\ascension-live\\Ascension.exe\0", client, "Ascension.exe"));
assert!(command_line_runs("C:\\windows\\system32\\start.exe /exec Z:\\HOME\\ANA\\coaserver\\client\\ascension-live\\ascension.exe", client, "Ascension.exe"), "case does not matter");
assert!(!command_line_runs("Z:\\home\\ana\\CoaServer\\client\\ascension-lab\\Ascension.exe", client, "Ascension.exe"), "another client folder");
assert!(!command_line_runs("umu-run Ascension.exe", client, "Ascension.exe"), "the launcher alone does not name the folder");
assert!(!command_line_runs("Z:\\home\\ana\\CoaServer\\client\\ascension-live\\Ascension.exe.ORIGINAL", Path::new("/other"), "Ascension.exe"));
}
#[test]
fn newer_versions_sort_after_older_ones() {
assert!(version_key("GE-Proton10-9") < version_key("GE-Proton10-34"));
assert!(version_key("GE-Proton9-27") < version_key("GE-Proton10-1"));
assert!(version_key("GE-Proton11-6-x86_64") < version_key("GE-Proton11-7-x86_64"));
}
#[cfg(unix)]
#[test]
fn a_real_launch_runs_the_program_in_the_client_folder_with_the_right_environment() {
use std::os::unix::fs::PermissionsExt;
let d = tempfile::tempdir().unwrap();
let client = d.path().join("client");
fs::create_dir_all(&client).unwrap();
let out = d.path().join("seen.txt");
let script = d.path().join("umu-run");
fs::write(&script, format!("#!/bin/sh\n{{ echo \"cwd=$(pwd)\"; echo \"arg=$1\"; echo \"prefix=$WINEPREFIX\"; echo \"gameid=$GAMEID\"; echo \"dll=$WINEDLLOVERRIDES\"; }} > '{}'\n", out.display())).unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let launcher = Launcher { runner: Runner::Umu { program: script, proton: None }, prefix: d.path().join("prefix") };
let p = plan(&launcher, &client, "Ascension.exe");
let mut child = Command::new(&p.program).args(&p.args).current_dir(&p.cwd).envs(p.env.iter().map(|(k, v)| (k, v))).spawn().unwrap();
assert!(child.wait().unwrap().success());
let seen = fs::read_to_string(&out).unwrap();
assert!(seen.contains(&format!("cwd={}", fs::canonicalize(&client).unwrap().display())), "{seen}");
assert!(seen.contains("arg=Ascension.exe") && seen.contains("gameid=0") && seen.contains("dll=divxtac=d") && seen.contains("prefix="), "{seen}");
}
}
+23 -2
View File
@@ -401,8 +401,29 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop,
produces `Database/baseline` without anyone's live database. The migration checksums recorded in the base are those of
files checked out on Windows (CRLF line endings, `core.autocrlf`): the SQL of the core, the modules and SQUID must be
checked out the same way for the checksums to match, or every applied migration looks edited.
* **Not done yet**: wiring the installer into the screens, downloading the game data, updates, port conflict detection,
the firewall and exposure checks, the Wildcard realm profiles, and the client under Wine/Proton.
* **In the screens** the host is asked once per command (`coa_core::platform::flavor()`: repack on Windows, Docker
elsewhere) and the Docker half lives in `src-tauri/src/docker_install.rs`; the repack arm of each install command is the
code that was there. `install_environment` tells the install screen the kind of server, the suggested folder
(`~/CoaServer`) and why Docker cannot be used, if it cannot. On Docker the screen also asks for the game data folder and
the package, and there is no default package address until a signed Linux package is published. Packages are checked with
the embedded key; only a debug build (`tauri dev`) accepts another one, named in `COA_DEV_TRUSTED_KEY`, to try the
installer on a package signed with a throwaway key.
* **Updates are refused for a Docker server, never half done.** `update::preview`, `apply` and `rollback` refuse it: the
update packages are the repack's (executables, a bundled MySQL, a launcher) and applying one would put Windows files in a
Linux folder. Found by installing a server with the screen: the update check had read the Windows channel and offered one.
* **Realm profiles (CoA / Wildcard) work on a Docker server.** What the repack's patched launcher did at each start is done
by the Docker backend: finish an interrupted switch (`realms::before_start`), name the realms in the realm list, and give
the containers the databases of the selected realm (`acore_world_wildcard` ...). The switch itself (configuration swap,
database copy, journal) is the shared code; `prepare_launcher` has nothing to patch, and Wildcard support is read from
`Core/worldserver` as it is from `Core/worldserver.exe`.
* **The game client starts under Wine on a Linux host** (`wine.rs`; `client::launch` and `client::is_running` hand over):
umu-launcher with a Proton build when `umu-run` is installed (the newest GE-Proton found for Steam, or umu's own choice),
else plain Wine; `WINEPREFIX` defaults to `~/Games/umu/coa-client`; `WINEDLLOVERRIDES=divxtac=d` is always set, because
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`.
* **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.
### D14. Keeping Windows and Linux apart
+125
View File
@@ -0,0 +1,125 @@
//! The Linux (Docker) side of the install commands. `lib.rs` asks `coa_core::platform::flavor()` once per command and
//! calls this module for a Docker install; the code for a repack install stays where it was and is not changed by what
//! happens here.
use std::path::{Path, PathBuf};
use coa_core::docker::{self, install as core_install, SystemDocker};
use coa_core::download::Cancel;
use coa_core::install::{Preflight, Source};
use coa_core::registry::Registry;
use coa_core::{Error, Result};
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use super::{blocking, data_dir, summary, AppState, InstallRequirements, ServerSummary, UiError};
/// What the install screen needs to know about this computer before it asks anything.
#[derive(Serialize)]
pub struct Environment {
pub flavor: coa_core::platform::Flavor,
pub default_dir: String,
/// Why Docker cannot be used (not installed, not running, no permission), in Docker's own words; none when it works.
pub docker_problem: Option<String>,
}
pub fn default_dir() -> String {
default_dir_in(std::env::var("HOME").ok().as_deref())
}
fn default_dir_in(home: Option<&str>) -> String {
match home.map(|h| h.trim_end_matches('/')).filter(|h| !h.is_empty()) {
Some(h) => format!("{h}/CoaServer"),
None => "/srv/coa".into(),
}
}
pub fn docker_problem() -> Option<String> {
docker::check_docker(&SystemDocker).err().map(|e| e.to_string())
}
pub fn environment() -> Environment {
Environment { flavor: coa_core::platform::flavor(), default_dir: default_dir(), docker_problem: docker_problem() }
}
pub fn preflight(dest: &str, data: Option<String>, needed: Option<u64>, registry: &Registry) -> Preflight {
// The size comes from the signed package list when the screen already has it; it is checked again at install time.
core_install::preflight(Path::new(dest), Path::new(data.as_deref().unwrap_or("")), needed.unwrap_or(1024 * 1024 * 1024), registry)
}
/// The Linux package has no default address yet: it is published only after the maintainer has signed it.
pub fn package_source(custom: Option<String>) -> Result<Source> {
let pick = custom.filter(|s| !s.trim().is_empty()).or_else(|| std::env::var("COA_PACKAGE_SOURCE").ok().filter(|s| !s.trim().is_empty()));
match pick {
Some(p) if p.to_ascii_lowercase().starts_with("http") => Ok(Source::Url(p)),
Some(p) => Ok(Source::Dir(PathBuf::from(p))),
None => Err(Error::Invalid("Choose the Linux server package (a folder or an address): none is published yet.".into())),
}
}
/// The key a Linux package must be signed with. A debug build (`tauri dev`) may name another one in `COA_DEV_TRUSTED_KEY`,
/// to try the installer on a package signed with a throwaway key; a release build always uses the embedded key.
pub fn trusted_key() -> String {
#[cfg(debug_assertions)]
if let Ok(k) = std::env::var("COA_DEV_TRUSTED_KEY") {
if !k.trim().is_empty() {
return k.trim().to_string();
}
}
coa_core::signing::EMBEDDED_PUBLIC_KEY.to_string()
}
pub async fn requirements(package: Option<String>) -> std::result::Result<InstallRequirements, UiError> {
blocking(move || {
let (m, _) = coa_core::pkgsource::fetch_manifest(&package_source(package)?, &trusted_key())?;
let archive = m.archive.ok_or_else(|| Error::InvalidManifest("no archive".into()))?;
Ok(InstallRequirements { download_bytes: archive.parts.iter().map(|p| p.size).sum(), unpacked_bytes: archive.unpacked_size, version: m.version })
})
.await
}
pub async fn install(app: AppHandle, state: State<'_, AppState>, dest: String, package: Option<String>, data: Option<String>) -> std::result::Result<ServerSummary, UiError> {
let source = package_source(package)?;
let data_dir_path = PathBuf::from(data.filter(|d| !d.trim().is_empty()).ok_or_else(|| Error::Invalid("Choose the folder that holds the game data.".into()))?);
let cancel = Cancel::default();
*state.install_cancel.lock().map_err(|_| Error::Invalid("state poisoned".into()))? = Some(cancel.clone());
let registry = Registry::at(data_dir().join("installs.json"));
let dest_path = PathBuf::from(dest);
let key = trusted_key();
let result = tauri::async_runtime::spawn_blocking(move || {
core_install::install(
&core_install::Params { source, dest: dest_path, data_dir: data_dir_path, trusted_key: &key, registry: &registry, cancel },
&|step| {
let _ = app.emit("install-progress", step);
},
)
})
.await;
if let Ok(mut c) = state.install_cancel.lock() {
*c = None;
}
let done = result.map_err(|e| Error::Invalid(e.to_string()))??;
Ok(summary(done.id, PathBuf::from(done.path)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_folder_is_in_the_home_folder() {
assert_eq!(default_dir_in(Some("/home/ana")), "/home/ana/CoaServer");
assert_eq!(default_dir_in(Some("/home/ana/")), "/home/ana/CoaServer");
assert_eq!(default_dir_in(None), "/srv/coa");
assert_eq!(default_dir_in(Some("")), "/srv/coa");
}
#[test]
fn a_linux_package_must_be_named_there_is_no_default() {
std::env::remove_var("COA_PACKAGE_SOURCE");
assert!(package_source(None).is_err());
assert!(package_source(Some(" ".into())).is_err());
assert!(matches!(package_source(Some("/tmp/pkg".into())).unwrap(), Source::Dir(_)));
assert!(matches!(package_source(Some("https://example.org/pkg".into())).unwrap(), Source::Url(_)));
}
}
+25 -2
View File
@@ -5,11 +5,14 @@ use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Mutex;
mod docker_install;
use coa_core::backup::{self, Kind, RecoveryPoint, Trigger, VerifyReport};
use coa_core::config::{self, Scope, SettingsView};
use coa_core::download::Cancel;
use coa_core::driver::{self, DriverOutcome, Verb};
use coa_core::install::{self, Preflight, Source};
use coa_core::platform::{self, Flavor};
use coa_core::ra::Ra;
use coa_core::update::{self, Resolution};
use coa_core::error::UiError;
@@ -105,9 +108,19 @@ fn summary(id: String, path: PathBuf) -> ServerSummary {
#[tauri::command]
fn default_install_dir() -> String {
if platform::flavor() == Flavor::Docker {
return docker_install::default_dir();
}
"C:\\Games\\CoA Server".into()
}
/// What the install screen needs to know about this computer: the kind of server it installs, the suggested folder, and
/// whether Docker can be used.
#[tauri::command]
async fn install_environment() -> docker_install::Environment {
tauri::async_runtime::spawn_blocking(docker_install::environment).await.unwrap_or_else(|_| docker_install::Environment { flavor: platform::flavor(), default_dir: default_install_dir(), docker_problem: None })
}
#[tauri::command]
async fn scan_server(path: String) -> std::result::Result<ScanReport, UiError> {
tauri::async_runtime::spawn_blocking(move || layout::scan(std::path::Path::new(&path)))
@@ -450,7 +463,10 @@ async fn restore_backup_database(state: State<'_, AppState>, id: String, backup_
}
#[tauri::command]
fn install_preflight(state: State<'_, AppState>, dest: String, needed: Option<u64>) -> Preflight {
fn install_preflight(state: State<'_, AppState>, dest: String, needed: Option<u64>, game_data: Option<String>) -> Preflight {
if platform::flavor() == Flavor::Docker {
return docker_install::preflight(&dest, game_data, needed, &state.registry);
}
// `needed` is the real size from the signed package list when the screen already has it; the real size is checked
// again at install time either way.
install::preflight(std::path::Path::new(&dest), needed.unwrap_or(6 * 1024 * 1024 * 1024), &state.registry)
@@ -468,6 +484,9 @@ struct InstallRequirements {
/// How big the server is, read from the package's signed list before anything is downloaded.
#[tauri::command]
async fn install_requirements(package: Option<String>) -> std::result::Result<InstallRequirements, UiError> {
if platform::flavor() == Flavor::Docker {
return docker_install::requirements(package).await;
}
blocking(move || {
let (m, _) = coa_core::pkgsource::fetch_manifest(&package_source(package), coa_core::signing::EMBEDDED_PUBLIC_KEY)?;
let archive = m.archive.ok_or_else(|| Error::InvalidManifest("no archive".into()))?;
@@ -477,7 +496,10 @@ async fn install_requirements(package: Option<String>) -> std::result::Result<In
}
#[tauri::command]
async fn install_new(app: AppHandle, state: State<'_, AppState>, dest: String, package: Option<String>) -> std::result::Result<ServerSummary, UiError> {
async fn install_new(app: AppHandle, state: State<'_, AppState>, dest: String, package: Option<String>, game_data: Option<String>) -> std::result::Result<ServerSummary, UiError> {
if platform::flavor() == Flavor::Docker {
return docker_install::install(app, state, dest, package, game_data).await;
}
let cancel = Cancel::default();
*state.install_cancel.lock().map_err(|_| Error::Invalid("state poisoned".into()))? = Some(cancel.clone());
let source = package_source(package);
@@ -1590,6 +1612,7 @@ 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,
install_environment,
scan_server,
add_server,
list_servers,
+1
View File
@@ -91,6 +91,7 @@ export {};
if(cmd==="report_targets") return [{id:"manager",repo:"Corfirean/coa-server-manager",ours:true},{id:"companions",repo:"Corfirean/mod-coa-playerbots",ours:true},{id:"squid",repo:"Zyth45/mod-playerbots",ours:false}];
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==="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"};
if(cmd==="list_accounts"){const w=window as any;w.__acc=w.__acc??[{id:2,name:"ALICE",access:3,online:true,last_login:"2026-10-01 12:04:11",characters:4},{id:5,name:"BOB",access:0,online:false,last_login:null,characters:0},{id:6,name:"CAROL",access:2,online:false,last_login:"2026-09-30 21:40:02",characters:2}];return w.__acc;}
+8
View File
@@ -190,6 +190,14 @@ const en = {
"install.localPkg": "Install from a local package folder instead of downloading",
"install.localPkgHint": "(leave empty to download)",
"install.button": "Install",
"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",
"install.data.label": "Game data folder",
"install.data.hint": "The folder with the game's dbc, maps, vmaps and mmaps. It is used where it is and never changed.",
"install.data.dialogTitle": "Choose the game data folder",
"install.linuxPkg.label": "Server package",
"install.linuxPkg.hint": "A folder or an address of the Linux server package",
"install.progressTitle": "Preparing your server",
"install.progressText": "This can take a few minutes. You can leave this window open.",
"install.step.checking": "Checking your computer",
+13 -2
View File
@@ -265,6 +265,16 @@ export interface ReportTarget {
ours: boolean;
}
/** The kind of server this computer installs: a repack (Windows) or Docker containers (Linux). */
export type Flavor = "repack" | "docker";
export interface InstallEnvironment {
flavor: Flavor;
default_dir: string;
/** Why Docker cannot be used, in its own words; null when it works. */
docker_problem: string | null;
}
export interface InstallRequirements {
download_bytes: number;
unpacked_bytes: number;
@@ -454,7 +464,8 @@ export const api = {
deleteBackup: (id: string, backupId: string) => invoke<void>("delete_backup", { id, backupId }),
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 }),
installPreflight: (dest: string, needed?: number) => invoke<Preflight>("install_preflight", { dest, needed: needed ?? null }),
installEnvironment: () => invoke<InstallEnvironment>("install_environment"),
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 }),
realmlistSave: (id: string, profileId: string | null, name: string, data: string) => invoke<RealmProfile>("realmlist_save", { id, profileId, name, data }),
@@ -473,7 +484,7 @@ export const api = {
accountSetAccess: (id: string, name: string, level: number) => invoke<void>("account_set_access", { id, name, level }),
accountRename: (id: string, name: string, newName: string, password: string) => invoke<void>("account_rename", { id, name, newName, password }),
accountDelete: (id: string, name: string) => invoke<void>("account_delete", { id, name }),
installNew: (dest: string, pkg?: string) => invoke<ServerSummary>("install_new", { dest, package: pkg ?? null }),
installNew: (dest: string, pkg?: string, gameData?: string) => invoke<ServerSummary>("install_new", { dest, package: pkg ?? null, gameData: gameData ?? null }),
cancelInstall: () => invoke<void>("cancel_install"),
createAccount: (id: string, username: string, password: string, administrator: boolean) =>
invoke<void>("create_account", { id, username, password, administrator }),
+53 -10
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import { Check, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import { api, asUiError, type InstallRequirements, type InstallStep, type Preflight, type ServerSummary, type UiError } from "@/lib/api";
import { api, asUiError, type InstallEnvironment, type InstallRequirements, type InstallStep, type Preflight, type ServerSummary, type UiError } from "@/lib/api";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -33,6 +33,19 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
const [pkg, setPkg] = useState("");
const [installed, setInstalled] = useState<ServerSummary | null>(null);
const [showDetails, setShowDetails] = useState(false);
// What this computer installs. A repack (Windows) needs nothing more than a folder; Docker (Linux) also needs the game data
// folder and a package, and Docker itself.
const [env, setEnv] = useState<InstallEnvironment | null>(null);
const [gameData, setGameData] = useState("");
const docker = env?.flavor === "docker";
function loadEnvironment(first: boolean) {
void api.installEnvironment().then((e) => {
setEnv(e);
if (first && e.flavor === "docker") setDest(e.default_dir);
}).catch(() => setEnv(null));
}
useEffect(() => loadEnvironment(true), []);
// how big the server is, from the package's signed list (re-read when another package source is entered)
useEffect(() => {
@@ -43,9 +56,9 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
// room for the unpacked server plus the download that is removed afterwards
const needed = req ? req.unpacked_bytes + req.download_bytes : undefined;
useEffect(() => {
const timer = setTimeout(() => void api.installPreflight(dest, needed).then(setPre).catch(() => setPre(null)), 250);
const timer = setTimeout(() => void api.installPreflight(dest, needed, docker ? gameData.trim() : undefined).then(setPre).catch(() => setPre(null)), 250);
return () => clearTimeout(timer);
}, [dest, needed]);
}, [dest, needed, docker, gameData]);
useEffect(() => {
const un = listen<InstallStep>("install-progress", (e) => setStep(e.payload));
@@ -56,7 +69,12 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
async function browse() {
const picked = await open({ directory: true, multiple: false, title: t("install.dialogTitle") });
if (typeof picked === "string") setDest(picked.replace(/[\\/]+$/, "") + (picked.toLowerCase().includes("coa") ? "" : "\\CoA Server"));
if (typeof picked === "string") setDest(picked.replace(/[\\/]+$/, "") + (picked.toLowerCase().includes("coa") ? "" : docker ? "/CoaServer" : "\\CoA Server"));
}
async function browseGameData() {
const picked = await open({ directory: true, multiple: false, title: t("install.data.dialogTitle") });
if (typeof picked === "string") setGameData(picked);
}
async function browsePackage() {
@@ -69,7 +87,7 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
setPhase("installing");
setStep({ step: "Preparing your server", percent: 0, detail: null });
try {
const s = await api.installNew(dest, pkg.trim() || undefined);
const s = await api.installNew(dest, pkg.trim() || undefined, docker ? gameData.trim() : undefined);
setInstalled(s);
setPhase("done");
} catch (e) {
@@ -139,9 +157,34 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
</div>
</div>
{pre && pre.problems.length > 0 && (
{docker && (
<div>
<label htmlFor="gamedata" className="text-sm text-muted">{t("install.data.label")}</label>
<div className="mt-1 flex gap-2">
<input id="gamedata" value={gameData} onChange={(e) => setGameData(e.target.value)} className="selectable flex-1 rounded-md border border-line bg-card px-3 py-2.5 text-[15px] outline-none focus:border-gold" />
<Button onClick={browseGameData}>{t("install.browse")}</Button>
</div>
<p className="mt-1 text-xs text-muted">{t("install.data.hint")}</p>
<label htmlFor="linuxpkg" className="mt-4 block text-sm text-muted">{t("install.linuxPkg.label")}</label>
<div className="mt-1 flex gap-2">
<input id="linuxpkg" value={pkg} onChange={(e) => setPkg(e.target.value)} placeholder={t("install.linuxPkg.hint")} className="selectable flex-1 rounded-md border border-line bg-card px-3 py-2.5 text-[15px] outline-none focus:border-gold" />
<Button onClick={browsePackage}>{t("install.browse")}</Button>
</div>
</div>
)}
{docker && env?.docker_problem && (
<Card className="border-warn/40 p-4" role="alert">
{pre.problems.map((p) => (
<p className="font-medium text-warn">{t("install.docker.title")}</p>
<p className="mt-1 text-sm text-muted">{t("install.docker.text")}</p>
<pre className="mt-2 max-h-24 overflow-auto whitespace-pre-wrap rounded bg-black/40 p-2 text-xs">{env.docker_problem}</pre>
<Button className="mt-3" size="sm" onClick={() => loadEnvironment(false)}>{t("install.docker.retry")}</Button>
</Card>
)}
{pre && pre.problems.some((p) => !(docker && !gameData.trim() && p.code.startsWith("data"))) && (
<Card className="border-warn/40 p-4" role="alert">
{pre.problems.filter((p) => !(docker && !gameData.trim() && p.code.startsWith("data"))).map((p) => (
<p key={p.code} className="text-sm text-warn">{p.message}</p>
))}
</Card>
@@ -170,7 +213,7 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
</Card>
)}
<div>
{!docker && <div>
<button onClick={() => setShowAdvanced((v) => !v)} aria-expanded={showAdvanced} className="flex cursor-pointer items-center gap-1 text-sm text-muted hover:text-ink">
{showAdvanced ? <ChevronDown className="h-4 w-4" aria-hidden /> : <ChevronRight className="h-4 w-4" aria-hidden />}
{t("install.advanced")}
@@ -184,10 +227,10 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
</div>
</div>
)}
</div>
</div>}
<div className="flex items-center gap-3">
<Button variant="primary" size="md" disabled={!pre?.ok} onClick={() => void install()}>
<Button variant="primary" size="md" disabled={!pre?.ok || (docker && (!!env?.docker_problem || !gameData.trim() || !pkg.trim()))} onClick={() => void install()}>
{t("install.button")}
</Button>
{props.canCancel && (