mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-12 04:01:01 +00:00
Phase 12: Coordinator deployment files, join/claim commands and UI, access options for published realms
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
71e1488336
commit
2caa687490
18 files changed
+856
-8
No files matched your search
Generated
+1
@@ -644,6 +644,7 @@ dependencies = [
|
||||
name = "coa-server-manager"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"coa-control-proto",
|
||||
"coa-core",
|
||||
"coa-registry-proto",
|
||||
"serde",
|
||||
|
||||
@@ -93,6 +93,8 @@ pub struct RealmPublishStatus {
|
||||
pub description: String,
|
||||
pub language: String,
|
||||
pub region: Option<String>,
|
||||
pub existing_only: bool,
|
||||
pub route: Option<String>,
|
||||
pub metadata_revision: Option<u64>,
|
||||
pub last_ok_unix: Option<i64>,
|
||||
pub last_error: Option<String>,
|
||||
@@ -284,6 +286,25 @@ impl RegistryHost {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How joining players reach this realm: whether it creates accounts for them, and an address a game client can use right now. The realm has to have been published
|
||||
/// before (the choices live with its publishing settings); they take effect at the next heartbeat and on the control link at once.
|
||||
pub fn set_access(&mut self, local_id: &str, existing_only: bool, route: Option<&str>) -> Result<()> {
|
||||
let route = route.map(str::trim).filter(|r| !r.is_empty()).map(settings::validate_route).transpose()?;
|
||||
let Some(cfg) = self.settings.realms.get_mut(local_id) else { return Err(Error::Invalid("Publish this realm first.".into())) };
|
||||
let before = cfg.clone();
|
||||
cfg.existing_only = existing_only;
|
||||
cfg.route = route;
|
||||
let cfg = cfg.clone();
|
||||
if let Err(e) = self.save() {
|
||||
self.settings.realms.insert(local_id.to_string(), before);
|
||||
return Err(e);
|
||||
}
|
||||
if let Some(p) = self.publishers.get_mut(local_id) {
|
||||
p.cfg = cfg;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop publishing: the realm is unpublished at the Registry (retried in the background); its identity is kept for a later republication.
|
||||
pub fn unpublish(&mut self, local_id: &str, now: Instant) -> Result<()> {
|
||||
let Some(cfg) = self.settings.realms.get_mut(local_id) else { return Ok(()) };
|
||||
@@ -327,6 +348,8 @@ impl RegistryHost {
|
||||
description: p.cfg.description.clone(),
|
||||
language: p.cfg.language.clone(),
|
||||
region: p.cfg.region.clone(),
|
||||
existing_only: p.cfg.existing_only,
|
||||
route: p.cfg.route.clone(),
|
||||
metadata_revision: p.revision,
|
||||
last_ok_unix: p.last_ok,
|
||||
last_error: p.last_error.clone(),
|
||||
|
||||
@@ -16,6 +16,7 @@ enum Cmd {
|
||||
SetUrl(Option<String>, Sender<Result<()>>),
|
||||
Publish { local_id: String, name: String, description: String, language: String, region: Option<String>, reply: Sender<Result<()>> },
|
||||
Unpublish(String, Sender<Result<()>>),
|
||||
SetAccess { local_id: String, existing_only: bool, route: Option<String>, reply: Sender<Result<()>> },
|
||||
Retry(String),
|
||||
Shutdown,
|
||||
}
|
||||
@@ -50,6 +51,9 @@ impl RegistryRuntime {
|
||||
Ok(Cmd::Unpublish(local_id, reply)) => {
|
||||
let _ = reply.send(host.unpublish(&local_id, Instant::now()));
|
||||
}
|
||||
Ok(Cmd::SetAccess { local_id, existing_only, route, reply }) => {
|
||||
let _ = reply.send(host.set_access(&local_id, existing_only, route.as_deref()));
|
||||
}
|
||||
Ok(Cmd::Retry(local_id)) => host.retry(&local_id, Instant::now()),
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
@@ -85,6 +89,10 @@ impl RegistryRuntime {
|
||||
self.ask(|reply| Cmd::Unpublish(local_id.into(), reply))
|
||||
}
|
||||
|
||||
pub fn set_access(&self, local_id: &str, existing_only: bool, route: Option<&str>) -> Result<()> {
|
||||
self.ask(|reply| Cmd::SetAccess { local_id: local_id.into(), existing_only, route: route.map(str::to_string), reply })
|
||||
}
|
||||
|
||||
pub fn retry(&self, local_id: &str) {
|
||||
let _ = self.tx.send(Cmd::Retry(local_id.into()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Build context: the repository root. docker build -f services/coa-coordinator/Dockerfile -t coa-coordinator:<tag> .
|
||||
FROM rust:1-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY crates/coa-registry-proto crates/coa-registry-proto
|
||||
COPY crates/coa-control-proto crates/coa-control-proto
|
||||
COPY services/coa-coordinator services/coa-coordinator
|
||||
WORKDIR /src/services/coa-coordinator
|
||||
RUN cargo build --release --locked --bin coa-coordinator
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN useradd --system --uid 10002 --user-group --no-create-home --shell /usr/sbin/nologin coa
|
||||
COPY --from=build /src/services/coa-coordinator/target/release/coa-coordinator /usr/local/bin/coa-coordinator
|
||||
USER 10002:10002
|
||||
EXPOSE 8081
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["/usr/local/bin/coa-coordinator", "--healthcheck"]
|
||||
ENTRYPOINT ["/usr/local/bin/coa-coordinator"]
|
||||
@@ -0,0 +1,37 @@
|
||||
# /opt/coa/coordinator/compose.yaml (Phase 12). The Coordinator publishes no host port: Caddy reaches it as coa-coordinator:8081 on coa-ingress. It reads the realms' public
|
||||
# keys from the Registry's database with a role that can read four columns and nothing else. It keeps nothing on disk and writes no database row.
|
||||
name: coa-coordinator
|
||||
services:
|
||||
coordinator:
|
||||
image: coa-coordinator:${COORDINATOR_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
COORD_LISTEN: "0.0.0.0:8081"
|
||||
COORD_TRUST_PROXY: "1"
|
||||
COORD_DB_HOST: coa-postgres
|
||||
COORD_DB_NAME: coa_registry
|
||||
COORD_DB_USER: coa_coordinator
|
||||
COORD_DB_PASSWORD_FILE: /run/secrets/coordinator_db_password
|
||||
RUST_LOG: "info"
|
||||
secrets:
|
||||
- coordinator_db_password
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
mem_limit: 256m
|
||||
pids_limit: 128
|
||||
networks:
|
||||
coa-ingress:
|
||||
aliases:
|
||||
- coa-coordinator
|
||||
coa-db: {}
|
||||
secrets:
|
||||
coordinator_db_password:
|
||||
file: /opt/coa/secrets/coordinator_db_password
|
||||
networks:
|
||||
coa-ingress:
|
||||
external: true
|
||||
coa-db:
|
||||
external: true
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Build the Coordinator image on the node from the working tree and (re)start it. Usage (from the repository root):
|
||||
# VPS=ubuntu@<VPS_IP> TAG=p12a sh services/coa-coordinator/deploy/deploy.sh
|
||||
# The database role must exist (provision-db.sh) and /opt/coa/coordinator/compose.yaml must be in place (copied here on the first run).
|
||||
set -eu
|
||||
: "${VPS:?set VPS=user@host}" "${TAG:?set TAG=<image tag>}"
|
||||
tar --exclude=target -czf /tmp/coordinator-src.tgz crates/coa-registry-proto crates/coa-control-proto services/coa-coordinator .dockerignore
|
||||
scp -q /tmp/coordinator-src.tgz "$VPS:/tmp/coordinator-src.tgz"
|
||||
scp -q services/coa-coordinator/deploy/coordinator.compose.yaml "$VPS:/tmp/coordinator.compose.yaml"
|
||||
scp -q services/coa-coordinator/deploy/provision-db.sh "$VPS:/tmp/coordinator-provision-db.sh"
|
||||
ssh "$VPS" "set -e
|
||||
sudo mkdir -p /opt/coa/coordinator && sudo cp /tmp/coordinator.compose.yaml /opt/coa/coordinator/compose.yaml
|
||||
sudo sh /tmp/coordinator-provision-db.sh
|
||||
sudo rm -rf /opt/coa/coordinator/src && sudo mkdir -p /opt/coa/coordinator/src && sudo tar -xzf /tmp/coordinator-src.tgz -C /opt/coa/coordinator/src
|
||||
cd /opt/coa/coordinator/src && sudo docker build -q -f services/coa-coordinator/Dockerfile -t coa-coordinator:$TAG . >/dev/null
|
||||
cd /opt/coa/coordinator && echo COORDINATOR_TAG=$TAG | sudo tee .env >/dev/null && sudo docker compose up -d
|
||||
sleep 8 && sudo docker ps --format '{{.Names}} {{.Status}}'"
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Create the Coordinator's database role (idempotent). Run on the node as root: sudo sh provision-db.sh
|
||||
# The role may read exactly four columns of one table. The password is read from /opt/coa/secrets/coordinator_db_password (created here when missing) and goes to psql on stdin.
|
||||
set -eu
|
||||
umask 077
|
||||
if [ ! -s /opt/coa/secrets/coordinator_db_password ]; then
|
||||
head -c 32 /dev/urandom | base64 | tr -d '/+=\n' | head -c 40 > /opt/coa/secrets/coordinator_db_password
|
||||
fi
|
||||
chown root:10002 /opt/coa/secrets/coordinator_db_password
|
||||
chmod 0440 /opt/coa/secrets/coordinator_db_password
|
||||
PW=$(cat /opt/coa/secrets/coordinator_db_password)
|
||||
docker exec -i coa-postgres-postgres-1 sh -c 'PGPASSWORD=$(cat /run/secrets/postgres_password) exec psql -U coa -d coa_registry -v ON_ERROR_STOP=1 -q' <<SQL
|
||||
DO \$\$ BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'coa_coordinator') THEN
|
||||
CREATE ROLE coa_coordinator LOGIN PASSWORD '$PW' NOSUPERUSER NOCREATEDB NOCREATEROLE CONNECTION LIMIT 8;
|
||||
ELSE
|
||||
ALTER ROLE coa_coordinator PASSWORD '$PW';
|
||||
END IF;
|
||||
END \$\$;
|
||||
REVOKE ALL ON DATABASE coa_registry FROM coa_coordinator;
|
||||
GRANT CONNECT ON DATABASE coa_registry TO coa_coordinator;
|
||||
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM coa_coordinator;
|
||||
GRANT USAGE ON SCHEMA public TO coa_coordinator;
|
||||
GRANT SELECT (realm_id, public_key, published, advert_version) ON realms TO coa_coordinator;
|
||||
SQL
|
||||
echo "coordinator role is in place"
|
||||
@@ -21,5 +21,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
# Phase 12: the control plane. WebSocket connections are proxied as they are; the Coordinator rate-limits by the address Caddy saw.
|
||||
handle /coord/* {
|
||||
reverse_proxy coa-coordinator:8081 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
transport http {
|
||||
dial_timeout 3s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
respond "not found" 404
|
||||
}
|
||||
@@ -21,4 +21,5 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
coa-core = { path = "../crates/coa-core" }
|
||||
coa-registry-proto = { path = "../crates/coa-registry-proto" }
|
||||
coa-control-proto = { path = "../crates/coa-control-proto" }
|
||||
tracing = "0.1"
|
||||
@@ -0,0 +1,312 @@
|
||||
//! The control plane in the interface (Phase 12): joining a realm (an account made or reused, the game started against the realm's address), linking an account that
|
||||
//! already exists, claiming a legacy character, and the owner's choices for their own realm. The work is in `coa_core::control`; this file only connects it to the app.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use coa_core::control::backend::LocalBackend;
|
||||
use coa_core::control::host_manager::{HostControl, HostControlRealm};
|
||||
use coa_core::control::join::{ControlFail, Credentials, PlayerControl, RealmTarget};
|
||||
use coa_core::control::secrets;
|
||||
use coa_core::control::store::ControlStore;
|
||||
use coa_core::portable::service::{self as portable_service, PortableRuntime, ServiceError};
|
||||
use coa_core::realm_registry::{FileKeyStore, KeyStore};
|
||||
use coa_core::registry::Registry;
|
||||
use coa_registry_proto::RealmId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use super::{data_dir, remote_dir, AppState, REMOTE_CLIENT_ID};
|
||||
|
||||
fn fail(code: &str, message: impl Into<String>) -> ServiceError {
|
||||
ServiceError { code: code.into(), message: message.into(), notes: vec![] }
|
||||
}
|
||||
|
||||
fn from_control(e: ControlFail) -> ServiceError {
|
||||
fail(&e.code(), e.to_string())
|
||||
}
|
||||
|
||||
fn registry_url() -> Result<String, ServiceError> {
|
||||
let settings = coa_core::realm_registry::settings::load(&data_dir().join("registry")).map_err(|e| fail("registry", e.to_string()))?;
|
||||
std::env::var("COA_REGISTRY_URL").ok().filter(|u| !u.is_empty()).or(settings.url).ok_or_else(|| fail("registry", "Enter the Registry address first."))
|
||||
}
|
||||
|
||||
fn player(state: &State<'_, AppState>) -> Result<Arc<PlayerControl>, ServiceError> {
|
||||
state.control.clone().ok_or_else(|| fail("stopped", "The server connection service could not start; see the Manager's log."))
|
||||
}
|
||||
|
||||
fn realm_id(text: &str) -> Result<RealmId, ServiceError> {
|
||||
RealmId::parse(text).map_err(|e| fail("invalid", e.to_string()))
|
||||
}
|
||||
|
||||
/// The folder of the control plane's own state: `control/player` and `control/host` (databases) and `secrets` (protected).
|
||||
fn control_dir() -> PathBuf {
|
||||
data_dir().join("control")
|
||||
}
|
||||
|
||||
/// Start both halves: the Player's operations and the Host's links. Either may be missing without stopping the Manager.
|
||||
pub(crate) fn start(dir: &std::path::Path, portable: Option<Arc<PortableRuntime>>) -> (Option<Arc<PlayerControl>>, Option<Arc<HostControl>>) {
|
||||
let base = dir.join("control");
|
||||
let secrets: Arc<dyn secrets::SecretStore> = Arc::from(secrets::default_store(&dir.join("secrets")));
|
||||
let player = match PlayerControl::open(&base.join("player"), secrets) {
|
||||
Ok(p) => Some(Arc::new(p)),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "the player control service did not start");
|
||||
None
|
||||
}
|
||||
};
|
||||
let host = portable.and_then(|portable| {
|
||||
let store = match ControlStore::open(&base.join("host")) {
|
||||
Ok(s) => Arc::new(Mutex::new(s)),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "the host control service did not start");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let installs_reg = Registry::at(dir.join("installs.json"));
|
||||
let backend = LocalBackend::new(portable, dir.join("registry"), dir.join("portable").join("realms"), Arc::new(move || installs_reg.list().unwrap_or_default()));
|
||||
let keys: Arc<dyn KeyStore> = Arc::new(FileKeyStore::new(dir.join("registry").join("keys")));
|
||||
let url = std::env::var("COA_REGISTRY_URL").ok().filter(|u| !u.is_empty());
|
||||
Some(Arc::new(HostControl::start(dir.join("registry"), keys, store, Arc::new(backend), url)))
|
||||
});
|
||||
(player, host)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct ControlStatus {
|
||||
secret_store: String,
|
||||
player_id: Option<String>,
|
||||
hosting: Vec<HostControlRealm>,
|
||||
preferred_username: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct Preferences {
|
||||
#[serde(default)]
|
||||
preferred_username: String,
|
||||
}
|
||||
|
||||
fn prefs_file() -> PathBuf {
|
||||
control_dir().join("preferences.json")
|
||||
}
|
||||
|
||||
fn load_prefs() -> Preferences {
|
||||
std::fs::read(prefs_file()).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn control_status(state: State<'_, AppState>) -> ControlStatus {
|
||||
ControlStatus {
|
||||
secret_store: state.control.as_ref().map(|c| c.secret_store_kind().to_string()).unwrap_or_default(),
|
||||
player_id: state.control.as_ref().map(|c| c.identity.player_id.to_string()),
|
||||
hosting: state.host_control.as_ref().map(|h| h.status()).unwrap_or_default(),
|
||||
preferred_username: load_prefs().preferred_username,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn control_set_preferred_username(name: String) -> Result<String, ServiceError> {
|
||||
let trimmed = name.trim();
|
||||
let clean = if trimmed.is_empty() { String::new() } else { coa_control_proto::app::sanitize_username(trimmed) };
|
||||
std::fs::create_dir_all(control_dir()).map_err(|e| fail("other", e.to_string()))?;
|
||||
let bytes = serde_json::to_vec(&Preferences { preferred_username: clean.clone() }).map_err(|e| fail("other", e.to_string()))?;
|
||||
coa_core::fsx::atomic_write(&prefs_file(), &bytes).map_err(|e| fail("other", e.to_string()))?;
|
||||
Ok(clean)
|
||||
}
|
||||
|
||||
/// How joining players reach a realm this Manager publishes: whether it creates their accounts, and the address a game client can use right now.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn registry_set_access(state: State<'_, AppState>, local_id: String, existing_only: bool, route: Option<String>) -> Result<(), ServiceError> {
|
||||
let rt = state.realm_registry.clone().ok_or_else(|| fail("stopped", "the publishing service did not start"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || rt.set_access(&local_id, existing_only, route.as_deref()).map_err(|e| fail("invalid", e.to_string())))
|
||||
.await
|
||||
.map_err(|e| fail("other", e.to_string()))?
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct JoinOutcome {
|
||||
/// `ready` (the game can start), `launched`, `needs_link`, `needs_relay`, `needs_client`, `incompatible`, `needs_transfer`.
|
||||
status: String,
|
||||
username: Option<String>,
|
||||
account_created: bool,
|
||||
password_reset: bool,
|
||||
route: Option<String>,
|
||||
notes: Vec<portable_service::Note>,
|
||||
}
|
||||
|
||||
impl JoinOutcome {
|
||||
fn stop(status: &str, username: Option<String>) -> Self {
|
||||
Self { status: status.into(), username, account_created: false, password_reset: false, route: None, notes: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
fn target_of(url: &str, rid: &RealmId) -> Result<(RealmTarget, coa_registry_proto::RealmDetail), ServiceError> {
|
||||
let client = coa_core::realm_registry::BrowseClient::new(url).map_err(|e| fail("registry", e.to_string()))?;
|
||||
let detail = client.detail(rid).map_err(|e| fail("registry", e.to_string()))?;
|
||||
let target = RealmTarget::from_detail(url, &detail).map_err(|e| fail("registry", e.to_string()))?;
|
||||
Ok((target, detail))
|
||||
}
|
||||
|
||||
/// Join a server: an account is made on it (or the one the player has is reused), the character is checked against the server, and when the server can be reached
|
||||
/// the game starts against it. Everything that is not possible yet is reported as a status, not as a failure.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn join_realm(state: State<'_, AppState>, realm: String, character: Option<String>, launch: bool) -> Result<JoinOutcome, ServiceError> {
|
||||
let pc = player(&state)?;
|
||||
let rid = realm_id(&realm)?;
|
||||
let prefer = Some(load_prefs().preferred_username).filter(|n| !n.is_empty());
|
||||
let character_for_check = character.clone();
|
||||
let portable = state.portable.clone();
|
||||
let mut outcome = tauri::async_runtime::spawn_blocking(move || -> Result<JoinOutcome, ServiceError> {
|
||||
let url = registry_url()?;
|
||||
let (target, detail) = target_of(&url, &rid)?;
|
||||
let mut ch = pc.connect(&target).map_err(from_control)?;
|
||||
let (_automatic, existing_only, route) = pc.welcome(&mut ch).map_err(from_control)?;
|
||||
if pc.credentials(&rid).map_err(|e| fail("other", e.to_string()))?.is_none() && existing_only {
|
||||
return Ok(JoinOutcome::stop("needs_link", None));
|
||||
}
|
||||
let account = pc.ensure_account(&mut ch, &rid, prefer.as_deref()).map_err(from_control)?;
|
||||
ch.close();
|
||||
let mut out = JoinOutcome { status: "ready".into(), username: Some(account.username), account_created: account.created, password_reset: account.password_reset, route: route.clone(), notes: vec![] };
|
||||
if let Some(character) = character_for_check {
|
||||
let portable = portable.ok_or_else(|| fail("stopped", "The portable play service could not start."))?;
|
||||
let caps = serde_json::to_value(&detail.capabilities).map_err(|e| fail("other", e.to_string()))?;
|
||||
let id = character.clone();
|
||||
let pre = portable.call(move |s| s.preflight_advert(&id, &caps))?.map_err(|e| e)?;
|
||||
out.notes = pre.notes.clone();
|
||||
if pre.step == portable_service::Step::Blocked {
|
||||
out.status = "incompatible".into();
|
||||
return Ok(out);
|
||||
}
|
||||
let here = pc.claimed_here(&rid).map_err(from_control)?;
|
||||
if !here.iter().any(|(id, _)| id.to_string() == character) {
|
||||
// the character is not from this server: putting it there needs the Host to receive it, which the control channel does not carry yet
|
||||
out.status = "needs_transfer".into();
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
if route.is_none() {
|
||||
out.status = "needs_relay".into();
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| fail("other", e.to_string()))??;
|
||||
|
||||
if outcome.status == "ready" {
|
||||
let host = outcome.route.clone().unwrap_or_default();
|
||||
let client_ready = {
|
||||
let profile = coa_core::remote_client::load(&remote_dir()).map_err(|e| fail("other", e.to_string()))?;
|
||||
profile.client_path.as_deref().is_some_and(|p| coa_core::client::detect(std::path::Path::new(p), None).is_some())
|
||||
};
|
||||
if !client_ready {
|
||||
outcome.status = "needs_client".into();
|
||||
} else if launch {
|
||||
let dir = remote_dir();
|
||||
let mut profile = coa_core::remote_client::load(&dir).map_err(|e| fail("other", e.to_string()))?;
|
||||
profile.host = host;
|
||||
coa_core::remote_client::save(&dir, &profile).map_err(|e| fail("launch", e.to_string()))?;
|
||||
super::play(state, REMOTE_CLIENT_ID.to_string()).await.map_err(|e| fail("launch", e.technical))?;
|
||||
outcome.status = "launched".into();
|
||||
}
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Link an account the server already has (one time). The name and password go to the server's Manager inside the encrypted channel and are checked against the server's own
|
||||
/// login records; they are then kept in the protected store.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn join_link(state: State<'_, AppState>, realm: String, login: String, password: String) -> Result<String, ServiceError> {
|
||||
let pc = player(&state)?;
|
||||
let rid = realm_id(&realm)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let (target, _) = target_of(®istry_url()?, &rid)?;
|
||||
let mut ch = pc.connect(&target).map_err(from_control)?;
|
||||
let name = pc.link_existing(&mut ch, &rid, login.trim(), &password).map_err(from_control)?;
|
||||
ch.close();
|
||||
Ok(name)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| fail("other", e.to_string()))?
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct AccountView {
|
||||
username: String,
|
||||
kind: String,
|
||||
has_password: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn join_account(state: State<'_, AppState>, realm: String) -> Result<Option<AccountView>, ServiceError> {
|
||||
let pc = player(&state)?;
|
||||
let rid = realm_id(&realm)?;
|
||||
let known = pc.known_account(&rid).map_err(from_control)?;
|
||||
let has = pc.credentials(&rid).map_err(|e| fail("other", e.to_string()))?.is_some();
|
||||
Ok(known.map(|(username, kind)| AccountView { username, kind: kind.as_str().into(), has_password: has }))
|
||||
}
|
||||
|
||||
/// The login the game asks for. Only on the player's explicit request ("Show" / "Copy"); never logged.
|
||||
#[tauri::command]
|
||||
pub(crate) fn join_credentials(state: State<'_, AppState>, realm: String) -> Result<Option<Credentials>, ServiceError> {
|
||||
let pc = player(&state)?;
|
||||
pc.credentials(&realm_id(&realm)?).map_err(|e| fail("other", e.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn join_forget(state: State<'_, AppState>, realm: String) -> Result<(), ServiceError> {
|
||||
player(&state)?.forget(&realm_id(&realm)?).map_err(from_control)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct RemoteCharacter {
|
||||
name: String,
|
||||
class: u32,
|
||||
race: u32,
|
||||
level: u32,
|
||||
eligible: bool,
|
||||
reasons: Vec<String>,
|
||||
yours: bool,
|
||||
}
|
||||
|
||||
/// "Characters on this server": the characters of the player's account on a realm, read by that realm's Manager.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn join_characters(state: State<'_, AppState>, realm: String) -> Result<Vec<RemoteCharacter>, ServiceError> {
|
||||
let pc = player(&state)?;
|
||||
let rid = realm_id(&realm)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let (target, _) = target_of(®istry_url()?, &rid)?;
|
||||
let mut ch = pc.connect(&target).map_err(from_control)?;
|
||||
let list = pc.list_characters(&mut ch).map_err(from_control)?;
|
||||
ch.close();
|
||||
Ok(list.into_iter().map(|c| RemoteCharacter { name: c.name, class: c.class, race: c.race, level: c.level, eligible: c.eligible, reasons: c.reasons, yours: c.yours }).collect())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| fail("other", e.to_string()))?
|
||||
}
|
||||
|
||||
/// "Make portable": the realm's Manager checks, over the encrypted channel, that the character belongs to the player's account on that realm, that the account is at the character
|
||||
/// selection screen and that the character is offline and eligible; it exports the character and the player's Manager stores it as revision 1 of a portable character.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn join_claim(state: State<'_, AppState>, realm: String, name: String) -> Result<portable_service::CharacterView, ServiceError> {
|
||||
let pc = player(&state)?;
|
||||
let rid = realm_id(&realm)?;
|
||||
let portable = state.portable.clone().ok_or_else(|| fail("stopped", "The portable play service could not start."))?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let (target, _) = target_of(®istry_url()?, &rid)?;
|
||||
let mut ch = pc.connect(&target).map_err(from_control)?;
|
||||
let list = pc.list_characters(&mut ch).map_err(from_control)?;
|
||||
// the character's name is what the player chose; a name is unique on a realm
|
||||
let _ = list.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).ok_or_else(|| fail("not_yours", "That character is not on your account on this server."))?;
|
||||
let token = list.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).map(|c| c.token).unwrap_or(0);
|
||||
let claimed = pc.claim(&mut ch, token).map_err(from_control)?;
|
||||
let server_id = format!("rr-{rid}");
|
||||
let cid = coa_core::portable::ids::CharacterId::from_uuid(claimed.character_id).map_err(|e| fail("other", e.to_string()))?;
|
||||
let (payload, hash, collections) = (claimed.payload.clone(), claimed.sha256, claimed.collections.clone());
|
||||
let view = portable.call(move |s| s.adopt_claim(&server_id, cid, &payload, &hash, &collections))??;
|
||||
pc.acknowledge(&mut ch, claimed.character_id, &rid, &view.name).map_err(from_control)?;
|
||||
ch.close();
|
||||
Ok(view)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| fail("other", e.to_string()))?
|
||||
}
|
||||
+28
-3
@@ -23,6 +23,8 @@ use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
mod control_cmds;
|
||||
|
||||
struct AppState {
|
||||
registry: Registry,
|
||||
/// Installation ids with a start/stop currently running (one action at a time per server).
|
||||
@@ -35,6 +37,10 @@ struct AppState {
|
||||
portable: Option<Arc<PortableRuntime>>,
|
||||
/// Publishing this Manager's realms to the central Registry (Phase 10): its own loop, independent of Player Mode.
|
||||
realm_registry: Option<Arc<coa_core::realm_registry::RegistryRuntime>>,
|
||||
/// The player's side of the control plane (Phase 12): identity, protected credentials, joining servers.
|
||||
control: Option<Arc<coa_core::control::join::PlayerControl>>,
|
||||
/// The host's side: one control link per published realm.
|
||||
host_control: Option<Arc<coa_core::control::host_manager::HostControl>>,
|
||||
}
|
||||
|
||||
/// Starting and stopping the servers this Manager installed, for the portable runtime.
|
||||
@@ -1648,11 +1654,13 @@ async fn console_command(state: State<'_, AppState>, id: String, command: String
|
||||
pub fn run() {
|
||||
let dir = data_dir();
|
||||
let _ = coa_core::logging::init(&dir.join("logs").join("manager.log"));
|
||||
let portable = start_portable(&dir);
|
||||
let (control, host_control) = control_cmds::start(&dir, portable.clone());
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.manage(AppState { registry: Registry::at(dir.join("installs.json")), busy: Mutex::new(HashSet::new()), install_cancel: Mutex::new(None), client_cancel: Mutex::new(None), portable: start_portable(&dir), realm_registry: start_realm_registry(&dir) })
|
||||
.manage(AppState { registry: Registry::at(dir.join("installs.json")), busy: Mutex::new(HashSet::new()), install_cancel: Mutex::new(None), client_cancel: Mutex::new(None), portable, realm_registry: start_realm_registry(&dir), control, host_control })
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
default_install_dir,
|
||||
scan_server,
|
||||
@@ -1757,7 +1765,17 @@ pub fn run() {
|
||||
registry_set_url,
|
||||
registry_publish,
|
||||
registry_unpublish,
|
||||
registry_retry
|
||||
registry_retry,
|
||||
control_cmds::control_status,
|
||||
control_cmds::control_set_preferred_username,
|
||||
control_cmds::registry_set_access,
|
||||
control_cmds::join_realm,
|
||||
control_cmds::join_link,
|
||||
control_cmds::join_account,
|
||||
control_cmds::join_credentials,
|
||||
control_cmds::join_forget,
|
||||
control_cmds::join_characters,
|
||||
control_cmds::join_claim
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building CoA Server Manager")
|
||||
@@ -1770,6 +1788,9 @@ pub fn run() {
|
||||
if let Some(rt) = app.state::<AppState>().realm_registry.clone() {
|
||||
rt.shutdown();
|
||||
}
|
||||
if let Some(rt) = app.state::<AppState>().host_control.clone() {
|
||||
rt.shutdown();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1864,7 +1885,11 @@ fn registry_retry(state: State<'_, AppState>, local_id: String) {
|
||||
fn start_realm_registry(dir: &std::path::Path) -> Option<Arc<coa_core::realm_registry::RegistryRuntime>> {
|
||||
use coa_core::realm_registry::{FileKeyStore, LocalRealmsSource, RegistryRuntime};
|
||||
let installs = Registry::at(dir.join("installs.json"));
|
||||
let source = LocalRealmsSource::new(dir.join("portable").join("realms"), move || installs.list().unwrap_or_default());
|
||||
let registry_dir = dir.join("registry");
|
||||
let source = LocalRealmsSource::with_provisioning(dir.join("portable").join("realms"), move || installs.list().unwrap_or_default(), move |local_id| {
|
||||
// a realm creates accounts for joining players unless its owner said it only links the ones that exist
|
||||
coa_core::realm_registry::settings::load(®istry_dir).ok().and_then(|s| s.realms.get(local_id).map(|c| !c.existing_only)).unwrap_or(true)
|
||||
});
|
||||
let keys = FileKeyStore::new(dir.join("registry").join("keys"));
|
||||
match RegistryRuntime::start(&dir.join("registry"), Arc::new(keys), Arc::new(source)) {
|
||||
Ok(rt) => Some(Arc::new(rt)),
|
||||
|
||||
@@ -1061,6 +1061,69 @@ const en = {
|
||||
"registry.state.rejected": "The Registry refused this realm. Publishing is stopped.",
|
||||
"registry.state.unpublishing": "Removing the listing…",
|
||||
"registry.state.disabled": "Not published.",
|
||||
"join.button": "Join this server",
|
||||
"join.haveAccount": "I already have an account on this server",
|
||||
"join.account": "Your account on this server",
|
||||
"join.copyName": "Copy account name",
|
||||
"join.copyPassword": "Copy password",
|
||||
"join.copied": "Copied",
|
||||
"join.show": "Show password",
|
||||
"join.hide": "Hide password",
|
||||
"join.forget": "Forget",
|
||||
"join.accountHint": "The game's own login window asks for these. The Manager cannot fill that window in for you yet: paste the name and password there. The password is kept in protected storage on this computer only.",
|
||||
"join.created": "An account {name} was created for you on this server.",
|
||||
"join.reset": "Your saved password was lost, so the server gave this account a new one.",
|
||||
"join.bring": "Play my selected character on this server",
|
||||
"join.bringHint": "Only characters that came from this server can be played there for now.",
|
||||
"join.preferred": "Account name on servers",
|
||||
"join.preferredSaved": "Saved",
|
||||
"join.preferredHint": "Used when a server creates your account. If the name is taken the server adds a short suffix, for example PLAYER_7K4M. Leave empty to use PLAYER.",
|
||||
"join.status.launched": "The game is starting. Log in with the name and password above.",
|
||||
"join.status.ready": "Your account is ready.",
|
||||
"join.status.needsRelay": "This server requires Relay support, which is not available yet.",
|
||||
"join.status.needsClient": "Your account is ready. Set up your game client first (Settings > Game client), then join again.",
|
||||
"join.status.needsTransfer": "Your account is ready, but this character came from another server. Moving a character onto another server arrives with the Relay step.",
|
||||
"join.status.incompatible": "This server cannot take the selected character; nothing was changed.",
|
||||
"join.status.needsLink": "This server only accepts accounts that already exist. Link yours below.",
|
||||
"join.link.title": "Link an existing account",
|
||||
"join.link.text": "Enter the name and password of the account you already have on this server. They are sent encrypted to the server's Manager, checked there once and kept in protected storage here.",
|
||||
"join.link.login": "Account name",
|
||||
"join.link.password": "Password",
|
||||
"join.link.submit": "Link account",
|
||||
"join.chars.title": "Characters on this server",
|
||||
"join.chars.text": "To make a character portable: log in to this server, stay at the character selection screen, switch back here (Alt+Tab) and press Make portable.",
|
||||
"join.chars.show": "Show my characters",
|
||||
"join.chars.none": "Your account has no characters on this server.",
|
||||
"join.chars.notEligible": "Cannot be made portable yet",
|
||||
"join.chars.portable": "Portable",
|
||||
"join.chars.make": "Make portable",
|
||||
"join.chars.done": "{name} is portable now (revision {revision}).",
|
||||
"join.err.host_offline": "The server's Manager is not online right now. Try again later.",
|
||||
"join.err.host_busy": "The server's Manager is busy. Try again in a moment.",
|
||||
"join.err.rate_limited": "Too many attempts. Wait a minute.",
|
||||
"join.err.host_not_verified": "The server could not be verified as the one in the list, so nothing was sent to it.",
|
||||
"join.err.unreachable": "The server's Manager could not be reached.",
|
||||
"join.err.timeout": "The server did not answer in time.",
|
||||
"join.err.provisioning_off": "This server does not create accounts. Link the account you already have.",
|
||||
"join.err.wrong_credentials": "The server does not accept that account name and password.",
|
||||
"join.err.account_taken": "That account already belongs to another player on this server.",
|
||||
"join.err.no_account": "You have no account on this server yet. Join it first.",
|
||||
"join.err.not_at_character_select": "Log in to this server and stay at the character selection screen, then try again.",
|
||||
"join.err.not_yours": "That character is not on your account on this server.",
|
||||
"join.err.already_claimed": "That character is already portable.",
|
||||
"join.err.not_eligible": "That character cannot be made portable yet.",
|
||||
"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.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",
|
||||
"access.routeHint": "A host name or IP address (and port) a game client can reach right now, for example one you have opened on your router. Leave empty if there is none: players then see that the server needs Relay support.",
|
||||
"access.save": "Save",
|
||||
"access.saved": "Saved",
|
||||
"access.linkUp": "Connected to the control service: players can get accounts and claim characters.",
|
||||
"access.linkDown": "Not connected to the control service yet.",
|
||||
"access.fileStore": "Secrets are kept in a protected file, not encrypted by the system, on this computer.",
|
||||
} as const;
|
||||
|
||||
export default en;
|
||||
@@ -1059,6 +1059,69 @@ const ru: Partial<Record<keyof typeof en, string>> = {
|
||||
"registry.state.rejected": "Реестр отклонил этот реалм. Публикация остановлена.",
|
||||
"registry.state.unpublishing": "Убираю запись из списка…",
|
||||
"registry.state.disabled": "Не опубликован.",
|
||||
"join.button": "Присоединиться к серверу",
|
||||
"join.haveAccount": "У меня уже есть аккаунт на этом сервере",
|
||||
"join.account": "Ваш аккаунт на этом сервере",
|
||||
"join.copyName": "Скопировать имя аккаунта",
|
||||
"join.copyPassword": "Скопировать пароль",
|
||||
"join.copied": "Скопировано",
|
||||
"join.show": "Показать пароль",
|
||||
"join.hide": "Скрыть пароль",
|
||||
"join.forget": "Забыть",
|
||||
"join.accountHint": "Эти данные просит окно входа самой игры. Менеджер пока не может заполнить это окно за вас: вставьте имя и пароль сами. Пароль хранится в защищённом хранилище только на этом компьютере.",
|
||||
"join.created": "Для вас создан аккаунт {name} на этом сервере.",
|
||||
"join.reset": "Сохранённый пароль был потерян, поэтому сервер выдал этому аккаунту новый.",
|
||||
"join.bring": "Играть выбранным персонажем на этом сервере",
|
||||
"join.bringHint": "Пока на сервере можно играть только персонажами, которые с него пришли.",
|
||||
"join.preferred": "Имя аккаунта на серверах",
|
||||
"join.preferredSaved": "Сохранено",
|
||||
"join.preferredHint": "Используется, когда сервер создаёт ваш аккаунт. Если имя занято, сервер добавит короткий суффикс, например PLAYER_7K4M. Пусто — будет PLAYER.",
|
||||
"join.status.launched": "Игра запускается. Войдите с именем и паролем выше.",
|
||||
"join.status.ready": "Ваш аккаунт готов.",
|
||||
"join.status.needsRelay": "Этому серверу нужна поддержка Relay, которой пока нет.",
|
||||
"join.status.needsClient": "Аккаунт готов. Сначала настройте игровой клиент (Настройки > Игровой клиент), затем присоединитесь снова.",
|
||||
"join.status.needsTransfer": "Аккаунт готов, но этот персонаж пришёл с другого сервера. Перенос персонажа на другой сервер появится вместе с Relay.",
|
||||
"join.status.incompatible": "Этот сервер не может принять выбранного персонажа; ничего не изменено.",
|
||||
"join.status.needsLink": "Этот сервер принимает только уже существующие аккаунты. Привяжите свой ниже.",
|
||||
"join.link.title": "Привязать существующий аккаунт",
|
||||
"join.link.text": "Введите имя и пароль аккаунта, который у вас уже есть на этом сервере. Они отправляются в зашифрованном виде Менеджеру сервера, один раз проверяются там и хранятся здесь в защищённом хранилище.",
|
||||
"join.link.login": "Имя аккаунта",
|
||||
"join.link.password": "Пароль",
|
||||
"join.link.submit": "Привязать аккаунт",
|
||||
"join.chars.title": "Персонажи на этом сервере",
|
||||
"join.chars.text": "Чтобы сделать персонажа переносимым: войдите на этот сервер, останьтесь на экране выбора персонажа, вернитесь сюда (Alt+Tab) и нажмите «Сделать переносимым».",
|
||||
"join.chars.show": "Показать моих персонажей",
|
||||
"join.chars.none": "На вашем аккаунте нет персонажей на этом сервере.",
|
||||
"join.chars.notEligible": "Пока нельзя сделать переносимым",
|
||||
"join.chars.portable": "Переносимый",
|
||||
"join.chars.make": "Сделать переносимым",
|
||||
"join.chars.done": "{name} теперь переносимый (ревизия {revision}).",
|
||||
"join.err.host_offline": "Менеджер сервера сейчас не в сети. Попробуйте позже.",
|
||||
"join.err.host_busy": "Менеджер сервера занят. Попробуйте через минуту.",
|
||||
"join.err.rate_limited": "Слишком много попыток. Подождите минуту.",
|
||||
"join.err.host_not_verified": "Не удалось убедиться, что это тот сервер, что в списке, поэтому ему ничего не отправлено.",
|
||||
"join.err.unreachable": "Не удалось связаться с Менеджером сервера.",
|
||||
"join.err.timeout": "Сервер не ответил вовремя.",
|
||||
"join.err.provisioning_off": "Этот сервер не создаёт аккаунты. Привяжите аккаунт, который у вас уже есть.",
|
||||
"join.err.wrong_credentials": "Сервер не принимает это имя аккаунта и пароль.",
|
||||
"join.err.account_taken": "Этот аккаунт уже принадлежит другому игроку на этом сервере.",
|
||||
"join.err.no_account": "У вас ещё нет аккаунта на этом сервере. Сначала присоединитесь к нему.",
|
||||
"join.err.not_at_character_select": "Войдите на этот сервер и оставайтесь на экране выбора персонажа, затем повторите.",
|
||||
"join.err.not_yours": "Этого персонажа нет на вашем аккаунте на этом сервере.",
|
||||
"join.err.already_claimed": "Этот персонаж уже переносимый.",
|
||||
"join.err.not_eligible": "Этого персонажа пока нельзя сделать переносимым.",
|
||||
"join.err.unavailable": "Сервер сейчас не смог этого сделать.",
|
||||
"access.title": "Вход игроков",
|
||||
"access.text": "Как попадают на этот мир игроки, нашедшие его в списке.",
|
||||
"access.existingOnly": "Не создавать аккаунты для новых игроков",
|
||||
"access.existingOnlyHint": "Игроки смогут только привязать аккаунт, который у них уже есть на этом мире.",
|
||||
"access.route": "Адрес для подключения игроков",
|
||||
"access.routeHint": "Имя хоста или IP-адрес (и порт), по которому клиент игры может подключиться прямо сейчас, например открытый на вашем роутере. Оставьте пустым, если такого нет: игроки увидят, что серверу нужна поддержка Relay.",
|
||||
"access.save": "Сохранить",
|
||||
"access.saved": "Сохранено",
|
||||
"access.linkUp": "Подключено к службе управления: игроки могут получать аккаунты и переносить персонажей.",
|
||||
"access.linkDown": "К службе управления пока не подключено.",
|
||||
"access.fileStore": "Секреты хранятся в защищённом файле без шифрования системой на этом компьютере.",
|
||||
};
|
||||
|
||||
export default ru;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { CharacterView, Note } from "@/lib/portable";
|
||||
|
||||
export type JoinStatus = "ready" | "launched" | "needs_link" | "needs_relay" | "needs_client" | "incompatible" | "needs_transfer";
|
||||
|
||||
export interface JoinOutcome {
|
||||
status: JoinStatus;
|
||||
username: string | null;
|
||||
account_created: boolean;
|
||||
password_reset: boolean;
|
||||
route: string | null;
|
||||
notes: Note[];
|
||||
}
|
||||
|
||||
export interface AccountView { username: string; kind: "generated" | "linked"; has_password: boolean }
|
||||
export interface Credentials { username: string; password: string }
|
||||
export interface RemoteCharacter { name: string; class: number; race: number; level: number; eligible: boolean; reasons: string[]; yours: boolean }
|
||||
|
||||
export interface LinkStatus { connected: boolean; last_error: string | null; channels: number; requests: number }
|
||||
export interface HostControlRealm { local_id: string; realm_id: string; link: LinkStatus }
|
||||
export interface ControlStatus { secret_store: string; player_id: string | null; hosting: HostControlRealm[]; preferred_username: string }
|
||||
|
||||
export interface ControlError { code: string; message: string }
|
||||
|
||||
export function asControlError(e: unknown): ControlError {
|
||||
if (e && typeof e === "object" && "code" in e && "message" in e) return e as ControlError;
|
||||
return { code: "other", message: String(e) };
|
||||
}
|
||||
|
||||
export const control = {
|
||||
status: () => invoke<ControlStatus>("control_status"),
|
||||
setPreferredUsername: (name: string) => invoke<string>("control_set_preferred_username", { name }),
|
||||
setAccess: (localId: string, existingOnly: boolean, route: string | null) => invoke<void>("registry_set_access", { localId, existingOnly, route }),
|
||||
join: (realm: string, character: string | null, launch: boolean) => invoke<JoinOutcome>("join_realm", { realm, character, launch }),
|
||||
link: (realm: string, login: string, password: string) => invoke<string>("join_link", { realm, login, password }),
|
||||
account: (realm: string) => invoke<AccountView | null>("join_account", { realm }),
|
||||
credentials: (realm: string) => invoke<Credentials | null>("join_credentials", { realm }),
|
||||
forget: (realm: string) => invoke<void>("join_forget", { realm }),
|
||||
characters: (realm: string) => invoke<RemoteCharacter[]>("join_characters", { realm }),
|
||||
claim: (realm: string, name: string) => invoke<CharacterView>("join_claim", { realm, name }),
|
||||
};
|
||||
@@ -12,6 +12,8 @@ export interface RealmPublishStatus {
|
||||
description: string;
|
||||
language: string;
|
||||
region: string | null;
|
||||
existing_only: boolean;
|
||||
route: string | null;
|
||||
metadata_revision: number | null;
|
||||
last_ok_unix: number | null;
|
||||
last_error: string | null;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { asUiError } from "@/lib/api";
|
||||
import { realmRegistry, useRegistryStatus } from "@/lib/registry";
|
||||
import { asPortableError, portable, usePortable, type PreflightView } from "@/lib/portable";
|
||||
import { Notes, Projection } from "@/screens/PortablePage";
|
||||
import { JoinPanel } from "@/screens/JoinPanel";
|
||||
import { asControlError, control } from "@/lib/control";
|
||||
import { browse, rate, type BrowseParams, type ModuleEntry, type ModuleInfo, type RealmDetail, type RealmSummary, type SortKey } from "@/lib/browse";
|
||||
|
||||
const VISIBLE_MODULES = 3;
|
||||
@@ -65,7 +67,7 @@ function SortHeader({ id, sort, order, onSort, children, className }: { id: Sort
|
||||
|
||||
function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog: Catalog; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const { state } = usePortable();
|
||||
const { state, refresh: refreshPortable } = usePortable();
|
||||
const [detail, setDetail] = useState<RealmDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [character, setCharacter] = useState("");
|
||||
@@ -140,10 +142,9 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:
|
||||
</div>}
|
||||
</>}
|
||||
</section>
|
||||
</div>
|
||||
<div className="border-t border-line p-5">
|
||||
<Button variant="primary" className="w-full" disabled>{t("browse.play")}</Button>
|
||||
<p className="mt-2 text-xs text-muted">{t("browse.playSoon")}</p>
|
||||
<section>
|
||||
<JoinPanel realm={summary.realm_id} character={character} automatic={summary.account_provisioning.automatic} onClaimed={() => void refreshPortable()} />
|
||||
</section>
|
||||
</div>
|
||||
</aside>;
|
||||
}
|
||||
@@ -166,8 +167,12 @@ export function BrowsePage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<RealmSummary | null>(null);
|
||||
const [catalog, setCatalog] = useState<Catalog>({});
|
||||
const [preferred, setPreferred] = useState("");
|
||||
const [preferredSaved, setPreferredSaved] = useState("");
|
||||
const [preferredNote, setPreferredNote] = useState<string | null>(null);
|
||||
const ticket = useRef(0);
|
||||
|
||||
useEffect(() => { void control.status().then((c) => { setPreferred(c.preferred_username); setPreferredSaved(c.preferred_username); }).catch(() => {}); }, []);
|
||||
useEffect(() => { void browse.modules().then((m) => setCatalog(Object.fromEntries(m.map((x) => [x.id, x])))).catch(() => {}); }, []);
|
||||
useEffect(() => { if (status?.url && url === "") setUrl(status.url); }, [status?.url]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -225,6 +230,13 @@ export function BrowsePage() {
|
||||
<Button size="sm" disabled={url.trim() === (status?.url ?? "")} onClick={() => void realmRegistry.setUrl(url.trim() || null).then(refresh).catch((e) => setError(asUiError(e).technical))}>{t("registry.urlSave")}</Button>
|
||||
</div>
|
||||
{noUrl && <p className="mt-2 text-sm text-muted">{t("browse.noUrl")}</p>}
|
||||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||||
<label htmlFor="preferred-name" className="shrink-0 text-muted">{t("join.preferred")}</label>
|
||||
<input id="preferred-name" value={preferred} maxLength={16} placeholder="PLAYER" onChange={(e) => { setPreferred(e.target.value); setPreferredNote(null); }} className="w-44 rounded-md border border-line bg-card px-3 py-1.5" data-testid="preferred-name" />
|
||||
<Button size="sm" disabled={preferred.trim() === preferredSaved} onClick={() => void control.setPreferredUsername(preferred).then((n) => { setPreferred(n); setPreferredSaved(n); setPreferredNote(t("join.preferredSaved")); }).catch((e) => setError(asControlError(e).message))}>{t("registry.urlSave")}</Button>
|
||||
{preferredNote && <span className="text-xs text-ok" role="status">{preferredNote}</span>}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">{t("join.preferredHint")}</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-6">
|
||||
<input aria-label={t("browse.search")} value={q} onChange={(e) => setQ(e.target.value)} placeholder={t("browse.search")} className="col-span-2 rounded-md border border-line bg-card px-3 py-2 text-sm" />
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, Copy, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n, type Key } from "@/i18n";
|
||||
import { asControlError, control, type AccountView, type Credentials, type JoinOutcome, type RemoteCharacter } from "@/lib/control";
|
||||
import { Notes } from "@/screens/PortablePage";
|
||||
|
||||
const STATUS_TEXT: Record<string, Key> = {
|
||||
launched: "join.status.launched",
|
||||
ready: "join.status.ready",
|
||||
needs_relay: "join.status.needsRelay",
|
||||
needs_client: "join.status.needsClient",
|
||||
needs_transfer: "join.status.needsTransfer",
|
||||
incompatible: "join.status.incompatible",
|
||||
needs_link: "join.status.needsLink",
|
||||
};
|
||||
|
||||
function ErrorLine({ code, message }: { code: string; message: string }) {
|
||||
const { t } = useI18n();
|
||||
const key = `join.err.${code}` as Key;
|
||||
const known = t(key) !== key;
|
||||
return <p className="mt-2 text-sm text-bad" role="alert" data-code={code}>{known ? t(key) : message}</p>;
|
||||
}
|
||||
|
||||
/** The player's account on one server: shown as a name; the password appears only when asked for and goes to the clipboard, never to the screen by default. */
|
||||
function AccountBox({ realm, account, onForget }: { realm: string; account: AccountView; onForget: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [creds, setCreds] = useState<Credentials | null>(null);
|
||||
const [shown, setShown] = useState(false);
|
||||
const [copied, setCopied] = useState<"name" | "password" | null>(null);
|
||||
useEffect(() => { setCreds(null); setShown(false); }, [realm, account.username]);
|
||||
async function load(): Promise<Credentials | null> {
|
||||
if (creds) return creds;
|
||||
const c = await control.credentials(realm);
|
||||
setCreds(c);
|
||||
return c;
|
||||
}
|
||||
async function copy(what: "name" | "password") {
|
||||
const c = await load();
|
||||
if (!c) return;
|
||||
try { await navigator.clipboard.writeText(what === "name" ? c.username : c.password); setCopied(what); setTimeout(() => setCopied(null), 4000); } catch { /* the clipboard is not available */ }
|
||||
}
|
||||
return <div className="rounded-md border border-line p-3" data-testid="account-box">
|
||||
<p className="text-xs uppercase tracking-wide text-muted">{t("join.account")}</p>
|
||||
<p className="mt-1 flex items-center gap-2 font-mono text-base" data-testid="account-name">{account.username}
|
||||
<button aria-label={t("join.copyName")} onClick={() => void copy("name")} className="cursor-pointer text-muted hover:text-ink">{copied === "name" ? <Check className="h-4 w-4 text-ok" /> : <Copy className="h-4 w-4" />}</button>
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={() => void copy("password")}>{copied === "password" ? <Check className="h-4 w-4 text-ok" aria-hidden /> : <Copy className="h-4 w-4" aria-hidden />}{copied === "password" ? t("join.copied") : t("join.copyPassword")}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => void load().then(() => setShown((s) => !s))}>{shown ? <EyeOff className="h-4 w-4" aria-hidden /> : <Eye className="h-4 w-4" aria-hidden />}{shown ? t("join.hide") : t("join.show")}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onForget}>{t("join.forget")}</Button>
|
||||
</div>
|
||||
{shown && creds && <p className="mt-2 break-all font-mono text-sm" data-testid="account-password">{creds.password}</p>}
|
||||
<p className="mt-2 text-xs text-muted">{t("join.accountHint")}</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function LinkForm({ realm, onLinked }: { realm: string; onLinked: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<{ code: string; message: string } | null>(null);
|
||||
async function submit() {
|
||||
setBusy(true); setErr(null);
|
||||
try { await control.link(realm, login.trim(), password); setPassword(""); onLinked(); } catch (e) { setErr(asControlError(e)); } finally { setBusy(false); }
|
||||
}
|
||||
return <form className="rounded-md border border-line p-3" onSubmit={(e) => { e.preventDefault(); void submit(); }}>
|
||||
<p className="text-sm font-medium">{t("join.link.title")}</p>
|
||||
<p className="mt-1 text-xs text-muted">{t("join.link.text")}</p>
|
||||
<label htmlFor="link-login" className="mt-3 block text-sm">{t("join.link.login")}</label>
|
||||
<input id="link-login" value={login} autoComplete="off" onChange={(e) => setLogin(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" />
|
||||
<label htmlFor="link-password" className="mt-3 block text-sm">{t("join.link.password")}</label>
|
||||
<input id="link-password" type="password" value={password} autoComplete="off" onChange={(e) => setPassword(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" />
|
||||
<Button type="submit" className="mt-3" disabled={busy || login.trim() === "" || password === ""}>{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}{t("join.link.submit")}</Button>
|
||||
{err && <ErrorLine code={err.code} message={err.message} />}
|
||||
</form>;
|
||||
}
|
||||
|
||||
const CLASS_NAMES: Record<number, string> = { 1: "Warrior", 2: "Paladin", 3: "Hunter", 4: "Rogue", 5: "Priest", 6: "Death Knight", 7: "Shaman", 8: "Mage", 9: "Warlock", 11: "Druid" };
|
||||
|
||||
function ServerCharacters({ realm, onClaimed }: { realm: string; onClaimed: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [list, setList] = useState<RemoteCharacter[] | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<{ code: string; message: string } | null>(null);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
async function load() {
|
||||
setBusy("*"); setErr(null);
|
||||
try { setList(await control.characters(realm)); } catch (e) { setErr(asControlError(e)); setList(null); } finally { setBusy(null); }
|
||||
}
|
||||
async function claim(name: string) {
|
||||
setBusy(name); setErr(null); setDone(null);
|
||||
try { const c = await control.claim(realm, name); setDone(t("join.chars.done", { name: c.name, revision: c.revision })); onClaimed(); await load(); } catch (e) { setErr(asControlError(e)); } finally { setBusy(null); }
|
||||
}
|
||||
return <section data-testid="server-characters">
|
||||
<h3 className="mb-1 font-medium">{t("join.chars.title")}</h3>
|
||||
<p className="text-xs text-muted">{t("join.chars.text")}</p>
|
||||
<Button size="sm" className="mt-2" onClick={() => void load()} disabled={busy !== null}>{busy === "*" && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}{t("join.chars.show")}</Button>
|
||||
{list && list.length === 0 && <p className="mt-2 text-sm text-muted">{t("join.chars.none")}</p>}
|
||||
{list && list.length > 0 && <ul className="mt-2 space-y-2">
|
||||
{list.map((c) => <li key={c.name} className="flex items-center justify-between gap-3 rounded-md border border-line p-2">
|
||||
<span><span className="font-medium">{c.name}</span><span className="text-muted"> — {CLASS_NAMES[c.class] ?? `#${c.class}`}, {t("portable.level", { level: c.level })}</span>
|
||||
{!c.eligible && <span className="block text-xs text-warn">{t("join.chars.notEligible")} ({c.reasons.join(", ")})</span>}</span>
|
||||
{c.yours ? <span className="text-xs text-ok">{t("join.chars.portable")}</span>
|
||||
: <Button size="sm" disabled={!c.eligible || busy !== null} onClick={() => void claim(c.name)}>{busy === c.name && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}{t("join.chars.make")}</Button>}
|
||||
</li>)}
|
||||
</ul>}
|
||||
{done && <p className="mt-2 text-sm text-ok" role="status">{done}</p>}
|
||||
{err && <ErrorLine code={err.code} message={err.message} />}
|
||||
</section>;
|
||||
}
|
||||
|
||||
/** Joining one server: the account, the game, the character-claim and the honest "not yet" cases. */
|
||||
export function JoinPanel({ realm, character, automatic, onClaimed }: { realm: string; character: string; automatic: boolean; onClaimed: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [account, setAccount] = useState<AccountView | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [outcome, setOutcome] = useState<JoinOutcome | null>(null);
|
||||
const [err, setErr] = useState<{ code: string; message: string } | null>(null);
|
||||
const [linking, setLinking] = useState(false);
|
||||
const [bring, setBring] = useState(false);
|
||||
const refresh = () => control.account(realm).then(setAccount).catch(() => setAccount(null));
|
||||
useEffect(() => { setOutcome(null); setErr(null); setLinking(false); void refresh(); }, [realm]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function join() {
|
||||
setBusy(true); setErr(null); setOutcome(null);
|
||||
try {
|
||||
const o = await control.join(realm, bring && character ? character : null, true);
|
||||
setOutcome(o);
|
||||
if (o.status === "needs_link") setLinking(true);
|
||||
await refresh();
|
||||
} catch (e) { setErr(asControlError(e)); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const text = outcome ? STATUS_TEXT[outcome.status] : undefined;
|
||||
const bad = outcome && ["incompatible", "needs_relay", "needs_client", "needs_transfer", "needs_link"].includes(outcome.status);
|
||||
return <div className="space-y-4">
|
||||
{account && <AccountBox realm={realm} account={account} onForget={() => void control.forget(realm).then(() => { setAccount(null); setOutcome(null); })} />}
|
||||
{(linking || (!account && !automatic)) && <LinkForm realm={realm} onLinked={() => { setLinking(false); void refresh(); setOutcome(null); }} />}
|
||||
<div>
|
||||
{character && <label className="mb-2 flex items-start gap-2 text-sm"><input type="checkbox" checked={bring} onChange={(e) => setBring(e.target.checked)} className="mt-1" data-testid="bring-character" /><span>{t("join.bring")}<span className="block text-xs text-muted">{t("join.bringHint")}</span></span></label>}
|
||||
<Button variant="primary" className="w-full" disabled={busy} onClick={() => void join()} data-testid="join-button">{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}{t("join.button")}</Button>
|
||||
{!account && automatic && !linking && <button className="mt-2 cursor-pointer text-xs text-muted underline hover:text-ink" onClick={() => setLinking(true)}>{t("join.haveAccount")}</button>}
|
||||
{outcome && text && <div className={cn("mt-3 rounded-md border p-3 text-sm", bad ? "border-warn/50 bg-warn/10" : "border-ok/40 bg-ok/10")} role="status" data-status={outcome.status}>
|
||||
<p>{t(text)}</p>
|
||||
{outcome.account_created && <p className="mt-1 text-xs text-muted">{t("join.created", { name: outcome.username ?? "" })}</p>}
|
||||
{outcome.password_reset && <p className="mt-1 text-xs text-muted">{t("join.reset")}</p>}
|
||||
{outcome.notes.length > 0 && <Notes notes={outcome.notes} />}
|
||||
</div>}
|
||||
{err && <ErrorLine code={err.code} message={err.message} />}
|
||||
</div>
|
||||
<ServerCharacters realm={realm} onClaimed={onClaimed} />
|
||||
</div>;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useT, type Key } from "@/i18n";
|
||||
import { asUiError } from "@/lib/api";
|
||||
import { realmRegistry, useRegistryStatus } from "@/lib/registry";
|
||||
import { asControlError, control, type ControlStatus } from "@/lib/control";
|
||||
|
||||
const LANGUAGES = ["en", "ru", "de", "fr", "es", "zh", "pt", "pl", "uk"];
|
||||
|
||||
@@ -23,6 +24,24 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server
|
||||
const [region, setRegion] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [existingOnly, setExistingOnly] = useState(false);
|
||||
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(() => {
|
||||
const load = () => void control.status().then(setCtl).catch(() => {});
|
||||
load();
|
||||
const timer = setInterval(load, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
useEffect(() => { if (mine) { setExistingOnly(mine.existing_only); setRoute(mine.route ?? ""); } }, [mine?.existing_only, mine?.route]);
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
useEffect(() => { if (status && status.url !== null) setUrl((u) => (u === "" ? status.url ?? "" : u)); }, [status?.url]);
|
||||
useEffect(() => {
|
||||
@@ -78,6 +97,22 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server
|
||||
<Button size="sm" disabled={busy} onClick={() => void run(() => realmRegistry.unpublish(localId))}>{t("registry.stop")}</Button>
|
||||
</div>
|
||||
</div>}
|
||||
{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={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" />
|
||||
<p className="mt-1 text-xs text-muted">{t("access.routeHint")}</p>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button size="sm" disabled={busy} onClick={() => void saveAccess()}>{t("access.save")}</Button>
|
||||
{accessSaved && <span className="text-xs text-ok" role="status">{t("access.saved")}</span>}
|
||||
</div>
|
||||
<p className={cn("mt-3 text-xs", link?.connected ? "text-ok" : "text-muted")} data-testid="control-link" data-connected={link?.connected ? "yes" : "no"}>
|
||||
{link?.connected ? t("access.linkUp") : t("access.linkDown")}{link?.last_error && !link.connected ? ` — ${link.last_error}` : ""}
|
||||
</p>
|
||||
{ctl && ctl.secret_store === "file" && <p className="mt-1 text-xs text-warn">{t("access.fileStore")}</p>}
|
||||
</div>}
|
||||
{error && <p className="mt-3 text-sm text-bad" role="alert">{error}</p>}
|
||||
</Card>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user