diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4a4aca9..91b94bb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -72,17 +72,25 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" +tempfile = "3" + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_Security", + "Win32_Security_Cryptography", + "Win32_Security_WinTrust", "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", "Win32_System_Ioctl", "Win32_System_IO", + "Win32_System_Registry", + "Win32_System_Threading", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", ] } [dev-dependencies] -tempfile = "3" assert_matches = "1.5" filetime = "0.2" diff --git a/src-tauri/src/commands/diagnostics.rs b/src-tauri/src/commands/diagnostics.rs new file mode 100644 index 0000000..39f0986 --- /dev/null +++ b/src-tauri/src/commands/diagnostics.rs @@ -0,0 +1,159 @@ +//! Tauri command for running the diagnostic suite. +//! +//! Resolves the patcher DLL path the same way `start_patcher` does, snapshots +//! settings, and runs every check in [`crate::diagnostics::run_all`]. The +//! command never returns an error — checks that fail to gather data report +//! `Severity::Warn` or `Severity::Bad` instead. + +use crate::diagnostics::{run_all, CheckCtx, DiagnosticReport}; +use crate::error::{AppError, AppResult, IpcResult, MutexResultExt}; +use crate::legacy_patcher::api::PATCHER_DLL_NAME; +use crate::state::{get_app_data_dir, SettingsState}; +use std::path::PathBuf; +use tauri::{AppHandle, Manager, State}; + +/// Same lookup chain as `commands::patcher::resolve_patcher_dll_path`, but +/// returns `None` instead of an error so we can still report the rest of the +/// diagnostics when the DLL is missing. +fn resolve_patcher_dll(app_handle: &AppHandle) -> Option { + if let Ok(dir) = app_handle.path().resource_dir() { + let p = dir.join(PATCHER_DLL_NAME); + if p.exists() { + return Some(p); + } + } + if let Some(dev) = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .map(|p| p.join(PATCHER_DLL_NAME)) + { + if dev.exists() { + return Some(dev); + } + } + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join(PATCHER_DLL_NAME); + if manifest.exists() { + return Some(manifest); + } + None +} + +#[tauri::command] +pub fn run_diagnostics( + app_handle: AppHandle, + settings: State, +) -> IpcResult { + run_diagnostics_inner(&app_handle, &settings).into() +} + +/// Launch an elevated PowerShell window so the user can run a fix command. +/// +/// On click of a "Run as administrator" button in the diagnostics UI, the +/// frontend copies the command to the clipboard and then calls this command. +/// We `ShellExecuteW` PowerShell with the `runas` verb (UAC prompt), then +/// `-NoExit` so the window stays open. When `with_banner` is true a short +/// hint line is printed up front telling the user the command is on their +/// clipboard and they should paste (Ctrl+V or right-click) and press Enter. +/// +/// Why not auto-execute the command? Auto-running registry deletes from a +/// freshly-elevated PowerShell with no review step is a footgun — the user +/// should at least see the command they're about to execute. Paste-then-Enter +/// is one extra keystroke and gives them a chance to bail out. +#[tauri::command] +pub fn open_elevated_terminal(with_banner: bool) -> IpcResult<()> { + open_elevated_terminal_inner(with_banner).into() +} + +#[cfg(target_os = "windows")] +fn open_elevated_terminal_inner(with_banner: bool) -> AppResult<()> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::ptr; + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + fn to_wide(s: &str) -> Vec { + OsStr::new(s) + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + // Build a `-NoExit -Command "..."` argument string. With a banner we + // print one cyan hint line, then return control to the prompt. The + // command itself is on the clipboard (frontend put it there) and was + // already visible in the diagnostics UI — re-printing it here only + // creates noise. + let args = if with_banner { + "-NoExit -Command \"Write-Host 'LTK Manager: paste the fix command (Ctrl+V), review it, then press Enter.' -ForegroundColor Cyan; Write-Host ''\"".to_string() + } else { + "-NoExit".to_string() + }; + + let exe = to_wide("powershell.exe"); + let verb = to_wide("runas"); + let args_w = to_wide(&args); + + // SAFETY: all pointers are null-terminated wide strings owned by the + // local Vecs above; ShellExecuteW returns a pseudo-HINSTANCE we + // only use as an integer error code. + let result = unsafe { + ShellExecuteW( + ptr::null_mut(), + verb.as_ptr(), + exe.as_ptr(), + args_w.as_ptr(), + ptr::null(), + SW_SHOWNORMAL, + ) + }; + + // ShellExecuteW: values <= 32 indicate failure. Most common: 5 (access + // denied — user clicked No on UAC) or 2 (file not found). + if (result as usize) > 32 { + Ok(()) + } else { + Err(AppError::Other(format!( + "Failed to launch elevated terminal (ShellExecute code {})", + result as usize + ))) + } +} + +#[cfg(not(target_os = "windows"))] +fn open_elevated_terminal_inner(_with_banner: bool) -> AppResult<()> { + Err(AppError::Other( + "Elevated terminal launch is only supported on Windows".to_string(), + )) +} + +fn run_diagnostics_inner( + app_handle: &AppHandle, + settings: &State, +) -> AppResult { + let snapshot = settings.0.lock().mutex_err()?.clone(); + // Mirror `ModLibrary::storage_dir` — fall back to the Tauri app-data dir + // when the user hasn't set a custom storage path. The diagnostics should + // inspect whatever path the rest of the app actually uses. + let storage_is_default = snapshot.mod_storage_path.is_none(); + let mod_storage_path = snapshot + .mod_storage_path + .clone() + .or_else(|| get_app_data_dir(app_handle)); + let ctx = CheckCtx { + league_path: snapshot.league_path.clone(), + mod_storage_path, + mod_storage_is_default: storage_is_default, + patcher_dll_path: resolve_patcher_dll(app_handle), + manager_exe: std::env::current_exe().ok(), + }; + let checks = run_all(&ctx); + let generated_at = chrono::Utc::now().to_rfc3339(); + Ok(DiagnosticReport { + generated_at, + app_version: env!("CARGO_PKG_VERSION").to_string(), + checks, + }) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4eb61b6..542f1e6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ mod app; mod deep_link; +mod diagnostics; mod folders; pub(crate) mod hotkeys; mod migration; @@ -32,6 +33,7 @@ mod workshop; pub use app::*; pub use deep_link::*; +pub use diagnostics::*; pub use folders::*; pub use hotkeys::*; pub use migration::*; diff --git a/src-tauri/src/diagnostics/compat_flags.rs b/src-tauri/src/diagnostics/compat_flags.rs new file mode 100644 index 0000000..5796830 --- /dev/null +++ b/src-tauri/src/diagnostics/compat_flags.rs @@ -0,0 +1,160 @@ +//! AppCompatFlags scan — the load-bearing check from cslol-diag. +//! +//! Windows stores per-executable compatibility-mode settings under +//! `Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers` in +//! both HKCU and HKLM. The value name is the executable's full path; the +//! value itself is a space-separated list of layer tokens (e.g. `RUNASADMIN`, +//! `WIN8RTM`, `~`). +//! +//! When users right-click `League of Legends.exe` → Properties → Compatibility +//! → "Run as administrator", an entry lands here. League being elevated then +//! breaks the patcher's process-injection: handles can't cross the integrity +//! boundary. This is, by far, the #1 cause of "patcher running but mods don't +//! load" in the wild. +//! +//! Phase 1 is read-only. We list every offending entry as a [`CheckDetail`] +//! so the user can copy paths and remove them manually, and we ship a +//! `reg delete` command for each. Phase 3 will add a one-click fix gated +//! behind explicit confirmation. + +use super::{check, Category, Check, Severity}; + +#[cfg(target_os = "windows")] +use super::win_util::{reg_list_value_names, ROOTS}; +#[cfg(target_os = "windows")] +use super::{check_ok, CheckDetail}; + +#[cfg(target_os = "windows")] +const COMPAT_KEY: &str = "Software\\Microsoft\\Windows NT\\CurrentVersion\\AppCompatFlags\\Layers"; + +#[cfg(target_os = "windows")] +const BAD_PREFIXES: &[&str] = &["League", "Riot"]; + +#[cfg(target_os = "windows")] +const SUS_PREFIXES: &[&str] = &["cslol-", "ltk-manager"]; + +/// Returns the basename of a value name (path) for matching against prefix +/// lists. Compat-flag value names are full paths like +/// `C:\Riot Games\League of Legends\League of Legends.exe`. +#[cfg(target_os = "windows")] +fn basename(path: &str) -> &str { + match path.rfind(['\\', '/']) { + Some(i) => &path[i + 1..], + None => path, + } +} + +#[cfg(target_os = "windows")] +pub fn check_compat_flags() -> Check { + let mut bad = Vec::<(String, String)>::new(); // (root, path) + let mut sus = Vec::<(String, String)>::new(); + + for (root, root_label) in ROOTS { + for value_name in reg_list_value_names(*root, COMPAT_KEY) { + let name = basename(&value_name); + if BAD_PREFIXES.iter().any(|p| name.starts_with(p)) { + bad.push((root_label.to_string(), value_name.clone())); + continue; + } + if SUS_PREFIXES.iter().any(|p| name.starts_with(p)) { + sus.push((root_label.to_string(), value_name)); + } + } + } + + if bad.is_empty() && sus.is_empty() { + return check_ok( + "compat_flags.layers", + "League/Riot compatibility flags", + Category::League, + "No League or Riot compatibility entries found", + ); + } + + let severity = if !bad.is_empty() { + Severity::Bad + } else { + Severity::Warn + }; + + let summary = if !bad.is_empty() { + format!( + "{} League/Riot entr{} forcing compatibility mode", + bad.len(), + if bad.len() == 1 { "y" } else { "ies" } + ) + } else { + format!("{} cslol/ltk-manager entries (suspicious)", sus.len()) + }; + + let mut c = check( + "compat_flags.layers", + "League/Riot compatibility flags", + Category::League, + severity, + summary, + ); + + for (root, path) in &bad { + c.details + .push(CheckDetail::new(format!("{} (BAD)", root), path.clone())); + } + for (root, path) in &sus { + c.details.push(CheckDetail::new( + format!("{} (suspicious)", root), + path.clone(), + )); + } + + if !bad.is_empty() { + c.suggestion = Some( + "Found compatibility-mode entries on League/Riot executables. \"Run as administrator\" or any compatibility flag on League's binaries breaks the patcher's process injection. Remove every entry below — right-click the .exe → Properties → Compatibility → uncheck everything, OR run the command as administrator." + .into(), + ); + // `reg.exe` accepts `HKCU\...` / `HKLM\...` — the `HKCU:\` form is a + // PowerShell PSDrive convention and is rejected by reg.exe with + // "Invalid key name". Stick to the universal syntax. No leading + // comment line: cmd-style `::` is a parse error in PowerShell and + // PowerShell-style `#` is a parse error in cmd, so we ship the bare + // commands and rely on the UI to label them. + let mut script = String::new(); + for (root, path) in &bad { + script.push_str(&format!( + "reg delete \"{}\\{}\" /v \"{}\" /f\n", + root, COMPAT_KEY, path + )); + } + c.fix_command = Some(script.trim_end().to_string()); + } + c +} + +#[cfg(not(target_os = "windows"))] +pub fn check_compat_flags() -> Check { + check( + "compat_flags.layers", + "League/Riot compatibility flags", + Category::League, + Severity::Info, + "Not applicable", + ) +} + +#[cfg(test)] +#[cfg(target_os = "windows")] +mod tests { + use super::*; + + #[test] + fn basename_strips_drive_path() { + assert_eq!( + basename(r"C:\Riot Games\League of Legends\League of Legends.exe"), + "League of Legends.exe" + ); + } + + #[test] + fn basename_handles_no_separator() { + assert_eq!(basename("League of Legends.exe"), "League of Legends.exe"); + } +} diff --git a/src-tauri/src/diagnostics/library_index.rs b/src-tauri/src/diagnostics/library_index.rs new file mode 100644 index 0000000..42c83f4 --- /dev/null +++ b/src-tauri/src/diagnostics/library_index.rs @@ -0,0 +1,96 @@ +//! Library index integrity check. +//! +//! Parses `mod_library_index.json` (without going through the full schema +//! migration that the live `ModLibrary` does) and reports a hard error if it +//! is unparseable. This catches the corruption case fixed in commit e96dff9 +//! before the user hits a broken library that won't load at all. + +use super::{check, check_ok, Category, Check, CheckCtx, CheckDetail, Severity}; + +const INDEX_FILENAME: &str = "mod_library_index.json"; + +pub fn check_library_index(ctx: &CheckCtx) -> Check { + let Some(storage) = ctx.mod_storage_path.as_ref() else { + return check( + "library.index", + "Mod library index", + Category::Library, + Severity::Info, + "No storage path resolved", + ); + }; + let path = storage.join(INDEX_FILENAME); + if !path.exists() { + let mut c = check_ok( + "library.index", + "Mod library index", + Category::Library, + "No index yet (fresh install)", + ); + c.details + .push(CheckDetail::new("path", path.display().to_string())); + return c; + } + let raw = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + let mut c = check( + "library.index", + "Mod library index", + Category::Library, + Severity::Bad, + "Could not read library index", + ); + c.details + .push(CheckDetail::new("path", path.display().to_string())); + c.details.push(CheckDetail::new("error", e.to_string())); + return c; + } + }; + let size = raw.len(); + match serde_json::from_slice::(&raw) { + Ok(v) => { + let mut c = check_ok( + "library.index", + "Mod library index", + Category::Library, + "Index parses successfully", + ); + c.details + .push(CheckDetail::new("path", path.display().to_string())); + c.details + .push(CheckDetail::new("size_bytes", size.to_string())); + if let Some(version) = v.get("version").and_then(|x| x.as_u64()) { + c.details + .push(CheckDetail::new("schema_version", version.to_string())); + } + if let Some(mods) = v.get("mods").and_then(|x| x.as_object()) { + c.details + .push(CheckDetail::new("mod_count", mods.len().to_string())); + } else if let Some(mods) = v.get("mods").and_then(|x| x.as_array()) { + c.details + .push(CheckDetail::new("mod_count", mods.len().to_string())); + } + c + } + Err(e) => { + let mut c = check( + "library.index", + "Mod library index", + Category::Library, + Severity::Bad, + "Index file is corrupted (JSON parse failed)", + ); + c.details + .push(CheckDetail::new("path", path.display().to_string())); + c.details + .push(CheckDetail::new("size_bytes", size.to_string())); + c.details.push(CheckDetail::new("error", e.to_string())); + c.suggestion = Some( + "Your library index is corrupted. The latest version of LTK Manager will skip a corrupt index and let you re-import mods, but if you're stuck on an older version, delete or rename the file shown above and re-launch." + .into(), + ); + c + } + } +} diff --git a/src-tauri/src/diagnostics/mod.rs b/src-tauri/src/diagnostics/mod.rs new file mode 100644 index 0000000..98f7d4d --- /dev/null +++ b/src-tauri/src/diagnostics/mod.rs @@ -0,0 +1,204 @@ +//! Diagnostics module — system health checks for troubleshooting patcher issues. +//! +//! Replaces and extends the original `cslol-diag.exe` tool from cslol-manager. +//! Each check is a pure function that returns a [`Check`]; the report is the +//! ordered list of all checks. Phase 1 is read-only; fixes (registry edits, +//! service stops) are deferred to a later phase via shown commands the user +//! runs in an elevated terminal. + +use serde::Serialize; +use std::path::PathBuf; +use ts_rs::TS; + +mod compat_flags; +mod library_index; +mod patcher_dll; +mod paths; +mod processes; +mod storage_medium; +mod windows; + +#[cfg(target_os = "windows")] +pub(crate) mod win_util; + +/// Severity of a diagnostic check result. +/// +/// Variants are declared best-to-worst (`Ok < Info < Warn < Bad`). The +/// frontend re-sorts to display worst-first; do not derive `Ord` from this +/// declaration order without revisiting the UI sort logic in +/// `DiagnosticsReport.tsx`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + /// Check passed. + Ok, + /// Informational — no action needed (e.g. CPU model, language). + Info, + /// Suspicious — may cause problems, worth investigating. + Warn, + /// Known to break the patcher, should be fixed. + Bad, +} + +/// Coarse grouping for the UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "lowercase")] +pub enum Category { + /// OS-level checks (Windows version, UAC, long paths). + System, + /// League installation checks (path, writability, compat flags). + League, + /// LTK Manager checks (admin status, install path). + Manager, + /// Patcher / DLL checks (presence, signature, locked-by handles). + Patcher, + /// Storage / mod-storage path checks. + Storage, + /// Mod library state checks (index integrity). + Library, +} + +/// A single key/value detail row attached to a check. +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct CheckDetail { + pub key: String, + pub value: String, +} + +impl CheckDetail { + pub fn new(key: impl Into, value: impl Into) -> Self { + Self { + key: key.into(), + value: value.into(), + } + } +} + +/// Result of a single diagnostic check. +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct Check { + /// Stable identifier (e.g. `"windows.long_paths"`). Survives label changes. + pub id: String, + /// Human-readable label. + pub label: String, + pub category: Category, + pub severity: Severity, + /// One-line summary of the result, shown next to the label. + pub summary: String, + /// Optional structured details, shown when the row is expanded. + #[serde(default)] + pub details: Vec, + /// Optional plain-text guidance for the user. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub suggestion: Option, + /// Optional command (PowerShell / cmd / shell) to run as a fix. Shown + /// alongside the suggestion with a copy button. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub fix_command: Option, +} + +/// Full diagnostic report returned by `run_diagnostics`. +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct DiagnosticReport { + /// ISO-8601 UTC timestamp. + pub generated_at: String, + /// Manager version (matches `Cargo.toml`). + pub app_version: String, + /// All checks in display order. + pub checks: Vec, +} + +/// Context passed to each check. Keeps individual checks free of `tauri` +/// dependencies so they remain unit-testable. +pub(crate) struct CheckCtx { + /// League install root (e.g. `C:\Riot Games\League of Legends`). + pub league_path: Option, + /// Resolved mod storage directory — the path the rest of the app actually + /// uses, with the `app_data_dir` fallback already applied. Only `None` if + /// even the fallback could not be resolved (no Tauri app-data dir). + pub mod_storage_path: Option, + /// True when [`mod_storage_path`] came from the fallback (user has not + /// configured a custom path in Settings). + pub mod_storage_is_default: bool, + /// Resource directory containing `cslol-dll.dll`. None if it could not be resolved. + pub patcher_dll_path: Option, + /// Manager executable path. Unused by phase-1 checks but kept for the + /// future handle-leak / signature checks on the manager itself. + #[allow(dead_code)] + pub manager_exe: Option, +} + +/// Build a [`Check`] for a quick OK result with no details. +pub(crate) fn check_ok(id: &str, label: &str, category: Category, summary: &str) -> Check { + Check { + id: id.into(), + label: label.into(), + category, + severity: Severity::Ok, + summary: summary.into(), + details: Vec::new(), + suggestion: None, + fix_command: None, + } +} + +/// Build a [`Check`] for a non-OK result. Use the builder helpers to attach +/// details / suggestions. +pub(crate) fn check( + id: &str, + label: &str, + category: Category, + severity: Severity, + summary: impl Into, +) -> Check { + Check { + id: id.into(), + label: label.into(), + category, + severity, + summary: summary.into(), + details: Vec::new(), + suggestion: None, + fix_command: None, + } +} + +/// Run the full suite of diagnostics. Each check is independent and infallible +/// at this layer — checks that fail to gather data report a `Warn` or `Bad` +/// severity rather than propagating an error. +pub fn run_all(ctx: &CheckCtx) -> Vec { + vec![ + // System + windows::check_version(), + windows::check_long_paths_enabled(), + windows::check_uac_enabled(), + // Manager + processes::check_manager_not_admin(), + // League + paths::check_league_path(ctx), + paths::check_league_writability(ctx), + compat_flags::check_compat_flags(), + // Storage + paths::check_storage_path(ctx), + paths::check_storage_writability(ctx), + paths::check_storage_in_league(ctx), + paths::check_free_space(ctx), + storage_medium::check_storage_medium(ctx), + // Patcher + patcher_dll::check_dll_present(ctx), + patcher_dll::check_dll_signature(ctx), + patcher_dll::check_dll_not_locked(ctx), + // Library + library_index::check_library_index(ctx), + ] +} diff --git a/src-tauri/src/diagnostics/patcher_dll.rs b/src-tauri/src/diagnostics/patcher_dll.rs new file mode 100644 index 0000000..baa2d4a --- /dev/null +++ b/src-tauri/src/diagnostics/patcher_dll.rs @@ -0,0 +1,208 @@ +//! Patcher DLL diagnostics: presence, Authenticode signature, file lock. +//! +//! Vanguard / unrelated processes occasionally leave file handles open on +//! `cslol-dll.dll`. When that happens, the patcher's next start either fails +//! to load the DLL or silently runs against a stale image. The lock-probe +//! check here is the phase-1 detector; phase 2 will add full handle-owner +//! enumeration via NtQuerySystemInformation. + +use super::{check, check_ok, Category, Check, CheckCtx, CheckDetail, Severity}; + +#[cfg(target_os = "windows")] +use super::win_util::is_file_locked; + +pub fn check_dll_present(ctx: &CheckCtx) -> Check { + match ctx.patcher_dll_path.as_ref() { + Some(p) if p.exists() => { + let mut c = check_ok( + "patcher.dll.present", + "Patcher DLL present", + Category::Patcher, + &p.display().to_string(), + ); + if let Ok(meta) = std::fs::metadata(p) { + c.details + .push(CheckDetail::new("size", meta.len().to_string())); + } + c + } + Some(p) => { + let mut c = check( + "patcher.dll.present", + "Patcher DLL present", + Category::Patcher, + Severity::Bad, + "cslol-dll.dll not found at resolved path", + ); + c.details + .push(CheckDetail::new("path", p.display().to_string())); + c.suggestion = Some( + "The patcher's bundled DLL is missing. Reinstall LTK Manager from the latest release." + .into(), + ); + c + } + None => check( + "patcher.dll.present", + "Patcher DLL present", + Category::Patcher, + Severity::Bad, + "Could not resolve resource directory", + ), + } +} + +#[cfg(target_os = "windows")] +pub fn check_dll_signature(ctx: &CheckCtx) -> Check { + let Some(path) = ctx.patcher_dll_path.as_ref().filter(|p| p.exists()) else { + return check( + "patcher.dll.signature", + "Patcher DLL signature", + Category::Patcher, + Severity::Info, + "DLL not present, skipped", + ); + }; + let result = verify_authenticode(path); + match result { + Ok(0) => check_ok( + "patcher.dll.signature", + "Patcher DLL signature", + Category::Patcher, + "Valid Authenticode signature", + ), + Ok(code) => { + let mut c = check( + "patcher.dll.signature", + "Patcher DLL signature", + Category::Patcher, + Severity::Warn, + format!("Unsigned or invalid signature (0x{:08x})", code as u32), + ); + c.details + .push(CheckDetail::new("path", path.display().to_string())); + c.suggestion = Some( + "The bundled DLL is unsigned or its signature didn't validate. This is informational — official builds may not be signed yet — but if you downloaded from somewhere other than the official GitHub release, redownload from there." + .into(), + ); + c + } + Err(e) => { + let mut c = check( + "patcher.dll.signature", + "Patcher DLL signature", + Category::Patcher, + Severity::Info, + "Could not verify (system call failed)", + ); + c.details.push(CheckDetail::new("error", e)); + c + } + } +} + +#[cfg(not(target_os = "windows"))] +pub fn check_dll_signature(_ctx: &CheckCtx) -> Check { + check( + "patcher.dll.signature", + "Patcher DLL signature", + Category::Patcher, + Severity::Info, + "Not applicable", + ) +} + +pub fn check_dll_not_locked(ctx: &CheckCtx) -> Check { + #[cfg(target_os = "windows")] + { + let Some(path) = ctx.patcher_dll_path.as_ref().filter(|p| p.exists()) else { + return check( + "patcher.dll.not_locked", + "Patcher DLL not locked", + Category::Patcher, + Severity::Info, + "DLL not present, skipped", + ); + }; + if is_file_locked(path) { + let mut c = check( + "patcher.dll.not_locked", + "Patcher DLL not locked", + Category::Patcher, + Severity::Warn, + "Another process is holding cslol-dll.dll open", + ); + c.details + .push(CheckDetail::new("path", path.display().to_string())); + c.suggestion = Some( + "An external process — most often Vanguard's vgc.exe or a previous patcher run that didn't clean up — has a handle on cslol-dll.dll. The patcher won't be able to swap in a fresh copy until the lock is released. Try: stop the patcher, close the manager, restart your PC, then start the manager again before launching League. (We'll add a per-handle 'who's holding this?' view in a future release.)" + .into(), + ); + c + } else { + check_ok( + "patcher.dll.not_locked", + "Patcher DLL not locked", + Category::Patcher, + "Not locked", + ) + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = ctx; + check( + "patcher.dll.not_locked", + "Patcher DLL not locked", + Category::Patcher, + Severity::Info, + "Not applicable", + ) + } +} + +/// Verify the Authenticode signature on `path`. Returns: +/// - `Ok(0)` — valid trust chain +/// - `Ok(code)` — `WinVerifyTrust` returned a non-zero (HRESULT-style) status +/// - `Err(msg)` — the call itself couldn't be made +#[cfg(target_os = "windows")] +fn verify_authenticode(path: &std::path::Path) -> Result { + use std::ptr; + use windows_sys::Win32::Security::WinTrust::{ + WinVerifyTrust, WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_DATA_0, + WINTRUST_FILE_INFO, WTD_CHOICE_FILE, WTD_REVOKE_NONE, WTD_STATEACTION_CLOSE, + WTD_STATEACTION_VERIFY, WTD_UI_NONE, + }; + + let wide = super::win_util::path_to_wide(path); + + let mut file = WINTRUST_FILE_INFO { + cbStruct: std::mem::size_of::() as u32, + pcwszFilePath: wide.as_ptr(), + hFile: ptr::null_mut(), + pgKnownSubject: ptr::null_mut(), + }; + + let mut data: WINTRUST_DATA = unsafe { std::mem::zeroed() }; + data.cbStruct = std::mem::size_of::() as u32; + data.dwUIChoice = WTD_UI_NONE; + data.fdwRevocationChecks = WTD_REVOKE_NONE; + data.dwUnionChoice = WTD_CHOICE_FILE; + data.Anonymous = WINTRUST_DATA_0 { + pFile: &mut file as *mut _, + }; + data.dwStateAction = WTD_STATEACTION_VERIFY; + + let mut guid = WINTRUST_ACTION_GENERIC_VERIFY_V2; + // SAFETY: `data` and `guid` are valid for the duration of the call. + let result = + unsafe { WinVerifyTrust(ptr::null_mut(), &mut guid, &mut data as *mut _ as *mut _) }; + // Release the trust-state allocation. Per WinVerifyTrust docs, every + // VERIFY must be paired with a CLOSE — otherwise we leak per call. + data.dwStateAction = WTD_STATEACTION_CLOSE; + // SAFETY: same `data` / `guid` lifetime as above. + unsafe { + WinVerifyTrust(ptr::null_mut(), &mut guid, &mut data as *mut _ as *mut _); + } + Ok(result) +} diff --git a/src-tauri/src/diagnostics/paths.rs b/src-tauri/src/diagnostics/paths.rs new file mode 100644 index 0000000..e75a1ed --- /dev/null +++ b/src-tauri/src/diagnostics/paths.rs @@ -0,0 +1,459 @@ +//! Path / install / storage diagnostic checks. +//! +//! - League path validity, writability, length +//! - Mod storage path validity, writability, free space +//! - Storage NOT inside League dir +//! - Cloud-sync attribute (OneDrive offline / RECALL_ON_DATA_ACCESS) +//! +//! Free-space and length thresholds match cslol-diag (1 GB / 128 chars). + +use std::path::{Path, PathBuf}; + +use super::{check, check_ok, Category, Check, CheckCtx, CheckDetail, Severity}; + +#[cfg(target_os = "windows")] +use super::win_util::has_cloud_sync_attrs; + +const PATH_LEN_WARN: usize = 128; +const FREE_SPACE_WARN: u64 = 1024 * 1024 * 1024; // 1 GB + +fn bytes_to_str(x: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + const TB: u64 = GB * 1024; + if x >= TB { + format!("{:.1} TB", x as f64 / TB as f64) + } else if x >= GB { + format!("{:.1} GB", x as f64 / GB as f64) + } else if x >= MB { + format!("{:.1} MB", x as f64 / MB as f64) + } else if x >= KB { + format!("{:.1} KB", x as f64 / KB as f64) + } else { + format!("{} B", x) + } +} + +/// Try to write a temp file inside `dir` and let it auto-clean. Returns +/// `Ok(())` on success or the underlying io::Error otherwise. +/// +/// We only care about the *write* succeeding — that's what the patcher and +/// overlay builder need. `tempfile::Builder` handles unique naming and RAII +/// cleanup, so we never leak `.ltk-diag-probe-*` files when the remove path +/// fails (locked by AV, transient handle, etc.). +fn probe_writable(dir: &Path) -> std::io::Result<()> { + use std::io::Write; + let mut f = tempfile::Builder::new() + .prefix(".ltk-diag-probe-") + .tempfile_in(dir)?; + f.write_all(b"ltk")?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn free_disk_bytes(path: &Path) -> Option { + use std::ptr; + use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + + let wide = super::win_util::path_to_wide(path); + let mut free: u64 = 0; + // SAFETY: null-terminated wide string; `free` is a stack u64. + let ok = unsafe { + GetDiskFreeSpaceExW( + wide.as_ptr(), + &mut free as *mut u64, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if ok == 0 { + None + } else { + Some(free) + } +} + +#[cfg(not(target_os = "windows"))] +fn free_disk_bytes(_path: &Path) -> Option { + None +} + +#[cfg(not(target_os = "windows"))] +fn has_cloud_sync_attrs(_path: &Path) -> bool { + false +} + +fn is_subpath_of(child: &Path, parent: &Path) -> bool { + let Ok(c) = std::fs::canonicalize(child) else { + return child.starts_with(parent); + }; + let Ok(p) = std::fs::canonicalize(parent) else { + return false; + }; + c.starts_with(p) +} + +/// Recognized cloud-sync directory tokens. Used as a path-component fallback +/// when the file-attribute query returns false (cloud syncing client not +/// installed or path outside the synced root). +const CLOUD_TOKENS: &[&str] = &[ + "OneDrive", + "Dropbox", + "iCloudDrive", + "iCloud Drive", + "Google Drive", + "GoogleDrive", + "Box", + "pCloud", +]; + +fn cloud_token_in_path(path: &Path) -> Option<&'static str> { + let s = path.display().to_string(); + CLOUD_TOKENS.iter().copied().find(|t| s.contains(t)) +} + +pub fn check_league_path(ctx: &CheckCtx) -> Check { + let Some(p) = ctx.league_path.as_ref() else { + return check( + "paths.league.exists", + "League installation path", + Category::League, + Severity::Bad, + "Not configured", + ); + }; + let game_exe = p.join("Game").join("League of Legends.exe"); + let mac_path = p.join("Contents").join("LoL").join("Game"); + let exists = game_exe.exists() || mac_path.exists(); + if !exists { + let mut c = check( + "paths.league.exists", + "League installation path", + Category::League, + Severity::Bad, + "Configured path doesn't contain League of Legends", + ); + c.details + .push(CheckDetail::new("path", p.display().to_string())); + c.suggestion = Some( + "League's executable was not found at the configured path. Re-run setup or update the path in Settings." + .into(), + ); + return c; + } + let mut c = check_ok( + "paths.league.exists", + "League installation path", + Category::League, + &p.display().to_string(), + ); + let len = p.display().to_string().len(); + if len > PATH_LEN_WARN { + c.severity = Severity::Warn; + c.summary = format!("{} (path length {} > {})", p.display(), len, PATH_LEN_WARN); + c.suggestion = Some( + "League is installed under a deeply nested path. Combined with a similarly deep mod folder this can hit the legacy 260-character path limit. Consider moving League to a shorter path." + .into(), + ); + } + c.details.push(CheckDetail::new("length", len.to_string())); + if has_cloud_sync_attrs(p) { + c.severity = Severity::Bad; + c.summary = format!("{} (cloud-only / OneDrive)", p.display()); + c.suggestion = Some( + "League's installation has cloud-sync attributes set (e.g. OneDrive Files-On-Demand). The patcher cannot reliably overlay files that may be evicted to the cloud. Move League outside any cloud-synced folder." + .into(), + ); + c.details.push(CheckDetail::new( + "cloud_attrs", + "FILE_ATTRIBUTE_OFFLINE / RECALL_ON_DATA_ACCESS", + )); + } else if let Some(t) = cloud_token_in_path(p) { + if c.severity == Severity::Ok { + c.severity = Severity::Warn; + c.summary = format!("{} (path contains \"{}\")", p.display(), t); + } + c.details.push(CheckDetail::new("cloud_token", t)); + } + c +} + +pub fn check_league_writability(ctx: &CheckCtx) -> Check { + let Some(p) = ctx.league_path.as_ref() else { + return check( + "paths.league.writable", + "League directory is writable", + Category::League, + Severity::Info, + "League path not configured", + ); + }; + let game_dir = p.join("Game"); + let target = if game_dir.exists() { + game_dir + } else { + p.clone() + }; + match probe_writable(&target) { + Ok(()) => check_ok( + "paths.league.writable", + "League directory is writable", + Category::League, + "Writable", + ), + Err(e) => { + let mut c = check( + "paths.league.writable", + "League directory is writable", + Category::League, + Severity::Bad, + "Cannot write to League's Game directory", + ); + c.details + .push(CheckDetail::new("path", target.display().to_string())); + c.details.push(CheckDetail::new("error", e.to_string())); + c.suggestion = Some( + "The patcher needs to write the overlay into the Game folder. Make sure ltk-manager is NOT running as administrator (a non-admin manager + non-admin League is the supported config), check NTFS permissions, and confirm no antivirus is blocking writes to the League directory." + .into(), + ); + c + } + } +} + +pub fn check_storage_path(ctx: &CheckCtx) -> Check { + let Some(p) = ctx.mod_storage_path.as_ref() else { + return check( + "paths.storage.exists", + "Mod storage path", + Category::Storage, + Severity::Bad, + "Could not resolve a storage directory (Tauri app-data dir unavailable)", + ); + }; + let summary_suffix = if ctx.mod_storage_is_default { + " (default)" + } else { + "" + }; + if !p.exists() { + let mut c = check( + "paths.storage.exists", + "Mod storage path", + Category::Storage, + Severity::Warn, + format!( + "Storage directory does not exist yet{} — will be created on first use", + summary_suffix + ), + ); + c.details + .push(CheckDetail::new("path", p.display().to_string())); + if ctx.mod_storage_is_default { + c.details + .push(CheckDetail::new("source", "default (app-data dir)")); + } + return c; + } + let mut c = check_ok( + "paths.storage.exists", + "Mod storage path", + Category::Storage, + &format!("{}{}", p.display(), summary_suffix), + ); + if ctx.mod_storage_is_default { + c.details + .push(CheckDetail::new("source", "default (app-data dir)")); + } + if has_cloud_sync_attrs(p) { + c.severity = Severity::Bad; + c.summary = format!("{} (cloud-only / OneDrive){}", p.display(), summary_suffix); + c.suggestion = Some( + "Your mod storage folder has cloud-sync attributes. Mods on cloud-only storage will be re-downloaded every time the patcher reads them — slow at best, broken at worst. Move storage to a local-only folder." + .into(), + ); + c.details.push(CheckDetail::new( + "cloud_attrs", + "FILE_ATTRIBUTE_OFFLINE / RECALL_ON_DATA_ACCESS", + )); + } else if let Some(t) = cloud_token_in_path(p) { + c.severity = Severity::Warn; + c.summary = format!( + "{} (path contains \"{}\"){}", + p.display(), + t, + summary_suffix + ); + c.suggestion = Some( + format!( + "Storage path appears to live under a {} folder. If sync is active this will cause patcher slowness and potential corruption — recommend moving storage out of any cloud-synced folder.", + t + ), + ); + c.details.push(CheckDetail::new("cloud_token", t)); + } + c +} + +pub fn check_storage_writability(ctx: &CheckCtx) -> Check { + let Some(p) = ctx.mod_storage_path.as_ref() else { + return check( + "paths.storage.writable", + "Storage directory is writable", + Category::Storage, + Severity::Info, + "No storage path resolved", + ); + }; + if !p.exists() { + return check( + "paths.storage.writable", + "Storage directory is writable", + Category::Storage, + Severity::Info, + "Storage directory does not exist yet — skipped", + ); + } + match probe_writable(p) { + Ok(()) => check_ok( + "paths.storage.writable", + "Storage directory is writable", + Category::Storage, + "Writable", + ), + Err(e) => { + let mut c = check( + "paths.storage.writable", + "Storage directory is writable", + Category::Storage, + Severity::Bad, + "Cannot write to mod storage directory", + ); + c.details.push(CheckDetail::new("error", e.to_string())); + c.suggestion = Some( + "Mods can't be installed if the storage folder isn't writable. Check NTFS permissions and antivirus exclusions." + .into(), + ); + c + } + } +} + +pub fn check_storage_in_league(ctx: &CheckCtx) -> Check { + let (Some(storage), Some(league)) = (ctx.mod_storage_path.as_ref(), ctx.league_path.as_ref()) + else { + return check( + "paths.storage.not_in_league", + "Storage outside League directory", + Category::Storage, + Severity::Info, + "League path not configured — skipped", + ); + }; + if is_subpath_of(storage, league) { + let mut c = check( + "paths.storage.not_in_league", + "Storage outside League directory", + Category::Storage, + Severity::Bad, + "Mod storage is inside the League installation", + ); + c.details + .push(CheckDetail::new("storage", storage.display().to_string())); + c.details + .push(CheckDetail::new("league", league.display().to_string())); + c.suggestion = Some( + "Storing mods inside the League folder confuses the patcher (mods get rescanned as game WADs and double-counted). Move storage to a separate folder." + .into(), + ); + c + } else { + check_ok( + "paths.storage.not_in_league", + "Storage outside League directory", + Category::Storage, + "OK", + ) + } +} + +pub fn check_free_space(ctx: &CheckCtx) -> Check { + let target: PathBuf = ctx + .mod_storage_path + .clone() + .or_else(|| ctx.league_path.clone()) + .or_else(|| { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) + }) + .unwrap_or_else(|| PathBuf::from(".")); + let Some(free) = free_disk_bytes(&target) else { + return check( + "paths.free_space", + "Free disk space", + Category::Storage, + Severity::Info, + "Could not query free space", + ); + }; + let display = bytes_to_str(free); + if free < FREE_SPACE_WARN { + let mut c = check( + "paths.free_space", + "Free disk space", + Category::Storage, + Severity::Warn, + format!("{} free (< 1 GB)", display), + ); + c.details + .push(CheckDetail::new("path", target.display().to_string())); + c.suggestion = Some( + "Less than 1 GB free on the storage drive. Building the overlay requires temporarily duplicating WAD contents — free up space before starting the patcher." + .into(), + ); + c + } else { + let mut c = check_ok( + "paths.free_space", + "Free disk space", + Category::Storage, + &format!("{} free", display), + ); + c.details + .push(CheckDetail::new("path", target.display().to_string())); + c + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bytes_to_str_formats_units() { + assert_eq!(bytes_to_str(0), "0 B"); + assert_eq!(bytes_to_str(1024), "1.0 KB"); + assert_eq!(bytes_to_str(1024 * 1024), "1.0 MB"); + assert_eq!(bytes_to_str(2 * 1024 * 1024 * 1024), "2.0 GB"); + } + + #[test] + fn cloud_token_detection() { + assert_eq!( + cloud_token_in_path(Path::new(r"C:\Users\foo\OneDrive\Mods")), + Some("OneDrive") + ); + assert_eq!( + cloud_token_in_path(Path::new(r"D:\Riot Games\League of Legends")), + None + ); + } + + #[test] + fn probe_writable_works_in_temp() { + let dir = std::env::temp_dir(); + assert!(probe_writable(&dir).is_ok()); + } +} diff --git a/src-tauri/src/diagnostics/processes.rs b/src-tauri/src/diagnostics/processes.rs new file mode 100644 index 0000000..04754ff --- /dev/null +++ b/src-tauri/src/diagnostics/processes.rs @@ -0,0 +1,190 @@ +//! Process-level diagnostics: manager-not-admin, league-not-running. +//! +//! Both are deliberately worded as positive ("X should be true"). The supported +//! configuration is *non-elevated* manager + *non-elevated* League; running +//! either as administrator breaks the patcher's process-injection. + +use super::{check, Category, Check, Severity}; + +#[cfg(target_os = "windows")] +use super::{check_ok, CheckDetail}; + +#[cfg(target_os = "windows")] +pub fn check_manager_not_admin() -> Check { + if is_running_as_admin() { + let mut c = check( + "process.manager_not_admin", + "LTK Manager not running as admin", + Category::Manager, + Severity::Bad, + "LTK Manager is running elevated", + ); + c.suggestion = Some( + "Running the manager as administrator is the single most common cause of \"patcher running but mods don't load\". Close LTK Manager and relaunch it normally (double-click — do NOT \"Run as administrator\"). If you have a compatibility flag on ltk-manager.exe forcing elevation, remove it from Properties → Compatibility." + .into(), + ); + c + } else { + check_ok( + "process.manager_not_admin", + "LTK Manager not running as admin", + Category::Manager, + "Not elevated", + ) + } +} + +#[cfg(not(target_os = "windows"))] +pub fn check_manager_not_admin() -> Check { + check( + "process.manager_not_admin", + "LTK Manager not running as admin", + Category::Manager, + Severity::Info, + "Not applicable", + ) +} + +#[cfg(target_os = "windows")] +fn is_running_as_admin() -> bool { + use std::mem::size_of; + use std::ptr; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token = ptr::null_mut(); + // SAFETY: GetCurrentProcess is a pseudo-handle that needs no closing. + let ok = unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }; + if ok == 0 { + return false; + } + let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 }; + let mut ret_len: u32 = 0; + // SAFETY: token is valid; struct sizes match. + let ok = unsafe { + GetTokenInformation( + token, + TokenElevation, + &mut elevation as *mut _ as *mut _, + size_of::() as u32, + &mut ret_len, + ) + }; + // SAFETY: token came from OpenProcessToken. + unsafe { CloseHandle(token) }; + ok != 0 && elevation.TokenIsElevated != 0 +} + +/// Diagnostic that flags running League/Vanguard processes. +/// +/// Currently NOT included in the default suite — see [`super::run_all`]. The +/// problem it tries to surface ("close League before re-running") was noisy +/// for every user who ran diagnostics mid-session, while none of the other +/// checks actually require League to be closed. Kept here so the phase-2 +/// Vanguard handle-correlation work can call it and present a more specific +/// signal ("vgc.exe holds a handle on cslol-dll.dll") instead. +#[cfg(target_os = "windows")] +#[allow(dead_code)] +pub fn check_league_not_running() -> Check { + let running = list_running_league(); + if running.is_empty() { + return check_ok( + "process.league_not_running", + "League is not currently running", + Category::Manager, + "League is closed", + ); + } + let mut c = check( + "process.league_not_running", + "League is not currently running", + Category::Manager, + Severity::Warn, + format!( + "{} League/Riot process(es) running — close the game before re-running diagnostics", + running.len() + ), + ); + for (name, pid) in &running { + c.details + .push(CheckDetail::new(name, format!("PID {}", pid))); + } + c.suggestion = Some( + "Some checks (especially the patcher DLL lock probe) give cleaner results when League and Vanguard are not running. Close the client and any running game, then re-run diagnostics." + .into(), + ); + c +} + +#[cfg(not(target_os = "windows"))] +#[allow(dead_code)] +pub fn check_league_not_running() -> Check { + check( + "process.league_not_running", + "League is not currently running", + Category::Manager, + Severity::Info, + "Not applicable", + ) +} + +/// Lowercase basenames considered League / Riot / Vanguard processes. +#[cfg(target_os = "windows")] +#[allow(dead_code)] +const LEAGUE_PROCESS_NAMES: &[&str] = &[ + "league of legends.exe", + "leagueclient.exe", + "leagueclientux.exe", + "leagueclientuxrender.exe", + "riotclientservices.exe", + "riotclientux.exe", + "riotclientuxrender.exe", + "riotclientcrashhandler.exe", + "vgc.exe", + "vgtray.exe", +]; + +#[cfg(target_os = "windows")] +#[allow(dead_code)] +fn list_running_league() -> Vec<(String, u32)> { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + + let mut out = Vec::new(); + // SAFETY: documented snapshot creation. + let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snap == INVALID_HANDLE_VALUE || snap.is_null() { + return out; + } + let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + entry.dwSize = std::mem::size_of::() as u32; + // SAFETY: entry is correctly sized. + if unsafe { Process32FirstW(snap, &mut entry) } == 0 { + unsafe { CloseHandle(snap) }; + return out; + } + loop { + let len = entry + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(entry.szExeFile.len()); + let name = String::from_utf16_lossy(&entry.szExeFile[..len]); + if LEAGUE_PROCESS_NAMES.contains(&name.to_lowercase().as_str()) { + out.push((name, entry.th32ProcessID)); + } + // SAFETY: entry is correctly sized; loop terminates on Process32NextW returning 0. + if unsafe { Process32NextW(snap, &mut entry) } == 0 { + break; + } + } + // SAFETY: snap came from CreateToolhelp32Snapshot. + unsafe { CloseHandle(snap) }; + out +} diff --git a/src-tauri/src/diagnostics/storage_medium.rs b/src-tauri/src/diagnostics/storage_medium.rs new file mode 100644 index 0000000..153a559 --- /dev/null +++ b/src-tauri/src/diagnostics/storage_medium.rs @@ -0,0 +1,57 @@ +//! Storage medium diagnostic — wraps `crate::storage::detect_path_storage_medium`. +//! +//! Builds on HDD can take 15–20 minutes for a large library; this surfaces +//! that explicitly so users see the same warning as the first-time setup +//! flow even after they dismiss the banner. + +use super::{check, check_ok, Category, Check, CheckCtx, CheckDetail, Severity}; +use crate::storage::{detect_path_storage_medium, StorageMedium}; + +pub fn check_storage_medium(ctx: &CheckCtx) -> Check { + let Some(p) = ctx.mod_storage_path.as_ref() else { + return check( + "storage.medium", + "Storage medium", + Category::Storage, + Severity::Info, + "No storage path resolved", + ); + }; + let medium = detect_path_storage_medium(&p.display().to_string()); + match medium { + StorageMedium::Ssd => { + let mut c = check_ok("storage.medium", "Storage medium", Category::Storage, "SSD"); + c.details + .push(CheckDetail::new("path", p.display().to_string())); + c + } + StorageMedium::Hdd => { + let mut c = check( + "storage.medium", + "Storage medium", + Category::Storage, + Severity::Warn, + "HDD — overlay builds will be slow", + ); + c.details + .push(CheckDetail::new("path", p.display().to_string())); + c.suggestion = Some( + "Mod storage is on a spinning hard drive. Building the overlay involves rewriting many WAD files; on HDD this can take 10–20 minutes for large libraries. Move storage to an SSD if you have one available." + .into(), + ); + c + } + StorageMedium::Unknown => { + let mut c = check( + "storage.medium", + "Storage medium", + Category::Storage, + Severity::Info, + "Unknown", + ); + c.details + .push(CheckDetail::new("path", p.display().to_string())); + c + } + } +} diff --git a/src-tauri/src/diagnostics/win_util.rs b/src-tauri/src/diagnostics/win_util.rs new file mode 100644 index 0000000..110937e --- /dev/null +++ b/src-tauri/src/diagnostics/win_util.rs @@ -0,0 +1,236 @@ +//! Small Windows-only helpers shared across diagnostic checks. +//! +//! Wraps registry reads/enumeration and a couple of file-attribute queries. +//! Kept narrow: these are diagnostic conveniences, not a general-purpose API. + +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::ptr; + +use windows_sys::Win32::Foundation::{ + ERROR_MORE_DATA, ERROR_SUCCESS, HANDLE, INVALID_HANDLE_VALUE, +}; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, GetFileAttributesW, FILE_ATTRIBUTE_OFFLINE, FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS, + FILE_GENERIC_READ, INVALID_FILE_ATTRIBUTES, OPEN_EXISTING, +}; +use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegEnumValueW, RegGetValueW, RegOpenKeyExW, HKEY, KEY_QUERY_VALUE, REG_VALUE_TYPE, + RRF_RT_REG_DWORD, RRF_RT_REG_QWORD, RRF_RT_REG_SZ, RRF_ZEROONFAILURE, +}; + +pub use windows_sys::Win32::System::Registry::{ + HKEY_CURRENT_USER as HKCU, HKEY_LOCAL_MACHINE as HKLM, +}; + +fn to_wide(s: &str) -> Vec { + OsStr::new(s) + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +/// Encode a `Path` as a null-terminated UTF-16 buffer suitable for Win32 +/// `*W` calls. Goes via `OsStr::encode_wide` so non-UTF-8 paths (rare but +/// possible on NTFS) round-trip without lossy conversion through `display()`. +pub fn path_to_wide(path: &Path) -> Vec { + path.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +/// Read a `REG_DWORD` or `REG_QWORD` value from the registry. Returns `None` +/// on any failure (missing key, wrong type, access denied). +pub fn reg_read_num(root: HKEY, subkey: &str, value: &str) -> Option { + let subkey_w = to_wide(subkey); + let value_w = to_wide(value); + let mut buf: u64 = 0; + let mut size = std::mem::size_of::() as u32; + // SAFETY: pointers are valid for the supplied lengths; RegGetValueW writes + // at most `size` bytes into `buf`. + let status = unsafe { + RegGetValueW( + root, + subkey_w.as_ptr(), + value_w.as_ptr(), + RRF_RT_REG_DWORD | RRF_RT_REG_QWORD | RRF_ZEROONFAILURE, + ptr::null_mut(), + &mut buf as *mut _ as *mut _, + &mut size, + ) + }; + if status == ERROR_SUCCESS { + Some(buf) + } else { + None + } +} + +/// Read a `REG_SZ` value as a UTF-16 string. Returns `None` on failure. +#[allow(dead_code)] // used in future fix-action code +pub fn reg_read_str(root: HKEY, subkey: &str, value: &str) -> Option { + let subkey_w = to_wide(subkey); + let value_w = to_wide(value); + let mut size: u32 = 0; + // First call: probe the required size. + // SAFETY: passing null buffer with size=0 is the documented probe pattern. + let status = unsafe { + RegGetValueW( + root, + subkey_w.as_ptr(), + value_w.as_ptr(), + RRF_RT_REG_SZ, + ptr::null_mut(), + ptr::null_mut(), + &mut size, + ) + }; + if status != ERROR_SUCCESS && status != ERROR_MORE_DATA { + return None; + } + if size == 0 { + return Some(String::new()); + } + let mut buf: Vec = vec![0; (size as usize).div_ceil(2)]; + let mut size_out = (buf.len() * 2) as u32; + // SAFETY: buf has capacity for at least `size_out` bytes. + let status = unsafe { + RegGetValueW( + root, + subkey_w.as_ptr(), + value_w.as_ptr(), + RRF_RT_REG_SZ | RRF_ZEROONFAILURE, + ptr::null_mut(), + buf.as_mut_ptr() as *mut _, + &mut size_out, + ) + }; + if status != ERROR_SUCCESS { + return None; + } + let chars = (size_out as usize) / 2; + let trimmed = if chars > 0 && buf[chars - 1] == 0 { + &buf[..chars - 1] + } else { + &buf[..chars] + }; + Some(String::from_utf16_lossy(trimmed)) +} + +/// Enumerate the value names under a registry key. Returns an empty Vec on +/// any failure or if the key doesn't exist. +pub fn reg_list_value_names(root: HKEY, subkey: &str) -> Vec { + let subkey_w = to_wide(subkey); + let mut hkey: HKEY = ptr::null_mut(); + // SAFETY: standard RegOpenKeyExW pattern. + let status = unsafe { RegOpenKeyExW(root, subkey_w.as_ptr(), 0, KEY_QUERY_VALUE, &mut hkey) }; + if status != ERROR_SUCCESS { + return Vec::new(); + } + let mut names = Vec::new(); + let mut idx: u32 = 0; + let mut buf: Vec = vec![0; 1024]; + loop { + let mut name_len = buf.len() as u32; + let mut value_type: REG_VALUE_TYPE = 0; + // SAFETY: buf has name_len capacity. RegEnumValueW writes at most + // name_len wchars into buf and updates name_len with the actual count. + let st = unsafe { + RegEnumValueW( + hkey, + idx, + buf.as_mut_ptr(), + &mut name_len, + ptr::null_mut(), + &mut value_type, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if st == ERROR_MORE_DATA { + buf.resize((buf.len() * 2).max(2048), 0); + continue; + } + if st != ERROR_SUCCESS { + break; + } + let name = String::from_utf16_lossy(&buf[..name_len as usize]); + names.push(name); + idx += 1; + } + // SAFETY: hkey was returned by RegOpenKeyExW above. + unsafe { RegCloseKey(hkey) }; + names +} + +/// Returns true if any of the supplied OneDrive / cloud-sync attributes are +/// set on `path`. Returns false if the path doesn't exist or attrs can't be +/// read — diagnostics treat that as "not flagged". +pub fn has_cloud_sync_attrs(path: &Path) -> bool { + let wide = path_to_wide(path); + // SAFETY: null-terminated wide string. + let attrs = unsafe { GetFileAttributesW(wide.as_ptr()) }; + if attrs == INVALID_FILE_ATTRIBUTES { + return false; + } + (attrs & (FILE_ATTRIBUTE_OFFLINE | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS)) != 0 +} + +/// Try to open `path` for reading with no sharing. If the call fails with +/// ERROR_SHARING_VIOLATION (or any failure where the file otherwise exists), +/// return true — somebody else holds a handle. +/// +/// This is a coarse "is anything else holding it open?" probe. We can't tell +/// *who* without phase-2 NT handle enumeration. +pub fn is_file_locked(path: &Path) -> bool { + if !path.exists() { + return false; + } + let wide = path_to_wide(path); + // SAFETY: null-terminated wide string. Zero share mode forces exclusive + // open; failure is the signal we want. + let h: HANDLE = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_GENERIC_READ, + 0, + ptr::null_mut(), + OPEN_EXISTING, + 0, + ptr::null_mut(), + ) + }; + if h == INVALID_HANDLE_VALUE || h.is_null() { + return true; + } + // SAFETY: h came from CreateFileW. + unsafe { windows_sys::Win32::Foundation::CloseHandle(h) }; + false +} + +/// Re-export commonly used roots so check files don't need to depend on +/// windows-sys directly. +pub const ROOTS: &[(HKEY, &str)] = &[(HKCU, "HKCU"), (HKLM, "HKLM")]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reg_read_num_handles_missing_key() { + let v = reg_read_num( + HKLM, + "SOFTWARE\\__ltk_diag_test_definitely_missing__", + "value", + ); + assert!(v.is_none()); + } + + #[test] + fn reg_list_handles_missing_key() { + let v = reg_list_value_names(HKLM, "SOFTWARE\\__ltk_diag_test_definitely_missing__"); + assert!(v.is_empty()); + } +} diff --git a/src-tauri/src/diagnostics/windows.rs b/src-tauri/src/diagnostics/windows.rs new file mode 100644 index 0000000..f022c05 --- /dev/null +++ b/src-tauri/src/diagnostics/windows.rs @@ -0,0 +1,160 @@ +//! OS-level diagnostic checks: Windows version, long-paths registry, +//! UAC enabled. These don't depend on any user paths and run on app launch. + +use super::{check, Category, Check, Severity}; + +#[cfg(target_os = "windows")] +use super::win_util::{reg_read_num, HKLM}; +#[cfg(target_os = "windows")] +use super::{check_ok, CheckDetail}; + +#[cfg(target_os = "windows")] +const MIN_OK_BUILD: u32 = 19045; +#[cfg(target_os = "windows")] +const KNOWN_BAD_BUILD: u32 = 22000; + +/// Read OS major/minor/build from the kernel-shared user data page (KUSER_SHARED_DATA). +/// This is the same trick `cslol-diag` used and avoids `GetVersionExW` lying +/// when the manager isn't manifested for the current OS. +#[cfg(target_os = "windows")] +fn read_kuser_version() -> (u32, u32, u32) { + // SAFETY: KUSER_SHARED_DATA is mapped read-only at 0x7FFE0000 on every + // Windows process; reads from these documented offsets are well-defined. + unsafe { + let major = std::ptr::read_volatile((0x7ffe0000 + 0x26c) as *const u32); + let minor = std::ptr::read_volatile((0x7ffe0000 + 0x270) as *const u32); + let build = std::ptr::read_volatile((0x7ffe0000 + 0x260) as *const u32); + (major, minor, build) + } +} + +#[cfg(target_os = "windows")] +pub fn check_version() -> Check { + let (major, minor, build) = read_kuser_version(); + let display = format!("Windows {}.{}.{}", major, minor, build); + if build < MIN_OK_BUILD || build == KNOWN_BAD_BUILD { + let mut c = check( + "windows.version", + "Windows version", + Category::System, + Severity::Bad, + display, + ); + c.suggestion = Some( + "Your Windows build is older than the minimum supported by League. Run Windows Update to install the latest cumulative update before troubleshooting further." + .into(), + ); + c.details.push(CheckDetail::new("major", major.to_string())); + c.details.push(CheckDetail::new("minor", minor.to_string())); + c.details.push(CheckDetail::new("build", build.to_string())); + c + } else { + let mut c = check_ok( + "windows.version", + "Windows version", + Category::System, + &display, + ); + c.details.push(CheckDetail::new("build", build.to_string())); + c + } +} + +#[cfg(not(target_os = "windows"))] +pub fn check_version() -> Check { + check( + "windows.version", + "Windows version", + Category::System, + Severity::Info, + "Not running on Windows", + ) +} + +#[cfg(target_os = "windows")] +pub fn check_long_paths_enabled() -> Check { + let value = reg_read_num( + HKLM, + "SYSTEM\\CurrentControlSet\\Control\\FileSystem", + "LongPathsEnabled", + ) + .unwrap_or(0); + if value == 0 { + let mut c = check( + "windows.long_paths", + "Long paths enabled", + Category::System, + Severity::Warn, + "Disabled — paths longer than 260 chars will fail", + ); + c.suggestion = Some( + "Mods stored deep in folders (especially under OneDrive) can hit the legacy 260-char path limit. Enable long-path support in the Windows registry." + .into(), + ); + c.fix_command = Some( + r#"reg add "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled /t REG_DWORD /d 1 /f"# + .into(), + ); + c + } else { + check_ok( + "windows.long_paths", + "Long paths enabled", + Category::System, + "Enabled", + ) + } +} + +#[cfg(not(target_os = "windows"))] +pub fn check_long_paths_enabled() -> Check { + check( + "windows.long_paths", + "Long paths enabled", + Category::System, + Severity::Info, + "Not applicable", + ) +} + +#[cfg(target_os = "windows")] +pub fn check_uac_enabled() -> Check { + let value = reg_read_num( + HKLM, + "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System", + "EnableLUA", + ) + .unwrap_or(1); + if value == 0 { + let mut c = check( + "windows.uac", + "User Account Control", + Category::System, + Severity::Bad, + "Disabled — every process runs elevated", + ); + c.suggestion = Some( + "UAC is disabled, which means everything runs as administrator. League's anti-cheat and the patcher both rely on UAC being on. Re-enable it under User Accounts → Change User Account Control settings, then reboot." + .into(), + ); + c + } else { + check_ok( + "windows.uac", + "User Account Control", + Category::System, + "Enabled", + ) + } +} + +#[cfg(not(target_os = "windows"))] +pub fn check_uac_enabled() -> Check { + check( + "windows.uac", + "User Account Control", + Category::System, + Severity::Info, + "Not applicable", + ) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3b8b3d9..9d5519f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,6 +5,7 @@ mod commands; mod deep_link; +mod diagnostics; mod error; mod hotkeys; mod legacy_patcher; @@ -105,6 +106,9 @@ fn main() { commands::minimize_to_tray, // Storage commands::detect_storage_medium, + // Diagnostics + commands::run_diagnostics, + commands::open_elevated_terminal, // Workshop commands::get_workshop_projects, commands::create_workshop_project, diff --git a/src/lib/bindings/Category.ts b/src/lib/bindings/Category.ts new file mode 100644 index 0000000..1b6bb6b --- /dev/null +++ b/src/lib/bindings/Category.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Coarse grouping for the UI. + */ +export type Category = "system" | "league" | "manager" | "patcher" | "storage" | "library"; diff --git a/src/lib/bindings/Check.ts b/src/lib/bindings/Check.ts new file mode 100644 index 0000000..ad1e4e7 --- /dev/null +++ b/src/lib/bindings/Check.ts @@ -0,0 +1,37 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Category } from "./Category"; +import type { CheckDetail } from "./CheckDetail"; +import type { Severity } from "./Severity"; + +/** + * Result of a single diagnostic check. + */ +export type Check = { + /** + * Stable identifier (e.g. `"windows.long_paths"`). Survives label changes. + */ + id: string; + /** + * Human-readable label. + */ + label: string; + category: Category; + severity: Severity; + /** + * One-line summary of the result, shown next to the label. + */ + summary: string; + /** + * Optional structured details, shown when the row is expanded. + */ + details: Array; + /** + * Optional plain-text guidance for the user. + */ + suggestion?: string; + /** + * Optional command (PowerShell / cmd / shell) to run as a fix. Shown + * alongside the suggestion with a copy button. + */ + fixCommand?: string; +}; diff --git a/src/lib/bindings/CheckDetail.ts b/src/lib/bindings/CheckDetail.ts new file mode 100644 index 0000000..548cd33 --- /dev/null +++ b/src/lib/bindings/CheckDetail.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A single key/value detail row attached to a check. + */ +export type CheckDetail = { key: string; value: string }; diff --git a/src/lib/bindings/DiagnosticReport.ts b/src/lib/bindings/DiagnosticReport.ts new file mode 100644 index 0000000..e798a3b --- /dev/null +++ b/src/lib/bindings/DiagnosticReport.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Check } from "./Check"; + +/** + * Full diagnostic report returned by `run_diagnostics`. + */ +export type DiagnosticReport = { + /** + * ISO-8601 UTC timestamp. + */ + generatedAt: string; + /** + * Manager version (matches `Cargo.toml`). + */ + appVersion: string; + /** + * All checks in display order. + */ + checks: Array; +}; diff --git a/src/lib/bindings/Severity.ts b/src/lib/bindings/Severity.ts new file mode 100644 index 0000000..40210f9 --- /dev/null +++ b/src/lib/bindings/Severity.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Severity of a diagnostic check result. Ordered from best to worst. + */ +export type Severity = "ok" | "info" | "warn" | "bad"; diff --git a/src/lib/bindings/index.ts b/src/lib/bindings/index.ts index 0f6525c..84fb8a2 100644 --- a/src/lib/bindings/index.ts +++ b/src/lib/bindings/index.ts @@ -4,10 +4,14 @@ export type { AppInfo } from "./AppInfo"; export type { AuthorProfile } from "./AuthorProfile"; export type { BulkInstallError } from "./BulkInstallError"; export type { BulkInstallResult } from "./BulkInstallResult"; +export type { Category } from "./Category"; +export type { Check } from "./Check"; +export type { CheckDetail } from "./CheckDetail"; export type { ContentEntry } from "./ContentEntry"; export type { ContentTree } from "./ContentTree"; export type { CreateProjectArgs } from "./CreateProjectArgs"; export type { CslolModInfo } from "./CslolModInfo"; +export type { DiagnosticReport } from "./DiagnosticReport"; export type { ErrorCode } from "./ErrorCode"; export type { FantomeImportProgress } from "./FantomeImportProgress"; export type { FantomeImportStage } from "./FantomeImportStage"; @@ -40,6 +44,7 @@ export type { Profile } from "./Profile"; export type { ProfileSlug } from "./ProfileSlug"; export type { SaveProjectConfigArgs } from "./SaveProjectConfigArgs"; export type { Settings } from "./Settings"; +export type { Severity } from "./Severity"; export type { StorageMedium } from "./StorageMedium"; export type { Theme } from "./Theme"; export type { ValidationResult } from "./ValidationResult"; diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 392ab25..5068a1d 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -7,6 +7,7 @@ import type { ContentTree, CreateProjectArgs, CslolModInfo, + DiagnosticReport, FantomePeekResult, HotkeyAction, ImportFantomeArgs, @@ -170,6 +171,11 @@ export const api = { detectStorageMedium: (path: string) => invokeResult("detect_storage_medium", { path }), + // Diagnostics + runDiagnostics: () => invokeResult("run_diagnostics"), + openElevatedTerminal: (withBanner: boolean) => + invokeResult("open_elevated_terminal", { withBanner }), + // Workshop getWorkshopProjects: () => invokeResult("get_workshop_projects"), createWorkshopProject: (args: CreateProjectArgs) => diff --git a/src/modules/diagnostics/api/index.ts b/src/modules/diagnostics/api/index.ts new file mode 100644 index 0000000..ecaf88b --- /dev/null +++ b/src/modules/diagnostics/api/index.ts @@ -0,0 +1,2 @@ +export { diagnosticsKeys } from "./keys"; +export { useDiagnostics } from "./useDiagnostics"; diff --git a/src/modules/diagnostics/api/keys.ts b/src/modules/diagnostics/api/keys.ts new file mode 100644 index 0000000..c6129bb --- /dev/null +++ b/src/modules/diagnostics/api/keys.ts @@ -0,0 +1,3 @@ +export const diagnosticsKeys = { + report: () => ["diagnostics", "report"] as const, +}; diff --git a/src/modules/diagnostics/api/useDiagnostics.ts b/src/modules/diagnostics/api/useDiagnostics.ts new file mode 100644 index 0000000..09d188c --- /dev/null +++ b/src/modules/diagnostics/api/useDiagnostics.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api, type AppError, type DiagnosticReport } from "@/lib/tauri"; +import { queryFn } from "@/utils/query"; + +import { diagnosticsKeys } from "./keys"; + +/** + * Fetch the diagnostic report. Auto-fires on mount (the page is the only + * caller) and is otherwise stable — `staleTime: Infinity` means TanStack + * won't background-refetch on focus or reconnect; the user re-runs explicitly + * via `query.refetch()` from the Re-run button. + */ +export function useDiagnostics() { + return useQuery({ + queryKey: diagnosticsKeys.report(), + queryFn: queryFn(api.runDiagnostics), + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: Infinity, + gcTime: Infinity, + }); +} diff --git a/src/modules/diagnostics/components/CheckRow.tsx b/src/modules/diagnostics/components/CheckRow.tsx new file mode 100644 index 0000000..a77876d --- /dev/null +++ b/src/modules/diagnostics/components/CheckRow.tsx @@ -0,0 +1,130 @@ +import { ChevronRight, Copy, ShieldUser } from "lucide-react"; +import { useState } from "react"; +import { twMerge } from "tailwind-merge"; + +import { Button, IconButton, Tooltip, useToast } from "@/components"; +import { api, type Check, isErr } from "@/lib/tauri"; + +import { SeverityBadge } from "./SeverityBadge"; + +const VERBATIM_PREFIX = /^(\\\\\?\\|\/\/\?\/)/; + +function normalizePath(value: string): string { + return value.replace(VERBATIM_PREFIX, ""); +} + +export function CheckRow({ check }: { check: Check }) { + const [open, setOpen] = useState(false); + const toast = useToast(); + const hasDetails = check.details.length > 0 || !!check.suggestion || !!check.fixCommand; + + function copyCommand() { + if (!check.fixCommand) return; + navigator.clipboard + .writeText(check.fixCommand) + .then(() => toast.success("Copied", "Run the command in an elevated terminal")) + .catch(() => toast.error("Copy failed", "Could not access the clipboard")); + } + + async function runAsAdmin() { + if (!check.fixCommand) return; + let clipboardOk = true; + try { + await navigator.clipboard.writeText(check.fixCommand); + } catch { + clipboardOk = false; + } + const result = await api.openElevatedTerminal(clipboardOk); + if (isErr(result)) { + toast.error("Could not open elevated terminal", result.error.message); + return; + } + if (clipboardOk) { + toast.success("Elevated terminal opened", "Paste with Ctrl+V and press Enter"); + } else { + toast.warning( + "Elevated terminal opened, but copy failed", + "Copy the command manually from the diagnostics row before running it.", + ); + } + } + + return ( +
+ + + {open && hasDetails && ( +
+ {check.details.length > 0 && ( +
+ {check.details.map((d, i) => ( +
+
{d.key}
+
{normalizePath(d.value)}
+
+ ))} +
+ )} + {check.suggestion && ( +

{check.suggestion}

+ )} + {check.fixCommand && ( +
+
+ + FIX COMMAND (run as administrator) + +
+ + } + variant="ghost" + size="sm" + onClick={copyCommand} + aria-label="Copy command" + /> + + +
+
+
+                {check.fixCommand}
+              
+
+ )} +
+ )} +
+ ); +} diff --git a/src/modules/diagnostics/components/DiagnosticsReport.tsx b/src/modules/diagnostics/components/DiagnosticsReport.tsx new file mode 100644 index 0000000..9b3e53a --- /dev/null +++ b/src/modules/diagnostics/components/DiagnosticsReport.tsx @@ -0,0 +1,107 @@ +import { useMemo } from "react"; + +import type { Category, Check, DiagnosticReport, Severity } from "@/lib/tauri"; + +import { CheckRow } from "./CheckRow"; + +const CATEGORY_ORDER: Category[] = ["system", "manager", "league", "patcher", "storage", "library"]; + +const CATEGORY_LABELS: Record = { + system: { + title: "System", + description: "Windows version, UAC, long-paths support", + }, + manager: { + title: "LTK Manager", + description: "Manager elevation, conflicting processes", + }, + league: { + title: "League installation", + description: "Install path, writability, compatibility flags", + }, + patcher: { + title: "Patcher", + description: "DLL presence, signature, file lock state", + }, + storage: { + title: "Mod storage", + description: "Storage path, free space, drive type", + }, + library: { + title: "Library", + description: "Mod library index integrity", + }, +}; + +const SEVERITY_ORDER: Severity[] = ["bad", "warn", "info", "ok"]; + +interface DiagnosticsReportProps { + report: DiagnosticReport; +} + +export function DiagnosticsReportView({ report }: DiagnosticsReportProps) { + const grouped = useMemo(() => groupChecks(report.checks), [report.checks]); + return ( +
+ + {CATEGORY_ORDER.map((cat) => { + const checks = grouped.get(cat); + if (!checks || checks.length === 0) return null; + const labels = CATEGORY_LABELS[cat]; + return ( +
+
+

{labels.title}

+

{labels.description}

+
+
+ {checks.map((c) => ( + + ))} +
+
+ ); + })} +
+ ); +} + +function groupChecks(checks: Check[]): Map { + const map = new Map(); + for (const c of checks) { + if (!map.has(c.category)) map.set(c.category, []); + map.get(c.category)!.push(c); + } + for (const arr of map.values()) { + arr.sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)); + } + return map; +} + +function CountsHeader({ checks }: { checks: Check[] }) { + const counts: Record = { ok: 0, info: 0, warn: 0, bad: 0 }; + for (const c of checks) counts[c.severity]++; + + const items: { sev: Severity; label: string; cls: string }[] = [ + { sev: "bad", label: "Issues", cls: "text-red-300" }, + { sev: "warn", label: "Warnings", cls: "text-amber-300" }, + { sev: "ok", label: "Passing", cls: "text-green-300" }, + { sev: "info", label: "Info", cls: "text-blue-300" }, + ]; + + return ( +
+ {items.map((it) => ( +
+ + {it.label} + + {counts[it.sev]} +
+ ))} +
+ ); +} diff --git a/src/modules/diagnostics/components/SeverityBadge.tsx b/src/modules/diagnostics/components/SeverityBadge.tsx new file mode 100644 index 0000000..b1df26f --- /dev/null +++ b/src/modules/diagnostics/components/SeverityBadge.tsx @@ -0,0 +1,37 @@ +import { CircleAlert, CircleCheck, CircleX, Info } from "lucide-react"; +import { twMerge } from "tailwind-merge"; +import { match } from "ts-pattern"; + +import type { Severity } from "@/lib/tauri"; + +const styles: Record = { + ok: { bg: "bg-green-950/40 border-green-800/60", text: "text-green-300", label: "OK" }, + info: { bg: "bg-blue-950/40 border-blue-800/60", text: "text-blue-300", label: "INFO" }, + warn: { bg: "bg-amber-950/40 border-amber-800/60", text: "text-amber-300", label: "WARN" }, + bad: { bg: "bg-red-950/40 border-red-800/60", text: "text-red-300", label: "BAD" }, +}; + +export function SeverityBadge({ severity }: { severity: Severity }) { + const s = styles[severity]; + return ( + + + {s.label} + + ); +} + +export function SeverityIcon({ severity, className }: { severity: Severity; className?: string }) { + return match(severity) + .with("ok", () => ) + .with("info", () => ) + .with("warn", () => ) + .with("bad", () => ) + .exhaustive(); +} diff --git a/src/modules/diagnostics/components/index.ts b/src/modules/diagnostics/components/index.ts new file mode 100644 index 0000000..6d35e40 --- /dev/null +++ b/src/modules/diagnostics/components/index.ts @@ -0,0 +1,3 @@ +export { CheckRow } from "./CheckRow"; +export { DiagnosticsReportView } from "./DiagnosticsReport"; +export { SeverityBadge, SeverityIcon } from "./SeverityBadge"; diff --git a/src/modules/diagnostics/index.ts b/src/modules/diagnostics/index.ts new file mode 100644 index 0000000..6c31bb1 --- /dev/null +++ b/src/modules/diagnostics/index.ts @@ -0,0 +1,2 @@ +export { diagnosticsKeys, useDiagnostics } from "./api"; +export * from "./components"; diff --git a/src/modules/shell/components/TitleBar.tsx b/src/modules/shell/components/TitleBar.tsx index c07ec40..f7f82c0 100644 --- a/src/modules/shell/components/TitleBar.tsx +++ b/src/modules/shell/components/TitleBar.tsx @@ -10,6 +10,7 @@ import { Minus, Settings, Square, + Stethoscope, X, } from "lucide-react"; import { useEffect, useState } from "react"; @@ -197,6 +198,26 @@ export function TitleBar({ title = "LTK Manager", appInfo }: TitleBarProps) { /> + + + {({ isActive }) => ( + <> + + {isActive && } + + )} + + + {/* Settings button */} ${cmdLine}`); + } + } + lines.push(""); + } + return lines.join("\n"); +} + +export function Diagnostics() { + const diagnostics = useDiagnostics(); + const toast = useToast(); + const report = diagnostics.data; + + function copyReport() { + if (!report) return; + navigator.clipboard + .writeText(reportToText(report)) + .then(() => toast.success("Copied", "Diagnostic report copied to clipboard")) + .catch(() => toast.error("Copy failed", "Could not access the clipboard")); + } + + return ( +
+
+
+
+

+ + Diagnostics +

+

+ Checks the most common reasons the patcher fails to load mods. Re-run after changing + settings or a Windows update. All checks are read-only — fixes are shown as commands + you can copy and run in an elevated terminal. +

+ {report && ( +

+ Last run: {formatGeneratedAt(report.generatedAt)} · LTK Manager v{report.appVersion} +

+ )} +
+
+ + +
+
+ + {diagnostics.isError && ( + + {diagnostics.error?.message ?? "Unknown error"} + + )} + + {!report && diagnostics.isFetching && ( +
+ +
+ )} + + {report && } +
+
+ ); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 89a498a..741a3b1 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WorkshopRouteImport } from './routes/workshop' import { Route as SettingsRouteImport } from './routes/settings' +import { Route as DiagnosticsRouteImport } from './routes/diagnostics' import { Route as IndexRouteImport } from './routes/index' import { Route as WorkshopIndexRouteImport } from './routes/workshop/index' import { Route as WorkshopProjectNameRouteImport } from './routes/workshop/$projectName' @@ -29,6 +30,11 @@ const SettingsRoute = SettingsRouteImport.update({ path: '/settings', getParentRoute: () => rootRouteImport, } as any) +const DiagnosticsRoute = DiagnosticsRouteImport.update({ + id: '/diagnostics', + path: '/diagnostics', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -70,6 +76,7 @@ const WorkshopProjectNameContentRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/diagnostics': typeof DiagnosticsRoute '/settings': typeof SettingsRoute '/workshop': typeof WorkshopRouteWithChildren '/folder/$folderId': typeof FolderFolderIdRoute @@ -81,6 +88,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/diagnostics': typeof DiagnosticsRoute '/settings': typeof SettingsRoute '/folder/$folderId': typeof FolderFolderIdRoute '/workshop': typeof WorkshopIndexRoute @@ -91,6 +99,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/diagnostics': typeof DiagnosticsRoute '/settings': typeof SettingsRoute '/workshop': typeof WorkshopRouteWithChildren '/folder/$folderId': typeof FolderFolderIdRoute @@ -104,6 +113,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/diagnostics' | '/settings' | '/workshop' | '/folder/$folderId' @@ -115,6 +125,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/diagnostics' | '/settings' | '/folder/$folderId' | '/workshop' @@ -124,6 +135,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/diagnostics' | '/settings' | '/workshop' | '/folder/$folderId' @@ -136,6 +148,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + DiagnosticsRoute: typeof DiagnosticsRoute SettingsRoute: typeof SettingsRoute WorkshopRoute: typeof WorkshopRouteWithChildren FolderFolderIdRoute: typeof FolderFolderIdRoute @@ -157,6 +170,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } + '/diagnostics': { + id: '/diagnostics' + path: '/diagnostics' + fullPath: '/diagnostics' + preLoaderRoute: typeof DiagnosticsRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -240,6 +260,7 @@ const WorkshopRouteWithChildren = WorkshopRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + DiagnosticsRoute: DiagnosticsRoute, SettingsRoute: SettingsRoute, WorkshopRoute: WorkshopRouteWithChildren, FolderFolderIdRoute: FolderFolderIdRoute, diff --git a/src/routes/diagnostics.tsx b/src/routes/diagnostics.tsx new file mode 100644 index 0000000..db475f7 --- /dev/null +++ b/src/routes/diagnostics.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { Diagnostics } from "../pages/Diagnostics"; + +export const Route = createFileRoute("/diagnostics")({ + component: Diagnostics, +});