Files
shacraft-launcher/src-tauri/src/commands/game.rs
T

319 lines
13 KiB
Rust

use super::{data_dir, profiles::ProfileMetadata, shacraft::resolve_identity};
use crate::{
installation_lock::InstallationLock, java, launch, manifest, mojang, neoforge,
operations::LauncherOperations, profile, remote, runtime, session, settings, shacraft_account,
};
use reqwest::blocking::Client;
use serde::Serialize;
use std::{path::Path, sync::Arc, time::SystemTime};
use tauri::{AppHandle, Emitter, State};
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct InstallProgress {
stage: &'static str,
current_bytes: u64,
total_bytes: u64,
}
fn progress(app: &AppHandle, 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,
},
);
})
}
fn resolve_merged_version(
client: &Client,
manifest: &manifest::Manifest,
java: &Path,
game_dir: &Path,
cache_dir: &Path,
on_progress: &mojang::ProgressCallback,
) -> Result<mojang::MergedVersion, String> {
let catalog = mojang::fetch_version_manifest(client).map_err(|e| e.to_string())?;
let entry = mojang::find_version(&catalog, &manifest.minecraft.version)
.ok_or_else(|| format!("Mojang does not list {}", manifest.minecraft.version))?;
let vanilla = mojang::fetch_version_json(client, entry).map_err(|e| e.to_string())?;
// The installer may reuse an existing vanilla JAR without verifying it.
// Establish provider integrity BEFORE any NeoForge processor uses that input.
mojang::ensure_client_jar(
client,
game_dir,
&vanilla.id,
&vanilla
.downloads
.as_ref()
.ok_or("Vanilla client download metadata is missing")?
.client,
)
.map_err(|e| e.to_string())?;
if manifest.minecraft.loader.kind == "neoforge" {
let installer_client = neoforge::http_client().map_err(|e| e.to_string())?;
let loader = neoforge::ensure_client_installed(
&installer_client,
java,
game_dir,
cache_dir,
&manifest.minecraft.loader.version,
&vanilla,
on_progress,
)
.map_err(|e| e.to_string())?;
mojang::merge_versions(&vanilla, Some(&loader)).map_err(|e| e.to_string())
} else {
mojang::merge_versions(&vanilla, None).map_err(|e| e.to_string())
}
}
/// Internal stages receive the same verified snapshot; none can refetch it.
fn prepare(
directory: &Path,
snapshot: &remote::VerifiedSnapshot,
progress: &impl Fn(&'static str) -> mojang::ProgressCallback,
) -> Result<
(
mojang::MergedVersion,
java::JavaInstallation,
profile::ProfileInspection,
),
String,
> {
let manifest = &snapshot.manifest;
let root = directory.join("profiles").join(&manifest.id);
progress("mods")(0, 0);
profile::sync_snapshot(&root, snapshot).map_err(|e| e.to_string())?;
let inspection = profile::inspect(&root, manifest).map_err(|e| e.to_string())?;
if !inspection.up_to_date {
return Err(
"Синхронизация не завершена; запуск остановлен. Проверьте конфликты файлов.".into(),
);
}
let game_dir = directory.join("game");
let client = mojang::http_client().map_err(|e| e.to_string())?;
let runtime_client = runtime::http_client().map_err(|e| e.to_string())?;
let java = java::ensure_java(
&runtime_client,
&game_dir.join("runtime"),
manifest.minecraft.java_major,
&progress("java"),
)
.map_err(|e| e.to_string())?;
let merged = resolve_merged_version(
&client,
manifest,
Path::new(&java.executable),
&game_dir,
&game_dir.join("cache"),
&progress("neoforge"),
)?;
let libraries_client = mojang::library_http_client().map_err(|e| e.to_string())?;
mojang::ensure_client_jar(
&client,
&game_dir,
&merged.client_jar_version_id,
&merged.client,
)
.map_err(|e| e.to_string())?;
mojang::ensure_libraries(
&libraries_client,
&game_dir,
&merged.libraries,
&progress("libraries"),
)
.map_err(|e| e.to_string())?;
let index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index)
.map_err(|e| e.to_string())?;
mojang::ensure_assets(&client, &game_dir, &index, &progress("assets"))
.map_err(|e| e.to_string())?;
Ok((merged, java, inspection))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PreparationResult {
inspection: profile::ProfileInspection,
metadata: ProfileMetadata,
onboarding: Option<shacraft_account::LinkStart>,
}
/// Full repair, using one snapshot and one writer lock for mods AND the game.
#[tauri::command]
pub(crate) async fn ensure_game_installed(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<PreparationResult, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let _lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
let (_, _, inspection) = prepare(&directory, &snapshot, &|stage| progress(&app, stage))?;
Ok(PreparationResult {
inspection,
metadata: (&snapshot).into(),
onboarding: None,
})
})
.await
.map_err(|e| e.to_string())?
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GameExited {
profile_id: String,
exit_code: Option<i32>,
}
async fn play(
app: AppHandle,
state: &LauncherOperations,
profile_id: String,
onboarding_name: Option<String>,
) -> Result<PreparationResult, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?;
let account_operation = state.shacraft_account.clone();
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
if onboarding_name.is_some() && (profile_id != "aeronautics" || !snapshot.manifest.files.iter().any(|f|
f.path.starts_with("mods/shacraft-game-bridge-") && f.path.ends_with(".jar") && f.policy == manifest::FilePolicy::Managed)) {
return Err("Опубликованная сборка ещё не поддерживает первый вход. Нужен подписанный мод ShaCraft Game Bridge.".into());
}
let (merged, java, inspection) = prepare(&directory, &snapshot, &|stage| progress(&app, stage))?;
// Refresh identity AFTER downloads; no long-lived cached permission.
let grant = if let Some(name) = onboarding_name {
let _account = account_operation.acquire("ShaCraft account operation")?;
let grant = shacraft_account::start_onboarding(&directory, &name).map_err(|e| e.to_string())?;
shacraft_account::validate_onboarding(&directory, &grant).map_err(|e| e.to_string())?;
Some(grant)
} else { None };
let identity = if let Some(grant) = &grant {
session::PlayerIdentity::Offline { name: grant.challenge.mc_username.clone() }
} else { resolve_identity(&directory, &account_operation)? };
let preferences = settings::load(&directory).map_err(|e| e.to_string())?;
let logs = directory.join("logs");
std::fs::create_dir_all(&logs).map_err(|e| e.to_string())?;
let timestamp = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
let game_dir = directory.join("game");
let profile_dir = directory.join("profiles").join(&snapshot.manifest.id);
let log_path = logs.join(format!("{profile_id}-{timestamp}.log"));
let request = launch::LaunchRequest { java_executable: Path::new(&java.executable), game_dir: &game_dir,
profile_dir: &profile_dir, merged: &merged, identity: &identity, memory_mb: preferences.memory_mb,
log_path: &log_path, onboarding_token: grant.as_ref().map(|g|g.grant_token.as_str()) };
progress(&app, "launch")(0,0);
lock.starting()?;
let mut child = match launch::launch(&request) {
Ok(child) => child,
Err(error) => { lock.finished()?; return Err(error.to_string()); }
};
// Failure to record a living child, including an immediate exit,
// terminates and waits for it before releasing the writer lock.
if let Err(error) = lock.running(child.id()) {
let _ = child.kill();
if child.wait().is_ok() { let _ = lock.finished(); }
return Err(error);
}
let watch_app = app.clone();
std::thread::spawn(move || {
let exit = child.wait();
if exit.is_ok() { let _ = lock.finished(); }
let _ = watch_app.emit("game-exited", GameExited {profile_id,exit_code: exit.ok().and_then(|s|s.code())});
drop(lock);
});
Ok(PreparationResult { inspection, metadata: (&snapshot).into(), onboarding: grant.map(|g|g.challenge) })
}).await.map_err(|e|e.to_string())?
}
/// Legacy command also performs the entire preparation; no public IPC can skip
/// reconciliation or substitute a fresh manifest between install and launch.
#[tauri::command]
pub(crate) async fn launch_game(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<PreparationResult, String> {
play(app, &state, profile_id, None).await
}
#[tauri::command]
pub(crate) async fn launch_onboarding(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
nickname: String,
) -> Result<PreparationResult, String> {
if !shacraft_account::valid_nickname(&nickname) {
return Err("Неверный игровой ник".into());
}
play(app, &state, profile_id, Some(nickname)).await
}
#[cfg(test)]
mod tests {
use super::*;
/// Real provider downloads and installer execution; never logs in or joins
/// a server. Artifacts stay in a fresh temporary directory for diagnosis.
#[test]
#[ignore = "downloads the real pack/game and executes the official installer; needs network and Java 21"]
fn live_cold_install_and_corruption_repair() {
let directory = std::env::temp_dir().canonicalize().unwrap().join(format!(
"shacraft-cold-install-{}",
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
assert!(!directory.exists());
eprintln!("Isolated installation: {}", directory.display());
let _lock = InstallationLock::acquire(&directory).unwrap();
let snapshot = remote::fetch_snapshot("aeronautics").unwrap();
let callback = |stage| -> mojang::ProgressCallback {
eprintln!("Stage: {stage}");
Arc::new(|_, _| {})
};
let (_, java, inspection) = prepare(&directory, &snapshot, &callback).unwrap();
assert!(inspection.up_to_date);
assert_eq!(java.major, snapshot.manifest.minecraft.java_major);
let game = directory.join("game");
let version = &snapshot.manifest.minecraft.loader.version;
let json = neoforge::installed_version_json_path(&game, version);
let jar = game.join(format!(
"libraries/net/neoforged/neoforge/{version}/neoforge-{version}-client.jar"
));
let original_json = std::fs::read(&json).unwrap();
std::fs::write(&json, b"nonempty corrupted version JSON").unwrap();
std::fs::write(&jar, b"nonempty corrupted patched JAR").unwrap();
let (_, _, repaired) = prepare(&directory, &snapshot, &callback).unwrap();
assert!(repaired.up_to_date);
assert_eq!(std::fs::read(&json).unwrap(), original_json);
assert!(std::fs::metadata(&jar).unwrap().len() > 1024);
// A third preparation verifies the receipt and all downloads again.
assert!(
prepare(&directory, &snapshot, &callback)
.unwrap()
.2
.up_to_date
);
eprintln!(
"Cold install, corrupt JSON/JAR repair and healthy recheck passed: {}",
directory.display()
);
}
}