- 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.
218 lines
8.6 KiB
Python
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
|