diff --git a/Cargo.lock b/Cargo.lock index 7fd0787..4b728e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -644,6 +644,7 @@ dependencies = [ name = "coa-server-manager" version = "0.6.6" dependencies = [ + "coa-control-proto", "coa-core", "coa-registry-proto", "serde", diff --git a/crates/coa-core/src/realm_registry/host.rs b/crates/coa-core/src/realm_registry/host.rs index 2045149..317a1c5 100644 --- a/crates/coa-core/src/realm_registry/host.rs +++ b/crates/coa-core/src/realm_registry/host.rs @@ -93,6 +93,8 @@ pub struct RealmPublishStatus { pub description: String, pub language: String, pub region: Option, + pub existing_only: bool, + pub route: Option, pub metadata_revision: Option, pub last_ok_unix: Option, pub last_error: Option, @@ -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(), diff --git a/crates/coa-core/src/realm_registry/runtime.rs b/crates/coa-core/src/realm_registry/runtime.rs index 689c12b..b39eb63 100644 --- a/crates/coa-core/src/realm_registry/runtime.rs +++ b/crates/coa-core/src/realm_registry/runtime.rs @@ -16,6 +16,7 @@ enum Cmd { SetUrl(Option, Sender>), Publish { local_id: String, name: String, description: String, language: String, region: Option, reply: Sender> }, Unpublish(String, Sender>), + SetAccess { local_id: String, existing_only: bool, route: Option, reply: Sender> }, 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())); } diff --git a/services/coa-coordinator/Dockerfile b/services/coa-coordinator/Dockerfile new file mode 100644 index 0000000..2afa53d --- /dev/null +++ b/services/coa-coordinator/Dockerfile @@ -0,0 +1,16 @@ +# Build context: the repository root. docker build -f services/coa-coordinator/Dockerfile -t coa-coordinator: . +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"] diff --git a/services/coa-coordinator/deploy/coordinator.compose.yaml b/services/coa-coordinator/deploy/coordinator.compose.yaml new file mode 100644 index 0000000..121b743 --- /dev/null +++ b/services/coa-coordinator/deploy/coordinator.compose.yaml @@ -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 diff --git a/services/coa-coordinator/deploy/deploy.sh b/services/coa-coordinator/deploy/deploy.sh new file mode 100644 index 0000000..2c551dc --- /dev/null +++ b/services/coa-coordinator/deploy/deploy.sh @@ -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@ 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=}" +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}}'" diff --git a/services/coa-coordinator/deploy/provision-db.sh b/services/coa-coordinator/deploy/provision-db.sh new file mode 100644 index 0000000..97d2815 --- /dev/null +++ b/services/coa-coordinator/deploy/provision-db.sh @@ -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' <) -> 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 { + 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, 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::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>) -> (Option>, Option>) { + let base = dir.join("control"); + let secrets: Arc = 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 = 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, + hosting: Vec, + 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 { + 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) -> 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, + account_created: bool, + password_reset: bool, + route: Option, + notes: Vec, +} + +impl JoinOutcome { + fn stop(status: &str, username: Option) -> 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, launch: bool) -> Result { + 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 { + 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 { + 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, 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, 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, + 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, 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 { + 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()))? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 44b5c0e..e4c9cd9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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>, /// Publishing this Manager's realms to the central Registry (Phase 10): its own loop, independent of Player Mode. realm_registry: Option>, + /// The player's side of the control plane (Phase 12): identity, protected credentials, joining servers. + control: Option>, + /// The host's side: one control link per published realm. + host_control: Option>, } /// 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::().realm_registry.clone() { rt.shutdown(); } + if let Some(rt) = app.state::().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> { 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)), diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 35e3cbf..fd70c15 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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; diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 92085f0..323575c 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -1059,6 +1059,69 @@ const ru: Partial> = { "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; diff --git a/src/lib/control.ts b/src/lib/control.ts new file mode 100644 index 0000000..abc52d5 --- /dev/null +++ b/src/lib/control.ts @@ -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("control_status"), + setPreferredUsername: (name: string) => invoke("control_set_preferred_username", { name }), + setAccess: (localId: string, existingOnly: boolean, route: string | null) => invoke("registry_set_access", { localId, existingOnly, route }), + join: (realm: string, character: string | null, launch: boolean) => invoke("join_realm", { realm, character, launch }), + link: (realm: string, login: string, password: string) => invoke("join_link", { realm, login, password }), + account: (realm: string) => invoke("join_account", { realm }), + credentials: (realm: string) => invoke("join_credentials", { realm }), + forget: (realm: string) => invoke("join_forget", { realm }), + characters: (realm: string) => invoke("join_characters", { realm }), + claim: (realm: string, name: string) => invoke("join_claim", { realm, name }), +}; diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 90e5813..60ab0b8 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -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; diff --git a/src/screens/BrowsePage.tsx b/src/screens/BrowsePage.tsx index 7a60e84..cec6fdd 100644 --- a/src/screens/BrowsePage.tsx +++ b/src/screens/BrowsePage.tsx @@ -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(null); const [error, setError] = useState(null); const [character, setCharacter] = useState(""); @@ -140,10 +142,9 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog: } } - -
- -

{t("browse.playSoon")}

+
+ void refreshPortable()} /> +
; } @@ -166,8 +167,12 @@ export function BrowsePage() { const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [catalog, setCatalog] = useState({}); + const [preferred, setPreferred] = useState(""); + const [preferredSaved, setPreferredSaved] = useState(""); + const [preferredNote, setPreferredNote] = useState(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() { {noUrl &&

{t("browse.noUrl")}

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

{t("join.preferredHint")}

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" /> diff --git a/src/screens/JoinPanel.tsx b/src/screens/JoinPanel.tsx new file mode 100644 index 0000000..5601ad5 --- /dev/null +++ b/src/screens/JoinPanel.tsx @@ -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 = { + 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

{known ? t(key) : message}

; +} + +/** 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(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 { + 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
+

{t("join.account")}

+

{account.username} + +

+
+ + + +
+ {shown && creds &&

{creds.password}

} +

{t("join.accountHint")}

+
; +} + +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
{ e.preventDefault(); void submit(); }}> +

{t("join.link.title")}

+

{t("join.link.text")}

+ + setLogin(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" /> + + setPassword(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" /> + + {err && } + ; +} + +const CLASS_NAMES: Record = { 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(null); + const [busy, setBusy] = useState(null); + const [err, setErr] = useState<{ code: string; message: string } | null>(null); + const [done, setDone] = useState(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
+

{t("join.chars.title")}

+

{t("join.chars.text")}

+ + {list && list.length === 0 &&

{t("join.chars.none")}

} + {list && list.length > 0 &&
    + {list.map((c) =>
  • + {c.name} — {CLASS_NAMES[c.class] ?? `#${c.class}`}, {t("portable.level", { level: c.level })} + {!c.eligible && {t("join.chars.notEligible")} ({c.reasons.join(", ")})} + {c.yours ? {t("join.chars.portable")} + : } +
  • )} +
} + {done &&

{done}

} + {err && } +
; +} + +/** 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(null); + const [busy, setBusy] = useState(false); + const [outcome, setOutcome] = useState(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
+ {account && void control.forget(realm).then(() => { setAccount(null); setOutcome(null); })} />} + {(linking || (!account && !automatic)) && { setLinking(false); void refresh(); setOutcome(null); }} />} +
+ {character && } + + {!account && automatic && !linking && } + {outcome && text &&
+

{t(text)}

+ {outcome.account_created &&

{t("join.created", { name: outcome.username ?? "" })}

} + {outcome.password_reset &&

{t("join.reset")}

} + {outcome.notes.length > 0 && } +
} + {err && } +
+ +
; +} diff --git a/src/screens/PublishCard.tsx b/src/screens/PublishCard.tsx index 9ca70f3..8b9439e 100644 --- a/src/screens/PublishCard.tsx +++ b/src/screens/PublishCard.tsx @@ -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(null); + const [existingOnly, setExistingOnly] = useState(false); + const [route, setRoute] = useState(""); + const [accessSaved, setAccessSaved] = useState(false); + const [ctl, setCtl] = useState(null); + const link = ctl?.hosting.find((h) => h.local_id === localId)?.link; + + useEffect(() => { + 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
} + {mine && (published || mine.state === "unpublishing") &&
+

{t("access.title")}

+

{t("access.text")}

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

{t("access.routeHint")}

+
+ + {accessSaved && {t("access.saved")}} +
+

+ {link?.connected ? t("access.linkUp") : t("access.linkDown")}{link?.last_error && !link.connected ? ` — ${link.last_error}` : ""} +

+ {ctl && ctl.secret_store === "file" &&

{t("access.fileStore")}

} +
} {error &&

{error}

} );