fix launcher UI and game launch pipeline
This commit is contained in:
+24
-2
@@ -8,7 +8,7 @@ use crate::mojang::{self, MergedVersion};
|
||||
use crate::session::PlayerIdentity;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
fmt, fs, io,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
@@ -57,6 +57,12 @@ fn classpath_separator() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_classpath_entries(mut entries: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut seen = HashSet::new();
|
||||
entries.retain(|path| seen.insert(path.clone()));
|
||||
entries
|
||||
}
|
||||
|
||||
fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -> String {
|
||||
let no_features = HashMap::new();
|
||||
let mut entries: Vec<PathBuf> = merged
|
||||
@@ -67,6 +73,11 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -
|
||||
.map(|artifact| game_dir.join("libraries").join(&artifact.path))
|
||||
.collect();
|
||||
entries.push(client_jar.to_path_buf());
|
||||
// NeoForge's inherited profile can repeat vanilla libraries verbatim.
|
||||
// Passing the same jar twice makes SecureJarHandler abort during startup
|
||||
// (for example on gson-2.10.1.jar), so preserve order and keep each path
|
||||
// only once.
|
||||
let entries = unique_classpath_entries(entries);
|
||||
entries.iter().map(|path| path.display().to_string()).collect::<Vec<_>>().join(classpath_separator())
|
||||
}
|
||||
|
||||
@@ -134,7 +145,12 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
|
||||
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());
|
||||
// NeoForge's inherited JVM profile uses `${version_name}.jar` in
|
||||
// `-DignoreList`. The actual client jar belongs to the vanilla parent
|
||||
// (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this
|
||||
// token must identify the parent or both vanilla and patched Minecraft
|
||||
// modules are loaded and Java aborts with a ResolutionException.
|
||||
vars.insert("version_name", request.merged.client_jar_version_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());
|
||||
@@ -203,4 +219,10 @@ mod tests {
|
||||
assert_eq!(substitute("${auth_player_name}", &vars), "Steve");
|
||||
assert_eq!(substitute("-Djava.library.path=${natives_directory}", &vars), "-Djava.library.path=${natives_directory}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classpath_entries_are_unique() {
|
||||
let entries = unique_classpath_entries(vec![PathBuf::from("gson.jar"), PathBuf::from("gson.jar"), PathBuf::from("client.jar")]);
|
||||
assert_eq!(entries, vec![PathBuf::from("gson.jar"), PathBuf::from("client.jar")]);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-6
@@ -58,6 +58,13 @@ fn detect_java() -> Option<java::JavaInstallation> {
|
||||
java::detect()
|
||||
}
|
||||
|
||||
/// Whether Microsoft sign-in was configured for this launcher build.
|
||||
/// The UI uses this to avoid advertising a login flow that cannot start.
|
||||
#[tauri::command]
|
||||
fn microsoft_login_available() -> bool {
|
||||
msa::is_configured()
|
||||
}
|
||||
|
||||
/// Validates an untrusted profile manifest before any file is downloaded.
|
||||
#[tauri::command]
|
||||
fn validate_manifest(manifest_json: String) -> Result<(), String> {
|
||||
@@ -122,6 +129,15 @@ async fn sync_remote_profile(app: AppHandle, profile_id: String) -> Result<profi
|
||||
}).await.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
||||
}
|
||||
|
||||
/// Gets live, read-only player count for a supported profile. Failure is
|
||||
/// surfaced to the interface, which displays the server as unavailable.
|
||||
#[tauri::command]
|
||||
async fn get_server_status(profile_id: String) -> Result<remote::ServerStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || remote::fetch_server_status(&profile_id).map_err(|error| error.to_string()))
|
||||
.await
|
||||
.map_err(|error| format!("Server-status task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = app
|
||||
@@ -311,12 +327,12 @@ async fn ensure_game_installed(app: AppHandle, profile_id: String) -> Result<(),
|
||||
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())?;
|
||||
};
|
||||
// The NeoForge installer creates the loader profile and patched
|
||||
// client, but it does not guarantee that every vanilla runtime
|
||||
// library (notably LWJGL and its platform natives) is present.
|
||||
// Verify the complete merged launch set for every loader kind.
|
||||
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())?;
|
||||
@@ -391,11 +407,13 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
native_host,
|
||||
detect_java,
|
||||
microsoft_login_available,
|
||||
validate_manifest,
|
||||
inspect_profile,
|
||||
sync_profile,
|
||||
inspect_remote_profile,
|
||||
sync_remote_profile,
|
||||
get_server_status,
|
||||
load_settings,
|
||||
save_settings,
|
||||
start_microsoft_login,
|
||||
|
||||
+18
-4
@@ -44,6 +44,13 @@ 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)
|
||||
}
|
||||
|
||||
/// Library entries in a merged loader profile may point at the loader's
|
||||
/// own fixed Maven. The profile itself comes from the SHA-256-verified
|
||||
/// NeoForge installer, never from the ShaCraft manifest.
|
||||
fn is_allowed_library_host(url: &str) -> bool {
|
||||
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MojangError {
|
||||
Network(reqwest::Error),
|
||||
@@ -430,7 +437,7 @@ pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library],
|
||||
checksum: Checksum::Sha1(artifact.sha1.clone()),
|
||||
});
|
||||
}
|
||||
download_many(client, tasks, on_progress)?;
|
||||
download_many(client, tasks, on_progress, is_allowed_library_host)?;
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
@@ -473,7 +480,7 @@ pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_pr
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
download_many(client, tasks, on_progress)
|
||||
download_many(client, tasks, on_progress, is_allowed_host)
|
||||
}
|
||||
|
||||
struct DownloadTask {
|
||||
@@ -509,7 +516,12 @@ fn download_with_retries(client: &Client, task: &DownloadTask) -> Result<u64, Do
|
||||
/// 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> {
|
||||
fn download_many(
|
||||
client: &Client,
|
||||
tasks: Vec<DownloadTask>,
|
||||
on_progress: &ProgressCallback,
|
||||
is_allowed: fn(&str) -> bool,
|
||||
) -> Result<(), MojangError> {
|
||||
let total: u64 = tasks.iter().map(|task| task.size).sum();
|
||||
if total == 0 {
|
||||
return Ok(());
|
||||
@@ -529,7 +541,7 @@ fn download_many(client: &Client, tasks: Vec<DownloadTask>, on_progress: &Progre
|
||||
break;
|
||||
}
|
||||
let Some(task) = queue.lock().unwrap().pop() else { break };
|
||||
if !is_allowed_host(&task.url) {
|
||||
if !is_allowed(&task.url) {
|
||||
*first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url));
|
||||
continue;
|
||||
}
|
||||
@@ -648,6 +660,8 @@ mod tests {
|
||||
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"));
|
||||
assert!(is_allowed_library_host("https://maven.neoforged.net/releases/net/neoforged/example.jar"));
|
||||
assert!(!is_allowed_library_host("https://example.com/evil.jar"));
|
||||
}
|
||||
|
||||
/// Live smoke test against the real Mojang CDN: manifest -> version JSON
|
||||
|
||||
@@ -33,7 +33,7 @@ use std::{
|
||||
/// 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 {
|
||||
pub fn is_configured() -> bool {
|
||||
MSA_CLIENT_ID != "00000000-0000-0000-0000-000000000000"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ struct DeviceCodeResponse {
|
||||
}
|
||||
|
||||
pub fn start_device_code(client: &Client) -> Result<DeviceCodeStart, MsaError> {
|
||||
if !client_id_is_configured() {
|
||||
if !is_configured() {
|
||||
return Err(MsaError::NotConfigured);
|
||||
}
|
||||
let response = client
|
||||
@@ -187,7 +187,7 @@ pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<Micr
|
||||
}
|
||||
|
||||
pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result<MicrosoftTokens, MsaError> {
|
||||
if !client_id_is_configured() {
|
||||
if !is_configured() {
|
||||
return Err(MsaError::NotConfigured);
|
||||
}
|
||||
let response = client
|
||||
@@ -457,7 +457,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn refuses_to_run_with_placeholder_client_id() {
|
||||
assert!(!client_id_is_configured());
|
||||
assert!(!is_configured());
|
||||
let client = Client::builder().build().unwrap();
|
||||
assert!(matches!(start_device_code(&client), Err(MsaError::NotConfigured)));
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ impl From<io::Error> for NeoForgeError {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_allowed_host(url: &str) -> bool {
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, Pr
|
||||
missing_files += 1;
|
||||
continue;
|
||||
}
|
||||
// Seed files are only supplied on the first install. Once present,
|
||||
// player changes are intentional and must not make the profile look
|
||||
// out of date: `sync` preserves them for the same reason.
|
||||
if matches!(expected.policy, FilePolicy::Seed) {
|
||||
continue;
|
||||
}
|
||||
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||
if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
|
||||
mismatched_files += 1;
|
||||
@@ -164,4 +170,24 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_changed_seed_files_as_current() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-launcher-seed-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
));
|
||||
let mut expected = manifest("0".repeat(64), 42);
|
||||
expected.files[0].policy = FilePolicy::Seed;
|
||||
fs::create_dir_all(root.join("mods")).unwrap();
|
||||
fs::write(root.join("mods/example.jar"), b"player customization").unwrap();
|
||||
|
||||
let inspection = inspect(&root, &expected).unwrap();
|
||||
assert_eq!(inspection.missing_files, 0);
|
||||
assert_eq!(inspection.mismatched_files, 0);
|
||||
assert!(inspection.up_to_date);
|
||||
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+29
-1
@@ -2,11 +2,12 @@ use crate::manifest::{self, Manifest};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use reqwest::{blocking::Client, redirect::Policy};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
const AERONAUTICS_MANIFEST: &str =
|
||||
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
|
||||
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
|
||||
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -18,6 +19,16 @@ struct SignedManifest {
|
||||
signature: String,
|
||||
}
|
||||
|
||||
/// Read-only player count for the profile currently supported by the launcher.
|
||||
/// This URL is deliberately fixed here rather than supplied by a manifest.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerStatus {
|
||||
pub online: Option<u32>,
|
||||
pub max: Option<u32>,
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RemoteError {
|
||||
UnknownProfile,
|
||||
@@ -74,3 +85,20 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
|
||||
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)
|
||||
}
|
||||
|
||||
pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError> {
|
||||
let url = match profile_id {
|
||||
"aeronautics" => AERONAUTICS_ONLINE,
|
||||
_ => return Err(RemoteError::UnknownProfile),
|
||||
};
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.map_err(RemoteError::Network)?;
|
||||
let response = client.get(url).send().map_err(RemoteError::Network)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(RemoteError::Status(response.status()));
|
||||
}
|
||||
response.json::<ServerStatus>().map_err(RemoteError::Network)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user