From 9f48cfc8d5378b28e7be29b1fb6b91c0a57480d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 6 Nov 2026 23:49:03 +0500 Subject: [PATCH] preview 0.9.10: bare modular core for new servers, modular copy of classic servers - A new server goes on to the preview core without compiling any module (recommended modules are declined once; modules come from the Modules page). - Choosing a server with the classic core makes a modular copy next to it: every acore_* database (read from the running database, or from a private copy of the stopped data files opened by the new server's own mysqld), module configs and realm profiles, ports and console login. The old server is only read. The copy then takes the preview core with the modules the old server had switched on. - Installs and the preview update run on unused ports, so a server running on the shipped ports (the old one, or any other) no longer stops them; the schema check after "Preparing database" used the shipped port before. - Dungeon Clear is part of SQUID's Playerbots: no card of its own (it said "Always on" while SQUID was off), switched with SQUID, settings on the SQUID card. E2E (examples/preview_bare_and_copy_e2e, disposable servers, another server running on the shipped ports): fresh install committed with no plugins and a clean boot report; copy of a stopped and of a running classic server: data identical, old server's files byte-for-byte unchanged, Content Scaling and Companions built on the copy, preview core committed. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 6 +- Cargo.toml | 2 +- .../20261106-dungeon-clear-part-of-squid.md | 8 + ...1106-preview-bare-core-and-modular-copy.md | 10 + .../examples/preview_bare_and_copy_e2e.rs | 177 +++++++++ crates/coa-core/src/db.rs | 11 + crates/coa-core/src/install.rs | 23 +- crates/coa-core/src/lib.rs | 1 + crates/coa-core/src/modular_copy.rs | 336 ++++++++++++++++++ crates/coa-core/src/modules.rs | 21 +- crates/coa-core/src/rebuild.rs | 42 +++ package-lock.json | 4 +- package.json | 2 +- src-tauri/src/lib.rs | 70 +++- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 13 +- src/i18n/locales/en.ts | 12 +- src/i18n/locales/ru.ts | 12 +- src/lib/api.ts | 12 +- src/screens/ImportServer.tsx | 41 ++- src/screens/InstallServer.tsx | 26 +- src/screens/ModulesPage.tsx | 24 +- 22 files changed, 799 insertions(+), 56 deletions(-) create mode 100644 changelog.d/20261106-dungeon-clear-part-of-squid.md create mode 100644 changelog.d/20261106-preview-bare-core-and-modular-copy.md create mode 100644 crates/coa-core/examples/preview_bare_and_copy_e2e.rs create mode 100644 crates/coa-core/src/modular_copy.rs diff --git a/Cargo.lock b/Cargo.lock index 35ae8cc..c7bd8f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,7 +586,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.9.9" +version = "0.9.10" dependencies = [ "base64 0.22.1", "chrono", @@ -634,7 +634,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.9.9" +version = "0.9.10" dependencies = [ "chrono", "coa-core", @@ -772,7 +772,7 @@ dependencies = [ [[package]] name = "csm-modules-preview" -version = "0.9.9" +version = "0.9.10" dependencies = [ "coa-control-proto", "coa-core", diff --git a/Cargo.toml b/Cargo.toml index 05daa91..be96eef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/coa-core", "crates/coa-release", "crates/coa-registry-proto", exclude = ["services/coa-registry", "services/coa-registry-e2e", "services/coa-coordinator", "services/coa-relay"] [workspace.package] -version = "0.9.9" +version = "0.9.10" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261106-dungeon-clear-part-of-squid.md b/changelog.d/20261106-dungeon-clear-part-of-squid.md new file mode 100644 index 0000000..a19a99e --- /dev/null +++ b/changelog.d/20261106-dungeon-clear-part-of-squid.md @@ -0,0 +1,8 @@ +--- +area: manager +type: changed +audience: admins +title: Dungeon Clear is shown and switched as part of SQUID's Playerbots +--- +Dungeon Clear no longer has a card of its own, which said "Always on" while SQUID was off. It goes on +and off together with SQUID's Playerbots, and its settings open from the SQUID card. diff --git a/changelog.d/20261106-preview-bare-core-and-modular-copy.md b/changelog.d/20261106-preview-bare-core-and-modular-copy.md new file mode 100644 index 0000000..33cda79 --- /dev/null +++ b/changelog.d/20261106-preview-bare-core-and-modular-copy.md @@ -0,0 +1,10 @@ +--- +area: manager +type: changed +audience: admins +title: Modules Preview installs the bare modular core and copies classic servers instead of converting them +--- +A new server installed by the Modules Preview goes straight on to the modular core and compiles no +module; modules are added later from the Modules page. Choosing an existing server with the classic +core no longer adds it as it is: a modular server is installed next to it and its accounts, characters, +world, bots and module settings are copied into it. The old server is only read and keeps working. diff --git a/crates/coa-core/examples/preview_bare_and_copy_e2e.rs b/crates/coa-core/examples/preview_bare_and_copy_e2e.rs new file mode 100644 index 0000000..2d10632 --- /dev/null +++ b/crates/coa-core/examples/preview_bare_and_copy_e2e.rs @@ -0,0 +1,177 @@ +//! 0.9.10 install paths of the Modules Preview, end to end on DISPOSABLE servers under : +//! +//! 1. fresh: a new server goes on to the preview core with the bare core (no module is compiled). +//! 2. copy: a classic server (stopped) gets a modular copy next to it: accounts, characters, the world and +//! module settings arrive, the old server's files are byte-for-byte unchanged, the copy takes the +//! preview core with the modules the old server had switched on. +//! 3. copy-running: the same copy step from a classic server whose database is running. +//! +//! usage: preview_bare_and_copy_e2e [fresh|copy|all] +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use coa_core::install::{install_base, Params, Source}; +use coa_core::registry::Registry; +use coa_core::update::{self, RepackEnv}; + +const KEY: &str = coa_core::signing::EMBEDDED_PUBLIC_KEY; +type R = Result>; + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port() +} + +fn sql(root: &Path, q: &str) -> String { + coa_core::backup::with_database(root, |db| db.query(q)).unwrap_or_else(|e| panic!("{q}: {e}")) +} + +fn install(root: &Path, reg: &Registry) -> R<()> { + let image = coa_core::channels::stable_source()?; + let t = std::time::Instant::now(); + let done = install_base( + &Params { source: Source::Installation(Box::new(image)), dest: root.into(), trusted_key: KEY, registry: reg, cancel: Default::default() }, + &|s| eprintln!(" {:>3}% {}", s.percent, s.step), + )?; + println!("[{}] installed {} in {:?}", root.display(), done.version, t.elapsed()); + Ok(()) +} + +fn set_free_ports(root: &Path) -> R<()> { + let repack = root.join("Settings/repack.json"); + let mut cfg: serde_json::Value = serde_json::from_slice(&std::fs::read(&repack)?)?; + for k in ["mysqlPort", "authPort", "worldPort", "raPort"] { + cfg[k] = free_port().into(); + } + std::fs::write(&repack, serde_json::to_vec_pretty(&cfg)?)?; + Ok(()) +} + +/// The same steps as the Manager's install_preview_core. +fn preview_core(root: &Path) -> R { + let meta_dir = coa_core::registry::metadata_dir_for(root)?; + let (_, meta) = coa_core::registry::MetaDir::open(&meta_dir)?; + let source = coa_core::update_route::select(&meta, coa_core::channels::preview_update_source()?, KEY)?.source; + let env = RepackEnv { root, meta_dir: &meta_dir }; + let t = std::time::Instant::now(); + let out = coa_core::install::with_scratch_ports(root, || update::apply( + &update::Params { root, meta_dir: &meta_dir, source, trusted_key: KEY, cancel: Default::default(), resolutions: Default::default(), env: &env, fail_after_ops: None }, + &|phase, percent| println!(" {percent}% {phase}"), + )); + let _ = coa_core::driver::run(root, coa_core::driver::Verb::StopAll); + let out = out?; + println!("[{}] preview core: {:?} in {:?}; modules {:?}", root.display(), out.txn.state, t.elapsed(), out.modules); + assert_eq!(out.txn.state, update::State::Committed); + assert!(coa_core::provenance::realm_host_fingerprint(root).is_some(), "the plugin host is installed"); + Ok(out) +} + +/// sha256 of every file under the old server, except the launcher's own run state. +fn tree(root: &Path) -> BTreeMap { + fn walk(base: &Path, dir: &Path, out: &mut BTreeMap) { + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + walk(base, &p, out); + } else { + let rel = p.strip_prefix(base).unwrap().to_string_lossy().replace('\\', "/"); + out.insert(rel, coa_core::fsx::sha256_file(&p).unwrap_or_else(|e| format!("unreadable: {e}"))); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +fn fresh(work: &Path, reg: &Registry) -> R<()> { + let root = work.join("fresh-fixture"); + install(&root, reg)?; // shipped ports, as in the Manager: another server may be running on them + coa_core::rebuild::decline_recommended(&root)?; + let out = preview_core(&root)?; + assert!(out.modules.is_empty(), "a new server compiles nothing: {:?}", out.modules); + let registry = coa_core::plugins::load_registry(&root)?; + assert!(registry.plugins.values().all(|e| !e.installed), "no plugin installed: {:?}", registry.plugins.keys()); + let boot: serde_json::Value = serde_json::from_slice(&std::fs::read(root.join("Core/plugins/plugin-boot-report.json"))?)?; + println!("boot report: {boot}"); + assert_eq!(boot["anyFaulted"], serde_json::json!(false)); + println!("FRESH PASS"); + Ok(()) +} + +fn copy(work: &Path, reg: &Registry) -> R<()> { + // A classic server with data of its own. + let old = work.join("classic-fixture"); + install(&old, reg)?; + set_free_ports(&old)?; // the test runs this "old" server; it must not meet a real one on the shipped ports + sql(&old, "INSERT INTO acore_auth.account (username, salt, verifier, email, reg_mail) \ + VALUES ('E2ECOPY', UNHEX(REPEAT('11',32)), UNHEX(REPEAT('22',32)), '', '')"); + let account = sql(&old, "SELECT id FROM acore_auth.account WHERE username='E2ECOPY'").trim().to_string(); + sql(&old, &format!("SET SESSION sql_mode=''; INSERT INTO acore_characters.characters (guid, account, name, race, class, gender, level, money) \ + VALUES (900001, {account}, 'Copytest', 1, 1, 0, 42, 123456)")); + let conf = old.join("Core/configs/modules/mod-coa-content-scaling.conf"); + let conf_text = std::fs::read_to_string(&conf).unwrap_or_default() + "\n# e2e owner edit\n"; + std::fs::write(&conf, &conf_text)?; + let fingerprint = |root: &Path| sql(root, "SELECT CONCAT((SELECT COUNT(*) FROM acore_auth.account),'|',(SELECT COUNT(*) FROM acore_characters.characters),'|',\ + (SELECT CONCAT(name,':',level,':',money) FROM acore_characters.characters WHERE guid=900001))").trim().to_string(); + let expected = fingerprint(&old); + let _ = coa_core::driver::run(&old, coa_core::driver::Verb::StopAll); + println!("old server data: {expected}"); + assert!(coa_core::modular_copy::is_classic(&old)); + + // 2. copy of the stopped server, then the preview core on the copy. + let before = tree(&old); + let new = work.join("copy-fixture"); + install(&new, reg)?; + let report = coa_core::modular_copy::copy_data(&old, &new, &|w| println!(" copy: {w}"))?; + println!("copy report: {report:?}"); + assert_eq!(report.read_from, "copy of the stopped database"); + assert_eq!(tree(&old), before, "the old server's files changed"); + let observed = coa_core::process::observe(&new, &coa_core::layout::read_ports(&new)); + assert_eq!(observed.mysql.state, coa_core::process::ServiceState::Stopped, "a database of the copy is still running: {:?}", observed.mysql); + assert!(!coa_core::registry::metadata_dir_for(&new)?.join("import").exists(), "the private copy of the old data is removed"); + assert_eq!(std::fs::read_to_string(new.join("Core/configs/modules/mod-coa-content-scaling.conf"))?, conf_text); + let out = preview_core(&new)?; + println!("modules on the copy: {:?}", out.modules); + let got = fingerprint(&new); + let _ = coa_core::driver::run(&new, coa_core::driver::Verb::StopAll); + assert_eq!(got, expected, "data of the copy after the preview core"); + assert_eq!(tree(&old), before, "the old server's files changed"); + println!("COPY (stopped) PASS"); + + // 3. copy while the old database runs: read from it, change nothing. + let started = coa_core::driver::run(&old, coa_core::driver::Verb::StartMysql)?; + assert!(started.ok, "{}", started.output); + let data_before: BTreeMap = tree(&old).into_iter().filter(|(k, _)| !k.starts_with("mysql/") && !k.starts_with(".state/") && !k.starts_with("Core/configs/")).collect(); + let rows_before = fingerprint(&old); + let new2 = work.join("copy-running-fixture"); + install(&new2, reg)?; + let report = coa_core::modular_copy::copy_data(&old, &new2, &|w| println!(" copy: {w}")); + let rows_after = fingerprint(&old); + let _ = coa_core::driver::run(&old, coa_core::driver::Verb::StopAll); + let report = report?; + assert_eq!(report.read_from, "running database"); + assert_eq!(rows_after, rows_before); + let data_after: BTreeMap = tree(&old).into_iter().filter(|(k, _)| !k.starts_with("mysql/") && !k.starts_with(".state/") && !k.starts_with("Core/configs/")).collect(); + assert_eq!(data_after, data_before, "files outside the database changed"); + assert_eq!(fingerprint(&new2), expected); + let _ = coa_core::driver::run(&new2, coa_core::driver::Verb::StopAll); + println!("COPY (running) PASS"); + Ok(()) +} + +fn main() -> R<()> { + let a: Vec = std::env::args().skip(1).collect(); + let work = PathBuf::from(a.first().expect("usage: preview_bare_and_copy_e2e [fresh|copy|all]")); + let mode = a.get(1).map(String::as_str).unwrap_or("all"); + assert!(!work.exists() || std::fs::read_dir(&work)?.next().is_none(), "work folder must be new or empty"); + std::fs::create_dir_all(&work)?; + let reg = Registry::at(work.join("installs.json")); + if mode == "fresh" || mode == "all" { + fresh(&work, ®)?; + } + if mode == "copy" || mode == "all" { + copy(&work, ®)?; + } + println!("ALL PASS ({mode})"); + Ok(()) +} diff --git a/crates/coa-core/src/db.rs b/crates/coa-core/src/db.rs index 2bdd46b..176c2f0 100644 --- a/crates/coa-core/src/db.rs +++ b/crates/coa-core/src/db.rs @@ -337,6 +337,17 @@ impl Db { .map(|o| o.status.success()).unwrap_or(false) } + /// Ask the server to shut down cleanly (`mysqladmin shutdown`). + pub fn shutdown_server(&self) -> Result<()> { + let mut c = self.command("mysqladmin.exe"); + c.arg("shutdown").arg("--connect-timeout=5"); + let out = bounded_client_output(&mut c, b"", Instant::now() + Duration::from_secs(60))?; + if !out.status.success() { + return Err(self.fail(&out.stderr)); + } + Ok(()) + } + pub(crate) fn with_read_deadline(mut self, deadline: Instant) -> Self { self.read_deadline = Some(deadline); self diff --git a/crates/coa-core/src/install.rs b/crates/coa-core/src/install.rs index f027005..806ee29 100644 --- a/crates/coa-core/src/install.rs +++ b/crates/coa-core/src/install.rs @@ -210,7 +210,7 @@ pub(crate) fn free_port() -> Result { /// Run `f` with the staging copy listening on unused ports, so an installation never collides with a server (or any /// other program) already using the shipped ports. The shipped ports are put back afterwards. -pub(crate) fn with_scratch_ports(root: &Path, f: impl FnOnce() -> Result) -> Result { +pub fn with_scratch_ports(root: &Path, f: impl FnOnce() -> Result) -> Result { const KEYS: [&str; 4] = ["mysqlPort", "authPort", "worldPort", "raPort"]; let path = root.join("Settings/repack.json"); let original: serde_json::Value = fsx::read_json(&path)?; @@ -378,15 +378,18 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result { })?; say("Preparing database", 82, None); bootstrap_database(&staging_root)?; - crate::backup::with_database(&staging_root, |db| { - let problems = crate::schema_check::check(db, &staging_root)?; - if let Some(p) = problems.first() { - return Err(Error::Invalid(format!( - "The packaged database is incomplete: {}.{}.{}: {}.", - p.database, p.table, p.column, p.detail - ))); - } - Ok(()) + // On unused ports too: another server running on the shipped ports must not stop the installation. + with_scratch_ports(&staging_root, || { + crate::backup::with_database(&staging_root, |db| { + let problems = crate::schema_check::check(db, &staging_root)?; + if let Some(p) = problems.first() { + return Err(Error::Invalid(format!( + "The packaged database is incomplete: {}.{}.{}: {}.", + p.database, p.table, p.column, p.detail + ))); + } + Ok(()) + }) })?; // 4. Commit: the only moment the destination changes. The marker stays until everything is done, so an // unfinished install can always be recognised as ours (and cleaned up), wherever it stopped. diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 3ccefae..c07e8bb 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -43,6 +43,7 @@ pub mod logging; pub mod server_logging; pub mod manifest; pub mod migrations; +pub mod modular_copy; pub mod modules; pub mod module_sources; pub mod msvc_setup; diff --git a/crates/coa-core/src/modular_copy.rs b/crates/coa-core/src/modular_copy.rs new file mode 100644 index 0000000..23820aa --- /dev/null +++ b/crates/coa-core/src/modular_copy.rs @@ -0,0 +1,336 @@ +//! A modular copy of a classic server, next to it. +//! +//! A server with the classic core (every module compiled into `worldserver.exe`) is never converted in place. +//! The Modules Preview installs a new server beside it and copies the old one's data into it: every game +//! database (accounts, characters, world, bots) and the module settings. The old server is only read: +//! +//! * if its database is running, the schemas are dumped from it (consistent snapshots, nothing is written); +//! * if it is stopped, its data files are copied to a private folder and read by a temporary database +//! process of the NEW server; nothing of the old server is started or executed. +//! +//! The new server then takes the preview core like any other server (the caller applies the update). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde::Serialize; + +use crate::db::{Account, Db}; +use crate::error::{Error, Result}; +use crate::fsx; +use crate::process::ServiceState; + +/// Whether `root` runs the classic core: a Windows repack whose `worldserver.exe` carries no plugin-host +/// fingerprint. +pub fn is_classic(root: &Path) -> bool { + !crate::docker::is_docker(root) + && root.join("Core/worldserver.exe").is_file() + && crate::provenance::realm_host_fingerprint(root).is_none() +} + +#[derive(Debug, Clone, Serialize)] +pub struct CopyReport { + pub schemas: Vec, + pub accounts: u64, + pub characters: u64, + pub config_files: Vec, + /// How the old data was read: "running database" or "copy of the stopped database". + pub read_from: String, +} + +/// Game schemas of a CoA server: `acore_*` (auth, characters, world, their Wildcard/Reborn variants, bots). +fn game_schemas(db: &Db) -> Result> { + let mut out: Vec = db + .query("SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'acore\\_%' ORDER BY SCHEMA_NAME;")? + .lines() + .map(|l| l.trim().to_string()) + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) + // work schemas a restore leaves behind are not game data + .filter(|s| !s.contains("_restore_") && !s.contains("_before_restore_")) + .collect(); + out.dedup(); + if !out.iter().any(|s| s == "acore_auth") || !out.iter().any(|s| s == "acore_characters") { + return Err(Error::Invalid("The old server's database has no accounts or characters schema.".into())); + } + Ok(out) +} + +fn count(db: &Db, sql: &str) -> u64 { + db.query(sql).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0) +} + +/// A database process of the new server reading a private copy of the old server's stopped data files. +struct TemporaryDatabase { + child: Child, + dir: PathBuf, + db: Option, + stopped: bool, +} + +impl TemporaryDatabase { + /// Shut it down cleanly, force it if it does not go, and remove the private copy. Fails if the process is + /// still there afterwards, so a leftover database never holds the new server's files. + fn stop(&mut self) -> Result<()> { + if self.stopped { + return Ok(()); + } + if let Some(db) = &self.db { + let _ = db.shutdown_server(); + } + let deadline = Instant::now() + Duration::from_secs(90); + loop { + match self.child.try_wait()? { + Some(_) => break, + None if Instant::now() > deadline => { + tracing::warn!(pid = self.child.id(), "temporary database did not shut down; terminating it"); + self.child.kill()?; + self.child.wait()?; + break; + } + None => std::thread::sleep(Duration::from_millis(250)), + } + } + self.stopped = true; + fs::remove_dir_all(&self.dir)?; + Ok(()) + } +} + +impl Drop for TemporaryDatabase { + fn drop(&mut self) { + if !self.stopped { + if let Err(e) = self.stop() { + tracing::warn!(error = %e, pid = self.child.id(), "temporary database left behind"); + } + } + } +} + +fn copy_tree(from: &Path, to: &Path) -> Result { + let mut bytes = 0; + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + let md = fs::symlink_metadata(entry.path())?; + if md.file_type().is_symlink() { + return Err(Error::Invalid(format!("{} is a link; the database folder is not copied.", entry.path().display()))); + } + let target = to.join(entry.file_name()); + if md.is_dir() { + bytes += copy_tree(&entry.path(), &target)?; + } else { + fs::copy(entry.path(), &target)?; + bytes += md.len(); + } + } + Ok(bytes) +} + +fn dir_size(dir: &Path) -> u64 { + fs::read_dir(dir).map(|rd| rd.flatten().map(|e| { + let p = e.path(); + if p.is_dir() { dir_size(&p) } else { e.metadata().map(|m| m.len()).unwrap_or(0) } + }).sum()).unwrap_or(0) +} + +fn start_temporary(old: &Path, new: &Path, work: &Path, progress: &dyn Fn(&str)) -> Result<(TemporaryDatabase, Db)> { + let source = old.join("mysql/data"); + if !source.join("mysql").is_dir() || !source.join("ibdata1").is_file() { + return Err(Error::Invalid("The old server's database folder is incomplete.".into())); + } + let dir = work.join("old-database"); + let _ = fs::remove_dir_all(&dir); + fsx::require_space(work, dir_size(&source))?; + progress("Copying the old server's database files"); + let data = dir.join("data"); + copy_tree(&source, &data)?; + let (root_password, _) = crate::db::credentials(old)?; + let bin = new.join("mysql/bin"); + let port = crate::install::free_port()?; + let child = Command::new(bin.join("mysqld.exe")) + .arg("--no-defaults") + .arg(format!("--basedir={}", new.join("mysql").display())) + .arg(format!("--datadir={}", data.display())) + .arg(format!("--port={port}")) + .args(["--bind-address=127.0.0.1", "--mysqlx=0", "--skip-log-bin", "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_unicode_ci", "--max_allowed_packet=128M"]) + .arg(format!("--log-error={}", dir.join("mysql-error.log").display())) + .arg(format!("--pid-file={}", dir.join("mysql.pid").display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags_hidden() + .spawn()?; + let db = Db::with_tools(bin, port, "root", &root_password, crate::realms::Mode::Coa); + let mut temp = TemporaryDatabase { child, dir, db: Some(db.clone()), stopped: false }; + progress("Opening the copy of the old database"); + let deadline = Instant::now() + Duration::from_secs(300); + while !db.ping() { + if let Ok(Some(status)) = temp.child.try_wait() { + let log = fs::read_to_string(temp.dir.join("mysql-error.log")).unwrap_or_default(); + let tail: Vec<&str> = log.lines().rev().take(8).collect(); + return Err(Error::Invalid(format!( + "The copy of the old database did not open ({status}). {}", + tail.into_iter().rev().collect::>().join(" ") + ))); + } + if Instant::now() > deadline { + return Err(Error::Invalid("The copy of the old database did not open within 5 minutes.".into())); + } + std::thread::sleep(Duration::from_millis(300)); + } + Ok((temp, db)) +} + +trait HiddenWindow { + fn creation_flags_hidden(&mut self) -> &mut Self; +} + +impl HiddenWindow for Command { + fn creation_flags_hidden(&mut self) -> &mut Self { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + self.creation_flags(0x0800_0000); + } + self + } +} + +/// Settings the owner may have changed that the new core can take as they are: the module configs +/// (`Core/configs/modules/*.conf`; the shipped `.dist` files belong to the new core) and the realm profiles. +/// `worldserver.conf` and `authserver.conf` are rendered by the launcher from the new server's own +/// templates at every start, so they are not copied. +fn settings_files(old: &Path) -> Vec { + crate::backup::config_files(old) + .into_iter() + .filter(|f| { + (f.starts_with("Core/configs/modules/") && f.ends_with(".conf") && !f.ends_with("/mod_ascension_compat.conf")) + || f.starts_with("Settings/realm-profile") + }) + .collect() +} + +/// Copy the old server's data into `new`, a server just installed and not yet started by anyone. +pub fn copy_data(old: &Path, new: &Path, progress: &dyn Fn(&str)) -> Result { + if !is_classic(old) { + return Err(Error::Invalid("Only a server with the classic core is copied.".into())); + } + if fsx::canonicalize_lenient(old)? == fsx::canonicalize_lenient(new)? { + return Err(Error::Invalid("The new server must be in another folder.".into())); + } + let work = crate::registry::metadata_dir_for(new)?.join("import"); + let _ = fs::remove_dir_all(&work); + fs::create_dir_all(&work)?; + + // 1. Read the old databases. + let observed = crate::process::observe(old, &crate::layout::read_ports(old)); + let running = observed.mysql.state == ServiceState::Running + && Db::from_repack(old, Account::Admin).map(|db| db.ping()).unwrap_or(false); + let mut temporary: Option = None; + let (source, read_from) = if running { + (Db::from_repack(old, Account::Admin)?.for_realm(crate::realms::Mode::Coa), "running database") + } else { + let (temp, db) = start_temporary(old, new, &work, progress)?; + temporary = Some(temp); + (db, "copy of the stopped database") + }; + let schemas = game_schemas(&source)?; + let accounts = count(&source, "SELECT COUNT(*) FROM acore_auth.account;"); + let characters = count(&source, "SELECT COUNT(*) FROM acore_characters.characters;"); + let mut dumps = Vec::new(); + for schema in &schemas { + progress(&format!("Copying {schema}")); + let file = work.join(format!("{schema}.sql.zst")); + source.dump_to(schema, &file)?; + dumps.push((schema.clone(), file)); + } + // The private copy of the old data files is not needed any more; its database must be gone before the new + // server's own database starts. + if let Some(mut temp) = temporary.take() { + progress("Closing the copy of the old database"); + temp.stop()?; + } + + // 2. Put them into the new server, on ports nobody uses, so a running old server is not disturbed. + crate::install::with_scratch_ports(new, || { + crate::backup::with_database(new, |db| { + let db = db.clone().for_realm(crate::realms::Mode::Coa); + for (schema, file) in &dumps { + progress(&format!("Importing {schema}")); + db.query(&format!( + "DROP DATABASE IF EXISTS `{schema}`; CREATE DATABASE `{schema}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + ))?; + db.import_from(schema, file)?; + } + let (a, c) = ( + count(&db, "SELECT COUNT(*) FROM acore_auth.account;"), + count(&db, "SELECT COUNT(*) FROM acore_characters.characters;"), + ); + if (a, c) != (accounts, characters) { + return Err(Error::Invalid(format!( + "The copy is incomplete: {a} of {accounts} accounts and {c} of {characters} characters arrived." + ))); + } + Ok(()) + }) + })?; + + // 3. Settings: module configs and realm profiles; ports and console login as the old server had them + // (the console account lives in the copied accounts database). + progress("Copying settings"); + let files = settings_files(old); + for rel in &files { + let from = fsx::ensure_within(old, &fsx::safe_join(old, rel)?)?; + let to = fsx::ensure_within(new, &fsx::safe_join(new, rel)?)?; + fsx::atomic_write(&to, &fs::read(from)?)?; + } + let old_repack: serde_json::Value = fsx::read_json(&old.join("Settings/repack.json"))?; + let mut repack: serde_json::Value = fsx::read_json(&new.join("Settings/repack.json"))?; + if let (Some(dst), Some(src)) = (repack.as_object_mut(), old_repack.as_object()) { + for (k, v) in src { + dst.insert(k.clone(), v.clone()); + } + } + fsx::atomic_write_json(&new.join("Settings/repack.json"), &repack)?; + let _ = fs::remove_dir_all(&work); + Ok(CopyReport { schemas, accounts, characters, config_files: files, read_from: read_from.into() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_owner_settings_are_copied() { + let dir = tempfile::tempdir().unwrap(); + let old = dir.path(); + for rel in [ + "Core/configs/worldserver.conf", + "Core/configs/worldserver.conf.dist", + "Core/configs/modules/mod_x.conf", + "Core/configs/modules/mod_x.conf.dist", + "Core/configs/modules/mod_ascension_compat.conf", + "Settings/repack.json", + "Settings/worldserver.conf.template", + "Settings/realm-profile.json", + ] { + let p = old.join(rel); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(p, b"x").unwrap(); + } + let mut got = settings_files(old); + got.sort(); + assert_eq!(got, vec![ + "Core/configs/modules/mod_x.conf".to_string(), + "Settings/realm-profile.json".to_string(), + ]); + } + + #[test] + fn a_folder_without_a_core_is_not_classic() { + let dir = tempfile::tempdir().unwrap(); + assert!(!is_classic(dir.path())); + } +} diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index 9246ca6..c6e6b92 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -711,6 +711,17 @@ pub fn ensure_bot_exclusivity(root: &Path) -> Result<()> { Ok(()) } +/// Dungeon Clear is part of SQUID's Playerbots (the plugin core builds it into the SQUID DLL): it goes on +/// with SQUID, and off with it, wherever its config exists. +fn switch_on_dungeon_clear_with_squid(cat: &[Entry], root: &Path, meta: &Path) { + if let Some(dc) = cat.iter().find(|entry| entry.id == "dungeon-clear") { + let name = conf_name(root, dc); + if dir(root).join(&name).exists() || dir(root).join(format!("{name}.dist")).exists() { + let _ = set_enabled_in(cat, root, meta, "dungeon-clear", true); + } + } +} + fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> { match entry_in(cat, id) { Ok(e) => { @@ -770,6 +781,7 @@ fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) - } if id == "playerbots" && on { let _ = crate::plugins::record_squid_migrations_applied(root); + switch_on_dungeon_clear_with_squid(cat, root, meta); } tracing::info!(module = id, on, "module switched"); @@ -806,6 +818,9 @@ fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) - let _ = set_enabled_in(cat, root, meta, "dungeon-clear", false); } } + if id == "playerbots" && on { + switch_on_dungeon_clear_with_squid(cat, root, meta); + } return Ok(()); } } @@ -1255,7 +1270,7 @@ mod tests { } #[test] - fn dungeon_clear_requires_squid_and_stays_off_after_squid_is_reenabled() { + fn dungeon_clear_requires_squid_and_follows_it() { let d = tempfile::tempdir().unwrap(); let root = d.path(); fs::create_dir_all(dir(root)).unwrap(); @@ -1266,13 +1281,13 @@ mod tests { let cat = catalog(); assert!(set_enabled_in(&cat, root, root, "dungeon-clear", true).is_err()); assert!(!list_in(&cat, root).iter().find(|entry| entry.id == "dungeon-clear").unwrap().switchable); + // part of SQUID: on with it, off with it, on again with it set_enabled_in(&cat, root, root, "playerbots", true).unwrap(); - set_enabled_in(&cat, root, root, "dungeon-clear", true).unwrap(); assert!(read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap()); set_enabled_in(&cat, root, root, "playerbots", false).unwrap(); assert!(!read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap()); set_enabled_in(&cat, root, root, "playerbots", true).unwrap(); - assert!(!read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap()); + assert!(read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap()); } #[test] diff --git a/crates/coa-core/src/rebuild.rs b/crates/coa-core/src/rebuild.rs index 9c069d7..21577a7 100644 --- a/crates/coa-core/src/rebuild.rs +++ b/crates/coa-core/src/rebuild.rs @@ -259,9 +259,32 @@ fn split_repo(repo: &str) -> std::result::Result<(&str, &str), String> { .ok_or_else(|| format!("invalid repository '{repo}'")) } +/// Mark a server that starts with the bare core: the modules the core recommends are not installed on it +/// (the owner adds modules from the Modules page). Taken into account the first time a plugin-host core +/// arrives; after that every recommended module counts as dealt with. +pub fn decline_recommended(realm_root: &Path) -> Result<()> { + let body = serde_json::json!({ "schema": 1, "handled": load_handled(realm_root), "declineAll": true }); + fs::create_dir_all(plugins::meta_dir(realm_root))?; + crate::fsx::atomic_write(&handled_path(realm_root), serde_json::to_string_pretty(&body)?.as_bytes()) +} + +fn declined_all(realm_root: &Path) -> bool { + fs::read(handled_path(realm_root)).ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|v| v.get("declineAll")?.as_bool()) + .unwrap_or(false) +} + fn install_recommended(realm_root: &Path, progress: &dyn Fn(&str, &str)) -> Vec { let Some(rec) = recommended(realm_root) else { return Vec::new() }; let mut handled = load_handled(realm_root); + if declined_all(realm_root) { + for m in &rec.modules { + handled.entry(m.id.clone()).or_insert_with(|| "not installed: new server with the bare core".into()); + } + save_handled(realm_root, &handled); // drops the mark: it applies to this first core only + return Vec::new(); + } let mut out = Vec::new(); for m in &rec.modules { let registry = plugins::load_registry(realm_root).unwrap_or_default(); @@ -455,6 +478,25 @@ mod tests { assert!(!handled.contains_key("companions")); } + #[test] + fn a_new_server_with_the_bare_core_installs_no_recommended_module() { + let dir = tempfile::tempdir().unwrap(); + let server = dir.path(); + fs::create_dir_all(server.join("Core/configs/modules")).unwrap(); + fs::create_dir_all(server.join("Core/plugins")).unwrap(); + fs::write(server.join("Core/worldserver.exe"), image("worldserver", &"c".repeat(64))).unwrap(); + decline_recommended(server).unwrap(); + // the mark is written before the plugin-host core (and its recommended.json) arrives + fs::write(server.join("Core/plugins/recommended.json"), r#"{"schema":1,"modules":[ + {"id":"companions","repo":"not-a-repo","commit":"0000000000000000000000000000000000000000"}]}"#).unwrap(); + let out = rebuild_for_current_core(server, &|_, _| panic!("nothing is built")).unwrap(); + assert!(out.is_empty(), "{out:?}"); + assert!(load_handled(server)["companions"].contains("bare core")); + assert!(!declined_all(server), "the mark applies to the first plugin-host core only"); + // a later core change does not bring it back either + assert!(rebuild_for_current_core(server, &|_, _| panic!("nothing is built")).unwrap().is_empty()); + } + /// Real run: COA_TEST_RECOMMENDED_REALM is a plugin-host realm with Core/plugins/recommended.json (or /// plugins/recommended.json); the modules are downloaded from GitHub, built and switched on. #[test] diff --git a/package-lock.json b/package-lock.json index 2f93a6d..c03e5ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.9.9", + "version": "0.9.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.9.9", + "version": "0.9.10", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 643484c..a0d6431 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.9.9", + "version": "0.9.10", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 99f9f18..5224e01 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1062,12 +1062,25 @@ async fn install_new( dest: String, package: Option, game_data: Option, + copy_from: Option, ) -> std::result::Result { + let copy_from = copy_from.filter(|p| !p.trim().is_empty()).map(PathBuf::from); if platform::flavor() == Flavor::Docker { + if copy_from.is_some() { + return Err(Error::Invalid("A modular copy is made of a Windows server only.".into()).into()); + } // The plugin host is Windows-only for now: a Linux server keeps the classic core. return docker_install::install(app, state, dest, package, game_data) .await - .map(|summary| InstalledServer { summary, preview_core_error: None, modules_off: Vec::new() }); + .map(|summary| InstalledServer { summary, preview_core_error: None, modules_off: Vec::new(), copied: None }); + } + if let Some(old) = ©_from { + if !coa_core::modular_copy::is_classic(old) { + return Err(Error::Invalid("This server already has the modular core; add it as it is.".into()).into()); + } + } + if std::fs::read_dir(&dest).is_ok_and(|mut d| d.next().is_some()) { + return Err(Error::Invalid("Choose a new or empty folder for the server: this one already has files.".into()).into()); } let cancel = Cancel::default(); *state @@ -1077,14 +1090,15 @@ async fn install_new( let registry = Registry::at(data_dir().join("installs.json")); let dest_path = PathBuf::from(dest); // A package the owner picked by hand is installed as it is; otherwise the new server goes straight on to the - // preview core, so it starts out with modules as plugins instead of the classic core with them built in. - let to_preview = package.as_deref().map_or(true, |p| p.trim().is_empty()); + // preview core. A new server starts with the bare core (modules are added later from the Modules page); a + // modular copy of a classic server gets the modules its old server had switched on. + let to_preview = package.as_deref().map_or(true, |p| p.trim().is_empty()) || copy_from.is_some(); let result = tauri::async_runtime::spawn_blocking(move || { let source = installation_source(package)?; let done = install::install_base( &install::Params { source, - dest: dest_path, + dest: dest_path.clone(), trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, registry: ®istry, cancel, @@ -1093,27 +1107,63 @@ async fn install_new( let _ = app.emit("install-progress", step); }, )?; + let root = PathBuf::from(&done.path); + let copied = match ©_from { + Some(old) => { + let report = coa_core::modular_copy::copy_data(old, &root, &|what| { + let _ = app.emit( + "install-progress", + serde_json::json!({ "step": "Copying the old server's data", "percent": 100, "detail": what }), + ); + }); + match report { + Ok(r) => Some(r), + Err(e) => { + // Nothing of the old server was changed; take away the half-made copy (this call created it). + let _ = registry.unregister(&done.id); + let _ = coa_core::driver::run(&root, Verb::StopAll); + let _ = std::fs::remove_dir_all(&root); + if let Ok(meta) = metadata_dir_for(&root) { + let _ = std::fs::remove_dir_all(meta); + } + return Err(Error::Invalid(format!("The old server's data could not be copied: {e}"))); + } + } + } + None => { + if to_preview { + coa_core::rebuild::decline_recommended(&root)?; + } + None + } + }; let (preview_core_error, modules_off) = match to_preview { false => (None, Vec::new()), - true => match install_preview_core(&app, &PathBuf::from(&done.path)) { + true => match install_preview_core(&app, &root) { Ok(off) => (None, off), Err(e) => (Some(e), Vec::new()), }, }; - Ok::<_, Error>((done, preview_core_error, modules_off)) + Ok::<_, Error>((done, preview_core_error, modules_off, copied)) }) .await; if let Ok(mut c) = state.install_cancel.lock() { *c = None; } - let (done, preview_core_error, modules_off) = result.map_err(|e| Error::Invalid(e.to_string()))??; + let (done, preview_core_error, modules_off, copied) = result.map_err(|e| Error::Invalid(e.to_string()))??; Ok(InstalledServer { summary: summary(done.id, PathBuf::from(done.path)), preview_core_error, modules_off, + copied, }) } +#[tauri::command] +fn is_classic_server(path: String) -> bool { + coa_core::modular_copy::is_classic(Path::new(&path)) +} + #[derive(Serialize)] struct InstalledServer { #[serde(flatten)] @@ -1122,6 +1172,8 @@ struct InstalledServer { preview_core_error: Option, /// Modules that did not build for the preview core and were switched off, with the reason. modules_off: Vec, + /// For a modular copy of a classic server: what was copied. + copied: Option, } /// Put the preview core on a server that was just installed from the stable image: the same signed update every @@ -1160,7 +1212,8 @@ fn install_preview_core(app: &AppHandle, root: &Path) -> std::result::Result(null); const [view, setView] = useState(initialView); const [activeId, setActiveId] = useState(null); + // A classic server the owner chose: the install screen makes a modular copy of it next to it. + const [copyFrom, setCopyFrom] = useState(null); const refresh = useCallback(async () => { const list = await api.list().catch(() => []); @@ -56,8 +58,13 @@ function Main() { return ( setView(servers.length > 0 ? "add" : "welcome")} + copyFrom={copyFrom ?? undefined} + onCancel={() => { + setView(copyFrom ? "import" : servers.length > 0 ? "add" : "welcome"); + setCopyFrom(null); + }} onDone={async (s) => { + setCopyFrom(null); await refresh(); setActiveId(s.id); setView("welcome"); @@ -76,6 +83,10 @@ function Main() { setActiveId(s.id); setView("welcome"); }} + onCopy={(path) => { + setCopyFrom(path); + setView("install"); + }} /> ); } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 349fa80..dc96805 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -267,8 +267,16 @@ const en = { "install.step.unpacking": "Unpacking", "install.step.database": "Preparing database", "install.step.finishing": "Finishing", - "install.step.modularCore": "Installing the modular core and building modules", - "install.modularCoreHint": "This step compiles the modules on this computer and can take several minutes.", + "install.step.modularCore": "Installing the modular core", + "install.modularCoreHint": "Modules the old server had switched on are compiled on this computer; this can take several minutes.", + "install.step.copying": "Copying the old server's data", + "install.copy.title": "Modular copy of your server", + "install.copy.subtitle": "Your server has the classic core. A new server with the modular core is installed next to it, and accounts, characters, the world and module settings are copied into it. The old server is not changed; you can keep using it.", + "install.copy.from": "Copy from:", + "install.copy.done": "Copied {accounts} accounts, {characters} characters and {settings} settings files.", + "import.classic.title": "This server has the classic core", + "import.classic.text": "Its modules are compiled into the core. The Modules Preview does not change it: it installs a modular server next to it and copies the data into it. Run one of the two at a time, they use the same ports.", + "import.classic.copy": "Create a modular copy", "install.classicCoreTitle": "The server stays on the classic core", "install.classicCoreText": "The server is installed and works, but the modular core did not go on, so its modules are built into the core. You can try again from Updates.", "install.modulesOffTitle": "Some modules were switched off", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 9db3e4c..edd2755 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -251,8 +251,16 @@ const ru: Partial> = { "install.step.unpacking": "Распаковка", "install.step.database": "Подготовка базы данных", "install.step.finishing": "Завершение", - "install.step.modularCore": "Установка модульного ядра и сборка модулей", - "install.modularCoreHint": "На этом шаге модули компилируются на вашем компьютере, это может занять несколько минут.", + "install.step.modularCore": "Установка модульного ядра", + "install.modularCoreHint": "Модули, которые были включены на старом сервере, компилируются на вашем компьютере; это может занять несколько минут.", + "install.step.copying": "Копирование данных старого сервера", + "install.copy.title": "Модульная копия вашего сервера", + "install.copy.subtitle": "У вашего сервера классическое ядро. Рядом ставится новый сервер с модульным ядром, в него копируются аккаунты, персонажи, мир и настройки модулей. Старый сервер не меняется, им можно продолжать пользоваться.", + "install.copy.from": "Копировать из:", + "install.copy.done": "Скопировано аккаунтов: {accounts}, персонажей: {characters}, файлов настроек: {settings}.", + "import.classic.title": "У этого сервера классическое ядро", + "import.classic.text": "Модули вкомпилированы в ядро. Превью-менеджер его не меняет: он ставит рядом модульный сервер и копирует в него данные. Запускайте один из двух: порты у них одинаковые.", + "import.classic.copy": "Создать модульную копию", "install.classicCoreTitle": "Сервер остался на классическом ядре", "install.classicCoreText": "Сервер установлен и работает, но модульное ядро не встало, поэтому модули встроены в ядро. Можно повторить из раздела «Обновления».", "install.modulesOffTitle": "Часть модулей выключена", diff --git a/src/lib/api.ts b/src/lib/api.ts index dda39e0..72f524c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -470,6 +470,14 @@ export interface AccountInfo { characters: number; } +export interface CopyReport { + schemas: string[]; + accounts: number; + characters: number; + config_files: string[]; + read_from: string; +} + export interface InstallStep { step: string; percent: number; @@ -798,7 +806,9 @@ 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, gameData?: string) => invoke("install_new", { dest, package: pkg ?? null, gameData: gameData ?? null }), + installNew: (dest: string, pkg?: string, gameData?: string, copyFrom?: string) => + invoke("install_new", { dest, package: pkg ?? null, gameData: gameData ?? null, copyFrom: copyFrom ?? null }), + isClassicServer: (path: string) => invoke("is_classic_server", { path }), 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/ImportServer.tsx b/src/screens/ImportServer.tsx index 2267fba..1beefff 100644 --- a/src/screens/ImportServer.tsx +++ b/src/screens/ImportServer.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { AlertTriangle, Check, Minus } from "lucide-react"; import { api, asUiError, type Classification, type ScanReport, type ServerSummary, type UiError } from "@/lib/api"; @@ -17,10 +17,17 @@ export function ImportServer(props: { canCancel: boolean; onCancel: () => void; onAdded: (s: ServerSummary) => void | Promise; + onCopy: (path: string) => void; }) { const t = useT(); const human = useHuman(); const [report, setReport] = useState(null); + // A classic core (modules compiled in) is not added as it is: the Modules Preview makes a modular copy next to it. + const [classic, setClassic] = useState(false); + useEffect(() => { + setClassic(false); + if (report && report.classification !== "incompatible") void api.isClassicServer(report.path).then(setClassic).catch(() => setClassic(false)); + }, [report]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -149,16 +156,28 @@ export function ImportServer(props: {

))} -
-

{t("import.noChanges")}

- -
+ {classic ? ( +
+

{t("import.classic.title")}

+

{t("import.classic.text")}

+
+ +
+
+ ) : ( +
+

{t("import.noChanges")}

+ +
+ )} )} diff --git a/src/screens/InstallServer.tsx b/src/screens/InstallServer.tsx index c9606aa..74a8bd9 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 InstallEnvironment, type InstallRequirements, type InstallStep, type Preflight, type ServerSummary, type UiError } from "@/lib/api"; +import { api, asUiError, type CopyReport, 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"; @@ -17,14 +17,17 @@ const STEP_KEYS: Record = { "Preparing database": "install.step.database", Finishing: "install.step.finishing", "Installing the modular core": "install.step.modularCore", + "Copying the old server's data": "install.step.copying", }; type Phase = "choose" | "installing" | "done"; -export function InstallServer(props: { canCancel: boolean; onCancel: () => void; onDone: (s: ServerSummary) => void | Promise }) { +export function InstallServer(props: { canCancel: boolean; copyFrom?: string; onCancel: () => void; onDone: (s: ServerSummary) => void | Promise }) { const t = useT(); const human = useHuman(); - const [dest, setDest] = useState("C:\\Games\\CoA Server"); + const copyFrom = props.copyFrom; + const [dest, setDest] = useState(copyFrom ? copyFrom.replace(/[\\/]+$/, "") + " (modular)" : "C:\\Games\\CoA Server"); + const [copied, setCopied] = useState(null); const [pre, setPre] = useState(null); const [req, setReq] = useState(null); const [phase, setPhase] = useState("choose"); @@ -90,8 +93,9 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void; setPhase("installing"); setStep({ step: "Preparing your server", percent: 0, detail: null }); try { - const { preview_core_error, modules_off, ...s } = await api.installNew(dest, pkg.trim() || undefined, docker ? gameData.trim() : undefined); + const { preview_core_error, modules_off, copied, ...s } = await api.installNew(dest, copyFrom ? undefined : pkg.trim() || undefined, docker ? gameData.trim() : undefined, copyFrom); setInstalled(s); + setCopied(copied); setClassicCore(preview_core_error); setModulesOff(modules_off); setPhase("done"); @@ -117,7 +121,7 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
{step.detail &&

{step.detail}

} - {step.step === "Installing the modular core" &&

{t("install.modularCoreHint")}

} + {step.step === "Installing the modular core" && copyFrom &&

{t("install.modularCoreHint")}

} {step.step === "Downloading server" && ( )} + {m.id === "playerbots" && m.installed && dungeonClear && ( + + )} {canRemove && (