""" Job-Engine: Download eines macOS-Installers + Beschreiben eines USB-Sticks. Zwei Pfade, unterschiedlich robust: legacy_dmg (macOS bis Catalina/10.15): Apple liefert ein fertiges, bootfaehiges BaseSystem.dmg/InstallESD.dmg. Wir wandeln es mit dmg2img in ein rohes Image und schreiben es 1:1 mit dd auf den Stick. Etablierter, zuverlaessiger Weg. recovery (macOS ab Big Sur/11): Apple liefert nur noch ein InstallAssistant.pkg. Das eigentliche createinstallmedia laeuft nur unter echtem macOS. Wir extrahieren das .pkg (xar -> pbzx -> cpio) und suchen darin ein SharedSupport.dmg/BaseSystem.dmg, das wir dann genauso per dd schreiben. EXPERIMENTELL: Apple hat den internen Aufbau von InstallAssistant.pkg mehrfach leicht geaendert, das kann brechen. Klar als "experimentell" markiert. Zwei Job-Typen: "stick" Download (oder Wiederverwendung aus der lokalen Bibliothek) + Schreiben auf einen USB-Stick. "download" Nur herunterladen, dauerhaft in der lokalen Bibliothek ablegen. Kein Geraet noetig. Gedacht um sich vorab eine Offline-Sammlung aller macOS-Installer aufzubauen (z.B. auf eine externe Platte), die man auch ohne Internetzugang zum Stick-Bauen nutzen kann. Lokale Bibliothek (Persistenz): Jeder erfolgreiche Download landet dauerhaft unter /data/downloads/library// (Original-Dateiname + meta.json). Das liegt im Bind-Mount ./downloads -- also ganz normal im Projektordner und damit portabel (Ordner/Platte kopieren reicht). Ein "stick"-Job prueft IMMER zuerst, ob die Datei schon lokal vorhanden ist, und ueberspringt den Download wenn ja -- so wird aus jedem einzelnen Stick-Bau ganz nebenbei eine wachsende Offline-Bibliothek, ganz ohne Zusatzaufwand. Sicherheits-Leitplanken: - Ziel-Device wird gegen die Root-Disk des Hosts geprueft (Refuse). - Ziel-Device muss "removable" oder USB-Transport sein (Refuse sonst). - Aufrufer muss den Device-Pfad im Request nochmal exakt bestaetigen. """ import json import logging import os import re import shutil import subprocess import tempfile import threading import time import uuid from queue import Queue, Empty import requests from devices import list_usb_sticks log = logging.getLogger("installer") DOWNLOAD_DIR = "/data/downloads" LIBRARY_DIR = os.path.join(DOWNLOAD_DIR, "library") JOBS: dict[str, "Job"] = {} class JobAborted(Exception): pass def _safe_name(product: dict) -> str: """Stabiler, lesbarer Ordnername fuer die Bibliothek, z.B. 'macOS_Sonoma_14.6_061-12345'. Die Produkt-ID am Ende macht ihn eindeutig, auch wenn zwei Eintraege denselben Titel haben.""" raw = f"{product.get('title', 'macOS')}_{product.get('version', '0')}_{product.get('id', '')}" return re.sub(r"[^A-Za-z0-9._-]+", "_", raw).strip("_") def _payload_url(product: dict) -> str: if product["mode"] == "legacy_dmg": return product["base_system_url"] return product["install_assistant_url"] def library_entry_dir(product: dict) -> str: return os.path.join(LIBRARY_DIR, _safe_name(product)) def library_payload_path(product: dict) -> str: filename = os.path.basename(_payload_url(product)) or ( "BaseSystem.dmg" if product["mode"] == "legacy_dmg" else "InstallAssistant.pkg" ) return os.path.join(library_entry_dir(product), filename) def get_cached_payload(product: dict): """Gibt den Pfad zurueck wenn die Datei schon vollstaendig in der Bibliothek liegt (meta.json + Datei vorhanden, Groesse passt), sonst None.""" path = library_payload_path(product) meta_path = os.path.join(library_entry_dir(product), "meta.json") if not (os.path.isfile(path) and os.path.isfile(meta_path)): return None try: with open(meta_path) as f: meta = json.load(f) if meta.get("complete") and os.path.getsize(path) == meta.get("size_bytes"): return path except (OSError, ValueError): pass return None def list_library(): """Fuer die GUI: alle vollstaendig heruntergeladenen Produkte + Gesamtgroesse.""" items = [] total = 0 if os.path.isdir(LIBRARY_DIR): for entry in sorted(os.listdir(LIBRARY_DIR)): meta_path = os.path.join(LIBRARY_DIR, entry, "meta.json") if not os.path.isfile(meta_path): continue try: with open(meta_path) as f: meta = json.load(f) except (OSError, ValueError): continue if not meta.get("complete"): continue items.append(meta) total += meta.get("size_bytes", 0) return {"items": items, "total_bytes": total} class Job: def __init__(self, product: dict, device_path: str = None, job_type: str = "stick"): self.id = str(uuid.uuid4()) self.product = product self.device_path = device_path self.job_type = job_type # "stick" | "download" self.status = "queued" # queued -> running -> done | failed | aborted self.phase = "warten" self.progress = 0 # 0-100 im aktuellen Phase self.error = None self._q: Queue = Queue() self._lock = threading.Lock() self.history = [] def emit(self, phase=None, progress=None, msg=None, status=None): with self._lock: if phase is not None: self.phase = phase if progress is not None: self.progress = progress if status is not None: self.status = status event = { "phase": self.phase, "progress": self.progress, "status": self.status, "msg": msg, "ts": time.time(), } self.history.append(event) self._q.put(event) def stream(self): # erst die bisherige Historie nachliefern, dann live weiter for event in list(self.history): yield event while True: try: yield self._q.get(timeout=30) except Empty: if self.status in ("done", "failed", "aborted"): return yield {"phase": self.phase, "progress": self.progress, "status": self.status, "msg": None, "ts": time.time()} def snapshot(self): with self._lock: return { "id": self.id, "job_type": self.job_type, "status": self.status, "phase": self.phase, "progress": self.progress, "error": self.error, } def create_job(product: dict, device_path: str = None, confirm_path: str = None, job_type: str = "stick") -> Job: if job_type == "download": job = Job(product, device_path=None, job_type="download") JOBS[job.id] = job t = threading.Thread(target=_run_job, args=(job,), daemon=True) t.start() return job if job_type != "stick": raise ValueError(f"Unbekannter job_type: {job_type}") if confirm_path != device_path: raise ValueError("Bestaetigungs-Pfad stimmt nicht mit dem Ziel-Geraet ueberein.") sticks = list_usb_sticks() match = next((s for s in sticks if s["path"] == device_path), None) if not match: raise ValueError(f"Geraet {device_path} nicht in der aktuellen USB-Liste gefunden.") if match["is_root_disk"]: raise ValueError("Verweigert: das ist die System-Platte des Hosts.") if match["mounted"]: raise ValueError("Geraet ist aktuell gemountet -- bitte vorher aushaengen.") if not (match["transport"] == "usb"): raise ValueError("Geraet ist nicht als USB-Transport erkannt -- Sicherheitsstop.") job = Job(product, device_path, job_type="stick") JOBS[job.id] = job t = threading.Thread(target=_run_job, args=(job,), daemon=True) t.start() return job def _run(job: Job, cmd: list[str], phase: str, progress_from_stderr=False): log.info("job %s: %s", job.id, " ".join(cmd)) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) for line in proc.stdout: line = line.strip() if line: job.emit(phase=phase, msg=line) ret = proc.wait() if ret != 0: raise RuntimeError(f"Befehl fehlgeschlagen ({ret}): {' '.join(cmd)}") def _download_to_library(job: Job, product: dict) -> str: """Laedt den Installer-Payload herunter und legt ihn dauerhaft in der Bibliothek ab (nicht im temporaeren Scratch-Verzeichnis). Schreibt zuerst in eine .part-Datei und benennt erst nach Erfolg um, damit ein abgebrochener Download nicht als "vollstaendig" durchgeht.""" url = _payload_url(product) entry_dir = library_entry_dir(product) os.makedirs(entry_dir, exist_ok=True) final_path = library_payload_path(product) part_path = final_path + ".part" job.emit(phase="download", progress=0, msg=f"Lade {url}") with requests.get(url, stream=True, timeout=30) as r: r.raise_for_status() total = int(r.headers.get("Content-Length", 0)) or None done = 0 last_pct = -1 with open(part_path, "wb") as f: for chunk in r.iter_content(chunk_size=1024 * 1024 * 4): if not chunk: continue f.write(chunk) done += len(chunk) if total: pct = int(done * 100 / total) if pct != last_pct: job.emit(phase="download", progress=pct) last_pct = pct size_bytes = os.path.getsize(part_path) os.replace(part_path, final_path) with open(os.path.join(entry_dir, "meta.json"), "w") as f: json.dump( { "id": product.get("id"), "title": product.get("title"), "version": product.get("version"), "mode": product.get("mode"), "source_url": url, "filename": os.path.basename(final_path), "size_bytes": size_bytes, "downloaded_at": time.time(), "complete": True, }, f, ) job.emit(phase="download", progress=100, msg=f"Download fertig, liegt dauerhaft unter {final_path}") return final_path def _dmg_to_raw(job: Job, dmg_path: str, work_dir: str) -> str: raw_path = os.path.join(work_dir, os.path.basename(dmg_path) + ".img") job.emit(phase="konvertieren", progress=0, msg="dmg2img: DMG -> rohes Image") _run(job, ["dmg2img", "-i", dmg_path, "-o", raw_path], phase="konvertieren") return raw_path def _write_raw_to_device(job: Job, raw_path: str, device_path: str): job.emit(phase="schreiben", progress=0, msg=f"Schreibe {raw_path} -> {device_path} (dd)") _run(job, ["wipefs", "-a", device_path], phase="schreiben") size = os.path.getsize(raw_path) cmd = ["dd", f"if={raw_path}", f"of={device_path}", "bs=4M", "status=progress", "conv=fsync"] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) for line in proc.stdout: line = line.strip() if not line: continue job.emit(phase="schreiben", msg=line) # dd status=progress schreibt Zeilen wie "1234567890 bytes (1.2 GB, 1.1 GiB) copied, ..." try: bytes_done = int(line.split(" ")[0]) pct = min(100, int(bytes_done * 100 / size)) job.emit(phase="schreiben", progress=pct) except (ValueError, IndexError, ZeroDivisionError): pass ret = proc.wait() if ret != 0: raise RuntimeError("dd fehlgeschlagen") job.emit(phase="schreiben", progress=100, msg="dd fertig, sync...") subprocess.run(["sync"]) def _extract_recovery_dmg(job: Job, pkg_path: str, work_dir: str) -> str: """Experimentell: InstallAssistant.pkg -> xar -> pbzx -> cpio, sucht SharedSupport/BaseSystem.dmg.""" job.emit(phase="extrahieren", progress=0, msg="xar: InstallAssistant.pkg entpacken (experimentell)") extract_dir = os.path.join(work_dir, "xar") os.makedirs(extract_dir, exist_ok=True) _run(job, ["xar", "-xf", pkg_path, "-C", extract_dir], phase="extrahieren") payloads = [] for root, _dirs, files in os.walk(extract_dir): for fn in files: if fn == "Payload": payloads.append(os.path.join(root, fn)) if not payloads: raise RuntimeError("Kein Payload in InstallAssistant.pkg gefunden -- Apple-Struktur hat sich vermutlich geaendert.") cpio_dir = os.path.join(work_dir, "cpio") os.makedirs(cpio_dir, exist_ok=True) for payload in payloads: job.emit(phase="extrahieren", msg=f"pbzx+cpio: {payload}") # pbzx dekomprimiert das payload, cpio packt den entstandenen Stream aus pbzx_proc = subprocess.Popen(["pbzx", "-n", payload], stdout=subprocess.PIPE) cpio_proc = subprocess.Popen( ["cpio", "-idm"], cwd=cpio_dir, stdin=pbzx_proc.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) pbzx_proc.stdout.close() for line in cpio_proc.stdout: line = line.strip() if line: job.emit(phase="extrahieren", msg=line) cpio_proc.wait() pbzx_proc.wait() dmg_candidates = [] for root, _dirs, files in os.walk(cpio_dir): for fn in files: if fn in ("SharedSupport.dmg", "BaseSystem.dmg"): dmg_candidates.append(os.path.join(root, fn)) if not dmg_candidates: raise RuntimeError( "Kein SharedSupport.dmg/BaseSystem.dmg im entpackten Installer gefunden. " "Das ist der bekannte fragile Punkt bei macOS Big Sur+ ohne echten Mac." ) # SharedSupport.dmg bevorzugen (enthaelt das vollstaendige Recovery-System) dmg_candidates.sort(key=lambda p: 0 if "SharedSupport" in p else 1) return dmg_candidates[0] def _run_job(job: Job): os.makedirs(DOWNLOAD_DIR, exist_ok=True) os.makedirs(LIBRARY_DIR, exist_ok=True) # Scratch-Verzeichnis NUR fuer temporaere Konvertierungs-/Extraktionsdaten # (dmg2img-Ausgabe, xar/cpio-Zwischenstand). Die eigentlichen # Original-Downloads landen in LIBRARY_DIR und bleiben dauerhaft. scratch_dir = tempfile.mkdtemp(dir=DOWNLOAD_DIR, prefix="scratch_") try: job.emit(status="running", phase="start", progress=0, msg="Job gestartet") product = job.product cached = get_cached_payload(product) if cached: payload_path = cached job.emit(phase="download", progress=100, msg=f"Bereits lokal in der Bibliothek vorhanden -- Download uebersprungen ({cached}).") else: payload_path = _download_to_library(job, product) if job.job_type == "download": job.emit(status="done", phase="fertig", progress=100, msg="Image liegt jetzt dauerhaft in der lokalen Bibliothek (Offline nutzbar).") return if product["mode"] != "legacy_dmg": job.emit(phase="hinweis", msg="Big Sur+ Pfad ist experimentell (siehe README).") dmg_path = _extract_recovery_dmg(job, payload_path, scratch_dir) else: dmg_path = payload_path raw_path = _dmg_to_raw(job, dmg_path, scratch_dir) _write_raw_to_device(job, raw_path, job.device_path) job.emit(status="done", phase="fertig", progress=100, msg="Stick ist fertig.") except Exception as exc: # noqa: BLE001 log.exception("job %s failed", job.id) job.error = str(exc) job.emit(status="failed", msg=f"FEHLER: {exc}") finally: shutil.rmtree(scratch_dir, ignore_errors=True)