diff --git a/changelog.d/20261005-linux-install-screen-and-client.md b/changelog.d/20261005-linux-install-screen-and-client.md new file mode 100644 index 0000000..c3d1db7 --- /dev/null +++ b/changelog.d/20261005-linux-install-screen-and-client.md @@ -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. diff --git a/crates/coa-core/src/client.rs b/crates/coa-core/src/client.rs index 564e7a9..855e5ae 100644 --- a/crates/coa-core/src/client.rs +++ b/crates/coa-core/src/client.rs @@ -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 { 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()) } diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index a4d6025..c7bde2b 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -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 { + 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 { 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"); diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index b105692..60d4258 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -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; diff --git a/crates/coa-core/src/platform.rs b/crates/coa-core/src/platform.rs new file mode 100644 index 0000000..d943aaa --- /dev/null +++ b/crates/coa-core/src/platform.rs @@ -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\""); + } +} diff --git a/crates/coa-core/src/realms.rs b/crates/coa-core/src/realms.rs index f99fb84..2885433 100644 --- a/crates/coa-core/src/realms.rs +++ b/crates/coa-core/src/realms.rs @@ -65,10 +65,16 @@ pub fn state(root: &Path) -> Result { 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 { 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 { /// 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(); diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index ab83667..c90711f 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -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) -> Result { preview_with(root, meta, source, trusted_key, resolutions, DatabaseAccess::Start) } pub fn preview_with(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &str, resolutions: &BTreeMap, access: DatabaseAccess) -> Result { + 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 { + 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 { + 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); diff --git a/crates/coa-core/src/wine.rs b/crates/coa-core/src/wine.rs new file mode 100644 index 0000000..3f06e67 --- /dev/null +++ b/crates/coa-core/src/wine.rs @@ -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 }, + 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, + 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, + pub prefix: Option, + /// "umu" or "wine": use this one even if the other is installed. + pub runner: Option, +} + +/// Everything detection looks at, so it can be tested without a real computer. +pub struct Host<'a> { + pub var: &'a dyn Fn(&str) -> Option, + pub which: &'a dyn Fn(&str) -> Option, + /// Names of the entries of a folder (empty when it does not exist). + pub list: &'a dyn Fn(&Path) -> Vec, + pub is_file: &'a dyn Fn(&Path) -> bool, +} + +fn proton_folders(home: &Path) -> Vec { + 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 { + 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 { + 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 { + 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 { + std::env::var(k).ok().filter(|v| !v.trim().is_empty()) +} + +fn find_on_path(program: &str) -> Option { + 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 { + 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 { + 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 { + data_home().map(|d| d.join("CoAServerManager/client-launch.json")) +} + +pub fn detect() -> Result { + 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 { + 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 { + 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 { + 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 { + 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}"); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97c48b5..1de9358 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/src-tauri/src/docker_install.rs b/src-tauri/src/docker_install.rs new file mode 100644 index 0000000..1768cfc --- /dev/null +++ b/src-tauri/src/docker_install.rs @@ -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, +} + +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 { + 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, needed: Option, 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) -> Result { + 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) -> std::result::Result { + 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, data: Option) -> std::result::Result { + 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: ®istry, 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(_))); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fa60d83..74eb209 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { 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) -> Preflight { +fn install_preflight(state: State<'_, AppState>, dest: String, needed: Option, game_data: Option) -> 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) -> std::result::Result { + 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) -> std::result::Result, dest: String, package: Option) -> std::result::Result { +async fn install_new(app: AppHandle, state: State<'_, AppState>, dest: String, package: Option, game_data: Option) -> std::result::Result { + 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, diff --git a/src/dev/mock.ts b/src/dev/mock.ts index cba1688..0faa305 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -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;} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4354787..cf09f90 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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", diff --git a/src/lib/api.ts b/src/lib/api.ts index b580709..0116456 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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("delete_backup", { id, backupId }), restoreConfigs: (id: string, backupId: string) => invoke("restore_backup_configs", { id, backupId }), restoreDatabase: (id: string, backupId: string, database: string) => invoke("restore_backup_database", { id, backupId, database }), - installPreflight: (dest: string, needed?: number) => invoke("install_preflight", { dest, needed: needed ?? null }), + installEnvironment: () => invoke("install_environment"), + installPreflight: (dest: string, needed?: number, gameData?: string) => invoke("install_preflight", { dest, needed: needed ?? null, gameData: gameData ?? null }), installRequirements: (pkg?: string) => invoke("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("realmlist_save", { id, profileId, name, data }), @@ -473,7 +484,7 @@ export const api = { accountSetAccess: (id: string, name: string, level: number) => invoke("account_set_access", { id, name, level }), accountRename: (id: string, name: string, newName: string, password: string) => invoke("account_rename", { id, name, newName, password }), accountDelete: (id: string, name: string) => invoke("account_delete", { id, name }), - installNew: (dest: string, pkg?: string) => invoke("install_new", { dest, package: pkg ?? null }), + installNew: (dest: string, pkg?: string, gameData?: string) => invoke("install_new", { dest, package: pkg ?? null, gameData: gameData ?? null }), cancelInstall: () => invoke("cancel_install"), createAccount: (id: string, username: string, password: string, administrator: boolean) => invoke("create_account", { id, username, password, administrator }), diff --git a/src/screens/InstallServer.tsx b/src/screens/InstallServer.tsx index c9f92f8..230c5c4 100644 --- a/src/screens/InstallServer.tsx +++ b/src/screens/InstallServer.tsx @@ -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(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(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("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; - {pre && pre.problems.length > 0 && ( + {docker && ( +
+ +
+ 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" /> + +
+

{t("install.data.hint")}

+ +
+ 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" /> + +
+
+ )} + + {docker && env?.docker_problem && ( - {pre.problems.map((p) => ( +

{t("install.docker.title")}

+

{t("install.docker.text")}

+
{env.docker_problem}
+ +
+ )} + + {pre && pre.problems.some((p) => !(docker && !gameData.trim() && p.code.startsWith("data"))) && ( + + {pre.problems.filter((p) => !(docker && !gameData.trim() && p.code.startsWith("data"))).map((p) => (

{p.message}

))}
@@ -170,7 +213,7 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void; )} -
+ {!docker &&
)} - + }
- {props.canCancel && (