//! LQP authentication via League Client lockfile. //! //! The League Client writes a lockfile on startup containing connection credentials. use std::fs; use std::path::PathBuf; use std::time::Duration; use tracing::{debug, trace, warn}; use crate::error::{LqpError, Result}; /// League Client lockfile location (relative to League Client install). pub const LOCKFILE_NAME: &str = "lockfile"; /// Default League Client paths on Linux (via Lutris/Wine). pub const LINUX_CLIENT_PATHS: &[&str] = &[ // Lutris default path "$HOME/Games/League of Legends/LeagueClient", // Wine prefix "$HOME/.wine/drive_c/Riot Games/League of Legends/LeagueClient", ]; /// Default League Client paths on Windows. pub const WINDOWS_CLIENT_PATHS: &[&str] = &[ "C:\\Riot Games\\League of Legends\\lockfile", "D:\\Riot Games\\League of Legends\\lockfile", ]; /// Credentials extracted from the League Client lockfile. #[derive(Debug, Clone)] pub struct LockfileCredentials { /// Process name (usually "LeagueClient"). pub process_name: String, /// Process ID. pub pid: u32, /// Port for the LQP API. pub port: u16, /// Password for authentication. pub password: String, /// Protocol (usually "https"). pub protocol: String, } impl LockfileCredentials { /// Parse lockfile contents into credentials. /// /// Lockfile format: `LeagueClient:PID:PORT:PASSWORD:PROTOCOL` pub fn parse(contents: &str) -> Result { let parts: Vec<&str> = contents.trim().split(':').collect(); if parts.len() != 5 { return Err(LqpError::LockfileParseError(format!( "Expected 5 fields, got {}", parts.len() )) .into()); } let process_name = parts[0].to_string(); let pid = parts[1] .parse::() .map_err(|e| LqpError::LockfileParseError(format!("Invalid PID: {}", e)))?; let port = parts[2] .parse::() .map_err(|e| LqpError::LockfileParseError(format!("Invalid port: {}", e)))?; let password = parts[3].to_string(); let protocol = parts[4].to_string(); Ok(Self { process_name, pid, port, password, protocol, }) } /// Get the base URL for the LQP REST API. pub fn base_url(&self) -> String { format!("{}://127.0.0.1:{}", self.protocol, self.port) } /// Get the WebSocket URL for the LQP WebSocket API. pub fn ws_url(&self) -> String { format!("wss://127.0.0.1:{}", self.port) } /// Get the Basic Auth header value. pub fn auth_header(&self) -> String { let credentials = format!("riot:{}", self.password); let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, credentials); format!("Basic {}", encoded) } } /// Watcher for League Client lockfile. /// /// Monitors for the League Client starting and stopping by watching /// for the lockfile to appear/disappear. pub struct LockfileWatcher { /// Possible lockfile paths to check. paths: Vec, /// Current credentials if client is running. current: Option, } impl LockfileWatcher { /// Create a new lockfile watcher with default paths. pub fn new() -> Self { let paths = Self::discover_lockfile_paths(); Self { paths, current: None, } } /// Create a watcher with a specific lockfile path. pub fn with_path(path: PathBuf) -> Self { Self { paths: vec![path], current: None, } } /// Discover possible lockfile paths based on OS. fn discover_lockfile_paths() -> Vec { let mut paths = Vec::new(); #[cfg(target_os = "linux")] { // Check for Wine/Lutris installations if let Some(home) = std::env::var_os("HOME") { let home_path = PathBuf::from(&home); // Lutris default let lutris_path = home_path .join("Games") .join("League of Legends") .join("lockfile"); paths.push(lutris_path); // Wine prefix let wine_path = home_path .join(".wine") .join("drive_c") .join("Riot Games") .join("League of Legends") .join("lockfile"); paths.push(wine_path); // Proton (Steam) let proton_path = home_path .join(".local") .join("share") .join("Steam") .join("steamapps") .join("compatdata") .join("League of Legends") .join("pfx") .join("drive_c") .join("Riot Games") .join("League of Legends") .join("lockfile"); paths.push(proton_path); } } #[cfg(target_os = "windows")] { // Default Windows paths paths.push(PathBuf::from("C:\\Riot Games\\League of Legends\\lockfile")); paths.push(PathBuf::from("D:\\Riot Games\\League of Legends\\lockfile")); // Check Program Files paths.push(PathBuf::from( "C:\\Program Files\\Riot Games\\League of Legends\\lockfile", )); paths.push(PathBuf::from( "C:\\Program Files (x86)\\Riot Games\\League of Legends\\lockfile", )); } paths } /// Check if the League Client is currently running. pub fn is_client_running(&self) -> bool { self.current.is_some() } /// Get current credentials if available. pub fn credentials(&self) -> Option<&LockfileCredentials> { self.current.as_ref() } /// Check for lockfile and update credentials. /// /// Returns: /// - `Some(true)` if client just started /// - `Some(false)` if client just stopped /// - `None` if no change pub fn check(&mut self) -> Result> { // Try to find and read lockfile let found = self.find_lockfile()?; match (found, &self.current) { (Some(creds), None) => { // Client started debug!( "League Client detected (PID: {}, Port: {})", creds.pid, creds.port ); self.current = Some(creds); Ok(Some(true)) } (Some(new_creds), Some(old_creds)) => { // Client still running, check if restarted if new_creds.pid != old_creds.pid { debug!( "League Client restarted (PID: {} -> {})", old_creds.pid, new_creds.pid ); self.current = Some(new_creds); // Don't report as change, just update Ok(None) } else { // No change Ok(None) } } (None, Some(_)) => { // Client stopped debug!("League Client stopped"); self.current = None; Ok(Some(false)) } (None, None) => { // Still not running Ok(None) } } } /// Find and read the lockfile. fn find_lockfile(&self) -> Result> { for path in &self.paths { trace!("Checking lockfile path: {:?}", path); if path.exists() { match fs::read_to_string(path) { Ok(contents) => match LockfileCredentials::parse(&contents) { Ok(creds) => return Ok(Some(creds)), Err(e) => { warn!("Failed to parse lockfile at {:?}: {}", path, e); continue; } }, Err(e) => { trace!("Failed to read lockfile at {:?}: {}", path, e); continue; } } } } Ok(None) } /// Wait for the League Client to start. /// /// Blocks until the client is detected or timeout is reached. pub async fn wait_for_client(&mut self, timeout: Duration) -> Result { let start = std::time::Instant::now(); loop { if let Some(creds) = self.find_lockfile()? { self.current = Some(creds.clone()); return Ok(creds); } if start.elapsed() >= timeout { return Err(LqpError::Timeout.into()); } tokio::time::sleep(Duration::from_millis(500)).await; } } } impl Default for LockfileWatcher { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_lockfile() { let contents = "LeagueClient:12345:52436:abc123:https"; let creds = LockfileCredentials::parse(contents).unwrap(); assert_eq!(creds.process_name, "LeagueClient"); assert_eq!(creds.pid, 12345); assert_eq!(creds.port, 52436); assert_eq!(creds.password, "abc123"); assert_eq!(creds.protocol, "https"); } #[test] fn test_base_url() { let creds = LockfileCredentials { process_name: "LeagueClient".to_string(), pid: 12345, port: 52436, password: "abc123".to_string(), protocol: "https".to_string(), }; assert_eq!(creds.base_url(), "https://127.0.0.1:52436"); } #[test] fn test_auth_header() { let creds = LockfileCredentials { process_name: "LeagueClient".to_string(), pid: 12345, port: 52436, password: "abc123".to_string(), protocol: "https".to_string(), }; // base64("riot:abc123") = "cmlvdDphYmMxMjM=" assert!(creds.auth_header().contains("Basic ")); } }