1use crate::record::{
9 read_live_record, try_read_live_record, try_read_record, write_takeover, ServiceRecord,
10 TakeoverRequest, RANK_UNKNOWN,
11};
12use crate::suite::{make_private_dir, Suite};
13use crate::transport::{pid_alive, probe_endpoint};
14use serde::Serialize;
15use std::collections::BTreeMap;
16use std::io;
17use std::path::{Path, PathBuf};
18use std::process::{Command, Stdio};
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20
21#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
23pub struct InstalledApp {
24 pub app: String,
25 pub app_path: String,
26 pub engine_path: String,
27 pub version: Option<String>,
28 pub source: String,
29 pub completeness: Completeness,
32}
33
34#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum Completeness {
38 #[default]
40 Complete,
41 EngineMissing,
44}
45
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub struct DiscoverOptions {
49 pub include_incomplete: bool,
55}
56
57#[deprecated(
62 since = "0.1.2",
63 note = "pass your own (app, engine) registry to discover_apps_in"
64)]
65pub const KNOWN_APPS: &[(&str, &str)] = SUITE_APPS;
66
67const SUITE_APPS: &[(&str, &str)] = &[
69 ("HeardRight", "heardright-engine"),
70 ("ScrapeRight", "scraperight-engine"),
71 ("GenRight", "genright-engine"),
72 ("CutRight", "cutright-engine"),
73];
74
75pub fn discover_apps_in(registry: &[(&str, &str)]) -> Vec<InstalledApp> {
78 discover_apps_in_with(registry, &DiscoverOptions::default())
79}
80
81pub fn discover_apps_in_with(
83 registry: &[(&str, &str)],
84 options: &DiscoverOptions,
85) -> Vec<InstalledApp> {
86 #[cfg(target_os = "macos")]
87 {
88 discover_macos_with(&default_macos_roots(), registry, options)
89 }
90 #[cfg(windows)]
91 {
92 discover_windows_with(
93 &read_windows_uninstall_entries(),
94 registry,
95 |p| Path::new(p).is_file(),
96 |p| Path::new(p).is_dir(),
97 options,
98 )
99 }
100 #[cfg(not(any(target_os = "macos", windows)))]
101 {
102 let _ = (registry, options);
103 Vec::new()
104 }
105}
106
107#[cfg(target_os = "macos")]
108fn default_macos_roots() -> Vec<PathBuf> {
109 let mut roots = vec![PathBuf::from("/Applications")];
110 if let Ok(home) = crate::suite::passwd_home_dir() {
114 roots.push(home.join("Applications"));
115 }
116 roots
117}
118
119#[deprecated(since = "0.1.2", note = "use discover_apps_in with your own registry")]
121pub fn discover_apps() -> Vec<InstalledApp> {
122 #[allow(deprecated)]
123 discover_apps_in(KNOWN_APPS)
124}
125
126pub fn discover_macos(roots: &[PathBuf], apps: &[(&str, &str)]) -> Vec<InstalledApp> {
128 discover_macos_with(roots, apps, &DiscoverOptions::default())
129}
130
131pub fn discover_macos_with(
135 roots: &[PathBuf],
136 apps: &[(&str, &str)],
137 options: &DiscoverOptions,
138) -> Vec<InstalledApp> {
139 let mut found = Vec::new();
140 for (name, engine) in apps {
141 let mut incomplete: Option<InstalledApp> = None;
142 let mut complete: Option<InstalledApp> = None;
143 for root in roots {
144 let bundle = root.join(format!("{name}.app"));
145 let binary = bundle.join("Contents/MacOS").join(engine);
146 let present = binary.is_file();
147 if !present && (!options.include_incomplete || incomplete.is_some() || !bundle.is_dir())
148 {
149 continue;
150 }
151 let app = InstalledApp {
152 app: (*name).to_string(),
153 app_path: bundle.to_string_lossy().into_owned(),
154 engine_path: binary.to_string_lossy().into_owned(),
155 version: read_plist_version(&bundle.join("Contents/Info.plist")),
156 source: "macos-bundle".into(),
157 completeness: if present {
158 Completeness::Complete
159 } else {
160 Completeness::EngineMissing
161 },
162 };
163 if present {
164 complete = Some(app);
165 break;
166 }
167 incomplete = Some(app);
168 }
169 found.extend(complete.or(incomplete));
170 }
171 found
172}
173
174fn read_plist_version(path: &Path) -> Option<String> {
175 parse_plist_string(
176 &std::fs::read_to_string(path).ok()?,
177 "CFBundleShortVersionString",
178 )
179}
180
181pub fn parse_plist_string(xml: &str, key: &str) -> Option<String> {
184 let marker = format!("<key>{key}</key>");
185 let after = &xml[xml.find(&marker)? + marker.len()..];
186 let string_start = after.find("<string>")? + "<string>".len();
187 if after[..string_start].contains("<key>") {
188 return None;
189 }
190 let end = after[string_start..].find("</string>")? + string_start;
191 let value = after[string_start..end].trim();
192 (!value.is_empty()).then(|| value.to_string())
193}
194
195#[derive(Debug, Clone, Default, PartialEq, Eq)]
198pub struct UninstallEntry {
199 pub display_name: String,
200 pub install_location: Option<String>,
201 pub display_icon: Option<String>,
202 pub uninstall_string: Option<String>,
203 pub display_version: Option<String>,
204}
205
206pub fn parse_reg_query(text: &str) -> Vec<UninstallEntry> {
209 let mut out = Vec::new();
210 let mut current = UninstallEntry::default();
211 let mut active = false;
212 for raw in text.lines() {
213 let line = raw.trim_end();
214 let trimmed = line.trim();
215 if trimmed.starts_with("HKEY_") {
216 if active && !current.display_name.is_empty() {
217 out.push(current);
218 }
219 current = UninstallEntry::default();
220 active = true;
221 continue;
222 }
223 let mut fields = trimmed.split_whitespace();
224 let Some(name) = fields.next() else { continue };
225 let Some(_kind) = fields.next() else { continue };
226 let value = fields.collect::<Vec<_>>().join(" ");
227 if value.is_empty() {
228 continue;
229 }
230 match name {
231 "DisplayName" => current.display_name = value,
232 "InstallLocation" => current.install_location = Some(value),
233 "DisplayIcon" => current.display_icon = Some(value),
234 "UninstallString" => current.uninstall_string = Some(value),
235 "DisplayVersion" => current.display_version = Some(value),
236 _ => {}
237 }
238 }
239 if active && !current.display_name.is_empty() {
240 out.push(current);
241 }
242 out
243}
244
245fn strip_quotes(value: &str) -> &str {
246 value.trim().trim_matches('"').trim()
247}
248
249fn parent(path: &str) -> Option<String> {
250 let i = path.rfind(['\\', '/'])?;
251 (!path[..i].is_empty()).then(|| path[..i].to_string())
252}
253
254fn executable_from_value(value: &str) -> Option<String> {
255 let value = value.trim();
256 let path = if let Some(rest) = value.strip_prefix('"') {
257 rest.split('"').next()?.to_string()
258 } else {
259 let lower = value.to_ascii_lowercase();
260 let end = lower.find(".exe").map(|i| i + 4).unwrap_or(value.len());
261 value[..end].to_string()
262 };
263 (!path.is_empty()).then_some(path)
264}
265
266pub fn resolve_install_dir(entry: &UninstallEntry) -> Option<String> {
267 if let Some(loc) = entry.install_location.as_deref() {
268 let loc = strip_quotes(loc).trim_end_matches(['\\', '/']);
269 if !loc.is_empty() {
270 return Some(loc.to_string());
271 }
272 }
273 entry
274 .display_icon
275 .as_deref()
276 .and_then(executable_from_value)
277 .and_then(|p| parent(&p))
278 .or_else(|| {
279 entry
280 .uninstall_string
281 .as_deref()
282 .and_then(executable_from_value)
283 .and_then(|p| parent(&p))
284 })
285}
286
287pub fn discover_windows(
290 entries: &[UninstallEntry],
291 apps: &[(&str, &str)],
292 exists: impl Fn(&str) -> bool,
293) -> Vec<InstalledApp> {
294 discover_windows_with(
295 entries,
296 apps,
297 exists,
298 |_| false,
299 &DiscoverOptions::default(),
300 )
301}
302
303pub fn discover_windows_with(
308 entries: &[UninstallEntry],
309 apps: &[(&str, &str)],
310 exists: impl Fn(&str) -> bool,
311 dir_exists: impl Fn(&str) -> bool,
312 options: &DiscoverOptions,
313) -> Vec<InstalledApp> {
314 let mut found = Vec::new();
315 for (name, engine) in apps {
316 let mut incomplete: Option<InstalledApp> = None;
317 let mut complete: Option<InstalledApp> = None;
318 for entry in entries.iter().filter(|entry| entry.display_name == *name) {
319 let Some(dir) = resolve_install_dir(entry) else {
320 continue;
321 };
322 let engine_path = format!(r"{dir}\{engine}.exe");
323 let present = exists(&engine_path);
324 if !present
325 && (!options.include_incomplete || incomplete.is_some() || !dir_exists(&dir))
326 {
327 continue;
328 }
329 let app = InstalledApp {
330 app: (*name).to_string(),
331 app_path: dir,
332 engine_path,
333 version: entry
334 .display_version
335 .clone()
336 .filter(|v| !v.trim().is_empty()),
337 source: "windows-uninstall".into(),
338 completeness: if present {
339 Completeness::Complete
340 } else {
341 Completeness::EngineMissing
342 },
343 };
344 if present {
345 complete = Some(app);
346 break;
347 }
348 incomplete = Some(app);
349 }
350 if let Some(app) = complete.or(incomplete) {
351 found.push(app);
352 }
353 }
354 found
355}
356
357#[derive(Debug)]
358pub enum SpawnClaimError {
359 AlreadyHeld(PathBuf),
360 Io(io::Error),
361}
362impl std::fmt::Display for SpawnClaimError {
363 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364 match self {
365 Self::AlreadyHeld(path) => write!(f, "spawn claim is held: {}", path.display()),
366 Self::Io(e) => write!(f, "spawn claim: {e}"),
367 }
368 }
369}
370impl std::error::Error for SpawnClaimError {}
371impl From<io::Error> for SpawnClaimError {
372 fn from(e: io::Error) -> Self {
373 Self::Io(e)
374 }
375}
376
377pub struct SpawnClaim {
383 _file: std::fs::File,
384 path: PathBuf,
385}
386
387impl SpawnClaim {
388 pub fn path(suite: &Suite, service: &str) -> PathBuf {
389 suite.service_dir(service).join("spawn.claim")
390 }
391
392 pub fn acquire(suite: &Suite, service: &str) -> Result<Self, SpawnClaimError> {
393 make_private_dir(&suite.service_dir(service))
394 .map_err(|e| SpawnClaimError::Io(io::Error::other(e.to_string())))?;
395 let path = Self::path(suite, service);
396 match try_lock_exclusive(&path) {
397 Ok(Some(file)) => Ok(Self { _file: file, path }),
398 Ok(None) => Err(SpawnClaimError::AlreadyHeld(path)),
399 Err(e) => Err(SpawnClaimError::Io(e)),
400 }
401 }
402
403 pub fn claim_path(&self) -> &Path {
405 &self.path
406 }
407}
408
409#[cfg(unix)]
411pub(crate) fn try_lock_exclusive(path: &Path) -> io::Result<Option<std::fs::File>> {
412 use std::fs::TryLockError;
413 use std::io::Write;
414 let mut f = std::fs::OpenOptions::new()
415 .read(true)
416 .write(true)
417 .create(true)
418 .truncate(false)
419 .open(path)?;
420 match f.try_lock() {
422 Ok(()) => {}
423 Err(TryLockError::WouldBlock) => return Ok(None),
424 Err(TryLockError::Error(e)) => return Err(e),
425 }
426 let _ = f.set_len(0);
428 let _ = f.write_all(std::process::id().to_string().as_bytes());
429 Ok(Some(f))
430}
431
432#[cfg(windows)]
437pub(crate) fn try_lock_exclusive(path: &Path) -> io::Result<Option<std::fs::File>> {
438 use std::io::Write;
439 use std::os::windows::fs::OpenOptionsExt;
440 match std::fs::OpenOptions::new()
441 .write(true)
442 .create(true)
443 .truncate(false)
444 .share_mode(0)
445 .open(path)
446 {
447 Ok(mut f) => {
448 let _ = f.write_all(std::process::id().to_string().as_bytes());
449 Ok(Some(f))
450 }
451 Err(e) if e.raw_os_error() == Some(32) => Ok(None),
452 Err(e) => Err(e),
453 }
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
457pub enum AdoptOrSpawn {
458 Adopt(ServiceRecord),
459 Spawn,
460}
461
462pub fn adopt_or_spawn(suite: &Suite, service: &str) -> AdoptOrSpawn {
467 try_adopt_or_spawn(suite, service).unwrap_or(AdoptOrSpawn::Spawn)
468}
469
470pub fn try_adopt_or_spawn(suite: &Suite, service: &str) -> io::Result<AdoptOrSpawn> {
473 Ok(match try_read_record(suite, service)? {
474 Some(record) if record_process_matches(&record) && probe_endpoint(&record.endpoint) => {
475 AdoptOrSpawn::Adopt(record)
476 }
477 _ => AdoptOrSpawn::Spawn,
478 })
479}
480
481pub fn wait_for_claim_winner(
483 suite: &Suite,
484 service: &str,
485 timeout: Duration,
486) -> Option<ServiceRecord> {
487 let deadline = Instant::now() + timeout;
488 while Instant::now() < deadline {
489 if let Ok(AdoptOrSpawn::Adopt(record)) = try_adopt_or_spawn(suite, service) {
491 return Some(record);
492 }
493 std::thread::sleep(
494 Duration::from_millis(25).min(deadline.saturating_duration_since(Instant::now())),
495 );
496 }
497 None
498}
499
500pub fn record_process_matches(record: &ServiceRecord) -> bool {
502 record_process_matches_inner(record)
503}
504
505fn record_process_matches_inner(record: &ServiceRecord) -> bool {
506 if !pid_alive(record.pid) {
507 return false;
508 }
509 if record.process_started_ms == 0 {
512 return true;
513 }
514 match process_start_ms(record.pid) {
515 Some(started) => started.abs_diff(record.process_started_ms as u128) <= 2_000,
516 None => true,
518 }
519}
520
521pub(crate) fn own_start_ms() -> Option<u64> {
523 process_start_ms(std::process::id()).map(|v| v as u64)
524}
525
526fn process_start_ms(pid: u32) -> Option<u128> {
527 #[cfg(target_os = "linux")]
528 {
529 let stat = std::fs::read_to_string(if pid == std::process::id() {
530 "/proc/self/stat".into()
531 } else {
532 format!("/proc/{pid}/stat")
533 })
534 .ok()?;
535 let ticks = stat
536 .rsplit(')')
537 .next()?
538 .split_whitespace()
539 .nth(19)?
540 .parse::<u128>()
541 .ok()?;
542 let boot = std::fs::read_to_string("/proc/stat")
543 .ok()?
544 .lines()
545 .find_map(|l| l.strip_prefix("btime "))?
546 .parse::<u128>()
547 .ok()?;
548 return Some(boot * 1000 + ticks * 1000 / 100);
549 }
550 #[cfg(target_os = "macos")]
551 {
552 let out = std::process::Command::new("ps")
553 .args(["-o", "lstart=", "-p", &pid.to_string()])
554 .output()
555 .ok()?;
556 let text = String::from_utf8(out.stdout).ok()?;
557 let value = text.trim();
558 let parsed = std::process::Command::new("date")
559 .args(["-j", "-f", "%a %b %e %H:%M:%S %Y", value, "+%s"])
560 .output()
561 .ok()?;
562 let seconds = String::from_utf8(parsed.stdout)
563 .ok()?
564 .trim()
565 .parse::<u128>()
566 .ok()?;
567 return Some(seconds * 1000);
568 }
569 #[cfg(windows)]
570 {
571 use std::ffi::c_void;
572 #[repr(C)]
573 struct FileTime {
574 low: u32,
575 high: u32,
576 }
577 #[link(name = "kernel32")]
578 unsafe extern "system" {
579 fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut c_void;
580 fn GetProcessTimes(
581 handle: *mut c_void,
582 creation: *mut FileTime,
583 exit: *mut FileTime,
584 kernel: *mut FileTime,
585 user: *mut FileTime,
586 ) -> i32;
587 fn CloseHandle(handle: *mut c_void) -> i32;
588 }
589 const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
590 let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
591 if handle.is_null() {
592 return None;
593 }
594 let mut creation = FileTime { low: 0, high: 0 };
595 let mut exit = FileTime { low: 0, high: 0 };
596 let mut kernel = FileTime { low: 0, high: 0 };
597 let mut user = FileTime { low: 0, high: 0 };
598 let ok =
599 unsafe { GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) }
600 != 0;
601 unsafe {
602 CloseHandle(handle);
603 }
604 if !ok {
605 return None;
606 }
607 let ticks = ((creation.high as u128) << 32) | creation.low as u128;
608 return Some(ticks / 10_000 - 11_644_473_600_000);
609 }
610 #[allow(unreachable_code)]
611 None
612}
613
614#[cfg(windows)]
615fn read_windows_uninstall_entries() -> Vec<UninstallEntry> {
616 let mut all = String::new();
617 for key in [
618 r"HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall",
619 r"HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall",
620 r"HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
621 ] {
622 let mut reg = std::process::Command::new("reg");
623 reg.args(["query", key, "/s"]);
624 #[cfg(windows)]
625 {
626 use std::os::windows::process::CommandExt;
627 reg.creation_flags(0x0800_0000); }
629 if let Ok(output) = reg.output() {
630 all.push_str(&String::from_utf8_lossy(&output.stdout));
631 all.push('\n');
632 }
633 }
634 parse_reg_query(&all)
635}
636
637pub fn resolve_rank(role: &str, rank: u8, known_roles: &BTreeMap<String, u8>) -> Option<u8> {
647 if rank != RANK_UNKNOWN {
648 Some(rank)
649 } else {
650 known_roles.get(role).copied()
651 }
652}
653
654#[derive(Debug, Clone, PartialEq, Eq)]
656pub enum HostPlan {
657 UseRunning,
659 TakeOver { from_role: String },
661 HostMyself,
663 StartInstalled(PathBuf),
665}
666
667pub fn plan_host(
671 suite: &Suite,
672 service: &str,
673 my_rank: u8,
674 known_roles: &BTreeMap<String, u8>,
675 installed_engine: Option<&Path>,
676) -> HostPlan {
677 plan_from(
678 read_live_record(suite, service),
679 my_rank,
680 known_roles,
681 installed_engine,
682 )
683}
684
685pub fn try_plan_host(
689 suite: &Suite,
690 service: &str,
691 my_rank: u8,
692 known_roles: &BTreeMap<String, u8>,
693 installed_engine: Option<&Path>,
694) -> io::Result<HostPlan> {
695 Ok(plan_from(
696 try_read_live_record(suite, service)?,
697 my_rank,
698 known_roles,
699 installed_engine,
700 ))
701}
702
703fn plan_from(
704 live: Option<ServiceRecord>,
705 my_rank: u8,
706 known_roles: &BTreeMap<String, u8>,
707 installed_engine: Option<&Path>,
708) -> HostPlan {
709 if let Some(record) = live {
710 if let Some(their) = resolve_rank(&record.role, record.rank, known_roles) {
711 return if my_rank < their {
712 HostPlan::TakeOver {
713 from_role: record.role,
714 }
715 } else {
716 HostPlan::UseRunning
717 };
718 }
719 }
720 match installed_engine {
721 Some(engine) if engine.exists() => HostPlan::StartInstalled(engine.to_path_buf()),
722 _ => HostPlan::HostMyself,
723 }
724}
725
726pub fn should_request_takeover(my_rank: u8, incumbent: Option<(u8, u32)>, own_pid: u32) -> bool {
729 incumbent.is_some_and(|(their, pid)| my_rank < their && pid != own_pid)
730}
731
732pub fn takeover_obliges_yield(
735 my_rank: u8,
736 request: Option<(u8, u32)>,
737 own_pid: u32,
738 alive: &dyn Fn(u32) -> bool,
739) -> bool {
740 request.is_some_and(|(their, pid)| pid != own_pid && their < my_rank && alive(pid))
741}
742
743pub fn publish_takeover(suite: &Suite, service: &str, role: &str, rank: u8) -> io::Result<()> {
745 write_takeover(
746 suite,
747 service,
748 &TakeoverRequest {
749 role: role.into(),
750 rank,
751 pid: std::process::id(),
752 requested_at_ms: now_ms(),
753 },
754 )
755}
756
757fn now_ms() -> u64 {
758 SystemTime::now()
759 .duration_since(UNIX_EPOCH)
760 .map(|d| d.as_millis() as u64)
761 .unwrap_or(0)
762}
763
764pub fn spawn_detached(program: &Path, args: &[String], env: &[(String, String)]) -> io::Result<()> {
768 let mut cmd = Command::new(program);
769 cmd.args(args)
770 .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
771 .stdin(Stdio::null())
772 .stdout(Stdio::null())
773 .stderr(Stdio::null());
774 #[cfg(windows)]
775 {
776 use std::os::windows::process::CommandExt;
777 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
778 const DETACHED_PROCESS: u32 = 0x0000_0008;
779 cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS);
780 }
781 #[cfg(unix)]
782 {
783 use std::os::unix::process::CommandExt;
784 cmd.process_group(0);
785 }
786 drop(cmd.spawn()?);
787 Ok(())
788}
789
790pub fn poll_until(wait: Duration, mut ready: impl FnMut() -> bool) -> bool {
793 let deadline = Instant::now() + wait;
794 loop {
795 if ready() {
796 return true;
797 }
798 if Instant::now() >= deadline {
799 return false;
800 }
801 std::thread::sleep(Duration::from_millis(100));
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 fn eventually<T, E>(mut f: impl FnMut() -> Result<T, E>) -> Result<T, E> {
812 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
813 loop {
814 match f() {
815 Ok(v) => return Ok(v),
816 Err(e) if std::time::Instant::now() >= deadline => return Err(e),
817 Err(_) => std::thread::sleep(std::time::Duration::from_millis(20)),
818 }
819 }
820 }
821
822 use super::*;
823 use std::fs;
824
825 #[test]
826 fn takeover_predicates_compare_resolved_ranks_and_skip_self() {
827 let always = |_: u32| true;
828 assert!(should_request_takeover(0, Some((1, 99)), 7));
830 assert!(!should_request_takeover(1, Some((1, 99)), 7));
831 assert!(!should_request_takeover(0, Some((1, 7)), 7));
833 assert!(!should_request_takeover(0, None, 7));
834 assert!(takeover_obliges_yield(1, Some((0, 99)), 7, &always));
835 assert!(!takeover_obliges_yield(0, Some((1, 99)), 7, &always));
836 assert!(!takeover_obliges_yield(1, Some((0, 7)), 7, &always));
837 assert!(!takeover_obliges_yield(1, Some((0, 99)), 7, &|_| false));
838 }
839
840 #[test]
841 fn unresolvable_ranks_never_win_and_explicit_rank_beats_role_table() {
842 let roles = BTreeMap::from([("shell".to_string(), 0u8)]);
843 assert_eq!(resolve_rank("shell", RANK_UNKNOWN, &roles), Some(0));
844 assert_eq!(resolve_rank("unknown", RANK_UNKNOWN, &roles), None);
845 assert_eq!(resolve_rank("shell", 3, &roles), Some(3));
846 }
847
848 #[test]
849 fn spawn_poll_and_takeover_publication_round_trip() {
850 let d = tempfile::tempdir().unwrap();
851 let s = Suite::at(d.path()).unwrap();
852 publish_takeover(&s, "demo", "shell", 0).unwrap();
853 let t = crate::record::read_takeover(&s, "demo").unwrap();
854 assert_eq!(
855 (t.role.as_str(), t.rank, t.pid),
856 ("shell", 0, std::process::id())
857 );
858 assert!(poll_until(Duration::from_millis(50), || true));
859 assert!(!poll_until(Duration::from_millis(50), || false));
860 }
861
862 #[test]
863 fn mac_discovery_uses_known_bundle_and_first_root() {
864 let d = tempfile::tempdir().unwrap();
865 let bundle = d.path().join("DemoApp.app/Contents/MacOS");
866 fs::create_dir_all(&bundle).unwrap();
867 fs::write(bundle.join("demo-engine"), b"engine").unwrap();
868 fs::write(
869 d.path().join("DemoApp.app/Contents/Info.plist"),
870 b"<key>CFBundleShortVersionString</key><string>1.2</string>",
871 )
872 .unwrap();
873 let apps = discover_macos(&[d.path().to_path_buf()], &[("DemoApp", "demo-engine")]);
874 assert_eq!(apps[0].version.as_deref(), Some("1.2"));
875 }
876
877 #[test]
878 fn mac_discovery_reports_incomplete_installs_only_on_request() {
879 let d = tempfile::tempdir().unwrap();
880 let first = d.path().join("first");
882 let second = d.path().join("second");
883 fs::create_dir_all(first.join("DemoApp.app/Contents/MacOS")).unwrap();
884 fs::create_dir_all(&second).unwrap();
885 let roots = [first.clone(), second.clone()];
886 let reg = [("DemoApp", "demo-engine"), ("Absent", "absent-engine")];
887
888 assert!(discover_macos(&roots, ®).is_empty());
890 assert!(discover_macos_with(&roots, ®, &DiscoverOptions::default()).is_empty());
891
892 let opts = DiscoverOptions {
893 include_incomplete: true,
894 };
895 let apps = discover_macos_with(&roots, ®, &opts);
896 assert_eq!(
897 apps.len(),
898 1,
899 "an app with no bundle at all is never reported"
900 );
901 assert_eq!(apps[0].app, "DemoApp");
902 assert_eq!(apps[0].completeness, Completeness::EngineMissing);
903 assert!(std::path::Path::new(&apps[0].engine_path).ends_with(
904 std::path::Path::new("Contents")
905 .join("MacOS")
906 .join("demo-engine")
907 ));
908 assert!(apps[0].app_path.starts_with(first.to_str().unwrap()));
909
910 let good = second.join("DemoApp.app/Contents/MacOS");
912 fs::create_dir_all(&good).unwrap();
913 fs::write(good.join("demo-engine"), b"engine").unwrap();
914 let apps = discover_macos_with(&roots, ®, &opts);
915 assert_eq!(apps.len(), 1);
916 assert_eq!(apps[0].completeness, Completeness::Complete);
917 assert!(apps[0].app_path.starts_with(second.to_str().unwrap()));
918 assert_eq!(discover_macos(&roots, ®).len(), 1);
919 }
920
921 #[test]
922 fn windows_discovery_reports_incomplete_installs_only_on_request() {
923 let entry = |location: &str| UninstallEntry {
924 display_name: "DemoApp".into(),
925 install_location: Some(location.into()),
926 display_icon: None,
927 uninstall_string: None,
928 display_version: Some("3.0".into()),
929 };
930 let reg = [("DemoApp", "demo-engine")];
931 let broken = r"C:\Apps\Broken";
932 let entries = [entry(broken), entry(r"C:\Apps\Gone")];
933 let engine = |p: &str| p.starts_with("C:\\Apps\\Good\\");
935 let dirs = |p: &str| p == broken;
936
937 assert!(discover_windows(&entries, ®, engine).is_empty());
938 let opts = DiscoverOptions {
939 include_incomplete: true,
940 };
941 let apps = discover_windows_with(&entries, ®, engine, dirs, &opts);
942 assert_eq!(apps.len(), 1);
943 assert_eq!(apps[0].app_path, broken);
944 assert_eq!(apps[0].completeness, Completeness::EngineMissing);
945 assert_eq!(apps[0].version.as_deref(), Some("3.0"));
946
947 let entries = [entry(broken), entry(r"C:\Apps\Good")];
949 let apps = discover_windows_with(&entries, ®, engine, dirs, &opts);
950 assert_eq!(apps.len(), 1);
951 assert_eq!(apps[0].completeness, Completeness::Complete);
952 assert_eq!(apps[0].app_path, r"C:\Apps\Good");
953 }
954
955 #[test]
956 fn registry_parser_and_windows_projection_are_deterministic() {
957 let text = "HKEY_CURRENT_USER\\x\n DisplayName REG_SZ DemoApp\n InstallLocation REG_SZ C:\\Apps\\DemoApp\n DisplayVersion REG_SZ 2.0\n";
958 let rows = parse_reg_query(text);
959 assert_eq!(rows.len(), 1);
960 let apps = discover_windows(&rows, &[("DemoApp", "demo-engine")], |p| {
961 p.ends_with("demo-engine.exe")
962 });
963 assert_eq!(apps[0].version.as_deref(), Some("2.0"));
964 }
965
966 #[test]
967 fn spawn_claim_is_single_winner_and_released() {
968 let d = tempfile::tempdir().unwrap();
969 let s = Suite::at(d.path()).unwrap();
970 let c = SpawnClaim::acquire(&s, "demo").unwrap();
971 assert!(matches!(
972 SpawnClaim::acquire(&s, "demo"),
973 Err(SpawnClaimError::AlreadyHeld(_))
974 ));
975 drop(c);
976 assert!(eventually(|| SpawnClaim::acquire(&s, "demo")).is_ok());
977 }
978}