341 lines
10 KiB
Rust
341 lines
10 KiB
Rust
//! 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<Self> {
|
|
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::<u32>()
|
|
.map_err(|e| LqpError::LockfileParseError(format!("Invalid PID: {}", e)))?;
|
|
let port = parts[2]
|
|
.parse::<u16>()
|
|
.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<PathBuf>,
|
|
/// Current credentials if client is running.
|
|
current: Option<LockfileCredentials>,
|
|
}
|
|
|
|
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<PathBuf> {
|
|
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<Option<bool>> {
|
|
// 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<Option<LockfileCredentials>> {
|
|
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<LockfileCredentials> {
|
|
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 "));
|
|
}
|
|
}
|