Add account admission tickets and nickname feedback for launcher 0.1.2

This commit is contained in:
Emil
2026-09-10 01:30:50 +03:00
parent 3f3be65a3f
commit bf43254c57
21 changed files with 730 additions and 160 deletions
+37 -8
View File
@@ -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
+45 -4
View File
@@ -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 доказательством
неизменённости клиентского бинарника.
+106 -10
View File
@@ -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: "<standard base64 raw 32-byte 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.
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
]
+4 -2
View File
@@ -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"] }
+214
View File
@@ -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<String>,
}
#[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<String>,
private_key: Zeroizing<String>,
identity: PlayerIdentity,
}
impl AdmissionKey {
pub(crate) fn generate() -> Result<Self, &'static str> {
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<Admission, &'static str> {
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<Box<dyn Fn(&mut TicketResponse)>> = 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);
}
}
+8 -4
View File
@@ -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,
};
+13 -36
View File
@@ -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<shacraft_account::Account, String> {
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<session::PlayerIdentity, String> {
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();
}
}
+83 -15
View File
@@ -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<Child, LaunchError> {
Ok(build_command(request)?.spawn()?)
}
fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
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<Child, LaunchError> {
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<Child, LaunchError> {
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<Child, LaunchError> {
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<Child, LaunchError> {
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();
+2
View File
@@ -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,
+121 -13
View File
@@ -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<LinkStatus, Acc
response.json::<LinkStatus>().map_err(AccountError::Network)
}
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
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<Response, AccountError> {
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<Account, AccountError> {
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<crate::admission::Admission, AccountError> {
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("зарезервирован"));
}
}
+1 -1
View File
@@ -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",
+8 -1
View File
@@ -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<string | null>(null)
const [errorFeedback, setErrorFeedback] = useState<Feedback | null>(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() {
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
<FeedbackDialog feedback={session.recoveryCodes.length ? null : session.feedback ?? errorFeedback}
onDismiss={() => { session.dismissFeedback(); setErrorFeedback(null) }} />
</div>
)
}
+3 -3
View File
@@ -41,15 +41,15 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
</form>
)}
{account && !linkedNickname && (
<form onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
<form noValidate onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
<label className="text-setting">
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
<small>Подтвердите владение ником на сервере Aeronautics.</small>
<small>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
</label>
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник'}</span>
<span>{session.linking ? 'Ожидаем подтверждения…' : busy ? 'Создаём проверку…' : 'Привязать ник'}</span>
</button>
</form>
)}
+34
View File
@@ -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<HTMLDialogElement>(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 (
<dialog ref={dialog} className={`feedback-dialog ${feedback.kind}`} aria-labelledby="feedback-title"
aria-describedby="feedback-message" onCancel={(event) => { event.preventDefault(); onDismiss() }}
onKeyDown={(event) => event.stopPropagation()}>
<div aria-live={feedback.kind === 'error' ? 'assertive' : 'polite'} aria-atomic="true">
<h2 id="feedback-title">{feedback.title}</h2>
<p id="feedback-message">{feedback.message}</p>
</div>
<button type="button" autoFocus onClick={onDismiss}>Понятно</button>
</dialog>
)
}
+1
View File
@@ -8,6 +8,7 @@ import type { useAccount } from '../hooks/useAccount'
function session(): ReturnType<typeof useAccount> {
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: () => {},
+35 -59
View File
@@ -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<ShaCraftAccount | null | undefined>(isNative() ? undefined : null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const [challenge, setChallenge] = useState<PendingLink | null>(null)
const [linkMessage, setLinkMessage] = useState<string | null>(null)
const [feedback, setFeedback] = useState<Feedback | null>(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<typeof setTimeout> | 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([]),
+1
View File
@@ -26,6 +26,7 @@ export const native = {
accountRequests.enqueue(() => invoke<ShaCraftLoginResult>('shacraft_authenticate', { username, password, register })),
logout: () => accountRequests.enqueue(() => invoke<void>('shacraft_logout')),
startLink: (nickname: string) => accountRequests.enqueue(() => invoke<LinkChallenge>('shacraft_start_link', { nickname })),
claimNickname: (nickname: string) => accountRequests.enqueue(() => invoke<ShaCraftAccount>('shacraft_claim_nickname', { nickname })),
linkStatus: (challengeId: number) => accountRequests.enqueue(() => invoke<LinkStatus>('shacraft_link_status', { challengeId })),
serverStatus: (profileId: string) => invoke<ServerStatus>('get_server_status', { profileId }),
installGame: (profileId: string) => invoke<void>('ensure_game_installed', { profileId }),
+8
View File
@@ -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; }