feat: Multiboot-Stick-Modus (mehrere macOS-Versionen auf einem USB-Stick)

- 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)
This commit is contained in:
ARIA
2026-07-18 11:34:59 +02:00
parent 5c2cdd7543
commit 6f8538428c
7 changed files with 420 additions and 96 deletions
+1
View File
@@ -16,6 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \ python3 python3-pip \
util-linux \ util-linux \
gdisk \ gdisk \
parted \
hfsprogs \ hfsprogs \
dmg2img \ dmg2img \
cpio \ cpio \
+57 -3
View File
@@ -53,9 +53,11 @@ geaendert hat — dann muss `backend/installer.py` (`_extract_recovery_dmg`)
angepasst werden. angepasst werden.
### 3. Offline-Bibliothek / "nur herunterladen"-Modus ### 3. Offline-Bibliothek / "nur herunterladen"-Modus
Oben in der GUI laesst sich per Umschalter zwischen zwei Modi waehlen: Oben in der GUI laesst sich per Umschalter zwischen drei Modi waehlen:
- **"Auf USB-Stick schreiben"** (Standard) — wie bisher. - **"Auf USB-Stick schreiben"** (Standard) — wie bisher, eine Version, ein
kompletter Stick.
- **"Multiboot-Stick (mehrere Versionen)"** — siehe Abschnitt 4 unten.
- **"Nur herunterladen (Offline-Bibliothek)"** — laedt ausschliesslich das - **"Nur herunterladen (Offline-Bibliothek)"** — laedt ausschliesslich das
Original-Image (`BaseSystem.dmg`/`InstallESD.dmg` bzw. `InstallAssistant.pkg`, Original-Image (`BaseSystem.dmg`/`InstallESD.dmg` bzw. `InstallAssistant.pkg`,
je nach Version) und legt es **dauerhaft** unter `./downloads/library/<name>/` je nach Version) und legt es **dauerhaft** unter `./downloads/library/<name>/`
@@ -80,7 +82,53 @@ Die GUI zeigt oben die Gesamtgroesse + Anzahl bereits heruntergeladener
Installer an, und markiert bereits vorhandene Versionen in der Auswahlliste Installer an, und markiert bereits vorhandene Versionen in der Auswahlliste
mit "bereits offline vorhanden". mit "bereits offline vorhanden".
### 4. USB-Geraete-Erkennung ### 4. Multiboot-Stick (mehrere macOS-Versionen auf einem Stick)
Im Modus **"Multiboot-Stick"** waehlst du in Schritt 1 statt einer Version
per Checkbox **2 bis 8** Versionen gleichzeitig aus. Das Tool baut daraus
einen einzigen Stick mit **einer eigenen GPT-Partition pro Version**.
**Warum das funktioniert:** Macs EFI-Bootmanager (beim Start `Alt`/`Option`
gedrueckt halten) scannt beim Boot-Picker **alle** Partitionen eines
angeschlossenen Datentraegers nach bootfaehigen Volumes, nicht nur die
erste. Legt man also mehrere `Apple_HFS`-Partitionen an und schreibt in
jede das vollstaendige, fuer sich bootfaehige Installer-Image, zeigt der
Boot-Picker automatisch alle als eigene Wahlmoeglichkeiten an. Das ist eine
in der Mac-Community seit Jahren dokumentierte Technik, kein Neuland dieses
Tools.
**Ablauf im Backend (`backend/installer.py`, `_run_multiboot_job`):**
1. Alle gewaehlten Images werden (aus der Bibliothek oder frisch) geladen
und wie im Einzel-Stick-Modus konvertiert -- ERST wenn alle fertig
konvertiert sind, geht's weiter (kein Halb-Zustand auf dem Stick).
2. Anhand der tatsaechlichen Image-Groessen + Puffer (`PARTITION_PADDING_MB`)
wird geprueft, ob der ausgewaehlte Stick ueberhaupt gross genug ist --
**bevor** irgendetwas geschrieben wird. Ist er zu klein, bricht der Job
mit einer klaren Fehlermeldung (benoetigt vs. vorhanden in GB) ab, ohne
den Stick anzuruehren.
3. `wipefs -a` + `sgdisk --zap-all` loeschen den Stick komplett, dann legt
`sgdisk` in einem Rutsch N Partitionen an (Typ `AF00` = Apple HFS/HFS+,
mit Label = Titel/Version der jeweiligen macOS-Version, sichtbar im
Boot-Picker).
4. Jedes Image wird per `dd` in seine eigene Partition geschrieben
(`/dev/sdb1`, `/dev/sdb2`, ...), nicht auf das gesamte Geraet.
**Ehrliche Einschraenkung -- als experimentell markiert:**
- Fuer den `legacy_dmg`-Pfad (bis Catalina) ist die Technik gut etabliert
und robust.
- Fuer den `recovery`-Pfad (Big Sur+) erbt Multiboot dieselbe Unsicherheit
wie der normale Einzel-Stick-Modus (siehe Abschnitt 2) -- kommt on top zur
Multiboot-Partitionierung selbst dazu. Mischungen aus alten + neuen
Versionen auf einem Stick sind technisch kein Problem, aber jede
Big-Sur+-Version im Mix bleibt der fragilste Teil.
- Groessenbedarf: 3 Versionen macOS koennen leicht 30-50GB+ zusammen
ergeben, ein Stick mit 64GB+ ist fuer sinnvolle Kombinationen realistisch.
- Es gab noch **keinen echten Boot-Test auf echter Mac-Hardware** durch
dieses Projekt selbst -- die Partitionierungs-/Schreib-Logik ist gegen
einen echten Docker-Host verifiziert (Build + Job-Ablauf), das
tatsaechliche "zeigt der Boot-Picker alle Partitionen an und bootet jede"
muss beim ersten echten Einsatz am Mac verifiziert werden.
### 5. USB-Geraete-Erkennung
Der Container laeuft mit `privileged: true` und mountet `/dev` sowie Der Container laeuft mit `privileged: true` und mountet `/dev` sowie
`/run/udev` vom Host durch &mdash; noetig, um Sticks direkt zu partitionieren `/run/udev` vom Host durch &mdash; noetig, um Sticks direkt zu partitionieren
und zu beschreiben. Das ist bewusst so gebaut, bedeutet aber: der Container und zu beschreiben. Das ist bewusst so gebaut, bedeutet aber: der Container
@@ -178,3 +226,9 @@ jetzt tatsaechlich Big Sur bis Tahoe zurueck).
kein Rewrite). kein Rewrite).
3. Kein Windows/macOS-Docker-Host-Support fuer das Device-Passthrough &mdash; 3. Kein Windows/macOS-Docker-Host-Support fuer das Device-Passthrough &mdash;
nur natives Linux. nur natives Linux.
4. Der Multiboot-Modus (Abschnitt 4) wurde bislang nur end-to-end bis zur
fertig geschriebenen Partitionstabelle/den Images verifiziert, NICHT
durch einen echten Boot-Test auf Mac-Hardware. Die zugrundeliegende
Multi-Partition-Bootpicker-Technik ist etabliertes Community-Wissen,
aber "funktioniert bei diesem Tool nachweislich" != "wurde von uns am
Mac gebootet".
+17 -3
View File
@@ -86,16 +86,30 @@ def api_library():
@app.post("/api/jobs") @app.post("/api/jobs")
def api_create_job(): def api_create_job():
body = request.get_json(force=True) body = request.get_json(force=True)
product_id = body.get("product_id")
job_type = body.get("job_type", "stick") job_type = body.get("job_type", "stick")
device_path = body.get("device_path") device_path = body.get("device_path")
confirm_path = body.get("confirm_device_path") confirm_path = body.get("confirm_device_path")
if job_type not in ("stick", "download"): if job_type not in ("stick", "download", "multiboot"):
return jsonify({"error": "Unbekannter job_type."}), 400 return jsonify({"error": "Unbekannter job_type."}), 400
with _catalog_lock: with _catalog_lock:
product = next((p for p in _catalog_cache["items"] if p["id"] == product_id), None) catalog_items = list(_catalog_cache["items"])
if job_type == "multiboot":
product_ids = body.get("product_ids") or []
if not isinstance(product_ids, list) or len(product_ids) < 2:
return jsonify({"error": "Multiboot braucht eine Liste mit mindestens 2 macOS-Versionen (product_ids)."}), 400
products = []
for pid in product_ids:
match = next((p for p in catalog_items if p["id"] == pid), None)
if not match:
return jsonify({"error": f"Unbekannte macOS-Version '{pid}' -- Katalog neu laden."}), 400
products.append(match)
product = products
else:
product_id = body.get("product_id")
product = next((p for p in catalog_items if p["id"] == product_id), None)
if not product: if not product:
return jsonify({"error": "Unbekannte macOS-Version -- Katalog neu laden."}), 400 return jsonify({"error": "Unbekannte macOS-Version -- Katalog neu laden."}), 400
+216 -50
View File
@@ -16,24 +16,33 @@ Zwei Pfade, unterschiedlich robust:
den internen Aufbau von InstallAssistant.pkg mehrfach leicht den internen Aufbau von InstallAssistant.pkg mehrfach leicht
geaendert, das kann brechen. Klar als "experimentell" markiert. geaendert, das kann brechen. Klar als "experimentell" markiert.
Zwei Job-Typen: Drei Job-Typen:
"stick" Download (oder Wiederverwendung aus der lokalen Bibliothek) + "stick" Download (oder Wiederverwendung aus der lokalen Bibliothek) +
Schreiben auf einen USB-Stick. Schreiben EINER macOS-Version auf einen kompletten USB-Stick.
"download" Nur herunterladen, dauerhaft in der lokalen Bibliothek ablegen. "download" Nur herunterladen, dauerhaft in der lokalen Bibliothek ablegen.
Kein Geraet noetig. Gedacht um sich vorab eine Offline-Sammlung Kein Geraet noetig. Gedacht um sich vorab eine Offline-Sammlung
aller macOS-Installer aufzubauen (z.B. auf eine externe Platte), aller macOS-Installer aufzubauen (z.B. auf eine externe Platte),
die man auch ohne Internetzugang zum Stick-Bauen nutzen kann. 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): Lokale Bibliothek (Persistenz):
Jeder erfolgreiche Download landet dauerhaft unter Jeder erfolgreiche Download landet dauerhaft unter
/data/downloads/library/<safer-name>/ (Original-Dateiname + meta.json). /data/downloads/library/<safer-name>/ (Original-Dateiname + meta.json).
Das liegt im Bind-Mount ./downloads -- also ganz normal im Projektordner Das liegt im Bind-Mount ./downloads -- also ganz normal im Projektordner
und damit portabel (Ordner/Platte kopieren reicht). Ein "stick"-Job prueft und damit portabel (Ordner/Platte kopieren reicht). Ein "stick"- oder
IMMER zuerst, ob die Datei schon lokal vorhanden ist, und ueberspringt den "multiboot"-Job prueft IMMER zuerst, ob die Datei schon lokal vorhanden
Download wenn ja -- so wird aus jedem einzelnen Stick-Bau ganz nebenbei ist, und ueberspringt den Download wenn ja -- so wird aus jedem einzelnen
eine wachsende Offline-Bibliothek, ganz ohne Zusatzaufwand. Stick-Bau ganz nebenbei eine wachsende Offline-Bibliothek, ganz ohne
Zusatzaufwand.
Sicherheits-Leitplanken: Sicherheits-Leitplanken:
- Ziel-Device wird gegen die Root-Disk des Hosts geprueft (Refuse). - Ziel-Device wird gegen die Root-Disk des Hosts geprueft (Refuse).
@@ -62,6 +71,14 @@ DOWNLOAD_DIR = "/data/downloads"
LIBRARY_DIR = os.path.join(DOWNLOAD_DIR, "library") LIBRARY_DIR = os.path.join(DOWNLOAD_DIR, "library")
JOBS: dict[str, "Job"] = {} 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): class JobAborted(Exception):
pass pass
@@ -75,6 +92,14 @@ def _safe_name(product: dict) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "_", raw).strip("_") 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: def _payload_url(product: dict) -> str:
if product["mode"] == "legacy_dmg": if product["mode"] == "legacy_dmg":
return product["base_system_url"] return product["base_system_url"]
@@ -131,12 +156,31 @@ def list_library():
return {"items": items, "total_bytes": total} 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: class Job:
def __init__(self, product: dict, device_path: str = None, job_type: str = "stick"): def __init__(self, product, device_path: str = None, job_type: str = "stick"):
self.id = str(uuid.uuid4()) 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.product = product
self.device_path = device_path self.device_path = device_path
self.job_type = job_type # "stick" | "download" self.job_type = job_type # "stick" | "download" | "multiboot"
self.status = "queued" # queued -> running -> done | failed | aborted self.status = "queued" # queued -> running -> done | failed | aborted
self.phase = "warten" self.phase = "warten"
self.progress = 0 # 0-100 im aktuellen Phase self.progress = 0 # 0-100 im aktuellen Phase
@@ -187,20 +231,9 @@ class Job:
} }
def create_job(product: dict, device_path: str = None, confirm_path: str = None, job_type: str = "stick") -> Job: def _validate_stick_target(device_path: str, confirm_path: str):
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: if confirm_path != device_path:
raise ValueError("Bestaetigungs-Pfad stimmt nicht mit dem Ziel-Geraet ueberein.") raise ValueError("Bestaetigungs-Pfad stimmt nicht mit dem Ziel-Geraet ueberein.")
sticks = list_usb_sticks() sticks = list_usb_sticks()
match = next((s for s in sticks if s["path"] == device_path), None) match = next((s for s in sticks if s["path"] == device_path), None)
if not match: if not match:
@@ -212,6 +245,29 @@ def create_job(product: dict, device_path: str = None, confirm_path: str = None,
if not (match["transport"] == "usb"): if not (match["transport"] == "usb"):
raise ValueError("Geraet ist nicht als USB-Transport erkannt -- Sicherheitsstop.") 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") job = Job(product, device_path, job_type="stick")
JOBS[job.id] = job JOBS[job.id] = job
t = threading.Thread(target=_run_job, args=(job,), daemon=True) t = threading.Thread(target=_run_job, args=(job,), daemon=True)
@@ -282,37 +338,43 @@ def _download_to_library(job: Job, product: dict) -> str:
def _dmg_to_raw(job: Job, dmg_path: str, work_dir: str) -> str: 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") raw_path = os.path.join(work_dir, os.path.basename(dmg_path) + ".img")
job.emit(phase="konvertieren", progress=0, msg="dmg2img: DMG -> rohes Image") job.emit(phase="konvertieren", progress=0, msg="dmg2img: DMG -> rohes Image")
_run(job, ["dmg2img", "-i", dmg_path, "-o", raw_path], phase="konvertieren") _run(job, ["dmg2img", "-i", dmg_path, "-o", raw_path], phase="konvertieren")
return raw_path return raw_path
def _write_raw_to_device(job: Job, raw_path: str, device_path: str): def _dd_with_progress(job: Job, raw_path: str, target_path: str, phase: 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) size = os.path.getsize(raw_path)
cmd = ["dd", f"if={raw_path}", f"of={device_path}", "bs=4M", "status=progress", "conv=fsync"] 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) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
for line in proc.stdout: for line in proc.stdout:
line = line.strip() line = line.strip()
if not line: if not line:
continue continue
job.emit(phase="schreiben", msg=line) job.emit(phase=phase, msg=line)
# dd status=progress schreibt Zeilen wie "1234567890 bytes (1.2 GB, 1.1 GiB) copied, ..." # dd status=progress schreibt Zeilen wie "1234567890 bytes (1.2 GB, 1.1 GiB) copied, ..."
try: try:
bytes_done = int(line.split(" ")[0]) bytes_done = int(line.split(" ")[0])
pct = min(100, int(bytes_done * 100 / size)) pct = min(100, int(bytes_done * 100 / size))
job.emit(phase="schreiben", progress=pct) job.emit(phase=phase, progress=pct)
except (ValueError, IndexError, ZeroDivisionError): except (ValueError, IndexError, ZeroDivisionError):
pass pass
ret = proc.wait() ret = proc.wait()
if ret != 0: if ret != 0:
raise RuntimeError("dd fehlgeschlagen") raise RuntimeError(f"dd fehlgeschlagen ({target_path})")
job.emit(phase="schreiben", progress=100, msg="dd fertig, sync...") job.emit(phase=phase, progress=100, msg=f"dd fertig ({target_path}), sync...")
subprocess.run(["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: def _extract_recovery_dmg(job: Job, pkg_path: str, work_dir: str) -> str:
"""Experimentell: InstallAssistant.pkg -> xar -> pbzx -> cpio, sucht SharedSupport/BaseSystem.dmg.""" """Experimentell: InstallAssistant.pkg -> xar -> pbzx -> cpio, sucht SharedSupport/BaseSystem.dmg."""
job.emit(phase="extrahieren", progress=0, msg="xar: InstallAssistant.pkg entpacken (experimentell)") job.emit(phase="extrahieren", progress=0, msg="xar: InstallAssistant.pkg entpacken (experimentell)")
@@ -361,41 +423,145 @@ def _extract_recovery_dmg(job: Job, pkg_path: str, work_dir: str) -> str:
return dmg_candidates[0] return dmg_candidates[0]
def _run_job(job: Job): def _fetch_and_convert(job: Job, product: dict, work_dir: str, tag: str) -> str:
os.makedirs(DOWNLOAD_DIR, exist_ok=True) """Gemeinsamer Schritt fuer 'stick' und 'multiboot': Bibliothek pruefen /
os.makedirs(LIBRARY_DIR, exist_ok=True) herunterladen, bei Bedarf Big-Sur+-Pfad extrahieren, am Ende immer ein
# Scratch-Verzeichnis NUR fuer temporaere Konvertierungs-/Extraktionsdaten rohes dd-faehiges Image zurueckgeben."""
# (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) cached = get_cached_payload(product)
if cached: if cached:
payload_path = cached payload_path = cached
job.emit(phase="download", progress=100, msg=f"Bereits lokal in der Bibliothek vorhanden -- Download uebersprungen ({cached}).") job.emit(phase="download", progress=100, msg=f"{tag}: bereits lokal in der Bibliothek vorhanden, Download uebersprungen.")
else: else:
job.emit(phase="download", progress=0, msg=f"{tag}: lade herunter...")
payload_path = _download_to_library(job, product) 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": if product["mode"] != "legacy_dmg":
job.emit(phase="hinweis", msg="Big Sur+ Pfad ist experimentell (siehe README).") job.emit(phase="extrahieren", msg=f"{tag}: Big Sur+ Pfad, experimentell.")
dmg_path = _extract_recovery_dmg(job, payload_path, scratch_dir) dmg_path = _extract_recovery_dmg(job, payload_path, os.path.join(work_dir, "extract"))
else: else:
dmg_path = payload_path dmg_path = payload_path
raw_path = _dmg_to_raw(job, dmg_path, scratch_dir) return _dmg_to_raw(job, dmg_path, os.path.join(work_dir, "raw"))
_write_raw_to_device(job, raw_path, job.device_path)
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.") 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 except Exception as exc: # noqa: BLE001
log.exception("job %s failed", job.id) log.exception("job %s failed", job.id)
job.error = str(exc) job.error = str(exc)
job.emit(status="failed", msg=f"FEHLER: {exc}") job.emit(status="failed", msg=f"FEHLER: {exc}")
finally:
shutil.rmtree(scratch_dir, ignore_errors=True)
+94 -29
View File
@@ -1,10 +1,14 @@
let selectedProduct = null; let selectedProduct = null; // "stick" | "download"
let selectedProductIds = new Set(); // "multiboot"
let selectedDevice = null; let selectedDevice = null;
let catalogItems = []; let catalogItems = [];
let libraryIds = new Set(); // Produkt-IDs die schon lokal (dauerhaft) vorhanden sind let libraryIds = new Set(); // Produkt-IDs die schon lokal (dauerhaft) vorhanden sind
let mode = "stick"; // "stick" | "download" let mode = "stick"; // "stick" | "download" | "multiboot"
const versionSelect = document.getElementById("versionSelect"); const MULTIBOOT_MAX = 8;
const step1Title = document.getElementById("step1Title");
const versionList = document.getElementById("versionList");
const versionDetail = document.getElementById("versionDetail"); const versionDetail = document.getElementById("versionDetail");
const catalogStatus = document.getElementById("catalogStatus"); const catalogStatus = document.getElementById("catalogStatus");
const deviceSection = document.getElementById("deviceSection"); const deviceSection = document.getElementById("deviceSection");
@@ -12,6 +16,7 @@ const deviceList = document.getElementById("deviceList");
const confirmPath = document.getElementById("confirmPath"); const confirmPath = document.getElementById("confirmPath");
const stickConfirm = document.getElementById("stickConfirm"); const stickConfirm = document.getElementById("stickConfirm");
const downloadHint = document.getElementById("downloadHint"); const downloadHint = document.getElementById("downloadHint");
const multibootHint = document.getElementById("multibootHint");
const step3Title = document.getElementById("step3Title"); const step3Title = document.getElementById("step3Title");
const startBtn = document.getElementById("startBtn"); const startBtn = document.getElementById("startBtn");
const progressCard = document.getElementById("progressCard"); const progressCard = document.getElementById("progressCard");
@@ -21,6 +26,7 @@ const logEl = document.getElementById("log");
const libraryStatus = document.getElementById("libraryStatus"); const libraryStatus = document.getElementById("libraryStatus");
const modeStickBtn = document.getElementById("modeStick"); const modeStickBtn = document.getElementById("modeStick");
const modeDownloadBtn = document.getElementById("modeDownload"); const modeDownloadBtn = document.getElementById("modeDownload");
const modeMultibootBtn = document.getElementById("modeMultiboot");
function fmtSize(bytes) { function fmtSize(bytes) {
if (!bytes) return "?"; if (!bytes) return "?";
@@ -32,15 +38,24 @@ function setMode(next) {
mode = next; mode = next;
modeStickBtn.classList.toggle("active", mode === "stick"); modeStickBtn.classList.toggle("active", mode === "stick");
modeDownloadBtn.classList.toggle("active", mode === "download"); modeDownloadBtn.classList.toggle("active", mode === "download");
deviceSection.style.display = mode === "stick" ? "" : "none"; modeMultibootBtn.classList.toggle("active", mode === "multiboot");
stickConfirm.style.display = mode === "stick" ? "" : "none";
const needsDevice = mode === "stick" || mode === "multiboot";
deviceSection.style.display = needsDevice ? "" : "none";
stickConfirm.style.display = needsDevice ? "" : "none";
downloadHint.style.display = mode === "download" ? "" : "none"; downloadHint.style.display = mode === "download" ? "" : "none";
step3Title.textContent = mode === "stick" ? "3. Bestaetigen & Schreiben" : "3. Herunterladen"; multibootHint.style.display = mode === "multiboot" ? "" : "none";
startBtn.textContent = mode === "stick" ? "Stick erstellen" : "Image herunterladen";
step1Title.textContent = mode === "multiboot" ? "1. macOS-Versionen (2-8 auswaehlen)" : "1. macOS-Version";
step3Title.textContent = mode === "download" ? "3. Herunterladen" : "3. Bestaetigen & Schreiben";
startBtn.textContent = mode === "download" ? "Image herunterladen" : (mode === "multiboot" ? "Multiboot-Stick erstellen" : "Stick erstellen");
renderVersionOptions();
updateStartEnabled(); updateStartEnabled();
} }
modeStickBtn.addEventListener("click", () => setMode("stick")); modeStickBtn.addEventListener("click", () => setMode("stick"));
modeDownloadBtn.addEventListener("click", () => setMode("download")); modeDownloadBtn.addEventListener("click", () => setMode("download"));
modeMultibootBtn.addEventListener("click", () => setMode("multiboot"));
async function loadLibrary() { async function loadLibrary() {
const res = await fetch("/api/library"); const res = await fetch("/api/library");
@@ -54,16 +69,68 @@ async function loadLibrary() {
renderVersionOptions(); renderVersionOptions();
} }
function updateMultibootSummary() {
const items = catalogItems.filter((i) => selectedProductIds.has(i.id));
const totalBytes = items.reduce((sum, i) => sum + (i.size_bytes || 0), 0);
versionDetail.textContent = items.length
? `${items.length} Versionen ausgewaehlt, Original-Images zusammen ca. ${fmtSize(totalBytes)} -- Stick muss inkl. Partitions-Puffer entsprechend groesser sein.`
: `Waehle 2-${MULTIBOOT_MAX} macOS-Versionen fuer den Multiboot-Stick aus.`;
}
function renderVersionOptions() { function renderVersionOptions() {
versionSelect.innerHTML = ""; versionList.innerHTML = "";
for (const item of catalogItems) { for (const item of catalogItems) {
const opt = document.createElement("option"); const row = document.createElement("label");
opt.value = item.id; row.className = "version-row";
const input = document.createElement("input");
const modeTag = item.mode === "recovery" ? " [experimentell]" : ""; const modeTag = item.mode === "recovery" ? " [experimentell]" : "";
const offlineTag = libraryIds.has(item.id) ? " -- bereits offline vorhanden" : ""; const offlineTag = libraryIds.has(item.id) ? " -- bereits offline vorhanden" : "";
opt.textContent = `${item.title} ${item.version} - ${fmtSize(item.size_bytes)}${modeTag}${offlineTag}`; const labelText = `${item.title} ${item.version} - ${fmtSize(item.size_bytes)}${modeTag}${offlineTag}`;
versionSelect.appendChild(opt);
if (mode === "multiboot") {
input.type = "checkbox";
input.checked = selectedProductIds.has(item.id);
input.addEventListener("change", () => {
if (input.checked) {
if (selectedProductIds.size >= MULTIBOOT_MAX) {
input.checked = false;
return;
} }
selectedProductIds.add(item.id);
} else {
selectedProductIds.delete(item.id);
}
row.classList.toggle("selected", input.checked);
updateMultibootSummary();
updateStartEnabled();
});
row.classList.toggle("selected", input.checked);
} else {
input.type = "radio";
input.name = "versionPick";
input.checked = !!selectedProduct && selectedProduct.id === item.id;
input.addEventListener("change", () => {
selectedProduct = item;
const modeText = item.mode === "recovery"
? "Big Sur+ Pfad: experimentell, Extraktion kann fehlschlagen."
: "Klassischer dd-Pfad: robust und gut getestet.";
const offlineText = libraryIds.has(item.id) ? " | bereits offline vorhanden, kein erneuter Download noetig." : "";
versionDetail.textContent = `${item.title} ${item.version} | ${fmtSize(item.size_bytes)} | ${modeText}${offlineText}`;
document.querySelectorAll(".version-row").forEach((r) => r.classList.remove("selected"));
row.classList.add("selected");
updateStartEnabled();
});
row.classList.toggle("selected", input.checked);
}
const span = document.createElement("span");
span.textContent = labelText;
row.appendChild(input);
row.appendChild(span);
versionList.appendChild(row);
}
if (mode === "multiboot") updateMultibootSummary();
} }
async function loadVersions() { async function loadVersions() {
@@ -84,18 +151,6 @@ async function loadVersions() {
renderVersionOptions(); renderVersionOptions();
} }
versionSelect.addEventListener("change", () => {
selectedProduct = catalogItems.find((i) => i.id === versionSelect.value) || null;
if (selectedProduct) {
const modeText = selectedProduct.mode === "recovery"
? "Big Sur+ Pfad: experimentell, Extraktion kann fehlschlagen."
: "Klassischer dd-Pfad: robust und gut getestet.";
const offlineText = libraryIds.has(selectedProduct.id) ? " | bereits offline vorhanden, kein erneuter Download noetig." : "";
versionDetail.textContent = `${selectedProduct.title} ${selectedProduct.version} | ${fmtSize(selectedProduct.size_bytes)} | ${modeText}${offlineText}`;
}
updateStartEnabled();
});
async function loadDevices() { async function loadDevices() {
deviceList.innerHTML = "<div class='detail'>Scanne USB-Geraete...</div>"; deviceList.innerHTML = "<div class='detail'>Scanne USB-Geraete...</div>";
const res = await fetch("/api/devices"); const res = await fetch("/api/devices");
@@ -126,9 +181,14 @@ async function loadDevices() {
} }
function updateStartEnabled() { function updateStartEnabled() {
const ok = mode === "download" let ok;
? !!selectedProduct if (mode === "download") {
: selectedProduct && selectedDevice && confirmPath.value.trim() === selectedDevice.path; ok = !!selectedProduct;
} else if (mode === "multiboot") {
ok = selectedProductIds.size >= 2 && !!selectedDevice && confirmPath.value.trim() === selectedDevice.path;
} else {
ok = !!selectedProduct && !!selectedDevice && confirmPath.value.trim() === selectedDevice.path;
}
startBtn.disabled = !ok; startBtn.disabled = !ok;
} }
confirmPath.addEventListener("input", updateStartEnabled); confirmPath.addEventListener("input", updateStartEnabled);
@@ -143,8 +203,13 @@ startBtn.addEventListener("click", async () => {
startBtn.disabled = true; startBtn.disabled = true;
progressCard.style.display = "block"; progressCard.style.display = "block";
logEl.textContent = ""; logEl.textContent = "";
const payload = { product_id: selectedProduct.id, job_type: mode }; const payload = { job_type: mode };
if (mode === "stick") { if (mode === "multiboot") {
payload.product_ids = Array.from(selectedProductIds);
} else {
payload.product_id = selectedProduct.id;
}
if (mode === "stick" || mode === "multiboot") {
payload.device_path = selectedDevice.path; payload.device_path = selectedDevice.path;
payload.confirm_device_path = confirmPath.value.trim(); payload.confirm_device_path = confirmPath.value.trim();
} }
+4 -2
View File
@@ -19,19 +19,21 @@
</div> </div>
<div class="mode-toggle"> <div class="mode-toggle">
<button id="modeStick" class="mode-btn active" type="button">Auf USB-Stick schreiben</button> <button id="modeStick" class="mode-btn active" type="button">Auf USB-Stick schreiben</button>
<button id="modeMultiboot" class="mode-btn" type="button">Multiboot-Stick (mehrere Versionen)</button>
<button id="modeDownload" class="mode-btn" type="button">Nur herunterladen (Offline-Bibliothek)</button> <button id="modeDownload" class="mode-btn" type="button">Nur herunterladen (Offline-Bibliothek)</button>
</div> </div>
<p class="detail">Im Download-Modus wird das Original-Image dauerhaft unter <code>./downloads/library/</code> im Projektordner abgelegt &mdash; ideal um sich vorab eine komplette Offline-Sammlung aller macOS-Versionen aufzubauen (z.B. auf eine externe Platte), die auch ganz ohne Internet zum Stick-Bauen reicht. Ein Stick-Bau nutzt automatisch bereits heruntergeladene Images wieder, statt sie erneut zu laden.</p> <p class="detail">Im Download-Modus wird das Original-Image dauerhaft unter <code>./downloads/library/</code> im Projektordner abgelegt &mdash; ideal um sich vorab eine komplette Offline-Sammlung aller macOS-Versionen aufzubauen (z.B. auf eine externe Platte), die auch ganz ohne Internet zum Stick-Bauen reicht. Ein Stick-Bau nutzt automatisch bereits heruntergeladene Images wieder, statt sie erneut zu laden.</p>
<p id="multibootHint" class="detail" style="display:none">Legt 2-8 gewaehlte macOS-Versionen als eigene Partitionen auf EINEN Stick &mdash; Macs Bootpicker (Alt/Option-Taste beim Start) zeigt dann alle zur Auswahl. Braucht entsprechend mehr Platz auf dem Stick (Summe aller Image-Groessen + Puffer). <strong>Experimentell</strong>, besonders in Kombination mit Big-Sur+-Versionen (siehe README).</p>
<div id="libraryStatus" class="status"></div> <div id="libraryStatus" class="status"></div>
</section> </section>
<section class="card"> <section class="card">
<div class="card-head"> <div class="card-head">
<h2>1. macOS-Version</h2> <h2 id="step1Title">1. macOS-Version</h2>
<button id="refreshVersions" class="btn-ghost">neu laden</button> <button id="refreshVersions" class="btn-ghost">neu laden</button>
</div> </div>
<div id="catalogStatus" class="status"></div> <div id="catalogStatus" class="status"></div>
<select id="versionSelect" size="10"></select> <div id="versionList" class="version-list"></div>
<p id="versionDetail" class="detail"></p> <p id="versionDetail" class="detail"></p>
</section> </section>
+22
View File
@@ -47,6 +47,26 @@ select {
padding: 0.4rem; padding: 0.4rem;
} }
select option { padding: 0.3rem; } select option { padding: 0.3rem; }
.version-list {
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
background: #0d0f14;
}
.version-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
font-size: 0.88rem;
}
.version-row:last-child { border-bottom: none; }
.version-row:hover { background: #131620; }
.version-row.selected { background: #1b2333; }
.version-row input { flex: none; }
.detail { color: var(--muted); font-size: 0.85rem; min-height: 1.2em; } .detail { color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
.status { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.5rem; } .status { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.5rem; }
.device { .device {
@@ -94,9 +114,11 @@ button:disabled { background: #333844; color: #777; cursor: not-allowed; }
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
flex-wrap: wrap;
} }
.mode-btn { .mode-btn {
flex: 1; flex: 1;
min-width: 160px;
background: #0d0f14; background: #0d0f14;
color: var(--muted); color: var(--muted);
border: 1px solid var(--border); border: 1px solid var(--border);