1use std::collections::BTreeMap;
11use std::fmt;
12use std::path::{Path, PathBuf};
13use std::time::SystemTime;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Discovery {
18 pub pid: Option<u32>,
20 pub console_port: Option<u16>,
22 pub adb_port: Option<u16>,
24 pub avd_name: Option<String>,
26 pub emulator_version: Option<String>,
28 pub grpc_port: Option<u16>,
30 pub grpc_token: Option<String>,
32 pub entries: BTreeMap<String, String>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum DiscoveryError {
39 BadPort { key: String, value: String },
41}
42
43impl fmt::Display for DiscoveryError {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 DiscoveryError::BadPort { key, value } => {
47 write!(f, "{key} is not a port number: {value:?}")
48 }
49 }
50 }
51}
52
53impl std::error::Error for DiscoveryError {}
54
55fn port(entries: &BTreeMap<String, String>, key: &str) -> Result<Option<u16>, DiscoveryError> {
56 entries
57 .get(key)
58 .map(|value| {
59 value.parse().map_err(|_| DiscoveryError::BadPort {
60 key: key.to_owned(),
61 value: value.clone(),
62 })
63 })
64 .transpose()
65}
66
67impl Discovery {
68 pub fn parse(contents: &str, pid: Option<u32>) -> Result<Self, DiscoveryError> {
70 let mut entries = BTreeMap::new();
71 for line in contents.lines() {
72 let line = line.trim();
73 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
74 continue;
75 }
76 let Some((key, value)) = line.split_once('=') else {
78 continue;
79 };
80 entries.insert(key.trim().to_owned(), value.trim().to_owned());
81 }
82 let text = |key: &str| entries.get(key).filter(|v| !v.is_empty()).cloned();
83 Ok(Discovery {
84 pid,
85 console_port: port(&entries, "port.serial")?,
86 adb_port: port(&entries, "port.adb")?,
87 avd_name: text("avd.name"),
88 emulator_version: text("emulator.version"),
89 grpc_port: port(&entries, "grpc.port")?,
90 grpc_token: text("grpc.token"),
91 entries,
92 })
93 }
94
95 pub fn serial(&self) -> Option<String> {
97 self.console_port.map(|p| format!("emulator-{p}"))
98 }
99
100 pub fn grpc_endpoint(&self) -> Option<String> {
102 self.grpc_port.map(|p| format!("http://127.0.0.1:{p}"))
103 }
104
105 pub fn avd_dir(&self) -> Option<PathBuf> {
107 self.entries
108 .get("avd.dir")
109 .filter(|v| !v.is_empty())
110 .map(PathBuf::from)
111 }
112}
113
114pub fn pid_from_file_name(name: &str) -> Option<u32> {
116 name.strip_prefix("pid_")?
117 .strip_suffix(".ini")?
118 .parse()
119 .ok()
120}
121
122pub fn console_port_from_serial(serial: &str) -> Option<u16> {
124 serial.strip_prefix("emulator-")?.parse().ok()
125}
126
127pub fn find_for_serial<'a>(found: &'a [Discovery], serial: &str) -> Option<&'a Discovery> {
131 let port = console_port_from_serial(serial)?;
132 found.iter().rev().find(|d| d.console_port == Some(port))
133}
134
135pub fn candidate_dirs() -> Vec<PathBuf> {
142 let var = |name: &str| {
143 std::env::var_os(name)
144 .filter(|v| !v.is_empty())
145 .map(PathBuf::from)
146 };
147 let mut dirs = Vec::new();
148 if let Some(runtime) = var("XDG_RUNTIME_DIR") {
149 dirs.push(runtime.join("avd/running"));
150 }
151 if cfg!(target_os = "macos") {
152 dirs.extend(var("HOME").map(|home| home.join("Library/Caches/TemporaryItems/avd/running")));
153 } else if cfg!(windows) {
154 dirs.extend(
155 var("LOCALAPPDATA").map(|local| local.join("Temp").join("avd").join("running")),
156 );
157 }
158 dirs.extend(run_user_dir());
159 dirs.push(std::env::temp_dir().join("avd/running"));
160 dirs.dedup();
161 dirs
162}
163
164#[cfg(target_os = "linux")]
167fn run_user_dir() -> Option<PathBuf> {
168 use std::os::unix::fs::MetadataExt;
169 let uid = std::fs::metadata("/proc/self").ok()?.uid();
170 Some(PathBuf::from(format!("/run/user/{uid}/avd/running")))
171}
172
173#[cfg(not(target_os = "linux"))]
174fn run_user_dir() -> Option<PathBuf> {
175 None
176}
177
178#[derive(Debug)]
180pub struct Found {
181 pub path: PathBuf,
182 pub modified: Option<SystemTime>,
183 pub parsed: Result<Discovery, DiscoveryError>,
184}
185
186pub fn scan_dir(dir: &Path) -> std::io::Result<Vec<Found>> {
189 let mut found = Vec::new();
190 for entry in std::fs::read_dir(dir)? {
191 let entry = entry?;
192 let Some(pid) = entry.file_name().to_str().and_then(pid_from_file_name) else {
193 continue;
194 };
195 let path = entry.path();
196 let modified = entry.metadata().and_then(|m| m.modified()).ok();
197 let Ok(contents) = std::fs::read_to_string(&path) else {
198 continue;
199 };
200 found.push(Found {
201 parsed: Discovery::parse(&contents, Some(pid)),
202 path,
203 modified,
204 });
205 }
206 found.sort_by_key(|f| f.modified);
207 Ok(found)
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use std::fs;
214 use std::time::Duration;
215
216 const REAL_LAYOUT: &str = "emulator.build=15917651\n\
219avd.id=pixel_api36\n\
220emulator.version=37.1.11.0\n\
221port.serial=5554\n\
222port.adb=5555\n\
223avd.name=pixel_api36\n\
224avd.dir=/placeholder/.android/avd/pixel_api36.avd\n\
225cmdline=\"/placeholder/emulator/qemu/darwin-aarch64/qemu-system-aarch64-headless\" \"-avd\" \"pixel_api36\" \"-grpc\" \"8554\" \"-grpc-use-token\"\n\
226grpc.token=placeholder+token/value==\n\
227launcher.dir=/placeholder/emulator\n\
228grpc.allowlist=/placeholder/emulator/lib/emulator_access.json\n\
229grpc.port=8554\n";
230
231 const NO_GRPC: &str = "emulator.version=37.1.11.0\n\
233port.serial=5556\n\
234port.adb=5557\n\
235avd.name=pixel_api36\n";
236
237 fn temp_dir(tag: &str) -> PathBuf {
239 let dir = std::env::temp_dir().join(format!(
240 "mobile-agent-discovery-{tag}-{}",
241 std::process::id()
242 ));
243 let _ = fs::remove_dir_all(&dir);
244 fs::create_dir_all(&dir).unwrap();
245 dir
246 }
247
248 #[test]
249 fn parses_the_real_layout() {
250 let d = Discovery::parse(REAL_LAYOUT, Some(59226)).unwrap();
251 assert_eq!(d.pid, Some(59226));
252 assert_eq!(d.console_port, Some(5554));
253 assert_eq!(d.adb_port, Some(5555));
254 assert_eq!(d.avd_name.as_deref(), Some("pixel_api36"));
255 assert_eq!(d.emulator_version.as_deref(), Some("37.1.11.0"));
256 assert_eq!(d.grpc_port, Some(8554));
257 assert_eq!(d.entries.len(), 12);
258 assert_eq!(
259 d.avd_dir(),
260 Some(PathBuf::from("/placeholder/.android/avd/pixel_api36.avd"))
261 );
262 }
263
264 #[test]
265 fn the_token_keeps_its_equals_signs() {
266 let d = Discovery::parse(REAL_LAYOUT, None).unwrap();
267 assert_eq!(d.grpc_token.as_deref(), Some("placeholder+token/value=="));
268 }
269
270 #[test]
271 fn quoted_values_are_kept_verbatim() {
272 let d = Discovery::parse(REAL_LAYOUT, None).unwrap();
273 assert!(d.entries["cmdline"].starts_with("\"/placeholder/emulator/qemu/"));
274 assert!(d.entries["cmdline"].ends_with("\"-grpc-use-token\""));
275 }
276
277 #[test]
278 fn a_missing_grpc_port_means_no_endpoint() {
279 let d = Discovery::parse(NO_GRPC, Some(1)).unwrap();
280 assert_eq!(d.grpc_port, None);
281 assert_eq!(d.grpc_token, None);
282 assert_eq!(d.grpc_endpoint(), None);
283 assert_eq!(d.serial().as_deref(), Some("emulator-5556"));
284 assert_eq!(d.avd_dir(), None);
285 }
286
287 #[test]
288 fn a_grpc_port_without_a_token_is_an_open_endpoint() {
289 let d = Discovery::parse("port.serial=5554\ngrpc.port=8554\ngrpc.token=\n", None).unwrap();
290 assert_eq!(d.grpc_endpoint().as_deref(), Some("http://127.0.0.1:8554"));
291 assert_eq!(d.grpc_token, None);
292 }
293
294 #[test]
295 fn crlf_blank_and_comment_lines_are_tolerated() {
296 let text = "; written on Windows\r\n\r\nport.serial=5554\r\n# note\r\ngrpc.port=8554\r\ngrpc.token=abc=\r\n";
297 let d = Discovery::parse(text, None).unwrap();
298 assert_eq!(d.console_port, Some(5554));
299 assert_eq!(d.grpc_port, Some(8554));
300 assert_eq!(d.grpc_token.as_deref(), Some("abc="));
301 assert_eq!(d.entries.len(), 3);
302 }
303
304 #[test]
305 fn a_bad_port_is_an_error() {
306 let err = Discovery::parse("port.serial=5554\ngrpc.port=85x4\n", None).unwrap_err();
307 assert_eq!(
308 err,
309 DiscoveryError::BadPort {
310 key: "grpc.port".into(),
311 value: "85x4".into()
312 }
313 );
314 }
315
316 #[test]
317 fn the_pid_comes_from_the_file_name() {
318 assert_eq!(pid_from_file_name("pid_59226.ini"), Some(59226));
319 assert_eq!(pid_from_file_name("pid_.ini"), None);
320 assert_eq!(pid_from_file_name("pid_12.txt"), None);
321 assert_eq!(pid_from_file_name("pid_12_info.ini"), None);
322 assert_eq!(pid_from_file_name("emulator.ini"), None);
323 }
324
325 #[test]
326 fn a_serial_maps_to_its_console_port() {
327 assert_eq!(console_port_from_serial("emulator-5554"), Some(5554));
328 assert_eq!(console_port_from_serial("emulator-"), None);
329 assert_eq!(console_port_from_serial("R58M123ABC"), None);
330 let d = Discovery::parse(REAL_LAYOUT, None).unwrap();
331 assert_eq!(d.serial().as_deref(), Some("emulator-5554"));
332 assert_eq!(d.grpc_endpoint().as_deref(), Some("http://127.0.0.1:8554"));
333 }
334
335 #[test]
336 fn find_for_serial_matches_the_console_port_and_prefers_the_newest() {
337 let a = Discovery::parse(REAL_LAYOUT, Some(1)).unwrap();
338 let b = Discovery::parse(NO_GRPC, Some(2)).unwrap();
339 let stale = Discovery::parse("port.serial=5554\ngrpc.port=9999\n", Some(3)).unwrap();
340 let found = vec![stale, b.clone(), a.clone()];
341 assert_eq!(find_for_serial(&found, "emulator-5556"), Some(&b));
342 assert_eq!(find_for_serial(&found, "emulator-5554"), Some(&a));
343 assert_eq!(find_for_serial(&found, "emulator-5558"), None);
344 assert_eq!(find_for_serial(&found, "R58M123ABC"), None);
345 }
346
347 fn write_aged(dir: &Path, name: &str, contents: &str, age: Duration) {
349 let path = dir.join(name);
350 fs::write(&path, contents).unwrap();
351 let file = fs::File::options().write(true).open(&path).unwrap();
352 file.set_modified(SystemTime::now() - age).unwrap();
353 }
354
355 #[test]
356 fn scan_dir_reads_pid_files_oldest_first() {
357 let dir = temp_dir("scan");
358 let secs = Duration::from_secs;
359 write_aged(
360 &dir,
361 "pid_1.ini",
362 "port.serial=5554\ngrpc.port=9999\n",
363 secs(30),
364 );
365 write_aged(&dir, "notes.txt", "port.serial=5554\n", secs(25));
366 write_aged(
367 &dir,
368 "pid_3.ini",
369 "port.serial=5554\ngrpc.port=bad\n",
370 secs(20),
371 );
372 write_aged(&dir, "pid_2.ini", REAL_LAYOUT, secs(10));
373 let found = scan_dir(&dir).unwrap();
374 fs::remove_dir_all(&dir).unwrap();
375
376 let names: Vec<_> = found
377 .iter()
378 .map(|f| f.path.file_name().unwrap().to_string_lossy().into_owned())
379 .collect();
380 assert_eq!(names, ["pid_1.ini", "pid_3.ini", "pid_2.ini"]);
381 let parsed: Vec<Discovery> = found.into_iter().filter_map(|f| f.parsed.ok()).collect();
382 assert_eq!(
383 parsed.len(),
384 2,
385 "the file with a bad port is reported, not parsed"
386 );
387 let newest = find_for_serial(&parsed, "emulator-5554").unwrap();
388 assert_eq!((newest.pid, newest.grpc_port), (Some(2), Some(8554)));
389 }
390
391 #[test]
392 fn the_candidate_dirs_end_with_the_temporary_directory() {
393 let dirs = candidate_dirs();
394 assert!(dirs.len() >= 2, "{dirs:?}");
395 assert_eq!(dirs.last(), Some(&std::env::temp_dir().join("avd/running")));
396 if cfg!(target_os = "macos") {
397 assert!(
398 dirs.iter()
399 .any(|d| d.ends_with("Library/Caches/TemporaryItems/avd/running")),
400 "{dirs:?}"
401 );
402 }
403 }
404}