commit e93ab96faaf73acf89140d6420e4017a993d076c Author: ARIA Date: Sat Jul 18 08:25:03 2026 +0000 Initial commit: macOS USB Stick Creator - Docker Compose Web-GUI fuer bootfaehige macOS-Installer diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0b621bf --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d49e4bd --- /dev/null +++ b/README.md @@ -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. diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..31ff794 --- /dev/null +++ b/backend/app.py @@ -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("/") +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/") +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//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) diff --git a/backend/catalog.py b/backend/catalog.py new file mode 100644 index 0000000..f8732c3 --- /dev/null +++ b/backend/catalog.py @@ -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---...-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 diff --git a/backend/devices.py b/backend/devices.py new file mode 100644 index 0000000..5be39e3 --- /dev/null +++ b/backend/devices.py @@ -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 diff --git a/backend/installer.py b/backend/installer.py new file mode 100644 index 0000000..38030ea --- /dev/null +++ b/backend/installer.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..76d9083 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.3 +requests==2.32.3 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8bec031 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..a4888c4 --- /dev/null +++ b/frontend/app.js @@ -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 = "
Scanne USB-Geraete...
"; + const res = await fetch("/api/devices"); + const data = await res.json(); + deviceList.innerHTML = ""; + if (!data.items || data.items.length === 0) { + deviceList.innerHTML = "
Keine USB-Sticks gefunden. Stick einstecken, dann 'neu scannen'.
"; + 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 = `
+
${dev.path} ${dev.is_root_disk ? "(SYSTEM-PLATTE - gesperrt)" : ""}
+
${dev.vendor || ""} ${dev.model || ""} - ${fmtSize(dev.size_bytes)} - ${dev.transport || "?"}${dev.mounted ? " - GEMOUNTET" : ""}
+
`; + 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(); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c985b1f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,52 @@ + + + + + +macOS USB Installer Tool + + + +
+

macOS USB Installer Tool

+

Laeuft lokal via Docker — laedt Installer direkt von Apple, kein Mac noetig.

+
+ +
+
+
+

1. macOS-Version

+ +
+
+ +

+
+ +
+
+

2. USB-Stick

+ +
+
+
+ +
+

3. Bestaetigen & Schreiben

+

ACHTUNG: Der gesamte Inhalt des gewaehlten Sticks wird geloescht.

+ + + +
+ + +
+ + + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..dab51a9 --- /dev/null +++ b/frontend/style.css @@ -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; }