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:
ARIA
2026-07-18 10:58:55 +02:00
parent 40964b9130
commit 5c2cdd7543
6 changed files with 307 additions and 46 deletions
+29 -1
View File
@@ -52,7 +52,35 @@ kein Bug im eigentlichen Sinn, sondern ein Zeichen dass sich intern was
geaendert hat &mdash; dann muss `backend/installer.py` (`_extract_recovery_dmg`) geaendert hat &mdash; 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) &mdash; wie bisher.
- **"Nur herunterladen (Offline-Bibliothek)"** &mdash; 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 &mdash; solange Internet da ist &mdash; 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 &mdash; 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 &mdash; 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 &mdash; noetig, um Sticks direkt zu partitionieren `/run/udev` vom Host durch &mdash; 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 &mdash; 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 &amp; Schreiben</h2> <h2 id="step3Title">3. Bestaetigen &amp; 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>
+18
View File
@@ -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);