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
+33
View File
@@ -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"]
+86
View File
@@ -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
View File
@@ -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)
+158
View File
@@ -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
+53
View File
@@ -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
+266
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
Flask==3.0.3
requests==2.32.3
+18
View File
@@ -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
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; }