fix launcher UI and game launch pipeline

This commit is contained in:
Emil
2026-09-06 18:12:22 +03:00
parent cc19a24e45
commit b0c2687677
14 changed files with 287 additions and 108 deletions
+9
View File
@@ -34,3 +34,12 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- run: npm ci
- run: npm run tauri:build -- ${{ matrix.args }}
- name: Upload Windows installers
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: shacraft-launcher-windows-x64
if-no-files-found: error
path: |
src-tauri/target/release/bundle/nsis/*.exe
src-tauri/target/release/bundle/msi/*.msi
+4 -1
View File
@@ -21,8 +21,11 @@ payload are in `/root/shacraft` on the ShaCraft host; see
## Trust model
- The only supported remote profile endpoint is
- The only supported remote profile manifest endpoint is
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`.
The read-only Aeronautics player-count endpoint
`https://shacraft.ru/api/online/aoc` is also hardcoded in `remote.rs`; it
is display-only and is never allowed to influence downloads or launching.
- The response is an Ed25519 envelope. `src-tauri/src/remote.rs` verifies its
embedded public key and `keyId` **before** parsing the payload.
- `src-tauri/src/manifest.rs` then validates paths, SHA-256, sizes, HTTPS and
+8 -4
View File
@@ -33,10 +33,14 @@ real main class is `net.minecraftforge.installer.SimpleInstaller`, which
supports this flag. **Empirically verified (2026-09-06)**: it refuses to
target a directory unless a `launcher_profiles.json` stub already exists
there ("you need to run the launcher first!") — `ensure_launcher_profiles_stub`
writes a minimal one. It then fetches and patches vanilla itself; no
pre-seeding needed. Its own downloads go straight to `maven.neoforged.net`/
Mojang, outside our control — an accepted trust delegation to NeoForge's
official tooling once the installer binary itself is verified.
writes a minimal one. It fetches the inputs needed to patch vanilla, but does
not guarantee that the complete vanilla runtime library set is present.
After installation, `mojang::ensure_client_jar` and `ensure_libraries` always
verify and download the complete merged launch set, including LWJGL and its
platform natives. The installer's own downloads go straight to
`maven.neoforged.net`/Mojang, outside our control — an accepted trust
delegation to NeoForge's official tooling once the installer binary itself is
verified.
Also verified: the resulting
`libraries/net/neoforged/neoforge/<ver>/neoforge-<ver>-client.jar` (the
+5
View File
@@ -9,6 +9,10 @@ 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.
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:
the result never controls files, versions, URLs, or the launch command.
Not yet implemented: a user-selectable profile directory, a "reset managed
files only" recovery action, and signed cross-platform release builds of the
launcher itself. Do not represent these as completed in UI or release notes.
@@ -28,6 +32,7 @@ Game itself (never controlled by the manifest above)
-> Java 21 via Adoptium if none installed (runtime.rs)
-> 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)
-> java process spawned with the merged classpath/args (launch.rs)
```
+7 -1
View File
@@ -3,5 +3,11 @@
"identifier": "main-window",
"description": "Minimal permissions for the ShaCraft main window.",
"windows": ["main"],
"permissions": ["core:default"]
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-start-dragging"
]
}
+24 -2
View File
@@ -8,7 +8,7 @@ use crate::mojang::{self, MergedVersion};
use crate::session::PlayerIdentity;
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
fmt, fs, io,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
@@ -57,6 +57,12 @@ fn classpath_separator() -> &'static str {
}
}
fn unique_classpath_entries(mut entries: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
entries.retain(|path| seen.insert(path.clone()));
entries
}
fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -> String {
let no_features = HashMap::new();
let mut entries: Vec<PathBuf> = merged
@@ -67,6 +73,11 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -
.map(|artifact| game_dir.join("libraries").join(&artifact.path))
.collect();
entries.push(client_jar.to_path_buf());
// NeoForge's inherited profile can repeat vanilla libraries verbatim.
// Passing the same jar twice makes SecureJarHandler abort during startup
// (for example on gson-2.10.1.jar), so preserve order and keep each path
// only once.
let entries = unique_classpath_entries(entries);
entries.iter().map(|path| path.display().to_string()).collect::<Vec<_>>().join(classpath_separator())
}
@@ -134,7 +145,12 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
let mut vars: HashMap<&str, String> = HashMap::new();
vars.insert("auth_player_name", request.identity.name().to_string());
vars.insert("version_name", request.merged.id.clone());
// NeoForge's inherited JVM profile uses `${version_name}.jar` in
// `-DignoreList`. The actual client jar belongs to the vanilla parent
// (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this
// token must identify the parent or both vanilla and patched Minecraft
// modules are loaded and Java aborts with a ResolutionException.
vars.insert("version_name", request.merged.client_jar_version_id.clone());
vars.insert("game_directory", request.profile_dir.display().to_string());
vars.insert("assets_root", assets_root.display().to_string());
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
@@ -203,4 +219,10 @@ mod tests {
assert_eq!(substitute("${auth_player_name}", &vars), "Steve");
assert_eq!(substitute("-Djava.library.path=${natives_directory}", &vars), "-Djava.library.path=${natives_directory}");
}
#[test]
fn classpath_entries_are_unique() {
let entries = unique_classpath_entries(vec![PathBuf::from("gson.jar"), PathBuf::from("gson.jar"), PathBuf::from("client.jar")]);
assert_eq!(entries, vec![PathBuf::from("gson.jar"), PathBuf::from("client.jar")]);
}
}
+24 -6
View File
@@ -58,6 +58,13 @@ fn detect_java() -> Option<java::JavaInstallation> {
java::detect()
}
/// Whether Microsoft sign-in was configured for this launcher build.
/// The UI uses this to avoid advertising a login flow that cannot start.
#[tauri::command]
fn microsoft_login_available() -> bool {
msa::is_configured()
}
/// Validates an untrusted profile manifest before any file is downloaded.
#[tauri::command]
fn validate_manifest(manifest_json: String) -> Result<(), String> {
@@ -122,6 +129,15 @@ async fn sync_remote_profile(app: AppHandle, profile_id: String) -> Result<profi
}).await.map_err(|error| format!("Profile synchronization task failed: {error}"))?
}
/// Gets live, read-only player count for a supported profile. Failure is
/// surfaced to the interface, which displays the server as unavailable.
#[tauri::command]
async fn get_server_status(profile_id: String) -> Result<remote::ServerStatus, String> {
tauri::async_runtime::spawn_blocking(move || remote::fetch_server_status(&profile_id).map_err(|error| error.to_string()))
.await
.map_err(|error| format!("Server-status task failed: {error}"))?
}
#[tauri::command]
async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
let data_dir = app
@@ -311,12 +327,12 @@ async fn ensure_game_installed(app: AppHandle, profile_id: String) -> Result<(),
let java_install = java::ensure_java(&client, &runtime_root, manifest.minecraft.java_major, &stage_progress("java")).map_err(|error| error.to_string())?;
let merged = resolve_merged_version(&client, &manifest, Path::new(&java_install.executable), &game_dir, &cache_dir, &stage_progress("neoforge"))?;
if manifest.minecraft.loader.kind != "neoforge" {
// Vanilla-only profiles skip the installer, which normally
// downloads vanilla itself; do it ourselves here instead.
mojang::ensure_client_jar(&client, &game_dir, &merged.client_jar_version_id, &merged.client).map_err(|error| error.to_string())?;
mojang::ensure_libraries(&client, &game_dir, &merged.libraries, &stage_progress("libraries")).map_err(|error| error.to_string())?;
};
// The NeoForge installer creates the loader profile and patched
// client, but it does not guarantee that every vanilla runtime
// library (notably LWJGL and its platform natives) is present.
// Verify the complete merged launch set for every loader kind.
mojang::ensure_client_jar(&client, &game_dir, &merged.client_jar_version_id, &merged.client).map_err(|error| error.to_string())?;
mojang::ensure_libraries(&client, &game_dir, &merged.libraries, &stage_progress("libraries")).map_err(|error| error.to_string())?;
let asset_index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index).map_err(|error| error.to_string())?;
mojang::ensure_assets(&client, &game_dir, &asset_index, &stage_progress("assets")).map_err(|error| error.to_string())?;
@@ -391,11 +407,13 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
native_host,
detect_java,
microsoft_login_available,
validate_manifest,
inspect_profile,
sync_profile,
inspect_remote_profile,
sync_remote_profile,
get_server_status,
load_settings,
save_settings,
start_microsoft_login,
+18 -4
View File
@@ -44,6 +44,13 @@ pub fn is_allowed_host(url: &str) -> bool {
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host))).unwrap_or(false)
}
/// Library entries in a merged loader profile may point at the loader's
/// own fixed Maven. The profile itself comes from the SHA-256-verified
/// NeoForge installer, never from the ShaCraft manifest.
fn is_allowed_library_host(url: &str) -> bool {
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
}
#[derive(Debug)]
pub enum MojangError {
Network(reqwest::Error),
@@ -430,7 +437,7 @@ pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library],
checksum: Checksum::Sha1(artifact.sha1.clone()),
});
}
download_many(client, tasks, on_progress)?;
download_many(client, tasks, on_progress, is_allowed_library_host)?;
Ok(paths)
}
@@ -473,7 +480,7 @@ pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_pr
}
})
.collect();
download_many(client, tasks, on_progress)
download_many(client, tasks, on_progress, is_allowed_host)
}
struct DownloadTask {
@@ -509,7 +516,12 @@ fn download_with_retries(client: &Client, task: &DownloadTask) -> Result<u64, Do
/// Downloads `tasks` using a small worker pool, calling `on_progress` with
/// cumulative (downloaded, total) bytes as each file completes. Stops
/// spawning new work once the first error is seen and returns it.
fn download_many(client: &Client, tasks: Vec<DownloadTask>, on_progress: &ProgressCallback) -> Result<(), MojangError> {
fn download_many(
client: &Client,
tasks: Vec<DownloadTask>,
on_progress: &ProgressCallback,
is_allowed: fn(&str) -> bool,
) -> Result<(), MojangError> {
let total: u64 = tasks.iter().map(|task| task.size).sum();
if total == 0 {
return Ok(());
@@ -529,7 +541,7 @@ fn download_many(client: &Client, tasks: Vec<DownloadTask>, on_progress: &Progre
break;
}
let Some(task) = queue.lock().unwrap().pop() else { break };
if !is_allowed_host(&task.url) {
if !is_allowed(&task.url) {
*first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url));
continue;
}
@@ -648,6 +660,8 @@ mod tests {
fn disallowed_host_is_rejected() {
assert!(!is_allowed_host("https://example.com/evil.jar"));
assert!(is_allowed_host("https://piston-data.mojang.com/v1/objects/x/client.jar"));
assert!(is_allowed_library_host("https://maven.neoforged.net/releases/net/neoforged/example.jar"));
assert!(!is_allowed_library_host("https://example.com/evil.jar"));
}
/// Live smoke test against the real Mojang CDN: manifest -> version JSON
+4 -4
View File
@@ -33,7 +33,7 @@ use std::{
/// access. Replace this before shipping login — see the module doc above.
const MSA_CLIENT_ID: &str = "00000000-0000-0000-0000-000000000000";
fn client_id_is_configured() -> bool {
pub fn is_configured() -> bool {
MSA_CLIENT_ID != "00000000-0000-0000-0000-000000000000"
}
@@ -106,7 +106,7 @@ struct DeviceCodeResponse {
}
pub fn start_device_code(client: &Client) -> Result<DeviceCodeStart, MsaError> {
if !client_id_is_configured() {
if !is_configured() {
return Err(MsaError::NotConfigured);
}
let response = client
@@ -187,7 +187,7 @@ pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<Micr
}
pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result<MicrosoftTokens, MsaError> {
if !client_id_is_configured() {
if !is_configured() {
return Err(MsaError::NotConfigured);
}
let response = client
@@ -457,7 +457,7 @@ mod tests {
#[test]
fn refuses_to_run_with_placeholder_client_id() {
assert!(!client_id_is_configured());
assert!(!is_configured());
let client = Client::builder().build().unwrap();
assert!(matches!(start_device_code(&client), Err(MsaError::NotConfigured)));
}
+1 -1
View File
@@ -88,7 +88,7 @@ impl From<io::Error> for NeoForgeError {
}
}
fn is_allowed_host(url: &str) -> bool {
pub(crate) fn is_allowed_host(url: &str) -> bool {
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST)).unwrap_or(false)
}
+26
View File
@@ -50,6 +50,12 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, Pr
missing_files += 1;
continue;
}
// Seed files are only supplied on the first install. Once present,
// player changes are intentional and must not make the profile look
// out of date: `sync` preserves them for the same reason.
if matches!(expected.policy, FilePolicy::Seed) {
continue;
}
let checksum = Checksum::Sha256(expected.sha256.clone());
if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
mismatched_files += 1;
@@ -164,4 +170,24 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn preserves_changed_seed_files_as_current() {
let root = std::env::temp_dir().join(format!(
"shacraft-launcher-seed-test-{}-{}",
process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
));
let mut expected = manifest("0".repeat(64), 42);
expected.files[0].policy = FilePolicy::Seed;
fs::create_dir_all(root.join("mods")).unwrap();
fs::write(root.join("mods/example.jar"), b"player customization").unwrap();
let inspection = inspect(&root, &expected).unwrap();
assert_eq!(inspection.missing_files, 0);
assert_eq!(inspection.mismatched_files, 0);
assert!(inspection.up_to_date);
fs::remove_dir_all(root).unwrap();
}
}
+29 -1
View File
@@ -2,11 +2,12 @@ use crate::manifest::{self, Manifest};
use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use reqwest::{blocking::Client, redirect::Policy};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::{fmt, time::Duration};
const AERONAUTICS_MANIFEST: &str =
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
#[derive(Deserialize)]
@@ -18,6 +19,16 @@ struct SignedManifest {
signature: String,
}
/// Read-only player count for the profile currently supported by the launcher.
/// This URL is deliberately fixed here rather than supplied by a manifest.
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerStatus {
pub online: Option<u32>,
pub max: Option<u32>,
pub reachable: bool,
}
#[derive(Debug)]
pub enum RemoteError {
UnknownProfile,
@@ -74,3 +85,20 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)
}
pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError> {
let url = match profile_id {
"aeronautics" => AERONAUTICS_ONLINE,
_ => return Err(RemoteError::UnknownProfile),
};
let client = Client::builder()
.timeout(Duration::from_secs(10))
.redirect(Policy::none())
.build()
.map_err(RemoteError::Network)?;
let response = client.get(url).send().map_err(RemoteError::Network)?;
if !response.status().is_success() {
return Err(RemoteError::Status(response.status()));
}
response.json::<ServerStatus>().map_err(RemoteError::Network)
}
+125 -79
View File
@@ -2,17 +2,15 @@ import React, { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import {
ChevronRight,
Download,
FolderOpen,
Gauge,
Globe2,
Library,
LogOut,
MessageCircle,
Minus,
Newspaper,
Play,
RotateCcw,
Settings,
@@ -30,12 +28,8 @@ type Server = {
kicker: string
name: string
subtitle: string
players: string
version: string
memory: string
installed: boolean
profileId?: string
disabled?: boolean
profileId: string
}
type NativeHost = {
@@ -86,6 +80,17 @@ type LoginResultPayload = {
error?: string
}
type ServerStatus = {
online: number | null
max: number | null
reachable: boolean
}
type GameExitedPayload = {
profileId: string
exitCode: number | null
}
type InstallProgressPayload = {
stage: 'java' | 'neoforge' | 'libraries' | 'assets'
currentBytes: number
@@ -105,29 +110,21 @@ const servers: Server[] = [
kicker: 'Основная сборка',
name: 'Aeronautics',
subtitle: 'Строй корабли. Поднимай города в небо.',
players: '7 / 20',
version: '1.21.1 · NeoForge',
memory: '6 ГБ',
installed: true,
version: '1.21.1 · NeoForge 21.1.248',
profileId: 'aeronautics',
},
{
id: 'create',
kicker: 'На техобслуживании',
name: 'Create',
subtitle: 'Механизмы, фабрики и большие идеи.',
players: 'Сервер остановлен',
version: '1.21.1 · NeoForge',
memory: '4 ГБ',
installed: false,
disabled: true,
},
]
function isTauri() {
return '__TAURI_INTERNALS__' in window
}
function errorMessage(error: unknown, fallback: string) {
if (typeof error === 'string' && error.trim()) return error
if (error instanceof Error && error.message) return error.message
return fallback
}
function App() {
const [selected, setSelected] = useState(servers[0])
const [progress, setProgress] = useState<number | null>(null)
@@ -148,8 +145,11 @@ function App() {
const [loginError, setLoginError] = useState<string | null>(null)
const [loggingIn, setLoggingIn] = useState(false)
const [installing, setInstalling] = useState(false)
const [gameRunning, setGameRunning] = useState(false)
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)
useEffect(() => {
if (progress === null) return
@@ -177,6 +177,9 @@ 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)
@@ -188,6 +191,26 @@ function App() {
.catch(() => setAccount(null))
}, [])
useEffect(() => {
if (!isTauri()) return
let disposed = false
const refresh = () => {
invoke<ServerStatus>('get_server_status', { profileId: selected.profileId })
.then((status) => {
if (!disposed) setServerStatus(status)
})
.catch(() => {
if (!disposed) setServerStatus({ online: null, max: null, reachable: false })
})
}
refresh()
const timer = window.setInterval(refresh, 30_000)
return () => {
disposed = true
window.clearInterval(timer)
}
}, [selected.profileId])
useEffect(() => {
if (!isTauri()) return
const unlisten = [
@@ -203,7 +226,15 @@ function App() {
}
}),
listen<InstallProgressPayload>('game-install-progress', (event) => setInstallProgress(event.payload)),
listen('game-exited', () => setInstalling(false)),
listen<GameExitedPayload>('game-exited', (event) => {
setInstalling(false)
setGameRunning(false)
setInstallProgress(null)
if (event.payload.exitCode !== 0) {
const suffix = event.payload.exitCode === null ? '' : ` (код ${event.payload.exitCode})`
setLaunchError(`Игра завершилась с ошибкой${suffix}. Подробности сохранены в журнале лаунчера.`)
}
}),
]
return () => {
unlisten.forEach((promise) => promise.then((off) => off()))
@@ -233,8 +264,7 @@ function App() {
}
const repair = async () => {
if (selected.disabled) return
if (isTauri() && selected.profileId) {
if (isTauri()) {
setSyncError(null)
setSyncing(true)
setReady(false)
@@ -243,7 +273,7 @@ function App() {
setProfile({ managedFiles: result.downloadedFiles + result.reusedFiles, missingFiles: 0, mismatchedFiles: 0, upToDate: true })
setReady(true)
} catch (error) {
setSyncError(error instanceof Error ? error.message : 'Не удалось синхронизировать сборку')
setSyncError(errorMessage(error, 'Не удалось синхронизировать сборку'))
} finally {
setSyncing(false)
}
@@ -254,14 +284,17 @@ function App() {
}
const startLogin = async () => {
if (!isTauri()) return
if (!isTauri() || !microsoftLoginAvailable) {
setLoginError('Вход через Microsoft пока не настроен для этой версии лаунчера')
return
}
setLoginError(null)
setLoggingIn(true)
try {
await invoke('start_microsoft_login')
} catch (error) {
setLoggingIn(false)
setLoginError(error instanceof Error ? error.message : 'Не удалось начать вход через Microsoft')
setLoginError(errorMessage(error, 'Не удалось начать вход через Microsoft'))
}
}
@@ -272,7 +305,11 @@ function App() {
}
const playOrLogin = async () => {
if (selected.disabled || !isTauri() || !selected.profileId) return
if (!isTauri()) return
if (accountMode === 'microsoft' && !microsoftLoginAvailable) {
setLaunchError('Вход Microsoft пока недоступен. Выберите Offline-аккаунт в настройках.')
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)) {
@@ -280,51 +317,68 @@ function App() {
return
}
setLaunchError(null)
setInstalling(true)
setSyncError(null)
setSyncing(true)
setInstallProgress(null)
try {
// A launch must always reconcile the signed ShaCraft profile first.
// Installing Minecraft/NeoForge alone produces a valid but unmodded
// game, so profile sync is deliberately part of the Play path.
const syncResult = await invoke<SyncResult>('sync_remote_profile', { profileId: selected.profileId })
setProfile({ managedFiles: syncResult.downloadedFiles + syncResult.reusedFiles, missingFiles: 0, mismatchedFiles: 0, upToDate: true })
setReady(true)
setSyncing(false)
setInstalling(true)
await invoke('ensure_game_installed', { profileId: selected.profileId })
setInstallProgress(null)
setInstalling(false)
setGameRunning(true)
await invoke('launch_game', { profileId: selected.profileId })
} catch (error) {
setLaunchError(error instanceof Error ? error.message : 'Не удалось запустить игру')
setLaunchError(errorMessage(error, 'Не удалось запустить игру'))
setSyncing(false)
setInstalling(false)
setGameRunning(false)
}
}
const playLabel = () => {
if (selected.disabled) return 'Недоступно'
if (accountMode === 'microsoft' && !microsoftLoginAvailable) return 'Microsoft недоступен'
if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…'
if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft'
if (gameRunning) return 'Игра запущена'
if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}` : 'Подготовка…'
if (syncing || progress !== null) return 'Обновление'
return ready ? 'Играть' : 'Проверить'
}
const installPercent = installProgress && installProgress.totalBytes > 0 ? Math.min(100, Math.round((installProgress.currentBytes / installProgress.totalBytes) * 100)) : null
const onlineLabel = serverStatus?.reachable && serverStatus.online !== null && serverStatus.max !== null
? `${serverStatus.online} / ${serverStatus.max}`
: serverStatus === null ? 'Проверяем…' : 'Нет связи'
const minimizeWindow = () => { if (isTauri()) void getCurrentWindow().minimize() }
const toggleMaximizeWindow = () => { if (isTauri()) void getCurrentWindow().toggleMaximize() }
const closeWindow = () => { if (isTauri()) void getCurrentWindow().close() }
return (
<div className="app-shell">
<header className="titlebar">
<div className="brand">
<img src={logo} alt="" />
<span>ShaCraft</span>
<header className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region>
<img src={logo} alt="" data-tauri-drag-region />
<span data-tauri-drag-region>ShaCraft</span>
</div>
<div className="titlebar-drag">{nativeHost ? `Лаунчер · ${nativeHost.platform}` : 'Лаунчер'}</div>
<div className="titlebar-drag" data-tauri-drag-region>{nativeHost ? `Лаунчер · ${nativeHost.platform}` : 'Лаунчер'}</div>
<div className="window-actions" aria-label="Управление окном">
<button aria-label="Свернуть"><Minus size={15} /></button>
<button aria-label="Развернуть"><Square size={12} /></button>
<button className="close" aria-label="Закрыть"><X size={15} /></button>
<button aria-label="Свернуть" onClick={minimizeWindow}><Minus size={15} /></button>
<button aria-label="Развернуть" onClick={toggleMaximizeWindow}><Square size={12} /></button>
<button className="close" aria-label="Закрыть" onClick={closeWindow}><X size={15} /></button>
</div>
</header>
<div className="workspace">
<nav className="rail" aria-label="Основное меню">
<div className="rail-main">
<button className="rail-button active" aria-label="Сборки"><Library /></button>
<button className="rail-button" aria-label="Новости"><Newspaper /></button>
<button className="rail-button" aria-label="Сообщество"><MessageCircle /></button>
</div>
<button className="rail-button" aria-label="Настройки" onClick={() => setSettingsOpen(true)}>
<nav className="rail" aria-label="Настройки лаунчера">
<button className="rail-button active" aria-label="Настройки" onClick={() => setSettingsOpen(true)}>
<Settings />
</button>
</nav>
@@ -341,7 +395,7 @@ function App() {
className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
onClick={() => {
setSelected(server)
setReady(server.installed)
setReady(profile?.upToDate ?? false)
setProgress(null)
}}
>
@@ -350,51 +404,49 @@ function App() {
</span>
<span className="server-copy">
<strong>{server.name}</strong>
<small>{server.disabled ? 'На паузе' : 'Установлена'}</small>
<small>{profile?.upToDate ? 'Файлы проверены' : 'Требуется проверка'}</small>
</span>
<ChevronRight size={16} />
</button>
))}
</div>
<div className="account-chip">
<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>
<strong>{accountMode === 'offline' ? nickname : (account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : account.name)}</strong>
<small>{accountMode === 'offline' ? 'Offline-аккаунт' : (account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть')}</small>
</span>
{accountMode === 'microsoft' && account ? (
<button aria-label="Выйти из аккаунта" onClick={logout} style={{ background: 'transparent', border: 0, cursor: 'pointer', color: 'inherit' }}>
<LogOut size={16} />
</button>
) : (
<ChevronRight size={16} />
)}
</div>
<ChevronRight size={16} />
</button>
</aside>
<main className={`stage stage-${selected.id}`}>
<div className="stage-top">
<div className={`live-pill ${selected.disabled ? 'offline' : ''}`}>
<span /> {selected.disabled ? 'Не в сети' : 'Сервер работает'}
<div className={`live-pill ${serverStatus === null || serverStatus.reachable ? '' : 'offline'}`}>
<span /> {serverStatus === null ? 'Проверяем сервер' : serverStatus.reachable ? 'Сервер доступен' : 'Сервер недоступен'}
</div>
<div className="players"><Users size={16} /> {selected.players}</div>
<div className="players"><Users size={16} /> {onlineLabel}</div>
</div>
<section className="hero-copy">
<p>{selected.id === 'aoc' ? 'All of Create / сборка 2.5' : selected.kicker}</p>
<p>{selected.kicker}</p>
<h1>{selected.name}</h1>
<h2>{selected.subtitle}</h2>
<dl className="hero-meta">
<div><dt>Состав</dt><dd>{selected.id === 'aoc' ? '250 модов' : '41 мод'}</dd></div>
<div><dt>Загрузчик</dt><dd>{selected.id === 'aoc' ? 'NeoForge 21.1.248' : 'NeoForge 21.1.249'}</dd></div>
<div><dt>Загрузчик</dt><dd>NeoForge 21.1.248</dd></div>
<div><dt>Java</dt><dd>Версия 21</dd></div>
</dl>
</section>
<section className="play-dock">
<div className="build-state">
{installing ? (
{gameRunning ? (
<>
<span className="state-icon"><Play size={19} /></span>
<span><strong>Игра запущена</strong><small>Лаунчер готов к работе после выхода</small></span>
</>
) : installing ? (
<>
<span className="state-icon downloading"><Download size={19} /></span>
<span>
@@ -415,17 +467,12 @@ function App() {
<small>Файлы и обновления · {progress}%</small>
</span>
</>
) : selected.disabled ? (
<>
<span className="state-icon muted"><Wrench size={19} /></span>
<span><strong>Техобслуживание</strong><small>Сообщим, когда сервер вернётся</small></span>
</>
) : (
<>
<span className="state-icon"><ShieldCheck size={19} /></span>
<span>
<strong>{accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Сборка готова' : 'Требуется проверка'}</strong>
<small>{launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов под контролем` : 'Проверяем локальные файлы')}</small>
<strong>{accountMode === 'microsoft' && !microsoftLoginAvailable ? 'Microsoft пока недоступен' : accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'}</strong>
<small>{launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов сборки` : 'Проверяем локальные файлы')}</small>
</span>
</>
)}
@@ -438,12 +485,12 @@ function App() {
<span><Gauge size={15} /> {ram} ГБ памяти</span>
</div>
<button className="repair-button" onClick={repair} disabled={progress !== null || syncing || installing || selected.disabled} aria-label="Проверить файлы">
<button className="repair-button" onClick={repair} disabled={progress !== null || syncing || installing || gameRunning} aria-label="Проверить файлы">
<RotateCcw size={19} />
</button>
<button
className="play-button"
disabled={progress !== null || syncing || installing || selected.disabled || (accountMode === 'microsoft' && account === undefined) || loggingIn}
disabled={progress !== null || syncing || installing || gameRunning || (accountMode === 'microsoft' && (account === undefined || !microsoftLoginAvailable)) || loggingIn}
onClick={playOrLogin}
>
<Play size={21} fill="currentColor" />
@@ -476,7 +523,7 @@ function App() {
</label>
<div className="setting-row static">
<span><Users />Аккаунт</span>
<small>{accountMode === 'offline' ? 'Offline' : (account ? account.name : 'Не авторизован')}</small>
<small>{accountMode === 'offline' ? 'Offline' : (microsoftLoginAvailable ? (account ? account.name : 'Не авторизован') : 'Временно недоступен')}</small>
</div>
{accountMode === 'offline' && (
<label className="text-setting">
@@ -493,7 +540,7 @@ function App() {
style={{ background: 'transparent', border: 0, color: 'inherit', textAlign: 'right' }}
>
<option value="offline">Offline</option>
<option value="microsoft">Microsoft</option>
<option value="microsoft" disabled={!microsoftLoginAvailable}>Microsoft (скоро)</option>
</select>
</div>
{accountMode === 'microsoft' && account && (
@@ -501,7 +548,7 @@ function App() {
<span><LogOut />Выйти из Microsoft</span>
</button>
)}
{accountMode === 'microsoft' && !account && (
{accountMode === 'microsoft' && !account && microsoftLoginAvailable && (
<button className="setting-row" onClick={startLogin}>
<span><LogOut />Войти через Microsoft</span>
</button>
@@ -522,7 +569,6 @@ function App() {
: 'Лаунчер установит Java 21 автоматически'}
</small>
</div>
<button className="setting-row"><span><Wrench />Дополнительные параметры</span><ChevronRight /></button>
<div className="drawer-note">
{nativeHost ? `Данные лаунчера: ${nativeHost.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}
</div>
+3 -5
View File
@@ -48,8 +48,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
.window-actions .close:hover { background: #a93939; }
.workspace { height: calc(100vh - 48px); display: grid; grid-template-columns: 64px 250px 1fr; }
.rail { background: #0c100d; border-right: 1px solid var(--line); padding: 18px 10px 14px; display: flex; flex-direction: column; justify-content: space-between; }
.rail-main { display: grid; gap: 8px; }
.rail { background: #0c100d; border-right: 1px solid var(--line); padding: 18px 10px 14px; display: flex; flex-direction: column; justify-content: flex-end; }
.rail-button { border: 0; width: 44px; height: 44px; border-radius: 5px; display: grid; place-items: center; background: transparent; color: #69716a; cursor: pointer; }
.rail-button svg { width: 20px; }
.rail-button:hover { color: #cfd6cf; background: #171c18; }
@@ -71,7 +70,8 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
.server-copy strong { font-size: 13px; font-weight: 700; }
.server-copy small { font-size: 10px; color: var(--green); margin-top: 2px; }
.server-row:not(.selected) .server-copy small { color: #707871; }
.account-chip { margin-top: auto; padding: 10px; border-top: 1px solid var(--line); display: grid; grid-template-columns: 34px 1fr 16px; gap: 9px; align-items: center; }
.account-chip { width: 100%; margin-top: auto; padding: 10px; border: 0; border-top: 1px solid var(--line); background: transparent; color: inherit; display: grid; grid-template-columns: 34px 1fr 16px; gap: 9px; align-items: center; text-align: left; cursor: pointer; }
.account-chip:hover { background: #171c18; }
.account-chip .avatar { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; background: #243127; color: #bfe1c1; font-size: 11px; font-weight: 800; }
.account-chip > span:nth-child(2) { display: grid; }
.account-chip strong { font-size: 12px; }
@@ -85,7 +85,6 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
linear-gradient(112deg, #0b130e 0%, #11291d 39%, #28301f 66%, #512713 100%);
}
.stage::after { content: ''; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: linear-gradient(180deg, transparent 45%, rgba(5,8,6,.9) 100%); }
.stage-create::before { filter: grayscale(.55); background: linear-gradient(110deg, #152019, #27372b 55%, #151a16); }
.stage-top { display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 22px 26px; }
.live-pill, .players { height: 28px; padding: 0 0 0 12px; border: 0; border-left: 1px solid rgba(255,255,255,.16); border-radius: 0; background: transparent; display: flex; align-items: center; gap: 7px; font-size: 11px; color: #c0c8c0; }
.live-pill span { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px rgba(119,209,122,.1), 0 0 12px rgba(119,209,122,.55); }
@@ -96,7 +95,6 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
.hero-copy p::before { content: ''; width: 26px; height: 2px; background: var(--copper); }
.hero-copy h1 { font-family: 'Unbounded', sans-serif; font-size: clamp(40px, 5vw, 66px); line-height: 1; letter-spacing: -.045em; margin: 0; text-shadow: 0 8px 36px rgba(0,0,0,.3); }
.hero-copy h2 { max-width: 440px; font-size: 15px; line-height: 1.5; font-weight: 500; color: #aeb7af; margin: 16px 0 0; }
.stage-create .hero-copy p { color: #9aa49c; }
.hero-meta { display: flex; gap: 0; margin: 36px 0 0; padding: 0; }
.hero-meta div { min-width: 130px; padding: 0 26px; border-left: 1px solid rgba(235,243,234,.14); }
.hero-meta div:first-child { padding-left: 0; border-left: 0; }