feat: install and launch Minecraft with NeoForge and Microsoft login

Wires up the actual game pipeline behind the existing ShaCraft manifest
sync: Mojang version resolution, Java 21 auto-provisioning via Adoptium,
headless NeoForge installation, real Microsoft/Xbox/Minecraft Services
login, and an offline account mode, then builds and spawns the java
process itself.

- mojang.rs: vanilla trust boundary, inheritsFrom version-JSON merge,
  asset/library downloading with a worker pool and per-file retries
- neoforge.rs: runs NeoForge's own installer headlessly, with live
  progress parsed from its output against its own install_profile.json
- runtime.rs / java.rs: detects a usable local Java or provisions one
  from Eclipse Temurin, with real download progress
- msa.rs: device-code OAuth -> Xbox Live -> XSTS -> Minecraft Services,
  gated on ShaCraft registering its own Azure AD app (see MSA_CLIENT_ID)
- session.rs / launch.rs: offline deterministic UUIDs and the merged
  java invocation itself
- download.rs: shared verified-download helper (temp file, hash,
  atomic rename, retries, progress) used across all of the above and
  refactored into profile.rs
- UI: account mode toggle (Microsoft/offline), login modal, and real
  per-stage install progress instead of start/done placeholders
This commit is contained in:
emil28092005
2026-09-06 05:09:50 +03:00
parent c37d38522e
commit cc19a24e45
19 changed files with 3270 additions and 180 deletions
+223
View File
@@ -0,0 +1,223 @@
use reqwest::blocking::{Client, Response};
use sha1::Sha1;
use sha2::{Digest, Sha256};
use std::{
fmt,
fs::{self, File},
io::{self, Read, Write},
path::{Path, PathBuf},
sync::Arc,
};
/// A progress reporter shared across worker threads: `(bytes_done, bytes_total)`.
/// `bytes_total` may be a coarse estimate (e.g. item counts rather than bytes)
/// for callers that can't know exact sizes upfront, as long as it converges
/// to the true total by completion.
pub type ProgressCallback = Arc<dyn Fn(u64, u64) + Send + Sync>;
/// Expected content hash for a downloaded file. Mojang publishes SHA-1 for
/// game files; ShaCraft and everything else here uses SHA-256.
#[derive(Clone, Debug)]
pub enum Checksum {
Sha1(String),
Sha256(String),
}
impl Checksum {
fn matches(&self, sha1_hex: &str, sha256_hex: &str) -> bool {
match self {
Self::Sha1(expected) => expected.eq_ignore_ascii_case(sha1_hex),
Self::Sha256(expected) => expected.eq_ignore_ascii_case(sha256_hex),
}
}
}
#[derive(Debug)]
pub enum DownloadError {
Io(io::Error),
Network(reqwest::Error),
HttpStatus(reqwest::StatusCode),
SizeMismatch { expected: u64, actual: u64 },
ChecksumMismatch,
InvalidTargetPath,
}
impl fmt::Display for DownloadError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(formatter, "I/O error: {error}"),
Self::Network(error) => write!(formatter, "network error: {error}"),
Self::HttpStatus(status) => write!(formatter, "server returned {status}"),
Self::SizeMismatch { expected, actual } => {
write!(formatter, "expected {expected} bytes, received {actual}")
}
Self::ChecksumMismatch => formatter.write_str("checksum does not match"),
Self::InvalidTargetPath => formatter.write_str("target has no valid filename"),
}
}
}
/// Computes both SHA-1 and SHA-256 of a local file in a single pass, so a
/// caller can check whichever `Checksum` variant it needs without re-reading
/// the file.
pub fn file_hashes(path: &Path) -> io::Result<(String, String)> {
let mut file = File::open(path)?;
let mut sha1 = Sha1::new();
let mut sha256 = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
sha1.update(&buffer[..read]);
sha256.update(&buffer[..read]);
}
Ok((format!("{:x}", sha1.finalize()), format!("{:x}", sha256.finalize())))
}
/// True if `path` already exists, matches `expected_size` (when given) and
/// `checksum`. Used to skip re-downloading files that are already current.
pub fn is_current(path: &Path, expected_size: Option<u64>, checksum: &Checksum) -> io::Result<bool> {
let metadata = match path.metadata() {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error),
};
if !metadata.is_file() {
return Ok(false);
}
if let Some(expected_size) = expected_size {
if metadata.len() != expected_size {
return Ok(false);
}
}
let (sha1_hex, sha256_hex) = file_hashes(path)?;
Ok(checksum.matches(&sha1_hex, &sha256_hex))
}
fn temp_path(target: &Path) -> Result<PathBuf, DownloadError> {
let file_name = target
.file_name()
.and_then(|name| name.to_str())
.ok_or(DownloadError::InvalidTargetPath)?;
Ok(target.with_file_name(format!(".{file_name}.shacraft.part")))
}
/// Downloads `url` to `target`, verifying size (if known ahead of time) and
/// `checksum` before atomically renaming the temporary file into place.
/// `on_progress(downloaded_bytes, total_bytes)` is called after every chunk;
/// `total_bytes` is `None` when the server did not send `Content-Length`.
pub fn download_verified(
client: &Client,
url: &str,
target: &Path,
expected_size: Option<u64>,
checksum: &Checksum,
mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<u64, DownloadError> {
let parent = target.parent().ok_or(DownloadError::InvalidTargetPath)?;
fs::create_dir_all(parent).map_err(DownloadError::Io)?;
let mut response = client.get(url).send().map_err(DownloadError::Network)?;
if !response.status().is_success() {
return Err(DownloadError::HttpStatus(response.status()));
}
let total = expected_size.or_else(|| response.content_length());
if let (Some(expected), Some(length)) = (expected_size, response.content_length()) {
if expected != length {
return Err(DownloadError::SizeMismatch { expected, actual: length });
}
}
let temporary = temp_path(target)?;
let result = write_and_verify(&mut response, &temporary, expected_size, checksum, total, &mut on_progress);
if let Err(error) = result {
let _ = fs::remove_file(&temporary);
return Err(error);
}
let bytes = result.unwrap();
fs::rename(&temporary, target).map_err(DownloadError::Io)?;
Ok(bytes)
}
fn write_and_verify(
response: &mut Response,
temporary: &Path,
expected_size: Option<u64>,
checksum: &Checksum,
total: Option<u64>,
on_progress: &mut impl FnMut(u64, Option<u64>),
) -> Result<u64, DownloadError> {
let mut output = File::create(temporary).map_err(DownloadError::Io)?;
let mut sha1 = Sha1::new();
let mut sha256 = Sha256::new();
let mut bytes = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = response.read(&mut buffer).map_err(DownloadError::Io)?;
if read == 0 {
break;
}
output.write_all(&buffer[..read]).map_err(DownloadError::Io)?;
sha1.update(&buffer[..read]);
sha256.update(&buffer[..read]);
bytes += read as u64;
on_progress(bytes, total);
}
output.sync_all().map_err(DownloadError::Io)?;
if let Some(expected) = expected_size {
if bytes != expected {
return Err(DownloadError::SizeMismatch { expected, actual: bytes });
}
}
let sha1_hex = format!("{:x}", sha1.finalize());
let sha256_hex = format!("{:x}", sha256.finalize());
if !checksum.matches(&sha1_hex, &sha256_hex) {
return Err(DownloadError::ChecksumMismatch);
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::{file_hashes, is_current, Checksum};
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
fn temp_file(contents: &[u8]) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!(
"shacraft-download-test-{}-{}",
process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
));
fs::write(&path, contents).unwrap();
path
}
#[test]
fn computes_both_hashes() {
let path = temp_file(b"hello shacraft");
let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap();
assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4");
assert_eq!(sha256_hex, "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d");
fs::remove_file(path).unwrap();
}
#[test]
fn is_current_checks_size_and_hash() {
let path = temp_file(b"hello shacraft");
let (_, sha256_hex) = file_hashes(&path).unwrap();
assert!(is_current(&path, Some(14), &Checksum::Sha256(sha256_hex.clone())).unwrap());
assert!(!is_current(&path, Some(13), &Checksum::Sha256(sha256_hex.clone())).unwrap());
assert!(!is_current(&path, Some(14), &Checksum::Sha256("0".repeat(64))).unwrap());
fs::remove_file(path).unwrap();
}
#[test]
fn is_current_false_for_missing_file() {
let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz");
assert!(!is_current(&path, None, &Checksum::Sha256("0".repeat(64))).unwrap());
}
}
+38 -1
View File
@@ -1,5 +1,8 @@
use crate::download::ProgressCallback;
use crate::runtime::{self, RuntimeError};
use reqwest::blocking::Client;
use serde::Serialize;
use std::{env, path::PathBuf, process::Command};
use std::{env, fmt, path::{Path, PathBuf}, process::Command};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -9,6 +12,23 @@ pub struct JavaInstallation {
pub version: String,
}
#[derive(Debug)]
pub enum EnsureJavaError {
Provisioning(RuntimeError),
ProvisionedButUnrecognised(PathBuf),
}
impl fmt::Display for EnsureJavaError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Provisioning(error) => write!(formatter, "Cannot install a Java runtime: {error}"),
Self::ProvisionedButUnrecognised(path) => {
write!(formatter, "Installed a Java runtime at {path:?}, but it did not report a usable version")
}
}
}
}
/// Finds a usable Java runtime without modifying the machine.
///
/// The launcher will later use this result to decide whether Java 21 needs to
@@ -18,6 +38,23 @@ pub fn detect() -> Option<JavaInstallation> {
candidates().into_iter().find_map(check_candidate)
}
/// Returns a Java runtime with at least `required_major`, preferring
/// whatever the user already has installed. Only downloads and extracts a
/// ShaCraft-managed Eclipse Temurin JRE under `runtime_root` (never touches
/// the user's own Java) when nothing suitable is already on the machine.
/// `on_progress` reports real download bytes when a JRE actually needs
/// fetching; it fires once with `(1, 1)` when an existing Java is reused.
pub fn ensure_java(client: &Client, runtime_root: &Path, required_major: u8, on_progress: &ProgressCallback) -> Result<JavaInstallation, EnsureJavaError> {
if let Some(installation) = detect() {
if installation.major >= required_major {
on_progress(1, 1);
return Ok(installation);
}
}
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress).map_err(EnsureJavaError::Provisioning)?;
check_candidate(executable.clone()).ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
}
fn candidates() -> Vec<PathBuf> {
let executable = if cfg!(target_os = "windows") {
"java.exe"
+206
View File
@@ -0,0 +1,206 @@
//! Builds and spawns the real `java` invocation for a merged launch
//! profile. The `${auth_*}` placeholders are filled from a `PlayerIdentity`,
//! which is either a real Microsoft-authenticated session (`msa::LoginResult`)
//! or an explicit offline account (`PlayerIdentity::Offline`). Offline mode is
//! never silently substituted for a Microsoft session.
use crate::mojang::{self, MergedVersion};
use crate::session::PlayerIdentity;
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
fmt, fs, io,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
#[derive(Debug)]
pub enum LaunchError {
Io(io::Error),
}
impl fmt::Display for LaunchError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(formatter, "cannot launch Minecraft: {error}"),
}
}
}
impl From<io::Error> for LaunchError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
pub struct LaunchRequest<'a> {
pub java_executable: &'a Path,
/// Shared vanilla+NeoForge files: versions/, libraries/, assets/.
pub game_dir: &'a Path,
/// ShaCraft-managed mods/config for this profile; becomes `--gameDir`
/// so worlds/screenshots/config the player creates land there, not in
/// the shared `game_dir`.
pub profile_dir: &'a Path,
pub merged: &'a MergedVersion,
pub identity: &'a PlayerIdentity,
pub memory_mb: u16,
pub log_path: &'a Path,
}
fn classpath_separator() -> &'static str {
if cfg!(target_os = "windows") {
";"
} else {
":"
}
}
fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -> String {
let no_features = HashMap::new();
let mut entries: Vec<PathBuf> = merged
.libraries
.iter()
.filter(|library| mojang::rule_allows(&library.rules, &no_features))
.filter_map(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()))
.map(|artifact| game_dir.join("libraries").join(&artifact.path))
.collect();
entries.push(client_jar.to_path_buf());
entries.iter().map(|path| path.display().to_string()).collect::<Vec<_>>().join(classpath_separator())
}
/// A persistent-but-not-security-sensitive per-install identifier for the
/// `${clientid}` launch argument (Microsoft telemetry only, unrelated to
/// auth). Deliberately avoids adding a `uuid`/`rand` dependency for this:
/// it's hashed from time/process entropy via the `sha2` we already depend
/// on, formatted as a version-4-shaped UUID.
fn launcher_client_id(game_dir: &Path) -> Result<String, LaunchError> {
let path = game_dir.join(".shacraft-client-id");
if let Ok(existing) = fs::read_to_string(&path) {
let trimmed = existing.trim();
if trimmed.len() == 36 {
return Ok(trimmed.to_string());
}
}
fs::create_dir_all(game_dir)?;
let generated = random_uuid_v4();
fs::write(&path, &generated)?;
Ok(generated)
}
static UUID_COUNTER: AtomicU64 = AtomicU64::new(0);
fn random_uuid_v4() -> String {
let mut hasher = Sha256::new();
hasher.update(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos().to_le_bytes());
hasher.update(std::process::id().to_le_bytes());
hasher.update(UUID_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
let stack_marker = 0_u8;
hasher.update((&stack_marker as *const u8 as usize).to_le_bytes());
let digest = hasher.finalize();
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[0..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
let hex = bytes.iter().map(|byte| format!("{byte:02x}")).collect::<String>();
crate::session::format_uuid_with_dashes(&hex)
}
fn substitute(template: &str, vars: &HashMap<&str, String>) -> String {
let mut result = template.to_string();
for (key, value) in vars {
let token = format!("${{{key}}}");
if result.contains(&token) {
result = result.replace(&token, value);
}
}
result
}
/// Builds the full `java` command line for `request.merged` and spawns it
/// detached, with stdout/stderr both redirected to `request.log_path`.
/// Never blocks on the child exiting — the caller decides how to observe
/// that (see `lib.rs`'s launch command, which watches it on a background
/// thread and emits an event).
pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
fs::create_dir_all(request.profile_dir)?;
let natives_dir = mojang::natives_directory(request.game_dir, &request.merged.id);
fs::create_dir_all(&natives_dir)?;
let assets_root = request.game_dir.join("assets");
let libraries_dir = request.game_dir.join("libraries");
let client_jar = mojang::client_jar_path(request.game_dir, &request.merged.client_jar_version_id);
let classpath = build_classpath(request.game_dir, request.merged, &client_jar);
let mut vars: HashMap<&str, String> = HashMap::new();
vars.insert("auth_player_name", request.identity.name().to_string());
vars.insert("version_name", request.merged.id.clone());
vars.insert("game_directory", request.profile_dir.display().to_string());
vars.insert("assets_root", assets_root.display().to_string());
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
vars.insert("auth_uuid", request.identity.uuid());
vars.insert("auth_access_token", request.identity.access_token().to_string());
vars.insert("clientid", launcher_client_id(request.game_dir)?);
vars.insert("auth_xuid", request.identity.xuid().to_string());
vars.insert("user_type", request.identity.user_type().to_string());
vars.insert("version_type", "ShaCraft Launcher".to_string());
vars.insert("natives_directory", natives_dir.display().to_string());
vars.insert("launcher_name", "ShaCraft Launcher".to_string());
vars.insert("launcher_version", env!("CARGO_PKG_VERSION").to_string());
vars.insert("classpath", classpath);
vars.insert("library_directory", libraries_dir.display().to_string());
vars.insert("classpath_separator", classpath_separator().to_string());
let no_features = HashMap::new();
let jvm_args = mojang::resolve_arguments(&request.merged.jvm_arguments, &no_features);
let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features);
let mut command = Command::new(request.java_executable);
command.arg(format!("-Xmx{}M", request.memory_mb));
for argument in jvm_args {
command.arg(substitute(&argument, &vars));
}
command.arg(&request.merged.main_class);
for argument in game_args {
command.arg(substitute(&argument, &vars));
}
command.current_dir(request.profile_dir);
let log_file = fs::File::create(request.log_path)?;
command.stdout(Stdio::from(log_file.try_clone()?));
command.stderr(Stdio::from(log_file));
Ok(command.spawn()?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generates_rfc4122_version_4_uuids() {
let id = random_uuid_v4();
let parts: Vec<&str> = id.split('-').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[2].chars().next().unwrap(), '4');
assert!(matches!(parts[3].chars().next().unwrap(), '8' | '9' | 'a' | 'b'));
}
#[test]
fn client_id_is_persisted_across_calls() {
let dir = std::env::temp_dir().join(format!("shacraft-launch-clientid-test-{}", std::process::id()));
let first = launcher_client_id(&dir).unwrap();
let second = launcher_client_id(&dir).unwrap();
assert_eq!(first, second);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn substitutes_known_tokens_only() {
let mut vars = HashMap::new();
vars.insert("auth_player_name", "Steve".to_string());
assert_eq!(substitute("--username", &vars), "--username");
assert_eq!(substitute("${auth_player_name}", &vars), "Steve");
assert_eq!(substitute("-Djava.library.path=${natives_directory}", &vars), "-Djava.library.path=${natives_directory}");
}
}
+268 -2
View File
@@ -1,11 +1,32 @@
mod download;
mod java;
mod launch;
mod manifest;
mod mojang;
mod msa;
mod neoforge;
mod profile;
mod remote;
mod runtime;
mod session;
mod settings;
use reqwest::blocking::Client;
use serde::Serialize;
use tauri::{AppHandle, Manager};
use std::{path::Path, sync::Arc, time::SystemTime};
use tauri::{AppHandle, Emitter, Manager};
fn http_client() -> Client {
Client::new()
}
fn game_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
Ok(app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?.join("game"))
}
fn profile_dir(app: &AppHandle, profile_id: &str) -> Result<std::path::PathBuf, String> {
Ok(app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?.join("profiles").join(profile_id))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@@ -125,6 +146,246 @@ async fn save_settings(app: AppHandle, settings: settings::LauncherSettings) ->
.map_err(|error| error.to_string())
}
// ---------------------------------------------------------------------
// Microsoft account login
// ---------------------------------------------------------------------
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DeviceCodePayload {
verification_uri: String,
user_code: String,
expires_in_seconds: u64,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LoginResultPayload {
ok: bool,
profile: Option<msa::MinecraftProfile>,
error: Option<String>,
}
/// Starts a Microsoft device-code login in the background. Emits
/// `msa-login-code` as soon as the user code is available (show it to the
/// player immediately — they have a limited time to enter it), then
/// `msa-login-result` once sign-in finishes, fails, or times out. Returns
/// immediately; it does not wait for the user to finish signing in.
#[tauri::command]
fn start_microsoft_login(app: AppHandle) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let client = http_client();
let start = match msa::start_device_code(&client) {
Ok(start) => start,
Err(error) => {
let _ = app.emit("msa-login-result", LoginResultPayload { ok: false, profile: None, error: Some(error.to_string()) });
return;
}
};
let _ = app.emit(
"msa-login-code",
DeviceCodePayload { verification_uri: start.verification_uri.clone(), user_code: start.user_code.clone(), expires_in_seconds: start.expires_in_seconds },
);
match msa::login_with_device_code(&client, &start) {
Ok(result) => {
if let Ok(data_dir) = app.path().app_data_dir() {
let _ = msa::save_refresh_token(&data_dir, &result.refresh_token);
}
let _ = app.emit("msa-login-result", LoginResultPayload { ok: true, profile: Some(result.profile), error: None });
}
Err(error) => {
let _ = app.emit("msa-login-result", LoginResultPayload { ok: false, profile: None, error: Some(error.to_string()) });
}
}
});
Ok(())
}
/// Tries to restore a session from a previously saved refresh token
/// (silent, no browser/user code). Returns `None` if there is none saved
/// or it no longer works — the UI should fall back to offering login.
#[tauri::command]
async fn get_account(app: AppHandle) -> Result<Option<msa::MinecraftProfile>, String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || {
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else { return Ok(None) };
let client = http_client();
match msa::login_with_refresh_token(&client, &refresh_token) {
Ok(result) => {
let _ = msa::save_refresh_token(&data_dir, &result.refresh_token);
Ok(Some(result.profile))
}
Err(_) => Ok(None),
}
})
.await
.map_err(|error| format!("Account restore task failed: {error}"))?
}
#[tauri::command]
async fn logout(app: AppHandle) -> Result<(), String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || msa::clear_account(&data_dir))
.await
.map_err(|error| format!("Logout task failed: {error}"))?
.map_err(|error| error.to_string())
}
/// Resolves the identity to launch as, based on the persisted `account_mode`.
/// In `Microsoft` mode this requires a real signed-in session (see
/// `msa::login_with_refresh_token`) and returns an error if there is none;
/// in `Offline` mode it uses the local nickname from settings, so no
/// Microsoft account is needed at all. Offline is never silently used in
/// place of a missing Microsoft session.
fn resolve_identity(client: &Client, data_dir: &Path) -> Result<session::PlayerIdentity, String> {
let settings = settings::load(data_dir).map_err(|error| error.to_string())?;
match settings.account_mode {
settings::AccountMode::Offline => Ok(session::PlayerIdentity::Offline { name: settings.nickname }),
settings::AccountMode::Microsoft => {
let refresh_token = msa::load_refresh_token(data_dir).ok_or("Not signed in with a Microsoft account")?;
let result = msa::login_with_refresh_token(client, &refresh_token).map_err(|error| error.to_string())?;
let _ = msa::save_refresh_token(data_dir, &result.refresh_token);
Ok(session::PlayerIdentity::Microsoft(result))
}
}
}
// ---------------------------------------------------------------------
// Game install + launch
// ---------------------------------------------------------------------
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct InstallProgress {
stage: &'static str,
current_bytes: u64,
total_bytes: u64,
}
/// Resolves the vanilla + (if any) loader version JSONs for `manifest` and
/// merges them, ensuring a Java runtime and (for NeoForge profiles) running
/// the installer along the way. Shared by `ensure_game_installed` and
/// `launch_game` so both always agree on exactly what "installed" means.
/// `on_progress` is forwarded to the NeoForge installer when one runs;
/// callers that don't display progress (e.g. `launch_game`, which only
/// hits this after `ensure_game_installed` already installed everything)
/// pass a no-op callback.
fn resolve_merged_version(client: &Client, manifest: &manifest::Manifest, java_executable: &Path, game_dir: &Path, cache_dir: &Path, on_progress: &mojang::ProgressCallback) -> Result<mojang::MergedVersion, String> {
let mojang_manifest = mojang::fetch_version_manifest(client).map_err(|error| error.to_string())?;
let vanilla_entry = mojang::find_version(&mojang_manifest, &manifest.minecraft.version)
.ok_or_else(|| format!("Mojang does not list Minecraft version {}", manifest.minecraft.version))?;
let vanilla = mojang::fetch_version_json(client, vanilla_entry).map_err(|error| error.to_string())?;
if manifest.minecraft.loader.kind == "neoforge" {
let neoforge_version = neoforge::ensure_client_installed(client, java_executable, game_dir, cache_dir, &manifest.minecraft.loader.version, on_progress).map_err(|error| error.to_string())?;
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
} else {
mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string())
}
}
/// Downloads and installs everything needed to run `profile_id`: the
/// exact Minecraft/loader version the ShaCraft-signed manifest specifies,
/// a Java runtime if none is already usable, and game assets. Emits
/// `game-install-progress` throughout with real progress for every stage:
/// download bytes for Java, installer-confirmed library/processor counts
/// for NeoForge, and download bytes for libraries/assets.
#[tauri::command]
async fn ensure_game_installed(app: AppHandle, profile_id: String) -> Result<(), String> {
let game_dir = game_dir(&app)?;
let runtime_root = game_dir.join("runtime");
let cache_dir = game_dir.join("cache");
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let client = http_client();
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
let stage_progress = |stage: &'static str| -> mojang::ProgressCallback {
let app = app.clone();
Arc::new(move |current, total| {
let _ = app.emit("game-install-progress", InstallProgress { stage, current_bytes: current, total_bytes: total });
})
};
let java_install = java::ensure_java(&client, &runtime_root, manifest.minecraft.java_major, &stage_progress("java")).map_err(|error| error.to_string())?;
let merged = resolve_merged_version(&client, &manifest, Path::new(&java_install.executable), &game_dir, &cache_dir, &stage_progress("neoforge"))?;
if manifest.minecraft.loader.kind != "neoforge" {
// Vanilla-only profiles skip the installer, which normally
// downloads vanilla itself; do it ourselves here instead.
mojang::ensure_client_jar(&client, &game_dir, &merged.client_jar_version_id, &merged.client).map_err(|error| error.to_string())?;
mojang::ensure_libraries(&client, &game_dir, &merged.libraries, &stage_progress("libraries")).map_err(|error| error.to_string())?;
};
let asset_index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index).map_err(|error| error.to_string())?;
mojang::ensure_assets(&client, &game_dir, &asset_index, &stage_progress("assets")).map_err(|error| error.to_string())?;
Ok(())
})
.await
.map_err(|error| format!("Install task failed: {error}"))?
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameExited {
profile_id: String,
exit_code: Option<i32>,
}
/// Launches `profile_id` as the account chosen in settings (`account_mode`).
/// In `Microsoft` mode a real session is required (see `resolve_identity`);
/// in `Offline` mode the local nickname from settings is used, so no
/// Microsoft account is needed. Spawns the game detached; watches it on a
/// background thread only to emit `game-exited` when it eventually closes.
#[tauri::command]
async fn launch_game(app: AppHandle, profile_id: String) -> Result<(), String> {
let game_dir = game_dir(&app)?;
let profile_dir = profile_dir(&app, &profile_id)?;
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let client = http_client();
let identity = resolve_identity(&client, &data_dir)?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
let settings = settings::load(&data_dir).map_err(|error| error.to_string())?;
// Everything here should already be installed by `ensure_game_installed`,
// so these are expected to hit their fast paths; no progress to show.
let no_progress: mojang::ProgressCallback = Arc::new(|_, _| {});
let java_install = java::ensure_java(&client, &game_dir.join("runtime"), manifest.minecraft.java_major, &no_progress).map_err(|error| error.to_string())?;
let merged = resolve_merged_version(&client, &manifest, Path::new(&java_install.executable), &game_dir, &game_dir.join("cache"), &no_progress)?;
let timestamp = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
let log_dir = data_dir.join("logs");
std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?;
let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log"));
let request = launch::LaunchRequest {
java_executable: Path::new(&java_install.executable),
game_dir: &game_dir,
profile_dir: &profile_dir,
merged: &merged,
identity: &identity,
memory_mb: settings.memory_mb,
log_path: &log_path,
};
let mut child = launch::launch(&request).map_err(|error| error.to_string())?;
let watch_app = app.clone();
let watch_profile_id = profile_id.clone();
std::thread::spawn(move || {
let exit_code = child.wait().ok().and_then(|status| status.code());
let _ = watch_app.emit("game-exited", GameExited { profile_id: watch_profile_id, exit_code });
});
Ok(())
})
.await
.map_err(|error| format!("Launch task failed: {error}"))?
}
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
@@ -136,7 +397,12 @@ pub fn run() {
inspect_remote_profile,
sync_remote_profile,
load_settings,
save_settings
save_settings,
start_microsoft_login,
get_account,
logout,
ensure_game_installed,
launch_game
])
.run(tauri::generate_context!())
.expect("error while running ShaCraft Launcher");
+697
View File
@@ -0,0 +1,697 @@
//! Vanilla Minecraft trust boundary.
//!
//! Everything here talks only to Mojang's own public, unauthenticated CDN.
//! Which Minecraft version to install is decided entirely by the
//! ShaCraft-signed manifest (`manifest.rs`, `Manifest.minecraft`); this
//! module never takes a URL from that manifest. Every JSON document and
//! binary is verified against a SHA-1 obtained from an already-verified
//! parent document, all the way back to `version_manifest_v2.json`.
//!
//! The version-JSON types and `inheritsFrom` merge here follow the same
//! spec NeoForge's installer targets (see `neoforge.rs`), so a modloader
//! profile is handled by the identical, loader-agnostic merge algorithm
//! every vanilla-compatible third-party launcher uses.
use crate::download::{self, Checksum, DownloadError};
use reqwest::blocking::Client;
use serde::{de::DeserializeOwned, Deserialize};
use sha1::{Digest, Sha1};
use std::{
collections::HashMap,
fmt, fs, io,
path::{Path, PathBuf},
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
};
use url::Url;
const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
const MOJANG_HOSTS: [&str; 4] = [
"piston-meta.mojang.com",
"piston-data.mojang.com",
"libraries.minecraft.net",
"resources.download.minecraft.net",
];
// Asset downloads are latency-bound (tens of thousands of small files), not
// bandwidth-bound, so throughput scales with concurrent in-flight requests
// far more than with per-connection speed; Mojang's CDN comfortably handles
// this many. Raised from an earlier, overly conservative 12.
const ASSET_WORKERS: usize = 48;
pub fn is_allowed_host(url: &str) -> bool {
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host))).unwrap_or(false)
}
#[derive(Debug)]
pub enum MojangError {
Network(reqwest::Error),
HttpStatus(reqwest::StatusCode),
InvalidJson(serde_json::Error),
ChecksumMismatch(String),
DisallowedHost(String),
MissingField(String),
Download(DownloadError),
Io(io::Error),
}
impl fmt::Display for MojangError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(error) => write!(formatter, "network error: {error}"),
Self::HttpStatus(status) => write!(formatter, "Mojang returned {status}"),
Self::InvalidJson(error) => write!(formatter, "invalid Mojang JSON: {error}"),
Self::ChecksumMismatch(context) => write!(formatter, "checksum mismatch for {context}"),
Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised Mojang host: {url}"),
Self::MissingField(field) => write!(formatter, "version JSON is missing {field}"),
Self::Download(error) => write!(formatter, "{error}"),
Self::Io(error) => write!(formatter, "I/O error: {error}"),
}
}
}
impl From<DownloadError> for MojangError {
fn from(error: DownloadError) -> Self {
Self::Download(error)
}
}
impl From<io::Error> for MojangError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
// ---------------------------------------------------------------------
// Version manifest
// ---------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct VersionManifest {
pub versions: Vec<VersionManifestEntry>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct VersionManifestEntry {
pub id: String,
pub url: String,
pub sha1: String,
}
pub fn fetch_version_manifest(client: &Client) -> Result<VersionManifest, MojangError> {
fetch_json(client, VERSION_MANIFEST_URL, None)
}
pub fn find_version<'a>(manifest: &'a VersionManifest, id: &str) -> Option<&'a VersionManifestEntry> {
manifest.versions.iter().find(|entry| entry.id == id)
}
// ---------------------------------------------------------------------
// Version JSON (shared shape with NeoForge's installed profile JSON)
// ---------------------------------------------------------------------
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VersionJson {
pub id: String,
pub main_class: String,
#[serde(default)]
pub arguments: Option<Arguments>,
#[serde(default)]
pub asset_index: Option<AssetIndexRef>,
#[serde(default)]
pub downloads: Option<Downloads>,
#[serde(default)]
pub libraries: Vec<Library>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct Arguments {
#[serde(default)]
pub game: Vec<ArgumentValue>,
#[serde(default)]
pub jvm: Vec<ArgumentValue>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum ArgumentValue {
Plain(String),
Conditional { rules: Vec<Rule>, value: StringOrList },
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum StringOrList {
One(String),
Many(Vec<String>),
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AssetIndexRef {
pub id: String,
pub sha1: String,
pub size: u64,
pub url: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Downloads {
pub client: DownloadRef,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DownloadRef {
pub sha1: String,
pub size: u64,
pub url: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Library {
pub name: String,
#[serde(default)]
pub downloads: Option<LibraryDownloads>,
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct LibraryDownloads {
pub artifact: Option<Artifact>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Artifact {
pub path: String,
pub url: String,
pub sha1: String,
pub size: u64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Rule {
pub action: RuleAction,
#[serde(default)]
pub os: Option<RuleOs>,
#[serde(default)]
pub features: Option<HashMap<String, bool>>,
}
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RuleAction {
Allow,
Disallow,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct RuleOs {
pub name: Option<String>,
pub arch: Option<String>,
}
fn current_os_name() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"osx"
} else {
"linux"
}
}
fn arch_matches(expected: &str) -> bool {
let normalized = if expected == "arm64" { "aarch64" } else { expected };
normalized == std::env::consts::ARCH
}
fn os_matches(os: &RuleOs) -> bool {
if let Some(name) = &os.name {
if name != current_os_name() {
return false;
}
}
if let Some(arch) = &os.arch {
if !arch_matches(arch) {
return false;
}
}
true
}
fn features_match(required: &HashMap<String, bool>, active: &HashMap<String, bool>) -> bool {
required.iter().all(|(key, value)| active.get(key).copied().unwrap_or(false) == *value)
}
/// Evaluates a Mojang-style rule list: no rules means always allowed;
/// otherwise the last matching rule (in order) decides, defaulting to
/// disallowed if nothing matched. `active_features` should list only the
/// optional launch features actually supported (today: none — no demo
/// mode, no custom resolution, no quick-play).
pub fn rule_allows(rules: &[Rule], active_features: &HashMap<String, bool>) -> bool {
if rules.is_empty() {
return true;
}
let mut allowed = false;
for rule in rules {
let os_ok = rule.os.as_ref().is_none_or(os_matches);
let features_ok = rule.features.as_ref().is_none_or(|required| features_match(required, active_features));
if os_ok && features_ok {
allowed = rule.action == RuleAction::Allow;
}
}
allowed
}
/// Flattens an argument list into plain strings, dropping conditional
/// entries whose rules don't match this platform/feature set.
pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap<String, bool>) -> Vec<String> {
let mut resolved = Vec::new();
for argument in arguments {
match argument {
ArgumentValue::Plain(value) => resolved.push(value.clone()),
ArgumentValue::Conditional { rules, value } => {
if rule_allows(rules, active_features) {
match value {
StringOrList::One(value) => resolved.push(value.clone()),
StringOrList::Many(values) => resolved.extend(values.iter().cloned()),
}
}
}
}
}
resolved
}
pub fn fetch_version_json(client: &Client, entry: &VersionManifestEntry) -> Result<VersionJson, MojangError> {
fetch_json(client, &entry.url, Some(&entry.sha1))
}
fn fetch_json<T: DeserializeOwned>(client: &Client, url: &str, expected_sha1: Option<&str>) -> Result<T, MojangError> {
if !is_allowed_host(url) {
return Err(MojangError::DisallowedHost(url.to_string()));
}
let response = client.get(url).send().map_err(MojangError::Network)?;
if !response.status().is_success() {
return Err(MojangError::HttpStatus(response.status()));
}
let bytes = response.bytes().map_err(MojangError::Network)?;
if let Some(expected) = expected_sha1 {
let mut hasher = Sha1::new();
hasher.update(&bytes);
let actual = format!("{:x}", hasher.finalize());
if !actual.eq_ignore_ascii_case(expected) {
return Err(MojangError::ChecksumMismatch(url.to_string()));
}
}
serde_json::from_slice(&bytes).map_err(MojangError::InvalidJson)
}
// ---------------------------------------------------------------------
// inheritsFrom merge
// ---------------------------------------------------------------------
pub struct MergedVersion {
/// The version being launched: the child's id when there is a loader
/// (e.g. `neoforge-21.1.248`), otherwise the parent's. Used for
/// `${version_name}` and as the name of the `versions/<id>/` directory
/// logs and natives live under.
pub id: String,
/// The id whose `<id>.jar` actually exists on disk and belongs on the
/// classpath — always the vanilla parent's, confirmed empirically: a
/// NeoForge profile has no `versions/neoforge-<ver>/neoforge-<ver>.jar`
/// of its own (FancyModLoader loads the patched client itself, see
/// `neoforge.rs`), so callers must use this id, not `id` above, to find
/// the client jar (`client_jar_path`).
pub client_jar_version_id: String,
pub main_class: String,
pub game_arguments: Vec<ArgumentValue>,
pub jvm_arguments: Vec<ArgumentValue>,
pub libraries: Vec<Library>,
pub asset_index: AssetIndexRef,
pub client: DownloadRef,
}
/// Merges a child version JSON (e.g. NeoForge's) onto its vanilla parent
/// the same way the official Minecraft Launcher merges any `inheritsFrom`
/// profile: the child's `mainClass` wins, its arguments are appended after
/// the parent's, and its libraries are appended after the parent's.
/// `assetIndex`/`downloads.client` always come from the parent, since
/// modloader profiles don't redeclare them.
pub fn merge_versions(parent: &VersionJson, child: Option<&VersionJson>) -> Result<MergedVersion, MojangError> {
let asset_index = parent.asset_index.clone().ok_or_else(|| MojangError::MissingField("assetIndex".into()))?;
let client = parent
.downloads
.as_ref()
.map(|downloads| downloads.client.clone())
.ok_or_else(|| MojangError::MissingField("downloads.client".into()))?;
let parent_arguments = parent.arguments.clone().unwrap_or_default();
let Some(child) = child else {
return Ok(MergedVersion {
id: parent.id.clone(),
client_jar_version_id: parent.id.clone(),
main_class: parent.main_class.clone(),
game_arguments: parent_arguments.game,
jvm_arguments: parent_arguments.jvm,
libraries: parent.libraries.clone(),
asset_index,
client,
});
};
let child_arguments = child.arguments.clone().unwrap_or_default();
let mut game_arguments = parent_arguments.game;
game_arguments.extend(child_arguments.game);
let mut jvm_arguments = parent_arguments.jvm;
jvm_arguments.extend(child_arguments.jvm);
let mut libraries = parent.libraries.clone();
libraries.extend(child.libraries.clone());
Ok(MergedVersion {
id: child.id.clone(),
client_jar_version_id: parent.id.clone(),
main_class: child.main_class.clone(),
game_arguments,
jvm_arguments,
libraries,
asset_index,
client,
})
}
// ---------------------------------------------------------------------
// Downloading
// ---------------------------------------------------------------------
pub use crate::download::ProgressCallback;
pub fn natives_directory(game_dir: &Path, version_id: &str) -> PathBuf {
game_dir.join("versions").join(version_id).join("natives")
}
pub fn client_jar_path(game_dir: &Path, version_id: &str) -> PathBuf {
game_dir.join("versions").join(version_id).join(format!("{version_id}.jar"))
}
pub fn ensure_client_jar(client: &Client, game_dir: &Path, version_id: &str, download_ref: &DownloadRef) -> Result<PathBuf, MojangError> {
if !is_allowed_host(&download_ref.url) {
return Err(MojangError::DisallowedHost(download_ref.url.clone()));
}
let target = client_jar_path(game_dir, version_id);
let checksum = Checksum::Sha1(download_ref.sha1.clone());
if !download::is_current(&target, Some(download_ref.size), &checksum)? {
download::download_verified(client, &download_ref.url, &target, Some(download_ref.size), &checksum, |_, _| {})?;
}
Ok(target)
}
/// Downloads every rule-allowed library with a `downloads.artifact`,
/// returning the resulting jar paths in the same order as `libraries`.
pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library], on_progress: &ProgressCallback) -> Result<Vec<PathBuf>, MojangError> {
let mut paths = Vec::new();
let mut tasks = Vec::new();
for library in libraries {
if !rule_allows(&library.rules, &HashMap::new()) {
continue;
}
let Some(artifact) = library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()) else {
continue;
};
let target = game_dir.join("libraries").join(&artifact.path);
paths.push(target.clone());
tasks.push(DownloadTask {
url: artifact.url.clone(),
target,
size: artifact.size,
checksum: Checksum::Sha1(artifact.sha1.clone()),
});
}
download_many(client, tasks, on_progress)?;
Ok(paths)
}
#[derive(Debug, Deserialize)]
pub struct AssetIndex {
pub objects: HashMap<String, AssetObject>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AssetObject {
pub hash: String,
pub size: u64,
}
pub fn ensure_asset_index(client: &Client, game_dir: &Path, asset_index: &AssetIndexRef) -> Result<AssetIndex, MojangError> {
if !is_allowed_host(&asset_index.url) {
return Err(MojangError::DisallowedHost(asset_index.url.clone()));
}
let target = game_dir.join("assets").join("indexes").join(format!("{}.json", asset_index.id));
let checksum = Checksum::Sha1(asset_index.sha1.clone());
if !download::is_current(&target, Some(asset_index.size), &checksum)? {
download::download_verified(client, &asset_index.url, &target, Some(asset_index.size), &checksum, |_, _| {})?;
}
let bytes = fs::read(&target)?;
serde_json::from_slice(&bytes).map_err(MojangError::InvalidJson)
}
pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_progress: &ProgressCallback) -> Result<(), MojangError> {
let objects_dir = game_dir.join("assets").join("objects");
let tasks = index
.objects
.values()
.map(|object| {
let prefix = &object.hash[0..2];
DownloadTask {
url: format!("https://resources.download.minecraft.net/{prefix}/{}", object.hash),
target: objects_dir.join(prefix).join(&object.hash),
size: object.size,
checksum: Checksum::Sha1(object.hash.clone()),
}
})
.collect();
download_many(client, tasks, on_progress)
}
struct DownloadTask {
url: String,
target: PathBuf,
size: u64,
checksum: Checksum,
}
const MAX_DOWNLOAD_ATTEMPTS: u32 = 5;
/// Retries a single file a few times with a short backoff before giving up.
/// With tens of thousands of individual requests in `ensure_assets`, an
/// occasional transient failure (reset connection, one bad TLS record) is
/// expected network noise, not a reason to abort the whole install — this
/// is exactly what real launchers do at this scale.
fn download_with_retries(client: &Client, task: &DownloadTask) -> Result<u64, DownloadError> {
let mut last_error = None;
for attempt in 1..=MAX_DOWNLOAD_ATTEMPTS {
match download::download_verified(client, &task.url, &task.target, Some(task.size), &task.checksum, |_, _| {}) {
Ok(bytes) => return Ok(bytes),
Err(error) => {
last_error = Some(error);
if attempt < MAX_DOWNLOAD_ATTEMPTS {
std::thread::sleep(std::time::Duration::from_millis(200 * attempt as u64));
}
}
}
}
Err(last_error.expect("loop runs at least once"))
}
/// Downloads `tasks` using a small worker pool, calling `on_progress` with
/// cumulative (downloaded, total) bytes as each file completes. Stops
/// spawning new work once the first error is seen and returns it.
fn download_many(client: &Client, tasks: Vec<DownloadTask>, on_progress: &ProgressCallback) -> Result<(), MojangError> {
let total: u64 = tasks.iter().map(|task| task.size).sum();
if total == 0 {
return Ok(());
}
let downloaded = AtomicU64::new(0);
let queue = Mutex::new(tasks);
let first_error: Mutex<Option<MojangError>> = Mutex::new(None);
std::thread::scope(|scope| {
for _ in 0..ASSET_WORKERS {
let queue = &queue;
let downloaded = &downloaded;
let first_error = &first_error;
let on_progress = Arc::clone(on_progress);
scope.spawn(move || loop {
if first_error.lock().unwrap().is_some() {
break;
}
let Some(task) = queue.lock().unwrap().pop() else { break };
if !is_allowed_host(&task.url) {
*first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url));
continue;
}
let already_current = download::is_current(&task.target, Some(task.size), &task.checksum).unwrap_or(false);
if !already_current {
if let Err(error) = download_with_retries(client, &task) {
*first_error.lock().unwrap() = Some(MojangError::Download(error));
continue;
}
}
let done = downloaded.fetch_add(task.size, Ordering::SeqCst) + task.size;
on_progress(done, total);
});
}
});
match first_error.into_inner().unwrap() {
Some(error) => Err(error),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(action: RuleAction, os_name: Option<&str>) -> Rule {
Rule {
action,
os: os_name.map(|name| RuleOs { name: Some(name.into()), arch: None }),
features: None,
}
}
#[test]
fn empty_rules_always_allow() {
assert!(rule_allows(&[], &HashMap::new()));
}
#[test]
fn single_matching_os_rule_allows() {
let rules = vec![rule(RuleAction::Allow, Some(current_os_name()))];
assert!(rule_allows(&rules, &HashMap::new()));
}
#[test]
fn non_matching_os_rule_disallows() {
let other = if current_os_name() == "windows" { "linux" } else { "windows" };
let rules = vec![rule(RuleAction::Allow, Some(other))];
assert!(!rule_allows(&rules, &HashMap::new()));
}
#[test]
fn unsupported_feature_is_excluded_by_default() {
let mut features = HashMap::new();
features.insert("is_demo_user".to_string(), true);
let rules = vec![Rule { action: RuleAction::Allow, os: None, features: Some(features) }];
// We never activate optional features, so a rule requiring one
// must not match even though there's no OS constraint.
assert!(!rule_allows(&rules, &HashMap::new()));
}
#[test]
fn resolves_plain_and_conditional_arguments() {
let args = vec![
ArgumentValue::Plain("--username".into()),
ArgumentValue::Plain("${auth_player_name}".into()),
ArgumentValue::Conditional {
rules: vec![rule(RuleAction::Allow, Some(current_os_name()))],
value: StringOrList::Many(vec!["--this-os-only".into()]),
},
ArgumentValue::Conditional {
rules: vec![rule(RuleAction::Allow, Some("nonexistent-os"))],
value: StringOrList::One("--never".into()),
},
];
let resolved = resolve_arguments(&args, &HashMap::new());
assert_eq!(resolved, vec!["--username", "${auth_player_name}", "--this-os-only"]);
}
#[test]
fn merge_appends_child_after_parent() {
let parent: VersionJson = serde_json::from_str(
r#"{
"id": "1.21.1",
"mainClass": "net.minecraft.client.main.Main",
"arguments": {"game": ["--parentGame"], "jvm": ["--parentJvm"]},
"assetIndex": {"id": "17", "sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1, "url": "https://piston-meta.mojang.com/x"},
"downloads": {"client": {"sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "size": 2, "url": "https://piston-data.mojang.com/x"}},
"libraries": [{"name": "parent:lib:1"}]
}"#,
)
.unwrap();
let child: VersionJson = serde_json::from_str(
r#"{
"id": "neoforge-21.1.248",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"inheritsFrom": "1.21.1",
"arguments": {"game": ["--childGame"], "jvm": ["--childJvm"]},
"libraries": [{"name": "child:lib:1"}]
}"#,
)
.unwrap();
let merged = merge_versions(&parent, Some(&child)).unwrap();
assert_eq!(merged.id, "neoforge-21.1.248");
assert_eq!(merged.client_jar_version_id, "1.21.1");
assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher");
assert_eq!(resolve_arguments(&merged.game_arguments, &HashMap::new()), vec!["--parentGame", "--childGame"]);
assert_eq!(resolve_arguments(&merged.jvm_arguments, &HashMap::new()), vec!["--parentJvm", "--childJvm"]);
assert_eq!(merged.libraries.iter().map(|library| library.name.as_str()).collect::<Vec<_>>(), vec!["parent:lib:1", "child:lib:1"]);
assert_eq!(merged.asset_index.id, "17");
}
#[test]
fn disallowed_host_is_rejected() {
assert!(!is_allowed_host("https://example.com/evil.jar"));
assert!(is_allowed_host("https://piston-data.mojang.com/v1/objects/x/client.jar"));
}
/// Live smoke test against the real Mojang CDN: manifest -> version JSON
/// (SHA-1 verified) -> asset index -> a handful of libraries + the
/// client jar. Not run by default (`cargo test`); run explicitly with
/// `cargo test -- --ignored mojang::` when checking real connectivity.
#[test]
#[ignore]
fn live_fetches_1_21_1_and_downloads_a_few_files() {
let client = Client::builder().build().unwrap();
let manifest = fetch_version_manifest(&client).unwrap();
let entry = find_version(&manifest, "1.21.1").expect("1.21.1 must be listed");
let version = fetch_version_json(&client, entry).unwrap();
assert_eq!(version.main_class, "net.minecraft.client.main.Main");
let game_dir = std::env::temp_dir().join(format!("shacraft-mojang-live-{}", std::process::id()));
let merged = merge_versions(&version, None).unwrap();
let client_jar = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
assert!(client_jar.exists());
let asset_index = ensure_asset_index(&client, &game_dir, &merged.asset_index).unwrap();
assert!(!asset_index.objects.is_empty());
let mut small_libraries: Vec<Library> = merged
.libraries
.iter()
.filter(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()).is_some_and(|artifact| artifact.size < 200_000))
.take(5)
.cloned()
.collect();
assert!(!small_libraries.is_empty(), "expected at least one small library to sanity-check downloads with");
small_libraries.truncate(5);
let progress: ProgressCallback = Arc::new(|_, _| {});
let paths = ensure_libraries(&client, &game_dir, &small_libraries, &progress).unwrap();
for path in &paths {
assert!(path.exists(), "{path:?} should have been downloaded");
}
// Re-running against already-downloaded files must be a no-op (the
// `is_current` fast path), not re-download or fail.
let client_jar_again = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
assert_eq!(client_jar, client_jar_again);
fs::remove_dir_all(&game_dir).ok();
}
}
+479
View File
@@ -0,0 +1,479 @@
//! Real Microsoft account login: device-code OAuth -> Xbox Live -> XSTS ->
//! Minecraft Services -> game-ownership check. This is what makes the
//! launcher only playable by people who actually own Minecraft Java
//! Edition; nothing here is optional or bypassable by a manifest.
//!
//! CORRECTION (2026-09-06): an earlier version of this module assumed a
//! public, no-registration-needed client ID existed for this flow. That
//! was wrong — verified live against `login.microsoftonline.com`, which
//! rejects it (`AADSTS700016`, app not found). Microsoft requires every
//! app to have its own Azure AD (Entra ID) "public client" registration
//! (no client secret needed for the device code grant — see
//! <https://aka.ms/AppRegistrations>), *and* new registrations must be
//! separately approved for Minecraft/Xbox API access via
//! <https://aka.ms/mce-reviewappid> before Xbox Live/Minecraft Services
//! will accept their tokens. `MSA_CLIENT_ID` below is a placeholder until
//! ShaCraft completes that registration; `start_device_code` refuses to
//! run while it's still the placeholder rather than fail confusingly
//! against Microsoft. Every endpoint below is otherwise a hardcoded HTTPS
//! constant, matching the trust-domain pattern used for Mojang/NeoForge
//! elsewhere in this crate — only the client ID is deployment-specific.
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use std::{
fmt, fs, io,
path::Path,
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
/// ShaCraft's own Azure AD application (client) ID, registered as a public
/// client with device-code flow allowed and approved for Minecraft API
/// access. Replace this before shipping login — see the module doc above.
const MSA_CLIENT_ID: &str = "00000000-0000-0000-0000-000000000000";
fn client_id_is_configured() -> bool {
MSA_CLIENT_ID != "00000000-0000-0000-0000-000000000000"
}
const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
const XBOX_USER_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
const XSTS_AUTHORIZE_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
const MINECRAFT_LOGIN_URL: &str = "https://api.minecraftservices.com/authentication/login_with_xbox";
const MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
const ACCOUNT_FILE: &str = "account.json";
#[derive(Debug)]
pub enum MsaError {
NotConfigured,
Network(reqwest::Error),
HttpStatus(reqwest::StatusCode),
AuthorizationDeclined,
AuthorizationExpired,
NoXboxAccount,
DoesNotOwnMinecraft,
Io(io::Error),
UnexpectedResponse(String),
}
impl fmt::Display for MsaError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotConfigured => formatter.write_str(
"ShaCraft has not configured Microsoft login yet (MSA_CLIENT_ID is a placeholder) — \
register an Azure AD app at https://aka.ms/AppRegistrations and get it approved for \
the Minecraft API at https://aka.ms/mce-reviewappid, then set MSA_CLIENT_ID in msa.rs",
),
Self::Network(error) => write!(formatter, "network error: {error}"),
Self::HttpStatus(status) => write!(formatter, "unexpected response: {status}"),
Self::AuthorizationDeclined => formatter.write_str("Login was declined"),
Self::AuthorizationExpired => formatter.write_str("Login code expired before it was used"),
Self::NoXboxAccount => formatter.write_str("This Microsoft account has no Xbox profile"),
Self::DoesNotOwnMinecraft => formatter.write_str("This Microsoft account does not own Minecraft: Java Edition"),
Self::Io(error) => write!(formatter, "I/O error: {error}"),
Self::UnexpectedResponse(message) => write!(formatter, "unexpected response: {message}"),
}
}
}
impl From<io::Error> for MsaError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
// ---------------------------------------------------------------------
// Device code flow
// ---------------------------------------------------------------------
pub struct DeviceCodeStart {
pub verification_uri: String,
pub user_code: String,
pub expires_in_seconds: u64,
device_code: String,
interval_seconds: u64,
}
#[derive(Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
expires_in: u64,
interval: u64,
}
pub fn start_device_code(client: &Client) -> Result<DeviceCodeStart, MsaError> {
if !client_id_is_configured() {
return Err(MsaError::NotConfigured);
}
let response = client
.post(DEVICE_CODE_URL)
.form(&[("client_id", MSA_CLIENT_ID), ("scope", "XboxLive.signin offline_access")])
.send()
.map_err(MsaError::Network)?;
if !response.status().is_success() {
return Err(MsaError::HttpStatus(response.status()));
}
let body: DeviceCodeResponse = response.json().map_err(MsaError::Network)?;
Ok(DeviceCodeStart {
verification_uri: body.verification_uri,
user_code: body.user_code,
expires_in_seconds: body.expires_in,
device_code: body.device_code,
interval_seconds: body.interval.max(5),
})
}
pub struct MicrosoftTokens {
pub access_token: String,
pub refresh_token: String,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
error: Option<String>,
}
/// Blocks, polling on `start.interval_seconds`, until the user finishes
/// signing in at `start.verification_uri`, the code expires, or they
/// decline. This is the slow step in the whole login flow — the caller
/// should already have shown `verification_uri`/`user_code` to the user
/// before calling this (see `start_device_code`).
pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<MicrosoftTokens, MsaError> {
let deadline = Instant::now() + Duration::from_secs(start.expires_in_seconds);
let mut interval = Duration::from_secs(start.interval_seconds);
loop {
if Instant::now() >= deadline {
return Err(MsaError::AuthorizationExpired);
}
thread::sleep(interval);
let response = client
.post(TOKEN_URL)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("client_id", MSA_CLIENT_ID),
("device_code", &start.device_code),
])
.send()
.map_err(MsaError::Network)?;
let status = response.status();
let body: TokenResponse = response.json().map_err(MsaError::Network)?;
if status.is_success() {
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else {
return Err(MsaError::UnexpectedResponse("token response missing access_token/refresh_token".into()));
};
return Ok(MicrosoftTokens { access_token, refresh_token });
}
match body.error.as_deref() {
Some("authorization_pending") => continue,
Some("slow_down") => {
interval += Duration::from_secs(5);
continue;
}
Some("authorization_declined") => return Err(MsaError::AuthorizationDeclined),
Some("expired_token") => return Err(MsaError::AuthorizationExpired),
other => return Err(MsaError::UnexpectedResponse(other.unwrap_or("unknown device code error").into())),
}
}
}
pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result<MicrosoftTokens, MsaError> {
if !client_id_is_configured() {
return Err(MsaError::NotConfigured);
}
let response = client
.post(TOKEN_URL)
.form(&[
("grant_type", "refresh_token"),
("client_id", MSA_CLIENT_ID),
("refresh_token", refresh_token),
("scope", "XboxLive.signin offline_access"),
])
.send()
.map_err(MsaError::Network)?;
if !response.status().is_success() {
return Err(MsaError::HttpStatus(response.status()));
}
let body: TokenResponse = response.json().map_err(MsaError::Network)?;
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else {
return Err(MsaError::UnexpectedResponse("refresh response missing access_token/refresh_token".into()));
};
Ok(MicrosoftTokens { access_token, refresh_token })
}
// ---------------------------------------------------------------------
// Xbox Live -> XSTS -> Minecraft Services
// ---------------------------------------------------------------------
#[derive(Serialize)]
struct XboxUserAuthRequest<'a> {
#[serde(rename = "Properties")]
properties: XboxUserAuthProperties<'a>,
#[serde(rename = "RelyingParty")]
relying_party: &'a str,
#[serde(rename = "TokenType")]
token_type: &'a str,
}
#[derive(Serialize)]
struct XboxUserAuthProperties<'a> {
#[serde(rename = "AuthMethod")]
auth_method: &'a str,
#[serde(rename = "SiteName")]
site_name: &'a str,
#[serde(rename = "RpsTicket")]
rps_ticket: String,
}
#[derive(Serialize)]
struct XstsRequest<'a> {
#[serde(rename = "Properties")]
properties: XstsProperties<'a>,
#[serde(rename = "RelyingParty")]
relying_party: &'a str,
#[serde(rename = "TokenType")]
token_type: &'a str,
}
#[derive(Serialize)]
struct XstsProperties<'a> {
#[serde(rename = "SandboxId")]
sandbox_id: &'a str,
#[serde(rename = "UserTokens")]
user_tokens: [&'a str; 1],
}
#[derive(Deserialize)]
struct XboxTokenResponse {
#[serde(rename = "Token")]
token: String,
#[serde(rename = "DisplayClaims")]
display_claims: XboxDisplayClaims,
}
#[derive(Deserialize)]
struct XboxDisplayClaims {
xui: Vec<XboxUserHash>,
}
#[derive(Deserialize)]
struct XboxUserHash {
uhs: String,
/// Xbox User ID, used for the game's `${auth_xuid}` launch argument.
/// Absent for some account states; not required to play.
#[serde(default)]
xid: Option<String>,
}
fn xbox_live_user_token(client: &Client, microsoft_access_token: &str) -> Result<(String, String), MsaError> {
let request = XboxUserAuthRequest {
properties: XboxUserAuthProperties {
auth_method: "RPS",
site_name: "user.auth.xboxlive.com",
rps_ticket: format!("d={microsoft_access_token}"),
},
relying_party: "http://auth.xboxlive.com",
token_type: "JWT",
};
let response = client.post(XBOX_USER_AUTH_URL).json(&request).send().map_err(MsaError::Network)?;
if !response.status().is_success() {
return Err(MsaError::HttpStatus(response.status()));
}
let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?;
let uhs = body.display_claims.xui.into_iter().next().map(|claim| claim.uhs).ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
Ok((body.token, uhs))
}
fn xsts_authorize(client: &Client, xbox_live_token: &str) -> Result<(String, String, Option<String>), MsaError> {
let request = XstsRequest {
properties: XstsProperties { sandbox_id: "RETAIL", user_tokens: [xbox_live_token] },
relying_party: "rp://api.minecraftservices.com/",
token_type: "JWT",
};
let response = client.post(XSTS_AUTHORIZE_URL).json(&request).send().map_err(MsaError::Network)?;
let status = response.status();
if status.as_u16() == 401 {
// XErr 2148916233 means the account has no Xbox profile at all
// (common for brand-new Microsoft accounts); other 401 causes
// (family/child accounts, regional restrictions) surface the same
// way for now, kept as one clear error rather than guessing.
return Err(MsaError::NoXboxAccount);
}
if !status.is_success() {
return Err(MsaError::HttpStatus(status));
}
let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?;
let claim = body.display_claims.xui.into_iter().next().ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
Ok((body.token, claim.uhs, claim.xid))
}
#[derive(Serialize)]
struct MinecraftLoginRequest {
#[serde(rename = "identityToken")]
identity_token: String,
}
#[derive(Deserialize)]
struct MinecraftLoginResponse {
access_token: String,
}
fn minecraft_login(client: &Client, user_hash: &str, xsts_token: &str) -> Result<String, MsaError> {
let request = MinecraftLoginRequest { identity_token: format!("XBL3.0 x={user_hash};{xsts_token}") };
let response = client.post(MINECRAFT_LOGIN_URL).json(&request).send().map_err(MsaError::Network)?;
if !response.status().is_success() {
return Err(MsaError::HttpStatus(response.status()));
}
let body: MinecraftLoginResponse = response.json().map_err(MsaError::Network)?;
Ok(body.access_token)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftProfile {
pub id: String,
pub name: String,
}
/// Confirms game ownership. A 404 here means the account has no Java
/// Edition profile — i.e. doesn't own the game — and nothing should
/// install or launch.
fn fetch_minecraft_profile(client: &Client, minecraft_access_token: &str) -> Result<MinecraftProfile, MsaError> {
let response = client
.get(MINECRAFT_PROFILE_URL)
.bearer_auth(minecraft_access_token)
.send()
.map_err(MsaError::Network)?;
if response.status().as_u16() == 404 {
return Err(MsaError::DoesNotOwnMinecraft);
}
if !response.status().is_success() {
return Err(MsaError::HttpStatus(response.status()));
}
response.json().map_err(MsaError::Network)
}
pub struct LoginResult {
pub minecraft_access_token: String,
pub profile: MinecraftProfile,
pub refresh_token: String,
/// Xbox User ID for the `${auth_xuid}` launch argument. Not every
/// account state returns one; the game works fine with an empty value.
pub xuid: Option<String>,
}
fn complete_login(client: &Client, tokens: MicrosoftTokens) -> Result<LoginResult, MsaError> {
let (xbox_live_token, _uhs) = xbox_live_user_token(client, &tokens.access_token)?;
let (xsts_token, user_hash, xuid) = xsts_authorize(client, &xbox_live_token)?;
let minecraft_access_token = minecraft_login(client, &user_hash, &xsts_token)?;
let profile = fetch_minecraft_profile(client, &minecraft_access_token)?;
Ok(LoginResult { minecraft_access_token, profile, refresh_token: tokens.refresh_token, xuid })
}
pub fn login_with_device_code(client: &Client, start: &DeviceCodeStart) -> Result<LoginResult, MsaError> {
let tokens = poll_device_code(client, start)?;
complete_login(client, tokens)
}
pub fn login_with_refresh_token(client: &Client, refresh_token: &str) -> Result<LoginResult, MsaError> {
let tokens = refresh_microsoft_tokens(client, refresh_token)?;
complete_login(client, tokens)
}
// ---------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------
#[derive(Serialize, Deserialize)]
struct StoredAccount {
refresh_token: String,
saved_at_unix: u64,
}
pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()> {
fs::create_dir_all(data_dir)?;
let saved_at_unix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let contents = serde_json::to_vec_pretty(&StoredAccount { refresh_token: refresh_token.to_string(), saved_at_unix }).expect("StoredAccount is serializable");
let target = data_dir.join(ACCOUNT_FILE);
let temporary = data_dir.join(".account.json.shacraft.part");
fs::write(&temporary, contents)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
}
fs::rename(temporary, target)
}
pub fn load_refresh_token(data_dir: &Path) -> Option<String> {
let contents = fs::read_to_string(data_dir.join(ACCOUNT_FILE)).ok()?;
let account: StoredAccount = serde_json::from_str(&contents).ok()?;
Some(account.refresh_token)
}
pub fn clear_account(data_dir: &Path) -> io::Result<()> {
match fs::remove_file(data_dir.join(ACCOUNT_FILE)) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_stored_refresh_token() {
let dir = std::env::temp_dir().join(format!("shacraft-msa-test-{}", std::process::id()));
assert!(load_refresh_token(&dir).is_none());
save_refresh_token(&dir, "super-secret-refresh-token").unwrap();
assert_eq!(load_refresh_token(&dir).as_deref(), Some("super-secret-refresh-token"));
clear_account(&dir).unwrap();
assert!(load_refresh_token(&dir).is_none());
fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn stored_account_file_is_not_world_or_group_readable() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("shacraft-msa-perm-test-{}", std::process::id()));
save_refresh_token(&dir, "secret").unwrap();
let mode = fs::metadata(dir.join(ACCOUNT_FILE)).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn refuses_to_run_with_placeholder_client_id() {
assert!(!client_id_is_configured());
let client = Client::builder().build().unwrap();
assert!(matches!(start_device_code(&client), Err(MsaError::NotConfigured)));
}
/// Live smoke test: requests a real device code from Microsoft and
/// checks the shape of the response. Does not (and cannot, without a
/// human) complete the actual sign-in. Needs `MSA_CLIENT_ID` set to a
/// real, approved Azure app id first — see the module doc comment.
/// Run with `cargo test -- --ignored live_requests_device_code`.
#[test]
#[ignore]
fn live_requests_device_code() {
let client = Client::builder().build().unwrap();
let start = start_device_code(&client).unwrap();
assert!(!start.user_code.is_empty());
assert!(start.verification_uri.starts_with("https://"));
assert!(start.expires_in_seconds > 0);
}
}
+394
View File
@@ -0,0 +1,394 @@
//! NeoForge trust boundary: downloads the official installer for
//! `manifest.minecraft.loader.version` from `maven.neoforged.net` and runs
//! it headlessly to produce a standard, vanilla-launcher-compatible version
//! profile under the shared game directory.
//!
//! We deliberately do not reimplement the installer's client processor
//! pipeline (mapping extraction, jar splitting, renaming, binary patching):
//! running NeoForge's own official installer jar is far less code, matches
//! exactly what a human running the installer manually would get, and
//! survives future NeoForge releases changing their processor format.
//!
//! Empirically verified (2026-09-06, against the real
//! neoforge-21.1.248-installer.jar and a real Temurin 21 JRE): the
//! installer's `net.minecraftforge.installer.SimpleInstaller` refuses to
//! target a directory that doesn't already look like a `.minecraft` folder
//! ("you need to run the launcher first!") unless a `launcher_profiles.json`
//! stub already exists there — see `ensure_launcher_profiles_stub`. After
//! that, `--installClient <dir>` downloads/patches everything itself and
//! writes a standard `versions/neoforge-<version>/neoforge-<version>.json`
//! that inherits from the vanilla version and needs no NeoForge-specific
//! classpath handling: `mojang::merge_versions` + the resulting libraries
//! list is everything `launch.rs` needs. The separately-produced
//! `libraries/net/neoforged/neoforge/<version>/neoforge-<version>-client.jar`
//! is loaded by FancyModLoader itself at runtime (via the `--fml.*` game
//! arguments already present on the merged profile) and is intentionally
//! never added to our own classpath.
use crate::download::{self, Checksum, DownloadError, ProgressCallback};
use crate::mojang::VersionJson;
use reqwest::blocking::Client;
use std::{
fmt, fs, io,
io::{BufRead, BufReader, Read},
path::{Path, PathBuf},
process::{Command, Stdio},
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
thread,
};
use url::Url;
const NEOFORGE_HOST: &str = "maven.neoforged.net";
/// Minimal `launcher_profiles.json` accepted by the legacy NeoForge/Forge
/// installer as proof that a directory is a legitimate launcher data
/// directory. Written once; never overwrites an existing file.
const LAUNCHER_PROFILES_STUB: &str = r#"{"profiles":{},"selectedProfile":"","clientToken":"","authenticationDatabase":{},"settings":{"enableSnapshots":false,"enableAdvanced":false,"keepLauncherOpen":false,"soundOn":false,"showGameLog":false,"profileSorting":"ByLastPlayed","showMenu":false,"enableHistorical":false,"enableReleases":true,"crashAssistance":true},"version":3}"#;
#[derive(Debug)]
pub enum NeoForgeError {
DisallowedHost(String),
Network(reqwest::Error),
HttpStatus(reqwest::StatusCode),
InvalidChecksum(String),
Download(DownloadError),
Io(io::Error),
InvalidJson(serde_json::Error),
InstallerFailed { exit_code: Option<i32>, output_tail: String },
}
impl fmt::Display for NeoForgeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised NeoForge host: {url}"),
Self::Network(error) => write!(formatter, "network error: {error}"),
Self::HttpStatus(status) => write!(formatter, "maven.neoforged.net returned {status}"),
Self::InvalidChecksum(text) => write!(formatter, "unexpected checksum response: {text}"),
Self::Download(error) => write!(formatter, "{error}"),
Self::Io(error) => write!(formatter, "I/O error: {error}"),
Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"),
Self::InstallerFailed { exit_code, output_tail } => {
write!(formatter, "NeoForge installer failed (exit {exit_code:?}):\n{output_tail}")
}
}
}
}
impl From<DownloadError> for NeoForgeError {
fn from(error: DownloadError) -> Self {
Self::Download(error)
}
}
impl From<io::Error> for NeoForgeError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
fn is_allowed_host(url: &str) -> bool {
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST)).unwrap_or(false)
}
fn installer_jar_url(loader_version: &str) -> String {
format!("https://{NEOFORGE_HOST}/releases/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar")
}
/// Downloads (or reuses a cached, still-valid) NeoForge installer jar,
/// verified against the `.sha256` sidecar Maven publishes next to every
/// artifact.
pub fn ensure_installer(client: &Client, cache_dir: &Path, loader_version: &str) -> Result<PathBuf, NeoForgeError> {
let jar_url = installer_jar_url(loader_version);
let checksum_url = format!("{jar_url}.sha256");
if !is_allowed_host(&jar_url) {
return Err(NeoForgeError::DisallowedHost(jar_url));
}
let response = client.get(&checksum_url).send().map_err(NeoForgeError::Network)?;
if !response.status().is_success() {
return Err(NeoForgeError::HttpStatus(response.status()));
}
let sha256 = response.text().map_err(NeoForgeError::Network)?.trim().to_ascii_lowercase();
if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(NeoForgeError::InvalidChecksum(sha256));
}
let target = cache_dir.join(format!("neoforge-{loader_version}-installer.jar"));
let checksum = Checksum::Sha256(sha256);
if !download::is_current(&target, None, &checksum)? {
download::download_verified(client, &jar_url, &target, None, &checksum, |_, _| {})?;
}
Ok(target)
}
fn ensure_launcher_profiles_stub(game_dir: &Path) -> io::Result<()> {
let path = game_dir.join("launcher_profiles.json");
if path.exists() {
return Ok(());
}
fs::create_dir_all(game_dir)?;
fs::write(path, LAUNCHER_PROFILES_STUB)
}
pub fn installed_version_json_path(game_dir: &Path, loader_version: &str) -> PathBuf {
game_dir
.join("versions")
.join(format!("neoforge-{loader_version}"))
.join(format!("neoforge-{loader_version}.json"))
}
/// The installer jar bundles its own `install_profile.json`, which lists
/// exactly which libraries it will download and which processors it will
/// run to patch the client — the same manifest the installer itself reads.
/// Reading it upfront gives a real, version-agnostic total for progress
/// reporting instead of a guessed constant.
fn read_install_profile_counts(installer_path: &Path) -> Option<(u64, u64)> {
let file = fs::File::open(installer_path).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let mut entry = archive.by_name("install_profile.json").ok()?;
let mut contents = String::new();
entry.read_to_string(&mut contents).ok()?;
let profile: serde_json::Value = serde_json::from_str(&contents).ok()?;
let libraries = profile.get("libraries")?.as_array()?.len() as u64;
let processors = profile.get("processors")?.as_array()?.len() as u64;
Some((libraries, processors))
}
/// Bumps `downloads_done`/`processors_done` from one line of the installer's
/// output and reports the combined total, clamped so a miscount (e.g. the
/// installer logging a couple of extra non-library downloads) never exceeds
/// or exceeds `total` by much. `total_libraries` caps the download half so
/// those extra lines cannot crowd out the processor half of the bar.
fn observe_installer_line(line: &str, downloads_done: &AtomicU64, processors_done: &AtomicU64, total_libraries: u64, total: u64, on_progress: &ProgressCallback) {
let trimmed = line.trim_start();
if trimmed.starts_with("Download completed") {
downloads_done.fetch_add(1, Ordering::Relaxed);
} else if trimmed.starts_with("Processor: ") && trimmed.matches(':').count() == 2 {
// Exactly two colons is the processor *header* line
// ("Processor: net.neoforged.installertools:jarsplitter"); its
// sub-step lines ("Processor: ...: Loading patch files") have three.
processors_done.fetch_add(1, Ordering::Relaxed);
} else {
return;
}
let current = downloads_done.load(Ordering::Relaxed).min(total_libraries) + processors_done.load(Ordering::Relaxed);
on_progress(current.min(total), total);
}
fn truncate_tail(text: &str) -> String {
text.chars().rev().take(4000).collect::<String>().chars().rev().collect()
}
/// Runs the installer with piped output, reporting live progress as its own
/// log lines confirm each library download and processor step, instead of
/// blocking silently until the whole (often minutes-long) run finishes.
/// Returns the process's exit code and its combined stdout+stderr, which the
/// caller uses to build a diagnostic if the install turns out to have failed
/// silently (exit 0 but no version JSON produced).
fn run_installer_with_progress(
java_executable: &Path,
installer_path: &Path,
game_dir: &Path,
cache_dir: &Path,
total_libraries: u64,
total: u64,
on_progress: &ProgressCallback,
) -> Result<(Option<i32>, String), NeoForgeError> {
let mut child = Command::new(java_executable)
.arg("-jar")
.arg(installer_path)
.arg("--installClient")
.arg(game_dir)
.current_dir(cache_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("stdout was piped");
let stderr = child.stderr.take().expect("stderr was piped");
let combined_log = Arc::new(Mutex::new(String::new()));
let downloads_done = Arc::new(AtomicU64::new(0));
let processors_done = Arc::new(AtomicU64::new(0));
let stdout_thread = {
let combined_log = Arc::clone(&combined_log);
let downloads_done = Arc::clone(&downloads_done);
let processors_done = Arc::clone(&processors_done);
let on_progress = Arc::clone(on_progress);
thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
observe_installer_line(&line, &downloads_done, &processors_done, total_libraries, total, &on_progress);
let mut log = combined_log.lock().unwrap();
log.push_str(&line);
log.push('\n');
}
})
};
let stderr_thread = {
let combined_log = Arc::clone(&combined_log);
thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
let mut log = combined_log.lock().unwrap();
log.push_str(&line);
log.push('\n');
}
})
};
let status = child.wait()?;
stdout_thread.join().ok();
stderr_thread.join().ok();
let tail = truncate_tail(&combined_log.lock().unwrap());
if !status.success() {
return Err(NeoForgeError::InstallerFailed { exit_code: status.code(), output_tail: tail });
}
Ok((status.code(), tail))
}
/// Ensures NeoForge `loader_version` is installed into the shared
/// `game_dir` (vanilla libraries/version must already be there so the
/// installer can reuse them). No-op if already installed. Runs the
/// installer headlessly with `java_executable`; its own network calls go
/// straight to `maven.neoforged.net`/Mojang, outside our control, which is
/// an accepted trust delegation to NeoForge's official tooling once the
/// installer binary itself is SHA-256 verified. `on_progress` reports real
/// progress (installer-confirmed library downloads plus patch-processor
/// steps, read from the installer's own `install_profile.json`) while it
/// runs; it fires once with `(1, 1)` when already installed.
pub fn ensure_client_installed(client: &Client, java_executable: &Path, game_dir: &Path, cache_dir: &Path, loader_version: &str, on_progress: &ProgressCallback) -> Result<VersionJson, NeoForgeError> {
let version_json_path = installed_version_json_path(game_dir, loader_version);
if !version_json_path.exists() {
ensure_launcher_profiles_stub(game_dir)?;
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
let (total_libraries, total_processors) = read_install_profile_counts(&installer_path).unwrap_or((0, 0));
let total = (total_libraries + total_processors).max(1);
on_progress(0, total);
let (exit_code, tail) = run_installer_with_progress(java_executable, &installer_path, game_dir, cache_dir, total_libraries, total, on_progress)?;
if !version_json_path.exists() {
return Err(NeoForgeError::InstallerFailed { exit_code, output_tail: tail });
}
on_progress(total, total);
} else {
on_progress(1, 1);
}
let bytes = fs::read(&version_json_path)?;
serde_json::from_slice(&bytes).map_err(NeoForgeError::InvalidJson)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn installer_url_matches_maven_layout() {
assert_eq!(
installer_jar_url("21.1.248"),
"https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-installer.jar"
);
}
#[test]
fn rejects_non_neoforge_hosts() {
assert!(!is_allowed_host("https://example.com/evil.jar"));
assert!(is_allowed_host("https://maven.neoforged.net/releases/x.jar"));
}
#[test]
fn launcher_profiles_stub_is_idempotent() {
let dir = std::env::temp_dir().join(format!("shacraft-neoforge-test-{}", std::process::id()));
ensure_launcher_profiles_stub(&dir).unwrap();
let first = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap();
fs::write(dir.join("launcher_profiles.json"), "custom-content").unwrap();
ensure_launcher_profiles_stub(&dir).unwrap();
let second = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap();
assert_eq!(second, "custom-content");
assert!(first.contains("\"profiles\""));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn observe_installer_line_counts_downloads_and_processor_headers() {
let downloads_done = AtomicU64::new(0);
let processors_done = AtomicU64::new(0);
let calls: Arc<Mutex<Vec<(u64, u64)>>> = Arc::new(Mutex::new(Vec::new()));
let on_progress: ProgressCallback = {
let calls = Arc::clone(&calls);
Arc::new(move |current, total| calls.lock().unwrap().push((current, total)))
};
let total_libraries = 2;
let total = 3; // 2 libraries + 1 processor
// A "Downloading library from ..." start line reports nothing by
// itself; only its "Download completed" confirmation counts.
observe_installer_line("Downloading library from https://example/a.jar", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line("Processor: net.neoforged.installertools:jarsplitter", &downloads_done, &processors_done, total_libraries, total, &on_progress);
// A processor's sub-step lines (three colons) must not double-count.
observe_installer_line("Processor: net.neoforged.installertools:jarsplitter: Loading patch files", &downloads_done, &processors_done, total_libraries, total, &on_progress);
assert_eq!(*calls.lock().unwrap(), vec![(1, 3), (2, 3), (3, 3)]);
}
/// Full live pipeline: provisions a real Java 21 (runtime.rs) if none
/// is already usable, then runs the real NeoForge 21.1.248 installer
/// into an empty game dir (it fetches and patches vanilla 1.21.1
/// itself — confirmed manually, no pre-seeding needed) and checks the
/// installed profile merges into a launch-shaped spec together with a
/// separately-fetched vanilla version JSON (mojang.rs), exactly as
/// `lib.rs`'s `ensure_game_installed` command will do it. Not run by
/// default; `cargo test -- --ignored live_full_pipeline`.
#[test]
#[ignore]
fn live_full_pipeline_installs_neoforge() {
use crate::{java, mojang};
let client = Client::builder().build().unwrap();
let root = std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
let game_dir = root.join("game");
let cache_dir = root.join("cache");
fs::create_dir_all(&cache_dir).unwrap();
let manifest = mojang::fetch_version_manifest(&client).unwrap();
let entry = mojang::find_version(&manifest, "1.21.1").unwrap();
let vanilla = mojang::fetch_version_json(&client, entry).unwrap();
let no_progress: ProgressCallback = Arc::new(|_, _| {});
let java_install = java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap();
// The installer fetches and patches vanilla itself; we don't
// pre-download it. It only needs a Java runtime and an empty dir.
let progress_calls: Arc<Mutex<Vec<(u64, u64)>>> = Arc::new(Mutex::new(Vec::new()));
let progress: ProgressCallback = {
let progress_calls = Arc::clone(&progress_calls);
Arc::new(move |current, total| progress_calls.lock().unwrap().push((current, total)))
};
let neoforge_version = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &progress).unwrap();
let merged = mojang::merge_versions(&vanilla, Some(&neoforge_version)).unwrap();
assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher");
assert!(merged.libraries.len() > 100, "expected vanilla (97) + neoforge (47) libraries, got {}", merged.libraries.len());
let patched_client = game_dir.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar");
assert!(patched_client.exists(), "FancyModLoader needs this at runtime even though it is not on the generic classpath");
let calls = progress_calls.lock().unwrap();
assert!(calls.len() > 5, "expected many incremental progress calls, got {}", calls.len());
let (last_current, last_total) = *calls.last().unwrap();
assert_eq!(last_current, last_total, "progress must reach 100% on success");
assert!(calls.windows(2).all(|pair| pair[0].0 <= pair[1].0), "reported progress must never go backwards");
drop(calls);
// Re-running must skip straight to reading the cached version JSON
// rather than invoking the installer again.
let neoforge_again = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &no_progress).unwrap();
assert_eq!(neoforge_again.libraries.len(), neoforge_version.libraries.len());
fs::remove_dir_all(&root).ok();
}
}
+18 -119
View File
@@ -1,8 +1,8 @@
use crate::download::{self, Checksum, DownloadError};
use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest};
use reqwest::{blocking::Client, redirect::Policy};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{fmt, fs::{self, File}, io::{self, Read, Write}, path::{Path, PathBuf}};
use std::{fmt, io, path::Path};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -27,9 +27,7 @@ pub struct SyncResult {
pub enum ProfileError {
Io(io::Error),
Network(reqwest::Error),
HttpStatus { path: String, status: reqwest::StatusCode },
InvalidResponse { path: String, message: String },
Integrity { path: String, message: String },
Download { path: String, source: DownloadError },
}
impl fmt::Display for ProfileError {
@@ -37,9 +35,7 @@ impl fmt::Display for ProfileError {
match self {
Self::Io(error) => write!(formatter, "Cannot inspect profile: {error}"),
Self::Network(error) => write!(formatter, "Cannot download profile file: {error}"),
Self::HttpStatus { path, status } => write!(formatter, "Download failed for {path}: server returned {status}"),
Self::InvalidResponse { path, message } => write!(formatter, "Invalid response for {path}: {message}"),
Self::Integrity { path, message } => write!(formatter, "Integrity check failed for {path}: {message}"),
Self::Download { path, source } => write!(formatter, "Download failed for {path}: {source}"),
}
}
}
@@ -50,20 +46,12 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, Pr
for expected in &manifest.files {
let path = root.join(&expected.path);
let metadata = match path.metadata() {
Ok(metadata) if metadata.is_file() => metadata,
Ok(_) => {
mismatched_files += 1;
continue;
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
missing_files += 1;
continue;
}
Err(error) => return Err(ProfileError::Io(error)),
};
if metadata.len() != expected.size || sha256(&path)? != expected.sha256.to_ascii_lowercase() {
if !path.exists() {
missing_files += 1;
continue;
}
let checksum = Checksum::Sha256(expected.sha256.clone());
if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
mismatched_files += 1;
}
}
@@ -99,12 +87,13 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
reused_files += 1;
continue;
}
if is_current(&target, expected)? {
let checksum = Checksum::Sha256(expected.sha256.clone());
if download::is_current(&target, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
reused_files += 1;
continue;
}
let bytes = download_file(&client, expected, &target)?;
let bytes = download_managed_file(&client, expected, &target)?;
downloaded_files += 1;
downloaded_bytes += bytes;
}
@@ -117,101 +106,11 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
})
}
fn is_current(path: &Path, expected: &ManagedFile) -> Result<bool, ProfileError> {
let metadata = match path.metadata() {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(ProfileError::Io(error)),
};
Ok(metadata.is_file() && metadata.len() == expected.size && sha256(path)? == expected.sha256.to_ascii_lowercase())
}
fn download_file(client: &Client, expected: &ManagedFile, target: &Path) -> Result<u64, ProfileError> {
let parent = target.parent().ok_or_else(|| ProfileError::Integrity {
path: expected.path.clone(),
message: "target has no parent directory".into(),
})?;
fs::create_dir_all(parent).map_err(ProfileError::Io)?;
let mut response = client.get(&expected.url).send().map_err(ProfileError::Network)?;
if !response.status().is_success() {
return Err(ProfileError::HttpStatus { path: expected.path.clone(), status: response.status() });
}
if let Some(length) = response.content_length() {
if length != expected.size {
return Err(ProfileError::InvalidResponse {
path: expected.path.clone(),
message: format!("expected {} bytes, received Content-Length {length}", expected.size),
});
}
}
let temporary = temp_path(target)?;
let result = write_and_verify(&mut response, &temporary, expected);
if let Err(error) = result {
let _ = fs::remove_file(&temporary);
return Err(error);
}
fs::rename(&temporary, target).map_err(ProfileError::Io)?;
Ok(expected.size)
}
fn temp_path(target: &Path) -> Result<PathBuf, ProfileError> {
let file_name = target.file_name().and_then(|name| name.to_str()).ok_or_else(|| ProfileError::Integrity {
path: target.display().to_string(),
message: "target has no valid filename".into(),
})?;
Ok(target.with_file_name(format!(".{file_name}.shacraft.part")))
}
fn write_and_verify(response: &mut reqwest::blocking::Response, temporary: &Path, expected: &ManagedFile) -> Result<(), ProfileError> {
let mut output = File::create(temporary).map_err(ProfileError::Io)?;
let mut digest = Sha256::new();
let mut bytes = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = response.read(&mut buffer).map_err(ProfileError::Io)?;
if read == 0 {
break;
}
output.write_all(&buffer[..read]).map_err(ProfileError::Io)?;
digest.update(&buffer[..read]);
bytes += read as u64;
}
output.sync_all().map_err(ProfileError::Io)?;
if bytes != expected.size {
return Err(ProfileError::Integrity {
path: expected.path.clone(),
message: format!("expected {} bytes, downloaded {bytes}", expected.size),
});
}
let actual = format!("{:x}", digest.finalize());
if actual != expected.sha256.to_ascii_lowercase() {
return Err(ProfileError::Integrity {
path: expected.path.clone(),
message: "SHA-256 does not match manifest".into(),
});
}
Ok(())
}
fn sha256(path: &Path) -> Result<String, ProfileError> {
let mut file = File::open(path).map_err(ProfileError::Io)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer).map_err(ProfileError::Io)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(format!("{:x}", digest.finalize()))
fn download_managed_file(client: &Client, expected: &ManagedFile, target: &Path) -> Result<u64, ProfileError> {
let checksum = Checksum::Sha256(expected.sha256.clone());
download::download_verified(client, &expected.url, target, Some(expected.size), &checksum, |_, _| {}).map_err(|error| {
ProfileError::Download { path: expected.path.clone(), source: error }
})
}
#[cfg(test)]
+234
View File
@@ -0,0 +1,234 @@
//! Java runtime auto-provisioning via Eclipse Adoptium (Temurin), GPLv2+CE.
//!
//! Independent, hardcoded trust domain: `api.adoptium.net` only. The API
//! returns the release's SHA-256 inline, which we verify before extracting
//! anything. The user's own system Java is never touched — see
//! `java.rs::ensure_java`, which only calls into this module when no
//! sufficiently new Java is already installed.
use crate::download::{self, Checksum, DownloadError, ProgressCallback};
use reqwest::blocking::Client;
use serde::Deserialize;
use std::{fmt, fs, io, path::{Path, PathBuf}};
const ADOPTIUM_HOST: &str = "api.adoptium.net";
#[derive(Debug)]
pub enum RuntimeError {
Network(reqwest::Error),
HttpStatus(reqwest::StatusCode),
NoRelease,
UnexpectedArchiveLayout,
Download(DownloadError),
Io(io::Error),
Zip(zip::result::ZipError),
}
impl fmt::Display for RuntimeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(error) => write!(formatter, "network error: {error}"),
Self::HttpStatus(status) => write!(formatter, "Adoptium returned {status}"),
Self::NoRelease => formatter.write_str("Adoptium has no matching JRE release for this platform"),
Self::UnexpectedArchiveLayout => formatter.write_str("Java archive did not contain a single top-level directory as expected"),
Self::Download(error) => write!(formatter, "{error}"),
Self::Io(error) => write!(formatter, "I/O error: {error}"),
Self::Zip(error) => write!(formatter, "zip error: {error}"),
}
}
}
impl From<reqwest::Error> for RuntimeError {
fn from(error: reqwest::Error) -> Self {
Self::Network(error)
}
}
impl From<DownloadError> for RuntimeError {
fn from(error: DownloadError) -> Self {
Self::Download(error)
}
}
impl From<io::Error> for RuntimeError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<zip::result::ZipError> for RuntimeError {
fn from(error: zip::result::ZipError) -> Self {
Self::Zip(error)
}
}
#[derive(Debug, Deserialize)]
struct AdoptiumAsset {
binary: AdoptiumBinary,
}
#[derive(Debug, Deserialize)]
struct AdoptiumBinary {
package: AdoptiumPackage,
}
#[derive(Debug, Deserialize)]
struct AdoptiumPackage {
link: String,
checksum: String,
name: String,
}
fn adoptium_os() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"mac"
} else {
"linux"
}
}
/// Adoptium's `architecture` query parameter values, which don't match
/// Rust's `std::env::consts::ARCH` strings 1:1 (notably `x86_64` -> `x64`).
fn adoptium_arch() -> Option<&'static str> {
match std::env::consts::ARCH {
"x86_64" => Some("x64"),
"aarch64" => Some("aarch64"),
_ => None,
}
}
fn java_executable_name() -> &'static str {
if cfg!(target_os = "windows") {
"java.exe"
} else {
"java"
}
}
pub fn java_path_in(runtime_dir: &Path) -> PathBuf {
let mac_bundle = runtime_dir.join("Contents/Home/bin").join(java_executable_name());
if mac_bundle.exists() {
return mac_bundle;
}
runtime_dir.join("bin").join(java_executable_name())
}
/// Ensures a Java `major` runtime is available under
/// `runtime_root/<major>-<os>-<arch>/`, downloading and extracting it from
/// Adoptium if it isn't already there. Returns the path to the `java`
/// executable. Never touches any Java the user already has installed
/// elsewhere. `on_progress` reports real download bytes (extraction is fast
/// enough afterwards not to need its own progress).
pub fn ensure_runtime(client: &Client, runtime_root: &Path, major: u8, on_progress: &ProgressCallback) -> Result<PathBuf, RuntimeError> {
let arch = adoptium_arch().ok_or(RuntimeError::NoRelease)?;
let runtime_dir = runtime_root.join(format!("{major}-{}-{arch}", adoptium_os()));
let marker = runtime_dir.join(".shacraft-complete");
let java_path = java_path_in(&runtime_dir);
if marker.exists() && java_path.exists() {
on_progress(1, 1);
return Ok(java_path);
}
let url = format!(
"https://{ADOPTIUM_HOST}/v3/assets/latest/{major}/hotspot?image_type=jre&os={}&architecture={arch}&vendor=eclipse",
adoptium_os()
);
let response = client.get(&url).send()?;
if !response.status().is_success() {
return Err(RuntimeError::HttpStatus(response.status()));
}
let assets: Vec<AdoptiumAsset> = response.json()?;
let package = assets.into_iter().next().map(|asset| asset.binary.package).ok_or(RuntimeError::NoRelease)?;
fs::create_dir_all(runtime_root)?;
let archive_path = runtime_root.join(&package.name);
let progress = on_progress.clone();
download::download_verified(client, &package.link, &archive_path, None, &Checksum::Sha256(package.checksum), move |current, total| {
progress(current, total.unwrap_or(current));
})?;
extract_single_root_archive(&archive_path, &runtime_dir)?;
fs::remove_file(&archive_path).ok();
fs::write(&marker, b"ok")?;
let java_path = java_path_in(&runtime_dir);
if !java_path.exists() {
return Err(RuntimeError::UnexpectedArchiveLayout);
}
Ok(java_path)
}
/// Extracts a `.tar.gz` or `.zip` archive that contains exactly one
/// top-level directory (true of every Adoptium release archive), and
/// renames that directory into place as `target_dir`.
fn extract_single_root_archive(archive_path: &Path, target_dir: &Path) -> Result<(), RuntimeError> {
let staging = target_dir.with_file_name(format!(
"{}.staging",
target_dir.file_name().and_then(|name| name.to_str()).unwrap_or("runtime")
));
if staging.exists() {
fs::remove_dir_all(&staging)?;
}
fs::create_dir_all(&staging)?;
let is_zip = archive_path.extension().and_then(|extension| extension.to_str()) == Some("zip");
if is_zip {
let file = fs::File::open(archive_path)?;
let mut archive = zip::ZipArchive::new(file)?;
archive.extract(&staging)?;
} else {
let file = fs::File::open(archive_path)?;
let decompressed = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decompressed);
archive.unpack(&staging)?;
}
let mut entries = fs::read_dir(&staging)?.collect::<Result<Vec<_>, io::Error>>()?;
if entries.len() != 1 || !entries[0].file_type()?.is_dir() {
fs::remove_dir_all(&staging).ok();
return Err(RuntimeError::UnexpectedArchiveLayout);
}
let inner = entries.remove(0).path();
if target_dir.exists() {
fs::remove_dir_all(target_dir)?;
}
if let Some(parent) = target_dir.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(&inner, target_dir)?;
fs::remove_dir_all(&staging).ok();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn java_path_uses_platform_executable_name() {
let dir = Path::new("/tmp/example-runtime");
let path = java_path_in(dir);
assert!(path.ends_with(java_executable_name()));
}
/// Live smoke test: resolves the current platform's latest Temurin 21
/// JRE from Adoptium, downloads it, verifies the checksum, and extracts
/// it. Not run by default; `cargo test -- --ignored ensure_runtime`.
#[test]
#[ignore]
fn live_provisions_java_21() {
let client = Client::builder().build().unwrap();
let root = std::env::temp_dir().join(format!("shacraft-runtime-live-{}", std::process::id()));
let no_progress: ProgressCallback = std::sync::Arc::new(|_, _| {});
let java = ensure_runtime(&client, &root, 21, &no_progress).unwrap();
assert!(java.exists());
let output = std::process::Command::new(&java).arg("-version").output().unwrap();
assert!(output.status.success());
// Second call must hit the "already provisioned" fast path.
let java_again = ensure_runtime(&client, &root, 21, &no_progress).unwrap();
assert_eq!(java, java_again);
fs::remove_dir_all(&root).ok();
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Player identity for launching the game.
//!
//! A profile can launch either as a real Microsoft-authenticated player
//! (see `msa.rs`) or as an offline account. Offline mode is an explicit,
//! opt-in choice in the UI, never a fallback that silently weakens the
//! Microsoft path: if a player has signed in with Microsoft, that session
//! is always preferred when present.
//!
//! Offline identity uses the same algorithm every vanilla offline-mode
//! server relies on (and that the official launcher uses for demo accounts):
//! an MD5 of `OfflinePlayer:<nickname>` with the version-3 (name-based)
//! and RFC 4122 variant bits set, formatted as a dashed UUID. The game
//! derives the same UUID client-side, so `--uuid`/`--accessToken` can be
//! a deterministic placeholder; the server's own `loginsystem` mod
//! (ShaCraft's chosen approach) then authenticates by nickname/password.
use crate::msa;
use md5::{Digest, Md5};
/// A resolved identity used to fill `${auth_player_name}`, `${auth_uuid}`,
/// `${auth_access_token}`, `${user_type}` and friends at launch time.
pub enum PlayerIdentity {
Microsoft(msa::LoginResult),
Offline { name: String },
}
impl PlayerIdentity {
pub fn name(&self) -> &str {
match self {
Self::Microsoft(result) => &result.profile.name,
Self::Offline { name } => name,
}
}
/// The `--accessToken` value. A real Microsoft session passes the
/// live token; offline mode uses a fixed placeholder because the game
/// client only requires a non-empty value and the server is in offline
/// mode with its own auth mod.
pub fn access_token(&self) -> &str {
match self {
Self::Microsoft(result) => &result.minecraft_access_token,
Self::Offline { .. } => "0",
}
}
/// The dashed UUID for `${auth_uuid}`. Microsoft profiles come back
/// from Mojang as 32 hex chars; offline mode computes the deterministic
/// name-based UUID.
pub fn uuid(&self) -> String {
match self {
Self::Microsoft(result) => format_uuid_with_dashes(&result.profile.id),
Self::Offline { name } => offline_uuid(name),
}
}
/// The `${user_type}` argument: `msa` for Microsoft accounts,
/// `legacy` for offline mode (the value the vanilla launcher uses for
/// demo/offline sessions).
pub fn user_type(&self) -> &'static str {
match self {
Self::Microsoft(_) => "msa",
Self::Offline { .. } => "legacy",
}
}
/// The `${auth_xuid}` argument. Offline mode has no Xbox identity and
/// passes an empty string, which the game accepts.
pub fn xuid(&self) -> &str {
match self {
Self::Microsoft(result) => result.xuid.as_deref().unwrap_or(""),
Self::Offline { .. } => "",
}
}
}
/// Mojang profile ids come back as 32 hex chars with no dashes; the game
/// itself expects the standard dashed UUID form for `${auth_uuid}`.
pub fn format_uuid_with_dashes(id: &str) -> String {
if id.len() != 32 || id.contains('-') {
return id.to_string();
}
format!("{}-{}-{}-{}-{}", &id[0..8], &id[8..12], &id[12..16], &id[16..20], &id[20..32])
}
/// Computes the deterministic offline UUID for a nickname, using the same
/// name-based (version 3) algorithm the vanilla server uses for offline
/// players: MD5 of `OfflinePlayer:<nick>` with the version and variant bits
/// set. This is what lets the client and an offline-mode server agree on a
/// player's UUID without any account lookup.
pub fn offline_uuid(nickname: &str) -> String {
let mut hasher = Md5::new();
hasher.update(format!("OfflinePlayer:{nickname}"));
let digest = hasher.finalize();
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest);
bytes[6] = (bytes[6] & 0x0f) | 0x30; // version 3 (name-based)
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
let hex = bytes.iter().map(|byte| format!("{byte:02x}")).collect::<String>();
format_uuid_with_dashes(&hex)
}
#[cfg(test)]
mod tests {
use super::*;
/// Reference values computed from the vanilla server's offline UUID
/// algorithm; these are stable and must not change.
#[test]
fn computes_reference_offline_uuids() {
assert_eq!(offline_uuid("Emil"), "947b017f-e6de-3cc4-894d-019938ca63d4");
assert_eq!(offline_uuid("Emil_Shanaty"), "c89467f6-2526-381f-a5c4-c82b677e4150");
assert_eq!(offline_uuid("Notch"), "b50ad385-829d-3141-a216-7e7d7539ba7f");
assert_eq!(offline_uuid("Steve"), "5627dd98-e6be-3c21-b8a8-e92344183641");
}
#[test]
fn offline_uuid_has_correct_version_and_variant() {
let id = offline_uuid("Emil");
let parts: Vec<&str> = id.split('-').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[2].chars().next().unwrap(), '3'); // name-based
assert!(matches!(parts[3].chars().next().unwrap(), '8' | '9' | 'a' | 'b'));
}
#[test]
fn offline_identity_uses_legacy_user_type() {
let identity = PlayerIdentity::Offline { name: "Emil".into() };
assert_eq!(identity.name(), "Emil");
assert_eq!(identity.user_type(), "legacy");
assert_eq!(identity.access_token(), "0");
assert_eq!(identity.uuid(), offline_uuid("Emil"));
}
#[test]
fn formats_dashless_uuid() {
assert_eq!(
format_uuid_with_dashes("0123456789abcdef0123456789abcdef"),
"01234567-89ab-cdef-0123-456789abcdef"
);
let dashed = "01234567-89ab-cdef-0123-456789abcdef";
assert_eq!(format_uuid_with_dashes(dashed), dashed);
}
}
+38 -7
View File
@@ -7,12 +7,29 @@ const MAX_MEMORY_MB: u16 = 12 * 1024;
const DEFAULT_MEMORY_MB: u16 = 6 * 1024;
const DEFAULT_NICKNAME: &str = "Emil";
/// Which account the player launches as. `Microsoft` requires a real signed-in
/// session; `Offline` uses the local nickname (no Microsoft account needed).
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AccountMode {
Microsoft,
Offline,
}
impl Default for AccountMode {
fn default() -> Self {
Self::Offline
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LauncherSettings {
pub memory_mb: u16,
#[serde(default = "default_nickname")]
pub nickname: String,
#[serde(default)]
pub account_mode: AccountMode,
}
fn default_nickname() -> String {
@@ -21,7 +38,7 @@ fn default_nickname() -> String {
impl Default for LauncherSettings {
fn default() -> Self {
Self { memory_mb: DEFAULT_MEMORY_MB, nickname: DEFAULT_NICKNAME.into() }
Self { memory_mb: DEFAULT_MEMORY_MB, nickname: DEFAULT_NICKNAME.into(), account_mode: AccountMode::Offline }
}
}
@@ -80,7 +97,7 @@ fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
#[cfg(test)]
mod tests {
use super::{load, save, LauncherSettings};
use super::{load, save, AccountMode, LauncherSettings};
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
fn temporary_directory() -> std::path::PathBuf {
@@ -94,11 +111,14 @@ mod tests {
#[test]
fn defaults_then_persists_memory() {
let directory = temporary_directory();
assert_eq!(load(&directory).unwrap().memory_mb, 6 * 1024);
let default = load(&directory).unwrap();
assert_eq!(default.memory_mb, 6 * 1024);
assert_eq!(default.nickname, "Emil");
assert_eq!(default.account_mode, AccountMode::Offline);
let saved = save(&directory, LauncherSettings { memory_mb: 8 * 1024, nickname: "Emil".into() }).unwrap();
let saved = save(&directory, LauncherSettings { memory_mb: 8 * 1024, nickname: "Emil".into(), account_mode: AccountMode::Microsoft }).unwrap();
assert_eq!(saved.memory_mb, 8 * 1024);
assert_eq!(load(&directory).unwrap().memory_mb, 8 * 1024);
assert_eq!(load(&directory).unwrap().account_mode, AccountMode::Microsoft);
fs::remove_dir_all(directory).unwrap();
}
@@ -106,7 +126,18 @@ mod tests {
#[test]
fn rejects_unsafe_memory_values() {
let directory = temporary_directory();
assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into() }).is_err());
assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into() }).is_err());
assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into(), account_mode: AccountMode::Offline }).is_err());
assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into(), account_mode: AccountMode::Offline }).is_err());
}
#[test]
fn old_settings_without_nickname_defaults_gracefully() {
let directory = temporary_directory();
fs::create_dir_all(&directory).unwrap();
fs::write(directory.join("settings.json"), r#"{"memoryMb": 6144}"#).unwrap();
let settings = load(&directory).unwrap();
assert_eq!(settings.memory_mb, 6 * 1024);
assert_eq!(settings.nickname, "Emil");
fs::remove_dir_all(directory).unwrap();
}
}