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" && (