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:
+66
-16
@@ -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();
|
||||
|
||||
+20
-5
@@ -13,6 +13,18 @@
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<div class="card-head">
|
||||
<h2>1. macOS-Version</h2>
|
||||
@@ -23,7 +35,7 @@
|
||||
<p id="versionDetail" class="detail"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<section class="card" id="deviceSection">
|
||||
<div class="card-head">
|
||||
<h2>2. USB-Stick</h2>
|
||||
<button id="refreshDevices" class="btn-ghost">neu scannen</button>
|
||||
@@ -32,10 +44,13 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>3. Bestaetigen & Schreiben</h2>
|
||||
<p class="warn">ACHTUNG: Der gesamte Inhalt des gewaehlten Sticks wird geloescht.</p>
|
||||
<label>Tippe den Geraete-Pfad zur Bestaetigung ein (z.B. <code>/dev/sdb</code>):</label>
|
||||
<input type="text" id="confirmPath" placeholder="/dev/sdX">
|
||||
<h2 id="step3Title">3. Bestaetigen & Schreiben</h2>
|
||||
<div id="stickConfirm">
|
||||
<p class="warn">ACHTUNG: Der gesamte Inhalt des gewaehlten Sticks wird geloescht.</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user