Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
159 changes: 159 additions & 0 deletions src-tauri/src/commands/diagnostics.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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<SettingsState>,
) -> IpcResult<DiagnosticReport> {
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<u16> {
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 Vec<u16>s 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<SettingsState>,
) -> AppResult<DiagnosticReport> {
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,
})
}
2 changes: 2 additions & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

mod app;
mod deep_link;
mod diagnostics;
mod folders;
pub(crate) mod hotkeys;
mod migration;
Expand All @@ -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::*;
Expand Down
160 changes: 160 additions & 0 deletions src-tauri/src/diagnostics/compat_flags.rs
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading
Loading