mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 20:01:01 +00:00
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:
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.
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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\"");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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: ®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(_)));
|
||||
}
|
||||
}
|
||||
+25
-2
@@ -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,
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user