Merge remote-tracking branch 'origin/codex/client-only-mode' into codex/release-all-pending

This commit is contained in:
Дмитрий committed 2026-10-05 03:09:02 +05:00
commit 7c096a4844
19 files changed
+412 -50

No files matched your search

@@ -0,0 +1,7 @@
---
area: manager
type: added
audience: players
title: Download and update the game client without installing a server
---
Choose to host a server or connect to someone else's. Players joining a friend can download, update and launch the client, save the host's address and use the Manager without installing a local server.
+1 -1
View File
@@ -180,7 +180,7 @@ fn backup_dir(meta: &Path) -> PathBuf {
meta.join("backups").join("client")
}
fn host_ok(h: &str) -> bool {
pub fn host_ok(h: &str) -> bool {
!h.is_empty() && h.len() <= 253 && h.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':')) && !h.starts_with('-')
}
+1
View File
@@ -3,6 +3,7 @@ pub mod backup;
pub mod cleanbase;
pub mod companions;
pub mod client;
pub mod remote_client;
pub mod clientdl;
pub mod config;
pub mod console;
+64
View File
@@ -0,0 +1,64 @@
//! A player's client connection, independent of any local server installation.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{client, fsx, Error, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Profile {
pub host: String,
pub client_path: Option<String>,
}
pub fn load(dir: &Path) -> Result<Profile> {
match std::fs::read(dir.join("connection.json")) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Profile::default()),
Err(e) => Err(e.into()),
}
}
pub fn save(dir: &Path, profile: &Profile) -> Result<()> {
if !profile.host.is_empty() && !client::host_ok(&profile.host) {
return Err(Error::Invalid("Enter a server IP address or hostname, without a URL or port.".into()));
}
if let Some(path) = &profile.client_path {
let client = PathBuf::from(path);
if client::detect(&client, None).is_none() {
return Err(Error::Invalid("This folder does not look like a game client (it needs Data and the game executable).".into()));
}
if client::is_running(&client) {
return Err(Error::Invalid("Close the game before changing its connection.".into()));
}
if !profile.host.is_empty() { client::set_realmlist(&client, dir, &profile.host)?; }
}
fsx::atomic_write_json(&dir.join("connection.json"), profile)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connection_needs_no_server_and_survives_restart() {
let dir = tempfile::tempdir().unwrap();
let profile = Profile { host: "192.168.1.10".into(), client_path: None };
save(dir.path(), &profile).unwrap();
assert_eq!(load(dir.path()).unwrap().host, profile.host);
assert!(!dir.path().join("install.json").exists());
}
#[test]
fn linking_client_sets_host_without_touching_other_game_settings() {
let dir = tempfile::tempdir().unwrap();
let game = dir.path().join("game");
std::fs::create_dir_all(game.join("Data/enUS")).unwrap();
std::fs::write(game.join("Wow.exe"), b"fixture").unwrap();
std::fs::write(game.join("Data/enUS/realmlist.wtf"), "set realmlist 127.0.0.1\n").unwrap();
std::fs::create_dir_all(game.join("WTF")).unwrap();
std::fs::write(game.join("WTF/Config.wtf"), "SET realmName \"My realm\"\n").unwrap();
let profile = Profile { host: "192.168.1.10".into(), client_path: Some(game.to_string_lossy().into()) };
save(dir.path(), &profile).unwrap();
assert!(std::fs::read_to_string(game.join("Data/enUS/realmlist.wtf")).unwrap().contains(&profile.host));
assert_eq!(std::fs::read_to_string(game.join("WTF/Config.wtf")).unwrap(), "SET realmName \"My realm\"\n");
assert!(save(dir.path(), &Profile { host: "bad\nset realmlist other".into(), ..profile }).is_err());
assert_eq!(load(dir.path()).unwrap().host, "192.168.1.10");
}
}
+6 -1
View File
@@ -578,6 +578,11 @@ The reference install classifies as **Healthy (customised)**: repack shape, bina
* **UI:** PLAY becomes **UPDATE CLIENT** when a tracked client is outdated (with "Play without updating"); with no client linked it
becomes **Set up game client** (choose an existing folder, or download into `<chosen folder>/CoA Client` with a free-space check).
An existing client that is not tracked can be adopted from Settings > Game client ("Keep this client up to date").
A fresh download gets `realmlist.wtf` for each installed locale (the manifest has none) and the companion addon.
A fresh download gets `realmlist.wtf` for each installed locale (the manifest has none) and, for local hosts, the companion addon.
* **Joining another server:** the welcome screen offers hosting or joining. Joining uses a separate player profile in
the Manager's `remote-client/connection.json`, storing the host address and client folder without a server registry entry,
installation metadata, database or server processes. Only Overview and application Settings appear in the sidebar.
Downloading, adopting and updating clients use the same verified downloader; Play applies the saved realmlist without
forcing a local realm selection. The player can return to hosting without removing their saved connection.
* **Open points:** ask the launcher maintainers before putting heavy load on their API; DBCs live inside the MPQ patch archives
(extracting them would need an MPQ reader), so the server's `Data\dbc` still comes from our `base` package for now.
+99 -31
View File
@@ -929,6 +929,68 @@ async fn add_companions(state: State<'_, AppState>, id: String, count: u32) -> s
.await
}
const REMOTE_CLIENT_ID: &str = "@remote-client";
fn remote_dir() -> PathBuf { data_dir().join("remote-client") }
#[tauri::command]
fn remote_connection() -> std::result::Result<coa_core::remote_client::Profile, UiError> {
Ok(coa_core::remote_client::load(&remote_dir())?)
}
#[tauri::command]
fn remote_connect(state: State<'_, AppState>, host: String) -> std::result::Result<coa_core::remote_client::Profile, UiError> {
let job = state.client_cancel.lock().unwrap();
if job.is_some() { return Err(Error::Invalid("Wait for the client operation to finish.".into()).into()); }
let dir = remote_dir();
let mut profile = coa_core::remote_client::load(&dir)?;
let host = host.trim();
if host.is_empty() { return Err(Error::Invalid("Enter the host's IP address or hostname.".into()).into()); }
profile.host = host.into();
coa_core::remote_client::save(&dir, &profile)?;
Ok(profile)
}
struct ClientContext {
dir: PathBuf,
path: Option<PathBuf>,
source: Option<PathBuf>,
host: String,
root: Option<PathBuf>,
}
fn client_context(state: &AppState, id: &str) -> Result<ClientContext> {
if id == REMOTE_CLIENT_ID {
let dir = remote_dir();
let profile = coa_core::remote_client::load(&dir)?;
return Ok(ClientContext { dir, path: profile.client_path.map(PathBuf::from), source: None, host: profile.host, root: None });
}
let root = path_of(state, id)?;
let (dir, meta) = install_meta(&root)?;
let source = coa_core::client::addon_source(&root);
Ok(ClientContext { dir, path: meta.client_path.map(PathBuf::from), source, host: "127.0.0.1".into(), root: Some(root) })
}
impl ClientContext {
fn linked(&self) -> Option<PathBuf> {
self.path.clone().filter(|p| coa_core::client::detect(p, None).is_some())
}
fn require(&self) -> Result<PathBuf> {
self.linked().ok_or_else(|| Error::Invalid("Set up a game client first.".into()))
}
fn link(&self, path: &str) -> Result<()> {
if let Some(root) = &self.root {
let (_, mut meta) = install_meta(root)?;
meta.client_path = Some(path.into());
coa_core::fsx::atomic_write_json(&self.dir.join("install.json"), &meta)
} else {
let mut profile = coa_core::remote_client::load(&self.dir)?;
profile.client_path = Some(path.into());
coa_core::remote_client::save(&self.dir, &profile)
}
}
}
fn client_of(root: &std::path::Path) -> Result<(PathBuf, InstallMeta, Option<PathBuf>)> {
let (dir, meta) = install_meta(root)?;
let client = meta.client_path.clone().map(PathBuf::from).ok_or_else(|| Error::Invalid("No game client is set up for this server yet.".into()))?;
@@ -937,22 +999,19 @@ fn client_of(root: &std::path::Path) -> Result<(PathBuf, InstallMeta, Option<Pat
#[tauri::command]
fn client_info(state: State<'_, AppState>, id: String) -> std::result::Result<Option<coa_core::client::ClientInfo>, UiError> {
let root = path_of(&state, &id)?;
let (_, meta) = install_meta(&root)?;
let source = coa_core::client::addon_source(&root);
Ok(meta.client_path.and_then(|p| coa_core::client::detect(std::path::Path::new(&p), source.as_deref())))
let ctx = client_context(&state, &id)?;
Ok(ctx.path.and_then(|p| coa_core::client::detect(&p, ctx.source.as_deref())))
}
#[tauri::command]
fn set_client(state: State<'_, AppState>, id: String, path: String) -> std::result::Result<coa_core::client::ClientInfo, UiError> {
let root = path_of(&state, &id)?;
let (dir, mut meta) = install_meta(&root)?;
let source = coa_core::client::addon_source(&root);
let info = coa_core::client::detect(std::path::Path::new(&path), source.as_deref())
let job = state.client_cancel.lock().unwrap();
if job.is_some() { return Err(Error::Invalid("Wait for the client operation to finish.".into()).into()); }
let ctx = client_context(&state, &id)?;
let info = coa_core::client::detect(std::path::Path::new(&path), ctx.source.as_deref())
.ok_or_else(|| Error::Invalid("This folder does not look like a game client (it needs Data and the game executable).".into()))?;
meta.client_path = Some(info.path.clone());
coa_core::fsx::atomic_write_json(&dir.join("install.json"), &meta).map_err(UiError::from)?;
Ok(info)
ctx.link(&info.path)?;
Ok(coa_core::client::detect(std::path::Path::new(&info.path), ctx.source.as_deref()).unwrap_or(info))
}
#[tauri::command]
@@ -991,9 +1050,9 @@ fn linked_client(root: &std::path::Path) -> Result<Option<PathBuf>> {
/// Cheap: one small manifest request and the local state file; no game file is read.
#[tauri::command]
async fn client_status(state: State<'_, AppState>, id: String) -> std::result::Result<ClientStatus, UiError> {
let root = path_of(&state, &id)?;
let ctx = client_context(&state, &id)?;
blocking(move || {
let client = linked_client(&root)?;
let client = ctx.linked();
let latest = coa_core::clientdl::fetch_latest().ok();
let local = client.as_deref().map(coa_core::clientdl::local);
let managed = local.as_ref().map(|l| l.managed).unwrap_or(false);
@@ -1040,10 +1099,10 @@ async fn client_download_check(parent: String) -> std::result::Result<ClientDown
/// Compare the linked client with the published one. Hashes files only where the recorded state cannot vouch for them.
#[tauri::command]
async fn client_plan(app: AppHandle, state: State<'_, AppState>, id: String) -> std::result::Result<coa_core::clientdl::Plan, UiError> {
let root = path_of(&state, &id)?;
let ctx = client_context(&state, &id)?;
let cancel = begin_client_job(&state)?;
let result = blocking(move || {
let client = linked_client(&root)?.ok_or_else(|| Error::Invalid("No game client is set up for this server yet.".into()))?;
let client = ctx.require()?;
let manifest = coa_core::clientdl::fetch_latest()?;
let current = coa_core::clientdl::load_state(&client).unwrap_or_default();
coa_core::clientdl::plan(&client, &manifest, &current, &cancel, &|s| {
@@ -1058,11 +1117,13 @@ async fn client_plan(app: AppHandle, state: State<'_, AppState>, id: String) ->
/// Bring the linked client to the published version. `keep_modified` leaves files the player changed alone.
#[tauri::command]
async fn client_sync(app: AppHandle, state: State<'_, AppState>, id: String, keep_modified: bool) -> std::result::Result<(), UiError> {
let root = path_of(&state, &id)?;
let ctx = client_context(&state, &id)?;
let cancel = begin_client_job(&state)?;
let result = blocking(move || {
let client = linked_client(&root)?.ok_or_else(|| Error::Invalid("No game client is set up for this server yet.".into()))?;
run_client_sync(&app, &client, keep_modified, &cancel)
let client = ctx.require()?;
run_client_sync(&app, &client, keep_modified, &cancel)?;
if ctx.root.is_none() && !ctx.host.is_empty() { coa_core::client::set_realmlist(&client, &ctx.dir, &ctx.host)?; }
Ok(())
})
.await;
end_client_job(&state);
@@ -1086,13 +1147,12 @@ fn run_client_sync(app: &AppHandle, client: &std::path::Path, keep_modified: boo
Ok(())
}
/// Download the whole client into `<parent>/CoA Client`, then link it to this server.
/// Download the client into `<parent>/CoA Client`, then link it to a host or player profile.
#[tauri::command]
async fn client_download(app: AppHandle, state: State<'_, AppState>, id: String, parent: String) -> std::result::Result<coa_core::client::ClientInfo, UiError> {
let root = path_of(&state, &id)?;
let ctx = client_context(&state, &id)?;
let cancel = begin_client_job(&state)?;
let result = blocking(move || {
let (dir, mut meta) = install_meta(&root)?;
let parent = PathBuf::from(&parent);
if !parent.is_dir() {
return Err(Error::Invalid("Choose an existing folder to put the game client in.".into()));
@@ -1105,16 +1165,11 @@ async fn client_download(app: AppHandle, state: State<'_, AppState>, id: String,
}
std::fs::create_dir_all(&dest)?;
run_client_sync(&app, &dest, false, &cancel)?;
let source = coa_core::client::addon_source(&root);
let info = coa_core::client::detect(&dest, source.as_deref()).ok_or_else(|| Error::Invalid("The downloaded client looks incomplete.".into()))?;
meta.client_path = Some(info.path.clone());
coa_core::fsx::atomic_write_json(&dir.join("install.json"), &meta)?;
// a fresh client has no realmlist; this server is on this computer
coa_core::client::set_realmlist(&dest, &dir, "127.0.0.1")?;
if let Some(src) = source {
coa_core::client::install_addon(&dest, &dir, &src)?;
}
Ok(coa_core::client::detect(&dest, coa_core::client::addon_source(&root).as_deref()).unwrap_or(info))
let info = coa_core::client::detect(&dest, ctx.source.as_deref()).ok_or_else(|| Error::Invalid("The downloaded client looks incomplete.".into()))?;
ctx.link(&info.path)?;
if !ctx.host.is_empty() { coa_core::client::set_realmlist(&dest, &ctx.dir, &ctx.host)?; }
if let Some(src) = &ctx.source { coa_core::client::install_addon(&dest, &ctx.dir, src)?; }
Ok(coa_core::client::detect(&dest, ctx.source.as_deref()).unwrap_or(info))
})
.await;
end_client_job(&state);
@@ -1149,6 +1204,17 @@ fn end_client_job(state: &AppState) {
/// Launch the game client independently of the local server.
#[tauri::command]
async fn play(state: State<'_, AppState>, id: String) -> std::result::Result<DriverOutcome, UiError> {
if id == REMOTE_CLIENT_ID {
let ctx = client_context(&state, &id)?;
let client = ctx.require()?;
return blocking(move || {
if ctx.host.is_empty() { return Err(Error::Invalid("Enter the host's IP address or hostname first.".into())); }
if coa_core::client::is_running(&client) { return Err(Error::Invalid("The game is already running.".into())); }
coa_core::client::set_realmlist(&client, &ctx.dir, &ctx.host)?;
coa_core::client::launch(&client)?;
Ok(DriverOutcome { ok: true, exit_code: None, code: None, human: None, output: String::new() })
}).await;
}
let root = path_of(&state, &id)?;
let (dir, _, client) = client_of(&root)?;
let client = client.unwrap();
@@ -1501,6 +1567,8 @@ pub fn run() {
open_link,
companion_sizes,
add_companions,
remote_connection,
remote_connect,
client_info,
set_client,
client_realmlist,
+17 -2
View File
@@ -4,14 +4,20 @@ import { Welcome } from "@/screens/Welcome";
import { ImportServer } from "@/screens/ImportServer";
import { Shell } from "@/screens/Shell";
import { InstallServer } from "@/screens/InstallServer";
import { RemoteClient } from "@/screens/RemoteClient";
import { UpdateBanner } from "@/components/UpdateBanner";
import { autoUpdateEnabled, installOnClose, lookForUpdate } from "@/lib/selfUpdate";
type View = "welcome" | "add" | "import" | "install";
type View = "welcome" | "add" | "import" | "install" | "remote";
function initialView(): View {
try { return localStorage.getItem("coa-play-mode") === "remote" ? "remote" : "welcome"; }
catch { return "welcome"; }
}
function Main() {
const [servers, setServers] = useState<ServerSummary[] | null>(null);
const [view, setView] = useState<View>("welcome");
const [view, setView] = useState<View>(initialView);
const [activeId, setActiveId] = useState<string | null>(null);
const refresh = useCallback(async () => {
@@ -26,6 +32,11 @@ function Main() {
if (servers === null) return null;
if (view === "remote") return <RemoteClient onHost={() => {
try { localStorage.setItem("coa-play-mode", "host"); } catch { /* storage unavailable */ }
setView(servers.length ? "welcome" : "add");
}} />;
if (servers.length > 0 && view === "welcome" && activeId) {
return (
<Shell
@@ -71,6 +82,10 @@ function Main() {
return (
<Welcome
onConnect={() => {
try { localStorage.setItem("coa-play-mode", "remote"); } catch { /* storage unavailable */ }
setView("remote");
}}
onImport={() => setView("import")}
onInstall={() => setView("install")}
onBack={servers.length > 0 ? () => setView("welcome") : undefined}
+5 -1
View File
@@ -10,6 +10,7 @@ export {};
let added = new URLSearchParams(location.search).has("demo"), running = false;
let realm: "coa" | "wildcard" = "coa", wildcardCreated = false;
let simultaneous = false;
let remoteHost = localStorage.getItem("demo-remote-host") ?? "";
const moduleEdits: Record<string, Record<string, string>> = {};
const modState: Record<string, boolean> = { "companions": true, "content-scaling": true, "client-compat": true, "tbc-content": true, "wotlk-content": true, "npc-enchanter": true };
const report = {path:"C:\\games\\CoA-Repack",classification:"healthy",items:[
@@ -30,6 +31,9 @@ export {};
if(cmd==="check_database") return [{realm, migrations:[],problems:[],full_schema:false}];
if(cmd==="repair_server") { if(running) throw new Error("Stop the server before repairing it."); return {restored:[],applied:[],backup:"demo-before-repair",database:[{realm,migrations:[],problems:[],full_schema:false}],error:null}; }
if(cmd==="realm_select"){await new Promise(r=>setTimeout(r,600));realm=args.mode;wildcardCreated ||= realm==="wildcard";modState.companions=realm==="coa";return {active:realm,wildcard_created:wildcardCreated,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:simultaneous&&running};}
if(cmd==="remote_connection") return {host:remoteHost,client_path:null};
if(cmd==="remote_connect") { remoteHost=args.host.trim(); localStorage.setItem("demo-remote-host",remoteHost); return {host:remoteHost,client_path:null}; }
if(cmd==="play") return {ok:true,exit_code:null,code:null,human:null,output:""};
if(cmd==="list_servers") return added?[{id:"1",name:"CoA-Repack",path:report.path}]:[];
if(cmd==="scan_server") return report;
if(cmd==="add_server"){added=true;return {id:"1",name:"CoA-Repack",path:report.path};}
@@ -92,7 +96,7 @@ export {};
if(cmd==="account_set_access"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.access=args.level;return null;}
if(cmd==="account_set_password") return null;
if(cmd==="account_rename"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.name=String(args.newName).toUpperCase();return null;}
if(cmd==="client_info"){const c=(window as any).__client??"none";return c==="none"?null:{path:"C:\\games\\CoA Client",executable:"Ascension.exe",realmlists:[{path:"x",host:"127.0.0.1"}],addon:{installed:true,version:"1",up_to_date:true},other_addons:2};}
if(cmd==="client_info"){const c=(window as any).__client??"none";return c==="none"?null:{path:"C:\\games\\CoA Client",executable:"Ascension.exe",realmlists:[{path:"x",host:args.id==="@remote-client"?remoteHost:"127.0.0.1"}],addon:{installed:true,version:"1",up_to_date:true},other_addons:2};}
if(cmd==="set_client"){(window as any).__client="foreign";return {path:args.path,executable:"Ascension.exe",realmlists:[],addon:{installed:false,version:null,up_to_date:null},other_addons:0};}
if(cmd==="plugin:event|listen"){const w=window as any;(w.__handlers??={})[args.event]=[...(w.__handlers[args.event]??[]),args.handler];return 1;}
if(cmd==="plugin:event|unlisten") return null;
+15
View File
@@ -2,6 +2,21 @@ import type en from "./en";
/** Deutsch. Entwurf einer Übersetzung; vor der Veröffentlichung von Muttersprachlern zu prüfen. */
const de: Partial<Record<keyof typeof en, string>> = {
"remote.clientReady": "Dein Client ist bereit. Speichere die Hostadresse und klicke auf Spielen.",
"remote.clientIntro": "Wähle einen vorhandenen Spielclient oder lade ihn hier herunter. Ein lokaler Server ist nicht nötig.",
"welcome.host.title": "Einen Server hosten",
"welcome.host.text": "Einen neuen Server installieren oder einen vorhandenen verwalten.",
"welcome.connect.title": "Mit anderem Server verbinden",
"welcome.connect.text": "Den Spielclient herunterladen oder aktualisieren und bei Freunden spielen.",
"remote.intro": "Richte deinen Spielclient ein. Dein Freund betreibt den Server.",
"remote.address": "Serveradresse",
"remote.addressHint": "Für LAN-Spiele frage den Host nach der lokalen IP-Adresse seines Computers.",
"remote.save": "Adresse speichern",
"remote.saved": "Serveradresse gespeichert.",
"remote.accountHint": "Bitte den Host, ein Spielkonto für dich anzulegen.",
"remote.hostInstead": "Eigenen Server hosten",
"welcome.roleSubtitle": "Wie möchtest du spielen?",
"nav.overview": "Übersicht",
"nav.bots": "Bots",
"nav.server": "Server",
+15
View File
@@ -1,5 +1,20 @@
/** English is the source of truth: every key must exist here; other locales fall back to it. */
const en = {
"remote.clientReady": "Your client is ready. Save the host’s address, then press Play.",
"remote.clientIntro": "Choose an existing game client or download one here. No local server is needed.",
"welcome.host.title": "Host a server",
"welcome.host.text": "Install a new server or manage one you already have.",
"welcome.connect.title": "Connect to another server",
"welcome.connect.text": "Download or update the game client and play on a friend’s server.",
"remote.intro": "Set up your game client. Your friend runs the server.",
"remote.address": "Server address",
"remote.addressHint": "For LAN play, ask the host for their computer’s local IP address.",
"remote.save": "Save address",
"remote.saved": "Server address saved.",
"remote.accountHint": "Ask the host to create a game account for you.",
"remote.hostInstead": "Host my own server",
"welcome.roleSubtitle": "How would you like to play?",
"realm.profileLabel": "Realm",
"realm.startupTitle": "Realm startup",
"realm.secondWorld": "Second world",
+15
View File
@@ -2,6 +2,21 @@ import type en from "./en";
/** Español. Traducción provisional; debe revisarla un hablante nativo antes de publicarse. */
const es: Partial<Record<keyof typeof en, string>> = {
"remote.clientReady": "Tu cliente está listo. Guarda la dirección del anfitrión y pulsa Jugar.",
"remote.clientIntro": "Elige un cliente existente o descárgalo aquí. No necesitas un servidor local.",
"welcome.host.title": "Alojar un servidor",
"welcome.host.text": "Instalar un servidor nuevo o gestionar uno existente.",
"welcome.connect.title": "Conectarse a otro servidor",
"welcome.connect.text": "Descargar o actualizar el cliente y jugar en el servidor de un amigo.",
"remote.intro": "Configura tu cliente. Tu amigo aloja el servidor.",
"remote.address": "Dirección del servidor",
"remote.addressHint": "Para jugar en LAN, pide al anfitrión la IP local de su ordenador.",
"remote.save": "Guardar dirección",
"remote.saved": "Dirección del servidor guardada.",
"remote.accountHint": "Pide al anfitrión que te cree una cuenta de juego.",
"remote.hostInstead": "Alojar mi servidor",
"welcome.roleSubtitle": "¿Cómo quieres jugar?",
"nav.overview": "Resumen",
"nav.bots": "Bots",
"nav.server": "Servidor",
+15
View File
@@ -2,6 +2,21 @@ import type en from "./en";
/** Français. Traduction provisoire ; à faire relire par un locuteur natif avant la publication. */
const fr: Partial<Record<keyof typeof en, string>> = {
"remote.clientReady": "Votre client est prêt. Enregistrez l’adresse de l’hôte, puis cliquez sur Jouer.",
"remote.clientIntro": "Choisissez un client existant ou téléchargez-le ici. Aucun serveur local n’est nécessaire.",
"welcome.host.title": "Héberger un serveur",
"welcome.host.text": "Installer un serveur ou gérer un serveur existant.",
"welcome.connect.title": "Rejoindre un autre serveur",
"welcome.connect.text": "Télécharger ou mettre à jour le client et jouer sur le serveur d’un ami.",
"remote.intro": "Configurez votre client. Votre ami héberge le serveur.",
"remote.address": "Adresse du serveur",
"remote.addressHint": "Pour jouer en réseau local, demandez l’adresse IP locale de l’ordinateur hôte.",
"remote.save": "Enregistrer l’adresse",
"remote.saved": "Adresse du serveur enregistrée.",
"remote.accountHint": "Demandez à l’hôte de créer un compte de jeu pour vous.",
"remote.hostInstead": "Héberger mon serveur",
"welcome.roleSubtitle": "Comment souhaitez-vous jouer ?",
"nav.overview": "Vue d’ensemble",
"nav.bots": "Bots",
"nav.server": "Serveur",
+15
View File
@@ -2,6 +2,21 @@ import type en from "./en";
/** Русский. Черновой перевод; требует проверки носителем перед релизом. */
const ru: Partial<Record<keyof typeof en, string>> = {
"remote.clientReady": "Клиент готов. Сохраните адрес хоста и нажмите «Играть».",
"remote.clientIntro": "Выберите существующий клиент игры или скачайте его здесь. Локальный сервер не требуется.",
"welcome.host.title": "Хочу быть хостом",
"welcome.host.text": "Установить новый сервер или управлять существующим.",
"welcome.connect.title": "Подключиться к другому серверу",
"welcome.connect.text": "Скачать или обновить клиент и играть на сервере друга.",
"remote.intro": "Настройте игровой клиент. Сервер запускает ваш друг.",
"remote.address": "Адрес сервера",
"remote.addressHint": "Для игры по локальной сети попросите у хоста локальный IP его компьютера.",
"remote.save": "Сохранить адрес",
"remote.saved": "Адрес сервера сохранён.",
"remote.accountHint": "Попросите хоста создать для вас игровой аккаунт.",
"remote.hostInstead": "Создать свой сервер",
"welcome.roleSubtitle": "Как вы хотите играть?",
"realm.profileLabel": "Реалм",
"realm.startupTitle": "Запуск реалмов",
"realm.secondWorld": "Второй мир",
+15
View File
@@ -1,5 +1,20 @@
/** Simplified Chinese localization (zh-CN) */
const zh = {
"remote.clientReady": "客户端已就绪。保存服主地址,然后点击开始游戏。",
"remote.clientIntro": "选择已有游戏客户端,或在此下载。无需安装本地服务器。",
"welcome.host.title": "我要搭建服务器",
"welcome.host.text": "安装新服务器或管理已有服务器。",
"welcome.connect.title": "连接其他服务器",
"welcome.connect.text": "下载或更新游戏客户端,在朋友的服务器上游玩。",
"remote.intro": "设置游戏客户端,服务器由朋友运行。",
"remote.address": "服务器地址",
"remote.addressHint": "在局域网中游玩时,请向服主询问其电脑的局域网 IP 地址。",
"remote.save": "保存地址",
"remote.saved": "服务器地址已保存。",
"remote.accountHint": "请服主为你创建游戏账号。",
"remote.hostInstead": "搭建自己的服务器",
"welcome.roleSubtitle": "你想如何游玩?",
"realm.profileLabel": "服务器",
"realm.switching": "正在切换服务器…",
"realm.create": "创建 Wildcard 服务器",
+5
View File
@@ -1,5 +1,8 @@
import { invoke } from "@tauri-apps/api/core";
export const REMOTE_CLIENT_ID = "@remote-client";
export interface RemoteConnection { host: string; client_path: string | null }
export type Classification = "healthy" | "partial" | "unknown-custom" | "incompatible";
export type ItemStatus = "found" | "missing" | "attention";
@@ -376,6 +379,8 @@ export interface ConsoleLine {
}
export const api = {
remoteConnection: () => invoke<RemoteConnection>("remote_connection"),
remoteConnect: (host: string) => invoke<RemoteConnection>("remote_connect", { host }),
realmProfiles: (id: string) => invoke<RealmProfiles>("realm_profiles", { id }),
realmSelect: (id: string, mode: RealmMode, restart: boolean) => invoke<RealmProfiles>("realm_select", { id, mode, restart }),
realmSimultaneous: (id: string, enabled: boolean) => invoke<RealmProfiles>("realm_simultaneous", { id, enabled }),
+9 -6
View File
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useHuman, useT } from "@/i18n";
export function ClientCard({ serverId }: { serverId: string }) {
export function ClientCard({ serverId, remote = false }: { serverId: string; remote?: boolean }) {
const t = useT();
const human = useHuman();
const [info, setInfo] = useState<ClientInfo | null | undefined>(undefined);
@@ -33,6 +33,7 @@ export function ClientCard({ serverId }: { serverId: string }) {
const msg = await fn();
if (msg) setNote(msg);
await refresh();
void checkClient(serverId);
} catch (e) {
setError(asUiError(e));
} finally {
@@ -54,7 +55,7 @@ export function ClientCard({ serverId }: { serverId: string }) {
{info === undefined && <p className="mt-2 text-sm text-muted">{t("client.looking")}</p>}
{info === null && (
<>
<p className="mt-1 text-sm text-muted">{t("client.intro")}</p>
<p className="mt-1 text-sm text-muted">{t(remote ? "remote.clientIntro" : "client.intro")}</p>
<Button className="mt-4" onClick={() => setDialog("setup")} disabled={busy}>{t("btn.setupClient")}</Button>
</>
)}
@@ -65,7 +66,7 @@ export function ClientCard({ serverId }: { serverId: string }) {
<dt className="text-muted">{t("client.connectsTo")}</dt>
<dd>
{hosts.length ? hosts.filter(Boolean).join(", ") : "—"}
{pointsLocal ? <span className="ml-2 text-ok">{t("client.thisComputer")}</span> : <span className="ml-2 text-warn">{t("client.notThisServer")}</span>}
{!remote && (pointsLocal ? <span className="ml-2 text-ok">{t("client.thisComputer")}</span> : <span className="ml-2 text-warn">{t("client.notThisServer")}</span>)}
</dd>
<dt className="text-muted">{t("client.version")}</dt>
<dd>
@@ -74,27 +75,29 @@ export function ClientCard({ serverId }: { serverId: string }) {
: t("client.versionUntracked")}
{status?.update_available && <span className="ml-2 text-warn">{t("client.updateAvailable")}</span>}
</dd>
{!remote && <>
<dt className="text-muted">{t("client.addon")}</dt>
<dd>
{info.addon.installed ? (info.addon.version ? t("client.installedVersion", { v: info.addon.version }) : t("client.installed")) : t("client.addonNotInstalled")}
{info.addon.up_to_date === false && <span className="ml-2 text-warn">{t("client.updateAvailable")}</span>}
<span className="mt-1 block text-xs text-muted">{t("client.addonWhat")}</span>
</dd>
</>}
<dt className="text-muted">{t("client.otherAddons")}</dt>
<dd>{t("client.leftAsIs", { n: info.other_addons })}</dd>
</dl>
<div className="mt-4 flex flex-wrap gap-2">
{!pointsLocal && (
{!remote && !pointsLocal && (
<Button size="sm" disabled={busy} onClick={() => void run(async () => { await api.setRealmlist(serverId, "127.0.0.1"); return t("client.pointedNote"); })}>
{t("client.pointLocal")}
</Button>
)}
{(!info.addon.installed || info.addon.up_to_date === false) && (
{!remote && (!info.addon.installed || info.addon.up_to_date === false) && (
<Button size="sm" disabled={busy} onClick={() => void run(async () => { await api.installAddon(serverId); return t("client.addonNote"); })}>
{info.addon.installed ? t("client.updateAddon") : t("client.installAddon")}
</Button>
)}
{status?.update_available && (
{(status?.update_available || (remote && status?.managed)) && (
<Button size="sm" variant="primary" disabled={busy} onClick={() => setDialog("update")}>{t("client.upd.start")}</Button>
)}
{status && !status.managed && (
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { listen } from "@tauri-apps/api/event";
import { Check, Loader2 } from "lucide-react";
import { api, asUiError, type ClientDownloadCheck, type ClientPlan, type ClientStep, type UiError } from "@/lib/api";
import { api, asUiError, REMOTE_CLIENT_ID, type ClientDownloadCheck, type ClientPlan, type ClientStep, type UiError } from "@/lib/api";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -261,7 +261,7 @@ export function ClientDialog(props: {
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-ok/15 text-ok"><Check className="h-5 w-5" aria-hidden /></span>
<div>
<p className="font-medium">{mode === "setup" ? t("client.dl.done") : t("client.upd.done")}</p>
<p className="text-sm text-muted">{mode === "setup" ? t("client.dl.doneText") : t("client.upd.doneText")}</p>
<p className="text-sm text-muted">{mode === "setup" ? t(serverId === REMOTE_CLIENT_ID ? "remote.clientReady" : "client.dl.doneText") : t("client.upd.doneText")}</p>
</div>
</div>
<div className="mt-5 flex justify-end">
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { Gauge, Settings } from "lucide-react";
import { api, asUiError, REMOTE_CLIENT_ID } from "@/lib/api";
import { checkClient, startClientPolling, useClientStatus } from "@/lib/clientUpdate";
import { ClientCard } from "@/screens/ClientCard";
import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog";
import { AboutCard } from "@/screens/AboutCard";
import { LanguagePicker } from "@/components/LanguagePicker";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useHuman, useT } from "@/i18n";
import { cn } from "@/lib/utils";
import logo from "@/assets/logo.png";
export function RemoteClient({ onHost }: { onHost: () => void }) {
const t = useT();
const human = useHuman();
const [page, setPage] = useState<"overview" | "settings">("overview");
const [host, setHost] = useState("");
const [savedHost, setSavedHost] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
const [dialog, setDialog] = useState<ClientDialogMode | null>(null);
const { status } = useClientStatus(REMOTE_CLIENT_ID);
function fail(e: unknown) {
const err = asUiError(e);
setError(err.human.code === "unknown" ? err.technical : human(err.human).message);
}
useEffect(() => {
void api.remoteConnection().then(p => { setHost(p.host); setSavedHost(p.host); }).catch(fail).finally(() => setLoading(false));
return startClientPolling(REMOTE_CLIENT_ID);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { void checkClient(REMOTE_CLIENT_ID); }, [revision]);
async function save() {
setBusy(true); setError(null); setNote(null);
try {
const profile = await api.remoteConnect(host);
setHost(profile.host); setSavedHost(profile.host); setRevision(n => n + 1);
setNote(t("remote.saved"));
} catch (e) { fail(e); } finally { setBusy(false); }
}
async function launch() {
setBusy(true); setError(null); setNote(null);
try { await api.play(REMOTE_CLIENT_ID); } catch (e) { fail(e); } finally { setBusy(false); }
}
function play() {
if (!status?.linked) setDialog("setup");
else if (status.update_available) setDialog("update");
else void launch();
}
return <div className="flex h-full">
<nav className="flex w-60 shrink-0 flex-col border-r border-line bg-[#0b0c0e] p-3" aria-label={t("nav.main")}>
<div className="flex items-center gap-2 px-3 pb-4 pt-2 text-sm font-semibold tracking-wide text-gold"><img src={logo} alt="" className="h-7 w-7" />{t("app.name")}</div>
<ul className="flex flex-col gap-0.5">
{(["overview", "settings"] as const).map(id => { const Icon = id === "overview" ? Gauge : Settings; return <li key={id}>
<button onClick={() => setPage(id)} aria-current={page === id ? "page" : undefined} className={cn("flex h-10 w-full cursor-pointer items-center gap-3 rounded-md px-3 text-left text-[15px]", page === id ? "bg-white/[0.07] text-ink" : "text-muted hover:bg-white/5 hover:text-ink")}><Icon className="h-[18px] w-[18px]" aria-hidden />{t(id === "overview" ? "nav.overview" : "nav.settings")}</button>
</li>; })}
</ul>
<div className="mt-auto border-t border-line pt-3"><Button variant="ghost" size="sm" className="w-full justify-start" onClick={onHost}>{t("remote.hostInstead")}</Button></div>
</nav>
<main className="h-full flex-1 overflow-y-auto px-10 py-8">
{page === "settings" ? <><div className="flex items-center justify-between"><h1 className="text-2xl font-semibold">{t("nav.settings")}</h1><LanguagePicker /></div><AboutCard /></> : <>
<div className="flex items-center justify-between"><h1 className="text-2xl font-semibold">{t("welcome.connect.title")}</h1><LanguagePicker /></div>
<p className="mt-2 text-muted">{t("remote.intro")}</p>
<Card className="mt-6 p-6">
<form onSubmit={e => { e.preventDefault(); void save(); }}>
<label htmlFor="remote-host" className="font-semibold">{t("remote.address")}</label>
<p id="remote-host-hint" className="mt-1 text-sm text-muted">{t("remote.addressHint")}</p>
<div className="mt-4 flex gap-3"><input id="remote-host" aria-describedby="remote-host-hint" value={host} onChange={e => { setHost(e.target.value); setNote(null); }} disabled={loading || busy} placeholder="192.168.1.10" className="min-w-0 flex-1 rounded-md border border-line bg-card px-3 py-2" /><Button type="submit" disabled={loading || busy || !host.trim()}>{t("remote.save")}</Button></div>
</form>
<p className="mt-3 text-sm text-muted">{t("remote.accountHint")}</p>
<Button variant="primary" className="mt-4" disabled={loading || busy || !savedHost || host.trim() !== savedHost || status === null} onClick={play}>{t("btn.play")}</Button>
{note && <p className="mt-3 text-sm text-ok" role="status">{note}</p>}
{error && <p className="mt-3 text-sm text-bad" role="alert">{error}</p>}
</Card>
<ClientCard key={revision} serverId={REMOTE_CLIENT_ID} remote />
</>}
</main>
{dialog && <ClientDialog serverId={REMOTE_CLIENT_ID} mode={dialog} onClose={() => setDialog(null)} onChanged={() => setRevision(n => n + 1)} onPlayAnyway={() => void launch()} />}
</div>;
}
+21 -6
View File
@@ -1,20 +1,34 @@
import { FolderSearch, Download } from "lucide-react";
import { useState } from "react";
import { FolderSearch, Download, Server, Globe } from "lucide-react";
import { useT } from "@/i18n";
import logo from "@/assets/logo.png";
import { LanguagePicker } from "@/components/LanguagePicker";
export function Welcome({ onImport, onInstall, onBack }: { onImport: () => void; onInstall: () => void; onBack?: () => void }) {
export function Welcome({ onImport, onInstall, onConnect, onBack }: { onImport: () => void; onInstall: () => void; onConnect: () => void; onBack?: () => void }) {
const t = useT();
const [hosting, setHosting] = useState(false);
return (
<main className="relative flex h-full flex-col items-center justify-center px-8">
<LanguagePicker className="absolute right-6 top-5" />
<div className="mb-10 text-center">
<img src={logo} alt="" aria-hidden className="mx-auto mb-5 h-24 w-24 drop-shadow-[0_6px_24px_rgb(201_162_74/0.35)]" />
<h1 className="text-3xl font-semibold tracking-tight">{t("welcome.title")}</h1>
<p className="mt-2 text-muted">{t("welcome.subtitle")}</p>
<p className="mt-2 text-muted">{t(hosting ? "welcome.subtitle" : "welcome.roleSubtitle")}</p>
</div>
<div className="grid w-full max-w-3xl grid-cols-2 gap-5">
{!hosting ? <>
<button onClick={() => setHosting(true)} className="cursor-pointer rounded-card border border-line bg-card/90 p-7 text-left transition-colors hover:border-gold/60">
<Server className="mb-4 h-7 w-7 text-gold" aria-hidden />
<h2 className="text-lg font-semibold">{t("welcome.host.title")}</h2>
<p className="mt-1 text-sm text-muted">{t("welcome.host.text")}</p>
</button>
<button onClick={onConnect} className="cursor-pointer rounded-card border border-line bg-card/90 p-7 text-left transition-colors hover:border-gold/60">
<Globe className="mb-4 h-7 w-7 text-gold" aria-hidden />
<h2 className="text-lg font-semibold">{t("welcome.connect.title")}</h2>
<p className="mt-1 text-sm text-muted">{t("welcome.connect.text")}</p>
</button>
</> : <>
<button
onClick={onInstall}
className="cursor-pointer rounded-card border border-line bg-card/90 p-7 text-left shadow-[0_8px_30px_rgb(0_0_0/0.28)] transition-colors hover:border-gold/60"
@@ -34,10 +48,11 @@ export function Welcome({ onImport, onInstall, onBack }: { onImport: () => void;
{t("welcome.import.text")}
</p>
</button>
</>}
</div>
{onBack && (
<button onClick={onBack} className="mt-8 cursor-pointer text-sm text-muted hover:text-ink">
{t("welcome.back")}
{(hosting || onBack) && (
<button onClick={hosting ? () => setHosting(false) : onBack} className="mt-8 cursor-pointer text-sm text-muted hover:text-ink">
{t(hosting ? "client.dl.back" : "welcome.back")}
</button>
)}
</main>