diff --git a/AGENTS.md b/AGENTS.md index 371e0b3..31fe003 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,15 +39,13 @@ payload are in `/root/shacraft` on the ShaCraft host; see independent, hardcoded-host trust domains (Mojang, NeoForge, Microsoft, Adoptium) that install and run the actual game. Do not let manifest data control a URL in any of those domains. -- Account modes: the launcher supports launching as either a genuine - Microsoft account that owns Minecraft Java Edition (`src-tauri/src/msa.rs`, - device-code OAuth -> Xbox Live -> XSTS -> Minecraft Services) or as a local - offline profile (nickname + deterministic offline UUID, see - `src-tauri/src/session.rs`). The mode is an explicit player choice - (`account_mode` in settings); offline is never silently substituted for a - Microsoft session. The mc-aoc/mc-create servers' own `ONLINE_MODE=FALSE` + - whitelist + Login System are a separate, independent access-control layer - on the server side. +- 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. ## 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 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. - `src-tauri/src/settings.rs` — durable local preferences; maintain backward compatibility with already-written JSON. diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index bb85839..ddfe8ed 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -4,10 +4,10 @@ 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. Players can -launch either with a real Microsoft account or with a local offline profile -(nickname + deterministic offline UUID) — see `docs/game-trust-boundary.md` -and `AGENTS.md`'s trust model section. +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. 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: @@ -33,7 +33,8 @@ 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) - -> 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) ``` @@ -42,8 +43,9 @@ screenshots/resourcepacks) live below Tauri's `app_data_dir()/profiles/ ` — this becomes `--gameDir`. The shared vanilla+NeoForge 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`, the Microsoft refresh token at -`app_data_dir()/account.json` (mode 600). None of these should be assumed to +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 be the system `.minecraft` directory. ## Aeronautics contract @@ -57,6 +59,9 @@ be the system `.minecraft` directory. no launcher release. - 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. ## Planned but not implemented diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8ad6034..c590295 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod profile; mod remote; mod runtime; mod session; +mod shacraft_account; mod settings; use reqwest::blocking::Client; @@ -162,6 +163,48 @@ async fn save_settings(app: AppHandle, settings: settings::LauncherSettings) -> .map_err(|error| error.to_string()) } +#[tauri::command] +async fn shacraft_authenticate(app: AppHandle, username: String, password: String, register: bool) -> Result { + 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, 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 { + 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 { + 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 // --------------------------------------------------------------------- @@ -248,23 +291,12 @@ async fn logout(app: AppHandle) -> Result<(), String> { .map_err(|error| error.to_string()) } -/// Resolves the identity to launch as, based on the persisted `account_mode`. -/// In `Microsoft` mode this requires a real signed-in session (see -/// `msa::login_with_refresh_token`) and returns an error if there is none; -/// in `Offline` mode it uses the local nickname from settings, so no -/// Microsoft account is needed at all. Offline is never silently used in -/// place of a missing Microsoft session. -fn resolve_identity(client: &Client, data_dir: &Path) -> Result { - 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)) - } - } +/// Resolves the identity from the server-side ShaCraft account link. Local +/// settings are deliberately not trusted for a nickname, so editing an old +/// settings file cannot change the identity used by this launcher. +fn resolve_identity(_client: &Client, data_dir: &Path) -> Result { + let name = shacraft_account::aeronautics_nickname(data_dir).map_err(|error| error.to_string())?; + Ok(session::PlayerIdentity::Offline { name }) } // --------------------------------------------------------------------- @@ -416,6 +448,11 @@ pub fn run() { get_server_status, load_settings, save_settings, + shacraft_authenticate, + get_shacraft_account, + shacraft_logout, + shacraft_start_link, + shacraft_link_status, start_microsoft_login, get_account, logout, diff --git a/src-tauri/src/shacraft_account.rs b/src-tauri/src/shacraft_account.rs new file mode 100644 index 0000000..558e9e7 --- /dev/null +++ b/src-tauri/src/shacraft_account.rs @@ -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, +} + +#[derive(Deserialize)] +struct AuthResponse { + session_token: String, + account: Account, + recovery_codes: Vec, +} + +#[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, +} + +#[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, +} + +#[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::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 } + let status = response.status(); + let detail = response.json::().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 { + 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 { + 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::().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 { + 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::().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 { + 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::().map_err(AccountError::Network) +} + +pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result { + 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::().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) +} + +#[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(); + } +} diff --git a/src/main.tsx b/src/main.tsx index d4df264..8ec1efc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -63,21 +63,14 @@ type SyncResult = { downloadedBytes: number } -type MinecraftProfile = { - id: string - name: string +type ShaCraftAccount = { + username: string + links: { server_id: string; mc_username: string }[] } -type DeviceCodePayload = { - verificationUri: string - userCode: string - expiresInSeconds: number -} - -type LoginResultPayload = { - ok: boolean - profile?: MinecraftProfile - error?: string +type ShaCraftLoginResult = { + account: ShaCraftAccount + recoveryCodes: string[] } type ServerStatus = { @@ -140,8 +133,13 @@ function App() { const [syncError, setSyncError] = useState(null) // undefined = still checking for a saved session; null = signed out. - const [account, setAccount] = useState(undefined) - const [loginCode, setLoginCode] = useState(null) + const [account, setAccount] = useState(undefined) + const [accountUsername, setAccountUsername] = useState('') + const [accountPassword, setAccountPassword] = useState('') + const [registering, setRegistering] = useState(false) + const [recoveryCodes, setRecoveryCodes] = useState([]) + const [linkNickname, setLinkNickname] = useState('') + const [linkMessage, setLinkMessage] = useState(null) const [loginError, setLoginError] = useState(null) const [loggingIn, setLoggingIn] = useState(false) const [installing, setInstalling] = useState(false) @@ -149,7 +147,7 @@ function App() { const [installProgress, setInstallProgress] = useState(null) const [launchError, setLaunchError] = useState(null) const [serverStatus, setServerStatus] = useState(null) - const [microsoftLoginAvailable, setMicrosoftLoginAvailable] = useState(false) + const linkedNickname = account?.links.find((link) => link.server_id === 'aoc')?.mc_username useEffect(() => { if (progress === null) return @@ -177,16 +175,13 @@ function App() { invoke('detect_java') .then(setJava) .catch(() => setJava(null)) - invoke('microsoft_login_available') - .then(setMicrosoftLoginAvailable) - .catch(() => setMicrosoftLoginAvailable(false)) invoke('inspect_remote_profile', { profileId: 'aeronautics' }) .then((inspection) => { setProfile(inspection) setReady(inspection.upToDate) }) .catch(() => undefined) - invoke('get_account') + invoke('get_shacraft_account') .then(setAccount) .catch(() => setAccount(null)) }, []) @@ -214,17 +209,6 @@ function App() { useEffect(() => { if (!isTauri()) return const unlisten = [ - listen('msa-login-code', (event) => setLoginCode(event.payload)), - listen('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('game-install-progress', (event) => setInstallProgress(event.payload)), listen('game-exited', (event) => { setInstalling(false) @@ -252,17 +236,6 @@ function App() { 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 () => { if (isTauri()) { setSyncError(null) @@ -284,36 +257,79 @@ function App() { } const startLogin = async () => { - if (!isTauri() || !microsoftLoginAvailable) { - setLoginError('Вход через Microsoft пока не настроен для этой версии лаунчера') + if (!isTauri()) return + setLoginError(null) + if (!/^[A-Za-z0-9_]{3,32}$/.test(accountUsername) || accountPassword.length < 8) { + setLoginError('Логин: 3–32 символа; пароль: минимум 8 символов') return } - setLoginError(null) setLoggingIn(true) try { - await invoke('start_microsoft_login') + const result = await invoke('shacraft_authenticate', { + username: accountUsername, + password: accountPassword, + register: registering, + }) + setAccount(result.account) + setAccountPassword('') + setRecoveryCodes(result.recoveryCodes) + setLoginError(null) } catch (error) { + setLoginError(errorMessage(error, registering ? 'Не удалось зарегистрироваться' : 'Не удалось войти')) + } finally { setLoggingIn(false) - setLoginError(errorMessage(error, 'Не удалось начать вход через Microsoft')) } } const logout = async () => { if (!isTauri()) return - await invoke('logout').catch(() => undefined) + await invoke('shacraft_logout').catch(() => undefined) 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('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 () => { if (!isTauri()) return - if (accountMode === 'microsoft' && !microsoftLoginAvailable) { - setLaunchError('Вход Microsoft пока недоступен. Выберите Offline-аккаунт в настройках.') + if (!account) { + setSettingsOpen(true) + setLaunchError('Войдите в аккаунт ShaCraft, чтобы играть.') return } - // In offline mode we can launch without any Microsoft session. In - // Microsoft mode a signed-in account is still required first. - if (accountMode === 'microsoft' && (account === null || account === undefined)) { - await startLogin() + if (!linkedNickname) { + setSettingsOpen(true) + setLaunchError('Привяжите игровой ник к Aeronautics, чтобы играть.') return } setLaunchError(null) @@ -343,9 +359,9 @@ function App() { } const playLabel = () => { - if (accountMode === 'microsoft' && !microsoftLoginAvailable) return 'Microsoft недоступен' - if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…' - if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft' + if (account === undefined) return 'Загрузка…' + if (account === null) return 'Войти в ShaCraft' + if (!linkedNickname) return 'Привязать ник' if (gameRunning) return 'Игра запущена' if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}…` : 'Подготовка…' if (syncing || progress !== null) return 'Обновление' @@ -412,10 +428,10 @@ function App() { @@ -471,7 +487,7 @@ function App() { <> - {accountMode === 'microsoft' && !microsoftLoginAvailable ? 'Microsoft пока недоступен' : accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'} + {account === null ? 'Нужен вход ShaCraft' : !linkedNickname ? 'Нужно привязать ник' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'} {launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов сборки` : 'Проверяем локальные файлы')} @@ -490,7 +506,7 @@ function App() { )} @@ -523,34 +539,46 @@ function App() {
Аккаунт - {accountMode === 'offline' ? 'Offline' : (microsoftLoginAvailable ? (account ? account.name : 'Не авторизован') : 'Временно недоступен')} + {account ? account.username : 'Не авторизован'}
- {accountMode === 'offline' && ( - + {!account && ( + <> + + + {loginError &&
{loginError}
} + + + )} -
- Тип аккаунта - -
- {accountMode === 'microsoft' && account && ( + {account && !linkedNickname && ( + <> + + + {linkMessage &&
{linkMessage}
} + + )} + {account && linkedNickname && ( +
+ Игровой ник{linkedNickname} +
+ )} + {account && ( - )} - {accountMode === 'microsoft' && !account && microsoftLoginAvailable && ( - )}