feat: require ShaCraft account for launcher identity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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/
|
||||
<profile-id>` — 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
|
||||
|
||||
|
||||
+54
-17
@@ -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<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
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -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<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))
|
||||
}
|
||||
}
|
||||
/// 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<session::PlayerIdentity, String> {
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+119
-91
@@ -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<string | null>(null)
|
||||
|
||||
// undefined = still checking for a saved session; null = signed out.
|
||||
const [account, setAccount] = useState<MinecraftProfile | null | undefined>(undefined)
|
||||
const [loginCode, setLoginCode] = useState<DeviceCodePayload | null>(null)
|
||||
const [account, setAccount] = useState<ShaCraftAccount | null | undefined>(undefined)
|
||||
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 [loggingIn, setLoggingIn] = useState(false)
|
||||
const [installing, setInstalling] = useState(false)
|
||||
@@ -149,7 +147,7 @@ function App() {
|
||||
const [installProgress, setInstallProgress] = useState<InstallProgressPayload | null>(null)
|
||||
const [launchError, setLaunchError] = useState<string | 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(() => {
|
||||
if (progress === null) return
|
||||
@@ -177,16 +175,13 @@ function App() {
|
||||
invoke<JavaInstallation | null>('detect_java')
|
||||
.then(setJava)
|
||||
.catch(() => setJava(null))
|
||||
invoke<boolean>('microsoft_login_available')
|
||||
.then(setMicrosoftLoginAvailable)
|
||||
.catch(() => setMicrosoftLoginAvailable(false))
|
||||
invoke<ProfileInspection>('inspect_remote_profile', { profileId: 'aeronautics' })
|
||||
.then((inspection) => {
|
||||
setProfile(inspection)
|
||||
setReady(inspection.upToDate)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
invoke<MinecraftProfile | null>('get_account')
|
||||
invoke<ShaCraftAccount | null>('get_shacraft_account')
|
||||
.then(setAccount)
|
||||
.catch(() => setAccount(null))
|
||||
}, [])
|
||||
@@ -214,17 +209,6 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
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<GameExitedPayload>('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<ShaCraftLoginResult>('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<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 () => {
|
||||
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() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<strong>{accountMode === 'offline' ? nickname : (account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : account.name)}</strong>
|
||||
<small>{accountMode === 'offline' ? 'Offline-аккаунт' : (account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть')}</small>
|
||||
<strong>{account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : (linkedNickname ?? account.username)}</strong>
|
||||
<small>{account ? `ShaCraft · ${account.username}` : 'Войдите, чтобы играть'}</small>
|
||||
</span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
@@ -471,7 +487,7 @@ function App() {
|
||||
<>
|
||||
<span className="state-icon"><ShieldCheck size={19} /></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>
|
||||
</span>
|
||||
</>
|
||||
@@ -490,7 +506,7 @@ function App() {
|
||||
</button>
|
||||
<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}
|
||||
>
|
||||
<Play size={21} fill="currentColor" />
|
||||
@@ -500,13 +516,13 @@ function App() {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div className={`drawer-backdrop ${loginCode ? 'visible' : ''}`} />
|
||||
{loginCode && (
|
||||
<div className={`drawer-backdrop ${recoveryCodes.length ? 'visible' : ''}`} />
|
||||
{recoveryCodes.length > 0 && (
|
||||
<div className="login-modal" role="dialog" aria-modal="true">
|
||||
<h2>Вход через Microsoft</h2>
|
||||
<p>Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.</p>
|
||||
<div className="login-code">{loginCode.userCode}</div>
|
||||
<p className="login-url">{loginCode.verificationUri}</p>
|
||||
<h2>Коды восстановления</h2>
|
||||
<p>Сохраните их сейчас. Каждый код можно использовать один раз для восстановления пароля.</p>
|
||||
<div className="login-code" style={{ whiteSpace: 'pre-line', fontSize: '15px' }}>{recoveryCodes.join('\n')}</div>
|
||||
<button className="setting-row" onClick={() => setRecoveryCodes([])}><span>Я сохранил коды</span></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -523,34 +539,46 @@ function App() {
|
||||
</label>
|
||||
<div className="setting-row static">
|
||||
<span><Users />Аккаунт</span>
|
||||
<small>{accountMode === 'offline' ? 'Offline' : (microsoftLoginAvailable ? (account ? account.name : 'Не авторизован') : 'Временно недоступен')}</small>
|
||||
<small>{account ? account.username : 'Не авторизован'}</small>
|
||||
</div>
|
||||
{accountMode === 'offline' && (
|
||||
{!account && (
|
||||
<>
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Offline-профиль</small></span>
|
||||
<input value={nickname} maxLength={16} onChange={(event) => setNickname(event.target.value)} onBlur={saveNickname} placeholder="Player" />
|
||||
<small>Латинские буквы, цифры и _ · от 3 до 16 символов</small>
|
||||
<span><strong>Логин ShaCraft</strong><small>3–32 символа</small></span>
|
||||
<input value={accountUsername} maxLength={32} autoComplete="username" onChange={(event) => setAccountUsername(event.target.value)} placeholder="Логин" />
|
||||
</label>
|
||||
)}
|
||||
<div className="setting-row">
|
||||
<span>Тип аккаунта</span>
|
||||
<select
|
||||
value={accountMode}
|
||||
onChange={(e) => setMode(e.target.value as 'microsoft' | 'offline')}
|
||||
style={{ background: 'transparent', border: 0, color: 'inherit', textAlign: 'right' }}
|
||||
>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="microsoft" disabled={!microsoftLoginAvailable}>Microsoft (скоро)</option>
|
||||
</select>
|
||||
</div>
|
||||
{accountMode === 'microsoft' && account && (
|
||||
<button className="setting-row" onClick={logout}>
|
||||
<span><LogOut />Выйти из Microsoft</span>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{accountMode === 'microsoft' && !account && microsoftLoginAvailable && (
|
||||
<button className="setting-row" onClick={startLogin}>
|
||||
<span><LogOut />Войти через Microsoft</span>
|
||||
{account && !linkedNickname && (
|
||||
<>
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
|
||||
<input value={linkNickname} maxLength={16} onChange={(event) => setLinkNickname(event.target.value)} placeholder="Player" />
|
||||
<small>Ник нельзя будет подменить локальной настройкой</small>
|
||||
</label>
|
||||
<button className="setting-row" onClick={startNicknameLink}><span>Привязать ник</span></button>
|
||||
{linkMessage && <div className="drawer-note">{linkMessage}</div>}
|
||||
</>
|
||||
)}
|
||||
{account && linkedNickname && (
|
||||
<div className="setting-row static">
|
||||
<span>Игровой ник</span><small>{linkedNickname}</small>
|
||||
</div>
|
||||
)}
|
||||
{account && (
|
||||
<button className="setting-row" onClick={logout}>
|
||||
<span><LogOut />Выйти из ShaCraft</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="setting-row static">
|
||||
|
||||
Reference in New Issue
Block a user