159 lines
5.9 KiB
Python
159 lines
5.9 KiB
Python
"""
|
|
Apple Software-Update-Katalog: Liste verfuegbarer macOS-Installer laden.
|
|
|
|
Funktionsweise (live gegen swscan.apple.com verifiziert, 2026-07-18):
|
|
|
|
Apple veroeffentlicht einen kumulativen Produktkatalog unter einer URL, deren
|
|
Dateiname die Kette der unterstuetzten Major-Versionen absteigend auflistet,
|
|
z.B. fuer den aktuell (Stand Verifikation) neuesten Stand:
|
|
|
|
index-16-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
|
|
|
|
Jedes Mal wenn Apple eine neue macOS-Hauptversion (naechstes Jahr z.B. 17)
|
|
veroeffentlicht, legt Apple einen NEUEN Katalog mit einer um 1 hoeheren
|
|
fuehrenden Zahl an ("index-17-16-15-..."). Der neue Katalog ist kumulativ --
|
|
er enthaelt weiterhin alle aelteren Installer. Wir muessen also nur bei jedem
|
|
Start herausfinden, welche fuehrende Zahl AKTUELL die hoechste gueltige ist --
|
|
das macht find_latest_catalog_url() per HTTP-Probing.
|
|
|
|
WICHTIG / Grenze der Zukunftssicherheit: Das Namensschema selbst
|
|
(("index-<N>-<N-1>-...-legacy-suffix.sucatalog.gz") liegt in Apples Hand.
|
|
Es ist seit vielen Jahren stabil, aber falls Apple es grundlegend aendert,
|
|
muss dieses Modul angepasst werden -- 100% garantiert "nie wieder anfassen"
|
|
gibt es bei einer nicht dokumentierten Drittanbieter-Schnittstelle nicht.
|
|
Was wir bieten: neue macOS-Versionen tauchen OHNE Code-Aenderung automatisch
|
|
auf, solange Apple das Schema beibehaelt.
|
|
"""
|
|
import gzip
|
|
import logging
|
|
import plistlib
|
|
|
|
import requests
|
|
|
|
log = logging.getLogger("catalog")
|
|
|
|
CATALOG_HOST = "https://swscan.apple.com/content/catalogs/others/"
|
|
LEGACY_SUFFIX = (
|
|
"10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-"
|
|
"mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz"
|
|
)
|
|
FLOOR = 12 # aelteste bekannte Kette, ab der wir hochzaehlen
|
|
PROBE_HEADROOM = 15 # so viele Fehlschlaege in Folge tolerieren wir, bevor wir abbrechen
|
|
|
|
|
|
def _build_catalog_url(max_major: int) -> str:
|
|
chain = "-".join(str(n) for n in range(max_major, 11, -1))
|
|
return f"{CATALOG_HOST}index-{chain}-{LEGACY_SUFFIX}"
|
|
|
|
|
|
def find_latest_catalog_url(timeout: float = 8) -> str:
|
|
best = None
|
|
misses = 0
|
|
m = FLOOR
|
|
while misses < PROBE_HEADROOM:
|
|
url = _build_catalog_url(m)
|
|
try:
|
|
r = requests.head(url, timeout=timeout)
|
|
ok = r.status_code == 200
|
|
except requests.RequestException:
|
|
ok = False
|
|
if ok:
|
|
best = url
|
|
misses = 0
|
|
else:
|
|
misses += 1
|
|
m += 1
|
|
if not best:
|
|
raise RuntimeError(
|
|
"Konnte keinen Apple SUCatalog finden -- Apple hat vermutlich das "
|
|
"URL-Schema geaendert. catalog.py muss aktualisiert werden."
|
|
)
|
|
return best
|
|
|
|
|
|
def _fetch_catalog_products(url: str, timeout: float = 60) -> dict:
|
|
r = requests.get(url, timeout=timeout)
|
|
r.raise_for_status()
|
|
raw = gzip.decompress(r.content)
|
|
data = plistlib.loads(raw)
|
|
return data.get("Products", {})
|
|
|
|
|
|
def _is_macos_installer(product: dict) -> bool:
|
|
emi = product.get("ExtendedMetaInfo", {}) or {}
|
|
ids = emi.get("InstallAssistantPackageIdentifiers", {}) or {}
|
|
return ids.get("OSInstall") == "com.apple.mpkg.OSInstall"
|
|
|
|
|
|
def _fetch_product_metadata(product: dict, timeout: float = 20):
|
|
smd_url = product.get("ServerMetadataURL")
|
|
title, version = None, None
|
|
if smd_url:
|
|
try:
|
|
r = requests.get(smd_url, timeout=timeout)
|
|
r.raise_for_status()
|
|
meta = plistlib.loads(r.content)
|
|
version = meta.get("CFBundleShortVersionString")
|
|
loc = meta.get("localization", {}) or {}
|
|
en = loc.get("English") or (next(iter(loc.values())) if loc else {})
|
|
title = en.get("title")
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning("Metadaten-Fetch fehlgeschlagen fuer %s: %s", smd_url, exc)
|
|
return title, version
|
|
|
|
|
|
def _find_packages(product: dict):
|
|
"""Liefert (install_assistant_pkg, base_system_dmg, total_size_bytes)."""
|
|
packages = product.get("Packages", []) or []
|
|
install_assistant = None
|
|
base_system = None
|
|
total_size = 0
|
|
for p in packages:
|
|
url = p.get("URL", "")
|
|
size = p.get("Size", 0) or 0
|
|
total_size += size
|
|
if url.endswith("InstallAssistant.pkg"):
|
|
install_assistant = {"url": url, "size": size}
|
|
if url.endswith("BaseSystem.dmg") or url.endswith("InstallESD.dmg"):
|
|
base_system = {"url": url, "size": size}
|
|
return install_assistant, base_system, total_size
|
|
|
|
|
|
def list_macos_installers():
|
|
"""Gibt (items, catalog_url) zurueck. items ist eine sortierte Liste von dicts."""
|
|
url = find_latest_catalog_url()
|
|
products = _fetch_catalog_products(url)
|
|
results = []
|
|
for pid, product in products.items():
|
|
if not _is_macos_installer(product):
|
|
continue
|
|
ia, base, size = _find_packages(product)
|
|
if not ia and not base:
|
|
continue
|
|
title, version = _fetch_product_metadata(product)
|
|
post_date = product.get("PostDate")
|
|
results.append(
|
|
{
|
|
"id": pid,
|
|
"title": title or "macOS (Name unbekannt)",
|
|
"version": version or "0",
|
|
"post_date": post_date.isoformat() if post_date else None,
|
|
"size_bytes": size,
|
|
# "recovery" = Big Sur+ (InstallAssistant.pkg, Extraktion noetig, experimentell)
|
|
# "legacy_dmg" = bis Catalina (direktes dd-Image, robust)
|
|
"mode": "recovery" if ia else "legacy_dmg",
|
|
"install_assistant_url": ia["url"] if ia else None,
|
|
"base_system_url": base["url"] if base else None,
|
|
}
|
|
)
|
|
|
|
def _sort_key(item):
|
|
try:
|
|
return tuple(int(x) for x in item["version"].split("."))
|
|
except Exception: # noqa: BLE001
|
|
return (0,)
|
|
|
|
results.sort(key=_sort_key, reverse=True)
|
|
return results, url
|