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:
+16
-1
@@ -72,20 +72,35 @@ def api_devices():
|
||||
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")
|
||||
def api_create_job():
|
||||
body = request.get_json(force=True)
|
||||
product_id = body.get("product_id")
|
||||
job_type = body.get("job_type", "stick")
|
||||
device_path = body.get("device_path")
|
||||
confirm_path = body.get("confirm_device_path")
|
||||
|
||||
if job_type not in ("stick", "download"):
|
||||
return jsonify({"error": "Unbekannter job_type."}), 400
|
||||
|
||||
with _catalog_lock:
|
||||
product = next((p for p in _catalog_cache["items"] if p["id"] == product_id), None)
|
||||
if not product:
|
||||
return jsonify({"error": "Unbekannte macOS-Version -- Katalog neu laden."}), 400
|
||||
|
||||
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:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
|
||||
+158
-23
@@ -16,13 +16,34 @@ Zwei Pfade, unterschiedlich robust:
|
||||
den internen Aufbau von InstallAssistant.pkg mehrfach leicht
|
||||
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:
|
||||
- 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
|
||||
@@ -38,6 +59,7 @@ 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"] = {}
|
||||
|
||||
|
||||
@@ -45,11 +67,76 @@ 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 _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:
|
||||
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.product = product
|
||||
self.device_path = device_path
|
||||
self.job_type = job_type # "stick" | "download"
|
||||
self.status = "queued" # queued -> running -> done | failed | aborted
|
||||
self.phase = "warten"
|
||||
self.progress = 0 # 0-100 im aktuellen Phase
|
||||
@@ -92,6 +179,7 @@ class Job:
|
||||
with self._lock:
|
||||
return {
|
||||
"id": self.id,
|
||||
"job_type": self.job_type,
|
||||
"status": self.status,
|
||||
"phase": self.phase,
|
||||
"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:
|
||||
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"):
|
||||
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
|
||||
t = threading.Thread(target=_run_job, args=(job,), daemon=True)
|
||||
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)}")
|
||||
|
||||
|
||||
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}")
|
||||
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(dest_path, "wb") as f:
|
||||
with open(part_path, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=1024 * 1024 * 4):
|
||||
if not chunk:
|
||||
continue
|
||||
@@ -151,11 +259,30 @@ def _download(job: Job, url: str, dest_path: str):
|
||||
if pct != last_pct:
|
||||
job.emit(phase="download", progress=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:
|
||||
raw_path = dmg_path + ".img"
|
||||
def _dmg_to_raw(job: Job, dmg_path: str, work_dir: str) -> str:
|
||||
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
|
||||
@@ -236,26 +363,34 @@ def _extract_recovery_dmg(job: Job, pkg_path: str, work_dir: str) -> str:
|
||||
|
||||
def _run_job(job: Job):
|
||||
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:
|
||||
job.emit(status="running", phase="start", progress=0, msg="Job gestartet")
|
||||
product = job.product
|
||||
mode = product["mode"]
|
||||
|
||||
if mode == "legacy_dmg":
|
||||
url = product["base_system_url"]
|
||||
dmg_path = os.path.join(work_dir, "BaseSystem.dmg")
|
||||
_download(job, url, dmg_path)
|
||||
raw_path = _dmg_to_raw(job, dmg_path)
|
||||
_write_raw_to_device(job, raw_path, job.device_path)
|
||||
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).")
|
||||
url = product["install_assistant_url"]
|
||||
pkg_path = os.path.join(work_dir, "InstallAssistant.pkg")
|
||||
_download(job, url, pkg_path)
|
||||
dmg_path = _extract_recovery_dmg(job, pkg_path, work_dir)
|
||||
raw_path = _dmg_to_raw(job, dmg_path)
|
||||
_write_raw_to_device(job, raw_path, job.device_path)
|
||||
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)
|
||||
_write_raw_to_device(job, raw_path, job.device_path)
|
||||
|
||||
job.emit(status="done", phase="fertig", progress=100, msg="Stick ist fertig.")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -263,4 +398,4 @@ def _run_job(job: Job):
|
||||
job.error = str(exc)
|
||||
job.emit(status="failed", msg=f"FEHLER: {exc}")
|
||||
finally:
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
shutil.rmtree(scratch_dir, ignore_errors=True)
|
||||
|
||||
Reference in New Issue
Block a user