Initial commit: macOS USB Stick Creator - Docker Compose Web-GUI fuer bootfaehige macOS-Installer
This commit is contained in:
+140
@@ -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();
|
||||
@@ -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 — 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 & 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>
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user