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 - uses: dtolnay/rust-toolchain@stable
- run: npm ci - run: npm ci
- run: npm run tauri:build -- ${{ matrix.args }} - 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 ## 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`. `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 - The response is an Ed25519 envelope. `src-tauri/src/remote.rs` verifies its
embedded public key and `keyId` **before** parsing the payload. embedded public key and `keyId` **before** parsing the payload.
- `src-tauri/src/manifest.rs` then validates paths, SHA-256, sizes, HTTPS and - `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 supports this flag. **Empirically verified (2026-09-06)**: it refuses to
target a directory unless a `launcher_profiles.json` stub already exists target a directory unless a `launcher_profiles.json` stub already exists
there ("you need to run the launcher first!") — `ensure_launcher_profiles_stub` there ("you need to run the launcher first!") — `ensure_launcher_profiles_stub`
writes a minimal one. It then fetches and patches vanilla itself; no writes a minimal one. It fetches the inputs needed to patch vanilla, but does
pre-seeding needed. Its own downloads go straight to `maven.neoforged.net`/ not guarantee that the complete vanilla runtime library set is present.
Mojang, outside our control — an accepted trust delegation to NeoForge's After installation, `mojang::ensure_client_jar` and `ensure_libraries` always
official tooling once the installer binary itself is verified. 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 Also verified: the resulting
`libraries/net/neoforged/neoforge/<ver>/neoforge-<ver>-client.jar` (the `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` (nickname + deterministic offline UUID) — see `docs/game-trust-boundary.md`
and `AGENTS.md`'s trust model section. 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 Not yet implemented: a user-selectable profile directory, a "reset managed
files only" recovery action, and signed cross-platform release builds of the 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. 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) -> Java 21 via Adoptium if none installed (runtime.rs)
-> NeoForge's own installer, run headlessly (neoforge.rs) -> NeoForge's own installer, run headlessly (neoforge.rs)
-> generic inheritsFrom merge of the two version JSONs (mojang.rs) -> generic inheritsFrom merge of the two version JSONs (mojang.rs)
-> SHA-1-verified merged libraries + platform natives (mojang.rs)
-> real Microsoft/Xbox/Minecraft Services login (msa.rs) -> real Microsoft/Xbox/Minecraft Services login (msa.rs)
-> java process spawned with the merged classpath/args (launch.rs) -> java process spawned with the merged classpath/args (launch.rs)
``` ```
+7 -1
View File
@@ -3,5 +3,11 @@
"identifier": "main-window", "identifier": "main-window",
"description": "Minimal permissions for the ShaCraft main window.", "description": "Minimal permissions for the ShaCraft main window.",
"windows": ["main"], "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 crate::session::PlayerIdentity;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{ use std::{
collections::HashMap, collections::{HashMap, HashSet},
fmt, fs, io, fmt, fs, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::{Child, Command, Stdio}, 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 { fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -> String {
let no_features = HashMap::new(); let no_features = HashMap::new();
let mut entries: Vec<PathBuf> = merged 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)) .map(|artifact| game_dir.join("libraries").join(&artifact.path))
.collect(); .collect();
entries.push(client_jar.to_path_buf()); 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()) 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(); let mut vars: HashMap<&str, String> = HashMap::new();
vars.insert("auth_player_name", request.identity.name().to_string()); 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("game_directory", request.profile_dir.display().to_string());
vars.insert("assets_root", assets_root.display().to_string()); vars.insert("assets_root", assets_root.display().to_string());
vars.insert("assets_index_name", request.merged.asset_index.id.clone()); 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("${auth_player_name}", &vars), "Steve");
assert_eq!(substitute("-Djava.library.path=${natives_directory}", &vars), "-Djava.library.path=${natives_directory}"); 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() 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. /// Validates an untrusted profile manifest before any file is downloaded.
#[tauri::command] #[tauri::command]
fn validate_manifest(manifest_json: String) -> Result<(), String> { 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}"))? }).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] #[tauri::command]
async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> { async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
let data_dir = app 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 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"))?; 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" { // The NeoForge installer creates the loader profile and patched
// Vanilla-only profiles skip the installer, which normally // client, but it does not guarantee that every vanilla runtime
// downloads vanilla itself; do it ourselves here instead. // library (notably LWJGL and its platform natives) is present.
mojang::ensure_client_jar(&client, &game_dir, &merged.client_jar_version_id, &merged.client).map_err(|error| error.to_string())?; // Verify the complete merged launch set for every loader kind.
mojang::ensure_libraries(&client, &game_dir, &merged.libraries, &stage_progress("libraries")).map_err(|error| error.to_string())?; 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())?; 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())?; 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![ .invoke_handler(tauri::generate_handler![
native_host, native_host,
detect_java, detect_java,
microsoft_login_available,
validate_manifest, validate_manifest,
inspect_profile, inspect_profile,
sync_profile, sync_profile,
inspect_remote_profile, inspect_remote_profile,
sync_remote_profile, sync_remote_profile,
get_server_status,
load_settings, load_settings,
save_settings, save_settings,
start_microsoft_login, 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) 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)] #[derive(Debug)]
pub enum MojangError { pub enum MojangError {
Network(reqwest::Error), Network(reqwest::Error),
@@ -430,7 +437,7 @@ pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library],
checksum: Checksum::Sha1(artifact.sha1.clone()), checksum: Checksum::Sha1(artifact.sha1.clone()),
}); });
} }
download_many(client, tasks, on_progress)?; download_many(client, tasks, on_progress, is_allowed_library_host)?;
Ok(paths) Ok(paths)
} }
@@ -473,7 +480,7 @@ pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_pr
} }
}) })
.collect(); .collect();
download_many(client, tasks, on_progress) download_many(client, tasks, on_progress, is_allowed_host)
} }
struct DownloadTask { 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 /// Downloads `tasks` using a small worker pool, calling `on_progress` with
/// cumulative (downloaded, total) bytes as each file completes. Stops /// cumulative (downloaded, total) bytes as each file completes. Stops
/// spawning new work once the first error is seen and returns it. /// 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(); let total: u64 = tasks.iter().map(|task| task.size).sum();
if total == 0 { if total == 0 {
return Ok(()); return Ok(());
@@ -529,7 +541,7 @@ fn download_many(client: &Client, tasks: Vec<DownloadTask>, on_progress: &Progre
break; break;
} }
let Some(task) = queue.lock().unwrap().pop() else { 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)); *first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url));
continue; continue;
} }
@@ -648,6 +660,8 @@ mod tests {
fn disallowed_host_is_rejected() { fn disallowed_host_is_rejected() {
assert!(!is_allowed_host("https://example.com/evil.jar")); 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_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 /// 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. /// access. Replace this before shipping login — see the module doc above.
const MSA_CLIENT_ID: &str = "00000000-0000-0000-0000-000000000000"; 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" MSA_CLIENT_ID != "00000000-0000-0000-0000-000000000000"
} }
@@ -106,7 +106,7 @@ struct DeviceCodeResponse {
} }
pub fn start_device_code(client: &Client) -> Result<DeviceCodeStart, MsaError> { pub fn start_device_code(client: &Client) -> Result<DeviceCodeStart, MsaError> {
if !client_id_is_configured() { if !is_configured() {
return Err(MsaError::NotConfigured); return Err(MsaError::NotConfigured);
} }
let response = client 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> { 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); return Err(MsaError::NotConfigured);
} }
let response = client let response = client
@@ -457,7 +457,7 @@ mod tests {
#[test] #[test]
fn refuses_to_run_with_placeholder_client_id() { fn refuses_to_run_with_placeholder_client_id() {
assert!(!client_id_is_configured()); assert!(!is_configured());
let client = Client::builder().build().unwrap(); let client = Client::builder().build().unwrap();
assert!(matches!(start_device_code(&client), Err(MsaError::NotConfigured))); 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) 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; missing_files += 1;
continue; 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()); let checksum = Checksum::Sha256(expected.sha256.clone());
if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? { if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
mismatched_files += 1; mismatched_files += 1;
@@ -164,4 +170,24 @@ mod tests {
fs::remove_dir_all(root).unwrap(); 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 base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use reqwest::{blocking::Client, redirect::Policy}; use reqwest::{blocking::Client, redirect::Policy};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::{fmt, time::Duration}; use std::{fmt, time::Duration};
const AERONAUTICS_MANIFEST: &str = const AERONAUTICS_MANIFEST: &str =
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest"; "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="; const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -18,6 +19,16 @@ struct SignedManifest {
signature: String, 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)] #[derive(Debug)]
pub enum RemoteError { pub enum RemoteError {
UnknownProfile, 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)?; let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest) 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 { createRoot } from 'react-dom/client'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { import {
ChevronRight, ChevronRight,
Download, Download,
FolderOpen, FolderOpen,
Gauge, Gauge,
Globe2, Globe2,
Library,
LogOut, LogOut,
MessageCircle,
Minus, Minus,
Newspaper,
Play, Play,
RotateCcw, RotateCcw,
Settings, Settings,
@@ -30,12 +28,8 @@ type Server = {
kicker: string kicker: string
name: string name: string
subtitle: string subtitle: string
players: string
version: string version: string
memory: string profileId: string
installed: boolean
profileId?: string
disabled?: boolean
} }
type NativeHost = { type NativeHost = {
@@ -86,6 +80,17 @@ type LoginResultPayload = {
error?: string error?: string
} }
type ServerStatus = {
online: number | null
max: number | null
reachable: boolean
}
type GameExitedPayload = {
profileId: string
exitCode: number | null
}
type InstallProgressPayload = { type InstallProgressPayload = {
stage: 'java' | 'neoforge' | 'libraries' | 'assets' stage: 'java' | 'neoforge' | 'libraries' | 'assets'
currentBytes: number currentBytes: number
@@ -105,29 +110,21 @@ const servers: Server[] = [
kicker: 'Основная сборка', kicker: 'Основная сборка',
name: 'Aeronautics', name: 'Aeronautics',
subtitle: 'Строй корабли. Поднимай города в небо.', subtitle: 'Строй корабли. Поднимай города в небо.',
players: '7 / 20', version: '1.21.1 · NeoForge 21.1.248',
version: '1.21.1 · NeoForge',
memory: '6 ГБ',
installed: true,
profileId: 'aeronautics', profileId: 'aeronautics',
}, },
{
id: 'create',
kicker: 'На техобслуживании',
name: 'Create',
subtitle: 'Механизмы, фабрики и большие идеи.',
players: 'Сервер остановлен',
version: '1.21.1 · NeoForge',
memory: '4 ГБ',
installed: false,
disabled: true,
},
] ]
function isTauri() { function isTauri() {
return '__TAURI_INTERNALS__' in window 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() { function App() {
const [selected, setSelected] = useState(servers[0]) const [selected, setSelected] = useState(servers[0])
const [progress, setProgress] = useState<number | null>(null) const [progress, setProgress] = useState<number | null>(null)
@@ -148,8 +145,11 @@ function App() {
const [loginError, setLoginError] = useState<string | null>(null) const [loginError, setLoginError] = useState<string | null>(null)
const [loggingIn, setLoggingIn] = useState(false) const [loggingIn, setLoggingIn] = useState(false)
const [installing, setInstalling] = useState(false) const [installing, setInstalling] = useState(false)
const [gameRunning, setGameRunning] = useState(false)
const [installProgress, setInstallProgress] = useState<InstallProgressPayload | null>(null) const [installProgress, setInstallProgress] = useState<InstallProgressPayload | null>(null)
const [launchError, setLaunchError] = useState<string | null>(null) const [launchError, setLaunchError] = useState<string | null>(null)
const [serverStatus, setServerStatus] = useState<ServerStatus | null>(null)
const [microsoftLoginAvailable, setMicrosoftLoginAvailable] = useState(false)
useEffect(() => { useEffect(() => {
if (progress === null) return if (progress === null) return
@@ -177,6 +177,9 @@ function App() {
invoke<JavaInstallation | null>('detect_java') invoke<JavaInstallation | null>('detect_java')
.then(setJava) .then(setJava)
.catch(() => setJava(null)) .catch(() => setJava(null))
invoke<boolean>('microsoft_login_available')
.then(setMicrosoftLoginAvailable)
.catch(() => setMicrosoftLoginAvailable(false))
invoke<ProfileInspection>('inspect_remote_profile', { profileId: 'aeronautics' }) invoke<ProfileInspection>('inspect_remote_profile', { profileId: 'aeronautics' })
.then((inspection) => { .then((inspection) => {
setProfile(inspection) setProfile(inspection)
@@ -188,6 +191,26 @@ function App() {
.catch(() => setAccount(null)) .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(() => { useEffect(() => {
if (!isTauri()) return if (!isTauri()) return
const unlisten = [ const unlisten = [
@@ -203,7 +226,15 @@ function App() {
} }
}), }),
listen<InstallProgressPayload>('game-install-progress', (event) => setInstallProgress(event.payload)), 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 () => { return () => {
unlisten.forEach((promise) => promise.then((off) => off())) unlisten.forEach((promise) => promise.then((off) => off()))
@@ -233,8 +264,7 @@ function App() {
} }
const repair = async () => { const repair = async () => {
if (selected.disabled) return if (isTauri()) {
if (isTauri() && selected.profileId) {
setSyncError(null) setSyncError(null)
setSyncing(true) setSyncing(true)
setReady(false) setReady(false)
@@ -243,7 +273,7 @@ function App() {
setProfile({ managedFiles: result.downloadedFiles + result.reusedFiles, missingFiles: 0, mismatchedFiles: 0, upToDate: true }) setProfile({ managedFiles: result.downloadedFiles + result.reusedFiles, missingFiles: 0, mismatchedFiles: 0, upToDate: true })
setReady(true) setReady(true)
} catch (error) { } catch (error) {
setSyncError(error instanceof Error ? error.message : 'Не удалось синхронизировать сборку') setSyncError(errorMessage(error, 'Не удалось синхронизировать сборку'))
} finally { } finally {
setSyncing(false) setSyncing(false)
} }
@@ -254,14 +284,17 @@ function App() {
} }
const startLogin = async () => { const startLogin = async () => {
if (!isTauri()) return if (!isTauri() || !microsoftLoginAvailable) {
setLoginError('Вход через Microsoft пока не настроен для этой версии лаунчера')
return
}
setLoginError(null) setLoginError(null)
setLoggingIn(true) setLoggingIn(true)
try { try {
await invoke('start_microsoft_login') await invoke('start_microsoft_login')
} catch (error) { } catch (error) {
setLoggingIn(false) setLoggingIn(false)
setLoginError(error instanceof Error ? error.message : 'Не удалось начать вход через Microsoft') setLoginError(errorMessage(error, 'Не удалось начать вход через Microsoft'))
} }
} }
@@ -272,7 +305,11 @@ function App() {
} }
const playOrLogin = async () => { 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 // In offline mode we can launch without any Microsoft session. In
// Microsoft mode a signed-in account is still required first. // Microsoft mode a signed-in account is still required first.
if (accountMode === 'microsoft' && (account === null || account === undefined)) { if (accountMode === 'microsoft' && (account === null || account === undefined)) {
@@ -280,51 +317,68 @@ function App() {
return return
} }
setLaunchError(null) setLaunchError(null)
setInstalling(true) setSyncError(null)
setSyncing(true)
setInstallProgress(null) setInstallProgress(null)
try { 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 }) await invoke('ensure_game_installed', { profileId: selected.profileId })
setInstallProgress(null)
setInstalling(false)
setGameRunning(true)
await invoke('launch_game', { profileId: selected.profileId }) await invoke('launch_game', { profileId: selected.profileId })
} catch (error) { } catch (error) {
setLaunchError(error instanceof Error ? error.message : 'Не удалось запустить игру') setLaunchError(errorMessage(error, 'Не удалось запустить игру'))
setSyncing(false)
setInstalling(false) setInstalling(false)
setGameRunning(false)
} }
} }
const playLabel = () => { const playLabel = () => {
if (selected.disabled) return 'Недоступно' if (accountMode === 'microsoft' && !microsoftLoginAvailable) return 'Microsoft недоступен'
if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…' if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…'
if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft' if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft'
if (gameRunning) return 'Игра запущена'
if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}` : 'Подготовка…' if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}` : 'Подготовка…'
if (syncing || progress !== null) return 'Обновление' if (syncing || progress !== null) return 'Обновление'
return ready ? 'Играть' : 'Проверить' return ready ? 'Играть' : 'Проверить'
} }
const installPercent = installProgress && installProgress.totalBytes > 0 ? Math.min(100, Math.round((installProgress.currentBytes / installProgress.totalBytes) * 100)) : null 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 ( return (
<div className="app-shell"> <div className="app-shell">
<header className="titlebar"> <header className="titlebar" data-tauri-drag-region>
<div className="brand"> <div className="brand" data-tauri-drag-region>
<img src={logo} alt="" /> <img src={logo} alt="" data-tauri-drag-region />
<span>ShaCraft</span> <span data-tauri-drag-region>ShaCraft</span>
</div> </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="Управление окном"> <div className="window-actions" aria-label="Управление окном">
<button aria-label="Свернуть"><Minus size={15} /></button> <button aria-label="Свернуть" onClick={minimizeWindow}><Minus size={15} /></button>
<button aria-label="Развернуть"><Square size={12} /></button> <button aria-label="Развернуть" onClick={toggleMaximizeWindow}><Square size={12} /></button>
<button className="close" aria-label="Закрыть"><X size={15} /></button> <button className="close" aria-label="Закрыть" onClick={closeWindow}><X size={15} /></button>
</div> </div>
</header> </header>
<div className="workspace"> <div className="workspace">
<nav className="rail" aria-label="Основное меню"> <nav className="rail" aria-label="Настройки лаунчера">
<div className="rail-main"> <button className="rail-button active" aria-label="Настройки" onClick={() => setSettingsOpen(true)}>
<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)}>
<Settings /> <Settings />
</button> </button>
</nav> </nav>
@@ -341,7 +395,7 @@ function App() {
className={`server-row ${selected.id === server.id ? 'selected' : ''}`} className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
onClick={() => { onClick={() => {
setSelected(server) setSelected(server)
setReady(server.installed) setReady(profile?.upToDate ?? false)
setProgress(null) setProgress(null)
}} }}
> >
@@ -350,51 +404,49 @@ function App() {
</span> </span>
<span className="server-copy"> <span className="server-copy">
<strong>{server.name}</strong> <strong>{server.name}</strong>
<small>{server.disabled ? 'На паузе' : 'Установлена'}</small> <small>{profile?.upToDate ? 'Файлы проверены' : 'Требуется проверка'}</small>
</span> </span>
<ChevronRight size={16} /> <ChevronRight size={16} />
</button> </button>
))} ))}
</div> </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 className="avatar">{accountMode === 'offline' ? nickname.slice(0, 2).toUpperCase() : (account ? account.name.slice(0, 2).toUpperCase() : '?')}</span>
<span> <span>
<strong>{accountMode === 'offline' ? nickname : (account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : account.name)}</strong> <strong>{accountMode === 'offline' ? nickname : (account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : account.name)}</strong>
<small>{accountMode === 'offline' ? 'Offline-аккаунт' : (account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть')}</small> <small>{accountMode === 'offline' ? 'Offline-аккаунт' : (account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть')}</small>
</span> </span>
{accountMode === 'microsoft' && account ? ( <ChevronRight size={16} />
<button aria-label="Выйти из аккаунта" onClick={logout} style={{ background: 'transparent', border: 0, cursor: 'pointer', color: 'inherit' }}> </button>
<LogOut size={16} />
</button>
) : (
<ChevronRight size={16} />
)}
</div>
</aside> </aside>
<main className={`stage stage-${selected.id}`}> <main className={`stage stage-${selected.id}`}>
<div className="stage-top"> <div className="stage-top">
<div className={`live-pill ${selected.disabled ? 'offline' : ''}`}> <div className={`live-pill ${serverStatus === null || serverStatus.reachable ? '' : 'offline'}`}>
<span /> {selected.disabled ? 'Не в сети' : 'Сервер работает'} <span /> {serverStatus === null ? 'Проверяем сервер' : serverStatus.reachable ? 'Сервер доступен' : 'Сервер недоступен'}
</div> </div>
<div className="players"><Users size={16} /> {selected.players}</div> <div className="players"><Users size={16} /> {onlineLabel}</div>
</div> </div>
<section className="hero-copy"> <section className="hero-copy">
<p>{selected.id === 'aoc' ? 'All of Create / сборка 2.5' : selected.kicker}</p> <p>{selected.kicker}</p>
<h1>{selected.name}</h1> <h1>{selected.name}</h1>
<h2>{selected.subtitle}</h2> <h2>{selected.subtitle}</h2>
<dl className="hero-meta"> <dl className="hero-meta">
<div><dt>Состав</dt><dd>{selected.id === 'aoc' ? '250 модов' : '41 мод'}</dd></div> <div><dt>Загрузчик</dt><dd>NeoForge 21.1.248</dd></div>
<div><dt>Загрузчик</dt><dd>{selected.id === 'aoc' ? 'NeoForge 21.1.248' : 'NeoForge 21.1.249'}</dd></div>
<div><dt>Java</dt><dd>Версия 21</dd></div> <div><dt>Java</dt><dd>Версия 21</dd></div>
</dl> </dl>
</section> </section>
<section className="play-dock"> <section className="play-dock">
<div className="build-state"> <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 className="state-icon downloading"><Download size={19} /></span>
<span> <span>
@@ -415,17 +467,12 @@ function App() {
<small>Файлы и обновления · {progress}%</small> <small>Файлы и обновления · {progress}%</small>
</span> </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 className="state-icon"><ShieldCheck size={19} /></span>
<span> <span>
<strong>{accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Сборка готова' : 'Требуется проверка'}</strong> <strong>{accountMode === 'microsoft' && !microsoftLoginAvailable ? 'Microsoft пока недоступен' : accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'}</strong>
<small>{launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов под контролем` : 'Проверяем локальные файлы')}</small> <small>{launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов сборки` : 'Проверяем локальные файлы')}</small>
</span> </span>
</> </>
)} )}
@@ -438,12 +485,12 @@ function App() {
<span><Gauge size={15} /> {ram} ГБ памяти</span> <span><Gauge size={15} /> {ram} ГБ памяти</span>
</div> </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} /> <RotateCcw size={19} />
</button> </button>
<button <button
className="play-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} onClick={playOrLogin}
> >
<Play size={21} fill="currentColor" /> <Play size={21} fill="currentColor" />
@@ -476,7 +523,7 @@ function App() {
</label> </label>
<div className="setting-row static"> <div className="setting-row static">
<span><Users />Аккаунт</span> <span><Users />Аккаунт</span>
<small>{accountMode === 'offline' ? 'Offline' : (account ? account.name : 'Не авторизован')}</small> <small>{accountMode === 'offline' ? 'Offline' : (microsoftLoginAvailable ? (account ? account.name : 'Не авторизован') : 'Временно недоступен')}</small>
</div> </div>
{accountMode === 'offline' && ( {accountMode === 'offline' && (
<label className="text-setting"> <label className="text-setting">
@@ -493,7 +540,7 @@ function App() {
style={{ background: 'transparent', border: 0, color: 'inherit', textAlign: 'right' }} style={{ background: 'transparent', border: 0, color: 'inherit', textAlign: 'right' }}
> >
<option value="offline">Offline</option> <option value="offline">Offline</option>
<option value="microsoft">Microsoft</option> <option value="microsoft" disabled={!microsoftLoginAvailable}>Microsoft (скоро)</option>
</select> </select>
</div> </div>
{accountMode === 'microsoft' && account && ( {accountMode === 'microsoft' && account && (
@@ -501,7 +548,7 @@ function App() {
<span><LogOut />Выйти из Microsoft</span> <span><LogOut />Выйти из Microsoft</span>
</button> </button>
)} )}
{accountMode === 'microsoft' && !account && ( {accountMode === 'microsoft' && !account && microsoftLoginAvailable && (
<button className="setting-row" onClick={startLogin}> <button className="setting-row" onClick={startLogin}>
<span><LogOut />Войти через Microsoft</span> <span><LogOut />Войти через Microsoft</span>
</button> </button>
@@ -522,7 +569,6 @@ function App() {
: 'Лаунчер установит Java 21 автоматически'} : 'Лаунчер установит Java 21 автоматически'}
</small> </small>
</div> </div>
<button className="setting-row"><span><Wrench />Дополнительные параметры</span><ChevronRight /></button>
<div className="drawer-note"> <div className="drawer-note">
{nativeHost ? `Данные лаунчера: ${nativeHost.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'} {nativeHost ? `Данные лаунчера: ${nativeHost.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}
</div> </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; } .window-actions .close:hover { background: #a93939; }
.workspace { height: calc(100vh - 48px); display: grid; grid-template-columns: 64px 250px 1fr; } .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 { background: #0c100d; border-right: 1px solid var(--line); padding: 18px 10px 14px; display: flex; flex-direction: column; justify-content: flex-end; }
.rail-main { display: grid; gap: 8px; }
.rail-button { border: 0; width: 44px; height: 44px; border-radius: 5px; display: grid; place-items: center; background: transparent; color: #69716a; cursor: pointer; } .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 svg { width: 20px; }
.rail-button:hover { color: #cfd6cf; background: #171c18; } .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 strong { font-size: 13px; font-weight: 700; }
.server-copy small { font-size: 10px; color: var(--green); margin-top: 2px; } .server-copy small { font-size: 10px; color: var(--green); margin-top: 2px; }
.server-row:not(.selected) .server-copy small { color: #707871; } .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 .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 > span:nth-child(2) { display: grid; }
.account-chip strong { font-size: 12px; } .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%); 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::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; } .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, .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); } .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 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 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; } .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 { 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 { 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; } .hero-meta div:first-child { padding-left: 0; border-left: 0; }