Initial commit: macOS USB Stick Creator - Docker Compose Web-GUI fuer bootfaehige macOS-Installer
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
|||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 python3-pip \
|
||||||
|
util-linux \
|
||||||
|
gdisk \
|
||||||
|
hfsprogs \
|
||||||
|
dmg2img \
|
||||||
|
xar \
|
||||||
|
cpio \
|
||||||
|
xz-utils liblzma-dev \
|
||||||
|
git build-essential \
|
||||||
|
ca-certificates curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# pbzx: entpackt Apples pbzx-komprimierte Installer-Payloads (macOS Big Sur+).
|
||||||
|
# Bewusst aus dem gepflegten Upstream-Repo gebaut statt selbst nachgebaut --
|
||||||
|
# das Binaerformat ist reverse-engineered und ein fertiges, getestetes Tool
|
||||||
|
# ist zuverlaessiger als eine selbstgeschriebene Parser-Vermutung.
|
||||||
|
RUN git clone --depth 1 https://github.com/mackyle/pbzx.git /tmp/pbzx \
|
||||||
|
&& cd /tmp/pbzx && make && install -m 755 pbzx /usr/local/bin/pbzx \
|
||||||
|
&& rm -rf /tmp/pbzx
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/requirements.txt /app/backend/requirements.txt
|
||||||
|
RUN pip3 install --no-cache-dir --break-system-packages -r /app/backend/requirements.txt
|
||||||
|
|
||||||
|
COPY backend /app/backend
|
||||||
|
COPY frontend /app/frontend
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
CMD ["python3", "/app/backend/app.py"]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# macOS USB Installer Tool
|
||||||
|
|
||||||
|
Lokale Web-GUI (Docker Compose) die macOS-Installer direkt von Apples
|
||||||
|
Servern laedt und einen bootfaehigen USB-Stick daraus baut — ohne Mac.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann im Browser: **http://127.0.0.1:8080**
|
||||||
|
|
||||||
|
Der Port bindet bewusst NUR an `127.0.0.1` (nicht `0.0.0.0`) — von aussen
|
||||||
|
im Netzwerk nicht erreichbar.
|
||||||
|
|
||||||
|
## Wie es funktioniert
|
||||||
|
|
||||||
|
### 1. Versions-Liste (automatisch aktuell)
|
||||||
|
Beim Container-Start wird gegen Apples echten Software-Update-Katalog
|
||||||
|
(`swscan.apple.com`) geprobt, welche macOS-Installer aktuell verfuegbar
|
||||||
|
sind. Das Namensschema der Katalog-URLs ist bekannt und wurde live
|
||||||
|
verifiziert (Stand: Erstellung dieses Tools). Wenn Apple eine neue
|
||||||
|
macOS-Hauptversion veroeffentlicht, taucht sie **automatisch beim naechsten
|
||||||
|
Container-Start** in der Liste auf — ohne Code-Aenderung, solange Apple
|
||||||
|
das Namensschema beibehaelt (das ist seit vielen Jahren stabil, aber keine
|
||||||
|
100%-Garantie fuer alle Zeit).
|
||||||
|
|
||||||
|
Per "neu laden"-Button in der GUI kann der Katalog auch ohne Neustart
|
||||||
|
aktualisiert werden.
|
||||||
|
|
||||||
|
### 2. Zwei Installations-Pfade
|
||||||
|
|
||||||
|
| macOS-Version | Pfad | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| bis Catalina (10.15) | `legacy_dmg`: BaseSystem.dmg direkt per `dd` auf den Stick | **robust, gut etabliert** |
|
||||||
|
| ab Big Sur (11) aufwaerts | `recovery`: `InstallAssistant.pkg` wird entpackt (`xar` → `pbzx` → `cpio`), das darin enthaltene `SharedSupport.dmg`/`BaseSystem.dmg` wird dann per `dd` geschrieben | **experimentell** |
|
||||||
|
|
||||||
|
**Warum der Unterschied:** Ab macOS Big Sur liefert Apple keinen fertigen
|
||||||
|
Installer-Datentraeger mehr, sondern ein Programm-Paket. Das eigentliche
|
||||||
|
Tool das daraus einen vollstaendigen Multi-Volume-Installer-Stick baut
|
||||||
|
(`createinstallmedia`) laeuft nur unter echtem macOS. Dieses Tool geht
|
||||||
|
stattdessen den Community-erprobten Weg: das darin enthaltene
|
||||||
|
Recovery-/BaseSystem-Image direkt extrahieren und schreiben. Der Stick
|
||||||
|
bootet damit in ein Internet-Recovery-System, das den Rest der
|
||||||
|
Installation von Apples Servern nachlaedt.
|
||||||
|
|
||||||
|
**Das ist der fragilste Teil des ganzen Tools.** Apple hat den internen
|
||||||
|
Aufbau von `InstallAssistant.pkg` in der Vergangenheit leicht veraendert.
|
||||||
|
Wenn die Extraktion fehlschlaegt (Fehlermeldung im Log-Fenster), ist das
|
||||||
|
kein Bug im eigentlichen Sinn, sondern ein Zeichen dass sich intern was
|
||||||
|
geaendert hat — dann muss `backend/installer.py` (`_extract_recovery_dmg`)
|
||||||
|
angepasst werden.
|
||||||
|
|
||||||
|
### 3. USB-Geraete-Erkennung
|
||||||
|
Der Container laeuft mit `privileged: true` und mountet `/dev` sowie
|
||||||
|
`/run/udev` vom Host durch — noetig, um Sticks direkt zu partitionieren
|
||||||
|
und zu beschreiben. Das ist bewusst so gebaut, bedeutet aber: der Container
|
||||||
|
hat vollen Zugriff auf **alle** Block-Devices des Rechners. Deshalb:
|
||||||
|
|
||||||
|
- Port ist NUR auf `127.0.0.1` gebunden (kein Netzwerk-Zugriff von aussen).
|
||||||
|
- Die System-Platte (auf der `/` liegt) wird automatisch als Ziel gesperrt.
|
||||||
|
- Nur Geraete mit USB-Transport werden als Ziel akzeptiert.
|
||||||
|
- Vor dem Schreiben muss der exakte Geraetepfad (z.B. `/dev/sdb`) nochmal
|
||||||
|
von Hand eingetippt werden — kein versehentliches Ein-Klick-Loeschen.
|
||||||
|
|
||||||
|
**Trotzdem gilt:** das Tool schreibt mit `dd` und `wipefs` direkt auf ein
|
||||||
|
Block-Device. Wenn du das falsche Geraet auswaehlst, sind die Daten weg.
|
||||||
|
Vor dem Start immer pruefen, welcher Stick tatsaechlich gemeint ist (Groesse
|
||||||
|
und Modell in der GUI vergleichen).
|
||||||
|
|
||||||
|
## Voraussetzungen auf dem Host
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Linux (getestet gegen die Grundannahmen von `lsblk`/`/dev`/`udev`; auf
|
||||||
|
macOS/Windows-Docker-Desktop funktioniert das Device-Passthrough so nicht)
|
||||||
|
|
||||||
|
## Bekannte Grenzen (ehrlich, nicht schoengeredet)
|
||||||
|
1. Der `recovery`-Pfad (Big Sur+) ist nicht production-hardened getestet
|
||||||
|
worden, sondern nach dokumentiertem, in der Hackintosh-/Recovery-Community
|
||||||
|
etabliertem Verfahren gebaut. Rechne bei sehr neuen macOS-Versionen mit
|
||||||
|
Nacharbeit.
|
||||||
|
2. Die Katalog-Zukunftssicherheit haengt an Apples URL-Namensschema. Bricht
|
||||||
|
Apple das Schema, muss `backend/catalog.py` angepasst werden (kurzer Fix,
|
||||||
|
kein Rewrite).
|
||||||
|
3. Kein Windows/macOS-Docker-Host-Support fuer das Device-Passthrough —
|
||||||
|
nur natives Linux.
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Flask, Response, jsonify, request, send_from_directory
|
||||||
|
|
||||||
|
import catalog
|
||||||
|
import installer
|
||||||
|
from devices import list_usb_sticks
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
log = logging.getLogger("app")
|
||||||
|
|
||||||
|
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
||||||
|
app = Flask(__name__, static_folder=None)
|
||||||
|
|
||||||
|
_catalog_cache = {"items": [], "catalog_url": None, "loaded_at": None, "error": None, "loading": True}
|
||||||
|
_catalog_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_catalog():
|
||||||
|
with _catalog_lock:
|
||||||
|
_catalog_cache["loading"] = True
|
||||||
|
try:
|
||||||
|
items, url = catalog.list_macos_installers()
|
||||||
|
with _catalog_lock:
|
||||||
|
_catalog_cache.update(
|
||||||
|
items=items, catalog_url=url, loaded_at=time.time(), error=None, loading=False
|
||||||
|
)
|
||||||
|
log.info("Katalog geladen: %d macOS-Installer gefunden (%s)", len(items), url)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.exception("Katalog-Laden fehlgeschlagen")
|
||||||
|
with _catalog_lock:
|
||||||
|
_catalog_cache.update(error=str(exc), loading=False)
|
||||||
|
|
||||||
|
|
||||||
|
# Beim Container-Start einmal im Hintergrund laden, damit die GUI sofort
|
||||||
|
# ansprechbar ist und nicht auf den ersten Request wartet.
|
||||||
|
threading.Thread(target=_load_catalog, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
return send_from_directory(FRONTEND_DIR, "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/<path:filename>")
|
||||||
|
def static_files(filename):
|
||||||
|
return send_from_directory(FRONTEND_DIR, filename)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/versions")
|
||||||
|
def api_versions():
|
||||||
|
with _catalog_lock:
|
||||||
|
return jsonify(dict(_catalog_cache))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/versions/refresh")
|
||||||
|
def api_versions_refresh():
|
||||||
|
threading.Thread(target=_load_catalog, daemon=True).start()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/devices")
|
||||||
|
def api_devices():
|
||||||
|
try:
|
||||||
|
return jsonify({"items": list_usb_sticks()})
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.exception("device listing failed")
|
||||||
|
return jsonify({"items": [], "error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/jobs")
|
||||||
|
def api_create_job():
|
||||||
|
body = request.get_json(force=True)
|
||||||
|
product_id = body.get("product_id")
|
||||||
|
device_path = body.get("device_path")
|
||||||
|
confirm_path = body.get("confirm_device_path")
|
||||||
|
|
||||||
|
with _catalog_lock:
|
||||||
|
product = next((p for p in _catalog_cache["items"] if p["id"] == product_id), None)
|
||||||
|
if not product:
|
||||||
|
return jsonify({"error": "Unbekannte macOS-Version -- Katalog neu laden."}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
job = installer.create_job(product, device_path, confirm_path)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
return jsonify({"job_id": job.id})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/jobs/<job_id>")
|
||||||
|
def api_job_status(job_id):
|
||||||
|
job = installer.JOBS.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"error": "unbekannter Job"}), 404
|
||||||
|
return jsonify(job.snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/jobs/<job_id>/stream")
|
||||||
|
def api_job_stream(job_id):
|
||||||
|
job = installer.JOBS.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"error": "unbekannter Job"}), 404
|
||||||
|
|
||||||
|
def gen():
|
||||||
|
for event in job.stream():
|
||||||
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
|
|
||||||
|
return Response(gen(), mimetype="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8080, threaded=True)
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
Apple Software-Update-Katalog: Liste verfuegbarer macOS-Installer laden.
|
||||||
|
|
||||||
|
Funktionsweise (live gegen swscan.apple.com verifiziert, 2026-07-18):
|
||||||
|
|
||||||
|
Apple veroeffentlicht einen kumulativen Produktkatalog unter einer URL, deren
|
||||||
|
Dateiname die Kette der unterstuetzten Major-Versionen absteigend auflistet,
|
||||||
|
z.B. fuer den aktuell (Stand Verifikation) neuesten Stand:
|
||||||
|
|
||||||
|
index-16-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-
|
||||||
|
mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz
|
||||||
|
|
||||||
|
Jedes Mal wenn Apple eine neue macOS-Hauptversion (naechstes Jahr z.B. 17)
|
||||||
|
veroeffentlicht, legt Apple einen NEUEN Katalog mit einer um 1 hoeheren
|
||||||
|
fuehrenden Zahl an ("index-17-16-15-..."). Der neue Katalog ist kumulativ --
|
||||||
|
er enthaelt weiterhin alle aelteren Installer. Wir muessen also nur bei jedem
|
||||||
|
Start herausfinden, welche fuehrende Zahl AKTUELL die hoechste gueltige ist --
|
||||||
|
das macht find_latest_catalog_url() per HTTP-Probing.
|
||||||
|
|
||||||
|
WICHTIG / Grenze der Zukunftssicherheit: Das Namensschema selbst
|
||||||
|
(("index-<N>-<N-1>-...-legacy-suffix.sucatalog.gz") liegt in Apples Hand.
|
||||||
|
Es ist seit vielen Jahren stabil, aber falls Apple es grundlegend aendert,
|
||||||
|
muss dieses Modul angepasst werden -- 100% garantiert "nie wieder anfassen"
|
||||||
|
gibt es bei einer nicht dokumentierten Drittanbieter-Schnittstelle nicht.
|
||||||
|
Was wir bieten: neue macOS-Versionen tauchen OHNE Code-Aenderung automatisch
|
||||||
|
auf, solange Apple das Schema beibehaelt.
|
||||||
|
"""
|
||||||
|
import gzip
|
||||||
|
import logging
|
||||||
|
import plistlib
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
log = logging.getLogger("catalog")
|
||||||
|
|
||||||
|
CATALOG_HOST = "https://swscan.apple.com/content/catalogs/others/"
|
||||||
|
LEGACY_SUFFIX = (
|
||||||
|
"10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-"
|
||||||
|
"mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz"
|
||||||
|
)
|
||||||
|
FLOOR = 12 # aelteste bekannte Kette, ab der wir hochzaehlen
|
||||||
|
PROBE_HEADROOM = 15 # so viele Fehlschlaege in Folge tolerieren wir, bevor wir abbrechen
|
||||||
|
|
||||||
|
|
||||||
|
def _build_catalog_url(max_major: int) -> str:
|
||||||
|
chain = "-".join(str(n) for n in range(max_major, 11, -1))
|
||||||
|
return f"{CATALOG_HOST}index-{chain}-{LEGACY_SUFFIX}"
|
||||||
|
|
||||||
|
|
||||||
|
def find_latest_catalog_url(timeout: float = 8) -> str:
|
||||||
|
best = None
|
||||||
|
misses = 0
|
||||||
|
m = FLOOR
|
||||||
|
while misses < PROBE_HEADROOM:
|
||||||
|
url = _build_catalog_url(m)
|
||||||
|
try:
|
||||||
|
r = requests.head(url, timeout=timeout)
|
||||||
|
ok = r.status_code == 200
|
||||||
|
except requests.RequestException:
|
||||||
|
ok = False
|
||||||
|
if ok:
|
||||||
|
best = url
|
||||||
|
misses = 0
|
||||||
|
else:
|
||||||
|
misses += 1
|
||||||
|
m += 1
|
||||||
|
if not best:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Konnte keinen Apple SUCatalog finden -- Apple hat vermutlich das "
|
||||||
|
"URL-Schema geaendert. catalog.py muss aktualisiert werden."
|
||||||
|
)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_catalog_products(url: str, timeout: float = 60) -> dict:
|
||||||
|
r = requests.get(url, timeout=timeout)
|
||||||
|
r.raise_for_status()
|
||||||
|
raw = gzip.decompress(r.content)
|
||||||
|
data = plistlib.loads(raw)
|
||||||
|
return data.get("Products", {})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_macos_installer(product: dict) -> bool:
|
||||||
|
emi = product.get("ExtendedMetaInfo", {}) or {}
|
||||||
|
ids = emi.get("InstallAssistantPackageIdentifiers", {}) or {}
|
||||||
|
return ids.get("OSInstall") == "com.apple.mpkg.OSInstall"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_product_metadata(product: dict, timeout: float = 20):
|
||||||
|
smd_url = product.get("ServerMetadataURL")
|
||||||
|
title, version = None, None
|
||||||
|
if smd_url:
|
||||||
|
try:
|
||||||
|
r = requests.get(smd_url, timeout=timeout)
|
||||||
|
r.raise_for_status()
|
||||||
|
meta = plistlib.loads(r.content)
|
||||||
|
version = meta.get("CFBundleShortVersionString")
|
||||||
|
loc = meta.get("localization", {}) or {}
|
||||||
|
en = loc.get("English") or (next(iter(loc.values())) if loc else {})
|
||||||
|
title = en.get("title")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("Metadaten-Fetch fehlgeschlagen fuer %s: %s", smd_url, exc)
|
||||||
|
return title, version
|
||||||
|
|
||||||
|
|
||||||
|
def _find_packages(product: dict):
|
||||||
|
"""Liefert (install_assistant_pkg, base_system_dmg, total_size_bytes)."""
|
||||||
|
packages = product.get("Packages", []) or []
|
||||||
|
install_assistant = None
|
||||||
|
base_system = None
|
||||||
|
total_size = 0
|
||||||
|
for p in packages:
|
||||||
|
url = p.get("URL", "")
|
||||||
|
size = p.get("Size", 0) or 0
|
||||||
|
total_size += size
|
||||||
|
if url.endswith("InstallAssistant.pkg"):
|
||||||
|
install_assistant = {"url": url, "size": size}
|
||||||
|
if url.endswith("BaseSystem.dmg") or url.endswith("InstallESD.dmg"):
|
||||||
|
base_system = {"url": url, "size": size}
|
||||||
|
return install_assistant, base_system, total_size
|
||||||
|
|
||||||
|
|
||||||
|
def list_macos_installers():
|
||||||
|
"""Gibt (items, catalog_url) zurueck. items ist eine sortierte Liste von dicts."""
|
||||||
|
url = find_latest_catalog_url()
|
||||||
|
products = _fetch_catalog_products(url)
|
||||||
|
results = []
|
||||||
|
for pid, product in products.items():
|
||||||
|
if not _is_macos_installer(product):
|
||||||
|
continue
|
||||||
|
ia, base, size = _find_packages(product)
|
||||||
|
if not ia and not base:
|
||||||
|
continue
|
||||||
|
title, version = _fetch_product_metadata(product)
|
||||||
|
post_date = product.get("PostDate")
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"id": pid,
|
||||||
|
"title": title or "macOS (Name unbekannt)",
|
||||||
|
"version": version or "0",
|
||||||
|
"post_date": post_date.isoformat() if post_date else None,
|
||||||
|
"size_bytes": size,
|
||||||
|
# "recovery" = Big Sur+ (InstallAssistant.pkg, Extraktion noetig, experimentell)
|
||||||
|
# "legacy_dmg" = bis Catalina (direktes dd-Image, robust)
|
||||||
|
"mode": "recovery" if ia else "legacy_dmg",
|
||||||
|
"install_assistant_url": ia["url"] if ia else None,
|
||||||
|
"base_system_url": base["url"] if base else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sort_key(item):
|
||||||
|
try:
|
||||||
|
return tuple(int(x) for x in item["version"].split("."))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return (0,)
|
||||||
|
|
||||||
|
results.sort(key=_sort_key, reverse=True)
|
||||||
|
return results, url
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""USB-Block-Devices des Hosts auflisten (via lsblk, /dev + /run/udev gemountet)."""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def list_usb_sticks():
|
||||||
|
out = subprocess.run(
|
||||||
|
["lsblk", "-J", "-b", "-o", "NAME,SIZE,MODEL,VENDOR,TRAN,RM,TYPE,MOUNTPOINT"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout
|
||||||
|
data = json.loads(out)
|
||||||
|
root = get_root_disk_name()
|
||||||
|
sticks = []
|
||||||
|
for dev in data.get("blockdevices", []):
|
||||||
|
if dev.get("type") != "disk":
|
||||||
|
continue
|
||||||
|
is_removable = str(dev.get("rm")) in ("1", "True", "true")
|
||||||
|
is_usb_tran = dev.get("tran") == "usb"
|
||||||
|
if not (is_removable or is_usb_tran):
|
||||||
|
continue
|
||||||
|
name = dev["name"]
|
||||||
|
mounted = bool(dev.get("mountpoint")) or any(
|
||||||
|
c.get("mountpoint") for c in (dev.get("children") or [])
|
||||||
|
)
|
||||||
|
sticks.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"path": f"/dev/{name}",
|
||||||
|
"size_bytes": int(dev.get("size") or 0),
|
||||||
|
"model": (dev.get("model") or "").strip(),
|
||||||
|
"vendor": (dev.get("vendor") or "").strip(),
|
||||||
|
"transport": dev.get("tran"),
|
||||||
|
"mounted": mounted,
|
||||||
|
"is_root_disk": name == root,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sticks
|
||||||
|
|
||||||
|
|
||||||
|
def get_root_disk_name():
|
||||||
|
"""Name (ohne /dev/) der Platte, auf der / liegt -- fuer Schutz vor Selbstzerstoerung."""
|
||||||
|
try:
|
||||||
|
src = subprocess.run(
|
||||||
|
["findmnt", "-n", "-o", "SOURCE", "/"], capture_output=True, text=True, check=True
|
||||||
|
).stdout.strip()
|
||||||
|
pk = subprocess.run(
|
||||||
|
["lsblk", "-no", "PKNAME", src], capture_output=True, text=True
|
||||||
|
).stdout.strip()
|
||||||
|
return pk or src.replace("/dev/", "")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
Job-Engine: Download eines macOS-Installers + Beschreiben eines USB-Sticks.
|
||||||
|
|
||||||
|
Zwei Pfade, unterschiedlich robust:
|
||||||
|
|
||||||
|
legacy_dmg (macOS bis Catalina/10.15): Apple liefert ein fertiges,
|
||||||
|
bootfaehiges BaseSystem.dmg/InstallESD.dmg. Wir wandeln es mit
|
||||||
|
dmg2img in ein rohes Image und schreiben es 1:1 mit dd auf den
|
||||||
|
Stick. Etablierter, zuverlaessiger Weg.
|
||||||
|
|
||||||
|
recovery (macOS ab Big Sur/11): Apple liefert nur noch ein
|
||||||
|
InstallAssistant.pkg. Das eigentliche createinstallmedia laeuft
|
||||||
|
nur unter echtem macOS. Wir extrahieren das .pkg (xar -> pbzx
|
||||||
|
-> cpio) und suchen darin ein SharedSupport.dmg/BaseSystem.dmg,
|
||||||
|
das wir dann genauso per dd schreiben. EXPERIMENTELL: Apple hat
|
||||||
|
den internen Aufbau von InstallAssistant.pkg mehrfach leicht
|
||||||
|
geaendert, das kann brechen. Klar als "experimentell" markiert.
|
||||||
|
|
||||||
|
Sicherheits-Leitplanken:
|
||||||
|
- Ziel-Device wird gegen die Root-Disk des Hosts geprueft (Refuse).
|
||||||
|
- Ziel-Device muss "removable" oder USB-Transport sein (Refuse sonst).
|
||||||
|
- Aufrufer muss den Device-Pfad im Request nochmal exakt bestaetigen.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from queue import Queue, Empty
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from devices import list_usb_sticks
|
||||||
|
|
||||||
|
log = logging.getLogger("installer")
|
||||||
|
|
||||||
|
DOWNLOAD_DIR = "/data/downloads"
|
||||||
|
JOBS: dict[str, "Job"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class JobAborted(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Job:
|
||||||
|
def __init__(self, product: dict, device_path: str):
|
||||||
|
self.id = str(uuid.uuid4())
|
||||||
|
self.product = product
|
||||||
|
self.device_path = device_path
|
||||||
|
self.status = "queued" # queued -> running -> done | failed | aborted
|
||||||
|
self.phase = "warten"
|
||||||
|
self.progress = 0 # 0-100 im aktuellen Phase
|
||||||
|
self.error = None
|
||||||
|
self._q: Queue = Queue()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.history = []
|
||||||
|
|
||||||
|
def emit(self, phase=None, progress=None, msg=None, status=None):
|
||||||
|
with self._lock:
|
||||||
|
if phase is not None:
|
||||||
|
self.phase = phase
|
||||||
|
if progress is not None:
|
||||||
|
self.progress = progress
|
||||||
|
if status is not None:
|
||||||
|
self.status = status
|
||||||
|
event = {
|
||||||
|
"phase": self.phase,
|
||||||
|
"progress": self.progress,
|
||||||
|
"status": self.status,
|
||||||
|
"msg": msg,
|
||||||
|
"ts": time.time(),
|
||||||
|
}
|
||||||
|
self.history.append(event)
|
||||||
|
self._q.put(event)
|
||||||
|
|
||||||
|
def stream(self):
|
||||||
|
# erst die bisherige Historie nachliefern, dann live weiter
|
||||||
|
for event in list(self.history):
|
||||||
|
yield event
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
yield self._q.get(timeout=30)
|
||||||
|
except Empty:
|
||||||
|
if self.status in ("done", "failed", "aborted"):
|
||||||
|
return
|
||||||
|
yield {"phase": self.phase, "progress": self.progress, "status": self.status, "msg": None, "ts": time.time()}
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"status": self.status,
|
||||||
|
"phase": self.phase,
|
||||||
|
"progress": self.progress,
|
||||||
|
"error": self.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_job(product: dict, device_path: str, confirm_path: str) -> Job:
|
||||||
|
if confirm_path != device_path:
|
||||||
|
raise ValueError("Bestaetigungs-Pfad stimmt nicht mit dem Ziel-Geraet ueberein.")
|
||||||
|
|
||||||
|
sticks = list_usb_sticks()
|
||||||
|
match = next((s for s in sticks if s["path"] == device_path), None)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Geraet {device_path} nicht in der aktuellen USB-Liste gefunden.")
|
||||||
|
if match["is_root_disk"]:
|
||||||
|
raise ValueError("Verweigert: das ist die System-Platte des Hosts.")
|
||||||
|
if match["mounted"]:
|
||||||
|
raise ValueError("Geraet ist aktuell gemountet -- bitte vorher aushaengen.")
|
||||||
|
if not (match["transport"] == "usb"):
|
||||||
|
raise ValueError("Geraet ist nicht als USB-Transport erkannt -- Sicherheitsstop.")
|
||||||
|
|
||||||
|
job = Job(product, device_path)
|
||||||
|
JOBS[job.id] = job
|
||||||
|
t = threading.Thread(target=_run_job, args=(job,), daemon=True)
|
||||||
|
t.start()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _run(job: Job, cmd: list[str], phase: str, progress_from_stderr=False):
|
||||||
|
log.info("job %s: %s", job.id, " ".join(cmd))
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
||||||
|
for line in proc.stdout:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
job.emit(phase=phase, msg=line)
|
||||||
|
ret = proc.wait()
|
||||||
|
if ret != 0:
|
||||||
|
raise RuntimeError(f"Befehl fehlgeschlagen ({ret}): {' '.join(cmd)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _download(job: Job, url: str, dest_path: str):
|
||||||
|
job.emit(phase="download", progress=0, msg=f"Lade {url}")
|
||||||
|
with requests.get(url, stream=True, timeout=30) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
total = int(r.headers.get("Content-Length", 0)) or None
|
||||||
|
done = 0
|
||||||
|
last_pct = -1
|
||||||
|
with open(dest_path, "wb") as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=1024 * 1024 * 4):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
f.write(chunk)
|
||||||
|
done += len(chunk)
|
||||||
|
if total:
|
||||||
|
pct = int(done * 100 / total)
|
||||||
|
if pct != last_pct:
|
||||||
|
job.emit(phase="download", progress=pct)
|
||||||
|
last_pct = pct
|
||||||
|
job.emit(phase="download", progress=100, msg="Download fertig")
|
||||||
|
|
||||||
|
|
||||||
|
def _dmg_to_raw(job: Job, dmg_path: str) -> str:
|
||||||
|
raw_path = dmg_path + ".img"
|
||||||
|
job.emit(phase="konvertieren", progress=0, msg="dmg2img: DMG -> rohes Image")
|
||||||
|
_run(job, ["dmg2img", "-i", dmg_path, "-o", raw_path], phase="konvertieren")
|
||||||
|
return raw_path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_raw_to_device(job: Job, raw_path: str, device_path: str):
|
||||||
|
job.emit(phase="schreiben", progress=0, msg=f"Schreibe {raw_path} -> {device_path} (dd)")
|
||||||
|
_run(job, ["wipefs", "-a", device_path], phase="schreiben")
|
||||||
|
size = os.path.getsize(raw_path)
|
||||||
|
cmd = ["dd", f"if={raw_path}", f"of={device_path}", "bs=4M", "status=progress", "conv=fsync"]
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
||||||
|
for line in proc.stdout:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
job.emit(phase="schreiben", msg=line)
|
||||||
|
# dd status=progress schreibt Zeilen wie "1234567890 bytes (1.2 GB, 1.1 GiB) copied, ..."
|
||||||
|
try:
|
||||||
|
bytes_done = int(line.split(" ")[0])
|
||||||
|
pct = min(100, int(bytes_done * 100 / size))
|
||||||
|
job.emit(phase="schreiben", progress=pct)
|
||||||
|
except (ValueError, IndexError, ZeroDivisionError):
|
||||||
|
pass
|
||||||
|
ret = proc.wait()
|
||||||
|
if ret != 0:
|
||||||
|
raise RuntimeError("dd fehlgeschlagen")
|
||||||
|
job.emit(phase="schreiben", progress=100, msg="dd fertig, sync...")
|
||||||
|
subprocess.run(["sync"])
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_recovery_dmg(job: Job, pkg_path: str, work_dir: str) -> str:
|
||||||
|
"""Experimentell: InstallAssistant.pkg -> xar -> pbzx -> cpio, sucht SharedSupport/BaseSystem.dmg."""
|
||||||
|
job.emit(phase="extrahieren", progress=0, msg="xar: InstallAssistant.pkg entpacken (experimentell)")
|
||||||
|
extract_dir = os.path.join(work_dir, "xar")
|
||||||
|
os.makedirs(extract_dir, exist_ok=True)
|
||||||
|
_run(job, ["xar", "-xf", pkg_path, "-C", extract_dir], phase="extrahieren")
|
||||||
|
|
||||||
|
payloads = []
|
||||||
|
for root, _dirs, files in os.walk(extract_dir):
|
||||||
|
for fn in files:
|
||||||
|
if fn == "Payload":
|
||||||
|
payloads.append(os.path.join(root, fn))
|
||||||
|
if not payloads:
|
||||||
|
raise RuntimeError("Kein Payload in InstallAssistant.pkg gefunden -- Apple-Struktur hat sich vermutlich geaendert.")
|
||||||
|
|
||||||
|
cpio_dir = os.path.join(work_dir, "cpio")
|
||||||
|
os.makedirs(cpio_dir, exist_ok=True)
|
||||||
|
for payload in payloads:
|
||||||
|
job.emit(phase="extrahieren", msg=f"pbzx+cpio: {payload}")
|
||||||
|
# pbzx dekomprimiert das payload, cpio packt den entstandenen Stream aus
|
||||||
|
pbzx_proc = subprocess.Popen(["pbzx", "-n", payload], stdout=subprocess.PIPE)
|
||||||
|
cpio_proc = subprocess.Popen(
|
||||||
|
["cpio", "-idm"], cwd=cpio_dir, stdin=pbzx_proc.stdout,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
)
|
||||||
|
pbzx_proc.stdout.close()
|
||||||
|
for line in cpio_proc.stdout:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
job.emit(phase="extrahieren", msg=line)
|
||||||
|
cpio_proc.wait()
|
||||||
|
pbzx_proc.wait()
|
||||||
|
|
||||||
|
dmg_candidates = []
|
||||||
|
for root, _dirs, files in os.walk(cpio_dir):
|
||||||
|
for fn in files:
|
||||||
|
if fn in ("SharedSupport.dmg", "BaseSystem.dmg"):
|
||||||
|
dmg_candidates.append(os.path.join(root, fn))
|
||||||
|
if not dmg_candidates:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Kein SharedSupport.dmg/BaseSystem.dmg im entpackten Installer gefunden. "
|
||||||
|
"Das ist der bekannte fragile Punkt bei macOS Big Sur+ ohne echten Mac."
|
||||||
|
)
|
||||||
|
# SharedSupport.dmg bevorzugen (enthaelt das vollstaendige Recovery-System)
|
||||||
|
dmg_candidates.sort(key=lambda p: 0 if "SharedSupport" in p else 1)
|
||||||
|
return dmg_candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_job(job: Job):
|
||||||
|
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
||||||
|
work_dir = tempfile.mkdtemp(dir=DOWNLOAD_DIR)
|
||||||
|
try:
|
||||||
|
job.emit(status="running", phase="start", progress=0, msg="Job gestartet")
|
||||||
|
product = job.product
|
||||||
|
mode = product["mode"]
|
||||||
|
|
||||||
|
if mode == "legacy_dmg":
|
||||||
|
url = product["base_system_url"]
|
||||||
|
dmg_path = os.path.join(work_dir, "BaseSystem.dmg")
|
||||||
|
_download(job, url, dmg_path)
|
||||||
|
raw_path = _dmg_to_raw(job, dmg_path)
|
||||||
|
_write_raw_to_device(job, raw_path, job.device_path)
|
||||||
|
else:
|
||||||
|
job.emit(phase="hinweis", msg="Big Sur+ Pfad ist experimentell (siehe README).")
|
||||||
|
url = product["install_assistant_url"]
|
||||||
|
pkg_path = os.path.join(work_dir, "InstallAssistant.pkg")
|
||||||
|
_download(job, url, pkg_path)
|
||||||
|
dmg_path = _extract_recovery_dmg(job, pkg_path, work_dir)
|
||||||
|
raw_path = _dmg_to_raw(job, dmg_path)
|
||||||
|
_write_raw_to_device(job, raw_path, job.device_path)
|
||||||
|
|
||||||
|
job.emit(status="done", phase="fertig", progress=100, msg="Stick ist fertig.")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.exception("job %s failed", job.id)
|
||||||
|
job.error = str(exc)
|
||||||
|
job.emit(status="failed", msg=f"FEHLER: {exc}")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(work_dir, ignore_errors=True)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
requests==2.32.3
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
macos-usb-tool:
|
||||||
|
build: .
|
||||||
|
container_name: macos-usb-tool
|
||||||
|
# privileged + /dev-Mount sind noetig um USB-Sticks direkt zu partitionieren
|
||||||
|
# (gdisk) und zu beschreiben (dd). Das gibt dem Container vollen Zugriff auf
|
||||||
|
# ALLE Block-Devices des Hosts -- deshalb bindet der Port NUR an 127.0.0.1.
|
||||||
|
privileged: true
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
volumes:
|
||||||
|
- /dev:/dev
|
||||||
|
- /run/udev:/run/udev:ro
|
||||||
|
- macos_downloads:/data/downloads
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
macos_downloads:
|
||||||
+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