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
|
## 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
|
- Launcher-managed payload is limited to ShaCraft-owned configuration and
|
||||||
approved modpack files. Never make arbitrary URLs, shell commands, or local
|
approved modpack files. Never make arbitrary URLs, shell commands, or local
|
||||||
paths controllable by a remote manifest.
|
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.
|
allowed ShaCraft hosts. Do not weaken this whitelist.
|
||||||
- `src-tauri/src/profile.rs` downloads to a temporary sibling file, verifies
|
- `src-tauri/src/profile.rs` downloads to a temporary sibling file, verifies
|
||||||
size + SHA-256, and atomically replaces only launcher-managed files.
|
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
|
## Layout
|
||||||
|
|
||||||
- `src/main.tsx` — UI state and Tauri command calls; do not put privileged
|
- `src/main.tsx` — UI state and Tauri command calls; do not put privileged
|
||||||
operations in the web layer.
|
operations in the web layer.
|
||||||
- `src-tauri/src/` — native commands and security-sensitive logic.
|
- `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
|
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward
|
||||||
compatibility with already-written JSON.
|
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.
|
- `.github/workflows/build.yml` — manual cross-platform build matrix.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
- безопасное обнаружение установленной Java (включая `JAVA_HOME`) перед запуском;
|
- безопасное обнаружение установленной Java (включая `JAVA_HOME`) перед запуском;
|
||||||
- интеграция с фиксированным ShaCraft launcher v2 manifest для Aeronautics;
|
- интеграция с фиксированным ShaCraft launcher v2 manifest для Aeronautics;
|
||||||
- адаптивное окно для Windows, Linux и macOS;
|
- адаптивное окно для Windows, Linux и macOS;
|
||||||
- базовая структура, в которую будут добавлены манифесты, проверка файлов,
|
- вход через настоящий аккаунт Microsoft (без него игра не устанавливается
|
||||||
Java 21 и запуск Minecraft.
|
и не запускается — так владение игрой проверяется по-настоящему);
|
||||||
|
- установка Minecraft и NeoForge версии, которую задаёт manifest, и запуск
|
||||||
|
игры.
|
||||||
|
|
||||||
## Локальная разработка
|
## Локальная разработка
|
||||||
|
|
||||||
@@ -32,6 +34,8 @@ Tauri и требует прав администратора.
|
|||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
Лаунчер пока не загружает игровые файлы и не запускает Minecraft. Следующий
|
Вход через Microsoft, установка и запуск Aeronautics уже работают. Вход
|
||||||
этап — спроектировать новый подписываемый манифест ShaCraft v2 и добавить
|
через Microsoft пока не активен на боевой сборке: нужна собственная
|
||||||
Rust-менеджер профилей.
|
регистрация приложения 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
|
## Current capability
|
||||||
|
|
||||||
The launcher can persist local settings, discover an installed Java runtime,
|
The launcher persists local settings, synchronises Aeronautics mod/config
|
||||||
inspect a profile and synchronise Aeronautics files from the signed ShaCraft
|
files from the signed ShaCraft v2 manifest, installs the exact Minecraft +
|
||||||
v2 manifest. It does **not** launch Minecraft yet.
|
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
|
## Data flow
|
||||||
|
|
||||||
|
Two independent pipelines feed one launch:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
signed-manifest endpoint
|
ShaCraft manifest (mods/config + which MC/loader/Java version to use)
|
||||||
-> Ed25519 verification in remote.rs
|
signed-manifest endpoint -> Ed25519 verification (remote.rs)
|
||||||
-> manifest schema + URL/path validation
|
-> manifest schema + URL/path validation (manifest.rs)
|
||||||
-> local profile inspection
|
-> temporary download, SHA-256 verification, atomic replacement (profile.rs)
|
||||||
-> temporary download, SHA-256 verification, atomic replacement
|
|
||||||
|
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
|
Profiles (ShaCraft-managed mods/config, and the player's own worlds/
|
||||||
live at `app_data_dir()/settings.json`. Neither location should be assumed to
|
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.
|
be the system `.minecraft` directory.
|
||||||
|
|
||||||
## Aeronautics contract
|
## Aeronautics contract
|
||||||
@@ -25,15 +46,21 @@ be the system `.minecraft` directory.
|
|||||||
- Profile ID: `aeronautics`
|
- Profile ID: `aeronautics`
|
||||||
- Manifest endpoint:
|
- Manifest endpoint:
|
||||||
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`
|
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`
|
||||||
- Payload: manifest schema v1, 251 managed files at the time of writing.
|
- Payload: manifest schema v1; also carries `minecraft.{version, loader,
|
||||||
- Download files: HTTPS only, exact hosts `shacraft.ru` and
|
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`.
|
`cdn.shacraft.ru`.
|
||||||
|
|
||||||
## Planned but not implemented
|
## Planned but not implemented
|
||||||
|
|
||||||
1. Signed, cross-platform Java 21 runtime installation.
|
1. User-selectable profile directory and structured launcher logs.
|
||||||
2. User-selectable profile directory and structured launcher logs.
|
2. "Reset managed files only" recovery action that doesn't touch player
|
||||||
3. Official Microsoft authentication and a compliant Minecraft/NeoForge launch
|
worlds/screenshots/resourcepacks.
|
||||||
flow.
|
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.
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
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]]
|
[[package]]
|
||||||
name = "atk"
|
name = "atk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -623,6 +632,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -848,6 +868,16 @@ dependencies = [
|
|||||||
"typeid",
|
"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]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -879,6 +909,16 @@ dependencies = [
|
|||||||
"rustc_version",
|
"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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -1860,6 +1900,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -1898,6 +1944,16 @@ dependencies = [
|
|||||||
"web_atoms",
|
"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]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
@@ -2810,6 +2866,19 @@ dependencies = [
|
|||||||
"semver",
|
"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]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.43"
|
||||||
@@ -3123,6 +3192,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -3140,13 +3220,18 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
|
"flate2",
|
||||||
|
"md-5",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"tar",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"url",
|
"url",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3425,6 +3510,17 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.12.16"
|
version = "0.12.16"
|
||||||
@@ -4837,6 +4933,16 @@ dependencies = [
|
|||||||
"pkg-config",
|
"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]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -4920,6 +5026,23 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"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]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.7"
|
version = "0.6.7"
|
||||||
@@ -4931,3 +5054,15 @@ name = "zmij"
|
|||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
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]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
sha1 = "0.10"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
md-5 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
ed25519-dalek = "2"
|
ed25519-dalek = "2"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
url = "2"
|
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 serde::Serialize;
|
||||||
use std::{env, path::PathBuf, process::Command};
|
use std::{env, fmt, path::{Path, PathBuf}, process::Command};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -9,6 +12,23 @@ pub struct JavaInstallation {
|
|||||||
pub version: String,
|
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.
|
/// Finds a usable Java runtime without modifying the machine.
|
||||||
///
|
///
|
||||||
/// The launcher will later use this result to decide whether Java 21 needs to
|
/// 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)
|
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> {
|
fn candidates() -> Vec<PathBuf> {
|
||||||
let executable = if cfg!(target_os = "windows") {
|
let executable = if cfg!(target_os = "windows") {
|
||||||
"java.exe"
|
"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 java;
|
||||||
|
mod launch;
|
||||||
mod manifest;
|
mod manifest;
|
||||||
|
mod mojang;
|
||||||
|
mod msa;
|
||||||
|
mod neoforge;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod remote;
|
mod remote;
|
||||||
|
mod runtime;
|
||||||
|
mod session;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
|
||||||
|
use reqwest::blocking::Client;
|
||||||
use serde::Serialize;
|
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)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -125,6 +146,246 @@ async fn save_settings(app: AppHandle, settings: settings::LauncherSettings) ->
|
|||||||
.map_err(|error| error.to_string())
|
.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() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -136,7 +397,12 @@ pub fn run() {
|
|||||||
inspect_remote_profile,
|
inspect_remote_profile,
|
||||||
sync_remote_profile,
|
sync_remote_profile,
|
||||||
load_settings,
|
load_settings,
|
||||||
save_settings
|
save_settings,
|
||||||
|
start_microsoft_login,
|
||||||
|
get_account,
|
||||||
|
logout,
|
||||||
|
ensure_game_installed,
|
||||||
|
launch_game
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running ShaCraft Launcher");
|
.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 crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest};
|
||||||
use reqwest::{blocking::Client, redirect::Policy};
|
use reqwest::{blocking::Client, redirect::Policy};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sha2::{Digest, Sha256};
|
use std::{fmt, io, path::Path};
|
||||||
use std::{fmt, fs::{self, File}, io::{self, Read, Write}, path::{Path, PathBuf}};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -27,9 +27,7 @@ pub struct SyncResult {
|
|||||||
pub enum ProfileError {
|
pub enum ProfileError {
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
Network(reqwest::Error),
|
Network(reqwest::Error),
|
||||||
HttpStatus { path: String, status: reqwest::StatusCode },
|
Download { path: String, source: DownloadError },
|
||||||
InvalidResponse { path: String, message: String },
|
|
||||||
Integrity { path: String, message: String },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for ProfileError {
|
impl fmt::Display for ProfileError {
|
||||||
@@ -37,9 +35,7 @@ impl fmt::Display for ProfileError {
|
|||||||
match self {
|
match self {
|
||||||
Self::Io(error) => write!(formatter, "Cannot inspect profile: {error}"),
|
Self::Io(error) => write!(formatter, "Cannot inspect profile: {error}"),
|
||||||
Self::Network(error) => write!(formatter, "Cannot download profile file: {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::Download { path, source } => write!(formatter, "Download failed for {path}: {source}"),
|
||||||
Self::InvalidResponse { path, message } => write!(formatter, "Invalid response for {path}: {message}"),
|
|
||||||
Self::Integrity { path, message } => write!(formatter, "Integrity check failed for {path}: {message}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,20 +46,12 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, Pr
|
|||||||
|
|
||||||
for expected in &manifest.files {
|
for expected in &manifest.files {
|
||||||
let path = root.join(&expected.path);
|
let path = root.join(&expected.path);
|
||||||
let metadata = match path.metadata() {
|
if !path.exists() {
|
||||||
Ok(metadata) if metadata.is_file() => metadata,
|
missing_files += 1;
|
||||||
Ok(_) => {
|
continue;
|
||||||
mismatched_files += 1;
|
}
|
||||||
continue;
|
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||||
}
|
if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
|
||||||
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() {
|
|
||||||
mismatched_files += 1;
|
mismatched_files += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,12 +87,13 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
|
|||||||
reused_files += 1;
|
reused_files += 1;
|
||||||
continue;
|
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;
|
reused_files += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = download_file(&client, expected, &target)?;
|
let bytes = download_managed_file(&client, expected, &target)?;
|
||||||
downloaded_files += 1;
|
downloaded_files += 1;
|
||||||
downloaded_bytes += bytes;
|
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> {
|
fn download_managed_file(client: &Client, expected: &ManagedFile, target: &Path) -> Result<u64, ProfileError> {
|
||||||
let metadata = match path.metadata() {
|
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||||
Ok(metadata) => metadata,
|
download::download_verified(client, &expected.url, target, Some(expected.size), &checksum, |_, _| {}).map_err(|error| {
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
|
ProfileError::Download { path: expected.path.clone(), source: error }
|
||||||
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()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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_MEMORY_MB: u16 = 6 * 1024;
|
||||||
const DEFAULT_NICKNAME: &str = "Emil";
|
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)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LauncherSettings {
|
pub struct LauncherSettings {
|
||||||
pub memory_mb: u16,
|
pub memory_mb: u16,
|
||||||
#[serde(default = "default_nickname")]
|
#[serde(default = "default_nickname")]
|
||||||
pub nickname: String,
|
pub nickname: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub account_mode: AccountMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_nickname() -> String {
|
fn default_nickname() -> String {
|
||||||
@@ -21,7 +38,7 @@ fn default_nickname() -> String {
|
|||||||
|
|
||||||
impl Default for LauncherSettings {
|
impl Default for LauncherSettings {
|
||||||
fn default() -> Self {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{load, save, LauncherSettings};
|
use super::{load, save, AccountMode, LauncherSettings};
|
||||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
||||||
|
|
||||||
fn temporary_directory() -> std::path::PathBuf {
|
fn temporary_directory() -> std::path::PathBuf {
|
||||||
@@ -94,11 +111,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn defaults_then_persists_memory() {
|
fn defaults_then_persists_memory() {
|
||||||
let directory = temporary_directory();
|
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!(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();
|
fs::remove_dir_all(directory).unwrap();
|
||||||
}
|
}
|
||||||
@@ -106,7 +126,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rejects_unsafe_memory_values() {
|
fn rejects_unsafe_memory_values() {
|
||||||
let directory = temporary_directory();
|
let directory = temporary_directory();
|
||||||
assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".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() }).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 React, { useEffect, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
Gauge,
|
Gauge,
|
||||||
Globe2,
|
Globe2,
|
||||||
Library,
|
Library,
|
||||||
|
LogOut,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Minus,
|
Minus,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
@@ -45,6 +47,7 @@ type NativeHost = {
|
|||||||
type NativeSettings = {
|
type NativeSettings = {
|
||||||
memoryMb: number
|
memoryMb: number
|
||||||
nickname: string
|
nickname: string
|
||||||
|
accountMode: 'microsoft' | 'offline'
|
||||||
}
|
}
|
||||||
|
|
||||||
type JavaInstallation = {
|
type JavaInstallation = {
|
||||||
@@ -66,6 +69,36 @@ type SyncResult = {
|
|||||||
downloadedBytes: number
|
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[] = [
|
const servers: Server[] = [
|
||||||
{
|
{
|
||||||
id: 'aoc',
|
id: 'aoc',
|
||||||
@@ -91,6 +124,10 @@ const servers: Server[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function isTauri() {
|
||||||
|
return '__TAURI_INTERNALS__' in window
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [selected, setSelected] = useState(servers[0])
|
const [selected, setSelected] = useState(servers[0])
|
||||||
const [progress, setProgress] = useState<number | null>(null)
|
const [progress, setProgress] = useState<number | null>(null)
|
||||||
@@ -98,12 +135,22 @@ function App() {
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [ram, setRam] = useState(6)
|
const [ram, setRam] = useState(6)
|
||||||
const [nickname, setNickname] = useState('Emil')
|
const [nickname, setNickname] = useState('Emil')
|
||||||
|
const [accountMode, setAccountMode] = useState<'microsoft' | 'offline'>('offline')
|
||||||
const [nativeHost, setNativeHost] = useState<NativeHost | null>(null)
|
const [nativeHost, setNativeHost] = useState<NativeHost | null>(null)
|
||||||
const [java, setJava] = useState<JavaInstallation | null | undefined>(undefined)
|
const [java, setJava] = useState<JavaInstallation | null | undefined>(undefined)
|
||||||
const [profile, setProfile] = useState<ProfileInspection | null>(null)
|
const [profile, setProfile] = useState<ProfileInspection | null>(null)
|
||||||
const [syncing, setSyncing] = useState(false)
|
const [syncing, setSyncing] = useState(false)
|
||||||
const [syncError, setSyncError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (progress === null) return
|
if (progress === null) return
|
||||||
if (progress >= 100) {
|
if (progress >= 100) {
|
||||||
@@ -118,10 +165,14 @@ function App() {
|
|||||||
}, [progress])
|
}, [progress])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!('__TAURI_INTERNALS__' in window)) return
|
if (!isTauri()) return
|
||||||
invoke<NativeHost>('native_host').then(setNativeHost).catch(() => setNativeHost(null))
|
invoke<NativeHost>('native_host').then(setNativeHost).catch(() => setNativeHost(null))
|
||||||
invoke<NativeSettings>('load_settings')
|
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)
|
.catch(() => undefined)
|
||||||
invoke<JavaInstallation | null>('detect_java')
|
invoke<JavaInstallation | null>('detect_java')
|
||||||
.then(setJava)
|
.then(setJava)
|
||||||
@@ -132,25 +183,58 @@ function App() {
|
|||||||
setReady(inspection.upToDate)
|
setReady(inspection.upToDate)
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
invoke<MinecraftProfile | null>('get_account')
|
||||||
|
.then(setAccount)
|
||||||
|
.catch(() => setAccount(null))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const updateRam = (memoryGb: number) => {
|
useEffect(() => {
|
||||||
setRam(memoryGb)
|
if (!isTauri()) return
|
||||||
if ('__TAURI_INTERNALS__' in window) {
|
const unlisten = [
|
||||||
invoke<NativeSettings>('save_settings', { settings: { memoryMb: memoryGb * 1024, nickname } })
|
listen<DeviceCodePayload>('msa-login-code', (event) => setLoginCode(event.payload)),
|
||||||
.catch(() => undefined)
|
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 = () => {
|
const saveNickname = () => {
|
||||||
if ('__TAURI_INTERNALS__' in window && /^[A-Za-z0-9_]{3,16}$/.test(nickname)) {
|
if (/^[A-Za-z0-9_]{3,16}$/.test(nickname)) {
|
||||||
invoke<NativeSettings>('save_settings', { settings: { memoryMb: ram * 1024, nickname } }).catch(() => undefined)
|
saveSettings(ram, nickname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setMode = (mode: 'microsoft' | 'offline') => {
|
||||||
|
setAccountMode(mode)
|
||||||
|
saveSettings(ram, nickname, mode)
|
||||||
|
}
|
||||||
|
|
||||||
const repair = async () => {
|
const repair = async () => {
|
||||||
if (selected.disabled) return
|
if (selected.disabled) return
|
||||||
if ('__TAURI_INTERNALS__' in window && selected.profileId) {
|
if (isTauri() && selected.profileId) {
|
||||||
setSyncError(null)
|
setSyncError(null)
|
||||||
setSyncing(true)
|
setSyncing(true)
|
||||||
setReady(false)
|
setReady(false)
|
||||||
@@ -169,6 +253,55 @@ function App() {
|
|||||||
setProgress(0)
|
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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="titlebar">
|
<header className="titlebar">
|
||||||
@@ -225,9 +358,18 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="account-chip">
|
<div className="account-chip">
|
||||||
<span className="avatar">ES</span>
|
<span className="avatar">{accountMode === 'offline' ? nickname.slice(0, 2).toUpperCase() : (account ? account.name.slice(0, 2).toUpperCase() : '?')}</span>
|
||||||
<span><strong>{nickname}</strong><small>локальный профиль</small></span>
|
<span>
|
||||||
<ChevronRight size={16} />
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -252,7 +394,15 @@ function App() {
|
|||||||
|
|
||||||
<section className="play-dock">
|
<section className="play-dock">
|
||||||
<div className="build-state">
|
<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 className="state-icon downloading"><Download size={19} /></span>
|
||||||
<span><strong>Синхронизируем сборку</strong><small>Скачиваем и проверяем файлы</small></span>
|
<span><strong>Синхронизируем сборку</strong><small>Скачиваем и проверяем файлы</small></span>
|
||||||
@@ -273,10 +423,14 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="state-icon"><ShieldCheck size={19} /></span>
|
<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>}
|
{progress !== null && <div className="progress-track"><i style={{ width: `${progress}%` }} /></div>}
|
||||||
|
{installPercent !== null && <div className="progress-track"><i style={{ width: `${installPercent}%` }} /></div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="build-facts">
|
<div className="build-facts">
|
||||||
@@ -284,21 +438,31 @@ function App() {
|
|||||||
<span><Gauge size={15} /> {ram} ГБ памяти</span>
|
<span><Gauge size={15} /> {ram} ГБ памяти</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="repair-button" onClick={repair} disabled={progress !== null || syncing || selected.disabled} aria-label="Проверить файлы">
|
<button className="repair-button" onClick={repair} disabled={progress !== null || syncing || installing || selected.disabled} aria-label="Проверить файлы">
|
||||||
<RotateCcw size={19} />
|
<RotateCcw size={19} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="play-button"
|
className="play-button"
|
||||||
disabled={progress !== null || syncing || selected.disabled}
|
disabled={progress !== null || syncing || installing || selected.disabled || (accountMode === 'microsoft' && account === undefined) || loggingIn}
|
||||||
onClick={() => !ready && repair()}
|
onClick={playOrLogin}
|
||||||
>
|
>
|
||||||
<Play size={21} fill="currentColor" />
|
<Play size={21} fill="currentColor" />
|
||||||
<span>{selected.disabled ? 'Недоступно' : syncing || progress !== null ? 'Обновление' : ready ? 'Играть' : 'Проверить'}</span>
|
<span>{playLabel()}</span>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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)} />
|
<div className={`drawer-backdrop ${settingsOpen ? 'visible' : ''}`} onClick={() => setSettingsOpen(false)} />
|
||||||
<aside className={`settings-drawer ${settingsOpen ? 'open' : ''}`} aria-hidden={!settingsOpen}>
|
<aside className={`settings-drawer ${settingsOpen ? 'open' : ''}`} aria-hidden={!settingsOpen}>
|
||||||
<div className="drawer-title">
|
<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))} />
|
<input type="range" min="3" max="12" value={ram} onChange={(e) => updateRam(Number(e.target.value))} />
|
||||||
<small>Для Aeronautics рекомендуется 6 ГБ</small>
|
<small>Для Aeronautics рекомендуется 6 ГБ</small>
|
||||||
</label>
|
</label>
|
||||||
<label className="text-setting">
|
<div className="setting-row static">
|
||||||
<span><strong>Игровой ник</strong><small>Локальный профиль</small></span>
|
<span><Users />Аккаунт</span>
|
||||||
<input value={nickname} maxLength={16} onChange={(event) => setNickname(event.target.value)} onBlur={saveNickname} placeholder="Player" />
|
<small>{accountMode === 'offline' ? 'Offline' : (account ? account.name : 'Не авторизован')}</small>
|
||||||
<small>Латинские буквы, цифры и _ · от 3 до 16 символов</small>
|
</div>
|
||||||
</label>
|
{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">
|
<div className="setting-row static">
|
||||||
<span><FolderOpen />Папка игры</span>
|
<span><FolderOpen />Папка игры</span>
|
||||||
<small>{nativeHost ? 'В каталоге лаунчера' : 'Определяется…'}</small>
|
<small>{nativeHost ? 'В каталоге лаунчера' : 'Определяется…'}</small>
|
||||||
@@ -328,7 +519,7 @@ function App() {
|
|||||||
? `Java ${java.major} найдена`
|
? `Java ${java.major} найдена`
|
||||||
: java
|
: java
|
||||||
? `Нужна Java 21 · найдена ${java.major}`
|
? `Нужна Java 21 · найдена ${java.major}`
|
||||||
: 'Java не найдена'}
|
: 'Лаунчер установит Java 21 автоматически'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<button className="setting-row"><span><Wrench />Дополнительные параметры</span><ChevronRight /></button>
|
<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); }
|
.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; }
|
.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) {
|
@media (max-width: 1160px) {
|
||||||
.workspace { grid-template-columns: 58px 224px 1fr; }
|
.workspace { grid-template-columns: 58px 224px 1fr; }
|
||||||
.brand { width: 240px; }
|
.brand { width: 240px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user