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:
+223
-57
@@ -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/<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"-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)
|
||||
|
||||
Reference in New Issue
Block a user