From c39a7a0ac7afdab163cd4a56f7c554876c7192e0 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: Mon, 5 Oct 2026 01:22:35 +0500 Subject: [PATCH 1/2] Let players download and update clients without a local server --- .../20261005-connect-without-server.md | 7 + crates/coa-core/src/client.rs | 2 +- crates/coa-core/src/lib.rs | 1 + crates/coa-core/src/remote_client.rs | 64 +++++++++ src-tauri/src/lib.rs | 130 +++++++++++++----- src/App.tsx | 19 ++- src/dev/mock.ts | 6 +- src/i18n/locales/de.ts | 15 ++ src/i18n/locales/en.ts | 15 ++ src/i18n/locales/es.ts | 15 ++ src/i18n/locales/fr.ts | 15 ++ src/i18n/locales/ru.ts | 15 ++ src/i18n/locales/zh.ts | 15 ++ src/lib/api.ts | 5 + src/screens/ClientCard.tsx | 15 +- src/screens/ClientDialog.tsx | 4 +- src/screens/RemoteClient.tsx | 85 ++++++++++++ src/screens/Welcome.tsx | 27 +++- 18 files changed, 406 insertions(+), 49 deletions(-) create mode 100644 changelog.d/20261005-connect-without-server.md create mode 100644 crates/coa-core/src/remote_client.rs create mode 100644 src/screens/RemoteClient.tsx diff --git a/changelog.d/20261005-connect-without-server.md b/changelog.d/20261005-connect-without-server.md new file mode 100644 index 0000000..21f8de9 --- /dev/null +++ b/changelog.d/20261005-connect-without-server.md @@ -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. diff --git a/crates/coa-core/src/client.rs b/crates/coa-core/src/client.rs index 3b55269..564e7a9 100644 --- a/crates/coa-core/src/client.rs +++ b/crates/coa-core/src/client.rs @@ -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('-') } diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index a3b0dc1..a49e7d4 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -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; diff --git a/crates/coa-core/src/remote_client.rs b/crates/coa-core/src/remote_client.rs new file mode 100644 index 0000000..d8473ba --- /dev/null +++ b/crates/coa-core/src/remote_client.rs @@ -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, +} + +pub fn load(dir: &Path) -> Result { + 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"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 09e4e83..fc3b271 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { + Ok(coa_core::remote_client::load(&remote_dir())?) +} + +#[tauri::command] +fn remote_connect(state: State<'_, AppState>, host: String) -> std::result::Result { + 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, + source: Option, + host: String, + root: Option, +} + +fn client_context(state: &AppState, id: &str) -> Result { + 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 { + self.path.clone().filter(|p| coa_core::client::detect(p, None).is_some()) + } + fn require(&self) -> Result { + 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)> { 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, id: String) -> std::result::Result, 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 { - 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> { /// 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 { - 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, id: String) -> std::result::Result { - 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, ¤t, &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 `/CoA Client`, then link it to this server. +/// Download the client into `/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 { - 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 { + 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, diff --git a/src/App.tsx b/src/App.tsx index 3018e18..5475158 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); - const [view, setView] = useState("welcome"); + const [view, setView] = useState(initialView); const [activeId, setActiveId] = useState(null); const refresh = useCallback(async () => { @@ -26,6 +32,11 @@ function Main() { if (servers === null) return null; + if (view === "remote") return { + try { localStorage.setItem("coa-play-mode", "host"); } catch { /* storage unavailable */ } + setView(servers.length ? "welcome" : "add"); + }} />; + if (servers.length > 0 && view === "welcome" && activeId) { return ( { + 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} diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 74d1a79..435710b 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -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> = {}; const modState: Record = { "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; diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index cef95ac..feb7f7d 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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> = { + "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", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 19228b5..f325a59 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index e06b4af..bf19888 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -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> = { + "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", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 1701abc..0d2b4ee 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -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> = { + "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", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index ce6f0f3..267c6c3 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -2,6 +2,21 @@ import type en from "./en"; /** Русский. Черновой перевод; требует проверки носителем перед релизом. */ const ru: Partial> = { + "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": "Второй мир", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 13a3d62..0c26d5d 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -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 服务器", diff --git a/src/lib/api.ts b/src/lib/api.ts index 450c320..5865a5b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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("remote_connection"), + remoteConnect: (host: string) => invoke("remote_connect", { host }), realmProfiles: (id: string) => invoke("realm_profiles", { id }), realmSelect: (id: string, mode: RealmMode, restart: boolean) => invoke("realm_select", { id, mode, restart }), realmSimultaneous: (id: string, enabled: boolean) => invoke("realm_simultaneous", { id, enabled }), diff --git a/src/screens/ClientCard.tsx b/src/screens/ClientCard.tsx index 040010e..a2c2b7f 100644 --- a/src/screens/ClientCard.tsx +++ b/src/screens/ClientCard.tsx @@ -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(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 &&

{t("client.looking")}

} {info === null && ( <> -

{t("client.intro")}

+

{t(remote ? "remote.clientIntro" : "client.intro")}

)} @@ -65,7 +66,7 @@ export function ClientCard({ serverId }: { serverId: string }) {
{t("client.connectsTo")}
{hosts.length ? hosts.filter(Boolean).join(", ") : "—"} - {pointsLocal ? {t("client.thisComputer")} : {t("client.notThisServer")}} + {!remote && (pointsLocal ? {t("client.thisComputer")} : {t("client.notThisServer")})}
{t("client.version")}
@@ -74,27 +75,29 @@ export function ClientCard({ serverId }: { serverId: string }) { : t("client.versionUntracked")} {status?.update_available && {t("client.updateAvailable")}}
+ {!remote && <>
{t("client.addon")}
{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 && {t("client.updateAvailable")}} {t("client.addonWhat")}
+ }
{t("client.otherAddons")}
{t("client.leftAsIs", { n: info.other_addons })}
- {!pointsLocal && ( + {!remote && !pointsLocal && ( )} - {(!info.addon.installed || info.addon.up_to_date === false) && ( + {!remote && (!info.addon.installed || info.addon.up_to_date === false) && ( )} - {status?.update_available && ( + {(status?.update_available || (remote && status?.managed)) && ( )} {status && !status.managed && ( diff --git a/src/screens/ClientDialog.tsx b/src/screens/ClientDialog.tsx index f54e627..a77e8d2 100644 --- a/src/screens/ClientDialog.tsx +++ b/src/screens/ClientDialog.tsx @@ -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: {

{mode === "setup" ? t("client.dl.done") : t("client.upd.done")}

-

{mode === "setup" ? t("client.dl.doneText") : t("client.upd.doneText")}

+

{mode === "setup" ? t(serverId === REMOTE_CLIENT_ID ? "remote.clientReady" : "client.dl.doneText") : t("client.upd.doneText")}

diff --git a/src/screens/RemoteClient.tsx b/src/screens/RemoteClient.tsx new file mode 100644 index 0000000..cd3a325 --- /dev/null +++ b/src/screens/RemoteClient.tsx @@ -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(null); + const [note, setNote] = useState(null); + const [revision, setRevision] = useState(0); + const [dialog, setDialog] = useState(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
+ +
+ {page === "settings" ? <>

{t("nav.settings")}

: <> +

{t("welcome.connect.title")}

+

{t("remote.intro")}

+ +
{ e.preventDefault(); void save(); }}> + +

{t("remote.addressHint")}

+
{ 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" />
+
+

{t("remote.accountHint")}

+ + {note &&

{note}

} + {error &&

{error}

} +
+ + } +
+ {dialog && setDialog(null)} onChanged={() => setRevision(n => n + 1)} onPlayAnyway={() => void launch()} />} +
; +} diff --git a/src/screens/Welcome.tsx b/src/screens/Welcome.tsx index 1e2664e..5b9d4e8 100644 --- a/src/screens/Welcome.tsx +++ b/src/screens/Welcome.tsx @@ -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 (

{t("welcome.title")}

-

{t("welcome.subtitle")}

+

{t(hosting ? "welcome.subtitle" : "welcome.roleSubtitle")}

+ {!hosting ? <> + + + : <> + }
- {onBack && ( - )}
From f05f94ba012df0ccfcd446f5e61922d3cfed5e49 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: Mon, 5 Oct 2026 01:24:01 +0500 Subject: [PATCH 2/2] Document standalone client connections --- docs/ARCHITECTURE.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c83fc52..cff05a6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -573,6 +573,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 `/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.