feat: portable play settings, improved offline messaging, and system tray support

This commit is contained in:
Дмитрий committed 2026-10-23 08:53:48 +05:00
1 parent a48717340b
commit b3bb0fdc19
12 files changed
+147 -8

No files matched your search

+11
View File
@@ -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",
+1 -1
View File
@@ -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"
+70 -3
View File
@@ -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::<AppState>().portable.clone() {
rt.shutdown();
}
@@ -2896,6 +2962,7 @@ pub fn run() {
rt.shutdown();
}
}
_ => {}
});
}
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -986,7 +986,7 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"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<Record<keyof typeof en, string>> = {
"browse.noCharacter": "У вас пока нет переносимого персонажа. Сделайте его переносимым на странице «Персонажи», чтобы увидеть, сможет ли он играть здесь.",
"browse.canPlay": "Можно играть",
"browse.cannotPlay": "Здесь играть нельзя",
"browse.realmOffline": "Оффлайн",
"browse.offlineWarning": "Сервер сейчас не запущен хостом. Игра переносимым персонажем станет доступна, когда владелец включит сервер.",
"browse.play": "Играть",
"browse.playSoon": "Вход на сервер появится вместе с поддержкой аккаунтов на следующем шаге.",
"registry.title": "Публичный список",
@@ -1101,6 +1103,7 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"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<Record<keyof typeof en, string>> = {
"join.err.unavailable": "Сервер сейчас не смог этого сделать.",
"access.title": "Вход игроков",
"access.text": "Как попадают на этот мир игроки, нашедшие его в списке.",
"access.portableEnabled": "Разрешить вход переносимым персонажам (переносимая игра)",
"access.portableEnabledHint": "Позволяет игрокам с сохранёнными в Менеджере персонажами играть на этом сервере (PortableSession.Enable).",
"access.existingOnly": "Не создавать аккаунты для новых игроков",
"access.existingOnlyHint": "Игроки смогут только привязать аккаунт, который у них уже есть на этом мире.",
"access.route": "Адрес для подключения игроков",
+4
View File
@@ -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."
+4
View File
@@ -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."
+4
View File
@@ -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."
+4
View File
@@ -375,6 +375,10 @@
"t": "Интервал автосохранения",
"d": "Как часто персонажи игроков сохраняются, пока они в сети."
},
"PortableSession.Enable": {
"t": "Переносимые персонажи (переносимая игра)",
"d": "Разрешает игрокам заходить на этот сервер переносимыми персонажами, сохранёнными в их Менеджере."
},
"CharactersPerAccount": {
"t": "Персонажей на учётную запись",
"d": "Наибольшее число персонажей на учётной записи во всех мирах."
+4
View File
@@ -375,6 +375,10 @@
"t": "角色数据自动保存间隔",
"d": "在线玩家角色数据写入数据库的时间间隔。"
},
"PortableSession.Enable": {
"t": "便携式角色(跨服漫游)",
"d": "允许玩家使用保存在管理器中的便携式角色进入此服务器进行游玩。"
},
"CharactersPerAccount": {
"t": "每个账号最大角色数",
"d": "单个账号在所有服务器可创建的角色总数上限。"
+2
View File
@@ -117,6 +117,7 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:
<h2 className="truncate text-lg font-semibold" data-testid="drawer-title">{summary.display_name}</h2>
<p className="mt-0.5 text-sm text-muted"><Mode ruleset={summary.ruleset} /> · {t("browse.cap")} {summary.level_cap ?? "—"}</p>
<p className="text-sm"><Online p={summary.population} />{summary.population.capacity !== null && <span className="text-muted"> · {t("browse.capacity", { n: summary.population.capacity })}</span>}</p>
{!summary.online && <span className="mt-1 inline-block rounded bg-bad/15 px-2 py-0.5 text-xs font-medium text-bad">{t("browse.realmOffline")}</span>}
</div>
<button onClick={onClose} aria-label={t("portable.close")} className="cursor-pointer text-muted hover:text-ink"><X className="h-5 w-5" /></button>
</div>
@@ -180,6 +181,7 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:
<p className="font-medium">{blocked ? t("browse.cannotPlay") : t("browse.canPlay")}</p>
{pre.projection && <Projection from={pre.projection[0]} to={pre.projection[1]} cap={summary.level_cap} />}
<Notes notes={pre.notes} />
{!summary.online && <p className="mt-2 border-t border-line/40 pt-2 text-xs text-warn">{t("browse.offlineWarning")}</p>}
</div>}
</>}
</section>
+31 -2
View File
@@ -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<string | null>(null);
const [existingOnly, setExistingOnly] = useState(false);
const [portableEnabled, setPortableEnabled] = useState(true);
const [route, setRoute] = useState("");
const [accessSaved, setAccessSaved] = useState(false);
const [ctl, setCtl] = useState<ControlStatus | null>(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") && <div className="mt-4 rounded-md border border-line p-3 text-sm" role="status">
<p className={cn("font-medium", tone)} data-state={mine.state}>{t(`registry.state.${mine.state}` as Key)}</p>
<p className="mt-1 text-xs text-muted">{mine.display_name}{mine.metadata_revision ? ` · ${t("registry.revision", { n: mine.metadata_revision })}` : ""}</p>
{mine.state === "realm_stopped" && <p className="mt-2 text-xs text-warn">{t("registry.realmStoppedHint")}</p>}
{mine.last_error && <p className="mt-1 text-xs text-warn">{mine.last_error}</p>}
{mine.state === "retrying" && mine.retry_in_secs !== null && <p className="mt-1 text-xs text-muted">{t("registry.retryIn", { s: mine.retry_in_secs })}</p>}
<div className="mt-3 flex gap-2">
@@ -93,6 +121,7 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server
{mine && (published || mine.state === "unpublishing") && <div className="mt-4 rounded-md border border-line p-3 text-sm" data-testid="access-options">
<p className="font-medium">{t("access.title")}</p>
<p className="mt-1 text-xs text-muted">{t("access.text")}</p>
<label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={portableEnabled} onChange={(e) => setPortableEnabled(e.target.checked)} className="mt-1" data-testid="portable-enabled" /><span>{t("access.portableEnabled")}<span className="block text-xs text-muted">{t("access.portableEnabledHint")}</span></span></label>
<label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={existingOnly} onChange={(e) => setExistingOnly(e.target.checked)} className="mt-1" data-testid="existing-only" /><span>{t("access.existingOnly")}<span className="block text-xs text-muted">{t("access.existingOnlyHint")}</span></span></label>
<label htmlFor="access-route" className="mt-3 block font-medium">{t("access.route")}</label>
<input id="access-route" value={route} placeholder="play.example.org:3724" onChange={(e) => setRoute(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" data-testid="access-route" />