fix launcher UI and game launch pipeline
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user