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:
@@ -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 \
|
||||||
|
|||||||
@@ -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 — noetig, um Sticks direkt zu partitionieren
|
`/run/udev` vom Host durch — 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 —
|
3. Kein Windows/macOS-Docker-Host-Support fuer das Device-Passthrough —
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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 — 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 — 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 — 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user