""" 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---...-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