diff --git a/Dockerfile b/Dockerfile index b80ff26..3a7a149 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-pip \ util-linux \ gdisk \ + parted \ hfsprogs \ dmg2img \ cpio \ diff --git a/README.md b/README.md index b99ec25..295b466 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,11 @@ geaendert hat — dann muss `backend/installer.py` (`_extract_recovery_dmg`) angepasst werden. ### 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 Original-Image (`BaseSystem.dmg`/`InstallESD.dmg` bzw. `InstallAssistant.pkg`, je nach Version) und legt es **dauerhaft** unter `./downloads/library//` @@ -80,7 +82,53 @@ Die GUI zeigt oben die Gesamtgroesse + Anzahl bereits heruntergeladener Installer an, und markiert bereits vorhandene Versionen in der Auswahlliste 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 `/run/udev` vom Host durch — noetig, um Sticks direkt zu partitionieren 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). 3. Kein Windows/macOS-Docker-Host-Support fuer das Device-Passthrough — 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". diff --git a/backend/app.py b/backend/app.py index 4237f9c..0c8a889 100644 --- a/backend/app.py +++ b/backend/app.py @@ -86,18 +86,32 @@ def api_library(): @app.post("/api/jobs") def api_create_job(): body = request.get_json(force=True) - product_id = body.get("product_id") job_type = body.get("job_type", "stick") device_path = body.get("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 with _catalog_lock: - product = next((p for p in _catalog_cache["items"] if p["id"] == product_id), None) - if not product: - return jsonify({"error": "Unbekannte macOS-Version -- Katalog neu laden."}), 400 + 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: + return jsonify({"error": "Unbekannte macOS-Version -- Katalog neu laden."}), 400 try: job = installer.create_job(product, device_path, confirm_path, job_type=job_type) diff --git a/backend/installer.py b/backend/installer.py index 3b37bb1..35402b2 100644 --- a/backend/installer.py +++ b/backend/installer.py @@ -16,24 +16,33 @@ Zwei Pfade, unterschiedlich robust: den internen Aufbau von InstallAssistant.pkg mehrfach leicht geaendert, das kann brechen. Klar als "experimentell" markiert. -Zwei Job-Typen: +Drei Job-Typen: "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. 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// (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. + 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). @@ -62,6 +71,14 @@ 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 @@ -75,6 +92,14 @@ def _safe_name(product: dict) -> str: 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"] @@ -131,12 +156,31 @@ def list_library(): 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: 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()) + # "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" + 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 @@ -187,20 +231,9 @@ class Job: } -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}") - +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: @@ -212,6 +245,29 @@ def create_job(product: dict, device_path: str = None, confirm_path: str = None, 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) @@ -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: + 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 _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") +def _dd_with_progress(job: Job, raw_path: str, target_path: str, phase: str): 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) for line in proc.stdout: line = line.strip() if not line: 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, ..." try: bytes_done = int(line.split(" ")[0]) pct = min(100, int(bytes_done * 100 / size)) - job.emit(phase="schreiben", progress=pct) + job.emit(phase=phase, 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...") + 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)") @@ -361,41 +423,145 @@ def _extract_recovery_dmg(job: Job, pkg_path: str, work_dir: str) -> str: 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. +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") - 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) + 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}") - finally: - shutil.rmtree(scratch_dir, ignore_errors=True) diff --git a/frontend/app.js b/frontend/app.js index dc2e763..6d21a20 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,10 +1,14 @@ -let selectedProduct = null; +let selectedProduct = null; // "stick" | "download" +let selectedProductIds = new Set(); // "multiboot" let selectedDevice = null; let catalogItems = []; 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 catalogStatus = document.getElementById("catalogStatus"); const deviceSection = document.getElementById("deviceSection"); @@ -12,6 +16,7 @@ const deviceList = document.getElementById("deviceList"); const confirmPath = document.getElementById("confirmPath"); const stickConfirm = document.getElementById("stickConfirm"); const downloadHint = document.getElementById("downloadHint"); +const multibootHint = document.getElementById("multibootHint"); const step3Title = document.getElementById("step3Title"); const startBtn = document.getElementById("startBtn"); const progressCard = document.getElementById("progressCard"); @@ -21,6 +26,7 @@ const logEl = document.getElementById("log"); const libraryStatus = document.getElementById("libraryStatus"); const modeStickBtn = document.getElementById("modeStick"); const modeDownloadBtn = document.getElementById("modeDownload"); +const modeMultibootBtn = document.getElementById("modeMultiboot"); function fmtSize(bytes) { if (!bytes) return "?"; @@ -32,15 +38,24 @@ function setMode(next) { mode = next; modeStickBtn.classList.toggle("active", mode === "stick"); modeDownloadBtn.classList.toggle("active", mode === "download"); - deviceSection.style.display = mode === "stick" ? "" : "none"; - stickConfirm.style.display = mode === "stick" ? "" : "none"; + modeMultibootBtn.classList.toggle("active", mode === "multiboot"); + + const needsDevice = mode === "stick" || mode === "multiboot"; + deviceSection.style.display = needsDevice ? "" : "none"; + stickConfirm.style.display = needsDevice ? "" : "none"; downloadHint.style.display = mode === "download" ? "" : "none"; - step3Title.textContent = mode === "stick" ? "3. Bestaetigen & Schreiben" : "3. Herunterladen"; - startBtn.textContent = mode === "stick" ? "Stick erstellen" : "Image herunterladen"; + multibootHint.style.display = mode === "multiboot" ? "" : "none"; + + 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(); } modeStickBtn.addEventListener("click", () => setMode("stick")); modeDownloadBtn.addEventListener("click", () => setMode("download")); +modeMultibootBtn.addEventListener("click", () => setMode("multiboot")); async function loadLibrary() { const res = await fetch("/api/library"); @@ -54,16 +69,68 @@ async function loadLibrary() { 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() { - versionSelect.innerHTML = ""; + versionList.innerHTML = ""; for (const item of catalogItems) { - const opt = document.createElement("option"); - opt.value = item.id; + const row = document.createElement("label"); + row.className = "version-row"; + + const input = document.createElement("input"); const modeTag = item.mode === "recovery" ? " [experimentell]" : ""; const offlineTag = libraryIds.has(item.id) ? " -- bereits offline vorhanden" : ""; - opt.textContent = `${item.title} ${item.version} - ${fmtSize(item.size_bytes)}${modeTag}${offlineTag}`; - versionSelect.appendChild(opt); + const labelText = `${item.title} ${item.version} - ${fmtSize(item.size_bytes)}${modeTag}${offlineTag}`; + + 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() { @@ -84,18 +151,6 @@ async function loadVersions() { 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() { deviceList.innerHTML = "
Scanne USB-Geraete...
"; const res = await fetch("/api/devices"); @@ -126,9 +181,14 @@ async function loadDevices() { } function updateStartEnabled() { - const ok = mode === "download" - ? !!selectedProduct - : selectedProduct && selectedDevice && confirmPath.value.trim() === selectedDevice.path; + let ok; + if (mode === "download") { + 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; } confirmPath.addEventListener("input", updateStartEnabled); @@ -143,8 +203,13 @@ startBtn.addEventListener("click", async () => { startBtn.disabled = true; progressCard.style.display = "block"; logEl.textContent = ""; - const payload = { product_id: selectedProduct.id, job_type: mode }; - if (mode === "stick") { + const payload = { job_type: mode }; + 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.confirm_device_path = confirmPath.value.trim(); } diff --git a/frontend/index.html b/frontend/index.html index 1001800..e91fdb3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,19 +19,21 @@
+

Im Download-Modus wird das Original-Image dauerhaft unter ./downloads/library/ im Projektordner abgelegt — 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.

+
-

1. macOS-Version

+

1. macOS-Version

- +

diff --git a/frontend/style.css b/frontend/style.css index 0165ada..31fb340 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -47,6 +47,26 @@ select { padding: 0.4rem; } 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; } .status { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.5rem; } .device { @@ -94,9 +114,11 @@ button:disabled { background: #333844; color: #777; cursor: not-allowed; } display: flex; gap: 0.5rem; margin-bottom: 0.75rem; + flex-wrap: wrap; } .mode-btn { flex: 1; + min-width: 160px; background: #0d0f14; color: var(--muted); border: 1px solid var(--border);