diff --git a/README.md b/README.md index a6b9bb4..b99ec25 100644 --- a/README.md +++ b/README.md @@ -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`) 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//` + 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 `/run/udev` vom Host durch — noetig, um Sticks direkt zu partitionieren und zu beschreiben. Das ist bewusst so gebaut, bedeutet aber: der Container diff --git a/backend/app.py b/backend/app.py index 31ff794..4237f9c 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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 diff --git a/backend/installer.py b/backend/installer.py index 38030ea..3b37bb1 100644 --- a/backend/installer.py +++ b/backend/installer.py @@ -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// (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) diff --git a/frontend/app.js b/frontend/app.js index a4888c4..dc2e763 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,17 +1,26 @@ let selectedProduct = null; let selectedDevice = null; 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 versionDetail = document.getElementById("versionDetail"); const catalogStatus = document.getElementById("catalogStatus"); +const deviceSection = document.getElementById("deviceSection"); const deviceList = document.getElementById("deviceList"); 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 progressCard = document.getElementById("progressCard"); const progressFill = document.getElementById("progressFill"); const progressPhase = document.getElementById("progressPhase"); const logEl = document.getElementById("log"); +const libraryStatus = document.getElementById("libraryStatus"); +const modeStickBtn = document.getElementById("modeStick"); +const modeDownloadBtn = document.getElementById("modeDownload"); function fmtSize(bytes) { if (!bytes) return "?"; @@ -19,9 +28,46 @@ function fmtSize(bytes) { 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() { catalogStatus.textContent = "Lade Apple-Katalog..."; - versionSelect.innerHTML = ""; const res = await fetch("/api/versions"); const data = await res.json(); catalogItems = data.items || []; @@ -35,13 +81,7 @@ async function loadVersions() { return; } catalogStatus.textContent = `${catalogItems.length} macOS-Installer gefunden (Katalog: ${data.catalog_url ? data.catalog_url.split("/").pop() : "?"})`; - for (const item of catalogItems) { - 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); - } + renderVersionOptions(); } versionSelect.addEventListener("change", () => { @@ -50,7 +90,8 @@ versionSelect.addEventListener("change", () => { const modeText = selectedProduct.mode === "recovery" ? "Big Sur+ Pfad: experimentell, Extraktion kann fehlschlagen." : "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(); }); @@ -85,7 +126,9 @@ async function loadDevices() { } 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; } confirmPath.addEventListener("input", updateStartEnabled); @@ -100,18 +143,20 @@ startBtn.addEventListener("click", async () => { startBtn.disabled = true; progressCard.style.display = "block"; 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", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - product_id: selectedProduct.id, - device_path: selectedDevice.path, - confirm_device_path: confirmPath.value.trim(), - }), + body: JSON.stringify(payload), }); const data = await res.json(); if (data.error) { logEl.textContent = "FEHLER: " + data.error; + updateStartEnabled(); return; } streamJob(data.job_id); @@ -129,6 +174,10 @@ function streamJob(jobId) { } if (data.status === "done" || data.status === "failed" || data.status === "aborted") { es.close(); + if (data.status === "done") { + loadLibrary(); + } + updateStartEnabled(); } }; es.onerror = () => { @@ -136,5 +185,6 @@ function streamJob(jobId) { }; } -loadVersions(); +setMode("stick"); +loadLibrary().then(loadVersions); loadDevices(); diff --git a/frontend/index.html b/frontend/index.html index c985b1f..1001800 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,6 +13,18 @@
+
+
+

Modus

+
+
+ + +
+

Im Download-Modus wird das Original-Image dauerhaft unter ./downloads/library/ 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.

+
+
+

1. macOS-Version

@@ -23,7 +35,7 @@

-
+

2. USB-Stick

@@ -32,10 +44,13 @@
-

3. Bestaetigen & Schreiben

-

ACHTUNG: Der gesamte Inhalt des gewaehlten Sticks wird geloescht.

- - +

3. Bestaetigen & Schreiben

+
+

ACHTUNG: Der gesamte Inhalt des gewaehlten Sticks wird geloescht.

+ + +
+
diff --git a/frontend/style.css b/frontend/style.css index dab51a9..0165ada 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -90,6 +90,24 @@ button:disabled { background: #333844; color: #777; cursor: not-allowed; } padding: 0.3rem 0.7rem; 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 { background: #0d0f14; border: 1px solid var(--border);