From b0c2687677c8e7cb65de94fe15a6d42700ba0416 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 6 Sep 2026 18:12:22 +0300 Subject: [PATCH] fix launcher UI and game launch pipeline --- .github/workflows/build.yml | 9 ++ AGENTS.md | 5 +- docs/game-trust-boundary.md | 12 +- docs/launcher-architecture.md | 5 + src-tauri/capabilities/default.json | 8 +- src-tauri/src/launch.rs | 26 +++- src-tauri/src/lib.rs | 30 +++- src-tauri/src/mojang.rs | 22 ++- src-tauri/src/msa.rs | 8 +- src-tauri/src/neoforge.rs | 2 +- src-tauri/src/profile.rs | 26 ++++ src-tauri/src/remote.rs | 30 +++- src/main.tsx | 204 +++++++++++++++++----------- src/styles.css | 8 +- 14 files changed, 287 insertions(+), 108 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5eb8325..74009d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index e275ad1..371e0b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/game-trust-boundary.md b/docs/game-trust-boundary.md index e730490..804adf0 100644 --- a/docs/game-trust-boundary.md +++ b/docs/game-trust-boundary.md @@ -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//neoforge--client.jar` (the diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index b025464..bb85839 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -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) ``` diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0e15dea..7a2e208 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" + ] } diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index db88ee5..a39a118 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -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) -> Vec { + 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 = 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::>().join(classpath_separator()) } @@ -134,7 +145,12 @@ pub fn launch(request: &LaunchRequest) -> Result { 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")]); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4dbf863..8ad6034 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -58,6 +58,13 @@ fn detect_java() -> Option { 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 Result { + 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 { 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, diff --git a/src-tauri/src/mojang.rs b/src-tauri/src/mojang.rs index ed81f44..497bb6c 100644 --- a/src-tauri/src/mojang.rs +++ b/src-tauri/src/mojang.rs @@ -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, on_progress: &ProgressCallback) -> Result<(), MojangError> { +fn download_many( + client: &Client, + tasks: Vec, + 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, 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 diff --git a/src-tauri/src/msa.rs b/src-tauri/src/msa.rs index 75a5878..e215657 100644 --- a/src-tauri/src/msa.rs +++ b/src-tauri/src/msa.rs @@ -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 { - 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 Result { - 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))); } diff --git a/src-tauri/src/neoforge.rs b/src-tauri/src/neoforge.rs index 32d5e02..a66bac6 100644 --- a/src-tauri/src/neoforge.rs +++ b/src-tauri/src/neoforge.rs @@ -88,7 +88,7 @@ impl From 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) } diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 23ea3ad..6e4c532 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -50,6 +50,12 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result, + pub max: Option, + pub reachable: bool, +} + #[derive(Debug)] pub enum RemoteError { UnknownProfile, @@ -74,3 +85,20 @@ pub fn fetch_manifest(profile_id: &str) -> Result { 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 { + 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::().map_err(RemoteError::Network) +} diff --git a/src/main.tsx b/src/main.tsx index 1b4e896..d4df264 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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(null) @@ -148,8 +145,11 @@ function App() { const [loginError, setLoginError] = useState(null) const [loggingIn, setLoggingIn] = useState(false) const [installing, setInstalling] = useState(false) + const [gameRunning, setGameRunning] = useState(false) const [installProgress, setInstallProgress] = useState(null) const [launchError, setLaunchError] = useState(null) + const [serverStatus, setServerStatus] = useState(null) + const [microsoftLoginAvailable, setMicrosoftLoginAvailable] = useState(false) useEffect(() => { if (progress === null) return @@ -177,6 +177,9 @@ 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) @@ -188,6 +191,26 @@ function App() { .catch(() => setAccount(null)) }, []) + useEffect(() => { + if (!isTauri()) return + let disposed = false + const refresh = () => { + invoke('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('game-install-progress', (event) => setInstallProgress(event.payload)), - listen('game-exited', () => setInstalling(false)), + listen('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('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 (
-
-
- - ShaCraft +
+
+ + ShaCraft
-
{nativeHost ? `Лаунчер · ${nativeHost.platform}` : 'Лаунчер'}
+
{nativeHost ? `Лаунчер · ${nativeHost.platform}` : 'Лаунчер'}
- - - + + +
- @@ -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() { {server.name} - {server.disabled ? 'На паузе' : 'Установлена'} + {profile?.upToDate ? 'Файлы проверены' : 'Требуется проверка'} ))}
-
+ - ) : ( - - )} -
+ +
-
- {selected.disabled ? 'Не в сети' : 'Сервер работает'} +
+ {serverStatus === null ? 'Проверяем сервер' : serverStatus.reachable ? 'Сервер доступен' : 'Сервер недоступен'}
-
{selected.players}
+
{onlineLabel}
-

{selected.id === 'aoc' ? 'All of Create / сборка 2.5' : selected.kicker}

+

{selected.kicker}

{selected.name}

{selected.subtitle}

-
Состав
{selected.id === 'aoc' ? '250 модов' : '41 мод'}
-
Загрузчик
{selected.id === 'aoc' ? 'NeoForge 21.1.248' : 'NeoForge 21.1.249'}
+
Загрузчик
NeoForge 21.1.248
Java
Версия 21
- {installing ? ( + {gameRunning ? ( + <> + + Игра запущенаЛаунчер готов к работе после выхода + + ) : installing ? ( <> @@ -415,17 +467,12 @@ function App() { Файлы и обновления · {progress}% - ) : selected.disabled ? ( - <> - - ТехобслуживаниеСообщим, когда сервер вернётся - ) : ( <> - {accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Сборка готова' : 'Требуется проверка'} - {launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов под контролем` : 'Проверяем локальные файлы')} + {accountMode === 'microsoft' && !microsoftLoginAvailable ? 'Microsoft пока недоступен' : accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'} + {launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов сборки` : 'Проверяем локальные файлы')} )} @@ -438,12 +485,12 @@ function App() { {ram} ГБ памяти
-
{accountMode === 'microsoft' && account && ( @@ -501,7 +548,7 @@ function App() { Выйти из Microsoft )} - {accountMode === 'microsoft' && !account && ( + {accountMode === 'microsoft' && !account && microsoftLoginAvailable && ( @@ -522,7 +569,6 @@ function App() { : 'Лаунчер установит Java 21 автоматически'}
-
{nativeHost ? `Данные лаунчера: ${nativeHost.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}
diff --git a/src/styles.css b/src/styles.css index 16785de..09c614b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; }