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:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Generated
+135
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user