Files
ARIA 6f8538428c 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)
2026-07-18 11:34:59 +02:00

568 lines
24 KiB
Python

"""
Job-Engine: Download eines macOS-Installers + Beschreiben eines USB-Sticks.
Zwei Pfade, unterschiedlich robust:
legacy_dmg (macOS bis Catalina/10.15): Apple liefert ein fertiges,
bootfaehiges BaseSystem.dmg/InstallESD.dmg. Wir wandeln es mit
dmg2img in ein rohes Image und schreiben es 1:1 mit dd auf den
Stick. Etablierter, zuverlaessiger Weg.
recovery (macOS ab Big Sur/11): Apple liefert nur noch ein
InstallAssistant.pkg. Das eigentliche createinstallmedia laeuft
nur unter echtem macOS. Wir extrahieren das .pkg (xar -> pbzx
-> cpio) und suchen darin ein SharedSupport.dmg/BaseSystem.dmg,
das wir dann genauso per dd schreiben. EXPERIMENTELL: Apple hat
den internen Aufbau von InstallAssistant.pkg mehrfach leicht
geaendert, das kann brechen. Klar als "experimentell" markiert.
Drei Job-Typen:
"stick" Download (oder Wiederverwendung aus der lokalen Bibliothek) +
Schreiben EINER macOS-Version auf einen kompletten USB-Stick.
"download" Nur herunterladen, dauerhaft in der lokalen Bibliothek ablegen.
Kein Geraet noetig. Gedacht um sich vorab eine Offline-Sammlung
aller macOS-Installer aufzubauen (z.B. auf eine externe Platte),
die man auch ohne Internetzugang zum Stick-Bauen nutzen kann.
"multiboot" Mehrere macOS-Versionen (2-8) auf EINEN Stick, jede in ihrer
eigenen GPT-Partition. Macs EFI-Bootpicker (Alt/Option beim
Start) scannt alle Partitionen eines angeschlossenen Datentraegers
nach bootfaehigen Volumes -- deshalb reicht es, den Stick mit
N Apple_HFS-Partitionen anzulegen und in jede das jeweilige
vollstaendige Installer-Image zu dd'en. EXPERIMENTELL (siehe
README): funktioniert zuverlaessig fuer den legacy_dmg-Pfad,
beim recovery-Pfad (Big Sur+) erbt es dessen Unsicherheiten.
Lokale Bibliothek (Persistenz):
Jeder erfolgreiche Download landet dauerhaft unter
/data/downloads/library/<safer-name>/ (Original-Dateiname + meta.json).
Das liegt im Bind-Mount ./downloads -- also ganz normal im Projektordner
und damit portabel (Ordner/Platte kopieren reicht). Ein "stick"- oder
"multiboot"-Job prueft IMMER zuerst, ob die Datei schon lokal vorhanden
ist, und ueberspringt den Download wenn ja -- so wird aus jedem einzelnen
Stick-Bau ganz nebenbei eine wachsende Offline-Bibliothek, ganz ohne
Zusatzaufwand.
Sicherheits-Leitplanken:
- Ziel-Device wird gegen die Root-Disk des Hosts geprueft (Refuse).
- Ziel-Device muss "removable" oder USB-Transport sein (Refuse sonst).
- Aufrufer muss den Device-Pfad im Request nochmal exakt bestaetigen.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import tempfile
import threading
import time
import uuid
from queue import Queue, Empty
import requests
from devices import list_usb_sticks
log = logging.getLogger("installer")
DOWNLOAD_DIR = "/data/downloads"
LIBRARY_DIR = os.path.join(DOWNLOAD_DIR, "library")
JOBS: dict[str, "Job"] = {}
# Multiboot-Partitionierung: pro Partition oben drauf gerechneter Puffer
# (Dateisystem-Rundungen etc.), plus grober Abzug fuer GPT-Header/Backup-GPT
# und Ausrichtungs-Luecken zwischen den Partitionen.
PARTITION_PADDING_MB = 64
GPT_OVERHEAD_MB = 16
MULTIBOOT_MIN_IMAGES = 2
MULTIBOOT_MAX_IMAGES = 8
class JobAborted(Exception):
pass
def _safe_name(product: dict) -> str:
"""Stabiler, lesbarer Ordnername fuer die Bibliothek, z.B.
'macOS_Sonoma_14.6_061-12345'. Die Produkt-ID am Ende macht ihn eindeutig,
auch wenn zwei Eintraege denselben Titel haben."""
raw = f"{product.get('title', 'macOS')}_{product.get('version', '0')}_{product.get('id', '')}"
return re.sub(r"[^A-Za-z0-9._-]+", "_", raw).strip("_")
def _partition_label(product: dict) -> str:
"""Kurzes, GPT-taugliches Label fuer den Bootpicker, z.B. 'macOS Sonoma 14.6'.
GPT-Partitionsnamen sind auf 36 UTF-16-Zeichen begrenzt."""
raw = f"{product.get('title', 'macOS')} {product.get('version', '')}".strip()
label = re.sub(r"[^A-Za-z0-9 ._-]+", "", raw).strip()
return (label or "macOS")[:36]
def _payload_url(product: dict) -> str:
if product["mode"] == "legacy_dmg":
return product["base_system_url"]
return product["install_assistant_url"]
def library_entry_dir(product: dict) -> str:
return os.path.join(LIBRARY_DIR, _safe_name(product))
def library_payload_path(product: dict) -> str:
filename = os.path.basename(_payload_url(product)) or (
"BaseSystem.dmg" if product["mode"] == "legacy_dmg" else "InstallAssistant.pkg"
)
return os.path.join(library_entry_dir(product), filename)
def get_cached_payload(product: dict):
"""Gibt den Pfad zurueck wenn die Datei schon vollstaendig in der
Bibliothek liegt (meta.json + Datei vorhanden, Groesse passt), sonst
None."""
path = library_payload_path(product)
meta_path = os.path.join(library_entry_dir(product), "meta.json")
if not (os.path.isfile(path) and os.path.isfile(meta_path)):
return None
try:
with open(meta_path) as f:
meta = json.load(f)
if meta.get("complete") and os.path.getsize(path) == meta.get("size_bytes"):
return path
except (OSError, ValueError):
pass
return None
def list_library():
"""Fuer die GUI: alle vollstaendig heruntergeladenen Produkte + Gesamtgroesse."""
items = []
total = 0
if os.path.isdir(LIBRARY_DIR):
for entry in sorted(os.listdir(LIBRARY_DIR)):
meta_path = os.path.join(LIBRARY_DIR, entry, "meta.json")
if not os.path.isfile(meta_path):
continue
try:
with open(meta_path) as f:
meta = json.load(f)
except (OSError, ValueError):
continue
if not meta.get("complete"):
continue
items.append(meta)
total += meta.get("size_bytes", 0)
return {"items": items, "total_bytes": total}
def _device_size_bytes(device_path: str) -> int:
match = next((s for s in list_usb_sticks() if s["path"] == device_path), None)
if not match:
raise ValueError(f"Geraet {device_path} nicht mehr in der aktuellen USB-Liste gefunden.")
return match["size_bytes"]
def _partition_path(device_path: str, index: int) -> str:
"""Partitions-Devicenode fuer ein Ziel-Device, z.B. /dev/sdb -> /dev/sdb1,
aber /dev/nvme0n1 -> /dev/nvme0n1p1 (Devices die schon auf eine Ziffer
enden brauchen ein 'p' vor der Partitionsnummer, sonst waer der Name
mehrdeutig)."""
base = os.path.basename(device_path)
sep = "p" if base and base[-1].isdigit() else ""
return f"{device_path}{sep}{index}"
class Job:
def __init__(self, product, device_path: str = None, job_type: str = "stick"):
self.id = str(uuid.uuid4())
# "stick"/"download": product ist ein einzelnes Produkt-Dict.
# "multiboot": product ist eine Liste von 2-8 Produkt-Dicts.
self.product = product
self.device_path = device_path
self.job_type = job_type # "stick" | "download" | "multiboot"
self.status = "queued" # queued -> running -> done | failed | aborted
self.phase = "warten"
self.progress = 0 # 0-100 im aktuellen Phase
self.error = None
self._q: Queue = Queue()
self._lock = threading.Lock()
self.history = []
def emit(self, phase=None, progress=None, msg=None, status=None):
with self._lock:
if phase is not None:
self.phase = phase
if progress is not None:
self.progress = progress
if status is not None:
self.status = status
event = {
"phase": self.phase,
"progress": self.progress,
"status": self.status,
"msg": msg,
"ts": time.time(),
}
self.history.append(event)
self._q.put(event)
def stream(self):
# erst die bisherige Historie nachliefern, dann live weiter
for event in list(self.history):
yield event
while True:
try:
yield self._q.get(timeout=30)
except Empty:
if self.status in ("done", "failed", "aborted"):
return
yield {"phase": self.phase, "progress": self.progress, "status": self.status, "msg": None, "ts": time.time()}
def snapshot(self):
with self._lock:
return {
"id": self.id,
"job_type": self.job_type,
"status": self.status,
"phase": self.phase,
"progress": self.progress,
"error": self.error,
}
def _validate_stick_target(device_path: str, confirm_path: str):
if confirm_path != device_path:
raise ValueError("Bestaetigungs-Pfad stimmt nicht mit dem Ziel-Geraet ueberein.")
sticks = list_usb_sticks()
match = next((s for s in sticks if s["path"] == device_path), None)
if not match:
raise ValueError(f"Geraet {device_path} nicht in der aktuellen USB-Liste gefunden.")
if match["is_root_disk"]:
raise ValueError("Verweigert: das ist die System-Platte des Hosts.")
if match["mounted"]:
raise ValueError("Geraet ist aktuell gemountet -- bitte vorher aushaengen.")
if not (match["transport"] == "usb"):
raise ValueError("Geraet ist nicht als USB-Transport erkannt -- Sicherheitsstop.")
def create_job(product, device_path: str = None, confirm_path: str = None, job_type: str = "stick") -> Job:
if job_type == "download":
job = Job(product, device_path=None, job_type="download")
JOBS[job.id] = job
threading.Thread(target=_run_job, args=(job,), daemon=True).start()
return job
if job_type == "multiboot":
if not isinstance(product, list) or len(product) < MULTIBOOT_MIN_IMAGES:
raise ValueError(f"Multiboot braucht mindestens {MULTIBOOT_MIN_IMAGES} macOS-Versionen.")
if len(product) > MULTIBOOT_MAX_IMAGES:
raise ValueError(f"Zu viele Versionen fuer einen Multiboot-Stick (max. {MULTIBOOT_MAX_IMAGES}).")
_validate_stick_target(device_path, confirm_path)
job = Job(product, device_path, job_type="multiboot")
JOBS[job.id] = job
threading.Thread(target=_run_job, args=(job,), daemon=True).start()
return job
if job_type != "stick":
raise ValueError(f"Unbekannter job_type: {job_type}")
_validate_stick_target(device_path, confirm_path)
job = Job(product, device_path, job_type="stick")
JOBS[job.id] = job
t = threading.Thread(target=_run_job, args=(job,), daemon=True)
t.start()
return job
def _run(job: Job, cmd: list[str], phase: str, progress_from_stderr=False):
log.info("job %s: %s", job.id, " ".join(cmd))
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
for line in proc.stdout:
line = line.strip()
if line:
job.emit(phase=phase, msg=line)
ret = proc.wait()
if ret != 0:
raise RuntimeError(f"Befehl fehlgeschlagen ({ret}): {' '.join(cmd)}")
def _download_to_library(job: Job, product: dict) -> str:
"""Laedt den Installer-Payload herunter und legt ihn dauerhaft in der
Bibliothek ab (nicht im temporaeren Scratch-Verzeichnis). Schreibt zuerst
in eine .part-Datei und benennt erst nach Erfolg um, damit ein
abgebrochener Download nicht als "vollstaendig" durchgeht."""
url = _payload_url(product)
entry_dir = library_entry_dir(product)
os.makedirs(entry_dir, exist_ok=True)
final_path = library_payload_path(product)
part_path = final_path + ".part"
job.emit(phase="download", progress=0, msg=f"Lade {url}")
with requests.get(url, stream=True, timeout=30) as r:
r.raise_for_status()
total = int(r.headers.get("Content-Length", 0)) or None
done = 0
last_pct = -1
with open(part_path, "wb") as f:
for chunk in r.iter_content(chunk_size=1024 * 1024 * 4):
if not chunk:
continue
f.write(chunk)
done += len(chunk)
if total:
pct = int(done * 100 / total)
if pct != last_pct:
job.emit(phase="download", progress=pct)
last_pct = pct
size_bytes = os.path.getsize(part_path)
os.replace(part_path, final_path)
with open(os.path.join(entry_dir, "meta.json"), "w") as f:
json.dump(
{
"id": product.get("id"),
"title": product.get("title"),
"version": product.get("version"),
"mode": product.get("mode"),
"source_url": url,
"filename": os.path.basename(final_path),
"size_bytes": size_bytes,
"downloaded_at": time.time(),
"complete": True,
},
f,
)
job.emit(phase="download", progress=100, msg=f"Download fertig, liegt dauerhaft unter {final_path}")
return final_path
def _dmg_to_raw(job: Job, dmg_path: str, work_dir: str) -> str:
os.makedirs(work_dir, exist_ok=True)
raw_path = os.path.join(work_dir, os.path.basename(dmg_path) + ".img")
job.emit(phase="konvertieren", progress=0, msg="dmg2img: DMG -> rohes Image")
_run(job, ["dmg2img", "-i", dmg_path, "-o", raw_path], phase="konvertieren")
return raw_path
def _dd_with_progress(job: Job, raw_path: str, target_path: str, phase: str):
size = os.path.getsize(raw_path)
job.emit(phase=phase, progress=0, msg=f"Schreibe {raw_path} -> {target_path} (dd)")
cmd = ["dd", f"if={raw_path}", f"of={target_path}", "bs=4M", "status=progress", "conv=fsync"]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
for line in proc.stdout:
line = line.strip()
if not line:
continue
job.emit(phase=phase, msg=line)
# dd status=progress schreibt Zeilen wie "1234567890 bytes (1.2 GB, 1.1 GiB) copied, ..."
try:
bytes_done = int(line.split(" ")[0])
pct = min(100, int(bytes_done * 100 / size))
job.emit(phase=phase, progress=pct)
except (ValueError, IndexError, ZeroDivisionError):
pass
ret = proc.wait()
if ret != 0:
raise RuntimeError(f"dd fehlgeschlagen ({target_path})")
job.emit(phase=phase, progress=100, msg=f"dd fertig ({target_path}), sync...")
subprocess.run(["sync"])
def _write_raw_to_device(job: Job, raw_path: str, device_path: str):
job.emit(phase="schreiben", progress=0, msg=f"Loesche vorhandene Signaturen auf {device_path} (wipefs)")
_run(job, ["wipefs", "-a", device_path], phase="schreiben")
_dd_with_progress(job, raw_path, device_path, phase="schreiben")
def _extract_recovery_dmg(job: Job, pkg_path: str, work_dir: str) -> str:
"""Experimentell: InstallAssistant.pkg -> xar -> pbzx -> cpio, sucht SharedSupport/BaseSystem.dmg."""
job.emit(phase="extrahieren", progress=0, msg="xar: InstallAssistant.pkg entpacken (experimentell)")
extract_dir = os.path.join(work_dir, "xar")
os.makedirs(extract_dir, exist_ok=True)
_run(job, ["xar", "-xf", pkg_path, "-C", extract_dir], phase="extrahieren")
payloads = []
for root, _dirs, files in os.walk(extract_dir):
for fn in files:
if fn == "Payload":
payloads.append(os.path.join(root, fn))
if not payloads:
raise RuntimeError("Kein Payload in InstallAssistant.pkg gefunden -- Apple-Struktur hat sich vermutlich geaendert.")
cpio_dir = os.path.join(work_dir, "cpio")
os.makedirs(cpio_dir, exist_ok=True)
for payload in payloads:
job.emit(phase="extrahieren", msg=f"pbzx+cpio: {payload}")
# pbzx dekomprimiert das payload, cpio packt den entstandenen Stream aus
pbzx_proc = subprocess.Popen(["pbzx", "-n", payload], stdout=subprocess.PIPE)
cpio_proc = subprocess.Popen(
["cpio", "-idm"], cwd=cpio_dir, stdin=pbzx_proc.stdout,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
pbzx_proc.stdout.close()
for line in cpio_proc.stdout:
line = line.strip()
if line:
job.emit(phase="extrahieren", msg=line)
cpio_proc.wait()
pbzx_proc.wait()
dmg_candidates = []
for root, _dirs, files in os.walk(cpio_dir):
for fn in files:
if fn in ("SharedSupport.dmg", "BaseSystem.dmg"):
dmg_candidates.append(os.path.join(root, fn))
if not dmg_candidates:
raise RuntimeError(
"Kein SharedSupport.dmg/BaseSystem.dmg im entpackten Installer gefunden. "
"Das ist der bekannte fragile Punkt bei macOS Big Sur+ ohne echten Mac."
)
# SharedSupport.dmg bevorzugen (enthaelt das vollstaendige Recovery-System)
dmg_candidates.sort(key=lambda p: 0 if "SharedSupport" in p else 1)
return dmg_candidates[0]
def _fetch_and_convert(job: Job, product: dict, work_dir: str, tag: str) -> str:
"""Gemeinsamer Schritt fuer 'stick' und 'multiboot': Bibliothek pruefen /
herunterladen, bei Bedarf Big-Sur+-Pfad extrahieren, am Ende immer ein
rohes dd-faehiges Image zurueckgeben."""
cached = get_cached_payload(product)
if cached:
payload_path = cached
job.emit(phase="download", progress=100, msg=f"{tag}: bereits lokal in der Bibliothek vorhanden, Download uebersprungen.")
else:
job.emit(phase="download", progress=0, msg=f"{tag}: lade herunter...")
payload_path = _download_to_library(job, product)
if product["mode"] != "legacy_dmg":
job.emit(phase="extrahieren", msg=f"{tag}: Big Sur+ Pfad, experimentell.")
dmg_path = _extract_recovery_dmg(job, payload_path, os.path.join(work_dir, "extract"))
else:
dmg_path = payload_path
return _dmg_to_raw(job, dmg_path, os.path.join(work_dir, "raw"))
def _plan_partitions(raw_images: list[dict], device_size_bytes: int):
"""Rechnet pro Image die noetige Partitionsgroesse (inkl. Puffer) aus und
wirft eine verstaendliche Fehlermeldung, wenn der Stick zu klein ist --
BEVOR irgendwas geschrieben wird."""
total_mb = GPT_OVERHEAD_MB
for img in raw_images:
img["partition_mb"] = (img["size_bytes"] // (1024 * 1024)) + 1 + PARTITION_PADDING_MB
total_mb += img["partition_mb"]
needed_bytes = total_mb * 1024 * 1024
if needed_bytes > device_size_bytes:
raise ValueError(
f"Stick zu klein: benoetigt ca. {needed_bytes / 1e9:.1f} GB fuer {len(raw_images)} "
f"Versionen (inkl. Partitions-Puffer), Stick hat aber nur {device_size_bytes / 1e9:.1f} GB."
)
def _partition_device_multiboot(job: Job, device_path: str, raw_images: list[dict]):
job.emit(phase="partitionieren", progress=0, msg=f"Loesche {device_path} komplett (wipefs + sgdisk zap)")
_run(job, ["wipefs", "-a", device_path], phase="partitionieren")
_run(job, ["sgdisk", "--zap-all", device_path], phase="partitionieren")
cmd = ["sgdisk"]
for idx, img in enumerate(raw_images, start=1):
cmd += [
"-n", f"{idx}:0:+{img['partition_mb']}M",
"-t", f"{idx}:AF00", # AF00 = Apple HFS/HFS+ Partitionstyp
"-c", f"{idx}:{img['label']}",
]
cmd.append(device_path)
job.emit(phase="partitionieren", progress=50, msg=f"Lege {len(raw_images)} Partitionen an (sgdisk)")
_run(job, cmd, phase="partitionieren")
# Kernel/udev Zeit geben, die neuen Partitions-Devicenodes anzulegen,
# bevor wir versuchen sie zu beschreiben.
subprocess.run(["partprobe", device_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(2)
job.emit(phase="partitionieren", progress=100, msg="Partitionstabelle steht.")
def _run_stick_job(job: Job):
"""job_type == 'stick': eine einzelne macOS-Version auf einen ganzen Stick."""
scratch_dir = tempfile.mkdtemp(dir=DOWNLOAD_DIR, prefix="scratch_")
try:
job.emit(status="running", phase="start", progress=0, msg="Job gestartet")
raw_path = _fetch_and_convert(job, job.product, scratch_dir, tag=job.product.get("title", "macOS"))
_write_raw_to_device(job, raw_path, job.device_path)
job.emit(status="done", phase="fertig", progress=100, msg="Stick ist fertig.")
finally:
shutil.rmtree(scratch_dir, ignore_errors=True)
def _run_download_job(job: Job):
"""job_type == 'download': nur in die lokale Bibliothek laden."""
cached = get_cached_payload(job.product)
if cached:
job.emit(status="running", phase="download", progress=100, msg=f"Bereits lokal vorhanden ({cached}).")
else:
job.emit(status="running", phase="start", progress=0, msg="Job gestartet")
_download_to_library(job, job.product)
job.emit(status="done", phase="fertig", progress=100, msg="Image liegt jetzt dauerhaft in der lokalen Bibliothek (Offline nutzbar).")
def _run_multiboot_job(job: Job):
"""job_type == 'multiboot': mehrere macOS-Versionen, je eine eigene
GPT-Partition auf demselben Stick. Erst ALLE Images vollstaendig
herunterladen/konvertieren, dann Groesse pruefen, dann EINMAL
partitionieren, dann nacheinander in die jeweilige Partition schreiben."""
products = job.product # Liste
scratch_dir = tempfile.mkdtemp(dir=DOWNLOAD_DIR, prefix="scratch_mb_")
try:
job.emit(status="running", phase="start", progress=0, msg=f"Multiboot-Job gestartet ({len(products)} Versionen)")
raw_images = []
for idx, product in enumerate(products, start=1):
tag = f"[{idx}/{len(products)}] {product.get('title', 'macOS')} {product.get('version', '')}"
work_dir = os.path.join(scratch_dir, f"img{idx}")
raw_path = _fetch_and_convert(job, product, work_dir, tag)
raw_images.append(
{
"label": _partition_label(product),
"path": raw_path,
"size_bytes": os.path.getsize(raw_path),
"title": f"{product.get('title', 'macOS')} {product.get('version', '')}".strip(),
}
)
job.emit(phase="konvertieren", progress=100, msg=f"{tag}: fertig konvertiert.")
job.emit(phase="planen", progress=0, msg="Pruefe ob der Stick fuer alle gewaehlten Versionen gross genug ist...")
device_size = _device_size_bytes(job.device_path)
_plan_partitions(raw_images, device_size)
_partition_device_multiboot(job, job.device_path, raw_images)
for idx, img in enumerate(raw_images, start=1):
part_path = _partition_path(job.device_path, idx)
tag = f"[{idx}/{len(raw_images)}] {img['title']}"
job.emit(phase="schreiben", progress=0, msg=f"{tag}: schreibe nach {part_path}")
_dd_with_progress(job, img["path"], part_path, phase="schreiben")
job.emit(
status="done", phase="fertig", progress=100,
msg=f"Multiboot-Stick fertig -- {len(raw_images)} Versionen, per Alt/Option-Taste beim Boot auswaehlbar.",
)
finally:
shutil.rmtree(scratch_dir, ignore_errors=True)
def _run_job(job: Job):
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
os.makedirs(LIBRARY_DIR, exist_ok=True)
try:
if job.job_type == "multiboot":
_run_multiboot_job(job)
elif job.job_type == "download":
_run_download_job(job)
else:
_run_stick_job(job)
except Exception as exc: # noqa: BLE001
log.exception("job %s failed", job.id)
job.error = str(exc)
job.emit(status="failed", msg=f"FEHLER: {exc}")