From 45a699d2cac95a3cf3ed4f6337b24272be5c95a3 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:37:00 +0200 Subject: [PATCH 1/6] feat: install a new Docker server from the install screen (Linux) The install commands ask coa_core::platform::flavor() once and hand a Docker install to src-tauri/src/docker_install.rs; the repack arm of each command is untouched (an early return and an extra optional parameter are all that changed in lib.rs). New command install_environment tells the screen the kind of server, the suggested folder (~/CoaServer) and why Docker cannot be used, if it cannot. On Linux the install screen shows the game data folder (used in place, read-only) and the server package (a folder or an address; there is no default address until a signed Linux package is published), and disables Install until both are given and Docker works. It shows Docker's own message with a "Check again" button when Docker is missing, stopped or not allowed for the user. The Windows screen is unchanged (checked side by side in the browser mock). A debug build (tauri dev) accepts another package key named 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. New text is English only for now (other languages fall back to it). --- crates/coa-core/src/lib.rs | 1 + crates/coa-core/src/platform.rs | 41 +++++++++++ docs/ARCHITECTURE.md | 11 ++- src-tauri/src/docker_install.rs | 125 ++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 27 ++++++- src/dev/mock.ts | 1 + src/i18n/locales/en.ts | 8 ++ src/lib/api.ts | 15 +++- src/screens/InstallServer.tsx | 63 +++++++++++++--- 9 files changed, 276 insertions(+), 16 deletions(-) create mode 100644 crates/coa-core/src/platform.rs create mode 100644 src-tauri/src/docker_install.rs diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index b105692..6cd6d08 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; 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97c48b5..bef72b0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -401,8 +401,15 @@ 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. +* **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 && ( From 25098b5a637c1708408c98a57a79b528fb5e00ee Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:53:48 +0200 Subject: [PATCH 2/6] fix: a Docker server is never offered or given the repack's updates Found by installing a server through the new screen: the update check of a Docker server read the Windows "stable" channel and offered "0.261003.17 (you have 0.261003.1)". Applying it would have put the repack's executables, MySQL and launcher into a Linux server folder. Refused in the core (update::preview, apply and rollback), so no caller can do it. The Settings tab says that updates are not available for Docker servers yet; the background check stays quiet, as for any failed check. A test applies the same update package to the same folder with and without Settings/docker.json: refused and untouched with it, committed without it. --- crates/coa-core/src/update.rs | 33 +++++++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 3 +++ 2 files changed, 36 insertions(+) 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bef72b0..885a89c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -408,6 +408,9 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop, 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. * **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. From 5c3a9def0c387b178c140c52fd752c339945b7c5 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:53:48 +0200 Subject: [PATCH 3/6] feat: realm profiles (CoA / Wildcard) work on a Docker server The Realm card showed "io: No such file or directory (os error 2)" on a Docker server: the Wildcard check read Core/worldserver.exe. The card lets the owner switch realm, so it is made to work rather than hidden. 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, acore_characters_wildcard). The switch itself is the shared code. prepare_launcher has nothing to patch on a Docker server, and Wildcard support is read from Core/worldserver as it is from Core/worldserver.exe (the .exe wins when both exist, so a repack is read as before). Tests: the support check on both binary names, no launcher to adapt, and the databases passed to the containers for each realm. Creating the Wildcard realm itself needs a build and a database with the Wildcard migrations and was not run. --- crates/coa-core/src/docker/lifecycle.rs | 45 ++++++++++++++++++++++--- crates/coa-core/src/realms.rs | 35 ++++++++++++++++++- docs/ARCHITECTURE.md | 5 +++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index a4d6025..b727664 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -223,6 +223,9 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L return Ok(()); } + // Realm profiles (CoA / Wildcard): 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 +237,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 +246,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 +375,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 +752,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/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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 885a89c..319e51a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -411,6 +411,11 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop, * **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`. * **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. From a625b065cff302e85ad66b2799ab25ca418c243f Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:53:48 +0200 Subject: [PATCH 4/6] feat: start the game client under umu-launcher / Wine on Linux client::launch started Ascension.exe directly, which cannot work on Linux, and client::is_running found nothing (it reads the Windows process list). wine.rs is the Linux counterpart, and both hand over to it on a Linux host. It runs the command of the project's own launcher script: umu-run with a Proton build (GAMEID=0, PROTONPATH, WINEPREFIX, started from the client folder), else plain Wine, with WINEDLLOVERRIDES=divxtac=d (DivxTac.dll deadlocks the world loading screen under wine-mono). The newest GE-Proton found for Steam is used, or umu's own choice; COA_PROTONPATH, COA_WINEPREFIX and client-launch.json override the detection; the prefix defaults to ~/Games/umu/coa-client. The game runs in its own process group, so closing the Manager does not close it, and prints to client.log. Whether it runs is read from /proc, where the command line holds the Windows form of the client path. A clear message says what to install when neither umu nor Wine is found. Tests: detection (newest build, no build, Wine only, nothing, overrides), the command, the /proc match, and a real spawn with a stand-in umu-run that records its environment. Not run: the real client. --- crates/coa-core/src/client.rs | 6 + crates/coa-core/src/lib.rs | 1 + crates/coa-core/src/wine.rs | 363 ++++++++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 6 + 4 files changed, 376 insertions(+) create mode 100644 crates/coa-core/src/wine.rs 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/lib.rs b/crates/coa-core/src/lib.rs index 6cd6d08..60d4258 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -42,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/wine.rs b/crates/coa-core/src/wine.rs new file mode 100644 index 0000000..1d355ba --- /dev/null +++ b/crates/coa-core/src/wine.rs @@ -0,0 +1,363 @@ +//! 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) +} + +pub fn is_running(client: &Path, exes: &[&str]) -> bool { + let client = fs::canonicalize(client).unwrap_or_else(|_| client.to_path_buf()); + 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); + exes.iter().any(|exe| command_line_runs(&line, &client, exe)) + }) +} + +// Linux only: the folders are written as Linux path strings. +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::collections::{BTreeMap, BTreeSet}; + + /// 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 319e51a..1de9358 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -416,6 +416,12 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop, 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. From c882a2133439e191e2144e366038d2f1216b4709 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:10:53 +0200 Subject: [PATCH 5/6] docs: changelog entry for the Linux install screen --- changelog.d/20261005-linux-install-screen-and-client.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/20261005-linux-install-screen-and-client.md 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. From f934bef3030a06edc5d602e4b2ead4c26f6bf968 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:30:08 +0200 Subject: [PATCH 6/6] fix: find a running Wine client under both names of a linked home folder On SteamOS and Bazzite /home is a link to /var/home; Wine keeps the path it was given, so the resolved one alone can miss the command line. The start order is unchanged (database up and healthy before the realm list is updated): a comment says so. --- crates/coa-core/src/docker/lifecycle.rs | 3 ++- crates/coa-core/src/wine.rs | 34 +++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index b727664..c7bde2b 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -223,7 +223,8 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L return Ok(()); } - // Realm profiles (CoA / Wildcard): finish an interrupted switch, then make the realm list say what is selected. The repack's + // 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)?; diff --git a/crates/coa-core/src/wine.rs b/crates/coa-core/src/wine.rs index 1d355ba..3f06e67 100644 --- a/crates/coa-core/src/wine.rs +++ b/crates/coa-core/src/wine.rs @@ -209,15 +209,28 @@ pub fn command_line_runs(cmdline: &str, client: &Path, exe: &str) -> bool { 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 client = fs::canonicalize(client).unwrap_or_else(|_| client.to_path_buf()); + 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); - exes.iter().any(|exe| command_line_runs(&line, &client, exe)) + folders.iter().any(|folder| exes.iter().any(|exe| command_line_runs(&line, folder, exe))) }) } @@ -227,6 +240,23 @@ 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>,