1use std::sync::OnceLock;
2use tracing::warn;
3
4static MACOS_VERSION: OnceLock<MacOsVersion> = OnceLock::new();
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct MacOsVersion {
10 pub major: u32,
11 pub minor: u32,
12 pub patch: u32,
13}
14
15impl MacOsVersion {
16 pub fn new(major: u32, minor: u32, patch: u32) -> Self {
17 Self {
18 major,
19 minor,
20 patch,
21 }
22 }
23
24 pub fn is_at_least(&self, major: u32, minor: u32) -> bool {
26 if self.major != major {
27 return self.major > major;
28 }
29 self.minor >= minor
30 }
31
32 pub fn is_min_tahoe(&self) -> bool {
33 self.is_at_least(26, 0)
34 }
35}
36
37pub fn require_min_sequoia() -> Result<(), String> {
40 let v = macos_version();
41 if v.is_at_least(15, 0) {
42 Ok(())
43 } else {
44 Err(format!(
45 "imessage-rs requires macOS Sequoia (15.0) or newer, but detected macOS {v}"
46 ))
47 }
48}
49
50impl std::fmt::Display for MacOsVersion {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
53 }
54}
55
56fn detect_version() -> MacOsVersion {
58 let output = std::process::Command::new("sw_vers")
59 .arg("-productVersion")
60 .output();
61
62 match output {
63 Ok(out) if out.status.success() => {
64 let version_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
65 parse_version(&version_str).unwrap_or_else(|| {
66 warn!("Failed to parse macOS version string: {version_str}");
67 MacOsVersion::new(0, 0, 0)
68 })
69 }
70 Ok(out) => {
71 warn!(
72 "sw_vers failed: {}",
73 String::from_utf8_lossy(&out.stderr).trim()
74 );
75 MacOsVersion::new(0, 0, 0)
76 }
77 Err(e) => {
78 warn!("Failed to run sw_vers: {e}");
79 MacOsVersion::new(0, 0, 0)
80 }
81 }
82}
83
84fn parse_version(s: &str) -> Option<MacOsVersion> {
86 let parts: Vec<&str> = s.split('.').collect();
87 let major = parts.first()?.parse().ok()?;
88 let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
89 let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
90 Some(MacOsVersion::new(major, minor, patch))
91}
92
93pub fn macos_version() -> MacOsVersion {
95 *MACOS_VERSION.get_or_init(detect_version)
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn parse_three_part() {
104 let v = parse_version("26.3.0").unwrap();
105 assert_eq!(v.major, 26);
106 assert_eq!(v.minor, 3);
107 assert_eq!(v.patch, 0);
108 }
109
110 #[test]
111 fn parse_two_part() {
112 let v = parse_version("15.0").unwrap();
113 assert_eq!(v.major, 15);
114 assert_eq!(v.minor, 0);
115 assert_eq!(v.patch, 0);
116 }
117
118 #[test]
119 fn tahoe_version_flags() {
120 let v = MacOsVersion::new(26, 3, 0);
121 assert!(v.is_min_tahoe());
122 assert!(v.is_at_least(15, 0));
123 }
124
125 #[test]
126 fn sequoia_version_flags() {
127 let v = MacOsVersion::new(15, 0, 0);
128 assert!(!v.is_min_tahoe());
129 assert!(v.is_at_least(15, 0));
130 }
131
132 #[test]
133 fn require_min_sequoia_passes() {
134 assert!(require_min_sequoia().is_ok());
136 }
137
138 #[test]
139 fn display_format() {
140 let v = MacOsVersion::new(26, 3, 0);
141 assert_eq!(format!("{v}"), "26.3.0");
142 }
143}