record-daemon: initial commit
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
//! 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 "));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user