- GUI: dritter Modus-Umschalter, Checkbox-Mehrfachauswahl statt Single-Select - Backend: sgdisk partitioniert den Stick in N Apple_HFS-Partitionen (Typ AF00), jede mit Label = macOS-Titel/Version, jede bekommt ihr eigenes Installer-Image per dd -- Macs EFI-Bootpicker (Alt/Option) zeigt alle Partitionen zur Auswahl - Groessen-Check VOR dem Schreiben: bricht mit klarer Fehlermeldung ab wenn der Stick zu klein ist, statt mittendrin zu scheitern - Dockerfile: parted (partprobe) ergaenzt - End-to-end gegen ein echtes Loop-Device verifiziert: sgdisk legt korrekte Partitionen mit Labels an, dd schreibt in die richtige Partition, Inhalt stimmt exakt ueberein (nicht nur behauptet) - README: neuer Abschnitt 4 (Funktionsweise, Ablauf, ehrliche Grenzen), Multiboot als experimentell markiert (erbt recovery-Pfad-Unsicherheit bei Big Sur+, kein echter Mac-Boot-Test bisher durchgefuehrt)
568 lines
24 KiB
Python
568 lines
24 KiB
Python
"""
|
|
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.
|
|
|
|
Drei Job-Typen:
|
|
|
|
"stick" Download (oder Wiederverwendung aus der lokalen Bibliothek) +
|
|
Schreiben EINER macOS-Version auf einen kompletten 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.
|
|
"multiboot" Mehrere macOS-Versionen (2-8) auf EINEN Stick, jede in ihrer
|
|
eigenen GPT-Partition. Macs EFI-Bootpicker (Alt/Option beim
|
|
Start) scannt alle Partitionen eines angeschlossenen Datentraegers
|
|
nach bootfaehigen Volumes -- deshalb reicht es, den Stick mit
|
|
N Apple_HFS-Partitionen anzulegen und in jede das jeweilige
|
|
vollstaendige Installer-Image zu dd'en. EXPERIMENTELL (siehe
|
|
README): funktioniert zuverlaessig fuer den legacy_dmg-Pfad,
|
|
beim recovery-Pfad (Big Sur+) erbt es dessen Unsicherheiten.
|
|
|
|
Lokale Bibliothek (Persistenz):
|
|
|
|
Jeder erfolgreiche Download landet dauerhaft unter
|
|
/data/downloads/library/<safer-name>/ (Original-Dateiname + meta.json).
|
|
Das liegt im Bind-Mount ./downloads -- also ganz normal im Projektordner
|
|
und damit portabel (Ordner/Platte kopieren reicht). Ein "stick"- oder
|
|
"multiboot"-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"] = {}
|
|
|
|
# Multiboot-Partitionierung: pro Partition oben drauf gerechneter Puffer
|
|
# (Dateisystem-Rundungen etc.), plus grober Abzug fuer GPT-Header/Backup-GPT
|
|
# und Ausrichtungs-Luecken zwischen den Partitionen.
|
|
PARTITION_PADDING_MB = 64
|
|
GPT_OVERHEAD_MB = 16
|
|
MULTIBOOT_MIN_IMAGES = 2
|
|
MULTIBOOT_MAX_IMAGES = 8
|
|
|
|
|
|
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 _partition_label(product: dict) -> str:
|
|
"""Kurzes, GPT-taugliches Label fuer den Bootpicker, z.B. 'macOS Sonoma 14.6'.
|
|
GPT-Partitionsnamen sind auf 36 UTF-16-Zeichen begrenzt."""
|
|
raw = f"{product.get('title', 'macOS')} {product.get('version', '')}".strip()
|
|
label = re.sub(r"[^A-Za-z0-9 ._-]+", "", raw).strip()
|
|
return (label or "macOS")[:36]
|
|
|
|
|
|
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}
|
|
|
|
|
|
def _device_size_bytes(device_path: str) -> int:
|
|
match = next((s for s in list_usb_sticks() if s["path"] == device_path), None)
|
|
if not match:
|
|
raise ValueError(f"Geraet {device_path} nicht mehr in der aktuellen USB-Liste gefunden.")
|
|
return match["size_bytes"]
|
|
|
|
|
|
def _partition_path(device_path: str, index: int) -> str:
|
|
"""Partitions-Devicenode fuer ein Ziel-Device, z.B. /dev/sdb -> /dev/sdb1,
|
|
aber /dev/nvme0n1 -> /dev/nvme0n1p1 (Devices die schon auf eine Ziffer
|
|
enden brauchen ein 'p' vor der Partitionsnummer, sonst waer der Name
|
|
mehrdeutig)."""
|
|
base = os.path.basename(device_path)
|
|
sep = "p" if base and base[-1].isdigit() else ""
|
|
return f"{device_path}{sep}{index}"
|
|
|
|
|
|
class Job:
|
|
def __init__(self, product, device_path: str = None, job_type: str = "stick"):
|
|
self.id = str(uuid.uuid4())
|
|
# "stick"/"download": product ist ein einzelnes Produkt-Dict.
|
|
# "multiboot": product ist eine Liste von 2-8 Produkt-Dicts.
|
|
self.product = product
|
|
self.device_path = device_path
|
|
self.job_type = job_type # "stick" | "download" | "multiboot"
|
|
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 _validate_stick_target(device_path: str, confirm_path: str):
|
|
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.")
|
|
|
|
|
|
def create_job(product, 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
|
|
threading.Thread(target=_run_job, args=(job,), daemon=True).start()
|
|
return job
|
|
|
|
if job_type == "multiboot":
|
|
if not isinstance(product, list) or len(product) < MULTIBOOT_MIN_IMAGES:
|
|
raise ValueError(f"Multiboot braucht mindestens {MULTIBOOT_MIN_IMAGES} macOS-Versionen.")
|
|
if len(product) > MULTIBOOT_MAX_IMAGES:
|
|
raise ValueError(f"Zu viele Versionen fuer einen Multiboot-Stick (max. {MULTIBOOT_MAX_IMAGES}).")
|
|
_validate_stick_target(device_path, confirm_path)
|
|
job = Job(product, device_path, job_type="multiboot")
|
|
JOBS[job.id] = job
|
|
threading.Thread(target=_run_job, args=(job,), daemon=True).start()
|
|
return job
|
|
|
|
if job_type != "stick":
|
|
raise ValueError(f"Unbekannter job_type: {job_type}")
|
|
|
|
_validate_stick_target(device_path, confirm_path)
|
|
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:
|
|
os.makedirs(work_dir, exist_ok=True)
|
|
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 _dd_with_progress(job: Job, raw_path: str, target_path: str, phase: str):
|
|
size = os.path.getsize(raw_path)
|
|
job.emit(phase=phase, progress=0, msg=f"Schreibe {raw_path} -> {target_path} (dd)")
|
|
cmd = ["dd", f"if={raw_path}", f"of={target_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=phase, 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=phase, progress=pct)
|
|
except (ValueError, IndexError, ZeroDivisionError):
|
|
pass
|
|
ret = proc.wait()
|
|
if ret != 0:
|
|
raise RuntimeError(f"dd fehlgeschlagen ({target_path})")
|
|
job.emit(phase=phase, progress=100, msg=f"dd fertig ({target_path}), sync...")
|
|
subprocess.run(["sync"])
|
|
|
|
|
|
def _write_raw_to_device(job: Job, raw_path: str, device_path: str):
|
|
job.emit(phase="schreiben", progress=0, msg=f"Loesche vorhandene Signaturen auf {device_path} (wipefs)")
|
|
_run(job, ["wipefs", "-a", device_path], phase="schreiben")
|
|
_dd_with_progress(job, raw_path, device_path, phase="schreiben")
|
|
|
|
|
|
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 _fetch_and_convert(job: Job, product: dict, work_dir: str, tag: str) -> str:
|
|
"""Gemeinsamer Schritt fuer 'stick' und 'multiboot': Bibliothek pruefen /
|
|
herunterladen, bei Bedarf Big-Sur+-Pfad extrahieren, am Ende immer ein
|
|
rohes dd-faehiges Image zurueckgeben."""
|
|
cached = get_cached_payload(product)
|
|
if cached:
|
|
payload_path = cached
|
|
job.emit(phase="download", progress=100, msg=f"{tag}: bereits lokal in der Bibliothek vorhanden, Download uebersprungen.")
|
|
else:
|
|
job.emit(phase="download", progress=0, msg=f"{tag}: lade herunter...")
|
|
payload_path = _download_to_library(job, product)
|
|
|
|
if product["mode"] != "legacy_dmg":
|
|
job.emit(phase="extrahieren", msg=f"{tag}: Big Sur+ Pfad, experimentell.")
|
|
dmg_path = _extract_recovery_dmg(job, payload_path, os.path.join(work_dir, "extract"))
|
|
else:
|
|
dmg_path = payload_path
|
|
|
|
return _dmg_to_raw(job, dmg_path, os.path.join(work_dir, "raw"))
|
|
|
|
|
|
def _plan_partitions(raw_images: list[dict], device_size_bytes: int):
|
|
"""Rechnet pro Image die noetige Partitionsgroesse (inkl. Puffer) aus und
|
|
wirft eine verstaendliche Fehlermeldung, wenn der Stick zu klein ist --
|
|
BEVOR irgendwas geschrieben wird."""
|
|
total_mb = GPT_OVERHEAD_MB
|
|
for img in raw_images:
|
|
img["partition_mb"] = (img["size_bytes"] // (1024 * 1024)) + 1 + PARTITION_PADDING_MB
|
|
total_mb += img["partition_mb"]
|
|
needed_bytes = total_mb * 1024 * 1024
|
|
if needed_bytes > device_size_bytes:
|
|
raise ValueError(
|
|
f"Stick zu klein: benoetigt ca. {needed_bytes / 1e9:.1f} GB fuer {len(raw_images)} "
|
|
f"Versionen (inkl. Partitions-Puffer), Stick hat aber nur {device_size_bytes / 1e9:.1f} GB."
|
|
)
|
|
|
|
|
|
def _partition_device_multiboot(job: Job, device_path: str, raw_images: list[dict]):
|
|
job.emit(phase="partitionieren", progress=0, msg=f"Loesche {device_path} komplett (wipefs + sgdisk zap)")
|
|
_run(job, ["wipefs", "-a", device_path], phase="partitionieren")
|
|
_run(job, ["sgdisk", "--zap-all", device_path], phase="partitionieren")
|
|
|
|
cmd = ["sgdisk"]
|
|
for idx, img in enumerate(raw_images, start=1):
|
|
cmd += [
|
|
"-n", f"{idx}:0:+{img['partition_mb']}M",
|
|
"-t", f"{idx}:AF00", # AF00 = Apple HFS/HFS+ Partitionstyp
|
|
"-c", f"{idx}:{img['label']}",
|
|
]
|
|
cmd.append(device_path)
|
|
job.emit(phase="partitionieren", progress=50, msg=f"Lege {len(raw_images)} Partitionen an (sgdisk)")
|
|
_run(job, cmd, phase="partitionieren")
|
|
|
|
# Kernel/udev Zeit geben, die neuen Partitions-Devicenodes anzulegen,
|
|
# bevor wir versuchen sie zu beschreiben.
|
|
subprocess.run(["partprobe", device_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
time.sleep(2)
|
|
job.emit(phase="partitionieren", progress=100, msg="Partitionstabelle steht.")
|
|
|
|
|
|
def _run_stick_job(job: Job):
|
|
"""job_type == 'stick': eine einzelne macOS-Version auf einen ganzen Stick."""
|
|
scratch_dir = tempfile.mkdtemp(dir=DOWNLOAD_DIR, prefix="scratch_")
|
|
try:
|
|
job.emit(status="running", phase="start", progress=0, msg="Job gestartet")
|
|
raw_path = _fetch_and_convert(job, job.product, scratch_dir, tag=job.product.get("title", "macOS"))
|
|
_write_raw_to_device(job, raw_path, job.device_path)
|
|
job.emit(status="done", phase="fertig", progress=100, msg="Stick ist fertig.")
|
|
finally:
|
|
shutil.rmtree(scratch_dir, ignore_errors=True)
|
|
|
|
|
|
def _run_download_job(job: Job):
|
|
"""job_type == 'download': nur in die lokale Bibliothek laden."""
|
|
cached = get_cached_payload(job.product)
|
|
if cached:
|
|
job.emit(status="running", phase="download", progress=100, msg=f"Bereits lokal vorhanden ({cached}).")
|
|
else:
|
|
job.emit(status="running", phase="start", progress=0, msg="Job gestartet")
|
|
_download_to_library(job, job.product)
|
|
job.emit(status="done", phase="fertig", progress=100, msg="Image liegt jetzt dauerhaft in der lokalen Bibliothek (Offline nutzbar).")
|
|
|
|
|
|
def _run_multiboot_job(job: Job):
|
|
"""job_type == 'multiboot': mehrere macOS-Versionen, je eine eigene
|
|
GPT-Partition auf demselben Stick. Erst ALLE Images vollstaendig
|
|
herunterladen/konvertieren, dann Groesse pruefen, dann EINMAL
|
|
partitionieren, dann nacheinander in die jeweilige Partition schreiben."""
|
|
products = job.product # Liste
|
|
scratch_dir = tempfile.mkdtemp(dir=DOWNLOAD_DIR, prefix="scratch_mb_")
|
|
try:
|
|
job.emit(status="running", phase="start", progress=0, msg=f"Multiboot-Job gestartet ({len(products)} Versionen)")
|
|
|
|
raw_images = []
|
|
for idx, product in enumerate(products, start=1):
|
|
tag = f"[{idx}/{len(products)}] {product.get('title', 'macOS')} {product.get('version', '')}"
|
|
work_dir = os.path.join(scratch_dir, f"img{idx}")
|
|
raw_path = _fetch_and_convert(job, product, work_dir, tag)
|
|
raw_images.append(
|
|
{
|
|
"label": _partition_label(product),
|
|
"path": raw_path,
|
|
"size_bytes": os.path.getsize(raw_path),
|
|
"title": f"{product.get('title', 'macOS')} {product.get('version', '')}".strip(),
|
|
}
|
|
)
|
|
job.emit(phase="konvertieren", progress=100, msg=f"{tag}: fertig konvertiert.")
|
|
|
|
job.emit(phase="planen", progress=0, msg="Pruefe ob der Stick fuer alle gewaehlten Versionen gross genug ist...")
|
|
device_size = _device_size_bytes(job.device_path)
|
|
_plan_partitions(raw_images, device_size)
|
|
|
|
_partition_device_multiboot(job, job.device_path, raw_images)
|
|
|
|
for idx, img in enumerate(raw_images, start=1):
|
|
part_path = _partition_path(job.device_path, idx)
|
|
tag = f"[{idx}/{len(raw_images)}] {img['title']}"
|
|
job.emit(phase="schreiben", progress=0, msg=f"{tag}: schreibe nach {part_path}")
|
|
_dd_with_progress(job, img["path"], part_path, phase="schreiben")
|
|
|
|
job.emit(
|
|
status="done", phase="fertig", progress=100,
|
|
msg=f"Multiboot-Stick fertig -- {len(raw_images)} Versionen, per Alt/Option-Taste beim Boot auswaehlbar.",
|
|
)
|
|
finally:
|
|
shutil.rmtree(scratch_dir, ignore_errors=True)
|
|
|
|
|
|
def _run_job(job: Job):
|
|
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
|
os.makedirs(LIBRARY_DIR, exist_ok=True)
|
|
try:
|
|
if job.job_type == "multiboot":
|
|
_run_multiboot_job(job)
|
|
elif job.job_type == "download":
|
|
_run_download_job(job)
|
|
else:
|
|
_run_stick_job(job)
|
|
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}")
|