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/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..258073f --- /dev/null +++ b/tauri-app/src-tauri/src/diagnostics.rs @@ -0,0 +1,1333 @@ +//! 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::atomic::{AtomicBool, Ordering}; +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 { + // Nova installs one directory per game version: FPSMaster-Nova-. + if version_id == "FPSMaster-Nova" || version_id.starts_with("FPSMaster-Nova-") { + return "NOVA"; + } + match version_id { + "FPSMaster-Edge" => "EDGE", + _ => "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. +/// +/// 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 { + 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; + } + } + 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 +/// 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, + target_path: Option<&Path>, +) -> 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 path = match target_path { + // A location the user picked in the save dialog. + Some(target) if target.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("zip")) => { + target.to_path_buf() + } + Some(target) => target.with_extension("zip"), + None => { + 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([':', '-'], ""); + 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 `target_path` (picked by the +/// user in a save dialog) or, without one, to `/exports/`. +#[tauri::command] +pub(crate) async fn export_support_bundle( + app: AppHandle, + game_dir: String, + version_id: Option, + health_report: Option, + target_path: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let game_dir = crate::resolve_game_dir_path(&game_dir)?; + let target = target_path + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + export_support_bundle_blocking( + &app, + &game_dir, + version_id.as_deref(), + health_report, + target.as_deref(), + ) + }) + .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 new file mode 100644 index 0000000..aabc174 --- /dev/null +++ b/tauri-app/src-tauri/src/downloader.rs @@ -0,0 +1,1199 @@ +//! 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..35c850d 100644 --- a/tauri-app/src-tauri/src/launcher_api.rs +++ b/tauri-app/src-tauri/src/launcher_api.rs @@ -1,19 +1,19 @@ 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)] pub struct ApiEnvelope { pub success: bool, + #[serde(default)] + pub code: Option, pub message: Option, pub data: Option, } @@ -385,9 +385,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}"))? @@ -466,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) @@ -535,7 +539,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 +571,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 +597,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 +614,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 +627,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 +663,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 @@ -853,14 +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(extract_api_error_message(body) - .unwrap_or_else(|| format!("login failed with HTTP {}", status.as_u16()))); + 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)) @@ -884,22 +911,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}" )); @@ -909,33 +932,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); @@ -943,17 +940,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) @@ -978,14 +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(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")); } + 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()) } @@ -997,23 +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() { - 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")); } + 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}")) } @@ -1026,23 +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() { - 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")); } + 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}")) } @@ -1057,15 +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(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")); } + 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}")) } @@ -1078,56 +1042,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) } @@ -1266,3 +1230,69 @@ fn find_error_message_in_value(value: &serde_json::Value) -> Option { None } + +#[cfg(test)] +mod tests { + 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 + .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()); + } + + #[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); + } +} diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index aefb0d3..e474808 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -1,5 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod diagnostics; +mod downloader; mod launcher_api; mod microsoft_auth; mod minecraft_core; @@ -18,7 +20,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}; @@ -592,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)] @@ -659,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. @@ -773,12 +835,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); @@ -795,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); @@ -804,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), + _ => {} }); } @@ -861,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 @@ -873,22 +1007,49 @@ 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>, - 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 { fn default() -> Self { Self { minimize_to_tray: AtomicBool::new(true), + frontend_handles_close: AtomicBool::new(false), 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")] @@ -1079,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::() @@ -1126,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| { @@ -1175,12 +1338,29 @@ fn terminate_game_process_blocking(pid: i64, force: Option) -> Result 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, @@ -1321,6 +1521,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"); @@ -1402,8 +1605,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, @@ -1416,6 +1639,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"); @@ -1441,16 +1666,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); @@ -1538,7 +1781,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()); @@ -1587,6 +1830,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, @@ -1594,6 +1840,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)?; @@ -1611,13 +1860,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 @@ -1626,8 +1876,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()); @@ -1638,19 +1891,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()); @@ -1660,17 +1917,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(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); @@ -1749,16 +2008,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}")) @@ -1851,17 +2118,91 @@ fn get_default_game_dir() -> Result { .to_string()) } +/// Copies the current system wallpaper into the launcher's background folder +/// and returns that path (see `import_background_image_into`). #[tauri::command] -fn get_system_wallpaper() -> Result, String> { - let path = match get_system_wallpaper_path()? { - Some(path) => path, - None => return Ok(None), +async fn get_system_wallpaper(app: tauri::AppHandle) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let path = match get_system_wallpaper_path()? { + Some(path) => path, + None => return Ok(None), + }; + import_background_image_into(&app, Path::new(&path)).map(Some) + }) + .await + .map_err(|e| format!("Failed to join wallpaper task: {e}"))? +} + +/// Copies a user-picked image into the launcher's background folder. +#[tauri::command] +async fn import_background_image(app: tauri::AppHandle, source_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + import_background_image_into(&app, Path::new(source_path.trim())) + }) + .await + .map_err(|e| format!("Failed to join background import task: {e}"))? +} + +/// Settings keep only a file path to the background: a base64 data URL in +/// localStorage overflowed its ~5 MB quota and crashed the UI (launcher-ui-2). +/// The copy lives under `/backgrounds/`, which the asset protocol +/// scope allows, so images from any drive display and survive the original +/// being moved. Older copies are removed; only one background is active. +fn import_background_image_into(app: &tauri::AppHandle, source: &Path) -> Result { + const MAX_BYTES: u64 = 32 * 1024 * 1024; + let size = fs::metadata(source) + .map_err(|e| format!("Failed to read background image {}: {e}", source.display()))? + .len(); + if size > MAX_BYTES { + return Err(format!("Background image is larger than 32 MB: {}", source.display())); + } + let bytes = fs::read(source) + .map_err(|e| format!("Failed to read background image {}: {e}", source.display()))?; + let extension = match detect_image_mime(source, &bytes)? { + "image/jpeg" => "jpg", + "image/png" => "png", + "image/gif" => "gif", + "image/bmp" => "bmp", + _ => "webp", }; - Ok(Some(read_image_file_as_data_url(Path::new(&path))?)) + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to resolve the app data directory: {e}"))? + .join("backgrounds"); + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?; + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let _ = fs::remove_file(entry.path()); + } + } + let target = dir.join(format!("background-{}.{extension}", now_epoch_millis())); + fs::write(&target, &bytes).map_err(|e| format!("Failed to write {}: {e}", target.display()))?; + Ok(strip_windows_verbatim_prefix(&target).to_string_lossy().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 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, @@ -1880,10 +2221,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 +2239,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,10 +2255,12 @@ 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, needs_x64, + false, )?; emit_log( @@ -2004,64 +2353,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(()) @@ -2069,12 +2409,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] @@ -2104,9 +2443,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, @@ -2115,7 +2455,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}")) @@ -2158,10 +2501,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 +2515,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}")) @@ -2188,9 +2536,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, @@ -2200,7 +2549,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}")) @@ -2349,6 +2701,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" { @@ -2400,11 +2753,11 @@ fn install_modrinth_project_blocking( sanitize_file_name(&file.filename) )); download_file_quiet_with_progress_blocking( - &client, &file.url, &download_path, window.as_ref(), Some(&project_key), + ipc_session, ) .map_err(|err| format!("Failed to download Modrinth file {}: {err}", file.filename))?; @@ -2433,24 +2786,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, @@ -2594,6 +2942,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" { @@ -2660,11 +3009,11 @@ 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(), Some(&project_key), + ipc_session, ) .map_err(|err| { format!( @@ -2675,24 +3024,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, @@ -2853,17 +3197,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"); @@ -2896,7 +3236,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()); @@ -3130,16 +3470,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 +3513,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 +3561,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,11 +3851,11 @@ fn install_fabric_api_blocking( "Downloading", ); if let Err(err) = download_file_quiet_with_progress_blocking( - &client, &file.url, &download_path, window, Some("fabric-api"), + ipc_session, ) { let _ = fs::remove_file(&download_path); return Err(format!( @@ -3616,6 +3951,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 +4056,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 +4092,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 +4139,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 +4533,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 +4578,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 +4615,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,21 +4669,61 @@ 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 调用必须快速失败。 +/// UI language the backend localizes `message` into (Accept-Language). Set by +/// the frontend via `set_ui_language`; English until then, which is also what +/// the backend falls back to without the header. +fn ui_language() -> &'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)) @@ -5339,11 +5706,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()) @@ -5352,20 +5741,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(); @@ -5630,10 +6022,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; @@ -5833,14 +6227,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 +6256,34 @@ 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( - client: &reqwest::blocking::Client, url: &str, target: &Path, window: Option<&tauri::Window>, project_key: Option<&str>, + ipc_session: Option<&str>, ) -> Result<(), String> { - download_file_with_progress_callback_blocking(client, url, target, |downloaded, total| { + 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); } }) } -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( @@ -6041,12 +6350,33 @@ fn normalize_manifest_relative_path(raw: &str) -> 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() )); @@ -6082,13 +6412,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(()) @@ -6262,6 +6599,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, @@ -6498,6 +6842,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() { @@ -6517,7 +6862,14 @@ 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 launched_at = SystemTime::now(); + 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); @@ -6557,13 +6909,7 @@ fn launch_vanilla_blocking( let _ = stderr_handle.join(); let exit_code = status.code().unwrap_or(-1); cleanup_launch_natives_dir(&natives_dir); - let _ = window.emit("game-exit", GameExitEvent { pid, exit_code }); - push_ui_log( - "game", - "exit", - &format!("process exited pid={pid} code={exit_code}"), - ); - clear_runtime_pid(pid); + report_game_exit(&window, pid, exit_code, &version_id, &runtime_dir, launched_at); return Ok(LaunchExecutionResult { version_id, @@ -6572,12 +6918,14 @@ 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), }); } let wait_natives_dir = natives_dir.clone(); let wait_window = window.clone(); + let wait_version_id = version_id.clone(); + let wait_runtime_dir = runtime_dir.clone(); thread::spawn(move || { let exit_code = child .wait() @@ -6587,13 +6935,14 @@ fn launch_vanilla_blocking( let _ = stdout_handle.join(); let _ = stderr_handle.join(); cleanup_launch_natives_dir(&wait_natives_dir); - let _ = wait_window.emit("game-exit", GameExitEvent { pid, exit_code }); - push_ui_log( - "game", - "exit", - &format!("process exited pid={pid} code={exit_code}"), + report_game_exit( + &wait_window, + pid, + exit_code, + &wait_version_id, + &wait_runtime_dir, + launched_at, ); - clear_runtime_pid(pid); }); Ok(LaunchExecutionResult { @@ -6607,7 +6956,7 @@ fn launch_vanilla_blocking( } else { "direct".to_string() }, - command: normalized_command, + command: redact_launch_secrets(normalized_command), }) } @@ -6670,14 +7019,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(()) } @@ -6689,9 +7042,21 @@ async fn install_native_app( download_url: String, version_tag: Option, checksum: Option, + ipc_session: Option, + force: 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, + force.unwrap_or(false), + ); + clear_install_cancel(ipc_session.as_deref()); + result }) .await .map_err(|e| format!("Failed to join native app install task: {e}")) @@ -6699,12 +7064,16 @@ 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, version_id: String, 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")); @@ -6728,7 +7097,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 @@ -6744,14 +7113,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 +7121,26 @@ 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); + 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))) + .on_progress(&on_progress), + ) + .map_err(|err| format!("Failed to download {version_id}: {err}"))?; let apps_dir = install_dir.parent().ok_or_else(|| { format!( @@ -6858,9 +7234,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 @@ -6926,12 +7309,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, @@ -7023,7 +7421,8 @@ fn launch_native_app_blocking( ), ); - let mut child = spawn_game_process(Some(&window), &install_dir, &executable, &args)?; + let launched_at = SystemTime::now(); + 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()); @@ -7050,13 +7449,7 @@ fn launch_native_app_blocking( let _ = stdout_handle.join(); let _ = stderr_handle.join(); let exit_code = status.code().unwrap_or(-1); - let _ = window.emit("game-exit", GameExitEvent { pid, exit_code }); - push_ui_log( - "game", - "exit", - &format!("process exited pid={pid} code={exit_code}"), - ); - clear_runtime_pid(pid); + report_game_exit(&window, pid, exit_code, &version_id, &install_dir, launched_at); return Ok(LaunchExecutionResult { version_id, pid, @@ -7069,6 +7462,8 @@ fn launch_native_app_blocking( } let wait_window = window.clone(); + let wait_version_id = version_id.clone(); + let wait_install_dir = install_dir.clone(); thread::spawn(move || { let exit_code = child .wait() @@ -7077,13 +7472,14 @@ fn launch_native_app_blocking( .unwrap_or(-1); let _ = stdout_handle.join(); let _ = stderr_handle.join(); - let _ = wait_window.emit("game-exit", GameExitEvent { pid, exit_code }); - push_ui_log( - "game", - "exit", - &format!("process exited pid={pid} code={exit_code}"), + report_game_exit( + &wait_window, + pid, + exit_code, + &wait_version_id, + &wait_install_dir, + launched_at, ); - clear_runtime_pid(pid); }); Ok(LaunchExecutionResult { @@ -7237,8 +7633,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, @@ -7547,6 +7963,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() @@ -7652,10 +8097,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()) @@ -7749,6 +8196,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())) } @@ -7765,19 +8233,69 @@ fn managed_jdk_runtime_root(game_dir: &Path, major: i32, x64: bool) -> PathBuf { game_dir.join("runtime").join(name) } -// 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 -// macOS >= 14.4. LWJGL 3 majors (16/17) don't use AWT for the window, so Mojang's -// builds stay in use there. -fn prefers_zulu_over_mojang_x64(major: i32, force_x64: bool) -> bool { - cfg!(target_os = "macos") && force_x64 && major == 8 +/// 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)) } -#[cfg(all(target_os = "macos", target_arch = "aarch64"))] -fn ensure_x64_emulation_available() -> Result<(), String> { - let status = Command::new("/usr/bin/arch") - .args(["-x86_64", "/usr/bin/true"]) +/// 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, + 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 cancel = downloader::CancelToken::new(ipc_session.as_deref()); + let result = ensure_managed_jdk_runtime( + Some(&window), + &cancel, + &root, + major, + Some(DEFAULT_DOWNLOAD_THREADS), + x64, + true, + ); + 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 +// macOS >= 14.4. LWJGL 3 majors (16/17) don't use AWT for the window, so Mojang's +// builds stay in use there. +fn prefers_zulu_over_mojang_x64(major: i32, force_x64: bool) -> bool { + cfg!(target_os = "macos") && force_x64 && major == 8 +} + +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +fn ensure_x64_emulation_available() -> Result<(), String> { + let status = Command::new("/usr/bin/arch") + .args(["-x86_64", "/usr/bin/true"]) .status() .map_err(|e| format!("Failed to check Rosetta 2 availability: {e}"))?; if status.success() { @@ -7803,57 +8321,324 @@ 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 = 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) + .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()) +} + +const RUNTIME_NOT_COMPLETED: &str = "installation was not completed (no completion marker)"; + +/// Runtimes installed before completion markers existed have none, yet are +/// usually complete: without adoption every upgraded user would see them as +/// broken and the next launch would move them aside and need the network. +/// A marker is generated from the current files only when the tree looks +/// like a finished JRE; anything that hints at an interrupted install (a +/// staging sibling, download temp files, no class library or JVM library) +/// stays "not completed" and goes through the repair path. +fn adopt_legacy_runtime(runtime_root: &Path) -> 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> { + 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) +} + +/// 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, runtime_root: &Path, major: i32, download_threads: Option, force_x64: bool, + reinstall: bool, ) -> Result { 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) { - // 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 discard_existing = false; + 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+", - ); - 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 { - 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(_) => {} } } + // Install into a sibling staging directory and only promote it once the + // 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); let platform_keys = mojang_java_platform_keys(); @@ -7867,7 +8652,7 @@ fn ensure_managed_jdk_runtime( ), ); - 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 +8664,149 @@ 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()?; + 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", + &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()?; + 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, + "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()) + 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_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| { + format!( + "Failed to replace runtime {}: {e}", + runtime_root.display() + ) + })?; + } + fs::rename(staging_dir, 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)] @@ -8066,34 +8910,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, @@ -8101,11 +8917,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}")) @@ -8125,6 +8944,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, @@ -8139,13 +9111,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}")) @@ -8181,10 +9159,12 @@ 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), false, + false, )?) } else { None @@ -8244,10 +9224,12 @@ 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), false, + false, )? .to_string_lossy() .to_string() @@ -8321,6 +9303,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, @@ -8494,12 +9509,13 @@ fn mojang_java_platform_keys() -> Vec<&'static str> { /// when a runtime was installed. fn install_mojang_java_runtime( window: Option<&tauri::Window>, - runtime_root: &Path, + cancel: &downloader::CancelToken, + staging: &RuntimeStaging, 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 +9537,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 +9551,31 @@ 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) + 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() + .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 +9612,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>, - runtime_root: &Path, + cancel: &downloader::CancelToken, + staging: &RuntimeStaging, 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, staging, major, download_threads, arch) } /// Downloads and extracts a Zulu JRE for an explicit CPU arch into `runtime_root`. @@ -8586,11 +9626,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>, - runtime_root: &Path, + cancel: &downloader::CancelToken, + staging: &RuntimeStaging, 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 +9650,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 = 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) { + 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 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()))?; 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 +9697,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 +9813,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 +9841,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 +9915,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}") })?; @@ -8860,6 +9938,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(), @@ -8871,21 +9991,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 +10030,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 +10129,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 +10187,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() { @@ -9926,8 +10671,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) @@ -9956,11 +10708,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) } @@ -10118,6 +10880,181 @@ 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![ + "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(); + 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()); + 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(); + 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") + ); + } + + 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#"{ @@ -10219,6 +11156,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"); @@ -10243,6 +11181,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)] @@ -10258,7 +11200,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"); @@ -10552,7 +11494,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!( @@ -10618,6 +11560,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(), @@ -10631,14 +11584,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()); @@ -10840,13 +11799,6 @@ fn normalize_system_wallpaper_path(path: String) -> Result, Strin )) } -fn read_image_file_as_data_url(path: &Path) -> Result { - let bytes = fs::read(path).map_err(|e| format!("Failed to read wallpaper image: {e}"))?; - let mime = detect_image_mime(path, &bytes)?; - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - Ok(format!("data:{mime};base64,{encoded}")) -} - fn detect_image_mime(path: &Path, bytes: &[u8]) -> Result<&'static str, String> { if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF { return Ok("image/jpeg"); @@ -10914,7 +11866,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") @@ -11143,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()); @@ -11153,6 +12107,7 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + set_ui_language, ensure_jdk, launcher_login, launcher_list_available_versions, @@ -11172,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, @@ -11200,6 +12156,7 @@ fn main() { build_vanilla_launch_plan, launch_vanilla, cancel_install, + cancel_all_downloads, list_fabric_loaders, install_fabric, list_forge_versions, @@ -11222,16 +12179,26 @@ fn main() { toggle_mod_disabled, get_default_game_dir, get_system_wallpaper, + import_background_image, extract_background_theme_accent, show_main_window, hide_main_window, quit_launcher_app, + claim_main_window_close, configure_tray_behavior, set_launch_on_startup, terminate_game_process, secure_storage_get, secure_storage_set, - secure_storage_delete + secure_storage_delete, + 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, + diagnostics::probe_download_sources ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); 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!( diff --git a/tauri-app/src-tauri/src/minecraft_core.rs b/tauri-app/src-tauri/src/minecraft_core.rs index a3ac50e..a5f6d03 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}; @@ -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}")), } } @@ -229,6 +231,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)] @@ -596,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, @@ -1366,6 +1528,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 +1553,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, @@ -1678,9 +1842,10 @@ 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( + ipc_session, game_dir, java_executable, &base_jar, @@ -1705,7 +1870,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}", @@ -1791,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!( @@ -1853,7 +2027,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}", @@ -1898,6 +2072,7 @@ fn download_standard_launchwrapper( } fn run_optifine_patcher( + ipc_session: Option<&str>, game_dir: &Path, java_executable: &Path, base_jar: &Path, @@ -1913,7 +2088,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 +2124,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<()> { @@ -2092,21 +2334,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() @@ -2176,6 +2406,7 @@ pub(crate) fn build_vanilla_launch_plan( main_class, }, natives_dir, + env, }) } @@ -2485,21 +2716,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 @@ -2562,6 +2781,7 @@ pub(crate) fn build_edge_aot_launch_plan( main_class, }, natives_dir, + env, }) } @@ -3502,98 +3722,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 +3747,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 +3765,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( @@ -4348,6 +4279,7 @@ fn install_forge_old_profile( } fn run_forge_installer( + ipc_session: Option<&str>, game_dir: &Path, java_executable: &Path, installer_path: &Path, @@ -4360,26 +4292,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( @@ -4998,10 +4915,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 +5008,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>, @@ -5605,6 +5488,102 @@ mod tests { should_omit_resolved_arg, DownloadSource, MavenCoordinates, StandaloneOptiFineOverlay, 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 { + 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() { + 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}; 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, 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()); diff --git a/tauri-app/src-tauri/tauri.conf.json b/tauri-app/src-tauri/tauri.conf.json index 3e6f8b6..de77157 100644 --- a/tauri-app/src-tauri/tauri.conf.json +++ b/tauri-app/src-tauri/tauri.conf.json @@ -29,7 +29,8 @@ "$PICTURE/**", "$VIDEO/**", "$DOWNLOAD/**", - "$DESKTOP/**" + "$DESKTOP/**", + "$APPDATA/backgrounds/**" ] }, "csp": "default-src 'self' tauri: ipc: http://ipc.localhost; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https: tauri: asset: http://asset.localhost; media-src 'self' blob: data: asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost https://api.fpsmaster.top https://picsum.photos https://textures.minecraft.net https://crafatar.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index 2ee96e4..2f6925e 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -11,12 +11,13 @@ import LaunchErrorDialog from "./components/LaunchErrorDialog"; import LaunchPrepareDialog from "./components/LaunchPrepareDialog"; import Sidebar from "./components/Sidebar"; import ToastViewport from "./components/ToastViewport"; +import ChoiceDialog from "./components/ChoiceDialog"; +import CrashDialog from "./components/CrashDialog"; import WindowTitleBar from "./components/WindowTitleBar"; import { DEFAULT_LOGIN_PREFS, DEFAULT_SETTINGS, LAUNCHER_API_BASE_URL, - NEWS_ITEMS, NOVA_DEFAULT_GAME_VERSION, PRESET_INSTANCES, STORAGE_KEYS, @@ -27,6 +28,7 @@ import { I18nProvider, resolveLocale } from "./i18n"; +import { acceptLanguageFor, syncApiLanguage } from "./lib/apiLanguage"; import { mapLaunchPreparePhaseKey, mergePhaseByteProgress, @@ -40,15 +42,19 @@ import { isLauncherVersionCompatible } from "./lib/version"; import { describeApiError, isAuthExpiredError, + isCancelledError, + isNetworkError, normalizeLoginError } from "./lib/launcherError"; -import { notifyError, notifyWarning } from "./lib/toast"; +import { notifyError, notifyInfo, notifySuccess, notifyWarning } from "./lib/toast"; +import { hasContentTasks, isContentTaskRunningFor } from "./lib/contentTasks"; import { parseMinecraftAccounts, resolveMinecraftLaunchIdentity, shouldRefreshMicrosoftAccount } from "./lib/minecraftAccount"; import { + loadOrCreateLauncherSessionId, normalizeStoredToken, parseLauncherAuthState, parseLauncherLoginPrefs, @@ -78,6 +84,7 @@ import type { EdgeAotInstallResult, FabricInstallResult, ForgeInstallResult, + GameExitEvent, GameRuntimeStats, InstalledContentItem, InstalledContentUpdate, @@ -103,6 +110,7 @@ import type { Loader, MinecraftAccount, ModpackInstallResult, + OfflineLaunchReadiness, OptiFineInstallResult, OptiFineVersion, Page, @@ -243,12 +251,22 @@ function Launcher() { const [windowVisible, setWindowVisible] = useState(false); const autoLoginAttemptedRef = useRef(false); const [minecraftAccountPromptOpen, setMinecraftAccountPromptOpen] = useState(false); + const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); + const [crashReport, setCrashReport] = useState(null); + const [autostartPromptOpen, setAutostartPromptOpen] = useState( + () => localStorage.getItem(STORAGE_KEYS.autostartPrompted) !== "1" + ); + const [trayNoticeOpen, setTrayNoticeOpen] = useState(false); const logCursorRef = useRef(null); const pollingRef = useRef(false); 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]); @@ -270,6 +288,7 @@ function Launcher() { user: launcherAuth?.user ?? null, playerName: settings.playerName, backgroundMode, + language: settings.language, t, onAuthExpired: handleAuthExpired }); @@ -344,11 +363,19 @@ function Launcher() { }, []); useEffect(() => { - localStorage.setItem(STORAGE_KEYS.instances, JSON.stringify(instances)); + persistLocal(STORAGE_KEYS.instances, instances); }, [instances]); + // Debounced: dragging an appearance slider used to serialize and write the + // whole settings object on every frame. Flushed when the page goes away. useEffect(() => { - localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings)); + const flush = () => persistLocal(STORAGE_KEYS.settings, settings); + const timer = window.setTimeout(flush, 250); + window.addEventListener("pagehide", flush); + return () => { + window.clearTimeout(timer); + window.removeEventListener("pagehide", flush); + }; }, [settings]); useEffect(() => { @@ -489,6 +516,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]); @@ -535,6 +566,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; @@ -549,6 +589,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); @@ -558,12 +599,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 { @@ -578,7 +619,7 @@ function Launcher() { active = false; window.clearInterval(timer); }; - }, [shouldPollUiLogs, launchingInstanceId, installDialog, launchPrepareDialog]); + }, [shouldPollUiLogs]); useEffect(() => { if (activeGamePid === null || activeGamePid <= 0) return; @@ -605,6 +646,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]); @@ -639,7 +702,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" @@ -885,32 +948,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 @@ -926,10 +963,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 = @@ -1040,98 +1074,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. @@ -1152,7 +1094,7 @@ function Launcher() { setStatus( t("app.status.gameStartedMonitorFailed", { pid, - error: String(error) + error: describeApiError(error, t) }) ); } @@ -1217,10 +1159,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 @@ -1261,31 +1202,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; } @@ -1295,10 +1272,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) { @@ -1306,31 +1280,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); @@ -1341,10 +1294,7 @@ function Launcher() { } if (!targetVersion) { - setPresetPackageStatuses((prev) => ({ - ...prev, - [instance.id]: createPresetPackageStatus("missing") - })); + setPresetStatus(instance, () => createPresetPackageStatus("missing")); return; } @@ -1352,31 +1302,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) @@ -1387,9 +1340,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, @@ -1400,15 +1351,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") @@ -1444,35 +1387,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; } } @@ -1499,8 +1445,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; } @@ -1524,35 +1480,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. @@ -1636,7 +1624,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", { @@ -1647,9 +1635,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", { @@ -1657,13 +1648,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, @@ -1692,7 +1684,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, @@ -1722,7 +1714,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", { @@ -1732,10 +1724,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, @@ -1775,7 +1769,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; @@ -1817,7 +1811,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", { @@ -1855,10 +1849,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, @@ -1924,7 +1918,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 })); @@ -1932,229 +1926,369 @@ 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() { + // 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); + } + + // 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 (isContentTaskRunningFor(localVersionIdFor(target))) { + 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) { - setStatus(t("app.status.singleInstanceRunning", { pid: activeGamePid })); + launchingInstanceRef.current = null; + 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(); 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; } + cancelledLaunchSessionsRef.current.delete(sessionId); 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() { @@ -2164,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; } @@ -2284,7 +2410,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, @@ -2370,7 +2496,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; @@ -2402,7 +2528,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; @@ -2438,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); } @@ -2454,54 +2581,140 @@ 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); } } + // 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); } @@ -2527,6 +2740,7 @@ function Launcher() { function closeLaunchPrepareDialog() { if (!launchPrepareDialog?.canClose) return; + offlineRetryRef.current = null; setLaunchPrepareDialog(null); } @@ -2681,12 +2895,127 @@ 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() { + if (!window.confirm(t("diagnostics.fileFallbackConsent"))) return; + try { + await invoke("secure_storage_set_file_fallback", { enabled: true }); + window.location.reload(); + } catch (error) { + notifyError(describeApiError(error, t)); + } + } + async function closeLauncherWindow() { if (settings.minimizeToTray) { + // First time only: say where the window went before it disappears. + if (localStorage.getItem(STORAGE_KEYS.trayNoticeShown) !== "1") { + setTrayNoticeOpen(true); + return; + } await invoke("hide_main_window"); return; } - await getCurrentWindow().close(); + // Quitting mid-download used to drop it silently and leave half-written + // version dirs behind (launcher-ui-24). + if (hasRunningWork()) { + setCloseConfirmOpen(true); + return; + } + 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) { + localStorage.setItem(STORAGE_KEYS.autostartPrompted, "1"); + setAutostartPromptOpen(false); + setSettings((prev) => ({ ...prev, launchOnStartup: enabled })); + } + + async function acknowledgeTrayNotice() { + localStorage.setItem(STORAGE_KEYS.trayNoticeShown, "1"); + setTrayNoticeOpen(false); + await invoke("hide_main_window"); + } + + 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 invoke("quit_launcher_app"); + } } // Stable callback identities so React.memo children (Sidebar, pages) don't @@ -2694,6 +3023,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); @@ -2709,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")); @@ -2729,7 +3094,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); @@ -2778,7 +3143,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); @@ -2787,7 +3155,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); @@ -2798,16 +3166,23 @@ function Launcher() { }); const onRefreshLauncherUpdate = useStableCallback(() => void launcherUpdate.refresh(false)); const onInstallLauncherUpdate = useStableCallback(() => void launcherUpdate.install()); - const onSettingsReset = useStableCallback(() => + const onCancelLauncherUpdate = useStableCallback(() => void launcherUpdate.cancel()); + // Resets preferences only: the game directory (where the installed games + // live) and the CurseForge key survive, and nothing happens without a + // confirmation (launcher-ui-19). + const onSettingsReset = useStableCallback(() => { + if (!window.confirm(t("settings.resetConfirm"))) return; setSettings({ ...DEFAULT_SETTINGS, - gameDir: defaultGameDir, + gameDir: settings.gameDir || defaultGameDir, + curseforgeApiKey: settings.curseforgeApiKey, + playerName: settings.playerName, language: settings.language, themeMode: settings.themeMode, themeAccent: settings.themeAccent, customAccentHex: settings.customAccentHex - }) - ); + }); + }); const routerContext: PageRouterContext = { page, @@ -2844,7 +3219,9 @@ function Launcher() { launchProgressPercent, launchProgressText, onLaunch: stableLaunch, + onQuickLaunch: stableQuickLaunch, onLaunchToServer: stableLaunchToServer, + gameRunning: activeGamePid !== null && activeGamePid > 0, minecraftAccounts: mcAccounts.accounts, currentMinecraftAccount: mcAccounts.currentAccount, minecraftAccountRequired: minecraftAccountPromptOpen, @@ -2861,6 +3238,7 @@ function Launcher() { launcherUpdateDownload: launcherUpdate.download, onRefreshLauncherUpdate, onInstallLauncherUpdate, + onCancelLauncherUpdate, catalogLoading: installCtl.catalogLoading, catalogCount: installCtl.catalog.length, majors: installCtl.majors, @@ -2900,7 +3278,8 @@ function Launcher() { onLogoutLauncherAccount: stableLogout, onChangeSettings: stableUpdateSettings, onClampMemory: stableUpdateMemory, - onResetSettings: onSettingsReset + onResetSettings: onSettingsReset, + diagnosticsVersionId: current ? localVersionIdFor(current) : null }; return ( @@ -2934,9 +3313,16 @@ function Launcher() {
                   {secureStorageError}
                 
- +
+ {secureStorageError.startsWith("CREDENTIAL_STORE_UNAVAILABLE") && ( + + )} + +
) : ( @@ -2974,19 +3360,101 @@ function Launcher() { )} {authenticated && launchPrepareDialog && launchPrepareDialog.open && ( - + )} {authenticated && launchError && ( 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)} + /> + )} + + {authenticated && autostartPromptOpen && !crashReport && ( + answerAutostartPrompt(false)} + actions={[ + { label: t("autostart.decline"), variant: "ghost", onClick: () => answerAutostartPrompt(false) }, + { label: t("autostart.enable"), variant: "primary", onClick: () => answerAutostartPrompt(true) } + ]} + /> + )} + + {trayNoticeOpen && ( + setTrayNoticeOpen(false)} + actions={[{ label: t("dialog.confirm"), variant: "primary", onClick: () => void acknowledgeTrayNotice() }]} + /> + )} + + {closeConfirmOpen && ( + void resolveCloseConfirm("stay")} + actions={[ + { label: t("close.busy.stay"), variant: "ghost", onClick: () => void resolveCloseConfirm("stay") }, + { label: t("close.busy.quit"), variant: "danger", onClick: () => void resolveCloseConfirm("quit") }, + { label: t("close.busy.minimize"), variant: "primary", onClick: () => void resolveCloseConfirm("minimize") } + ]} + /> + )} + ); } +// localStorage writes can throw (QuotaExceededError). Uncaught in an effect +// that took the whole UI down to the error boundary (launcher-ui-2); now the +// previous value stays and the user is told once per session. +let localSaveFailureReported = false; +function persistLocal(key: string, value: unknown) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(`[storage] failed to save ${key}:`, error); + if (!localSaveFailureReported) { + localSaveFailureReported = true; + notifyError( + createTranslator(loadSettings().language)("settings.backgroundSaveFailed", { error: String(error) }) + ); + } + } +} + function PageFallback() { return (
diff --git a/tauri-app/src/components/ChoiceDialog.tsx b/tauri-app/src/components/ChoiceDialog.tsx new file mode 100644 index 0000000..e4da244 --- /dev/null +++ b/tauri-app/src/components/ChoiceDialog.tsx @@ -0,0 +1,44 @@ +import Button from "./Button"; +import Card from "./Card"; +import ModalFrame from "./ModalFrame"; + +type ChoiceAction = { + label: string; + variant: "primary" | "secondary" | "ghost" | "danger"; + onClick: () => void; +}; + +type ChoiceDialogProps = { + id: string; + title: string; + message: string; + actions: ChoiceAction[]; + /** Escape. */ + onDismiss: () => void; +}; + +// A small question with a few answers (close during downloads, autostart, +// tray notice). +export default function ChoiceDialog({ id, title, message, actions, onDismiss }: ChoiceDialogProps) { + return ( + + +

+ {title} +

+

{message}

+
+ {actions.map((action) => ( + + ))} +
+
+
+ ); +} 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/components/ErrorBoundary.tsx b/tauri-app/src/components/ErrorBoundary.tsx index f848c50..c327bc2 100644 --- a/tauri-app/src/components/ErrorBoundary.tsx +++ b/tauri-app/src/components/ErrorBoundary.tsx @@ -1,4 +1,6 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; +import { createTranslator } from "../i18n"; +import { loadSettings } from "../utils/launcher"; type ErrorBoundaryProps = { children: ReactNode; @@ -33,11 +35,14 @@ export default class ErrorBoundary extends Component -

出了点问题

+

{t("errorBoundary.title")}

- 界面渲染时发生错误。你可以尝试重新加载,如果问题持续,请重启启动器。 + {t("errorBoundary.description")}

           {this.state.error.message}
@@ -47,7 +52,7 @@ export default class ErrorBoundary extends Component
-          重新加载
+          {t("errorBoundary.reload")}
         
       
); diff --git a/tauri-app/src/components/InstallDialog.tsx b/tauri-app/src/components/InstallDialog.tsx index 7f101ef..b763d78 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,12 +14,12 @@ export default function InstallDialog({ dialog, onClose, onCancel }: InstallDial const { t } = useI18n(); return ( -
+
-

+

{!dialog.canClose ?

{t("dialog.session", { id: dialog.sessionId.slice(-6) })}
@@ -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/MinecraftProfileCard.tsx b/tauri-app/src/components/MinecraftProfileCard.tsx index bd963f9..5923532 100644 --- a/tauri-app/src/components/MinecraftProfileCard.tsx +++ b/tauri-app/src/components/MinecraftProfileCard.tsx @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; +import { getAllWebviewWindows } from "@tauri-apps/api/webviewWindow"; import { ChevronDown, Crown, LoaderCircle, Plus, TriangleAlert, User, X } from "lucide-react"; import { memo, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; @@ -43,6 +44,9 @@ function MinecraftProfileCard({ const [microsoftStatus, setMicrosoftStatus] = useState(""); const [microsoftError, setMicrosoftError] = useState(""); const containerRef = useRef(null); + // Bumped on every new attempt and on cancel: a browser login that resolves + // after the user cancelled must not add the account (launcher-ui-22). + const microsoftAttemptRef = useRef(0); const inputRef = useRef(null); const requiredDialogOpen = required && accounts.length === 0; const dialogVisible = dialogOpen || requiredDialogOpen; @@ -86,14 +90,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"); @@ -122,10 +126,19 @@ function MinecraftProfileCard({ if (required && !force) { return; } + if (microsoftBusy) { + cancelMicrosoftLogin(); + } setDialogOpen(false); resetDialogState("offline"); } + // Closing the OAuth window makes the backend stop waiting for the callback. + function cancelMicrosoftLogin() { + microsoftAttemptRef.current += 1; + void closeMicrosoftAuthWindows(); + } + function submitOfflineAccount() { const username = offlineName.trim(); if (!username) { @@ -137,20 +150,23 @@ function MinecraftProfileCard({ } async function beginMicrosoftLogin() { + const attempt = ++microsoftAttemptRef.current; setMicrosoftBusy(true); setMicrosoftError(""); setMicrosoftStatus(t("minecraftAccount.microsoftBrowserOpening")); try { const account = await startMinecraftBrowserLogin(); + if (microsoftAttemptRef.current !== attempt) return; onSaveMicrosoftAccount(account); closeAddDialog(true); } catch (error) { + if (microsoftAttemptRef.current !== attempt) return; // Raw `String(error)` here exposed reqwest/hyper source chains from the // Rust side; users saw "error sending request for url ..." and no hint of // what to do about it. setMicrosoftError(describeApiError(error, t)); } finally { - setMicrosoftBusy(false); + if (microsoftAttemptRef.current === attempt) setMicrosoftBusy(false); } } @@ -392,6 +408,20 @@ async function startMinecraftBrowserLogin(): Promise { return invoke("start_minecraft_browser_login"); } +// The embedded Microsoft login windows are labelled minecraft-auth-*. +async function closeMicrosoftAuthWindows(): Promise { + try { + const windows = await getAllWebviewWindows(); + await Promise.all( + windows + .filter((win) => win.label.startsWith("minecraft-auth-")) + .map((win) => win.destroy().catch(() => win.close())) + ); + } catch (error) { + console.warn("[minecraft-auth] cannot close the login window:", error); + } +} + // Resolves the skin to render for an account: the stored skinUrl when present, // otherwise a cached backend lookup by uuid (premium) / username (offline names // that map to a real Mojang profile). Returns null while unresolved or when the 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..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. @@ -71,7 +72,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; @@ -90,6 +93,7 @@ export type PageRouterContext = { launcherUpdateDownload: DownloadedLauncherUpdate | null; onRefreshLauncherUpdate: () => void; onInstallLauncherUpdate: () => void; + onCancelLauncherUpdate: () => void; // install wizard catalogLoading: boolean; catalogCount: number; @@ -132,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 }) { @@ -162,6 +168,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 +184,7 @@ export default function PageRouter({ ctx }: { ctx: PageRouterContext }) { ); + case "diagnostics": + return ( + + ); case "mandatory-update": return ( ); default: @@ -300,6 +320,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/components/Sidebar.tsx b/tauri-app/src/components/Sidebar.tsx index 046353a..0b40250 100644 --- a/tauri-app/src/components/Sidebar.tsx +++ b/tauri-app/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { memo, useState } from "react"; -import { Compass, Crown, Gamepad2, Home, Server, Settings } from "lucide-react"; +import { Compass, Crown, Gamepad2, Home, Server, Settings, Stethoscope } from "lucide-react"; import AppLogo from "./AppLogo"; import { useI18n } from "../i18n"; import type { LauncherUser, Page } from "../types"; @@ -22,6 +22,7 @@ function Sidebar({ currentPage, user, setPage }: SidebarProps) { { id: "instances" as const, icon: Gamepad2, label: t("nav.myGames") }, { id: "servers" as const, icon: Server, label: t("nav.servers") }, { id: "content" as const, icon: Compass, label: t("nav.content") }, + { id: "diagnostics" as const, icon: Stethoscope, label: t("nav.diagnostics") }, { id: "settings" as const, icon: Settings, label: t("nav.settings") } ]; diff --git a/tauri-app/src/constants.ts b/tauri-app/src/constants.ts index bca4df0..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"; @@ -25,7 +24,11 @@ export const STORAGE_KEYS = { selectedMinecraftAccount: "fpsmaster.selectedMinecraftAccount", // Which Minecraft game version the user picked inside the Nova region (Nova stays one preset, // this sub-selection drives the launched MC version + matching mod jar). - selectedNovaGameVersion: "fpsmaster.selectedNovaGameVersion" + selectedNovaGameVersion: "fpsmaster.selectedNovaGameVersion", + // "1" once the user answered the start-with-computer question. + autostartPrompted: "fpsmaster.autostartPrompted", + // "1" once the user was told that closing only hides the launcher to the tray. + trayNoticeShown: "fpsmaster.trayNoticeShown" } as const; // Default/fallback Nova game version highlighted in the picker when the catalog hasn't loaded yet. @@ -38,23 +41,18 @@ 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: "", - downloadSource: "mirror-first", + downloadSource: "auto", downloadThreads: 16, launcherUpdateChannel: "beta", maxMemoryMb: 4096, hideMainOnLaunch: true, minimizeToTray: true, - launchOnStartup: true, + // Off until the user opts in (asked once, see the autostart prompt): it used + // to be registered silently on first run (launcher-ui-14). + launchOnStartup: false, language: "en-US", themeMode: "dark", themeAccent: "emerald", 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..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, formatLaunchError, 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, @@ -91,8 +94,7 @@ export function useLauncherUpdate(deps: UseLauncherUpdateDeps): LauncherUpdateCo ); } } catch (error) { - const rawText = formatLaunchError(error); - if (isLauncherAppUpdateMissing(rawText)) { + if (isLauncherAppUpdateMissing(error)) { setAppUpdate(null); setDownload(null); } else { @@ -128,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; @@ -142,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(); @@ -174,6 +195,7 @@ export function useLauncherUpdate(deps: UseLauncherUpdateDeps): LauncherUpdateCo mandatoryRequired, refresh, refreshChannels, - install + install, + cancel }; } 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/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/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/lib/presetPackage.ts b/tauri-app/src/lib/presetPackage.ts index efc28c1..a333d21 100644 --- a/tauri-app/src/lib/presetPackage.ts +++ b/tauri-app/src/lib/presetPackage.ts @@ -21,7 +21,7 @@ export function resolvePresetAccessState( versionMap: LauncherVersionMap, // For NOVA, the entry for the picked game version (Nova has several); ignored for other products. novaVersion?: LauncherVersion | null -): { state: "ok" | "pending-release"; versionTag?: string | null; changelog?: string | null; lastError?: string | null } { +): { state: "ok" | "pending-release"; versionTag?: string | null; changelog?: string | null } { if (!instance.preset || !instance.launcherVersionType) { return { state: "ok" }; } @@ -29,16 +29,13 @@ export function resolvePresetAccessState( return { state: "ok" }; } // NOVA and EXTREME are gated: each resolves against its own catalog entry, which is only - // present once released. NOVA resolves - // against the picked game version's entry; EXTREME against its single product entry. + // present once released. NOVA resolves against the picked game version's entry; EXTREME + // against its single product entry. The caller words the message (it knows whether the + // catalog actually loaded). const version = instance.launcherVersionType === "NOVA" ? (novaVersion ?? null) : versionMap[instance.launcherVersionType]; if (!version) { - const label = instance.launcherVersionType === "EXTREME" ? "Extreme" : "Nova"; - return { - state: "pending-release", - lastError: `${label} has not been released yet.` - }; + return { state: "pending-release" }; } return { state: "ok", diff --git a/tauri-app/src/lib/system.ts b/tauri-app/src/lib/system.ts index 424bea9..7914093 100644 --- a/tauri-app/src/lib/system.ts +++ b/tauri-app/src/lib/system.ts @@ -22,12 +22,14 @@ async function closeExistingMonitorWindows(): Promise { } } +// `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 591e962..92409c2 100644 --- a/tauri-app/src/locales/en-US.ts +++ b/tauri-app/src/locales/en-US.ts @@ -770,7 +770,165 @@ 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.", + "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", + "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.", + "nav.diagnostics": "Diagnostics", + "diagnostics.eyebrow": "Support", + "diagnostics.title": "Diagnostics & repair", + "diagnostics.subtitle": "Checks Java runtimes, disk space, download sources, credential storage and recent crashes.", + "diagnostics.subtitleInstance": "Checks {name} plus Java runtimes, disk space, download sources, credential storage and recent crashes.", + "diagnostics.deep": "Verify file hashes (slow)", + "diagnostics.run": "Run check", + "diagnostics.fixAll": "Fix all", + "diagnostics.fix": "Fix", + "diagnostics.repairInstance": "Repair instance", + "diagnostics.openSourceSettings": "Download source settings", + "diagnostics.export": "Export support bundle", + "diagnostics.exporting": "Exporting...", + "diagnostics.exportTitle": "Save support bundle", + "diagnostics.exportDone": "Support bundle saved to {path}", + "diagnostics.exportHint": "Tokens and passwords are removed. Send this file to FPSMaster support.", + "diagnostics.exportFailed": "Export failed", + "diagnostics.openFile": "Open", + "diagnostics.checkFailed": "Check failed", + "diagnostics.fixFailed": "Repair failed", + "diagnostics.fixDone": "Repaired.", + "diagnostics.fixAllDone": "{count} problem(s) repaired.", + "diagnostics.summaryOk": "No problems found.", + "diagnostics.summaryProblems": "{count} problem(s) found.", + "diagnostics.checking": "Checking...", + "diagnostics.checkingHint": "Testing the download sources takes a few seconds.", + "diagnostics.fileFallbackConsent": "The system credential store is unavailable. Allow the launcher to keep its encryption key in a file only your user account can read? Your accounts are less protected this way than in the system keychain.", + "diagnostics.source.official": "Mojang (official)", + "diagnostics.source.bmclapi": "BMCLAPI mirror", + "diagnostics.source.api": "FPSMaster server", + "diagnostics.code.JAVA_RUNTIME_OK.title": "Java runtime {runtime}", + "diagnostics.code.JAVA_RUNTIME_OK.desc": "Installed and complete.", + "diagnostics.code.JAVA_RUNTIME_INCOMPLETE.title": "Java runtime {runtime} is damaged", + "diagnostics.code.JAVA_RUNTIME_INCOMPLETE.desc": "Missing or modified files. Repair downloads only the bad files.", + "diagnostics.code.JAVA_RUNTIME_MISSING.title": "Java {major} is not installed", + "diagnostics.code.JAVA_RUNTIME_MISSING.desc": "{versionId} needs it. It is downloaded on the next launch, or now with Fix.", + "diagnostics.code.INSTANCE_FILES_OK.title": "Game files of {versionId}", + "diagnostics.code.INSTANCE_FILES_OK.desc": "All files are present.", + "diagnostics.code.INSTANCE_FILES_MISSING.title": "{versionId} is missing {missing} file(s)", + "diagnostics.code.INSTANCE_FILES_MISSING.desc": "Fix downloads the missing files again.", + "diagnostics.code.INSTANCE_FILES_CORRUPTED.title": "{versionId} has damaged files", + "diagnostics.code.INSTANCE_FILES_CORRUPTED.desc": "{corrupted} damaged, {missing} missing. Fix downloads them again.", + "diagnostics.code.INSTANCE_PROFILE_UNREADABLE.title": "The profile of {versionId} cannot be read", + "diagnostics.code.INSTANCE_PROFILE_UNREADABLE.desc": "The instance is not installed or its profile is broken. Repairing the instance reinstalls it.", + "diagnostics.code.NATIVE_APP_INCOMPLETE.title": "The Extreme client is incomplete", + "diagnostics.code.NATIVE_APP_INCOMPLETE.desc": "Repairing the instance downloads the client package again.", + "diagnostics.code.DISK_SPACE_OK.title": "Disk space", + "diagnostics.code.DISK_SPACE_OK.desc": "{freeBytes} free of {totalBytes}.", + "diagnostics.code.DISK_SPACE_LOW.title": "Disk space is low", + "diagnostics.code.DISK_SPACE_LOW.desc": "Only {freeBytes} free. Updates and new versions may fail to download.", + "diagnostics.code.DISK_SPACE_CRITICAL.title": "The disk is almost full", + "diagnostics.code.DISK_SPACE_CRITICAL.desc": "Only {freeBytes} free. Free up space before launching or installing.", + "diagnostics.code.DISK_UNKNOWN.title": "Disk space unknown", + "diagnostics.code.DISK_UNKNOWN.desc": "The disk that holds the game directory could not be found.", + "diagnostics.code.SOURCE_OK.title": "{source}", + "diagnostics.code.SOURCE_OK.desc": "Reachable. Latency {latencyMs} ms, speed {bytesPerSecond}.", + "diagnostics.code.SOURCE_SLOW.title": "{source} is slow", + "diagnostics.code.SOURCE_SLOW.desc": "Speed {bytesPerSecond}. Switching the download source or using Auto may help.", + "diagnostics.code.SOURCE_UNREACHABLE.title": "{source} is unreachable", + "diagnostics.code.SOURCE_UNREACHABLE.desc": "Check your network, or switch the download source in Settings.", + "diagnostics.code.CREDENTIAL_STORE_OK.title": "Credential storage", + "diagnostics.code.CREDENTIAL_STORE_OK.desc": "Accounts are stored securely ({backend}).", + "diagnostics.code.CREDENTIAL_STORE_UNAVAILABLE.title": "The system credential store is unavailable", + "diagnostics.code.CREDENTIAL_STORE_UNAVAILABLE.desc": "Sign-ins cannot be remembered. Unlock the system keychain / start Secret Service, or allow a file-based key.", + "diagnostics.code.CREDENTIAL_STORE_ACCESS_DENIED.title": "Access to the credential store was denied", + "diagnostics.code.CREDENTIAL_STORE_ACCESS_DENIED.desc": "Allow FPSMaster Launcher in the keychain prompt, then check again.", + "diagnostics.code.NO_RECENT_CRASHES.title": "Recent crashes", + "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", + "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", + "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}", + "instanceFiles.disabledSuffix": "(disabled)", + "instanceFiles.enableMod": "Enable mod", + "instanceFiles.disableMod": "Disable mod", + "instanceFiles.delete": "Delete", + "account.memberBadge": "Member", + "account.proBadge": "Pro member", + "errorBoundary.title": "Something went wrong", + "errorBoundary.description": "The interface hit an error while rendering. Try reloading; if it keeps happening, restart the launcher.", + "errorBoundary.reload": "Reload", + "settings.resetConfirm": "Reset launcher preferences to their defaults? Your game directory and CurseForge API key are kept.", + "autostart.title": "Start FPSMaster with your computer?", + "autostart.message": "The launcher can start in the tray when you sign in to your computer, so it is ready right away. You can change this anytime in Settings.", + "autostart.enable": "Start automatically", + "autostart.decline": "No thanks", + "tray.minimizedNotice": "The launcher keeps running in the system tray. Turn off \"Minimize to tray\" in Settings to quit on close.", + "settings.backgroundSaveFailed": "Could not save the settings: {error}", + "settings.backgroundImportFailed": "Could not use this image: {error}", + "settings.backgroundImagePick": "Choose a background image" } 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..c46bb91 100644 --- a/tauri-app/src/locales/zh-CN.ts +++ b/tauri-app/src/locales/zh-CN.ts @@ -765,7 +765,165 @@ 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": "已取消。", + "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": "运行中", + "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": "已取消更新下载。", + "nav.diagnostics": "诊断修复", + "diagnostics.eyebrow": "支持", + "diagnostics.title": "诊断与修复", + "diagnostics.subtitle": "检查 Java 运行时、磁盘空间、下载源、凭据存储和最近的崩溃。", + "diagnostics.subtitleInstance": "检查 {name},以及 Java 运行时、磁盘空间、下载源、凭据存储和最近的崩溃。", + "diagnostics.deep": "校验文件哈希(较慢)", + "diagnostics.run": "重新检查", + "diagnostics.fixAll": "一键修复", + "diagnostics.fix": "修复", + "diagnostics.repairInstance": "修复实例", + "diagnostics.openSourceSettings": "下载源设置", + "diagnostics.export": "导出求助包", + "diagnostics.exporting": "正在导出...", + "diagnostics.exportTitle": "保存求助包", + "diagnostics.exportDone": "求助包已保存到 {path}", + "diagnostics.exportHint": "其中的令牌和密码已移除,可以直接发给 FPSMaster 客服。", + "diagnostics.exportFailed": "导出失败", + "diagnostics.openFile": "打开", + "diagnostics.checkFailed": "检查失败", + "diagnostics.fixFailed": "修复失败", + "diagnostics.fixDone": "修复完成。", + "diagnostics.fixAllDone": "已修复 {count} 个问题。", + "diagnostics.summaryOk": "未发现问题。", + "diagnostics.summaryProblems": "发现 {count} 个问题。", + "diagnostics.checking": "正在检查...", + "diagnostics.checkingHint": "测试下载源需要几秒钟。", + "diagnostics.fileFallbackConsent": "系统凭据存储不可用。是否允许启动器把加密密钥保存在仅当前用户可读的文件中?这种方式的安全性低于系统钥匙串。", + "diagnostics.source.official": "Mojang 官方源", + "diagnostics.source.bmclapi": "BMCLAPI 镜像", + "diagnostics.source.api": "FPSMaster 服务器", + "diagnostics.code.JAVA_RUNTIME_OK.title": "Java 运行时 {runtime}", + "diagnostics.code.JAVA_RUNTIME_OK.desc": "已安装且完整。", + "diagnostics.code.JAVA_RUNTIME_INCOMPLETE.title": "Java 运行时 {runtime} 已损坏", + "diagnostics.code.JAVA_RUNTIME_INCOMPLETE.desc": "存在缺失或被修改的文件。修复只会重新下载有问题的文件。", + "diagnostics.code.JAVA_RUNTIME_MISSING.title": "未安装 Java {major}", + "diagnostics.code.JAVA_RUNTIME_MISSING.desc": "{versionId} 需要该运行时。下次启动时会自动下载,也可以点击修复立即下载。", + "diagnostics.code.INSTANCE_FILES_OK.title": "{versionId} 的游戏文件", + "diagnostics.code.INSTANCE_FILES_OK.desc": "文件完整。", + "diagnostics.code.INSTANCE_FILES_MISSING.title": "{versionId} 缺少 {missing} 个文件", + "diagnostics.code.INSTANCE_FILES_MISSING.desc": "修复会重新下载缺失的文件。", + "diagnostics.code.INSTANCE_FILES_CORRUPTED.title": "{versionId} 存在损坏的文件", + "diagnostics.code.INSTANCE_FILES_CORRUPTED.desc": "损坏 {corrupted} 个,缺失 {missing} 个。修复会重新下载这些文件。", + "diagnostics.code.INSTANCE_PROFILE_UNREADABLE.title": "无法读取 {versionId} 的版本配置", + "diagnostics.code.INSTANCE_PROFILE_UNREADABLE.desc": "实例未安装或版本配置已损坏,修复实例会重新安装。", + "diagnostics.code.NATIVE_APP_INCOMPLETE.title": "Extreme 客户端不完整", + "diagnostics.code.NATIVE_APP_INCOMPLETE.desc": "修复实例会重新下载客户端包。", + "diagnostics.code.DISK_SPACE_OK.title": "磁盘空间", + "diagnostics.code.DISK_SPACE_OK.desc": "可用 {freeBytes},共 {totalBytes}。", + "diagnostics.code.DISK_SPACE_LOW.title": "磁盘空间不足", + "diagnostics.code.DISK_SPACE_LOW.desc": "仅剩 {freeBytes},更新或安装新版本可能失败。", + "diagnostics.code.DISK_SPACE_CRITICAL.title": "磁盘空间严重不足", + "diagnostics.code.DISK_SPACE_CRITICAL.desc": "仅剩 {freeBytes},请先清理磁盘再启动或安装。", + "diagnostics.code.DISK_UNKNOWN.title": "无法获取磁盘空间", + "diagnostics.code.DISK_UNKNOWN.desc": "找不到游戏目录所在的磁盘。", + "diagnostics.code.SOURCE_OK.title": "{source}", + "diagnostics.code.SOURCE_OK.desc": "连接正常。延迟 {latencyMs} ms,速度 {bytesPerSecond}。", + "diagnostics.code.SOURCE_SLOW.title": "{source} 速度较慢", + "diagnostics.code.SOURCE_SLOW.desc": "速度 {bytesPerSecond}。可以在设置中切换下载源或选择“自动”。", + "diagnostics.code.SOURCE_UNREACHABLE.title": "无法连接 {source}", + "diagnostics.code.SOURCE_UNREACHABLE.desc": "请检查网络,或在设置中切换下载源。", + "diagnostics.code.CREDENTIAL_STORE_OK.title": "凭据存储", + "diagnostics.code.CREDENTIAL_STORE_OK.desc": "账号信息已安全保存({backend})。", + "diagnostics.code.CREDENTIAL_STORE_UNAVAILABLE.title": "系统凭据存储不可用", + "diagnostics.code.CREDENTIAL_STORE_UNAVAILABLE.desc": "无法保存登录状态。请解锁系统钥匙串或启动 Secret Service,也可以改用文件保存密钥。", + "diagnostics.code.CREDENTIAL_STORE_ACCESS_DENIED.title": "访问凭据存储被拒绝", + "diagnostics.code.CREDENTIAL_STORE_ACCESS_DENIED.desc": "请在系统钥匙串提示中允许 FPSMaster Launcher 访问,然后重新检查。", + "diagnostics.code.NO_RECENT_CRASHES.title": "最近的崩溃", + "diagnostics.code.NO_RECENT_CRASHES.desc": "最近 7 天没有崩溃记录。", + "diagnostics.code.RECENT_CRASHES.title": "最近 7 天崩溃 {count} 次", + "diagnostics.code.RECENT_CRASHES.desc": "最近一次:{latestSummary}。可以导出求助包发给客服。", + "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": "关闭", + "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}", + "instanceFiles.disabledSuffix": "(已禁用)", + "instanceFiles.enableMod": "启用模组", + "instanceFiles.disableMod": "禁用模组", + "instanceFiles.delete": "删除", + "account.memberBadge": "普通会员", + "account.proBadge": "Pro 会员", + "errorBoundary.title": "出了点问题", + "errorBoundary.description": "界面渲染时发生错误。你可以尝试重新加载,如果问题持续,请重启启动器。", + "errorBoundary.reload": "重新加载", + "settings.resetConfirm": "确定将启动器偏好恢复为默认值吗?游戏目录和 CurseForge API Key 会保留。", + "autostart.title": "开机时自动启动 FPSMaster?", + "autostart.message": "启动器可以在开机登录后于托盘中启动,随时可用。之后也可以在设置中更改。", + "autostart.enable": "开机自动启动", + "autostart.decline": "不用了", + "tray.minimizedNotice": "启动器已最小化到系统托盘,仍在后台运行。如需关闭即退出,请在设置中关闭“最小化到托盘”。", + "settings.backgroundSaveFailed": "无法保存设置:{error}", + "settings.backgroundImportFailed": "无法使用这张图片:{error}", + "settings.backgroundImagePick": "选择背景图片" } 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/AccountCenter.tsx b/tauri-app/src/pages/AccountCenter.tsx index 3256119..74043df 100644 --- a/tauri-app/src/pages/AccountCenter.tsx +++ b/tauri-app/src/pages/AccountCenter.tsx @@ -96,12 +96,12 @@ function AccountCenterPage({ launcherDashboard }: AccountCenterPageProps) { {/* Role Badge - Only show for non-admin users */} {!isAdmin && !isSponsor && (
- 普通会员 + {t("account.memberBadge")}
)} {isSponsor && (
- Pro会员 + {t("account.proBadge")}
)} 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" && ( + + )} + + +
+
+ + {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/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/pages/InstanceSettings.tsx b/tauri-app/src/pages/InstanceSettings.tsx index 6468b63..96f1fb0 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) }) } })); } @@ -428,18 +429,20 @@ function InstanceSettingsPage({ {entry.name} {entry.disabled && ( - (已禁用) + {t("instanceFiles.disabledSuffix")} )} {/* Actions */} -
+ {/* Also revealed on keyboard focus, not only on hover. */} +
{isMods && ( @@ -448,7 +451,8 @@ function InstanceSettingsPage({ type="button" onClick={() => deleteEntry(activeTab, entry.name)} className="flex h-7 w-7 items-center justify-center rounded-lg text-[var(--text-muted)] transition-colors hover:bg-[var(--accent-danger)]/10 hover:text-[var(--accent-danger)]" - title="删除" + title={t("instanceFiles.delete")} + aria-label={t("instanceFiles.delete")} > diff --git a/tauri-app/src/pages/Instances.tsx b/tauri-app/src/pages/Instances.tsx index 80cd865..01fa4ea 100644 --- a/tauri-app/src/pages/Instances.tsx +++ b/tauri-app/src/pages/Instances.tsx @@ -367,14 +367,19 @@ function InstancesPage({ )} {target.category !== "VANILLA" && - presetStatus && - (presetStatus.state === "update-available" || - presetStatus.state === "missing") && + presetStatus?.state === "update-available" && canAccess && ( {t("instances.status.updateAvailable")} )} + {target.category !== "VANILLA" && + presetStatus?.state === "missing" && + canAccess && ( + + {t("instances.status.missing")} + + )} {target.category !== "VANILLA" && presetStatus?.state === "needs-repair" && canAccess && ( diff --git a/tauri-app/src/pages/MandatoryUpdate.tsx b/tauri-app/src/pages/MandatoryUpdate.tsx index dd92ec1..f0c04b8 100644 --- a/tauri-app/src/pages/MandatoryUpdate.tsx +++ b/tauri-app/src/pages/MandatoryUpdate.tsx @@ -12,6 +12,7 @@ type MandatoryUpdatePageProps = { launcherUpdateProgressPercent: number | null; launcherUpdateDownload: DownloadedLauncherUpdate | null; onInstallLauncherUpdate: () => void; + onCancelLauncherUpdate: () => void; }; function MandatoryUpdatePage({ @@ -20,6 +21,7 @@ function MandatoryUpdatePage({ launcherUpdateProgressPercent, launcherUpdateDownload, onInstallLauncherUpdate, + onCancelLauncherUpdate, }: MandatoryUpdatePageProps) { const { t } = useI18n(); const notes = launcherUpdate?.notes?.trim() ?? ""; @@ -103,6 +105,11 @@ function MandatoryUpdatePage({ : t("mandatoryUpdate.installing") : t("mandatoryUpdate.install")} + {launcherUpdateDownloading && ( + + )}
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..ac5a751 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,8 +36,10 @@ 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 { BackgroundSource, BlurMode, @@ -46,6 +49,7 @@ import type { LauncherAppUpdateInfo, LauncherUser, Settings, + SourceProbeReport, ThemeAccent, ThemeMode, VisualProfile @@ -81,6 +85,7 @@ type SettingsPageProps = { onLogoutLauncherAccount: () => void; onRefreshLauncherUpdate: () => void; onInstallLauncherUpdate: () => void; + onCancelLauncherUpdate: () => void; onChange: (next: Settings) => void; onClampMemory: (input: string) => void; onReset: () => void; @@ -100,6 +105,7 @@ function SettingsPage({ onLogoutLauncherAccount, onRefreshLauncherUpdate, onInstallLauncherUpdate, + onCancelLauncherUpdate, onChange, onClampMemory, onReset @@ -112,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); @@ -171,7 +179,7 @@ function SettingsPage({ } } } catch (error) { - setGameDirError(t("settings.gameDirPickerFailed", { error: String(error) })); + setGameDirError(t("settings.gameDirPickerFailed", { error: describeApiError(error, t) })); } } @@ -187,6 +195,7 @@ function SettingsPage({ const themeModes: ThemeMode[] = ["dark", "light"]; const downloadSources: DownloadSource[] = [ + "auto", "official-only", "mirror-only", "mirror-first", @@ -211,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 @@ -238,7 +260,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); @@ -275,6 +297,32 @@ function SettingsPage({ } } + // The image is copied into the launcher's data folder and only its path is + // stored: a multi-MB data URL in localStorage exceeded the quota and took the + // whole UI down (launcher-ui-2). Browser dev has no dialog: fall back to the + // hidden , 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. @@ -354,7 +402,7 @@ function SettingsPage({ backgroundSource: "system", backgroundImage: "" }); - setBackgroundError(t("settings.backgroundSystemLoadFailed", { error: String(error) })); + setBackgroundError(t("settings.backgroundSystemLoadFailed", { error: describeApiError(error, t) })); } return; } @@ -708,6 +756,7 @@ function SettingsPage({

{t("settings.downloadSource")}

+

{t("settings.downloadSourceHint")}