Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
a74924e
Branches
Tags
main
apple-crates
macos-catalog
src
lib.rs
Refactor and unify a lot of things for another project and implement airdrop support
Dadoum
commited
a74924e
at 2026-10-25 22:15:58
lib.rs
Blame
History
Raw
//! The macOS releases Apple still distributes, the boards each one supports and //! the Apple-silicon Macs behind those boards. //! //! Releases come from the software update catalog behind `softwareupdate //! --list-full-installers`: each full installer's distribution file names its //! version, build and the boards it may be installed on. Models and their names //! come from the restore catalog and image (see [`apple_silicon_models`]). mod device_tree; mod restore; pub use restore::{Model, apple_silicon_models}; use futures_util::future::join_all; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::io::Read; use thiserror::Error; const CATALOG: &str = "https://swscan.apple.com/content/catalogs/others/index-26-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz"; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Release { /// For example `26.7.1`. pub version: String, /// For example `25G241`. pub build: String, /// Intel board IDs (`Mac-…`) and device IDs (`J…AP`) of the Macs it supports. pub boards: Vec<String>, } impl Release { pub fn supports(&self, board: &str) -> bool { self.boards .iter() .any(|known| known.eq_ignore_ascii_case(board)) } } /// Releases and models, fetched together. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Catalog { pub releases: Vec<Release>, pub models: Vec<Model>, } pub async fn catalog(client: &reqwest::Client) -> Result<Catalog, Error> { let (releases, models) = futures_util::try_join!(releases(client), apple_silicon_models(client))?; Ok(Catalog { releases, models }) } #[derive(Debug, Error)] pub enum Error { #[error("Cannot reach Apple's software catalog: {0}")] Network(#[from] reqwest::Error), #[error("Apple's software catalog is malformed")] InvalidCatalog, } #[derive(Deserialize)] struct SoftwareCatalog { #[serde(rename = "Products")] products: HashMap<String, Product>, } #[derive(Deserialize)] struct Product { #[serde(rename = "Distributions", default)] distributions: HashMap<String, String>, #[serde(rename = "ExtendedMetaInfo", default)] extended_meta_info: plist::Dictionary, } /// Every full macOS installer in the catalog, newest first, one per build. pub async fn releases(client: &reqwest::Client) -> Result<Vec<Release>, Error> { let compressed = client .get(CATALOG) .send() .await? .error_for_status()? .bytes() .await?; let mut data = Vec::new(); flate2::read::GzDecoder::new(&compressed[..]) .read_to_end(&mut data) .map_err(|_| Error::InvalidCatalog)?; let catalog: SoftwareCatalog = plist::from_bytes(&data).map_err(|_| Error::InvalidCatalog)?; let distributions = catalog .products .into_values() .filter(|product| { product .extended_meta_info .contains_key("InstallAssistantPackageIdentifiers") }) .filter_map(|mut product| product.distributions.remove("English")); let texts = join_all(distributions.map(|url| async move { client .get(url) .send() .await? .error_for_status()? .text() .await })) .await; let mut releases: Vec<Release> = Vec::new(); for text in texts { let release = parse_distribution(&text?).ok_or(Error::InvalidCatalog)?; if !releases.iter().any(|known| known.build == release.build) { releases.push(release); } } releases.sort_by_key(|release| std::cmp::Reverse(version_key(&release.version))); Ok(releases) } pub(crate) fn version_key(version: &str) -> Vec<u32> { version .split('.') .map(|part| part.parse().unwrap_or(0)) .collect() } /// Read a distribution file's `VERSION`, `BUILD` and board lists. Older installers /// call the list `boardIds`; newer ones split Intel boards and device IDs. fn parse_distribution(text: &str) -> Option<Release> { let string = |key: &str| { let (_, after) = text.split_once(&format!("<key>{key}</key>"))?; let (_, after) = after.split_once("<string>")?; Some(after.split_once("</string>")?.0.trim().to_string()) }; let mut boards = Vec::new(); for variable in ["supportedBoardIDs", "supportedDeviceIDs", "boardIds"] { let Some((_, after)) = text.split_once(&format!("var {variable}")) else { continue; }; let (_, list) = after.split_once('[')?; let (list, _) = list.split_once(']')?; boards.extend( list.split(',') .map(|item| item.trim().trim_matches(['\'', '"'])) .filter(|item| !item.is_empty()) .map(String::from), ); } Some(Release { version: string("VERSION")?, build: string("BUILD")?, boards, }) } #[cfg(test)] mod tests { use super::*; #[test] fn reads_versions_and_both_kinds_of_board_lists() { let text = r#"<script> var supportedBoardIDs = ['Mac-0000000000000001', 'Mac-0000000000000002']; var supportedDeviceIDs = ['J614sAP', 'VMM-x86_64']; </script> <auxinfo><dict> <key>BUILD</key> <string>25G241</string> <key>VERSION</key> <string>26.7.1</string> </dict></auxinfo>"#; let release = parse_distribution(text).unwrap(); assert_eq!(release.version, "26.7.1"); assert_eq!(release.build, "25G241"); assert!(release.supports("j614sap")); assert!(release.supports("Mac-0000000000000002")); assert_eq!(release.boards.len(), 4); let older = "var boardIds = [\"Mac-0000000000000003\"];\n\ <key>VERSION</key><string>10.15.7</string>\ <key>BUILD</key><string>19H15</string>"; assert!( parse_distribution(older) .unwrap() .supports("Mac-0000000000000003") ); assert!(parse_distribution("<key>VERSION</key><string>1</string>").is_none()); } #[test] fn fetching_can_run_on_any_thread() { fn send<T: Send>(_: T) {} send(catalog(&reqwest::Client::new())); } #[test] fn versions_sort_numerically() { assert!(version_key("10.15.7") < version_key("11.7.10")); assert!(version_key("26.7") < version_key("26.7.1")); } #[tokio::test] #[ignore = "contacts Apple's software update catalog"] async fn lists_current_installers() { let releases = releases(&reqwest::Client::new()).await.unwrap(); assert!(releases.len() > 10); assert!(releases.iter().all(|release| !release.boards.is_empty())); } }