- GUI: dritter Modus-Umschalter, Checkbox-Mehrfachauswahl statt Single-Select - Backend: sgdisk partitioniert den Stick in N Apple_HFS-Partitionen (Typ AF00), jede mit Label = macOS-Titel/Version, jede bekommt ihr eigenes Installer-Image per dd -- Macs EFI-Bootpicker (Alt/Option) zeigt alle Partitionen zur Auswahl - Groessen-Check VOR dem Schreiben: bricht mit klarer Fehlermeldung ab wenn der Stick zu klein ist, statt mittendrin zu scheitern - Dockerfile: parted (partprobe) ergaenzt - End-to-end gegen ein echtes Loop-Device verifiziert: sgdisk legt korrekte Partitionen mit Labels an, dd schreibt in die richtige Partition, Inhalt stimmt exakt ueberein (nicht nur behauptet) - README: neuer Abschnitt 4 (Funktionsweise, Ablauf, ehrliche Grenzen), Multiboot als experimentell markiert (erbt recovery-Pfad-Unsicherheit bei Big Sur+, kein echter Mac-Boot-Test bisher durchgefuehrt)
256 lines
10 KiB
JavaScript
256 lines
10 KiB
JavaScript
let selectedProduct = null; // "stick" | "download"
|
|
let selectedProductIds = new Set(); // "multiboot"
|
|
let selectedDevice = null;
|
|
let catalogItems = [];
|
|
let libraryIds = new Set(); // Produkt-IDs die schon lokal (dauerhaft) vorhanden sind
|
|
let mode = "stick"; // "stick" | "download" | "multiboot"
|
|
|
|
const MULTIBOOT_MAX = 8;
|
|
|
|
const step1Title = document.getElementById("step1Title");
|
|
const versionList = document.getElementById("versionList");
|
|
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 multibootHint = document.getElementById("multibootHint");
|
|
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");
|
|
const modeMultibootBtn = document.getElementById("modeMultiboot");
|
|
|
|
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");
|
|
modeMultibootBtn.classList.toggle("active", mode === "multiboot");
|
|
|
|
const needsDevice = mode === "stick" || mode === "multiboot";
|
|
deviceSection.style.display = needsDevice ? "" : "none";
|
|
stickConfirm.style.display = needsDevice ? "" : "none";
|
|
downloadHint.style.display = mode === "download" ? "" : "none";
|
|
multibootHint.style.display = mode === "multiboot" ? "" : "none";
|
|
|
|
step1Title.textContent = mode === "multiboot" ? "1. macOS-Versionen (2-8 auswaehlen)" : "1. macOS-Version";
|
|
step3Title.textContent = mode === "download" ? "3. Herunterladen" : "3. Bestaetigen & Schreiben";
|
|
startBtn.textContent = mode === "download" ? "Image herunterladen" : (mode === "multiboot" ? "Multiboot-Stick erstellen" : "Stick erstellen");
|
|
|
|
renderVersionOptions();
|
|
updateStartEnabled();
|
|
}
|
|
modeStickBtn.addEventListener("click", () => setMode("stick"));
|
|
modeDownloadBtn.addEventListener("click", () => setMode("download"));
|
|
modeMultibootBtn.addEventListener("click", () => setMode("multiboot"));
|
|
|
|
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 updateMultibootSummary() {
|
|
const items = catalogItems.filter((i) => selectedProductIds.has(i.id));
|
|
const totalBytes = items.reduce((sum, i) => sum + (i.size_bytes || 0), 0);
|
|
versionDetail.textContent = items.length
|
|
? `${items.length} Versionen ausgewaehlt, Original-Images zusammen ca. ${fmtSize(totalBytes)} -- Stick muss inkl. Partitions-Puffer entsprechend groesser sein.`
|
|
: `Waehle 2-${MULTIBOOT_MAX} macOS-Versionen fuer den Multiboot-Stick aus.`;
|
|
}
|
|
|
|
function renderVersionOptions() {
|
|
versionList.innerHTML = "";
|
|
for (const item of catalogItems) {
|
|
const row = document.createElement("label");
|
|
row.className = "version-row";
|
|
|
|
const input = document.createElement("input");
|
|
const modeTag = item.mode === "recovery" ? " [experimentell]" : "";
|
|
const offlineTag = libraryIds.has(item.id) ? " -- bereits offline vorhanden" : "";
|
|
const labelText = `${item.title} ${item.version} - ${fmtSize(item.size_bytes)}${modeTag}${offlineTag}`;
|
|
|
|
if (mode === "multiboot") {
|
|
input.type = "checkbox";
|
|
input.checked = selectedProductIds.has(item.id);
|
|
input.addEventListener("change", () => {
|
|
if (input.checked) {
|
|
if (selectedProductIds.size >= MULTIBOOT_MAX) {
|
|
input.checked = false;
|
|
return;
|
|
}
|
|
selectedProductIds.add(item.id);
|
|
} else {
|
|
selectedProductIds.delete(item.id);
|
|
}
|
|
row.classList.toggle("selected", input.checked);
|
|
updateMultibootSummary();
|
|
updateStartEnabled();
|
|
});
|
|
row.classList.toggle("selected", input.checked);
|
|
} else {
|
|
input.type = "radio";
|
|
input.name = "versionPick";
|
|
input.checked = !!selectedProduct && selectedProduct.id === item.id;
|
|
input.addEventListener("change", () => {
|
|
selectedProduct = item;
|
|
const modeText = item.mode === "recovery"
|
|
? "Big Sur+ Pfad: experimentell, Extraktion kann fehlschlagen."
|
|
: "Klassischer dd-Pfad: robust und gut getestet.";
|
|
const offlineText = libraryIds.has(item.id) ? " | bereits offline vorhanden, kein erneuter Download noetig." : "";
|
|
versionDetail.textContent = `${item.title} ${item.version} | ${fmtSize(item.size_bytes)} | ${modeText}${offlineText}`;
|
|
document.querySelectorAll(".version-row").forEach((r) => r.classList.remove("selected"));
|
|
row.classList.add("selected");
|
|
updateStartEnabled();
|
|
});
|
|
row.classList.toggle("selected", input.checked);
|
|
}
|
|
|
|
const span = document.createElement("span");
|
|
span.textContent = labelText;
|
|
row.appendChild(input);
|
|
row.appendChild(span);
|
|
versionList.appendChild(row);
|
|
}
|
|
if (mode === "multiboot") updateMultibootSummary();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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() {
|
|
let ok;
|
|
if (mode === "download") {
|
|
ok = !!selectedProduct;
|
|
} else if (mode === "multiboot") {
|
|
ok = selectedProductIds.size >= 2 && !!selectedDevice && confirmPath.value.trim() === selectedDevice.path;
|
|
} else {
|
|
ok = !!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 = { job_type: mode };
|
|
if (mode === "multiboot") {
|
|
payload.product_ids = Array.from(selectedProductIds);
|
|
} else {
|
|
payload.product_id = selectedProduct.id;
|
|
}
|
|
if (mode === "stick" || mode === "multiboot") {
|
|
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();
|