feat: Offline-Bibliothek + Download-only-Modus
Neuer Modus in der GUI: Image nur herunterladen und dauerhaft unter ./downloads/library/ ablegen, ohne USB-Stick. Baut sich so vorab eine komplette Offline-Sammlung aller macOS-Versionen auf (z.B. auf externe Platte kopierbar). Stick-Jobs pruefen jetzt IMMER zuerst die lokale Bibliothek und ueberspringen den Download bei Treffer -- jeder normale Stick-Bau traegt so nebenbei zur Bibliothek bei. Backend: installer.py bekommt job_type (stick|download), persistente Downloads in library/<name>/ mit meta.json + atomarem .part-Rename, neue /api/library. Frontend: Modus-Umschalter, Bibliotheks-Status, Offline-Badges in der Versionsliste. Live getestet auf aria-wohnung: Container neu gebaut, Katalog laedt 37 Installer, Download-Job End-to-End ueber die echte API (Cache-Miss dann Cache-Hit verifiziert), Stick-Job-Validierung weiterhin intakt.
This commit is contained in:
@@ -52,7 +52,35 @@ kein Bug im eigentlichen Sinn, sondern ein Zeichen dass sich intern was
|
|||||||
geaendert hat — dann muss `backend/installer.py` (`_extract_recovery_dmg`)
|
geaendert hat — dann muss `backend/installer.py` (`_extract_recovery_dmg`)
|
||||||
angepasst werden.
|
angepasst werden.
|
||||||
|
|
||||||
### 3. USB-Geraete-Erkennung
|
### 3. Offline-Bibliothek / "nur herunterladen"-Modus
|
||||||
|
Oben in der GUI laesst sich per Umschalter zwischen zwei Modi waehlen:
|
||||||
|
|
||||||
|
- **"Auf USB-Stick schreiben"** (Standard) — wie bisher.
|
||||||
|
- **"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/<name>/`
|
||||||
|
ab (inkl. `meta.json` mit Titel/Version/Groesse/Download-Zeitpunkt). Kein
|
||||||
|
Geraet noetig, nichts wird geloescht.
|
||||||
|
|
||||||
|
Damit kannst du dir vorab — solange Internet da ist — eine komplette
|
||||||
|
Offline-Sammlung aller verfuegbaren macOS-Versionen aufbauen. Weil die
|
||||||
|
Downloads im Bind-Mount `./downloads` liegen (siehe oben, kein Docker-Volume),
|
||||||
|
reicht es, den kompletten Projektordner (inkl. `downloads/library/`) auf eine
|
||||||
|
externe Platte zu kopieren — dort ist dann alles fuer den Stick-Bau parat,
|
||||||
|
auch ganz ohne Internetzugang.
|
||||||
|
|
||||||
|
**Wichtig:** ein "Stick erstellen"-Job prueft VOR jedem Download automatisch,
|
||||||
|
ob das Image schon in der Bibliothek liegt (`installer.get_cached_payload`),
|
||||||
|
und ueberspringt den Download dann komplett — das gilt fuer beide Modi.
|
||||||
|
Jeder normale Stick-Bau traegt also nebenbei zur Bibliothek bei, und ein
|
||||||
|
spaeterer Stick-Bau aus derselben Bibliothek (z.B. von der externen Platte)
|
||||||
|
laedt nichts erneut herunter.
|
||||||
|
|
||||||
|
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
|
||||||
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
|
||||||
|
|||||||
+16
-1
@@ -72,20 +72,35 @@ def api_devices():
|
|||||||
return jsonify({"items": [], "error": str(exc)}), 500
|
return jsonify({"items": [], "error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/library")
|
||||||
|
def api_library():
|
||||||
|
"""Was liegt schon dauerhaft lokal (Offline-Bibliothek), fuer 'nur
|
||||||
|
herunterladen' Badges + die Groessen-Anzeige in der GUI."""
|
||||||
|
try:
|
||||||
|
return jsonify(installer.list_library())
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.exception("library listing failed")
|
||||||
|
return jsonify({"items": [], "total_bytes": 0, "error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
@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")
|
product_id = body.get("product_id")
|
||||||
|
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"):
|
||||||
|
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)
|
product = next((p for p in _catalog_cache["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
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = installer.create_job(product, device_path, confirm_path)
|
job = installer.create_job(product, device_path, confirm_path, job_type=job_type)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
|||||||
+158
-23
@@ -16,13 +16,34 @@ 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:
|
||||||
|
|
||||||
|
"stick" Download (oder Wiederverwendung aus der lokalen Bibliothek) +
|
||||||
|
Schreiben auf einen 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
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).
|
||||||
- Ziel-Device muss "removable" oder USB-Transport sein (Refuse sonst).
|
- Ziel-Device muss "removable" oder USB-Transport sein (Refuse sonst).
|
||||||
- Aufrufer muss den Device-Pfad im Request nochmal exakt bestaetigen.
|
- Aufrufer muss den Device-Pfad im Request nochmal exakt bestaetigen.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -38,6 +59,7 @@ from devices import list_usb_sticks
|
|||||||
log = logging.getLogger("installer")
|
log = logging.getLogger("installer")
|
||||||
|
|
||||||
DOWNLOAD_DIR = "/data/downloads"
|
DOWNLOAD_DIR = "/data/downloads"
|
||||||
|
LIBRARY_DIR = os.path.join(DOWNLOAD_DIR, "library")
|
||||||
JOBS: dict[str, "Job"] = {}
|
JOBS: dict[str, "Job"] = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -45,11 +67,76 @@ class JobAborted(Exception):
|
|||||||
pass
|
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 _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}
|
||||||
|
|
||||||
|
|
||||||
class Job:
|
class Job:
|
||||||
def __init__(self, product: dict, device_path: str):
|
def __init__(self, product: dict, device_path: str = None, job_type: str = "stick"):
|
||||||
self.id = str(uuid.uuid4())
|
self.id = str(uuid.uuid4())
|
||||||
self.product = product
|
self.product = product
|
||||||
self.device_path = device_path
|
self.device_path = device_path
|
||||||
|
self.job_type = job_type # "stick" | "download"
|
||||||
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
|
||||||
@@ -92,6 +179,7 @@ class Job:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
|
"job_type": self.job_type,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"phase": self.phase,
|
"phase": self.phase,
|
||||||
"progress": self.progress,
|
"progress": self.progress,
|
||||||
@@ -99,7 +187,17 @@ class Job:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_job(product: dict, device_path: str, confirm_path: str) -> 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}")
|
||||||
|
|
||||||
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.")
|
||||||
|
|
||||||
@@ -114,7 +212,7 @@ def create_job(product: dict, device_path: str, confirm_path: str) -> Job:
|
|||||||
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.")
|
||||||
|
|
||||||
job = Job(product, device_path)
|
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)
|
||||||
t.start()
|
t.start()
|
||||||
@@ -133,14 +231,24 @@ def _run(job: Job, cmd: list[str], phase: str, progress_from_stderr=False):
|
|||||||
raise RuntimeError(f"Befehl fehlgeschlagen ({ret}): {' '.join(cmd)}")
|
raise RuntimeError(f"Befehl fehlgeschlagen ({ret}): {' '.join(cmd)}")
|
||||||
|
|
||||||
|
|
||||||
def _download(job: Job, url: str, dest_path: str):
|
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}")
|
job.emit(phase="download", progress=0, msg=f"Lade {url}")
|
||||||
with requests.get(url, stream=True, timeout=30) as r:
|
with requests.get(url, stream=True, timeout=30) as r:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
total = int(r.headers.get("Content-Length", 0)) or None
|
total = int(r.headers.get("Content-Length", 0)) or None
|
||||||
done = 0
|
done = 0
|
||||||
last_pct = -1
|
last_pct = -1
|
||||||
with open(dest_path, "wb") as f:
|
with open(part_path, "wb") as f:
|
||||||
for chunk in r.iter_content(chunk_size=1024 * 1024 * 4):
|
for chunk in r.iter_content(chunk_size=1024 * 1024 * 4):
|
||||||
if not chunk:
|
if not chunk:
|
||||||
continue
|
continue
|
||||||
@@ -151,11 +259,30 @@ def _download(job: Job, url: str, dest_path: str):
|
|||||||
if pct != last_pct:
|
if pct != last_pct:
|
||||||
job.emit(phase="download", progress=pct)
|
job.emit(phase="download", progress=pct)
|
||||||
last_pct = pct
|
last_pct = pct
|
||||||
job.emit(phase="download", progress=100, msg="Download fertig")
|
|
||||||
|
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) -> str:
|
def _dmg_to_raw(job: Job, dmg_path: str, work_dir: str) -> str:
|
||||||
raw_path = 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
|
||||||
@@ -236,26 +363,34 @@ def _extract_recovery_dmg(job: Job, pkg_path: str, work_dir: str) -> str:
|
|||||||
|
|
||||||
def _run_job(job: Job):
|
def _run_job(job: Job):
|
||||||
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
||||||
work_dir = tempfile.mkdtemp(dir=DOWNLOAD_DIR)
|
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.
|
||||||
|
scratch_dir = tempfile.mkdtemp(dir=DOWNLOAD_DIR, prefix="scratch_")
|
||||||
try:
|
try:
|
||||||
job.emit(status="running", phase="start", progress=0, msg="Job gestartet")
|
job.emit(status="running", phase="start", progress=0, msg="Job gestartet")
|
||||||
product = job.product
|
product = job.product
|
||||||
mode = product["mode"]
|
|
||||||
|
|
||||||
if mode == "legacy_dmg":
|
cached = get_cached_payload(product)
|
||||||
url = product["base_system_url"]
|
if cached:
|
||||||
dmg_path = os.path.join(work_dir, "BaseSystem.dmg")
|
payload_path = cached
|
||||||
_download(job, url, dmg_path)
|
job.emit(phase="download", progress=100, msg=f"Bereits lokal in der Bibliothek vorhanden -- Download uebersprungen ({cached}).")
|
||||||
raw_path = _dmg_to_raw(job, dmg_path)
|
|
||||||
_write_raw_to_device(job, raw_path, job.device_path)
|
|
||||||
else:
|
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).")
|
job.emit(phase="hinweis", msg="Big Sur+ Pfad ist experimentell (siehe README).")
|
||||||
url = product["install_assistant_url"]
|
dmg_path = _extract_recovery_dmg(job, payload_path, scratch_dir)
|
||||||
pkg_path = os.path.join(work_dir, "InstallAssistant.pkg")
|
else:
|
||||||
_download(job, url, pkg_path)
|
dmg_path = payload_path
|
||||||
dmg_path = _extract_recovery_dmg(job, pkg_path, work_dir)
|
|
||||||
raw_path = _dmg_to_raw(job, dmg_path)
|
raw_path = _dmg_to_raw(job, dmg_path, scratch_dir)
|
||||||
_write_raw_to_device(job, raw_path, job.device_path)
|
_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.")
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
@@ -263,4 +398,4 @@ def _run_job(job: Job):
|
|||||||
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:
|
finally:
|
||||||
shutil.rmtree(work_dir, ignore_errors=True)
|
shutil.rmtree(scratch_dir, ignore_errors=True)
|
||||||
|
|||||||
+66
-16
@@ -1,17 +1,26 @@
|
|||||||
let selectedProduct = null;
|
let selectedProduct = null;
|
||||||
let selectedDevice = null;
|
let selectedDevice = null;
|
||||||
let catalogItems = [];
|
let catalogItems = [];
|
||||||
|
let libraryIds = new Set(); // Produkt-IDs die schon lokal (dauerhaft) vorhanden sind
|
||||||
|
let mode = "stick"; // "stick" | "download"
|
||||||
|
|
||||||
const versionSelect = document.getElementById("versionSelect");
|
const versionSelect = document.getElementById("versionSelect");
|
||||||
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 deviceList = document.getElementById("deviceList");
|
const deviceList = document.getElementById("deviceList");
|
||||||
const confirmPath = document.getElementById("confirmPath");
|
const confirmPath = document.getElementById("confirmPath");
|
||||||
|
const stickConfirm = document.getElementById("stickConfirm");
|
||||||
|
const downloadHint = document.getElementById("downloadHint");
|
||||||
|
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");
|
||||||
const progressFill = document.getElementById("progressFill");
|
const progressFill = document.getElementById("progressFill");
|
||||||
const progressPhase = document.getElementById("progressPhase");
|
const progressPhase = document.getElementById("progressPhase");
|
||||||
const logEl = document.getElementById("log");
|
const logEl = document.getElementById("log");
|
||||||
|
const libraryStatus = document.getElementById("libraryStatus");
|
||||||
|
const modeStickBtn = document.getElementById("modeStick");
|
||||||
|
const modeDownloadBtn = document.getElementById("modeDownload");
|
||||||
|
|
||||||
function fmtSize(bytes) {
|
function fmtSize(bytes) {
|
||||||
if (!bytes) return "?";
|
if (!bytes) return "?";
|
||||||
@@ -19,9 +28,46 @@ function fmtSize(bytes) {
|
|||||||
return gb >= 1 ? gb.toFixed(1) + " GB" : (bytes / 1e6).toFixed(0) + " MB";
|
return gb >= 1 ? gb.toFixed(1) + " GB" : (bytes / 1e6).toFixed(0) + " MB";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
downloadHint.style.display = mode === "download" ? "" : "none";
|
||||||
|
step3Title.textContent = mode === "stick" ? "3. Bestaetigen & Schreiben" : "3. Herunterladen";
|
||||||
|
startBtn.textContent = mode === "stick" ? "Stick erstellen" : "Image herunterladen";
|
||||||
|
updateStartEnabled();
|
||||||
|
}
|
||||||
|
modeStickBtn.addEventListener("click", () => setMode("stick"));
|
||||||
|
modeDownloadBtn.addEventListener("click", () => setMode("download"));
|
||||||
|
|
||||||
|
async function loadLibrary() {
|
||||||
|
const res = await fetch("/api/library");
|
||||||
|
const data = await res.json();
|
||||||
|
libraryIds = new Set((data.items || []).map((it) => it.id));
|
||||||
|
const count = (data.items || []).length;
|
||||||
|
const size = fmtSize(data.total_bytes || 0);
|
||||||
|
libraryStatus.textContent = count
|
||||||
|
? `${count} Installer bereits dauerhaft lokal vorhanden -- ${size} in ./downloads/library/`
|
||||||
|
: "Noch keine Installer dauerhaft lokal gespeichert.";
|
||||||
|
renderVersionOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVersionOptions() {
|
||||||
|
versionSelect.innerHTML = "";
|
||||||
|
for (const item of catalogItems) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = item.id;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadVersions() {
|
async function loadVersions() {
|
||||||
catalogStatus.textContent = "Lade Apple-Katalog...";
|
catalogStatus.textContent = "Lade Apple-Katalog...";
|
||||||
versionSelect.innerHTML = "";
|
|
||||||
const res = await fetch("/api/versions");
|
const res = await fetch("/api/versions");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
catalogItems = data.items || [];
|
catalogItems = data.items || [];
|
||||||
@@ -35,13 +81,7 @@ async function loadVersions() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catalogStatus.textContent = `${catalogItems.length} macOS-Installer gefunden (Katalog: ${data.catalog_url ? data.catalog_url.split("/").pop() : "?"})`;
|
catalogStatus.textContent = `${catalogItems.length} macOS-Installer gefunden (Katalog: ${data.catalog_url ? data.catalog_url.split("/").pop() : "?"})`;
|
||||||
for (const item of catalogItems) {
|
renderVersionOptions();
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = item.id;
|
|
||||||
const modeTag = item.mode === "recovery" ? " [experimentell]" : "";
|
|
||||||
opt.textContent = `${item.title} ${item.version} - ${fmtSize(item.size_bytes)}${modeTag}`;
|
|
||||||
versionSelect.appendChild(opt);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
versionSelect.addEventListener("change", () => {
|
versionSelect.addEventListener("change", () => {
|
||||||
@@ -50,7 +90,8 @@ versionSelect.addEventListener("change", () => {
|
|||||||
const modeText = selectedProduct.mode === "recovery"
|
const modeText = selectedProduct.mode === "recovery"
|
||||||
? "Big Sur+ Pfad: experimentell, Extraktion kann fehlschlagen."
|
? "Big Sur+ Pfad: experimentell, Extraktion kann fehlschlagen."
|
||||||
: "Klassischer dd-Pfad: robust und gut getestet.";
|
: "Klassischer dd-Pfad: robust und gut getestet.";
|
||||||
versionDetail.textContent = `${selectedProduct.title} ${selectedProduct.version} | ${fmtSize(selectedProduct.size_bytes)} | ${modeText}`;
|
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();
|
updateStartEnabled();
|
||||||
});
|
});
|
||||||
@@ -85,7 +126,9 @@ async function loadDevices() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateStartEnabled() {
|
function updateStartEnabled() {
|
||||||
const ok = selectedProduct && selectedDevice && confirmPath.value.trim() === selectedDevice.path;
|
const ok = mode === "download"
|
||||||
|
? !!selectedProduct
|
||||||
|
: selectedProduct && selectedDevice && confirmPath.value.trim() === selectedDevice.path;
|
||||||
startBtn.disabled = !ok;
|
startBtn.disabled = !ok;
|
||||||
}
|
}
|
||||||
confirmPath.addEventListener("input", updateStartEnabled);
|
confirmPath.addEventListener("input", updateStartEnabled);
|
||||||
@@ -100,18 +143,20 @@ 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 };
|
||||||
|
if (mode === "stick") {
|
||||||
|
payload.device_path = selectedDevice.path;
|
||||||
|
payload.confirm_device_path = confirmPath.value.trim();
|
||||||
|
}
|
||||||
const res = await fetch("/api/jobs", {
|
const res = await fetch("/api/jobs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
product_id: selectedProduct.id,
|
|
||||||
device_path: selectedDevice.path,
|
|
||||||
confirm_device_path: confirmPath.value.trim(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
logEl.textContent = "FEHLER: " + data.error;
|
logEl.textContent = "FEHLER: " + data.error;
|
||||||
|
updateStartEnabled();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
streamJob(data.job_id);
|
streamJob(data.job_id);
|
||||||
@@ -129,6 +174,10 @@ function streamJob(jobId) {
|
|||||||
}
|
}
|
||||||
if (data.status === "done" || data.status === "failed" || data.status === "aborted") {
|
if (data.status === "done" || data.status === "failed" || data.status === "aborted") {
|
||||||
es.close();
|
es.close();
|
||||||
|
if (data.status === "done") {
|
||||||
|
loadLibrary();
|
||||||
|
}
|
||||||
|
updateStartEnabled();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
@@ -136,5 +185,6 @@ function streamJob(jobId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
loadVersions();
|
setMode("stick");
|
||||||
|
loadLibrary().then(loadVersions);
|
||||||
loadDevices();
|
loadDevices();
|
||||||
|
|||||||
+20
-5
@@ -13,6 +13,18 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Modus</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mode-toggle">
|
||||||
|
<button id="modeStick" class="mode-btn active" type="button">Auf USB-Stick schreiben</button>
|
||||||
|
<button id="modeDownload" class="mode-btn" type="button">Nur herunterladen (Offline-Bibliothek)</button>
|
||||||
|
</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>
|
||||||
|
<div id="libraryStatus" class="status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>1. macOS-Version</h2>
|
<h2>1. macOS-Version</h2>
|
||||||
@@ -23,7 +35,7 @@
|
|||||||
<p id="versionDetail" class="detail"></p>
|
<p id="versionDetail" class="detail"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card" id="deviceSection">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>2. USB-Stick</h2>
|
<h2>2. USB-Stick</h2>
|
||||||
<button id="refreshDevices" class="btn-ghost">neu scannen</button>
|
<button id="refreshDevices" class="btn-ghost">neu scannen</button>
|
||||||
@@ -32,10 +44,13 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>3. Bestaetigen & Schreiben</h2>
|
<h2 id="step3Title">3. Bestaetigen & Schreiben</h2>
|
||||||
<p class="warn">ACHTUNG: Der gesamte Inhalt des gewaehlten Sticks wird geloescht.</p>
|
<div id="stickConfirm">
|
||||||
<label>Tippe den Geraete-Pfad zur Bestaetigung ein (z.B. <code>/dev/sdb</code>):</label>
|
<p class="warn">ACHTUNG: Der gesamte Inhalt des gewaehlten Sticks wird geloescht.</p>
|
||||||
<input type="text" id="confirmPath" placeholder="/dev/sdX">
|
<label>Tippe den Geraete-Pfad zur Bestaetigung ein (z.B. <code>/dev/sdb</code>):</label>
|
||||||
|
<input type="text" id="confirmPath" placeholder="/dev/sdX">
|
||||||
|
</div>
|
||||||
|
<p id="downloadHint" class="detail" style="display:none">Laedt das Original-Image herunter und legt es dauerhaft in der lokalen Bibliothek ab. Kein Stick noetig, nichts wird geloescht.</p>
|
||||||
<button id="startBtn" disabled>Stick erstellen</button>
|
<button id="startBtn" disabled>Stick erstellen</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,24 @@ button:disabled { background: #333844; color: #777; cursor: not-allowed; }
|
|||||||
padding: 0.3rem 0.7rem;
|
padding: 0.3rem 0.7rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
background: #0d0f14;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.mode-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
background: #0d0f14;
|
background: #0d0f14;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
Reference in New Issue
Block a user