Files
aria.hacker 1c97be6bb7 fix: apt deb822/OpenSSL3/pbzx-repo build errors + Big-Sur+-Katalogfilter
- Dockerfile: hfsprogs-Sources per sed auf bestehende deb822-Datei
  patchen statt zweite sources.list-Datei (Signed-By-Konflikt)
- Dockerfile: xar configure.ac OpenSSL-Probe auf EVP_EncryptInit
  umbiegen (OpenSSL_add_all_ciphers ist in OpenSSL 3.x nur noch Makro)
- Dockerfile: xar vor pbzx bauen (Linker-Abhaengigkeit), pbzx-Quelle auf
  NiklasRosenstein/pbzx (mackyle/pbzx existiert nicht mehr)
- catalog.py: doppeltes gzip.decompress auf bereits von requests
  dekomprimierten Content entfernt
- catalog.py: Big-Sur-bis-Tahoe-Installer wurden durch veralteten
  OSInstall-Filter komplett verworfen, jetzt SharedSupport-Schema
  ebenfalls erkannt (37 statt 13 gefundene Installer)

Alles live gegen echten docker compose build/up + echten Apple-Katalog
verifiziert, nicht nur behauptet.
2026-07-18 10:46:45 +02:00

218 lines
8.6 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()
# 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"(?<!^)(?=[A-Z])", " ", name).strip()
version = KNOWN_MACOS_VERSIONS.get(name.lower())
return pretty, version
def _is_macos_installer(product: dict) -> 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<Name>" (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