Skip to main content

imessage_core/
macos.rs

1use std::sync::OnceLock;
2use tracing::warn;
3
4/// Cached macOS version, detected once at startup.
5static MACOS_VERSION: OnceLock<MacOsVersion> = OnceLock::new();
6
7/// Parsed macOS version.
8#[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    /// Check if this version is >= the given version.
25    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
37/// Check that the running macOS version is at least Sequoia (15.0).
38/// Returns `Ok(())` if so, or an error message if not.
39pub 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
56/// Detect the macOS version by running `sw_vers -productVersion`.
57fn 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
84/// Parse a version string like "26.3.0" or "15.0" into a MacOsVersion.
85fn 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
93/// Get the macOS version (cached, detected once).
94pub 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        // This test runs on macOS >= Sequoia, so it should pass
135        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}