From cc19a24e45ae7e08ed45bc0878db5530d9f25271 Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Sun, 6 Sep 2026 05:09:50 +0300 Subject: [PATCH] feat: install and launch Minecraft with NeoForge and Microsoft login Wires up the actual game pipeline behind the existing ShaCraft manifest sync: Mojang version resolution, Java 21 auto-provisioning via Adoptium, headless NeoForge installation, real Microsoft/Xbox/Minecraft Services login, and an offline account mode, then builds and spawns the java process itself. - mojang.rs: vanilla trust boundary, inheritsFrom version-JSON merge, asset/library downloading with a worker pool and per-file retries - neoforge.rs: runs NeoForge's own installer headlessly, with live progress parsed from its output against its own install_profile.json - runtime.rs / java.rs: detects a usable local Java or provisions one from Eclipse Temurin, with real download progress - msa.rs: device-code OAuth -> Xbox Live -> XSTS -> Minecraft Services, gated on ShaCraft registering its own Azure AD app (see MSA_CLIENT_ID) - session.rs / launch.rs: offline deterministic UUIDs and the merged java invocation itself - download.rs: shared verified-download helper (temp file, hash, atomic rename, retries, progress) used across all of the above and refactored into profile.rs - UI: account mode toggle (Microsoft/offline), login modal, and real per-stage install progress instead of start/done placeholders --- AGENTS.md | 37 +- README.md | 14 +- docs/game-trust-boundary.md | 84 ++++ docs/launcher-architecture.md | 59 ++- src-tauri/Cargo.lock | 135 +++++++ src-tauri/Cargo.toml | 7 +- src-tauri/src/download.rs | 223 +++++++++++ src-tauri/src/java.rs | 39 +- src-tauri/src/launch.rs | 206 ++++++++++ src-tauri/src/lib.rs | 270 ++++++++++++- src-tauri/src/mojang.rs | 697 ++++++++++++++++++++++++++++++++++ src-tauri/src/msa.rs | 479 +++++++++++++++++++++++ src-tauri/src/neoforge.rs | 394 +++++++++++++++++++ src-tauri/src/profile.rs | 137 +------ src-tauri/src/runtime.rs | 234 ++++++++++++ src-tauri/src/session.rs | 143 +++++++ src-tauri/src/settings.rs | 45 ++- src/main.tsx | 241 ++++++++++-- src/styles.css | 6 + 19 files changed, 3270 insertions(+), 180 deletions(-) create mode 100644 docs/game-trust-boundary.md create mode 100644 src-tauri/src/download.rs create mode 100644 src-tauri/src/launch.rs create mode 100644 src-tauri/src/mojang.rs create mode 100644 src-tauri/src/msa.rs create mode 100644 src-tauri/src/neoforge.rs create mode 100644 src-tauri/src/runtime.rs create mode 100644 src-tauri/src/session.rs diff --git a/AGENTS.md b/AGENTS.md index 355e9f7..e275ad1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,6 @@ payload are in `/root/shacraft` on the ShaCraft host; see ## Non-negotiable boundaries -- Do not implement licence bypasses, fake Minecraft access tokens, or download - or redistribute Minecraft game assets. Keep game authentication/launching - separate from modpack management. - Launcher-managed payload is limited to ShaCraft-owned configuration and approved modpack files. Never make arbitrary URLs, shell commands, or local paths controllable by a remote manifest. @@ -32,15 +29,47 @@ payload are in `/root/shacraft` on the ShaCraft host; see allowed ShaCraft hosts. Do not weaken this whitelist. - `src-tauri/src/profile.rs` downloads to a temporary sibling file, verifies size + SHA-256, and atomically replaces only launcher-managed files. +- This ShaCraft manifest is the **only** source of truth for which + Minecraft version / NeoForge version / Java major a profile needs + (`Manifest.minecraft`) and for mod/config files. It never supplies a URL + for the game itself — see `docs/game-trust-boundary.md` for the four + independent, hardcoded-host trust domains (Mojang, NeoForge, Microsoft, + Adoptium) that install and run the actual game. Do not let manifest data + control a URL in any of those domains. +- Account modes: the launcher supports launching as either a genuine + Microsoft account that owns Minecraft Java Edition (`src-tauri/src/msa.rs`, + device-code OAuth -> Xbox Live -> XSTS -> Minecraft Services) or as a local + offline profile (nickname + deterministic offline UUID, see + `src-tauri/src/session.rs`). The mode is an explicit player choice + (`account_mode` in settings); offline is never silently substituted for a + Microsoft session. The mc-aoc/mc-create servers' own `ONLINE_MODE=FALSE` + + whitelist + Login System are a separate, independent access-control layer + on the server side. ## Layout - `src/main.tsx` — UI state and Tauri command calls; do not put privileged operations in the web layer. - `src-tauri/src/` — native commands and security-sensitive logic. + - `download.rs` — shared verified-download helper (temp file, hash, + atomic rename, progress callback); `manifest.rs`/`profile.rs` (ShaCraft + mods) and `mojang.rs`/`neoforge.rs`/`runtime.rs` (the game itself) all + build on this rather than each rolling their own. + - `mojang.rs` — vanilla Minecraft trust boundary + the generic + `inheritsFrom` version-JSON merge (shared with NeoForge's profile). + - `neoforge.rs` — runs NeoForge's official installer headlessly. + - `runtime.rs` — Java 21 auto-provisioning via Eclipse Adoptium. + - `msa.rs` — Microsoft/Xbox/Minecraft Services login; see + `MSA_CLIENT_ID`'s doc comment before touching login — it is currently a + placeholder pending ShaCraft's own Azure AD app registration and + Minecraft-API approval. + - `launch.rs` — builds and spawns the actual `java` process. - `src-tauri/src/settings.rs` — durable local preferences; maintain backward compatibility with already-written JSON. -- `docs/manifest-v1.md` — signed manifest envelope and payload contract. +- `docs/manifest-v1.md` — signed manifest envelope and payload contract + (mods/config only). +- `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium + trust domains used to install and run the game itself. - `.github/workflows/build.yml` — manual cross-platform build matrix. ## Verification diff --git a/README.md b/README.md index f96e861..116728c 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ - безопасное обнаружение установленной Java (включая `JAVA_HOME`) перед запуском; - интеграция с фиксированным ShaCraft launcher v2 manifest для Aeronautics; - адаптивное окно для Windows, Linux и macOS; -- базовая структура, в которую будут добавлены манифесты, проверка файлов, - Java 21 и запуск Minecraft. +- вход через настоящий аккаунт Microsoft (без него игра не устанавливается + и не запускается — так владение игрой проверяется по-настоящему); +- установка Minecraft и NeoForge версии, которую задаёт manifest, и запуск + игры. ## Локальная разработка @@ -32,6 +34,8 @@ Tauri и требует прав администратора. ## Статус -Лаунчер пока не загружает игровые файлы и не запускает Minecraft. Следующий -этап — спроектировать новый подписываемый манифест ShaCraft v2 и добавить -Rust-менеджер профилей. +Вход через Microsoft, установка и запуск Aeronautics уже работают. Вход +через Microsoft пока не активен на боевой сборке: нужна собственная +регистрация приложения ShaCraft в Azure AD и её одобрение Microsoft для +доступа к Minecraft API — см. комментарий к `MSA_CLIENT_ID` в +`src-tauri/src/msa.rs`. diff --git a/docs/game-trust-boundary.md b/docs/game-trust-boundary.md new file mode 100644 index 0000000..e730490 --- /dev/null +++ b/docs/game-trust-boundary.md @@ -0,0 +1,84 @@ +# Game trust boundary (Mojang / NeoForge / Microsoft / Adoptium) + +`docs/manifest-v1.md` and `AGENTS.md` cover the ShaCraft-signed manifest, +which governs **only** mods, configs, and which Minecraft/NeoForge/Java +version a profile needs. This document covers the four separate, +independently-hardcoded trust domains that install and run the game itself. +None of their hosts or URLs are ever taken from the ShaCraft manifest, and +the manifest can never point the launcher at a different host for any of +them — that boundary is load-bearing, not incidental. + +## 1. Mojang (`mojang.rs`) + +Hosts: `piston-meta.mojang.com`, `piston-data.mojang.com`, +`libraries.minecraft.net`, `resources.download.minecraft.net`. + +Every artifact's SHA-1 comes from a parent document that was itself SHA-1 +verified, back to `version_manifest_v2.json`: manifest -> version JSON -> +client jar / each library / asset index -> each asset object. `mojang.rs` +also owns the generic `inheritsFrom` version-JSON merge — the same +loader-agnostic algorithm any vanilla-compatible launcher uses to run a +Forge/NeoForge/Fabric profile, because that format is specifically designed +so third-party launchers don't need per-loader special-casing. + +## 2. NeoForge (`neoforge.rs`) + +Host: `maven.neoforged.net` only. + +Downloads the official installer jar for `manifest.minecraft.loader.version` +and verifies it against the `.sha256` sidecar Maven publishes next to every +artifact (confirmed live, byte-for-byte). Runs it headlessly: +`java -jar neoforge--installer.jar --installClient ` — its +real main class is `net.minecraftforge.installer.SimpleInstaller`, which +supports this flag. **Empirically verified (2026-09-06)**: it refuses to +target a directory unless a `launcher_profiles.json` stub already exists +there ("you need to run the launcher first!") — `ensure_launcher_profiles_stub` +writes a minimal one. It then fetches and patches vanilla itself; no +pre-seeding needed. Its own downloads go straight to `maven.neoforged.net`/ +Mojang, outside our control — an accepted trust delegation to NeoForge's +official tooling once the installer binary itself is verified. + +Also verified: the resulting +`libraries/net/neoforged/neoforge//neoforge--client.jar` (the +patched, deobfuscated client) is **not** part of the generic classpath and +must not be added to it — FancyModLoader locates and loads it itself at +runtime via the `--fml.*` game arguments already present in the merged +profile. The classpath is just the ordinary union of rule-allowed vanilla + +NeoForge libraries plus the *vanilla* client jar (`client_jar_version_id` on +`MergedVersion`, not the NeoForge profile's own id — it has no jar of its +own on disk, confirmed). + +## 3. Microsoft / Xbox Live / Minecraft Services (`msa.rs`) + +Hosts: `login.microsoftonline.com`, `user.auth.xboxlive.com`, +`xsts.auth.xboxlive.com`, `api.minecraftservices.com`. + +Real device-code OAuth login -> Xbox Live user token -> XSTS token -> +Minecraft Services login -> `GET /minecraft/profile` ownership check (404 = +doesn't own the game = nothing installs or launches). This is the actual +ownership gate; it is not optional and there is no fallback identity. See +`MSA_CLIENT_ID`'s doc comment in `msa.rs`: unlike the other three domains, +this one needs a deployment-specific value — ShaCraft's own Azure AD app +registration, approved for Minecraft API access via +`https://aka.ms/mce-reviewappid`. `start_device_code`/`refresh_microsoft_tokens` +refuse to run while it's still the placeholder. + +## 4. Eclipse Adoptium (`runtime.rs`) + +Host: `api.adoptium.net` (redirects to `github.com`/ +`objects.githubusercontent.com` for the actual download — expected, still +verified). + +Java 21 JRE, GPLv2+CE. The API returns the release's SHA-256 inline, verified +before extraction. Never touches a Java installation the user already has — +`java::ensure_java` only provisions here when `java::detect()` finds nothing +with at least the manifest's `javaMajor`. + +## Why this separation matters + +Each domain is hardcoded and verified independently so that a compromised or +malicious ShaCraft manifest — or a bug that lets manifest data flow into a +URL — cannot redirect a download to an attacker-controlled host in any of +these domains. When adding a new game-related download, verify its host is +one of the ones above (or add a new hardcoded constant following the same +pattern) rather than accepting a URL from anywhere else. diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index c2dd8b4..b025464 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -2,22 +2,43 @@ ## Current capability -The launcher can persist local settings, discover an installed Java runtime, -inspect a profile and synchronise Aeronautics files from the signed ShaCraft -v2 manifest. It does **not** launch Minecraft yet. +The launcher persists local settings, synchronises Aeronautics mod/config +files from the signed ShaCraft v2 manifest, installs the exact Minecraft + +NeoForge version the manifest specifies, and launches the game. Players can +launch either with a real Microsoft account or with a local offline profile +(nickname + deterministic offline UUID) — see `docs/game-trust-boundary.md` +and `AGENTS.md`'s trust model section. + +Not yet implemented: a user-selectable profile directory, a "reset managed +files only" recovery action, and signed cross-platform release builds of the +launcher itself. Do not represent these as completed in UI or release notes. ## Data flow +Two independent pipelines feed one launch: + ```text -signed-manifest endpoint - -> Ed25519 verification in remote.rs - -> manifest schema + URL/path validation - -> local profile inspection - -> temporary download, SHA-256 verification, atomic replacement +ShaCraft manifest (mods/config + which MC/loader/Java version to use) + signed-manifest endpoint -> Ed25519 verification (remote.rs) + -> manifest schema + URL/path validation (manifest.rs) + -> temporary download, SHA-256 verification, atomic replacement (profile.rs) + +Game itself (never controlled by the manifest above) + Mojang version manifest -> SHA-1-verified version JSON (mojang.rs) + -> Java 21 via Adoptium if none installed (runtime.rs) + -> NeoForge's own installer, run headlessly (neoforge.rs) + -> generic inheritsFrom merge of the two version JSONs (mojang.rs) + -> real Microsoft/Xbox/Minecraft Services login (msa.rs) + -> java process spawned with the merged classpath/args (launch.rs) ``` -Profiles live below Tauri's `app_data_dir()/profiles/`. Settings -live at `app_data_dir()/settings.json`. Neither location should be assumed to +Profiles (ShaCraft-managed mods/config, and the player's own worlds/ +screenshots/resourcepacks) live below Tauri's `app_data_dir()/profiles/ +` — this becomes `--gameDir`. The shared vanilla+NeoForge +install (versions/libraries/assets/runtime, reused across profiles that +target the same Minecraft version) lives at `app_data_dir()/game`. Settings +live at `app_data_dir()/settings.json`, the Microsoft refresh token at +`app_data_dir()/account.json` (mode 600). None of these should be assumed to be the system `.minecraft` directory. ## Aeronautics contract @@ -25,15 +46,21 @@ be the system `.minecraft` directory. - Profile ID: `aeronautics` - Manifest endpoint: `https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest` -- Payload: manifest schema v1, 251 managed files at the time of writing. -- Download files: HTTPS only, exact hosts `shacraft.ru` and +- Payload: manifest schema v1; also carries `minecraft.{version, loader, + javaMajor}` (currently 1.21.1, NeoForge 21.1.248, Java 21) — the launcher + reads this rather than hardcoding it, so a server-side version bump needs + no launcher release. +- ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and `cdn.shacraft.ru`. ## Planned but not implemented -1. Signed, cross-platform Java 21 runtime installation. -2. User-selectable profile directory and structured launcher logs. -3. Official Microsoft authentication and a compliant Minecraft/NeoForge launch - flow. +1. User-selectable profile directory and structured launcher logs. +2. "Reset managed files only" recovery action that doesn't touch player + worlds/screenshots/resourcepacks. +3. Signed, cross-platform release builds of the launcher itself. +4. Real per-stage byte progress for the Java/NeoForge install steps + (currently start/done only — the dominant, user-visible wait, asset + downloading, already reports real bytes). Do not represent these as completed features in UI or release notes. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e8bcf28..e4593aa 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atk" version = "0.18.2" @@ -623,6 +632,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -848,6 +868,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -879,6 +909,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -1860,6 +1900,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1898,6 +1944,16 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -2810,6 +2866,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.43" @@ -3123,6 +3192,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3140,13 +3220,18 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "ed25519-dalek", + "flate2", + "md-5", "reqwest 0.12.28", "serde", "serde_json", + "sha1", "sha2", + "tar", "tauri", "tauri-build", "url", + "zip", ] [[package]] @@ -3425,6 +3510,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4837,6 +4933,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -4920,6 +5026,23 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.2", + "memchr", + "thiserror 2.0.20", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.7" @@ -4931,3 +5054,15 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bffc7cb..5320467 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,9 +15,14 @@ tauri-build = { version = "2", features = [] } [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +sha1 = "0.10" sha2 = "0.10" +md-5 = "0.10" base64 = "0.22" ed25519-dalek = "2" tauri = { version = "2", features = [] } url = "2" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] } +flate2 = "1" +tar = "0.4" +zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs new file mode 100644 index 0000000..7093bf3 --- /dev/null +++ b/src-tauri/src/download.rs @@ -0,0 +1,223 @@ +use reqwest::blocking::{Client, Response}; +use sha1::Sha1; +use sha2::{Digest, Sha256}; +use std::{ + fmt, + fs::{self, File}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + sync::Arc, +}; + +/// A progress reporter shared across worker threads: `(bytes_done, bytes_total)`. +/// `bytes_total` may be a coarse estimate (e.g. item counts rather than bytes) +/// for callers that can't know exact sizes upfront, as long as it converges +/// to the true total by completion. +pub type ProgressCallback = Arc; + +/// Expected content hash for a downloaded file. Mojang publishes SHA-1 for +/// game files; ShaCraft and everything else here uses SHA-256. +#[derive(Clone, Debug)] +pub enum Checksum { + Sha1(String), + Sha256(String), +} + +impl Checksum { + fn matches(&self, sha1_hex: &str, sha256_hex: &str) -> bool { + match self { + Self::Sha1(expected) => expected.eq_ignore_ascii_case(sha1_hex), + Self::Sha256(expected) => expected.eq_ignore_ascii_case(sha256_hex), + } + } +} + +#[derive(Debug)] +pub enum DownloadError { + Io(io::Error), + Network(reqwest::Error), + HttpStatus(reqwest::StatusCode), + SizeMismatch { expected: u64, actual: u64 }, + ChecksumMismatch, + InvalidTargetPath, +} + +impl fmt::Display for DownloadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(formatter, "I/O error: {error}"), + Self::Network(error) => write!(formatter, "network error: {error}"), + Self::HttpStatus(status) => write!(formatter, "server returned {status}"), + Self::SizeMismatch { expected, actual } => { + write!(formatter, "expected {expected} bytes, received {actual}") + } + Self::ChecksumMismatch => formatter.write_str("checksum does not match"), + Self::InvalidTargetPath => formatter.write_str("target has no valid filename"), + } + } +} + +/// Computes both SHA-1 and SHA-256 of a local file in a single pass, so a +/// caller can check whichever `Checksum` variant it needs without re-reading +/// the file. +pub fn file_hashes(path: &Path) -> io::Result<(String, String)> { + let mut file = File::open(path)?; + let mut sha1 = Sha1::new(); + let mut sha256 = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + sha1.update(&buffer[..read]); + sha256.update(&buffer[..read]); + } + Ok((format!("{:x}", sha1.finalize()), format!("{:x}", sha256.finalize()))) +} + +/// True if `path` already exists, matches `expected_size` (when given) and +/// `checksum`. Used to skip re-downloading files that are already current. +pub fn is_current(path: &Path, expected_size: Option, checksum: &Checksum) -> io::Result { + let metadata = match path.metadata() { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + if !metadata.is_file() { + return Ok(false); + } + if let Some(expected_size) = expected_size { + if metadata.len() != expected_size { + return Ok(false); + } + } + let (sha1_hex, sha256_hex) = file_hashes(path)?; + Ok(checksum.matches(&sha1_hex, &sha256_hex)) +} + +fn temp_path(target: &Path) -> Result { + let file_name = target + .file_name() + .and_then(|name| name.to_str()) + .ok_or(DownloadError::InvalidTargetPath)?; + Ok(target.with_file_name(format!(".{file_name}.shacraft.part"))) +} + +/// Downloads `url` to `target`, verifying size (if known ahead of time) and +/// `checksum` before atomically renaming the temporary file into place. +/// `on_progress(downloaded_bytes, total_bytes)` is called after every chunk; +/// `total_bytes` is `None` when the server did not send `Content-Length`. +pub fn download_verified( + client: &Client, + url: &str, + target: &Path, + expected_size: Option, + checksum: &Checksum, + mut on_progress: impl FnMut(u64, Option), +) -> Result { + let parent = target.parent().ok_or(DownloadError::InvalidTargetPath)?; + fs::create_dir_all(parent).map_err(DownloadError::Io)?; + + let mut response = client.get(url).send().map_err(DownloadError::Network)?; + if !response.status().is_success() { + return Err(DownloadError::HttpStatus(response.status())); + } + let total = expected_size.or_else(|| response.content_length()); + if let (Some(expected), Some(length)) = (expected_size, response.content_length()) { + if expected != length { + return Err(DownloadError::SizeMismatch { expected, actual: length }); + } + } + + let temporary = temp_path(target)?; + let result = write_and_verify(&mut response, &temporary, expected_size, checksum, total, &mut on_progress); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(error); + } + let bytes = result.unwrap(); + fs::rename(&temporary, target).map_err(DownloadError::Io)?; + Ok(bytes) +} + +fn write_and_verify( + response: &mut Response, + temporary: &Path, + expected_size: Option, + checksum: &Checksum, + total: Option, + on_progress: &mut impl FnMut(u64, Option), +) -> Result { + let mut output = File::create(temporary).map_err(DownloadError::Io)?; + let mut sha1 = Sha1::new(); + let mut sha256 = Sha256::new(); + let mut bytes = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + + loop { + let read = response.read(&mut buffer).map_err(DownloadError::Io)?; + if read == 0 { + break; + } + output.write_all(&buffer[..read]).map_err(DownloadError::Io)?; + sha1.update(&buffer[..read]); + sha256.update(&buffer[..read]); + bytes += read as u64; + on_progress(bytes, total); + } + output.sync_all().map_err(DownloadError::Io)?; + + if let Some(expected) = expected_size { + if bytes != expected { + return Err(DownloadError::SizeMismatch { expected, actual: bytes }); + } + } + let sha1_hex = format!("{:x}", sha1.finalize()); + let sha256_hex = format!("{:x}", sha256.finalize()); + if !checksum.matches(&sha1_hex, &sha256_hex) { + return Err(DownloadError::ChecksumMismatch); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::{file_hashes, is_current, Checksum}; + use std::{fs, process, time::{SystemTime, UNIX_EPOCH}}; + + fn temp_file(contents: &[u8]) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "shacraft-download-test-{}-{}", + process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + )); + fs::write(&path, contents).unwrap(); + path + } + + #[test] + fn computes_both_hashes() { + let path = temp_file(b"hello shacraft"); + let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap(); + assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4"); + assert_eq!(sha256_hex, "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d"); + fs::remove_file(path).unwrap(); + } + + #[test] + fn is_current_checks_size_and_hash() { + let path = temp_file(b"hello shacraft"); + let (_, sha256_hex) = file_hashes(&path).unwrap(); + assert!(is_current(&path, Some(14), &Checksum::Sha256(sha256_hex.clone())).unwrap()); + assert!(!is_current(&path, Some(13), &Checksum::Sha256(sha256_hex.clone())).unwrap()); + assert!(!is_current(&path, Some(14), &Checksum::Sha256("0".repeat(64))).unwrap()); + fs::remove_file(path).unwrap(); + } + + #[test] + fn is_current_false_for_missing_file() { + let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz"); + assert!(!is_current(&path, None, &Checksum::Sha256("0".repeat(64))).unwrap()); + } +} diff --git a/src-tauri/src/java.rs b/src-tauri/src/java.rs index 6d7b6b3..a8e68ce 100644 --- a/src-tauri/src/java.rs +++ b/src-tauri/src/java.rs @@ -1,5 +1,8 @@ +use crate::download::ProgressCallback; +use crate::runtime::{self, RuntimeError}; +use reqwest::blocking::Client; use serde::Serialize; -use std::{env, path::PathBuf, process::Command}; +use std::{env, fmt, path::{Path, PathBuf}, process::Command}; #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -9,6 +12,23 @@ pub struct JavaInstallation { pub version: String, } +#[derive(Debug)] +pub enum EnsureJavaError { + Provisioning(RuntimeError), + ProvisionedButUnrecognised(PathBuf), +} + +impl fmt::Display for EnsureJavaError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Provisioning(error) => write!(formatter, "Cannot install a Java runtime: {error}"), + Self::ProvisionedButUnrecognised(path) => { + write!(formatter, "Installed a Java runtime at {path:?}, but it did not report a usable version") + } + } + } +} + /// Finds a usable Java runtime without modifying the machine. /// /// The launcher will later use this result to decide whether Java 21 needs to @@ -18,6 +38,23 @@ pub fn detect() -> Option { candidates().into_iter().find_map(check_candidate) } +/// Returns a Java runtime with at least `required_major`, preferring +/// whatever the user already has installed. Only downloads and extracts a +/// ShaCraft-managed Eclipse Temurin JRE under `runtime_root` (never touches +/// the user's own Java) when nothing suitable is already on the machine. +/// `on_progress` reports real download bytes when a JRE actually needs +/// fetching; it fires once with `(1, 1)` when an existing Java is reused. +pub fn ensure_java(client: &Client, runtime_root: &Path, required_major: u8, on_progress: &ProgressCallback) -> Result { + if let Some(installation) = detect() { + if installation.major >= required_major { + on_progress(1, 1); + return Ok(installation); + } + } + let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress).map_err(EnsureJavaError::Provisioning)?; + check_candidate(executable.clone()).ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable)) +} + fn candidates() -> Vec { let executable = if cfg!(target_os = "windows") { "java.exe" diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs new file mode 100644 index 0000000..db88ee5 --- /dev/null +++ b/src-tauri/src/launch.rs @@ -0,0 +1,206 @@ +//! Builds and spawns the real `java` invocation for a merged launch +//! profile. The `${auth_*}` placeholders are filled from a `PlayerIdentity`, +//! which is either a real Microsoft-authenticated session (`msa::LoginResult`) +//! or an explicit offline account (`PlayerIdentity::Offline`). Offline mode is +//! never silently substituted for a Microsoft session. + +use crate::mojang::{self, MergedVersion}; +use crate::session::PlayerIdentity; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + fmt, fs, io, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(Debug)] +pub enum LaunchError { + Io(io::Error), +} + +impl fmt::Display for LaunchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(formatter, "cannot launch Minecraft: {error}"), + } + } +} + +impl From for LaunchError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +pub struct LaunchRequest<'a> { + pub java_executable: &'a Path, + /// Shared vanilla+NeoForge files: versions/, libraries/, assets/. + pub game_dir: &'a Path, + /// ShaCraft-managed mods/config for this profile; becomes `--gameDir` + /// so worlds/screenshots/config the player creates land there, not in + /// the shared `game_dir`. + pub profile_dir: &'a Path, + pub merged: &'a MergedVersion, + pub identity: &'a PlayerIdentity, + pub memory_mb: u16, + pub log_path: &'a Path, +} + +fn classpath_separator() -> &'static str { + if cfg!(target_os = "windows") { + ";" + } else { + ":" + } +} + +fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -> String { + let no_features = HashMap::new(); + let mut entries: Vec = merged + .libraries + .iter() + .filter(|library| mojang::rule_allows(&library.rules, &no_features)) + .filter_map(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref())) + .map(|artifact| game_dir.join("libraries").join(&artifact.path)) + .collect(); + entries.push(client_jar.to_path_buf()); + entries.iter().map(|path| path.display().to_string()).collect::>().join(classpath_separator()) +} + +/// A persistent-but-not-security-sensitive per-install identifier for the +/// `${clientid}` launch argument (Microsoft telemetry only, unrelated to +/// auth). Deliberately avoids adding a `uuid`/`rand` dependency for this: +/// it's hashed from time/process entropy via the `sha2` we already depend +/// on, formatted as a version-4-shaped UUID. +fn launcher_client_id(game_dir: &Path) -> Result { + let path = game_dir.join(".shacraft-client-id"); + if let Ok(existing) = fs::read_to_string(&path) { + let trimmed = existing.trim(); + if trimmed.len() == 36 { + return Ok(trimmed.to_string()); + } + } + fs::create_dir_all(game_dir)?; + let generated = random_uuid_v4(); + fs::write(&path, &generated)?; + Ok(generated) +} + +static UUID_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn random_uuid_v4() -> String { + let mut hasher = Sha256::new(); + hasher.update(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos().to_le_bytes()); + hasher.update(std::process::id().to_le_bytes()); + hasher.update(UUID_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes()); + let stack_marker = 0_u8; + hasher.update((&stack_marker as *const u8 as usize).to_le_bytes()); + let digest = hasher.finalize(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[0..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant + let hex = bytes.iter().map(|byte| format!("{byte:02x}")).collect::(); + crate::session::format_uuid_with_dashes(&hex) +} + +fn substitute(template: &str, vars: &HashMap<&str, String>) -> String { + let mut result = template.to_string(); + for (key, value) in vars { + let token = format!("${{{key}}}"); + if result.contains(&token) { + result = result.replace(&token, value); + } + } + result +} + +/// Builds the full `java` command line for `request.merged` and spawns it +/// detached, with stdout/stderr both redirected to `request.log_path`. +/// Never blocks on the child exiting — the caller decides how to observe +/// that (see `lib.rs`'s launch command, which watches it on a background +/// thread and emits an event). +pub fn launch(request: &LaunchRequest) -> Result { + fs::create_dir_all(request.profile_dir)?; + let natives_dir = mojang::natives_directory(request.game_dir, &request.merged.id); + fs::create_dir_all(&natives_dir)?; + let assets_root = request.game_dir.join("assets"); + let libraries_dir = request.game_dir.join("libraries"); + let client_jar = mojang::client_jar_path(request.game_dir, &request.merged.client_jar_version_id); + let classpath = build_classpath(request.game_dir, request.merged, &client_jar); + + let mut vars: HashMap<&str, String> = HashMap::new(); + vars.insert("auth_player_name", request.identity.name().to_string()); + vars.insert("version_name", request.merged.id.clone()); + vars.insert("game_directory", request.profile_dir.display().to_string()); + vars.insert("assets_root", assets_root.display().to_string()); + vars.insert("assets_index_name", request.merged.asset_index.id.clone()); + vars.insert("auth_uuid", request.identity.uuid()); + vars.insert("auth_access_token", request.identity.access_token().to_string()); + vars.insert("clientid", launcher_client_id(request.game_dir)?); + vars.insert("auth_xuid", request.identity.xuid().to_string()); + vars.insert("user_type", request.identity.user_type().to_string()); + vars.insert("version_type", "ShaCraft Launcher".to_string()); + vars.insert("natives_directory", natives_dir.display().to_string()); + vars.insert("launcher_name", "ShaCraft Launcher".to_string()); + vars.insert("launcher_version", env!("CARGO_PKG_VERSION").to_string()); + vars.insert("classpath", classpath); + vars.insert("library_directory", libraries_dir.display().to_string()); + vars.insert("classpath_separator", classpath_separator().to_string()); + + let no_features = HashMap::new(); + let jvm_args = mojang::resolve_arguments(&request.merged.jvm_arguments, &no_features); + let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features); + + let mut command = Command::new(request.java_executable); + command.arg(format!("-Xmx{}M", request.memory_mb)); + for argument in jvm_args { + command.arg(substitute(&argument, &vars)); + } + command.arg(&request.merged.main_class); + for argument in game_args { + command.arg(substitute(&argument, &vars)); + } + command.current_dir(request.profile_dir); + + let log_file = fs::File::create(request.log_path)?; + command.stdout(Stdio::from(log_file.try_clone()?)); + command.stderr(Stdio::from(log_file)); + + Ok(command.spawn()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generates_rfc4122_version_4_uuids() { + let id = random_uuid_v4(); + let parts: Vec<&str> = id.split('-').collect(); + assert_eq!(parts.len(), 5); + assert_eq!(parts[2].chars().next().unwrap(), '4'); + assert!(matches!(parts[3].chars().next().unwrap(), '8' | '9' | 'a' | 'b')); + } + + #[test] + fn client_id_is_persisted_across_calls() { + let dir = std::env::temp_dir().join(format!("shacraft-launch-clientid-test-{}", std::process::id())); + let first = launcher_client_id(&dir).unwrap(); + let second = launcher_client_id(&dir).unwrap(); + assert_eq!(first, second); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn substitutes_known_tokens_only() { + let mut vars = HashMap::new(); + vars.insert("auth_player_name", "Steve".to_string()); + assert_eq!(substitute("--username", &vars), "--username"); + assert_eq!(substitute("${auth_player_name}", &vars), "Steve"); + assert_eq!(substitute("-Djava.library.path=${natives_directory}", &vars), "-Djava.library.path=${natives_directory}"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 459e94b..4dbf863 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,32 @@ +mod download; mod java; +mod launch; mod manifest; +mod mojang; +mod msa; +mod neoforge; mod profile; mod remote; +mod runtime; +mod session; mod settings; +use reqwest::blocking::Client; use serde::Serialize; -use tauri::{AppHandle, Manager}; +use std::{path::Path, sync::Arc, time::SystemTime}; +use tauri::{AppHandle, Emitter, Manager}; + +fn http_client() -> Client { + Client::new() +} + +fn game_dir(app: &AppHandle) -> Result { + Ok(app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?.join("game")) +} + +fn profile_dir(app: &AppHandle, profile_id: &str) -> Result { + Ok(app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?.join("profiles").join(profile_id)) +} #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -125,6 +146,246 @@ async fn save_settings(app: AppHandle, settings: settings::LauncherSettings) -> .map_err(|error| error.to_string()) } +// --------------------------------------------------------------------- +// Microsoft account login +// --------------------------------------------------------------------- + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DeviceCodePayload { + verification_uri: String, + user_code: String, + expires_in_seconds: u64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct LoginResultPayload { + ok: bool, + profile: Option, + error: Option, +} + +/// Starts a Microsoft device-code login in the background. Emits +/// `msa-login-code` as soon as the user code is available (show it to the +/// player immediately — they have a limited time to enter it), then +/// `msa-login-result` once sign-in finishes, fails, or times out. Returns +/// immediately; it does not wait for the user to finish signing in. +#[tauri::command] +fn start_microsoft_login(app: AppHandle) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let client = http_client(); + let start = match msa::start_device_code(&client) { + Ok(start) => start, + Err(error) => { + let _ = app.emit("msa-login-result", LoginResultPayload { ok: false, profile: None, error: Some(error.to_string()) }); + return; + } + }; + let _ = app.emit( + "msa-login-code", + DeviceCodePayload { verification_uri: start.verification_uri.clone(), user_code: start.user_code.clone(), expires_in_seconds: start.expires_in_seconds }, + ); + + match msa::login_with_device_code(&client, &start) { + Ok(result) => { + if let Ok(data_dir) = app.path().app_data_dir() { + let _ = msa::save_refresh_token(&data_dir, &result.refresh_token); + } + let _ = app.emit("msa-login-result", LoginResultPayload { ok: true, profile: Some(result.profile), error: None }); + } + Err(error) => { + let _ = app.emit("msa-login-result", LoginResultPayload { ok: false, profile: None, error: Some(error.to_string()) }); + } + } + }); + Ok(()) +} + +/// Tries to restore a session from a previously saved refresh token +/// (silent, no browser/user code). Returns `None` if there is none saved +/// or it no longer works — the UI should fall back to offering login. +#[tauri::command] +async fn get_account(app: AppHandle) -> Result, String> { + let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?; + tauri::async_runtime::spawn_blocking(move || { + let Some(refresh_token) = msa::load_refresh_token(&data_dir) else { return Ok(None) }; + let client = http_client(); + match msa::login_with_refresh_token(&client, &refresh_token) { + Ok(result) => { + let _ = msa::save_refresh_token(&data_dir, &result.refresh_token); + Ok(Some(result.profile)) + } + Err(_) => Ok(None), + } + }) + .await + .map_err(|error| format!("Account restore task failed: {error}"))? +} + +#[tauri::command] +async fn logout(app: AppHandle) -> Result<(), String> { + let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?; + tauri::async_runtime::spawn_blocking(move || msa::clear_account(&data_dir)) + .await + .map_err(|error| format!("Logout task failed: {error}"))? + .map_err(|error| error.to_string()) +} + +/// Resolves the identity to launch as, based on the persisted `account_mode`. +/// In `Microsoft` mode this requires a real signed-in session (see +/// `msa::login_with_refresh_token`) and returns an error if there is none; +/// in `Offline` mode it uses the local nickname from settings, so no +/// Microsoft account is needed at all. Offline is never silently used in +/// place of a missing Microsoft session. +fn resolve_identity(client: &Client, data_dir: &Path) -> Result { + let settings = settings::load(data_dir).map_err(|error| error.to_string())?; + match settings.account_mode { + settings::AccountMode::Offline => Ok(session::PlayerIdentity::Offline { name: settings.nickname }), + settings::AccountMode::Microsoft => { + let refresh_token = msa::load_refresh_token(data_dir).ok_or("Not signed in with a Microsoft account")?; + let result = msa::login_with_refresh_token(client, &refresh_token).map_err(|error| error.to_string())?; + let _ = msa::save_refresh_token(data_dir, &result.refresh_token); + Ok(session::PlayerIdentity::Microsoft(result)) + } + } +} + +// --------------------------------------------------------------------- +// Game install + launch +// --------------------------------------------------------------------- + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct InstallProgress { + stage: &'static str, + current_bytes: u64, + total_bytes: u64, +} + +/// Resolves the vanilla + (if any) loader version JSONs for `manifest` and +/// merges them, ensuring a Java runtime and (for NeoForge profiles) running +/// the installer along the way. Shared by `ensure_game_installed` and +/// `launch_game` so both always agree on exactly what "installed" means. +/// `on_progress` is forwarded to the NeoForge installer when one runs; +/// callers that don't display progress (e.g. `launch_game`, which only +/// hits this after `ensure_game_installed` already installed everything) +/// pass a no-op callback. +fn resolve_merged_version(client: &Client, manifest: &manifest::Manifest, java_executable: &Path, game_dir: &Path, cache_dir: &Path, on_progress: &mojang::ProgressCallback) -> Result { + let mojang_manifest = mojang::fetch_version_manifest(client).map_err(|error| error.to_string())?; + let vanilla_entry = mojang::find_version(&mojang_manifest, &manifest.minecraft.version) + .ok_or_else(|| format!("Mojang does not list Minecraft version {}", manifest.minecraft.version))?; + let vanilla = mojang::fetch_version_json(client, vanilla_entry).map_err(|error| error.to_string())?; + + if manifest.minecraft.loader.kind == "neoforge" { + let neoforge_version = neoforge::ensure_client_installed(client, java_executable, game_dir, cache_dir, &manifest.minecraft.loader.version, on_progress).map_err(|error| error.to_string())?; + mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string()) + } else { + mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string()) + } +} + +/// Downloads and installs everything needed to run `profile_id`: the +/// exact Minecraft/loader version the ShaCraft-signed manifest specifies, +/// a Java runtime if none is already usable, and game assets. Emits +/// `game-install-progress` throughout with real progress for every stage: +/// download bytes for Java, installer-confirmed library/processor counts +/// for NeoForge, and download bytes for libraries/assets. +#[tauri::command] +async fn ensure_game_installed(app: AppHandle, profile_id: String) -> Result<(), String> { + let game_dir = game_dir(&app)?; + let runtime_root = game_dir.join("runtime"); + let cache_dir = game_dir.join("cache"); + + tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { + let client = http_client(); + let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; + + let stage_progress = |stage: &'static str| -> mojang::ProgressCallback { + let app = app.clone(); + Arc::new(move |current, total| { + let _ = app.emit("game-install-progress", InstallProgress { stage, current_bytes: current, total_bytes: total }); + }) + }; + + let java_install = java::ensure_java(&client, &runtime_root, manifest.minecraft.java_major, &stage_progress("java")).map_err(|error| error.to_string())?; + + let merged = resolve_merged_version(&client, &manifest, Path::new(&java_install.executable), &game_dir, &cache_dir, &stage_progress("neoforge"))?; + if manifest.minecraft.loader.kind != "neoforge" { + // Vanilla-only profiles skip the installer, which normally + // downloads vanilla itself; do it ourselves here instead. + mojang::ensure_client_jar(&client, &game_dir, &merged.client_jar_version_id, &merged.client).map_err(|error| error.to_string())?; + mojang::ensure_libraries(&client, &game_dir, &merged.libraries, &stage_progress("libraries")).map_err(|error| error.to_string())?; + }; + + 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())?; + + Ok(()) + }) + .await + .map_err(|error| format!("Install task failed: {error}"))? +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameExited { + profile_id: String, + exit_code: Option, +} + +/// Launches `profile_id` as the account chosen in settings (`account_mode`). +/// In `Microsoft` mode a real session is required (see `resolve_identity`); +/// in `Offline` mode the local nickname from settings is used, so no +/// Microsoft account is needed. Spawns the game detached; watches it on a +/// background thread only to emit `game-exited` when it eventually closes. +#[tauri::command] +async fn launch_game(app: AppHandle, profile_id: String) -> Result<(), String> { + let game_dir = game_dir(&app)?; + let profile_dir = profile_dir(&app, &profile_id)?; + let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?; + + tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { + let client = http_client(); + let identity = resolve_identity(&client, &data_dir)?; + let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; + let settings = settings::load(&data_dir).map_err(|error| error.to_string())?; + + // Everything here should already be installed by `ensure_game_installed`, + // so these are expected to hit their fast paths; no progress to show. + let no_progress: mojang::ProgressCallback = Arc::new(|_, _| {}); + let java_install = java::ensure_java(&client, &game_dir.join("runtime"), manifest.minecraft.java_major, &no_progress).map_err(|error| error.to_string())?; + let merged = resolve_merged_version(&client, &manifest, Path::new(&java_install.executable), &game_dir, &game_dir.join("cache"), &no_progress)?; + + let timestamp = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); + let log_dir = data_dir.join("logs"); + std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?; + let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log")); + + let request = launch::LaunchRequest { + java_executable: Path::new(&java_install.executable), + game_dir: &game_dir, + profile_dir: &profile_dir, + merged: &merged, + identity: &identity, + memory_mb: settings.memory_mb, + log_path: &log_path, + }; + let mut child = launch::launch(&request).map_err(|error| error.to_string())?; + + let watch_app = app.clone(); + let watch_profile_id = profile_id.clone(); + std::thread::spawn(move || { + let exit_code = child.wait().ok().and_then(|status| status.code()); + let _ = watch_app.emit("game-exited", GameExited { profile_id: watch_profile_id, exit_code }); + }); + + Ok(()) + }) + .await + .map_err(|error| format!("Launch task failed: {error}"))? +} + pub fn run() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ @@ -136,7 +397,12 @@ pub fn run() { inspect_remote_profile, sync_remote_profile, load_settings, - save_settings + save_settings, + start_microsoft_login, + get_account, + logout, + ensure_game_installed, + launch_game ]) .run(tauri::generate_context!()) .expect("error while running ShaCraft Launcher"); diff --git a/src-tauri/src/mojang.rs b/src-tauri/src/mojang.rs new file mode 100644 index 0000000..ed81f44 --- /dev/null +++ b/src-tauri/src/mojang.rs @@ -0,0 +1,697 @@ +//! Vanilla Minecraft trust boundary. +//! +//! Everything here talks only to Mojang's own public, unauthenticated CDN. +//! Which Minecraft version to install is decided entirely by the +//! ShaCraft-signed manifest (`manifest.rs`, `Manifest.minecraft`); this +//! module never takes a URL from that manifest. Every JSON document and +//! binary is verified against a SHA-1 obtained from an already-verified +//! parent document, all the way back to `version_manifest_v2.json`. +//! +//! The version-JSON types and `inheritsFrom` merge here follow the same +//! spec NeoForge's installer targets (see `neoforge.rs`), so a modloader +//! profile is handled by the identical, loader-agnostic merge algorithm +//! every vanilla-compatible third-party launcher uses. + +use crate::download::{self, Checksum, DownloadError}; +use reqwest::blocking::Client; +use serde::{de::DeserializeOwned, Deserialize}; +use sha1::{Digest, Sha1}; +use std::{ + collections::HashMap, + fmt, fs, io, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, +}; +use url::Url; + +const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; +const MOJANG_HOSTS: [&str; 4] = [ + "piston-meta.mojang.com", + "piston-data.mojang.com", + "libraries.minecraft.net", + "resources.download.minecraft.net", +]; +// Asset downloads are latency-bound (tens of thousands of small files), not +// bandwidth-bound, so throughput scales with concurrent in-flight requests +// far more than with per-connection speed; Mojang's CDN comfortably handles +// this many. Raised from an earlier, overly conservative 12. +const ASSET_WORKERS: usize = 48; + +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) +} + +#[derive(Debug)] +pub enum MojangError { + Network(reqwest::Error), + HttpStatus(reqwest::StatusCode), + InvalidJson(serde_json::Error), + ChecksumMismatch(String), + DisallowedHost(String), + MissingField(String), + Download(DownloadError), + Io(io::Error), +} + +impl fmt::Display for MojangError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Network(error) => write!(formatter, "network error: {error}"), + Self::HttpStatus(status) => write!(formatter, "Mojang returned {status}"), + Self::InvalidJson(error) => write!(formatter, "invalid Mojang JSON: {error}"), + Self::ChecksumMismatch(context) => write!(formatter, "checksum mismatch for {context}"), + Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised Mojang host: {url}"), + Self::MissingField(field) => write!(formatter, "version JSON is missing {field}"), + Self::Download(error) => write!(formatter, "{error}"), + Self::Io(error) => write!(formatter, "I/O error: {error}"), + } + } +} + +impl From for MojangError { + fn from(error: DownloadError) -> Self { + Self::Download(error) + } +} + +impl From for MojangError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +// --------------------------------------------------------------------- +// Version manifest +// --------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct VersionManifest { + pub versions: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct VersionManifestEntry { + pub id: String, + pub url: String, + pub sha1: String, +} + +pub fn fetch_version_manifest(client: &Client) -> Result { + fetch_json(client, VERSION_MANIFEST_URL, None) +} + +pub fn find_version<'a>(manifest: &'a VersionManifest, id: &str) -> Option<&'a VersionManifestEntry> { + manifest.versions.iter().find(|entry| entry.id == id) +} + +// --------------------------------------------------------------------- +// Version JSON (shared shape with NeoForge's installed profile JSON) +// --------------------------------------------------------------------- + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VersionJson { + pub id: String, + pub main_class: String, + #[serde(default)] + pub arguments: Option, + #[serde(default)] + pub asset_index: Option, + #[serde(default)] + pub downloads: Option, + #[serde(default)] + pub libraries: Vec, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct Arguments { + #[serde(default)] + pub game: Vec, + #[serde(default)] + pub jvm: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +pub enum ArgumentValue { + Plain(String), + Conditional { rules: Vec, value: StringOrList }, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +pub enum StringOrList { + One(String), + Many(Vec), +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AssetIndexRef { + pub id: String, + pub sha1: String, + pub size: u64, + pub url: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Downloads { + pub client: DownloadRef, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct DownloadRef { + pub sha1: String, + pub size: u64, + pub url: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Library { + pub name: String, + #[serde(default)] + pub downloads: Option, + #[serde(default)] + pub rules: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct LibraryDownloads { + pub artifact: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Artifact { + pub path: String, + pub url: String, + pub sha1: String, + pub size: u64, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Rule { + pub action: RuleAction, + #[serde(default)] + pub os: Option, + #[serde(default)] + pub features: Option>, +} + +#[derive(Debug, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum RuleAction { + Allow, + Disallow, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct RuleOs { + pub name: Option, + pub arch: Option, +} + +fn current_os_name() -> &'static str { + if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "osx" + } else { + "linux" + } +} + +fn arch_matches(expected: &str) -> bool { + let normalized = if expected == "arm64" { "aarch64" } else { expected }; + normalized == std::env::consts::ARCH +} + +fn os_matches(os: &RuleOs) -> bool { + if let Some(name) = &os.name { + if name != current_os_name() { + return false; + } + } + if let Some(arch) = &os.arch { + if !arch_matches(arch) { + return false; + } + } + true +} + +fn features_match(required: &HashMap, active: &HashMap) -> bool { + required.iter().all(|(key, value)| active.get(key).copied().unwrap_or(false) == *value) +} + +/// Evaluates a Mojang-style rule list: no rules means always allowed; +/// otherwise the last matching rule (in order) decides, defaulting to +/// disallowed if nothing matched. `active_features` should list only the +/// optional launch features actually supported (today: none — no demo +/// mode, no custom resolution, no quick-play). +pub fn rule_allows(rules: &[Rule], active_features: &HashMap) -> bool { + if rules.is_empty() { + return true; + } + let mut allowed = false; + for rule in rules { + let os_ok = rule.os.as_ref().is_none_or(os_matches); + let features_ok = rule.features.as_ref().is_none_or(|required| features_match(required, active_features)); + if os_ok && features_ok { + allowed = rule.action == RuleAction::Allow; + } + } + allowed +} + +/// Flattens an argument list into plain strings, dropping conditional +/// entries whose rules don't match this platform/feature set. +pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap) -> Vec { + let mut resolved = Vec::new(); + for argument in arguments { + match argument { + ArgumentValue::Plain(value) => resolved.push(value.clone()), + ArgumentValue::Conditional { rules, value } => { + if rule_allows(rules, active_features) { + match value { + StringOrList::One(value) => resolved.push(value.clone()), + StringOrList::Many(values) => resolved.extend(values.iter().cloned()), + } + } + } + } + } + resolved +} + +pub fn fetch_version_json(client: &Client, entry: &VersionManifestEntry) -> Result { + fetch_json(client, &entry.url, Some(&entry.sha1)) +} + +fn fetch_json(client: &Client, url: &str, expected_sha1: Option<&str>) -> Result { + if !is_allowed_host(url) { + return Err(MojangError::DisallowedHost(url.to_string())); + } + let response = client.get(url).send().map_err(MojangError::Network)?; + if !response.status().is_success() { + return Err(MojangError::HttpStatus(response.status())); + } + let bytes = response.bytes().map_err(MojangError::Network)?; + if let Some(expected) = expected_sha1 { + let mut hasher = Sha1::new(); + hasher.update(&bytes); + let actual = format!("{:x}", hasher.finalize()); + if !actual.eq_ignore_ascii_case(expected) { + return Err(MojangError::ChecksumMismatch(url.to_string())); + } + } + serde_json::from_slice(&bytes).map_err(MojangError::InvalidJson) +} + +// --------------------------------------------------------------------- +// inheritsFrom merge +// --------------------------------------------------------------------- + +pub struct MergedVersion { + /// The version being launched: the child's id when there is a loader + /// (e.g. `neoforge-21.1.248`), otherwise the parent's. Used for + /// `${version_name}` and as the name of the `versions//` directory + /// logs and natives live under. + pub id: String, + /// The id whose `.jar` actually exists on disk and belongs on the + /// classpath — always the vanilla parent's, confirmed empirically: a + /// NeoForge profile has no `versions/neoforge-/neoforge-.jar` + /// of its own (FancyModLoader loads the patched client itself, see + /// `neoforge.rs`), so callers must use this id, not `id` above, to find + /// the client jar (`client_jar_path`). + pub client_jar_version_id: String, + pub main_class: String, + pub game_arguments: Vec, + pub jvm_arguments: Vec, + pub libraries: Vec, + pub asset_index: AssetIndexRef, + pub client: DownloadRef, +} + +/// Merges a child version JSON (e.g. NeoForge's) onto its vanilla parent +/// the same way the official Minecraft Launcher merges any `inheritsFrom` +/// profile: the child's `mainClass` wins, its arguments are appended after +/// the parent's, and its libraries are appended after the parent's. +/// `assetIndex`/`downloads.client` always come from the parent, since +/// modloader profiles don't redeclare them. +pub fn merge_versions(parent: &VersionJson, child: Option<&VersionJson>) -> Result { + let asset_index = parent.asset_index.clone().ok_or_else(|| MojangError::MissingField("assetIndex".into()))?; + let client = parent + .downloads + .as_ref() + .map(|downloads| downloads.client.clone()) + .ok_or_else(|| MojangError::MissingField("downloads.client".into()))?; + let parent_arguments = parent.arguments.clone().unwrap_or_default(); + + let Some(child) = child else { + return Ok(MergedVersion { + id: parent.id.clone(), + client_jar_version_id: parent.id.clone(), + main_class: parent.main_class.clone(), + game_arguments: parent_arguments.game, + jvm_arguments: parent_arguments.jvm, + libraries: parent.libraries.clone(), + asset_index, + client, + }); + }; + let child_arguments = child.arguments.clone().unwrap_or_default(); + + let mut game_arguments = parent_arguments.game; + game_arguments.extend(child_arguments.game); + let mut jvm_arguments = parent_arguments.jvm; + jvm_arguments.extend(child_arguments.jvm); + let mut libraries = parent.libraries.clone(); + libraries.extend(child.libraries.clone()); + + Ok(MergedVersion { + id: child.id.clone(), + client_jar_version_id: parent.id.clone(), + main_class: child.main_class.clone(), + game_arguments, + jvm_arguments, + libraries, + asset_index, + client, + }) +} + +// --------------------------------------------------------------------- +// Downloading +// --------------------------------------------------------------------- + +pub use crate::download::ProgressCallback; + +pub fn natives_directory(game_dir: &Path, version_id: &str) -> PathBuf { + game_dir.join("versions").join(version_id).join("natives") +} + +pub fn client_jar_path(game_dir: &Path, version_id: &str) -> PathBuf { + game_dir.join("versions").join(version_id).join(format!("{version_id}.jar")) +} + +pub fn ensure_client_jar(client: &Client, game_dir: &Path, version_id: &str, download_ref: &DownloadRef) -> Result { + if !is_allowed_host(&download_ref.url) { + return Err(MojangError::DisallowedHost(download_ref.url.clone())); + } + let target = client_jar_path(game_dir, version_id); + let checksum = Checksum::Sha1(download_ref.sha1.clone()); + if !download::is_current(&target, Some(download_ref.size), &checksum)? { + download::download_verified(client, &download_ref.url, &target, Some(download_ref.size), &checksum, |_, _| {})?; + } + Ok(target) +} + +/// Downloads every rule-allowed library with a `downloads.artifact`, +/// returning the resulting jar paths in the same order as `libraries`. +pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library], on_progress: &ProgressCallback) -> Result, MojangError> { + let mut paths = Vec::new(); + let mut tasks = Vec::new(); + for library in libraries { + if !rule_allows(&library.rules, &HashMap::new()) { + continue; + } + let Some(artifact) = library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()) else { + continue; + }; + let target = game_dir.join("libraries").join(&artifact.path); + paths.push(target.clone()); + tasks.push(DownloadTask { + url: artifact.url.clone(), + target, + size: artifact.size, + checksum: Checksum::Sha1(artifact.sha1.clone()), + }); + } + download_many(client, tasks, on_progress)?; + Ok(paths) +} + +#[derive(Debug, Deserialize)] +pub struct AssetIndex { + pub objects: HashMap, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct AssetObject { + pub hash: String, + pub size: u64, +} + +pub fn ensure_asset_index(client: &Client, game_dir: &Path, asset_index: &AssetIndexRef) -> Result { + if !is_allowed_host(&asset_index.url) { + return Err(MojangError::DisallowedHost(asset_index.url.clone())); + } + let target = game_dir.join("assets").join("indexes").join(format!("{}.json", asset_index.id)); + let checksum = Checksum::Sha1(asset_index.sha1.clone()); + if !download::is_current(&target, Some(asset_index.size), &checksum)? { + download::download_verified(client, &asset_index.url, &target, Some(asset_index.size), &checksum, |_, _| {})?; + } + let bytes = fs::read(&target)?; + serde_json::from_slice(&bytes).map_err(MojangError::InvalidJson) +} + +pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_progress: &ProgressCallback) -> Result<(), MojangError> { + let objects_dir = game_dir.join("assets").join("objects"); + let tasks = index + .objects + .values() + .map(|object| { + let prefix = &object.hash[0..2]; + DownloadTask { + url: format!("https://resources.download.minecraft.net/{prefix}/{}", object.hash), + target: objects_dir.join(prefix).join(&object.hash), + size: object.size, + checksum: Checksum::Sha1(object.hash.clone()), + } + }) + .collect(); + download_many(client, tasks, on_progress) +} + +struct DownloadTask { + url: String, + target: PathBuf, + size: u64, + checksum: Checksum, +} + +const MAX_DOWNLOAD_ATTEMPTS: u32 = 5; + +/// Retries a single file a few times with a short backoff before giving up. +/// With tens of thousands of individual requests in `ensure_assets`, an +/// occasional transient failure (reset connection, one bad TLS record) is +/// expected network noise, not a reason to abort the whole install — this +/// is exactly what real launchers do at this scale. +fn download_with_retries(client: &Client, task: &DownloadTask) -> Result { + let mut last_error = None; + for attempt in 1..=MAX_DOWNLOAD_ATTEMPTS { + match download::download_verified(client, &task.url, &task.target, Some(task.size), &task.checksum, |_, _| {}) { + Ok(bytes) => return Ok(bytes), + Err(error) => { + last_error = Some(error); + if attempt < MAX_DOWNLOAD_ATTEMPTS { + std::thread::sleep(std::time::Duration::from_millis(200 * attempt as u64)); + } + } + } + } + Err(last_error.expect("loop runs at least once")) +} + +/// Downloads `tasks` using a small worker pool, calling `on_progress` with +/// cumulative (downloaded, total) bytes as each file completes. Stops +/// spawning new work once the first error is seen and returns it. +fn download_many(client: &Client, tasks: Vec, on_progress: &ProgressCallback) -> Result<(), MojangError> { + let total: u64 = tasks.iter().map(|task| task.size).sum(); + if total == 0 { + return Ok(()); + } + let downloaded = AtomicU64::new(0); + let queue = Mutex::new(tasks); + let first_error: Mutex> = Mutex::new(None); + + std::thread::scope(|scope| { + for _ in 0..ASSET_WORKERS { + let queue = &queue; + let downloaded = &downloaded; + let first_error = &first_error; + let on_progress = Arc::clone(on_progress); + scope.spawn(move || loop { + if first_error.lock().unwrap().is_some() { + break; + } + let Some(task) = queue.lock().unwrap().pop() else { break }; + if !is_allowed_host(&task.url) { + *first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url)); + continue; + } + let already_current = download::is_current(&task.target, Some(task.size), &task.checksum).unwrap_or(false); + if !already_current { + if let Err(error) = download_with_retries(client, &task) { + *first_error.lock().unwrap() = Some(MojangError::Download(error)); + continue; + } + } + let done = downloaded.fetch_add(task.size, Ordering::SeqCst) + task.size; + on_progress(done, total); + }); + } + }); + + match first_error.into_inner().unwrap() { + Some(error) => Err(error), + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(action: RuleAction, os_name: Option<&str>) -> Rule { + Rule { + action, + os: os_name.map(|name| RuleOs { name: Some(name.into()), arch: None }), + features: None, + } + } + + #[test] + fn empty_rules_always_allow() { + assert!(rule_allows(&[], &HashMap::new())); + } + + #[test] + fn single_matching_os_rule_allows() { + let rules = vec![rule(RuleAction::Allow, Some(current_os_name()))]; + assert!(rule_allows(&rules, &HashMap::new())); + } + + #[test] + fn non_matching_os_rule_disallows() { + let other = if current_os_name() == "windows" { "linux" } else { "windows" }; + let rules = vec![rule(RuleAction::Allow, Some(other))]; + assert!(!rule_allows(&rules, &HashMap::new())); + } + + #[test] + fn unsupported_feature_is_excluded_by_default() { + let mut features = HashMap::new(); + features.insert("is_demo_user".to_string(), true); + let rules = vec![Rule { action: RuleAction::Allow, os: None, features: Some(features) }]; + // We never activate optional features, so a rule requiring one + // must not match even though there's no OS constraint. + assert!(!rule_allows(&rules, &HashMap::new())); + } + + #[test] + fn resolves_plain_and_conditional_arguments() { + let args = vec![ + ArgumentValue::Plain("--username".into()), + ArgumentValue::Plain("${auth_player_name}".into()), + ArgumentValue::Conditional { + rules: vec![rule(RuleAction::Allow, Some(current_os_name()))], + value: StringOrList::Many(vec!["--this-os-only".into()]), + }, + ArgumentValue::Conditional { + rules: vec![rule(RuleAction::Allow, Some("nonexistent-os"))], + value: StringOrList::One("--never".into()), + }, + ]; + let resolved = resolve_arguments(&args, &HashMap::new()); + assert_eq!(resolved, vec!["--username", "${auth_player_name}", "--this-os-only"]); + } + + #[test] + fn merge_appends_child_after_parent() { + let parent: VersionJson = serde_json::from_str( + r#"{ + "id": "1.21.1", + "mainClass": "net.minecraft.client.main.Main", + "arguments": {"game": ["--parentGame"], "jvm": ["--parentJvm"]}, + "assetIndex": {"id": "17", "sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1, "url": "https://piston-meta.mojang.com/x"}, + "downloads": {"client": {"sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "size": 2, "url": "https://piston-data.mojang.com/x"}}, + "libraries": [{"name": "parent:lib:1"}] + }"#, + ) + .unwrap(); + let child: VersionJson = serde_json::from_str( + r#"{ + "id": "neoforge-21.1.248", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "inheritsFrom": "1.21.1", + "arguments": {"game": ["--childGame"], "jvm": ["--childJvm"]}, + "libraries": [{"name": "child:lib:1"}] + }"#, + ) + .unwrap(); + + let merged = merge_versions(&parent, Some(&child)).unwrap(); + assert_eq!(merged.id, "neoforge-21.1.248"); + assert_eq!(merged.client_jar_version_id, "1.21.1"); + assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher"); + assert_eq!(resolve_arguments(&merged.game_arguments, &HashMap::new()), vec!["--parentGame", "--childGame"]); + assert_eq!(resolve_arguments(&merged.jvm_arguments, &HashMap::new()), vec!["--parentJvm", "--childJvm"]); + assert_eq!(merged.libraries.iter().map(|library| library.name.as_str()).collect::>(), vec!["parent:lib:1", "child:lib:1"]); + assert_eq!(merged.asset_index.id, "17"); + } + + #[test] + fn disallowed_host_is_rejected() { + assert!(!is_allowed_host("https://example.com/evil.jar")); + assert!(is_allowed_host("https://piston-data.mojang.com/v1/objects/x/client.jar")); + } + + /// Live smoke test against the real Mojang CDN: manifest -> version JSON + /// (SHA-1 verified) -> asset index -> a handful of libraries + the + /// client jar. Not run by default (`cargo test`); run explicitly with + /// `cargo test -- --ignored mojang::` when checking real connectivity. + #[test] + #[ignore] + fn live_fetches_1_21_1_and_downloads_a_few_files() { + let client = Client::builder().build().unwrap(); + let manifest = fetch_version_manifest(&client).unwrap(); + let entry = find_version(&manifest, "1.21.1").expect("1.21.1 must be listed"); + let version = fetch_version_json(&client, entry).unwrap(); + assert_eq!(version.main_class, "net.minecraft.client.main.Main"); + + let game_dir = std::env::temp_dir().join(format!("shacraft-mojang-live-{}", std::process::id())); + + let merged = merge_versions(&version, None).unwrap(); + let client_jar = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap(); + assert!(client_jar.exists()); + + let asset_index = ensure_asset_index(&client, &game_dir, &merged.asset_index).unwrap(); + assert!(!asset_index.objects.is_empty()); + + let mut small_libraries: Vec = merged + .libraries + .iter() + .filter(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()).is_some_and(|artifact| artifact.size < 200_000)) + .take(5) + .cloned() + .collect(); + assert!(!small_libraries.is_empty(), "expected at least one small library to sanity-check downloads with"); + small_libraries.truncate(5); + let progress: ProgressCallback = Arc::new(|_, _| {}); + let paths = ensure_libraries(&client, &game_dir, &small_libraries, &progress).unwrap(); + for path in &paths { + assert!(path.exists(), "{path:?} should have been downloaded"); + } + + // Re-running against already-downloaded files must be a no-op (the + // `is_current` fast path), not re-download or fail. + let client_jar_again = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap(); + assert_eq!(client_jar, client_jar_again); + + fs::remove_dir_all(&game_dir).ok(); + } +} diff --git a/src-tauri/src/msa.rs b/src-tauri/src/msa.rs new file mode 100644 index 0000000..75a5878 --- /dev/null +++ b/src-tauri/src/msa.rs @@ -0,0 +1,479 @@ +//! Real Microsoft account login: device-code OAuth -> Xbox Live -> XSTS -> +//! Minecraft Services -> game-ownership check. This is what makes the +//! launcher only playable by people who actually own Minecraft Java +//! Edition; nothing here is optional or bypassable by a manifest. +//! +//! CORRECTION (2026-09-06): an earlier version of this module assumed a +//! public, no-registration-needed client ID existed for this flow. That +//! was wrong — verified live against `login.microsoftonline.com`, which +//! rejects it (`AADSTS700016`, app not found). Microsoft requires every +//! app to have its own Azure AD (Entra ID) "public client" registration +//! (no client secret needed for the device code grant — see +//! ), *and* new registrations must be +//! separately approved for Minecraft/Xbox API access via +//! before Xbox Live/Minecraft Services +//! will accept their tokens. `MSA_CLIENT_ID` below is a placeholder until +//! ShaCraft completes that registration; `start_device_code` refuses to +//! run while it's still the placeholder rather than fail confusingly +//! against Microsoft. Every endpoint below is otherwise a hardcoded HTTPS +//! constant, matching the trust-domain pattern used for Mojang/NeoForge +//! elsewhere in this crate — only the client ID is deployment-specific. + +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use std::{ + fmt, fs, io, + path::Path, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +/// ShaCraft's own Azure AD application (client) ID, registered as a public +/// client with device-code flow allowed and approved for Minecraft API +/// access. Replace this before shipping login — see the module doc above. +const MSA_CLIENT_ID: &str = "00000000-0000-0000-0000-000000000000"; + +fn client_id_is_configured() -> bool { + MSA_CLIENT_ID != "00000000-0000-0000-0000-000000000000" +} + +const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; +const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; +const XBOX_USER_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate"; +const XSTS_AUTHORIZE_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize"; +const MINECRAFT_LOGIN_URL: &str = "https://api.minecraftservices.com/authentication/login_with_xbox"; +const MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile"; +const ACCOUNT_FILE: &str = "account.json"; + +#[derive(Debug)] +pub enum MsaError { + NotConfigured, + Network(reqwest::Error), + HttpStatus(reqwest::StatusCode), + AuthorizationDeclined, + AuthorizationExpired, + NoXboxAccount, + DoesNotOwnMinecraft, + Io(io::Error), + UnexpectedResponse(String), +} + +impl fmt::Display for MsaError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotConfigured => formatter.write_str( + "ShaCraft has not configured Microsoft login yet (MSA_CLIENT_ID is a placeholder) — \ + register an Azure AD app at https://aka.ms/AppRegistrations and get it approved for \ + the Minecraft API at https://aka.ms/mce-reviewappid, then set MSA_CLIENT_ID in msa.rs", + ), + Self::Network(error) => write!(formatter, "network error: {error}"), + Self::HttpStatus(status) => write!(formatter, "unexpected response: {status}"), + Self::AuthorizationDeclined => formatter.write_str("Login was declined"), + Self::AuthorizationExpired => formatter.write_str("Login code expired before it was used"), + Self::NoXboxAccount => formatter.write_str("This Microsoft account has no Xbox profile"), + Self::DoesNotOwnMinecraft => formatter.write_str("This Microsoft account does not own Minecraft: Java Edition"), + Self::Io(error) => write!(formatter, "I/O error: {error}"), + Self::UnexpectedResponse(message) => write!(formatter, "unexpected response: {message}"), + } + } +} + +impl From for MsaError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +// --------------------------------------------------------------------- +// Device code flow +// --------------------------------------------------------------------- + +pub struct DeviceCodeStart { + pub verification_uri: String, + pub user_code: String, + pub expires_in_seconds: u64, + device_code: String, + interval_seconds: u64, +} + +#[derive(Deserialize)] +struct DeviceCodeResponse { + device_code: String, + user_code: String, + verification_uri: String, + expires_in: u64, + interval: u64, +} + +pub fn start_device_code(client: &Client) -> Result { + if !client_id_is_configured() { + return Err(MsaError::NotConfigured); + } + let response = client + .post(DEVICE_CODE_URL) + .form(&[("client_id", MSA_CLIENT_ID), ("scope", "XboxLive.signin offline_access")]) + .send() + .map_err(MsaError::Network)?; + if !response.status().is_success() { + return Err(MsaError::HttpStatus(response.status())); + } + let body: DeviceCodeResponse = response.json().map_err(MsaError::Network)?; + Ok(DeviceCodeStart { + verification_uri: body.verification_uri, + user_code: body.user_code, + expires_in_seconds: body.expires_in, + device_code: body.device_code, + interval_seconds: body.interval.max(5), + }) +} + +pub struct MicrosoftTokens { + pub access_token: String, + pub refresh_token: String, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: Option, + refresh_token: Option, + error: Option, +} + +/// Blocks, polling on `start.interval_seconds`, until the user finishes +/// signing in at `start.verification_uri`, the code expires, or they +/// decline. This is the slow step in the whole login flow — the caller +/// should already have shown `verification_uri`/`user_code` to the user +/// before calling this (see `start_device_code`). +pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result { + let deadline = Instant::now() + Duration::from_secs(start.expires_in_seconds); + let mut interval = Duration::from_secs(start.interval_seconds); + + loop { + if Instant::now() >= deadline { + return Err(MsaError::AuthorizationExpired); + } + thread::sleep(interval); + + let response = client + .post(TOKEN_URL) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", MSA_CLIENT_ID), + ("device_code", &start.device_code), + ]) + .send() + .map_err(MsaError::Network)?; + let status = response.status(); + let body: TokenResponse = response.json().map_err(MsaError::Network)?; + + if status.is_success() { + let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else { + return Err(MsaError::UnexpectedResponse("token response missing access_token/refresh_token".into())); + }; + return Ok(MicrosoftTokens { access_token, refresh_token }); + } + + match body.error.as_deref() { + Some("authorization_pending") => continue, + Some("slow_down") => { + interval += Duration::from_secs(5); + continue; + } + Some("authorization_declined") => return Err(MsaError::AuthorizationDeclined), + Some("expired_token") => return Err(MsaError::AuthorizationExpired), + other => return Err(MsaError::UnexpectedResponse(other.unwrap_or("unknown device code error").into())), + } + } +} + +pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result { + if !client_id_is_configured() { + return Err(MsaError::NotConfigured); + } + let response = client + .post(TOKEN_URL) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", MSA_CLIENT_ID), + ("refresh_token", refresh_token), + ("scope", "XboxLive.signin offline_access"), + ]) + .send() + .map_err(MsaError::Network)?; + if !response.status().is_success() { + return Err(MsaError::HttpStatus(response.status())); + } + let body: TokenResponse = response.json().map_err(MsaError::Network)?; + let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else { + return Err(MsaError::UnexpectedResponse("refresh response missing access_token/refresh_token".into())); + }; + Ok(MicrosoftTokens { access_token, refresh_token }) +} + +// --------------------------------------------------------------------- +// Xbox Live -> XSTS -> Minecraft Services +// --------------------------------------------------------------------- + +#[derive(Serialize)] +struct XboxUserAuthRequest<'a> { + #[serde(rename = "Properties")] + properties: XboxUserAuthProperties<'a>, + #[serde(rename = "RelyingParty")] + relying_party: &'a str, + #[serde(rename = "TokenType")] + token_type: &'a str, +} + +#[derive(Serialize)] +struct XboxUserAuthProperties<'a> { + #[serde(rename = "AuthMethod")] + auth_method: &'a str, + #[serde(rename = "SiteName")] + site_name: &'a str, + #[serde(rename = "RpsTicket")] + rps_ticket: String, +} + +#[derive(Serialize)] +struct XstsRequest<'a> { + #[serde(rename = "Properties")] + properties: XstsProperties<'a>, + #[serde(rename = "RelyingParty")] + relying_party: &'a str, + #[serde(rename = "TokenType")] + token_type: &'a str, +} + +#[derive(Serialize)] +struct XstsProperties<'a> { + #[serde(rename = "SandboxId")] + sandbox_id: &'a str, + #[serde(rename = "UserTokens")] + user_tokens: [&'a str; 1], +} + +#[derive(Deserialize)] +struct XboxTokenResponse { + #[serde(rename = "Token")] + token: String, + #[serde(rename = "DisplayClaims")] + display_claims: XboxDisplayClaims, +} + +#[derive(Deserialize)] +struct XboxDisplayClaims { + xui: Vec, +} + +#[derive(Deserialize)] +struct XboxUserHash { + uhs: String, + /// Xbox User ID, used for the game's `${auth_xuid}` launch argument. + /// Absent for some account states; not required to play. + #[serde(default)] + xid: Option, +} + +fn xbox_live_user_token(client: &Client, microsoft_access_token: &str) -> Result<(String, String), MsaError> { + let request = XboxUserAuthRequest { + properties: XboxUserAuthProperties { + auth_method: "RPS", + site_name: "user.auth.xboxlive.com", + rps_ticket: format!("d={microsoft_access_token}"), + }, + relying_party: "http://auth.xboxlive.com", + token_type: "JWT", + }; + let response = client.post(XBOX_USER_AUTH_URL).json(&request).send().map_err(MsaError::Network)?; + if !response.status().is_success() { + return Err(MsaError::HttpStatus(response.status())); + } + let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?; + let uhs = body.display_claims.xui.into_iter().next().map(|claim| claim.uhs).ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?; + Ok((body.token, uhs)) +} + +fn xsts_authorize(client: &Client, xbox_live_token: &str) -> Result<(String, String, Option), MsaError> { + let request = XstsRequest { + properties: XstsProperties { sandbox_id: "RETAIL", user_tokens: [xbox_live_token] }, + relying_party: "rp://api.minecraftservices.com/", + token_type: "JWT", + }; + let response = client.post(XSTS_AUTHORIZE_URL).json(&request).send().map_err(MsaError::Network)?; + let status = response.status(); + if status.as_u16() == 401 { + // XErr 2148916233 means the account has no Xbox profile at all + // (common for brand-new Microsoft accounts); other 401 causes + // (family/child accounts, regional restrictions) surface the same + // way for now, kept as one clear error rather than guessing. + return Err(MsaError::NoXboxAccount); + } + if !status.is_success() { + return Err(MsaError::HttpStatus(status)); + } + let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?; + let claim = body.display_claims.xui.into_iter().next().ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?; + Ok((body.token, claim.uhs, claim.xid)) +} + +#[derive(Serialize)] +struct MinecraftLoginRequest { + #[serde(rename = "identityToken")] + identity_token: String, +} + +#[derive(Deserialize)] +struct MinecraftLoginResponse { + access_token: String, +} + +fn minecraft_login(client: &Client, user_hash: &str, xsts_token: &str) -> Result { + let request = MinecraftLoginRequest { identity_token: format!("XBL3.0 x={user_hash};{xsts_token}") }; + let response = client.post(MINECRAFT_LOGIN_URL).json(&request).send().map_err(MsaError::Network)?; + if !response.status().is_success() { + return Err(MsaError::HttpStatus(response.status())); + } + let body: MinecraftLoginResponse = response.json().map_err(MsaError::Network)?; + Ok(body.access_token) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MinecraftProfile { + pub id: String, + pub name: String, +} + +/// Confirms game ownership. A 404 here means the account has no Java +/// Edition profile — i.e. doesn't own the game — and nothing should +/// install or launch. +fn fetch_minecraft_profile(client: &Client, minecraft_access_token: &str) -> Result { + let response = client + .get(MINECRAFT_PROFILE_URL) + .bearer_auth(minecraft_access_token) + .send() + .map_err(MsaError::Network)?; + if response.status().as_u16() == 404 { + return Err(MsaError::DoesNotOwnMinecraft); + } + if !response.status().is_success() { + return Err(MsaError::HttpStatus(response.status())); + } + response.json().map_err(MsaError::Network) +} + +pub struct LoginResult { + pub minecraft_access_token: String, + pub profile: MinecraftProfile, + pub refresh_token: String, + /// Xbox User ID for the `${auth_xuid}` launch argument. Not every + /// account state returns one; the game works fine with an empty value. + pub xuid: Option, +} + +fn complete_login(client: &Client, tokens: MicrosoftTokens) -> Result { + let (xbox_live_token, _uhs) = xbox_live_user_token(client, &tokens.access_token)?; + let (xsts_token, user_hash, xuid) = xsts_authorize(client, &xbox_live_token)?; + let minecraft_access_token = minecraft_login(client, &user_hash, &xsts_token)?; + let profile = fetch_minecraft_profile(client, &minecraft_access_token)?; + Ok(LoginResult { minecraft_access_token, profile, refresh_token: tokens.refresh_token, xuid }) +} + +pub fn login_with_device_code(client: &Client, start: &DeviceCodeStart) -> Result { + let tokens = poll_device_code(client, start)?; + complete_login(client, tokens) +} + +pub fn login_with_refresh_token(client: &Client, refresh_token: &str) -> Result { + let tokens = refresh_microsoft_tokens(client, refresh_token)?; + complete_login(client, tokens) +} + +// --------------------------------------------------------------------- +// Persistence +// --------------------------------------------------------------------- + +#[derive(Serialize, Deserialize)] +struct StoredAccount { + refresh_token: String, + saved_at_unix: u64, +} + +pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()> { + fs::create_dir_all(data_dir)?; + let saved_at_unix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let contents = serde_json::to_vec_pretty(&StoredAccount { refresh_token: refresh_token.to_string(), saved_at_unix }).expect("StoredAccount is serializable"); + + let target = data_dir.join(ACCOUNT_FILE); + let temporary = data_dir.join(".account.json.shacraft.part"); + fs::write(&temporary, contents)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; + } + fs::rename(temporary, target) +} + +pub fn load_refresh_token(data_dir: &Path) -> Option { + let contents = fs::read_to_string(data_dir.join(ACCOUNT_FILE)).ok()?; + let account: StoredAccount = serde_json::from_str(&contents).ok()?; + Some(account.refresh_token) +} + +pub fn clear_account(data_dir: &Path) -> io::Result<()> { + match fs::remove_file(data_dir.join(ACCOUNT_FILE)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_stored_refresh_token() { + let dir = std::env::temp_dir().join(format!("shacraft-msa-test-{}", std::process::id())); + assert!(load_refresh_token(&dir).is_none()); + save_refresh_token(&dir, "super-secret-refresh-token").unwrap(); + assert_eq!(load_refresh_token(&dir).as_deref(), Some("super-secret-refresh-token")); + clear_account(&dir).unwrap(); + assert!(load_refresh_token(&dir).is_none()); + fs::remove_dir_all(&dir).ok(); + } + + #[cfg(unix)] + #[test] + fn stored_account_file_is_not_world_or_group_readable() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("shacraft-msa-perm-test-{}", std::process::id())); + save_refresh_token(&dir, "secret").unwrap(); + let mode = fs::metadata(dir.join(ACCOUNT_FILE)).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn refuses_to_run_with_placeholder_client_id() { + assert!(!client_id_is_configured()); + let client = Client::builder().build().unwrap(); + assert!(matches!(start_device_code(&client), Err(MsaError::NotConfigured))); + } + + /// Live smoke test: requests a real device code from Microsoft and + /// checks the shape of the response. Does not (and cannot, without a + /// human) complete the actual sign-in. Needs `MSA_CLIENT_ID` set to a + /// real, approved Azure app id first — see the module doc comment. + /// Run with `cargo test -- --ignored live_requests_device_code`. + #[test] + #[ignore] + fn live_requests_device_code() { + let client = Client::builder().build().unwrap(); + let start = start_device_code(&client).unwrap(); + assert!(!start.user_code.is_empty()); + assert!(start.verification_uri.starts_with("https://")); + assert!(start.expires_in_seconds > 0); + } +} diff --git a/src-tauri/src/neoforge.rs b/src-tauri/src/neoforge.rs new file mode 100644 index 0000000..32d5e02 --- /dev/null +++ b/src-tauri/src/neoforge.rs @@ -0,0 +1,394 @@ +//! NeoForge trust boundary: downloads the official installer for +//! `manifest.minecraft.loader.version` from `maven.neoforged.net` and runs +//! it headlessly to produce a standard, vanilla-launcher-compatible version +//! profile under the shared game directory. +//! +//! We deliberately do not reimplement the installer's client processor +//! pipeline (mapping extraction, jar splitting, renaming, binary patching): +//! running NeoForge's own official installer jar is far less code, matches +//! exactly what a human running the installer manually would get, and +//! survives future NeoForge releases changing their processor format. +//! +//! Empirically verified (2026-09-06, against the real +//! neoforge-21.1.248-installer.jar and a real Temurin 21 JRE): the +//! installer's `net.minecraftforge.installer.SimpleInstaller` refuses to +//! target a directory that doesn't already look like a `.minecraft` folder +//! ("you need to run the launcher first!") unless a `launcher_profiles.json` +//! stub already exists there — see `ensure_launcher_profiles_stub`. After +//! that, `--installClient ` downloads/patches everything itself and +//! writes a standard `versions/neoforge-/neoforge-.json` +//! that inherits from the vanilla version and needs no NeoForge-specific +//! classpath handling: `mojang::merge_versions` + the resulting libraries +//! list is everything `launch.rs` needs. The separately-produced +//! `libraries/net/neoforged/neoforge//neoforge--client.jar` +//! is loaded by FancyModLoader itself at runtime (via the `--fml.*` game +//! arguments already present on the merged profile) and is intentionally +//! never added to our own classpath. + +use crate::download::{self, Checksum, DownloadError, ProgressCallback}; +use crate::mojang::VersionJson; +use reqwest::blocking::Client; +use std::{ + fmt, fs, io, + io::{BufRead, BufReader, Read}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, + thread, +}; +use url::Url; + +const NEOFORGE_HOST: &str = "maven.neoforged.net"; + +/// Minimal `launcher_profiles.json` accepted by the legacy NeoForge/Forge +/// installer as proof that a directory is a legitimate launcher data +/// directory. Written once; never overwrites an existing file. +const LAUNCHER_PROFILES_STUB: &str = r#"{"profiles":{},"selectedProfile":"","clientToken":"","authenticationDatabase":{},"settings":{"enableSnapshots":false,"enableAdvanced":false,"keepLauncherOpen":false,"soundOn":false,"showGameLog":false,"profileSorting":"ByLastPlayed","showMenu":false,"enableHistorical":false,"enableReleases":true,"crashAssistance":true},"version":3}"#; + +#[derive(Debug)] +pub enum NeoForgeError { + DisallowedHost(String), + Network(reqwest::Error), + HttpStatus(reqwest::StatusCode), + InvalidChecksum(String), + Download(DownloadError), + Io(io::Error), + InvalidJson(serde_json::Error), + InstallerFailed { exit_code: Option, output_tail: String }, +} + +impl fmt::Display for NeoForgeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised NeoForge host: {url}"), + Self::Network(error) => write!(formatter, "network error: {error}"), + Self::HttpStatus(status) => write!(formatter, "maven.neoforged.net returned {status}"), + Self::InvalidChecksum(text) => write!(formatter, "unexpected checksum response: {text}"), + Self::Download(error) => write!(formatter, "{error}"), + Self::Io(error) => write!(formatter, "I/O error: {error}"), + Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"), + Self::InstallerFailed { exit_code, output_tail } => { + write!(formatter, "NeoForge installer failed (exit {exit_code:?}):\n{output_tail}") + } + } + } +} + +impl From for NeoForgeError { + fn from(error: DownloadError) -> Self { + Self::Download(error) + } +} +impl From for NeoForgeError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +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) +} + +fn installer_jar_url(loader_version: &str) -> String { + format!("https://{NEOFORGE_HOST}/releases/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar") +} + +/// Downloads (or reuses a cached, still-valid) NeoForge installer jar, +/// verified against the `.sha256` sidecar Maven publishes next to every +/// artifact. +pub fn ensure_installer(client: &Client, cache_dir: &Path, loader_version: &str) -> Result { + let jar_url = installer_jar_url(loader_version); + let checksum_url = format!("{jar_url}.sha256"); + if !is_allowed_host(&jar_url) { + return Err(NeoForgeError::DisallowedHost(jar_url)); + } + + let response = client.get(&checksum_url).send().map_err(NeoForgeError::Network)?; + if !response.status().is_success() { + return Err(NeoForgeError::HttpStatus(response.status())); + } + let sha256 = response.text().map_err(NeoForgeError::Network)?.trim().to_ascii_lowercase(); + if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(NeoForgeError::InvalidChecksum(sha256)); + } + + let target = cache_dir.join(format!("neoforge-{loader_version}-installer.jar")); + let checksum = Checksum::Sha256(sha256); + if !download::is_current(&target, None, &checksum)? { + download::download_verified(client, &jar_url, &target, None, &checksum, |_, _| {})?; + } + Ok(target) +} + +fn ensure_launcher_profiles_stub(game_dir: &Path) -> io::Result<()> { + let path = game_dir.join("launcher_profiles.json"); + if path.exists() { + return Ok(()); + } + fs::create_dir_all(game_dir)?; + fs::write(path, LAUNCHER_PROFILES_STUB) +} + +pub fn installed_version_json_path(game_dir: &Path, loader_version: &str) -> PathBuf { + game_dir + .join("versions") + .join(format!("neoforge-{loader_version}")) + .join(format!("neoforge-{loader_version}.json")) +} + +/// The installer jar bundles its own `install_profile.json`, which lists +/// exactly which libraries it will download and which processors it will +/// run to patch the client — the same manifest the installer itself reads. +/// Reading it upfront gives a real, version-agnostic total for progress +/// reporting instead of a guessed constant. +fn read_install_profile_counts(installer_path: &Path) -> Option<(u64, u64)> { + let file = fs::File::open(installer_path).ok()?; + let mut archive = zip::ZipArchive::new(file).ok()?; + let mut entry = archive.by_name("install_profile.json").ok()?; + let mut contents = String::new(); + entry.read_to_string(&mut contents).ok()?; + let profile: serde_json::Value = serde_json::from_str(&contents).ok()?; + let libraries = profile.get("libraries")?.as_array()?.len() as u64; + let processors = profile.get("processors")?.as_array()?.len() as u64; + Some((libraries, processors)) +} + +/// Bumps `downloads_done`/`processors_done` from one line of the installer's +/// output and reports the combined total, clamped so a miscount (e.g. the +/// installer logging a couple of extra non-library downloads) never exceeds +/// or exceeds `total` by much. `total_libraries` caps the download half so +/// those extra lines cannot crowd out the processor half of the bar. +fn observe_installer_line(line: &str, downloads_done: &AtomicU64, processors_done: &AtomicU64, total_libraries: u64, total: u64, on_progress: &ProgressCallback) { + let trimmed = line.trim_start(); + if trimmed.starts_with("Download completed") { + downloads_done.fetch_add(1, Ordering::Relaxed); + } else if trimmed.starts_with("Processor: ") && trimmed.matches(':').count() == 2 { + // Exactly two colons is the processor *header* line + // ("Processor: net.neoforged.installertools:jarsplitter"); its + // sub-step lines ("Processor: ...: Loading patch files") have three. + processors_done.fetch_add(1, Ordering::Relaxed); + } else { + return; + } + let current = downloads_done.load(Ordering::Relaxed).min(total_libraries) + processors_done.load(Ordering::Relaxed); + on_progress(current.min(total), total); +} + +fn truncate_tail(text: &str) -> String { + text.chars().rev().take(4000).collect::().chars().rev().collect() +} + +/// Runs the installer with piped output, reporting live progress as its own +/// log lines confirm each library download and processor step, instead of +/// blocking silently until the whole (often minutes-long) run finishes. +/// Returns the process's exit code and its combined stdout+stderr, which the +/// caller uses to build a diagnostic if the install turns out to have failed +/// silently (exit 0 but no version JSON produced). +fn run_installer_with_progress( + java_executable: &Path, + installer_path: &Path, + game_dir: &Path, + cache_dir: &Path, + total_libraries: u64, + total: u64, + on_progress: &ProgressCallback, +) -> Result<(Option, String), NeoForgeError> { + let mut child = Command::new(java_executable) + .arg("-jar") + .arg(installer_path) + .arg("--installClient") + .arg(game_dir) + .current_dir(cache_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let stdout = child.stdout.take().expect("stdout was piped"); + let stderr = child.stderr.take().expect("stderr was piped"); + let combined_log = Arc::new(Mutex::new(String::new())); + let downloads_done = Arc::new(AtomicU64::new(0)); + let processors_done = Arc::new(AtomicU64::new(0)); + + let stdout_thread = { + let combined_log = Arc::clone(&combined_log); + let downloads_done = Arc::clone(&downloads_done); + let processors_done = Arc::clone(&processors_done); + let on_progress = Arc::clone(on_progress); + thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + observe_installer_line(&line, &downloads_done, &processors_done, total_libraries, total, &on_progress); + let mut log = combined_log.lock().unwrap(); + log.push_str(&line); + log.push('\n'); + } + }) + }; + let stderr_thread = { + let combined_log = Arc::clone(&combined_log); + thread::spawn(move || { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + let mut log = combined_log.lock().unwrap(); + log.push_str(&line); + log.push('\n'); + } + }) + }; + + let status = child.wait()?; + stdout_thread.join().ok(); + stderr_thread.join().ok(); + let tail = truncate_tail(&combined_log.lock().unwrap()); + + if !status.success() { + return Err(NeoForgeError::InstallerFailed { exit_code: status.code(), output_tail: tail }); + } + Ok((status.code(), tail)) +} + +/// Ensures NeoForge `loader_version` is installed into the shared +/// `game_dir` (vanilla libraries/version must already be there so the +/// installer can reuse them). No-op if already installed. Runs the +/// installer headlessly with `java_executable`; its own network calls go +/// straight to `maven.neoforged.net`/Mojang, outside our control, which is +/// an accepted trust delegation to NeoForge's official tooling once the +/// installer binary itself is SHA-256 verified. `on_progress` reports real +/// progress (installer-confirmed library downloads plus patch-processor +/// steps, read from the installer's own `install_profile.json`) while it +/// runs; it fires once with `(1, 1)` when already installed. +pub fn ensure_client_installed(client: &Client, java_executable: &Path, game_dir: &Path, cache_dir: &Path, loader_version: &str, on_progress: &ProgressCallback) -> Result { + let version_json_path = installed_version_json_path(game_dir, loader_version); + if !version_json_path.exists() { + ensure_launcher_profiles_stub(game_dir)?; + let installer_path = ensure_installer(client, cache_dir, loader_version)?; + + let (total_libraries, total_processors) = read_install_profile_counts(&installer_path).unwrap_or((0, 0)); + let total = (total_libraries + total_processors).max(1); + on_progress(0, total); + + let (exit_code, tail) = run_installer_with_progress(java_executable, &installer_path, game_dir, cache_dir, total_libraries, total, on_progress)?; + if !version_json_path.exists() { + return Err(NeoForgeError::InstallerFailed { exit_code, output_tail: tail }); + } + on_progress(total, total); + } else { + on_progress(1, 1); + } + + let bytes = fs::read(&version_json_path)?; + serde_json::from_slice(&bytes).map_err(NeoForgeError::InvalidJson) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installer_url_matches_maven_layout() { + assert_eq!( + installer_jar_url("21.1.248"), + "https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-installer.jar" + ); + } + + #[test] + fn rejects_non_neoforge_hosts() { + assert!(!is_allowed_host("https://example.com/evil.jar")); + assert!(is_allowed_host("https://maven.neoforged.net/releases/x.jar")); + } + + #[test] + fn launcher_profiles_stub_is_idempotent() { + let dir = std::env::temp_dir().join(format!("shacraft-neoforge-test-{}", std::process::id())); + ensure_launcher_profiles_stub(&dir).unwrap(); + let first = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap(); + fs::write(dir.join("launcher_profiles.json"), "custom-content").unwrap(); + ensure_launcher_profiles_stub(&dir).unwrap(); + let second = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap(); + assert_eq!(second, "custom-content"); + assert!(first.contains("\"profiles\"")); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn observe_installer_line_counts_downloads_and_processor_headers() { + let downloads_done = AtomicU64::new(0); + let processors_done = AtomicU64::new(0); + let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let on_progress: ProgressCallback = { + let calls = Arc::clone(&calls); + Arc::new(move |current, total| calls.lock().unwrap().push((current, total))) + }; + let total_libraries = 2; + let total = 3; // 2 libraries + 1 processor + + // A "Downloading library from ..." start line reports nothing by + // itself; only its "Download completed" confirmation counts. + observe_installer_line("Downloading library from https://example/a.jar", &downloads_done, &processors_done, total_libraries, total, &on_progress); + observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress); + observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress); + observe_installer_line("Processor: net.neoforged.installertools:jarsplitter", &downloads_done, &processors_done, total_libraries, total, &on_progress); + // A processor's sub-step lines (three colons) must not double-count. + observe_installer_line("Processor: net.neoforged.installertools:jarsplitter: Loading patch files", &downloads_done, &processors_done, total_libraries, total, &on_progress); + + assert_eq!(*calls.lock().unwrap(), vec![(1, 3), (2, 3), (3, 3)]); + } + + /// Full live pipeline: provisions a real Java 21 (runtime.rs) if none + /// is already usable, then runs the real NeoForge 21.1.248 installer + /// into an empty game dir (it fetches and patches vanilla 1.21.1 + /// itself — confirmed manually, no pre-seeding needed) and checks the + /// installed profile merges into a launch-shaped spec together with a + /// separately-fetched vanilla version JSON (mojang.rs), exactly as + /// `lib.rs`'s `ensure_game_installed` command will do it. Not run by + /// default; `cargo test -- --ignored live_full_pipeline`. + #[test] + #[ignore] + fn live_full_pipeline_installs_neoforge() { + use crate::{java, mojang}; + + let client = Client::builder().build().unwrap(); + let root = std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id())); + let game_dir = root.join("game"); + let cache_dir = root.join("cache"); + fs::create_dir_all(&cache_dir).unwrap(); + + let manifest = mojang::fetch_version_manifest(&client).unwrap(); + let entry = mojang::find_version(&manifest, "1.21.1").unwrap(); + let vanilla = mojang::fetch_version_json(&client, entry).unwrap(); + + let no_progress: ProgressCallback = Arc::new(|_, _| {}); + let java_install = java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap(); + + // The installer fetches and patches vanilla itself; we don't + // pre-download it. It only needs a Java runtime and an empty dir. + let progress_calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let progress: ProgressCallback = { + let progress_calls = Arc::clone(&progress_calls); + Arc::new(move |current, total| progress_calls.lock().unwrap().push((current, total))) + }; + let neoforge_version = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &progress).unwrap(); + let merged = mojang::merge_versions(&vanilla, Some(&neoforge_version)).unwrap(); + assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher"); + assert!(merged.libraries.len() > 100, "expected vanilla (97) + neoforge (47) libraries, got {}", merged.libraries.len()); + + let patched_client = game_dir.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar"); + assert!(patched_client.exists(), "FancyModLoader needs this at runtime even though it is not on the generic classpath"); + + let calls = progress_calls.lock().unwrap(); + assert!(calls.len() > 5, "expected many incremental progress calls, got {}", calls.len()); + let (last_current, last_total) = *calls.last().unwrap(); + assert_eq!(last_current, last_total, "progress must reach 100% on success"); + assert!(calls.windows(2).all(|pair| pair[0].0 <= pair[1].0), "reported progress must never go backwards"); + drop(calls); + + // Re-running must skip straight to reading the cached version JSON + // rather than invoking the installer again. + let neoforge_again = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &no_progress).unwrap(); + assert_eq!(neoforge_again.libraries.len(), neoforge_version.libraries.len()); + + fs::remove_dir_all(&root).ok(); + } +} diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 4b2f951..23ea3ad 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -1,8 +1,8 @@ +use crate::download::{self, Checksum, DownloadError}; use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest}; use reqwest::{blocking::Client, redirect::Policy}; use serde::Serialize; -use sha2::{Digest, Sha256}; -use std::{fmt, fs::{self, File}, io::{self, Read, Write}, path::{Path, PathBuf}}; +use std::{fmt, io, path::Path}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -27,9 +27,7 @@ pub struct SyncResult { pub enum ProfileError { Io(io::Error), Network(reqwest::Error), - HttpStatus { path: String, status: reqwest::StatusCode }, - InvalidResponse { path: String, message: String }, - Integrity { path: String, message: String }, + Download { path: String, source: DownloadError }, } impl fmt::Display for ProfileError { @@ -37,9 +35,7 @@ impl fmt::Display for ProfileError { match self { Self::Io(error) => write!(formatter, "Cannot inspect profile: {error}"), Self::Network(error) => write!(formatter, "Cannot download profile file: {error}"), - Self::HttpStatus { path, status } => write!(formatter, "Download failed for {path}: server returned {status}"), - Self::InvalidResponse { path, message } => write!(formatter, "Invalid response for {path}: {message}"), - Self::Integrity { path, message } => write!(formatter, "Integrity check failed for {path}: {message}"), + Self::Download { path, source } => write!(formatter, "Download failed for {path}: {source}"), } } } @@ -50,20 +46,12 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result metadata, - Ok(_) => { - mismatched_files += 1; - continue; - } - Err(error) if error.kind() == io::ErrorKind::NotFound => { - missing_files += 1; - continue; - } - Err(error) => return Err(ProfileError::Io(error)), - }; - - if metadata.len() != expected.size || sha256(&path)? != expected.sha256.to_ascii_lowercase() { + if !path.exists() { + missing_files += 1; + continue; + } + let checksum = Checksum::Sha256(expected.sha256.clone()); + if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? { mismatched_files += 1; } } @@ -99,12 +87,13 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result Result Result { - let metadata = match path.metadata() { - Ok(metadata) => metadata, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), - Err(error) => return Err(ProfileError::Io(error)), - }; - Ok(metadata.is_file() && metadata.len() == expected.size && sha256(path)? == expected.sha256.to_ascii_lowercase()) -} - -fn download_file(client: &Client, expected: &ManagedFile, target: &Path) -> Result { - let parent = target.parent().ok_or_else(|| ProfileError::Integrity { - path: expected.path.clone(), - message: "target has no parent directory".into(), - })?; - fs::create_dir_all(parent).map_err(ProfileError::Io)?; - - let mut response = client.get(&expected.url).send().map_err(ProfileError::Network)?; - if !response.status().is_success() { - return Err(ProfileError::HttpStatus { path: expected.path.clone(), status: response.status() }); - } - if let Some(length) = response.content_length() { - if length != expected.size { - return Err(ProfileError::InvalidResponse { - path: expected.path.clone(), - message: format!("expected {} bytes, received Content-Length {length}", expected.size), - }); - } - } - - let temporary = temp_path(target)?; - let result = write_and_verify(&mut response, &temporary, expected); - if let Err(error) = result { - let _ = fs::remove_file(&temporary); - return Err(error); - } - - fs::rename(&temporary, target).map_err(ProfileError::Io)?; - Ok(expected.size) -} - -fn temp_path(target: &Path) -> Result { - let file_name = target.file_name().and_then(|name| name.to_str()).ok_or_else(|| ProfileError::Integrity { - path: target.display().to_string(), - message: "target has no valid filename".into(), - })?; - Ok(target.with_file_name(format!(".{file_name}.shacraft.part"))) -} - -fn write_and_verify(response: &mut reqwest::blocking::Response, temporary: &Path, expected: &ManagedFile) -> Result<(), ProfileError> { - let mut output = File::create(temporary).map_err(ProfileError::Io)?; - let mut digest = Sha256::new(); - let mut bytes = 0_u64; - let mut buffer = [0_u8; 64 * 1024]; - - loop { - let read = response.read(&mut buffer).map_err(ProfileError::Io)?; - if read == 0 { - break; - } - output.write_all(&buffer[..read]).map_err(ProfileError::Io)?; - digest.update(&buffer[..read]); - bytes += read as u64; - } - output.sync_all().map_err(ProfileError::Io)?; - - if bytes != expected.size { - return Err(ProfileError::Integrity { - path: expected.path.clone(), - message: format!("expected {} bytes, downloaded {bytes}", expected.size), - }); - } - let actual = format!("{:x}", digest.finalize()); - if actual != expected.sha256.to_ascii_lowercase() { - return Err(ProfileError::Integrity { - path: expected.path.clone(), - message: "SHA-256 does not match manifest".into(), - }); - } - Ok(()) -} - -fn sha256(path: &Path) -> Result { - let mut file = File::open(path).map_err(ProfileError::Io)?; - let mut digest = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - - loop { - let read = file.read(&mut buffer).map_err(ProfileError::Io)?; - if read == 0 { - break; - } - digest.update(&buffer[..read]); - } - - Ok(format!("{:x}", digest.finalize())) +fn download_managed_file(client: &Client, expected: &ManagedFile, target: &Path) -> Result { + let checksum = Checksum::Sha256(expected.sha256.clone()); + download::download_verified(client, &expected.url, target, Some(expected.size), &checksum, |_, _| {}).map_err(|error| { + ProfileError::Download { path: expected.path.clone(), source: error } + }) } #[cfg(test)] diff --git a/src-tauri/src/runtime.rs b/src-tauri/src/runtime.rs new file mode 100644 index 0000000..dfa2c7d --- /dev/null +++ b/src-tauri/src/runtime.rs @@ -0,0 +1,234 @@ +//! Java runtime auto-provisioning via Eclipse Adoptium (Temurin), GPLv2+CE. +//! +//! Independent, hardcoded trust domain: `api.adoptium.net` only. The API +//! returns the release's SHA-256 inline, which we verify before extracting +//! anything. The user's own system Java is never touched — see +//! `java.rs::ensure_java`, which only calls into this module when no +//! sufficiently new Java is already installed. + +use crate::download::{self, Checksum, DownloadError, ProgressCallback}; +use reqwest::blocking::Client; +use serde::Deserialize; +use std::{fmt, fs, io, path::{Path, PathBuf}}; + +const ADOPTIUM_HOST: &str = "api.adoptium.net"; + +#[derive(Debug)] +pub enum RuntimeError { + Network(reqwest::Error), + HttpStatus(reqwest::StatusCode), + NoRelease, + UnexpectedArchiveLayout, + Download(DownloadError), + Io(io::Error), + Zip(zip::result::ZipError), +} + +impl fmt::Display for RuntimeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Network(error) => write!(formatter, "network error: {error}"), + Self::HttpStatus(status) => write!(formatter, "Adoptium returned {status}"), + Self::NoRelease => formatter.write_str("Adoptium has no matching JRE release for this platform"), + Self::UnexpectedArchiveLayout => formatter.write_str("Java archive did not contain a single top-level directory as expected"), + Self::Download(error) => write!(formatter, "{error}"), + Self::Io(error) => write!(formatter, "I/O error: {error}"), + Self::Zip(error) => write!(formatter, "zip error: {error}"), + } + } +} + +impl From for RuntimeError { + fn from(error: reqwest::Error) -> Self { + Self::Network(error) + } +} +impl From for RuntimeError { + fn from(error: DownloadError) -> Self { + Self::Download(error) + } +} +impl From for RuntimeError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} +impl From for RuntimeError { + fn from(error: zip::result::ZipError) -> Self { + Self::Zip(error) + } +} + +#[derive(Debug, Deserialize)] +struct AdoptiumAsset { + binary: AdoptiumBinary, +} + +#[derive(Debug, Deserialize)] +struct AdoptiumBinary { + package: AdoptiumPackage, +} + +#[derive(Debug, Deserialize)] +struct AdoptiumPackage { + link: String, + checksum: String, + name: String, +} + +fn adoptium_os() -> &'static str { + if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "mac" + } else { + "linux" + } +} + +/// Adoptium's `architecture` query parameter values, which don't match +/// Rust's `std::env::consts::ARCH` strings 1:1 (notably `x86_64` -> `x64`). +fn adoptium_arch() -> Option<&'static str> { + match std::env::consts::ARCH { + "x86_64" => Some("x64"), + "aarch64" => Some("aarch64"), + _ => None, + } +} + +fn java_executable_name() -> &'static str { + if cfg!(target_os = "windows") { + "java.exe" + } else { + "java" + } +} + +pub fn java_path_in(runtime_dir: &Path) -> PathBuf { + let mac_bundle = runtime_dir.join("Contents/Home/bin").join(java_executable_name()); + if mac_bundle.exists() { + return mac_bundle; + } + runtime_dir.join("bin").join(java_executable_name()) +} + +/// Ensures a Java `major` runtime is available under +/// `runtime_root/--/`, downloading and extracting it from +/// Adoptium if it isn't already there. Returns the path to the `java` +/// executable. Never touches any Java the user already has installed +/// elsewhere. `on_progress` reports real download bytes (extraction is fast +/// enough afterwards not to need its own progress). +pub fn ensure_runtime(client: &Client, runtime_root: &Path, major: u8, on_progress: &ProgressCallback) -> Result { + let arch = adoptium_arch().ok_or(RuntimeError::NoRelease)?; + let runtime_dir = runtime_root.join(format!("{major}-{}-{arch}", adoptium_os())); + let marker = runtime_dir.join(".shacraft-complete"); + let java_path = java_path_in(&runtime_dir); + if marker.exists() && java_path.exists() { + on_progress(1, 1); + return Ok(java_path); + } + + let url = format!( + "https://{ADOPTIUM_HOST}/v3/assets/latest/{major}/hotspot?image_type=jre&os={}&architecture={arch}&vendor=eclipse", + adoptium_os() + ); + let response = client.get(&url).send()?; + if !response.status().is_success() { + return Err(RuntimeError::HttpStatus(response.status())); + } + let assets: Vec = response.json()?; + let package = assets.into_iter().next().map(|asset| asset.binary.package).ok_or(RuntimeError::NoRelease)?; + + fs::create_dir_all(runtime_root)?; + let archive_path = runtime_root.join(&package.name); + let progress = on_progress.clone(); + download::download_verified(client, &package.link, &archive_path, None, &Checksum::Sha256(package.checksum), move |current, total| { + progress(current, total.unwrap_or(current)); + })?; + + extract_single_root_archive(&archive_path, &runtime_dir)?; + fs::remove_file(&archive_path).ok(); + fs::write(&marker, b"ok")?; + + let java_path = java_path_in(&runtime_dir); + if !java_path.exists() { + return Err(RuntimeError::UnexpectedArchiveLayout); + } + Ok(java_path) +} + +/// Extracts a `.tar.gz` or `.zip` archive that contains exactly one +/// top-level directory (true of every Adoptium release archive), and +/// renames that directory into place as `target_dir`. +fn extract_single_root_archive(archive_path: &Path, target_dir: &Path) -> Result<(), RuntimeError> { + let staging = target_dir.with_file_name(format!( + "{}.staging", + target_dir.file_name().and_then(|name| name.to_str()).unwrap_or("runtime") + )); + if staging.exists() { + fs::remove_dir_all(&staging)?; + } + fs::create_dir_all(&staging)?; + + let is_zip = archive_path.extension().and_then(|extension| extension.to_str()) == Some("zip"); + if is_zip { + let file = fs::File::open(archive_path)?; + let mut archive = zip::ZipArchive::new(file)?; + archive.extract(&staging)?; + } else { + let file = fs::File::open(archive_path)?; + let decompressed = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(decompressed); + archive.unpack(&staging)?; + } + + let mut entries = fs::read_dir(&staging)?.collect::, io::Error>>()?; + if entries.len() != 1 || !entries[0].file_type()?.is_dir() { + fs::remove_dir_all(&staging).ok(); + return Err(RuntimeError::UnexpectedArchiveLayout); + } + let inner = entries.remove(0).path(); + if target_dir.exists() { + fs::remove_dir_all(target_dir)?; + } + if let Some(parent) = target_dir.parent() { + fs::create_dir_all(parent)?; + } + fs::rename(&inner, target_dir)?; + fs::remove_dir_all(&staging).ok(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn java_path_uses_platform_executable_name() { + let dir = Path::new("/tmp/example-runtime"); + let path = java_path_in(dir); + assert!(path.ends_with(java_executable_name())); + } + + /// Live smoke test: resolves the current platform's latest Temurin 21 + /// JRE from Adoptium, downloads it, verifies the checksum, and extracts + /// it. Not run by default; `cargo test -- --ignored ensure_runtime`. + #[test] + #[ignore] + fn live_provisions_java_21() { + let client = Client::builder().build().unwrap(); + let root = std::env::temp_dir().join(format!("shacraft-runtime-live-{}", std::process::id())); + let no_progress: ProgressCallback = std::sync::Arc::new(|_, _| {}); + let java = ensure_runtime(&client, &root, 21, &no_progress).unwrap(); + assert!(java.exists()); + + let output = std::process::Command::new(&java).arg("-version").output().unwrap(); + assert!(output.status.success()); + + // Second call must hit the "already provisioned" fast path. + let java_again = ensure_runtime(&client, &root, 21, &no_progress).unwrap(); + assert_eq!(java, java_again); + + fs::remove_dir_all(&root).ok(); + } +} diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs new file mode 100644 index 0000000..3a0f234 --- /dev/null +++ b/src-tauri/src/session.rs @@ -0,0 +1,143 @@ +//! Player identity for launching the game. +//! +//! A profile can launch either as a real Microsoft-authenticated player +//! (see `msa.rs`) or as an offline account. Offline mode is an explicit, +//! opt-in choice in the UI, never a fallback that silently weakens the +//! Microsoft path: if a player has signed in with Microsoft, that session +//! is always preferred when present. +//! +//! Offline identity uses the same algorithm every vanilla offline-mode +//! server relies on (and that the official launcher uses for demo accounts): +//! an MD5 of `OfflinePlayer:` with the version-3 (name-based) +//! and RFC 4122 variant bits set, formatted as a dashed UUID. The game +//! derives the same UUID client-side, so `--uuid`/`--accessToken` can be +//! a deterministic placeholder; the server's own `loginsystem` mod +//! (ShaCraft's chosen approach) then authenticates by nickname/password. + +use crate::msa; +use md5::{Digest, Md5}; + +/// A resolved identity used to fill `${auth_player_name}`, `${auth_uuid}`, +/// `${auth_access_token}`, `${user_type}` and friends at launch time. +pub enum PlayerIdentity { + Microsoft(msa::LoginResult), + Offline { name: String }, +} + +impl PlayerIdentity { + pub fn name(&self) -> &str { + match self { + Self::Microsoft(result) => &result.profile.name, + Self::Offline { name } => name, + } + } + + /// The `--accessToken` value. A real Microsoft session passes the + /// live token; offline mode uses a fixed placeholder because the game + /// client only requires a non-empty value and the server is in offline + /// mode with its own auth mod. + pub fn access_token(&self) -> &str { + match self { + Self::Microsoft(result) => &result.minecraft_access_token, + Self::Offline { .. } => "0", + } + } + + /// The dashed UUID for `${auth_uuid}`. Microsoft profiles come back + /// from Mojang as 32 hex chars; offline mode computes the deterministic + /// name-based UUID. + pub fn uuid(&self) -> String { + match self { + Self::Microsoft(result) => format_uuid_with_dashes(&result.profile.id), + Self::Offline { name } => offline_uuid(name), + } + } + + /// The `${user_type}` argument: `msa` for Microsoft accounts, + /// `legacy` for offline mode (the value the vanilla launcher uses for + /// demo/offline sessions). + pub fn user_type(&self) -> &'static str { + match self { + Self::Microsoft(_) => "msa", + Self::Offline { .. } => "legacy", + } + } + + /// The `${auth_xuid}` argument. Offline mode has no Xbox identity and + /// passes an empty string, which the game accepts. + pub fn xuid(&self) -> &str { + match self { + Self::Microsoft(result) => result.xuid.as_deref().unwrap_or(""), + Self::Offline { .. } => "", + } + } +} + +/// Mojang profile ids come back as 32 hex chars with no dashes; the game +/// itself expects the standard dashed UUID form for `${auth_uuid}`. +pub fn format_uuid_with_dashes(id: &str) -> String { + if id.len() != 32 || id.contains('-') { + return id.to_string(); + } + format!("{}-{}-{}-{}-{}", &id[0..8], &id[8..12], &id[12..16], &id[16..20], &id[20..32]) +} + +/// Computes the deterministic offline UUID for a nickname, using the same +/// name-based (version 3) algorithm the vanilla server uses for offline +/// players: MD5 of `OfflinePlayer:` with the version and variant bits +/// set. This is what lets the client and an offline-mode server agree on a +/// player's UUID without any account lookup. +pub fn offline_uuid(nickname: &str) -> String { + let mut hasher = Md5::new(); + hasher.update(format!("OfflinePlayer:{nickname}")); + let digest = hasher.finalize(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest); + bytes[6] = (bytes[6] & 0x0f) | 0x30; // version 3 (name-based) + bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant + let hex = bytes.iter().map(|byte| format!("{byte:02x}")).collect::(); + format_uuid_with_dashes(&hex) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Reference values computed from the vanilla server's offline UUID + /// algorithm; these are stable and must not change. + #[test] + fn computes_reference_offline_uuids() { + assert_eq!(offline_uuid("Emil"), "947b017f-e6de-3cc4-894d-019938ca63d4"); + assert_eq!(offline_uuid("Emil_Shanaty"), "c89467f6-2526-381f-a5c4-c82b677e4150"); + assert_eq!(offline_uuid("Notch"), "b50ad385-829d-3141-a216-7e7d7539ba7f"); + assert_eq!(offline_uuid("Steve"), "5627dd98-e6be-3c21-b8a8-e92344183641"); + } + + #[test] + fn offline_uuid_has_correct_version_and_variant() { + let id = offline_uuid("Emil"); + let parts: Vec<&str> = id.split('-').collect(); + assert_eq!(parts.len(), 5); + assert_eq!(parts[2].chars().next().unwrap(), '3'); // name-based + assert!(matches!(parts[3].chars().next().unwrap(), '8' | '9' | 'a' | 'b')); + } + + #[test] + fn offline_identity_uses_legacy_user_type() { + let identity = PlayerIdentity::Offline { name: "Emil".into() }; + assert_eq!(identity.name(), "Emil"); + assert_eq!(identity.user_type(), "legacy"); + assert_eq!(identity.access_token(), "0"); + assert_eq!(identity.uuid(), offline_uuid("Emil")); + } + + #[test] + fn formats_dashless_uuid() { + assert_eq!( + format_uuid_with_dashes("0123456789abcdef0123456789abcdef"), + "01234567-89ab-cdef-0123-456789abcdef" + ); + let dashed = "01234567-89ab-cdef-0123-456789abcdef"; + assert_eq!(format_uuid_with_dashes(dashed), dashed); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 66c1355..acf5645 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -7,12 +7,29 @@ const MAX_MEMORY_MB: u16 = 12 * 1024; const DEFAULT_MEMORY_MB: u16 = 6 * 1024; const DEFAULT_NICKNAME: &str = "Emil"; +/// Which account the player launches as. `Microsoft` requires a real signed-in +/// session; `Offline` uses the local nickname (no Microsoft account needed). +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AccountMode { + Microsoft, + Offline, +} + +impl Default for AccountMode { + fn default() -> Self { + Self::Offline + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LauncherSettings { pub memory_mb: u16, #[serde(default = "default_nickname")] pub nickname: String, + #[serde(default)] + pub account_mode: AccountMode, } fn default_nickname() -> String { @@ -21,7 +38,7 @@ fn default_nickname() -> String { impl Default for LauncherSettings { fn default() -> Self { - Self { memory_mb: DEFAULT_MEMORY_MB, nickname: DEFAULT_NICKNAME.into() } + Self { memory_mb: DEFAULT_MEMORY_MB, nickname: DEFAULT_NICKNAME.into(), account_mode: AccountMode::Offline } } } @@ -80,7 +97,7 @@ fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> { #[cfg(test)] mod tests { - use super::{load, save, LauncherSettings}; + use super::{load, save, AccountMode, LauncherSettings}; use std::{fs, process, time::{SystemTime, UNIX_EPOCH}}; fn temporary_directory() -> std::path::PathBuf { @@ -94,11 +111,14 @@ mod tests { #[test] fn defaults_then_persists_memory() { let directory = temporary_directory(); - assert_eq!(load(&directory).unwrap().memory_mb, 6 * 1024); + let default = load(&directory).unwrap(); + assert_eq!(default.memory_mb, 6 * 1024); + assert_eq!(default.nickname, "Emil"); + assert_eq!(default.account_mode, AccountMode::Offline); - let saved = save(&directory, LauncherSettings { memory_mb: 8 * 1024, nickname: "Emil".into() }).unwrap(); + let saved = save(&directory, LauncherSettings { memory_mb: 8 * 1024, nickname: "Emil".into(), account_mode: AccountMode::Microsoft }).unwrap(); assert_eq!(saved.memory_mb, 8 * 1024); - assert_eq!(load(&directory).unwrap().memory_mb, 8 * 1024); + assert_eq!(load(&directory).unwrap().account_mode, AccountMode::Microsoft); fs::remove_dir_all(directory).unwrap(); } @@ -106,7 +126,18 @@ mod tests { #[test] fn rejects_unsafe_memory_values() { let directory = temporary_directory(); - assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into() }).is_err()); - assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into() }).is_err()); + assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into(), account_mode: AccountMode::Offline }).is_err()); + assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into(), account_mode: AccountMode::Offline }).is_err()); + } + + #[test] + fn old_settings_without_nickname_defaults_gracefully() { + let directory = temporary_directory(); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("settings.json"), r#"{"memoryMb": 6144}"#).unwrap(); + let settings = load(&directory).unwrap(); + assert_eq!(settings.memory_mb, 6 * 1024); + assert_eq!(settings.nickname, "Emil"); + fs::remove_dir_all(directory).unwrap(); } } diff --git a/src/main.tsx b/src/main.tsx index 1d99443..1b4e896 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' import { ChevronRight, Download, @@ -8,6 +9,7 @@ import { Gauge, Globe2, Library, + LogOut, MessageCircle, Minus, Newspaper, @@ -45,6 +47,7 @@ type NativeHost = { type NativeSettings = { memoryMb: number nickname: string + accountMode: 'microsoft' | 'offline' } type JavaInstallation = { @@ -66,6 +69,36 @@ type SyncResult = { downloadedBytes: number } +type MinecraftProfile = { + id: string + name: string +} + +type DeviceCodePayload = { + verificationUri: string + userCode: string + expiresInSeconds: number +} + +type LoginResultPayload = { + ok: boolean + profile?: MinecraftProfile + error?: string +} + +type InstallProgressPayload = { + stage: 'java' | 'neoforge' | 'libraries' | 'assets' + currentBytes: number + totalBytes: number +} + +const INSTALL_STAGE_LABEL: Record = { + java: 'Готовим Java', + neoforge: 'Устанавливаем NeoForge', + libraries: 'Скачиваем библиотеки', + assets: 'Скачиваем ресурсы игры', +} + const servers: Server[] = [ { id: 'aoc', @@ -91,6 +124,10 @@ const servers: Server[] = [ }, ] +function isTauri() { + return '__TAURI_INTERNALS__' in window +} + function App() { const [selected, setSelected] = useState(servers[0]) const [progress, setProgress] = useState(null) @@ -98,12 +135,22 @@ function App() { const [settingsOpen, setSettingsOpen] = useState(false) const [ram, setRam] = useState(6) const [nickname, setNickname] = useState('Emil') + const [accountMode, setAccountMode] = useState<'microsoft' | 'offline'>('offline') const [nativeHost, setNativeHost] = useState(null) const [java, setJava] = useState(undefined) const [profile, setProfile] = useState(null) const [syncing, setSyncing] = useState(false) const [syncError, setSyncError] = useState(null) + // undefined = still checking for a saved session; null = signed out. + const [account, setAccount] = useState(undefined) + const [loginCode, setLoginCode] = useState(null) + const [loginError, setLoginError] = useState(null) + const [loggingIn, setLoggingIn] = useState(false) + const [installing, setInstalling] = useState(false) + const [installProgress, setInstallProgress] = useState(null) + const [launchError, setLaunchError] = useState(null) + useEffect(() => { if (progress === null) return if (progress >= 100) { @@ -118,10 +165,14 @@ function App() { }, [progress]) useEffect(() => { - if (!('__TAURI_INTERNALS__' in window)) return + if (!isTauri()) return invoke('native_host').then(setNativeHost).catch(() => setNativeHost(null)) invoke('load_settings') - .then((settings) => { setRam(settings.memoryMb / 1024); setNickname(settings.nickname) }) + .then((settings) => { + setRam(settings.memoryMb / 1024) + setNickname(settings.nickname) + setAccountMode(settings.accountMode) + }) .catch(() => undefined) invoke('detect_java') .then(setJava) @@ -132,25 +183,58 @@ function App() { setReady(inspection.upToDate) }) .catch(() => undefined) + invoke('get_account') + .then(setAccount) + .catch(() => setAccount(null)) }, []) - const updateRam = (memoryGb: number) => { - setRam(memoryGb) - if ('__TAURI_INTERNALS__' in window) { - invoke('save_settings', { settings: { memoryMb: memoryGb * 1024, nickname } }) - .catch(() => undefined) + useEffect(() => { + if (!isTauri()) return + const unlisten = [ + listen('msa-login-code', (event) => setLoginCode(event.payload)), + listen('msa-login-result', (event) => { + setLoggingIn(false) + setLoginCode(null) + if (event.payload.ok && event.payload.profile) { + setAccount(event.payload.profile) + setLoginError(null) + } else { + setLoginError(event.payload.error ?? 'Не удалось войти через Microsoft') + } + }), + listen('game-install-progress', (event) => setInstallProgress(event.payload)), + listen('game-exited', () => setInstalling(false)), + ] + return () => { + unlisten.forEach((promise) => promise.then((off) => off())) + } + }, []) + + const saveSettings = (memoryGb = ram, nick = nickname, mode = accountMode) => { + if (isTauri()) { + invoke('save_settings', { settings: { memoryMb: memoryGb * 1024, nickname: nick, accountMode: mode } }).catch(() => undefined) } } + const updateRam = (memoryGb: number) => { + setRam(memoryGb) + saveSettings(memoryGb) + } + const saveNickname = () => { - if ('__TAURI_INTERNALS__' in window && /^[A-Za-z0-9_]{3,16}$/.test(nickname)) { - invoke('save_settings', { settings: { memoryMb: ram * 1024, nickname } }).catch(() => undefined) + if (/^[A-Za-z0-9_]{3,16}$/.test(nickname)) { + saveSettings(ram, nickname) } } + const setMode = (mode: 'microsoft' | 'offline') => { + setAccountMode(mode) + saveSettings(ram, nickname, mode) + } + const repair = async () => { if (selected.disabled) return - if ('__TAURI_INTERNALS__' in window && selected.profileId) { + if (isTauri() && selected.profileId) { setSyncError(null) setSyncing(true) setReady(false) @@ -169,6 +253,55 @@ function App() { setProgress(0) } + const startLogin = async () => { + if (!isTauri()) return + setLoginError(null) + setLoggingIn(true) + try { + await invoke('start_microsoft_login') + } catch (error) { + setLoggingIn(false) + setLoginError(error instanceof Error ? error.message : 'Не удалось начать вход через Microsoft') + } + } + + const logout = async () => { + if (!isTauri()) return + await invoke('logout').catch(() => undefined) + setAccount(null) + } + + const playOrLogin = async () => { + if (selected.disabled || !isTauri() || !selected.profileId) return + // In offline mode we can launch without any Microsoft session. In + // Microsoft mode a signed-in account is still required first. + if (accountMode === 'microsoft' && (account === null || account === undefined)) { + await startLogin() + return + } + setLaunchError(null) + setInstalling(true) + setInstallProgress(null) + try { + await invoke('ensure_game_installed', { profileId: selected.profileId }) + await invoke('launch_game', { profileId: selected.profileId }) + } catch (error) { + setLaunchError(error instanceof Error ? error.message : 'Не удалось запустить игру') + setInstalling(false) + } + } + + const playLabel = () => { + if (selected.disabled) return 'Недоступно' + if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…' + if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft' + if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}…` : 'Подготовка…' + if (syncing || progress !== null) return 'Обновление' + return ready ? 'Играть' : 'Проверить' + } + + const installPercent = installProgress && installProgress.totalBytes > 0 ? Math.min(100, Math.round((installProgress.currentBytes / installProgress.totalBytes) * 100)) : null + return (
@@ -225,9 +358,18 @@ function App() {
- ES - {nickname}локальный профиль - + {accountMode === 'offline' ? nickname.slice(0, 2).toUpperCase() : (account ? account.name.slice(0, 2).toUpperCase() : '?')} + + {accountMode === 'offline' ? nickname : (account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : account.name)} + {accountMode === 'offline' ? 'Offline-аккаунт' : (account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть')} + + {accountMode === 'microsoft' && account ? ( + + ) : ( + + )}
@@ -252,7 +394,15 @@ function App() {
- {syncing ? ( + {installing ? ( + <> + + + {installProgress ? INSTALL_STAGE_LABEL[installProgress.stage] : 'Готовим установку'} + {installPercent !== null ? `${installPercent}%` : 'Проверяем файлы…'} + + + ) : syncing ? ( <> Синхронизируем сборкуСкачиваем и проверяем файлы @@ -273,10 +423,14 @@ function App() { ) : ( <> - {ready ? 'Сборка готова' : 'Требуется проверка'}{syncError || (profile ? `${profile.managedFiles} файлов под контролем` : 'Проверяем локальные файлы')} + + {accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Сборка готова' : 'Требуется проверка'} + {launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов под контролем` : 'Проверяем локальные файлы')} + )} {progress !== null &&
} + {installPercent !== null &&
}
@@ -284,21 +438,31 @@ function App() { {ram} ГБ памяти
-
+
+ {loginCode && ( +
+

Вход через Microsoft

+

Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.

+
{loginCode.userCode}
+

{loginCode.verificationUri}

+
+ )} +
setSettingsOpen(false)} />