Initial commit: macOS USB Stick Creator - Docker Compose Web-GUI fuer bootfaehige macOS-Installer

This commit is contained in:
ARIA
2026-07-18 08:25:03 +00:00
commit e93ab96faa
11 changed files with 1037 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
let selectedProduct = null;
let selectedDevice = null;
let catalogItems = [];
const versionSelect = document.getElementById("versionSelect");
const versionDetail = document.getElementById("versionDetail");
const catalogStatus = document.getElementById("catalogStatus");
const deviceList = document.getElementById("deviceList");
const confirmPath = document.getElementById("confirmPath");
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");
function fmtSize(bytes) {
if (!bytes) return "?";
const gb = bytes / 1e9;
return gb >= 1 ? gb.toFixed(1) + " GB" : (bytes / 1e6).toFixed(0) + " MB";
}
async function loadVersions() {
catalogStatus.textContent = "Lade Apple-Katalog...";
versionSelect.innerHTML = "";
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() : "?"})`;
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);
}
}
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.";
versionDetail.textContent = `${selectedProduct.title} ${selectedProduct.version} | ${fmtSize(selectedProduct.size_bytes)} | ${modeText}`;
}
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 = 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 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(),
}),
});
const data = await res.json();
if (data.error) {
logEl.textContent = "FEHLER: " + data.error;
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();
}
};
es.onerror = () => {
es.close();
};
}
loadVersions();
loadDevices();
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>macOS USB Installer Tool</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>macOS USB Installer Tool</h1>
<p class="sub">Laeuft lokal via Docker &mdash; laedt Installer direkt von Apple, kein Mac noetig.</p>
</header>
<main>
<section class="card">
<div class="card-head">
<h2>1. macOS-Version</h2>
<button id="refreshVersions" class="btn-ghost">neu laden</button>
</div>
<div id="catalogStatus" class="status"></div>
<select id="versionSelect" size="10"></select>
<p id="versionDetail" class="detail"></p>
</section>
<section class="card">
<div class="card-head">
<h2>2. USB-Stick</h2>
<button id="refreshDevices" class="btn-ghost">neu scannen</button>
</div>
<div id="deviceList"></div>
</section>
<section class="card">
<h2>3. Bestaetigen &amp; 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">
<button id="startBtn" disabled>Stick erstellen</button>
</section>
<section class="card" id="progressCard" style="display:none">
<h2>Fortschritt</h2>
<div class="progress-bar"><div id="progressFill" class="progress-fill"></div></div>
<div id="progressPhase" class="detail"></div>
<pre id="log"></pre>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
:root {
--bg: #0f1115;
--card: #171a21;
--text: #e6e8ec;
--muted: #8b92a3;
--accent: #4f8cff;
--warn: #ff6b6b;
--ok: #46c98a;
--border: #2a2e38;
}
* { box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0 0 3rem;
}
header {
padding: 2rem 2rem 1rem;
border-bottom: 1px solid var(--border);
}
header h1 { margin: 0 0 0.25rem; font-size: 1.5rem; }
.sub { color: var(--muted); margin: 0; font-size: 0.9rem; }
main {
max-width: 720px;
margin: 1.5rem auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
}
.card-head { display: flex; justify-content: space-between; align-items: center; }
.card h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
select {
width: 100%;
background: #0d0f14;
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.4rem;
}
select option { padding: 0.3rem; }
.detail { color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
.status { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.5rem; }
.device {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.4rem;
cursor: pointer;
}
.device.selected { border-color: var(--accent); background: #1b2333; }
.device.disabled { opacity: 0.4; cursor: not-allowed; }
.device .path { font-weight: 600; }
.device .meta { color: var(--muted); font-size: 0.82rem; }
.warn { color: var(--warn); font-weight: 600; font-size: 0.9rem; }
input[type=text] {
width: 100%;
background: #0d0f14;
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem;
margin: 0.4rem 0 1rem;
}
button {
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
padding: 0.55rem 1rem;
cursor: pointer;
font-size: 0.9rem;
}
button:disabled { background: #333844; color: #777; cursor: not-allowed; }
.btn-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 0.3rem 0.7rem;
font-size: 0.8rem;
}
.progress-bar {
background: #0d0f14;
border: 1px solid var(--border);
border-radius: 6px;
height: 14px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill { background: var(--accent); height: 100%; width: 0%; transition: width 0.2s; }
pre#log {
background: #0d0f14;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.6rem;
height: 220px;
overflow-y: auto;
font-size: 0.78rem;
white-space: pre-wrap;
}
code { background: #0d0f14; padding: 0.1rem 0.3rem; border-radius: 4px; }