feat: require ShaCraft account for launcher identity

This commit is contained in:
Emil
2026-09-07 14:26:37 +03:00
parent 6311a7c502
commit 513be6274f
5 changed files with 410 additions and 126 deletions
+9 -9
View File
@@ -39,15 +39,13 @@ payload are in `/root/shacraft` on the ShaCraft host; see
independent, hardcoded-host trust domains (Mojang, NeoForge, Microsoft, independent, hardcoded-host trust domains (Mojang, NeoForge, Microsoft,
Adoptium) that install and run the actual game. Do not let manifest data Adoptium) that install and run the actual game. Do not let manifest data
control a URL in any of those domains. control a URL in any of those domains.
- Account modes: the launcher supports launching as either a genuine - ShaCraft accounts: `src-tauri/src/shacraft_account.rs` talks only to the
Microsoft account that owns Minecraft Java Edition (`src-tauri/src/msa.rs`, hardcoded `https://shacraft.ru` origin. Passwords are never persisted. The
device-code OAuth -> Xbox Live -> XSTS -> Minecraft Services) or as a local revocable session token is stored locally with mode 600 on Unix. At launch,
offline profile (nickname + deterministic offline UUID, see the nickname is fetched from the verified `aoc` account link; the legacy
`src-tauri/src/session.rs`). The mode is an explicit player choice nickname in `settings.json` is ignored as an identity source. Server-side
(`account_mode` in settings); offline is never silently substituted for a whitelist enforcement and LoginSystem remain the final access-control
Microsoft session. The mc-aoc/mc-create servers' own `ONLINE_MODE=FALSE` + boundary, including for old launcher versions.
whitelist + Login System are a separate, independent access-control layer
on the server side.
## Layout ## Layout
@@ -66,6 +64,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
`MSA_CLIENT_ID`'s doc comment before touching login — it is currently a `MSA_CLIENT_ID`'s doc comment before touching login — it is currently a
placeholder pending ShaCraft's own Azure AD app registration and placeholder pending ShaCraft's own Azure AD app registration and
Minecraft-API approval. 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. - `launch.rs` — builds and spawns the actual `java` process.
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward - `src-tauri/src/settings.rs` — durable local preferences; maintain backward
compatibility with already-written JSON. compatibility with already-written JSON.
+12 -7
View File
@@ -4,10 +4,10 @@
The launcher persists local settings, synchronises Aeronautics mod/config The launcher persists local settings, synchronises Aeronautics mod/config
files from the signed ShaCraft v2 manifest, installs the exact Minecraft + files from the signed ShaCraft v2 manifest, installs the exact Minecraft +
NeoForge version the manifest specifies, and launches the game. Players can NeoForge version the manifest specifies, and launches the game. A player
launch either with a real Microsoft account or with a local offline profile signs in with the same local ShaCraft account used on the website. The game
(nickname + deterministic offline UUID) — see `docs/game-trust-boundary.md` identity is derived only from that account's verified Aeronautics nickname;
and `AGENTS.md`'s trust model section. the legacy editable nickname setting is not trusted at launch.
The interface also shows a live Aeronautics player count from the fixed, 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: read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only:
@@ -33,7 +33,8 @@ Game itself (never controlled by the manifest above)
-> NeoForge's own installer, run headlessly (neoforge.rs) -> NeoForge's own installer, run headlessly (neoforge.rs)
-> generic inheritsFrom merge of the two version JSONs (mojang.rs) -> generic inheritsFrom merge of the two version JSONs (mojang.rs)
-> SHA-1-verified merged libraries + platform natives (mojang.rs) -> SHA-1-verified merged libraries + platform natives (mojang.rs)
-> real Microsoft/Xbox/Minecraft Services login (msa.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) -> java process spawned with the merged classpath/args (launch.rs)
``` ```
@@ -42,8 +43,9 @@ screenshots/resourcepacks) live below Tauri's `app_data_dir()/profiles/
<profile-id>` — this becomes `--gameDir`. The shared vanilla+NeoForge <profile-id>` — this becomes `--gameDir`. The shared vanilla+NeoForge
install (versions/libraries/assets/runtime, reused across profiles that install (versions/libraries/assets/runtime, reused across profiles that
target the same Minecraft version) lives at `app_data_dir()/game`. Settings target the same Minecraft version) lives at `app_data_dir()/game`. Settings
live at `app_data_dir()/settings.json`, the Microsoft refresh token at live at `app_data_dir()/settings.json`, and the revocable ShaCraft session at
`app_data_dir()/account.json` (mode 600). None of these should be assumed to `app_data_dir()/shacraft-session` (mode 600 on Unix). Passwords are never
written to disk. None of these should be assumed to
be the system `.minecraft` directory. be the system `.minecraft` directory.
## Aeronautics contract ## Aeronautics contract
@@ -57,6 +59,9 @@ be the system `.minecraft` directory.
no launcher release. no launcher release.
- ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and - ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and
`cdn.shacraft.ru`. `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.
## Planned but not implemented ## Planned but not implemented
+54 -17
View File
@@ -9,6 +9,7 @@ mod profile;
mod remote; mod remote;
mod runtime; mod runtime;
mod session; mod session;
mod shacraft_account;
mod settings; mod settings;
use reqwest::blocking::Client; use reqwest::blocking::Client;
@@ -162,6 +163,48 @@ async fn save_settings(app: AppHandle, settings: settings::LauncherSettings) ->
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
#[tauri::command]
async fn shacraft_authenticate(app: AppHandle, username: String, password: String, register: bool) -> Result<shacraft_account::LoginResult, String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || shacraft_account::authenticate(&data_dir, &username, &password, register))
.await.map_err(|error| format!("Account task failed: {error}"))?
.map_err(|error| error.to_string())
}
#[tauri::command]
async fn get_shacraft_account(app: AppHandle) -> Result<Option<shacraft_account::Account>, String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || match shacraft_account::get_account(&data_dir) {
Ok(account) => Ok(Some(account)),
Err(shacraft_account::AccountError::InvalidSession) => Ok(None),
Err(error) => Err(error.to_string()),
}).await.map_err(|error| format!("Account task failed: {error}"))?
}
#[tauri::command]
async fn shacraft_logout(app: AppHandle) -> Result<(), String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || shacraft_account::logout(&data_dir))
.await.map_err(|error| format!("Account task failed: {error}"))?
.map_err(|error| error.to_string())
}
#[tauri::command]
async fn shacraft_start_link(app: AppHandle, nickname: String) -> Result<shacraft_account::LinkStart, String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || shacraft_account::start_link(&data_dir, "aoc", &nickname))
.await.map_err(|error| format!("Link task failed: {error}"))?
.map_err(|error| error.to_string())
}
#[tauri::command]
async fn shacraft_link_status(app: AppHandle, challenge_id: i64) -> Result<shacraft_account::LinkStatus, String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || shacraft_account::link_status(&data_dir, challenge_id))
.await.map_err(|error| format!("Link task failed: {error}"))?
.map_err(|error| error.to_string())
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Microsoft account login // Microsoft account login
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -248,23 +291,12 @@ async fn logout(app: AppHandle) -> Result<(), String> {
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
/// Resolves the identity to launch as, based on the persisted `account_mode`. /// Resolves the identity from the server-side ShaCraft account link. Local
/// In `Microsoft` mode this requires a real signed-in session (see /// settings are deliberately not trusted for a nickname, so editing an old
/// `msa::login_with_refresh_token`) and returns an error if there is none; /// settings file cannot change the identity used by this launcher.
/// in `Offline` mode it uses the local nickname from settings, so no fn resolve_identity(_client: &Client, data_dir: &Path) -> Result<session::PlayerIdentity, String> {
/// Microsoft account is needed at all. Offline is never silently used in let name = shacraft_account::aeronautics_nickname(data_dir).map_err(|error| error.to_string())?;
/// place of a missing Microsoft session. Ok(session::PlayerIdentity::Offline { name })
fn resolve_identity(client: &Client, data_dir: &Path) -> Result<session::PlayerIdentity, String> {
let settings = settings::load(data_dir).map_err(|error| error.to_string())?;
match settings.account_mode {
settings::AccountMode::Offline => Ok(session::PlayerIdentity::Offline { name: settings.nickname }),
settings::AccountMode::Microsoft => {
let refresh_token = msa::load_refresh_token(data_dir).ok_or("Not signed in with a Microsoft account")?;
let result = msa::login_with_refresh_token(client, &refresh_token).map_err(|error| error.to_string())?;
let _ = msa::save_refresh_token(data_dir, &result.refresh_token);
Ok(session::PlayerIdentity::Microsoft(result))
}
}
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -416,6 +448,11 @@ pub fn run() {
get_server_status, get_server_status,
load_settings, load_settings,
save_settings, save_settings,
shacraft_authenticate,
get_shacraft_account,
shacraft_logout,
shacraft_start_link,
shacraft_link_status,
start_microsoft_login, start_microsoft_login,
get_account, get_account,
logout, logout,
+214
View File
@@ -0,0 +1,214 @@
//! ShaCraft local-account client.
//!
//! The API origin is fixed in the binary. Passwords are sent only over HTTPS
//! and are never persisted; only the random, revocable session token is kept.
use reqwest::blocking::{Client, Response};
use reqwest::redirect::Policy;
use serde::{Deserialize, Serialize};
use std::{fmt, fs, io, path::Path, time::Duration};
const API_ORIGIN: &str = "https://shacraft.ru";
const SESSION_FILE: &str = "shacraft-session";
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AccountLink {
pub server_id: String,
pub mc_username: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Account {
pub username: String,
pub links: Vec<AccountLink>,
}
#[derive(Deserialize)]
struct AuthResponse {
session_token: String,
account: Account,
recovery_codes: Vec<String>,
}
#[derive(Serialize)]
struct Credentials<'a> {
username: &'a str,
password: &'a str,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginResult {
pub account: Account,
pub recovery_codes: Vec<String>,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct LinkStart {
pub challenge_id: i64,
pub expires_in_seconds: u64,
pub registered_on_server: bool,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct LinkStatus {
pub status: String,
pub detail: Option<String>,
}
#[derive(Debug)]
pub enum AccountError {
Network(reqwest::Error),
Api(String),
Io(io::Error),
InvalidSession,
NoLinkedNickname,
}
impl fmt::Display for AccountError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(error) => write!(formatter, "Нет связи с аккаунтами ShaCraft: {error}"),
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"),
}
}
}
fn client() -> Result<Client, AccountError> {
Client::builder()
.timeout(Duration::from_secs(20))
.redirect(Policy::none())
.build()
.map_err(AccountError::Network)
}
fn api_error(response: Response) -> AccountError {
#[derive(Deserialize)]
struct ErrorBody { detail: Option<String> }
let status = response.status();
let detail = response.json::<ErrorBody>().ok().and_then(|body| body.detail);
AccountError::Api(detail.unwrap_or_else(|| format!("ShaCraft API: HTTP {status}")))
}
fn session_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(SESSION_FILE)
}
fn save_session(data_dir: &Path, token: &str) -> Result<(), AccountError> {
fs::create_dir_all(data_dir).map_err(AccountError::Io)?;
let path = session_path(data_dir);
let temporary = data_dir.join(".shacraft-session.part");
fs::write(&temporary, token.as_bytes()).map_err(AccountError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)).map_err(AccountError::Io)?;
}
fs::rename(temporary, path).map_err(AccountError::Io)
}
fn load_session(data_dir: &Path) -> Result<String, AccountError> {
let token = fs::read_to_string(session_path(data_dir)).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound { AccountError::InvalidSession } else { AccountError::Io(error) }
})?;
let token = token.trim();
if token.len() < 32 || token.bytes().any(|byte| byte.is_ascii_whitespace()) {
return Err(AccountError::InvalidSession);
}
Ok(token.to_owned())
}
pub fn authenticate(data_dir: &Path, username: &str, password: &str, register: bool) -> Result<LoginResult, AccountError> {
let endpoint = if register { "/api/launcher/auth/register" } else { "/api/launcher/auth/login" };
let response = client()?.post(format!("{API_ORIGIN}{endpoint}"))
.json(&Credentials { username, password }).send().map_err(AccountError::Network)?;
if !response.status().is_success() { return Err(api_error(response)); }
let payload = response.json::<AuthResponse>().map_err(AccountError::Network)?;
save_session(data_dir, &payload.session_token)?;
Ok(LoginResult { account: payload.account, recovery_codes: payload.recovery_codes })
}
pub fn get_account(data_dir: &Path) -> Result<Account, AccountError> {
let token = load_session(data_dir)?;
let response = client()?.get(format!("{API_ORIGIN}/api/launcher/account"))
.bearer_auth(token).send().map_err(AccountError::Network)?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
let _ = fs::remove_file(session_path(data_dir));
return Err(AccountError::InvalidSession);
}
if !response.status().is_success() { return Err(api_error(response)); }
response.json::<Account>().map_err(AccountError::Network)
}
pub fn logout(data_dir: &Path) -> Result<(), AccountError> {
if let Ok(token) = load_session(data_dir) {
let _ = client()?.post(format!("{API_ORIGIN}/api/launcher/auth/logout"))
.bearer_auth(token).json(&serde_json::json!({})).send();
}
match fs::remove_file(session_path(data_dir)) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(AccountError::Io(error)),
}
}
pub fn start_link(data_dir: &Path, server_id: &str, nickname: &str) -> Result<LinkStart, AccountError> {
let token = load_session(data_dir)?;
let response = client()?.post(format!("{API_ORIGIN}/api/account/link/start"))
.bearer_auth(token).json(&serde_json::json!({"server_id": server_id, "mc_username": nickname}))
.send().map_err(AccountError::Network)?;
if !response.status().is_success() { return Err(api_error(response)); }
response.json::<LinkStart>().map_err(AccountError::Network)
}
pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, AccountError> {
let token = load_session(data_dir)?;
let response = client()?.get(format!("{API_ORIGIN}/api/account/link/status/{challenge_id}"))
.bearer_auth(token).send().map_err(AccountError::Network)?;
if !response.status().is_success() { return Err(api_error(response)); }
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)
}
#[cfg(test)]
mod tests {
use super::{load_session, save_session, session_path};
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
fn temporary_directory() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"shacraft-account-test-{}-{}",
process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
))
}
#[test]
fn session_round_trips_without_password_storage() {
let directory = temporary_directory();
let token = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
save_session(&directory, token).unwrap();
assert_eq!(load_session(&directory).unwrap(), token);
assert_eq!(fs::read_to_string(session_path(&directory)).unwrap(), token);
fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn session_is_private_on_unix() {
use std::os::unix::fs::PermissionsExt;
let directory = temporary_directory();
save_session(&directory, "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG").unwrap();
assert_eq!(fs::metadata(session_path(&directory)).unwrap().permissions().mode() & 0o077, 0);
fs::remove_dir_all(directory).unwrap();
}
}
+121 -93
View File
@@ -63,21 +63,14 @@ type SyncResult = {
downloadedBytes: number downloadedBytes: number
} }
type MinecraftProfile = { type ShaCraftAccount = {
id: string username: string
name: string links: { server_id: string; mc_username: string }[]
} }
type DeviceCodePayload = { type ShaCraftLoginResult = {
verificationUri: string account: ShaCraftAccount
userCode: string recoveryCodes: string[]
expiresInSeconds: number
}
type LoginResultPayload = {
ok: boolean
profile?: MinecraftProfile
error?: string
} }
type ServerStatus = { type ServerStatus = {
@@ -140,8 +133,13 @@ function App() {
const [syncError, setSyncError] = useState<string | null>(null) const [syncError, setSyncError] = useState<string | null>(null)
// undefined = still checking for a saved session; null = signed out. // undefined = still checking for a saved session; null = signed out.
const [account, setAccount] = useState<MinecraftProfile | null | undefined>(undefined) const [account, setAccount] = useState<ShaCraftAccount | null | undefined>(undefined)
const [loginCode, setLoginCode] = useState<DeviceCodePayload | null>(null) const [accountUsername, setAccountUsername] = useState('')
const [accountPassword, setAccountPassword] = useState('')
const [registering, setRegistering] = useState(false)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const [linkNickname, setLinkNickname] = useState('')
const [linkMessage, setLinkMessage] = useState<string | null>(null)
const [loginError, setLoginError] = useState<string | null>(null) const [loginError, setLoginError] = useState<string | null>(null)
const [loggingIn, setLoggingIn] = useState(false) const [loggingIn, setLoggingIn] = useState(false)
const [installing, setInstalling] = useState(false) const [installing, setInstalling] = useState(false)
@@ -149,7 +147,7 @@ function App() {
const [installProgress, setInstallProgress] = useState<InstallProgressPayload | null>(null) const [installProgress, setInstallProgress] = useState<InstallProgressPayload | null>(null)
const [launchError, setLaunchError] = useState<string | null>(null) const [launchError, setLaunchError] = useState<string | null>(null)
const [serverStatus, setServerStatus] = useState<ServerStatus | null>(null) const [serverStatus, setServerStatus] = useState<ServerStatus | null>(null)
const [microsoftLoginAvailable, setMicrosoftLoginAvailable] = useState(false) const linkedNickname = account?.links.find((link) => link.server_id === 'aoc')?.mc_username
useEffect(() => { useEffect(() => {
if (progress === null) return if (progress === null) return
@@ -177,16 +175,13 @@ function App() {
invoke<JavaInstallation | null>('detect_java') invoke<JavaInstallation | null>('detect_java')
.then(setJava) .then(setJava)
.catch(() => setJava(null)) .catch(() => setJava(null))
invoke<boolean>('microsoft_login_available')
.then(setMicrosoftLoginAvailable)
.catch(() => setMicrosoftLoginAvailable(false))
invoke<ProfileInspection>('inspect_remote_profile', { profileId: 'aeronautics' }) invoke<ProfileInspection>('inspect_remote_profile', { profileId: 'aeronautics' })
.then((inspection) => { .then((inspection) => {
setProfile(inspection) setProfile(inspection)
setReady(inspection.upToDate) setReady(inspection.upToDate)
}) })
.catch(() => undefined) .catch(() => undefined)
invoke<MinecraftProfile | null>('get_account') invoke<ShaCraftAccount | null>('get_shacraft_account')
.then(setAccount) .then(setAccount)
.catch(() => setAccount(null)) .catch(() => setAccount(null))
}, []) }, [])
@@ -214,17 +209,6 @@ function App() {
useEffect(() => { useEffect(() => {
if (!isTauri()) return if (!isTauri()) return
const unlisten = [ const unlisten = [
listen<DeviceCodePayload>('msa-login-code', (event) => setLoginCode(event.payload)),
listen<LoginResultPayload>('msa-login-result', (event) => {
setLoggingIn(false)
setLoginCode(null)
if (event.payload.ok && event.payload.profile) {
setAccount(event.payload.profile)
setLoginError(null)
} else {
setLoginError(event.payload.error ?? 'Не удалось войти через Microsoft')
}
}),
listen<InstallProgressPayload>('game-install-progress', (event) => setInstallProgress(event.payload)), listen<InstallProgressPayload>('game-install-progress', (event) => setInstallProgress(event.payload)),
listen<GameExitedPayload>('game-exited', (event) => { listen<GameExitedPayload>('game-exited', (event) => {
setInstalling(false) setInstalling(false)
@@ -252,17 +236,6 @@ function App() {
saveSettings(memoryGb) saveSettings(memoryGb)
} }
const saveNickname = () => {
if (/^[A-Za-z0-9_]{3,16}$/.test(nickname)) {
saveSettings(ram, nickname)
}
}
const setMode = (mode: 'microsoft' | 'offline') => {
setAccountMode(mode)
saveSettings(ram, nickname, mode)
}
const repair = async () => { const repair = async () => {
if (isTauri()) { if (isTauri()) {
setSyncError(null) setSyncError(null)
@@ -284,36 +257,79 @@ function App() {
} }
const startLogin = async () => { const startLogin = async () => {
if (!isTauri() || !microsoftLoginAvailable) { if (!isTauri()) return
setLoginError('Вход через Microsoft пока не настроен для этой версии лаунчера') setLoginError(null)
if (!/^[A-Za-z0-9_]{3,32}$/.test(accountUsername) || accountPassword.length < 8) {
setLoginError('Логин: 3–32 символа; пароль: минимум 8 символов')
return return
} }
setLoginError(null)
setLoggingIn(true) setLoggingIn(true)
try { try {
await invoke('start_microsoft_login') const result = await invoke<ShaCraftLoginResult>('shacraft_authenticate', {
username: accountUsername,
password: accountPassword,
register: registering,
})
setAccount(result.account)
setAccountPassword('')
setRecoveryCodes(result.recoveryCodes)
setLoginError(null)
} catch (error) { } catch (error) {
setLoginError(errorMessage(error, registering ? 'Не удалось зарегистрироваться' : 'Не удалось войти'))
} finally {
setLoggingIn(false) setLoggingIn(false)
setLoginError(errorMessage(error, 'Не удалось начать вход через Microsoft'))
} }
} }
const logout = async () => { const logout = async () => {
if (!isTauri()) return if (!isTauri()) return
await invoke('logout').catch(() => undefined) await invoke('shacraft_logout').catch(() => undefined)
setAccount(null) setAccount(null)
} }
const startNicknameLink = async () => {
if (!/^[A-Za-z0-9_]{3,16}$/.test(linkNickname)) {
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
return
}
setLinkMessage('Создаём проверку…')
try {
const started = await invoke<{ challenge_id: number; registered_on_server: boolean }>('shacraft_start_link', { nickname: linkNickname })
setLinkMessage(started.registered_on_server
? 'Зайдите на Aeronautics с этим ником и выполните /login.'
: 'Зайдите на Aeronautics с этим ником и выполните /register.')
const timer = window.setInterval(async () => {
try {
const result = await invoke<{ status: string; detail?: string }>('shacraft_link_status', { challengeId: started.challenge_id })
if (result.status === 'verified') {
window.clearInterval(timer)
const refreshed = await invoke<ShaCraftAccount>('get_shacraft_account')
setAccount(refreshed)
setLinkMessage('Ник подтверждён.')
} else if (result.status === 'expired' || result.status === 'conflict') {
window.clearInterval(timer)
setLinkMessage(result.detail ?? 'Проверка завершилась. Попробуйте ещё раз.')
}
} catch (error) {
window.clearInterval(timer)
setLinkMessage(errorMessage(error, 'Не удалось проверить ник'))
}
}, 3000)
} catch (error) {
setLinkMessage(errorMessage(error, 'Не удалось начать привязку'))
}
}
const playOrLogin = async () => { const playOrLogin = async () => {
if (!isTauri()) return if (!isTauri()) return
if (accountMode === 'microsoft' && !microsoftLoginAvailable) { if (!account) {
setLaunchError('Вход Microsoft пока недоступен. Выберите Offline-аккаунт в настройках.') setSettingsOpen(true)
setLaunchError('Войдите в аккаунт ShaCraft, чтобы играть.')
return return
} }
// In offline mode we can launch without any Microsoft session. In if (!linkedNickname) {
// Microsoft mode a signed-in account is still required first. setSettingsOpen(true)
if (accountMode === 'microsoft' && (account === null || account === undefined)) { setLaunchError('Привяжите игровой ник к Aeronautics, чтобы играть.')
await startLogin()
return return
} }
setLaunchError(null) setLaunchError(null)
@@ -343,9 +359,9 @@ function App() {
} }
const playLabel = () => { const playLabel = () => {
if (accountMode === 'microsoft' && !microsoftLoginAvailable) return 'Microsoft недоступен' if (account === undefined) return 'Загрузка…'
if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…' if (account === null) return 'Войти в ShaCraft'
if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft' if (!linkedNickname) return 'Привязать ник'
if (gameRunning) return 'Игра запущена' if (gameRunning) return 'Игра запущена'
if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}` : 'Подготовка…' if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}` : 'Подготовка…'
if (syncing || progress !== null) return 'Обновление' if (syncing || progress !== null) return 'Обновление'
@@ -412,10 +428,10 @@ function App() {
</div> </div>
<button className="account-chip" onClick={() => setSettingsOpen(true)}> <button className="account-chip" onClick={() => setSettingsOpen(true)}>
<span className="avatar">{accountMode === 'offline' ? nickname.slice(0, 2).toUpperCase() : (account ? account.name.slice(0, 2).toUpperCase() : '?')}</span> <span className="avatar">{linkedNickname ? linkedNickname.slice(0, 2).toUpperCase() : (account ? account.username.slice(0, 2).toUpperCase() : '?')}</span>
<span> <span>
<strong>{accountMode === 'offline' ? nickname : (account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : account.name)}</strong> <strong>{account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : (linkedNickname ?? account.username)}</strong>
<small>{accountMode === 'offline' ? 'Offline-аккаунт' : (account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть')}</small> <small>{account ? `ShaCraft · ${account.username}` : 'Войдите, чтобы играть'}</small>
</span> </span>
<ChevronRight size={16} /> <ChevronRight size={16} />
</button> </button>
@@ -471,7 +487,7 @@ function App() {
<> <>
<span className="state-icon"><ShieldCheck size={19} /></span> <span className="state-icon"><ShieldCheck size={19} /></span>
<span> <span>
<strong>{accountMode === 'microsoft' && !microsoftLoginAvailable ? 'Microsoft пока недоступен' : accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'}</strong> <strong>{account === null ? 'Нужен вход ShaCraft' : !linkedNickname ? 'Нужно привязать ник' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'}</strong>
<small>{launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов сборки` : 'Проверяем локальные файлы')}</small> <small>{launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов сборки` : 'Проверяем локальные файлы')}</small>
</span> </span>
</> </>
@@ -490,7 +506,7 @@ function App() {
</button> </button>
<button <button
className="play-button" className="play-button"
disabled={progress !== null || syncing || installing || gameRunning || (accountMode === 'microsoft' && (account === undefined || !microsoftLoginAvailable)) || loggingIn} disabled={progress !== null || syncing || installing || gameRunning || account === undefined || loggingIn}
onClick={playOrLogin} onClick={playOrLogin}
> >
<Play size={21} fill="currentColor" /> <Play size={21} fill="currentColor" />
@@ -500,13 +516,13 @@ function App() {
</main> </main>
</div> </div>
<div className={`drawer-backdrop ${loginCode ? 'visible' : ''}`} /> <div className={`drawer-backdrop ${recoveryCodes.length ? 'visible' : ''}`} />
{loginCode && ( {recoveryCodes.length > 0 && (
<div className="login-modal" role="dialog" aria-modal="true"> <div className="login-modal" role="dialog" aria-modal="true">
<h2>Вход через Microsoft</h2> <h2>Коды восстановления</h2>
<p>Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.</p> <p>Сохраните их сейчас. Каждый код можно использовать один раз для восстановления пароля.</p>
<div className="login-code">{loginCode.userCode}</div> <div className="login-code" style={{ whiteSpace: 'pre-line', fontSize: '15px' }}>{recoveryCodes.join('\n')}</div>
<p className="login-url">{loginCode.verificationUri}</p> <button className="setting-row" onClick={() => setRecoveryCodes([])}><span>Я сохранил коды</span></button>
</div> </div>
)} )}
@@ -523,34 +539,46 @@ function App() {
</label> </label>
<div className="setting-row static"> <div className="setting-row static">
<span><Users />Аккаунт</span> <span><Users />Аккаунт</span>
<small>{accountMode === 'offline' ? 'Offline' : (microsoftLoginAvailable ? (account ? account.name : 'Не авторизован') : 'Временно недоступен')}</small> <small>{account ? account.username : 'Не авторизован'}</small>
</div> </div>
{accountMode === 'offline' && ( {!account && (
<label className="text-setting"> <>
<span><strong>Игровой ник</strong><small>Offline-профиль</small></span> <label className="text-setting">
<input value={nickname} maxLength={16} onChange={(event) => setNickname(event.target.value)} onBlur={saveNickname} placeholder="Player" /> <span><strong>Логин ShaCraft</strong><small>332 символа</small></span>
<small>Латинские буквы, цифры и _ · от 3 до 16 символов</small> <input value={accountUsername} maxLength={32} autoComplete="username" onChange={(event) => setAccountUsername(event.target.value)} placeholder="Логин" />
</label> </label>
<label className="text-setting">
<span><strong>Пароль</strong><small>Минимум 8 символов</small></span>
<input type="password" value={accountPassword} maxLength={128} autoComplete={registering ? 'new-password' : 'current-password'} onChange={(event) => setAccountPassword(event.target.value)} placeholder="Пароль" />
</label>
{loginError && <div className="drawer-note">{loginError}</div>}
<button className="setting-row" onClick={startLogin} disabled={loggingIn}>
<span>{loggingIn ? 'Подождите…' : registering ? 'Создать аккаунт' : 'Войти'}</span>
</button>
<button className="setting-row" onClick={() => { setRegistering(!registering); setLoginError(null) }}>
<span>{registering ? 'Уже есть аккаунт' : 'Нет аккаунта — регистрация'}</span>
</button>
</>
)} )}
<div className="setting-row"> {account && !linkedNickname && (
<span>Тип аккаунта</span> <>
<select <label className="text-setting">
value={accountMode} <span><strong>Игровой ник</strong><small>Aeronautics</small></span>
onChange={(e) => setMode(e.target.value as 'microsoft' | 'offline')} <input value={linkNickname} maxLength={16} onChange={(event) => setLinkNickname(event.target.value)} placeholder="Player" />
style={{ background: 'transparent', border: 0, color: 'inherit', textAlign: 'right' }} <small>Ник нельзя будет подменить локальной настройкой</small>
> </label>
<option value="offline">Offline</option> <button className="setting-row" onClick={startNicknameLink}><span>Привязать ник</span></button>
<option value="microsoft" disabled={!microsoftLoginAvailable}>Microsoft (скоро)</option> {linkMessage && <div className="drawer-note">{linkMessage}</div>}
</select> </>
</div> )}
{accountMode === 'microsoft' && account && ( {account && linkedNickname && (
<div className="setting-row static">
<span>Игровой ник</span><small>{linkedNickname}</small>
</div>
)}
{account && (
<button className="setting-row" onClick={logout}> <button className="setting-row" onClick={logout}>
<span><LogOut />Выйти из Microsoft</span> <span><LogOut />Выйти из ShaCraft</span>
</button>
)}
{accountMode === 'microsoft' && !account && microsoftLoginAvailable && (
<button className="setting-row" onClick={startLogin}>
<span><LogOut />Войти через Microsoft</span>
</button> </button>
)} )}
<div className="setting-row static"> <div className="setting-row static">