From b3bb0fdc19a1dbd44ad43d41eb81e8e5c3170587 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, 23 Oct 2026 08:53:48 +0500 Subject: [PATCH] feat: portable play settings, improved offline messaging, and system tray support --- schemas/server.json | 11 ++++++ src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 73 +++++++++++++++++++++++++++++++++++-- src/i18n/locales/en.ts | 7 +++- src/i18n/locales/ru.ts | 7 +++- src/i18n/schema/de.json | 4 ++ src/i18n/schema/es.json | 4 ++ src/i18n/schema/fr.json | 4 ++ src/i18n/schema/ru.json | 4 ++ src/i18n/schema/zh.json | 4 ++ src/screens/BrowsePage.tsx | 2 + src/screens/PublishCard.tsx | 33 ++++++++++++++++- 12 files changed, 147 insertions(+), 8 deletions(-) diff --git a/schemas/server.json b/schemas/server.json index b8bc720..a794486 100644 --- a/schemas/server.json +++ b/schemas/server.json @@ -263,6 +263,17 @@ "max": 3600000, "unit": "ms" }, + { + "key": "PortableSession.Enable", + "type": "bool", + "category": "characters", + "title": "Portable characters", + "description": "Allow players to play on this realm with portable characters saved in their Manager.", + "default": true, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, { "key": "CharactersPerAccount", "type": "int", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b0cf1ac..6479bb9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-dialog = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 15eae79..70217c3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2729,6 +2729,8 @@ async fn console_command( .await } +static QUIT_REQUESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + pub fn run() { let dir = data_dir(); let _ = coa_core::logging::init(&dir.join("logs").join("manager.log")); @@ -2881,11 +2883,75 @@ pub fn run() { control_cmds::join_characters, control_cmds::join_claim ]) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window.hide(); + } + }) + .setup(|app| { + #[cfg(desktop)] + { + use tauri::menu::{Menu, MenuItem}; + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + use tauri::Manager; + + let show = MenuItem::with_id(app, "show", "Open CoA Server Manager", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Exit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &quit])?; + + let mut builder = TrayIconBuilder::new() + .tooltip("CoA Server Manager") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } + "quit" => { + QUIT_REQUESTED.store(true, std::sync::atomic::Ordering::SeqCst); + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } + }); + + if let Some(icon) = app.default_window_icon() { + builder = builder.icon(icon.clone()); + } + + builder.build(app)?; + } + Ok(()) + }) .build(tauri::generate_context!()) .expect("error while building CoA Server Manager") - .run(|app, event| { - // the Host loop stops with the application: what it is doing is finished first, the stores keep every session - if let tauri::RunEvent::Exit = event { + .run(|app, event| match event { + tauri::RunEvent::ExitRequested { api, .. } => { + if !QUIT_REQUESTED.load(std::sync::atomic::Ordering::SeqCst) { + api.prevent_exit(); + } + } + tauri::RunEvent::Exit => { + // the Host loop stops with the application: what it is doing is finished first, the stores keep every session if let Some(rt) = app.state::().portable.clone() { rt.shutdown(); } @@ -2896,6 +2962,7 @@ pub fn run() { rt.shutdown(); } } + _ => {} }); } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 10a28fd..f27fa8e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1004,7 +1004,7 @@ const en = { "portable.note.blocked_ruleset": "This character is for another kind of realm.", "portable.note.blocked_format": "This realm reads another version of the character format. Update the Manager or the realm.", "portable.note.blocked_online_import": "This realm cannot take characters while it runs. Update the realm.", - "portable.note.blocked_sessions": "This realm cannot follow a game session. Ask its owner to enable portable play.", + "portable.note.blocked_sessions": "This realm cannot follow a game session. The server or host's Server Manager is offline, or portable play is not enabled.", "portable.note.blocked_client_data": "The game data the Manager sees is not the data the realm loaded.", "portable.note.blocked_progression": "The realm’s level cap is not known yet or cannot be handled. Start the realm and try again.", "portable.note.realm_offline": "The realm cannot be reached.", @@ -1099,6 +1099,8 @@ const en = { "browse.noCharacter": "You have no portable character yet. Make one portable on the Characters page to see whether it can play here.", "browse.canPlay": "Can play", "browse.cannotPlay": "Cannot play here", + "browse.realmOffline": "Offline", + "browse.offlineWarning": "The server is currently offline. Joining with a portable character will become available once the host starts the server.", "browse.play": "Play", "browse.playSoon": "Joining arrives with the account support of the next step.", "registry.title": "Public listing", @@ -1119,6 +1121,7 @@ const en = { "registry.state.starting": "Starting to publish…", "registry.state.waiting_for_realm": "Waiting for the realm to be ready…", "registry.state.realm_stopped": "The realm is not running; it is listed as offline.", + "registry.realmStoppedHint": "The realm is currently stopped. Start the server so players can join and track character sessions.", "registry.state.online": "Published and online.", "registry.state.retrying": "The Registry could not be reached; trying again. Your realm is not affected.", "registry.state.rejected": "The Registry refused this realm. Publishing is stopped.", @@ -1178,6 +1181,8 @@ const en = { "join.err.unavailable": "The server could not do that right now.", "access.title": "Joining players", "access.text": "How players who find this realm in the list get in.", + "access.portableEnabled": "Allow portable characters (portable play)", + "access.portableEnabledHint": "Allows players with Manager-saved characters to play on this realm (PortableSession.Enable).", "access.existingOnly": "Do not create accounts for new players", "access.existingOnlyHint": "Players can only link an account they already have on this realm.", "access.route": "Address players connect to", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index d92939f..34a14b9 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -986,7 +986,7 @@ const ru: Partial> = { "portable.note.blocked_ruleset": "Этот персонаж — для реалма другого типа.", "portable.note.blocked_format": "Реалм читает другую версию формата персонажа. Обновите Менеджер или реалм.", "portable.note.blocked_online_import": "Этот реалм не принимает персонажей, пока работает. Обновите реалм.", - "portable.note.blocked_sessions": "Этот реалм не может следить за игровой сессией. Попросите владельца включить переносимую игру.", + "portable.note.blocked_sessions": "Этот реалм не может следить за игровой сессией. Сервер или Server Manager хоста выключен, либо в ядре не включена переносимая игра.", "portable.note.blocked_client_data": "Игровые данные, которые видит Менеджер, не те, что загрузил реалм.", "portable.note.blocked_progression": "Предел уровня реалма пока неизвестен или не поддерживается. Запустите реалм и попробуйте снова.", "portable.note.realm_offline": "Реалм недоступен.", @@ -1081,6 +1081,8 @@ const ru: Partial> = { "browse.noCharacter": "У вас пока нет переносимого персонажа. Сделайте его переносимым на странице «Персонажи», чтобы увидеть, сможет ли он играть здесь.", "browse.canPlay": "Можно играть", "browse.cannotPlay": "Здесь играть нельзя", + "browse.realmOffline": "Оффлайн", + "browse.offlineWarning": "Сервер сейчас не запущен хостом. Игра переносимым персонажем станет доступна, когда владелец включит сервер.", "browse.play": "Играть", "browse.playSoon": "Вход на сервер появится вместе с поддержкой аккаунтов на следующем шаге.", "registry.title": "Публичный список", @@ -1101,6 +1103,7 @@ const ru: Partial> = { "registry.state.starting": "Начинаю публикацию…", "registry.state.waiting_for_realm": "Жду, пока реалм будет готов…", "registry.state.realm_stopped": "Реалм не запущен; в списке он отмечен как недоступный.", + "registry.realmStoppedHint": "Сервер сейчас выключен. Запустите реалм, чтобы игроки могли подключаться и отслеживать сессии персонажей.", "registry.state.online": "Опубликован и доступен.", "registry.state.retrying": "Реестр недоступен, пробую снова. На ваш реалм это не влияет.", "registry.state.rejected": "Реестр отклонил этот реалм. Публикация остановлена.", @@ -1160,6 +1163,8 @@ const ru: Partial> = { "join.err.unavailable": "Сервер сейчас не смог этого сделать.", "access.title": "Вход игроков", "access.text": "Как попадают на этот мир игроки, нашедшие его в списке.", + "access.portableEnabled": "Разрешить вход переносимым персонажам (переносимая игра)", + "access.portableEnabledHint": "Позволяет игрокам с сохранёнными в Менеджере персонажами играть на этом сервере (PortableSession.Enable).", "access.existingOnly": "Не создавать аккаунты для новых игроков", "access.existingOnlyHint": "Игроки смогут только привязать аккаунт, который у них уже есть на этом мире.", "access.route": "Адрес для подключения игроков", diff --git a/src/i18n/schema/de.json b/src/i18n/schema/de.json index 075054e..fead6f8 100644 --- a/src/i18n/schema/de.json +++ b/src/i18n/schema/de.json @@ -375,6 +375,10 @@ "t": "Autospeicher-Intervall", "d": "Wie oft Spielercharaktere gespeichert werden, solange sie online sind." }, + "PortableSession.Enable": { + "t": "Portable Charaktere (portables Spiel)", + "d": "Erlaubt Spielern, mit ihren im Manager gespeicherten portablen Charakteren auf diesem Realm zu spielen." + }, "CharactersPerAccount": { "t": "Charaktere pro Konto", "d": "Maximale Zahl an Charakteren, die ein Konto über alle Realms hat." diff --git a/src/i18n/schema/es.json b/src/i18n/schema/es.json index 11dab15..66f6f2c 100644 --- a/src/i18n/schema/es.json +++ b/src/i18n/schema/es.json @@ -375,6 +375,10 @@ "t": "Intervalo de autoguardado", "d": "Con qué frecuencia se guardan los personajes de los jugadores mientras están conectados." }, + "PortableSession.Enable": { + "t": "Personajes portátiles (juego portátil)", + "d": "Permite a los jugadores jugar en este reino con personajes portátiles guardados en su Manager." + }, "CharactersPerAccount": { "t": "Personajes por cuenta", "d": "Máximo de personajes que puede tener una cuenta entre todos los reinos." diff --git a/src/i18n/schema/fr.json b/src/i18n/schema/fr.json index b399d7b..5edfc9f 100644 --- a/src/i18n/schema/fr.json +++ b/src/i18n/schema/fr.json @@ -375,6 +375,10 @@ "t": "Intervalle de sauvegarde automatique", "d": "Fréquence de sauvegarde des personnages des joueurs pendant qu’ils sont en ligne." }, + "PortableSession.Enable": { + "t": "Personnages portables (jeu portable)", + "d": "Permet aux joueurs de jouer sur ce royaume avec des personnages portables enregistrés dans leur Manager." + }, "CharactersPerAccount": { "t": "Personnages par compte", "d": "Nombre maximal de personnages qu’un compte peut avoir sur tous les royaumes." diff --git a/src/i18n/schema/ru.json b/src/i18n/schema/ru.json index fbd248c..be8a365 100644 --- a/src/i18n/schema/ru.json +++ b/src/i18n/schema/ru.json @@ -375,6 +375,10 @@ "t": "Интервал автосохранения", "d": "Как часто персонажи игроков сохраняются, пока они в сети." }, + "PortableSession.Enable": { + "t": "Переносимые персонажи (переносимая игра)", + "d": "Разрешает игрокам заходить на этот сервер переносимыми персонажами, сохранёнными в их Менеджере." + }, "CharactersPerAccount": { "t": "Персонажей на учётную запись", "d": "Наибольшее число персонажей на учётной записи во всех мирах." diff --git a/src/i18n/schema/zh.json b/src/i18n/schema/zh.json index c38a478..5bddf6c 100644 --- a/src/i18n/schema/zh.json +++ b/src/i18n/schema/zh.json @@ -375,6 +375,10 @@ "t": "角色数据自动保存间隔", "d": "在线玩家角色数据写入数据库的时间间隔。" }, + "PortableSession.Enable": { + "t": "便携式角色(跨服漫游)", + "d": "允许玩家使用保存在管理器中的便携式角色进入此服务器进行游玩。" + }, "CharactersPerAccount": { "t": "每个账号最大角色数", "d": "单个账号在所有服务器可创建的角色总数上限。" diff --git a/src/screens/BrowsePage.tsx b/src/screens/BrowsePage.tsx index 988d612..c5f12ef 100644 --- a/src/screens/BrowsePage.tsx +++ b/src/screens/BrowsePage.tsx @@ -117,6 +117,7 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:

{summary.display_name}

· {t("browse.cap")} {summary.level_cap ?? "—"}

{summary.population.capacity !== null && · {t("browse.capacity", { n: summary.population.capacity })}}

+ {!summary.online && {t("browse.realmOffline")}} @@ -180,6 +181,7 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:

{blocked ? t("browse.cannotPlay") : t("browse.canPlay")}

{pre.projection && } + {!summary.online &&

{t("browse.offlineWarning")}

} } } diff --git a/src/screens/PublishCard.tsx b/src/screens/PublishCard.tsx index 3118a47..5255db0 100644 --- a/src/screens/PublishCard.tsx +++ b/src/screens/PublishCard.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { useT, type Key } from "@/i18n"; -import { asUiError } from "@/lib/api"; +import { asUiError, api } from "@/lib/api"; import { realmRegistry, useRegistryStatus } from "@/lib/registry"; import { asControlError, control, type ControlStatus } from "@/lib/control"; @@ -24,11 +24,24 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [existingOnly, setExistingOnly] = useState(false); + const [portableEnabled, setPortableEnabled] = useState(true); const [route, setRoute] = useState(""); const [accessSaved, setAccessSaved] = useState(false); const [ctl, setCtl] = useState(null); const link = ctl?.hosting.find((h) => h.local_id === localId)?.link; + useEffect(() => { + let active = true; + void api.allSettings(serverId).then((items) => { + if (!active) return; + const item = items.find((i) => i.key === "PortableSession.Enable"); + if (item) { + setPortableEnabled(item.value === "1" || item.value.toLowerCase() === "true"); + } + }).catch(() => {}); + return () => { active = false; }; + }, [serverId]); + useEffect(() => { const load = () => void control.status().then(setCtl).catch(() => {}); load(); @@ -39,7 +52,21 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server async function saveAccess() { setBusy(true); setError(null); setAccessSaved(false); - try { await control.setAccess(localId, existingOnly, route.trim() === "" ? null : route.trim()); await refresh(); setAccessSaved(true); } catch (e) { setError(asControlError(e).message); } finally { setBusy(false); } + try { + await Promise.all([ + control.setAccess(localId, existingOnly, route.trim() === "" ? null : route.trim()), + api.allSettingsSave(serverId, { + "PortableSession.Enable": portableEnabled ? "1" : "0", + ...(portableEnabled ? { "CharacterDatabase.WorkerThreads": "1" } : {}), + }), + ]); + await refresh(); + setAccessSaved(true); + } catch (e) { + setError(asControlError(e).message); + } finally { + setBusy(false); + } } useEffect(() => { @@ -83,6 +110,7 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server {mine && (published || mine.state === "unpublishing") &&

{t(`registry.state.${mine.state}` as Key)}

{mine.display_name}{mine.metadata_revision ? ` · ${t("registry.revision", { n: mine.metadata_revision })}` : ""}

+ {mine.state === "realm_stopped" &&

{t("registry.realmStoppedHint")}

} {mine.last_error &&

{mine.last_error}

} {mine.state === "retrying" && mine.retry_in_secs !== null &&

{t("registry.retryIn", { s: mine.retry_in_secs })}

}
@@ -93,6 +121,7 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server {mine && (published || mine.state === "unpublishing") &&

{t("access.title")}

{t("access.text")}

+ setRoute(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" data-testid="access-route" />