diff --git a/Cargo.lock b/Cargo.lock index f4746c1..a19f2ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,7 +347,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.3.0" +version = "0.3.1" dependencies = [ "base64 0.22.1", "chrono", @@ -375,7 +375,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.3.0" +version = "0.3.1" dependencies = [ "chrono", "coa-core", @@ -384,7 +384,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.3.0" +version = "0.3.1" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 98ad862..92fa576 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/coa-core", "crates/coa-release", "src-tauri"] [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2021" license = "AGPL-3.0-only" diff --git a/README.md b/README.md index cfbcb79..5c8f804 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ no console, no config files, no database tools. Tauri 2 + Rust + React. from the app. * **Companions (bots).** Fill the world with level-appropriate bots, see progress, stop spawning, take them offline, despawn N to relieve the server, and choose how many log in at server start. +* **Realmlist switcher.** Keep named realmlists (for example *Solo* and *PTR*) and switch the game client between them from a + drop-down next to PLAY; the file that was replaced is saved first. +* **Accounts.** See everyone who can log in, change passwords and access levels, rename an account. +* **Modules.** A page for the server's optional modules (switch on/off, GitHub, settings); it fills up as modules are added. +* **Report a problem.** A form that prepares a ready bug report for GitHub with your versions filled in. * **Settings without files.** Bots and Server pages with plain-language options and presets, a console to the world server, player list with account tools. * **Play with friends.** Checks whether your server is reachable, opens firewall rules, finds your router (UPnP), diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index 2241684..b24b6ee 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -36,8 +36,8 @@ pub fn catalog() -> Vec { serde_json::from_str::(CATALOG).map(|c| c.modules).unwrap_or_default() } -fn entry(id: &str) -> Result { - catalog().into_iter().find(|e| e.id == id).ok_or_else(|| Error::Invalid("That module is not known to the Manager.".into())) +fn entry_in(cat: &[Entry], id: &str) -> Result { + cat.iter().find(|e| e.id == id).cloned().ok_or_else(|| Error::Invalid("That module is not known to the Manager.".into())) } fn dir(root: &Path) -> PathBuf { @@ -64,8 +64,12 @@ pub struct ModuleView { } pub fn list(root: &Path) -> Vec { - catalog() - .into_iter() + list_in(&catalog(), root) +} + +fn list_in(cat: &[Entry], root: &Path) -> Vec { + cat.iter() + .cloned() .map(|e| { let conf = dir(root).join(&e.conf); let dist = dir(root).join(format!("{}.dist", e.conf)); @@ -97,7 +101,11 @@ fn backup(meta: &Path, conf: &Path) -> Result<()> { } pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> { - let e = entry(id)?; + set_enabled_in(&catalog(), root, meta, id, on) +} + +fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> { + let e = entry_in(cat, id)?; let conf = active_file(root, &e)?; let mut file = ConfFile::parse_bytes(&fs::read(&conf)?)?; if file.get(&e.enable_key).map(truthy) == Some(on) { @@ -121,7 +129,11 @@ pub struct Setting { /// Every active setting of the module with its current value, its documented default and its description. pub fn settings(root: &Path, id: &str) -> Result> { - let e = entry(id)?; + settings_in(&catalog(), root, id) +} + +fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result> { + let e = entry_in(cat, id)?; let conf = dir(root).join(&e.conf); let dist = read(&dir(root).join(format!("{}.dist", e.conf))); let active = read(&conf).or_else(|| dist.clone()).ok_or_else(|| Error::Invalid("This module is not part of this server.".into()))?; @@ -138,7 +150,11 @@ pub fn settings(root: &Path, id: &str) -> Result> { /// Change values of settings the module already has. A value is one line of at most 500 characters. pub fn save_settings(root: &Path, meta: &Path, id: &str, changes: &BTreeMap) -> Result> { - let e = entry(id)?; + save_settings_in(&catalog(), root, meta, id, changes) +} + +fn save_settings_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, changes: &BTreeMap) -> Result> { + let e = entry_in(cat, id)?; let conf = active_file(root, &e)?; let mut file = ConfFile::parse_bytes(&fs::read(&conf)?)?; let mut changed = Vec::new(); @@ -169,6 +185,18 @@ pub fn save_settings(root: &Path, meta: &Path, id: &str, changes: &BTreeMap Vec { + let one = |id: &str, name: &str, conf: &str, key: &str| Entry { + id: id.into(), + name: name.into(), + description: ["en", "ru", "de", "fr", "es"].iter().map(|l| (l.to_string(), format!("{name} ({l})"))).collect(), + repo: format!("https://github.com/Corfirean/example/{id}"), + conf: conf.into(), + enable_key: key.into(), + }; + vec![one("war-games", "War Games", "war_games.conf", "WarGames.Enable"), one("spellbook", "Spellbook", "spellbook.conf", "Spellbook.Enable")] + } + fn server(d: &Path) -> (PathBuf, PathBuf) { let (root, meta) = (d.join("srv"), d.join("meta")); let m = dir(&root); @@ -179,9 +207,9 @@ mod tests { } #[test] - fn the_bundled_catalog_is_complete() { + fn the_bundled_catalog_is_valid() { let c = catalog(); - assert!(c.len() >= 5); + // it may be empty (modules are added to it one by one); whatever is in it must be complete for e in &c { assert!(e.description.contains_key("en") && e.description.len() == 5, "{} has all five descriptions", e.id); assert!(e.repo.starts_with("https://github.com/Corfirean/") && e.conf.ends_with(".conf") && e.enable_key.ends_with(".Enable"), "{}", e.id); @@ -196,43 +224,43 @@ mod tests { fn a_module_is_listed_as_installed_with_its_documented_default_until_a_file_exists() { let d = tempfile::tempdir().unwrap(); let (root, _) = server(d.path()); - let wg = list(&root).into_iter().find(|m| m.id == "war-games").unwrap(); + let wg = list_in(&test_catalog(), &root).into_iter().find(|m| m.id == "war-games").unwrap(); assert!(wg.installed && wg.enabled, "the .dist says 1"); - assert!(!list(&root).into_iter().find(|m| m.id == "spellbook").unwrap().installed, "no configuration of that module on this server"); + assert!(!list_in(&test_catalog(), &root).into_iter().find(|m| m.id == "spellbook").unwrap().installed, "no configuration of that module on this server"); } #[test] fn switching_a_module_creates_its_file_saves_the_old_one_and_changes_only_that_line() { let d = tempfile::tempdir().unwrap(); let (root, meta) = server(d.path()); - set_enabled(&root, &meta, "war-games", false).unwrap(); + set_enabled_in(&test_catalog(), &root, &meta, "war-games", false).unwrap(); let conf = fs::read_to_string(dir(&root).join("war_games.conf")).unwrap(); assert!(conf.contains("WarGames.Enable = 0") && conf.contains("WarGames.ChallengeSeconds = 60"), "{conf}"); - assert!(!list(&root).into_iter().find(|m| m.id == "war-games").unwrap().enabled); - set_enabled(&root, &meta, "war-games", false).unwrap(); + assert!(!list_in(&test_catalog(), &root).into_iter().find(|m| m.id == "war-games").unwrap().enabled); + set_enabled_in(&test_catalog(), &root, &meta, "war-games", false).unwrap(); assert_eq!(fs::read_dir(meta.join("backups/modules")).unwrap().count(), 1, "no change, no backup"); - set_enabled(&root, &meta, "war-games", true).unwrap(); + set_enabled_in(&test_catalog(), &root, &meta, "war-games", true).unwrap(); assert!(fs::read_to_string(dir(&root).join("war_games.conf")).unwrap().contains("WarGames.Enable = 1")); - assert!(set_enabled(&root, &meta, "spellbook", true).is_err(), "not part of this server"); - assert!(set_enabled(&root, &meta, "nonsense", true).is_err()); + assert!(set_enabled_in(&test_catalog(), &root, &meta, "spellbook", true).is_err(), "not part of this server"); + assert!(set_enabled_in(&test_catalog(), &root, &meta, "nonsense", true).is_err()); } #[test] fn settings_show_the_documentation_and_only_known_keys_with_one_line_values_can_be_saved() { let d = tempfile::tempdir().unwrap(); let (root, meta) = server(d.path()); - let s = settings(&root, "war-games").unwrap(); + let s = settings_in(&test_catalog(), &root, "war-games").unwrap(); assert_eq!(s.len(), 2); assert_eq!(s[1], Setting { key: "WarGames.ChallengeSeconds".into(), value: "60".into(), default: Some("60".into()), doc: "How long a challenge stays open.".into() }); assert_eq!(s[0].doc, "Turns War Games on. Second line."); let ok = BTreeMap::from([("WarGames.ChallengeSeconds".to_string(), "90".to_string())]); - assert_eq!(save_settings(&root, &meta, "war-games", &ok).unwrap(), ["WarGames.ChallengeSeconds"]); - assert!(settings(&root, "war-games").unwrap()[1].value == "90"); - assert!(save_settings(&root, &meta, "war-games", &ok).unwrap().is_empty(), "same value: nothing to do"); + assert_eq!(save_settings_in(&test_catalog(), &root, &meta, "war-games", &ok).unwrap(), ["WarGames.ChallengeSeconds"]); + assert!(settings_in(&test_catalog(), &root, "war-games").unwrap()[1].value == "90"); + assert!(save_settings_in(&test_catalog(), &root, &meta, "war-games", &ok).unwrap().is_empty(), "same value: nothing to do"); for bad in [("Other.Key", "1"), ("WarGames.ChallengeSeconds", ""), ("WarGames.ChallengeSeconds", "1\nInjected = 1")] { let c = BTreeMap::from([(bad.0.to_string(), bad.1.to_string())]); - assert!(save_settings(&root, &meta, "war-games", &c).is_err(), "{bad:?}"); + assert!(save_settings_in(&test_catalog(), &root, &meta, "war-games", &c).is_err(), "{bad:?}"); } assert!(!fs::read_to_string(dir(&root).join("war_games.conf")).unwrap().contains("Injected")); } diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index c0630fe..2b95287 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -315,25 +315,33 @@ AC>"; } #[test] - fn changes_a_password_and_an_access_level_with_the_expected_commands() { + fn changes_a_password_with_the_expected_command() { let _lock = serial(); let (port, h) = fake_ra("The password was changed"); let mut ra = Ra::connect_to(port, "u", "p").unwrap(); ra.set_account_password("Player1", "newpass9").unwrap(); drop(ra); assert_eq!(h.join().unwrap()[2], "account set password Player1 newpass9 newpass9"); + } + #[test] + fn changes_an_access_level_with_the_expected_command() { + let _lock = serial(); let (port, h) = fake_ra("You have changed security level of Player1 to 2."); let mut ra = Ra::connect_to(port, "u", "p").unwrap(); ra.set_account_access("Player1", 2).unwrap(); assert!(ra.set_account_access("Player1", 4).is_err(), "only 0 to 3"); drop(ra); assert_eq!(h.join().unwrap()[2], "account set gmlevel Player1 2 -1"); + } + #[test] + fn a_failed_account_command_is_reported_and_bad_names_never_reach_the_console() { + let _lock = serial(); let (port, _h) = fake_ra("Account not found."); let mut ra = Ra::connect_to(port, "u", "p").unwrap(); - assert!(ra.set_account_password("Nobody", "newpass9").unwrap_err().to_string().contains("not found")); assert!(ra.set_account_password("x", "newpass9").is_err(), "the name is validated before anything is sent"); + assert!(ra.set_account_password("Nobody", "newpass9").unwrap_err().to_string().contains("not found")); } #[test] diff --git a/modules/catalog.json b/modules/catalog.json index cbfa516..65f1b1d 100644 --- a/modules/catalog.json +++ b/modules/catalog.json @@ -1,131 +1,4 @@ { "schema": 1, - "modules": [ - { - "id": "spellbook", - "name": "Spellbook", - "description": { - "en": "Restores the Books of Ascension as class trainers: right-click a book to see your class abilities by rank and train them for gold.", - "ru": "Возвращает Книги Вознесения как учителей класса: ПКМ по книге показывает умения вашего класса по рангам, их можно изучить за золото.", - "de": "Bringt die Bücher des Aufstiegs als Klassenlehrer zurück: Rechtsklick auf ein Buch zeigt die Fähigkeiten deiner Klasse nach Rang, die du für Gold erlernen kannst.", - "fr": "Rétablit les Livres de l’Ascension comme maîtres de classe : un clic droit sur un livre affiche les capacités de votre classe par rang, à apprendre contre de l’or.", - "es": "Restaura los Libros de Ascensión como instructores de clase: clic derecho en un libro muestra las habilidades de tu clase por rango para aprenderlas por oro." - }, - "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-spellbook", - "conf": "spellbook.conf", - "enable_key": "Spellbook.Enable" - }, - { - "id": "war-games", - "name": "War Games", - "description": { - "en": "Private arena matches between two groups, arranged by Teri Glozilk in Orgrimmar and Stormwind.", - "ru": "Частные матчи на арене между двумя группами: их устраивает Тери Глозилк в Оргриммаре и Штормграде.", - "de": "Private Arenakämpfe zwischen zwei Gruppen, vermittelt von Teri Glozilk in Orgrimmar und Sturmwind.", - "fr": "Matchs d’arène privés entre deux groupes, organisés par Teri Glozilk à Orgrimmar et à Hurlevent.", - "es": "Combates de arena privados entre dos grupos, organizados por Teri Glozilk en Orgrimmar y Ventormenta." - }, - "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-war-games", - "conf": "war_games.conf", - "enable_key": "WarGames.Enable" - }, - { - "id": "item-recovery", - "name": "Item Recovery", - "description": { - "en": "Answers the client's Item Recovery window from the vendor buyback slots: get back an item you just sold, for the price paid.", - "ru": "Отвечает окну «Восстановление предметов» клиента данными выкупа у торговца: можно вернуть недавно проданный предмет за ту же цену.", - "de": "Beantwortet das Fenster „Gegenstandswiederherstellung“ des Clients mit den Rückkauf-Plätzen des Händlers: kürzlich verkaufte Gegenstände zum gezahlten Preis zurückholen.", - "fr": "Répond à la fenêtre de récupération d’objets du client avec les emplacements de rachat du marchand : récupérez un objet vendu au prix payé.", - "es": "Responde a la ventana de recuperación de objetos del cliente con las ranuras de recompra del vendedor: recupera un objeto recién vendido por el precio pagado." - }, - "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-item-recovery", - "conf": "item_recovery.conf", - "enable_key": "ItemRecovery.Enable" - }, - { - "id": "hero-call-board", - "name": "Hero's Call Board", - "description": { - "en": "The Hero's Call Board (Stormwind) and the Warchief's Command Board (Orgrimmar): 47 daily quests in PvE, Profession and PvP categories.", - "ru": "Доска призыва героев (Штормград) и Доска приказов вождя (Оргриммар): 47 ежедневных заданий в категориях PvE, профессии и PvP.", - "de": "Das Heldenruf-Brett (Sturmwind) und das Kriegshäuptlings-Befehlsbrett (Orgrimmar): 47 tägliche Quests in den Kategorien PvE, Beruf und PvP.", - "fr": "Le tableau d’appel des héros (Hurlevent) et le tableau de commandement du chef de guerre (Orgrimmar) : 47 quêtes quotidiennes JcE, métiers et JcJ.", - "es": "El Tablón de Llamada del Héroe (Ventormenta) y el Tablón de Mando del Jefe de Guerra (Orgrimmar): 47 misiones diarias de JcE, profesión y JcJ." - }, - "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-hero-call-board", - "conf": "hero_call_board.conf", - "enable_key": "HeroCallBoard.Enable" - }, - { - "id": "path-to-ascension", - "name": "Path to Ascension", - "description": { - "en": "The tutorial book of the Ascension client: the server side of its tutorials, objectives and rewards.", - "ru": "Обучающая книга клиента Ascension: серверная часть её уроков, целей и наград.", - "de": "Das Tutorialbuch des Ascension-Clients: die Serverseite seiner Tutorials, Ziele und Belohnungen.", - "fr": "Le livre de tutoriels du client Ascension : la partie serveur de ses tutoriels, objectifs et récompenses.", - "es": "El libro de tutoriales del cliente Ascension: la parte del servidor de sus tutoriales, objetivos y recompensas." - }, - "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-path-to-ascension", - "conf": "path_to_ascension.conf", - "enable_key": "PathToAscension.Enable" - }, - { - "id": "craftsmans-codex", - "name": "Craftsman's Codex", - "description": { - "en": "Restores the Craftsman's Codex: a character may learn one more primary profession than the standard two, once per codex used.", - "ru": "Возвращает Кодекс мастера: персонаж может выучить ещё одну основную профессию сверх обычных двух, по разу за использованный кодекс.", - "de": "Bringt den Kodex des Handwerkers zurück: Ein Charakter kann einen weiteren Hauptberuf zusätzlich zu den üblichen zwei erlernen, einmal pro benutztem Kodex.", - "fr": "Rétablit le Codex de l’artisan : un personnage peut apprendre un métier principal de plus que les deux habituels, une fois par codex utilisé.", - "es": "Restaura el Códice del Artesano: un personaje puede aprender una profesión principal más que las dos habituales, una vez por códice usado." - }, - "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-craftsmans-codex", - "conf": "craftsmans_codex.conf", - "enable_key": "CraftsmansCodex.Enable" - }, - { - "id": "coa-prestige", - "name": "Prestige Mode", - "description": { - "en": "Chromie's Prestige Mode: at level 60 a character winds back to level 1 and levels again with an experience bonus and Prestige rewards.", - "ru": "Режим престижа Хроми: на 60 уровне персонаж откатывается на 1 уровень и снова растёт с бонусом к опыту и наградами за престиж.", - "de": "Chromies Prestigemodus: Auf Stufe 60 wird ein Charakter auf Stufe 1 zurückgesetzt und levelt erneut mit Erfahrungsbonus und Prestige-Belohnungen.", - "fr": "Le mode Prestige de Chromie : au niveau 60, un personnage revient au niveau 1 et remonte avec un bonus d’expérience et des récompenses de prestige.", - "es": "El modo Prestigio de Chromie: al nivel 60 un personaje vuelve al nivel 1 y sube de nuevo con bonificación de experiencia y recompensas de prestigio." - }, - "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-coa-prestige", - "conf": "mod-coa-prestige.conf", - "enable_key": "CoAPrestige.Enable" - }, - { - "id": "coa-challenges", - "name": "Challenges", - "description": { - "en": "The Conquest of Azeroth challenges / trials: lives, rules, objectives, completions and rewards behind the client's Challenge window.", - "ru": "Испытания Conquest of Azeroth: жизни, правила, цели, завершения и награды для окна испытаний клиента.", - "de": "Die Herausforderungen / Prüfungen von Conquest of Azeroth: Leben, Regeln, Ziele, Abschlüsse und Belohnungen hinter dem Herausforderungsfenster des Clients.", - "fr": "Les défis / épreuves de Conquest of Azeroth : vies, règles, objectifs, réussites et récompenses derrière la fenêtre de défis du client.", - "es": "Los desafíos / pruebas de Conquest of Azeroth: vidas, reglas, objetivos, finalizaciones y recompensas de la ventana de desafíos del cliente." - }, - "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-coa-challenges", - "conf": "mod-coa-challenges.conf", - "enable_key": "CoAChallenges.Enable" - }, - { - "id": "teleport-actionbar", - "name": "Teleport Action Bar", - "description": { - "en": "Makes CoA teleport tokens usable from the spellbook collection and from an action bar button, and keeps each stone on its own faction's side.", - "ru": "Делает телепорт-жетоны CoA доступными из коллекции книги заклинаний и с кнопки на панели действий и оставляет каждый камень на стороне своей фракции.", - "de": "Macht CoA-Teleportmarken aus der Zauberbuch-Sammlung und über eine Aktionsleisten-Schaltfläche nutzbar und hält jeden Stein auf der Seite seiner Fraktion.", - "fr": "Rend les jetons de téléportation CoA utilisables depuis la collection du grimoire et depuis un bouton de barre d’action, et garde chaque pierre du côté de sa faction.", - "es": "Hace que las fichas de teletransporte de CoA se puedan usar desde la colección del libro de hechizos y desde un botón de la barra de acción, y mantiene cada piedra del lado de su facción." - }, - "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-teleport-actionbar", - "conf": "mod_teleport_actionbar.conf", - "enable_key": "TeleportActionBar.Enable" - } - ] + "modules": [] } diff --git a/package.json b/package.json index d1a0364..3cc4d32 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.3.0", + "version": "0.3.1", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d5f3d23..ad4101a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CoA Server Manager", - "version": "0.3.0", + "version": "0.3.1", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e6beb01..c9280ed 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-45 cursor-pointer", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-45 cursor-pointer", { variants: { variant: { diff --git a/src/components/ui/password-input.tsx b/src/components/ui/password-input.tsx index 04790aa..befeef9 100644 --- a/src/components/ui/password-input.tsx +++ b/src/components/ui/password-input.tsx @@ -12,7 +12,7 @@ export const PasswordInput = React.forwardRef +
)} - {client && } +
+ {client && } {client && clientStatus?.update_available ? ( ) : client === null ? ( - ) : null} +
{anyUp && !running && !transitioning && (

{t("overview.partial")}