1use std::path::Path;
13use std::sync::LazyLock;
14
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17use sysinfo::{ProcessRefreshKind, System};
18
19use crate::error::LcuError;
20
21#[cfg(target_os = "windows")]
22const PROCESS_NAME: &str = "LeagueClientUx";
23#[cfg(not(target_os = "windows"))]
24const PROCESS_NAME: &str = "LeagueClient";
25
26static PORT_RE: LazyLock<Regex> =
27 LazyLock::new(|| Regex::new(r"--app-port=(\d+)").expect("static regex"));
28static PASS_RE: LazyLock<Regex> =
29 LazyLock::new(|| Regex::new(r"--remoting-auth-token=([\w-]+)").expect("static regex"));
30
31#[derive(Clone, Serialize, Deserialize)]
36pub struct Credentials {
37 pub port: u16,
39 pub password: String,
41 pub pid: u32,
43}
44
45impl std::fmt::Debug for Credentials {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.debug_struct("Credentials")
48 .field("port", &self.port)
49 .field("pid", &self.pid)
50 .field("password", &"***")
51 .finish()
52 }
53}
54
55impl Credentials {
56 pub fn basic_auth(&self) -> String {
58 use base64::{engine::general_purpose, Engine as _};
59 let raw = format!("riot:{}", self.password);
60 format!("Basic {}", general_purpose::STANDARD.encode(raw))
61 }
62
63 pub fn lcu_base_url(&self) -> String {
65 format!("https://127.0.0.1:{}", self.port)
66 }
67
68 pub fn lcu_ws_url(&self) -> String {
70 format!("wss://127.0.0.1:{}", self.port)
71 }
72}
73
74pub fn try_find_lcu() -> Option<Credentials> {
86 let mut sys = System::new();
87 sys.refresh_processes_specifics(
88 sysinfo::ProcessesToUpdate::All,
89 true,
90 ProcessRefreshKind::everything(),
91 );
92
93 for (pid, process) in sys.processes() {
94 let name = process.name().to_string_lossy();
95 if !name.contains(PROCESS_NAME) {
96 continue;
97 }
98
99 let cmdline: String = process
100 .cmd()
101 .iter()
102 .map(|s| s.to_string_lossy().into_owned())
103 .collect::<Vec<_>>()
104 .join(" ");
105
106 let Some(port_cap) = PORT_RE.captures(&cmdline) else { continue };
107 let Some(pass_cap) = PASS_RE.captures(&cmdline) else { continue };
108 let Some(port_match) = port_cap.get(1) else { continue };
109 let Some(pass_match) = pass_cap.get(1) else { continue };
110 let Ok(port) = port_match.as_str().parse::<u16>() else { continue };
111
112 return Some(Credentials {
113 port,
114 password: pass_match.as_str().to_string(),
115 pid: pid.as_u32(),
116 });
117 }
118
119 None
120}
121
122pub async fn try_find_lcu_async() -> Option<Credentials> {
128 tokio::task::spawn_blocking(try_find_lcu)
129 .await
130 .ok()
131 .flatten()
132}
133
134pub fn try_find_lcu_via_lockfile(lockfile_path: impl AsRef<Path>) -> Result<Credentials, LcuError> {
143 let content = std::fs::read_to_string(lockfile_path)?;
144 let parts: Vec<&str> = content.trim().split(':').collect();
145 if parts.len() < 5 {
146 return Err(LcuError::LockfileParse(format!(
147 "expected 5 colon-separated fields, found {}",
148 parts.len()
149 )));
150 }
151 let pid = parts[1]
152 .parse()
153 .map_err(|_| LcuError::LockfileParse(format!("invalid pid: {:?}", parts[1])))?;
154 let port = parts[2]
155 .parse()
156 .map_err(|_| LcuError::LockfileParse(format!("invalid port: {:?}", parts[2])))?;
157 let password = parts[3].to_string();
158 Ok(Credentials { port, password, pid })
159}
160
161pub async fn authenticate(
166 poll_interval_ms: u64,
167 timeout_secs: u64,
168) -> Result<Credentials, LcuError> {
169 let deadline =
170 tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
171 loop {
172 if let Some(creds) = try_find_lcu_async().await {
173 return Ok(creds);
174 }
175 if tokio::time::Instant::now() >= deadline {
176 return Err(LcuError::AuthTimeout);
177 }
178 tokio::time::sleep(tokio::time::Duration::from_millis(poll_interval_ms)).await;
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn basic_auth_is_well_formed() {
188 let creds = Credentials {
189 port: 12345,
190 password: "abc".into(),
191 pid: 42,
192 };
193 assert_eq!(creds.basic_auth(), "Basic cmlvdDphYmM=");
195 assert_eq!(creds.lcu_base_url(), "https://127.0.0.1:12345");
196 assert_eq!(creds.lcu_ws_url(), "wss://127.0.0.1:12345");
197 }
198
199 #[test]
200 fn debug_redacts_password() {
201 let creds = Credentials {
202 port: 1,
203 password: "topsecret".into(),
204 pid: 2,
205 };
206 let rendered = format!("{:?}", creds);
207 assert!(!rendered.contains("topsecret"));
208 assert!(rendered.contains("***"));
209 }
210
211 #[test]
212 fn lockfile_parses_well_formed_input() {
213 let dir = std::env::temp_dir();
214 let path = dir.join("league-link-test-lockfile");
215 std::fs::write(&path, "LeagueClient:1234:52437:secretpw:https").unwrap();
216 let creds = try_find_lcu_via_lockfile(&path).unwrap();
217 assert_eq!(creds.pid, 1234);
218 assert_eq!(creds.port, 52437);
219 assert_eq!(creds.password, "secretpw");
220 let _ = std::fs::remove_file(&path);
221 }
222
223 #[test]
224 fn lockfile_rejects_malformed_input() {
225 let dir = std::env::temp_dir();
226 let path = dir.join("league-link-test-lockfile-bad");
227 std::fs::write(&path, "not:enough:fields").unwrap();
228 assert!(matches!(
229 try_find_lcu_via_lockfile(&path),
230 Err(LcuError::LockfileParse(_))
231 ));
232 let _ = std::fs::remove_file(&path);
233 }
234}