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
This commit is contained in:
emil28092005
2026-09-06 05:09:50 +03:00
parent c37d38522e
commit cc19a24e45
19 changed files with 3270 additions and 180 deletions
+33 -4
View File
@@ -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
+9 -5
View File
@@ -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`.
+84
View File
@@ -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-<ver>-installer.jar --installClient <game_dir>` — 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/<ver>/neoforge-<ver>-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.
+43 -16
View File
@@ -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/<profile-id>`. 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/
<profile-id>` — 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.
+135
View File
@@ -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",
]
+6 -1
View File
@@ -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"] }
+223
View File
@@ -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<dyn Fn(u64, u64) + Send + Sync>;
/// 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<u64>, checksum: &Checksum) -> io::Result<bool> {
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<PathBuf, DownloadError> {
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<u64>,
checksum: &Checksum,
mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<u64, DownloadError> {
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<u64>,
checksum: &Checksum,
total: Option<u64>,
on_progress: &mut impl FnMut(u64, Option<u64>),
) -> Result<u64, DownloadError> {
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());
}
}
+38 -1
View File
@@ -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<JavaInstallation> {
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<JavaInstallation, EnsureJavaError> {
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<PathBuf> {
let executable = if cfg!(target_os = "windows") {
"java.exe"
+206
View File
@@ -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<io::Error> 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<PathBuf> = 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::<Vec<_>>().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<String, LaunchError> {
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::<String>();
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<Child, LaunchError> {
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}");
}
}
+268 -2
View File
@@ -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<std::path::PathBuf, String> {
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<std::path::PathBuf, String> {
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<msa::MinecraftProfile>,
error: Option<String>,
}
/// 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<Option<msa::MinecraftProfile>, 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<session::PlayerIdentity, String> {
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<mojang::MergedVersion, String> {
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<i32>,
}
/// 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");
+697
View File
@@ -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<DownloadError> for MojangError {
fn from(error: DownloadError) -> Self {
Self::Download(error)
}
}
impl From<io::Error> for MojangError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
// ---------------------------------------------------------------------
// Version manifest
// ---------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct VersionManifest {
pub versions: Vec<VersionManifestEntry>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct VersionManifestEntry {
pub id: String,
pub url: String,
pub sha1: String,
}
pub fn fetch_version_manifest(client: &Client) -> Result<VersionManifest, MojangError> {
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<Arguments>,
#[serde(default)]
pub asset_index: Option<AssetIndexRef>,
#[serde(default)]
pub downloads: Option<Downloads>,
#[serde(default)]
pub libraries: Vec<Library>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct Arguments {
#[serde(default)]
pub game: Vec<ArgumentValue>,
#[serde(default)]
pub jvm: Vec<ArgumentValue>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum ArgumentValue {
Plain(String),
Conditional { rules: Vec<Rule>, value: StringOrList },
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum StringOrList {
One(String),
Many(Vec<String>),
}
#[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<LibraryDownloads>,
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct LibraryDownloads {
pub artifact: Option<Artifact>,
}
#[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<RuleOs>,
#[serde(default)]
pub features: Option<HashMap<String, bool>>,
}
#[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<String>,
pub arch: Option<String>,
}
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<String, bool>, active: &HashMap<String, bool>) -> 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<String, bool>) -> 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<String, bool>) -> Vec<String> {
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<VersionJson, MojangError> {
fetch_json(client, &entry.url, Some(&entry.sha1))
}
fn fetch_json<T: DeserializeOwned>(client: &Client, url: &str, expected_sha1: Option<&str>) -> Result<T, MojangError> {
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/<id>/` directory
/// logs and natives live under.
pub id: String,
/// The id whose `<id>.jar` actually exists on disk and belongs on the
/// classpath — always the vanilla parent's, confirmed empirically: a
/// NeoForge profile has no `versions/neoforge-<ver>/neoforge-<ver>.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<ArgumentValue>,
pub jvm_arguments: Vec<ArgumentValue>,
pub libraries: Vec<Library>,
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<MergedVersion, MojangError> {
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<PathBuf, MojangError> {
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<Vec<PathBuf>, 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<String, AssetObject>,
}
#[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<AssetIndex, MojangError> {
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<u64, DownloadError> {
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<DownloadTask>, 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<Option<MojangError>> = 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<_>>(), 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<Library> = 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();
}
}
+479
View File
@@ -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
//! <https://aka.ms/AppRegistrations>), *and* new registrations must be
//! separately approved for Minecraft/Xbox API access via
//! <https://aka.ms/mce-reviewappid> 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<io::Error> 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<DeviceCodeStart, MsaError> {
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<String>,
refresh_token: Option<String>,
error: Option<String>,
}
/// 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<MicrosoftTokens, MsaError> {
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<MicrosoftTokens, MsaError> {
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<XboxUserHash>,
}
#[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<String>,
}
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<String>), 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<String, MsaError> {
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<MinecraftProfile, MsaError> {
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<String>,
}
fn complete_login(client: &Client, tokens: MicrosoftTokens) -> Result<LoginResult, MsaError> {
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<LoginResult, MsaError> {
let tokens = poll_device_code(client, start)?;
complete_login(client, tokens)
}
pub fn login_with_refresh_token(client: &Client, refresh_token: &str) -> Result<LoginResult, MsaError> {
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<String> {
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);
}
}
+394
View File
@@ -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 <dir>` downloads/patches everything itself and
//! writes a standard `versions/neoforge-<version>/neoforge-<version>.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/<version>/neoforge-<version>-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<i32>, 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<DownloadError> for NeoForgeError {
fn from(error: DownloadError) -> Self {
Self::Download(error)
}
}
impl From<io::Error> 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<PathBuf, NeoForgeError> {
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::<String>().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<i32>, 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<VersionJson, NeoForgeError> {
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<Mutex<Vec<(u64, u64)>>> = 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<Mutex<Vec<(u64, u64)>>> = 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();
}
}
+18 -119
View File
@@ -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<ProfileInspection, Pr
for expected in &manifest.files {
let path = root.join(&expected.path);
let metadata = match path.metadata() {
Ok(metadata) if metadata.is_file() => 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<SyncResult, ProfileError
reused_files += 1;
continue;
}
if is_current(&target, expected)? {
let checksum = Checksum::Sha256(expected.sha256.clone());
if download::is_current(&target, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
reused_files += 1;
continue;
}
let bytes = download_file(&client, expected, &target)?;
let bytes = download_managed_file(&client, expected, &target)?;
downloaded_files += 1;
downloaded_bytes += bytes;
}
@@ -117,101 +106,11 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
})
}
fn is_current(path: &Path, expected: &ManagedFile) -> Result<bool, ProfileError> {
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<u64, ProfileError> {
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<PathBuf, ProfileError> {
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<String, ProfileError> {
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<u64, ProfileError> {
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)]
+234
View File
@@ -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<reqwest::Error> for RuntimeError {
fn from(error: reqwest::Error) -> Self {
Self::Network(error)
}
}
impl From<DownloadError> for RuntimeError {
fn from(error: DownloadError) -> Self {
Self::Download(error)
}
}
impl From<io::Error> for RuntimeError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<zip::result::ZipError> 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/<major>-<os>-<arch>/`, 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<PathBuf, RuntimeError> {
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<AdoptiumAsset> = 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::<Result<Vec<_>, 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();
}
}
+143
View File
@@ -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:<nickname>` 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:<nick>` 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::<String>();
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);
}
}
+38 -7
View File
@@ -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();
}
}
+216 -25
View File
@@ -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<InstallProgressPayload['stage'], string> = {
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<number | null>(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<NativeHost | null>(null)
const [java, setJava] = useState<JavaInstallation | null | undefined>(undefined)
const [profile, setProfile] = useState<ProfileInspection | null>(null)
const [syncing, setSyncing] = useState(false)
const [syncError, setSyncError] = useState<string | null>(null)
// undefined = still checking for a saved session; null = signed out.
const [account, setAccount] = useState<MinecraftProfile | null | undefined>(undefined)
const [loginCode, setLoginCode] = useState<DeviceCodePayload | null>(null)
const [loginError, setLoginError] = useState<string | null>(null)
const [loggingIn, setLoggingIn] = useState(false)
const [installing, setInstalling] = useState(false)
const [installProgress, setInstallProgress] = useState<InstallProgressPayload | null>(null)
const [launchError, setLaunchError] = useState<string | null>(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<NativeHost>('native_host').then(setNativeHost).catch(() => setNativeHost(null))
invoke<NativeSettings>('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<JavaInstallation | null>('detect_java')
.then(setJava)
@@ -132,25 +183,58 @@ function App() {
setReady(inspection.upToDate)
})
.catch(() => undefined)
invoke<MinecraftProfile | null>('get_account')
.then(setAccount)
.catch(() => setAccount(null))
}, [])
const updateRam = (memoryGb: number) => {
setRam(memoryGb)
if ('__TAURI_INTERNALS__' in window) {
invoke<NativeSettings>('save_settings', { settings: { memoryMb: memoryGb * 1024, nickname } })
.catch(() => undefined)
useEffect(() => {
if (!isTauri()) return
const unlisten = [
listen<DeviceCodePayload>('msa-login-code', (event) => setLoginCode(event.payload)),
listen<LoginResultPayload>('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<InstallProgressPayload>('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<NativeSettings>('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<NativeSettings>('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 (
<div className="app-shell">
<header className="titlebar">
@@ -225,9 +358,18 @@ function App() {
</div>
<div className="account-chip">
<span className="avatar">ES</span>
<span><strong>{nickname}</strong><small>локальный профиль</small></span>
<ChevronRight size={16} />
<span className="avatar">{accountMode === 'offline' ? nickname.slice(0, 2).toUpperCase() : (account ? account.name.slice(0, 2).toUpperCase() : '?')}</span>
<span>
<strong>{accountMode === 'offline' ? nickname : (account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : account.name)}</strong>
<small>{accountMode === 'offline' ? 'Offline-аккаунт' : (account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть')}</small>
</span>
{accountMode === 'microsoft' && account ? (
<button aria-label="Выйти из аккаунта" onClick={logout} style={{ background: 'transparent', border: 0, cursor: 'pointer', color: 'inherit' }}>
<LogOut size={16} />
</button>
) : (
<ChevronRight size={16} />
)}
</div>
</aside>
@@ -252,7 +394,15 @@ function App() {
<section className="play-dock">
<div className="build-state">
{syncing ? (
{installing ? (
<>
<span className="state-icon downloading"><Download size={19} /></span>
<span>
<strong>{installProgress ? INSTALL_STAGE_LABEL[installProgress.stage] : 'Готовим установку'}</strong>
<small>{installPercent !== null ? `${installPercent}%` : 'Проверяем файлы…'}</small>
</span>
</>
) : syncing ? (
<>
<span className="state-icon downloading"><Download size={19} /></span>
<span><strong>Синхронизируем сборку</strong><small>Скачиваем и проверяем файлы</small></span>
@@ -273,10 +423,14 @@ function App() {
) : (
<>
<span className="state-icon"><ShieldCheck size={19} /></span>
<span><strong>{ready ? 'Сборка готова' : 'Требуется проверка'}</strong><small>{syncError || (profile ? `${profile.managedFiles} файлов под контролем` : 'Проверяем локальные файлы')}</small></span>
<span>
<strong>{accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Сборка готова' : 'Требуется проверка'}</strong>
<small>{launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов под контролем` : 'Проверяем локальные файлы')}</small>
</span>
</>
)}
{progress !== null && <div className="progress-track"><i style={{ width: `${progress}%` }} /></div>}
{installPercent !== null && <div className="progress-track"><i style={{ width: `${installPercent}%` }} /></div>}
</div>
<div className="build-facts">
@@ -284,21 +438,31 @@ function App() {
<span><Gauge size={15} /> {ram} ГБ памяти</span>
</div>
<button className="repair-button" onClick={repair} disabled={progress !== null || syncing || selected.disabled} aria-label="Проверить файлы">
<button className="repair-button" onClick={repair} disabled={progress !== null || syncing || installing || selected.disabled} aria-label="Проверить файлы">
<RotateCcw size={19} />
</button>
<button
className="play-button"
disabled={progress !== null || syncing || selected.disabled}
onClick={() => !ready && repair()}
disabled={progress !== null || syncing || installing || selected.disabled || (accountMode === 'microsoft' && account === undefined) || loggingIn}
onClick={playOrLogin}
>
<Play size={21} fill="currentColor" />
<span>{selected.disabled ? 'Недоступно' : syncing || progress !== null ? 'Обновление' : ready ? 'Играть' : 'Проверить'}</span>
<span>{playLabel()}</span>
</button>
</section>
</main>
</div>
<div className={`drawer-backdrop ${loginCode ? 'visible' : ''}`} />
{loginCode && (
<div className="login-modal" role="dialog" aria-modal="true">
<h2>Вход через Microsoft</h2>
<p>Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.</p>
<div className="login-code">{loginCode.userCode}</div>
<p className="login-url">{loginCode.verificationUri}</p>
</div>
)}
<div className={`drawer-backdrop ${settingsOpen ? 'visible' : ''}`} onClick={() => setSettingsOpen(false)} />
<aside className={`settings-drawer ${settingsOpen ? 'open' : ''}`} aria-hidden={!settingsOpen}>
<div className="drawer-title">
@@ -310,11 +474,38 @@ function App() {
<input type="range" min="3" max="12" value={ram} onChange={(e) => updateRam(Number(e.target.value))} />
<small>Для Aeronautics рекомендуется 6 ГБ</small>
</label>
<label className="text-setting">
<span><strong>Игровой ник</strong><small>Локальный профиль</small></span>
<input value={nickname} maxLength={16} onChange={(event) => setNickname(event.target.value)} onBlur={saveNickname} placeholder="Player" />
<small>Латинские буквы, цифры и _ · от 3 до 16 символов</small>
</label>
<div className="setting-row static">
<span><Users />Аккаунт</span>
<small>{accountMode === 'offline' ? 'Offline' : (account ? account.name : 'Не авторизован')}</small>
</div>
{accountMode === 'offline' && (
<label className="text-setting">
<span><strong>Игровой ник</strong><small>Offline-профиль</small></span>
<input value={nickname} maxLength={16} onChange={(event) => setNickname(event.target.value)} onBlur={saveNickname} placeholder="Player" />
<small>Латинские буквы, цифры и _ · от 3 до 16 символов</small>
</label>
)}
<div className="setting-row">
<span>Тип аккаунта</span>
<select
value={accountMode}
onChange={(e) => setMode(e.target.value as 'microsoft' | 'offline')}
style={{ background: 'transparent', border: 0, color: 'inherit', textAlign: 'right' }}
>
<option value="offline">Offline</option>
<option value="microsoft">Microsoft</option>
</select>
</div>
{accountMode === 'microsoft' && account && (
<button className="setting-row" onClick={logout}>
<span><LogOut />Выйти из Microsoft</span>
</button>
)}
{accountMode === 'microsoft' && !account && (
<button className="setting-row" onClick={startLogin}>
<span><LogOut />Войти через Microsoft</span>
</button>
)}
<div className="setting-row static">
<span><FolderOpen />Папка игры</span>
<small>{nativeHost ? 'В каталоге лаунчера' : 'Определяется…'}</small>
@@ -328,7 +519,7 @@ function App() {
? `Java ${java.major} найдена`
: java
? `Нужна Java 21 · найдена ${java.major}`
: 'Java не найдена'}
: 'Лаунчер установит Java 21 автоматически'}
</small>
</div>
<button className="setting-row"><span><Wrench />Дополнительные параметры</span><ChevronRight /></button>
+6
View File
@@ -152,6 +152,12 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
.text-setting input:focus { border-color: var(--accent); }
.drawer-note { position: absolute; left: 28px; right: 28px; bottom: 26px; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; }
.login-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 22; width: 360px; background: #121713; border: 1px solid var(--line); border-radius: 14px; padding: 26px; text-align: center; box-shadow: 0 30px 80px rgba(0,0,0,.5); }
.login-modal h2 { margin: 0 0 10px; font-family: 'Unbounded'; font-size: 18px; }
.login-modal p { margin: 0 0 16px; color: #aeb7af; font-size: 12px; line-height: 1.5; }
.login-code { font-family: 'Unbounded'; font-size: 28px; letter-spacing: .08em; color: var(--green); background: #191f1a; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
.login-url { color: var(--ice) !important; font-size: 11px !important; word-break: break-all; }
@media (max-width: 1160px) {
.workspace { grid-template-columns: 58px 224px 1fr; }
.brand { width: 240px; }