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 {
384 _file: LockedFile,
385 path: PathBuf,
386}
387
388impl SpawnClaim {
389 pub fn path(suite: &Suite, service: &str) -> PathBuf {
390 suite.service_dir(service).join("spawn.claim")
391 }
392
393 pub fn acquire(suite: &Suite, service: &str) -> Result<Self, SpawnClaimError> {
394 make_private_dir(&suite.service_dir(service))
395 .map_err(|e| SpawnClaimError::Io(io::Error::other(e.to_string())))?;
396 let path = Self::path(suite, service);
397 match try_lock_exclusive(&path) {
398 Ok(Some(file)) => Ok(Self { _file: file, path }),
399 Ok(None) => Err(SpawnClaimError::AlreadyHeld(path)),
400 Err(e) => Err(SpawnClaimError::Io(e)),
401 }
402 }
403
404 pub fn claim_path(&self) -> &Path {
406 &self.path
407 }
408}
409
410#[derive(Debug)]
421pub(crate) struct LockedFile {
422 #[cfg_attr(not(unix), allow(dead_code))]
423 file: std::fs::File,
424}
425
426impl Drop for LockedFile {
427 fn drop(&mut self) {
428 #[cfg(unix)]
429 {
430 let _ = self.file.unlock();
433 }
434 }
435}
436
437#[cfg(unix)]
439pub(crate) fn try_lock_exclusive(path: &Path) -> io::Result<Option<LockedFile>> {
440 use std::fs::TryLockError;
441 use std::io::Write;
442 let mut f = std::fs::OpenOptions::new()
443 .read(true)
444 .write(true)
445 .create(true)
446 .truncate(false)
447 .open(path)?;
448 match f.try_lock() {
450 Ok(()) => {}
451 Err(TryLockError::WouldBlock) => return Ok(None),
452 Err(TryLockError::Error(e)) => return Err(e),
453 }
454 let _ = f.set_len(0);
456 let _ = f.write_all(std::process::id().to_string().as_bytes());
457 Ok(Some(LockedFile { file: f }))
458}
459
460#[cfg(windows)]
465pub(crate) fn try_lock_exclusive(path: &Path) -> io::Result<Option<LockedFile>> {
466 use std::io::Write;
467 use std::os::windows::fs::OpenOptionsExt;
468 match std::fs::OpenOptions::new()
469 .write(true)
470 .create(true)
471 .truncate(false)
472 .share_mode(0)
473 .open(path)
474 {
475 Ok(mut f) => {
476 let _ = f.write_all(std::process::id().to_string().as_bytes());
477 Ok(Some(LockedFile { file: f }))
478 }
479 Err(e) if e.raw_os_error() == Some(32) => Ok(None),
480 Err(e) => Err(e),
481 }
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
485pub enum AdoptOrSpawn {
486 Adopt(ServiceRecord),
487 Spawn,
488}
489
490pub fn adopt_or_spawn(suite: &Suite, service: &str) -> AdoptOrSpawn {
495 try_adopt_or_spawn(suite, service).unwrap_or(AdoptOrSpawn::Spawn)
496}
497
498pub fn try_adopt_or_spawn(suite: &Suite, service: &str) -> io::Result<AdoptOrSpawn> {
501 Ok(match try_read_record(suite, service)? {
502 Some(record) if record_process_matches(&record) && probe_endpoint(&record.endpoint) => {
503 AdoptOrSpawn::Adopt(record)
504 }
505 _ => AdoptOrSpawn::Spawn,
506 })
507}
508
509pub fn wait_for_claim_winner(
511 suite: &Suite,
512 service: &str,
513 timeout: Duration,
514) -> Option<ServiceRecord> {
515 let deadline = Instant::now() + timeout;
516 while Instant::now() < deadline {
517 if let Ok(AdoptOrSpawn::Adopt(record)) = try_adopt_or_spawn(suite, service) {
519 return Some(record);
520 }
521 std::thread::sleep(
522 Duration::from_millis(25).min(deadline.saturating_duration_since(Instant::now())),
523 );
524 }
525 None
526}
527
528pub fn record_process_matches(record: &ServiceRecord) -> bool {
530 record_process_matches_inner(record)
531}
532
533fn record_process_matches_inner(record: &ServiceRecord) -> bool {
534 if !pid_alive(record.pid) {
535 return false;
536 }
537 if record.process_started_ms == 0 {
540 return true;
541 }
542 match process_start_ms(record.pid) {
543 Some(started) => started.abs_diff(record.process_started_ms as u128) <= 2_000,
544 None => true,
546 }
547}
548
549pub(crate) fn own_start_ms() -> Option<u64> {
551 process_start_ms(std::process::id()).map(|v| v as u64)
552}
553
554fn process_start_ms(pid: u32) -> Option<u128> {
555 #[cfg(target_os = "linux")]
556 {
557 let stat = std::fs::read_to_string(if pid == std::process::id() {
558 "/proc/self/stat".into()
559 } else {
560 format!("/proc/{pid}/stat")
561 })
562 .ok()?;
563 let ticks = stat
564 .rsplit(')')
565 .next()?
566 .split_whitespace()
567 .nth(19)?
568 .parse::<u128>()
569 .ok()?;
570 let boot = std::fs::read_to_string("/proc/stat")
571 .ok()?
572 .lines()
573 .find_map(|l| l.strip_prefix("btime "))?
574 .parse::<u128>()
575 .ok()?;
576 return Some(boot * 1000 + ticks * 1000 / 100);
577 }
578 #[cfg(target_os = "macos")]
579 {
580 let out = std::process::Command::new("ps")
581 .args(["-o", "lstart=", "-p", &pid.to_string()])
582 .output()
583 .ok()?;
584 let text = String::from_utf8(out.stdout).ok()?;
585 let value = text.trim();
586 let parsed = std::process::Command::new("date")
587 .args(["-j", "-f", "%a %b %e %H:%M:%S %Y", value, "+%s"])
588 .output()
589 .ok()?;
590 let seconds = String::from_utf8(parsed.stdout)
591 .ok()?
592 .trim()
593 .parse::<u128>()
594 .ok()?;
595 return Some(seconds * 1000);
596 }
597 #[cfg(windows)]
598 {
599 use std::ffi::c_void;
600 #[repr(C)]
601 struct FileTime {
602 low: u32,
603 high: u32,
604 }
605 #[link(name = "kernel32")]
606 unsafe extern "system" {
607 fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut c_void;
608 fn GetProcessTimes(
609 handle: *mut c_void,
610 creation: *mut FileTime,
611 exit: *mut FileTime,
612 kernel: *mut FileTime,
613 user: *mut FileTime,
614 ) -> i32;
615 fn CloseHandle(handle: *mut c_void) -> i32;
616 }
617 const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
618 let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
619 if handle.is_null() {
620 return None;
621 }
622 let mut creation = FileTime { low: 0, high: 0 };
623 let mut exit = FileTime { low: 0, high: 0 };
624 let mut kernel = FileTime { low: 0, high: 0 };
625 let mut user = FileTime { low: 0, high: 0 };
626 let ok =
627 unsafe { GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) }
628 != 0;
629 unsafe {
630 CloseHandle(handle);
631 }
632 if !ok {
633 return None;
634 }
635 let ticks = ((creation.high as u128) << 32) | creation.low as u128;
636 return Some(ticks / 10_000 - 11_644_473_600_000);
637 }
638 #[allow(unreachable_code)]
639 None
640}
641
642#[cfg(windows)]
643fn read_windows_uninstall_entries() -> Vec<UninstallEntry> {
644 let mut all = String::new();
645 for key in [
646 r"HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall",
647 r"HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall",
648 r"HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
649 ] {
650 let mut reg = std::process::Command::new("reg");
651 reg.args(["query", key, "/s"]);
652 #[cfg(windows)]
653 {
654 use std::os::windows::process::CommandExt;
655 reg.creation_flags(0x0800_0000); }
657 if let Ok(output) = reg.output() {
658 all.push_str(&String::from_utf8_lossy(&output.stdout));
659 all.push('\n');
660 }
661 }
662 parse_reg_query(&all)
663}
664
665pub fn resolve_rank(role: &str, rank: u8, known_roles: &BTreeMap<String, u8>) -> Option<u8> {
675 if rank != RANK_UNKNOWN {
676 Some(rank)
677 } else {
678 known_roles.get(role).copied()
679 }
680}
681
682#[derive(Debug, Clone, PartialEq, Eq)]
684pub enum HostPlan {
685 UseRunning,
687 TakeOver { from_role: String },
689 HostMyself,
691 StartInstalled(PathBuf),
693}
694
695pub fn plan_host(
699 suite: &Suite,
700 service: &str,
701 my_rank: u8,
702 known_roles: &BTreeMap<String, u8>,
703 installed_engine: Option<&Path>,
704) -> HostPlan {
705 plan_from(
706 read_live_record(suite, service),
707 my_rank,
708 known_roles,
709 installed_engine,
710 )
711}
712
713pub fn try_plan_host(
717 suite: &Suite,
718 service: &str,
719 my_rank: u8,
720 known_roles: &BTreeMap<String, u8>,
721 installed_engine: Option<&Path>,
722) -> io::Result<HostPlan> {
723 Ok(plan_from(
724 try_read_live_record(suite, service)?,
725 my_rank,
726 known_roles,
727 installed_engine,
728 ))
729}
730
731fn plan_from(
732 live: Option<ServiceRecord>,
733 my_rank: u8,
734 known_roles: &BTreeMap<String, u8>,
735 installed_engine: Option<&Path>,
736) -> HostPlan {
737 if let Some(record) = live {
738 if let Some(their) = resolve_rank(&record.role, record.rank, known_roles) {
739 return if my_rank < their {
740 HostPlan::TakeOver {
741 from_role: record.role,
742 }
743 } else {
744 HostPlan::UseRunning
745 };
746 }
747 }
748 match installed_engine {
749 Some(engine) if engine.exists() => HostPlan::StartInstalled(engine.to_path_buf()),
750 _ => HostPlan::HostMyself,
751 }
752}
753
754pub fn should_request_takeover(my_rank: u8, incumbent: Option<(u8, u32)>, own_pid: u32) -> bool {
757 incumbent.is_some_and(|(their, pid)| my_rank < their && pid != own_pid)
758}
759
760pub fn takeover_obliges_yield(
763 my_rank: u8,
764 request: Option<(u8, u32)>,
765 own_pid: u32,
766 alive: &dyn Fn(u32) -> bool,
767) -> bool {
768 request.is_some_and(|(their, pid)| pid != own_pid && their < my_rank && alive(pid))
769}
770
771pub fn publish_takeover(suite: &Suite, service: &str, role: &str, rank: u8) -> io::Result<()> {
773 write_takeover(
774 suite,
775 service,
776 &TakeoverRequest {
777 role: role.into(),
778 rank,
779 pid: std::process::id(),
780 requested_at_ms: now_ms(),
781 },
782 )
783}
784
785fn now_ms() -> u64 {
786 SystemTime::now()
787 .duration_since(UNIX_EPOCH)
788 .map(|d| d.as_millis() as u64)
789 .unwrap_or(0)
790}
791
792pub fn spawn_detached(program: &Path, args: &[String], env: &[(String, String)]) -> io::Result<()> {
796 let mut cmd = Command::new(program);
797 cmd.args(args)
798 .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
799 .stdin(Stdio::null())
800 .stdout(Stdio::null())
801 .stderr(Stdio::null());
802 #[cfg(windows)]
803 {
804 use std::os::windows::process::CommandExt;
805 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
806 const DETACHED_PROCESS: u32 = 0x0000_0008;
807 cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS);
808 }
809 #[cfg(unix)]
810 {
811 use std::os::unix::process::CommandExt;
812 cmd.process_group(0);
813 }
814 drop(cmd.spawn()?);
815 Ok(())
816}
817
818pub fn poll_until(wait: Duration, mut ready: impl FnMut() -> bool) -> bool {
821 let deadline = Instant::now() + wait;
822 loop {
823 if ready() {
824 return true;
825 }
826 if Instant::now() >= deadline {
827 return false;
828 }
829 std::thread::sleep(Duration::from_millis(100));
830 }
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836 use std::fs;
837
838 #[test]
839 fn takeover_predicates_compare_resolved_ranks_and_skip_self() {
840 let always = |_: u32| true;
841 assert!(should_request_takeover(0, Some((1, 99)), 7));
843 assert!(!should_request_takeover(1, Some((1, 99)), 7));
844 assert!(!should_request_takeover(0, Some((1, 7)), 7));
846 assert!(!should_request_takeover(0, None, 7));
847 assert!(takeover_obliges_yield(1, Some((0, 99)), 7, &always));
848 assert!(!takeover_obliges_yield(0, Some((1, 99)), 7, &always));
849 assert!(!takeover_obliges_yield(1, Some((0, 7)), 7, &always));
850 assert!(!takeover_obliges_yield(1, Some((0, 99)), 7, &|_| false));
851 }
852
853 #[test]
854 fn unresolvable_ranks_never_win_and_explicit_rank_beats_role_table() {
855 let roles = BTreeMap::from([("shell".to_string(), 0u8)]);
856 assert_eq!(resolve_rank("shell", RANK_UNKNOWN, &roles), Some(0));
857 assert_eq!(resolve_rank("unknown", RANK_UNKNOWN, &roles), None);
858 assert_eq!(resolve_rank("shell", 3, &roles), Some(3));
859 }
860
861 #[test]
862 fn spawn_poll_and_takeover_publication_round_trip() {
863 let d = tempfile::tempdir().unwrap();
864 let s = Suite::at(d.path()).unwrap();
865 publish_takeover(&s, "demo", "shell", 0).unwrap();
866 let t = crate::record::read_takeover(&s, "demo").unwrap();
867 assert_eq!(
868 (t.role.as_str(), t.rank, t.pid),
869 ("shell", 0, std::process::id())
870 );
871 assert!(poll_until(Duration::from_millis(50), || true));
872 assert!(!poll_until(Duration::from_millis(50), || false));
873 }
874
875 #[test]
876 fn mac_discovery_uses_known_bundle_and_first_root() {
877 let d = tempfile::tempdir().unwrap();
878 let bundle = d.path().join("DemoApp.app/Contents/MacOS");
879 fs::create_dir_all(&bundle).unwrap();
880 fs::write(bundle.join("demo-engine"), b"engine").unwrap();
881 fs::write(
882 d.path().join("DemoApp.app/Contents/Info.plist"),
883 b"<key>CFBundleShortVersionString</key><string>1.2</string>",
884 )
885 .unwrap();
886 let apps = discover_macos(&[d.path().to_path_buf()], &[("DemoApp", "demo-engine")]);
887 assert_eq!(apps[0].version.as_deref(), Some("1.2"));
888 }
889
890 #[test]
891 fn mac_discovery_reports_incomplete_installs_only_on_request() {
892 let d = tempfile::tempdir().unwrap();
893 let first = d.path().join("first");
895 let second = d.path().join("second");
896 fs::create_dir_all(first.join("DemoApp.app/Contents/MacOS")).unwrap();
897 fs::create_dir_all(&second).unwrap();
898 let roots = [first.clone(), second.clone()];
899 let reg = [("DemoApp", "demo-engine"), ("Absent", "absent-engine")];
900
901 assert!(discover_macos(&roots, ®).is_empty());
903 assert!(discover_macos_with(&roots, ®, &DiscoverOptions::default()).is_empty());
904
905 let opts = DiscoverOptions {
906 include_incomplete: true,
907 };
908 let apps = discover_macos_with(&roots, ®, &opts);
909 assert_eq!(
910 apps.len(),
911 1,
912 "an app with no bundle at all is never reported"
913 );
914 assert_eq!(apps[0].app, "DemoApp");
915 assert_eq!(apps[0].completeness, Completeness::EngineMissing);
916 assert!(std::path::Path::new(&apps[0].engine_path).ends_with(
917 std::path::Path::new("Contents")
918 .join("MacOS")
919 .join("demo-engine")
920 ));
921 assert!(apps[0].app_path.starts_with(first.to_str().unwrap()));
922
923 let good = second.join("DemoApp.app/Contents/MacOS");
925 fs::create_dir_all(&good).unwrap();
926 fs::write(good.join("demo-engine"), b"engine").unwrap();
927 let apps = discover_macos_with(&roots, ®, &opts);
928 assert_eq!(apps.len(), 1);
929 assert_eq!(apps[0].completeness, Completeness::Complete);
930 assert!(apps[0].app_path.starts_with(second.to_str().unwrap()));
931 assert_eq!(discover_macos(&roots, ®).len(), 1);
932 }
933
934 #[test]
935 fn windows_discovery_reports_incomplete_installs_only_on_request() {
936 let entry = |location: &str| UninstallEntry {
937 display_name: "DemoApp".into(),
938 install_location: Some(location.into()),
939 display_icon: None,
940 uninstall_string: None,
941 display_version: Some("3.0".into()),
942 };
943 let reg = [("DemoApp", "demo-engine")];
944 let broken = r"C:\Apps\Broken";
945 let entries = [entry(broken), entry(r"C:\Apps\Gone")];
946 let engine = |p: &str| p.starts_with("C:\\Apps\\Good\\");
948 let dirs = |p: &str| p == broken;
949
950 assert!(discover_windows(&entries, ®, engine).is_empty());
951 let opts = DiscoverOptions {
952 include_incomplete: true,
953 };
954 let apps = discover_windows_with(&entries, ®, engine, dirs, &opts);
955 assert_eq!(apps.len(), 1);
956 assert_eq!(apps[0].app_path, broken);
957 assert_eq!(apps[0].completeness, Completeness::EngineMissing);
958 assert_eq!(apps[0].version.as_deref(), Some("3.0"));
959
960 let entries = [entry(broken), entry(r"C:\Apps\Good")];
962 let apps = discover_windows_with(&entries, ®, engine, dirs, &opts);
963 assert_eq!(apps.len(), 1);
964 assert_eq!(apps[0].completeness, Completeness::Complete);
965 assert_eq!(apps[0].app_path, r"C:\Apps\Good");
966 }
967
968 #[test]
969 fn registry_parser_and_windows_projection_are_deterministic() {
970 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";
971 let rows = parse_reg_query(text);
972 assert_eq!(rows.len(), 1);
973 let apps = discover_windows(&rows, &[("DemoApp", "demo-engine")], |p| {
974 p.ends_with("demo-engine.exe")
975 });
976 assert_eq!(apps[0].version.as_deref(), Some("2.0"));
977 }
978
979 #[test]
980 fn spawn_claim_is_single_winner_and_released() {
981 let d = tempfile::tempdir().unwrap();
982 let s = Suite::at(d.path()).unwrap();
983 let c = SpawnClaim::acquire(&s, "demo").unwrap();
984 assert!(matches!(
985 SpawnClaim::acquire(&s, "demo"),
986 Err(SpawnClaimError::AlreadyHeld(_))
987 ));
988 drop(c);
989 assert!(SpawnClaim::acquire(&s, "demo").is_ok());
991 }
992}