From a653384cb6da2be4959f34953b6fc0fab86b7eb8 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:13:28 -0700 Subject: [PATCH 01/24] feat(launcher): unified resumable downloader and staged JRE installs - New downloader module: .part temp files appended to the full file name, Range resume guarded by If-Range/checksum, per-segment retry and resume for parallel downloads, exponential backoff with mirror failover, 30s idle read timeout instead of 30 minutes, per-session and global cancellation, and throttled progress callbacks. - Migrate every download path to it: Mojang/Zulu runtimes, launcher mod and Edge AOT packages, Extreme packages, manifest files, modpacks, content installs, launcher updates and vanilla libraries/assets. - Managed Java runtimes are installed into a staging directory and promoted only after a completion marker with per-file sizes/SHA1 is written; an incomplete runtime is repaired instead of reused. - Launcher mod packages, Edge AOT packages and manifest files now require a sha256 before install. - Old launcher installers in updates/ are cleaned up; metadata clients use a 60s timeout. - New command cancel_all_downloads; ensure_jdk, install_native_app, install_modpack and download_launcher_app_update accept an optional ipcSession for cancellation. --- tauri-app/src-tauri/src/downloader.rs | 1162 +++++++++++++++++ tauri-app/src-tauri/src/launcher_api.rs | 97 +- tauri-app/src-tauri/src/main.rs | 1403 +++++++++------------ tauri-app/src-tauri/src/minecraft_core.rs | 471 ++----- tauri-app/src-tauri/src/modpack.rs | 103 +- 5 files changed, 1951 insertions(+), 1285 deletions(-) create mode 100644 tauri-app/src-tauri/src/downloader.rs diff --git a/tauri-app/src-tauri/src/downloader.rs b/tauri-app/src-tauri/src/downloader.rs new file mode 100644 index 0000000..af62e73 --- /dev/null +++ b/tauri-app/src-tauri/src/downloader.rs @@ -0,0 +1,1162 @@ +//! Unified resumable downloader. +//! +//! Every file the launcher fetches (JDK archives and Mojang runtime files, +//! launcher mod packages, Edge AOT / Extreme packages, modpacks, content, +//! launcher updates, vanilla libraries/assets) goes through [`download`]. It +//! owns the policy that used to be re-implemented four times with different +//! bugs: +//! +//! - temp files are `.part` (appended, never `with_extension`, so +//! `java.exe` and `java.dll` cannot share a temp file); +//! - an interrupted `.part` is resumed with a `Range` request (guarded by +//! `If-Range` when the server gave a validator); +//! - large files can be split into Range segments, each of which retries and +//! resumes on its own instead of failing the whole transfer; +//! - retries use exponential backoff, failing over to the next candidate URL +//! first, and feed `mirror_health`; +//! - the HTTP client has a per-read idle timeout instead of a 30-minute one; +//! - every read checks a [`CancelToken`] (per install session and global); +//! - progress callbacks are throttled. + +use crate::minecraft_core::{lock_recover, mirror_health}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +use std::thread; +use std::time::{Duration, Instant}; + +pub(crate) const CANCELLED_MESSAGE: &str = "Installation cancelled"; +pub(crate) const PROGRESS_INTERVAL: Duration = Duration::from_millis(200); + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +/// Per-read idle timeout. reqwest's blocking `Response::read` wraps each read in +/// the client timeout, so on a streamed body this is "seconds of silence before +/// we give up on this connection", not a whole-transfer deadline. +const IDLE_READ_TIMEOUT: Duration = Duration::from_secs(30); +/// Attempts against the last remaining candidate URL. Earlier candidates get a +/// single attempt each: failing over is faster than re-waiting on a bad host. +const LAST_CANDIDATE_ATTEMPTS: usize = 4; +const BACKOFF_BASE: Duration = Duration::from_millis(800); +const BACKOFF_CAP: Duration = Duration::from_secs(15); +const SEGMENT_ATTEMPTS: usize = 5; +const MIN_PARALLEL_SIZE: u64 = 8 * 1024 * 1024; +const MIN_SEGMENT_SIZE: u64 = 4 * 1024 * 1024; +const BUFFER_SIZE: usize = 64 * 1024; + +// --------------------------------------------------------------------------- +// Cancellation +// --------------------------------------------------------------------------- + +struct TokenInner { + session: Option, + cancelled: AtomicBool, +} + +fn live_tokens() -> &'static Mutex>> { + static TOKENS: OnceLock>>> = OnceLock::new(); + TOKENS.get_or_init(|| Mutex::new(Vec::new())) +} + +/// Cancellation handle for one task. Cancelled either through its install +/// session (`cancel_install(session)`) or by [`cancel_all`]. +#[derive(Clone)] +pub(crate) struct CancelToken(Arc); + +impl CancelToken { + pub(crate) fn new(session: Option<&str>) -> Self { + let inner = Arc::new(TokenInner { + session: session + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + cancelled: AtomicBool::new(false), + }); + let mut tokens = lock_recover(live_tokens()); + tokens.retain(|token| token.strong_count() > 0); + tokens.push(Arc::downgrade(&inner)); + Self(inner) + } + + pub(crate) fn session(&self) -> Option<&str> { + self.0.session.as_deref() + } + + pub(crate) fn is_cancelled(&self) -> bool { + self.0.cancelled.load(Ordering::Relaxed) || crate::is_install_cancelled(self.session()) + } + + pub(crate) fn check(&self) -> Result<(), String> { + if self.is_cancelled() { + Err(CANCELLED_MESSAGE.to_string()) + } else { + Ok(()) + } + } + + /// Sleeps for `duration`, waking early (with an error) if cancelled. + pub(crate) fn sleep(&self, duration: Duration) -> Result<(), String> { + let deadline = Instant::now() + duration; + loop { + self.check()?; + let now = Instant::now(); + if now >= deadline { + return Ok(()); + } + thread::sleep((deadline - now).min(Duration::from_millis(100))); + } + } +} + +/// Cancels every download task that is currently running. Session-scoped work +/// that only checks `is_install_cancelled(session)` is stopped too, by marking +/// those sessions cancelled. +pub(crate) fn cancel_all() -> usize { + let tokens = { + let mut tokens = lock_recover(live_tokens()); + tokens.retain(|token| token.strong_count() > 0); + tokens.iter().filter_map(Weak::upgrade).collect::>() + }; + for token in &tokens { + token.cancelled.store(true, Ordering::Relaxed); + if let Some(session) = token.session.as_deref() { + let _ = crate::request_install_cancel(session); + } + } + tokens.len() +} + +// --------------------------------------------------------------------------- +// HTTP client and paths +// --------------------------------------------------------------------------- + +pub(crate) fn http_client() -> Result<&'static reqwest::blocking::Client, String> { + static CLIENT: OnceLock = OnceLock::new(); + if let Some(client) = CLIENT.get() { + return Ok(client); + } + let client = reqwest::blocking::Client::builder() + .user_agent(crate::JDK_DOWNLOAD_USER_AGENT) + .connect_timeout(CONNECT_TIMEOUT) + .timeout(IDLE_READ_TIMEOUT) + .redirect(reqwest::redirect::Policy::limited(10)) + .build() + .map_err(|e| format!("Failed to build download client: {e}"))?; + Ok(CLIENT.get_or_init(|| client)) +} + +/// `target` with `suffix` appended to its full file name (extension kept). +pub(crate) fn with_suffix(target: &Path, suffix: &str) -> PathBuf { + let mut name = target + .file_name() + .map(|value| value.to_os_string()) + .unwrap_or_default(); + name.push(suffix); + target.with_file_name(name) +} + +/// Temp file used while `target` is being downloaded: the full file name with +/// `.part` appended, so siblings that only differ by extension never collide. +pub(crate) fn part_path(target: &Path) -> PathBuf { + with_suffix(target, ".part") +} + +fn meta_path(target: &Path) -> PathBuf { + with_suffix(target, ".part.json") +} + +fn segment_path(target: &Path, index: usize, count: usize) -> PathBuf { + with_suffix(target, &format!(".part{index}of{count}")) +} + +/// Removes every temp artifact a download of `target` can leave behind. +pub(crate) fn discard_partial(target: &Path) { + let _ = fs::remove_file(part_path(target)); + if let Some(meta) = read_meta(target) { + if let Some(count) = meta.segments { + for index in 0..count { + let _ = fs::remove_file(segment_path(target, index, count)); + } + } + } + let _ = fs::remove_file(meta_path(target)); +} + +/// Validator recorded next to a `.part` so a resume only continues bytes that +/// came from the same remote object. +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] +struct PartMeta { + url: String, + #[serde(default)] + etag: Option, + #[serde(default)] + last_modified: Option, + #[serde(default)] + total: Option, + #[serde(default)] + segments: Option, +} + +impl PartMeta { + fn validator(&self) -> Option<&str> { + self.etag + .as_deref() + .filter(|value| !value.starts_with("W/")) + .or(self.last_modified.as_deref()) + } +} + +fn read_meta(target: &Path) -> Option { + let raw = fs::read(meta_path(target)).ok()?; + serde_json::from_slice(&raw).ok() +} + +fn write_meta(target: &Path, meta: &PartMeta) -> Result<(), String> { + let raw = serde_json::to_vec(meta).map_err(|e| e.to_string())?; + fs::write(meta_path(target), raw).map_err(|e| { + format!( + "Failed to write download state {}: {e}", + meta_path(target).display() + ) + }) +} + +fn header_string(response: &reqwest::blocking::Response, name: reqwest::header::HeaderName) -> Option { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string) +} + +/// Parses the start offset of a `Content-Range: bytes start-end/total` header. +fn content_range_start(response: &reqwest::blocking::Response) -> Option { + let raw = header_string(response, reqwest::header::CONTENT_RANGE)?; + let range = raw.trim().strip_prefix("bytes")?.trim(); + range.split('-').next()?.trim().parse().ok() +} + +pub(crate) fn backoff_delay(retry: usize) -> Duration { + let factor = 1_u32 << retry.saturating_sub(1).min(5); + (BACKOFF_BASE * factor).min(BACKOFF_CAP) +} + +// --------------------------------------------------------------------------- +// Progress throttling +// --------------------------------------------------------------------------- + +/// Rate limiter for progress callbacks shared between segment threads. +pub(crate) struct Throttle { + last: Mutex>, + interval: Duration, +} + +impl Throttle { + pub(crate) fn new(interval: Duration) -> Self { + Self { + last: Mutex::new(None), + interval, + } + } + + pub(crate) fn ready(&self) -> bool { + let mut last = lock_recover(&self.last); + let now = Instant::now(); + match *last { + Some(previous) if now.duration_since(previous) < self.interval => false, + _ => { + *last = Some(now); + true + } + } + } +} + +// --------------------------------------------------------------------------- +// Request +// --------------------------------------------------------------------------- + +pub(crate) type ProgressFn<'a> = &'a (dyn Fn(u64, Option) + Sync); +pub(crate) type RetryFn<'a> = &'a (dyn Fn(&str) + Sync); + +pub(crate) struct Request<'a> { + urls: Vec, + target: &'a Path, + cancel: &'a CancelToken, + expected_size: Option, + sha1: Option, + sha256: Option, + sha512: Option, + max_parts: usize, + on_progress: Option>, + on_retry: Option>, +} + +impl<'a> Request<'a> { + pub(crate) fn new(urls: &[String], target: &'a Path, cancel: &'a CancelToken) -> Self { + Self { + urls: urls + .iter() + .map(|url| url.trim().to_string()) + .filter(|url| !url.is_empty()) + .collect(), + target, + cancel, + expected_size: None, + sha1: None, + sha256: None, + sha512: None, + max_parts: 1, + on_progress: None, + on_retry: None, + } + } + + pub(crate) fn single(url: &str, target: &'a Path, cancel: &'a CancelToken) -> Self { + Self::new(&[url.to_string()], target, cancel) + } + + pub(crate) fn size(mut self, size: Option) -> Self { + self.expected_size = size; + self + } + + pub(crate) fn sha1(mut self, value: Option<&str>) -> Self { + self.sha1 = value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| !v.is_empty()); + self + } + + pub(crate) fn sha256(mut self, value: Option<&str>) -> Self { + self.sha256 = value.and_then(crate::normalize_sha256_value); + self + } + + pub(crate) fn sha512(mut self, value: Option<&str>) -> Self { + self.sha512 = value.and_then(crate::normalize_sha512_value); + self + } + + /// Upper bound of Range segments for this one file. Callers that already + /// run many downloads concurrently must leave this at 1. + pub(crate) fn max_parts(mut self, parts: usize) -> Self { + self.max_parts = parts.max(1); + self + } + + pub(crate) fn on_progress(mut self, callback: ProgressFn<'a>) -> Self { + self.on_progress = Some(callback); + self + } + + pub(crate) fn on_retry(mut self, callback: RetryFn<'a>) -> Self { + self.on_retry = Some(callback); + self + } + + fn has_checksum(&self) -> bool { + self.sha1.is_some() || self.sha256.is_some() || self.sha512.is_some() + } + + fn report(&self, downloaded: u64, total: Option) { + if let Some(callback) = self.on_progress { + callback(downloaded, total); + } + } + + fn verify(&self, path: &Path) -> Result<(), String> { + if let Some(expected) = self.expected_size { + let actual = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0); + if actual != expected { + return Err(format!("size mismatch: expected {expected} bytes, got {actual}")); + } + } + if let Some(expected) = self.sha1.as_deref() { + let actual = crate::compute_sha1_hex(path)?; + if !actual.eq_ignore_ascii_case(expected) { + return Err(format!("SHA1 mismatch: expected={expected} actual={actual}")); + } + } + if let Some(expected) = self.sha256.as_deref() { + let actual = crate::compute_file_sha256_hex(path)?; + if actual != expected { + return Err(format!("SHA256 mismatch: expected={expected} actual={actual}")); + } + } + if let Some(expected) = self.sha512.as_deref() { + let actual = crate::compute_file_sha512_hex(path)?; + if actual != expected { + return Err(format!("SHA512 mismatch: expected={expected} actual={actual}")); + } + } + Ok(()) + } +} + +enum AttemptError { + /// Cancelled or a local I/O failure: retrying cannot help. + Fatal(String), + /// This URL will never serve the file (4xx); try the next candidate. + Permanent(String), + /// Network trouble; retry (resuming) after a backoff. + Retryable(String), +} + +/// Downloads `request` into its target, verifying size/checksums when given. +pub(crate) fn download(request: &Request) -> Result<(), String> { + let target = request.target; + if request.urls.is_empty() { + return Err(format!("No download URL for {}", target.display())); + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory {}: {e}", parent.display()))?; + } + let client = http_client()?; + let candidates = mirror_health::prioritize(&request.urls); + let mut last_error = String::new(); + + for (index, url) in candidates.iter().enumerate() { + let attempts = if index + 1 == candidates.len() { + LAST_CANDIDATE_ATTEMPTS + } else { + 1 + }; + if request.max_parts > 1 { + match try_segmented(client, url, request) { + Ok(true) => { + mirror_health::record_success(url); + return Ok(()); + } + Ok(false) => {} + Err(AttemptError::Fatal(error)) => return Err(error), + Err(AttemptError::Permanent(error)) | Err(AttemptError::Retryable(error)) => { + // Segments exhausted their own retries; fall back to one stream. + mirror_health::record_failure(url); + discard_partial(target); + last_error = error; + if let Some(callback) = request.on_retry { + callback(&last_error); + } + } + } + } + for attempt in 1..=attempts { + request.cancel.check()?; + if attempt > 1 { + if let Some(callback) = request.on_retry { + callback(&format!("Retrying ({}/{}): {last_error}", attempt - 1, attempts - 1)); + } + request.cancel.sleep(backoff_delay(attempt - 1))?; + } + match attempt_stream(client, url, request) { + Ok(()) => { + mirror_health::record_success(url); + return finalize(request); + } + Err(AttemptError::Fatal(error)) => return Err(error), + Err(AttemptError::Permanent(error)) => { + last_error = error; + break; + } + Err(AttemptError::Retryable(error)) => { + mirror_health::record_failure(url); + last_error = error; + } + } + } + } + Err(if last_error.is_empty() { + format!("Failed to download {}", target.display()) + } else { + last_error + }) +} + +fn finalize(request: &Request) -> Result<(), String> { + let target = request.target; + let part = part_path(target); + if target.exists() { + fs::remove_file(target) + .map_err(|e| format!("Failed to replace existing file {}: {e}", target.display()))?; + } + fs::rename(&part, target) + .or_else(|_| fs::copy(&part, target).map(|_| ()).and_then(|_| fs::remove_file(&part))) + .map_err(|e| { + format!( + "Failed to move downloaded file from {} to {}: {e}", + part.display(), + target.display() + ) + })?; + let _ = fs::remove_file(meta_path(target)); + Ok(()) +} + +fn classify_status(url: &str, status: reqwest::StatusCode) -> AttemptError { + let message = format!("HTTP {status} from {url}"); + if status.is_client_error() + && status != reqwest::StatusCode::REQUEST_TIMEOUT + && status != reqwest::StatusCode::TOO_MANY_REQUESTS + { + AttemptError::Permanent(message) + } else { + AttemptError::Retryable(message) + } +} + +/// Copies the response body into `file`, checking cancellation on every chunk. +/// `on_chunk` receives the number of bytes just written. +fn pump_body( + response: &mut reqwest::blocking::Response, + file: &mut fs::File, + cancel: &CancelToken, + url: &str, + mut on_chunk: impl FnMut(u64), +) -> Result<(), AttemptError> { + let mut buffer = vec![0_u8; BUFFER_SIZE]; + loop { + if cancel.is_cancelled() { + return Err(AttemptError::Fatal(CANCELLED_MESSAGE.to_string())); + } + let read = match response.read(&mut buffer) { + Ok(0) => break, + Ok(read) => read, + Err(error) => { + return Err(AttemptError::Retryable(format!( + "Read from {url} failed: {error}" + ))) + } + }; + file.write_all(&buffer[..read]) + .map_err(|e| AttemptError::Fatal(format!("Failed to write download data: {e}")))?; + on_chunk(read as u64); + } + file.flush() + .map_err(|e| AttemptError::Fatal(format!("Failed to flush download data: {e}"))) +} + +/// One streaming attempt into `.part`, resuming an existing partial +/// file when it is safe to do so. +fn attempt_stream( + client: &reqwest::blocking::Client, + url: &str, + request: &Request, +) -> Result<(), AttemptError> { + let target = request.target; + let part = part_path(target); + let existing = fs::metadata(&part).map(|meta| meta.len()).unwrap_or(0); + let meta = read_meta(target).filter(|meta| meta.segments.is_none()); + // Resuming is only safe when we can prove the remote object is the same: + // either the server gave a validator (sent back as If-Range) or the final + // checksum will catch a mismatch. + let resumable = existing > 0 + && meta.as_ref().is_some_and(|meta| { + meta.url == url && (meta.validator().is_some() || request.has_checksum()) + }); + if existing > 0 && !resumable { + let _ = fs::remove_file(&part); + } + if resumable && request.expected_size == Some(existing) { + // A previous run finished the body but died before verification. + return verify_part(request, &part); + } + + let mut builder = client + .get(url) + .header(reqwest::header::ACCEPT, "*/*") + .header(reqwest::header::ACCEPT_ENCODING, "identity"); + if resumable { + builder = builder.header(reqwest::header::RANGE, format!("bytes={existing}-")); + if let Some(validator) = meta.as_ref().and_then(PartMeta::validator) { + builder = builder.header(reqwest::header::IF_RANGE, validator); + } + } + let mut response = builder.send().map_err(|e| { + AttemptError::Retryable(format!( + "Request to {url} failed: {}", + crate::describe_http_error(&e) + )) + })?; + let status = response.status(); + + let (mut file, mut downloaded, total) = if resumable + && status == reqwest::StatusCode::PARTIAL_CONTENT + && content_range_start(&response) == Some(existing) + { + let file = fs::OpenOptions::new() + .append(true) + .open(&part) + .map_err(|e| AttemptError::Fatal(format!("Failed to open {}: {e}", part.display())))?; + let total = response.content_length().map(|len| len + existing); + (file, existing, total) + } else if resumable && status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE { + let _ = fs::remove_file(&part); + let _ = fs::remove_file(meta_path(target)); + return Err(AttemptError::Retryable(format!( + "Server rejected resume of {url}; restarting" + ))); + } else if status.is_success() && status != reqwest::StatusCode::PARTIAL_CONTENT { + let meta = PartMeta { + url: url.to_string(), + etag: header_string(&response, reqwest::header::ETAG), + last_modified: header_string(&response, reqwest::header::LAST_MODIFIED), + total: response.content_length(), + segments: None, + }; + write_meta(target, &meta).map_err(AttemptError::Fatal)?; + let file = fs::File::create(&part) + .map_err(|e| AttemptError::Fatal(format!("Failed to create {}: {e}", part.display())))?; + (file, 0, response.content_length()) + } else if status == reqwest::StatusCode::PARTIAL_CONTENT { + // 206 that does not continue where we stopped: start over. + let _ = fs::remove_file(&part); + return Err(AttemptError::Retryable(format!( + "Unexpected partial response from {url}; restarting" + ))); + } else { + return Err(classify_status(url, status)); + }; + + let total = total.or(request.expected_size); + request.report(downloaded, total); + let throttle = Throttle::new(PROGRESS_INTERVAL); + let result = pump_body(&mut response, &mut file, request.cancel, url, |delta| { + downloaded += delta; + if throttle.ready() { + request.report(downloaded, total); + } + }); + drop(file); + result?; + if let Some(total) = total { + if downloaded < total { + return Err(AttemptError::Retryable(format!( + "Connection to {url} closed early ({downloaded}/{total} bytes)" + ))); + } + } + request.report(downloaded, total.or(Some(downloaded))); + verify_part(request, &part) +} + +fn verify_part(request: &Request, part: &Path) -> Result<(), AttemptError> { + if let Err(error) = request.verify(part) { + discard_partial(request.target); + return Err(AttemptError::Retryable(format!( + "Downloaded file {} failed verification: {error}", + request.target.display() + ))); + } + Ok(()) +} + +struct ProbeInfo { + total: u64, + etag: Option, + last_modified: Option, +} + +fn probe_range_support(client: &reqwest::blocking::Client, url: &str) -> Option { + let response = client + .head(url) + .header(reqwest::header::ACCEPT, "*/*") + .header(reqwest::header::ACCEPT_ENCODING, "identity") + .send() + .ok()?; + if !response.status().is_success() { + return None; + } + let accepts = header_string(&response, reqwest::header::ACCEPT_RANGES) + .is_some_and(|value| value.to_ascii_lowercase().contains("bytes")); + if !accepts { + return None; + } + Some(ProbeInfo { + total: response.content_length()?, + etag: header_string(&response, reqwest::header::ETAG), + last_modified: header_string(&response, reqwest::header::LAST_MODIFIED), + }) +} + +fn segment_bounds(total: u64, count: usize) -> Vec<(u64, u64)> { + let chunk = total.div_ceil(count as u64); + (0..count as u64) + .map(|index| index * chunk) + .take_while(|start| *start < total) + .map(|start| (start, (start + chunk).min(total) - 1)) + .collect() +} + +/// Parallel Range download. `Ok(false)` means the server or file size does not +/// qualify and the caller should stream normally. +fn try_segmented( + client: &reqwest::blocking::Client, + url: &str, + request: &Request, +) -> Result { + let target = request.target; + let Some(probe) = probe_range_support(client, url) else { + return Ok(false); + }; + if probe.total < MIN_PARALLEL_SIZE { + return Ok(false); + } + let count = request + .max_parts + .min(probe.total.div_ceil(MIN_SEGMENT_SIZE) as usize) + .max(1); + if count < 2 { + return Ok(false); + } + let meta = PartMeta { + url: url.to_string(), + etag: probe.etag.clone(), + last_modified: probe.last_modified.clone(), + total: Some(probe.total), + segments: Some(count), + }; + // Existing segments are only reused when they were written for exactly the + // same remote object and geometry. + let same_object = read_meta(target).is_some_and(|previous| { + previous == meta && (meta.validator().is_some() || request.has_checksum()) + }); + if !same_object { + discard_partial(target); + } + write_meta(target, &meta).map_err(AttemptError::Fatal)?; + + let bounds = segment_bounds(probe.total, count); + let paths: Vec = (0..bounds.len()) + .map(|index| segment_path(target, index, count)) + .collect(); + let downloaded = AtomicU64::new( + paths + .iter() + .map(|path| fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)) + .sum(), + ); + let throttle = Throttle::new(PROGRESS_INTERVAL); + let total = probe.total; + let validator = meta.validator().map(ToString::to_string); + request.report(downloaded.load(Ordering::Relaxed), Some(total)); + + let results: Vec> = thread::scope(|scope| { + let handles: Vec<_> = bounds + .iter() + .zip(paths.iter()) + .map(|(&(start, end), path)| { + let downloaded = &downloaded; + let throttle = &throttle; + let validator = validator.as_deref(); + scope.spawn(move || { + download_segment(client, url, path, start, end, validator, request, |delta| { + let now = downloaded.fetch_add(delta, Ordering::Relaxed) + delta; + if throttle.ready() { + request.report(now, Some(total)); + } + }) + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| { + handle.join().unwrap_or_else(|_| { + Err(AttemptError::Fatal("Download segment worker panicked".to_string())) + }) + }) + .collect() + }); + // A fatal error (cancel, disk) wins over a network error. + let mut network_error = None; + for result in results { + match result { + Ok(()) => {} + Err(AttemptError::Fatal(error)) => return Err(AttemptError::Fatal(error)), + Err(other) => network_error = network_error.or(Some(other)), + } + } + if let Some(error) = network_error { + return Err(error); + } + + let part = part_path(target); + let mut output = fs::File::create(&part) + .map_err(|e| AttemptError::Fatal(format!("Failed to create {}: {e}", part.display())))?; + for (path, &(start, end)) in paths.iter().zip(bounds.iter()) { + let expected = end - start + 1; + let actual = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0); + if actual != expected { + drop(output); + discard_partial(target); + return Err(AttemptError::Retryable(format!( + "Segment {} has {actual} bytes, expected {expected}", + path.display() + ))); + } + let mut input = fs::File::open(path) + .map_err(|e| AttemptError::Fatal(format!("Failed to open {}: {e}", path.display())))?; + std::io::copy(&mut input, &mut output) + .map_err(|e| AttemptError::Fatal(format!("Failed to join download segments: {e}")))?; + } + output + .flush() + .map_err(|e| AttemptError::Fatal(format!("Failed to flush {}: {e}", part.display())))?; + drop(output); + for path in &paths { + let _ = fs::remove_file(path); + } + // Segments are merged; the remaining state is a plain `.part`. + let mut merged = meta; + merged.segments = None; + write_meta(target, &merged).map_err(AttemptError::Fatal)?; + request.report(total, Some(total)); + verify_part(request, &part)?; + finalize(request).map_err(AttemptError::Fatal)?; + Ok(true) +} + +#[allow(clippy::too_many_arguments)] +fn download_segment( + client: &reqwest::blocking::Client, + url: &str, + path: &Path, + start: u64, + end: u64, + validator: Option<&str>, + request: &Request, + mut on_chunk: impl FnMut(u64), +) -> Result<(), AttemptError> { + let expected = end - start + 1; + let mut last_error = String::new(); + for attempt in 1..=SEGMENT_ATTEMPTS { + if attempt > 1 { + request + .cancel + .sleep(backoff_delay(attempt - 1)) + .map_err(AttemptError::Fatal)?; + } + let mut have = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0); + if have > expected { + let _ = fs::remove_file(path); + have = 0; + } + if have == expected { + return Ok(()); + } + let mut builder = client + .get(url) + .header(reqwest::header::ACCEPT, "*/*") + .header(reqwest::header::ACCEPT_ENCODING, "identity") + .header(reqwest::header::RANGE, format!("bytes={}-{end}", start + have)); + if let Some(validator) = validator { + builder = builder.header(reqwest::header::IF_RANGE, validator); + } + let mut response = match builder.send() { + Ok(response) => response, + Err(error) => { + last_error = format!("Segment request failed: {}", crate::describe_http_error(&error)); + continue; + } + }; + if response.status() != reqwest::StatusCode::PARTIAL_CONTENT + || content_range_start(&response) != Some(start + have) + { + // A 200 here means the object changed (If-Range) or Range is not + // honoured after all; segment results would be garbage. + return Err(classify_status(url, response.status())); + } + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| AttemptError::Fatal(format!("Failed to open {}: {e}", path.display())))?; + let _ = file.seek(SeekFrom::End(0)); + match pump_body(&mut response, &mut file, request.cancel, url, &mut on_chunk) { + Ok(()) => { + let now = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0); + if now == expected { + return Ok(()); + } + last_error = format!("Segment ended early ({now}/{expected} bytes)"); + } + Err(AttemptError::Retryable(error)) => last_error = error, + Err(other) => return Err(other), + } + } + Err(AttemptError::Retryable(last_error)) +} + +// --------------------------------------------------------------------------- +// Source probing +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProbeResult { + pub url: String, + pub ok: bool, + /// Time to response headers. + pub latency_ms: Option, + pub bytes_per_second: Option, + pub bytes_read: u64, + pub error: Option, +} + +/// Measures latency and throughput against `url` by reading at most +/// `max_bytes` (or for at most `budget`). +pub(crate) fn probe_url(url: &str, max_bytes: u64, budget: Duration) -> ProbeResult { + let mut result = ProbeResult { + url: url.to_string(), + ok: false, + latency_ms: None, + bytes_per_second: None, + bytes_read: 0, + error: None, + }; + let client = match http_client() { + Ok(client) => client, + Err(error) => { + result.error = Some(error); + return result; + } + }; + let started = Instant::now(); + let response = client + .get(url) + .header(reqwest::header::ACCEPT_ENCODING, "identity") + .header(reqwest::header::RANGE, format!("bytes=0-{}", max_bytes.saturating_sub(1))) + .timeout(budget) + .send(); + let mut response = match response { + Ok(response) => response, + Err(error) => { + result.error = Some(crate::describe_http_error(&error)); + return result; + } + }; + result.latency_ms = Some(started.elapsed().as_millis() as u64); + if !response.status().is_success() { + result.error = Some(format!("HTTP {}", response.status())); + return result; + } + let body_started = Instant::now(); + let mut buffer = vec![0_u8; BUFFER_SIZE]; + while result.bytes_read < max_bytes && body_started.elapsed() < budget { + match response.read(&mut buffer) { + Ok(0) => break, + Ok(read) => result.bytes_read += read as u64, + Err(error) => { + result.error = Some(error.to_string()); + break; + } + } + } + let elapsed = body_started.elapsed().as_secs_f64().max(0.001); + result.ok = result.bytes_read > 0; + if result.ok { + result.bytes_per_second = Some((result.bytes_read as f64 / elapsed) as u64); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn part_path_keeps_original_extension() { + let exe = part_path(Path::new("/rt/bin/java.exe")); + let dll = part_path(Path::new("/rt/bin/java.dll")); + assert_eq!(exe, Path::new("/rt/bin/java.exe.part")); + assert_ne!(exe, dll); + assert_ne!( + segment_path(Path::new("/a/java.policy"), 0, 2), + segment_path(Path::new("/a/java.security"), 0, 2) + ); + } + + #[test] + fn segment_bounds_cover_whole_file() { + let bounds = segment_bounds(10, 3); + assert_eq!(bounds, vec![(0, 3), (4, 7), (8, 9)]); + let bounds = segment_bounds(9 * 1024 * 1024, 16); + let covered: u64 = bounds.iter().map(|(s, e)| e - s + 1).sum(); + assert_eq!(covered, 9 * 1024 * 1024); + } + + #[test] + fn backoff_is_exponential_and_capped() { + assert_eq!(backoff_delay(1), BACKOFF_BASE); + assert_eq!(backoff_delay(2), BACKOFF_BASE * 2); + assert_eq!(backoff_delay(3), BACKOFF_BASE * 4); + assert_eq!(backoff_delay(30), BACKOFF_CAP); + } + + /// `cancel_all` is process-wide, so tests that download must not overlap it. + static GLOBAL_CANCEL_GUARD: Mutex<()> = Mutex::new(()); + + #[test] + fn cancel_all_reaches_live_tokens() { + let _guard = lock_recover(&GLOBAL_CANCEL_GUARD); + let session = CancelToken::new(Some("downloader-test-session")); + crate::request_install_cancel("downloader-test-session").unwrap(); + assert!(session.is_cancelled()); + crate::clear_install_cancel(Some("downloader-test-session")); + let token = CancelToken::new(None); + assert!(!token.is_cancelled()); + cancel_all(); + assert!(token.is_cancelled()); + assert!(token.check().is_err()); + let fresh = CancelToken::new(None); + assert!(!fresh.is_cancelled()); + } + + #[test] + fn throttle_limits_rate() { + let throttle = Throttle::new(Duration::from_secs(60)); + assert!(throttle.ready()); + assert!(!throttle.ready()); + } + + /// Minimal HTTP/1.1 server: `handler(request_index, request_head)` returns + /// the raw response bytes and whether to cut the body after that many bytes. + fn serve(handler: F) -> (String, Arc>>) + where + F: Fn(usize, &str) -> Vec + Send + 'static, + { + use std::io::{BufRead, BufReader}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&requests); + thread::spawn(move || { + for (index, stream) in listener.incoming().enumerate() { + let Ok(mut stream) = stream else { return }; + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut head = String::new(); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + head.push_str(&line); + } + seen.lock().unwrap().push(head.clone()); + let _ = stream.write_all(&handler(index, &head)); + let _ = stream.flush(); + } + }); + (address, requests) + } + + fn body() -> Vec { + (0..200_000u32).map(|value| (value % 251) as u8).collect() + } + + fn sha1_hex(bytes: &[u8]) -> String { + use sha1::{Digest, Sha1}; + format!("{:x}", Sha1::digest(bytes)) + } + + fn range_start(head: &str) -> Option { + head.lines() + .find_map(|line| line.to_ascii_lowercase().strip_prefix("range: bytes=").map(str::to_string)) + .and_then(|value| value.trim().trim_end_matches('-').parse().ok()) + } + + #[test] + fn resumes_interrupted_body_with_range() { + let _guard = lock_recover(&GLOBAL_CANCEL_GUARD); + let data = body(); + let served = data.clone(); + let file_hits = AtomicU64::new(0); + let (base, requests) = serve(move |_, head| { + // Only count our own requests: other local processes may probe the port. + if !head.starts_with("GET /file ") { + return b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_vec(); + } + let index = file_hits.fetch_add(1, Ordering::Relaxed); + let start = range_start(head).unwrap_or(0); + let slice = &served[start..]; + let mut response = if start > 0 { + format!( + "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{}/{}\r\nETag: \"v1\"\r\nConnection: close\r\n\r\n", + slice.len(), + served.len() - 1, + served.len() + ) + } else { + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nETag: \"v1\"\r\nAccept-Ranges: bytes\r\nConnection: close\r\n\r\n", + slice.len() + ) + } + .into_bytes(); + // The first response dies after 70 000 body bytes. + let cut = if index == 0 { 70_000 } else { slice.len() }; + response.extend_from_slice(&slice[..cut]); + response + }); + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("java.exe"); + let cancel = CancelToken::new(None); + let url = format!("{base}/file"); + download( + &Request::single(&url, &target, &cancel) + .size(Some(data.len() as u64)) + .sha1(Some(&sha1_hex(&data))), + ) + .unwrap(); + assert_eq!(fs::read(&target).unwrap(), data); + assert!(!part_path(&target).exists()); + assert!(!meta_path(&target).exists()); + let requests: Vec = requests + .lock() + .unwrap() + .iter() + .filter(|head| head.starts_with("GET /file ")) + .cloned() + .collect(); + assert_eq!(requests.len(), 2); + assert_eq!(range_start(&requests[1]), Some(70_000), "{requests:?}"); + } + + #[test] + fn fails_over_from_missing_mirror_and_rejects_bad_checksum() { + let _guard = lock_recover(&GLOBAL_CANCEL_GUARD); + let data = body(); + let served = data.clone(); + let (base, _requests) = serve(move |_, head| { + if head.starts_with("GET /missing") { + return b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_vec(); + } + let mut response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + served.len() + ) + .into_bytes(); + response.extend_from_slice(&served); + response + }); + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("lib.jar"); + let cancel = CancelToken::new(None); + let urls = vec![format!("{base}/missing"), format!("{base}/ok")]; + download(&Request::new(&urls, &target, &cancel).sha1(Some(&sha1_hex(&data)))).unwrap(); + assert_eq!(fs::read(&target).unwrap(), data); + + let bad = dir.path().join("bad.jar"); + let error = download( + &Request::new(&[format!("{base}/ok")], &bad, &cancel) + .sha1(Some("0000000000000000000000000000000000000000")), + ) + .unwrap_err(); + assert!(error.contains("verification"), "{error}"); + assert!(!bad.exists()); + assert!(!part_path(&bad).exists()); + } +} diff --git a/tauri-app/src-tauri/src/launcher_api.rs b/tauri-app/src-tauri/src/launcher_api.rs index 89fa4c5..9fee697 100644 --- a/tauri-app/src-tauri/src/launcher_api.rs +++ b/tauri-app/src-tauri/src/launcher_api.rs @@ -1,14 +1,12 @@ use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use rand::RngCore; use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager}; use crate::{ - build_api_http_client, build_blocking_http_client, describe_http_error, - download_file_with_progress_callback_blocking, open_file_with_system, sanitize_file_name, - verify_file_sha256, + build_api_http_client, describe_http_error, open_file_with_system, sanitize_file_name, }; #[derive(Debug, Serialize, Deserialize)] @@ -385,9 +383,14 @@ pub async fn download_launcher_app_update( download_url: String, version: String, checksum: Option, + ipc_session: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - download_launcher_app_update_blocking(&app, download_url, version, checksum) + let cancel = crate::downloader::CancelToken::new(ipc_session.as_deref()); + let result = + download_launcher_app_update_blocking(&app, &cancel, download_url, version, checksum); + crate::clear_install_cancel(ipc_session.as_deref()); + result }) .await .map_err(|e| format!("Failed to join launcher app download task: {e}"))? @@ -535,7 +538,7 @@ fn launcher_get_app_update_blocking( channel: Option, ) -> Result { let normalized_base = normalize_api_base_url(&base_url)?; - let client = build_blocking_http_client()?; + let client = build_api_http_client()?; let install_id = get_or_create_launcher_install_id(app)?; let mut url = reqwest::Url::parse(&format!("{normalized_base}/api/v1/launcher/app-update")) .map_err(|e| format!("Invalid launcher app update endpoint URL: {e}"))?; @@ -567,7 +570,7 @@ fn launcher_list_app_update_channels_blocking( token: Option, ) -> Result, String> { let normalized_base = normalize_api_base_url(&base_url)?; - let client = build_blocking_http_client()?; + let client = build_api_http_client()?; let url = reqwest::Url::parse(&format!( "{normalized_base}/api/v1/launcher/app-update/channels" )) @@ -593,6 +596,7 @@ fn launcher_list_app_update_channels_blocking( fn download_launcher_app_update_blocking( app: &tauri::AppHandle, + cancel: &crate::downloader::CancelToken, download_url: String, version: String, checksum: Option, @@ -609,7 +613,6 @@ fn download_launcher_app_update_blocking( .and_then(|item| normalize_launcher_update_checksum(&item)) .ok_or_else(|| "Launcher update checksum is missing or invalid".to_string())?; - let client = build_blocking_http_client()?; let file_name = infer_download_file_name(&normalized_download_url, &normalized_version); let updates_dir = app .path() @@ -623,37 +626,35 @@ fn download_launcher_app_update_blocking( ) })?; let target_path = updates_dir.join(&file_name); - download_file_with_progress_callback_blocking( - &client, - &normalized_download_url, - &target_path, - |downloaded_bytes, total_bytes| { - let percent = total_bytes - .filter(|value| *value > 0) - .map(|value| { - downloaded_bytes - .saturating_mul(100) - .min(value.saturating_mul(100)) - / value - }) - .and_then(|value| u8::try_from(value).ok()); - let _ = app.emit( - "launcher-app-update-progress", - LauncherAppUpdateProgress { - downloaded_bytes, - total_bytes, - percent, - }, - ); - }, + remove_stale_update_files(&updates_dir, &file_name); + // The downloader throttles this callback (launcher-core-19: it used to fire + // an IPC event for every 64 KiB read). + let progress = |downloaded_bytes: u64, total_bytes: Option| { + let percent = total_bytes + .filter(|value| *value > 0) + .map(|value| { + downloaded_bytes + .saturating_mul(100) + .min(value.saturating_mul(100)) + / value + }) + .and_then(|value| u8::try_from(value).ok()); + let _ = app.emit( + "launcher-app-update-progress", + LauncherAppUpdateProgress { + downloaded_bytes, + total_bytes, + percent, + }, + ); + }; + crate::downloader::download( + &crate::downloader::Request::single(&normalized_download_url, &target_path, cancel) + .sha256(Some(expected_checksum.as_str())) + .on_progress(&progress), ) .map_err(|e| format!("Failed to download launcher installer: {e}"))?; - if let Err(error) = verify_file_sha256(&target_path, &expected_checksum) { - let _ = fs::remove_file(&target_path); - return Err(format!("Launcher installer checksum mismatch: {error}")); - } - Ok(DownloadedLauncherUpdate { version: normalized_version, file_name, @@ -661,6 +662,30 @@ fn download_launcher_app_update_blocking( }) } +/// Deletes installers left over from earlier updates. Only the file being +/// downloaded now (and its resumable `.part` state) is kept. +fn remove_stale_update_files(updates_dir: &Path, keep_file_name: &str) { + let Ok(entries) = fs::read_dir(updates_dir) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name == keep_file_name || name.starts_with(&format!("{keep_file_name}.part")) { + continue; + } + let path = entry.path(); + if path.is_file() { + if let Err(error) = fs::remove_file(&path) { + crate::push_ui_log( + "core", + "warn", + &format!("Failed to remove old launcher installer {}: {error}", path.display()), + ); + } + } + } +} + fn normalize_launcher_update_checksum(value: &str) -> Option { let trimmed = value.trim(); let normalized = trimmed diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index aefb0d3..ad2a133 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -1,5 +1,6 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod downloader; mod launcher_api; mod microsoft_auth; mod minecraft_core; @@ -1660,6 +1661,7 @@ fn repair_instance_runtime_blocking( .ok_or_else(|| format!("No forge version available for {}", normalized_base_version))?; let jdk = ensure_jdk_blocking( window.clone(), + &downloader::CancelToken::new(None), game_dir_path.to_string_lossy().to_string(), normalized_base_version.clone(), Some(DEFAULT_DOWNLOAD_THREADS), @@ -1880,10 +1882,15 @@ async fn ensure_jdk( game_dir: String, version_id: String, download_threads: Option, + ipc_session: Option, ) -> Result { let window_clone = window.clone(); tauri::async_runtime::spawn_blocking(move || { - ensure_jdk_blocking(window_clone, game_dir, version_id, download_threads) + let cancel = downloader::CancelToken::new(ipc_session.as_deref()); + let result = + ensure_jdk_blocking(window_clone, &cancel, game_dir, version_id, download_threads); + clear_install_cancel(ipc_session.as_deref()); + result }) .await .map_err(|e| format!("Failed to join ensure_jdk task: {e}")) @@ -1893,6 +1900,7 @@ async fn ensure_jdk( fn ensure_jdk_blocking( window: tauri::Window, + cancel: &downloader::CancelToken, game_dir: String, version_id: String, download_threads: Option, @@ -1908,6 +1916,7 @@ fn ensure_jdk_blocking( let runtime_root = managed_jdk_runtime_root(&game_dir_path, major, needs_x64); let java_path = ensure_managed_jdk_runtime( Some(&window), + cancel, &runtime_root, major, download_threads, @@ -2158,10 +2167,13 @@ async fn install_modpack( api_key: Option, download_source: Option, download_threads: Option, + ipc_session: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - modpack::install_modpack_blocking( + let cancel = downloader::CancelToken::new(ipc_session.as_deref()); + let result = modpack::install_modpack_blocking( window, + &cancel, game_dir, source, project_id, @@ -2169,7 +2181,9 @@ async fn install_modpack( api_key, download_source, download_threads, - ) + ); + clear_install_cancel(ipc_session.as_deref()); + result }) .await .map_err(|e| format!("[modpack:finalize] Failed to join modpack install task: {e}")) @@ -2400,7 +2414,6 @@ fn install_modrinth_project_blocking( sanitize_file_name(&file.filename) )); download_file_quiet_with_progress_blocking( - &client, &file.url, &download_path, window.as_ref(), @@ -2660,7 +2673,6 @@ fn install_curseforge_project_blocking( sanitize_file_name(&file.file_name) )); download_file_quiet_with_progress_blocking( - &client, &download_url, &download_path, window.as_ref(), @@ -3130,16 +3142,23 @@ fn install_edge_aot_package_blocking( }); } + // The AOT runtime jar runs inside the game JVM; never install it on TLS alone + // (launcher-core-14). Checked after the up-to-date shortcut so an installed + // package keeps launching even if the catalog entry lacks a checksum. + let expected_checksum = normalized_checksum + .as_deref() + .and_then(normalize_sha256_value) + .ok_or_else(|| { + "Edge AOT package checksum is missing or invalid; refusing to install an unverified package" + .to_string() + })?; + let download_file_name = format!( "{}.zip", sanitize_file_name(&format!("fpsmaster-edge-aot-{}", normalized_tag)) ); - let temp_download_path = env::temp_dir().join(format!( - "fpsmaster-edge-aot-{}-{}-{}", - std::process::id(), - now_epoch_millis(), - download_file_name - )); + // Stable path so an interrupted download resumes on the next attempt. + let temp_download_path = launcher_download_cache_path(&download_file_name)?; // Reuses the "mods" phase key: from the launch-prepare dialog's point of view this // replaces the mod-sync step for no-Forge Edge instances. @@ -3166,45 +3185,40 @@ fn install_edge_aot_package_blocking( "Downloading", ); - let progress_session = ipc_session.map(str::to_string); - let progress_item_id = normalized_url.clone(); - let progress_item_name = download_file_name.clone(); - let progress_callback: DownloadProgressCallback = - Arc::new(move |downloaded: u64, total: Option| { - emit_launch_prepare_item( - progress_session.as_deref(), - "item-progress", + let progress_callback = |downloaded: u64, total: Option| { + emit_launch_prepare_item( + ipc_session, + "item-progress", + "mods", + &normalized_url, + &download_file_name, + "aot-package", + Some(downloaded as i64), + total.map(|value| value as i64), + None, + "Downloading", + ); + if let Some(total) = total.filter(|value| *value > 0) { + let percent = (downloaded.saturating_mul(100) / total).min(100) as i32; + emit_launch_prepare_ipc( + ipc_session, + "progress", "mods", - &progress_item_id, - &progress_item_name, - "aot-package", - Some(downloaded as i64), - total.map(|value| value as i64), + "download", + Some(percent), + Some(100), + &format!("Downloading {download_file_name} ({percent}%)"), None, - "Downloading", ); - if let Some(total) = total.filter(|value| *value > 0) { - let percent = (downloaded.saturating_mul(100) / total).min(100) as i32; - emit_launch_prepare_ipc( - progress_session.as_deref(), - "progress", - "mods", - "download", - Some(percent), - Some(100), - &format!("Downloading {progress_item_name} ({percent}%)"), - None, - ); - } - }); + } + }; - let download_result = download_file_blocking( - window, - "edge-aot", - &normalized_url, - &temp_download_path, - normalize_download_threads(Some(DEFAULT_DOWNLOAD_THREADS)), - Some(progress_callback), + let cancel = downloader::CancelToken::new(ipc_session); + let download_result = downloader::download( + &downloader::Request::single(&normalized_url, &temp_download_path, &cancel) + .sha256(Some(expected_checksum.as_str())) + .max_parts(normalize_download_threads(Some(DEFAULT_DOWNLOAD_THREADS))) + .on_progress(&progress_callback), ); emit_launch_prepare_item( ipc_session, @@ -3219,16 +3233,9 @@ fn install_edge_aot_package_blocking( "Downloaded", ); if let Err(err) = download_result { - let _ = fs::remove_file(&temp_download_path); return Err(format!("Failed to download Edge AOT package: {err}")); } - - if let Some(expected_checksum) = normalized_checksum.as_deref() { - if let Err(err) = verify_file_sha256(&temp_download_path, expected_checksum) { - let _ = fs::remove_file(&temp_download_path); - return Err(format!("Edge AOT package checksum mismatch: {err}")); - } - } + emit_log(window, "info", &format!("Downloaded Edge AOT package {download_file_name}")); let stage_dir = aot_dir.with_file_name(format!( ".fpsmaster-edge-aot-stage-{}-{}", @@ -3516,7 +3523,6 @@ fn install_fabric_api_blocking( "Downloading", ); if let Err(err) = download_file_quiet_with_progress_blocking( - &client, &file.url, &download_path, window, @@ -3616,6 +3622,15 @@ fn install_fabric_api_blocking( // download URL ends with (".../1783864173-0e0f....jar"). The extension is taken // from the URL when it looks like a real archive extension so that non-jar // packages (e.g. .zip) still route through the archive-extraction path. +/// Stable location for package downloads, so a `.part` left by an interrupted +/// attempt is resumed by the next one instead of being orphaned. +fn launcher_download_cache_path(file_name: &str) -> Result { + let dir = env::temp_dir().join("fpsmaster-launcher-downloads"); + fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create download cache {}: {e}", dir.display()))?; + Ok(dir.join(sanitize_file_name(file_name))) +} + fn launcher_mod_package_file_name(download_url: &str, version_id: &str, version_tag: &str) -> String { let ext = reqwest::Url::parse(download_url) .ok() @@ -3712,12 +3727,17 @@ fn install_launcher_version_mods_blocking( } else { let download_file_name = launcher_mod_package_file_name(&normalized_url, &version_id, &normalized_tag); - let temp_download_path = env::temp_dir().join(format!( - "fpsmaster-launcher-mods-{}-{}-{}", - std::process::id(), - now_epoch_millis(), - download_file_name - )); + // The package jars run inside the game JVM; never install them on TLS + // alone (launcher-core-14). + let expected_checksum = checksum + .as_deref() + .and_then(normalize_sha256_value) + .ok_or_else(|| { + "Launcher package checksum is missing or invalid; refusing to install an unverified package" + .to_string() + })?; + // Stable path so an interrupted download resumes on the next attempt. + let temp_download_path = launcher_download_cache_path(&download_file_name)?; emit_launch_prepare_ipc( ipc_session, "progress", @@ -3743,44 +3763,39 @@ fn install_launcher_version_mods_blocking( // The package is a single file, so unlike the manifest path there is no // per-file counter to move the bar. Stream byte-level progress instead so the // prepare dialog's task bar and the file row advance while it downloads. - let progress_session = ipc_session.map(str::to_string); - let progress_item_id = normalized_url.clone(); - let progress_item_name = download_file_name.clone(); - let progress_callback: DownloadProgressCallback = - Arc::new(move |downloaded: u64, total: Option| { - emit_launch_prepare_item( - progress_session.as_deref(), - "item-progress", + let progress_callback = |downloaded: u64, total: Option| { + emit_launch_prepare_item( + ipc_session, + "item-progress", + "mods", + &normalized_url, + &download_file_name, + "package", + Some(downloaded as i64), + total.map(|value| value as i64), + None, + "Downloading", + ); + if let Some(total) = total.filter(|value| *value > 0) { + let percent = (downloaded.saturating_mul(100) / total).min(100) as i32; + emit_launch_prepare_ipc( + ipc_session, + "progress", "mods", - &progress_item_id, - &progress_item_name, - "package", - Some(downloaded as i64), - total.map(|value| value as i64), + "download", + Some(percent), + Some(100), + &format!("Downloading {download_file_name} ({percent}%)"), None, - "Downloading", ); - if let Some(total) = total.filter(|value| *value > 0) { - let percent = (downloaded.saturating_mul(100) / total).min(100) as i32; - emit_launch_prepare_ipc( - progress_session.as_deref(), - "progress", - "mods", - "download", - Some(percent), - Some(100), - &format!("Downloading {progress_item_name} ({percent}%)"), - None, - ); - } - }); - let download_result = download_file_blocking( - None, - "launcher-mods", - &normalized_url, - &temp_download_path, - normalize_download_threads(Some(DEFAULT_DOWNLOAD_THREADS)), - Some(progress_callback), + } + }; + let cancel = downloader::CancelToken::new(ipc_session); + let download_result = downloader::download( + &downloader::Request::single(&normalized_url, &temp_download_path, &cancel) + .sha256(Some(expected_checksum.as_str())) + .max_parts(normalize_download_threads(Some(DEFAULT_DOWNLOAD_THREADS))) + .on_progress(&progress_callback), ); emit_launch_prepare_item( ipc_session, @@ -3795,15 +3810,8 @@ fn install_launcher_version_mods_blocking( "Downloaded", ); if let Err(err) = download_result { - let _ = fs::remove_file(&temp_download_path); return Err(format!("Failed to download launcher package: {err}")); } - if let Some(expected_checksum) = checksum.as_deref() { - if let Err(err) = verify_file_sha256(&temp_download_path, expected_checksum) { - let _ = fs::remove_file(&temp_download_path); - return Err(format!("Launcher package checksum mismatch: {err}")); - } - } let stage_dir = create_launcher_mods_stage_dir(&mods_dir)?; let stage_result = @@ -4196,7 +4204,7 @@ fn install_manifest_files_into_stage( manifest: &LauncherPackageManifest, stage_dir: &Path, ) -> Result, String> { - let client = build_blocking_http_client()?; + let cancel = downloader::CancelToken::new(ipc_session); let base_url = resolve_manifest_base_url(manifest_url, manifest.base_url.as_deref())?; let mut installed_files = Vec::new(); let total = manifest.files.len() as i32; @@ -4241,7 +4249,24 @@ fn install_manifest_files_into_stage( "Downloading", ); - download_file_quiet_blocking(&client, &resolved_url, &target_path).map_err(|err| { + // Every file in a launcher package ends up in the game JVM, so each + // one must carry a checksum (launcher-core-14). + let expected_sha256 = entry + .sha256 + .as_deref() + .and_then(normalize_sha256_value) + .ok_or_else(|| { + format!( + "Manifest file {} has no valid sha256; refusing to install an unverified file", + relative_path.display() + ) + })?; + downloader::download( + &downloader::Request::single(&resolved_url, &target_path, &cancel) + .size(entry.size) + .sha256(Some(expected_sha256.as_str())), + ) + .map_err(|err| { emit_launch_prepare_item( ipc_session, "item-error", @@ -4261,33 +4286,6 @@ fn install_manifest_files_into_stage( ) })?; - if let Some(expected_size) = entry.size { - let actual_size = fs::metadata(&target_path) - .map_err(|e| { - format!( - "Failed to inspect downloaded file {}: {e}", - target_path.display() - ) - })? - .len(); - if actual_size != expected_size { - return Err(format!( - "Manifest file size mismatch for {}: expected {}, got {}", - relative_path.display(), - expected_size, - actual_size - )); - } - } - if let Some(expected_sha256) = entry.sha256.as_deref() { - verify_file_sha256(&target_path, expected_sha256).map_err(|err| { - format!( - "Manifest file checksum mismatch for {}: {err}", - relative_path.display() - ) - })?; - } - installed_files.push(build_launcher_installed_file_record( &target_path, &relative_path, @@ -4342,18 +4340,21 @@ fn fetch_launcher_package_manifest(manifest_url: &str) -> Result Result { reqwest::blocking::Client::builder() .user_agent(LAUNCHER_HTTP_USER_AGENT) - .connect_timeout(std::time::Duration::from_secs(30)) - .timeout(std::time::Duration::from_secs(60 * 30)) + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(60)) .redirect(reqwest::redirect::Policy::limited(10)) .build() .map_err(|e| e.to_string()) } -/// 面向 api.fpsmaster.top 的短超时客户端。和 `build_blocking_http_client` 的区别 -/// 只在超时预算:那个是给大文件下载用的(30 分钟),API 调用必须快速失败。 +/// 面向 api.fpsmaster.top 的短超时客户端。API 调用必须快速失败。 pub(crate) fn build_api_http_client() -> Result { reqwest::blocking::Client::builder() .user_agent(LAUNCHER_HTTP_USER_AGENT) @@ -5833,14 +5834,6 @@ fn zip_archive_path(path: &Path) -> Result { Ok(output) } -pub(crate) fn download_file_quiet_blocking( - client: &reqwest::blocking::Client, - url: &str, - target: &Path, -) -> Result<(), String> { - download_file_with_progress_callback_blocking(client, url, target, |_, _| {}) -} - fn emit_content_install_progress( window: Option<&tauri::Window>, project_key: &str, @@ -5870,111 +5863,31 @@ fn emit_content_install_progress( ); } +/// Content downloads (Modrinth/CurseForge/Fabric API). They have no install +/// session, so they are only cancellable through `cancel_all_downloads`. fn download_file_quiet_with_progress_blocking( - client: &reqwest::blocking::Client, url: &str, target: &Path, window: Option<&tauri::Window>, project_key: Option<&str>, ) -> Result<(), String> { - download_file_with_progress_callback_blocking(client, url, target, |downloaded, total| { + let cancel = downloader::CancelToken::new(None); + download_file_with_progress_callback_blocking(&cancel, url, target, &|downloaded, total| { if let Some(key) = project_key { emit_content_install_progress(window, key, downloaded, total); } }) } -pub(crate) fn download_file_with_progress_callback_blocking( - client: &reqwest::blocking::Client, +/// Streams `url` into `target` through the unified downloader (resume, retry +/// with backoff, idle timeout, cancellation, throttled progress). +pub(crate) fn download_file_with_progress_callback_blocking( + cancel: &downloader::CancelToken, url: &str, target: &Path, - mut on_progress: F, -) -> Result<(), String> -where - F: FnMut(u64, Option), -{ - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|e| { - format!( - "Failed to create download directory {}: {e}", - parent.display() - ) - })?; - } - - let tmp = target.with_extension("download"); - let mut last_error = String::new(); - for attempt in 1..=3 { - let response = client - .get(url) - .header(reqwest::header::ACCEPT, "*/*") - .header(reqwest::header::ACCEPT_ENCODING, "identity") - .send(); - - let mut response = match response { - Ok(value) => value, - Err(err) => { - last_error = - format!("request failed on attempt {attempt}/3: {}", describe_http_error(&err)); - continue; - } - }; - if !response.status().is_success() { - last_error = format!("HTTP {}", response.status()); - continue; - } - let total_bytes = response.content_length(); - on_progress(0, total_bytes); - - let mut file = fs::File::create(&tmp) - .map_err(|e| format!("Failed to create temp file {}: {e}", tmp.display()))?; - let mut write_result = Ok(0_u64); - let mut downloaded_bytes = 0_u64; - let mut buffer = [0_u8; 64 * 1024]; - loop { - let read = match response.read(&mut buffer) { - Ok(0) => break, - Ok(value) => value, - Err(err) => { - write_result = Err(err); - break; - } - }; - if let Err(err) = file.write_all(&buffer[..read]) { - write_result = Err(err); - break; - } - downloaded_bytes += read as u64; - on_progress(downloaded_bytes, total_bytes); - } - match write_result { - Ok(_) => { - file.flush() - .map_err(|e| format!("Failed to flush temp file {}: {e}", tmp.display()))?; - if target.exists() { - fs::remove_file(target).map_err(|e| { - format!("Failed to replace existing file {}: {e}", target.display()) - })?; - } - fs::rename(&tmp, target).map_err(|e| { - format!( - "Failed to move downloaded file from {} to {}: {e}", - tmp.display(), - target.display() - ) - })?; - on_progress(downloaded_bytes, total_bytes.or(Some(downloaded_bytes))); - return Ok(()); - } - Err(err) => { - last_error = format!("read failed on attempt {attempt}/3: {err}"); - let _ = fs::remove_file(&tmp); - } - } - } - - let _ = fs::remove_file(&tmp); - Err(last_error) + on_progress: &(dyn Fn(u64, Option) + Sync), +) -> Result<(), String> { + downloader::download(&downloader::Request::single(url, target, cancel).on_progress(on_progress)) } fn resolve_manifest_base_url( @@ -6262,6 +6175,13 @@ async fn cancel_install(session_id: String) -> Result<(), String> { request_install_cancel(&session_id) } +/// Cancels every running download/install task (all sessions, plus content +/// downloads that have no session). Returns how many tasks were signalled. +#[tauri::command] +async fn cancel_all_downloads() -> usize { + downloader::cancel_all() +} + #[tauri::command] async fn list_vanilla_versions( window: tauri::Window, @@ -6689,9 +6609,19 @@ async fn install_native_app( download_url: String, version_tag: Option, checksum: Option, + ipc_session: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - install_native_app_blocking(game_dir, version_id, download_url, version_tag, checksum) + let result = install_native_app_blocking( + ipc_session.as_deref(), + game_dir, + version_id, + download_url, + version_tag, + checksum, + ); + clear_install_cancel(ipc_session.as_deref()); + result }) .await .map_err(|e| format!("Failed to join native app install task: {e}")) @@ -6700,6 +6630,7 @@ async fn install_native_app( } fn install_native_app_blocking( + ipc_session: Option<&str>, game_dir: String, version_id: String, download_url: String, @@ -6744,14 +6675,6 @@ fn install_native_app_blocking( } } - let client = reqwest::blocking::Client::builder() - .user_agent(JDK_DOWNLOAD_USER_AGENT) - .connect_timeout(Duration::from_secs(30)) - .timeout(Duration::from_secs(600)) - .redirect(reqwest::redirect::Policy::limited(10)) - .build() - .map_err(|e| e.to_string())?; - let tmp_dir = game_dir_path.join("apps").join(".downloads"); fs::create_dir_all(&tmp_dir) .map_err(|e| format!("Failed to create download dir {}: {e}", tmp_dir.display()))?; @@ -6760,11 +6683,13 @@ fn install_native_app_blocking( sanitize_file_name(&normalized_tag) )); - download_file_quiet_blocking(&client, &normalized_url, &archive_path)?; - verify_file_sha256(&archive_path, &normalized_checksum).map_err(|err| { - let _ = fs::remove_file(&archive_path); - format!("Checksum mismatch for {version_id}: {err}") - })?; + let cancel = downloader::CancelToken::new(ipc_session); + downloader::download( + &downloader::Request::single(&normalized_url, &archive_path, &cancel) + .sha256(Some(normalized_checksum.as_str())) + .max_parts(normalize_download_threads(Some(DEFAULT_DOWNLOAD_THREADS))), + ) + .map_err(|err| format!("Failed to download {version_id}: {err}"))?; let apps_dir = install_dir.parent().ok_or_else(|| { format!( @@ -7803,8 +7728,121 @@ fn is_mojang_bundle_layout(java_path: &Path) -> bool { .any(|component| component.as_os_str() == "jre.bundle") } +const RUNTIME_MARKER_FILE: &str = ".fpsmaster-runtime.json"; + +/// Written as the very last step of a runtime install. A runtime directory +/// without it (or whose files no longer match it) is treated as not installed, +/// so a download interrupted halfway is repaired instead of being launched +/// forever as a JRE missing `lib/modules` (launcher-core-1). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeInstallMarker { + source: String, + version: String, + platform: String, + major: i32, + installed_at: u64, + files: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RuntimeFileRecord { + path: String, + size: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + sha1: Option, +} + +fn runtime_staging_dir(runtime_root: &Path) -> PathBuf { + let name = runtime_root + .file_name() + .map(|value| value.to_string_lossy().to_string()) + .unwrap_or_else(|| "runtime".to_string()); + runtime_root.with_file_name(format!(".{name}.staging")) +} + +fn read_runtime_marker(runtime_root: &Path) -> Option { + let raw = fs::read(runtime_root.join(RUNTIME_MARKER_FILE)).ok()?; + serde_json::from_slice(&raw).ok() +} + +fn runtime_relative_path(root: &Path, relative: &str) -> PathBuf { + relative + .replace('\\', "/") + .split('/') + .filter(|component| !component.is_empty() && *component != ".." && *component != ".") + .fold(root.to_path_buf(), |acc, component| acc.join(component)) +} + +/// Checks a managed runtime against its completion marker. `deep` also +/// re-hashes files whose SHA1 is known (used by the health check). +fn verify_runtime_install(runtime_root: &Path, deep: bool) -> Result { + let marker = read_runtime_marker(runtime_root) + .ok_or_else(|| "installation was not completed (no completion marker)".to_string())?; + for file in &marker.files { + let path = runtime_relative_path(runtime_root, &file.path); + let size = fs::metadata(&path) + .map_err(|_| format!("missing file {}", file.path))? + .len(); + if size != file.size { + return Err(format!( + "file {} has {size} bytes, expected {}", + file.path, file.size + )); + } + if deep { + if let Some(expected) = file.sha1.as_deref() { + let actual = compute_sha1_hex(&path)?; + if !actual.eq_ignore_ascii_case(expected) { + return Err(format!("file {} is corrupted (SHA1 mismatch)", file.path)); + } + } + } + } + locate_java_binary(runtime_root).ok_or_else(|| "java executable not found".to_string()) +} + +/// Records every regular file below `root` (used for archive-based installs +/// that have no manifest of their own). +fn collect_runtime_file_records(root: &Path) -> Result, String> { + let mut records = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = + fs::read_dir(&dir).map_err(|e| format!("Failed to scan {}: {e}", dir.display()))?; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = fs::symlink_metadata(&path) else { + continue; + }; + if meta.is_dir() { + stack.push(path); + } else if meta.is_file() { + let relative = path + .strip_prefix(root) + .map_err(|e| e.to_string())? + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>() + .join("/"); + if relative == RUNTIME_MARKER_FILE { + continue; + } + records.push(RuntimeFileRecord { + path: relative, + size: meta.len(), + sha1: None, + }); + } + } + } + records.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(records) +} + fn ensure_managed_jdk_runtime( window: Option<&tauri::Window>, + cancel: &downloader::CancelToken, runtime_root: &Path, major: i32, download_threads: Option, @@ -7813,15 +7851,13 @@ fn ensure_managed_jdk_runtime( if force_x64 { ensure_x64_emulation_available()?; } - fs::create_dir_all(runtime_root).map_err(|e| { - format!( - "Failed creating runtime dir {}: {e}", - runtime_root.display() - ) - })?; - if let Some(java_path) = locate_java_binary(runtime_root) { - if prefers_zulu_over_mojang_x64(major, force_x64) && is_mojang_bundle_layout(&java_path) { + let mut discard_existing = false; + match verify_runtime_install(runtime_root, false) { + Ok(java_path) + if prefers_zulu_over_mojang_x64(major, force_x64) + && is_mojang_bundle_layout(&java_path) => + { // A Mojang jre-legacy install from before the Zulu switch — it crashes on // modern macOS (see below), so wipe it and reinstall instead of reusing it. emit_log( @@ -7829,19 +7865,9 @@ fn ensure_managed_jdk_runtime( "warn", "Replacing Mojang jre-legacy x64 runtime: its AWT crashes on macOS 14.4+", ); - fs::remove_dir_all(runtime_root).map_err(|e| { - format!( - "Failed removing outdated runtime {}: {e}", - runtime_root.display() - ) - })?; - fs::create_dir_all(runtime_root).map_err(|e| { - format!( - "Failed creating runtime dir {}: {e}", - runtime_root.display() - ) - })?; - } else { + discard_existing = true; + } + Ok(java_path) => { emit_log( window, "info", @@ -7852,22 +7878,54 @@ fn ensure_managed_jdk_runtime( ); return Ok(java_path); } + Err(reason) if runtime_root.exists() => emit_log( + window, + "warn", + &format!("Managed JDK major={major} is incomplete ({reason}); repairing it"), + ), + Err(_) => {} } - let normalized_download_threads = normalize_download_threads(download_threads); - let component = mojang_java_component_for_major(major); - let platform_keys = mojang_java_platform_keys(); - let native_key = platform_keys.first().copied(); - let x64_key = platform_keys.last().copied(); - emit_log( - window, - "info", - &format!( + // Install into a sibling staging directory and only promote it once the + // completion marker is written. An unfinished runtime directory is moved + // into staging rather than deleted: the per-file SHA1 skip in the Mojang + // path then only re-downloads what is actually missing or corrupt. + let staging = runtime_staging_dir(runtime_root); + if runtime_root.exists() { + if discard_existing || staging.exists() { + fs::remove_dir_all(runtime_root).map_err(|e| { + format!( + "Failed removing outdated runtime {}: {e}", + runtime_root.display() + ) + })?; + } else { + fs::rename(runtime_root, &staging).map_err(|e| { + format!( + "Failed to move incomplete runtime {} aside: {e}", + runtime_root.display() + ) + })?; + } + } + let _ = fs::remove_file(staging.join(RUNTIME_MARKER_FILE)); + fs::create_dir_all(&staging) + .map_err(|e| format!("Failed creating runtime dir {}: {e}", staging.display()))?; + + let normalized_download_threads = normalize_download_threads(download_threads); + let component = mojang_java_component_for_major(major); + let platform_keys = mojang_java_platform_keys(); + let native_key = platform_keys.first().copied(); + let x64_key = platform_keys.last().copied(); + emit_log( + window, + "info", + &format!( "Resolving Mojang runtime component={component} major={major} force_x64={force_x64}" ), ); - if force_x64 { + let marker = if force_x64 { // The game ships only x64 native libs (old LWJGL); the JVM must be x64 too, so // it runs under Rosetta 2 / Windows-on-ARM emulation. A native-arch JVM here // would fail to load the natives (UnsatisfiedLinkError). @@ -7879,90 +7937,121 @@ fn ensure_managed_jdk_runtime( // +[NSApplication _crashOnException:] takes the process down with SIGILL // (fixed upstream in 8u412). Install a current Zulu x64 JRE instead and keep // the Mojang runtime only as a fallback when Azul is unreachable. - let mut installed = false; + let mut installed = None; if prefers_zulu_over_mojang_x64(major, force_x64) { match install_zulu_jre_archive( window, - runtime_root, + cancel, + &staging, major, normalized_download_threads, "x64", ) { - Ok(()) => installed = true, - Err(zulu_err) => emit_log( - window, - "warn", - &format!( - "Zulu x64 JRE unavailable ({zulu_err}); falling back to Mojang x64 runtime" - ), - ), + Ok(marker) => installed = Some(marker), + Err(zulu_err) => { + cancel.check()?; + emit_log( + window, + "warn", + &format!( + "Zulu x64 JRE unavailable ({zulu_err}); falling back to Mojang x64 runtime" + ), + ) + } } } - if !installed - && !install_mojang_java_runtime( + match installed { + Some(marker) => marker, + None => install_mojang_java_runtime( window, - runtime_root, + cancel, + &staging, component, major, normalized_download_threads, &[key], )? - { - return Err(format!("No x64 Mojang runtime available for {component}")); + .ok_or_else(|| format!("No x64 Mojang runtime available for {component}"))?, } } else { // 1) Mojang's runtime for the native CPU arch (no emulation). let native = native_key .ok_or_else(|| format!("Unsupported platform for Mojang runtime, major={major}"))?; - let installed = install_mojang_java_runtime( + match install_mojang_java_runtime( window, - runtime_root, + cancel, + &staging, component, major, normalized_download_threads, &[native], - )?; - if !installed { - // 2) Mojang has no native build (e.g. macOS arm64 + Java 8/16). Try a native - // third-party JRE before considering emulation. - emit_log( - window, - "info", - &format!("No native Mojang runtime for {component}; fetching native JRE for major={major}"), - ); - match install_native_jre_archive( - window, - runtime_root, - major, - normalized_download_threads, - ) { - Ok(()) => {} - Err(native_err) => { - // 3) Last resort: Mojang's x64 build via emulation. - emit_log( - window, - "warn", - &format!("Native JRE unavailable ({native_err}); falling back to emulated x64 runtime"), - ); - let key = x64_key - .ok_or_else(|| format!("No Java runtime available for major={major}"))?; - if !install_mojang_java_runtime( - window, - runtime_root, - component, - major, - normalized_download_threads, - &[key], - )? { - return Err(format!("No Java runtime available for major={major}")); + )? { + Some(marker) => marker, + None => { + // 2) Mojang has no native build (e.g. macOS arm64 + Java 8/16). Try a native + // third-party JRE before considering emulation. + emit_log( + window, + "info", + &format!("No native Mojang runtime for {component}; fetching native JRE for major={major}"), + ); + match install_native_jre_archive( + window, + cancel, + &staging, + major, + normalized_download_threads, + ) { + Ok(marker) => marker, + Err(native_err) => { + cancel.check()?; + // 3) Last resort: Mojang's x64 build via emulation. + emit_log( + window, + "warn", + &format!("Native JRE unavailable ({native_err}); falling back to emulated x64 runtime"), + ); + let key = x64_key + .ok_or_else(|| format!("No Java runtime available for major={major}"))?; + install_mojang_java_runtime( + window, + cancel, + &staging, + component, + major, + normalized_download_threads, + &[key], + )? + .ok_or_else(|| format!("No Java runtime available for major={major}"))? } } } } - } + }; - locate_java_binary(runtime_root) - .ok_or_else(|| "JDK extracted but java executable not found".to_string()) + if locate_java_binary(&staging).is_none() { + return Err("JDK extracted but java executable not found".to_string()); + } + let marker_bytes = serde_json::to_vec_pretty(&marker) + .map_err(|e| format!("Failed to encode runtime marker: {e}"))?; + fs::write(staging.join(RUNTIME_MARKER_FILE), marker_bytes) + .map_err(|e| format!("Failed to write runtime marker: {e}"))?; + if runtime_root.exists() { + fs::remove_dir_all(runtime_root).map_err(|e| { + format!( + "Failed to replace runtime {}: {e}", + runtime_root.display() + ) + })?; + } + fs::rename(&staging, runtime_root).map_err(|e| { + format!( + "Failed to activate runtime {}: {e}", + runtime_root.display() + ) + })?; + verify_runtime_install(runtime_root, false) + .map_err(|e| format!("Installed runtime failed verification: {e}")) } #[cfg(windows)] @@ -8181,6 +8270,7 @@ async fn install_optifine( let java_major = requirement.major_version.max(8); Some(ensure_managed_jdk_runtime( Some(&window), + &downloader::CancelToken::new(session.as_deref()), &managed_jdk_runtime_root(&game_dir_path, java_major, false), java_major, Some(DEFAULT_DOWNLOAD_THREADS), @@ -8244,6 +8334,7 @@ async fn install_forge( let java_major = requirement.major_version.max(8); ensure_managed_jdk_runtime( Some(&window), + &downloader::CancelToken::new(session.as_deref()), &managed_jdk_runtime_root(&game_dir_path, java_major, false), java_major, Some(normalized_download_threads as i32), @@ -8494,12 +8585,13 @@ fn mojang_java_platform_keys() -> Vec<&'static str> { /// when a runtime was installed. fn install_mojang_java_runtime( window: Option<&tauri::Window>, + cancel: &downloader::CancelToken, runtime_root: &Path, component: &str, major: i32, download_threads: usize, platform_keys: &[&str], -) -> Result { +) -> Result, String> { if platform_keys.is_empty() { return Err(format!( "Unsupported platform for Mojang runtime, major={major}" @@ -8521,7 +8613,7 @@ fn install_mojang_java_runtime( }); let (platform, candidate) = match selected { Some(found) => found, - None => return Ok(false), + None => return Ok(None), }; emit_log( @@ -8535,8 +8627,29 @@ fn install_mojang_java_runtime( let manifest: MojangJavaManifest = fetch_json(window, "Mojang Java manifest", &candidate.manifest.url)?; - download_mojang_runtime_files(window, runtime_root, &manifest, download_threads)?; - Ok(true) + download_mojang_runtime_files(window, cancel, runtime_root, &manifest, download_threads)?; + let mut files = manifest + .files + .iter() + .filter(|(_, entry)| entry.entry_type == "file") + .filter_map(|(path, entry)| { + let raw = entry.downloads.get("raw")?; + Some(RuntimeFileRecord { + path: path.replace('\\', "/"), + size: raw.size, + sha1: Some(raw.sha1.clone()), + }) + }) + .collect::>(); + files.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(Some(RuntimeInstallMarker { + source: format!("mojang:{component}"), + version: candidate.version.name.clone(), + platform: platform.to_string(), + major, + installed_at: now_epoch_seconds(), + files, + })) } /// A native third-party JRE (Azul Zulu) used when Mojang ships no native build for this @@ -8573,12 +8686,13 @@ fn azul_arch() -> Option<&'static str> { /// Downloads and extracts a native Zulu JRE for the given Java major into `runtime_root`. fn install_native_jre_archive( window: Option<&tauri::Window>, + cancel: &downloader::CancelToken, runtime_root: &Path, major: i32, download_threads: usize, -) -> Result<(), String> { +) -> Result { let arch = azul_arch().ok_or_else(|| "Unsupported CPU arch for native JRE".to_string())?; - install_zulu_jre_archive(window, runtime_root, major, download_threads, arch) + install_zulu_jre_archive(window, cancel, runtime_root, major, download_threads, arch) } /// Downloads and extracts a Zulu JRE for an explicit CPU arch into `runtime_root`. @@ -8586,11 +8700,12 @@ fn install_native_jre_archive( /// current Zulu build replaces Mojang's unusably old jre-legacy. fn install_zulu_jre_archive( window: Option<&tauri::Window>, + cancel: &downloader::CancelToken, runtime_root: &Path, major: i32, download_threads: usize, arch: &str, -) -> Result<(), String> { +) -> Result { let os = azul_os().ok_or_else(|| "Unsupported OS for native JRE".to_string())?; let archive_type = if cfg!(windows) { "zip" } else { "tar.gz" }; let api_url = format!( @@ -8609,25 +8724,46 @@ fn install_zulu_jre_archive( "info", &format!("Downloading JDK {} ({os}/{arch})", package.name), ); - let archive_path = runtime_root.join(format!(".zulu-download.{archive_type}")); - download_file_blocking( - window, - "native-jre", - &package.download_url, - &archive_path, - download_threads, - None, - )?; - verify_file_sha256(&archive_path, &package.sha256_hash) - .map_err(|e| format!("Native JRE checksum failed: {e}"))?; + // Kept outside the staging directory under a stable name so an interrupted + // download resumes instead of starting over. + let archive_path = runtime_root + .parent() + .unwrap_or(runtime_root) + .join(".downloads") + .join(format!("{}.{archive_type}", sanitize_file_name(&package.name))); + let progress = |downloaded: u64, total: Option| match total.filter(|value| *value > 0) { + Some(total) => emit_log( + window, + "info", + &format!( + "JDK download progress: {}% ({downloaded}/{total} bytes)", + downloaded.saturating_mul(100) / total + ), + ), + None => emit_log( + window, + "info", + &format!("JDK download progress: {downloaded} bytes"), + ), + }; + downloader::download( + &downloader::Request::single(&package.download_url, &archive_path, cancel) + .sha256(Some(package.sha256_hash.as_str())) + .max_parts(download_threads) + .on_progress(&progress), + ) + .map_err(|e| format!("Native JRE download failed: {e}"))?; + // An archive install replaces whatever a previous attempt left in staging. + let _ = fs::remove_dir_all(runtime_root); + fs::create_dir_all(runtime_root) + .map_err(|e| format!("Failed creating runtime dir {}: {e}", runtime_root.display()))?; emit_log(window, "info", "Extracting JDK archive"); let extract_result = if archive_type == "zip" { extract_zip_into(&archive_path, runtime_root) } else { extract_tar_gz_into(&archive_path, runtime_root) }; - let _ = fs::remove_file(&archive_path); if let Err(error) = extract_result { // A capped extraction may have written files before it reaches the // limit. Never let a later runtime probe mistake that partial tree for @@ -8635,7 +8771,15 @@ fn install_zulu_jre_archive( let _ = fs::remove_dir_all(runtime_root); return Err(error); } - Ok(()) + let _ = fs::remove_file(&archive_path); + Ok(RuntimeInstallMarker { + source: "zulu".to_string(), + version: package.name, + platform: format!("{os}-{arch}"), + major, + installed_at: now_epoch_seconds(), + files: collect_runtime_file_records(runtime_root)?, + }) } fn extract_tar_gz_into(archive_path: &Path, dest: &Path) -> Result<(), String> { @@ -8743,6 +8887,7 @@ fn parse_java_major_version(version: &str) -> i32 { fn download_mojang_runtime_files( window: Option<&tauri::Window>, + cancel: &downloader::CancelToken, runtime_root: &Path, manifest: &MojangJavaManifest, download_threads: usize, @@ -8770,25 +8915,32 @@ fn download_mojang_runtime_files( let downloaded = Arc::clone(&downloaded); let runtime_root = runtime_root.to_path_buf(); let window = window.cloned(); + let cancel = cancel.clone(); workers.push(thread::spawn(move || loop { + if lock_recover(&error).is_some() { + return; + } + if let Err(cancelled) = cancel.check() { + let mut slot = lock_recover(&error); + if slot.is_none() { + *slot = Some(cancelled); + } + return; + } let next = lock_recover(&jobs).pop_front(); let Some((relative_path, entry)) = next else { return; }; - if lock_recover(&error).is_some() { - return; - } - - // One part per file: this pool already has `worker_count` files in - // flight, so also sharding each file would multiply out to - // `threads × threads` simultaneous connections. + // One file per worker connection: this pool already has + // `worker_count` files in flight, so the downloader is not asked to + // shard them further. let result = process_mojang_runtime_entry( window.as_ref(), + &cancel, &runtime_root, &relative_path, &entry, - 1, ); match result { @@ -8837,7 +8989,7 @@ fn download_mojang_runtime_files( return Err("Mojang runtime worker panicked".to_string()); } for (relative_path, entry) in links { - let fetched = process_mojang_runtime_entry(window, runtime_root, &relative_path, &entry, 1) + let fetched = process_mojang_runtime_entry(window, cancel, runtime_root, &relative_path, &entry) .map_err(|err| { format!("Failed processing Mojang runtime entry {relative_path}: {err}") })?; @@ -8871,21 +9023,15 @@ fn clone_mojang_entry(entry: &MojangJavaRemoteEntry) -> MojangJavaRemoteEntry { fn process_mojang_runtime_entry( window: Option<&tauri::Window>, + cancel: &downloader::CancelToken, runtime_root: &Path, relative_path: &str, entry: &MojangJavaRemoteEntry, - max_parts: usize, ) -> Result { // Manifest paths are always forward-slash separated. Join component-by-component so // the OS separator is used on every platform — `replace('/', "\\")` would create a // single file literally named "a\b\c" on macOS/Linux (where '\' isn't a separator). - let target = relative_path - .replace('\\', "/") - .split('/') - .filter(|component| !component.is_empty() && *component != ".." && *component != ".") - .fold(runtime_root.to_path_buf(), |acc, component| { - acc.join(component) - }); + let target = runtime_relative_path(runtime_root, relative_path); match entry.entry_type.as_str() { "directory" => { fs::create_dir_all(&target).map_err(|e| e.to_string())?; @@ -8916,90 +9062,90 @@ fn process_mojang_runtime_entry( } Ok(true) } - "file" => download_mojang_runtime_file(window, &target, entry, max_parts), + "file" => download_mojang_runtime_file(window, cancel, &target, entry), other => Err(format!("Unsupported Mojang runtime entry type: {other}")), } } fn download_mojang_runtime_file( window: Option<&tauri::Window>, + cancel: &downloader::CancelToken, target: &Path, entry: &MojangJavaRemoteEntry, - max_parts: usize, ) -> Result { if let Some(parent) = target.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - if let Some(raw) = entry.downloads.get("raw") { + let raw = entry.downloads.get("raw"); + if let Some(raw) = raw { if target.is_file() { let size_ok = fs::metadata(target) .map(|meta| meta.len() == raw.size) .unwrap_or(false); - let sha1_ok = compute_sha1_hex(target) - .map(|sha1| sha1.eq_ignore_ascii_case(&raw.sha1)) - .unwrap_or(false); - if size_ok && sha1_ok { + if size_ok + && compute_sha1_hex(target) + .map(|sha1| sha1.eq_ignore_ascii_case(&raw.sha1)) + .unwrap_or(false) + { return Ok(false); } } } if let Some(lzma) = entry.downloads.get("lzma") { - let temp_lzma = target.with_extension("lzma.download"); - download_file_with_sha1( - window, - "mojang-runtime-lzma", - &lzma.url, - &temp_lzma, - &lzma.sha1, - max_parts, + // Temp names append to the full file name: `with_extension` turned both + // bin/java.exe and bin/java.dll into bin/java.lzma.download, and two + // workers then raced on one temp file (launcher-core-2). + let temp_lzma = downloader::with_suffix(target, ".lzma"); + downloader::download( + &downloader::Request::single(&lzma.url, &temp_lzma, cancel) + .size(Some(lzma.size)) + .sha1(Some(lzma.sha1.as_str())), )?; - let temp_output = target.with_extension("tmp"); - let input = fs::File::open(&temp_lzma).map_err(|e| e.to_string())?; - let stream = Stream::new_auto_decoder(u64::MAX, 0).map_err(|e| e.to_string())?; - let mut decoder = XzDecoder::new_stream(BufReader::new(input), stream); - let mut output = fs::File::create(&temp_output).map_err(|e| e.to_string())?; - if let Err(error) = std::io::copy(&mut decoder, &mut output) { - let _ = fs::remove_file(&temp_output); - return Err(error.to_string()); - } - if let Err(error) = output.flush() { - let _ = fs::remove_file(&temp_output); - return Err(error.to_string()); - } - drop(output); - if let Some(raw) = entry.downloads.get("raw") { - let sha1 = match compute_sha1_hex(&temp_output) { - Ok(sha1) => sha1, - Err(error) => { - let _ = fs::remove_file(&temp_output); - return Err(error); + let temp_output = downloader::with_suffix(target, ".unpacking"); + let unpack = (|| -> Result<(), String> { + let input = fs::File::open(&temp_lzma).map_err(|e| e.to_string())?; + let stream = Stream::new_auto_decoder(u64::MAX, 0).map_err(|e| e.to_string())?; + let mut decoder = XzDecoder::new_stream(BufReader::new(input), stream); + let mut output = fs::File::create(&temp_output).map_err(|e| e.to_string())?; + std::io::copy(&mut decoder, &mut output).map_err(|e| e.to_string())?; + output.flush().map_err(|e| e.to_string())?; + drop(output); + if let Some(raw) = raw { + let sha1 = compute_sha1_hex(&temp_output)?; + if !sha1.eq_ignore_ascii_case(&raw.sha1) { + return Err(format!( + "SHA1 mismatch after decompressing Mojang runtime {}: expected={} actual={sha1}", + target.display(), + raw.sha1 + )); } - }; - if !sha1.eq_ignore_ascii_case(&raw.sha1) { - let _ = fs::remove_file(&temp_output); - return Err(format!( - "SHA1 mismatch after decompressing Mojang runtime {}: expected={} actual={sha1}", - target.display(), - raw.sha1 - )); } - } - fs::rename(&temp_output, target).map_err(|e| e.to_string())?; + if target.exists() { + fs::remove_file(target).map_err(|e| e.to_string())?; + } + fs::rename(&temp_output, target).map_err(|e| e.to_string()) + })(); let _ = fs::remove_file(&temp_lzma); - } else if let Some(raw) = entry.downloads.get("raw") { - download_file_with_sha1( - window, - "mojang-runtime-raw", - &raw.url, - target, - &raw.sha1, - max_parts, + if let Err(error) = unpack { + let _ = fs::remove_file(&temp_output); + return Err(error); + } + } else if let Some(raw) = raw { + downloader::download( + &downloader::Request::single(&raw.url, target, cancel) + .size(Some(raw.size)) + .sha1(Some(raw.sha1.as_str())), )?; } else { return Err("No downloadable source found".to_string()); } + emit_log( + window, + "info", + &format!("Downloaded runtime file: {}", redact_path_for_log(target)), + ); if entry.executable { #[cfg(unix)] @@ -9015,29 +9161,6 @@ fn download_mojang_runtime_file( Ok(true) } -fn download_file_with_sha1( - window: Option<&tauri::Window>, - source_name: &str, - url: &str, - target: &Path, - expected_sha1: &str, - max_parts: usize, -) -> Result<(), String> { - download_file_blocking(window, source_name, url, target, max_parts, None)?; - let sha1 = compute_sha1_hex(target)?; - if !sha1.eq_ignore_ascii_case(expected_sha1) { - return Err(format!( - "SHA1 mismatch for {url}: expected={expected_sha1} actual={sha1}" - )); - } - emit_log( - window, - "info", - &format!("Downloaded {source_name}: {}", target.display()), - ); - Ok(()) -} - fn compute_sha1_hex(path: &Path) -> Result { use sha1::{Digest as Sha1Digest, Sha1}; let mut file = fs::File::open(path).map_err(|e| e.to_string())?; @@ -9096,352 +9219,6 @@ struct MojangJavaRemoteEntry { target: String, } -/// Reports raw byte progress during a download: `(downloaded_bytes, total_bytes)`. -/// Owned (`Arc`) so it can be cloned into the parallel-download worker threads, -/// which require `'static + Send + Sync` closures. -type DownloadProgressCallback = Arc) + Send + Sync>; - -/// Shared client for runtime/JDK downloads. -/// -/// This used to be built per call. A Mojang Java manifest is hundreds of small -/// files, so that meant a fresh DNS + TCP + TLS handshake for every one of them -/// and no keep-alive reuse at all — the dominant cost of installing a runtime. -/// The 30-minute timeout is a whole-request budget for the multi-hundred-MB -/// archive downloads that also use this path. -fn jdk_download_client() -> Result<&'static reqwest::blocking::Client, String> { - static CLIENT: OnceLock = OnceLock::new(); - if let Some(client) = CLIENT.get() { - return Ok(client); - } - let client = reqwest::blocking::Client::builder() - .user_agent(JDK_DOWNLOAD_USER_AGENT) - .connect_timeout(std::time::Duration::from_secs(30)) - .timeout(std::time::Duration::from_secs(60 * 30)) - .redirect(reqwest::redirect::Policy::limited(10)) - .build() - .map_err(|e| e.to_string())?; - Ok(CLIENT.get_or_init(|| client)) -} - -/// Downloads one file, optionally splitting it into `max_parts` Range requests. -/// -/// `max_parts` is a budget for *this file only*. Callers that are already -/// downloading many files concurrently must pass 1: multiplying an outer worker -/// pool by an inner part pool opened `threads × threads` sockets at once, which -/// on a 16-thread setting meant 256 concurrent connections and made the runtime -/// install slower than a plain sequential fetch. -fn download_file_blocking( - window: Option<&tauri::Window>, - source_name: &str, - url: &str, - target: &Path, - max_parts: usize, - progress: Option, -) -> Result<(), String> { - const MIN_PARALLEL_SIZE: u64 = 8 * 1024 * 1024; - const MIN_PART_SIZE: u64 = 4 * 1024 * 1024; - - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - - let client = jdk_download_client()?; - - // The probe costs an extra HEAD round trip, so it is only worth doing when - // sharding is actually on the table. - if max_parts > 1 { - if let Ok(Some(total_size)) = probe_parallel_download_support(client, url) { - if total_size >= MIN_PARALLEL_SIZE { - let size_limited_parts = total_size.div_ceil(MIN_PART_SIZE) as usize; - let part_count = max_parts.min(size_limited_parts.max(1)); - if part_count > 1 { - emit_log( - window, - "info", - &format!( - "JDK download ({source_name}) using {part_count} parallel threads, size={total_size} bytes" - ), - ); - return download_file_blocking_parallel( - window, - client, - source_name, - url, - target, - total_size, - part_count, - progress, - ); - } - } - } - } - - download_file_blocking_single(window, client, source_name, url, target, progress) -} - -fn probe_parallel_download_support( - client: &reqwest::blocking::Client, - url: &str, -) -> Result, String> { - let response = client - .head(url) - .header(reqwest::header::ACCEPT, "*/*") - .send() - .map_err(|e| e.to_string())?; - if !response.status().is_success() { - return Ok(None); - } - let supports_range = response - .headers() - .get(reqwest::header::ACCEPT_RANGES) - .and_then(|value| value.to_str().ok()) - .map(|value| value.to_ascii_lowercase().contains("bytes")) - .unwrap_or(false); - if !supports_range { - return Ok(None); - } - Ok(response.content_length()) -} - -fn download_file_blocking_single( - window: Option<&tauri::Window>, - client: &reqwest::blocking::Client, - source_name: &str, - url: &str, - target: &Path, - progress: Option, -) -> Result<(), String> { - let mut last_error = String::new(); - for attempt in 1..=3 { - let tmp = target.with_extension("download"); - let mut response = match client - .get(url) - .header(reqwest::header::USER_AGENT, JDK_DOWNLOAD_USER_AGENT) - .header(reqwest::header::ACCEPT, "*/*") - .header(reqwest::header::ACCEPT_ENCODING, "identity") - .send() - { - Ok(resp) => resp, - Err(err) => { - last_error = describe_http_error(&err); - emit_log( - window, - "stderr", - &format!( - "JDK download ({source_name}) attempt {attempt}/3 request failed: {last_error}" - ), - ); - continue; - } - }; - - if !response.status().is_success() { - last_error = format!("HTTP {}", response.status()); - emit_log( - window, - "stderr", - &format!("JDK download ({source_name}) attempt {attempt}/3 failed: {last_error}"), - ); - continue; - } - - let total = response.content_length(); - let mut file = fs::File::create(&tmp).map_err(|e| e.to_string())?; - let mut downloaded: u64 = 0; - let mut buffer = [0_u8; 64 * 1024]; - let mut last_percent = 0_u64; - - loop { - let read = response.read(&mut buffer).map_err(|e| { - format!( - "attempt {attempt}/3 read failed after {} bytes: {e}", - downloaded - ) - }); - - let read = match read { - Ok(value) => value, - Err(err) => { - last_error = err; - emit_log( - window, - "stderr", - &format!("JDK download ({source_name}) {last_error}"), - ); - let _ = fs::remove_file(&tmp); - break; - } - }; - - if read == 0 { - file.flush().map_err(|e| e.to_string())?; - fs::rename(&tmp, target).map_err(|e| e.to_string())?; - emit_log(window, "info", "JDK download progress: 100%"); - return Ok(()); - } - - file.write_all(&buffer[..read]).map_err(|e| e.to_string())?; - downloaded += read as u64; - - if let Some(total_size) = total { - if total_size > 0 { - let percent = downloaded.saturating_mul(100) / total_size; - if percent >= last_percent + 2 || percent == 100 { - last_percent = percent; - emit_log( - window, - "info", - &format!("JDK download progress: {percent}% ({downloaded}/{total_size} bytes)"), - ); - if let Some(callback) = progress.as_ref() { - callback(downloaded, Some(total_size)); - } - } - } - } else if downloaded % (1024 * 1024) < 64 * 1024 { - emit_log( - window, - "info", - &format!("JDK download progress: {downloaded} bytes"), - ); - if let Some(callback) = progress.as_ref() { - callback(downloaded, None); - } - } - } - } - - emit_log( - window, - "stderr", - &format!("JDK download ({source_name}) failed after 3 attempts: {last_error}"), - ); - Err(last_error) -} - -fn download_file_blocking_parallel( - window: Option<&tauri::Window>, - client: &reqwest::blocking::Client, - source_name: &str, - url: &str, - target: &Path, - total_size: u64, - part_count: usize, - progress_cb: Option, -) -> Result<(), String> { - let tmp = target.with_extension("download"); - let chunk_size = total_size.div_ceil(part_count as u64); - let progress = Arc::new(std::sync::atomic::AtomicU64::new(0)); - let last_percent = Arc::new(std::sync::atomic::AtomicU64::new(0)); - let mut handles = Vec::with_capacity(part_count); - let mut part_paths = Vec::with_capacity(part_count); - - for part_index in 0..part_count { - let start = chunk_size * part_index as u64; - if start >= total_size { - break; - } - let end = (start + chunk_size).min(total_size) - 1; - let client = client.clone(); - let part_path = target.with_extension(format!("download.part{part_index}")); - let part_path_for_thread = part_path.clone(); - let url = url.to_string(); - let source_name = source_name.to_string(); - let progress = Arc::clone(&progress); - let last_percent = Arc::clone(&last_percent); - let progress_callback = progress_cb.clone(); - let window = window.cloned(); - part_paths.push(part_path.clone()); - handles.push(thread::spawn(move || -> Result<(), String> { - let mut response = client - .get(&url) - .header(reqwest::header::ACCEPT, "*/*") - .header(reqwest::header::RANGE, format!("bytes={start}-{end}")) - .send() - .map_err(|e| format!("part {part_index} request failed: {}", describe_http_error(&e)))?; - if response.status() != reqwest::StatusCode::PARTIAL_CONTENT { - return Err(format!( - "part {part_index} expected HTTP 206 but got {}", - response.status() - )); - } - let mut file = fs::File::create(&part_path_for_thread).map_err(|e| e.to_string())?; - let mut buffer = [0_u8; 64 * 1024]; - loop { - let read = response.read(&mut buffer).map_err(|e| { - format!("part {part_index} read failed for {source_name}: {e}") - })?; - if read == 0 { - file.flush().map_err(|e| e.to_string())?; - return Ok(()); - } - file.write_all(&buffer[..read]).map_err(|e| e.to_string())?; - let downloaded = - progress.fetch_add(read as u64, Ordering::Relaxed) + read as u64; - let percent = downloaded.saturating_mul(100) / total_size; - let previous = last_percent.load(Ordering::Relaxed); - if percent >= previous + 2 || percent == 100 { - if last_percent - .compare_exchange( - previous, - percent, - Ordering::Relaxed, - Ordering::Relaxed, - ) - .is_ok() - { - emit_log( - window.as_ref(), - "info", - &format!( - "JDK download progress: {percent}% ({downloaded}/{total_size} bytes)" - ), - ); - if let Some(callback) = progress_callback.as_ref() { - callback(downloaded, Some(total_size)); - } - } - } - } - })); - } - - for handle in handles { - match handle.join() { - Ok(Ok(())) => {} - Ok(Err(err)) => { - cleanup_download_parts(&part_paths, &tmp); - return Err(err); - } - Err(_) => { - cleanup_download_parts(&part_paths, &tmp); - return Err("JDK parallel download worker panicked".to_string()); - } - } - } - - let mut output = fs::File::create(&tmp).map_err(|e| e.to_string())?; - for part_path in &part_paths { - let mut input = fs::File::open(part_path).map_err(|e| e.to_string())?; - std::io::copy(&mut input, &mut output).map_err(|e| e.to_string())?; - } - output.flush().map_err(|e| e.to_string())?; - fs::rename(&tmp, target).map_err(|e| e.to_string())?; - cleanup_download_parts(&part_paths, Path::new("")); - emit_log(window, "info", "JDK download progress: 100%"); - Ok(()) -} - -fn cleanup_download_parts(part_paths: &[PathBuf], tmp: &Path) { - for path in part_paths { - let _ = fs::remove_file(path); - } - if !tmp.as_os_str().is_empty() { - let _ = fs::remove_file(tmp); - } -} - fn open_target_with_system(target: &str) -> Result<(), String> { let trimmed = target.trim(); if trimmed.is_empty() { @@ -10118,6 +9895,43 @@ mod tests { use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn runtime_without_completion_marker_is_not_reused() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("jdk-17"); + let java = if cfg!(windows) { "java.exe" } else { "java" }; + fs::create_dir_all(root.join("bin")).unwrap(); + fs::write(root.join("bin").join(java), b"binary").unwrap(); + fs::create_dir_all(root.join("lib")).unwrap(); + fs::write(root.join("lib").join("modules"), b"0123456789").unwrap(); + // java present but no marker: an interrupted install must not be reused. + assert!(super::verify_runtime_install(&root, false).is_err()); + + let marker = super::RuntimeInstallMarker { + source: "test".to_string(), + version: "17".to_string(), + platform: "test".to_string(), + major: 17, + installed_at: 0, + files: super::collect_runtime_file_records(&root).unwrap(), + }; + fs::write( + root.join(super::RUNTIME_MARKER_FILE), + serde_json::to_vec(&marker).unwrap(), + ) + .unwrap(); + assert!(super::verify_runtime_install(&root, false).is_ok()); + + // A truncated file is detected by the size check. + fs::write(root.join("lib").join("modules"), b"0123").unwrap(); + let error = super::verify_runtime_install(&root, false).unwrap_err(); + assert!(error.contains("lib/modules"), "{error}"); + assert_eq!( + super::runtime_staging_dir(&root), + temp.path().join(".jdk-17.staging") + ); + } + #[test] fn fabric_install_result_matches_java_core_payload() { let payload = r#"{ @@ -10258,7 +10072,7 @@ mod tests { target: "../lib/source.txt".to_string(), }; - process_mojang_runtime_entry(None, temp.path(), "bin/runtime-link", &entry, 1) + process_mojang_runtime_entry(None, &crate::downloader::CancelToken::new(None), temp.path(), "bin/runtime-link", &entry) .expect("runtime link should be created"); let link = temp.path().join("bin/runtime-link"); @@ -11200,6 +11014,7 @@ fn main() { build_vanilla_launch_plan, launch_vanilla, cancel_install, + cancel_all_downloads, list_fabric_loaders, install_fabric, list_forge_versions, diff --git a/tauri-app/src-tauri/src/minecraft_core.rs b/tauri-app/src-tauri/src/minecraft_core.rs index a3ac50e..000ae93 100644 --- a/tauri-app/src-tauri/src/minecraft_core.rs +++ b/tauri-app/src-tauri/src/minecraft_core.rs @@ -8,10 +8,10 @@ use serde_json::{Map, Value}; use std::collections::{HashMap, HashSet}; use std::ffi::{OsStr, OsString}; use std::fs; -use std::io::{self, Read, Write}; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -1678,7 +1678,7 @@ fn install_standalone_optifine( "patch", "Patching vanilla client for standalone OptiFine", ); - let patch_output = patched_path.with_extension("patching"); + let patch_output = crate::downloader::with_suffix(&patched_path, ".patching"); let _ = fs::remove_file(&patch_output); run_optifine_patcher( game_dir, @@ -1705,7 +1705,7 @@ fn install_standalone_optifine( } else { // Older OptiFine downloads (1.12.2 and earlier) already are the complete // mod jar; the download itself goes onto the classpath unchanged. - let staged = patched_path.with_extension("staging"); + let staged = crate::downloader::with_suffix(&patched_path, ".staging"); fs::copy(&installer_path, &staged).map_err(|error| { format!( "Failed to stage OptiFine jar {}: {error}", @@ -1853,7 +1853,7 @@ fn resolve_optifine_launchwrapper( let mut entry = archive .by_name(&entry_name) .map_err(|error| format!("OptiFine installer is missing {entry_name}: {error}"))?; - let temp = target.with_extension("extracting"); + let temp = crate::downloader::with_suffix(&target, ".extracting"); let mut output = fs::File::create(&temp).map_err(|error| { format!( "Failed to create OptiFine launchwrapper {}: {error}", @@ -3502,98 +3502,20 @@ fn download_file( if cached_file_usable(target, expected_sha1) { return Ok(()); } - - let parent = target - .parent() - .ok_or_else(|| format!("Target path has no parent: {}", target.display()))?; - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create directory {}: {e}", parent.display()))?; - - let tmp = target.with_extension("download"); - // Not the shared download client: this path buffers the whole body via `.bytes()`, - // where reqwest applies the client timeout as a single whole-body deadline. The client - // jar (tens of MB) legitimately needs longer than the streaming client's short per-read - // timeout, so keep the generous general-purpose client here. - let client = build_blocking_http_client()?; - let mut last_error = None; - for url in mirror_health::prioritize(urls) { - let url = url.as_str(); - let response = match client.get(url).send() { - Ok(response) => response, - Err(error) => { - mirror_health::record_failure(url); - last_error = Some(format!("Failed to download {label} from {url}: {error}")); - continue; - } - }; - let status = response.status(); - if !status.is_success() { - if !status.is_client_error() { - mirror_health::record_failure(url); - } - last_error = Some(format!( - "Failed to download {label} from {url}: HTTP {status}" - )); - continue; - } - - let bytes = match response.bytes() { - Ok(bytes) => bytes, - Err(error) => { - mirror_health::record_failure(url); - last_error = Some(format!( - "Failed reading {label} response from {url}: {error}" - )); - continue; - } - }; - if let Err(error) = fs::write(&tmp, &bytes) { - last_error = Some(format!( - "Failed writing temp file {}: {error}", - tmp.display() - )); - continue; - } - - if let Some(expected_sha1) = expected_sha1 { - match compute_sha1_hex(&tmp) { - Ok(actual) if actual.eq_ignore_ascii_case(expected_sha1) => {} - Ok(actual) => { - let _ = fs::remove_file(&tmp); - mirror_health::record_failure(url); - last_error = Some(format!( - "SHA1 mismatch for {url}: expected={expected_sha1} actual={actual}" - )); - continue; - } - Err(error) => { - let _ = fs::remove_file(&tmp); - last_error = Some(error); - continue; - } - } - } - - fs::rename(&tmp, target) - .or_else(|_| { - fs::copy(&tmp, target) - .map(|_| ()) - .and_then(|_| fs::remove_file(&tmp)) - }) - .map_err(|e| format!("Failed to move temp file to {}: {e}", target.display()))?; - - mirror_health::record_success(url); - emit_log( - window, - "info", - &format!("Downloaded {label}: {}", target.display()), - ); - return Ok(()); - } - - Err(last_error.unwrap_or_else(|| format!("Failed downloading {label} to {}", target.display()))) + let cancel = crate::downloader::CancelToken::new(None); + crate::downloader::download( + &crate::downloader::Request::new(urls, target, &cancel).sha1(expected_sha1), + ) + .map_err(|error| format!("Failed to download {label}: {error}"))?; + emit_log( + window, + "info", + &format!("Downloaded {label}: {}", target.display()), + ); + Ok(()) } +#[allow(clippy::too_many_arguments)] fn download_file_with_ipc( window: Option<&tauri::Window>, session: Option<&str>, @@ -3605,7 +3527,6 @@ fn download_file_with_ipc( aggregate: Option<&DownloadAggregate>, expected_size: Option, ) -> Result { - const DOWNLOAD_PROGRESS_CHUNK_SIZE: usize = 64 * 1024; install_cancel_error(session)?; if cached_file_usable(target, expected_sha1) { let total = fs::metadata(target).ok().map(|meta| meta.len()); @@ -3624,277 +3545,67 @@ fn download_file_with_ipc( return Ok(false); } - let parent = target - .parent() - .ok_or_else(|| format!("Target path has no parent: {}", target.display()))?; - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create directory {}: {e}", parent.display()))?; - emit_download_item_start(window, session, artifact, Some(0), expected_size, "Downloading"); - let tmp = target.with_extension("download"); - let client = download_http_client()?; - let mut last_error = None; - // Hosts known to be failing right now go last, so a dead mirror costs the - // first few files instead of all of them. - let candidates = mirror_health::prioritize(urls); - let candidate_count = candidates.len(); - for (index, url) in candidates.iter().enumerate() { - let url = url.as_str(); - // Retrying a host that just failed is only worth it when there is nothing - // else left to try; while another candidate is untried, failing over is - // strictly faster than waiting out another stall on the same host. - let attempts = if index + 1 == candidate_count { - DOWNLOAD_RETRY_ATTEMPTS - } else { - 1 - }; - for attempt in 1..=attempts { - install_cancel_error(session)?; - let response = match client.get(url).send() { - Ok(response) => response, - Err(error) => { - mirror_health::record_failure(url); - last_error = Some(format!("Failed to download {label} from {url}: {error}")); - if attempt < attempts { - emit_download_item_start( - window, - session, - artifact, - Some(0), - None, - &format!("Retrying ({attempt}/{})", attempts - 1), - ); - } - continue; - } - }; - if !response.status().is_success() { - let status = response.status(); - last_error = Some(format!( - "Failed to download {label} from {url}: HTTP {status}" - )); - // A 4xx means this URL will never serve the file (e.g. the mirror - // simply doesn't have it) — retrying it is pointless, so fail over to - // the next candidate immediately instead of burning all attempts here. - // It also says nothing about the host's health, so it is not recorded - // as a failure: a mirror missing one artifact is still worth using. - if status.is_client_error() { - break; - } - mirror_health::record_failure(url); - if attempt < attempts { - emit_download_item_start( - window, - session, - artifact, - Some(0), - None, - &format!("Retrying ({attempt}/{})", attempts - 1), - ); - } - continue; - } - - let total = response.content_length(); - emit_download_item_start(window, session, artifact, Some(0), total, "Downloading"); - let mut response = response; - let mut output = match fs::File::create(&tmp) { - Ok(file) => file, - Err(error) => { - last_error = Some(format!( - "Failed writing temp file {}: {error}", - tmp.display() - )); - if attempt < attempts { - emit_download_item_start( - window, - session, - artifact, - Some(0), - total, - &format!("Retrying ({attempt}/{})", attempts - 1), - ); - } - continue; - } - }; - let mut buffer = [0_u8; DOWNLOAD_PROGRESS_CHUNK_SIZE]; - let mut downloaded_bytes = 0_u64; - let mut stream_failed = None::; - let mut last_progress_emit = Instant::now(); - loop { - install_cancel_error(session)?; - match response.read(&mut buffer) { - Ok(0) => break, - Ok(read_bytes) => { - if let Err(error) = output.write_all(&buffer[..read_bytes]) { - stream_failed = Some(format!( - "Failed writing temp file {}: {error}", - tmp.display() - )); - break; - } - downloaded_bytes += read_bytes as u64; - if let Some(aggregate) = aggregate { - aggregate.add_transferred(read_bytes as u64); - } - if last_progress_emit.elapsed() >= DOWNLOAD_PROGRESS_EMIT_INTERVAL { - last_progress_emit = Instant::now(); - emit_download_item_progress( - window, - session, - artifact, - Some(downloaded_bytes), - total, - "Downloading", - ); - } - } - Err(error) => { - stream_failed = Some(format!( - "Failed reading {label} response from {url}: {error}" - )); - break; - } - } - } - if let Some(error) = stream_failed { - let _ = fs::remove_file(&tmp); - // A body that dies mid-stream is the signature of a stalled - // mirror, which is exactly what the health tracker exists for. - mirror_health::record_failure(url); - // The retry re-reads from byte 0, so its partial transfer must not - // stay counted or the aggregate bar would overshoot. - if let Some(aggregate) = aggregate { - aggregate.discard_transferred(downloaded_bytes); - } - last_error = Some(error); - if attempt < attempts { - emit_download_item_start( - window, - session, - artifact, - Some(0), - total, - &format!("Retrying ({attempt}/{})", attempts - 1), - ); - } - continue; - } - if let Err(error) = output.flush() { - let _ = fs::remove_file(&tmp); - if let Some(aggregate) = aggregate { - aggregate.discard_transferred(downloaded_bytes); - } - last_error = Some(format!( - "Failed flushing temp file {}: {error}", - tmp.display() - )); - if attempt < attempts { - emit_download_item_start( - window, - session, - artifact, - Some(0), - total, - &format!("Retrying ({attempt}/{})", attempts - 1), - ); - } - continue; - } - install_cancel_error(session)?; - - if let Some(expected_sha1) = expected_sha1 { - match compute_sha1_hex(&tmp) { - Ok(actual) if actual.eq_ignore_ascii_case(expected_sha1) => {} - Ok(actual) => { - let _ = fs::remove_file(&tmp); - if let Some(aggregate) = aggregate { - aggregate.discard_transferred(downloaded_bytes); - } - emit_log( - window, - "warn", - &format!( - "Checksum mismatch for {label}: expected sha1={expected_sha1}, got {actual}; re-downloading" - ), - ); - // Serving the wrong bytes is a mirror-health problem too: - // a mirror with a stale cache will keep doing it. - mirror_health::record_failure(url); - last_error = Some(format!( - "SHA1 mismatch for {url}: expected={expected_sha1} actual={actual}" - )); - if attempt < attempts { - emit_download_item_start( - window, - session, - artifact, - Some(0), - total.or(Some(downloaded_bytes)), - &format!( - "Checksum failed, retrying ({attempt}/{})", - attempts - 1 - ), - ); - } - continue; - } - Err(error) => { - let _ = fs::remove_file(&tmp); - if let Some(aggregate) = aggregate { - aggregate.discard_transferred(downloaded_bytes); - } - last_error = Some(error); - if attempt < attempts { - emit_download_item_start( - window, - session, - artifact, - Some(0), - total.or(Some(downloaded_bytes)), - &format!("Retrying ({attempt}/{})", attempts - 1), - ); - } - continue; - } - } - } - - fs::rename(&tmp, target) - .or_else(|_| { - fs::copy(&tmp, target) - .map(|_| ()) - .and_then(|_| fs::remove_file(&tmp)) - }) - .map_err(|e| format!("Failed to move temp file to {}: {e}", target.display()))?; - - mirror_health::record_success(url); - if let Some(aggregate) = aggregate { - aggregate.settle(expected_size, downloaded_bytes); + let cancel = crate::downloader::CancelToken::new(session); + // Bytes of this file currently credited to the stage aggregate. A retry + // that restarts from zero reports a smaller count, which is debited back + // so the stage bar never overshoots. + let accounted = AtomicU64::new(0); + let progress = |downloaded: u64, total: Option| { + if let Some(aggregate) = aggregate { + let previous = accounted.swap(downloaded, Ordering::Relaxed); + if downloaded >= previous { + aggregate.add_transferred(downloaded - previous); + } else { + aggregate.discard_transferred(previous - downloaded); } - emit_log( - window, - "info", - &format!("Downloaded {label}: {}", target.display()), - ); - emit_download_item_complete( - window, - session, - artifact, - Some(downloaded_bytes), - total.or(Some(downloaded_bytes)), - false, - "Completed", - ); - return Ok(true); } + emit_download_item_progress( + window, + session, + artifact, + Some(downloaded), + total.or(expected_size), + "Downloading", + ); + }; + let retry = |message: &str| { + emit_download_item_start(window, session, artifact, Some(0), expected_size, message); + }; + let result = crate::downloader::download( + &crate::downloader::Request::new(urls, target, &cancel) + .size(expected_size) + .sha1(expected_sha1) + .on_progress(&progress) + .on_retry(&retry), + ); + let downloaded_bytes = accounted.load(Ordering::Relaxed); + if let Err(error) = result { + if let Some(aggregate) = aggregate { + aggregate.discard_transferred(downloaded_bytes); + } + let error = format!("Failed to download {label}: {error}"); + emit_download_item_error(window, session, artifact, &error); + return Err(error); } - - let error = - last_error.unwrap_or_else(|| format!("Failed downloading {label} to {}", target.display())); - emit_download_item_error(window, session, artifact, &error); - Err(error) + if let Some(aggregate) = aggregate { + aggregate.settle(expected_size, downloaded_bytes); + } + emit_log( + window, + "info", + &format!("Downloaded {label}: {}", target.display()), + ); + emit_download_item_complete( + window, + session, + artifact, + Some(downloaded_bytes), + Some(downloaded_bytes), + false, + "Completed", + ); + Ok(true) } fn extract_native_jar( @@ -4998,10 +4709,6 @@ struct DownloadRateSnapshot { eta_seconds: Option, } -const DOWNLOAD_RETRY_ATTEMPTS: usize = 3; -// Progress IPC per item at most this often; the dialog only refreshes on a 500ms -// poll, so per-64KB-chunk events were pure serialization overhead. -const DOWNLOAD_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(200); /// Process-wide memory of which download hosts are currently reachable. /// @@ -5095,36 +4802,6 @@ pub(crate) mod mirror_health { } } -// One shared HTTP client for all file downloads. reqwest pools keep-alive connections -// per client, so building a client per file (the previous behavior) forced a fresh -// DNS + TCP + TLS handshake for every one of the thousands of small asset/library -// files — the dominant cost of an install, far ahead of actual transfer time. -// -// This is deliberately separate from the general API/metadata client so its aggressive -// stall detection stays on the download hot path — it is used only by the streaming -// `download_file_with_ipc`. The timeout is the important part: for reqwest's blocking -// client, `Response`'s `Read` impl wraps *each* `read()` call in `wait::timeout(_, timeout)` -// (see reqwest blocking/response.rs), so on a streamed body this acts as a per-read idle -// timeout, not a whole-request deadline — a healthy transfer resets it on every chunk. -// Without it a peer that accepts the connection then stalls mid-body — extremely common -// for the Mojang CDN from mainland China — blocks until the total timeout before the next -// mirror candidate is tried, so fallback never appears to happen. 20s of complete silence -// on the socket is a genuine stall and triggers a fast failover to the next candidate. -fn download_http_client() -> Result<&'static reqwest::blocking::Client, String> { - static CLIENT: OnceLock = OnceLock::new(); - if let Some(client) = CLIENT.get() { - return Ok(client); - } - let client = reqwest::blocking::Client::builder() - .user_agent(crate::LAUNCHER_HTTP_USER_AGENT) - .connect_timeout(Duration::from_secs(15)) - .timeout(Duration::from_secs(20)) - .redirect(reqwest::redirect::Policy::limited(10)) - .build() - .map_err(|e| e.to_string())?; - Ok(CLIENT.get_or_init(|| client)) -} - fn download_jobs_in_parallel( window: Option<&tauri::Window>, ipc_session: Option<&str>, diff --git a/tauri-app/src-tauri/src/modpack.rs b/tauri-app/src-tauri/src/modpack.rs index 6f322fb..b0193f2 100644 --- a/tauri-app/src-tauri/src/modpack.rs +++ b/tauri-app/src-tauri/src/modpack.rs @@ -810,6 +810,7 @@ struct ModpackFileJob { fn download_modpack_files( window: Option<&tauri::Window>, + cancel: &crate::downloader::CancelToken, project_key: &str, instance_root: &Path, jobs: Vec, @@ -840,17 +841,8 @@ fn download_modpack_files( let window = window.cloned(); let project_key = project_key.to_string(); let instance_root = instance_root.to_path_buf(); + let cancel = cancel.clone(); workers.push(thread::spawn(move || { - let client = match crate::build_blocking_http_client() { - Ok(value) => value, - Err(err) => { - let mut slot = crate::minecraft_core::lock_recover(&failure); - if slot.is_none() { - *slot = Some(err); - } - return; - } - }; loop { if crate::minecraft_core::lock_recover(&failure).is_some() { return; @@ -862,7 +854,7 @@ fn download_modpack_files( let Some(job) = job else { return; }; - if let Err(err) = download_single_modpack_file(&client, &instance_root, &job) { + if let Err(err) = download_single_modpack_file(&cancel, &instance_root, &job) { let mut slot = crate::minecraft_core::lock_recover(&failure); if slot.is_none() { *slot = Some(err); @@ -903,15 +895,11 @@ fn download_modpack_files( } fn download_single_modpack_file( - client: &reqwest::blocking::Client, + cancel: &crate::downloader::CancelToken, instance_root: &Path, job: &ModpackFileJob, ) -> Result<(), String> { let target = safe_join(instance_root, &job.relative_path)?; - if let Some(parent) = target.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create directory {}: {e}", parent.display()))?; - } // Skip files that already exist with a matching checksum (idempotent // reinstall). Files without any verifier are always re-downloaded. @@ -919,32 +907,24 @@ fn download_single_modpack_file( if has_verifier && target.is_file() && verify_job_checksums(&target, job).is_ok() { return Ok(()); } - - let mut last_error = format!( - "No download URL available for modpack file {}", - job.relative_path - ); - for url in &job.urls { - match crate::download_file_with_progress_callback_blocking(client, url, &target, |_, _| {}) { - Ok(()) => match verify_job_checksums(&target, job) { - Ok(()) => return Ok(()), - Err(err) => { - let _ = fs::remove_file(&target); - last_error = format!( - "Checksum mismatch for modpack file {}: {err}", - job.relative_path - ); - } - }, - Err(err) => { - last_error = format!( - "Failed to download modpack file {}: {err}", - job.relative_path - ); - } - } + if job.urls.is_empty() { + return Err(format!( + "No download URL available for modpack file {}", + job.relative_path + )); } - Err(last_error) + crate::downloader::download( + &crate::downloader::Request::new(&job.urls, &target, cancel) + .size(job.size) + .sha1(job.sha1.as_deref()) + .sha512(job.sha512.as_deref()), + ) + .map_err(|err| { + format!( + "Failed to download modpack file {}: {err}", + job.relative_path + ) + }) } fn verify_job_checksums(path: &Path, job: &ModpackFileJob) -> Result<(), String> { @@ -1019,6 +999,7 @@ fn allocate_instance_version_id(versions_dir: &Path, desired: &str) -> Result| { + crate::emit_content_install_progress(Some(window_ref), key_ref, downloaded, total); + emit_modpack_progress( + Some(window_ref), + key_ref, + STAGE_DOWNLOAD_PACK, + "Downloading modpack archive", + downloaded, + total.unwrap_or(0), + ); + }; crate::download_file_with_progress_callback_blocking( - &client, + cancel, &pack_download.url, &archive_path, - |downloaded, total| { - crate::emit_content_install_progress(Some(window_ref), key_ref, downloaded, total); - emit_modpack_progress( - Some(window_ref), - key_ref, - STAGE_DOWNLOAD_PACK, - "Downloading modpack archive", - downloaded, - total.unwrap_or(0), - ); - }, + &progress, ) .map_err(|err| { stage_err( @@ -1419,6 +1404,7 @@ pub(crate) fn install_modpack_blocking( })?; let (source_version_id, resolved_loader_version) = install_loader_profile( &window, + cancel, &game_dir_path, &parsed.minecraft_version, &parsed.loader, @@ -1462,6 +1448,7 @@ pub(crate) fn install_modpack_blocking( }; let file_count = download_modpack_files( Some(&window), + cancel, &project_key, &instance_root, jobs, From 4251b5dab5c966f377214a04bc6ef6c3b3ebc9fd Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:14:27 -0700 Subject: [PATCH 02/24] fix(launcher): one heartbeat loop per start, stoppable immediately Each start_launcher_heartbeat creates its own handle; stop wakes the sleeping loop through a condvar, and the loop reads the cached session on every beat instead of a snapshot (launcher-core-5). --- tauri-app/src-tauri/src/main.rs | 139 +++++++++++++++++++------------- 1 file changed, 84 insertions(+), 55 deletions(-) diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index ad2a133..6f7c511 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -875,8 +875,10 @@ fn is_autostart_launch() -> bool { struct LauncherRuntimeState { minimize_to_tray: AtomicBool, telemetry_session: Mutex>, - heartbeat_running: AtomicBool, - heartbeat_stop: Arc, + /// The one live heartbeat loop. Each start creates its own handle, so a + /// stop followed by a quick start can never revive an older thread + /// (launcher-core-5: a shared stop flag was reset by the next start). + heartbeat: Mutex>>, } impl Default for LauncherRuntimeState { @@ -884,12 +886,34 @@ impl Default for LauncherRuntimeState { Self { minimize_to_tray: AtomicBool::new(true), telemetry_session: Mutex::new(None), - heartbeat_running: AtomicBool::new(false), - heartbeat_stop: Arc::new(AtomicBool::new(false)), + heartbeat: Mutex::new(None), } } } +#[derive(Default)] +struct HeartbeatHandle { + stopped: Mutex, + wake: std::sync::Condvar, +} + +impl HeartbeatHandle { + fn stop(&self) { + *lock_recover(&self.stopped) = true; + self.wake.notify_all(); + } + + /// Sleeps up to `duration`; returns `true` once the handle was stopped. + fn wait_stopped(&self, duration: Duration) -> bool { + let guard = lock_recover(&self.stopped); + let (guard, _) = self + .wake + .wait_timeout_while(guard, duration, |stopped| !*stopped) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct LauncherTelemetrySession { #[serde(rename = "baseUrl")] @@ -2013,64 +2037,55 @@ fn post_launcher_heartbeat(session: &LauncherTelemetrySession) -> Result<(), Str #[tauri::command] fn start_launcher_heartbeat(app: tauri::AppHandle) -> Result<(), String> { let state = app.state::(); - // Check if already running - if state.heartbeat_running.load(Ordering::Relaxed) { - return Ok(()); - } - - // Get session - let session = state + if state .telemetry_session .lock() .map_err(|e| format!("Failed to lock telemetry session: {e}"))? - .clone() - .ok_or("No telemetry session cached")?; - - // Set running flag - state.heartbeat_running.store(true, Ordering::Relaxed); - state.heartbeat_stop.store(false, Ordering::Relaxed); + .is_none() + { + return Err("No telemetry session cached".to_string()); + } + let handle = { + let mut slot = lock_recover(&state.heartbeat); + if slot.is_some() { + return Ok(()); + } + let handle = Arc::new(HeartbeatHandle::default()); + *slot = Some(Arc::clone(&handle)); + handle + }; - let stop_flag = state.heartbeat_stop.clone(); let app_handle = app.clone(); - thread::spawn(move || { - let interval = Duration::from_secs(90); // Base interval: 90 seconds - let mut counter = 0u32; - + let interval = Duration::from_secs(90); loop { - if stop_flag.load(Ordering::Relaxed) { + // Read the session on every beat: after a re-login the cached + // session changes, and a snapshot taken at start would keep + // reporting the previous account. + let session = app_handle + .state::() + .telemetry_session + .lock() + .ok() + .and_then(|guard| guard.clone()); + let Some(session) = session else { break; - } - - // Send heartbeat + }; if let Err(err) = post_launcher_heartbeat(&session) { push_ui_log("launcher", "warn", &format!("heartbeat failed: {}", err)); } - - // Sleep with jitter - let jitter = (rand::random::() % 15) as u64; // 0-14 seconds jitter - let sleep_duration = interval + Duration::from_secs(jitter); - thread::sleep(sleep_duration); - - counter += 1; - // Every 10 heartbeats (~15 minutes), verify session still exists - if counter >= 10 { - counter = 0; - let still_valid = app_handle - .state::() - .heartbeat_running - .load(Ordering::Relaxed); - if !still_valid { - break; - } + let jitter = Duration::from_secs(rand::random::() % 15); + if handle.wait_stopped(interval + jitter) { + break; } } - - // Clear running flag when stopped - let _ = app - .state::() - .heartbeat_running - .compare_exchange(true, false, Ordering::Relaxed, Ordering::Relaxed); + // Only clear the slot if it still belongs to this loop. + let state = app_handle.state::(); + let mut slot = lock_recover(&state.heartbeat); + if slot.as_ref().is_some_and(|current| Arc::ptr_eq(current, &handle)) { + *slot = None; + } + drop(slot); }); Ok(()) @@ -2078,12 +2093,11 @@ fn start_launcher_heartbeat(app: tauri::AppHandle) -> Result<(), String> { #[tauri::command] fn stop_launcher_heartbeat(app: tauri::AppHandle) { - app.state::() - .heartbeat_stop - .store(true, Ordering::Relaxed); - app.state::() - .heartbeat_running - .store(false, Ordering::Relaxed); + let state = app.state::(); + let handle = lock_recover(&state.heartbeat).take(); + if let Some(handle) = handle { + handle.stop(); + } } #[tauri::command] @@ -9895,6 +9909,21 @@ mod tests { use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn heartbeat_stop_wakes_sleeping_loop_immediately() { + let handle = std::sync::Arc::new(super::HeartbeatHandle::default()); + let waiter = std::sync::Arc::clone(&handle); + let started = std::time::Instant::now(); + let thread = std::thread::spawn(move || waiter.wait_stopped(std::time::Duration::from_secs(60))); + std::thread::sleep(std::time::Duration::from_millis(50)); + handle.stop(); + assert!(thread.join().unwrap()); + assert!(started.elapsed() < std::time::Duration::from_secs(5)); + // A fresh handle (next start) is independent of the stopped one. + let next = super::HeartbeatHandle::default(); + assert!(!next.wait_stopped(std::time::Duration::from_millis(10))); + } + #[test] fn runtime_without_completion_marker_is_not_reused() { let temp = tempfile::tempdir().unwrap(); From e00bdd3fdec2b1c1ce2b538e602efea21ea9273c Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:16:37 -0700 Subject: [PATCH 03/24] fix(launcher): keep heavy instance commands off the main thread - rename/duplicate/export instance, list_instance_section_entries and extract_background_theme_accent now run on the blocking pool (launcher-core-6); background images use the short API client. - rename/duplicate/export validate version ids with ensure_safe_child_name before joining them under versions/ (launcher-core-20). - Instance and world archive imports stream from disk instead of reading the whole zip into memory (launcher-core-13). --- tauri-app/src-tauri/src/main.rs | 212 +++++++++++++++++++++++++------- 1 file changed, 167 insertions(+), 45 deletions(-) diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index 6f7c511..bac5aef 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -1332,8 +1332,28 @@ fn list_installed_versions(game_dir: String) -> Result, String> { Ok(installed) } +// Runs on the blocking pool: as a sync command it ran on the main thread and +// froze the window for the whole copy/compress/fetch (launcher-core-6). #[tauri::command] -fn rename_version_profile( +async fn rename_version_profile( + game_dir: String, + from_version_id: String, + to_version_id: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + rename_version_profile_blocking( + game_dir, + from_version_id, + to_version_id, + ) + }) + .await + .map_err(|e| format!("Failed to join rename_version_profile task: {e}")) + .and_then(std::convert::identity) + .inspect_err(|e| log_command_error("rename_version_profile", e)) +} + +fn rename_version_profile_blocking( game_dir: String, from_version_id: String, to_version_id: String, @@ -1346,6 +1366,9 @@ fn rename_version_profile( if from_id == to_id { return Ok(to_id.to_string()); } + // Both ids are joined under versions/ and the source may be deleted below. + ensure_safe_child_name(from_id, "Source version id")?; + ensure_safe_child_name(to_id, "Target version id")?; let game_dir_path = resolve_game_dir_path(&game_dir)?; let versions_dir = game_dir_path.join("versions"); @@ -1427,8 +1450,28 @@ fn is_native_app_version_id(version_id: &str) -> bool { matches!(version_id, "FPSMaster-Extreme") } +// Runs on the blocking pool: as a sync command it ran on the main thread and +// froze the window for the whole copy/compress/fetch (launcher-core-6). #[tauri::command] -fn duplicate_instance_storage( +async fn duplicate_instance_storage( + game_dir: String, + source_version_id: String, + target_version_id: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + duplicate_instance_storage_blocking( + game_dir, + source_version_id, + target_version_id, + ) + }) + .await + .map_err(|e| format!("Failed to join duplicate_instance_storage task: {e}")) + .and_then(std::convert::identity) + .inspect_err(|e| log_command_error("duplicate_instance_storage", e)) +} + +fn duplicate_instance_storage_blocking( game_dir: String, source_version_id: String, target_version_id: String, @@ -1441,6 +1484,8 @@ fn duplicate_instance_storage( if source_id == target_id { return Err("Source and target version ids must be different".to_string()); } + ensure_safe_child_name(source_id, "Source version id")?; + ensure_safe_child_name(target_id, "Target version id")?; let game_dir_path = resolve_game_dir_path(&game_dir)?; let versions_dir = game_dir_path.join("versions"); @@ -1466,16 +1511,34 @@ fn duplicate_instance_storage( Ok(target_id.to_string()) } +// Runs on the blocking pool: as a sync command it ran on the main thread and +// froze the window for the whole copy/compress/fetch (launcher-core-6). #[tauri::command] -fn export_instance_archive( +async fn export_instance_archive( + game_dir: String, + version_id: String, + archive_name: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + export_instance_archive_blocking( + game_dir, + version_id, + archive_name, + ) + }) + .await + .map_err(|e| format!("Failed to join export_instance_archive task: {e}")) + .and_then(std::convert::identity) + .inspect_err(|e| log_command_error("export_instance_archive", e)) +} + +fn export_instance_archive_blocking( game_dir: String, version_id: String, archive_name: Option, ) -> Result { let normalized_version_id = version_id.trim(); - if normalized_version_id.is_empty() { - return Err("Version id cannot be empty".to_string()); - } + ensure_safe_child_name(normalized_version_id, "Version id")?; let game_dir_path = resolve_game_dir_path(&game_dir)?; let source_dir = game_dir_path.join("versions").join(normalized_version_id); @@ -1563,7 +1626,7 @@ fn import_instance_archive_blocking( })?; let _stage_cleanup = StagingDirectoryCleanup(stage_root.clone()); - let extracted_entries = extract_archive_to_stage(&archive_data, &stage_root, "instance")?; + let extracted_entries = extract_archive_to_stage(archive_data.open()?, &stage_root, "instance")?; if extracted_entries == 0 { let _ = fs::remove_dir_all(&stage_root); return Err("Instance archive does not contain importable files".to_string()); @@ -1886,8 +1949,28 @@ fn get_system_wallpaper() -> Result, String> { Ok(Some(read_image_file_as_data_url(Path::new(&path))?)) } +// Runs on the blocking pool: as a sync command it ran on the main thread and +// froze the window for the whole copy/compress/fetch (launcher-core-6). #[tauri::command] -fn extract_background_theme_accent( +async fn extract_background_theme_accent( + background_source: String, + background_image: String, + background_web_url: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + extract_background_theme_accent_blocking( + background_source, + background_image, + background_web_url, + ) + }) + .await + .map_err(|e| format!("Failed to join extract_background_theme_accent task: {e}")) + .and_then(std::convert::identity) + .inspect_err(|e| log_command_error("extract_background_theme_accent", e)) +} + +fn extract_background_theme_accent_blocking( background_source: String, background_image: String, background_web_url: String, @@ -2460,24 +2543,19 @@ fn install_modrinth_project_blocking( version.version_number.clone() }; if normalized_project_type == "world" { - let archive_bytes = fs::read(&download_path).map_err(|e| { - format!( - "Failed to read downloaded Modrinth world archive {}: {e}", - download_path.display() - ) - })?; - let _ = fs::remove_file(&download_path); let world_result = install_world_archive_with_metadata( game_dir.clone(), normalized_version_id.clone(), file.filename.clone(), - archive_bytes, + ArchivePayload::File(download_path.clone()), Some(project_title.trim().to_string()), "modrinth".to_string(), Some(normalized_project_id.clone()), Some(project_title.trim().to_string()), Some((version.id.clone(), resolved_version_number.clone())), - )?; + ); + let _ = fs::remove_file(&download_path); + let world_result = world_result?; return Ok(ModrinthInstallResult { source: "modrinth".to_string(), project_id: normalized_project_id, @@ -2701,24 +2779,19 @@ fn install_curseforge_project_blocking( let version_label = resolve_curseforge_version_label(&file); if normalized_project_type == "world" { - let archive_bytes = fs::read(&download_path).map_err(|e| { - format!( - "Failed to read downloaded CurseForge world archive {}: {e}", - download_path.display() - ) - })?; - let _ = fs::remove_file(&download_path); let world_result = install_world_archive_with_metadata( game_dir.clone(), normalized_version_id.clone(), file.file_name.clone(), - archive_bytes, + ArchivePayload::File(download_path.clone()), Some(project_title.trim().to_string()), "curseforge".to_string(), Some(normalized_project_id.clone()), Some(project_title.trim().to_string()), Some((file.id.to_string(), version_label.clone())), - )?; + ); + let _ = fs::remove_file(&download_path); + let world_result = world_result?; return Ok(ModrinthInstallResult { source: "curseforge".to_string(), project_id: normalized_project_id, @@ -2879,17 +2952,13 @@ fn install_world_archive_with_metadata( game_dir: String, version_id: String, archive_name: String, - archive_data: Vec, + archive_data: ArchivePayload, world_name: Option, source: String, project_id_override: Option, project_title_override: Option, version_label_override: Option<(String, String)>, ) -> Result { - if archive_data.is_empty() { - return Err("World archive cannot be empty".to_string()); - } - let game_dir_path = resolve_game_dir_path(&game_dir)?; let runtime_root = resolve_version_runtime_dir(&game_dir_path, version_id.trim())?; let saves_dir = runtime_root.join("saves"); @@ -2922,7 +2991,7 @@ fn install_world_archive_with_metadata( })?; let _stage_cleanup = StagingDirectoryCleanup(stage_root.clone()); - let extracted_entries = extract_world_archive_to_stage(&archive_data, &stage_root)?; + let extracted_entries = extract_world_archive_to_stage(archive_data.open()?, &stage_root)?; if extracted_entries == 0 { let _ = fs::remove_dir_all(&stage_root); return Err("World archive does not contain importable files".to_string()); @@ -5354,11 +5423,33 @@ impl Drop for StagingDirectoryCleanup { /// Resolve the ZIP payload for an import command. Callers may pass either the /// raw bytes (legacy IPC shape) or a filesystem path picked through the native /// dialog; the path form avoids serializing whole archives across IPC. +/// An import archive. When the frontend hands over a path the archive is read +/// straight from disk; it used to be `fs::read` into memory first, so a 4 GB +/// instance export cost 4 GB of RAM to import (launcher-core-13). +enum ArchivePayload { + File(PathBuf), + Bytes(Vec), +} + +trait ReadSeek: Read + std::io::Seek {} +impl ReadSeek for T {} + +impl ArchivePayload { + fn open(&self) -> Result, String> { + match self { + Self::File(path) => fs::File::open(path) + .map(|file| Box::new(BufReader::new(file)) as Box) + .map_err(|e| format!("Failed to open archive {}: {e}", path.display())), + Self::Bytes(bytes) => Ok(Box::new(Cursor::new(bytes.as_slice()))), + } + } +} + fn resolve_archive_payload( archive_data: Option>, archive_path: Option<&str>, archive_label: &str, -) -> Result, String> { +) -> Result { if let Some(path_raw) = archive_path .map(str::trim) .filter(|value| !value.is_empty()) @@ -5367,20 +5458,23 @@ fn resolve_archive_payload( if !path.is_file() { return Err(format!("{archive_label} archive path is not a file")); } - return fs::read(path).map_err(|e| format!("Failed to read {archive_label} archive: {e}")); + if fs::metadata(path).map(|meta| meta.len()).unwrap_or(0) == 0 { + return Err(format!("{archive_label} archive cannot be empty")); + } + return Ok(ArchivePayload::File(path.to_path_buf())); } archive_data .filter(|data| !data.is_empty()) + .map(ArchivePayload::Bytes) .ok_or_else(|| format!("{archive_label} archive cannot be empty")) } fn extract_archive_to_stage( - archive_data: &[u8], + archive_data: impl Read + std::io::Seek, stage_root: &Path, archive_label: &str, ) -> Result { - let cursor = Cursor::new(archive_data); - let mut archive = zip::ZipArchive::new(cursor) + let mut archive = zip::ZipArchive::new(archive_data) .map_err(|e| format!("Invalid {archive_label} archive ZIP: {e}"))?; zip_budget::IMPORT_ARCHIVE_BUDGET.check_archive(&mut archive, archive_label)?; let mut budget = zip_budget::IMPORT_ARCHIVE_BUDGET.tracker(); @@ -5645,10 +5739,12 @@ fn detect_loader_version_from_profile_id( None } -fn extract_world_archive_to_stage(archive_data: &[u8], stage_root: &Path) -> Result { - let cursor = Cursor::new(archive_data); +fn extract_world_archive_to_stage( + archive_data: impl Read + std::io::Seek, + stage_root: &Path, +) -> Result { let mut archive = - zip::ZipArchive::new(cursor).map_err(|e| format!("Invalid world archive ZIP: {e}"))?; + zip::ZipArchive::new(archive_data).map_err(|e| format!("Invalid world archive ZIP: {e}"))?; zip_budget::IMPORT_ARCHIVE_BUDGET.check_archive(&mut archive, "world")?; let mut budget = zip_budget::IMPORT_ARCHIVE_BUDGET.tracker(); let mut extracted_entries = 0usize; @@ -7176,8 +7272,28 @@ fn resolve_instance_section_dir( Ok(resolve_version_runtime_dir(game_dir, version_id)?.join(normalized)) } +// Runs on the blocking pool: as a sync command it ran on the main thread and +// froze the window for the whole copy/compress/fetch (launcher-core-6). #[tauri::command] -fn list_instance_section_entries( +async fn list_instance_section_entries( + game_dir: String, + version_id: String, + section: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + list_instance_section_entries_blocking( + game_dir, + version_id, + section, + ) + }) + .await + .map_err(|e| format!("Failed to join list_instance_section_entries task: {e}")) + .and_then(std::convert::identity) + .inspect_err(|e| log_command_error("list_instance_section_entries", e)) +} + +fn list_instance_section_entries_blocking( game_dir: String, version_id: String, section: String, @@ -10395,7 +10511,7 @@ mod tests { writer.finish().expect("zip should be finalized"); } - let extracted = extract_archive_to_stage(&buffer, &stage_root, "instance") + let extracted = extract_archive_to_stage(std::io::Cursor::new(&buffer), &stage_root, "instance") .expect("extraction should succeed"); assert_eq!(extracted, 1, "only the safe entry should be extracted"); assert_eq!( @@ -10474,14 +10590,20 @@ mod tests { let archive = temp.path().join("world.zip"); fs::write(&archive, b"zip bytes").expect("archive should be written"); + let read_all = |payload: super::ArchivePayload| { + let mut bytes = Vec::new(); + std::io::Read::read_to_end(&mut payload.open().unwrap(), &mut bytes).unwrap(); + bytes + }; let from_path = resolve_archive_payload(None, Some(archive.to_string_lossy().as_ref()), "World") .expect("path payload should be read"); - assert_eq!(from_path, b"zip bytes"); + assert!(matches!(from_path, super::ArchivePayload::File(_))); + assert_eq!(read_all(from_path), b"zip bytes"); let from_data = resolve_archive_payload(Some(vec![1, 2, 3]), None, "World") .expect("byte payload should pass through"); - assert_eq!(from_data, vec![1, 2, 3]); + assert_eq!(read_all(from_data), vec![1, 2, 3]); assert!(resolve_archive_payload(None, None, "World").is_err()); assert!(resolve_archive_payload(Some(Vec::new()), None, "World").is_err()); @@ -10757,7 +10879,7 @@ fn load_remote_image_bytes(url: &str) -> Result, String> { if url.is_empty() { return Err("Background image URL is empty".to_string()); } - let client = build_blocking_http_client()?; + let client = build_api_http_client()?; let response = client .get(url) .header(reqwest::header::ACCEPT, "image/*,*/*;q=0.8") From fa3518335d3ac67f95d92b220b543b1201fe855a Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:17:02 -0700 Subject: [PATCH 04/24] fix(launcher): a broken user jar no longer blocks preset mod checks Unreadable jars in mods/ are logged and skipped during the runtime compatibility scan; a damaged launcher-managed jar marks the package for reinstall instead of failing the state query (launcher-core-9). --- tauri-app/src-tauri/src/main.rs | 35 ++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index bac5aef..818354c 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -9833,8 +9833,15 @@ fn launcher_package_has_unsupported_runtime_mods( } let relative_path = normalize_manifest_relative_path(&file.path)?; let target_path = mods_dir.join(relative_path); - if fabric_mod_uses_named_namespace(&target_path)? { - return Ok(true); + match fabric_mod_uses_named_namespace(&target_path) { + Ok(false) => {} + Ok(true) => return Ok(true), + // A launcher-managed jar that no longer opens is damaged: report the + // package as unusable so it gets reinstalled instead of failing. + Err(error) => { + push_ui_log("core", "warn", &format!("Launcher package file is damaged: {error}")); + return Ok(true); + } } } Ok(false) @@ -9863,11 +9870,21 @@ fn mods_dir_has_unsupported_runtime_mods(mods_dir: &Path) -> Result {} + Ok(true) => return Ok(true), + // Users drop arbitrary jars here (half-downloaded, not a zip at all). + // One unreadable user mod must not block package checks and syncs + // for the whole instance (launcher-core-9); the game reports it. + Err(error) => push_ui_log( + "core", + "warn", + &format!("Skipping unreadable mod while checking runtime compatibility: {error}"), + ), + } } Ok(false) } @@ -10025,6 +10042,14 @@ mod tests { use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn broken_user_jar_does_not_fail_mods_scan() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("broken.jar"), b"").unwrap(); + fs::write(temp.path().join("not-a-zip.jar"), b"hello").unwrap(); + assert!(!super::mods_dir_has_unsupported_runtime_mods(temp.path()).unwrap()); + } + #[test] fn heartbeat_stop_wakes_sleeping_loop_immediately() { let handle = std::sync::Arc::new(super::HeartbeatHandle::default()); From 27cb6cd337083d54d7a57207e15453d852ee34a3 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:18:00 -0700 Subject: [PATCH 05/24] fix(launcher): Microsoft OAuth callback survives stray connections Accepted sockets are switched back to blocking with a 5s read timeout, and requests that are not a GET on the redirect path or that carry a foreign state are answered and ignored instead of ending the login. The callback page escapes its message (launcher-core-10). --- tauri-app/src-tauri/src/microsoft_auth.rs | 137 ++++++++++++++++------ 1 file changed, 100 insertions(+), 37 deletions(-) diff --git a/tauri-app/src-tauri/src/microsoft_auth.rs b/tauri-app/src-tauri/src/microsoft_auth.rs index 981dcf0..017e183 100644 --- a/tauri-app/src-tauri/src/microsoft_auth.rs +++ b/tauri-app/src-tauri/src/microsoft_auth.rs @@ -1132,33 +1132,28 @@ fn wait_for_minecraft_oauth_callback( } match listener.accept() { Ok((mut stream, _)) => { - let mut request_line = String::new(); - { - let mut reader = BufReader::new(&mut stream); - reader.read_line(&mut request_line).map_err(|e| { - format!("Failed to read Microsoft OAuth callback request: {e}") - })?; + // Accepted sockets inherit the listener's non-blocking mode on + // Windows and macOS, so an immediate read_line returned + // WouldBlock and aborted the login (launcher-core-10). + let _ = stream.set_nonblocking(false); + let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + // Browser pre-connects, favicon requests and stray local + // connections are answered and ignored; only a callback that + // carries our state may finish (or fail) the login. + let Some(query) = read_oauth_callback_query(&mut stream, &redirect_base) else { + let _ = write_browser_callback_page(&mut stream, 404, "Not found", ""); + continue; + }; + let returned_state = query.get("state").map(String::as_str).unwrap_or_default(); + if returned_state != expected_state { + let _ = write_browser_callback_page( + &mut stream, + 400, + "Microsoft login failed", + "Microsoft login callback state did not match the launcher session.", + ); + continue; } - let path = request_line.split_whitespace().nth(1).ok_or_else(|| { - "Microsoft OAuth callback request line is invalid".to_string() - })?; - let callback_url = reqwest::Url::parse(&format!( - "{}://{}{}", - redirect_base.scheme(), - redirect_base - .host_str() - .ok_or_else(|| "Minecraft redirect URL is missing host".to_string())?, - if let Some(port) = redirect_base.port() { - format!(":{port}{path}") - } else { - path.to_string() - } - )) - .or_else(|_| reqwest::Url::parse(&format!("http://callback{path}"))) - .map_err(|e| format!("Failed to parse Microsoft OAuth callback URL: {e}"))?; - - let query: HashMap = - callback_url.query_pairs().into_owned().collect(); if let Some(error) = query.get("error") { let description = query .get("error_description") @@ -1176,21 +1171,17 @@ fn wait_for_minecraft_oauth_callback( ); return Err(message); } - let returned_state = query.get("state").map(String::as_str).unwrap_or_default(); - if returned_state != expected_state { - let message = - "Microsoft login callback state did not match the launcher session."; + let Some(code) = query.get("code").cloned() else { let _ = write_browser_callback_page( &mut stream, 400, "Microsoft login failed", - message, + "Microsoft login callback did not contain an authorization code.", ); - return Err(message.to_string()); - } - let code = query.get("code").cloned().ok_or_else(|| { - "Microsoft login callback did not contain an authorization code".to_string() - })?; + return Err( + "Microsoft login callback did not contain an authorization code".to_string(), + ); + }; let _ = write_browser_callback_page( &mut stream, 200, @@ -1271,6 +1262,39 @@ fn cleanup_minecraft_auth_data_directory(path: &Path) { } } +/// Reads one request and returns its query when it targets the redirect path. +fn read_oauth_callback_query( + stream: &mut std::net::TcpStream, + redirect_base: &reqwest::Url, +) -> Option> { + let mut request_line = String::new(); + BufReader::new(&mut *stream) + .read_line(&mut request_line) + .ok()?; + let mut parts = request_line.split_whitespace(); + if parts.next()? != "GET" { + return None; + } + let target = parts.next()?; + let callback_url = reqwest::Url::parse(&format!("http://callback{target}")).ok()?; + let expected_path = match redirect_base.path() { + "" => "/", + path => path, + }; + if callback_url.path() != expected_path { + return None; + } + Some(callback_url.query_pairs().into_owned().collect()) +} + +fn escape_html(raw: &str) -> String { + raw.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + fn write_browser_callback_page( stream: &mut std::net::TcpStream, status_code: u16, @@ -1279,7 +1303,9 @@ fn write_browser_callback_page( ) -> Result<(), String> { let body = format!( "{}

{}

{}

", - title, title, message + escape_html(title), + escape_html(title), + escape_html(message) ); let response = format!( "HTTP/1.1 {} OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", @@ -1399,6 +1425,43 @@ fn unix_timestamp_millis() -> i64 { mod tests { use super::{format_listener_host, is_loopback_redirect_host, normalize_minecraft_skin_url}; + fn query_for(request: &str) -> Option> { + use std::io::Write; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + // Mirror production: the listener is non-blocking. + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let mut client = std::net::TcpStream::connect(address).unwrap(); + let (mut stream, _) = loop { + match listener.accept() { + Ok(pair) => break pair, + Err(_) => std::thread::sleep(std::time::Duration::from_millis(5)), + } + }; + stream.set_nonblocking(false).unwrap(); + let request = request.to_string(); + let writer = std::thread::spawn(move || { + // Data arrives after accept, which used to fail with WouldBlock. + std::thread::sleep(std::time::Duration::from_millis(50)); + client.write_all(request.as_bytes()).unwrap(); + client + }); + let base = reqwest::Url::parse(&format!("http://{address}/")).unwrap(); + let query = super::read_oauth_callback_query(&mut stream, &base); + drop(writer.join()); + query + } + + #[test] + fn oauth_callback_ignores_unrelated_requests() { + assert!(query_for("GET /favicon.ico HTTP/1.1\r\n\r\n").is_none()); + assert!(query_for("garbage\r\n").is_none()); + let query = query_for("GET /?code=abc&state=xyz HTTP/1.1\r\n\r\n").unwrap(); + assert_eq!(query.get("code").map(String::as_str), Some("abc")); + assert_eq!(query.get("state").map(String::as_str), Some("xyz")); + assert_eq!(super::escape_html("&\""), "<b>&""); + } + #[test] fn normalizes_legacy_minecraft_texture_urls_to_https() { assert_eq!( From 404f287236cce51b29da5668bdbfd0e1a142ed1a Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:19:09 -0700 Subject: [PATCH 06/24] fix(launcher): cache the credential master key and classify store failures - The master key is resolved once per process instead of on every get/set. - Keychain failures carry stable codes CREDENTIAL_STORE_UNAVAILABLE / CREDENTIAL_STORE_ACCESS_DENIED as the error prefix. - New commands secure_storage_status and secure_storage_set_file_fallback: with the user's consent the master key can live in a 0600 file when the system store is unusable; a file key is never created while credentials encrypted with an unreachable keychain key exist (launcher-core-11). --- tauri-app/src-tauri/src/main.rs | 9 +- tauri-app/src-tauri/src/secure_storage.rs | 181 ++++++++++++++++++++-- 2 files changed, 179 insertions(+), 11 deletions(-) diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index 818354c..f69e268 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -19,7 +19,10 @@ use microsoft_auth::{ get_minecraft_auth_config, lookup_minecraft_skin_url, poll_minecraft_device_login, refresh_minecraft_account, start_minecraft_browser_login, start_minecraft_device_login, }; -use secure_storage::{secure_storage_delete, secure_storage_get, secure_storage_set}; +use secure_storage::{ + secure_storage_delete, secure_storage_get, secure_storage_set, secure_storage_set_file_fallback, + secure_storage_status, +}; use base64::Engine; use minecraft_core::{lock_recover, VanillaLaunchRequest}; @@ -11222,7 +11225,9 @@ fn main() { terminate_game_process, secure_storage_get, secure_storage_set, - secure_storage_delete + secure_storage_delete, + secure_storage_status, + secure_storage_set_file_fallback ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/tauri-app/src-tauri/src/secure_storage.rs b/tauri-app/src-tauri/src/secure_storage.rs index 463a0ba..90b32cd 100644 --- a/tauri-app/src-tauri/src/secure_storage.rs +++ b/tauri-app/src-tauri/src/secure_storage.rs @@ -4,7 +4,9 @@ use aes_gcm::{ }; use rand::{rngs::OsRng, RngCore}; use sha2::{Digest, Sha256}; +use serde::Serialize; use std::{ + collections::HashMap, fs::{self, OpenOptions}, io::Write, path::{Path, PathBuf}, @@ -21,10 +23,21 @@ const MAX_VALUE_LEN: usize = 256 * 1024; const CREDENTIALS_DIR: &str = "credentials-v1"; const MASTER_KEY_FILE: &str = "master.key"; const KEYRING_SERVICE: &str = "com.fpsmaster.launcher"; +/// Present when the user agreed to keep the master key in a 0600 file because +/// the system credential store is unusable (e.g. Linux without Secret Service). +const FILE_FALLBACK_FLAG: &str = "file-key-fallback.enabled"; -fn master_key_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) +/// Stable error codes (prefix of the error string) so the frontend can offer +/// the right action instead of matching message text. +pub(crate) const CODE_STORE_UNAVAILABLE: &str = "CREDENTIAL_STORE_UNAVAILABLE"; +pub(crate) const CODE_STORE_ACCESS_DENIED: &str = "CREDENTIAL_STORE_ACCESS_DENIED"; + +/// Master keys resolved in this process, by credentials directory. Every +/// get/set used to hit the keychain, and on macOS each access of an ad-hoc +/// signed build can raise an authorization prompt (launcher-core-11). +fn master_key_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) } fn validate_key(raw: &str) -> Result<&str, String> { @@ -99,18 +112,79 @@ fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> { result } +fn classify_keyring_error(context: &str, error: &keyring::Error) -> String { + let code = match error { + keyring::Error::NoStorageAccess(_) => CODE_STORE_ACCESS_DENIED, + _ => CODE_STORE_UNAVAILABLE, + }; + format!("{code}: {context}: {error}") +} + +fn has_stored_credentials(dir: &Path) -> bool { + fs::read_dir(dir) + .map(|entries| { + entries.flatten().any(|entry| { + entry + .file_name() + .to_string_lossy() + .ends_with(".credential") + }) + }) + .unwrap_or(false) +} + +/// Master key from the 0600 file. Only used when the user enabled the file +/// fallback. A new key is never created while credentials encrypted with an +/// unreachable keychain key exist: that would silently orphan them. +fn load_or_create_file_master_key(dir: &Path, keyring_error: String) -> Result<[u8; KEY_LEN], String> { + let path = dir.join(MASTER_KEY_FILE); + match fs::read(&path) { + Ok(bytes) => validate_master_key(&bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if has_stored_credentials(dir) { + return Err(keyring_error); + } + let mut bytes = [0u8; KEY_LEN]; + OsRng.fill_bytes(&mut bytes); + atomic_write(&path, &bytes)?; + Ok(bytes) + } + Err(e) => Err(format!("Failed to read the file master key: {e}")), + } +} + fn load_or_create_master_key(dir: &Path) -> Result<[u8; KEY_LEN], String> { - let _guard = master_key_lock() + let mut cache = master_key_cache() .lock() .map_err(|_| "Secure storage master key lock is poisoned".to_string())?; + if let Some(key) = cache.get(dir) { + return Ok(*key); + } ensure_dir(dir)?; + let key = match load_master_key_from_keyring(dir) { + Ok(key) => key, + Err(error) if dir.join(FILE_FALLBACK_FLAG).exists() => { + crate::push_ui_log( + "launcher", + "warn", + &format!("System credential store unusable, using the file key: {error}"), + ); + load_or_create_file_master_key(dir, error)? + } + Err(error) => return Err(error), + }; + cache.insert(dir.to_path_buf(), key); + Ok(key) +} + +fn load_master_key_from_keyring(dir: &Path) -> Result<[u8; KEY_LEN], String> { let legacy_path = dir.join(MASTER_KEY_FILE); let keyring_user = format!( "secure-storage-master-key-v1-{:x}", Sha256::digest(dir.as_os_str().to_string_lossy().as_bytes()) ); let entry = keyring::Entry::new(KEYRING_SERVICE, &keyring_user) - .map_err(|e| format!("Failed to access the system credential store: {e}"))?; + .map_err(|e| classify_keyring_error("Failed to access the system credential store", &e))?; let stored = match entry.get_secret() { Ok(bytes) => bytes, @@ -126,10 +200,16 @@ fn load_or_create_master_key(dir: &Path) -> Result<[u8; KEY_LEN], String> { }; validate_master_key(&bytes)?; entry.set_secret(&bytes).map_err(|e| { - format!("Failed to save master key in the system credential store: {e}") + classify_keyring_error( + "Failed to save master key in the system credential store", + &e, + ) })?; let confirmed = entry.get_secret().map_err(|e| { - format!("Failed to verify the system credential store master key: {e}") + classify_keyring_error( + "Failed to verify the system credential store master key", + &e, + ) })?; if confirmed != bytes { return Err("System credential store returned a different master key".into()); @@ -137,8 +217,9 @@ fn load_or_create_master_key(dir: &Path) -> Result<[u8; KEY_LEN], String> { confirmed } Err(e) => { - return Err(format!( - "Failed to read master key from the system credential store: {e}" + return Err(classify_keyring_error( + "Failed to read master key from the system credential store", + &e, )) } }; @@ -162,6 +243,74 @@ fn load_or_create_master_key(dir: &Path) -> Result<[u8; KEY_LEN], String> { Ok(key) } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SecureStorageStatus { + /// "system" | "file" | "unavailable" + pub backend: String, + pub file_fallback_enabled: bool, + /// Error code (CREDENTIAL_STORE_*) when the backend is unavailable. + pub error_code: Option, + pub error: Option, +} + +pub(crate) fn secure_storage_status_blocking(app: &AppHandle) -> Result { + let dir = credentials_dir(app)?; + let fallback = dir.join(FILE_FALLBACK_FLAG).exists(); + let result = load_or_create_master_key(&dir); + let file_key = dir.join(MASTER_KEY_FILE).exists(); + Ok(match result { + Ok(_) => SecureStorageStatus { + backend: if fallback && file_key { "file" } else { "system" }.to_string(), + file_fallback_enabled: fallback, + error_code: None, + error: None, + }, + Err(error) => SecureStorageStatus { + backend: "unavailable".to_string(), + file_fallback_enabled: fallback, + error_code: error + .split_once(':') + .map(|(code, _)| code.to_string()) + .filter(|code| code.starts_with("CREDENTIAL_STORE_")), + error: Some(error), + }, + }) +} + +#[tauri::command] +pub async fn secure_storage_status(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || secure_storage_status_blocking(&app)) + .await + .map_err(|e| format!("Failed to join secure storage status task: {e}"))? +} + +/// Records the user's consent (or its withdrawal) to keep the master key in a +/// 0600 file when the system credential store cannot be used. +#[tauri::command] +pub async fn secure_storage_set_file_fallback( + app: AppHandle, + enabled: bool, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let dir = credentials_dir(&app)?; + ensure_dir(&dir)?; + let flag = dir.join(FILE_FALLBACK_FLAG); + if enabled { + atomic_write(&flag, b"1")?; + } else if flag.exists() { + fs::remove_file(&flag) + .map_err(|e| format!("Failed to disable the file key fallback: {e}"))?; + } + if let Ok(mut cache) = master_key_cache().lock() { + cache.remove(&dir); + } + secure_storage_status_blocking(&app) + }) + .await + .map_err(|e| format!("Failed to join secure storage fallback task: {e}"))? +} + fn validate_master_key(bytes: &[u8]) -> Result<[u8; KEY_LEN], String> { if bytes.len() != KEY_LEN { return Err("Secure storage master key is truncated or invalid".into()); @@ -314,6 +463,20 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn file_fallback_never_orphans_existing_credentials() { + let temp = tempfile::tempdir().unwrap(); + let key = load_or_create_file_master_key(temp.path(), "denied".into()).unwrap(); + assert_eq!(load_or_create_file_master_key(temp.path(), "denied".into()).unwrap(), key); + + let other = tempfile::tempdir().unwrap(); + fs::write(other.path().join("abc.credential"), b"x").unwrap(); + assert_eq!( + load_or_create_file_master_key(other.path(), "denied".into()).unwrap_err(), + "denied" + ); + } + #[test] fn master_key_requires_exact_length() { assert!(validate_master_key(&[0; KEY_LEN]).is_ok()); From 25f69640dd42ef96fdc833530ad76663d1e5c3d6 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:21:02 -0700 Subject: [PATCH 07/24] fix(launcher): Forge installer and OptiFine patcher are cancellable Both run through run_install_subprocess: output drained on background threads, polled with try_wait, killed on install-session cancel or after a 15 minute ceiling (launcher-core-12). --- tauri-app/src-tauri/src/minecraft_core.rs | 155 ++++++++++++++++------ 1 file changed, 118 insertions(+), 37 deletions(-) diff --git a/tauri-app/src-tauri/src/minecraft_core.rs b/tauri-app/src-tauri/src/minecraft_core.rs index 000ae93..b55878d 100644 --- a/tauri-app/src-tauri/src/minecraft_core.rs +++ b/tauri-app/src-tauri/src/minecraft_core.rs @@ -1366,6 +1366,7 @@ pub(crate) fn install_forge( "Running forge installer (first attempt)", ); let first = run_forge_installer( + ipc_session, game_dir, java_executable, &installer_path, @@ -1390,6 +1391,7 @@ pub(crate) fn install_forge( "Retry forge installer with fallback args", ); let fallback = run_forge_installer( + ipc_session, game_dir, java_executable, &installer_path, @@ -1681,6 +1683,7 @@ fn install_standalone_optifine( let patch_output = crate::downloader::with_suffix(&patched_path, ".patching"); let _ = fs::remove_file(&patch_output); run_optifine_patcher( + ipc_session, game_dir, java_executable, &base_jar, @@ -1898,6 +1901,7 @@ fn download_standard_launchwrapper( } fn run_optifine_patcher( + ipc_session: Option<&str>, game_dir: &Path, java_executable: &Path, base_jar: &Path, @@ -1913,7 +1917,35 @@ fn run_optifine_patcher( .arg(base_jar) .arg(installer_path) .arg(output_path) - .current_dir(game_dir) + .current_dir(game_dir); + let output = run_install_subprocess(command, ipc_session, "OptiFine patch stage").map_err( + |error| format!("{error} (Java runtime {})", java_executable.display()), + )?; + if output.exit_code != 0 { + return Err(format!( + "OptiFine patch stage failed with exit code {}: {}", + output.exit_code, + bounded_process_output(&output.output) + )); + } + Ok(()) +} + +/// Hard ceiling for an installer subprocess. The Forge installer downloads +/// its own libraries; a stalled maven connection used to hang the install +/// dialog forever with no way to cancel (launcher-core-12). +const INSTALL_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(15 * 60); + +/// Runs an installer JVM with piped output, killing it when the install +/// session is cancelled or the timeout expires. Output is drained on +/// background threads so a chatty process cannot block on a full pipe. +fn run_install_subprocess( + mut command: std::process::Command, + ipc_session: Option<&str>, + label: &str, +) -> Result { + command + .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); #[cfg(windows)] @@ -1921,22 +1953,61 @@ fn run_optifine_patcher( use std::os::windows::process::CommandExt; command.creation_flags(0x0800_0000); } - let output = command.output().map_err(|error| { - format!( - "OptiFine patch stage could not start Java runtime {}: {error}", - java_executable.display() - ) - })?; - if !output.status.success() { - let mut detail = String::from_utf8_lossy(&output.stdout).to_string(); - detail.push_str(&String::from_utf8_lossy(&output.stderr)); - return Err(format!( - "OptiFine patch stage failed with exit code {}: {}", - output.status.code().unwrap_or(-1), - bounded_process_output(&detail) - )); - } - Ok(()) + let mut child = command + .spawn() + .map_err(|error| format!("{label} could not start: {error}"))?; + let drain = |stream: Option>| { + let (sender, receiver) = std::sync::mpsc::channel(); + thread::spawn(move || { + let mut buffer = Vec::new(); + if let Some(mut stream) = stream { + let _ = stream.read_to_end(&mut buffer); + } + let _ = sender.send(buffer); + }); + receiver + }; + let stdout = drain(child.stdout.take().map(|s| Box::new(s) as Box)); + let stderr = drain(child.stderr.take().map(|s| Box::new(s) as Box)); + let started = Instant::now(); + let outcome = loop { + match child.try_wait() { + Ok(Some(status)) => break Ok(status.code().unwrap_or(-1)), + Ok(None) => {} + Err(error) => break Err(format!("{label} could not be monitored: {error}")), + } + if let Err(cancelled) = install_cancel_error(ipc_session) { + break Err(cancelled); + } + if started.elapsed() >= INSTALL_SUBPROCESS_TIMEOUT { + break Err(format!( + "{label} did not finish within {} minutes", + INSTALL_SUBPROCESS_TIMEOUT.as_secs() / 60 + )); + } + thread::sleep(Duration::from_millis(200)); + }; + // After a kill, a grandchild may still hold the pipes open; do not wait + // for it beyond a short grace period. + let collect = |receiver: std::sync::mpsc::Receiver>| { + if outcome.is_err() { + receiver + .recv_timeout(Duration::from_secs(2)) + .unwrap_or_default() + } else { + receiver.recv().unwrap_or_default() + } + }; + if outcome.is_err() { + let _ = child.kill(); + let _ = child.wait(); + } + let mut text = String::from_utf8_lossy(&collect(stdout)).to_string(); + text.push_str(&String::from_utf8_lossy(&collect(stderr))); + outcome.map(|exit_code| ProcessOutput { + exit_code, + output: text, + }) } fn replace_file(source: &Path, target: &Path) -> io::Result<()> { @@ -4059,6 +4130,7 @@ fn install_forge_old_profile( } fn run_forge_installer( + ipc_session: Option<&str>, game_dir: &Path, java_executable: &Path, installer_path: &Path, @@ -4071,26 +4143,11 @@ fn run_forge_installer( .arg(installer_path); command.args(args); command.current_dir(game_dir); - command.stdout(std::process::Stdio::piped()); - command.stderr(std::process::Stdio::piped()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - command.creation_flags(0x0800_0000); - } - let output = command.output().map_err(|e| { - format!( - "Failed to run forge installer {}: {e}", - installer_path.display() - ) - })?; - let mut text = String::new(); - text.push_str(&String::from_utf8_lossy(&output.stdout)); - text.push_str(&String::from_utf8_lossy(&output.stderr)); - Ok(ProcessOutput { - exit_code: output.status.code().unwrap_or(-1), - output: text, - }) + run_install_subprocess( + command, + ipc_session, + &format!("Forge installer {}", installer_path.display()), + ) } fn select_forge_profile_id_after_install( @@ -5282,6 +5339,30 @@ mod tests { should_omit_resolved_arg, DownloadSource, MavenCoordinates, StandaloneOptiFineOverlay, VanillaLaunchRequest, }; + + #[cfg(unix)] + #[test] + fn install_subprocess_is_killed_on_cancel() { + let session = "subprocess-cancel-test"; + let mut command = std::process::Command::new("sh"); + command.arg("-c").arg("echo started; sleep 30"); + let canceller = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(300)); + crate::request_install_cancel(session).unwrap(); + }); + let started = std::time::Instant::now(); + let result = super::run_install_subprocess(command, Some(session), "test"); + canceller.join().unwrap(); + crate::clear_install_cancel(Some(session)); + assert_eq!(result.unwrap_err(), "Installation cancelled"); + assert!(started.elapsed() < std::time::Duration::from_secs(10)); + + let mut ok = std::process::Command::new("sh"); + ok.arg("-c").arg("echo out; echo err 1>&2; exit 3"); + let output = super::run_install_subprocess(ok, None, "test").unwrap(); + assert_eq!(output.exit_code, 3); + assert!(output.output.contains("out") && output.output.contains("err")); + } use serde_json::json; use std::fs; use std::path::{Path, PathBuf}; From d351b32e9586df99d508f34580052bc0d1b1802e Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:22:20 -0700 Subject: [PATCH 08/24] fix(launcher): safer Extreme updates and process termination - Extreme updates keep fpsmaster_worlds.json (launcher-core-4). - A swap whose old copy cannot be deleted is reported as success with a warning; leftover backups are cleaned on the next swap (launcher-core-18). - Extreme asset extraction goes through a staging dir with a completion marker, so an interrupted extraction is redone (launcher-core-15). - terminate_game_process only kills pids the launcher started, and refuses when the pid now belongs to a newer process (launcher-core-16). --- tauri-app/src-tauri/src/main.rs | 140 ++++++++++++++++++++++++++++---- 1 file changed, 123 insertions(+), 17 deletions(-) diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index f69e268..065e7e8 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -1203,12 +1203,27 @@ fn terminate_game_process_blocking(pid: i64, force: Option) -> Result Result { Ok(relative) } +const BACKUP_DIR_PREFIX: &str = ".fpsmaster-launcher-backup-"; + +/// Best-effort removal of backups a previous swap could not delete (typically +/// a file held open by antivirus or the indexer at the time). +fn remove_stale_backup_dirs(parent_dir: &Path) { + let Ok(entries) = fs::read_dir(parent_dir) else { + return; + }; + for entry in entries.flatten() { + if entry + .file_name() + .to_string_lossy() + .starts_with(BACKUP_DIR_PREFIX) + && entry.path().is_dir() + { + let _ = fs::remove_dir_all(entry.path()); + } + } +} + fn replace_directory_with_stage(target_dir: &Path, stage_dir: &Path) -> Result<(), String> { let parent_dir = target_dir .parent() .ok_or_else(|| format!("Invalid target directory: {}", target_dir.display()))?; + remove_stale_backup_dirs(parent_dir); let backup_dir = parent_dir.join(format!( - ".fpsmaster-launcher-backup-{}-{}", + "{BACKUP_DIR_PREFIX}{}-{}", std::process::id(), now_epoch_millis() )); @@ -6108,13 +6144,20 @@ fn replace_directory_with_stage(target_dir: &Path, stage_dir: &Path) -> Result<( )); } + // The new directory is live at this point. Failing to delete the old copy + // must not turn a successful update into "install failed" + // (launcher-core-18); the next swap in this directory retries the cleanup. if had_existing { - fs::remove_dir_all(&backup_dir).map_err(|e| { - format!( - "Failed to remove backup directory {}: {e}", - backup_dir.display() - ) - })?; + if let Err(error) = fs::remove_dir_all(&backup_dir) { + push_ui_log( + "core", + "warn", + &format!( + "Updated {}, but the old copy could not be removed yet: {error}", + redact_path_for_log(target_dir) + ), + ); + } } Ok(()) @@ -6703,14 +6746,18 @@ fn preserve_native_app_user_data(install_dir: &Path, stage_dir: &Path) -> Result for name in ["mods", "resourcepacks", "local_assets"] { copy_directory_contents(&install_dir.join(name), &stage_dir.join(name))?; } - let settings = install_dir.join("fpsmaster_options.txt"); - if settings.is_file() { - fs::copy(&settings, stage_dir.join("fpsmaster_options.txt")).map_err(|e| { - format!( - "Failed to preserve native app settings {}: {e}", - settings.display() - ) - })?; + // Extreme keeps these in its working directory (the install dir): + // settings, and the single-player world list (crates/fpsmaster_app/src/worlds.rs). + for file_name in ["fpsmaster_options.txt", "fpsmaster_worlds.json"] { + let source = install_dir.join(file_name); + if source.is_file() { + fs::copy(&source, stage_dir.join(file_name)).map_err(|e| { + format!( + "Failed to preserve native app user data {}: {e}", + source.display() + ) + })?; + } } Ok(()) } @@ -6896,9 +6943,16 @@ fn prepare_extreme_assets_blocking(game_dir: String, version_id: String) -> Resu let install_dir = native_app_install_dir(&game_dir_path, &version_id); let assets_root = install_dir.join("local_assets").join("minecraft-1.8.9"); let assets_minecraft = assets_root.join("assets").join("minecraft"); - if assets_minecraft.is_dir() { + // A directory alone does not prove the extraction finished: an interrupted + // run used to leave a partial tree that was then reused forever + // (launcher-core-15). Extraction goes to a staging dir and the marker is + // written last. + if assets_root.join(EXTREME_ASSETS_MARKER).is_file() && assets_minecraft.is_dir() { return Ok(assets_minecraft.to_string_lossy().to_string()); } + let final_root = assets_root; + let assets_root = downloader::with_suffix(&final_root, ".staging"); + let _ = fs::remove_dir_all(&assets_root); // The launcher installs vanilla clients under versions//.jar. let jar_path = game_dir_path @@ -6964,12 +7018,27 @@ fn prepare_extreme_assets_blocking(game_dir: String, version_id: String) -> Resu } extracted += 1; } - if extracted == 0 || !assets_minecraft.is_dir() { + if extracted == 0 || !assets_root.join("assets").join("minecraft").is_dir() { + let _ = fs::remove_dir_all(&assets_root); return Err("Client jar contained no assets/minecraft resources".to_string()); } + fs::write( + assets_root.join(EXTREME_ASSETS_MARKER), + format!("{{\"entries\":{extracted}}}\n"), + ) + .map_err(|e| format!("Failed to write assets marker: {e}"))?; + if final_root.exists() { + fs::remove_dir_all(&final_root) + .map_err(|e| format!("Failed to replace {}: {e}", final_root.display()))?; + } + fs::rename(&assets_root, &final_root) + .map_err(|e| format!("Failed to activate {}: {e}", final_root.display()))?; Ok(assets_minecraft.to_string_lossy().to_string()) } +const EXTREME_ASSETS_MARKER: &str = ".fpsmaster-assets.json"; + + #[tauri::command] async fn launch_native_app( window: tauri::Window, @@ -7807,6 +7876,27 @@ fn query_process_memory_kb(pid: i64) -> Result, String> { .map(|process| process.memory() / 1024)) } +/// Whether the process currently holding `pid` already existed when we +/// launched the game (a few seconds of slack for clock granularity). A later +/// start time means the pid was reused by another program. +fn process_started_before(pid: i64, launched_at: std::time::Instant) -> bool { + let Ok(pid_u32) = u32::try_from(pid) else { + return false; + }; + let sys_pid = sysinfo::Pid::from_u32(pid_u32); + let mut system = lock_recover(process_memory_sampler()); + system.refresh_processes_specifics( + sysinfo::ProcessesToUpdate::Some(&[sys_pid]), + true, + sysinfo::ProcessRefreshKind::nothing(), + ); + let Some(process) = system.process(sys_pid) else { + return false; + }; + let launched_epoch = now_epoch_seconds().saturating_sub(launched_at.elapsed().as_secs()); + process.start_time() <= launched_epoch + 5 +} + fn process_memory_sampler() -> &'static Mutex { PROCESS_MEMORY_SAMPLER.get_or_init(|| Mutex::new(sysinfo::System::new())) } @@ -10206,6 +10296,7 @@ mod tests { fs::write(installed.join("resourcepacks/user.zip"), "user pack").unwrap(); fs::write(installed.join("local_assets/asset.bin"), "asset").unwrap(); fs::write(installed.join("fpsmaster_options.txt"), "user settings").unwrap(); + fs::write(installed.join("fpsmaster_worlds.json"), "[worlds]").unwrap(); fs::write(staged.join("resourcepacks/bundled.zip"), "bundled pack").unwrap(); preserve_native_app_user_data(&installed, &staged).expect("user data should be preserved"); @@ -10230,6 +10321,10 @@ mod tests { fs::read_to_string(staged.join("fpsmaster_options.txt")).unwrap(), "user settings" ); + assert_eq!( + fs::read_to_string(staged.join("fpsmaster_worlds.json")).unwrap(), + "[worlds]" + ); } #[cfg(unix)] @@ -10605,6 +10700,17 @@ mod tests { assert!(!assets_root.join("outside.txt").exists()); assert!(!assets_root.join("META-INF").exists()); + // A tree without the completion marker (interrupted run) is rebuilt. + let lang = assets_root.join("assets/minecraft/lang/en_US.lang"); + fs::remove_file(&lang).unwrap(); + fs::remove_file(assets_root.join(super::EXTREME_ASSETS_MARKER)).unwrap(); + prepare_extreme_assets_blocking( + game_dir.to_string_lossy().to_string(), + "extreme-test".to_string(), + ) + .expect("re-extraction should succeed"); + assert_eq!(fs::read_to_string(&lang).unwrap(), "key=value"); + assert!(prepare_extreme_assets_blocking( game_dir.to_string_lossy().to_string(), "../escape".to_string(), From 114a83ed751d0dc9aa920a1ea3685501d65d171c Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:23:33 -0700 Subject: [PATCH 09/24] fix(launcher): pass the platform token through FPSMASTER_LAUNCH_TOKEN Edge/Nova now receive the FPSMaster token in the FPSMASTER_LAUNCH_TOKEN environment variable. The -Dfpsmaster.auth.token argument stays for one release for already-shipped clients (TODO in push_platform_token). Launch results returned to the UI no longer contain the platform or Minecraft access token (product-7, launcher-core-17). --- tauri-app/src-tauri/src/main.rs | 61 ++++++++++++- tauri-app/src-tauri/src/minecraft_core.rs | 101 +++++++++++++++------- 2 files changed, 128 insertions(+), 34 deletions(-) diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index 065e7e8..f3be554 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -6574,6 +6574,7 @@ fn launch_vanilla_blocking( }; let plan = resolved_plan.plan; let natives_dir = resolved_plan.natives_dir; + let launch_env = resolved_plan.env; let mut normalized_command = normalize_game_command_tokens(plan.command.clone()); if normalized_command.is_empty() { @@ -6593,7 +6594,13 @@ fn launch_vanilla_blocking( ); let should_wait = wait_for_exit.unwrap_or(false); - let mut child = match spawn_game_process(Some(&window), &runtime_dir, &executable, &args) { + let mut child = match spawn_game_process( + Some(&window), + &runtime_dir, + &executable, + &args, + &launch_env, + ) { Ok(child) => child, Err(error) => { cleanup_launch_natives_dir(&natives_dir); @@ -6648,7 +6655,7 @@ fn launch_vanilla_blocking( exit_code: Some(exit_code), main_class: plan.main_class, shell: "direct".to_string(), - command: normalized_command, + command: redact_launch_secrets(normalized_command), }); } @@ -6683,7 +6690,7 @@ fn launch_vanilla_blocking( } else { "direct".to_string() }, - command: normalized_command, + command: redact_launch_secrets(normalized_command), }) } @@ -7130,7 +7137,7 @@ fn launch_native_app_blocking( ), ); - let mut child = spawn_game_process(Some(&window), &install_dir, &executable, &args)?; + let mut child = spawn_game_process(Some(&window), &install_dir, &executable, &args, &[])?; let pid = i64::from(child.id()); if let Ok(mut store) = game_runtime_starts().lock() { store.insert(pid, std::time::Instant::now()); @@ -7674,6 +7681,35 @@ fn format_quoted_command(executable: &str, args: &[String]) -> String { parts.join(" ") } +/// Removes credentials from a launch command before it leaves the backend +/// (command results are shown and logged by the UI). +fn redact_launch_secrets(command: Vec) -> Vec { + let mut redact_next = false; + command + .into_iter() + .map(|arg| { + if std::mem::take(&mut redact_next) { + return "".to_string(); + } + let lower = arg.to_ascii_lowercase(); + if matches!(lower.as_str(), "--accesstoken" | "--access-token" | "--session") { + redact_next = true; + return arg; + } + match arg.split_once('=') { + Some((key, _)) + if lower.starts_with("-dfpsmaster.auth.token=") + || lower.starts_with("--accesstoken=") + || lower.starts_with("--access-token=") => + { + format!("{key}=") + } + _ => arg, + } + }) + .collect() +} + fn redact_path_for_log(path: &Path) -> String { let components = path .components() @@ -7779,10 +7815,12 @@ fn spawn_game_process( game_dir: &Path, executable: &str, args: &[String], + env: &[(String, String)], ) -> Result { let mut command = Command::new(executable); command .args(args) + .envs(env.iter().map(|(key, value)| (key.as_str(), value.as_str()))) .current_dir(game_dir) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -10135,6 +10173,21 @@ mod tests { use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn launch_command_result_hides_credentials() { + let redacted = super::redact_launch_secrets(vec![ + "java".to_string(), + "-Dfpsmaster.auth.token=jwt".to_string(), + "--accessToken".to_string(), + "mc-token".to_string(), + "--username".to_string(), + "Steve".to_string(), + ]); + assert_eq!(redacted[1], "-Dfpsmaster.auth.token="); + assert_eq!(redacted[3], ""); + assert_eq!(redacted[5], "Steve"); + } + #[test] fn broken_user_jar_does_not_fail_mods_scan() { let temp = tempfile::tempdir().unwrap(); diff --git a/tauri-app/src-tauri/src/minecraft_core.rs b/tauri-app/src-tauri/src/minecraft_core.rs index b55878d..dbc9d09 100644 --- a/tauri-app/src-tauri/src/minecraft_core.rs +++ b/tauri-app/src-tauri/src/minecraft_core.rs @@ -229,6 +229,44 @@ pub(crate) struct VanillaLaunchRequest { pub(crate) struct VanillaResolvedLaunchPlan { pub(crate) plan: LaunchPlan, pub(crate) natives_dir: PathBuf, + /// Extra environment for the game process (never logged). + pub(crate) env: Vec<(String, String)>, +} + +/// Environment variable carrying the FPSMaster platform token to Edge/Nova. +/// Unlike a `-D` argument it is not visible in the process command line and is +/// not written into hs_err files (product-7 / launcher-core-17). +pub(crate) const LAUNCH_TOKEN_ENV: &str = "FPSMASTER_LAUNCH_TOKEN"; + +/// The platform token to hand to the game, only for the preset Edge/Nova +/// instances that understand it. +fn preset_platform_token(request: &VanillaLaunchRequest) -> Option<&str> { + if !matches!( + request.version_id.trim(), + "FPSMaster-Edge" | "FPSMaster-Nova" + ) { + return None; + } + request + .fpsmaster_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn push_platform_token( + request: &VanillaLaunchRequest, + jvm_args: &mut Vec, + env: &mut Vec<(String, String)>, +) { + let Some(token) = preset_platform_token(request) else { + return; + }; + env.push((LAUNCH_TOKEN_ENV.to_string(), token.to_string())); + // TODO(remove one release after Edge/Nova read FPSMASTER_LAUNCH_TOKEN): + // already-released clients only read this system property, so it stays + // for one version cycle (CONTRACTS.md section 6). + jvm_args.push(format!("-Dfpsmaster.auth.token={token}")); } #[derive(Debug, Clone)] @@ -2163,21 +2201,9 @@ pub(crate) fn build_vanilla_launch_plan( append_launch_logging_argument(window, &version_json, request, source, &mut jvm_args); jvm_args = normalize_jvm_arguments(jvm_args); - // Inject the FPSMaster platform auth token only for preset Edge/Nova instances so the - // client inherits the launcher login. Edge/Nova read -Dfpsmaster.auth.token at startup. - if matches!( - request.version_id.trim(), - "FPSMaster-Edge" | "FPSMaster-Nova" - ) { - if let Some(token) = request - .fpsmaster_token - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - jvm_args.push(format!("-Dfpsmaster.auth.token={token}")); - } - } + // Hand the FPSMaster platform login to preset Edge/Nova instances. + let mut env = Vec::new(); + push_platform_token(request, &mut jvm_args, &mut env); let main_class = if optifine_overlay.is_some() { "net.minecraft.launchwrapper.Launch".to_string() @@ -2247,6 +2273,7 @@ pub(crate) fn build_vanilla_launch_plan( main_class, }, natives_dir, + env, }) } @@ -2556,21 +2583,9 @@ pub(crate) fn build_edge_aot_launch_plan( jvm_args = normalize_jvm_arguments(jvm_args); jvm_args.extend(jvm_extra); - // Inject the FPSMaster platform auth token only for preset Edge instances, matching - // the Forge launch path. - if matches!( - request.version_id.trim(), - "FPSMaster-Edge" | "FPSMaster-Nova" - ) { - if let Some(token) = request - .fpsmaster_token - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - jvm_args.push(format!("-Dfpsmaster.auth.token={token}")); - } - } + // Hand the FPSMaster platform login to preset Edge/Nova instances. + let mut env = Vec::new(); + push_platform_token(request, &mut jvm_args, &mut env); let main_class = "net.minecraft.launchwrapper.Launch".to_string(); let mut game_args = if let Some(args) = version_json @@ -2633,6 +2648,7 @@ pub(crate) fn build_edge_aot_launch_plan( main_class, }, natives_dir, + env, }) } @@ -5340,6 +5356,31 @@ mod tests { VanillaLaunchRequest, }; + #[test] + fn platform_token_goes_to_env_for_presets_only() { + let request = |version: &str| super::VanillaLaunchRequest { + game_dir: std::path::PathBuf::from("/tmp"), + version_id: version.to_string(), + player_name: "Player".to_string(), + uuid: String::new(), + access_token: String::new(), + user_type: None, + auth_xuid: None, + java_path: std::path::PathBuf::from("java"), + max_memory_mb: 1024, + server_address: None, + fpsmaster_token: Some(" jwt ".to_string()), + }; + let (mut args, mut env) = (Vec::new(), Vec::new()); + super::push_platform_token(&request("FPSMaster-Nova"), &mut args, &mut env); + assert_eq!(env, vec![(super::LAUNCH_TOKEN_ENV.to_string(), "jwt".to_string())]); + assert_eq!(args, vec!["-Dfpsmaster.auth.token=jwt".to_string()]); + + let (mut args, mut env) = (Vec::new(), Vec::new()); + super::push_platform_token(&request("1.8.9"), &mut args, &mut env); + assert!(env.is_empty() && args.is_empty()); + } + #[cfg(unix)] #[test] fn install_subprocess_is_killed_on_cancel() { From de54b737acb47ed72cce96ff54bf3c632d764ebf Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:30:23 -0700 Subject: [PATCH 10/24] feat(launcher): crash detection, health check, support bundle, source probing - game-exit now carries versionId, durationMs, crashed, terminatedByUser and the crash report / hs_err path and summary written during the run. - upload_game_crash_report sends a redacted crash report to /api/v1/telemetry/client-errors; HTTP 429 resolves as rate_limited. - run_health_check returns status, i18n code, params and a repair command for Java runtimes, instance files, disk space, download sources, the credential store and recent crashes; repair_java_runtime re-verifies a managed runtime and fetches only bad files. - export_support_bundle writes a redacted zip with launcher log, latest game log, crash reports, hs_err files, health check and system info. - probe_download_sources measures official vs BMCLAPI (and the FPSMaster API); the new "auto" download source id resolves to the faster one. - Secrets (JWTs, launch token, access tokens, Authorization, cookies, passwords) are stripped before anything is uploaded or exported. --- tauri-app/src-tauri/Cargo.toml | 2 +- tauri-app/src-tauri/src/diagnostics.rs | 1295 +++++++++++++++++++++ tauri-app/src-tauri/src/downloader.rs | 67 +- tauri-app/src-tauri/src/main.rs | 168 ++- tauri-app/src-tauri/src/minecraft_core.rs | 124 ++ 5 files changed, 1612 insertions(+), 44 deletions(-) create mode 100644 tauri-app/src-tauri/src/diagnostics.rs diff --git a/tauri-app/src-tauri/Cargo.toml b/tauri-app/src-tauri/Cargo.toml index f7661c2..4b8cfbb 100644 --- a/tauri-app/src-tauri/Cargo.toml +++ b/tauri-app/src-tauri/Cargo.toml @@ -26,7 +26,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha1 = "0.10" sha2 = "0.10" -sysinfo = { version = "0.33", default-features = false, features = ["system"] } +sysinfo = { version = "0.33", default-features = false, features = ["system", "disk"] } tar = "0.4" tauri = { version = "2.10", features = ["tray-icon", "image-png", "protocol-asset"] } tauri-plugin-autostart = "2" diff --git a/tauri-app/src-tauri/src/diagnostics.rs b/tauri-app/src-tauri/src/diagnostics.rs new file mode 100644 index 0000000..52f3540 --- /dev/null +++ b/tauri-app/src-tauri/src/diagnostics.rs @@ -0,0 +1,1295 @@ +//! Diagnostics: secret redaction, game crash detection and upload, download +//! source probing, the one-click health check and the support bundle export. +//! +//! Everything here that leaves the machine or lands in a file the user may +//! share goes through [`redact_secrets`] first (CONTRACTS.md sections 2 and 6). + +use crate::downloader::{self, ProbeResult}; +use crate::minecraft_core::{self, lock_recover, DownloadSource}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::HashSet; +use std::fs; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager}; + +// --------------------------------------------------------------------------- +// Redaction +// --------------------------------------------------------------------------- + +fn redaction_rules() -> &'static [(Regex, &'static str)] { + static RULES: OnceLock> = OnceLock::new(); + RULES.get_or_init(|| { + [ + // JWTs (FPSMaster platform tokens, Minecraft access tokens). + ( + r"eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}", + "", + ), + (r#"(?i)(-Dfpsmaster\.auth\.token=)[^\s"']+"#, "${1}"), + (r#"(?i)(FPSMASTER_LAUNCH_TOKEN=)[^\s"']+"#, "${1}"), + (r#"(?i)(--access-?token[\s=]+)[^\s"']+"#, "${1}"), + ( + r#"(?i)(authorization["']?\s*[:=]\s*["']?)(?:bearer\s+)?[^\s"',;]+"#, + "${1}", + ), + (r"(?i)((?:set-)?cookie\s*[:=]\s*)[^\r\n]+", "${1}"), + ( + r#"(?i)(\b(?:access_?token|refresh_?token|id_?token|auth_?token|token|password|passwd|pwd|secret|client_?secret)["']?\s*[:=]\s*["']?)[^\s"'&,;}]+"#, + "${1}", + ), + ] + .into_iter() + .map(|(pattern, replacement)| { + ( + Regex::new(pattern).expect("redaction pattern is valid"), + replacement, + ) + }) + .collect() + }) +} + +/// Removes tokens, passwords and cookies from free text (logs, crash reports, +/// hs_err files) before it is uploaded or exported. +pub(crate) fn redact_secrets(text: &str) -> String { + let mut output = text.to_string(); + for (regex, replacement) in redaction_rules() { + output = regex.replace_all(&output, *replacement).into_owned(); + } + output +} + +/// Reads at most the last `max_bytes` of a text file. +fn read_text_tail(path: &Path, max_bytes: u64) -> Option { + let mut file = fs::File::open(path).ok()?; + let len = file.metadata().ok()?.len(); + if len > max_bytes { + file.seek(SeekFrom::Start(len - max_bytes)).ok()?; + } + let mut bytes = Vec::new(); + file.take(max_bytes).read_to_end(&mut bytes).ok()?; + Some(String::from_utf8_lossy(&bytes).into_owned()) +} + +/// Reads at most the first `max_bytes` of a text file. +fn read_text_head(path: &Path, max_bytes: u64) -> Option { + let mut bytes = Vec::new(); + fs::File::open(path) + .ok()? + .take(max_bytes) + .read_to_end(&mut bytes) + .ok()?; + Some(String::from_utf8_lossy(&bytes).into_owned()) +} + +fn truncate_chars(text: &str, max: usize) -> String { + match text.char_indices().nth(max) { + Some((index, _)) => text[..index].to_string(), + None => text.to_string(), + } +} + +pub(crate) fn iso8601_utc(time: SystemTime) -> String { + let secs = time + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + // Civil-from-days (Howard Hinnant). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + rem / 3600, + rem % 3600 / 60, + rem % 60 + ) +} + +// --------------------------------------------------------------------------- +// Crash detection +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CrashArtifacts { + pub crash_report_path: Option, + pub crash_summary: Option, + pub hs_err_path: Option, + pub hs_err_summary: Option, +} + +fn user_terminated() -> &'static Mutex> { + static SET: OnceLock>> = OnceLock::new(); + SET.get_or_init(|| Mutex::new(HashSet::new())) +} + +/// Remembers that the user stopped this game, so its non-zero exit code is +/// not reported as a crash. +pub(crate) fn mark_user_terminated(pid: i64) { + lock_recover(user_terminated()).insert(pid); +} + +pub(crate) fn take_user_terminated(pid: i64) -> bool { + lock_recover(user_terminated()).remove(&pid) +} + +fn modified_at(path: &Path) -> Option { + fs::metadata(path).and_then(|meta| meta.modified()).ok() +} + +fn is_crash_report_name(name: &str) -> bool { + name.starts_with("crash-") && name.ends_with(".txt") +} + +fn is_hs_err_name(name: &str) -> bool { + name.starts_with("hs_err_pid") && name.ends_with(".log") +} + +/// Files in `dir` matching `predicate` modified at or after `since`, newest first. +fn files_since(dir: &Path, since: SystemTime, predicate: fn(&str) -> bool) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut found: Vec<(SystemTime, PathBuf)> = entries + .flatten() + .filter(|entry| predicate(&entry.file_name().to_string_lossy())) + .filter_map(|entry| { + let path = entry.path(); + let modified = modified_at(&path)?; + (modified >= since && path.is_file()).then_some((modified, path)) + }) + .collect(); + found.sort_by_key(|entry| std::cmp::Reverse(entry.0)); + found.into_iter().map(|(_, path)| path).collect() +} + +/// Description line plus the head of the stack trace of a Minecraft crash report. +pub(crate) fn summarize_crash_report(text: &str) -> Option { + let lines: Vec<&str> = text.lines().collect(); + let start = lines + .iter() + .position(|line| line.trim_start().starts_with("Description:"))?; + let summary = lines[start..] + .iter() + .map(|line| line.trim_end()) + .filter(|line| !line.trim().is_empty()) + .take(12) + .collect::>() + .join("\n"); + Some(redact_secrets(&summary)) +} + +/// Header block of a JVM fatal error log (`# A fatal error ...`, problematic frame). +pub(crate) fn summarize_hs_err(text: &str) -> Option { + let summary = text + .lines() + .take_while(|line| line.starts_with('#') || line.trim().is_empty()) + .map(|line| line.trim_start_matches('#').trim()) + .filter(|line| !line.is_empty()) + .take(12) + .collect::>() + .join("\n"); + (!summary.is_empty()).then(|| redact_secrets(&summary)) +} + +/// Looks for a crash report / hs_err file written by the game that just exited. +pub(crate) fn find_crash_artifacts(work_dir: &Path, pid: i64, since: SystemTime) -> CrashArtifacts { + // File times can be coarse (FAT, SMB); give a little slack. + let since = since.checked_sub(Duration::from_secs(2)).unwrap_or(since); + let mut artifacts = CrashArtifacts::default(); + if let Some(report) = files_since(&work_dir.join("crash-reports"), since, is_crash_report_name) + .into_iter() + .next() + { + artifacts.crash_summary = + read_text_head(&report, 256 * 1024).and_then(|text| summarize_crash_report(&text)); + artifacts.crash_report_path = Some(report.to_string_lossy().into_owned()); + } + let exact = work_dir.join(format!("hs_err_pid{pid}.log")); + let hs_err = if exact.is_file() { + Some(exact) + } else { + files_since(work_dir, since, is_hs_err_name) + .into_iter() + .next() + }; + if let Some(hs_err) = hs_err { + artifacts.hs_err_summary = + read_text_head(&hs_err, 64 * 1024).and_then(|text| summarize_hs_err(&text)); + artifacts.hs_err_path = Some(hs_err.to_string_lossy().into_owned()); + } + artifacts +} + +/// Crash files of all instances below `game_dir` from the last `max_age`. +fn recent_crash_files(game_dir: &Path, max_age: Duration) -> Vec { + let since = SystemTime::now().checked_sub(max_age).unwrap_or(UNIX_EPOCH); + let mut dirs = vec![game_dir.to_path_buf()]; + for parent in ["versions", "apps"] { + if let Ok(entries) = fs::read_dir(game_dir.join(parent)) { + dirs.extend( + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()), + ); + } + } + let mut files: Vec = Vec::new(); + for dir in dirs { + files.extend(files_since( + &dir.join("crash-reports"), + since, + is_crash_report_name, + )); + files.extend(files_since(&dir, since, is_hs_err_name)); + } + files.sort_by_key(|path| std::cmp::Reverse(modified_at(path))); + files +} + +// --------------------------------------------------------------------------- +// Crash upload +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CrashUploadRequest { + base_url: String, + version_id: String, + #[serde(default)] + exit_code: Option, + #[serde(default)] + duration_ms: Option, + #[serde(default)] + crash_report_path: Option, + #[serde(default)] + hs_err_path: Option, + #[serde(default)] + session_id: Option, + /// UI language, sent as Accept-Language ("zh-CN" / "en"). + #[serde(default)] + language: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CrashUploadResult { + /// "sent" or "rate_limited" (HTTP 429: dropped silently, do not retry). + status: String, +} + +/// Only files that are what they claim to be may be uploaded: the frontend +/// passes paths, and this must not become a way to send arbitrary files. +fn validated_crash_file( + raw: Option<&str>, + expect_crash_report: bool, +) -> Result, String> { + let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let path = PathBuf::from(raw); + let name = path + .file_name() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_default(); + let valid = if expect_crash_report { + is_crash_report_name(&name) + && path + .parent() + .and_then(Path::file_name) + .is_some_and(|parent| parent == "crash-reports") + } else { + is_hs_err_name(&name) + }; + if !valid || !path.is_file() { + return Err(format!("Not a game crash file: {name}")); + } + Ok(Some(path)) +} + +fn client_kind_for(version_id: &str) -> &'static str { + match version_id { + "FPSMaster-Edge" => "EDGE", + "FPSMaster-Nova" => "NOVA", + _ => "GENERIC", + } +} + +fn upload_crash_blocking(request: CrashUploadRequest) -> Result { + const MAX_FIELD: usize = 16_000; + let crash_report = validated_crash_file(request.crash_report_path.as_deref(), true)?; + let hs_err = validated_crash_file(request.hs_err_path.as_deref(), false)?; + if crash_report.is_none() && hs_err.is_none() { + return Err("No crash report to upload".to_string()); + } + let report_text = crash_report + .as_deref() + .and_then(|path| read_text_head(path, 512 * 1024)) + .map(|text| redact_secrets(&text)); + let hs_err_text = hs_err + .as_deref() + .and_then(|path| read_text_head(path, 512 * 1024)) + .map(|text| redact_secrets(&text)); + let summary = report_text + .as_deref() + .and_then(summarize_crash_report) + .or_else(|| hs_err_text.as_deref().and_then(summarize_hs_err)) + .unwrap_or_else(|| "Game crashed".to_string()); + let message = summary + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or("Game crashed") + .to_string(); + let stack_trace = report_text + .as_deref() + .or(hs_err_text.as_deref()) + .unwrap_or(""); + let payload = json!({ + "exitCode": request.exit_code, + "durationMs": request.duration_ms, + "versionId": request.version_id, + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "launcherVersion": env!("CARGO_PKG_VERSION"), + "summary": summary, + "hsErrSummary": hs_err_text.as_deref().and_then(summarize_hs_err), + }); + let body = json!({ + "clientName": truncate_chars(request.version_id.trim(), 80), + "clientKind": client_kind_for(request.version_id.trim()), + "sessionId": request.session_id, + "appVersion": env!("CARGO_PKG_VERSION"), + "severity": "fatal", + "errorType": if report_text.is_some() { "GAME_CRASH" } else { "JVM_FATAL_ERROR" }, + "message": truncate_chars(&message, 500), + "stackTrace": truncate_chars(stack_trace, MAX_FIELD), + "payloadJson": truncate_chars(&payload.to_string(), MAX_FIELD), + "occurredAt": iso8601_utc(SystemTime::now()), + }); + + let base = crate::normalize_api_base_url(&request.base_url)?; + let client = crate::build_api_http_client()?; + let mut builder = client + .post(format!("{base}/api/v1/telemetry/client-errors")) + .json(&body); + if let Some(language) = request + .language + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + builder = builder.header(reqwest::header::ACCEPT_LANGUAGE, language); + } + let response = builder.send().map_err(|e| { + format!( + "Crash report upload failed: {}", + crate::describe_http_error(&e) + ) + })?; + let status = response.status(); + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + return Ok(CrashUploadResult { + status: "rate_limited".to_string(), + }); + } + if !status.is_success() { + let text = response.text().unwrap_or_default(); + let message = serde_json::from_str::(&text) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| format!("HTTP {status}")); + return Err(format!("Crash report upload failed: {message}")); + } + Ok(CrashUploadResult { + status: "sent".to_string(), + }) +} + +/// Uploads a crash report / hs_err file to /telemetry/client-errors. The UI +/// decides whether the user agreed; this only performs the redacted upload. +#[tauri::command] +pub(crate) async fn upload_game_crash_report( + request: CrashUploadRequest, +) -> Result { + tauri::async_runtime::spawn_blocking(move || upload_crash_blocking(request)) + .await + .map_err(|e| format!("Failed to join crash upload task: {e}"))? +} + +// --------------------------------------------------------------------------- +// Download source probing +// --------------------------------------------------------------------------- + +const OFFICIAL_PROBE_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; +const MIRROR_PROBE_URL: &str = "https://bmclapi2.bangbang93.com/mc/game/version_manifest_v2.json"; +const PROBE_BYTES: u64 = 512 * 1024; +const PROBE_BUDGET: Duration = Duration::from_secs(5); +const AUTO_SOURCE_TTL: Duration = Duration::from_secs(30 * 60); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SourceProbeEntry { + /// "official" | "bmclapi" | "fpsmaster-api" + id: String, + #[serde(flatten)] + result: ProbeResult, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SourceProbeReport { + sources: Vec, + /// Download source id to use: "official-first" or "mirror-first". + recommended: String, +} + +fn probe_targets(api_base_url: Option<&str>) -> Vec<(String, String)> { + let mut targets = vec![ + ("official".to_string(), OFFICIAL_PROBE_URL.to_string()), + ("bmclapi".to_string(), MIRROR_PROBE_URL.to_string()), + ]; + if let Some(base) = api_base_url.and_then(|raw| crate::normalize_api_base_url(raw).ok()) { + targets.push(( + "fpsmaster-api".to_string(), + format!("{base}/api/v1/telemetry/online"), + )); + } + targets +} + +fn probe_all(targets: Vec<(String, String)>) -> Vec { + let handles: Vec<_> = targets + .into_iter() + .map(|(id, url)| { + thread::spawn(move || SourceProbeEntry { + id, + result: downloader::probe_url(&url, PROBE_BYTES, PROBE_BUDGET), + }) + }) + .collect(); + handles + .into_iter() + .filter_map(|handle| handle.join().ok()) + .collect() +} + +fn source_score(entry: Option<&SourceProbeEntry>) -> Option { + let entry = entry?; + entry + .result + .ok + .then(|| entry.result.bytes_per_second.unwrap_or(0)) +} + +fn recommend(sources: &[SourceProbeEntry]) -> DownloadSource { + let find = |id: &str| sources.iter().find(|entry| entry.id == id); + match ( + source_score(find("official")), + source_score(find("bmclapi")), + ) { + (Some(official), Some(mirror)) if official > mirror => DownloadSource::OfficialFirst, + (Some(_), None) => DownloadSource::OfficialFirst, + _ => DownloadSource::MirrorFirst, + } +} + +fn source_id(source: DownloadSource) -> &'static str { + match source { + DownloadSource::OfficialFirst => "official-first", + DownloadSource::OfficialOnly => "official", + DownloadSource::MirrorOnly => "mirror-only", + DownloadSource::MirrorFirst => "mirror-first", + } +} + +fn auto_source_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(None)) +} + +fn remember_recommendation(source: DownloadSource) { + *lock_recover(auto_source_cache()) = Some((Instant::now(), source)); +} + +/// Resolution of the "auto" download source id: the fastest of official and +/// BMCLAPI, measured at most every 30 minutes. +pub(crate) fn auto_download_source() -> DownloadSource { + if let Some((at, source)) = *lock_recover(auto_source_cache()) { + if at.elapsed() < AUTO_SOURCE_TTL { + return source; + } + } + let source = recommend(&probe_all(probe_targets(None))); + remember_recommendation(source); + source +} + +/// Measures latency/throughput of the Minecraft download sources (and the +/// FPSMaster API when a base URL is given) and recommends the faster one. +/// The recommendation is also what the "auto" download source resolves to. +#[tauri::command] +pub(crate) async fn probe_download_sources( + api_base_url: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let sources = probe_all(probe_targets(api_base_url.as_deref())); + let recommended = recommend(&sources); + remember_recommendation(recommended); + SourceProbeReport { + sources, + recommended: source_id(recommended).to_string(), + } + }) + .await + .map_err(|e| format!("Failed to join source probe task: {e}")) +} + +// --------------------------------------------------------------------------- +// Health check +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FixAction { + /// Tauri command that performs the repair. + command: String, + /// Arguments for that command (camelCase keys, ready for `invoke`). + args: Value, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HealthCheckItem { + id: String, + /// "java" | "instance" | "disk" | "network" | "credentials" | "crashes" + category: String, + /// "ok" | "warning" | "error" | "skipped" + status: String, + /// Stable code for the UI's i18n table (e.g. JAVA_RUNTIME_INCOMPLETE). + code: String, + /// Values for the localized text. + params: Map, + /// Technical detail in English (not for direct display as a sentence). + detail: String, + fix: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HealthReport { + generated_at: String, + items: Vec, +} + +fn item( + id: impl Into, + category: &str, + status: &str, + code: &str, + params: Value, + detail: impl Into, + fix: Option, +) -> HealthCheckItem { + HealthCheckItem { + id: id.into(), + category: category.to_string(), + status: status.to_string(), + code: code.to_string(), + params: params.as_object().cloned().unwrap_or_default(), + detail: detail.into(), + fix, + } +} + +fn fix(command: &str, args: Value) -> Option { + Some(FixAction { + command: command.to_string(), + args, + }) +} + +fn check_java_runtimes(game_dir: &Path, deep: bool, items: &mut Vec) { + let runtime_dir = game_dir.join("runtime"); + let mut names: Vec = fs::read_dir(&runtime_dir) + .map(|entries| { + entries + .flatten() + .filter(|entry| entry.path().is_dir()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .map(|name| { + // An interrupted install leaves `.jdk-N.staging`. + name.strip_prefix('.') + .and_then(|rest| rest.strip_suffix(".staging")) + .map(str::to_string) + .unwrap_or(name) + }) + .filter(|name| crate::parse_managed_runtime_name(name).is_some()) + .collect() + }) + .unwrap_or_default(); + names.sort(); + names.dedup(); + for name in names { + let root = runtime_dir.join(&name); + let args = json!({ "gameDir": game_dir.to_string_lossy(), "runtimeName": name }); + match crate::verify_runtime_install(&root, deep) { + Ok(java) => items.push(item( + format!("java:{name}"), + "java", + "ok", + "JAVA_RUNTIME_OK", + json!({ "runtime": name }), + java.to_string_lossy(), + None, + )), + Err(reason) => items.push(item( + format!("java:{name}"), + "java", + "error", + "JAVA_RUNTIME_INCOMPLETE", + json!({ "runtime": name, "reason": reason }), + reason.clone(), + fix("repair_java_runtime", args), + )), + } + } +} + +fn check_instance(game_dir: &Path, version_id: &str, deep: bool, items: &mut Vec) { + let id = format!("instance:{version_id}"); + if crate::is_native_app_version_id(version_id) { + let install_dir = crate::native_app_install_dir(game_dir, version_id); + let binary = install_dir.join(crate::native_app_binary_name()); + let marker = crate::native_app_marker_path(&install_dir); + let (status, code) = if binary.is_file() && marker.is_file() { + ("ok", "INSTANCE_FILES_OK") + } else { + ("error", "NATIVE_APP_INCOMPLETE") + }; + items.push(item( + id, + "instance", + status, + code, + json!({ "versionId": version_id }), + install_dir.to_string_lossy(), + None, + )); + return; + } + match minecraft_core::inspect_instance_files(game_dir, version_id, deep) { + Ok(report) if report.missing.is_empty() && report.corrupted.is_empty() => { + items.push(item( + id, + "instance", + "ok", + "INSTANCE_FILES_OK", + json!({ "versionId": version_id, "checked": report.checked }), + format!("{} files checked", report.checked), + None, + )); + check_instance_java(game_dir, version_id, report.java_major, items); + } + Ok(report) => { + let code = if report.corrupted.is_empty() { + "INSTANCE_FILES_MISSING" + } else { + "INSTANCE_FILES_CORRUPTED" + }; + items.push(item( + id, + "instance", + "error", + code, + json!({ + "versionId": version_id, + "missing": report.missing.len(), + "corrupted": report.corrupted.len(), + "samples": report.missing.iter().chain(report.corrupted.iter()).take(5).collect::>(), + }), + format!("missing={:?} corrupted={:?}", report.missing, report.corrupted), + fix( + "verify_installed_files", + json!({ "gameDir": game_dir.to_string_lossy(), "versionId": version_id }), + ), + )); + check_instance_java(game_dir, version_id, report.java_major, items); + } + Err(error) => items.push(item( + id, + "instance", + "error", + "INSTANCE_PROFILE_UNREADABLE", + json!({ "versionId": version_id }), + error, + None, + )), + } +} + +fn check_instance_java( + game_dir: &Path, + version_id: &str, + java_major: i32, + items: &mut Vec, +) { + let major = java_major.max(8); + let x64 = minecraft_core::macos_requires_x64_runtime(game_dir, version_id); + let root = crate::managed_jdk_runtime_root(game_dir, major, x64); + if root.exists() || crate::runtime_staging_dir(&root).exists() { + // Covered by the per-runtime check. + return; + } + items.push(item( + format!("java-required:{version_id}"), + "java", + "warning", + "JAVA_RUNTIME_MISSING", + json!({ "versionId": version_id, "major": major }), + root.to_string_lossy(), + fix( + "ensure_jdk", + json!({ "gameDir": game_dir.to_string_lossy(), "versionId": version_id }), + ), + )); +} + +fn check_disk_space(game_dir: &Path, items: &mut Vec) { + const GIB: u64 = 1024 * 1024 * 1024; + let target = game_dir + .canonicalize() + .unwrap_or_else(|_| game_dir.to_path_buf()); + let disks = sysinfo::Disks::new_with_refreshed_list(); + let disk = disks + .list() + .iter() + .filter(|disk| target.starts_with(disk.mount_point())) + .max_by_key(|disk| disk.mount_point().as_os_str().len()); + let Some(disk) = disk else { + items.push(item( + "disk", + "disk", + "skipped", + "DISK_UNKNOWN", + json!({}), + "No disk found for the game directory", + None, + )); + return; + }; + let free = disk.available_space(); + let (status, code) = if free < GIB { + ("error", "DISK_SPACE_CRITICAL") + } else if free < 5 * GIB { + ("warning", "DISK_SPACE_LOW") + } else { + ("ok", "DISK_SPACE_OK") + }; + items.push(item( + "disk", + "disk", + status, + code, + json!({ "freeBytes": free, "totalBytes": disk.total_space() }), + disk.mount_point().to_string_lossy(), + None, + )); +} + +fn check_network(api_base_url: Option<&str>, items: &mut Vec) { + const SLOW_BYTES_PER_SECOND: u64 = 100 * 1024; + let sources = probe_all(probe_targets(api_base_url)); + remember_recommendation(recommend(&sources)); + for source in sources { + let result = &source.result; + let (status, code) = if !result.ok { + ("error", "SOURCE_UNREACHABLE") + } else if source.id != "fpsmaster-api" + && result.bytes_per_second.unwrap_or(0) < SLOW_BYTES_PER_SECOND + { + ("warning", "SOURCE_SLOW") + } else { + ("ok", "SOURCE_OK") + }; + items.push(item( + format!("network:{}", source.id), + "network", + status, + code, + json!({ + "source": source.id, + "latencyMs": result.latency_ms, + "bytesPerSecond": result.bytes_per_second, + }), + result.error.clone().unwrap_or_else(|| result.url.clone()), + None, + )); + } +} + +fn check_credentials(app: &AppHandle, items: &mut Vec) { + match crate::secure_storage::secure_storage_status_blocking(app) { + Ok(status) if status.backend != "unavailable" => items.push(item( + "credentials", + "credentials", + "ok", + "CREDENTIAL_STORE_OK", + json!({ "backend": status.backend }), + "", + None, + )), + Ok(status) => { + let code = status + .error_code + .clone() + .unwrap_or_else(|| crate::secure_storage::CODE_STORE_UNAVAILABLE.to_string()); + let offer_fallback = !status.file_fallback_enabled + && code == crate::secure_storage::CODE_STORE_UNAVAILABLE; + items.push(item( + "credentials", + "credentials", + "error", + &code, + json!({ "fileFallbackEnabled": status.file_fallback_enabled }), + status.error.unwrap_or_default(), + // Needs the user's explicit consent in the UI before invoking. + offer_fallback.then(|| FixAction { + command: "secure_storage_set_file_fallback".to_string(), + args: json!({ "enabled": true }), + }), + )); + } + Err(error) => items.push(item( + "credentials", + "credentials", + "error", + crate::secure_storage::CODE_STORE_UNAVAILABLE, + json!({}), + error, + None, + )), + } +} + +fn check_recent_crashes(game_dir: &Path, items: &mut Vec) { + let files = recent_crash_files(game_dir, Duration::from_secs(7 * 86_400)); + let Some(latest) = files.first() else { + items.push(item( + "crashes", + "crashes", + "ok", + "NO_RECENT_CRASHES", + json!({}), + "", + None, + )); + return; + }; + let name = latest + .file_name() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_default(); + let summary = if is_hs_err_name(&name) { + read_text_head(latest, 64 * 1024).and_then(|text| summarize_hs_err(&text)) + } else { + read_text_head(latest, 256 * 1024).and_then(|text| summarize_crash_report(&text)) + }; + items.push(item( + "crashes", + "crashes", + "warning", + "RECENT_CRASHES", + json!({ + "count": files.len(), + "latestPath": latest.to_string_lossy(), + "latestSummary": summary, + }), + latest.to_string_lossy(), + fix( + "export_support_bundle", + json!({ "gameDir": game_dir.to_string_lossy() }), + ), + )); +} + +pub(crate) fn run_health_check_blocking( + app: &AppHandle, + game_dir: &Path, + version_id: Option<&str>, + api_base_url: Option<&str>, + deep: bool, +) -> HealthReport { + let mut items = Vec::new(); + check_java_runtimes(game_dir, deep, &mut items); + if let Some(version_id) = version_id.map(str::trim).filter(|value| !value.is_empty()) { + if crate::ensure_safe_child_name(version_id, "Version id").is_ok() { + check_instance(game_dir, version_id, deep, &mut items); + } + } + check_disk_space(game_dir, &mut items); + check_network(api_base_url, &mut items); + check_credentials(app, &mut items); + check_recent_crashes(game_dir, &mut items); + HealthReport { + generated_at: iso8601_utc(SystemTime::now()), + items, + } +} + +/// One-click health check. Every item carries a status, a stable code for the +/// UI text and, where possible, the command (with arguments) that repairs it. +/// `deep` re-hashes runtime and instance files (slow). +#[tauri::command] +pub(crate) async fn run_health_check( + app: AppHandle, + game_dir: String, + version_id: Option, + api_base_url: Option, + deep: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let game_dir = crate::resolve_game_dir_path(&game_dir)?; + Ok(run_health_check_blocking( + &app, + &game_dir, + version_id.as_deref(), + api_base_url.as_deref(), + deep.unwrap_or(false), + )) + }) + .await + .map_err(|e| format!("Failed to join health check task: {e}"))? +} + +// --------------------------------------------------------------------------- +// Support bundle +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SupportBundleResult { + path: String, + entries: Vec, + size_bytes: u64, +} + +fn system_info(app: &AppHandle, game_dir: &Path) -> Value { + let mut system = sysinfo::System::new(); + system.refresh_memory(); + system.refresh_cpu_list(sysinfo::CpuRefreshKind::nothing()); + let runtimes: Vec = fs::read_dir(game_dir.join("runtime")) + .map(|entries| { + entries + .flatten() + .map(|entry| { + let path = entry.path(); + json!({ + "name": entry.file_name().to_string_lossy(), + "complete": crate::verify_runtime_install(&path, false).is_ok(), + }) + }) + .collect() + }) + .unwrap_or_default(); + json!({ + "launcherVersion": env!("CARGO_PKG_VERSION"), + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "osVersion": sysinfo::System::long_os_version(), + "kernelVersion": sysinfo::System::kernel_version(), + "cpu": system.cpus().first().map(|cpu| cpu.brand().to_string()), + "cpuCount": system.cpus().len(), + "totalMemoryBytes": system.total_memory(), + "availableMemoryBytes": system.available_memory(), + "gameDir": game_dir.to_string_lossy(), + "appDataDir": app.path().app_data_dir().ok().map(|path| path.to_string_lossy().into_owned()), + "javaRuntimes": runtimes, + "generatedAt": iso8601_utc(SystemTime::now()), + }) +} + +fn launcher_log_text() -> String { + let store = lock_recover(crate::ui_log_store()); + let mut text = String::new(); + for entry in &store.entries { + text.push_str(&format!( + "[{}][{}][{}] {}\n", + entry.seq, entry.source, entry.level, entry.message + )); + } + text +} + +/// Most recently written `logs/latest.log` among the instance directories. +fn latest_game_log(game_dir: &Path, version_id: Option<&str>) -> Option { + if let Some(version_id) = + version_id.filter(|id| crate::ensure_safe_child_name(id, "id").is_ok()) + { + for base in ["versions", "apps"] { + let path = game_dir + .join(base) + .join(version_id) + .join("logs") + .join("latest.log"); + if path.is_file() { + return Some(path); + } + } + } + let mut candidates = Vec::new(); + for base in ["versions", "apps"] { + if let Ok(entries) = fs::read_dir(game_dir.join(base)) { + candidates.extend( + entries + .flatten() + .map(|entry| entry.path().join("logs").join("latest.log")) + .filter(|path| path.is_file()), + ); + } + } + candidates.into_iter().max_by_key(|path| modified_at(path)) +} + +fn export_support_bundle_blocking( + app: &AppHandle, + game_dir: &Path, + version_id: Option<&str>, + health_report: Option, +) -> Result { + const LOG_TAIL: u64 = 2 * 1024 * 1024; + let mut files: Vec<(String, String)> = vec![ + ( + "launcher-log.txt".to_string(), + redact_secrets(&launcher_log_text()), + ), + ( + "system-info.json".to_string(), + serde_json::to_string_pretty(&system_info(app, game_dir)).unwrap_or_default(), + ), + ]; + let health = match health_report { + Some(report) => report, + None => serde_json::to_value(run_health_check_blocking( + app, game_dir, version_id, None, false, + )) + .unwrap_or(Value::Null), + }; + files.push(( + "health-check.json".to_string(), + redact_secrets(&serde_json::to_string_pretty(&health).unwrap_or_default()), + )); + if let Some(log) = latest_game_log(game_dir, version_id) { + if let Some(text) = read_text_tail(&log, LOG_TAIL) { + files.push(("game-logs/latest.log".to_string(), redact_secrets(&text))); + } + } + let mut seen_names = HashSet::new(); + let (mut reports, mut hs_errs) = (0, 0); + for path in recent_crash_files(game_dir, Duration::from_secs(14 * 86_400)) { + let name = path + .file_name() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_default(); + let (folder, budget) = if is_hs_err_name(&name) { + hs_errs += 1; + ("hs_err", hs_errs <= 3) + } else { + reports += 1; + ("crash-reports", reports <= 5) + }; + if !budget || !seen_names.insert(name.clone()) { + continue; + } + if let Some(text) = read_text_tail(&path, 1024 * 1024) { + files.push((format!("{folder}/{name}"), redact_secrets(&text))); + } + } + + let exports_dir = game_dir.join("exports"); + fs::create_dir_all(&exports_dir) + .map_err(|e| format!("Failed to create {}: {e}", exports_dir.display()))?; + let stamp = iso8601_utc(SystemTime::now()).replace([':', '-'], ""); + let path = exports_dir.join(format!("fpsmaster-support-{stamp}.zip")); + let file = + fs::File::create(&path).map_err(|e| format!("Failed to create {}: {e}", path.display()))?; + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (name, content) in &files { + zip.start_file(name.as_str(), options) + .and_then(|_| zip.write_all(content.as_bytes()).map_err(Into::into)) + .map_err(|e| format!("Failed to write {name} into the support bundle: {e}"))?; + } + zip.finish() + .map_err(|e| format!("Failed to finish the support bundle: {e}"))?; + Ok(SupportBundleResult { + size_bytes: fs::metadata(&path).map(|meta| meta.len()).unwrap_or(0), + path: path.to_string_lossy().into_owned(), + entries: files.into_iter().map(|(name, _)| name).collect(), + }) +} + +/// Writes a redacted zip (launcher log, latest game log, crash reports, +/// hs_err files, health check, system info) to `/exports/`. +#[tauri::command] +pub(crate) async fn export_support_bundle( + app: AppHandle, + game_dir: String, + version_id: Option, + health_report: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let game_dir = crate::resolve_game_dir_path(&game_dir)?; + export_support_bundle_blocking(&app, &game_dir, version_id.as_deref(), health_report) + }) + .await + .map_err(|e| format!("Failed to join support bundle task: {e}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_tokens_passwords_and_cookies() { + let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTYifQ.c2lnbmF0dXJlLXZhbHVl"; + let text = format!( + "jvm_args: -Dfpsmaster.auth.token={jwt} -Xmx2G\n\ + --accessToken abc.def --username Steve\n\ + Authorization: Bearer secretvalue\n\ + Cookie: SESSION=1; other=2\n\ + {{\"password\":\"hunter2\",\"refresh_token\":\"rt-1\"}}\n\ + url?token=q123&x=1\n\ + FPSMASTER_LAUNCH_TOKEN=plain\n\ + path C:\\Users\\Steve\\AppData" + ); + let redacted = redact_secrets(&text); + for secret in [ + jwt, + "abc.def", + "secretvalue", + "SESSION=1", + "hunter2", + "rt-1", + "q123", + "=plain", + ] { + assert!(!redacted.contains(secret), "{secret} leaked: {redacted}"); + } + assert!(redacted.contains("--username Steve")); + assert!(redacted.contains("C:\\Users\\Steve")); + assert!(redacted.contains("-Xmx2G")); + } + + #[test] + fn summarizes_crash_report_and_hs_err() { + let report = "---- Minecraft Crash Report ----\n// Oops\n\nTime: today\nDescription: Rendering screen\n\njava.lang.NullPointerException: boom\n\tat a.b.C.d(C.java:1)\n"; + let summary = summarize_crash_report(report).unwrap(); + assert!(summary.starts_with("Description: Rendering screen")); + assert!(summary.contains("NullPointerException")); + + let hs_err = "#\n# A fatal error has been detected by the Java Runtime Environment:\n#\n# EXCEPTION_ACCESS_VIOLATION (0xc0000005)\n# Problematic frame:\n# C [lwjgl.dll+0x1234]\n#\n\n--------------- S U M M A R Y ------------\n\nCommand Line: -Dfpsmaster.auth.token=secret\n"; + let summary = summarize_hs_err(hs_err).unwrap(); + assert!(summary.contains("EXCEPTION_ACCESS_VIOLATION")); + assert!(summary.contains("lwjgl.dll")); + assert!(!summary.contains("secret")); + } + + #[test] + fn finds_crash_files_written_after_launch() { + let temp = tempfile::tempdir().unwrap(); + let launched = SystemTime::now() - Duration::from_secs(1); + fs::create_dir_all(temp.path().join("crash-reports")).unwrap(); + fs::write( + temp.path() + .join("crash-reports/crash-2026-10-15_10.00.00-client.txt"), + "Description: Ticking entity\n\njava.lang.IllegalStateException\n", + ) + .unwrap(); + fs::write( + temp.path().join("hs_err_pid42.log"), + "# A fatal error has been detected\n# Problematic frame: x\n", + ) + .unwrap(); + let found = find_crash_artifacts(temp.path(), 42, launched); + assert!(found.crash_report_path.is_some()); + assert!(found.crash_summary.unwrap().contains("Ticking entity")); + assert!(found.hs_err_path.unwrap().ends_with("hs_err_pid42.log")); + + let later = + find_crash_artifacts(temp.path(), 7, SystemTime::now() + Duration::from_secs(60)); + assert!(later.crash_report_path.is_none() && later.hs_err_path.is_none()); + } + + #[test] + fn crash_upload_only_accepts_crash_files() { + let temp = tempfile::tempdir().unwrap(); + let other = temp.path().join("secrets.txt"); + fs::write(&other, "x").unwrap(); + assert!(validated_crash_file(Some(&other.to_string_lossy()), true).is_err()); + assert!(validated_crash_file(Some(&other.to_string_lossy()), false).is_err()); + fs::create_dir_all(temp.path().join("crash-reports")).unwrap(); + let report = temp.path().join("crash-reports/crash-1-client.txt"); + fs::write(&report, "x").unwrap(); + assert!(validated_crash_file(Some(&report.to_string_lossy()), true) + .unwrap() + .is_some()); + assert!(validated_crash_file(None, true).unwrap().is_none()); + } + + #[test] + fn iso8601_formats_epoch_dates() { + assert_eq!(iso8601_utc(UNIX_EPOCH), "1970-01-01T00:00:00Z"); + assert_eq!( + iso8601_utc(UNIX_EPOCH + Duration::from_secs(1_760_000_000)), + "2025-10-09T08:53:20Z" + ); + } + + #[test] + fn recommends_faster_reachable_source() { + let entry = |id: &str, ok: bool, speed: u64| SourceProbeEntry { + id: id.to_string(), + result: ProbeResult { + url: String::new(), + ok, + latency_ms: Some(10), + bytes_per_second: Some(speed), + bytes_read: 1, + error: None, + }, + }; + assert!(matches!( + recommend(&[entry("official", true, 900), entry("bmclapi", true, 100)]), + DownloadSource::OfficialFirst + )); + assert!(matches!( + recommend(&[entry("official", false, 0), entry("bmclapi", true, 100)]), + DownloadSource::MirrorFirst + )); + assert!(matches!( + recommend(&[entry("official", true, 10), entry("bmclapi", false, 0)]), + DownloadSource::OfficialFirst + )); + } +} diff --git a/tauri-app/src-tauri/src/downloader.rs b/tauri-app/src-tauri/src/downloader.rs index af62e73..aabc174 100644 --- a/tauri-app/src-tauri/src/downloader.rs +++ b/tauri-app/src-tauri/src/downloader.rs @@ -223,7 +223,10 @@ fn write_meta(target: &Path, meta: &PartMeta) -> Result<(), String> { }) } -fn header_string(response: &reqwest::blocking::Response, name: reqwest::header::HeaderName) -> Option { +fn header_string( + response: &reqwest::blocking::Response, + name: reqwest::header::HeaderName, +) -> Option { response .headers() .get(name) @@ -324,7 +327,9 @@ impl<'a> Request<'a> { } pub(crate) fn sha1(mut self, value: Option<&str>) -> Self { - self.sha1 = value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| !v.is_empty()); + self.sha1 = value + .map(|v| v.trim().to_ascii_lowercase()) + .filter(|v| !v.is_empty()); self } @@ -369,25 +374,33 @@ impl<'a> Request<'a> { if let Some(expected) = self.expected_size { let actual = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0); if actual != expected { - return Err(format!("size mismatch: expected {expected} bytes, got {actual}")); + return Err(format!( + "size mismatch: expected {expected} bytes, got {actual}" + )); } } if let Some(expected) = self.sha1.as_deref() { let actual = crate::compute_sha1_hex(path)?; if !actual.eq_ignore_ascii_case(expected) { - return Err(format!("SHA1 mismatch: expected={expected} actual={actual}")); + return Err(format!( + "SHA1 mismatch: expected={expected} actual={actual}" + )); } } if let Some(expected) = self.sha256.as_deref() { let actual = crate::compute_file_sha256_hex(path)?; if actual != expected { - return Err(format!("SHA256 mismatch: expected={expected} actual={actual}")); + return Err(format!( + "SHA256 mismatch: expected={expected} actual={actual}" + )); } } if let Some(expected) = self.sha512.as_deref() { let actual = crate::compute_file_sha512_hex(path)?; if actual != expected { - return Err(format!("SHA512 mismatch: expected={expected} actual={actual}")); + return Err(format!( + "SHA512 mismatch: expected={expected} actual={actual}" + )); } } Ok(()) @@ -446,7 +459,11 @@ pub(crate) fn download(request: &Request) -> Result<(), String> { request.cancel.check()?; if attempt > 1 { if let Some(callback) = request.on_retry { - callback(&format!("Retrying ({}/{}): {last_error}", attempt - 1, attempts - 1)); + callback(&format!( + "Retrying ({}/{}): {last_error}", + attempt - 1, + attempts - 1 + )); } request.cancel.sleep(backoff_delay(attempt - 1))?; } @@ -482,7 +499,11 @@ fn finalize(request: &Request) -> Result<(), String> { .map_err(|e| format!("Failed to replace existing file {}: {e}", target.display()))?; } fs::rename(&part, target) - .or_else(|_| fs::copy(&part, target).map(|_| ()).and_then(|_| fs::remove_file(&part))) + .or_else(|_| { + fs::copy(&part, target) + .map(|_| ()) + .and_then(|_| fs::remove_file(&part)) + }) .map_err(|e| { format!( "Failed to move downloaded file from {} to {}: {e}", @@ -606,8 +627,9 @@ fn attempt_stream( segments: None, }; write_meta(target, &meta).map_err(AttemptError::Fatal)?; - let file = fs::File::create(&part) - .map_err(|e| AttemptError::Fatal(format!("Failed to create {}: {e}", part.display())))?; + let file = fs::File::create(&part).map_err(|e| { + AttemptError::Fatal(format!("Failed to create {}: {e}", part.display())) + })?; (file, 0, response.content_length()) } else if status == reqwest::StatusCode::PARTIAL_CONTENT { // 206 that does not continue where we stopped: start over. @@ -764,7 +786,9 @@ fn try_segmented( .into_iter() .map(|handle| { handle.join().unwrap_or_else(|_| { - Err(AttemptError::Fatal("Download segment worker panicked".to_string())) + Err(AttemptError::Fatal( + "Download segment worker panicked".to_string(), + )) }) }) .collect() @@ -850,14 +874,20 @@ fn download_segment( .get(url) .header(reqwest::header::ACCEPT, "*/*") .header(reqwest::header::ACCEPT_ENCODING, "identity") - .header(reqwest::header::RANGE, format!("bytes={}-{end}", start + have)); + .header( + reqwest::header::RANGE, + format!("bytes={}-{end}", start + have), + ); if let Some(validator) = validator { builder = builder.header(reqwest::header::IF_RANGE, validator); } let mut response = match builder.send() { Ok(response) => response, Err(error) => { - last_error = format!("Segment request failed: {}", crate::describe_http_error(&error)); + last_error = format!( + "Segment request failed: {}", + crate::describe_http_error(&error) + ); continue; } }; @@ -927,7 +957,10 @@ pub(crate) fn probe_url(url: &str, max_bytes: u64, budget: Duration) -> ProbeRes let response = client .get(url) .header(reqwest::header::ACCEPT_ENCODING, "identity") - .header(reqwest::header::RANGE, format!("bytes=0-{}", max_bytes.saturating_sub(1))) + .header( + reqwest::header::RANGE, + format!("bytes=0-{}", max_bytes.saturating_sub(1)), + ) .timeout(budget) .send(); let mut response = match response { @@ -1063,7 +1096,11 @@ mod tests { fn range_start(head: &str) -> Option { head.lines() - .find_map(|line| line.to_ascii_lowercase().strip_prefix("range: bytes=").map(str::to_string)) + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("range: bytes=") + .map(str::to_string) + }) .and_then(|value| value.trim().trim_end_matches('-').parse().ok()) } diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index f3be554..0d109e8 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -1,5 +1,6 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod diagnostics; mod downloader; mod launcher_api; mod microsoft_auth; @@ -596,11 +597,66 @@ struct CoreLogEvent { message: String, } +/// Payload of the `game-exit` event. #[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] struct GameExitEvent { pid: i64, - #[serde(rename = "exitCode")] exit_code: i32, + version_id: String, + duration_ms: u64, + /// Non-zero exit or a crash report / hs_err written during this run, and + /// not stopped by the user through `terminate_game_process`. + crashed: bool, + terminated_by_user: bool, + #[serde(flatten)] + crash: diagnostics::CrashArtifacts, +} + +/// Emits `game-exit` (with crash detection) once a launched game has ended. +fn report_game_exit( + window: &tauri::Window, + pid: i64, + exit_code: i32, + version_id: &str, + work_dir: &Path, + launched_at: SystemTime, +) { + let terminated_by_user = diagnostics::take_user_terminated(pid); + let crash = if terminated_by_user { + diagnostics::CrashArtifacts::default() + } else { + diagnostics::find_crash_artifacts(work_dir, pid, launched_at) + }; + let crashed = !terminated_by_user + && (exit_code != 0 || crash.crash_report_path.is_some() || crash.hs_err_path.is_some()); + let duration_ms = SystemTime::now() + .duration_since(launched_at) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0); + push_ui_log( + "game", + "exit", + &format!( + "process exited pid={pid} code={exit_code} crashed={crashed} durationMs={duration_ms}" + ), + ); + if let Some(summary) = crash.crash_summary.as_deref().or(crash.hs_err_summary.as_deref()) { + push_ui_log("game", "stderr", &format!("[crash] {summary}")); + } + let _ = window.emit( + "game-exit", + GameExitEvent { + pid, + exit_code, + version_id: version_id.to_string(), + duration_ms, + crashed, + terminated_by_user, + crash, + }, + ); + clear_runtime_pid(pid); } #[derive(Debug, Clone, Serialize)] @@ -1224,6 +1280,8 @@ fn terminate_game_process_blocking(pid: i64, force: Option) -> Result PathBuf { game_dir.join("runtime").join(name) } +/// Inverse of `managed_jdk_runtime_root`: "jdk-17" -> (17, false), +/// "jdk-8-x64" -> (8, true). +fn parse_managed_runtime_name(name: &str) -> Option<(i32, bool)> { + let rest = name.strip_prefix("jdk-")?; + let (major, x64) = match rest.strip_suffix("-x64") { + Some(major) => (major, true), + None => (rest, false), + }; + major.parse::().ok().filter(|major| *major > 0).map(|major| (major, x64)) +} + +/// Health-check repair for a managed runtime: drops its completion marker so +/// `ensure_managed_jdk_runtime` re-verifies every file against the manifest +/// and downloads only what is missing or corrupt. +#[tauri::command] +async fn repair_java_runtime( + window: tauri::Window, + game_dir: String, + runtime_name: String, + ipc_session: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let (major, x64) = parse_managed_runtime_name(runtime_name.trim()) + .ok_or_else(|| format!("Not a managed Java runtime: {runtime_name}"))?; + let game_dir_path = resolve_game_dir_path(&game_dir)?; + let root = managed_jdk_runtime_root(&game_dir_path, major, x64); + let marker = root.join(RUNTIME_MARKER_FILE); + if marker.exists() { + fs::remove_file(&marker) + .map_err(|e| format!("Failed to reset runtime marker {}: {e}", marker.display()))?; + } + let cancel = downloader::CancelToken::new(ipc_session.as_deref()); + let result = ensure_managed_jdk_runtime( + Some(&window), + &cancel, + &root, + major, + Some(DEFAULT_DOWNLOAD_THREADS), + x64, + ); + clear_install_cancel(ipc_session.as_deref()); + result.map(|java_path| JdkEnsureResult { + major_version: major, + java_path: java_path.to_string_lossy().to_string(), + cached: false, + }) + }) + .await + .map_err(|e| format!("Failed to join runtime repair task: {e}")) + .and_then(std::convert::identity) + .inspect_err(|e| log_command_error("repair_java_runtime", e)) +} + // Whether the emulated-x64 runtime for this Java major should come from Azul Zulu // instead of Mojang. Only the macOS Java 8 case (jre-legacy = 8u74) is known-broken: // LWJGL 2 versions (MC <= 1.12) go through AWT, whose ancient build crashes on @@ -11386,7 +11493,12 @@ fn main() { secure_storage_set, secure_storage_delete, secure_storage_status, - secure_storage_set_file_fallback + secure_storage_set_file_fallback, + repair_java_runtime, + diagnostics::run_health_check, + diagnostics::export_support_bundle, + diagnostics::upload_game_crash_report, + diagnostics::probe_download_sources ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/tauri-app/src-tauri/src/minecraft_core.rs b/tauri-app/src-tauri/src/minecraft_core.rs index dbc9d09..cfadb1c 100644 --- a/tauri-app/src-tauri/src/minecraft_core.rs +++ b/tauri-app/src-tauri/src/minecraft_core.rs @@ -49,6 +49,8 @@ impl DownloadSource { "mirror-only" => Ok(Self::MirrorOnly), "bmclapi" | "mirror" | "mirror-first" => Ok(Self::MirrorFirst), "official-first" => Ok(Self::OfficialFirst), + // Fastest of official/BMCLAPI by measurement (cached 30 min). + "auto" => Ok(crate::diagnostics::auto_download_source()), _ => Err(format!("Unsupported download source: {raw}")), } } @@ -634,6 +636,128 @@ pub(crate) fn install_vanilla( /// Metadata is resolved fully offline from the local `.json` (and its /// `inheritsFrom` chain), so an intact installation verifies without any network call /// and never re-runs loader processors — network is only touched to repair a bad file. +/// Read-only check of what an instance needs to launch, without any network +/// access. Used by the health check and by offline/quick launch. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InstanceFileInspection { + pub(crate) checked: usize, + /// Paths relative to the game directory (capped). + pub(crate) missing: Vec, + pub(crate) corrupted: Vec, + pub(crate) java_major: i32, +} + +const INSPECTION_LIST_CAP: usize = 50; + +pub(crate) fn inspect_instance_files( + game_dir: &Path, + version_id: &str, + deep: bool, +) -> Result { + let descriptor = resolve_version_descriptor(game_dir, version_id, 0)?; + let version_json = &descriptor.merged; + let (java_major, _) = extract_java_version(version_json); + let mut report = InstanceFileInspection { + checked: 0, + missing: Vec::new(), + corrupted: Vec::new(), + java_major, + }; + let relative = |path: &Path| { + path.strip_prefix(game_dir) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") + }; + let mut check = |path: &Path, sha1: Option<&str>, size: Option| { + report.checked += 1; + let Ok(meta) = fs::metadata(path) else { + if report.missing.len() < INSPECTION_LIST_CAP { + report.missing.push(relative(path)); + } + return; + }; + let bad_size = meta.len() == 0 || size.is_some_and(|size| size != meta.len()); + let bad_hash = deep + && sha1.is_some_and(|expected| { + compute_sha1_hex(path) + .map(|actual| !actual.eq_ignore_ascii_case(expected)) + .unwrap_or(true) + }); + if (bad_size || bad_hash) && report.corrupted.len() < INSPECTION_LIST_CAP { + report.corrupted.push(relative(path)); + } + }; + + let libraries = resolve_libraries( + version_json, + game_dir, + &build_rule_features(), + DownloadSource::OfficialOnly, + )?; + for library in &libraries { + check(&library.path, library.sha1.as_deref(), library.size); + } + let jar_version_id = &descriptor.jar_version_id; + let client_download = version_json + .get("downloads") + .and_then(|value| value.get("client")); + check( + &game_dir + .join("versions") + .join(jar_version_id) + .join(format!("{jar_version_id}.jar")), + client_download + .and_then(|value| value.get("sha1")) + .and_then(Value::as_str), + client_download + .and_then(|value| value.get("size")) + .and_then(Value::as_u64), + ); + if let Some(index_id) = version_json + .get("assetIndex") + .and_then(|value| value.get("id")) + .and_then(Value::as_str) + { + let index_path = game_dir + .join("assets") + .join("indexes") + .join(format!("{index_id}.json")); + check(&index_path, None, None); + if let Ok(text) = crate::zip_budget::read_text_file_bounded( + &index_path, + crate::zip_budget::MAX_TEXT_ENTRY_BYTES, + "asset index", + ) { + let objects = serde_json::from_str::(&text) + .ok() + .and_then(|json| json.get("objects").and_then(Value::as_object).cloned()) + .unwrap_or_default(); + let mut seen = HashSet::new(); + for object in objects.values() { + let Some(hash) = object.get("hash").and_then(Value::as_str) else { + continue; + }; + if hash.len() < 2 || !seen.insert(hash.to_string()) { + continue; + } + let path = game_dir + .join("assets") + .join("objects") + .join(&hash[0..2]) + .join(hash); + check( + &path, + deep.then_some(hash), + object.get("size").and_then(Value::as_u64), + ); + } + } + } + Ok(report) +} + pub(crate) fn verify_installed_files( window: Option<&tauri::Window>, game_dir: &Path, From 6e8c40c54c502de26636e94971d367d383764c90 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:31:03 -0700 Subject: [PATCH 11/24] fix(launcher): strip credentials from the in-memory launcher log push_ui_log runs lines that look like they carry a token, password, cookie or Authorization header through the shared redaction rules (CONTRACTS.md 6). --- tauri-app/src-tauri/src/main.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index 0d109e8..f6b75cc 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -833,12 +833,23 @@ fn sample_game_runtime_cache() { } pub(crate) fn push_ui_log(source: &str, level: &str, message: &str) { + // Game output and error texts can echo credentials (a JVM printing its + // arguments, an HTTP error with headers). The cheap substring prefilter + // keeps the regex pass off the per-line hot path for normal output. + let message = if ["eyJ", "oken", "OKEN", "assw", "ASSW", "ookie", "uthorization", "ecret"] + .iter() + .any(|needle| message.contains(needle)) + { + diagnostics::redact_secrets(message) + } else { + message.to_string() + }; if let Ok(mut store) = ui_log_store().lock() { let entry = UiLogEntry { seq: store.next_seq, source: source.to_string(), level: level.to_string(), - message: message.to_string(), + message, }; store.next_seq = store.next_seq.saturating_add(1); store.entries.push_back(entry); From e7b4e04ed9d1919d8e7f39edf462d453835d7a08 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 20:32:52 -0700 Subject: [PATCH 12/24] feat(launcher): offline launch readiness and cached loader catalogs - get_offline_launch_readiness checks an installed instance purely locally (profile, loader/OptiFine, libraries, client jar, assets, managed Java) so the UI can skip catalog lookups and update checks and launch directly. - list_forge_versions / list_optifine_versions remember their last good result on disk and fall back to it, with a warning, when the network lookup fails (launcher-ui-3, backend side). --- tauri-app/src-tauri/src/main.rs | 209 ++++++++++++++++++++-- tauri-app/src-tauri/src/minecraft_core.rs | 56 ++++++ 2 files changed, 253 insertions(+), 12 deletions(-) diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index f6b75cc..8df7925 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -8569,11 +8569,14 @@ async fn list_forge_versions( download_source: Option, ) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { - list_forge_versions_blocking_core( - Some(&window), - game_version.trim(), - download_source.as_deref(), - ) + let cache_key = format!("forge-{}", game_version.trim()); + with_metadata_cache(Some(&window), &cache_key, || { + list_forge_versions_blocking_core( + Some(&window), + game_version.trim(), + download_source.as_deref(), + ) + }) }) .await .map_err(|e| format!("Failed to join forge version listing task: {e}")) @@ -8593,6 +8596,159 @@ fn list_forge_versions_blocking_core( minecraft_core::list_forge_versions(window, normalized_game_version, download_source) } +/// Last successful result of a catalog lookup, kept on disk so an installed +/// instance still launches when Forge/OptiFine metadata hosts are unreachable +/// (launcher-ui-3). Errors are only replaced by the cached value, never hidden: +/// the fallback is logged. +fn with_metadata_cache(window: Option<&tauri::Window>, key: &str, fetch: F) -> Result +where + T: Serialize + serde::de::DeserializeOwned, + F: FnOnce() -> Result, +{ + let cache_path = window + .and_then(|window| window.app_handle().path().app_cache_dir().ok()) + .map(|dir| dir.join("metadata").join(format!("{}.json", sanitize_file_name(key)))); + cached_metadata_lookup(cache_path.as_deref(), window, key, fetch) +} + +fn cached_metadata_lookup( + cache_path: Option<&Path>, + window: Option<&tauri::Window>, + key: &str, + fetch: F, +) -> Result +where + T: Serialize + serde::de::DeserializeOwned, + F: FnOnce() -> Result, +{ + match fetch() { + Ok(value) => { + if let Some(path) = cache_path { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(bytes) = serde_json::to_vec(&value) { + let _ = fs::write(path, bytes); + } + } + Ok(value) + } + Err(error) => { + let cached = cache_path + .and_then(|path| fs::read(path).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + match cached { + Some(value) => { + emit_log( + window, + "warn", + &format!("{key}: network lookup failed ({error}); using the cached list"), + ); + Ok(value) + } + None => Err(error), + } + } + } +} + +/// What an installed instance needs for a launch without any network access. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct OfflineLaunchReadiness { + version_id: String, + /// Everything needed to start is on disk: the UI may skip Forge/OptiFine + /// catalog lookups and content update checks and launch directly. + ready: bool, + base_version: Option, + loader: Option, + loader_version: Option, + optifine_version: Option, + java_major: Option, + java_path: Option, + missing: Vec, + corrupted: Vec, + error: Option, +} + +fn get_offline_launch_readiness_blocking( + game_dir: String, + version_id: String, +) -> Result { + let version_id = version_id.trim().to_string(); + ensure_safe_child_name(&version_id, "Version id")?; + let game_dir_path = resolve_game_dir_path(&game_dir)?; + let mut readiness = OfflineLaunchReadiness { + version_id: version_id.clone(), + ready: false, + base_version: None, + loader: None, + loader_version: None, + optifine_version: None, + java_major: None, + java_path: None, + missing: Vec::new(), + corrupted: Vec::new(), + error: None, + }; + if is_native_app_version_id(&version_id) { + let install_dir = native_app_install_dir(&game_dir_path, &version_id); + let binary = install_dir.join(native_app_binary_name()); + readiness.ready = binary.is_file() && native_app_marker_path(&install_dir).is_file(); + if !binary.is_file() { + readiness.missing.push(native_app_binary_name().to_string()); + } + return Ok(readiness); + } + let version_dir = game_dir_path.join("versions").join(&version_id); + if let Ok(metadata) = + parse_instance_profile_metadata(&version_dir.join(format!("{version_id}.json"))) + { + readiness.base_version = Some(metadata.base_version); + readiness.loader = Some(metadata.loader); + readiness.loader_version = metadata.loader_version; + } + readiness.optifine_version = minecraft_core::installed_optifine_version(&version_dir); + let inspection = match minecraft_core::inspect_instance_files(&game_dir_path, &version_id, false) + { + Ok(inspection) => inspection, + Err(error) => { + readiness.error = Some(error); + return Ok(readiness); + } + }; + let major = inspection.java_major.max(8); + readiness.java_major = Some(major); + let runtime_root = managed_jdk_runtime_root( + &game_dir_path, + major, + minecraft_core::macos_requires_x64_runtime(&game_dir_path, &version_id), + ); + match verify_runtime_install(&runtime_root, false) { + Ok(java) => readiness.java_path = Some(java.to_string_lossy().to_string()), + Err(error) => readiness.missing.push(format!("java {major}: {error}")), + } + readiness.missing.extend(inspection.missing); + readiness.corrupted = inspection.corrupted; + readiness.ready = readiness.missing.is_empty() && readiness.corrupted.is_empty(); + Ok(readiness) +} + +/// Offline/quick launch support: a purely local check of an installed instance. +#[tauri::command] +async fn get_offline_launch_readiness( + game_dir: String, + version_id: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + get_offline_launch_readiness_blocking(game_dir, version_id) + }) + .await + .map_err(|e| format!("Failed to join offline readiness task: {e}")) + .and_then(std::convert::identity) + .inspect_err(|e| log_command_error("get_offline_launch_readiness", e)) +} + #[tauri::command] async fn list_optifine_versions( window: tauri::Window, @@ -8607,13 +8763,19 @@ async fn list_optifine_versions( return Err("Game version cannot be empty".to_string()); } let normalized_loader = normalize_loader_kind(&loader)?; - minecraft_core::list_optifine_versions( - Some(&window), - &normalized_game_version, - &normalized_loader, - loader_version.as_deref(), - download_source.as_deref(), - ) + let cache_key = format!( + "optifine-{normalized_game_version}-{normalized_loader}-{}", + loader_version.as_deref().unwrap_or("") + ); + with_metadata_cache(Some(&window), &cache_key, || { + minecraft_core::list_optifine_versions( + Some(&window), + &normalized_game_version, + &normalized_loader, + loader_version.as_deref(), + download_source.as_deref(), + ) + }) }) .await .map_err(|e| format!("Failed to join OptiFine version listing task: {e}")) @@ -10291,6 +10453,28 @@ mod tests { use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn metadata_lookup_falls_back_to_last_good_result() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("metadata").join("forge-1.8.9.json"); + let fresh: Result, String> = + super::cached_metadata_lookup(Some(&path), None, "forge", || Ok(vec!["11.15".into()])); + assert_eq!(fresh.unwrap(), vec!["11.15".to_string()]); + let offline: Result, String> = + super::cached_metadata_lookup(Some(&path), None, "forge", || Err("offline".into())); + assert_eq!(offline.unwrap(), vec!["11.15".to_string()]); + let no_cache: Result, String> = super::cached_metadata_lookup( + Some(&temp.path().join("none.json")), + None, + "forge", + || Err("offline".into()), + ); + assert_eq!(no_cache.unwrap_err(), "offline"); + assert_eq!(super::parse_managed_runtime_name("jdk-17"), Some((17, false))); + assert_eq!(super::parse_managed_runtime_name("jdk-8-x64"), Some((8, true))); + assert_eq!(super::parse_managed_runtime_name("jdk-x"), None); + } + #[test] fn launch_command_result_hides_credentials() { let redacted = super::redact_launch_secrets(vec![ @@ -11506,6 +11690,7 @@ fn main() { secure_storage_status, secure_storage_set_file_fallback, repair_java_runtime, + get_offline_launch_readiness, diagnostics::run_health_check, diagnostics::export_support_bundle, diagnostics::upload_game_crash_report, diff --git a/tauri-app/src-tauri/src/minecraft_core.rs b/tauri-app/src-tauri/src/minecraft_core.rs index cfadb1c..a5f6d03 100644 --- a/tauri-app/src-tauri/src/minecraft_core.rs +++ b/tauri-app/src-tauri/src/minecraft_core.rs @@ -1956,6 +1956,15 @@ fn read_standalone_optifine_overlay( Ok(Some(marker)) } +/// Version of the standalone OptiFine overlay installed for `version_dir`, if +/// its files are complete. Read-only; used for offline launch readiness. +pub(crate) fn installed_optifine_version(version_dir: &Path) -> Option { + let overlay = read_standalone_optifine_overlay(version_dir).ok()??; + let dir = version_dir.join("optifine"); + (dir.join(&overlay.optifine_jar).is_file() && dir.join(&overlay.launchwrapper_jar).is_file()) + .then_some(overlay.optifine_version) +} + fn optifine_installer_has_patcher(installer_path: &Path) -> Result { let file = fs::File::open(installer_path).map_err(|error| { format!( @@ -5480,6 +5489,53 @@ mod tests { VanillaLaunchRequest, }; + #[test] + fn instance_inspection_reports_missing_files_offline() { + let temp = tempfile::tempdir().unwrap(); + let game_dir = temp.path(); + let version_dir = game_dir.join("versions").join("1.8.9"); + fs::create_dir_all(&version_dir).unwrap(); + fs::write(version_dir.join("1.8.9.jar"), "client").unwrap(); + fs::write( + version_dir.join("1.8.9.json"), + serde_json::json!({ + "id": "1.8.9", + "mainClass": "net.minecraft.client.main.Main", + "javaVersion": { "majorVersion": 8, "component": "jre-legacy" }, + "assetIndex": { "id": "1.8" }, + "libraries": [{ + "name": "com.example:lib:1.0", + "downloads": { "artifact": { + "path": "com/example/lib/1.0/lib-1.0.jar", + "url": "https://example.invalid/lib-1.0.jar", + "size": 3 + }} + }] + }) + .to_string(), + ) + .unwrap(); + fs::create_dir_all(game_dir.join("assets/indexes")).unwrap(); + fs::write( + game_dir.join("assets/indexes/1.8.json"), + r#"{"objects":{"a":{"hash":"abcdef","size":1}}}"#, + ) + .unwrap(); + + let report = super::inspect_instance_files(game_dir, "1.8.9", false).unwrap(); + assert_eq!(report.java_major, 8); + assert!(report.missing.iter().any(|path| path.ends_with("lib-1.0.jar")), "{report:?}"); + assert!(report.missing.iter().any(|path| path.ends_with("ab/abcdef")), "{report:?}"); + + let library = game_dir.join("libraries/com/example/lib/1.0/lib-1.0.jar"); + fs::create_dir_all(library.parent().unwrap()).unwrap(); + fs::write(&library, "abc").unwrap(); + fs::create_dir_all(game_dir.join("assets/objects/ab")).unwrap(); + fs::write(game_dir.join("assets/objects/ab/abcdef"), "x").unwrap(); + let report = super::inspect_instance_files(game_dir, "1.8.9", false).unwrap(); + assert!(report.missing.is_empty() && report.corrupted.is_empty(), "{report:?}"); + } + #[test] fn platform_token_goes_to_env_for_presets_only() { let request = |version: &str| super::VanillaLaunchRequest { From a69e27d698eac050a67385d35453a7a630f3653a Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 21:47:18 -0700 Subject: [PATCH 13/24] fix(launcher): resolving the auto download source never blocks a launch from_id("auto") ran the source probes synchronously whenever the cached measurement was missing or older than 30 minutes, so an offline or already-installed launch waited on network probes. The probe now refreshes in the background and the last known answer (or mirror-first) is used. --- tauri-app/src-tauri/src/diagnostics.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tauri-app/src-tauri/src/diagnostics.rs b/tauri-app/src-tauri/src/diagnostics.rs index 52f3540..f6b450c 100644 --- a/tauri-app/src-tauri/src/diagnostics.rs +++ b/tauri-app/src-tauri/src/diagnostics.rs @@ -13,6 +13,7 @@ use std::collections::HashSet; use std::fs; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -533,15 +534,27 @@ fn remember_recommendation(source: DownloadSource) { /// Resolution of the "auto" download source id: the fastest of official and /// BMCLAPI, measured at most every 30 minutes. +/// +/// Never blocks on the network: `from_id("auto")` runs at the start of every +/// launch, and an offline or already-installed launch must not wait for the +/// probes. A missing or stale measurement is refreshed in the background and +/// the last known answer (or mirror-first, which still falls back to the +/// official source) is used meanwhile. pub(crate) fn auto_download_source() -> DownloadSource { - if let Some((at, source)) = *lock_recover(auto_source_cache()) { + static REFRESHING: AtomicBool = AtomicBool::new(false); + let cached = *lock_recover(auto_source_cache()); + if let Some((at, source)) = cached { if at.elapsed() < AUTO_SOURCE_TTL { return source; } } - let source = recommend(&probe_all(probe_targets(None))); - remember_recommendation(source); - source + if !REFRESHING.swap(true, Ordering::SeqCst) { + thread::spawn(|| { + remember_recommendation(recommend(&probe_all(probe_targets(None)))); + REFRESHING.store(false, Ordering::SeqCst); + }); + } + cached.map_or(DownloadSource::MirrorFirst, |(_, source)| source) } /// Measures latency/throughput of the Minecraft download sources (and the From 36467fb9ea9f5442e775956d8317d4f8f643b7f1 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:19:16 -0700 Subject: [PATCH 14/24] fix(launcher): keep pre-marker Java runtimes usable and repair them without going offline-broken Runtimes installed before completion markers existed are adopted when the tree looks like a finished JRE (java, class library, JVM library, no download temp files, no staging sibling); a marker is generated from the current files. Interrupted installs still go through repair. The installed runtime is now moved aside only after an installer has fetched its remote metadata, so an offline launch or health-check repair leaves a working runtime in place. Repair no longer deletes the marker up front, and it refuses to swap a Zulu runtime for jre-legacy or an emulated build when Azul is unreachable. A Mojang install over a tree left by a Zulu archive prunes files the manifest does not list. --- tauri-app/src-tauri/src/main.rs | 428 ++++++++++++++++++++++++++------ 1 file changed, 356 insertions(+), 72 deletions(-) diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index 8df7925..cd52e22 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -2115,6 +2115,7 @@ fn ensure_jdk_blocking( major, download_threads, needs_x64, + false, )?; emit_log( @@ -8027,9 +8028,10 @@ fn parse_managed_runtime_name(name: &str) -> Option<(i32, bool)> { major.parse::().ok().filter(|major| *major > 0).map(|major| (major, x64)) } -/// Health-check repair for a managed runtime: drops its completion marker so -/// `ensure_managed_jdk_runtime` re-verifies every file against the manifest -/// and downloads only what is missing or corrupt. +/// Health-check repair for a managed runtime: `ensure_managed_jdk_runtime` +/// re-verifies every file against the remote source and downloads only what +/// is missing or corrupt. The installed runtime is not touched until the +/// remote metadata was fetched, so repairing while offline changes nothing. #[tauri::command] async fn repair_java_runtime( window: tauri::Window, @@ -8042,11 +8044,6 @@ async fn repair_java_runtime( .ok_or_else(|| format!("Not a managed Java runtime: {runtime_name}"))?; let game_dir_path = resolve_game_dir_path(&game_dir)?; let root = managed_jdk_runtime_root(&game_dir_path, major, x64); - let marker = root.join(RUNTIME_MARKER_FILE); - if marker.exists() { - fs::remove_file(&marker) - .map_err(|e| format!("Failed to reset runtime marker {}: {e}", marker.display()))?; - } let cancel = downloader::CancelToken::new(ipc_session.as_deref()); let result = ensure_managed_jdk_runtime( Some(&window), @@ -8055,6 +8052,7 @@ async fn repair_java_runtime( major, Some(DEFAULT_DOWNLOAD_THREADS), x64, + true, ); clear_install_cancel(ipc_session.as_deref()); result.map(|java_path| JdkEnsureResult { @@ -8156,8 +8154,10 @@ fn runtime_relative_path(root: &Path, relative: &str) -> PathBuf { /// Checks a managed runtime against its completion marker. `deep` also /// re-hashes files whose SHA1 is known (used by the health check). fn verify_runtime_install(runtime_root: &Path, deep: bool) -> Result { - let marker = read_runtime_marker(runtime_root) - .ok_or_else(|| "installation was not completed (no completion marker)".to_string())?; + let marker = match read_runtime_marker(runtime_root) { + Some(marker) => marker, + None => adopt_legacy_runtime(runtime_root)?, + }; for file in &marker.files { let path = runtime_relative_path(runtime_root, &file.path); let size = fs::metadata(&path) @@ -8181,6 +8181,84 @@ fn verify_runtime_install(runtime_root: &Path, deep: bool) -> Result Result { + if runtime_root.join(RUNTIME_MARKER_FILE).exists() { + return Err("completion marker is unreadable".to_string()); + } + if !runtime_root.is_dir() || runtime_staging_dir(runtime_root).exists() { + return Err(RUNTIME_NOT_COMPLETED.to_string()); + } + let java = locate_java_binary(runtime_root).ok_or_else(|| RUNTIME_NOT_COMPLETED.to_string())?; + let home = java + .parent() + .and_then(Path::parent) + .ok_or_else(|| RUNTIME_NOT_COMPLETED.to_string())?; + let has_class_library = ["lib/modules", "lib/rt.jar", "jre/lib/rt.jar"] + .iter() + .any(|relative| runtime_relative_path(home, relative).is_file()); + let files = collect_runtime_file_records(runtime_root)?; + let file_name = |record: &RuntimeFileRecord| { + record.path.rsplit('/').next().unwrap_or_default().to_ascii_lowercase() + }; + let has_jvm_library = files.iter().any(|record| { + matches!( + file_name(record).as_str(), + "libjvm.so" | "libjvm.dylib" | "jvm.dll" + ) + }); + // Temp names used by the current and the pre-marker downloaders. + let has_download_leftovers = files.iter().any(|record| { + let name = file_name(record); + [".download", ".part", ".tmp", ".unpacking", ".lzma"] + .iter() + .any(|suffix| name.ends_with(suffix)) + }); + if !has_class_library || !has_jvm_library || has_download_leftovers { + return Err(RUNTIME_NOT_COMPLETED.to_string()); + } + let major = runtime_root + .file_name() + .and_then(|name| parse_managed_runtime_name(&name.to_string_lossy())) + .map(|(major, _)| major) + .unwrap_or(0); + let marker = RuntimeInstallMarker { + source: "adopted".to_string(), + version: "unknown".to_string(), + platform: format!("{}-{}", env::consts::OS, env::consts::ARCH), + major, + installed_at: now_epoch_seconds(), + files, + }; + let encoded = serde_json::to_vec_pretty(&marker) + .map_err(|e| format!("Failed to encode runtime marker: {e}"))?; + match fs::write(runtime_root.join(RUNTIME_MARKER_FILE), encoded) { + Ok(()) => emit_log( + None, + "info", + &format!("Adopted existing runtime {}", runtime_root.display()), + ), + // The runtime is still usable; the next check simply adopts it again. + Err(e) => emit_log( + None, + "warn", + &format!( + "Failed to write adopted runtime marker {}: {e}", + runtime_root.display() + ), + ), + } + Ok(marker) +} + /// Records every regular file below `root` (used for archive-based installs /// that have no manifest of their own). fn collect_runtime_file_records(root: &Path) -> Result, String> { @@ -8219,6 +8297,71 @@ fn collect_runtime_file_records(root: &Path) -> Result, S Ok(records) } +/// Install target for a managed runtime. The installed runtime is moved aside +/// only when an installer calls `prepare`, i.e. after its remote metadata was +/// fetched: an offline launch or repair then fails without touching a runtime +/// that still works. +struct RuntimeStaging<'a> { + runtime_root: &'a Path, + path: PathBuf, + discard_existing: bool, + prepared: std::cell::Cell, +} + +impl<'a> RuntimeStaging<'a> { + fn new(runtime_root: &'a Path, discard_existing: bool) -> Self { + Self { + runtime_root, + path: runtime_staging_dir(runtime_root), + discard_existing, + prepared: std::cell::Cell::new(false), + } + } + + /// Where download temp files that must survive a reset (archives) go. + fn download_parent(&self) -> &Path { + self.path.parent().unwrap_or(&self.path) + } + + fn is_prepared(&self) -> bool { + self.prepared.get() + } + + /// An unfinished runtime directory is moved into staging rather than + /// deleted: the per-file SHA1 skip in the Mojang path then only + /// re-downloads what is actually missing or corrupt. + fn prepare(&self) -> Result<&Path, String> { + if self.prepared.get() { + return Ok(&self.path); + } + let runtime_root = self.runtime_root; + if runtime_root.exists() { + if self.discard_existing || self.path.exists() { + fs::remove_dir_all(runtime_root).map_err(|e| { + format!( + "Failed removing outdated runtime {}: {e}", + runtime_root.display() + ) + })?; + } else { + fs::rename(runtime_root, &self.path).map_err(|e| { + format!( + "Failed to move runtime {} aside: {e}", + runtime_root.display() + ) + })?; + } + } + let _ = fs::remove_file(self.path.join(RUNTIME_MARKER_FILE)); + fs::create_dir_all(&self.path) + .map_err(|e| format!("Failed creating runtime dir {}: {e}", self.path.display()))?; + self.prepared.set(true); + Ok(&self.path) + } +} + +/// `reinstall` (health-check repair) skips the "already installed" shortcut +/// and re-verifies the runtime against the remote source. fn ensure_managed_jdk_runtime( window: Option<&tauri::Window>, cancel: &downloader::CancelToken, @@ -8226,70 +8369,59 @@ fn ensure_managed_jdk_runtime( major: i32, download_threads: Option, force_x64: bool, + reinstall: bool, ) -> Result { if force_x64 { ensure_x64_emulation_available()?; } let mut discard_existing = false; - match verify_runtime_install(runtime_root, false) { - Ok(java_path) - if prefers_zulu_over_mojang_x64(major, force_x64) - && is_mojang_bundle_layout(&java_path) => - { - // A Mojang jre-legacy install from before the Zulu switch — it crashes on - // modern macOS (see below), so wipe it and reinstall instead of reusing it. - emit_log( + let mut existing_legacy_java = None; + if reinstall { + emit_log( + window, + "info", + &format!("Repairing managed JDK major={major}"), + ); + } else { + match verify_runtime_install(runtime_root, false) { + Ok(java_path) + if prefers_zulu_over_mojang_x64(major, force_x64) + && is_mojang_bundle_layout(&java_path) => + { + // A Mojang jre-legacy install from before the Zulu switch — it crashes on + // modern macOS (see below), so replace it instead of reusing it. + emit_log( + window, + "warn", + "Replacing Mojang jre-legacy x64 runtime: its AWT crashes on macOS 14.4+", + ); + discard_existing = true; + existing_legacy_java = Some(java_path); + } + Ok(java_path) => { + emit_log( + window, + "info", + &format!( + "Managed JDK already exists major={major} path={}", + java_path.to_string_lossy() + ), + ); + return Ok(java_path); + } + Err(reason) if runtime_root.exists() => emit_log( window, "warn", - "Replacing Mojang jre-legacy x64 runtime: its AWT crashes on macOS 14.4+", - ); - discard_existing = true; - } - Ok(java_path) => { - emit_log( - window, - "info", - &format!( - "Managed JDK already exists major={major} path={}", - java_path.to_string_lossy() - ), - ); - return Ok(java_path); + &format!("Managed JDK major={major} is incomplete ({reason}); repairing it"), + ), + Err(_) => {} } - Err(reason) if runtime_root.exists() => emit_log( - window, - "warn", - &format!("Managed JDK major={major} is incomplete ({reason}); repairing it"), - ), - Err(_) => {} } // Install into a sibling staging directory and only promote it once the - // completion marker is written. An unfinished runtime directory is moved - // into staging rather than deleted: the per-file SHA1 skip in the Mojang - // path then only re-downloads what is actually missing or corrupt. - let staging = runtime_staging_dir(runtime_root); - if runtime_root.exists() { - if discard_existing || staging.exists() { - fs::remove_dir_all(runtime_root).map_err(|e| { - format!( - "Failed removing outdated runtime {}: {e}", - runtime_root.display() - ) - })?; - } else { - fs::rename(runtime_root, &staging).map_err(|e| { - format!( - "Failed to move incomplete runtime {} aside: {e}", - runtime_root.display() - ) - })?; - } - } - let _ = fs::remove_file(staging.join(RUNTIME_MARKER_FILE)); - fs::create_dir_all(&staging) - .map_err(|e| format!("Failed creating runtime dir {}: {e}", staging.display()))?; + // completion marker is written. + let staging = RuntimeStaging::new(runtime_root, discard_existing); let normalized_download_threads = normalize_download_threads(download_threads); let component = mojang_java_component_for_major(major); @@ -8329,6 +8461,26 @@ fn ensure_managed_jdk_runtime( Ok(marker) => installed = Some(marker), Err(zulu_err) => { cancel.check()?; + if !staging.is_prepared() { + // Falling back would only swap the runtime on disk for + // jre-legacy, which is either what is already there or + // the build this path exists to avoid. + if let Some(java) = existing_legacy_java { + emit_log( + window, + "warn", + &format!( + "Zulu x64 JRE unavailable ({zulu_err}); keeping the existing Mojang runtime" + ), + ); + return Ok(java); + } + if reinstall { + return Err(format!( + "Zulu x64 JRE unavailable ({zulu_err}); the installed runtime was left unchanged" + )); + } + } emit_log( window, "warn", @@ -8384,6 +8536,13 @@ fn ensure_managed_jdk_runtime( Ok(marker) => marker, Err(native_err) => { cancel.check()?; + if reinstall && !staging.is_prepared() { + // A repair must not trade the installed native JRE + // for an emulated one just because Azul is down. + return Err(format!( + "Native JRE unavailable ({native_err}); the installed runtime was left unchanged" + )); + } // 3) Last resort: Mojang's x64 build via emulation. emit_log( window, @@ -8408,12 +8567,13 @@ fn ensure_managed_jdk_runtime( } }; - if locate_java_binary(&staging).is_none() { + let staging_dir = staging.prepare()?; + if locate_java_binary(staging_dir).is_none() { return Err("JDK extracted but java executable not found".to_string()); } let marker_bytes = serde_json::to_vec_pretty(&marker) .map_err(|e| format!("Failed to encode runtime marker: {e}"))?; - fs::write(staging.join(RUNTIME_MARKER_FILE), marker_bytes) + fs::write(staging_dir.join(RUNTIME_MARKER_FILE), marker_bytes) .map_err(|e| format!("Failed to write runtime marker: {e}"))?; if runtime_root.exists() { fs::remove_dir_all(runtime_root).map_err(|e| { @@ -8423,7 +8583,7 @@ fn ensure_managed_jdk_runtime( ) })?; } - fs::rename(&staging, runtime_root).map_err(|e| { + fs::rename(staging_dir, runtime_root).map_err(|e| { format!( "Failed to activate runtime {}: {e}", runtime_root.display() @@ -8816,6 +8976,7 @@ async fn install_optifine( java_major, Some(DEFAULT_DOWNLOAD_THREADS), false, + false, )?) } else { None @@ -8880,6 +9041,7 @@ async fn install_forge( java_major, Some(normalized_download_threads as i32), false, + false, )? .to_string_lossy() .to_string() @@ -9127,7 +9289,7 @@ fn mojang_java_platform_keys() -> Vec<&'static str> { fn install_mojang_java_runtime( window: Option<&tauri::Window>, cancel: &downloader::CancelToken, - runtime_root: &Path, + staging: &RuntimeStaging, component: &str, major: i32, download_threads: usize, @@ -9168,7 +9330,9 @@ fn install_mojang_java_runtime( let manifest: MojangJavaManifest = fetch_json(window, "Mojang Java manifest", &candidate.manifest.url)?; + let runtime_root = staging.prepare()?; download_mojang_runtime_files(window, cancel, runtime_root, &manifest, download_threads)?; + prune_runtime_files_outside_manifest(runtime_root, &manifest)?; let mut files = manifest .files .iter() @@ -9228,12 +9392,12 @@ fn azul_arch() -> Option<&'static str> { fn install_native_jre_archive( window: Option<&tauri::Window>, cancel: &downloader::CancelToken, - runtime_root: &Path, + staging: &RuntimeStaging, major: i32, download_threads: usize, ) -> Result { let arch = azul_arch().ok_or_else(|| "Unsupported CPU arch for native JRE".to_string())?; - install_zulu_jre_archive(window, cancel, runtime_root, major, download_threads, arch) + install_zulu_jre_archive(window, cancel, staging, major, download_threads, arch) } /// Downloads and extracts a Zulu JRE for an explicit CPU arch into `runtime_root`. @@ -9242,7 +9406,7 @@ fn install_native_jre_archive( fn install_zulu_jre_archive( window: Option<&tauri::Window>, cancel: &downloader::CancelToken, - runtime_root: &Path, + staging: &RuntimeStaging, major: i32, download_threads: usize, arch: &str, @@ -9267,9 +9431,8 @@ fn install_zulu_jre_archive( ); // Kept outside the staging directory under a stable name so an interrupted // download resumes instead of starting over. - let archive_path = runtime_root - .parent() - .unwrap_or(runtime_root) + let archive_path = staging + .download_parent() .join(".downloads") .join(format!("{}.{archive_type}", sanitize_file_name(&package.name))); let progress = |downloaded: u64, total: Option| match total.filter(|value| *value > 0) { @@ -9296,6 +9459,7 @@ fn install_zulu_jre_archive( .map_err(|e| format!("Native JRE download failed: {e}"))?; // An archive install replaces whatever a previous attempt left in staging. + let runtime_root = staging.prepare()?; let _ = fs::remove_dir_all(runtime_root); fs::create_dir_all(runtime_root) .map_err(|e| format!("Failed creating runtime dir {}: {e}", runtime_root.display()))?; @@ -9553,6 +9717,48 @@ fn download_mojang_runtime_files( Ok(()) } +/// Staging may hold a tree from another source (a Zulu archive moved aside +/// when Azul became unreachable). Files the manifest does not list are +/// removed so a stray `java` cannot be picked by `locate_java_binary`. +fn prune_runtime_files_outside_manifest( + runtime_root: &Path, + manifest: &MojangJavaManifest, +) -> Result<(), String> { + let keep = manifest + .files + .iter() + .filter(|(_, entry)| entry.entry_type != "directory") + .map(|(path, _)| path.replace('\\', "/")) + .collect::>(); + let mut stack = vec![runtime_root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = + fs::read_dir(&dir).map_err(|e| format!("Failed to scan {}: {e}", dir.display()))?; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = fs::symlink_metadata(&path) else { + continue; + }; + if meta.is_dir() { + stack.push(path); + continue; + } + let relative = path + .strip_prefix(runtime_root) + .map_err(|e| e.to_string())? + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>() + .join("/"); + if relative != RUNTIME_MARKER_FILE && !keep.contains(&relative) { + fs::remove_file(&path) + .map_err(|e| format!("Failed to remove stray runtime file {}: {e}", path.display()))?; + } + } + } + Ok(()) +} + fn clone_mojang_entry(entry: &MojangJavaRemoteEntry) -> MojangJavaRemoteEntry { MojangJavaRemoteEntry { entry_type: entry.entry_type.clone(), @@ -10550,6 +10756,84 @@ mod tests { ); } + fn write_legacy_runtime(root: &std::path::Path) { + let java = if cfg!(windows) { "java.exe" } else { "java" }; + fs::create_dir_all(root.join("bin")).unwrap(); + fs::write(root.join("bin").join(java), b"binary").unwrap(); + fs::create_dir_all(root.join("lib").join("server")).unwrap(); + fs::write(root.join("lib").join("modules"), b"0123456789").unwrap(); + fs::write(root.join("lib").join("server").join("libjvm.so"), b"jvm").unwrap(); + } + + #[test] + fn complete_runtime_from_before_markers_is_adopted() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("jdk-17"); + write_legacy_runtime(&root); + assert!(super::verify_runtime_install(&root, true).is_ok()); + let marker = super::read_runtime_marker(&root).expect("adoption writes a marker"); + assert_eq!(marker.source, "adopted"); + assert_eq!(marker.major, 17); + // Later checks use the generated marker like any other. + fs::write(root.join("lib").join("modules"), b"0123").unwrap(); + assert!(super::verify_runtime_install(&root, false).is_err()); + } + + #[test] + fn interrupted_runtime_is_not_adopted() { + let temp = tempfile::tempdir().unwrap(); + + let leftover = temp.path().join("jdk-8"); + write_legacy_runtime(&leftover); + fs::write(leftover.join("lib").join("rt.download"), b"partial").unwrap(); + assert!(super::verify_runtime_install(&leftover, false).is_err()); + + let no_jvm = temp.path().join("jdk-16"); + write_legacy_runtime(&no_jvm); + fs::remove_file(no_jvm.join("lib").join("server").join("libjvm.so")).unwrap(); + assert!(super::verify_runtime_install(&no_jvm, false).is_err()); + + let staged = temp.path().join("jdk-21"); + write_legacy_runtime(&staged); + fs::create_dir_all(super::runtime_staging_dir(&staged)).unwrap(); + assert!(super::verify_runtime_install(&staged, false).is_err()); + + for root in [&leftover, &no_jvm, &staged] { + assert!(!root.join(super::RUNTIME_MARKER_FILE).exists()); + } + } + + #[test] + fn runtime_staging_leaves_installed_runtime_until_prepared() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("jdk-17"); + write_legacy_runtime(&root); + let staging = super::RuntimeStaging::new(&root, false); + // Nothing moves before an installer has its remote metadata. + assert!(!staging.is_prepared()); + assert!(root.join("lib").join("modules").is_file()); + let path = staging.prepare().unwrap().to_path_buf(); + assert!(!root.exists()); + assert!(path.join("lib").join("modules").is_file()); + } + + #[test] + fn mojang_install_prunes_files_from_another_source() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("staging"); + fs::create_dir_all(root.join("zulu-8.jre").join("bin")).unwrap(); + fs::write(root.join("zulu-8.jre").join("bin").join("java"), b"zulu").unwrap(); + fs::create_dir_all(root.join("bin")).unwrap(); + fs::write(root.join("bin").join("java"), b"mojang").unwrap(); + let manifest: super::MojangJavaManifest = serde_json::from_str( + r#"{"files":{"bin":{"type":"directory"},"bin/java":{"type":"file","downloads":{}}}}"#, + ) + .unwrap(); + super::prune_runtime_files_outside_manifest(&root, &manifest).unwrap(); + assert!(root.join("bin").join("java").is_file()); + assert!(!root.join("zulu-8.jre").join("bin").join("java").exists()); + } + #[test] fn fabric_install_result_matches_java_core_payload() { let payload = r#"{ From 35740f6f11a6a7de373de6747b4cc43c49fb56d9 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:31:58 -0700 Subject: [PATCH 15/24] fix(launcher): classify errors by backend code and send Accept-Language The Rust API client now asks the backend for the UI language and turns every failed FPSMaster API response into a tagged error carrying status, code and the localized message. The frontend branches on that code: only a 401 with a session code logs the user out, mirror 401/403s no longer do, transport failures name the download host instead of blaming the FPSMaster server, and cancelled, integrity and disk-full failures get their own text. Adds a Node test-runner suite for the classifier (npm test). --- tauri-app/package.json | 1 + tauri-app/src-tauri/src/launcher_api.rs | 210 ++++----- tauri-app/src-tauri/src/main.rs | 38 ++ tauri-app/src/App.tsx | 10 +- .../src/components/MinecraftProfileCard.tsx | 4 +- tauri-app/src/hooks/useLauncherTelemetry.ts | 8 +- tauri-app/src/hooks/useLauncherUpdate.ts | 5 +- tauri-app/src/lib/apiLanguage.ts | 20 + tauri-app/src/lib/launcherError.test.ts | 72 ++++ tauri-app/src/lib/launcherError.ts | 406 ++++++++++++------ tauri-app/src/locales/en-US.ts | 15 +- tauri-app/src/locales/zh-CN.ts | 15 +- tauri-app/src/main.tsx | 7 +- tauri-app/src/pages/InstanceSettings.tsx | 9 +- tauri-app/src/pages/Monitor.tsx | 5 +- tauri-app/src/pages/Settings.tsx | 7 +- tauri-app/tsconfig.json | 5 +- 17 files changed, 559 insertions(+), 278 deletions(-) create mode 100644 tauri-app/src/lib/apiLanguage.ts create mode 100644 tauri-app/src/lib/launcherError.test.ts diff --git a/tauri-app/package.json b/tauri-app/package.json index eaa7be1..9b33581 100644 --- a/tauri-app/package.json +++ b/tauri-app/package.json @@ -21,6 +21,7 @@ "pretauri": "npm run version:sync", "tauri": "tauri", "lint": "eslint .", + "test": "node --test \"src/**/*.test.ts\"", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"" }, "dependencies": { diff --git a/tauri-app/src-tauri/src/launcher_api.rs b/tauri-app/src-tauri/src/launcher_api.rs index 9fee697..e0272ea 100644 --- a/tauri-app/src-tauri/src/launcher_api.rs +++ b/tauri-app/src-tauri/src/launcher_api.rs @@ -12,6 +12,8 @@ use crate::{ #[derive(Debug, Serialize, Deserialize)] pub struct ApiEnvelope { pub success: bool, + #[serde(default)] + pub code: Option, pub message: Option, pub data: Option, } @@ -469,8 +471,7 @@ fn parse_launcher_servers_response( } if !status.is_success() { - return Err(extract_api_error_message(body) - .unwrap_or_else(|| format!("launcher servers failed with HTTP {}", status.as_u16()))); + return Err(api_failure(status, body, "launcher servers")); } let value: serde_json::Value = serde_json::from_str(body) @@ -882,8 +883,7 @@ fn parse_launcher_login_response( serde_json::from_str(body).map_err(|e| format!("Invalid login response JSON: {e}"))?; if !status.is_success() { - return Err(extract_api_error_message(body) - .unwrap_or_else(|| format!("login failed with HTTP {}", status.as_u16()))); + return Err(api_failure(status, body, "login")); } let container = login_payload_container(&value); @@ -909,22 +909,18 @@ fn parse_launcher_versions_response( if status.is_success() { return Ok(Vec::new()); } - return Err(format!( - "versions list failed with HTTP {} and empty response body", - status.as_u16() - )); + return Err(api_failure(status, trimmed_body, "versions list")); } let value: serde_json::Value = match serde_json::from_str(trimmed_body) { Ok(value) => value, Err(err) => { - let preview = response_body_preview(trimmed_body); if !status.is_success() { - return Err(format!( - "versions list failed with HTTP {}: {preview}", - status.as_u16() - )); + // Non-JSON error page (proxy, framework 404 while the backend is + // mid-deploy): the frontend words it by status. + return Err(api_failure(status, trimmed_body, "versions list")); } + let preview = response_body_preview(trimmed_body); return Err(format!( "Invalid versions list response (not JSON, {err}): {preview}" )); @@ -934,33 +930,7 @@ fn parse_launcher_versions_response( if let Ok(envelope) = serde_json::from_value::>>(value.clone()) { if !status.is_success() || !envelope.success { - let is_auth_error = status.as_u16() == 401 || status.as_u16() == 403; - // A 404 here means the release-catalog route itself didn't match on the server - // (e.g. a backend still mid-deploy, or a launcher/backend version skew). The raw - // framework body for that is "Unable to find matching target resource method", - // which is meaningless to a player — replace it with something actionable. - if status.as_u16() == 404 { - return Err( - "Release service is temporarily unavailable (HTTP 404). Please try again in a moment." - .to_string(), - ); - } - let message = envelope - .message - .and_then(|value| { - let trimmed = value.trim().to_string(); - (!trimmed.is_empty()).then_some(trimmed) - }) - .or_else(|| find_error_message_in_value(&value)) - .map(|msg| { - if is_auth_error { - format!("HTTP {} - {}", status.as_u16(), msg) - } else { - msg - } - }) - .unwrap_or_else(|| format!("versions list failed with HTTP {}", status.as_u16())); - return Err(message); + return Err(api_failure(status, trimmed_body, "versions list")); } if let Some(data) = envelope.data { return Ok(data); @@ -968,17 +938,7 @@ fn parse_launcher_versions_response( } if !status.is_success() { - let is_auth_error = status.as_u16() == 401 || status.as_u16() == 403; - if let Some(msg) = find_error_message_in_value(&value) { - if is_auth_error { - return Err(format!("HTTP {} - {}", status.as_u16(), msg)); - } - return Err(msg); - } - return Err(format!( - "versions list failed with HTTP {}", - status.as_u16() - )); + return Err(api_failure(status, trimmed_body, "versions list")); } extract_launcher_versions(&value) @@ -1007,8 +967,7 @@ fn parse_launcher_news_response( .map_err(|e| format!("Invalid launcher news response JSON: {e}"))?; if !status.is_success() { - return Err(extract_api_error_message(body) - .unwrap_or_else(|| format!("launcher news failed with HTTP {}", status.as_u16()))); + return Err(api_failure(status, body, "launcher news")); } extract_launcher_news(&value).ok_or_else(|| "launcher news response missing data".to_string()) @@ -1026,17 +985,7 @@ fn parse_launcher_dashboard_response( .map_err(|e| format!("Invalid launcher dashboard response JSON: {e}"))?; if !status.is_success() { - let is_auth_error = status.as_u16() == 401 || status.as_u16() == 403; - if let Some(msg) = extract_api_error_message(body) { - if is_auth_error { - return Err(format!("HTTP {} - {}", status.as_u16(), msg)); - } - return Err(msg); - } - return Err(format!( - "launcher dashboard failed with HTTP {}", - status.as_u16() - )); + return Err(api_failure(status, body, "launcher dashboard")); } serde_json::from_value::(value) @@ -1055,17 +1004,7 @@ fn parse_launcher_home_response( .map_err(|e| format!("Invalid launcher home response JSON: {e}"))?; if !status.is_success() { - let is_auth_error = status.as_u16() == 401 || status.as_u16() == 403; - if let Some(msg) = extract_api_error_message(body) { - if is_auth_error { - return Err(format!("HTTP {} - {}", status.as_u16(), msg)); - } - return Err(msg); - } - return Err(format!( - "launcher home failed with HTTP {}", - status.as_u16() - )); + return Err(api_failure(status, body, "launcher home")); } serde_json::from_value::(value) @@ -1086,9 +1025,7 @@ fn parse_launcher_app_update_response( .map_err(|e| format!("Invalid launcher app update response JSON: {e}"))?; if !status.is_success() { - return Err(extract_api_error_message(body).unwrap_or_else(|| { - format!("launcher app update failed with HTTP {}", status.as_u16()) - })); + return Err(api_failure(status, body, "launcher app update")); } serde_json::from_value::(value) @@ -1103,56 +1040,56 @@ pub(crate) fn parse_api_envelope Deserialize<'de>>( match serde_json::from_str::>(body) { Ok(payload) => { if !status.is_success() || !payload.success { - let is_auth_error = status.as_u16() == 401 || status.as_u16() == 403; - let message = payload - .message - .and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) - .or_else(|| extract_api_error_message(body)) - .map(|msg| { - if is_auth_error { - format!("HTTP {} - {}", status.as_u16(), msg) - } else { - msg - } - }) - .unwrap_or_else(|| format!("{context} failed with HTTP {}", status.as_u16())); - return Err(message); + return Err(api_failure(status, body, context)); } payload .data .ok_or_else(|| format!("{context} response missing data")) } Err(err) => { - let is_auth_error = status.as_u16() == 401 || status.as_u16() == 403; - if let Some(message) = extract_api_error_message(body) { - if is_auth_error { - return Err(format!("HTTP {} - {}", status.as_u16(), message)); - } - return Err(message); - } - if status.is_success() { + if status.is_success() && extract_api_error_message(body).is_none() { return Err(format!("Invalid {context} response JSON: {err}")); } - let compact = body.trim(); - if compact.is_empty() { - return Err(format!("{context} failed with HTTP {}", status.as_u16())); - } - Err(format!( - "{context} failed with HTTP {}: {}", - status.as_u16(), - compact - )) + Err(api_failure(status, body, context)) } } } +/// Marks a failed FPSMaster API call. What follows is a JSON object +/// `{"status","code","message","context"}` so the frontend can branch on the +/// stable `code` / HTTP status instead of matching message text (contract §1). +pub(crate) const API_ERROR_PREFIX: &str = "FPSMASTER_API_ERROR "; + +/// Builds the tagged error for a non-success API response. `message` is the +/// backend's localized text (or null when the body had none, e.g. a proxy's +/// HTML error page); the frontend falls back to its own text by status. +pub(crate) fn api_failure(status: reqwest::StatusCode, body: &str, context: &str) -> String { + let value = serde_json::from_str::(body.trim_start_matches('\u{feff}').trim()).ok(); + let code = value + .as_ref() + .and_then(|value| value.get("code")) + .and_then(|code| code.as_str()) + .map(str::trim) + .filter(|code| !code.is_empty()) + .map(str::to_string); + // `code` is an identifier, never display text: drop it before searching for + // the message so a body without one does not show "ITEM_ALREADY_OWNED". + let message = value.map(|mut value| { + if let Some(object) = value.as_object_mut() { + object.remove("code"); + } + value + }); + let message = message.as_ref().and_then(find_error_message_in_value); + let payload = serde_json::json!({ + "status": status.as_u16(), + "code": code, + "message": message, + "context": context, + }); + format!("{API_ERROR_PREFIX}{payload}") +} + fn login_payload_container(value: &serde_json::Value) -> &serde_json::Value { value.get("data").unwrap_or(value) } @@ -1291,3 +1228,44 @@ fn find_error_message_in_value(value: &serde_json::Value) -> Option { None } + +#[cfg(test)] +mod tests { + use super::{api_failure, parse_launcher_versions_response, API_ERROR_PREFIX}; + + fn decode(error: &str) -> serde_json::Value { + let json = error + .strip_prefix(API_ERROR_PREFIX) + .expect("tagged API error"); + serde_json::from_str(json).expect("JSON payload") + } + + #[test] + fn api_failure_carries_status_code_and_message() { + let body = r#"{"success":false,"code":"UNAUTHORIZED","message":"请先登录","data":{}}"#; + let value = decode(&api_failure(reqwest::StatusCode::UNAUTHORIZED, body, "login")); + assert_eq!(value["status"], 401); + assert_eq!(value["code"], "UNAUTHORIZED"); + assert_eq!(value["message"], "请先登录"); + } + + #[test] + fn api_failure_never_uses_the_code_as_message() { + let body = r#"{"success":false,"code":"ITEM_ALREADY_OWNED","message":null}"#; + let value = decode(&api_failure(reqwest::StatusCode::CONFLICT, body, "shop")); + assert_eq!(value["code"], "ITEM_ALREADY_OWNED"); + assert!(value["message"].is_null()); + } + + #[test] + fn non_json_error_page_keeps_the_status() { + let error = parse_launcher_versions_response( + reqwest::StatusCode::NOT_FOUND, + "Unable to find matching target resource method", + ) + .unwrap_err(); + let value = decode(&error); + assert_eq!(value["status"], 404); + assert!(value["code"].is_null()); + } +} diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index cd52e22..e23af5d 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -4525,10 +4525,47 @@ pub(crate) fn build_blocking_http_client() -> Result &'static std::sync::RwLock { + static LANGUAGE: OnceLock> = OnceLock::new(); + LANGUAGE.get_or_init(|| std::sync::RwLock::new("en".to_string())) +} + +/// Maps the launcher locale ("zh-CN" / "en-US") onto the Accept-Language value +/// the backend expects ("zh-CN" / "en"). +fn accept_language_for(locale: &str) -> &'static str { + if locale.trim().to_ascii_lowercase().starts_with("zh") { + "zh-CN" + } else { + "en" + } +} + +#[tauri::command] +fn set_ui_language(language: String) { + let value = accept_language_for(&language).to_string(); + match ui_language().write() { + Ok(mut guard) => *guard = value, + Err(poisoned) => *poisoned.into_inner() = value, + } +} + /// 面向 api.fpsmaster.top 的短超时客户端。API 调用必须快速失败。 +/// Every request carries the UI language so the backend's `message` matches it. pub(crate) fn build_api_http_client() -> Result { + let language = ui_language() + .read() + .map(|guard| guard.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().clone()); + let mut headers = reqwest::header::HeaderMap::new(); + if let Ok(value) = reqwest::header::HeaderValue::from_str(&language) { + headers.insert(reqwest::header::ACCEPT_LANGUAGE, value); + } reqwest::blocking::Client::builder() .user_agent(LAUNCHER_HTTP_USER_AGENT) + .default_headers(headers) .connect_timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(20)) .redirect(reqwest::redirect::Policy::limited(10)) @@ -11891,6 +11928,7 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + set_ui_language, ensure_jdk, launcher_login, launcher_list_available_versions, diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index 2ee96e4..aae3767 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -27,6 +27,7 @@ import { I18nProvider, resolveLocale } from "./i18n"; +import { syncApiLanguage } from "./lib/apiLanguage"; import { mapLaunchPreparePhaseKey, mergePhaseByteProgress, @@ -270,6 +271,7 @@ function Launcher() { user: launcherAuth?.user ?? null, playerName: settings.playerName, backgroundMode, + language: settings.language, t, onAuthExpired: handleAuthExpired }); @@ -489,6 +491,10 @@ function Launcher() { void launcherData.refreshHome(true); }, [backgroundMode, launcherAuth?.token]); + useEffect(() => { + void syncApiLanguage(settings.language); + }, [settings.language]); + useEffect(() => { applyTheme(settings.themeMode, settings.themeAccent, settings.customAccentHex); }, [settings.themeMode, settings.themeAccent, settings.customAccentHex]); @@ -1152,7 +1158,7 @@ function Launcher() { setStatus( t("app.status.gameStartedMonitorFailed", { pid, - error: String(error) + error: describeApiError(error, t) }) ); } @@ -2370,7 +2376,7 @@ function Launcher() { }); setInstallDialog(null); } catch (error) { - const errorText = String(error); + const errorText = describeApiError(error, t); setStatus(t("app.status.installFailed", { error: errorText })); setInstallDialog((prev) => { if (!prev || prev.sessionId !== sessionId) return prev; diff --git a/tauri-app/src/components/MinecraftProfileCard.tsx b/tauri-app/src/components/MinecraftProfileCard.tsx index bd963f9..db1e973 100644 --- a/tauri-app/src/components/MinecraftProfileCard.tsx +++ b/tauri-app/src/components/MinecraftProfileCard.tsx @@ -86,14 +86,14 @@ function MinecraftProfileCard({ if (!disposed) { setAuthConfig({ configured: false, - configurationHint: String(error) + configurationHint: describeApiError(error, t) }); } }); return () => { disposed = true; }; - }, [dialogVisible]); + }, [dialogVisible, t]); const displayAccount = currentAccount ?? accounts[0] ?? null; const profileTitle = displayAccount?.username?.trim() || t("minecraftAccount.emptyTitle"); diff --git a/tauri-app/src/hooks/useLauncherTelemetry.ts b/tauri-app/src/hooks/useLauncherTelemetry.ts index 35539c5..b4050a7 100644 --- a/tauri-app/src/hooks/useLauncherTelemetry.ts +++ b/tauri-app/src/hooks/useLauncherTelemetry.ts @@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { LAUNCHER_API_BASE_URL } from "../constants"; import type { TranslationKey } from "../i18n"; +import { acceptLanguageFor } from "../lib/apiLanguage"; import { isAuthExpiredError } from "../lib/launcherError"; import { loadOrCreateLauncherSessionId } from "../lib/launcherAuth"; import { nextRecurringDelay } from "../lib/system"; -import type { TelemetryOnlineSummary } from "../types"; +import type { Locale, TelemetryOnlineSummary } from "../types"; const ONLINE_REFRESH_INTERVAL_MS = 90_000; const ONLINE_REFRESH_JITTER_MS = 8_000; @@ -18,6 +19,7 @@ type UseLauncherTelemetryDeps = { user: SessionUser | null; playerName: string; backgroundMode: boolean; + language: Locale; t: Translator; onAuthExpired: (message: string) => void; }; @@ -72,7 +74,9 @@ export function useLauncherTelemetry(deps: UseLauncherTelemetryDeps): LauncherTe }, []); const fetchOnlineSummary = useCallback(async (): Promise => { - const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/v1/telemetry/online?clientKind=LAUNCHER`); + const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/v1/telemetry/online?clientKind=LAUNCHER`, { + headers: { "Accept-Language": acceptLanguageFor(depsRef.current.language) } + }); const raw = (await response.json()) as { success?: boolean; message?: string; diff --git a/tauri-app/src/hooks/useLauncherUpdate.ts b/tauri-app/src/hooks/useLauncherUpdate.ts index b0ac121..1a6e4ad 100644 --- a/tauri-app/src/hooks/useLauncherUpdate.ts +++ b/tauri-app/src/hooks/useLauncherUpdate.ts @@ -3,7 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { LAUNCHER_API_BASE_URL } from "../constants"; import type { TranslationKey } from "../i18n"; -import { describeApiError, formatLaunchError, isLauncherAppUpdateMissing } from "../lib/launcherError"; +import { describeApiError, isLauncherAppUpdateMissing } from "../lib/launcherError"; import { notifyError } from "../lib/toast"; import type { DownloadedLauncherUpdate, LauncherAppUpdateChannel, LauncherAppUpdateInfo } from "../types"; import { compareMajor } from "../utils/launcher"; @@ -91,8 +91,7 @@ export function useLauncherUpdate(deps: UseLauncherUpdateDeps): LauncherUpdateCo ); } } catch (error) { - const rawText = formatLaunchError(error); - if (isLauncherAppUpdateMissing(rawText)) { + if (isLauncherAppUpdateMissing(error)) { setAppUpdate(null); setDownload(null); } else { diff --git a/tauri-app/src/lib/apiLanguage.ts b/tauri-app/src/lib/apiLanguage.ts new file mode 100644 index 0000000..33a46f3 --- /dev/null +++ b/tauri-app/src/lib/apiLanguage.ts @@ -0,0 +1,20 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { Locale } from "../types"; + +/** Accept-Language value the backend expects for a launcher locale (contract §1). */ +export function acceptLanguageFor(locale: Locale): string { + return locale === "zh-CN" ? "zh-CN" : "en"; +} + +/** + * Tells the Rust API client which language to request. Every backend call is + * made from Rust, so the header has to be set there, not on a fetch(). + */ +export async function syncApiLanguage(locale: Locale): Promise { + try { + await invoke("set_ui_language", { language: locale }); + } catch (error) { + // Outside Tauri (vite dev in a browser) there is no backend to tell. + console.warn("[i18n] failed to sync API language:", error); + } +} diff --git a/tauri-app/src/lib/launcherError.test.ts b/tauri-app/src/lib/launcherError.test.ts new file mode 100644 index 0000000..03d1ad1 --- /dev/null +++ b/tauri-app/src/lib/launcherError.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + classifyApiError, + describeApiError, + isAuthExpiredError, + isLauncherAppUpdateMissing, + normalizeLoginError +} from "./launcherError.ts"; + +const t = (key: string, values?: Record) => + values ? `${key}${JSON.stringify(values)}` : key; + +function apiError(status: number, code: string | null, message: string | null, context = "launcher home") { + return `FPSMASTER_API_ERROR ${JSON.stringify({ status, code, message, context })}`; +} + +test("only a 401 with a session code from the FPSMaster API expires the session", () => { + assert.equal(isAuthExpiredError(apiError(401, "UNAUTHORIZED", "请先登录")), true); + assert.equal(isAuthExpiredError(apiError(401, "AUTHENTICATION_REQUIRED", "Please sign in first.")), true); + assert.equal(isAuthExpiredError(apiError(401, null, null)), true); + // Wrong password on login is a 401 too, but not a dead session. + assert.equal(isAuthExpiredError(apiError(401, "INVALID_CREDENTIALS", "用户名或密码错误")), false); + assert.equal(isAuthExpiredError(apiError(403, "ACCOUNT_BANNED", "账号已被封禁")), false); + // A mirror refusing a file must never log the user out. + assert.equal(isAuthExpiredError("HTTP 403 Forbidden from https://bmclapi2.bangbang93.com/x.jar"), false); + assert.equal(isAuthExpiredError("Mojang said: Forbidden"), false); +}); + +test("backend messages are shown verbatim, codes are not", () => { + assert.equal(describeApiError(apiError(409, "ITEM_ALREADY_OWNED", "你已拥有该物品"), t), "你已拥有该物品"); + assert.equal(describeApiError(apiError(403, "FORBIDDEN", null), t), "error.http.forbidden"); + assert.equal(normalizeLoginError(apiError(401, "INVALID_CREDENTIALS", "用户名或密码错误"), t), "用户名或密码错误"); +}); + +test("release catalog 404 without a body reads as unavailable, not unreleased", () => { + assert.equal(describeApiError(apiError(404, null, null, "versions list"), t), "error.catalog.unavailable"); +}); + +test("transport failures name the download source host", () => { + const raw = + "Failed to download x: error sending request for url (https://bmclapi2.bangbang93.com/assets/a): tcp connect error: Connection refused (os error 61)"; + const item = classifyApiError(raw); + assert.equal(item.kind, "offline"); + assert.equal(item.fromApi, false); + assert.equal(describeApiError(raw, t), 'error.source.offline{"host":"bmclapi2.bangbang93.com"}'); +}); + +test("transport failures to the FPSMaster API use the server text", () => { + const raw = + "Releases request failed: error sending request for url (https://api.fpsmaster.top/api/v1/launcher/releases/available): operation timed out"; + assert.equal(classifyApiError(raw).kind, "timeout"); + assert.equal(describeApiError(raw, t), "error.network.timeout"); +}); + +test("a proxy word in a message is not an offline error", () => { + assert.notEqual(classifyApiError("Mod 'Proxy Utils' is incompatible").kind, "offline"); + assert.equal(classifyApiError("error sending request: unsuccessful tunnel").kind, "proxy"); +}); + +test("download HTTP statuses are classified without touching auth", () => { + assert.equal(classifyApiError("HTTP 404 Not Found from https://libraries.minecraft.net/a.jar").kind, "notFound"); + assert.equal(classifyApiError("HTTP 503 Service Unavailable from https://bmclapi2.bangbang93.com/").kind, "server"); + assert.equal(classifyApiError("SHA1 mismatch: expected=a actual=b").kind, "integrity"); + assert.equal(classifyApiError("Installation cancelled").kind, "cancelled"); + assert.equal(classifyApiError("Failed to write: No space left on device (os error 28)").kind, "diskFull"); +}); + +test("missing launcher update is detected from the tagged status", () => { + assert.equal(isLauncherAppUpdateMissing(apiError(404, "NOT_FOUND", "not found", "launcher app update")), true); + assert.equal(isLauncherAppUpdateMissing(apiError(500, "INTERNAL_ERROR", null)), false); +}); diff --git a/tauri-app/src/lib/launcherError.ts b/tauri-app/src/lib/launcherError.ts index bbdedc2..c2cf43c 100644 --- a/tauri-app/src/lib/launcherError.ts +++ b/tauri-app/src/lib/launcherError.ts @@ -1,11 +1,11 @@ -// Error/message normalization helpers. Pure functions extracted from App.tsx. +// Error/message normalization helpers. Pure functions (no React, no Tauri). import type { TranslationKey } from "../i18n"; type Translator = (key: TranslationKey, values?: Record) => string; export function formatLaunchError(error: unknown): string { - const raw = String(error ?? ""); - if (raw === "") return "Unknown launch error"; + const raw = error instanceof Error ? error.message : String(error ?? ""); + if (raw === "") return ""; if (raw.startsWith("Error: ")) { return raw.slice("Error: ".length).trim(); } @@ -32,7 +32,9 @@ export function findMessageInUnknown(value: unknown): string | null { return candidate.trim(); } } - for (const nested of Object.values(record)) { + for (const [key, nested] of Object.entries(record)) { + // `code` is an identifier for logic, never display text. + if (key === "code") continue; const found = findMessageInUnknown(nested); if (found) return found; } @@ -49,22 +51,66 @@ export function tryExtractMessageFromJson(raw: string): string | null { } } +/** Prefix the Rust side puts on a failed FPSMaster API call (see `api_failure`). */ +const API_ERROR_PREFIX = "FPSMASTER_API_ERROR "; + +/** Host of the launcher's own backend; anything else is a download source. */ +const FPSMASTER_API_HOST = "api.fpsmaster.top"; + +/** Backend codes that mean "the bearer token is no longer valid". */ +const SESSION_INVALID_CODES = new Set(["UNAUTHORIZED", "AUTHENTICATION_REQUIRED"]); + +/** Exact text of `downloader::CANCELLED_MESSAGE`. */ +const CANCELLED_MESSAGE = "installation cancelled"; + +export type TaggedApiError = { + status: number; + code: string | null; + /** Backend text, already localized through Accept-Language. */ + message: string | null; + context: string | null; +}; + +/** Decodes the `FPSMASTER_API_ERROR {json}` string produced by the Rust API client. */ +export function parseTaggedApiError(raw: string): TaggedApiError | null { + const index = raw.indexOf(API_ERROR_PREFIX); + if (index < 0) return null; + try { + const value = JSON.parse(raw.slice(index + API_ERROR_PREFIX.length)) as Record; + const status = typeof value.status === "number" ? value.status : 0; + const text = (input: unknown) => + typeof input === "string" && input.trim() !== "" ? input.trim() : null; + return { + status, + code: text(value.code), + message: text(value.message), + context: text(value.context) + }; + } catch { + return null; + } +} + /** - * Cause of a failed backend call, derived from the error string the Rust side - * produced. The backend returns `Result<_, String>` everywhere, so this is the - * only classification signal available on the frontend — `describe_http_error` - * deliberately flattens the whole reqwest/hyper/rustls source chain into that - * string so the distinguishing text is present. + * What went wrong, as far as the frontend can tell. Every Tauri command returns + * `Result<_, String>`; FPSMaster API failures carry a tagged JSON payload with + * the backend's stable `code`, everything else is classified from the flattened + * reqwest / io error chain. */ export type ApiErrorKind = | "offline" | "timeout" | "dns" | "tls" - | "auth" + | "proxy" + | "sessionExpired" + | "forbidden" | "notFound" | "rateLimited" | "server" + | "integrity" + | "diskFull" + | "cancelled" | "internal" | "business"; @@ -72,11 +118,21 @@ export type ClassifiedApiError = { kind: ApiErrorKind; /** Original backend text, kept for log/detail surfaces. */ raw: string; - /** HTTP status parsed out of the message, when present. */ + /** HTTP status, when known. */ status: number | null; + /** Backend error code (FPSMaster API only). */ + code: string | null; + /** Backend message for display (FPSMaster API only). */ + serverMessage: string | null; + /** Host the failing request went to, when the error names one. */ + host: string | null; + /** True when the failing request targeted the FPSMaster API. */ + fromApi: boolean; + /** Which API call failed (Rust `context`), FPSMaster API only. */ + context: string | null; }; -const NETWORK_PATTERNS: ReadonlyArray<{ kind: ApiErrorKind; needles: readonly string[] }> = [ +const TRANSPORT_PATTERNS: ReadonlyArray<{ kind: ApiErrorKind; needles: readonly string[] }> = [ { kind: "dns", needles: [ @@ -84,24 +140,32 @@ const NETWORK_PATTERNS: ReadonlyArray<{ kind: ApiErrorKind; needles: readonly st "failed to lookup address", "name or service not known", "nodename nor servname", - "no such host" + "no such host", + "temporary failure in name resolution", + "os error 11001" ] }, { kind: "tls", needles: [ - "certificate", "invalid peer certificate", + "certificate verify failed", + "unknownissuer", "self-signed", + "self signed", "tls handshake", "handshake failure", - "unknownissuer", - "certificate verify failed" + "certificate has expired", + "certificate not valid" ] }, + { + kind: "proxy", + needles: ["unsuccessful tunnel", "proxy authentication required", "proxyconnect", "proxy error"] + }, { kind: "timeout", - needles: ["timed out", "timeout", "operation timed out", "deadline has elapsed"] + needles: ["timed out", "deadline has elapsed", "operation timed out", "os error 10060"] }, { kind: "offline", @@ -110,96 +174,190 @@ const NETWORK_PATTERNS: ReadonlyArray<{ kind: ApiErrorKind; needles: readonly st "network is unreachable", "no route to host", "connection reset", - "connection closed", + "connection aborted", + "connection closed before message completed", "failed to connect", "tcp connect error", - "proxy", - // ECONNREFUSED on macOS / Linux / Windows respectively. + // ECONNREFUSED on macOS / Linux / Windows, ENETUNREACH on Windows. "os error 61", "os error 111", - "os error 10061" + "os error 10061", + "os error 10051" ] } ]; -/** Extracts the JSON/plain business message the server sent, if any. */ -function extractBusinessMessage(text: string): string | null { - const maybeJson = text.match(/(\{[\s\S]*\}|\[[\s\S]*\])$/); - if (maybeJson) { - const parsed = tryExtractMessageFromJson(maybeJson[1]); - if (parsed) { - return parsed; - } +function extractHost(text: string): string | null { + const match = text.match(/https?:\/\/([^/\s:)"']+)/i); + return match ? match[1].toLowerCase() : null; +} + +function classified( + kind: ApiErrorKind, + raw: string, + extra: Partial> = {} +): ClassifiedApiError { + const host = extra.host ?? extractHost(raw); + return { + kind, + raw, + status: extra.status ?? null, + code: extra.code ?? null, + serverMessage: extra.serverMessage ?? null, + host, + fromApi: extra.fromApi ?? host === FPSMASTER_API_HOST, + context: extra.context ?? null + }; +} + +function classifyTagged(raw: string, tagged: TaggedApiError): ClassifiedApiError { + const base = { + status: tagged.status, + code: tagged.code, + serverMessage: tagged.message, + host: FPSMASTER_API_HOST, + fromApi: true, + context: tagged.context + }; + // Only a 401 means the token is dead. A 401 carrying another code (wrong + // password on login) is an ordinary business failure with its own message. + if (tagged.status === 401 && (tagged.code === null || SESSION_INVALID_CODES.has(tagged.code))) { + return classified("sessionExpired", raw, base); } - return null; + if (tagged.status === 429 || tagged.code === "RATE_LIMITED") { + return classified("rateLimited", raw, base); + } + if (tagged.status >= 500) { + return classified("server", raw, base); + } + if (tagged.message) { + return classified("business", raw, base); + } + if (tagged.status === 403) return classified("forbidden", raw, base); + if (tagged.status === 404) return classified("notFound", raw, base); + return classified("business", raw, base); } export function classifyApiError(error: unknown): ClassifiedApiError { const raw = formatLaunchError(error); - const normalized = raw.toLowerCase(); - - // The backend reports HTTP failures in two shapes: "... HTTP 404" (downloads, - // API calls) and "Request failed url=... status=404 Not Found" (metadata / - // catalog fetches). Recognize both so 404s classify as notFound instead of - // falling through as opaque business errors. - const statusMatch = - normalized.match(/http\s*(\d{3})\b/) ?? normalized.match(/\bstatus[=:]\s*(\d{3})\b/); - const status = statusMatch ? Number.parseInt(statusMatch[1], 10) : null; + const tagged = parseTaggedApiError(raw); + if (tagged) { + return classifyTagged(raw, tagged); + } + const normalized = raw.toLowerCase(); + if (normalized.includes(CANCELLED_MESSAGE)) { + return classified("cancelled", raw); + } // A `spawn_blocking` join failure means the backend task died, not a network // problem — surfacing it as "check your connection" would send users chasing // the wrong thing. if (normalized.includes("failed to join") && normalized.includes("task")) { - return { kind: "internal", raw, status }; + return classified("internal", raw); } - - if (status !== null) { - if (status === 401 || status === 403) return { kind: "auth", raw, status }; - if (status === 404) return { kind: "notFound", raw, status }; - if (status === 429) return { kind: "rateLimited", raw, status }; - if (status >= 500) return { kind: "server", raw, status }; - return { kind: "business", raw, status }; + // The launcher's own pre-flight check for a missing token. + if (normalized === "token is required") { + return classified("sessionExpired", raw, { fromApi: true, host: FPSMASTER_API_HOST }); + } + if (/\b(sha1|sha256|sha512|checksum|size) mismatch\b/.test(normalized)) { + return classified("integrity", raw); + } + if (/no space left on device|os error 28\b|os error 112\b|disk is full/.test(normalized)) { + return classified("diskFull", raw); } - if (isAuthExpiredError(error)) { - return { kind: "auth", raw, status }; + // Downloads and metadata fetches report HTTP failures as "HTTP 404 ..." or + // "... status=404 Not Found". These are mirror / Mojang / Modrinth answers, + // never a verdict on the launcher session, so 401/403 is "forbidden" here. + const statusMatch = + normalized.match(/\bhttp\s*(\d{3})\b/) ?? normalized.match(/\bstatus[=:]\s*(\d{3})\b/); + if (statusMatch) { + const status = Number.parseInt(statusMatch[1], 10); + if (status === 401 || status === 403) return classified("forbidden", raw, { status }); + if (status === 404 || status === 410) return classified("notFound", raw, { status }); + if (status === 429) return classified("rateLimited", raw, { status }); + if (status >= 500) return classified("server", raw, { status }); + return classified("business", raw, { status }); } - for (const { kind, needles } of NETWORK_PATTERNS) { + for (const { kind, needles } of TRANSPORT_PATTERNS) { if (needles.some((needle) => normalized.includes(needle))) { - return { kind, raw, status }; + return classified(kind, raw); } } // reqwest's outermost layer without a recognizable cause: still a transport // failure, just an unspecific one. if (normalized.includes("error sending request")) { - return { kind: "offline", raw, status }; + return classified("offline", raw); } - return { kind: "business", raw, status }; + return classified("business", raw); +} + +/** True when the classified error is a transport failure (no usable network path). */ +export function isNetworkErrorKind(kind: ApiErrorKind): boolean { + return kind === "offline" || kind === "timeout" || kind === "dns" || kind === "tls" || kind === "proxy"; +} + +/** True when `error` was a network failure, whatever endpoint it hit. */ +export function isNetworkError(error: unknown): boolean { + return isNetworkErrorKind(classifyApiError(error).kind); +} + +export function isCancelledError(error: unknown): boolean { + return classifyApiError(error).kind === "cancelled"; +} + +function transportText(kind: ApiErrorKind, item: ClassifiedApiError, t: Translator): string { + // FPSMaster API vs a download source matters: the fix for "our server is + // down" is to wait, the fix for "BMCLAPI is down" is to switch source. + const host = item.host; + if (item.fromApi || !host) { + if (kind === "offline") return t("error.network.offline"); + if (kind === "timeout") return t("error.network.timeout"); + if (kind === "dns") return t("error.network.dns"); + if (kind === "proxy") return t("error.network.proxy"); + return t("error.network.tls"); + } + if (kind === "offline") return t("error.source.offline", { host }); + if (kind === "timeout") return t("error.source.timeout", { host }); + if (kind === "dns") return t("error.source.dns", { host }); + if (kind === "proxy") return t("error.network.proxy"); + return t("error.source.tls", { host }); } /** - * Human-readable, localized description of a failed backend call. Technical - * transport failures get an actionable localized sentence; genuine business - * errors (which the server already phrases for humans) are passed through. + * Human-readable, localized description of a failed command. The backend's own + * message (already localized via Accept-Language) is shown as-is; everything + * the client detects itself comes from the locale table. */ export function describeApiError(error: unknown, t: Translator): string { - const classified = classifyApiError(error); - switch (classified.kind) { + const item = classifyApiError(error); + switch (item.kind) { case "offline": - return t("error.network.offline"); case "timeout": - return t("error.network.timeout"); case "dns": - return t("error.network.dns"); case "tls": - return t("error.network.tls"); - case "auth": + case "proxy": + return transportText(item.kind, item, t); + case "sessionExpired": return t("error.auth.rejected"); + case "forbidden": + return item.fromApi + ? t("error.http.forbidden") + : item.host + ? t("error.source.forbidden", { host: item.host }) + : t("error.http.forbidden"); case "notFound": { - const normalized = classified.raw.toLowerCase(); + if (item.fromApi) { + // A 404 without a backend body on the release catalog means the route + // itself is missing (backend mid-deploy), not that nothing is released. + return item.context === "versions list" + ? t("error.catalog.unavailable") + : t("error.http.notFound"); + } + const normalized = item.raw.toLowerCase(); if ( normalized.includes("catalog") || normalized.includes("version manifest") || @@ -207,107 +365,75 @@ export function describeApiError(error: unknown, t: Translator): string { ) { return t("error.catalog.notFound"); } - if ( - normalized.includes("download") || - normalized.includes("artifact") || - normalized.includes("installer") - ) { - return t("error.download.notFound"); - } - return t("error.http.notFound"); + return item.host + ? t("error.source.notFound", { host: item.host }) + : t("error.download.notFound"); } case "rateLimited": - return t("error.http.rateLimited"); + return item.serverMessage ?? t("error.http.rateLimited"); case "server": - return t("error.http.server", { status: classified.status ?? 500 }); + return item.fromApi + ? t("error.http.server", { status: item.status ?? 500 }) + : item.host + ? t("error.source.server", { host: item.host, status: item.status ?? 500 }) + : t("error.http.server", { status: item.status ?? 500 }); + case "integrity": + return t("error.download.integrity"); + case "diskFull": + return t("error.disk.full"); + case "cancelled": + return t("error.cancelled"); case "internal": return t("error.internal"); case "business": default: { - const business = extractBusinessMessage(classified.raw); + if (item.serverMessage) { + return item.serverMessage; + } + const maybeJson = item.raw.match(/(\{[\s\S]*\}|\[[\s\S]*\])$/); + const business = maybeJson ? tryExtractMessageFromJson(maybeJson[1]) : null; if (business) { return business; } - return classified.raw === "" ? t("error.unknown") : classified.raw; + return item.raw === "" ? t("error.unknown") : item.raw; } } } export function normalizeLoginError(error: unknown, t: Translator): string { - let text = formatLaunchError(error); - text = text.replace(/^login request failed:\s*/i, "").trim(); - text = text.replace(/^login failed with http \d+:\s*/i, "").trim(); - text = text.replace(/^login failed:\s*/i, "").trim(); - - const maybeJson = text.match(/(\{[\s\S]*\}|\[[\s\S]*\])$/); - if (maybeJson) { - const parsed = tryExtractMessageFromJson(maybeJson[1]); - if (parsed) { - return parsed; - } + const item = classifyApiError(error); + // Wrong password / banned account: the backend phrased it for the user. + if (item.fromApi && item.serverMessage && item.kind !== "server") { + return item.serverMessage; } - - // Transport-level failures reach here as raw reqwest chains ("error sending - // request for url ...: connection timed out"), which told the user nothing. - // Classify against the original error so HTTP status and cause are intact. - const classified = classifyApiError(error); - if (classified.kind !== "business") { + if (item.kind !== "business") { return describeApiError(error, t); } - - if (text === "") { - return t("login.failed"); - } - return text; + const text = item.raw + .replace(/^login request failed:\s*/i, "") + .replace(/^login failed:\s*/i, "") + .trim(); + return text === "" ? t("login.failed") : text; } +/** + * True only when the FPSMaster backend confirmed that the bearer token is no + * longer accepted (HTTP 401 with a session code). Mirror 403s, Mojang 401s and + * "forbidden" business errors must never log the user out. + */ export function isAuthExpiredError(error: unknown): boolean { - const normalized = formatLaunchError(error).trim().toLowerCase(); - if (!normalized) return false; - if (normalized === "token is required" || normalized === "authentication required") { - return true; - } - if (/\bhttp\s*401\b/i.test(normalized)) { - return true; - } - if (/\bhttp\s*403\b/i.test(normalized)) { - return true; - } - if (/\b(jwt|token)\b.*\b(expired|invalid|revoked)\b/i.test(normalized)) { - return true; - } - if (/\b(invalid|expired|missing)\b.*\b(jwt|token|authorization)\b/i.test(normalized)) { - return true; - } - if (normalized.includes("bearer") && normalized.includes("invalid")) { - return true; - } - const authErrorPatterns = [ - "unauthorized", - "forbidden", - "access denied", - "not authenticated", - "authentication failed", - "session expired", - "session invalid", - "login required", - "please login", - "请先登录", - "未授权", - "登录已过期", - "登录失效", - "认证失败", - "token无效", - "token过期" - ]; - return authErrorPatterns.some((pattern) => normalized.includes(pattern)); + return classifyApiError(error).kind === "sessionExpired"; } -export function isLauncherAppUpdateMissing(message: string): boolean { - const normalized = message.toLowerCase(); +/** The backend has no update configured for this platform/channel. */ +export function isLauncherAppUpdateMissing(error: unknown): boolean { + const item = classifyApiError(error); + if (item.fromApi && item.status === 404) { + return true; + } + const normalized = item.raw.toLowerCase(); return ( normalized.includes("launcher app update config not found") || - normalized.includes("http 404") || normalized.includes("not configured") ); } diff --git a/tauri-app/src/locales/en-US.ts b/tauri-app/src/locales/en-US.ts index 591e962..b5ef84e 100644 --- a/tauri-app/src/locales/en-US.ts +++ b/tauri-app/src/locales/en-US.ts @@ -770,7 +770,20 @@ const enUS = { "install.phase.optiFineCompleted": "OptiFine install completed", "install.phase.installingFabric": "Installing fabric {version}", "install.phase.installingForge": "Installing forge {version}", - "install.phase.installingOptiFine": "Installing OptiFine {version}" + "install.phase.installingOptiFine": "Installing OptiFine {version}", + "error.network.proxy": "The proxy refused the connection. Check your system proxy settings.", + "error.source.offline": "Cannot reach the download source {host}. Switch the download source in Settings, or try again later.", + "error.source.timeout": "The download source {host} is not responding. Switch the download source in Settings, or try again later.", + "error.source.dns": "Could not resolve {host}. Check your DNS settings or switch the download source.", + "error.source.tls": "Secure connection to {host} failed. Check your system clock, or any proxy/antivirus that inspects HTTPS traffic.", + "error.source.forbidden": "The download source {host} refused the request. Switch the download source in Settings.", + "error.source.notFound": "{host} does not have a required file. Switch the download source or choose another version.", + "error.source.server": "The download source {host} reported an error (HTTP {status}). Switch the download source or try again later.", + "error.http.forbidden": "You do not have permission to do this.", + "error.catalog.unavailable": "The release service is temporarily unavailable. Please try again in a moment.", + "error.download.integrity": "A downloaded file failed verification. It will be downloaded again on the next attempt.", + "error.disk.full": "The disk is full. Free up space and try again.", + "error.cancelled": "Cancelled." } as const; export default enUS; diff --git a/tauri-app/src/locales/zh-CN.ts b/tauri-app/src/locales/zh-CN.ts index f5824c4..8be1efd 100644 --- a/tauri-app/src/locales/zh-CN.ts +++ b/tauri-app/src/locales/zh-CN.ts @@ -765,7 +765,20 @@ const zhCN: EnUSTranslations = { "install.phase.optiFineCompleted": "OptiFine 安装完成", "install.phase.installingFabric": "正在安装 Fabric {version}", "install.phase.installingForge": "正在安装 Forge {version}", - "install.phase.installingOptiFine": "正在安装 OptiFine {version}" + "install.phase.installingOptiFine": "正在安装 OptiFine {version}", + "error.network.proxy": "代理服务器拒绝了连接,请检查系统代理设置。", + "error.source.offline": "无法连接到下载源 {host}。请在设置中切换下载源,或稍后重试。", + "error.source.timeout": "下载源 {host} 响应超时。请在设置中切换下载源,或稍后重试。", + "error.source.dns": "无法解析 {host} 的地址。请检查 DNS 设置或切换下载源。", + "error.source.tls": "与 {host} 建立安全连接失败。请检查系统时间,或检查代理/杀毒软件是否拦截了 HTTPS 流量。", + "error.source.forbidden": "下载源 {host} 拒绝了请求。请在设置中切换下载源。", + "error.source.notFound": "下载源 {host} 缺少必需的文件。请切换下载源或选择其他版本。", + "error.source.server": "下载源 {host} 返回错误(HTTP {status})。请切换下载源或稍后重试。", + "error.http.forbidden": "没有权限执行此操作。", + "error.catalog.unavailable": "版本服务暂时不可用,请稍后重试。", + "error.download.integrity": "下载的文件校验失败,下次尝试时会重新下载。", + "error.disk.full": "磁盘空间不足,请清理后重试。", + "error.cancelled": "已取消。" } as const; export default zhCN; diff --git a/tauri-app/src/main.tsx b/tauri-app/src/main.tsx index 8faca02..d60f355 100644 --- a/tauri-app/src/main.tsx +++ b/tauri-app/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client"; import { App } from "./App"; import ErrorBoundary from "./components/ErrorBoundary"; import { loadLocale, resolveLocale } from "./i18n"; +import { syncApiLanguage } from "./lib/apiLanguage"; import { loadSettings } from "./utils/launcher"; import "./styles.css"; @@ -21,11 +22,15 @@ function renderApp() { // Preload the active locale chunk before the first render so the synchronous // translator already has its table (no English flash for non-default locales). void (async () => { + const locale = resolveLocale(loadSettings().language); try { - await loadLocale(resolveLocale(loadSettings().language)); + await loadLocale(locale); } catch { // fall back to the static en-US table } + // Before the first render, so the very first API calls already ask the + // backend for the right language. + await syncApiLanguage(locale); renderApp(); })(); diff --git a/tauri-app/src/pages/InstanceSettings.tsx b/tauri-app/src/pages/InstanceSettings.tsx index 6468b63..a883563 100644 --- a/tauri-app/src/pages/InstanceSettings.tsx +++ b/tauri-app/src/pages/InstanceSettings.tsx @@ -5,6 +5,7 @@ import Button from "../components/Button"; import Card from "../components/Card"; import { resolvePresetVersionId } from "../constants"; import { useI18n } from "../i18n"; +import { describeApiError } from "../lib/launcherError"; import { novaVersionIdFor } from "../lib/novaTargets"; import type { Instance, InstanceSectionEntry } from "../types"; @@ -118,7 +119,7 @@ function InstanceSettingsPage({ [section]: { ...prev[section], loading: false, - error: t("instanceFiles.loadFailed", { error: String(error) }) + error: t("instanceFiles.loadFailed", { error: describeApiError(error, t) }) } })); } @@ -137,7 +138,7 @@ function InstanceSettingsPage({ ...prev, [section]: { ...prev[section], - error: t("instanceFiles.openFailed", { error: String(error) }) + error: t("instanceFiles.openFailed", { error: describeApiError(error, t) }) } })); } @@ -160,7 +161,7 @@ function InstanceSettingsPage({ ...prev, [section]: { ...prev[section], - error: t("instanceFiles.deleteFailed", { error: String(error) }) + error: t("instanceFiles.deleteFailed", { error: describeApiError(error, t) }) } })); } @@ -182,7 +183,7 @@ function InstanceSettingsPage({ ...prev, mods: { ...prev.mods, - error: t("instanceFiles.toggleFailed", { error: String(error) }) + error: t("instanceFiles.toggleFailed", { error: describeApiError(error, t) }) } })); } diff --git a/tauri-app/src/pages/Monitor.tsx b/tauri-app/src/pages/Monitor.tsx index 571b11a..5f777f0 100644 --- a/tauri-app/src/pages/Monitor.tsx +++ b/tauri-app/src/pages/Monitor.tsx @@ -5,6 +5,7 @@ import { memo, startTransition, useCallback, useEffect, useLayoutEffect, useRef, import Card from "../components/Card"; import TitleBar from "../components/TitleBar"; import { useI18n } from "../i18n"; +import { describeApiError } from "../lib/launcherError"; import type { GameRuntimeStats, UiLogPollResult } from "../types"; import { formatDuration, nowMs, parseIntSafe, prefix } from "../utils/launcher"; @@ -146,7 +147,7 @@ function MonitorPage({ params }: MonitorPageProps) { } } catch (error) { if (!active) return; - setStatus(t("monitor.runtimePollingFailed", { error: String(error) })); + setStatus(t("monitor.runtimePollingFailed", { error: describeApiError(error, t) })); } finally { runtimePollingRef.current = false; } @@ -214,7 +215,7 @@ function MonitorPage({ params }: MonitorPageProps) { setStatus(terminated ? t("monitor.stopSent") : t("monitor.processExited")); return terminated; } catch (error) { - setStatus(t("monitor.stopFailed", { error: String(error) })); + setStatus(t("monitor.stopFailed", { error: describeApiError(error, t) })); return false; } finally { setStopping(false); diff --git a/tauri-app/src/pages/Settings.tsx b/tauri-app/src/pages/Settings.tsx index 2b97aae..8f08c46 100644 --- a/tauri-app/src/pages/Settings.tsx +++ b/tauri-app/src/pages/Settings.tsx @@ -37,6 +37,7 @@ import LiquidGlass from "../components/LiquidGlass"; import Select from "../components/Select"; import { VISUAL_PROFILE_PRESETS, resolveVisualProfile } from "../constants"; import { LOCALE_OPTIONS, useI18n } from "../i18n"; +import { describeApiError } from "../lib/launcherError"; import type { BackgroundSource, BlurMode, @@ -171,7 +172,7 @@ function SettingsPage({ } } } catch (error) { - setGameDirError(t("settings.gameDirPickerFailed", { error: String(error) })); + setGameDirError(t("settings.gameDirPickerFailed", { error: describeApiError(error, t) })); } } @@ -238,7 +239,7 @@ function SettingsPage({ if (backgroundAccentRequestRef.current !== requestId) { return; } - setAccentError(t("settings.backgroundAccentFailed", { error: String(error) })); + setAccentError(t("settings.backgroundAccentFailed", { error: describeApiError(error, t) })); } finally { if (backgroundAccentRequestRef.current === requestId) { setBackgroundAccentLoading(false); @@ -354,7 +355,7 @@ function SettingsPage({ backgroundSource: "system", backgroundImage: "" }); - setBackgroundError(t("settings.backgroundSystemLoadFailed", { error: String(error) })); + setBackgroundError(t("settings.backgroundSystemLoadFailed", { error: describeApiError(error, t) })); } return; } diff --git a/tauri-app/tsconfig.json b/tauri-app/tsconfig.json index af52c59..fd1b7c2 100644 --- a/tauri-app/tsconfig.json +++ b/tauri-app/tsconfig.json @@ -15,5 +15,8 @@ "@/*": ["src/*"] } }, - "include": ["src"] + "include": ["src"], + // Node test-runner files (npm test): run through Node type stripping, which + // needs explicit .ts imports and node: modules this browser config lacks. + "exclude": ["src/**/*.test.ts"] } From 02392b3fa25b18c23bd10e2c1927fc6f96a1e454 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:38:20 -0700 Subject: [PATCH 16/24] fix(launcher): one launch path with quick launch, offline fallback and cancel Home, instances, servers and quick join now share launchInstance, so the single-instance check, the release gate and the Extreme branch apply everywhere; Extreme can join a server through launch_native_app. An installed instance no longer needs the network to start: Forge/OptiFine lookups, content auto-updates and preset package updates are best-effort, a missing catalog is retried once and otherwise the installed build starts, and a network failure offers "launch without updating" when the files are complete. A quick-launch button skips update checks entirely. The prepare dialog can be cancelled (cancel_install plus checkpoints between steps), errors are shown once instead of in two stacked modals, and check-instance no longer hides Forge/OptiFine progress. Nova package status is written under its per-version key too, the dead post-login package sync is gone, and the log poll keeps a fixed 500ms rhythm. Dialogs get role=dialog, focus trapping and Escape. --- tauri-app/src/App.tsx | 920 ++++++++++-------- tauri-app/src/components/InstallDialog.tsx | 9 +- .../src/components/LaunchErrorDialog.tsx | 11 +- .../src/components/LaunchPrepareDialog.tsx | 33 +- tauri-app/src/components/ModalFrame.tsx | 86 ++ tauri-app/src/components/PageRouter.tsx | 6 +- tauri-app/src/lib/presetPackage.ts | 13 +- tauri-app/src/lib/system.ts | 6 +- tauri-app/src/locales/en-US.ts | 21 +- tauri-app/src/locales/zh-CN.ts | 21 +- tauri-app/src/pages/Home.tsx | 28 +- tauri-app/src/styles.css | 1 + tauri-app/src/types.ts | 18 + tauri-app/src/utils/launcher.ts | 2 + 14 files changed, 727 insertions(+), 448 deletions(-) create mode 100644 tauri-app/src/components/ModalFrame.tsx diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index aae3767..b7c1db0 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -41,9 +41,11 @@ import { isLauncherVersionCompatible } from "./lib/version"; import { describeApiError, isAuthExpiredError, + isCancelledError, + isNetworkError, normalizeLoginError } from "./lib/launcherError"; -import { notifyError, notifyWarning } from "./lib/toast"; +import { notifyError, notifyInfo, notifyWarning } from "./lib/toast"; import { parseMinecraftAccounts, resolveMinecraftLaunchIdentity, @@ -104,6 +106,7 @@ import type { Loader, MinecraftAccount, ModpackInstallResult, + OfflineLaunchReadiness, OptiFineInstallResult, OptiFineVersion, Page, @@ -250,6 +253,10 @@ function Launcher() { const launchingInstanceRef = useRef(null); const installDialogRef = useRef(null); const launchPrepareDialogRef = useRef(null); + // Launch sessions the user cancelled; checked between prepare steps. + const cancelledLaunchSessionsRef = useRef(new Set()); + // What to relaunch when the user accepts the offline fallback after a network failure. + const offlineRetryRef = useRef<{ target: Instance; serverAddress?: string } | null>(null); const t = useMemo(() => createTranslator(settings.language), [settings.language]); @@ -541,6 +548,15 @@ function Launcher() { windowVisible && (launchingInstanceId !== null || installDialog !== null || launchPrepareDialog !== null); + // The poll loop reads the latest IPC handlers through this ref so its effect + // only depends on whether polling is needed. Depending on the dialog state + // restarted the loop (and fired an immediate poll) on every progress event, + // turning the 500ms poll into back-to-back IPC during downloads (launcher-ui-7). + const uiLogHandlersRef = useRef({ applyInstallIpc, applyLaunchPrepareIpc, applyLaunchPrepareLog }); + useEffect(() => { + uiLogHandlersRef.current = { applyInstallIpc, applyLaunchPrepareIpc, applyLaunchPrepareLog }; + }); + useEffect(() => { if (!shouldPollUiLogs) { return; @@ -555,6 +571,7 @@ function Launcher() { if (!active) return; logCursorRef.current = out.nextSeq; if (out.entries.length === 0) return; + const handlers = uiLogHandlersRef.current; for (const entry of out.entries) { if (launchingInstanceRef.current) { const launchProgress = parseLaunchProgressLog(entry.message); @@ -564,12 +581,12 @@ function Launcher() { setLaunchProgressPercent(launchProgress.percent); } } - applyLaunchPrepareLog(entry.message); + handlers.applyLaunchPrepareLog(entry.message); } const ipc = parseInstallIpc(entry.message); if (ipc) { - applyInstallIpc(ipc); - applyLaunchPrepareIpc(ipc); + handlers.applyInstallIpc(ipc); + handlers.applyLaunchPrepareIpc(ipc); } } } catch { @@ -584,7 +601,7 @@ function Launcher() { active = false; window.clearInterval(timer); }; - }, [shouldPollUiLogs, launchingInstanceId, installDialog, launchPrepareDialog]); + }, [shouldPollUiLogs]); useEffect(() => { if (activeGamePid === null || activeGamePid <= 0) return; @@ -891,32 +908,6 @@ function Launcher() { } } - async function syncPresetLauncherPackages( - versionMap?: LauncherVersionMap | null, - novaMap?: NovaVersionMap | null - ): Promise { - const targetMap = versionMap ?? launcherVersions; - const targetNovaMap = novaMap ?? novaGameVersions; - const presetInstances = instances.filter((item) => { - if (!item.preset || !item.launcherVersionType) return false; - if (item.launcherVersionType === "NOVA") { - return Boolean(resolveNovaEntry(selectedNovaGameVersion, targetNovaMap)); - } - return Boolean(targetMap[item.launcherVersionType]); - }); - if (presetInstances.length === 0) { - return; - } - - for (const instance of presetInstances) { - try { - await ensurePresetModsReady(resolveNovaEffectiveInstance(instance), targetMap, undefined, targetNovaMap); - } catch { - // Keep sync best-effort to avoid blocking login flow. - } - } - } - async function refreshPresetPackageStatuses( versionMapOverride?: LauncherVersionMap | null, novaMapOverride?: NovaVersionMap | null @@ -932,10 +923,7 @@ function Launcher() { const presetAccess = resolvePresetAccessState(instance, targetMap, novaEntry); if (presetAccess.state === "pending-release") { return createPresetPackageStatus(presetAccess.state, { - versionTag: presetAccess.versionTag ?? null, - targetVersionTag: presetAccess.versionTag ?? null, - changelog: presetAccess.changelog ?? null, - lastError: presetAccess.lastError ?? null + lastError: t("launch.error.notReleased", { name: productLabel(effective) }) }); } const expected = @@ -1046,98 +1034,6 @@ function Launcher() { return false; } - async function launchToServer(serverAddress: string): Promise { - if (!current) { - setStatus(t("servers.noInstance")); - return; - } - if (ensureMandatoryLauncherUpdate()) { - return; - } - if (!requireMinecraftAccountForLaunch()) { - return; - } - - const sessionId = createSessionId(); - openLaunchPrepare(current, sessionId); - markLaunchPreparePhase("check-instance", "running", "prepare", t("launch.progress.checkInstance")); - setLaunchError(null); - setLaunchingInstanceId(current.id); - setLaunchProgressPercent(0); - setLaunchProgressText(t("launch.progress.checkInstance")); - setBusy(true); - setStatus(t("app.status.launching", { name: current.name })); - let launchResult: LaunchExecutionResult | null = null; - try { - markLaunchPreparePhase("login", "running", "prepare", t("launch.progress.login")); - setLaunchProgressText(t("launch.progress.login")); - const readyAccount = await ensureMinecraftAccountReadyForLaunch(mcAccounts.currentAccount, sessionId); - markLaunchPreparePhase("login", "done", "complete", t("launch.progress.loginCompleted")); - const launchIdentity = resolveMinecraftLaunchIdentity(readyAccount); - markLaunchPreparePhase("check-instance", "running", "prepare", t("launch.progress.checkInstance")); - setLaunchProgressPercent(Math.round((1 / LAUNCH_PREPARE_STEPS) * 100)); - setLaunchProgressText(t("launch.progress.checkInstance")); - const prepared = await ensureInstanceReadyForLaunch(resolveNovaEffectiveInstance(current), sessionId); - markLaunchPreparePhase("check-instance", "done", "complete", t("launch.progress.checkInstance")); - - markLaunchPreparePhase("runtime", "running", "prepare", t("launch.progress.prepareRuntime")); - setLaunchProgressPercent(Math.round((2 / LAUNCH_PREPARE_STEPS) * 100)); - setLaunchProgressText(t("launch.progress.prepareRuntime")); - const jdk = await ensureJdk(settings.gameDir, prepared.versionId, settings.downloadThreads); - markLaunchPreparePhase("runtime", "done", "complete", t("launch.progress.prepareRuntime")); - - markLaunchPreparePhase("launch", "running", "prepare", t("launch.progress.buildCommand")); - setLaunchProgressPercent(Math.round((3 / LAUNCH_PREPARE_STEPS) * 100)); - setLaunchProgressText(t("launch.progress.buildCommand")); - launchResult = await invoke("launch_vanilla", { - gameDir: settings.gameDir, - versionId: prepared.versionId, - playerName: launchIdentity.playerName, - uuid: launchIdentity.uuid, - accessToken: launchIdentity.accessToken, - userType: launchIdentity.userType, - authXuid: launchIdentity.xuid, - maxMemoryMb: settings.maxMemoryMb, - javaPath: jdk.javaPath, - downloadSource: settings.downloadSource, - waitForExit: false, - serverAddress: serverAddress, - fpsmasterToken: launcherAuth?.token ?? null, - useForge: prepared.launcherVersionType === "EDGE" ? prepared.useForge !== false : undefined, - useOptiFine: - prepared.launcherVersionType === "EDGE" - ? prepared.useOptiFine !== false - : Boolean(prepared.optiFineVersion) - }); - setLaunchProgressPercent(100); - setLaunchProgressText(t("launch.progress.startingGame")); - markLaunchPreparePhase("launch", "done", "complete", t("launch.progress.startingGame")); - } catch (error) { - const errorText = describeApiError(error, t); - setStatus(t("app.status.launchFailed", { error: errorText })); - setLaunchError(errorText); - failLaunchPrepare(errorText); - setBusy(false); - setLaunchingInstanceId(null); - return; - } - - if (!launchResult) { - const errorText = t("app.status.launchMissingResult"); - setStatus(errorText); - setLaunchError(errorText); - failLaunchPrepare(errorText); - setBusy(false); - setLaunchingInstanceId(null); - return; - } - - await openMonitorForLaunch(launchResult.pid, current.name); - completeLaunchPrepare(); - setBusy(false); - setLaunchingInstanceId(null); - } - // Opens the runtime monitor as its own webview window and wires the launcher-side // bookkeeping (activeGamePid, monitorWindowOpen). Failure to open the window is // non-fatal: the game keeps running and the launcher reports it via status. @@ -1223,10 +1119,9 @@ function Launcher() { id: result.user?.id ?? null }); void launcherData.refreshHome(true, normalizedToken); + // Preset packages are downloaded when the user launches them, never + // silently in the background after sign-in. const refresh = await refreshLauncherVersions(false, normalizedToken); - if (!refresh.error && refresh.map) { - void syncPresetLauncherPackages(refresh.map, refresh.novaMap); - } setPage("home"); // Sign-in itself succeeded, so the login page unmounts right here and can // never render this error. Raise it as a toast instead of returning it @@ -1267,31 +1162,67 @@ function Launcher() { setStatus(t("login.tip.signInToContinue")); } + // Status keys a preset package status is stored under: the instance id (home, + // launch flow) and, for Nova, the per-game-version row key the instances page + // reads (launcher-ui-6). + function setPresetStatus(instance: Instance, update: (prev: PresetPackageStatus | undefined) => PresetPackageStatus) { + setPresetPackageStatuses((prev) => { + const next = { ...prev }; + const keys = + instance.launcherVersionType === "NOVA" + ? [instance.id, `nova:${instance.baseVersion}`] + : [instance.id]; + for (const key of keys) { + next[key] = update(prev[key]); + } + return next; + }); + } + + // Tag of the preset package already on disk, or null when none is installed. + async function installedPresetPackageTag(instance: Instance): Promise { + try { + const state = await invoke("get_launcher_package_state", { + gameDir: settings.gameDir, + versionId: instance.versionId + }); + return state.installed ? state.versionTag ?? "" : null; + } catch (error) { + console.warn("[preset-mods] cannot read installed package state:", error); + return null; + } + } + + function markPresetInstalledFallback(instance: Instance, installedTag: string) { + setPresetStatus(instance, () => + createPresetPackageStatus("ready", { + versionTag: installedTag || null, + installedVersionTag: installedTag || null, + targetVersionTag: installedTag || null + }) + ); + } + async function ensurePresetModsReady( instance: Instance, - versionMapOverride?: LauncherVersionMap | null, - launchSessionId?: string, - novaMapOverride?: NovaVersionMap | null + options: { versionMap?: LauncherVersionMap | null; novaMap?: NovaVersionMap | null; sessionId?: string } = {} ) { if (!instance.preset || !instance.launcherVersionType) return; + const launchSessionId = options.sessionId; // For Nova, `instance` is the game-version-specialised copy (baseVersion = picked MC version, // versionId = FPSMaster-Nova-). Resolve everything against that game version. const isNova = instance.launcherVersionType === "NOVA"; - const novaEntry = isNova ? resolveNovaEntry(instance.baseVersion, novaMapOverride) : null; + const versionMap = options.versionMap ?? launcherVersions; + const novaEntry = isNova ? resolveNovaEntry(instance.baseVersion, options.novaMap) : null; - const presetAccess = resolvePresetAccessState(instance, versionMapOverride ?? launcherVersions, novaEntry); + const presetAccess = resolvePresetAccessState(instance, versionMap, novaEntry); if (presetAccess.state === "pending-release") { - setPresetPackageStatuses((prev) => ({ - ...prev, - [instance.id]: createPresetPackageStatus("pending-release", { - versionTag: presetAccess.versionTag ?? null, - targetVersionTag: presetAccess.versionTag ?? null, - changelog: presetAccess.changelog ?? null, - lastError: presetAccess.lastError ?? null + setPresetStatus(instance, () => + createPresetPackageStatus("pending-release", { + lastError: t("launch.error.notReleased", { name: productLabel(instance) }) }) - })); - setStatus(presetAccess.lastError ?? t("app.status.authRequiredForPreset")); + ); return; } @@ -1301,10 +1232,7 @@ function Launcher() { return; } - let targetVersion = isNova - ? novaEntry - : versionMapOverride?.[instance.launcherVersionType] ?? - launcherVersions[instance.launcherVersionType]; + let targetVersion = isNova ? novaEntry : versionMap[instance.launcherVersionType]; if (!targetVersion) { const refresh = await refreshLauncherVersions(true, token); if (refresh.error) { @@ -1312,31 +1240,10 @@ function Launcher() { // already has a mod package installed on disk, a catalog hiccup (backend redeploy, a // transient 404, offline) must not block launching what's already installed — degrade // to the installed version instead of aborting the launch with a raw backend error. - let installedTag: string | null = null; - try { - const state = await invoke("get_launcher_package_state", { - gameDir: settings.gameDir, - versionId: instance.versionId - }); - if (state.installed) { - installedTag = state.versionTag ?? null; - } - } catch { - // fall through to throw below - } + const installedTag = await installedPresetPackageTag(instance); if (installedTag !== null) { - console.warn( - "[preset-mods] release catalog unavailable, launching installed package:", - refresh.error - ); - setPresetPackageStatuses((prev) => ({ - ...prev, - [instance.id]: createPresetPackageStatus("ready", { - versionTag: installedTag, - installedVersionTag: installedTag, - targetVersionTag: installedTag - }) - })); + console.warn("[preset-mods] release catalog unavailable, launching installed package:", refresh.error); + markPresetInstalledFallback(instance, installedTag); return; } throw new Error(refresh.error); @@ -1347,10 +1254,7 @@ function Launcher() { } if (!targetVersion) { - setPresetPackageStatuses((prev) => ({ - ...prev, - [instance.id]: createPresetPackageStatus("missing") - })); + setPresetStatus(instance, () => createPresetPackageStatus("missing")); return; } @@ -1358,31 +1262,34 @@ function Launcher() { const errorText = t("app.status.launcherUpgradeRequired", { required: targetVersion.minLauncherVersion ?? "-" }); - setPresetPackageStatuses((prev) => ({ - ...prev, - [instance.id]: createPresetPackageStatus("error", { + setPresetStatus(instance, () => + createPresetPackageStatus("error", { versionTag: targetVersion.versionName, targetVersionTag: targetVersion.versionName, changelog: targetVersion.changelog, lastError: errorText }) - })); - setStatus( - errorText ); + setStatus(errorText); return; } - setPresetPackageStatuses((prev) => ({ - ...prev, - [instance.id]: createPresetPackageStatus("syncing", { + setPresetStatus(instance, (prev) => + createPresetPackageStatus("syncing", { versionTag: targetVersion.versionName, - installedVersionTag: prev[instance.id]?.installedVersionTag ?? prev[instance.id]?.versionTag ?? null, + installedVersionTag: prev?.installedVersionTag ?? prev?.versionTag ?? null, targetVersionTag: targetVersion.versionName, changelog: targetVersion.changelog, lastError: null }) - })); + ); + const readyStatus = () => + createPresetPackageStatus("ready", { + versionTag: targetVersion.versionName, + installedVersionTag: targetVersion.versionName, + targetVersionTag: targetVersion.versionName, + changelog: targetVersion.changelog + }); setStatus(t("app.status.autoInstallMods", { name: instance.name })); // Edge with Forge disabled skips the mods/-jar install entirely and instead installs the // Forge-free AOT package (fpsmaster-runtime.jar + mappings.tiny only — never Mojang jars) @@ -1393,9 +1300,7 @@ function Launcher() { if (isEdgeNoForge) { const aotDownloadUrl = targetVersion.aotDownloadUrl?.trim(); if (!aotDownloadUrl) { - throw new Error( - "Edge AOT package URL is missing (aotDownloadUrl). This Edge release does not include a Forge-free package yet." - ); + throw new Error(t("launch.error.edgeAotMissing")); } const aotResult = await invoke("install_edge_aot_package", { gameDir: settings.gameDir, @@ -1406,15 +1311,7 @@ function Launcher() { versionTag: targetVersion.versionName, ipcSession: launchSessionId }); - setPresetPackageStatuses((prev) => ({ - ...prev, - [instance.id]: createPresetPackageStatus("ready", { - versionTag: targetVersion.versionName, - installedVersionTag: targetVersion.versionName, - targetVersionTag: targetVersion.versionName, - changelog: targetVersion.changelog - }) - })); + setPresetStatus(instance, readyStatus); setStatus( aotResult.skipped ? t("app.status.autoInstallModsUpToDate") @@ -1450,35 +1347,38 @@ function Launcher() { ipcSession: launchSessionId }); } catch (fabricApiError) { + if (isCancelledError(fabricApiError)) throw fabricApiError; console.warn("[preset-mods] Fabric API auto-install failed:", fabricApiError); } } - setPresetPackageStatuses((prev) => ({ - ...prev, - [instance.id]: createPresetPackageStatus("ready", { - versionTag: targetVersion.versionName, - installedVersionTag: targetVersion.versionName, - targetVersionTag: targetVersion.versionName, - changelog: targetVersion.changelog - }) - })); + setPresetStatus(instance, readyStatus); setStatus( result.skipped ? t("app.status.autoInstallModsUpToDate") : t("app.status.autoInstallModsDone", { count: result.installedFiles }) ); } catch (error) { + // An update that cannot be downloaded must not block the package already + // installed (same reasoning as the catalog fallback above). + if (isNetworkError(error)) { + const installedTag = isEdgeNoForge ? null : await installedPresetPackageTag(instance); + if (installedTag !== null) { + console.warn("[preset-mods] package update unavailable, launching installed package:", error); + notifyWarning(t("launch.warning.updateSkipped", { name: instance.name }), t("launch.offline.title")); + markPresetInstalledFallback(instance, installedTag); + return; + } + } const errorText = describeApiError(error, t); - setPresetPackageStatuses((prev) => ({ - ...prev, - [instance.id]: createPresetPackageStatus("error", { + setPresetStatus(instance, (prev) => + createPresetPackageStatus("error", { versionTag: targetVersion.versionName, - installedVersionTag: prev[instance.id]?.installedVersionTag ?? null, + installedVersionTag: prev?.installedVersionTag ?? null, targetVersionTag: targetVersion.versionName, changelog: targetVersion.changelog, lastError: errorText }) - })); + ); throw error; } } @@ -1505,8 +1405,18 @@ function Launcher() { return { items, updates }; } + // Keeps Modrinth/CurseForge content current before a launch. Best-effort: + // a failed check or download keeps the installed file and the launch goes on + // (launcher-ui-3); only a user cancel stops it. async function ensureManagedContentUpToDate(instance: Instance): Promise { - const { items, updates } = await collectInstanceContentUpdateState(instance); + let items: InstalledContentItem[]; + let updates: InstalledContentUpdate[]; + try { + ({ items, updates } = await collectInstanceContentUpdateState(instance)); + } catch (error) { + console.warn("[content] update check failed, launching installed content:", error); + return; + } if (items.length === 0 || updates.length === 0) { return; } @@ -1530,35 +1440,67 @@ function Launcher() { } setStatus(t("app.status.autoUpdatingContent", { name: instance.name, count: pendingItems.length })); + let failed = 0; for (const item of pendingItems) { - if (item.source === "curseforge") { - await invoke("install_curseforge_project", { - gameDir: settings.gameDir, - versionId: instance.versionId, - projectId: item.projectId, - projectTitle: item.projectTitle, - projectType: item.contentType, - gameVersion: instance.baseVersion, - loader: instance.loader, - apiKey: settings.curseforgeApiKey - }); - continue; + try { + if (item.source === "curseforge") { + await invoke("install_curseforge_project", { + gameDir: settings.gameDir, + versionId: instance.versionId, + projectId: item.projectId, + projectTitle: item.projectTitle, + projectType: item.contentType, + gameVersion: instance.baseVersion, + loader: instance.loader, + apiKey: settings.curseforgeApiKey + }); + } else { + await invoke("install_modrinth_project", { + gameDir: settings.gameDir, + versionId: instance.versionId, + projectId: item.projectId, + projectTitle: item.projectTitle, + projectType: item.contentType, + gameVersion: instance.baseVersion, + loader: instance.loader + }); + } + } catch (error) { + if (isCancelledError(error)) throw error; + failed += 1; + console.warn(`[content] auto-update of ${item.projectTitle} failed, keeping the installed file:`, error); } + } + if (failed > 0) { + notifyWarning(t("launch.warning.contentUpdateSkipped", { count: failed })); + } + setStatus(t("app.status.autoUpdatedContent", { name: instance.name, count: pendingItems.length - failed })); + } - await invoke("install_modrinth_project", { - gameDir: settings.gameDir, - versionId: instance.versionId, - projectId: item.projectId, - projectTitle: item.projectTitle, - projectType: item.contentType, - gameVersion: instance.baseVersion, - loader: instance.loader - }); + // Starts a download phase in the prepare dialog. check-instance is closed first: + // the dialog follows the first running phase, so leaving it open hid the real + // Forge/OptiFine progress behind a stuck "checking 0%" (launcher-ui-8). + function beginLaunchInstallPhase(key: LaunchPreparePhaseKey, message: string) { + markLaunchPreparePhase("check-instance", "done", "complete", t("launch.progress.checkInstance")); + markLaunchPreparePhase(key, "running", "prepare", message); + } + + // Latest-version lookups for an installed Edge are only an update check: when + // they fail (offline, mirror down) the installed loader keeps working. + async function bestEffortLoaderLookup(key: string, run: () => Promise): Promise { + try { + return await cachedLoaderLookup(key, run); + } catch (error) { + console.warn(`[launch] ${key.split("|")[0]} lookup failed, keeping the installed version:`, error); + return null; } - setStatus(t("app.status.autoUpdatedContent", { name: instance.name, count: pendingItems.length })); } - async function ensureInstanceReadyForLaunch(instance: Instance, launchSessionId?: string): Promise { + async function ensureInstanceReadyForLaunch( + instance: Instance, + launchSessionId?: string, + catalog?: { versionMap: LauncherVersionMap; novaMap: NovaVersionMap } + ): Promise { let workingInstance = instance; // Nova's on-disk versionId is game-version-specific (the caller already specialised the // instance), so use its versionId directly instead of the single fixed preset id. @@ -1642,7 +1584,7 @@ function Launcher() { let edgeInstanceChanged = false; if (useForge) { const optiFineVersions = useOptiFine - ? await cachedLoaderLookup( + ? await bestEffortLoaderLookup( `optifine|${workingInstance.baseVersion}|${workingInstance.loader}|${settings.downloadSource}`, () => invoke("list_optifine_versions", { @@ -1653,9 +1595,12 @@ function Launcher() { }) ) : []; - const selectedOptiFine = selectDefaultEdgeOptiFineVersion(optiFineVersions); - const latestOptiFine = selectedOptiFine?.version ?? ""; - const forgeVersions = await cachedLoaderLookup( + const selectedOptiFine = selectDefaultEdgeOptiFineVersion(optiFineVersions ?? []); + // A failed lookup keeps the installed OptiFine instead of removing it. + const latestOptiFine = optiFineVersions === null + ? workingInstance.optiFineVersion ?? "" + : selectedOptiFine?.version ?? ""; + const forgeVersions = await bestEffortLoaderLookup( `forge|${workingInstance.baseVersion}|${settings.downloadSource}`, () => invoke("list_forge_versions", { @@ -1663,13 +1608,14 @@ function Launcher() { downloadSource: settings.downloadSource }) ); - const latestForgeVersion = forgeVersions[0] ?? ""; + const installedForgeVersion = (workingInstance.loaderVersion ?? "").trim(); + const latestForgeVersion = forgeVersions?.[0] ?? installedForgeVersion; if (!latestForgeVersion) { - throw new Error(`No forge version available for ${workingInstance.baseVersion}`); + throw new Error(t("launch.error.noLoaderVersion", { loader: "Forge", version: workingInstance.baseVersion })); } - if ((workingInstance.loaderVersion ?? "").trim() !== latestForgeVersion) { - markLaunchPreparePhase("forge", "running", "prepare", t("install.phase.installingForge", { version: latestForgeVersion })); - const jdk = await ensureJdk(settings.gameDir, workingInstance.baseVersion, settings.downloadThreads); + if (installedForgeVersion !== latestForgeVersion) { + beginLaunchInstallPhase("forge", t("install.phase.installingForge", { version: latestForgeVersion })); + const jdk = await ensureJdk(settings.gameDir, workingInstance.baseVersion, settings.downloadThreads, launchSessionId); const forge = await invoke("install_forge", { gameDir: settings.gameDir, forgeVersion: latestForgeVersion, @@ -1698,7 +1644,7 @@ function Launcher() { markLaunchPreparePhase("forge", "done", "complete", t("install.phase.loaderCompleted")); } if (useOptiFine && latestOptiFine && (workingInstance.optiFineVersion !== latestOptiFine || edgeInstanceChanged)) { - markLaunchPreparePhase("optifine", "running", "prepare", t("install.phase.installingOptiFine", { version: latestOptiFine })); + beginLaunchInstallPhase("optifine", t("install.phase.installingOptiFine", { version: latestOptiFine })); const optiFine = await invoke("install_optifine", { gameDir: settings.gameDir, versionId: workingInstance.versionId, @@ -1728,7 +1674,7 @@ function Launcher() { edgeInstanceChanged = true; } if (useOptiFine) { - const optiFineVersions = await cachedLoaderLookup( + const optiFineVersions = await bestEffortLoaderLookup( `optifine|${workingInstance.baseVersion}|${workingInstance.loader}|${settings.downloadSource}`, () => invoke("list_optifine_versions", { @@ -1738,10 +1684,12 @@ function Launcher() { downloadSource: settings.downloadSource }) ); - const selectedOptiFine = selectDefaultEdgeOptiFineVersion(optiFineVersions); - const latestOptiFine = selectedOptiFine?.version ?? ""; + const selectedOptiFine = selectDefaultEdgeOptiFineVersion(optiFineVersions ?? []); + const latestOptiFine = optiFineVersions === null + ? workingInstance.optiFineVersion ?? "" + : selectedOptiFine?.version ?? ""; if (latestOptiFine && workingInstance.optiFineVersion !== latestOptiFine) { - markLaunchPreparePhase("optifine", "running", "prepare", t("install.phase.installingOptiFine", { version: latestOptiFine })); + beginLaunchInstallPhase("optifine", t("install.phase.installingOptiFine", { version: latestOptiFine })); const optiFine = await invoke("install_optifine", { gameDir: settings.gameDir, versionId: workingInstance.versionId, @@ -1781,7 +1729,7 @@ function Launcher() { }); markLaunchPreparePhase("verify", "done", "complete", t("launch.progress.verifyCompleted")); markLaunchPreparePhase("mods", "running", "prepare", t("launch.progress.syncMods")); - await ensurePresetModsReady(workingInstance, undefined, launchSessionId); + await ensurePresetModsReady(workingInstance, { ...catalog, sessionId: launchSessionId }); await ensureManagedContentUpToDate(workingInstance); markLaunchPreparePhase("mods", "done", "complete", t("launch.progress.syncModsCompleted")); return workingInstance; @@ -1823,7 +1771,7 @@ function Launcher() { nextLoaderVersion = loaderVersions[0] ?? ""; } if (!nextLoaderVersion) { - throw new Error(`No fabric loader version available for ${workingInstance.baseVersion}`); + throw new Error(t("launch.error.noLoaderVersion", { loader: "Fabric", version: workingInstance.baseVersion })); } markLaunchPreparePhase("fabric", "running", "prepare", t("install.phase.installingFabric", { version: nextLoaderVersion })); const fabric = await invoke("install_fabric", { @@ -1861,10 +1809,10 @@ function Launcher() { nextLoaderVersion = forgeVersions[0] ?? ""; } if (!nextLoaderVersion) { - throw new Error(`No forge version available for ${workingInstance.baseVersion}`); + throw new Error(t("launch.error.noLoaderVersion", { loader: "Forge", version: workingInstance.baseVersion })); } markLaunchPreparePhase("forge", "running", "prepare", t("install.phase.installingForge", { version: nextLoaderVersion })); - const jdk = await ensureJdk(settings.gameDir, workingInstance.baseVersion, settings.downloadThreads); + const jdk = await ensureJdk(settings.gameDir, workingInstance.baseVersion, settings.downloadThreads, sessionId); const forge = await invoke("install_forge", { gameDir: settings.gameDir, forgeVersion: nextLoaderVersion, @@ -1930,7 +1878,7 @@ function Launcher() { prev.map((item) => (item.id === updatedInstance.id ? updatedInstance : item)) ); markLaunchPreparePhase("mods", "running", "prepare", t("launch.progress.syncMods")); - await ensurePresetModsReady(updatedInstance, undefined, sessionId); + await ensurePresetModsReady(updatedInstance, { ...catalog, sessionId }); await ensureManagedContentUpToDate(updatedInstance); markLaunchPreparePhase("mods", "done", "complete", t("launch.progress.syncModsCompleted")); setStatus(t("app.status.autoInstallCompleted", { name: updatedInstance.name })); @@ -1938,229 +1886,359 @@ function Launcher() { return updatedInstance; } - async function launchTarget(target: Instance) { + type LaunchOptions = { + /** Join this server right after the game starts. */ + serverAddress?: string; + /** Skip every update check and launch what is installed (falls back to a full prepare if files are missing). */ + quick?: boolean; + }; + + // On-disk version id of an instance: presets live under their fixed id + // (Nova under its per-game-version id), custom instances under their own. + function localVersionIdFor(instance: Instance): string { + if (instance.preset && instance.launcherVersionType !== "NOVA") { + return resolvePresetVersionId(instance.id) ?? instance.versionId; + } + return instance.versionId; + } + + async function readOfflineReadiness(instance: Instance): Promise { + try { + return await invoke("get_offline_launch_readiness", { + gameDir: settings.gameDir, + versionId: localVersionIdFor(instance) + }); + } catch (error) { + console.warn("[launch] offline readiness check failed:", error); + return null; + } + } + + function productLabel(instance: Instance): string { + if (instance.launcherVersionType === "EXTREME") return "Extreme"; + if (instance.launcherVersionType === "NOVA") return `Nova ${instance.baseVersion}`; + return instance.name; + } + + // Release-catalog gate for Nova / Extreme (Edge is always offered). A missing + // entry only means "not released" once the catalog actually loaded: when the + // fetch failed, retry it once and otherwise launch what is installed instead of + // claiming the product does not exist (launcher-ui-4). + async function resolveLaunchGate(target: Instance): Promise< + | { ok: true; catalogUnavailable: boolean; versionMap: LauncherVersionMap; novaMap: NovaVersionMap } + | { ok: false; error: string } + > { + const novaEntryOf = (map: NovaVersionMap) => + target.launcherVersionType === "NOVA" ? map[target.baseVersion] ?? null : null; + if (resolvePresetAccessState(target, launcherVersions, novaEntryOf(novaGameVersions)).state === "ok") { + return { ok: true, catalogUnavailable: false, versionMap: launcherVersions, novaMap: novaGameVersions }; + } + const refresh = await refreshLauncherVersions(true); + if (!refresh.error && refresh.map) { + const novaMap = refresh.novaMap ?? {}; + if (resolvePresetAccessState(target, refresh.map, novaEntryOf(novaMap)).state === "ok") { + return { ok: true, catalogUnavailable: false, versionMap: refresh.map, novaMap }; + } + return { ok: false, error: t("launch.error.notReleased", { name: productLabel(target) }) }; + } + const readiness = await readOfflineReadiness(target); + if (readiness?.ready) { + notifyWarning(t("launch.warning.catalogUnavailable"), t("launch.offline.title")); + return { ok: true, catalogUnavailable: true, versionMap: launcherVersions, novaMap: novaGameVersions }; + } + return { + ok: false, + error: t("launch.error.catalogUnavailable", { error: refresh.error ?? t("error.unknown") }) + }; + } + + // Throws the downloader's own cancellation error at a checkpoint once the user + // cancelled, so steps that cannot be interrupted midway still stop afterwards. + function throwIfLaunchCancelled(sessionId: string) { + if (cancelledLaunchSessionsRef.current.has(sessionId)) { + throw new Error("Installation cancelled"); + } + } + + function endLaunch() { + setBusy(false); + setLaunchingInstanceId(null); + } + + // The single launch entry point (home, instances, servers, quick join): one + // place for the single-instance check, the release gate, the Extreme branch + // and offline fallback (launcher-ui-1). + async function launchInstance(rawTarget: Instance, options: LaunchOptions = {}) { if (ensureMandatoryLauncherUpdate()) { return; } if (!requireMinecraftAccountForLaunch()) { return; } - const presetAccess = resolvePresetAccessState( - target, - launcherVersions, - target.launcherVersionType === "NOVA" ? resolveNovaEntry(selectedNovaGameVersion) : null - ); - if (presetAccess.state === "pending-release") { - const errorText = presetAccess.lastError ?? t("app.status.authRequiredForPreset"); - setLaunchError(errorText); - setStatus(errorText); + if (launchingInstanceRef.current !== null) { + notifyInfo(t("launch.info.alreadyLaunching")); return; } + const target = resolveNovaEffectiveInstance(rawTarget); if (activeGamePid !== null && activeGamePid > 0) { try { const runtime = await invoke("poll_game_runtime", { pid: activeGamePid }); if (runtime.running) { - setStatus(t("app.status.singleInstanceRunning", { pid: activeGamePid })); + notifyWarning(t("app.status.singleInstanceRunning", { pid: activeGamePid })); return; } } catch { + // The pid is gone; fall through and launch. } setActiveGamePid(null); } - // FPSMaster-Extreme is a native binary, not a Java instance — install and - // launch it through the native-app path instead of the vanilla pipeline. - if (target.launcherVersionType === "EXTREME") { - await launchExtreme(target); - return; - } - const sessionId = createSessionId(); + launchingInstanceRef.current = target.id; openLaunchPrepare(target, sessionId); - markLaunchPreparePhase("check-instance", "running", "prepare", t("launch.progress.checkInstance")); setLaunchError(null); setLaunchingInstanceId(target.id); setLaunchProgressPercent(0); setLaunchProgressText(t("launch.progress.checkInstance")); setBusy(true); setStatus(t("app.status.launching", { name: target.name })); - let launchResult: LaunchExecutionResult | null = null; + + const gate = await resolveLaunchGate(target); + if (!gate.ok) { + setStatus(gate.error); + failLaunchPrepare(gate.error); + endLaunch(); + return; + } + + let launchResult: LaunchExecutionResult; try { markLaunchPreparePhase("login", "running", "prepare", t("launch.progress.login")); setLaunchProgressText(t("launch.progress.login")); const readyAccount = await ensureMinecraftAccountReadyForLaunch(mcAccounts.currentAccount, sessionId); markLaunchPreparePhase("login", "done", "complete", t("launch.progress.loginCompleted")); - const launchIdentity = resolveMinecraftLaunchIdentity(readyAccount); - markLaunchPreparePhase("check-instance", "running", "prepare", t("launch.progress.checkInstance")); - setLaunchProgressPercent(Math.round((1 / LAUNCH_PREPARE_STEPS) * 100)); - setLaunchProgressText(t("launch.progress.checkInstance")); - const prepared = await ensureInstanceReadyForLaunch(resolveNovaEffectiveInstance(target), sessionId); - markLaunchPreparePhase("check-instance", "done", "complete", t("launch.progress.checkInstance")); + throwIfLaunchCancelled(sessionId); - markLaunchPreparePhase("runtime", "running", "prepare", t("launch.progress.prepareRuntime")); - setLaunchProgressPercent(Math.round((2 / LAUNCH_PREPARE_STEPS) * 100)); - setLaunchProgressText(t("launch.progress.prepareRuntime")); - const jdk = await ensureJdk(settings.gameDir, prepared.versionId, settings.downloadThreads); - markLaunchPreparePhase("runtime", "done", "complete", t("launch.progress.prepareRuntime")); - - markLaunchPreparePhase("launch", "running", "prepare", t("launch.progress.buildCommand")); - setLaunchProgressPercent(Math.round((3 / LAUNCH_PREPARE_STEPS) * 100)); - setLaunchProgressText(t("launch.progress.buildCommand")); - launchResult = await invoke("launch_vanilla", { - gameDir: settings.gameDir, - versionId: prepared.versionId, - playerName: launchIdentity.playerName, - uuid: launchIdentity.uuid, - accessToken: launchIdentity.accessToken, - userType: launchIdentity.userType, - authXuid: launchIdentity.xuid, - maxMemoryMb: settings.maxMemoryMb, - javaPath: jdk.javaPath, - downloadSource: settings.downloadSource, - waitForExit: false, - fpsmasterToken: launcherAuth?.token ?? null, - useForge: prepared.launcherVersionType === "EDGE" ? prepared.useForge !== false : undefined, - useOptiFine: - prepared.launcherVersionType === "EDGE" - ? prepared.useOptiFine !== false - : Boolean(prepared.optiFineVersion) - }); + if (target.launcherVersionType === "EXTREME") { + launchResult = await runExtremeLaunch(target, readyAccount, sessionId, gate, options); + } else { + launchResult = await runJavaLaunch(target, readyAccount, sessionId, gate, options); + } setLaunchProgressPercent(100); setLaunchProgressText(t("launch.progress.startingGame")); markLaunchPreparePhase("launch", "done", "complete", t("launch.progress.startingGame")); } catch (error) { + cancelledLaunchSessionsRef.current.delete(sessionId); + if (isCancelledError(error)) { + setStatus(t("launch.info.cancelled")); + notifyInfo(t("launch.info.cancelled")); + setLaunchPrepareDialog(null); + endLaunch(); + return; + } const errorText = describeApiError(error, t); setStatus(t("app.status.launchFailed", { error: errorText })); - setLaunchError(errorText); - failLaunchPrepare(errorText); - setBusy(false); - setLaunchingInstanceId(null); - return; - } - - if (!launchResult) { - const errorText = t("app.status.launchMissingResult"); - setStatus(errorText); - setLaunchError(errorText); + // The prepare dialog already shows the error; a second modal with the + // same text on top of it only had to be dismissed twice (launcher-ui-18). failLaunchPrepare(errorText); - setBusy(false); - setLaunchingInstanceId(null); + if (isNetworkError(error) && !options.quick) { + const readiness = await readOfflineReadiness(target); + if (readiness?.ready) { + offlineRetryRef.current = { target: rawTarget, serverAddress: options.serverAddress }; + setLaunchPrepareDialog((prev) => (prev && prev.sessionId === sessionId ? { ...prev, offlineAvailable: true } : prev)); + } + } + endLaunch(); return; } await openMonitorForLaunch(launchResult.pid, target.name); completeLaunchPrepare(); - setBusy(false); - setLaunchingInstanceId(null); + endLaunch(); } - // Install + launch the native FPSMaster-Extreme client. Bypasses the Java - // vanilla/loader pipeline: downloads the native tarball (install_native_app), - // best-effort extracts vanilla 1.8.9 assets (prepare_extreme_assets), then - // spawns the binary (launch_native_app). See MiniCraft/docs/LAUNCHER_INTEGRATION.md. - async function launchExtreme(target: Instance) { - const sessionId = createSessionId(); - openLaunchPrepare(target, sessionId); - setLaunchError(null); - setLaunchingInstanceId(target.id); - setLaunchProgressPercent(0); - setBusy(true); - setStatus(t("app.status.launching", { name: target.name })); + // Java instances (Edge / Nova / custom). + async function runJavaLaunch( + target: Instance, + account: MinecraftAccount, + sessionId: string, + gate: { catalogUnavailable: boolean; versionMap: LauncherVersionMap; novaMap: NovaVersionMap }, + options: LaunchOptions + ): Promise { + const launchIdentity = resolveMinecraftLaunchIdentity(account); + let prepared: Instance | null = null; + let javaPath: string | null = null; + + // Quick launch / catalog down: use the installed files as they are, with no + // Forge/OptiFine lookups and no content or package update checks. + if (options.quick || gate.catalogUnavailable) { + markLaunchPreparePhase("check-instance", "running", "prepare", t("launch.progress.checkOffline")); + const readiness = await readOfflineReadiness(target); + if (readiness?.ready) { + prepared = { ...target, versionId: readiness.versionId }; + javaPath = readiness.javaPath ?? null; + markLaunchPreparePhase("check-instance", "done", "complete", t("launch.progress.checkOffline")); + } else if (options.quick) { + notifyInfo(t("launch.offline.notReady")); + } + } - let launchResult: LaunchExecutionResult | null = null; - try { - markLaunchPreparePhase("login", "running", "prepare", t("launch.progress.login")); - setLaunchProgressText(t("launch.progress.login")); - const readyAccount = await ensureMinecraftAccountReadyForLaunch(mcAccounts.currentAccount, sessionId); - markLaunchPreparePhase("login", "done", "complete", t("launch.progress.loginCompleted")); + if (!prepared) { + markLaunchPreparePhase("check-instance", "running", "prepare", t("launch.progress.checkInstance")); + setLaunchProgressPercent(Math.round((1 / LAUNCH_PREPARE_STEPS) * 100)); + setLaunchProgressText(t("launch.progress.checkInstance")); + prepared = await ensureInstanceReadyForLaunch(target, sessionId, gate); + markLaunchPreparePhase("check-instance", "done", "complete", t("launch.progress.checkInstance")); + } + throwIfLaunchCancelled(sessionId); - // 1. Resolve the EXTREME distributable from the backend registry. - const token = launcherAuth?.token?.trim() ?? ""; - let targetVersion = launcherVersions.EXTREME; - if (!targetVersion && token) { - const refresh = await refreshLauncherVersions(true, token); - if (refresh.error) throw new Error(refresh.error); - targetVersion = refresh.map?.EXTREME ?? null; - } + markLaunchPreparePhase("runtime", "running", "prepare", t("launch.progress.prepareRuntime")); + setLaunchProgressPercent(Math.round((2 / LAUNCH_PREPARE_STEPS) * 100)); + setLaunchProgressText(t("launch.progress.prepareRuntime")); + if (!javaPath) { + javaPath = (await ensureJdk(settings.gameDir, prepared.versionId, settings.downloadThreads, sessionId)).javaPath; + } + markLaunchPreparePhase("runtime", "done", "complete", t("launch.progress.prepareRuntime")); + throwIfLaunchCancelled(sessionId); + + markLaunchPreparePhase("launch", "running", "prepare", t("launch.progress.buildCommand")); + setLaunchProgressPercent(Math.round((3 / LAUNCH_PREPARE_STEPS) * 100)); + setLaunchProgressText(t("launch.progress.buildCommand")); + return invoke("launch_vanilla", { + gameDir: settings.gameDir, + versionId: prepared.versionId, + playerName: launchIdentity.playerName, + uuid: launchIdentity.uuid, + accessToken: launchIdentity.accessToken, + userType: launchIdentity.userType, + authXuid: launchIdentity.xuid, + maxMemoryMb: settings.maxMemoryMb, + javaPath, + downloadSource: settings.downloadSource, + waitForExit: false, + serverAddress: options.serverAddress, + fpsmasterToken: launcherAuth?.token ?? null, + useForge: prepared.launcherVersionType === "EDGE" ? prepared.useForge !== false : undefined, + useOptiFine: + prepared.launcherVersionType === "EDGE" + ? prepared.useOptiFine !== false + : Boolean(prepared.optiFineVersion) + }); + } + + // FPSMaster-Extreme is a native binary, not a Java instance: install the native + // tarball (install_native_app), best-effort extract vanilla 1.8.9 assets + // (prepare_extreme_assets), then spawn it (launch_native_app). See + // MiniCraft/docs/LAUNCHER_INTEGRATION.md. + async function runExtremeLaunch( + target: Instance, + account: MinecraftAccount, + sessionId: string, + gate: { catalogUnavailable: boolean; versionMap: LauncherVersionMap }, + options: LaunchOptions + ): Promise { + const versionId = localVersionIdFor(target); + markLaunchPreparePhase("check-instance", "running", "prepare", t("launch.progress.checkInstance")); + setLaunchProgressPercent(10); + setLaunchProgressText(t("launch.progress.checkInstance")); + const skipInstall = options.quick || gate.catalogUnavailable + ? Boolean((await readOfflineReadiness(target))?.ready) + : false; + if (!skipInstall) { + const targetVersion = gate.versionMap.EXTREME; if (!targetVersion) { - throw new Error(t("app.status.authRequiredForPreset")); + throw new Error(t("launch.error.notReleased", { name: productLabel(target) })); } if (!isLauncherVersionCompatible(currentLauncherVersion, targetVersion.minLauncherVersion)) { throw new Error( t("app.status.launcherUpgradeRequired", { required: targetVersion.minLauncherVersion ?? "-" }) ); } - - // 2. Download + install the native binary. - markLaunchPreparePhase("check-instance", "running", "prepare", t("launch.progress.checkInstance")); - setLaunchProgressPercent(20); - setLaunchProgressText(t("launch.progress.checkInstance")); - await invoke("install_native_app", { - gameDir: settings.gameDir, - versionId: target.versionId, - downloadUrl: targetVersion.downloadUrl, - versionTag: targetVersion.versionName, - checksum: targetVersion.checksum - }); - markLaunchPreparePhase("check-instance", "done", "complete", t("launch.progress.checkInstance")); - - // 3. Best-effort: extract vanilla 1.8.9 assets to feed the client (§5). If - // no 1.8.9 client jar is present yet, launch without --assets and let the - // client fall back to its own resolution. - markLaunchPreparePhase("runtime", "running", "prepare", t("launch.progress.prepareRuntime")); - setLaunchProgressPercent(55); - setLaunchProgressText(t("launch.progress.prepareRuntime")); - let assetsPath: string | undefined; try { - assetsPath = await invoke("prepare_extreme_assets", { + await invoke("install_native_app", { gameDir: settings.gameDir, - versionId: target.versionId + versionId, + downloadUrl: targetVersion.downloadUrl, + versionTag: targetVersion.versionName, + checksum: targetVersion.checksum, + ipcSession: sessionId }); - } catch { - assetsPath = undefined; + } catch (error) { + // An update that cannot be downloaded must not block the installed build. + if (!isNetworkError(error) || !(await readOfflineReadiness(target))?.ready) { + throw error; + } + console.warn("[launch] Extreme update unavailable, launching the installed build:", error); + notifyWarning(t("launch.warning.updateSkipped", { name: target.name }), t("launch.offline.title")); } - markLaunchPreparePhase("runtime", "done", "complete", t("launch.progress.prepareRuntime")); - - // 4. Spawn the native process (offline username for v1; see §7). - markLaunchPreparePhase("launch", "running", "prepare", t("launch.progress.buildCommand")); - setLaunchProgressPercent(85); - setLaunchProgressText(t("launch.progress.buildCommand")); - const playerName = readyAccount.username; - launchResult = await invoke("launch_native_app", { - gameDir: settings.gameDir, - versionId: target.versionId, - playerName, - assetsPath, - waitForExit: false - }); - setLaunchProgressPercent(100); - setLaunchProgressText(t("launch.progress.startingGame")); - markLaunchPreparePhase("launch", "done", "complete", t("launch.progress.startingGame")); - } catch (error) { - const errorText = describeApiError(error, t); - setStatus(t("app.status.launchFailed", { error: errorText })); - setLaunchError(errorText); - failLaunchPrepare(errorText); - setBusy(false); - setLaunchingInstanceId(null); - return; } + markLaunchPreparePhase("check-instance", "done", "complete", t("launch.progress.checkInstance")); + throwIfLaunchCancelled(sessionId); - if (!launchResult) { - setBusy(false); - setLaunchingInstanceId(null); - return; + // Best-effort: without a 1.8.9 client jar the client resolves assets itself. + markLaunchPreparePhase("runtime", "running", "prepare", t("launch.progress.prepareRuntime")); + setLaunchProgressPercent(70); + setLaunchProgressText(t("launch.progress.prepareRuntime")); + let assetsPath: string | undefined; + try { + assetsPath = await invoke("prepare_extreme_assets", { gameDir: settings.gameDir, versionId }); + } catch (error) { + console.warn("[launch] Extreme assets not prepared:", error); + assetsPath = undefined; } + markLaunchPreparePhase("runtime", "done", "complete", t("launch.progress.prepareRuntime")); + throwIfLaunchCancelled(sessionId); - await openMonitorForLaunch(launchResult.pid, target.name); - completeLaunchPrepare(); - setBusy(false); - setLaunchingInstanceId(null); + markLaunchPreparePhase("launch", "running", "prepare", t("launch.progress.buildCommand")); + setLaunchProgressPercent(90); + setLaunchProgressText(t("launch.progress.buildCommand")); + return invoke("launch_native_app", { + gameDir: settings.gameDir, + versionId, + playerName: account.username, + assetsPath, + serverAddress: options.serverAddress, + waitForExit: false + }); } async function launch() { if (!current) return; - await launchTarget(current); + await launchInstance(current); + } + + async function launchToServer(serverAddress: string) { + if (!current) { + notifyWarning(t("servers.noInstance")); + return; + } + await launchInstance(current, { serverAddress }); + } + + async function quickLaunch() { + if (!current) return; + await launchInstance(current, { quick: true }); + } + + function retryLaunchOffline() { + const retry = offlineRetryRef.current; + offlineRetryRef.current = null; + setLaunchPrepareDialog(null); + if (retry) { + void launchInstance(retry.target, { serverAddress: retry.serverAddress, quick: true }); + } + } + + async function cancelLaunchPrepare() { + const active = launchPrepareDialogRef.current; + if (!active || active.canClose || active.cancelling) return; + cancelledLaunchSessionsRef.current.add(active.sessionId); + setLaunchPrepareDialog((prev) => (prev && prev.sessionId === active.sessionId ? { ...prev, cancelling: true } : prev)); + try { + await invoke("cancel_install", { sessionId: active.sessionId }); + } catch (error) { + console.warn("[launch] cancel_install failed:", error); + } } async function install() { @@ -2290,7 +2368,7 @@ function Launcher() { }; }); - const jdk = await ensureJdk(settings.gameDir, installVersion, settings.downloadThreads); + const jdk = await ensureJdk(settings.gameDir, installVersion, settings.downloadThreads, sessionId); const result = await invoke("install_forge", { gameDir: settings.gameDir, forgeVersion: loaderVersion, @@ -2408,7 +2486,7 @@ function Launcher() { const item = instances.find((entry) => entry.id === id); if (!item) return; if (item.preset) { - setStatus(t("app.status.presetCannotDelete")); + notifyWarning(t("app.status.presetCannotDelete")); return; } if (!window.confirm(t("instances.deleteConfirm", { name: item.name }))) return; @@ -2533,6 +2611,7 @@ function Launcher() { function closeLaunchPrepareDialog() { if (!launchPrepareDialog?.canClose) return; + offlineRetryRef.current = null; setLaunchPrepareDialog(null); } @@ -2700,6 +2779,7 @@ function Launcher() { const stableNavigate = useStableCallback(navigatePage); const stableLaunch = useStableCallback(launch); const stableLaunchToServer = useStableCallback(launchToServer); + const stableQuickLaunch = useStableCallback(quickLaunch); const stableRemoveInstance = useStableCallback(removeInstance); const stableAddOfflineAccount = useStableCallback((username: string) => { mcAccounts.addOffline(username); @@ -2735,7 +2815,7 @@ function Launcher() { target.launcherVersionType === "NOVA" ? buildNovaEffectiveInstance(target, gameVersion || selectedNovaGameVersion || target.baseVersion) : target; - await launchTarget(effective); + await launchInstance(effective); }); const onOpenInstanceSettings = useStableCallback((id: string, gameVersion?: string) => { const target = instances.find((item) => item.id === id); @@ -2793,7 +2873,7 @@ function Launcher() { const onToggleSnapshots = useStableCallback(() => installCtl.setShowSnapshots((value) => !value)); const onToggleOptiFine = useStableCallback(() => { if (installCtl.loader === "fabric") { - setStatus(t("install.optifineFabricConflict")); + notifyWarning(t("install.optifineFabricConflict")); return; } installCtl.setOptiFineEnabled((value) => !value); @@ -2850,7 +2930,9 @@ function Launcher() { launchProgressPercent, launchProgressText, onLaunch: stableLaunch, + onQuickLaunch: stableQuickLaunch, onLaunchToServer: stableLaunchToServer, + gameRunning: activeGamePid !== null && activeGamePid > 0, minecraftAccounts: mcAccounts.accounts, currentMinecraftAccount: mcAccounts.currentAccount, minecraftAccountRequired: minecraftAccountPromptOpen, @@ -2980,7 +3062,13 @@ function Launcher() { )} {authenticated && launchPrepareDialog && launchPrepareDialog.open && ( - + )} {authenticated && launchError && ( diff --git a/tauri-app/src/components/InstallDialog.tsx b/tauri-app/src/components/InstallDialog.tsx index 7f101ef..0e10ee5 100644 --- a/tauri-app/src/components/InstallDialog.tsx +++ b/tauri-app/src/components/InstallDialog.tsx @@ -1,4 +1,5 @@ import Card from "./Card"; +import ModalFrame from "./ModalFrame"; import { useI18n } from "../i18n"; import { formatByteSize, formatEta, phasePercent } from "../lib/launchPrepare"; import type { InstallDialogState, InstallPhaseState, LaunchPrepareItem, LaunchPrepareItemStatus } from "../types"; @@ -13,10 +14,10 @@ export default function InstallDialog({ dialog, onClose, onCancel }: InstallDial const { t } = useI18n(); return ( -
+
-

+

{!dialog.canClose ?

@@ -30,7 +31,7 @@ export default function InstallDialog({ dialog, onClose, onCancel }: InstallDial {dialog.loaderPhase && } {dialog.optiFinePhase && }
- {dialog.errorText !== "" &&
{dialog.errorText}
} + {dialog.errorText !== "" &&
{dialog.errorText}
}
@@ -45,7 +46,7 @@ export default function InstallDialog({ dialog, onClose, onCancel }: InstallDial )}
-
+ ); } diff --git a/tauri-app/src/components/LaunchErrorDialog.tsx b/tauri-app/src/components/LaunchErrorDialog.tsx index c1a5289..e681116 100644 --- a/tauri-app/src/components/LaunchErrorDialog.tsx +++ b/tauri-app/src/components/LaunchErrorDialog.tsx @@ -1,6 +1,7 @@ import { memo } from "react"; import Button from "./Button"; import Card from "./Card"; +import ModalFrame from "./ModalFrame"; import { useI18n } from "../i18n"; type LaunchErrorDialogProps = { @@ -12,9 +13,13 @@ type LaunchErrorDialogProps = { function LaunchErrorDialog({ message, onConfirm }: LaunchErrorDialogProps) { const { t } = useI18n(); return ( -
+ -

{t("launch.error.title")}

+

{t("launch.error.title")}

{t("launch.error.subtitle")}

{message}

@@ -25,7 +30,7 @@ function LaunchErrorDialog({ message, onConfirm }: LaunchErrorDialogProps) {
-
+ ); } diff --git a/tauri-app/src/components/LaunchPrepareDialog.tsx b/tauri-app/src/components/LaunchPrepareDialog.tsx index c0d7ab7..d2fd141 100644 --- a/tauri-app/src/components/LaunchPrepareDialog.tsx +++ b/tauri-app/src/components/LaunchPrepareDialog.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import Card from "./Card"; +import ModalFrame from "./ModalFrame"; import { useI18n } from "../i18n"; import { formatByteSize, formatEta, phasePercent } from "../lib/launchPrepare"; import type { @@ -12,6 +13,8 @@ import type { type LaunchPrepareDialogProps = { dialog: LaunchPrepareDialogState; onClose: () => void; + onCancel: () => void; + onRetryOffline: () => void; }; // Maps every phase onto one of four macro steps used to estimate overall progress: @@ -33,7 +36,7 @@ const MACRO_STEP: Record = { const MACRO_TOTAL = 4; const MAX_VISIBLE_ITEMS = 6; -export default function LaunchPrepareDialog({ dialog, onClose }: LaunchPrepareDialogProps) { +export default function LaunchPrepareDialog({ dialog, onClose, onCancel, onRetryOffline }: LaunchPrepareDialogProps) { const { t } = useI18n(); const activePhase = dialog.phases.find((phase) => phase.status === "running") ?? @@ -59,11 +62,11 @@ export default function LaunchPrepareDialog({ dialog, onClose }: LaunchPrepareDi const hasError = dialog.errorText !== ""; return ( -
+
-

{t("launch.prepare.title", { name: dialog.instanceName })}

+

{t("launch.prepare.title", { name: dialog.instanceName })}

{dialog.versionId || activePhase?.title || t("launch.progress.preparing")}

{t("dialog.session", { id: dialog.sessionId.slice(-6) })} @@ -141,15 +144,29 @@ export default function LaunchPrepareDialog({ dialog, onClose }: LaunchPrepareDi )}
- {hasError &&
{dialog.errorText}
} + {hasError &&
{dialog.errorText}
} + {hasError && dialog.offlineAvailable &&

{t("launch.offline.hint")}

}
- + {dialog.canClose ? ( + <> + {dialog.offlineAvailable && ( + + )} + + + ) : ( + + )}
-
+ ); } diff --git a/tauri-app/src/components/ModalFrame.tsx b/tauri-app/src/components/ModalFrame.tsx new file mode 100644 index 0000000..0caa1ba --- /dev/null +++ b/tauri-app/src/components/ModalFrame.tsx @@ -0,0 +1,86 @@ +import { type ReactNode, useEffect, useRef } from "react"; + +type ModalFrameProps = { + /** id of the element that names the dialog. */ + labelledBy: string; + /** Escape handler; omit while the dialog must stay open (e.g. a running install). */ + onEscape?: () => void; + /** Overlay class: the launcher's existing modal backdrops. */ + overlayClassName?: string; + children: ReactNode; +}; + +const FOCUSABLE = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +// Shared modal semantics (launcher-ui-21): dialog role, initial focus inside, +// Tab kept within the dialog, Escape, and focus returned to the trigger. +export default function ModalFrame({ + labelledBy, + onEscape, + overlayClassName = "modalOverlay", + children +}: ModalFrameProps) { + const rootRef = useRef(null); + const onEscapeRef = useRef(onEscape); + + useEffect(() => { + onEscapeRef.current = onEscape; + }, [onEscape]); + + useEffect(() => { + const root = rootRef.current; + if (!root) return; + const previouslyFocused = document.activeElement as HTMLElement | null; + const focusables = () => Array.from(root.querySelectorAll(FOCUSABLE)); + (focusables()[0] ?? root).focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + if (onEscapeRef.current) { + event.preventDefault(); + onEscapeRef.current(); + } + return; + } + if (event.key !== "Tab") return; + const items = focusables(); + if (items.length === 0) { + event.preventDefault(); + root.focus(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + const active = document.activeElement; + const outside = !root.contains(active); + if (event.shiftKey && (active === first || outside)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (active === last || outside)) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", handleKeyDown, true); + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + if (previouslyFocused?.isConnected) { + previouslyFocused.focus(); + } + }; + }, []); + + return ( +
+ {children} +
+ ); +} diff --git a/tauri-app/src/components/PageRouter.tsx b/tauri-app/src/components/PageRouter.tsx index 8370d43..905c489 100644 --- a/tauri-app/src/components/PageRouter.tsx +++ b/tauri-app/src/components/PageRouter.tsx @@ -71,7 +71,9 @@ export type PageRouterContext = { launchProgressPercent: number | null; launchProgressText: string; onLaunch: () => void; + onQuickLaunch: () => void; onLaunchToServer: (serverAddress: string) => void; + gameRunning: boolean; // minecraft accounts minecraftAccounts: MinecraftAccount[]; currentMinecraftAccount: MinecraftAccount | null; @@ -162,6 +164,8 @@ export default function PageRouter({ ctx }: { ctx: PageRouterContext }) { minecraftAccountRequired={ctx.minecraftAccountRequired} onSelect={ctx.onSelect} onLaunch={ctx.onLaunch} + onQuickLaunch={ctx.onQuickLaunch} + gameRunning={ctx.gameRunning} onLaunchToServer={ctx.onLaunchToServer} onOpenSettings={ctx.onGoSettings} onOpenServers={ctx.onGoServers} @@ -176,7 +180,7 @@ export default function PageRouter({ ctx }: { ctx: PageRouterContext }) { { } } +// `ipcSession` makes the download cancellable through cancel_install(sessionId). export async function ensureJdk( gameDir: string, versionId: string, - downloadThreads: number + downloadThreads: number, + ipcSession?: string ): Promise { - return invoke("ensure_jdk", { gameDir, versionId, downloadThreads }); + return invoke("ensure_jdk", { gameDir, versionId, downloadThreads, ipcSession }); } export async function syncAutostart(enabled: boolean): Promise { diff --git a/tauri-app/src/locales/en-US.ts b/tauri-app/src/locales/en-US.ts index b5ef84e..1fe378c 100644 --- a/tauri-app/src/locales/en-US.ts +++ b/tauri-app/src/locales/en-US.ts @@ -783,7 +783,26 @@ const enUS = { "error.catalog.unavailable": "The release service is temporarily unavailable. Please try again in a moment.", "error.download.integrity": "A downloaded file failed verification. It will be downloaded again on the next attempt.", "error.disk.full": "The disk is full. Free up space and try again.", - "error.cancelled": "Cancelled." + "error.cancelled": "Cancelled.", + "launch.error.notReleased": "{name} has not been released yet.", + "launch.error.catalogUnavailable": "Could not load the release list, and this version is not installed yet: {error}", + "launch.error.edgeAotMissing": "This Edge release does not include the Forge-free package yet. Turn Forge back on in the instance settings.", + "launch.error.noLoaderVersion": "No {loader} version is available for Minecraft {version}.", + "launch.warning.catalogUnavailable": "The release list is unavailable right now, so update checks are skipped and the installed version starts.", + "launch.warning.updateSkipped": "The update for {name} could not be downloaded, so the installed version starts.", + "launch.warning.contentUpdateSkipped": "{count} content update(s) failed; the installed files are used.", + "launch.offline.title": "Offline launch", + "launch.offline.notReady": "Some files are missing, so a full preparation runs (this needs the network).", + "launch.offline.retry": "Launch without updating", + "launch.offline.hint": "The network is unavailable, but this instance is fully installed and can start without checking for updates.", + "launch.progress.checkOffline": "Checking installed files", + "launch.info.alreadyLaunching": "A launch is already in progress.", + "launch.info.cancelled": "Launch cancelled.", + "launch.prepare.cancel": "Cancel", + "launch.prepare.cancelling": "Cancelling...", + "home.quickLaunch": "Quick launch", + "home.quickLaunchHint": "Skip update checks and start the installed version", + "home.running": "Running" } as const; export default enUS; diff --git a/tauri-app/src/locales/zh-CN.ts b/tauri-app/src/locales/zh-CN.ts index 8be1efd..a3830ed 100644 --- a/tauri-app/src/locales/zh-CN.ts +++ b/tauri-app/src/locales/zh-CN.ts @@ -778,7 +778,26 @@ const zhCN: EnUSTranslations = { "error.catalog.unavailable": "版本服务暂时不可用,请稍后重试。", "error.download.integrity": "下载的文件校验失败,下次尝试时会重新下载。", "error.disk.full": "磁盘空间不足,请清理后重试。", - "error.cancelled": "已取消。" + "error.cancelled": "已取消。", + "launch.error.notReleased": "{name} 尚未发布。", + "launch.error.catalogUnavailable": "无法获取版本列表,且该版本尚未安装:{error}", + "launch.error.edgeAotMissing": "当前 Edge 版本尚未提供无 Forge 运行包,请在实例设置中重新开启 Forge。", + "launch.error.noLoaderVersion": "没有适用于 Minecraft {version} 的 {loader} 版本。", + "launch.warning.catalogUnavailable": "暂时无法获取版本列表,已跳过更新检查,直接启动已安装的版本。", + "launch.warning.updateSkipped": "{name} 的更新下载失败,已改为启动已安装的版本。", + "launch.warning.contentUpdateSkipped": "{count} 项内容更新失败,已沿用已安装的文件。", + "launch.offline.title": "离线启动", + "launch.offline.notReady": "部分文件缺失,需要联网完成完整准备。", + "launch.offline.retry": "跳过更新直接启动", + "launch.offline.hint": "网络不可用,但该实例已完整安装,可以跳过更新检查直接启动。", + "launch.progress.checkOffline": "正在检查已安装的文件", + "launch.info.alreadyLaunching": "正在启动中,请稍候。", + "launch.info.cancelled": "已取消启动。", + "launch.prepare.cancel": "取消", + "launch.prepare.cancelling": "正在取消...", + "home.quickLaunch": "快速启动", + "home.quickLaunchHint": "跳过更新检查,直接启动已安装的版本", + "home.running": "运行中" } as const; export default zhCN; diff --git a/tauri-app/src/pages/Home.tsx b/tauri-app/src/pages/Home.tsx index b10e4d4..ad04ae9 100644 --- a/tauri-app/src/pages/Home.tsx +++ b/tauri-app/src/pages/Home.tsx @@ -6,6 +6,7 @@ import { Check, ChevronRight, Clock, + FastForward, Gamepad2, Lock, Play, @@ -62,6 +63,8 @@ type HomePageProps = { minecraftAccountRequired: boolean; onSelect: (id: string) => void; onLaunch: () => void; + onQuickLaunch: () => void; + gameRunning: boolean; onLaunchToServer: (serverAddress: string) => void; onOpenSettings: () => void; onOpenServers: () => void; @@ -102,6 +105,8 @@ function HomePage({ minecraftAccountRequired, onSelect, onLaunch, + onQuickLaunch, + gameRunning, onLaunchToServer, onOpenSettings, onOpenServers, @@ -192,7 +197,8 @@ function HomePage({ const activeNewsContent = (activeNews?.content ?? activeNews?.summary ?? "").trim(); const instanceLaunchable = Boolean(selectedInstance) && canAccessInstance(selectedInstance as Instance, user); - const quickJoinDisabled = busy || launching || !instanceLaunchable; + const quickJoinDisabled = busy || launching || gameRunning || !instanceLaunchable; + const launchDisabled = busy || gameRunning || !selectedInstance || !instanceLaunchable; const launchAsName = currentMinecraftAccount?.username ?? t("nav.player"); const isOfflineAccount = !currentMinecraftAccount || currentMinecraftAccount.type === "offline"; const quickServers = useMemo(() => launcherServers.slice(0, 10), [launcherServers]); @@ -375,11 +381,23 @@ function HomePage({ > {t("home.loadout.change")} + @@ -740,7 +760,7 @@ function HomePage({ } }} currentInstance={current} - busy={busy} + busy={busy || gameRunning} launching={launching} launchProgressPercent={launchProgressPercent} /> diff --git a/tauri-app/src/styles.css b/tauri-app/src/styles.css index 9c9696a..b9e33b8 100644 --- a/tauri-app/src/styles.css +++ b/tauri-app/src/styles.css @@ -3776,6 +3776,7 @@ button:disabled { .modalActions { margin-top: 14px; display: flex; + gap: 10px; justify-content: flex-end; flex: 0 0 auto; } diff --git a/tauri-app/src/types.ts b/tauri-app/src/types.ts index 682dc97..fb3c3fa 100644 --- a/tauri-app/src/types.ts +++ b/tauri-app/src/types.ts @@ -175,6 +175,21 @@ export type FabricInstallResult = { profileId: string }; export type ForgeInstallResult = { profileId: string; forgeVersion: string }; export type JdkEnsureResult = { javaPath: string }; +// get_offline_launch_readiness: purely local check of an installed instance. +export type OfflineLaunchReadiness = { + versionId: string; + ready: boolean; + baseVersion?: string | null; + loader?: string | null; + loaderVersion?: string | null; + optifineVersion?: string | null; + javaMajor?: number | null; + javaPath?: string | null; + missing: string[]; + corrupted: string[]; + error?: string | null; +}; + export type LauncherLoginPrefs = { usernameOrEmail: string; password: string; @@ -342,6 +357,9 @@ export type LaunchPrepareDialogState = { instanceName: string; versionId: string; canClose: boolean; + cancelling: boolean; + /** The launch failed on the network but the installed files can start offline. */ + offlineAvailable: boolean; errorText: string; phases: LaunchPreparePhaseState[]; }; diff --git a/tauri-app/src/utils/launcher.ts b/tauri-app/src/utils/launcher.ts index d018372..019b5bd 100644 --- a/tauri-app/src/utils/launcher.ts +++ b/tauri-app/src/utils/launcher.ts @@ -88,6 +88,8 @@ export function createLaunchPrepareDialogState( instanceName, versionId, canClose: false, + cancelling: false, + offlineAvailable: false, errorText: "", phases: [ createLaunchPreparePhaseState("login", phaseTitles.login, waitingText), From 96b69db31ffc65a0b603529b1696f9d7f0cacf50 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:45:04 -0700 Subject: [PATCH 17/24] feat(launcher): cancellable downloads with real progress and a global content task store Content installs, uninstalls, world imports and modpacks now run as global tasks: leaving the Content page keeps their progress, the same project cannot be started twice, launching or repairing an instance that is being written to is refused, and every task can be cancelled (Modrinth/CurseForge installs take an ipcSession). Search, trending and installed-list requests only apply the latest response, so switching instances cannot show another instance's mods. The Extreme package download reports byte progress and speed instead of a fixed 20%. Repair runs in the install dialog with progress and cancel, honors the download source and thread settings, force-reinstalls Extreme's native package and refreshes every preset badge afterwards. The launcher self-update download can be cancelled, and closing the window during downloads asks whether to minimize to the tray or cancel and quit. --- tauri-app/src-tauri/src/main.rs | 168 ++++-- tauri-app/src/App.tsx | 185 ++++-- .../src/components/CloseConfirmDialog.tsx | 38 ++ tauri-app/src/components/InstallDialog.tsx | 2 +- tauri-app/src/components/PageRouter.tsx | 3 + tauri-app/src/hooks/useLauncherUpdate.ts | 41 +- tauri-app/src/lib/contentTasks.ts | 150 +++++ tauri-app/src/lib/launchPrepare.ts | 2 + tauri-app/src/locales/en-US.ts | 16 +- tauri-app/src/locales/zh-CN.ts | 16 +- tauri-app/src/pages/Content.tsx | 535 ++++++++++-------- tauri-app/src/pages/MandatoryUpdate.tsx | 7 + tauri-app/src/pages/Settings.tsx | 7 + tauri-app/src/types.ts | 5 +- tauri-app/src/utils/launcher.ts | 2 +- 15 files changed, 841 insertions(+), 336 deletions(-) create mode 100644 tauri-app/src/components/CloseConfirmDialog.tsx create mode 100644 tauri-app/src/lib/contentTasks.ts diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index e23af5d..995d0d7 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -1762,6 +1762,9 @@ fn import_instance_archive_blocking( }) } +/// Reinstalls an instance's runtime. Honors the user's download source and +/// thread count, reports progress and is cancellable under `ipc_session` +/// (launcher-ui-12). fn repair_instance_runtime_blocking( window: tauri::Window, game_dir: String, @@ -1769,6 +1772,9 @@ fn repair_instance_runtime_blocking( loader: String, base_version: String, loader_version: Option, + download_source: Option<&str>, + download_threads: Option, + ipc_session: Option<&str>, ) -> Result { let normalized_version_id = normalize_version_identifier(version_id.trim())?; let normalized_loader = normalize_loader_kind(&loader)?; @@ -1786,13 +1792,14 @@ fn repair_instance_runtime_blocking( ) })?; + let threads = normalize_download_threads(download_threads); let vanilla = minecraft_core::install_vanilla( Some(&window), &game_dir_path, &normalized_base_version, - None, - normalize_download_threads(Some(DEFAULT_DOWNLOAD_THREADS)), - None, + download_source, + threads, + ipc_session, )?; let mut source_version_id = vanilla.version_id; let mut resolved_loader_version = loader_version @@ -1801,8 +1808,11 @@ fn repair_instance_runtime_blocking( if normalized_loader == "fabric" { if resolved_loader_version.is_none() { - let loader_versions = - list_fabric_loaders_blocking_core(Some(&window), &normalized_base_version, None)?; + let loader_versions = list_fabric_loaders_blocking_core( + Some(&window), + &normalized_base_version, + download_source, + )?; resolved_loader_version = loader_versions .into_iter() .find(|value| !value.trim().is_empty()); @@ -1813,19 +1823,23 @@ fn repair_instance_runtime_blocking( normalized_base_version ) })?; - let fabric = install_fabric_blocking_core( + let fabric = minecraft_core::install_fabric( Some(&window), &game_dir_path, &normalized_base_version, &selected_loader_version, - None, - Some(DEFAULT_DOWNLOAD_THREADS), + download_source, + threads, + ipc_session, )?; source_version_id = fabric.profile_id; } else if normalized_loader == "forge" { if resolved_loader_version.is_none() { - let forge_versions = - list_forge_versions_blocking_core(Some(&window), &normalized_base_version, None)?; + let forge_versions = list_forge_versions_blocking_core( + Some(&window), + &normalized_base_version, + download_source, + )?; resolved_loader_version = forge_versions .into_iter() .find(|value| !value.trim().is_empty()); @@ -1835,18 +1849,19 @@ fn repair_instance_runtime_blocking( .ok_or_else(|| format!("No forge version available for {}", normalized_base_version))?; let jdk = ensure_jdk_blocking( window.clone(), - &downloader::CancelToken::new(None), + &downloader::CancelToken::new(ipc_session), game_dir_path.to_string_lossy().to_string(), normalized_base_version.clone(), - Some(DEFAULT_DOWNLOAD_THREADS), + Some(threads as i32), )?; - let forge = install_forge_blocking_core( + let forge = minecraft_core::install_forge( Some(&window), &game_dir_path, &selected_loader_version, - &jdk.java_path, - None, - Some(DEFAULT_DOWNLOAD_THREADS), + Path::new(&jdk.java_path), + download_source, + threads, + ipc_session, )?; source_version_id = forge.profile_id; resolved_loader_version = Some(forge.forge_version); @@ -1925,16 +1940,24 @@ async fn repair_instance_runtime( loader: String, base_version: String, loader_version: Option, + download_source: Option, + download_threads: Option, + ipc_session: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - repair_instance_runtime_blocking( + let result = repair_instance_runtime_blocking( window, game_dir, version_id, loader, base_version, loader_version, - ) + download_source.as_deref(), + download_threads, + ipc_session.as_deref(), + ); + clear_install_cancel(ipc_session.as_deref()); + result }) .await .map_err(|e| format!("Failed to join instance repair task: {e}")) @@ -2298,9 +2321,10 @@ async fn install_modrinth_project( project_type: String, game_version: String, loader: Option, + ipc_session: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - install_modrinth_project_blocking( + let result = install_modrinth_project_blocking( Some(window), game_dir, version_id, @@ -2309,7 +2333,10 @@ async fn install_modrinth_project( project_type, game_version, loader, - ) + ipc_session.as_deref(), + ); + clear_install_cancel(ipc_session.as_deref()); + result }) .await .map_err(|e| format!("Failed to join Modrinth install task: {e}")) @@ -2387,9 +2414,10 @@ async fn install_curseforge_project( game_version: String, loader: Option, api_key: String, + ipc_session: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - install_curseforge_project_blocking( + let result = install_curseforge_project_blocking( Some(window), game_dir, version_id, @@ -2399,7 +2427,10 @@ async fn install_curseforge_project( game_version, loader, api_key, - ) + ipc_session.as_deref(), + ); + clear_install_cancel(ipc_session.as_deref()); + result }) .await .map_err(|e| format!("Failed to join CurseForge install task: {e}")) @@ -2548,6 +2579,7 @@ fn install_modrinth_project_blocking( project_type: String, game_version: String, loader: Option, + ipc_session: Option<&str>, ) -> Result { let normalized_project_type = normalize_content_project_type(&project_type)?; if normalized_project_type == "modpack" { @@ -2603,6 +2635,7 @@ fn install_modrinth_project_blocking( &download_path, window.as_ref(), Some(&project_key), + ipc_session, ) .map_err(|err| format!("Failed to download Modrinth file {}: {err}", file.filename))?; @@ -2787,6 +2820,7 @@ fn install_curseforge_project_blocking( game_version: String, loader: Option, api_key: String, + ipc_session: Option<&str>, ) -> Result { let normalized_project_type = normalize_content_project_type(&project_type)?; if normalized_project_type == "modpack" { @@ -2857,6 +2891,7 @@ fn install_curseforge_project_blocking( &download_path, window.as_ref(), Some(&project_key), + ipc_session, ) .map_err(|err| { format!( @@ -3698,6 +3733,7 @@ fn install_fabric_api_blocking( &download_path, window, Some("fabric-api"), + ipc_session, ) { let _ = fs::remove_file(&download_path); return Err(format!( @@ -6100,13 +6136,16 @@ fn emit_content_install_progress( /// Content downloads (Modrinth/CurseForge/Fabric API). They have no install /// session, so they are only cancellable through `cancel_all_downloads`. +/// `ipc_session` lets cancel_install(session) stop this download; without one +/// only cancel_all_downloads can. fn download_file_quiet_with_progress_blocking( url: &str, target: &Path, window: Option<&tauri::Window>, project_key: Option<&str>, + ipc_session: Option<&str>, ) -> Result<(), String> { - let cancel = downloader::CancelToken::new(None); + let cancel = downloader::CancelToken::new(ipc_session); download_file_with_progress_callback_blocking(&cancel, url, target, &|downloaded, total| { if let Some(key) = project_key { emit_content_install_progress(window, key, downloaded, total); @@ -6882,6 +6921,7 @@ async fn install_native_app( version_tag: Option, checksum: Option, ipc_session: Option, + force: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { let result = install_native_app_blocking( @@ -6891,6 +6931,7 @@ async fn install_native_app( download_url, version_tag, checksum, + force.unwrap_or(false), ); clear_install_cancel(ipc_session.as_deref()); result @@ -6901,6 +6942,8 @@ async fn install_native_app( .inspect_err(|e| log_command_error("install_native_app", e)) } +/// `force` reinstalls even when the marker says the package is current: the +/// "repair" path for a binary that is present but broken. fn install_native_app_blocking( ipc_session: Option<&str>, game_dir: String, @@ -6908,6 +6951,7 @@ fn install_native_app_blocking( download_url: String, version_tag: Option, checksum: Option, + force: bool, ) -> Result { if !is_native_app_version_id(&version_id) { return Err(format!("{version_id} is not a native-app distributable")); @@ -6931,7 +6975,7 @@ fn install_native_app_blocking( let binary_path = install_dir.join(native_app_binary_name()); // Up to date? Same tag + checksum and the binary is present → skip. - if binary_path.exists() { + if !force && binary_path.exists() { if let Ok(bytes) = fs::read(&marker_path) { if let Ok(marker) = serde_json::from_slice::(&bytes) { if marker.version_tag == normalized_tag @@ -6956,10 +7000,23 @@ fn install_native_app_blocking( )); let cancel = downloader::CancelToken::new(ipc_session); + let started = std::time::Instant::now(); + let archive_name = archive_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + // Without this the dialog sat at a fixed 20% for the whole multi-hundred-MB + // download (launcher-ui-13). + let on_progress = |done: u64, total: Option| { + let elapsed = started.elapsed().as_secs_f64(); + let rate = if elapsed > 0.5 { (done as f64 / elapsed) as u64 } else { 0 }; + emit_install_bytes_progress(ipc_session, "native", done, total, rate, &archive_name); + }; downloader::download( &downloader::Request::single(&normalized_url, &archive_path, &cancel) .sha256(Some(normalized_checksum.as_str())) - .max_parts(normalize_download_threads(Some(DEFAULT_DOWNLOAD_THREADS))), + .max_parts(normalize_download_threads(Some(DEFAULT_DOWNLOAD_THREADS))) + .on_progress(&on_progress), ) .map_err(|err| format!("Failed to download {version_id}: {err}"))?; @@ -8731,34 +8788,6 @@ async fn install_fabric( .inspect_err(|e| log_command_error("install_fabric", e)) } -fn install_fabric_blocking_core( - window: Option<&tauri::Window>, - game_dir: &Path, - game_version: &str, - loader_version: &str, - download_source: Option<&str>, - download_threads: Option, -) -> Result { - let normalized_game_version = game_version.trim(); - if normalized_game_version.is_empty() { - return Err("Game version cannot be empty".to_string()); - } - let normalized_loader_version = loader_version.trim(); - if normalized_loader_version.is_empty() { - return Err("Loader version cannot be empty".to_string()); - } - let normalized_download_threads = normalize_download_threads(download_threads); - minecraft_core::install_fabric( - window, - game_dir, - normalized_game_version, - normalized_loader_version, - download_source, - normalized_download_threads, - None, - ) -} - #[tauri::command] async fn list_forge_versions( window: tauri::Window, @@ -9152,6 +9181,39 @@ fn log_command_error(command: &str, err: &str) { emit_log(None, "error", &format!("命令 {command} 执行失败: {err}")); } +/// Byte progress of one download on the "install" channel, which both the +/// launch-prepare and the install/repair dialogs consume. +fn emit_install_bytes_progress( + session: Option<&str>, + phase: &str, + done: u64, + total: Option, + bytes_per_second: u64, + message: &str, +) { + let Some(session) = session.filter(|value| !value.trim().is_empty()) else { + return; + }; + let mut payload = serde_json::json!({ + "channel": "install", + "event": "progress", + "phase": phase, + "stage": "download", + "session": session.trim(), + "message": message, + }); + if let Some(total) = total.filter(|value| *value > 0) { + let eta = (bytes_per_second > 0).then(|| total.saturating_sub(done) / bytes_per_second); + payload["bytesDone"] = done.min(total).into(); + payload["bytesTotal"] = total.into(); + payload["bytesPerSecond"] = bytes_per_second.into(); + if let Some(eta) = eta { + payload["etaSeconds"] = eta.into(); + } + } + push_ui_log("core", "stderr", &format!("[ipc]{payload}")); +} + pub(crate) fn emit_launch_prepare_ipc( session: Option<&str>, event: &str, diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index b7c1db0..5052ce9 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -11,6 +11,7 @@ import LaunchErrorDialog from "./components/LaunchErrorDialog"; import LaunchPrepareDialog from "./components/LaunchPrepareDialog"; import Sidebar from "./components/Sidebar"; import ToastViewport from "./components/ToastViewport"; +import CloseConfirmDialog from "./components/CloseConfirmDialog"; import WindowTitleBar from "./components/WindowTitleBar"; import { DEFAULT_LOGIN_PREFS, @@ -45,7 +46,8 @@ import { isNetworkError, normalizeLoginError } from "./lib/launcherError"; -import { notifyError, notifyInfo, notifyWarning } from "./lib/toast"; +import { notifyError, notifyInfo, notifySuccess, notifyWarning } from "./lib/toast"; +import { hasContentTasks, isContentTaskRunningFor } from "./lib/contentTasks"; import { parseMinecraftAccounts, resolveMinecraftLaunchIdentity, @@ -247,6 +249,7 @@ function Launcher() { const [windowVisible, setWindowVisible] = useState(false); const autoLoginAttemptedRef = useRef(false); const [minecraftAccountPromptOpen, setMinecraftAccountPromptOpen] = useState(false); + const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); const logCursorRef = useRef(null); const pollingRef = useRef(false); @@ -662,7 +665,7 @@ function Launcher() { if (ipc.session !== prev.sessionId) return prev; const phaseKey = - ipc.phase === "vanilla" + ipc.phase === prev.vanilla.sourcePhase ? "vanilla" : prev.loaderPhase && ipc.phase === prev.loaderPhase.sourcePhase ? "loaderPhase" @@ -1980,6 +1983,10 @@ function Launcher() { return; } const target = resolveNovaEffectiveInstance(rawTarget); + if (isContentTaskRunningFor(localVersionIdFor(target))) { + notifyWarning(t("launch.warning.contentInstalling", { name: target.name })); + return; + } if (activeGamePid !== null && activeGamePid > 0) { try { const runtime = await invoke("poll_game_runtime", { pid: activeGamePid }); @@ -2543,49 +2550,134 @@ function Launcher() { } } + // Repair reinstalls the runtime through the install dialog so it shows + // progress, can be cancelled and uses the chosen download source; Extreme is + // a native package and is force-reinstalled instead (launcher-ui-12). async function repairInstance(id: string) { if (ensureMandatoryLauncherUpdate()) { return; } + if (busy) { + notifyInfo(t("launch.info.alreadyLaunching")); + return; + } const rawSource = instances.find((entry) => entry.id === id); if (!rawSource) return; // Nova repairs the currently-picked game version's install dir, not the fixed default. const source = resolveNovaEffectiveInstance(rawSource); - + if (isContentTaskRunningFor(localVersionIdFor(source))) { + notifyWarning(t("launch.warning.contentInstalling", { name: source.name })); + return; + } + const isExtreme = source.launcherVersionType === "EXTREME"; + const sessionId = createSessionId(); + setInstallDialog({ + open: true, + sessionId, + title: t("repair.title", { name: source.name }), + versionId: localVersionIdFor(source), + loader: source.loader, + canClose: false, + cancelling: false, + errorText: "", + vanilla: { + ...createPhaseState( + isExtreme ? t("repair.phase.native") : t("install.phase.vanilla"), + isExtreme ? "native" : "vanilla" + ), + status: "running", + stage: "prepare", + message: t("app.status.instanceRepairing", { name: source.name }) + }, + loaderPhase: + isExtreme || source.loader === "vanilla" + ? null + : createPhaseState( + source.loader === "forge" ? t("install.phase.forge") : t("install.phase.fabric"), + source.loader + ), + optiFinePhase: null + }); setBusy(true); setStatus(t("app.status.instanceRepairing", { name: source.name })); try { - const result = await invoke("repair_instance_runtime", { - gameDir: settings.gameDir, - versionId: source.versionId, - loader: source.loader, - baseVersion: source.baseVersion, - loaderVersion: source.loaderVersion - }); - const repaired: Instance = { - ...source, - versionId: result.versionId, - baseVersion: result.baseVersion, - loader: result.loader, - loaderVersion: result.loaderVersion, - optiFineVersion: result.optiFineVersion - }; - // Keep the Nova preset generic in state (its versionId/baseVersion are picked at launch); - // custom/other-preset instances persist the repaired runtime ids as before. - const isNovaPreset = rawSource.preset && rawSource.launcherVersionType === "NOVA"; - const persistedInstance = isNovaPreset ? rawSource : repaired; - setInstances((prev) => - prev.map((item) => (item.id === persistedInstance.id ? persistedInstance : item)) - ); - installCtl.setInstalledVersions((prev) => - prev.includes(result.versionId) ? prev : [result.versionId, ...prev] - ); - if (repaired.preset) { - await ensurePresetModsReady(repaired); + if (isExtreme) { + const targetVersion = + launcherVersions.EXTREME ?? (await refreshLauncherVersions(true)).map?.EXTREME ?? null; + if (!targetVersion) { + throw new Error(t("launch.error.notReleased", { name: productLabel(source) })); + } + await invoke("install_native_app", { + gameDir: settings.gameDir, + versionId: localVersionIdFor(source), + downloadUrl: targetVersion.downloadUrl, + versionTag: targetVersion.versionName, + checksum: targetVersion.checksum, + ipcSession: sessionId, + force: true + }); + } else { + const result = await invoke("repair_instance_runtime", { + gameDir: settings.gameDir, + versionId: source.versionId, + loader: source.loader, + baseVersion: source.baseVersion, + loaderVersion: source.loaderVersion, + downloadSource: settings.downloadSource, + downloadThreads: settings.downloadThreads, + ipcSession: sessionId + }); + const repaired: Instance = { + ...source, + versionId: result.versionId, + baseVersion: result.baseVersion, + loader: result.loader, + loaderVersion: result.loaderVersion, + optiFineVersion: result.optiFineVersion + }; + // Keep the Nova preset generic in state (its versionId/baseVersion are picked at launch); + // custom/other-preset instances persist the repaired runtime ids as before. + const isNovaPreset = rawSource.preset && rawSource.launcherVersionType === "NOVA"; + const persistedInstance = isNovaPreset ? rawSource : repaired; + setInstances((prev) => + prev.map((item) => (item.id === persistedInstance.id ? persistedInstance : item)) + ); + installCtl.setInstalledVersions((prev) => + prev.includes(result.versionId) ? prev : [result.versionId, ...prev] + ); + if (repaired.preset) { + await ensurePresetModsReady(repaired, { sessionId }); + } } + // Re-derive every preset badge (incl. Nova's per-version keys) so a + // "needs repair" badge does not outlive the repair (launcher-ui-6). + void refreshPresetPackageStatuses(); + setInstallDialog(null); setStatus(t("app.status.instanceRepaired", { name: source.name })); + notifySuccess(t("repair.done", { name: source.name })); } catch (error) { - reportFailure(error); + if (isCancelledError(error)) { + setInstallDialog(null); + notifyInfo(t("launch.info.cancelled")); + return; + } + const errorText = describeApiError(error, t); + setStatus(t("app.status.failed", { error: errorText })); + setInstallDialog((prev) => { + if (!prev || prev.sessionId !== sessionId) return prev; + const fail = (phase: InstallPhaseState) => + phase.status === "running" || phase.status === "pending" + ? { ...phase, status: "error" as const, stage: "failed", message: errorText } + : phase; + return { + ...prev, + canClose: true, + cancelling: false, + errorText, + vanilla: fail(prev.vanilla), + loaderPhase: prev.loaderPhase ? fail(prev.loaderPhase) : null + }; + }); } finally { setBusy(false); } @@ -2771,9 +2863,29 @@ function Launcher() { await invoke("hide_main_window"); return; } + // Quitting mid-download used to drop it silently and leave half-written + // version dirs behind (launcher-ui-24). + if (busy || installDialogRef.current !== null || hasContentTasks() || launcherUpdate.downloading) { + setCloseConfirmOpen(true); + return; + } await getCurrentWindow().close(); } + async function resolveCloseConfirm(choice: "minimize" | "quit" | "stay") { + setCloseConfirmOpen(false); + if (choice === "minimize") { + await invoke("hide_main_window"); + } else if (choice === "quit") { + try { + await invoke("cancel_all_downloads"); + } catch (error) { + console.warn("[close] cancel_all_downloads failed:", error); + } + await getCurrentWindow().close(); + } + } + // Stable callback identities so React.memo children (Sidebar, pages) don't // re-render when an unrelated piece of App state changes (#5). const stableNavigate = useStableCallback(navigatePage); @@ -2864,7 +2976,10 @@ function Launcher() { ); setSelected(item.id); setStatus(t("app.status.installed", { name: item.name })); - navigatePage("instances"); + // The install may finish after the user moved on; only follow it from the content page. + if (page === "content") { + navigatePage("instances"); + } }); const onSelectMajor = useStableCallback((nextMajor: string) => { installCtl.setShowSnapshots(false); @@ -2884,6 +2999,7 @@ function Launcher() { }); const onRefreshLauncherUpdate = useStableCallback(() => void launcherUpdate.refresh(false)); const onInstallLauncherUpdate = useStableCallback(() => void launcherUpdate.install()); + const onCancelLauncherUpdate = useStableCallback(() => void launcherUpdate.cancel()); const onSettingsReset = useStableCallback(() => setSettings({ ...DEFAULT_SETTINGS, @@ -2949,6 +3065,7 @@ function Launcher() { launcherUpdateDownload: launcherUpdate.download, onRefreshLauncherUpdate, onInstallLauncherUpdate, + onCancelLauncherUpdate, catalogLoading: installCtl.catalogLoading, catalogCount: installCtl.catalog.length, majors: installCtl.majors, @@ -3075,6 +3192,10 @@ function Launcher() { setLaunchError(null)} /> )} + {closeConfirmOpen && ( + void resolveCloseConfirm(choice)} /> + )} + diff --git a/tauri-app/src/components/CloseConfirmDialog.tsx b/tauri-app/src/components/CloseConfirmDialog.tsx new file mode 100644 index 0000000..43ac3f9 --- /dev/null +++ b/tauri-app/src/components/CloseConfirmDialog.tsx @@ -0,0 +1,38 @@ +import Button from "./Button"; +import Card from "./Card"; +import ModalFrame from "./ModalFrame"; +import { useI18n } from "../i18n"; + +type CloseConfirmDialogProps = { + onChoose: (choice: "minimize" | "quit" | "stay") => void; +}; + +// Asked when the window is closed while downloads or installs are running. +export default function CloseConfirmDialog({ onChoose }: CloseConfirmDialogProps) { + const { t } = useI18n(); + return ( + onChoose("stay")} + overlayClassName="fixed inset-0 z-[96] flex items-center justify-center bg-[var(--bg-primary)]/82 p-6" + > + +

+ {t("close.busy.title")} +

+

{t("close.busy.message")}

+
+ + + +
+
+
+ ); +} diff --git a/tauri-app/src/components/InstallDialog.tsx b/tauri-app/src/components/InstallDialog.tsx index 0e10ee5..b763d78 100644 --- a/tauri-app/src/components/InstallDialog.tsx +++ b/tauri-app/src/components/InstallDialog.tsx @@ -19,7 +19,7 @@ export default function InstallDialog({ dialog, onClose, onCancel }: InstallDial

{!dialog.canClose ?

{t("dialog.session", { id: dialog.sessionId.slice(-6) })}
diff --git a/tauri-app/src/components/PageRouter.tsx b/tauri-app/src/components/PageRouter.tsx index 905c489..6c8a9f7 100644 --- a/tauri-app/src/components/PageRouter.tsx +++ b/tauri-app/src/components/PageRouter.tsx @@ -92,6 +92,7 @@ export type PageRouterContext = { launcherUpdateDownload: DownloadedLauncherUpdate | null; onRefreshLauncherUpdate: () => void; onInstallLauncherUpdate: () => void; + onCancelLauncherUpdate: () => void; // install wizard catalogLoading: boolean; catalogCount: number; @@ -286,6 +287,7 @@ export default function PageRouter({ ctx }: { ctx: PageRouterContext }) { launcherUpdateProgressPercent={ctx.launcherUpdateProgressPercent} launcherUpdateDownload={ctx.launcherUpdateDownload} onInstallLauncherUpdate={ctx.onInstallLauncherUpdate} + onCancelLauncherUpdate={ctx.onCancelLauncherUpdate} /> ); default: @@ -304,6 +306,7 @@ export default function PageRouter({ ctx }: { ctx: PageRouterContext }) { onLogoutLauncherAccount={ctx.onLogoutLauncherAccount} onRefreshLauncherUpdate={ctx.onRefreshLauncherUpdate} onInstallLauncherUpdate={ctx.onInstallLauncherUpdate} + onCancelLauncherUpdate={ctx.onCancelLauncherUpdate} onChange={ctx.onChangeSettings} onClampMemory={ctx.onClampMemory} onReset={ctx.onResetSettings} diff --git a/tauri-app/src/hooks/useLauncherUpdate.ts b/tauri-app/src/hooks/useLauncherUpdate.ts index 1a6e4ad..4b091f3 100644 --- a/tauri-app/src/hooks/useLauncherUpdate.ts +++ b/tauri-app/src/hooks/useLauncherUpdate.ts @@ -1,12 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { LAUNCHER_API_BASE_URL } from "../constants"; import type { TranslationKey } from "../i18n"; -import { describeApiError, isLauncherAppUpdateMissing } from "../lib/launcherError"; -import { notifyError } from "../lib/toast"; +import { describeApiError, isCancelledError, isLauncherAppUpdateMissing } from "../lib/launcherError"; +import { notifyError, notifyInfo } from "../lib/toast"; import type { DownloadedLauncherUpdate, LauncherAppUpdateChannel, LauncherAppUpdateInfo } from "../types"; -import { compareMajor } from "../utils/launcher"; +import { compareMajor, createSessionId } from "../utils/launcher"; import { IS_WINDOWS } from "../utils/platform"; type LauncherAppUpdateProgress = { @@ -38,6 +38,8 @@ export type LauncherUpdateController = { refresh: (silent?: boolean) => Promise; refreshChannels: () => Promise; install: () => Promise; + /** Stops a running installer download. */ + cancel: () => Promise; }; // Owns the launcher self-update domain: checking for new versions, listing @@ -51,6 +53,7 @@ export function useLauncherUpdate(deps: UseLauncherUpdateDeps): LauncherUpdateCo const [downloading, setDownloading] = useState(false); const [progressPercent, setProgressPercent] = useState(null); const [download, setDownload] = useState(null); + const downloadSessionRef = useRef(null); const available = useMemo( () => appUpdate !== null && compareMajor(appUpdate.version, currentLauncherVersion) > 0, @@ -127,10 +130,13 @@ export function useLauncherUpdate(deps: UseLauncherUpdateDeps): LauncherUpdateCo } } ); + const sessionId = createSessionId(); + downloadSessionRef.current = sessionId; const downloaded = await invoke("download_launcher_app_update", { downloadUrl: appUpdate.downloadUrl, version: appUpdate.version, - checksum: appUpdate.checksum + checksum: appUpdate.checksum, + ipcSession: sessionId }); setDownload(downloaded); completed = true; @@ -141,16 +147,32 @@ export function useLauncherUpdate(deps: UseLauncherUpdateDeps): LauncherUpdateCo await invoke("quit_launcher_app"); } } catch (error) { - const errorText = describeApiError(error, t); - setStatus(t("app.status.failed", { error: errorText })); - notifyError(errorText, t("toast.title.updateFailed")); + if (isCancelledError(error)) { + setStatus(t("settings.launcherUpdateCancelled")); + notifyInfo(t("settings.launcherUpdateCancelled")); + } else { + const errorText = describeApiError(error, t); + setStatus(t("app.status.failed", { error: errorText })); + notifyError(errorText, t("toast.title.updateFailed")); + } } finally { + downloadSessionRef.current = null; stopProgressListener?.(); if (!completed) setProgressPercent(null); setDownloading(false); } }, [appUpdate, available, flushTelemetry, setStatus, t]); + const cancel = useCallback(async () => { + const sessionId = downloadSessionRef.current; + if (!sessionId) return; + try { + await invoke("cancel_install", { sessionId }); + } catch (error) { + notifyError(describeApiError(error, t), t("toast.title.updateFailed")); + } + }, [t]); + // Load channels once, and re-check for updates whenever the channel changes. useEffect(() => { void refreshChannels(); @@ -173,6 +195,7 @@ export function useLauncherUpdate(deps: UseLauncherUpdateDeps): LauncherUpdateCo mandatoryRequired, refresh, refreshChannels, - install + install, + cancel }; } diff --git a/tauri-app/src/lib/contentTasks.ts b/tauri-app/src/lib/contentTasks.ts new file mode 100644 index 0000000..e8222a1 --- /dev/null +++ b/tauri-app/src/lib/contentTasks.ts @@ -0,0 +1,150 @@ +// Global store for content (mod / resource pack / modpack / world) tasks. +// +// These used to live in the Content page's local state, so leaving the page +// lost the progress, re-enabled the install buttons (two concurrent installs of +// the same pack) and the launch buttons never knew an instance was being +// written to (launcher-ui-11). A module-level store survives page unmounts and +// lets the launch flow refuse to start an instance that is mid-install. Same +// subscribe/snapshot shape as lib/toast.ts. +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useSyncExternalStore } from "react"; +import type { ContentInstallProgressEvent, ModpackInstallProgressEvent } from "../types"; + +export type ContentTaskKind = "install" | "modpack" | "uninstall" | "world-import"; + +export type ContentTask = { + /** ipcSession of the backend command; cancel_install(id) stops it. */ + id: string; + kind: ContentTaskKind; + /** `::`, matches the backend progress events. */ + projectKey: string | null; + title: string; + /** Instance directory being written; null for a modpack (creates a new one). */ + versionId: string | null; + progress: ContentInstallProgressEvent | null; + modpackProgress: ModpackInstallProgressEvent | null; + cancelling: boolean; +}; + +export type ContentTaskError = { versionId: string | null; message: string; at: number }; + +type State = { + tasks: ContentTask[]; + /** Bumped per versionId whenever a task that changed it ends. */ + revisions: Record; + lastError: ContentTaskError | null; + /** A batch update in progress: the page shows it, the next item is driven by its loop. */ + batch: { versionId: string; current: number; total: number } | null; +}; + +let state: State = { tasks: [], revisions: {}, lastError: null, batch: null }; +const listeners = new Set<() => void>(); + +function setState(update: (prev: State) => State) { + state = update(state); + for (const listener of listeners) { + listener(); + } +} + +export function subscribeContentTasks(listener: () => void): () => void { + ensureProgressListeners(); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getContentTasksSnapshot(): State { + return state; +} + +export function useContentTasks(): State { + return useSyncExternalStore(subscribeContentTasks, getContentTasksSnapshot); +} + +export function isContentTaskRunningFor(versionId: string): boolean { + return ( + state.tasks.some((task) => task.versionId === versionId) || + state.batch?.versionId === versionId + ); +} + +export function hasContentTasks(): boolean { + return state.tasks.length > 0 || state.batch !== null; +} + +export function findContentTask(projectKey: string): ContentTask | null { + return state.tasks.find((task) => task.projectKey === projectKey) ?? null; +} + +export function beginContentTask(task: Omit) { + setState((prev) => ({ + ...prev, + lastError: null, + tasks: [...prev.tasks, { ...task, progress: null, modpackProgress: null, cancelling: false }] + })); +} + +export function endContentTask(id: string, error?: string) { + setState((prev) => { + const task = prev.tasks.find((item) => item.id === id); + if (!task) return prev; + const revisions = task.versionId + ? { ...prev.revisions, [task.versionId]: (prev.revisions[task.versionId] ?? 0) + 1 } + : prev.revisions; + return { + ...prev, + tasks: prev.tasks.filter((item) => item.id !== id), + revisions, + lastError: error ? { versionId: task.versionId, message: error, at: Date.now() } : prev.lastError + }; + }); +} + +export function setContentBatch(batch: State["batch"]) { + setState((prev) => ({ ...prev, batch })); +} + +export function clearContentTaskError() { + if (state.lastError) { + setState((prev) => ({ ...prev, lastError: null })); + } +} + +export async function cancelContentTask(id: string): Promise { + setState((prev) => ({ + ...prev, + tasks: prev.tasks.map((task) => (task.id === id ? { ...task, cancelling: true } : task)) + })); + await invoke("cancel_install", { sessionId: id }); +} + +let progressListenersBound = false; + +// Bound once for the app lifetime: the events must keep updating tasks while +// the Content page is not mounted. +function ensureProgressListeners() { + if (progressListenersBound) return; + progressListenersBound = true; + const bind = ( + event: string, + apply: (task: ContentTask, payload: T) => ContentTask + ) => + listen(event, ({ payload }) => { + if (!state.tasks.some((task) => task.projectKey === payload.projectKey)) return; + setState((prev) => ({ + ...prev, + tasks: prev.tasks.map((task) => (task.projectKey === payload.projectKey ? apply(task, payload) : task)) + })); + }).catch((error) => { + progressListenersBound = false; + console.warn(`[content] cannot listen to ${event}:`, error); + }); + void bind("content-install-progress", (task, progress) => ({ ...task, progress })); + void bind("modpack-install-progress", (task, modpackProgress) => ({ + ...task, + modpackProgress + })); +} diff --git a/tauri-app/src/lib/launchPrepare.ts b/tauri-app/src/lib/launchPrepare.ts index a7b727d..eebd578 100644 --- a/tauri-app/src/lib/launchPrepare.ts +++ b/tauri-app/src/lib/launchPrepare.ts @@ -222,6 +222,8 @@ export function mapLaunchPreparePhaseKey(phase?: string): LaunchPreparePhaseKey if (phase === "optifine") return "optifine"; if (phase === "mods") return "mods"; if (phase === "runtime") return "runtime"; + // Extreme's native package download stands in for the instance check. + if (phase === "native") return "check-instance"; return null; } diff --git a/tauri-app/src/locales/en-US.ts b/tauri-app/src/locales/en-US.ts index 1fe378c..6df1789 100644 --- a/tauri-app/src/locales/en-US.ts +++ b/tauri-app/src/locales/en-US.ts @@ -802,7 +802,21 @@ const enUS = { "launch.prepare.cancelling": "Cancelling...", "home.quickLaunch": "Quick launch", "home.quickLaunchHint": "Skip update checks and start the installed version", - "home.running": "Running" + "home.running": "Running", + "content.cancelled": "Cancelled: {title}", + "common.dismiss": "Dismiss", + "content.batchProgress": "Updating {current} of {total}", + "launch.warning.contentInstalling": "Content is still being installed into {name}. Wait for it to finish, or cancel it on the Content page.", + "repair.title": "Repairing {name}", + "repair.phase.native": "Client package", + "repair.done": "{name} has been repaired.", + "close.busy.title": "Downloads are still running", + "close.busy.message": "Closing now cancels them and may leave files half-downloaded (they are checked again next time). Minimize to the tray to keep downloading?", + "close.busy.minimize": "Minimize to tray", + "close.busy.quit": "Cancel and quit", + "close.busy.stay": "Keep open", + "settings.launcherUpdateCancel": "Cancel download", + "settings.launcherUpdateCancelled": "The update download was cancelled." } as const; export default enUS; diff --git a/tauri-app/src/locales/zh-CN.ts b/tauri-app/src/locales/zh-CN.ts index a3830ed..11b771f 100644 --- a/tauri-app/src/locales/zh-CN.ts +++ b/tauri-app/src/locales/zh-CN.ts @@ -797,7 +797,21 @@ const zhCN: EnUSTranslations = { "launch.prepare.cancelling": "正在取消...", "home.quickLaunch": "快速启动", "home.quickLaunchHint": "跳过更新检查,直接启动已安装的版本", - "home.running": "运行中" + "home.running": "运行中", + "content.cancelled": "已取消:{title}", + "common.dismiss": "关闭", + "content.batchProgress": "正在更新第 {current}/{total} 项", + "launch.warning.contentInstalling": "{name} 正在安装内容,请等待完成,或在资源页取消后再启动。", + "repair.title": "正在修复 {name}", + "repair.phase.native": "客户端包", + "repair.done": "{name} 已修复完成。", + "close.busy.title": "仍有下载正在进行", + "close.busy.message": "现在关闭会取消下载,未完成的文件需要下次重新校验。是否最小化到托盘继续下载?", + "close.busy.minimize": "最小化到托盘", + "close.busy.quit": "取消下载并退出", + "close.busy.stay": "返回", + "settings.launcherUpdateCancel": "取消下载", + "settings.launcherUpdateCancelled": "已取消更新下载。" } as const; export default zhCN; diff --git a/tauri-app/src/pages/Content.tsx b/tauri-app/src/pages/Content.tsx index 0ad0de6..80fd5ee 100644 --- a/tauri-app/src/pages/Content.tsx +++ b/tauri-app/src/pages/Content.tsx @@ -1,4 +1,3 @@ -import { listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; import { Archive, Box, Download, File, Globe, HardDriveDownload, Layers, PackageCheck, PackageOpen, Palette, Search, Trash2, X } from "lucide-react"; @@ -10,10 +9,20 @@ import Select from "../components/Select"; import modrinthIcon from "../assets/icons/modrinth.ico"; import curseforgeIcon from "../assets/icons/curseforge.ico"; import { useI18n, type TranslationKey } from "../i18n"; -import { describeApiError } from "../lib/launcherError"; +import { + beginContentTask, + cancelContentTask, + clearContentTaskError, + endContentTask, + findContentTask, + setContentBatch, + useContentTasks, + type ContentTaskKind +} from "../lib/contentTasks"; +import { describeApiError, formatLaunchError, isCancelledError } from "../lib/launcherError"; +import { notifyError, notifyInfo, notifySuccess } from "../lib/toast"; import { buildNovaEffectiveInstance, listNovaVersionTargets } from "../lib/novaTargets"; import type { - ContentInstallProgressEvent, ContentProjectType, ContentSource, DownloadSource, @@ -28,7 +37,7 @@ import type { OnlineContentSource, WorldInstallResult } from "../types"; -import { resolveInstanceIconPath } from "../utils/launcher"; +import { createSessionId, resolveInstanceIconPath } from "../utils/launcher"; type RightTab = "search" | "installed"; @@ -96,6 +105,12 @@ function contentSourceLabel(value: ContentSource, t: ReturnType[ return t("content.source.local"); } +function modpackPercent(progress: ModpackInstallProgressEvent | null): number { + if (!progress) return 0; + if (progress.percent != null) return progress.percent; + return progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; +} + function formatCompactCount(value: number): string { if (!Number.isFinite(value) || value <= 0) return "0"; if (value >= 1_000_000) { @@ -254,19 +269,16 @@ function ContentPage({ const [source, setSource] = useState("modrinth"); const [contentType, setContentType] = useState("mod"); const [loading, setLoading] = useState(false); - const [installingProjectId, setInstallingProjectId] = useState(null); - const [uninstallingProjectId, setUninstallingProjectId] = useState(null); const [checkingUpdates, setCheckingUpdates] = useState(false); - const [batchUpdating, setBatchUpdating] = useState(false); - const [importingWorld, setImportingWorld] = useState(false); const [hasSearched, setHasSearched] = useState(false); const [rightTab, setRightTab] = useState("search"); const [results, setResults] = useState([]); const [installedItems, setInstalledItems] = useState([]); const [installedUpdates, setInstalledUpdates] = useState([]); const [error, setError] = useState(null); - const [contentInstallProgress, setContentInstallProgress] = useState(null); - const [modpackProgress, setModpackProgress] = useState(null); + const contentTasks = useContentTasks(); + const searchRequestRef = useRef(0); + const installedRequestRef = useRef(0); const [pendingUninstall, setPendingUninstall] = useState(null); const [uninstallDialogClosing, setUninstallDialogClosing] = useState(false); const novaPreset = useMemo( @@ -345,8 +357,22 @@ function ContentPage({ // Modpack search/install builds a brand-new instance, so it never depends on // the currently selected instance. const modpackMode = contentType === "modpack"; - const contentNavigationBusy = - loading || batchUpdating || importingWorld || Boolean(installingProjectId) || Boolean(uninstallingProjectId); + const currentVersionId = currentInstance?.versionId ?? null; + // Tasks writing to the selected instance block its other content actions; + // tasks for other instances (or a modpack, which creates its own) do not. + const instanceTasks = contentTasks.tasks.filter((task) => task.versionId !== null && task.versionId === currentVersionId); + const batchUpdating = contentTasks.batch !== null && contentTasks.batch.versionId === currentVersionId; + const instanceBusy = instanceTasks.length > 0 || batchUpdating; + const importingWorld = instanceTasks.some((task) => task.kind === "world-import"); + const modpackTask = contentTasks.tasks.find((task) => task.kind === "modpack") ?? null; + const taskFor = (projectKey: string) => contentTasks.tasks.find((task) => task.projectKey === projectKey) ?? null; + const taskError = + contentTasks.lastError && + (contentTasks.lastError.versionId === currentVersionId || (contentTasks.lastError.versionId === null && modpackMode)) + ? contentTasks.lastError.message + : null; + const displayedError = error ?? taskError; + const contentNavigationBusy = loading; const searchPlaceholder = worldImportMode ? t("content.worldSearchUnavailable") : contentType === "world" @@ -390,6 +416,46 @@ function ContentPage({ }); }, [filteredInstalledItems, updateMap]); + const loaderLabel = (loader: Instance["loader"]) => { + if (loader === "forge") return t("loader.forge"); + if (loader === "fabric") return t("loader.fabric"); + return t("loader.vanilla"); + }; + + const normalizeError = (errorValue: unknown) => { + const raw = typeof errorValue === "string" ? errorValue : errorValue instanceof Error ? errorValue.message : String(errorValue); + const normalized = raw.trim(); + const currentVersion = currentInstance?.baseVersion?.trim(); + const currentLoader = currentInstance ? loaderLabel(currentInstance.loader) : null; + if (/CurseForge download URL lookup failed with HTTP 403/i.test(normalized)) { + return t("content.error.curseforgeDistributionBlocked"); + } + if (/No compatible CurseForge file found for the current instance/i.test(normalized)) { + if (currentVersion && currentLoader) { + return t("content.error.curseforgeNoCompatibleFileDetailed", { version: currentVersion, loader: currentLoader }); + } + return t("content.error.curseforgeNoCompatibleFile"); + } + if (/CurseForge file does not expose a usable download URL/i.test(normalized)) { + return t("content.error.curseforgeNoDownloadUrl"); + } + if (/CurseForge project id cannot be empty/i.test(normalized)) { + return t("content.error.curseforgeMissingProjectId"); + } + // Anything not matched above is usually a transport failure; returning the + // raw reqwest chain gave users nothing actionable. + return describeApiError(errorValue, t); + }; + + // Modpack errors name the failed stage ("[modpack:] ..." prefix). + const describeContentError = (errorValue: unknown) => { + const { stage, message } = splitModpackError(formatLaunchError(errorValue)); + const stageLabelKey = stage ? MODPACK_STAGE_LABEL_KEYS[stage] : undefined; + return stageLabelKey + ? t("content.modpack.stageFailed", { stage: t(stageLabelKey), error: normalizeError(message) }) + : normalizeError(errorValue); + }; + async function fetchProjects(queryValue: string): Promise { if (!currentInstance && !modpackMode) return []; // Modpacks bring their own Minecraft version and loader, so their search @@ -416,27 +482,46 @@ function ContentPage({ return []; } - async function loadTrendingProjects() { - if ((!currentInstance && !modpackMode) || worldImportMode) return; + // Search and trending share one sequence: only the latest request may write + // results, so switching instance/source mid-request cannot show stale ones + // (launcher-ui-15). + async function runSearch(queryValue: string) { + const requestId = ++searchRequestRef.current; setLoading(true); setError(null); - onStatusChange(t("content.loadingTrending")); + if (queryValue) setResults([]); try { - const searchResults = await fetchProjects(""); + const searchResults = await fetchProjects(queryValue); + if (searchRequestRef.current !== requestId) return; setResults(searchResults); setHasSearched(true); setRightTab("search"); - onStatusChange(t("content.trendingLoaded", { count: searchResults.length })); + onStatusChange( + queryValue + ? t("content.searchDone", { count: searchResults.length }) + : t("content.trendingLoaded", { count: searchResults.length }) + ); } catch (invokeError) { + if (searchRequestRef.current !== requestId) return; setError(normalizeError(invokeError)); setResults([]); setHasSearched(true); } finally { - setLoading(false); + if (searchRequestRef.current === requestId) { + setLoading(false); + } } } + async function loadTrendingProjects() { + if ((!currentInstance && !modpackMode) || worldImportMode) return; + onStatusChange(t("content.loadingTrending")); + await runSearch(""); + } + async function refreshInstalledState(instance: Instance | null) { + const requestId = ++installedRequestRef.current; + const isCurrent = () => installedRequestRef.current === requestId; if (!instance) { setInstalledItems([]); setInstalledUpdates([]); @@ -448,6 +533,7 @@ function ContentPage({ gameDir, versionId: instance.versionId }); + if (!isCurrent()) return; setInstalledItems(items); if (items.length === 0) { setInstalledUpdates([]); @@ -462,14 +548,15 @@ function ContentPage({ loader: instance.loader, apiKey: curseforgeApiKey }); - setInstalledUpdates(updates); - } catch { - // Ignore update check errors + if (isCurrent()) setInstalledUpdates(updates); + } catch (updateError) { + // The list stays usable without update badges. + console.warn("[content] update check failed:", updateError); } - } catch { - // Ignore content refresh errors + } catch (listError) { + if (isCurrent()) setError(normalizeError(listError)); } finally { - setCheckingUpdates(false); + if (isCurrent()) setCheckingUpdates(false); } } @@ -480,19 +567,39 @@ function ContentPage({ await loadTrendingProjects(); return; } - setLoading(true); + await runSearch(trimmedQuery); + } + + // Runs one backend content command as a global task: it keeps running (and + // reporting) after this page unmounts, and its outcome is a toast so the + // user still learns about it from any page. + async function runContentTask( + task: { kind: ContentTaskKind; projectKey: string | null; title: string; versionId: string | null }, + run: (sessionId: string) => Promise, + successText: (result: T) => string + ): Promise { + if (task.projectKey && findContentTask(task.projectKey)) { + return null; + } + const sessionId = createSessionId(); + beginContentTask({ id: sessionId, ...task }); setError(null); - setResults([]); try { - const searchResults = await fetchProjects(trimmedQuery); - setResults(searchResults); - setHasSearched(true); - setRightTab("search"); - onStatusChange(t("content.searchDone", { count: searchResults.length })); + const result = await run(sessionId); + endContentTask(sessionId); + notifySuccess(successText(result)); + return result; } catch (invokeError) { - setError(normalizeError(invokeError)); - } finally { - setLoading(false); + if (isCancelledError(invokeError)) { + endContentTask(sessionId); + notifyInfo(t("content.cancelled", { title: task.title })); + return null; + } + const errorText = describeContentError(invokeError); + endContentTask(sessionId, errorText); + notifyError(errorText, task.title); + onStatusChange(t("app.status.failed", { error: errorText })); + return null; } } @@ -501,40 +608,31 @@ function ContentPage({ projectId: string; title: string; }): Promise { - const projectKey = `${params.source}:modpack:${params.projectId}`; - setInstallingProjectId(projectKey); - setContentInstallProgress({ projectKey, downloadedBytes: 0, totalBytes: null, percent: null }); - setModpackProgress(null); - setError(null); onStatusChange(t("content.modpack.installing", { title: params.title })); - try { - const result = await invoke("install_modpack", { - gameDir, - source: params.source, - projectId: params.projectId, - projectTitle: params.title, - apiKey: params.source === "curseforge" ? curseforgeApiKey : undefined, - downloadSource, - downloadThreads - }); + const result = await runContentTask( + { + kind: "modpack", + projectKey: `${params.source}:modpack:${params.projectId}`, + title: params.title, + versionId: null + }, + (sessionId) => + invoke("install_modpack", { + gameDir, + source: params.source, + projectId: params.projectId, + projectTitle: params.title, + apiKey: params.source === "curseforge" ? curseforgeApiKey : undefined, + downloadSource, + downloadThreads, + ipcSession: sessionId + }), + (installed) => t("content.modpack.installDone", { name: installed.name }) + ); + if (result) { onModpackInstalled(result); - onStatusChange(t("content.modpack.installDone", { name: result.name })); - return true; - } catch (invokeError) { - const raw = typeof invokeError === "string" ? invokeError : invokeError instanceof Error ? invokeError.message : String(invokeError); - const { stage, message } = splitModpackError(raw.trim()); - const stageLabelKey = stage ? MODPACK_STAGE_LABEL_KEYS[stage] : undefined; - const errorText = stageLabelKey - ? t("content.modpack.stageFailed", { stage: t(stageLabelKey), error: message }) - : normalizeError(message); - setError(errorText); - onStatusChange(t("app.status.failed", { error: errorText })); - return false; - } finally { - setInstallingProjectId(null); - setContentInstallProgress(null); - setModpackProgress(null); } + return result !== null; } async function installProject(params: { @@ -546,39 +644,37 @@ function ContentPage({ if (params.projectType === "modpack") { return installModpack(params); } - if (!currentInstance) { + const instance = currentInstance; + if (!instance) { setError(t("content.noInstance")); return false; } - const projectKey = `${params.source}:${params.projectType}:${params.projectId}`; - setInstallingProjectId(projectKey); - setContentInstallProgress({ projectKey, downloadedBytes: 0, totalBytes: null, percent: null }); - setError(null); onStatusChange(t("content.installing", { title: params.title })); - try { - const command = params.source === "curseforge" ? "install_curseforge_project" : "install_modrinth_project"; - const result = await invoke(command, { - gameDir, - versionId: currentInstance.versionId, - projectId: params.projectId, - projectTitle: params.title, - projectType: params.projectType, - gameVersion: currentInstance.baseVersion, - loader: currentInstance.loader, - ...(params.source === "curseforge" ? { apiKey: curseforgeApiKey } : {}) - }); - await refreshInstalledState(currentInstance); - onStatusChange(t("content.installDone", { title: params.title, file: result.fileName })); - return true; - } catch (invokeError) { - const errorText = normalizeError(invokeError); - setError(errorText); - onStatusChange(t("app.status.failed", { error: errorText })); - return false; - } finally { - setInstallingProjectId(null); - setContentInstallProgress(null); - } + const result = await runContentTask( + { + kind: "install", + projectKey: `${params.source}:${params.projectType}:${params.projectId}`, + title: params.title, + versionId: instance.versionId + }, + (sessionId) => + invoke( + params.source === "curseforge" ? "install_curseforge_project" : "install_modrinth_project", + { + gameDir, + versionId: instance.versionId, + projectId: params.projectId, + projectTitle: params.title, + projectType: params.projectType, + gameVersion: instance.baseVersion, + loader: instance.loader, + ipcSession: sessionId, + ...(params.source === "curseforge" ? { apiKey: curseforgeApiKey } : {}) + } + ), + (installed) => t("content.installDone", { title: params.title, file: installed.fileName }) + ); + return result !== null; } function closeUninstallDialog() { @@ -596,46 +692,42 @@ function ContentPage({ } async function uninstallProject(item: InstalledContentItem) { - if (!currentInstance) { + const instance = currentInstance; + if (!instance) { setError(t("content.noInstance")); return; } - setUninstallingProjectId(`${item.source}:${item.contentType}:${item.projectId}`); - setError(null); onStatusChange(t("content.uninstalling", { title: item.projectTitle })); - try { - await invoke("uninstall_installed_content", { - gameDir, - versionId: currentInstance.versionId, - source: item.source, - projectId: item.projectId, - contentType: item.contentType - }); - await refreshInstalledState(currentInstance); - onStatusChange(t("content.uninstallDone", { title: item.projectTitle })); - } catch (invokeError) { - const errorText = normalizeError(invokeError); - setError(errorText); - onStatusChange(t("app.status.failed", { error: errorText })); - } finally { - setUninstallingProjectId(null); - } + await runContentTask( + { + kind: "uninstall", + projectKey: `uninstall:${item.source}:${item.contentType}:${item.projectId}`, + title: item.projectTitle, + versionId: instance.versionId + }, + () => + invoke("uninstall_installed_content", { + gameDir, + versionId: instance.versionId, + source: item.source, + projectId: item.projectId, + contentType: item.contentType + }), + () => t("content.uninstallDone", { title: item.projectTitle }) + ); } async function updateAllProjects() { - if (!currentInstance || filteredUpdatableItems.length === 0) return; - setBatchUpdating(true); - setError(null); + const instance = currentInstance; + if (!instance || filteredUpdatableItems.length === 0 || contentTasks.batch) return; + const items = filteredUpdatableItems; try { let completed = 0; - for (const item of filteredUpdatableItems) { + for (const item of items) { completed += 1; + setContentBatch({ versionId: instance.versionId, current: completed, total: items.length }); onStatusChange( - t("content.updatingProgress", { - title: item.projectTitle, - current: completed, - total: filteredUpdatableItems.length - }) + t("content.updatingProgress", { title: item.projectTitle, current: completed, total: items.length }) ); const success = await installProject({ source: item.source as OnlineContentSource, @@ -645,14 +737,15 @@ function ContentPage({ }); if (!success) return; } - onStatusChange(t("content.updateAllDone", { count: filteredUpdatableItems.length })); + notifySuccess(t("content.updateAllDone", { count: items.length })); } finally { - setBatchUpdating(false); + setContentBatch(null); } } async function handleWorldImport() { - if (!currentInstance) { + const instance = currentInstance; + if (!instance) { setError(t("content.noInstance")); return; } @@ -666,40 +759,37 @@ function ContentPage({ return; } const archiveName = selected.split(/[\\/]/).pop() ?? "world.zip"; - setImportingWorld(true); - setError(null); onStatusChange(t("content.importingWorld")); - try { + await runContentTask( + { kind: "world-import", projectKey: `world-import:${selected}`, title: archiveName, versionId: instance.versionId }, // Pass the picked path instead of streaming the whole ZIP over IPC. - const result = await invoke("import_world_archive", { - gameDir, - versionId: currentInstance.versionId, - archiveName, - archivePath: selected - }); - await refreshInstalledState(currentInstance); - onStatusChange(t("content.worldImportDone", { title: result.projectTitle })); - } catch (invokeError) { - const errorText = normalizeError(invokeError); - setError(errorText); - onStatusChange(t("app.status.failed", { error: errorText })); - } finally { - setImportingWorld(false); - } + () => + invoke("import_world_archive", { + gameDir, + versionId: instance.versionId, + archiveName, + archivePath: selected + }), + (result) => t("content.worldImportDone", { title: result.projectTitle }) + ); } useEffect(() => { + // A new selection invalidates any search still in flight. + searchRequestRef.current += 1; + setLoading(false); setResults([]); setError(null); - setContentInstallProgress(null); - setModpackProgress(null); setHasSearched(false); setRightTab("search"); }, [currentInstance?.id, currentInstance?.versionId, contentType, source]); + // Re-read the installed list on selection change and whenever a content task + // that wrote to this instance ends (also one started before a page switch). + const currentRevision = currentInstance ? contentTasks.revisions[currentInstance.versionId] ?? 0 : 0; useEffect(() => { void refreshInstalledState(currentInstance); - }, [currentInstance]); + }, [currentInstance, currentRevision]); useEffect(() => { if ((!currentInstance && !modpackMode) || worldImportMode || query.trim()) { @@ -712,73 +802,6 @@ function ContentPage({ void loadTrendingProjects(); }, [currentInstance?.id, currentInstance?.versionId, contentType, source, worldImportMode]); - useEffect(() => { - let cancelled = false; - let unlisten: (() => void) | null = null; - void listen("content-install-progress", (event) => { - if (!cancelled) setContentInstallProgress(event.payload); - }).then((dispose) => { - if (cancelled) { - dispose(); - return; - } - unlisten = dispose; - }); - return () => { - cancelled = true; - unlisten?.(); - }; - }, []); - - useEffect(() => { - let cancelled = false; - let unlisten: (() => void) | null = null; - void listen("modpack-install-progress", (event) => { - if (!cancelled) setModpackProgress(event.payload); - }).then((dispose) => { - if (cancelled) { - dispose(); - return; - } - unlisten = dispose; - }); - return () => { - cancelled = true; - unlisten?.(); - }; - }, []); - - const loaderLabel = (loader: Instance["loader"]) => { - if (loader === "forge") return t("loader.forge"); - if (loader === "fabric") return t("loader.fabric"); - return t("loader.vanilla"); - }; - - const normalizeError = (errorValue: unknown) => { - const raw = typeof errorValue === "string" ? errorValue : errorValue instanceof Error ? errorValue.message : String(errorValue); - const normalized = raw.trim(); - const currentVersion = currentInstance?.baseVersion?.trim(); - const currentLoader = currentInstance ? loaderLabel(currentInstance.loader) : null; - if (/CurseForge download URL lookup failed with HTTP 403/i.test(normalized)) { - return t("content.error.curseforgeDistributionBlocked"); - } - if (/No compatible CurseForge file found for the current instance/i.test(normalized)) { - if (currentVersion && currentLoader) { - return t("content.error.curseforgeNoCompatibleFileDetailed", { version: currentVersion, loader: currentLoader }); - } - return t("content.error.curseforgeNoCompatibleFile"); - } - if (/CurseForge file does not expose a usable download URL/i.test(normalized)) { - return t("content.error.curseforgeNoDownloadUrl"); - } - if (/CurseForge project id cannot be empty/i.test(normalized)) { - return t("content.error.curseforgeMissingProjectId"); - } - // Anything not matched above is usually a transport failure; returning the - // raw reqwest chain gave users nothing actionable. - return describeApiError(errorValue, t); - }; - return (
@@ -974,7 +997,7 @@ function ContentPage({ {(worldImportMode || filteredUpdatableItems.length > 0) && (
{worldImportMode && ( - @@ -985,7 +1008,7 @@ function ContentPage({ variant="secondary" size="sm" className="!rounded-[10px] gap-2 border-amber-500/25 bg-amber-500/8 text-amber-200 hover:bg-amber-500/12" - disabled={busy || batchUpdating || checkingUpdates} + disabled={busy || instanceBusy || checkingUpdates} onClick={() => void updateAllProjects()} > @@ -996,42 +1019,66 @@ function ContentPage({
)} - {error && ( -
+ {displayedError && ( +
-
-

{error}

+
+

{displayedError}

+
)} - {modpackProgress && installingProjectId === modpackProgress.projectKey && ( + {batchUpdating && contentTasks.batch && ( +

+ {t("content.batchProgress", { current: contentTasks.batch.current, total: contentTasks.batch.total })} +

+ )} + + {modpackTask && (
- {t(MODPACK_STAGE_LABEL_KEYS[modpackProgress.stage] ?? "content.modpack.stage.files")} + {t(MODPACK_STAGE_LABEL_KEYS[modpackTask.modpackProgress?.stage ?? "catalog"] ?? "content.modpack.stage.files")}

- {t("content.modpack.stageProgress", { - stage: t(MODPACK_STAGE_LABEL_KEYS[modpackProgress.stage] ?? "content.modpack.stage.files") - })} + {modpackTask.title}

-
0 ? Math.round((modpackProgress.current / modpackProgress.total) * 100) : 0)}%` }} - /> +
- {modpackProgress.total > 0 && ( + {modpackTask.modpackProgress && modpackTask.modpackProgress.total > 0 && ( - {modpackProgress.percent != null ? `${modpackProgress.percent}%` : `${modpackProgress.current}/${modpackProgress.total}`} + {modpackTask.modpackProgress.percent != null + ? `${modpackTask.modpackProgress.percent}%` + : `${modpackTask.modpackProgress.current}/${modpackTask.modpackProgress.total}`} )} +
)} @@ -1058,8 +1105,9 @@ function ContentPage({ const canUpdate = updateState?.status === "update-available"; const isInstalled = installedMap.has(`${item.source}:${item.projectType}:${item.projectId}`); const itemKey = `${item.source}:${item.projectType}:${item.projectId}`; - const actionBusy = busy || Boolean(installingProjectId) || Boolean(uninstallingProjectId); - const installProgress = contentInstallProgress?.projectKey === itemKey ? contentInstallProgress : null; + const itemTask = taskFor(itemKey); + const actionBusy = busy || (modpackMode ? Boolean(itemTask) : instanceBusy); + const installProgress = itemTask ? itemTask.progress ?? { projectKey: itemKey, downloadedBytes: 0, totalBytes: null, percent: null } : null; return (
@@ -1089,7 +1137,19 @@ function ContentPage({ )}
-
+
+ {itemTask && itemTask.kind !== "uninstall" && ( + + )} + {launcherUpdateDownloading && ( + + )}
diff --git a/tauri-app/src/pages/Settings.tsx b/tauri-app/src/pages/Settings.tsx index 8f08c46..185281a 100644 --- a/tauri-app/src/pages/Settings.tsx +++ b/tauri-app/src/pages/Settings.tsx @@ -82,6 +82,7 @@ type SettingsPageProps = { onLogoutLauncherAccount: () => void; onRefreshLauncherUpdate: () => void; onInstallLauncherUpdate: () => void; + onCancelLauncherUpdate: () => void; onChange: (next: Settings) => void; onClampMemory: (input: string) => void; onReset: () => void; @@ -101,6 +102,7 @@ function SettingsPage({ onLogoutLauncherAccount, onRefreshLauncherUpdate, onInstallLauncherUpdate, + onCancelLauncherUpdate, onChange, onClampMemory, onReset @@ -1493,6 +1495,11 @@ function SettingsPage({ : t("settings.launcherUpdateInstall")} )} + {launcherUpdateDownloading && ( + + )} {!launcherUpdateAvailable && hasLauncherUpdateNotes && ( +
+ {secureStorageError.startsWith("CREDENTIAL_STORE_UNAVAILABLE") && ( + + )} + +
) : ( diff --git a/tauri-app/src/components/PageRouter.tsx b/tauri-app/src/components/PageRouter.tsx index 6c8a9f7..85427b7 100644 --- a/tauri-app/src/components/PageRouter.tsx +++ b/tauri-app/src/components/PageRouter.tsx @@ -29,6 +29,7 @@ const ContentPage = lazy(() => import("../pages/Content")); const MandatoryUpdatePage = lazy(() => import("../pages/MandatoryUpdate")); const AccountCenterPage = lazy(() => import("../pages/AccountCenter")); const SettingsPage = lazy(() => import("../pages/Settings")); +const DiagnosticsPage = lazy(() => import("../pages/Diagnostics")); // Everything the page tree needs, assembled by the Launcher. Grouped loosely // by domain so it maps cleanly onto the domain hooks that feed it. @@ -135,6 +136,8 @@ export type PageRouterContext = { onChangeSettings: (next: Settings) => void; onClampMemory: (input: string) => void; onResetSettings: () => void; + // diagnostics + diagnosticsVersionId: string | null; }; export default function PageRouter({ ctx }: { ctx: PageRouterContext }) { @@ -279,6 +282,17 @@ export default function PageRouter({ ctx }: { ctx: PageRouterContext }) { onStatusChange={ctx.onStatusChange} /> ); + case "diagnostics": + return ( + + ); case "mandatory-update": return ( void; + onOpenSettings: () => void; +}; + +type Translator = ReturnType["t"]; + +// Fixes that need something only the user can give: consent to store the +// credential key in a file, or a save location. +const CONSENT_FIXES = new Set(["secure_storage_set_file_fallback"]); +// Codes without a backend fix command that the instance repair resolves. +const REPAIR_INSTANCE_CODES = new Set(["NATIVE_APP_INCOMPLETE", "INSTANCE_PROFILE_UNREADABLE"]); +const SOURCE_CODES = new Set(["SOURCE_SLOW", "SOURCE_UNREACHABLE"]); + +const KNOWN_CODES = new Set([ + "JAVA_RUNTIME_OK", + "JAVA_RUNTIME_INCOMPLETE", + "JAVA_RUNTIME_MISSING", + "INSTANCE_FILES_OK", + "INSTANCE_FILES_MISSING", + "INSTANCE_FILES_CORRUPTED", + "INSTANCE_PROFILE_UNREADABLE", + "NATIVE_APP_INCOMPLETE", + "DISK_SPACE_OK", + "DISK_SPACE_LOW", + "DISK_SPACE_CRITICAL", + "DISK_UNKNOWN", + "SOURCE_OK", + "SOURCE_SLOW", + "SOURCE_UNREACHABLE", + "CREDENTIAL_STORE_OK", + "CREDENTIAL_STORE_UNAVAILABLE", + "CREDENTIAL_STORE_ACCESS_DENIED", + "NO_RECENT_CRASHES", + "RECENT_CRASHES" +]); + +function sourceLabel(id: unknown, t: Translator): string { + if (id === "official") return t("diagnostics.source.official"); + if (id === "bmclapi") return t("diagnostics.source.bmclapi"); + if (id === "fpsmaster-api") return t("diagnostics.source.api"); + return String(id ?? ""); +} + +// Turns the backend's raw params into display values for the locale templates. +function displayParams(item: HealthCheckItem, t: Translator): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(item.params)) { + if (value === null || value === undefined) continue; + if (key === "freeBytes" || key === "totalBytes") { + out[key] = formatByteSize(Number(value)); + } else if (key === "bytesPerSecond") { + out[key] = `${formatByteSize(Number(value))}/s`; + } else if (key === "source") { + out[key] = sourceLabel(value, t); + } else if (typeof value === "string" || typeof value === "number") { + out[key] = value; + } else if (Array.isArray(value)) { + out[key] = value.join(", "); + } + } + // Templates must never show a raw "{placeholder}" when the backend omits a value. + for (const key of ["latencyMs", "bytesPerSecond", "latestSummary", "backend", "major", "versionId", "runtime"]) { + out[key] ??= "-"; + } + return out; +} + +function itemText(item: HealthCheckItem, t: Translator): { title: string; description: string } { + const params = displayParams(item, t); + if (!KNOWN_CODES.has(item.code)) { + return { title: item.code, description: item.detail }; + } + return { + title: t(`diagnostics.code.${item.code}.title` as TranslationKey, params), + description: t(`diagnostics.code.${item.code}.desc` as TranslationKey, params) + }; +} + +function StatusIcon({ status }: { status: HealthCheckItem["status"] }) { + if (status === "ok") return ; + if (status === "warning") return ; + if (status === "error") return ; + return ; +} + +function DiagnosticsPage({ + versionId, + instanceName, + settings, + busy, + onRepairInstance, + onOpenSettings +}: DiagnosticsPageProps) { + const { t } = useI18n(); + const [report, setReport] = useState(null); + const [checking, setChecking] = useState(false); + const [deep, setDeep] = useState(false); + const [fixing, setFixing] = useState<{ itemId: string; sessionId: string } | null>(null); + const [fixingAll, setFixingAll] = useState(false); + const [exporting, setExporting] = useState(false); + const [lastExport, setLastExport] = useState(null); + const requestRef = useRef(0); + + const runCheck = useCallback( + async (deepCheck: boolean) => { + const requestId = ++requestRef.current; + setChecking(true); + try { + const next = await invoke("run_health_check", { + gameDir: settings.gameDir, + versionId, + apiBaseUrl: LAUNCHER_API_BASE_URL, + deep: deepCheck + }); + if (requestRef.current === requestId) setReport(next); + } catch (error) { + if (requestRef.current === requestId) notifyError(describeApiError(error, t), t("diagnostics.checkFailed")); + } finally { + if (requestRef.current === requestId) setChecking(false); + } + }, + [settings.gameDir, versionId, t] + ); + + useEffect(() => { + void runCheck(false); + }, [runCheck]); + + async function exportBundle() { + let targetPath: string | undefined; + try { + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); + const picked = await save({ + title: t("diagnostics.exportTitle"), + defaultPath: `fpsmaster-support-${stamp}.zip`, + filters: [{ name: "ZIP", extensions: ["zip"] }] + }); + if (picked === null) return; + targetPath = picked; + } catch (error) { + // No native dialog (browser dev): the backend writes to /exports. + console.warn("[diagnostics] save dialog unavailable:", error); + } + setExporting(true); + try { + const result = await invoke("export_support_bundle", { + gameDir: settings.gameDir, + versionId, + healthReport: report, + targetPath + }); + setLastExport(result); + notifySuccess(t("diagnostics.exportDone", { path: result.path })); + } catch (error) { + notifyError(describeApiError(error, t), t("diagnostics.exportFailed")); + } finally { + setExporting(false); + } + } + + async function runFix(item: HealthCheckItem): Promise { + if (!item.fix) return false; + if (item.fix.command === "export_support_bundle") { + await exportBundle(); + return true; + } + if (CONSENT_FIXES.has(item.fix.command) && !window.confirm(t("diagnostics.fileFallbackConsent"))) { + return false; + } + const sessionId = createSessionId(); + setFixing({ itemId: item.id, sessionId }); + try { + await invoke(item.fix.command, { + ...item.fix.args, + downloadSource: settings.downloadSource, + downloadThreads: settings.downloadThreads, + ipcSession: sessionId + }); + return true; + } catch (error) { + if (isCancelledError(error)) { + notifyInfo(t("error.cancelled")); + } else { + notifyError(describeApiError(error, t), t("diagnostics.fixFailed")); + } + return false; + } finally { + setFixing(null); + } + } + + async function fixOne(item: HealthCheckItem) { + if (await runFix(item)) { + notifySuccess(t("diagnostics.fixDone")); + await runCheck(deep); + } + } + + // Runs every fix that needs no extra input from the user, then re-checks. + async function fixAll() { + const items = (report?.items ?? []).filter( + (item) => + item.status !== "ok" && + item.fix && + !CONSENT_FIXES.has(item.fix.command) && + item.fix.command !== "export_support_bundle" + ); + if (items.length === 0) return; + setFixingAll(true); + let fixed = 0; + try { + for (const item of items) { + if (!(await runFix(item))) break; + fixed += 1; + } + } finally { + setFixingAll(false); + } + if (fixed > 0) notifySuccess(t("diagnostics.fixAllDone", { count: fixed })); + await runCheck(deep); + } + + async function cancelFix() { + if (!fixing) return; + try { + await invoke("cancel_install", { sessionId: fixing.sessionId }); + } catch (error) { + console.warn("[diagnostics] cancel failed:", error); + } + } + + const items = report?.items ?? []; + const problems = items.filter((item) => item.status === "error" || item.status === "warning"); + const autoFixable = problems.filter( + (item) => item.fix && !CONSENT_FIXES.has(item.fix.command) && item.fix.command !== "export_support_bundle" + ); + const working = checking || fixing !== null || fixingAll; + + return ( +
+
+
+

{t("diagnostics.eyebrow")}

+

{t("diagnostics.title")}

+

+ {instanceName ? t("diagnostics.subtitleInstance", { name: instanceName }) : t("diagnostics.subtitle")} +

+
+
+ + + + +
+
+ + {lastExport && ( +
+ +
+

{t("diagnostics.exportDone", { path: lastExport.path })}

+

{t("diagnostics.exportHint")}

+
+ +
+ )} + + {report && !checking && ( +

+ {problems.length === 0 + ? t("diagnostics.summaryOk") + : t("diagnostics.summaryProblems", { count: problems.length })} +

+ )} + + {!report && checking ? ( +
+ +

{t("diagnostics.checking")}

+

{t("diagnostics.checkingHint")}

+
+ ) : ( +
+ {items.map((item) => { + const text = itemText(item, t); + const itemFixing = fixing?.itemId === item.id; + const showRepairInstance = item.status !== "ok" && REPAIR_INSTANCE_CODES.has(item.code); + const showSourceSettings = item.status !== "ok" && SOURCE_CODES.has(item.code); + const showFix = item.status !== "ok" && item.fix !== null; + return ( + +
+ +
+

{text.title}

+

{text.description}

+ {item.detail && item.status !== "ok" && ( +

{item.detail}

+ )} +
+
+ {itemFixing && ( + + )} + {showFix && ( + + )} + {showRepairInstance && ( + + )} + {showSourceSettings && ( + + )} +
+
+
+ ); + })} +
+ )} +
+ ); +} + +export default memo(DiagnosticsPage); diff --git a/tauri-app/src/types.ts b/tauri-app/src/types.ts index 436d5b0..9ca42c1 100644 --- a/tauri-app/src/types.ts +++ b/tauri-app/src/types.ts @@ -1,4 +1,4 @@ -export type Page = "home" | "instances" | "install" | "servers" | "content" | "settings" | "instance-settings" | "mandatory-update" | "account-center"; +export type Page = "home" | "instances" | "install" | "servers" | "content" | "settings" | "instance-settings" | "mandatory-update" | "account-center" | "diagnostics"; export type Loader = "vanilla" | "forge" | "fabric"; export type OptiFineCompatibilityState = "compatible" | "incompatible" | "unknown"; export type PhaseStatus = "pending" | "running" | "done" | "error"; @@ -643,3 +643,46 @@ export type LauncherHomePayload = { online: TelemetryOnlineSummary; dashboard?: LauncherDashboard | null; }; + +// run_health_check +export type HealthCheckStatus = "ok" | "warning" | "error" | "skipped"; +export type HealthCheckCategory = "java" | "instance" | "disk" | "network" | "credentials" | "crashes"; +export type HealthCheckItem = { + id: string; + category: HealthCheckCategory; + status: HealthCheckStatus; + /** Stable code for the i18n table (diagnostics.code..*). */ + code: string; + params: Record; + /** Technical detail in English; shown small, never as the sentence. */ + detail: string; + fix: { command: string; args: Record } | null; +}; +export type HealthReport = { generatedAt: string; items: HealthCheckItem[] }; +export type SupportBundleResult = { path: string; entries: string[]; sizeBytes: number }; + +// probe_download_sources +export type SourceProbe = { + id: "official" | "bmclapi" | "fpsmaster-api"; + url: string; + ok: boolean; + latencyMs?: number | null; + bytesPerSecond?: number | null; + bytesRead: number; + error?: string | null; +}; +export type SourceProbeReport = { sources: SourceProbe[]; recommended: "official-first" | "mirror-first" }; + +// game-exit event payload +export type GameExitEvent = { + pid: number; + exitCode: number; + versionId: string; + durationMs: number; + crashed: boolean; + terminatedByUser: boolean; + crashReportPath?: string | null; + crashSummary?: string | null; + hsErrPath?: string | null; + hsErrSummary?: string | null; +}; From ca530a021c379d66c356ead76e02099b3926d2dc Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:49:35 -0700 Subject: [PATCH 19/24] feat(launcher): crash dialog on abnormal game exit The launcher now listens to game-exit. Any exit clears the running-game state; a crash (not a user stop) brings the main window back and shows the crash summary with buttons to open the crash report, the JVM error log and the logs folder, to jump to diagnostics for that instance, and to send the redacted report after the user agrees (HTTP 429 is dropped silently). --- tauri-app/src/App.tsx | 100 ++++++++++++++++- tauri-app/src/components/CrashDialog.tsx | 132 +++++++++++++++++++++++ tauri-app/src/locales/en-US.ts | 15 ++- tauri-app/src/locales/zh-CN.ts | 15 ++- 4 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 tauri-app/src/components/CrashDialog.tsx diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index 4d26675..540001f 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -12,6 +12,7 @@ import LaunchPrepareDialog from "./components/LaunchPrepareDialog"; import Sidebar from "./components/Sidebar"; import ToastViewport from "./components/ToastViewport"; import CloseConfirmDialog from "./components/CloseConfirmDialog"; +import CrashDialog from "./components/CrashDialog"; import WindowTitleBar from "./components/WindowTitleBar"; import { DEFAULT_LOGIN_PREFS, @@ -28,7 +29,7 @@ import { I18nProvider, resolveLocale } from "./i18n"; -import { syncApiLanguage } from "./lib/apiLanguage"; +import { acceptLanguageFor, syncApiLanguage } from "./lib/apiLanguage"; import { mapLaunchPreparePhaseKey, mergePhaseByteProgress, @@ -54,6 +55,7 @@ import { shouldRefreshMicrosoftAccount } from "./lib/minecraftAccount"; import { + loadOrCreateLauncherSessionId, normalizeStoredToken, parseLauncherAuthState, parseLauncherLoginPrefs, @@ -83,6 +85,7 @@ import type { EdgeAotInstallResult, FabricInstallResult, ForgeInstallResult, + GameExitEvent, GameRuntimeStats, InstalledContentItem, InstalledContentUpdate, @@ -250,6 +253,7 @@ function Launcher() { const autoLoginAttemptedRef = useRef(false); const [minecraftAccountPromptOpen, setMinecraftAccountPromptOpen] = useState(false); const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); + const [crashReport, setCrashReport] = useState(null); const logCursorRef = useRef(null); const pollingRef = useRef(false); @@ -631,6 +635,28 @@ function Launcher() { }; }, [activeGamePid, windowVisible]); + // A game that crashes after the launcher hid itself used to leave the user + // with nothing but "exited" in the monitor (launcher-ui-20 / product-4). + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + void listen("game-exit", ({ payload }) => { + setActiveGamePid((prev) => (prev === payload.pid ? null : prev)); + if (!payload.crashed || payload.terminatedByUser) return; + setCrashReport(payload); + invoke("show_main_window").catch((error) => console.warn("[crash] cannot show main window:", error)); + }) + .then((dispose) => { + if (disposed) dispose(); + else unlisten = dispose; + }) + .catch((error) => console.warn("[crash] cannot listen to game-exit:", error)); + return () => { + disposed = true; + unlisten?.(); + }; + }, []); + useEffect(() => { void syncAutostart(settings.launchOnStartup); }, [settings.launchOnStartup]); @@ -2858,6 +2884,57 @@ function Launcher() { + // Instance (and Nova game version) a game-exit versionId belongs to. + function findInstanceForVersionId(versionId: string): { instance: Instance; gameVersion?: string } | null { + if (versionId.startsWith(`${NOVA_VERSION_ID_PREFIX}-`)) { + const nova = instances.find((item) => item.preset && item.launcherVersionType === "NOVA"); + return nova ? { instance: nova, gameVersion: versionId.slice(NOVA_VERSION_ID_PREFIX.length + 1) } : null; + } + const instance = instances.find((item) => localVersionIdFor(item) === versionId); + return instance ? { instance } : null; + } + + function crashInstanceName(crash: GameExitEvent): string { + const match = findInstanceForVersionId(crash.versionId); + if (!match) return crash.versionId; + return match.gameVersion ? `Nova ${match.gameVersion}` : match.instance.name; + } + + function diagnoseCrash(crash: GameExitEvent) { + const match = findInstanceForVersionId(crash.versionId); + if (match) { + if (match.gameVersion) setSelectedNovaGameVersion(match.gameVersion); + setSelected(match.instance.id); + } + setCrashReport(null); + navigatePage("diagnostics"); + } + + function openPathWithSystem(path: string) { + invoke("open_downloaded_file", { filePath: path }).catch((error) => notifyError(describeApiError(error, t))); + } + + // Sent only after the user pressed the consent button in the crash dialog. + async function uploadCrashReport(crash: GameExitEvent) { + try { + // "rate_limited" means the backend dropped it (HTTP 429): no retry, by contract. + await invoke<{ status: string }>("upload_game_crash_report", { + request: { + baseUrl: LAUNCHER_API_BASE_URL, + versionId: crash.versionId, + exitCode: crash.exitCode, + durationMs: crash.durationMs, + crashReportPath: crash.crashReportPath ?? undefined, + hsErrPath: crash.hsErrPath ?? undefined, + sessionId: loadOrCreateLauncherSessionId(), + language: acceptLanguageFor(settings.language) + } + }); + } catch (error) { + throw new Error(describeApiError(error, t)); + } + } + // Only with explicit consent: the key then lives in a 0600 file instead of // the system keychain. async function enableCredentialFileFallback() { @@ -3212,6 +3289,27 @@ function Launcher() { setLaunchError(null)} /> )} + {authenticated && crashReport && ( + + void invoke("open_instance_section", { + gameDir: settings.gameDir, + versionId: crashReport.versionId, + section: "logs" + }).catch((error) => notifyError(describeApiError(error, t))) + } + onOpenFile={openPathWithSystem} + onDiagnose={() => diagnoseCrash(crashReport)} + onUpload={() => uploadCrashReport(crashReport)} + onClose={() => setCrashReport(null)} + /> + )} + {closeConfirmOpen && ( void resolveCloseConfirm(choice)} /> )} diff --git a/tauri-app/src/components/CrashDialog.tsx b/tauri-app/src/components/CrashDialog.tsx new file mode 100644 index 0000000..f1f637d --- /dev/null +++ b/tauri-app/src/components/CrashDialog.tsx @@ -0,0 +1,132 @@ +import { FileText, FolderOpen, LoaderCircle, Send, Stethoscope } from "lucide-react"; +import { useState } from "react"; +import Button from "./Button"; +import Card from "./Card"; +import ModalFrame from "./ModalFrame"; +import { useI18n } from "../i18n"; +import type { GameExitEvent } from "../types"; +import { formatDuration } from "../utils/launcher"; + +type CrashDialogProps = { + crash: GameExitEvent; + instanceName: string; + /** Instance logs folder; null when the client keeps its logs elsewhere (Extreme). */ + onOpenLogs: (() => void) | null; + onOpenFile: (path: string) => void; + onDiagnose: () => void; + /** Uploads the crash report; resolves once sent (or silently dropped on 429). */ + onUpload: () => Promise; + onClose: () => void; +}; + +// Shown when a launched game exits abnormally (launcher-ui-20 / product-4): +// what crashed, where the evidence is, and an opt-in report upload. +export default function CrashDialog({ + crash, + instanceName, + onOpenLogs, + onOpenFile, + onDiagnose, + onUpload, + onClose +}: CrashDialogProps) { + const { t } = useI18n(); + const [uploadState, setUploadState] = useState<"idle" | "sending" | "sent" | "failed">("idle"); + const [uploadError, setUploadError] = useState(""); + const summary = crash.crashSummary?.trim() || crash.hsErrSummary?.trim() || ""; + const canUpload = Boolean(crash.crashReportPath || crash.hsErrPath); + + async function upload() { + setUploadState("sending"); + setUploadError(""); + try { + await onUpload(); + setUploadState("sent"); + } catch (error) { + setUploadState("failed"); + setUploadError(String(error instanceof Error ? error.message : error)); + } + } + + return ( + + +

+ {t("crash.title", { name: instanceName })} +

+

+ {t("crash.subtitle", { code: crash.exitCode, duration: formatDuration(crash.durationMs) })} +

+ +
+

+ {t("crash.summary")} +

+
+            {summary || t("crash.noSummary")}
+          
+
+ +
+ {crash.crashReportPath && ( + + )} + {crash.hsErrPath && ( + + )} + {onOpenLogs && ( + + )} + +
+ + {canUpload && ( +
+

{t("crash.uploadExplain")}

+ {uploadState === "sent" ? ( +

{t("crash.uploadDone")}

+ ) : ( + + )} + {uploadState === "failed" && ( +

+ {t("crash.uploadFailed", { error: uploadError })} +

+ )} +
+ )} + +
+ +
+
+
+ ); +} diff --git a/tauri-app/src/locales/en-US.ts b/tauri-app/src/locales/en-US.ts index 6dabfe4..f614aad 100644 --- a/tauri-app/src/locales/en-US.ts +++ b/tauri-app/src/locales/en-US.ts @@ -887,7 +887,20 @@ const enUS = { "diagnostics.code.NO_RECENT_CRASHES.desc": "No crash in the last 7 days.", "diagnostics.code.RECENT_CRASHES.title": "{count} crash(es) in the last 7 days", "diagnostics.code.RECENT_CRASHES.desc": "Latest: {latestSummary}. Export a support bundle and send it to support.", - "secureStorage.useFileFallback": "Store the key in a file instead" + "secureStorage.useFileFallback": "Store the key in a file instead", + "crash.title": "{name} closed unexpectedly", + "crash.subtitle": "Exit code {code}, ran for {duration}.", + "crash.summary": "Crash summary", + "crash.noSummary": "No crash report was written. The game log may show what happened.", + "crash.openReport": "Open crash report", + "crash.openHsErr": "Open JVM error log", + "crash.openLogs": "Open logs folder", + "crash.diagnose": "Run diagnostics", + "crash.uploadExplain": "Send this crash report to FPSMaster so we can fix the cause. It contains the crash report, your system type and the launcher version; tokens and passwords are removed first.", + "crash.uploadConsent": "Agree and send", + "crash.uploadDone": "Sent. Thank you!", + "crash.uploadFailed": "Sending failed: {error}", + "crash.close": "Close" } as const; export default enUS; diff --git a/tauri-app/src/locales/zh-CN.ts b/tauri-app/src/locales/zh-CN.ts index 0ba8590..1fed33d 100644 --- a/tauri-app/src/locales/zh-CN.ts +++ b/tauri-app/src/locales/zh-CN.ts @@ -882,7 +882,20 @@ const zhCN: EnUSTranslations = { "diagnostics.code.NO_RECENT_CRASHES.desc": "最近 7 天没有崩溃记录。", "diagnostics.code.RECENT_CRASHES.title": "最近 7 天崩溃 {count} 次", "diagnostics.code.RECENT_CRASHES.desc": "最近一次:{latestSummary}。可以导出求助包发给客服。", - "secureStorage.useFileFallback": "改用文件保存密钥" + "secureStorage.useFileFallback": "改用文件保存密钥", + "crash.title": "{name} 异常退出", + "crash.subtitle": "退出码 {code},运行了 {duration}。", + "crash.summary": "崩溃摘要", + "crash.noSummary": "游戏没有生成崩溃报告,可以查看游戏日志了解原因。", + "crash.openReport": "打开崩溃报告", + "crash.openHsErr": "打开 JVM 错误日志", + "crash.openLogs": "打开日志文件夹", + "crash.diagnose": "一键诊断", + "crash.uploadExplain": "把这份崩溃报告发送给 FPSMaster,帮助我们修复问题。内容包括崩溃报告、系统类型和启动器版本,令牌和密码会先被移除。", + "crash.uploadConsent": "同意并发送", + "crash.uploadDone": "已发送,感谢反馈!", + "crash.uploadFailed": "发送失败:{error}", + "crash.close": "关闭" } as const; export default zhCN; From def3932207bf8b8607de1fa45bdc09ddae7cd9ff Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:50:35 -0700 Subject: [PATCH 20/24] feat(launcher): download source speed test and an auto source Settings offer "Auto" (the faster of Mojang and BMCLAPI, falling back to the other on failure), now the default for new installs, and a speed test that shows latency and throughput per source with a one-click switch to the recommended one. --- tauri-app/src/constants.ts | 2 +- tauri-app/src/locales/en-US.ts | 12 +++++- tauri-app/src/locales/zh-CN.ts | 12 +++++- tauri-app/src/pages/Settings.tsx | 68 +++++++++++++++++++++++++++++++- tauri-app/src/types.ts | 2 + tauri-app/src/utils/launcher.ts | 1 + 6 files changed, 93 insertions(+), 4 deletions(-) diff --git a/tauri-app/src/constants.ts b/tauri-app/src/constants.ts index bca4df0..39f4b26 100644 --- a/tauri-app/src/constants.ts +++ b/tauri-app/src/constants.ts @@ -48,7 +48,7 @@ export const NEWS_ITEMS: readonly NewsItem[] = [ export const DEFAULT_SETTINGS: Settings = { gameDir: "", playerName: "", - downloadSource: "mirror-first", + downloadSource: "auto", downloadThreads: 16, launcherUpdateChannel: "beta", maxMemoryMb: 4096, diff --git a/tauri-app/src/locales/en-US.ts b/tauri-app/src/locales/en-US.ts index f614aad..4bcde88 100644 --- a/tauri-app/src/locales/en-US.ts +++ b/tauri-app/src/locales/en-US.ts @@ -900,7 +900,17 @@ const enUS = { "crash.uploadConsent": "Agree and send", "crash.uploadDone": "Sent. Thank you!", "crash.uploadFailed": "Sending failed: {error}", - "crash.close": "Close" + "crash.close": "Close", + "settings.downloadSource.auto": "Auto (fastest)", + "settings.downloadSourceHint": "Auto picks whichever of Mojang and BMCLAPI answered faster, and falls back to the other one when a download fails.", + "settings.sourceProbe": "Download source speed test", + "settings.sourceProbeHint": "Measures latency and speed of each source from this network.", + "settings.sourceProbeRun": "Test now", + "settings.sourceProbeRunning": "Testing...", + "settings.sourceProbeApply": "Use {source}", + "settings.sourceProbeOk": "OK", + "settings.sourceProbeFailed": "Failed", + "settings.sourceProbeStats": "{latency} ms · {speed}" } as const; export default enUS; diff --git a/tauri-app/src/locales/zh-CN.ts b/tauri-app/src/locales/zh-CN.ts index 1fed33d..14b1fd2 100644 --- a/tauri-app/src/locales/zh-CN.ts +++ b/tauri-app/src/locales/zh-CN.ts @@ -895,7 +895,17 @@ const zhCN: EnUSTranslations = { "crash.uploadConsent": "同意并发送", "crash.uploadDone": "已发送,感谢反馈!", "crash.uploadFailed": "发送失败:{error}", - "crash.close": "关闭" + "crash.close": "关闭", + "settings.downloadSource.auto": "自动(选择更快的源)", + "settings.downloadSourceHint": "自动会选用 Mojang 和 BMCLAPI 中响应更快的一个,下载失败时换用另一个。", + "settings.sourceProbe": "下载源测速", + "settings.sourceProbeHint": "在当前网络下测量各下载源的延迟和速度。", + "settings.sourceProbeRun": "开始测速", + "settings.sourceProbeRunning": "正在测速...", + "settings.sourceProbeApply": "改用{source}", + "settings.sourceProbeOk": "正常", + "settings.sourceProbeFailed": "失败", + "settings.sourceProbeStats": "{latency} ms · {speed}" } as const; export default zhCN; diff --git a/tauri-app/src/pages/Settings.tsx b/tauri-app/src/pages/Settings.tsx index 185281a..e2fc30d 100644 --- a/tauri-app/src/pages/Settings.tsx +++ b/tauri-app/src/pages/Settings.tsx @@ -5,6 +5,7 @@ import { EyeOff, Film, FolderOpen, + Gauge, Globe, ImagePlus, LogOut, @@ -35,7 +36,8 @@ import Button from "../components/Button"; import ChangelogNotes from "../components/ChangelogNotes"; import LiquidGlass from "../components/LiquidGlass"; import Select from "../components/Select"; -import { VISUAL_PROFILE_PRESETS, resolveVisualProfile } from "../constants"; +import { LAUNCHER_API_BASE_URL, VISUAL_PROFILE_PRESETS, resolveVisualProfile } from "../constants"; +import { notifyError } from "../lib/toast"; import { LOCALE_OPTIONS, useI18n } from "../i18n"; import { describeApiError } from "../lib/launcherError"; import type { @@ -47,6 +49,7 @@ import type { LauncherAppUpdateInfo, LauncherUser, Settings, + SourceProbeReport, ThemeAccent, ThemeMode, VisualProfile @@ -115,6 +118,8 @@ function SettingsPage({ const [customAccentDraft, setCustomAccentDraft] = useState(settings.customAccentHex); const [showLauncherUpdateNotes, setShowLauncherUpdateNotes] = useState(false); const [curseforgeKeyVisible, setCurseforgeKeyVisible] = useState(false); + const [probing, setProbing] = useState(false); + const [probeReport, setProbeReport] = useState(null); const backgroundInputRef = useRef(null); const backgroundVideoInputRef = useRef(null); const backgroundAccentRequestRef = useRef(0); @@ -190,6 +195,7 @@ function SettingsPage({ const themeModes: ThemeMode[] = ["dark", "light"]; const downloadSources: DownloadSource[] = [ + "auto", "official-only", "mirror-only", "mirror-first", @@ -214,6 +220,19 @@ function SettingsPage({ const hasBackgroundImage = activeBackgroundUrl.trim() !== ""; const hasBackground = hasBackgroundImage || activeBackgroundVideoUrl !== ""; + // Measures Mojang, BMCLAPI and the FPSMaster API; the Rust side also keeps + // the result for the "auto" source. + async function probeSources() { + setProbing(true); + try { + setProbeReport(await invoke("probe_download_sources", { apiBaseUrl: LAUNCHER_API_BASE_URL })); + } catch (error) { + notifyError(describeApiError(error, t), t("settings.sourceProbe")); + } finally { + setProbing(false); + } + } + async function requestBackgroundAccent( targetSettings: Settings, activateBackgroundAccent: boolean @@ -711,6 +730,7 @@ function SettingsPage({

{t("settings.downloadSource")}

+

{t("settings.downloadSourceHint")}

, which still stores a data URL. + async function handlePickBackgroundImage() { + let selected: string | string[] | null; + try { + selected = await open({ + multiple: false, + title: t("settings.backgroundImagePick"), + filters: [{ name: "Image", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] }] + }); + } catch { + backgroundInputRef.current?.click(); + return; + } + if (!selected || typeof selected !== "string") return; + try { + const storedPath = await invoke("import_background_image", { sourcePath: selected }); + applyBackgroundSettings({ ...settings, backgroundSource: "local", backgroundImage: storedPath }); + setBackgroundError(""); + } catch (error) { + setBackgroundError(t("settings.backgroundImportFailed", { error: describeApiError(error, t) })); + } + } + // Prefers the native file dialog (returns a persistent absolute path); when // Tauri is unavailable (browser dev), falls back to a hidden // whose object URL lives until the window closes. @@ -1295,7 +1321,7 @@ function SettingsPage({ variant="secondary" size="sm" className="flex-1 gap-2" - onClick={() => backgroundInputRef.current?.click()} + onClick={() => void handlePickBackgroundImage()} > {t("settings.backgroundUpload")} diff --git a/tauri-app/src/utils/launcher.ts b/tauri-app/src/utils/launcher.ts index 9e49e6c..c3ea0d0 100644 --- a/tauri-app/src/utils/launcher.ts +++ b/tauri-app/src/utils/launcher.ts @@ -381,14 +381,20 @@ export function resolveBackgroundAssetUrl(settings: Settings): string { if (settings.backgroundSource === "web-random") { return settings.backgroundWebUrl; } - if (settings.backgroundSource === "system") { - const value = settings.backgroundImage.trim(); - if (!value) { - return ""; - } - return value.startsWith("data:") ? value : convertFileSrc(value); + // "local" and "system" store a file path (see import_background_image); + // older settings and the browser-dev fallback still hold a data URL. + const value = settings.backgroundImage.trim(); + if (!value) { + return ""; + } + if (/^(data|blob|https?|asset):/i.test(value)) { + return value; + } + try { + return convertFileSrc(value); + } catch { + return ""; } - return settings.backgroundImage; } // Playable URL for the local background video. Absolute paths go through the From 9e02986c1aab20e2ce1c2fb020f8437141a7c853 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 03:56:00 -0700 Subject: [PATCH 22/24] fix(launcher): toast feedback for install checks and instance actions, launch guard Install validation messages and duplicate/export results were written only to the hidden status line; they are toasts now. The launch guard is reserved before the first await and cleared directly, so a double click cannot start two launches and a launch that ends before React renders cannot leave the guard stuck. Drops the unused hard-coded fallback news item. --- tauri-app/src/App.tsx | 25 ++++++++++++------------- tauri-app/src/constants.ts | 8 -------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index f5aefd4..98ef47f 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -18,7 +18,6 @@ import { DEFAULT_LOGIN_PREFS, DEFAULT_SETTINGS, LAUNCHER_API_BASE_URL, - NEWS_ITEMS, NOVA_DEFAULT_GAME_VERSION, PRESET_INSTANCES, STORAGE_KEYS, @@ -2002,6 +2001,9 @@ function Launcher() { } function endLaunch() { + // Cleared directly: the state round-trip can coalesce to "no change" when a + // launch ends before React rendered its start, leaving the guard stuck. + launchingInstanceRef.current = null; setBusy(false); setLaunchingInstanceId(null); } @@ -2025,10 +2027,13 @@ function Launcher() { notifyWarning(t("launch.warning.contentInstalling", { name: target.name })); return; } + // Reserved before the first await so a double click cannot start two launches. + launchingInstanceRef.current = target.id; if (activeGamePid !== null && activeGamePid > 0) { try { const runtime = await invoke("poll_game_runtime", { pid: activeGamePid }); if (runtime.running) { + launchingInstanceRef.current = null; notifyWarning(t("app.status.singleInstanceRunning", { pid: activeGamePid })); return; } @@ -2039,7 +2044,6 @@ function Launcher() { } const sessionId = createSessionId(); - launchingInstanceRef.current = target.id; openLaunchPrepare(target, sessionId); setLaunchError(null); setLaunchingInstanceId(target.id); @@ -2097,6 +2101,7 @@ function Launcher() { return; } + cancelledLaunchSessionsRef.current.delete(sessionId); await openMonitorForLaunch(launchResult.pid, target.name); completeLaunchPrepare(); endLaunch(); @@ -2293,23 +2298,15 @@ function Launcher() { const { installVersion, loader, loaderVersion, optiFineEnabled, optiFineVersion } = installCtl; if (!installVersion) return; if (loader !== "vanilla" && !loaderVersion) { - setStatus( - t("app.status.selectLoaderVersionFirst", { - loader: loaderDisplayName(loader) - }) - ); + notifyWarning(t("app.status.selectLoaderVersionFirst", { loader: loaderDisplayName(loader) })); return; } if (optiFineEnabled && loader === "fabric") { - setStatus(t("install.optifineFabricConflict")); + notifyWarning(t("install.optifineFabricConflict")); return; } if (optiFineEnabled && !optiFineVersion) { - setStatus( - t("app.status.selectLoaderVersionFirst", { - loader: t("loader.optifine") - }) - ); + notifyWarning(t("app.status.selectLoaderVersionFirst", { loader: t("loader.optifine") })); return; } @@ -2567,6 +2564,7 @@ function Launcher() { ); setSelected(duplicated.id); setStatus(t("app.status.instanceDuplicated", { name: source.name })); + notifySuccess(t("app.status.instanceDuplicated", { name: source.name })); } catch (error) { reportFailure(error); } @@ -2583,6 +2581,7 @@ function Launcher() { archiveName: source.name }); setStatus(t("app.status.instanceExported", { name: source.name })); + notifySuccess(t("app.status.instanceExported", { name: source.name })); } catch (error) { reportFailure(error); } diff --git a/tauri-app/src/constants.ts b/tauri-app/src/constants.ts index 0bc1e5c..71e60df 100644 --- a/tauri-app/src/constants.ts +++ b/tauri-app/src/constants.ts @@ -2,7 +2,6 @@ import type { BlurMode, Instance, LauncherLoginPrefs, - NewsItem, Settings, VisualProfile } from "./types"; @@ -42,13 +41,6 @@ export const LAUNCHER_API_BASE_URL = "https://api.fpsmaster.top"; // Account registration happens on the website, not in the launcher. export const REGISTER_URL = "https://fpsmaster.top/register"; -export const NEWS_ITEMS: readonly NewsItem[] = [ - { - title: "欢迎使用 FPSMaster", - summary: "登录后即可同步客户端包、查看最新公告,并快速启动常用实例。" - } -]; - export const DEFAULT_SETTINGS: Settings = { gameDir: "", playerName: "", From 8ccdfa7e1342745c1f2d2aa88876c9d76ba08766 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 04:07:11 -0700 Subject: [PATCH 23/24] fix(launcher): tag non-JSON API error pages before strict parsing Login, news, dashboard, home and app update parsers ran a strict JSON parse before the status check, so a proxy 502 HTML page or a plain-text framework 404 surfaced as raw English parse errors instead of a status-tagged API error the frontend can word in zh/en. --- tauri-app/src-tauri/src/launcher_api.rs | 59 ++++++++++++++++++------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/tauri-app/src-tauri/src/launcher_api.rs b/tauri-app/src-tauri/src/launcher_api.rs index e0272ea..35c850d 100644 --- a/tauri-app/src-tauri/src/launcher_api.rs +++ b/tauri-app/src-tauri/src/launcher_api.rs @@ -879,13 +879,15 @@ fn parse_launcher_login_response( }); } - let value: serde_json::Value = - serde_json::from_str(body).map_err(|e| format!("Invalid login response JSON: {e}"))?; - + // Status first: a non-JSON error page (proxy 502, framework 404) must still + // come back tagged so the frontend words it by status (contract §1). if !status.is_success() { return Err(api_failure(status, body, "login")); } + let value: serde_json::Value = + serde_json::from_str(body).map_err(|e| format!("Invalid login response JSON: {e}"))?; + let container = login_payload_container(&value); let token = extract_login_token(container) .or_else(|| extract_login_token(&value)) @@ -963,13 +965,13 @@ fn parse_launcher_news_response( return Ok(items); } - let value: serde_json::Value = serde_json::from_str(body) - .map_err(|e| format!("Invalid launcher news response JSON: {e}"))?; - if !status.is_success() { return Err(api_failure(status, body, "launcher news")); } + let value: serde_json::Value = serde_json::from_str(body) + .map_err(|e| format!("Invalid launcher news response JSON: {e}"))?; + extract_launcher_news(&value).ok_or_else(|| "launcher news response missing data".to_string()) } @@ -981,13 +983,13 @@ fn parse_launcher_dashboard_response( return Ok(item); } - let value: serde_json::Value = serde_json::from_str(body) - .map_err(|e| format!("Invalid launcher dashboard response JSON: {e}"))?; - if !status.is_success() { return Err(api_failure(status, body, "launcher dashboard")); } + let value: serde_json::Value = serde_json::from_str(body) + .map_err(|e| format!("Invalid launcher dashboard response JSON: {e}"))?; + serde_json::from_value::(value) .map_err(|e| format!("launcher dashboard response missing data: {e}")) } @@ -1000,13 +1002,13 @@ fn parse_launcher_home_response( return Ok(item); } - let value: serde_json::Value = serde_json::from_str(body) - .map_err(|e| format!("Invalid launcher home response JSON: {e}"))?; - if !status.is_success() { return Err(api_failure(status, body, "launcher home")); } + let value: serde_json::Value = serde_json::from_str(body) + .map_err(|e| format!("Invalid launcher home response JSON: {e}"))?; + serde_json::from_value::(value) .map_err(|e| format!("launcher home response missing data: {e}")) } @@ -1021,13 +1023,13 @@ fn parse_launcher_app_update_response( return Ok(item); } - let value: serde_json::Value = serde_json::from_str(body) - .map_err(|e| format!("Invalid launcher app update response JSON: {e}"))?; - if !status.is_success() { return Err(api_failure(status, body, "launcher app update")); } + let value: serde_json::Value = serde_json::from_str(body) + .map_err(|e| format!("Invalid launcher app update response JSON: {e}"))?; + serde_json::from_value::(value) .map_err(|e| format!("launcher app update response missing data: {e}")) } @@ -1231,7 +1233,10 @@ fn find_error_message_in_value(value: &serde_json::Value) -> Option { #[cfg(test)] mod tests { - use super::{api_failure, parse_launcher_versions_response, API_ERROR_PREFIX}; + use super::{ + api_failure, parse_launcher_app_update_response, parse_launcher_login_response, + parse_launcher_versions_response, API_ERROR_PREFIX, + }; fn decode(error: &str) -> serde_json::Value { let json = error @@ -1268,4 +1273,26 @@ mod tests { assert_eq!(value["status"], 404); assert!(value["code"].is_null()); } + + #[test] + fn login_proxy_html_error_keeps_the_status() { + let error = parse_launcher_login_response( + reqwest::StatusCode::BAD_GATEWAY, + "

502 Bad Gateway

", + ) + .unwrap_err(); + let value = decode(&error); + assert_eq!(value["status"], 502); + assert!(value["message"].is_null()); + } + + #[test] + fn app_update_plain_text_404_keeps_the_status() { + let error = parse_launcher_app_update_response( + reqwest::StatusCode::NOT_FOUND, + "Unable to find matching target resource method", + ) + .unwrap_err(); + assert_eq!(decode(&error)["status"], 404); + } } From 096cf5afb2ea3e5dabc2af6e7ceef94566ae46e5 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 16 Oct 2026 04:11:49 -0700 Subject: [PATCH 24/24] fix(launcher): route native window close and app quit through the busy check The macOS traffic-light close, Alt+F4, Cmd+Q and tray Quit bypassed closeLauncherWindow, so they quit mid-download without confirmation or cancel_all_downloads, and hid to the tray without the first-time notice. The main window frontend now handles CloseRequested and a quit-requested event and claims the decision from the backend; Cmd+Q is a custom app menu item because the predefined one terminates immediately. Without a live frontend the backend keeps its old behaviour. --- tauri-app/src-tauri/src/main.rs | 84 ++++++++++++++++++++++++++++++--- tauri-app/src/App.tsx | 56 ++++++++++++++++++++-- 2 files changed, 131 insertions(+), 9 deletions(-) diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index 223e8d9..e474808 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -719,6 +719,8 @@ const CREATE_NO_WINDOW: u32 = 0x0800_0000; const TRAY_SHOW_ID: &str = "tray_show"; const TRAY_HIDE_ID: &str = "tray_hide"; const TRAY_QUIT_ID: &str = "tray_quit"; +#[cfg(target_os = "macos")] +const APP_MENU_QUIT_ID: &str = "app_menu_quit"; const GAME_RUNTIME_CACHE_TTL_MS: u128 = 3000; const GAME_RUNTIME_SAMPLE_INTERVAL_MS: u64 = 2000; // The monitor UI keeps at most 1000 log lines, so poll batches beyond that are wasted work. @@ -866,8 +868,14 @@ fn main_window(app: &AppHandle) -> Result { fn attach_main_window_handlers(window: &WebviewWindow, app: &AppHandle) { let app_handle = app.clone(); - window.on_window_event(move |event| { - if let WindowEvent::CloseRequested { api, .. } = event { + window.on_window_event(move |event| match event { + WindowEvent::CloseRequested { api, .. } => { + // Once the frontend claimed the close it decides (tray notice, + // busy confirmation); Tauri already prevented the close for its + // JS listener, and hiding here would skip both. + if frontend_handles_close(&app_handle) { + return; + } if should_minimize_to_tray(&app_handle) { api.prevent_close(); let _ = hide_main_window_internal(&app_handle); @@ -875,6 +883,9 @@ fn attach_main_window_handlers(window: &WebviewWindow, app: &AppHandle) { flush_launcher_telemetry_session(&app_handle); } } + // A recreated window gets a fresh frontend that must claim again. + WindowEvent::Destroyed => set_frontend_handles_close(&app_handle, false), + _ => {} }); } @@ -932,6 +943,58 @@ fn show_main_window_internal(app: &AppHandle) -> Result<(), String> { Ok(()) } +fn frontend_handles_close(app: &AppHandle) -> bool { + app.state::() + .frontend_handles_close + .load(Ordering::Relaxed) +} + +fn set_frontend_handles_close(app: &AppHandle, value: bool) { + app.state::() + .frontend_handles_close + .store(value, Ordering::Relaxed); +} + +/// Quit requests from the tray and the macOS app menu (Cmd+Q). A live frontend +/// asks first when downloads are running (launcher-ui-24); without one there is +/// nothing to lose, so quit directly. +fn request_app_quit(app: &AppHandle) { + if frontend_handles_close(app) { + if let Some(window) = app.get_webview_window("main") { + if window.emit("fpsmaster://quit-requested", ()).is_ok() { + return; + } + } + } + flush_launcher_telemetry_session(app); + app.exit(0); +} + +/// The default macOS menu's Quit item terminates the process on the spot (tao +/// has no applicationShouldTerminate hook), so swap it for one we can route. +#[cfg(target_os = "macos")] +fn route_app_menu_quit(app: &AppHandle) -> tauri::Result<()> { + use tauri::menu::MenuItemKind; + let Some(menu) = app.menu() else { + return Ok(()); + }; + let Some(MenuItemKind::Submenu(app_menu)) = menu.items()?.into_iter().next() else { + return Ok(()); + }; + if let Some(last @ MenuItemKind::Predefined(_)) = app_menu.items()?.into_iter().last() { + app_menu.remove(&last)?; + } + let label = format!("Quit {}", app.package_info().name); + let quit = MenuItem::with_id(app, APP_MENU_QUIT_ID, label, true, Some("CmdOrCtrl+Q"))?; + app_menu.append(&quit)?; + app.on_menu_event(|app, event| { + if event.id().as_ref() == APP_MENU_QUIT_ID { + request_app_quit(app); + } + }); + Ok(()) +} + fn should_minimize_to_tray(app: &AppHandle) -> bool { app.state::() .minimize_to_tray @@ -944,6 +1007,8 @@ fn is_autostart_launch() -> bool { struct LauncherRuntimeState { minimize_to_tray: AtomicBool, + /// Set once the main window's frontend registered its close/quit handlers. + frontend_handles_close: AtomicBool, telemetry_session: Mutex>, /// The one live heartbeat loop. Each start creates its own handle, so a /// stop followed by a quick start can never revive an older thread @@ -955,6 +1020,7 @@ impl Default for LauncherRuntimeState { fn default() -> Self { Self { minimize_to_tray: AtomicBool::new(true), + frontend_handles_close: AtomicBool::new(false), telemetry_session: Mutex::new(None), heartbeat: Mutex::new(None), } @@ -1174,6 +1240,11 @@ fn hide_main_window(app: tauri::AppHandle) -> Result<(), String> { hide_main_window_internal(&app) } +#[tauri::command] +fn claim_main_window_close(app: tauri::AppHandle) { + set_frontend_handles_close(&app, true); +} + #[tauri::command] fn configure_tray_behavior(app: tauri::AppHandle, minimize_to_tray: bool) { app.state::() @@ -1221,10 +1292,7 @@ fn create_tray(app: &AppHandle) -> Result<(), String> { TRAY_HIDE_ID => { let _ = hide_main_window_internal(app); } - TRAY_QUIT_ID => { - flush_launcher_telemetry_session(app); - app.exit(0); - } + TRAY_QUIT_ID => request_app_quit(app), _ => {} }) .on_tray_icon_event(|tray, event| { @@ -12027,6 +12095,8 @@ fn main() { .setup(|app| { let _ = app.path().app_data_dir(); create_tray(&app.handle())?; + #[cfg(target_os = "macos")] + route_app_menu_quit(&app.handle())?; let launched_from_autostart = is_autostart_launch(); if let Some(window) = app.get_webview_window("main") { attach_main_window_handlers(&window, &app.handle()); @@ -12057,6 +12127,7 @@ fn main() { lookup_minecraft_skin_url, open_external_link, quit_launcher_app, + claim_main_window_close, destroy_current_window, launcher_cache_telemetry_session, launcher_offline_telemetry_session, @@ -12113,6 +12184,7 @@ fn main() { show_main_window, hide_main_window, quit_launcher_app, + claim_main_window_close, configure_tray_behavior, set_launch_on_startup, terminate_game_process, diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index 98ef47f..2f6925e 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -2970,11 +2970,26 @@ function Launcher() { } // Quitting mid-download used to drop it silently and leave half-written // version dirs behind (launcher-ui-24). - if (busy || installDialogRef.current !== null || hasContentTasks() || launcherUpdate.downloading) { + if (hasRunningWork()) { setCloseConfirmOpen(true); return; } - await getCurrentWindow().close(); + await invoke("quit_launcher_app"); + } + + // Tray "Quit" and macOS Cmd+Q: an explicit quit, so no tray detour, but the + // same confirmation while something is still downloading. + async function requestLauncherQuit() { + if (hasRunningWork()) { + await invoke("show_main_window"); + setCloseConfirmOpen(true); + return; + } + await invoke("quit_launcher_app"); + } + + function hasRunningWork() { + return busy || installDialogRef.current !== null || hasContentTasks() || launcherUpdate.downloading; } function answerAutostartPrompt(enabled: boolean) { @@ -2999,7 +3014,7 @@ function Launcher() { } catch (error) { console.warn("[close] cancel_all_downloads failed:", error); } - await getCurrentWindow().close(); + await invoke("quit_launcher_app"); } } @@ -3024,6 +3039,41 @@ function Launcher() { const stableUpdateMemory = useStableCallback(updateMemory); const stableLogout = useStableCallback(logoutLauncherAccount); const stableCloseWindow = useStableCallback(closeLauncherWindow); + const stableRequestQuit = useStableCallback(requestLauncherQuit); + + // The native close (macOS traffic light, Alt+F4) and app quit (Cmd+Q, tray + // Quit) used to bypass closeLauncherWindow: no tray notice, no download + // confirmation. Route them here, then tell the backend we own the decision. + useEffect(() => { + let disposed = false; + const unlisteners: Array<() => void> = []; + // An unmount can land between awaits (StrictMode remount); drop late + // listeners right away so the handlers never fire twice. + const keep = (unlisten: () => void) => { + if (disposed) unlisten(); + else unlisteners.push(unlisten); + }; + const bind = async () => { + try { + keep( + await getCurrentWindow().onCloseRequested((event) => { + event.preventDefault(); + void stableCloseWindow(); + }) + ); + keep(await listen("fpsmaster://quit-requested", () => void stableRequestQuit())); + if (disposed) return; + await invoke("claim_main_window_close"); + } catch (error) { + console.warn("[close] failed to bind window close handlers:", error); + } + }; + void bind(); + return () => { + disposed = true; + unlisteners.forEach((unlisten) => unlisten()); + }; + }, [stableCloseWindow, stableRequestQuit]); const stableOnLocaleChange = useStableCallback((locale: Locale) => { setSettings((prev) => ({ ...prev, language: locale })); setStatus(createTranslator(locale)("app.status.ready"));