""" 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() # Live gegen swscan.apple.com verifiziert (2026-07-18): der Server # schickt "Content-Encoding: x-gzip" -- requests/urllib3 dekomprimiert # das bereits transparent, r.content ist also schon reines XML. Ein # zusaetzliches gzip.decompress() darauf crasht mit BadGzipFile, weil # die Gzip-Magic-Bytes (\x1f\x8b) dann laengst weg sind. Manche # Server/Proxies liefern aber ggf. noch echte rohe Gzip-Bytes ohne # das Encoding transparent aufzuloesen -- daher robust an den echten # Magic-Bytes pruefen statt blind zu (de-)komprimieren. raw = r.content if raw[:2] == b"\x1f\x8b": raw = gzip.decompress(raw) data = plistlib.loads(raw) return data.get("Products", {}) # Bekannte Marketing-Versionsnummern nach Codename. Nur eine Anzeige-Hilfe -- # taucht ein UNS unbekannter zukuenftiger Codename auf (n plane naechstes Jahr: # neuer Nachfolger von Tahoe), wird er trotzdem gelistet (siehe # _split_shared_support_name), nur ohne huebsche Versionsnummer. KNOWN_MACOS_VERSIONS = { "bigsur": "11", "monterey": "12", "ventura": "13", "sonoma": "14", "sequoia": "15", "tahoe": "26", } def _split_shared_support_name(shared_support_id: str): """'com.apple.pkg.InstallAssistant.macOSBigSur' -> ('Big Sur', '11').""" name = shared_support_id.rsplit(".macOS", 1)[-1] if not name: return None, None # CamelCase -> "Big Sur" (Ziffern/Grossbuchstaben-Grenzen als Trenner) import re pretty = re.sub(r"(? bool: emi = product.get("ExtendedMetaInfo", {}) or {} ids = emi.get("InstallAssistantPackageIdentifiers", {}) or {} # Klassisches Schema bis Catalina: eigener "OSInstall"-Schluessel. if ids.get("OSInstall") == "com.apple.mpkg.OSInstall": return True # Ab Big Sur (live gegen swscan.apple.com verifiziert, 2026-07-18): kein # "OSInstall"-Schluessel mehr, stattdessen "SharedSupport": # "com.apple.pkg.InstallAssistant.macOS" (z.B. macOSBigSur, # macOSMonterey, ..., macOSTahoe). Der alte Filter uebersah dadurch # STILLSCHWEIGEND alle Big-Sur-und-neuer-Installer -- genau die, die per # InstallAssistant.pkg (statt BaseSystem.dmg) geliefert werden und die # dieses Tool eigentlich explizit unterstuetzen soll. shared = ids.get("SharedSupport", "") return isinstance(shared, str) and shared.startswith( "com.apple.pkg.InstallAssistant.macOS" ) 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) if title: return title, version # Big Sur+ Produkte haben KEIN ServerMetadataURL (live verifiziert) -- # Name kommt stattdessen aus dem SharedSupport-Identifier. emi = product.get("ExtendedMetaInfo", {}) or {} ids = emi.get("InstallAssistantPackageIdentifiers", {}) or {} shared = ids.get("SharedSupport", "") if isinstance(shared, str) and shared.startswith( "com.apple.pkg.InstallAssistant.macOS" ): name, ver = _split_shared_support_name(shared) if name: return f"macOS {name}", (version or ver) 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