Files
macos-usb-stick-creator/frontend/app.js
T
ARIA 5c2cdd7543 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.
2026-07-18 10:58:55 +02:00

191 lines
7.4 KiB
JavaScript

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 "?";
const gb = bytes / 1e9;
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...";
const res = await fetch("/api/versions");
const data = await res.json();
catalogItems = data.items || [];
if (data.loading) {
catalogStatus.textContent = "Katalog wird noch geladen, gleich nochmal versuchen...";
setTimeout(loadVersions, 3000);
return;
}
if (data.error) {
catalogStatus.textContent = "Fehler beim Laden: " + data.error;
return;
}
catalogStatus.textContent = `${catalogItems.length} macOS-Installer gefunden (Katalog: ${data.catalog_url ? data.catalog_url.split("/").pop() : "?"})`;
renderVersionOptions();
}
versionSelect.addEventListener("change", () => {
selectedProduct = catalogItems.find((i) => i.id === versionSelect.value) || null;
if (selectedProduct) {
const modeText = selectedProduct.mode === "recovery"
? "Big Sur+ Pfad: experimentell, Extraktion kann fehlschlagen."
: "Klassischer dd-Pfad: robust und gut getestet.";
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();
});
async function loadDevices() {
deviceList.innerHTML = "<div class='detail'>Scanne USB-Geraete...</div>";
const res = await fetch("/api/devices");
const data = await res.json();
deviceList.innerHTML = "";
if (!data.items || data.items.length === 0) {
deviceList.innerHTML = "<div class='detail'>Keine USB-Sticks gefunden. Stick einstecken, dann 'neu scannen'.</div>";
return;
}
for (const dev of data.items) {
const el = document.createElement("div");
const blocked = dev.is_root_disk || dev.mounted;
el.className = "device" + (blocked ? " disabled" : "");
el.innerHTML = `<div>
<div class="path">${dev.path} ${dev.is_root_disk ? "(SYSTEM-PLATTE - gesperrt)" : ""}</div>
<div class="meta">${dev.vendor || ""} ${dev.model || ""} - ${fmtSize(dev.size_bytes)} - ${dev.transport || "?"}${dev.mounted ? " - GEMOUNTET" : ""}</div>
</div>`;
if (!blocked) {
el.addEventListener("click", () => {
document.querySelectorAll(".device").forEach((d) => d.classList.remove("selected"));
el.classList.add("selected");
selectedDevice = dev;
updateStartEnabled();
});
}
deviceList.appendChild(el);
}
}
function updateStartEnabled() {
const ok = mode === "download"
? !!selectedProduct
: selectedProduct && selectedDevice && confirmPath.value.trim() === selectedDevice.path;
startBtn.disabled = !ok;
}
confirmPath.addEventListener("input", updateStartEnabled);
document.getElementById("refreshVersions").addEventListener("click", async () => {
await fetch("/api/versions/refresh", { method: "POST" });
setTimeout(loadVersions, 1500);
});
document.getElementById("refreshDevices").addEventListener("click", loadDevices);
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(payload),
});
const data = await res.json();
if (data.error) {
logEl.textContent = "FEHLER: " + data.error;
updateStartEnabled();
return;
}
streamJob(data.job_id);
});
function streamJob(jobId) {
const es = new EventSource(`/api/jobs/${jobId}/stream`);
es.onmessage = (ev) => {
const data = JSON.parse(ev.data);
progressFill.style.width = (data.progress || 0) + "%";
progressPhase.textContent = `${data.phase} (${data.progress || 0}%) - ${data.status}`;
if (data.msg) {
logEl.textContent += data.msg + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
if (data.status === "done" || data.status === "failed" || data.status === "aborted") {
es.close();
if (data.status === "done") {
loadLibrary();
}
updateStartEnabled();
}
};
es.onerror = () => {
es.close();
};
}
setMode("stick");
loadLibrary().then(loadVersions);
loadDevices();