diff --git a/AGENTS.md b/AGENTS.md index 68ff036..bbe52f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,11 +41,28 @@ payload are in `/root/shacraft` on the ShaCraft host; see control a URL in any of those domains. - ShaCraft accounts: `src-tauri/src/shacraft_account.rs` talks only to the hardcoded `https://shacraft.ru` origin. Passwords are never persisted. The - revocable session token is stored locally with mode 600 on Unix. At launch, - the nickname is fetched from the verified `aoc` account link; the legacy - nickname in `settings.json` is ignored as an identity source. Server-side - whitelist enforcement and LoginSystem remain the final access-control - boundary, including for old launcher versions. + revocable session token is stored locally with mode 600 on Unix and never + passed to Java. The new admission flow uses fixed POST endpoints + `/api/launcher/v2/admission/nickname` and + `/api/launcher/v2/admission/tickets`; redirects are rejected. Neither the + manifest nor the webview can select their origin or URL. +- Free nicknames are claimed directly for the authenticated account. Existing + player names are reserved server-side and assigned by an administrator; + deleting a website account must not make an existing player's name free. +- After installation, `admission.rs` generates an ephemeral Ed25519 key with + the OS CSPRNG. The backend binds its public key to a one-use ticket, current + account session, canonical `aoc` nickname and server access. Only that + returned nickname selects launch identity; `settings.json` is never a fallback. + Ticket and PKCS#8 private key go only in the final Java child's environment + (`SHACRAFT_ADMISSION_TICKET`, `SHACRAFT_ADMISSION_PRIVATE_KEY`). Never put + them in global environment, argv/argfiles, settings, logs or IPC. A fresh + launch obtains a fresh ticket; there is no shared launcher secret. +- Once admission is enforced on Aeronautics, its server mod verifies the + challenge/proof before world entry, and server-side whitelist enforcement + remains a final access boundary. Replace LoginSystem only as part of the + validated `aoc` rollout; other servers are unaffected. Old launchers without + admission proof will be rejected after enforcement. This authenticates an + account's permission, not the integrity of an unmodified launcher binary. ## Layout @@ -60,7 +77,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was removed; only verified remote manifests may drive profile mutations. - `operations.rs` — process-local install/account permits owned by workers. - Launch must use the authenticated ShaCraft nickname; no settings fallback. + The account permit covers ticket issuance through Java spawn, preventing + local logout/account switching from racing that handoff. - `storage.rs` — unique same-directory atomic writes, owner-only Unix files. - `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider. - `download.rs` — shared verified-download helper (temp file, hash, @@ -76,8 +94,11 @@ payload are in `/root/shacraft` on the ShaCraft host; see placeholder pending ShaCraft's own Azure AD app registration and Minecraft-API approval. - `shacraft_account.rs` — local ShaCraft login/registration, session and - verified nickname-link API. - - `launch.rs` — builds and spawns the actual `java` process. + nickname claim/admission API; legacy link commands remain for compatibility. + - `admission.rs` — ephemeral key generation, strict ticket response validation, + canonical identity and child-only admission environment. + - `launch.rs` — builds and spawns the actual `java` process; admission secrets + must remain outside its argument substitution and JVM argfile paths. - `src-tauri/src/settings.rs` — durable local preferences; maintain backward compatibility with already-written JSON. - `docs/manifest-v1.md` — signed manifest envelope and payload contract @@ -105,6 +126,14 @@ not prove Tauri commands work. See `PLAN.md` for known gaps. Never label browser preview or unit tests as a successful cold game install / Microsoft OAuth / Windows/macOS beta test. +The admission implementation has local unit/UI checks and unsigned Linux +0.1.2 AppImage/deb packages built on Ubuntu 26.04. Older Ubuntu compatibility +has not been tested. The backend/mod rollout is active on Aeronautics as of +2026-09-10. Real isolated NeoForge admission tests and a production rejection +without the mod passed; these do not certify a full production modpack join. +The server has a required early duplicate-login guard so unauthenticated +connections cannot evict an already-online UUID. The client mod pins the actual +socket to `135.106.154.86:25567`. See architecture and server rollout records. ## Working conventions diff --git a/PLAN.md b/PLAN.md index 27775b2..8106e8a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -24,9 +24,50 @@ - [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже загружается через фиксированный display-only API. Не имитировать данные. +## Вход по одноразовому разрешению: внедрение 2026-09-10 + +- [x] Новый native claim закрепляет свободный ник за аккаунтом без входа в игру. + Старые имена резервируются backend и переносятся администратором. +- [x] После установки Rust создаёт Ed25519 ключ через OS CSPRNG и запрашивает + ticket на фиксированном ShaCraft API. Ник для запуска берётся из ticket; + локальные настройки не могут его заменить. +- [x] Ticket и PKCS#8 private key передаются только окружением дочерней Java; + session сайта остаётся в native. В argv, argfile, settings, логах и IPC + секретов нет. Account permit держится до spawn. +- [x] Ошибки и результаты привязки показаны в диалоге; двойной клик не создаёт + параллельных запросов, старого polling нет. +- [x] Локально пройдены 64 Rust-теста (5 live-тестов пропущены), 22 UI-теста, + TypeScript/Vite и сборка Linux x86-64 `tauri:build -- --no-bundle`. + Шесть браузерных сценариев используют только замоканный Tauri IPC. +- [x] Собраны неподписанные AppImage и deb версии 0.1.2 на Ubuntu 26.04; + проверены извлечение AppImage и metadata deb. Работа на старых Ubuntu и + установка пакетов на чистой машине этим не подтверждены. +- [x] Выложены backend, подписанный клиентский мод и серверный мод Aeronautics; + сервер перезапущен и healthy. Whitelist сохранён; LoginSystem заменён только + на `aoc`. Пакеты Linux 0.1.2 опубликованы на сайте, AppImage запущен локально. +- [x] Настоящий изолированный NeoForge: новый ticket допускает в мир, отсутствие + мода/билета и повтор билета отклоняются. Старый LoginSystem на клиенте не мешает. + Подключение дубликата без авторизации не выбивает уже играющего владельца. +- [x] На публичном сервере подключение без мода отклонено до входа в мир; + HTTPS verifier из контейнера, подпись manifest и SHA-256 мода проверены. + Backend Docker: 404 tests + 48 subtests, включая истечение, отзыв session, + чужую identity, whitelist, резервирование имён и атомарное погашение. +- [ ] Полный вход в production Aeronautics через установленный лаунчер + подтвердить отдельно; изолированный мир не заменяет проверку полного модпака. +- [ ] Проверить cold install и этот протокол на Windows/macOS, выпустить + подписанные пакеты. Локальная Linux-сборка не подтверждает эти платформы. +- [ ] Удобное переподключение: сейчас использованный или истёкший ticket + требует нового запуска игры из лаунчера; автоматического обновления нет. + ## Связанные серверные риски -Серверный план находится в `/root/shacraft/PLAN.md`. Важные следующие шаги: -одноразовое подтверждение ника внутри игры (NoGravity не связывает игрока -с веб-запросом), enforcement реферальных правил и очередь повторов whitelist. -Не менять этот протокол незаметно в клиентском рефакторинге. +Серверный план находится в `/root/shacraft/PLAN.md`. Для admission обязательны +атомарная одноразовая проверка ticket, привязка к текущим session/аккаунту/нику, +проверка до входа в мир и сохранение whitelist как серверного ограничения. +Старые игровые имена нельзя отдавать первому зарегистрировавшемуся; их +резервирование и назначение аккаунтам — отдельный этап миграции. Старый +NoGravity challenge-поток должен быть закрыт при включённом admission, чтобы +он не обходил резервирование имени. Остальные серверные задачи включают +enforcement реферальных правил и очередь повторов whitelist. Не менять +протокол незаметно в клиентском рефакторинге и не считать ticket доказательством +неизменённости клиентского бинарника. diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index 2c56b92..76da6ac 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -5,9 +5,18 @@ The launcher persists local settings, synchronises Aeronautics mod/config files from the signed ShaCraft v2 manifest, installs the exact Minecraft + NeoForge version the manifest specifies, and launches the game. A player -signs in with the same local ShaCraft account used on the website. The game -identity is derived only from that account's verified Aeronautics nickname; -the legacy editable nickname setting is not trusted at launch. +signs in with the same local ShaCraft account used on the website. The current +admission implementation claims a free nickname for that account and requests +a one-use permission immediately before launching Java. The game identity +comes only from the canonical Aeronautics nickname in that permission; the +legacy editable nickname setting is not trusted at launch. + +The backend and admission mod were deployed to Aeronautics on 2026-09-10; +the server is healthy with whitelist enforcement retained. Real isolated +NeoForge connections verified successful admission, absent/replayed proof +rejection and protection of an online player from duplicate login. A public +production connection without the mod was rejected before world entry. +This does not certify a full cold installation or Windows/macOS operation. The interface also shows a live Aeronautics player count from the fixed, read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only: @@ -19,7 +28,7 @@ launcher itself. Do not represent these as completed in UI or release notes. ## Data flow -Two independent pipelines feed one launch: +Managed files, game installation and account admission meet at Java spawn: ```text ShaCraft manifest (mods/config + which MC/loader/Java version to use) @@ -33,9 +42,14 @@ Game itself (never controlled by the manifest above) -> NeoForge's own installer, run headlessly (neoforge.rs) -> generic inheritsFrom merge of the two version JSONs (mojang.rs) -> SHA-1-verified merged libraries + platform natives (mojang.rs) - -> verified ShaCraft account link (shacraft_account.rs) - -> deterministic offline UUID for the linked nickname (session.rs) - -> java process spawned with the merged classpath/args (launch.rs) + +Admission (fixed ShaCraft account API; never controlled by a manifest) + native website session -> direct free-nickname claim or admin migration + -> after installation: OS CSPRNG -> ephemeral Ed25519 key (admission.rs) + -> public key + bearer session -> one-use ticket for canonical aoc nickname + -> deterministic offline UUID for that nickname (session.rs) + -> Java with merged classpath/args + child-only ticket/private-key environment + -> client mod signs server challenge; server validates before world entry ``` Profiles (ShaCraft-managed mods/config, and the player's own worlds/ @@ -45,7 +59,8 @@ install (versions/libraries/assets/runtime, reused across profiles that target the same Minecraft version) lives at `app_data_dir()/game`. Settings live at `app_data_dir()/settings.json`, and the revocable ShaCraft session at `app_data_dir()/shacraft-session` (mode 600 on Unix). Passwords are never -written to disk. None of these should be assumed to +written to disk. The admission private key and ticket are ephemeral native +values and are not persisted. None of these directories should be assumed to be the system `.minecraft` directory. ## Aeronautics contract @@ -60,8 +75,68 @@ be the system `.minecraft` directory. - ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and `cdn.shacraft.ru`. - Account API origin: fixed `https://shacraft.ru`; redirects are rejected. -- Launch identity: the most recently verified `aoc` nickname returned by the - authenticated account API. Local nickname edits cannot select an identity. +- Launch identity: the canonical `aoc` nickname returned with the admission + ticket. Local nickname edits cannot select an identity. + +## Admission contract (implementation: 2026-09-10) + +Both endpoints use the fixed account API origin, HTTPS without redirects and +the native website session as bearer authentication. The session is never +passed to the client mod or Java process. + +`POST /api/launcher/v2/admission/nickname` accepts +`{server_id: "aoc", mc_username: "Chosen_Name"}` and returns the existing +account shape `{username, links}`. The backend atomically assigns a free name +to that account. Existing player names remain reserved and require explicit +administrator migration. The launcher no longer asks the player to enter the +game to complete this claim. Legacy challenge IPC remains available to older +flows, but an admission-enabled backend must block those legacy endpoints +from bypassing reserved-name ownership. + +After installation completes, the native launcher generates a fresh Ed25519 +key using the OS CSPRNG and calls +`POST /api/launcher/v2/admission/tickets` with +`{server_id: "aoc", public_key: ""}`. +The response is `{ticket_id, mc_username, server_id, expires_in_seconds}`. +The native boundary requires a canonical 43-character base64url ticket ID, +an ASCII Minecraft nickname of 3–16 letters/digits/underscores, server `aoc` +and a positive lifetime of at most 600 seconds. The backend checks the current +account session, bound nickname and server access before issuing it. + +`admission.rs` has no secret-bearing `Debug` or `Serialize` implementation. +Only the final Java child's environment receives: + +- `SHACRAFT_ADMISSION_TICKET`: the one-use ticket ID. +- `SHACRAFT_ADMISSION_PRIVATE_KEY`: standard base64 of the Ed25519 PKCS#8 + private-key-only DER representation accepted by Java's `KeyFactory`. + +No global environment mutation, webview/IPC payload, argument substitution, +JVM argfile, settings file or log stores these values. The account operation +permit remains held from issuance through spawn so local account switching +or logout cannot race the handoff. Missing, disabled or invalid admission +responses fail the launch with a visible error; there is no legacy-name or +unsigned fallback. In this first version, a consumed or expired ticket needs +a fresh game launch from the launcher; transparent in-game reconnect is not +implemented. + +The client mod proves possession of the ephemeral private key by signing the +server's challenge. The server mod gates world entry on successful backend +verification, including one-use consumption and current account/link/access +checks. Whitelist enforcement is retained. The 2026-09-10 Aeronautics rollout +replaced its separate LoginSystem `/register` and `/login` flow; other servers +keep their existing authentication. Old launchers without admission proof +cannot join Aeronautics. A required server-only Mixin rejects a duplicate +online UUID before vanilla can disconnect the existing player. The client pins +the actual game socket to `135.106.154.86:25567`; changing that address requires +an explicit mod update. Server source and rollout records live in +`/root/shacraft/services/admission-mod` and `docs/admission-2026-09-10.md` on +the ShaCraft host. + +This protocol prevents entry without the account's current permission. It +does not attest that an original launcher or game binary is unmodified: +software running as the same user can read its own process environment, and +a compatible client can implement the protocol. Never replace account-bound +proof with a shared key embedded in distributed binaries. ## Planned but not implemented @@ -72,6 +147,8 @@ be the system `.minecraft` directory. 4. Cancellation, structured logs and a full cold-install/recovery beta on every target OS. Install progress reports bytes or installer work counts depending on the stage; these units are not interchangeable. +5. Transparent reconnect after the admission ticket has been consumed or + expired. The current implementation requires a new launch. Do not represent these as completed features in UI or release notes. @@ -105,3 +182,22 @@ The package workflow runs on main pushes or manually and builds Windows x64, Linux x64 and both macOS architectures with named artifacts. Packages are not yet signed release artifacts. Native cold-install and launch tests are required before calling a platform release-ready. + +The local admission checkpoint passed 64 Rust tests (5 live tests ignored), +22 UI unit tests, TypeScript/Vite build and a Linux x86-64 release build with +`npm run tauri:build -- --no-bundle`. Six browser scenarios with mocked Tauri +IPC covered invalid nicknames, deleted sessions, reserved-name errors, +successful claims, duplicate clicks and a session revoked during a claim. +They also checked modal feedback, Escape preserving the settings drawer and +the absence of legacy link polling. These checks used no production account +or real Minecraft connection. The Linux output is a dynamically linked +binary, not evidence of Windows/macOS support testing. + +Version 0.1.2 also produced unsigned Linux amd64 AppImage and deb packages with +`npm run tauri:build -- --bundles appimage,deb`. AppImage extraction and the +deb's version/architecture metadata were checked without running the app or +installing the package. The build host was Ubuntu 26.04; do not claim support +for older Ubuntu releases from this build. For local AppImage packaging, +linuxdeploy's GTK plugin needs `librsvg-2.0.pc` from the matching `librsvg2-dev` +package. Extracting that package into a temporary build directory and setting +`PKG_CONFIG_PATH` supplied the missing metadata without changing host packages. diff --git a/package-lock.json b/package-lock.json index 79b527c..e7daf76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shacraft-launcher-ui", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shacraft-launcher-ui", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@tauri-apps/api": "2.11.1", "lucide-react": "1.41.0", diff --git a/package.json b/package.json index d08e790..00bf7db 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "shacraft-launcher-ui", "license": "MIT", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2570698..57c2285 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3216,11 +3216,12 @@ dependencies = [ [[package]] name = "shacraft-launcher" -version = "0.1.1" +version = "0.1.2" dependencies = [ "base64 0.22.1", "ed25519-dalek", "flate2", + "getrandom 0.3.4", "md-5", "reqwest 0.12.28", "serde", @@ -3231,6 +3232,7 @@ dependencies = [ "tauri", "tauri-build", "url", + "zeroize", "zip", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 64a2ba6..b87aa88 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shacraft-launcher" -version = "0.1.1" +version = "0.1.2" description = "ShaCraft Minecraft launcher" authors = ["ShaCraft"] license = "MIT" @@ -20,7 +20,9 @@ sha1 = "0.10" sha2 = "0.10" md-5 = "0.10" base64 = "0.22" -ed25519-dalek = "2" +ed25519-dalek = { version = "2", features = ["pkcs8"] } +getrandom = "0.3" +zeroize = "1" tauri = { version = "2", features = [] } url = "2" reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] } diff --git a/src-tauri/src/admission.rs b/src-tauri/src/admission.rs new file mode 100644 index 0000000..881ffbd --- /dev/null +++ b/src-tauri/src/admission.rs @@ -0,0 +1,214 @@ +//! Ephemeral proof of possession for one Aeronautics connection. +//! +//! Neither this private key nor its ticket crosses IPC, enters launch arguments, +//! or is persisted. Only the new Java child's environment receives them. A new +//! launch obtains a new key and ticket; account session credentials stay native. + +use crate::session::PlayerIdentity; +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine, +}; +use ed25519_dalek::{ + pkcs8::{EncodePrivateKey, KeypairBytes}, + SigningKey, +}; +use serde::{Deserialize, Serialize}; +use std::process::Command; +use zeroize::Zeroizing; + +pub(crate) const TICKET_ENV: &str = "SHACRAFT_ADMISSION_TICKET"; +pub(crate) const PRIVATE_KEY_ENV: &str = "SHACRAFT_ADMISSION_PRIVATE_KEY"; +const SERVER_ID: &str = "aoc"; +const MAX_LIFETIME_SECONDS: u64 = 600; + +// Deliberately no Debug, Clone or Serialize for secret-bearing values. +pub(crate) struct AdmissionKey { + public_key: String, + private_key: Zeroizing, +} + +#[derive(Serialize)] +pub(crate) struct TicketRequest<'a> { + server_id: &'static str, + public_key: &'a str, +} + +#[derive(Deserialize)] +pub(crate) struct TicketResponse { + ticket_id: String, + mc_username: String, + server_id: String, + expires_in_seconds: u64, +} + +pub(crate) struct Admission { + ticket_id: Zeroizing, + private_key: Zeroizing, + identity: PlayerIdentity, +} + +impl AdmissionKey { + pub(crate) fn generate() -> Result { + let mut seed = Zeroizing::new([0_u8; 32]); + getrandom::fill(seed.as_mut()) + .map_err(|_| "Не удалось создать защищённый ключ входа. Повторите запуск лаунчера.")?; + let signing_key = SigningKey::from_bytes(&seed); + let public_key = STANDARD.encode(signing_key.verifying_key().to_bytes()); + // RFC 8410 PKCS#8 v1 (PrivateKeyInfo) without the optional public key. + // This is accepted by Java 21's Ed25519 KeyFactory/PKCS8EncodedKeySpec. + let key_bytes = KeypairBytes { + secret_key: signing_key.to_bytes(), + public_key: None, + }; + let encoded = key_bytes + .to_pkcs8_der() + .map_err(|_| "Не удалось подготовить защищённый ключ входа.")?; + Ok(Self { + public_key, + private_key: Zeroizing::new(STANDARD.encode(encoded.as_bytes())), + }) + } + + pub(crate) fn request(&self) -> TicketRequest<'_> { + TicketRequest { + server_id: SERVER_ID, + public_key: &self.public_key, + } + } + + pub(crate) fn bind(self, response: TicketResponse) -> Result { + let ticket_id = Zeroizing::new(response.ticket_id); + let valid_ticket = ticket_id.len() == 43 + && URL_SAFE_NO_PAD + .decode(ticket_id.as_bytes()) + .is_ok_and(|bytes| { + bytes.len() == 32 && URL_SAFE_NO_PAD.encode(bytes) == *ticket_id + }); + let valid_nickname = (3..=16).contains(&response.mc_username.len()) + && response + .mc_username + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + if !valid_ticket + || !valid_nickname + || response.server_id != SERVER_ID + || !(1..=MAX_LIFETIME_SECONDS).contains(&response.expires_in_seconds) + { + return Err("Сервер вернул некорректное разрешение на вход. Повторите попытку позже."); + } + Ok(Admission { + ticket_id, + private_key: self.private_key, + identity: PlayerIdentity::Offline { + name: response.mc_username, + }, + }) + } +} + +impl Admission { + pub(crate) fn identity(&self) -> &PlayerIdentity { + &self.identity + } + + pub(crate) fn configure_child(&self, command: &mut Command) { + command.env(TICKET_ENV, self.ticket_id.as_str()); + command.env(PRIVATE_KEY_ENV, self.private_key.as_str()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{pkcs8::DecodePrivateKey, Signer, Verifier}; + + fn response() -> TicketResponse { + TicketResponse { + ticket_id: URL_SAFE_NO_PAD.encode([37_u8; 32]), + mc_username: "Canonical_Name".into(), + server_id: SERVER_ID.into(), + expires_in_seconds: 600, + } + } + + #[test] + fn generates_distinct_keys_and_only_sends_the_public_key() { + let key = AdmissionKey::generate().unwrap(); + let other = AdmissionKey::generate().unwrap(); + assert_ne!(key.public_key, other.public_key); + let payload = serde_json::to_value(key.request()).unwrap(); + assert_eq!(payload.as_object().unwrap().len(), 2); + assert_eq!(payload["server_id"], SERVER_ID); + assert_eq!(payload["public_key"], key.public_key); + assert!(!payload.to_string().contains(key.private_key.as_str())); + let der = Zeroizing::new(STANDARD.decode(key.private_key.as_bytes()).unwrap()); + let restored = SigningKey::from_pkcs8_der(&der).unwrap(); + assert_eq!( + STANDARD.encode(restored.verifying_key().to_bytes()), + key.public_key + ); + let message = b"shacraft-admission-v1:challenge-fixture"; + restored + .verifying_key() + .verify(message, &restored.sign(message)) + .unwrap(); + // Java's standard Ed25519 encoding is the 48-byte private-key-only form. + assert_eq!(der.len(), 48); + } + + #[test] + fn rejects_untrusted_identity_ticket_server_and_expiry() { + let mutations: Vec> = vec![ + Box::new(|r| r.ticket_id = "../unsafe".into()), + Box::new(|r| r.ticket_id = "A".repeat(42) + "!"), + Box::new(|r| r.ticket_id = "A".repeat(42) + "B"), + Box::new(|r| r.mc_username = "../../outside".into()), + Box::new(|r| r.mc_username = "ab".into()), + Box::new(|r| r.mc_username = "a".repeat(17)), + Box::new(|r| r.server_id = "other".into()), + Box::new(|r| r.expires_in_seconds = 0), + Box::new(|r| r.expires_in_seconds = 601), + ]; + for mutate in mutations { + let mut payload = response(); + mutate(&mut payload); + assert!(AdmissionKey::generate().unwrap().bind(payload).is_err()); + } + } + + #[test] + fn secrets_only_enter_the_child_environment_and_identity_comes_from_ticket() { + let original_ticket = std::env::var_os(TICKET_ENV); + let original_key = std::env::var_os(PRIVATE_KEY_ENV); + let admission = AdmissionKey::generate().unwrap().bind(response()).unwrap(); + let mut command = Command::new("java"); + command + .arg("-Xmx6144M") + .arg("net.minecraft.client.main.Main"); + admission.configure_child(&mut command); + assert_eq!(admission.identity().name(), "Canonical_Name"); + let env: std::collections::HashMap<_, _> = command.get_envs().collect(); + assert_eq!( + env.get(std::ffi::OsStr::new(TICKET_ENV)).unwrap().unwrap(), + admission.ticket_id.as_str() + ); + assert_eq!( + env.get(std::ffi::OsStr::new(PRIVATE_KEY_ENV)) + .unwrap() + .unwrap(), + admission.private_key.as_str() + ); + assert_eq!(env.len(), 2); + for argument in command.get_args() { + assert!(!argument + .to_string_lossy() + .contains(admission.ticket_id.as_str())); + assert!(!argument + .to_string_lossy() + .contains(admission.private_key.as_str())); + } + assert_eq!(std::env::var_os(TICKET_ENV), original_ticket); + assert_eq!(std::env::var_os(PRIVATE_KEY_ENV), original_key); + } +} diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index 267c25d..9c07ccf 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -1,7 +1,7 @@ -use super::{data_dir, shacraft::resolve_identity}; +use super::data_dir; use crate::{ java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime, - settings, + settings, shacraft_account, }; use reqwest::blocking::Client; use serde::Serialize; @@ -172,7 +172,6 @@ pub(crate) async fn launch_game( let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; let profile_dir = data_dir.join("profiles").join(&manifest.id); let settings = settings::load(&data_dir).map_err(|error| error.to_string())?; - let identity = resolve_identity(&data_dir, &account_operation)?; // Everything here should already be installed by `ensure_game_installed`, // so these are expected to hit their fast paths; no progress to show. @@ -201,12 +200,17 @@ pub(crate) async fn launch_game( std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?; let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log")); + // Generate fresh proof only after installation. Keep the account gate + // through spawn so local logout/account switching cannot race issuance. + let _account_permit = account_operation.acquire("ShaCraft account operation")?; + let admission = + shacraft_account::issue_admission(&data_dir).map_err(|error| error.to_string())?; let request = launch::LaunchRequest { java_executable: Path::new(&java_install.executable), game_dir: &game_dir, profile_dir: &profile_dir, merged: &merged, - identity: &identity, + admission: &admission, memory_mb: settings.memory_mb, log_path: &log_path, }; diff --git a/src-tauri/src/commands/shacraft.rs b/src-tauri/src/commands/shacraft.rs index fea726d..e2b75a8 100644 --- a/src-tauri/src/commands/shacraft.rs +++ b/src-tauri/src/commands/shacraft.rs @@ -1,9 +1,6 @@ //! ShaCraft sessions and verified account links are the launch identity source. use super::data_dir; -use crate::{ - operations::{LauncherOperations, Operation}, - session, shacraft_account, -}; +use crate::{operations::LauncherOperations, shacraft_account}; use std::path::Path; use tauri::{AppHandle, State}; @@ -75,6 +72,18 @@ pub(crate) async fn shacraft_start_link( .await } +#[tauri::command] +pub(crate) async fn shacraft_claim_nickname( + app: AppHandle, + state: State<'_, LauncherOperations>, + nickname: String, +) -> Result { + account_task(&app, &state, move |directory| { + shacraft_account::claim_nickname(directory, &nickname) + }) + .await +} + #[tauri::command] pub(crate) async fn shacraft_link_status( app: AppHandle, @@ -86,35 +95,3 @@ pub(crate) async fn shacraft_link_status( }) .await } - -/// Always revalidate the server session and its aoc link. Legacy local settings -/// and Microsoft tokens do not select the identity in the ShaCraft-only flow. -pub(super) fn resolve_identity( - directory: &Path, - operation: &Operation, -) -> Result { - let _permit = operation.acquire("ShaCraft account operation")?; - let name = - shacraft_account::aeronautics_nickname(directory).map_err(|error| error.to_string())?; - Ok(session::PlayerIdentity::Offline { name }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn missing_shacraft_session_cannot_fall_back_to_legacy_nickname() { - let directory = std::env::temp_dir().join(format!( - "shacraft-identity-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap(); - assert!(resolve_identity(&directory, &Operation::default()).is_err()); - std::fs::remove_dir_all(directory).unwrap(); - } -} diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index 71ae513..5338163 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -1,11 +1,10 @@ //! Builds and spawns the real `java` invocation for a merged launch -//! profile. The `${auth_*}` placeholders are filled from a `PlayerIdentity`, -//! which is either a real Microsoft-authenticated session (`msa::LoginResult`) -//! or an explicit offline account (`PlayerIdentity::Offline`). Offline mode is -//! never silently substituted for a Microsoft session. +//! profile. The `${auth_*}` placeholders use only the identity bound to the +//! server-issued admission ticket. Its one-use proof stays out of arguments +//! and files; only the Java child's environment receives it. +use crate::admission::Admission; use crate::mojang::{self, MergedVersion}; -use crate::session::PlayerIdentity; use sha2::{Digest, Sha256}; use std::{ collections::{HashMap, HashSet}, @@ -44,7 +43,7 @@ pub struct LaunchRequest<'a> { /// the shared `game_dir`. pub profile_dir: &'a Path, pub merged: &'a MergedVersion, - pub identity: &'a PlayerIdentity, + pub admission: &'a Admission, pub memory_mb: u16, pub log_path: &'a Path, } @@ -183,6 +182,10 @@ fn write_jvm_argfile(path: &Path, arguments: &[String]) -> io::Result<()> { /// that (see `lib.rs`'s launch command, which watches it on a background /// thread and emits an event). pub fn launch(request: &LaunchRequest) -> Result { + Ok(build_command(request)?.spawn()?) +} + +fn build_command(request: &LaunchRequest) -> Result { fs::create_dir_all(request.profile_dir)?; let natives_dir = mojang::natives_directory(request.game_dir, &request.merged.id); fs::create_dir_all(&natives_dir)?; @@ -193,7 +196,8 @@ pub fn launch(request: &LaunchRequest) -> Result { let classpath = build_classpath(request.game_dir, request.merged, &client_jar); let mut vars: HashMap<&str, String> = HashMap::new(); - vars.insert("auth_player_name", request.identity.name().to_string()); + let identity = request.admission.identity(); + vars.insert("auth_player_name", identity.name().to_string()); // NeoForge's inherited JVM profile uses `${version_name}.jar` in // `-DignoreList`. The actual client jar belongs to the vanilla parent // (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this @@ -203,14 +207,11 @@ pub fn launch(request: &LaunchRequest) -> Result { vars.insert("game_directory", request.profile_dir.display().to_string()); vars.insert("assets_root", assets_root.display().to_string()); vars.insert("assets_index_name", request.merged.asset_index.id.clone()); - vars.insert("auth_uuid", request.identity.uuid()); - vars.insert( - "auth_access_token", - request.identity.access_token().to_string(), - ); + vars.insert("auth_uuid", identity.uuid()); + vars.insert("auth_access_token", identity.access_token().to_string()); vars.insert("clientid", launcher_client_id(request.game_dir)?); - vars.insert("auth_xuid", request.identity.xuid().to_string()); - vars.insert("user_type", request.identity.user_type().to_string()); + vars.insert("auth_xuid", identity.xuid().to_string()); + vars.insert("user_type", identity.user_type().to_string()); vars.insert("version_type", "ShaCraft Launcher".to_string()); vars.insert("natives_directory", natives_dir.display().to_string()); vars.insert("launcher_name", "ShaCraft Launcher".to_string()); @@ -227,6 +228,7 @@ pub fn launch(request: &LaunchRequest) -> Result { let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features); let mut command = Command::new(request.java_executable); + request.admission.configure_child(&mut command); let memory_argument = format!("-Xmx{}M", request.memory_mb); if cfg!(windows) { let argfile = request.profile_dir.join(".shacraft-jvm.args"); @@ -249,13 +251,79 @@ pub fn launch(request: &LaunchRequest) -> Result { command.stdout(Stdio::from(log_file.try_clone()?)); command.stderr(Stdio::from(log_file)); - Ok(command.spawn()?) + Ok(command) } #[cfg(test)] mod tests { use super::*; + #[test] + fn launch_arguments_and_written_files_never_contain_admission_secrets() { + use crate::admission::{AdmissionKey, PRIVATE_KEY_ENV, TICKET_ENV}; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + + let directory = + std::env::temp_dir().join(format!("shacraft-launch-proof-{}", random_uuid_v4())); + let game_dir = directory.join("game"); + let profile_dir = directory.join("profile"); + let log_path = directory.join("game.log"); + let vanilla: mojang::VersionJson = serde_json::from_value(serde_json::json!({ + "id": "1.21.1", + "mainClass": "net.minecraft.client.main.Main", + "arguments": { + "game": ["--username", "${auth_player_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}"], + "jvm": ["-cp", "${classpath}", "-Dlauncher=${launcher_name}"] + }, + "assetIndex": {"id": "17", "sha1": "0".repeat(40), "size": 1, "url": "https://piston-meta.mojang.com/assets"}, + "downloads": {"client": {"sha1": "0".repeat(40), "size": 1, "url": "https://piston-data.mojang.com/client.jar"}} + })).unwrap(); + let merged = mojang::merge_versions(&vanilla, None).unwrap(); + let response = serde_json::from_value(serde_json::json!({ + "ticket_id": URL_SAFE_NO_PAD.encode([73_u8; 32]), "mc_username": "Ticket_Name", + "server_id": "aoc", "expires_in_seconds": 600 + })) + .unwrap(); + let admission = AdmissionKey::generate().unwrap().bind(response).unwrap(); + let request = LaunchRequest { + java_executable: Path::new("java"), + game_dir: &game_dir, + profile_dir: &profile_dir, + merged: &merged, + admission: &admission, + memory_mb: 6144, + log_path: &log_path, + }; + let command = build_command(&request).unwrap(); + let env: HashMap<_, _> = command.get_envs().collect(); + let proof = [TICKET_ENV, PRIVATE_KEY_ENV] + .map(|name| env[std::ffi::OsStr::new(name)].unwrap().to_str().unwrap()); + let arguments: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect(); + assert!(arguments + .windows(2) + .any(|args| args == ["--username", "Ticket_Name"])); + assert!(arguments + .windows(2) + .any(|args| args == ["--accessToken", "0"])); + for secret in proof { + assert!(arguments.iter().all(|argument| !argument.contains(secret))); + for path in [ + log_path.clone(), + game_dir.join(".shacraft-client-id"), + profile_dir.join(".shacraft-jvm.args"), + ] { + if path.exists() { + assert!(!fs::read_to_string(path).unwrap().contains(secret)); + } + } + } + drop(command); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn generates_rfc4122_version_4_uuids() { let id = random_uuid_v4(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 325ad9f..b7c1adc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod admission; mod download; mod java; mod launch; @@ -34,6 +35,7 @@ pub fn run() { commands::shacraft::get_shacraft_account, commands::shacraft::shacraft_logout, commands::shacraft::shacraft_start_link, + commands::shacraft::shacraft_claim_nickname, commands::shacraft::shacraft_link_status, commands::account::start_microsoft_login, commands::account::get_account, diff --git a/src-tauri/src/shacraft_account.rs b/src-tauri/src/shacraft_account.rs index 39fb801..467e636 100644 --- a/src-tauri/src/shacraft_account.rs +++ b/src-tauri/src/shacraft_account.rs @@ -6,7 +6,12 @@ use reqwest::blocking::{Client, Response}; use reqwest::redirect::Policy; use serde::{Deserialize, Serialize}; -use std::{fmt, fs, io, path::Path, time::Duration}; +use std::{ + fmt, fs, + io::{self, Read}, + path::Path, + time::Duration, +}; const API_ORIGIN: &str = "https://shacraft.ru"; const SESSION_FILE: &str = "shacraft-session"; @@ -62,7 +67,6 @@ pub enum AccountError { Api(String), Io(io::Error), InvalidSession, - NoLinkedNickname, } impl fmt::Display for AccountError { @@ -72,9 +76,6 @@ impl fmt::Display for AccountError { Self::Api(message) => formatter.write_str(message), Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"), Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"), - Self::NoLinkedNickname => { - formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics") - } } } } @@ -221,18 +222,95 @@ pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result().map_err(AccountError::Network) } -pub fn aeronautics_nickname(data_dir: &Path) -> Result { - get_account(data_dir)? - .links - .into_iter() - .find(|link| link.server_id == "aoc") - .map(|link| link.mc_username) - .ok_or(AccountError::NoLinkedNickname) +const ADMISSION_ENDPOINT: &str = "/api/launcher/v2/admission/tickets"; + +/// Error responses at this boundary never echo arbitrary response bodies: a +/// misconfigured proxy/service must not copy credentials into UI diagnostics. +fn admission_error(status: reqwest::StatusCode) -> AccountError { + use reqwest::StatusCode; + match status { + StatusCode::UNAUTHORIZED => AccountError::InvalidSession, + StatusCode::FORBIDDEN => AccountError::Api( + "Нет разрешения на вход в Aeronautics. Проверьте привязку ника и доступ к серверу в аккаунте ShaCraft.".into()), + StatusCode::CONFLICT => AccountError::Api( + "Этот ник уже занят или зарезервирован. Если это ваш игровой ник, обратитесь в поддержку ShaCraft.".into()), + StatusCode::NOT_FOUND | StatusCode::SERVICE_UNAVAILABLE => AccountError::Api( + "Вход через ShaCraft Launcher пока не настроен на сервере. Повторите попытку позже.".into()), + StatusCode::TOO_MANY_REQUESTS => AccountError::Api( + "Слишком много запросов входа. Подождите немного и повторите попытку.".into()), + _ => AccountError::Api(format!("Не удалось получить разрешение ShaCraft: HTTP {status}")), + } +} + +fn checked_admission_response( + data_dir: &Path, + response: Response, +) -> Result { + if response.status().is_success() { + return Ok(response); + } + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + let _ = fs::remove_file(session_path(data_dir)); + } + Err(admission_error(response.status())) +} + +/// The server reserves a free nickname atomically for this account. Existing +/// player names remain reserved for administrator-assisted migration. +pub fn claim_nickname(data_dir: &Path, nickname: &str) -> Result { + let token = load_session(data_dir)?; + let response = client()? + .post(format!("{API_ORIGIN}/api/launcher/v2/admission/nickname")) + .bearer_auth(token) + .json(&serde_json::json!({"server_id": "aoc", "mc_username": nickname})) + .send() + .map_err(AccountError::Network)?; + checked_admission_response(data_dir, response)? + .json() + .map_err(AccountError::Network) +} + +/// Called only after installation, immediately before Java spawn. Nothing in +/// this response is exposed to the webview or persisted with account settings. +pub(crate) fn issue_admission( + data_dir: &Path, +) -> Result { + let token = load_session(data_dir)?; + let key = crate::admission::AdmissionKey::generate() + .map_err(|message| AccountError::Api(message.into()))?; + let response = client()? + .post(format!("{API_ORIGIN}{ADMISSION_ENDPOINT}")) + .bearer_auth(token) + .json(&key.request()) + .send() + .map_err(AccountError::Network)?; + let response = checked_admission_response(data_dir, response)?; + // The expected object is under 256 bytes; bound the remote allocation and + // use a fixed parse error without response values or secret-bearing bodies. + let mut body = zeroize::Zeroizing::new(Vec::new()); + response.take(4097).read_to_end(&mut body).map_err(|_| { + AccountError::Api( + "Не удалось прочитать разрешение на вход. Повторите попытку позже.".into(), + ) + })?; + let invalid = || { + AccountError::Api( + "Сервер вернул некорректное разрешение на вход. Повторите попытку позже.".into(), + ) + }; + if body.len() > 4096 { + return Err(invalid()); + } + let payload = serde_json::from_slice(&body).map_err(|_| invalid())?; + key.bind(payload) + .map_err(|message| AccountError::Api(message.into())) } #[cfg(test)] mod tests { - use super::{load_session, save_session, session_path}; + use super::{ + admission_error, issue_admission, load_session, save_session, session_path, AccountError, + }; use std::{ fs, process, time::{SystemTime, UNIX_EPOCH}, @@ -275,4 +353,34 @@ mod tests { ); fs::remove_dir_all(directory).unwrap(); } + + #[test] + fn admission_without_session_never_falls_back_to_legacy_nickname() { + let directory = temporary_directory(); + crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap(); + assert!(matches!( + issue_admission(&directory), + Err(AccountError::InvalidSession) + )); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn admission_unavailable_and_revoked_session_have_actionable_errors() { + for status in [ + reqwest::StatusCode::NOT_FOUND, + reqwest::StatusCode::SERVICE_UNAVAILABLE, + ] { + assert!(admission_error(status) + .to_string() + .contains("пока не настроен")); + } + assert!(matches!( + admission_error(reqwest::StatusCode::UNAUTHORIZED), + AccountError::InvalidSession + )); + assert!(admission_error(reqwest::StatusCode::CONFLICT) + .to_string() + .contains("зарезервирован")); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bffddb0..685af1b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShaCraft Launcher", - "version": "0.1.1", + "version": "0.1.2", "identifier": "ru.shacraft.launcher", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index da97d00..7ca9da1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { FeedbackDialog, type Feedback } from './components/FeedbackDialog' import { Library } from './components/Library' import { RecoveryCodesModal } from './components/RecoveryCodesModal' import { PlayDock } from './components/PlayDock' @@ -18,6 +19,7 @@ export function App() { const [selected, setSelected] = useState(servers[0]) const [settingsOpen, setSettingsOpen] = useState(false) const [windowError, setWindowError] = useState(null) + const [errorFeedback, setErrorFeedback] = useState(null) const preferences = useSettings() const session = useAccount() const launcher = useLauncher() @@ -35,6 +37,9 @@ export function App() { (access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady)) const repairDisabled = !desktop || busy || checking const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null + useEffect(() => { + if (error) setErrorFeedback({ kind: 'error', title: 'Ошибка лаунчера', message: error }) + }, [error]) let label = 'Играть' if (!desktop) label = 'В приложении' @@ -73,6 +78,8 @@ export function App() { + { session.dismissFeedback(); setErrorFeedback(null) }} /> ) } diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx index aad6ce9..e60698f 100644 --- a/src/components/AccountSettings.tsx +++ b/src/components/AccountSettings.tsx @@ -41,15 +41,15 @@ export function AccountSettings({ session, locked }: { session: ReturnType )} {account && !linkedNickname && ( -
{ event.preventDefault(); if (!disabled) void session.startLink(nickname) }}> + { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
)} diff --git a/src/components/FeedbackDialog.tsx b/src/components/FeedbackDialog.tsx new file mode 100644 index 0000000..2d0a07f --- /dev/null +++ b/src/components/FeedbackDialog.tsx @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react' + +export interface Feedback { + kind: 'info' | 'success' | 'error' + title: string + message: string +} + +export function FeedbackDialog({ feedback, onDismiss }: { feedback: Feedback | null; onDismiss: () => void }) { + const dialog = useRef(null) + const visible = feedback !== null + useEffect(() => { + const element = dialog.current + if (!visible || !element) return + const previous = document.activeElement + element.showModal() + return () => { + element.close() + if (previous instanceof HTMLElement && previous.isConnected) previous.focus() + } + }, [visible]) + if (!feedback) return null + return ( + { event.preventDefault(); onDismiss() }} + onKeyDown={(event) => event.stopPropagation()}> +
+

{feedback.title}

+

{feedback.message}

+
+ +
+ ) +} diff --git a/src/components/account.test.tsx b/src/components/account.test.tsx index 525c0ff..ae794cf 100644 --- a/src/components/account.test.tsx +++ b/src/components/account.test.tsx @@ -8,6 +8,7 @@ import type { useAccount } from '../hooks/useAccount' function session(): ReturnType { return { account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null, + feedback: null, dismissFeedback: () => {}, linking: false, linkedNickname: null, authenticate: async () => true, logout: async () => {}, startLink: async () => {}, clearError: () => {}, acknowledgeRecoveryCodes: () => {}, diff --git a/src/hooks/useAccount.ts b/src/hooks/useAccount.ts index 1bf4335..c4cc364 100644 --- a/src/hooks/useAccount.ts +++ b/src/hooks/useAccount.ts @@ -1,24 +1,23 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { Feedback } from '../components/FeedbackDialog' import { createRequestScope, errorMessage } from '../services/async' import { isNative, native } from '../services/native' import { linkedNickname, validCredentials } from '../state/account' import { isValidNickname } from '../state/settings' import type { ShaCraftAccount } from '../types/launcher' -interface PendingLink { - challengeId: number - expiresAt: number - isCurrent: () => boolean -} - export function useAccount() { // undefined = restoring saved account; null = signed out. const [account, setAccount] = useState(isNative() ? undefined : null) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const [recoveryCodes, setRecoveryCodes] = useState([]) - const [challenge, setChallenge] = useState(null) const [linkMessage, setLinkMessage] = useState(null) + const [feedback, setFeedback] = useState(null) + const reportLink = useCallback((message: string, kind: Feedback['kind'] = 'info') => { + setLinkMessage(message) + setFeedback({ kind, title: kind === 'error' ? 'Не удалось привязать ник' : 'Привязка игрового ника', message }) + }, []) const pending = useRef(false) const requests = useRef(createRequestScope()) @@ -35,44 +34,6 @@ export function useAccount() { return () => { active = false; requests.current.invalidate() } }, []) - useEffect(() => { - if (!challenge) return - let active = true - let timer: ReturnType | undefined - const current = () => active && challenge.isCurrent() - const poll = async () => { - if (!current()) return - if (Date.now() >= challenge.expiresAt) { - setChallenge(null) - setLinkMessage('Срок проверки истёк. Начните привязку ещё раз.') - return - } - try { - const result = await native.linkStatus(challenge.challengeId) - if (!current()) return - if (result.status === 'verified') { - const refreshed = await native.getAccount() - if (!current()) return - setAccount(refreshed) - setChallenge(null) - setLinkMessage(linkedNickname(refreshed) ? 'Ник подтверждён.' : 'Не удалось подтвердить привязку. Войдите снова.') - } else if (result.status === 'expired' || result.status === 'conflict') { - setChallenge(null) - setLinkMessage(result.detail || 'Проверка завершилась. Попробуйте ещё раз.') - } else { - // One request at a time; dispose and logout cancel future polling. - timer = setTimeout(() => { void poll() }, 3_000) - } - } catch (reason) { - if (!current()) return - setChallenge(null) - setLinkMessage(errorMessage(reason, 'Не удалось проверить ник')) - } - } - timer = setTimeout(() => { void poll() }, 3_000) - return () => { active = false; clearTimeout(timer) } - }, [challenge]) - const authenticate = async (username: string, password: string, register: boolean) => { if (pending.current || account === undefined) return false if (!validCredentials(username, password)) { @@ -92,7 +53,6 @@ export function useAccount() { const result = await native.authenticate(username, password, register) if (!currentRequest()) return false setAccount(result.account) - setChallenge(null) setLinkMessage(null) setRecoveryCodes(result.recoveryCodes) return true @@ -109,7 +69,6 @@ export function useAccount() { if (!isNative() || pending.current) return pending.current = true requests.current.invalidate() - setChallenge(null) setLinkMessage(null) setBusy(true) setError(null) @@ -126,26 +85,42 @@ export function useAccount() { } const startLink = async (nickname: string) => { - if (!isNative() || pending.current || challenge || !account) return + if (pending.current) return + if (!isNative()) { + reportLink('Привязка доступна в приложении лаунчера.', 'error') + return + } + if (!account) { + reportLink('Войдите в аккаунт ShaCraft, затем повторите привязку.', 'error') + return + } + nickname = nickname.trim() if (!isValidNickname(nickname)) { - setLinkMessage('Ник: 3–16 латинских букв, цифр или _') + reportLink('Ник: 3–16 латинских букв, цифр или _', 'error') return } pending.current = true setBusy(true) - setLinkMessage('Создаём проверку…') + setError(null) + reportLink('Проверяем аккаунт и закрепляем ник…') requests.current.invalidate() const currentRequest = requests.current.capture() try { - const started = await native.startLink(nickname) + const refreshed = await native.getAccount() if (!currentRequest()) return - setLinkMessage(started.registered_on_server - ? 'Зайдите на Aeronautics с этим ником и выполните /login.' - : 'Зайдите на Aeronautics с этим ником и выполните /register.') - setChallenge({ challengeId: started.challenge_id, - expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: currentRequest }) + setAccount(refreshed) + if (!refreshed) { + reportLink('Сессия завершена или аккаунт удалён. Войдите в ShaCraft снова; если аккаунт удалён, создайте новый.', 'error') + return + } + const linkedAccount = await native.claimNickname(nickname) + if (!currentRequest()) return + setAccount(linkedAccount) + const confirmed = linkedNickname(linkedAccount) + reportLink(confirmed ? `Ник ${confirmed} закреплён за аккаунтом. Теперь можно запускать игру.` + : 'Не удалось получить закреплённый ник. Войдите снова.', confirmed ? 'success' : 'error') } catch (reason) { - setLinkMessage(errorMessage(reason, 'Не удалось начать привязку')) + if (currentRequest()) reportLink(errorMessage(reason, 'Не удалось начать привязку'), 'error') } finally { pending.current = false setBusy(false) @@ -153,7 +128,8 @@ export function useAccount() { } return { - account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null, + account, error, busy, recoveryCodes, linkMessage, linking: false, + feedback, dismissFeedback: () => setFeedback(null), linkedNickname: linkedNickname(account), authenticate, logout, startLink, clearError: () => setError(null), acknowledgeRecoveryCodes: () => setRecoveryCodes([]), diff --git a/src/services/native.ts b/src/services/native.ts index 116ca76..c620490 100644 --- a/src/services/native.ts +++ b/src/services/native.ts @@ -26,6 +26,7 @@ export const native = { accountRequests.enqueue(() => invoke('shacraft_authenticate', { username, password, register })), logout: () => accountRequests.enqueue(() => invoke('shacraft_logout')), startLink: (nickname: string) => accountRequests.enqueue(() => invoke('shacraft_start_link', { nickname })), + claimNickname: (nickname: string) => accountRequests.enqueue(() => invoke('shacraft_claim_nickname', { nickname })), linkStatus: (challengeId: number) => accountRequests.enqueue(() => invoke('shacraft_link_status', { challengeId })), serverStatus: (profileId: string) => invoke('get_server_status', { profileId }), installGame: (profileId: string) => invoke('ensure_game_installed', { profileId }), diff --git a/src/styles.css b/src/styles.css index aa91dfd..6a77b5c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -166,6 +166,14 @@ button:disabled { cursor: default; } .login-code { font-family: 'Unbounded'; font-size: 28px; letter-spacing: .08em; color: var(--green); background: #191f1a; border-radius: 10px; padding: 14px; margin-bottom: 12px; } .login-url { color: var(--ice) !important; font-size: 11px !important; word-break: break-all; } +.feedback-dialog { width: min(440px, calc(100vw - 40px)); max-height: calc(100vh - 80px); overflow-y: auto; padding: 28px; margin: auto; color: #e9eee9; background: #151d17; border: 1px solid #456348; border-radius: 16px; box-shadow: 0 24px 90px #0009; } +.feedback-dialog::backdrop { background: #050a07b8; } +.feedback-dialog.error { border-color: #a46c54; } +.feedback-dialog h2 { margin: 0 0 14px; font-size: 19px; } +.feedback-dialog p { margin: 0 0 24px; color: #c1cbc2; font-size: 14px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; } +.feedback-dialog button { width: 100%; padding: 12px; border: 0; border-radius: 9px; background: var(--green); color: #102112; font: inherit; font-weight: 650; cursor: pointer; } +.feedback-dialog button:focus-visible { outline: 2px solid #e5ffe8; outline-offset: 4px; } + @media (max-width: 1160px) { .workspace { grid-template-columns: 58px 224px 1fr; } .brand { width: 240px; }