#!/bin/bash # # create-stick.sh — natives macOS-Terminal-Tool fuer bootfaehige # Installer-Sticks. Laeuft NUR auf echtem macOS. Nutzt ausschliesslich # Bordmittel: softwareupdate, diskutil, PlistBuddy, installer, asr, # createinstallmedia. Kein Homebrew, keine externen Abhaengigkeiten. # # Zwei moegliche QUELLEN fuer den Installer-Payload: # # 1) Apple-Server (Download) # softwareupdate --fetch-full-installer laedt die komplette # "Install macOS X.app" direkt von Apple. Danach normaler # createinstallmedia-Weg. Zukunftssicher: die Versionsliste kommt # live von Apples eigenen Servern, neue macOS-Versionen tauchen # automatisch auf. # # 2) Lokale Downloads-Bibliothek des Docker/Linux-Tools # (../downloads/library relativ zu diesem Script, wenn das ganze # Projekt inkl. Downloads z.B. per externer Platte auf den Mac # kopiert wurde). Kein Internet noetig, kein erneuter Download. # Zwei Payload-Formate stecken dort je nach macOS-Version, beide # werden hier unterstuetzt: # - InstallAssistant.pkg (Big Sur+): 'installer -pkg ... -target /' # entpackt daraus die komplette Install-App (inkl. # createinstallmedia-Binary) nach /Applications -- danach der # normale createinstallmedia-Weg wie bei Quelle 1. # - BaseSystem.dmg/InstallESD.dmg (bis Catalina): das ist schon # ein fertiges, bootfaehiges Volume. createinstallmedia braucht # es nicht extra -- Apples eigenes 'asr restore' schreibt es # direkt auf den Stick. Kein App-Download noetig. # # So wird aus jeder bereits vom Docker-Tool gebauten Offline-Bibliothek # (externe Platte, ohne Internet) auch am echten Mac ein bootfaehiger # Stick, ganz ohne doppelten Download. # # ZWEI MODI: # # A) Einzelner Stick — ein Stick, eine macOS-Version (Original-Modus). # B) Multiboot-Stick — ein Stick, MEHRERE macOS-Versionen gleichzeitig. # Technik: der Stick wird per 'diskutil partitionDisk' in N eigene # HFS+-Partitionen aufgeteilt (eine pro gewaehlter Version). Danach # laeuft createinstallmedia bzw. asr restore genau wie im # Einzel-Modus, nur pro Durchlauf gegen die jeweils EIGENE Partition # statt gegen die ganze Platte -- beide Tools reformatieren dabei # ausschliesslich ihr eigenes Zielvolume, die anderen Partitionen # bleiben unberuehrt. Macs EFI-Bootpicker (Alt/Option beim Start) # scannt alle Partitionen und zeigt jede bootfaehige einzeln an. # Das ist dieselbe, in der Community etablierte Technik wie sie das # Docker/Linux-Tool dieses Projekts fuer den dd-Weg schon nutzt. set -u # ---------- Darstellung ---------- BOLD="$(tput bold 2>/dev/null || true)" RED="$(tput setaf 1 2>/dev/null || true)" GREEN="$(tput setaf 2 2>/dev/null || true)" YELLOW="$(tput setaf 3 2>/dev/null || true)" CYAN="$(tput setaf 6 2>/dev/null || true)" RESET="$(tput sgr0 2>/dev/null || true)" hr() { printf '%s\n' "----------------------------------------------------------------------"; } title() { clear; hr; printf "%s%s%s\n" "$BOLD$CYAN" " $1" "$RESET"; hr; echo; } err() { printf "%s%s%s\n" "$RED" "FEHLER: $1" "$RESET" >&2; } ok() { printf "%s%s%s\n" "$GREEN" "$1" "$RESET"; } warn() { printf "%s%s%s\n" "$YELLOW" "$1" "$RESET"; } # Sehr simpler Flat-JSON-Feld-Extraktor -- reicht fuer die meta.json-Dateien # der Bibliothek (keine verschachtelten Objekte), spart eine Abhaengigkeit # zu jq/python3 die auf einem frischen Mac nicht garantiert vorhanden ist. json_field() { local file="$1" key="$2" sed -n 's/.*"'"$key"'":[[:space:]]*"\{0,1\}\([^",}]*\)"\{0,1\}.*/\1/p' "$file" | head -n1 } # Bytes -> aufgerundete ganze GB (fuer Anzeige und Partitionsgroessen). bytes_to_gb_ceil() { local b="$1" echo $(( (b + 999999999) / 1000000000 )) } # Kuerzt/saeubert einen Titel zu einem gueltigen, eindeutigen HFS+-Label. # HFS+-Volume-Namen: max. 27 Zeichen, kein ':' und kein '/'. Wir haengen # den Auswahl-Index voran, damit gleichnamige Versionen nie kollidieren. make_label() { local idx="$1" title="$2" local clean clean="$(echo "$title" | tr -d ':/' | tr -s ' ' ' ')" clean="V${idx}_${clean}" echo "${clean:0:27}" } # ---------- Vorpruefungen ---------- if [[ "$(uname)" != "Darwin" ]]; then err "Dieses Script laeuft nur auf echtem macOS (kein Linux, keine VM ohne macOS)." exit 1 fi DEFAULT_LIB_DIR="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)/downloads/library" # ====================================================================== # Gemeinsame Listen-Beschaffung (fuer Einzel- UND Multiboot-Modus) # ====================================================================== # Fuellt die globalen Arrays A_TITLES/A_VERSIONS/A_SIZES mit Apples # aktuell verfuegbaren kompletten Installern. Gibt 1 zurueck wenn keine # Liste beschafft werden konnte (kein Abbruch des Scripts -- der Aufrufer # entscheidet, ob das schlimm ist). fetch_apple_list() { A_TITLES=() A_VERSIONS=() A_SIZES=() command -v softwareupdate >/dev/null 2>&1 || return 1 local list_raw list_raw="$(softwareupdate --list-full-installers 2>/dev/null)" grep -q "Title:" <<<"$list_raw" || return 1 local line t v s while IFS= read -r line; do [[ "$line" == *"Title:"* ]] || continue t="$(sed -E 's/^\* Title: ([^,]+),.*/\1/' <<<"$line")" v="$(sed -E 's/.*Version: ([^,]+),.*/\1/' <<<"$line")" s="$(sed -E 's/.*Size: ([0-9]+),.*/\1/' <<<"$line")" [[ "$s" =~ ^[0-9]+$ ]] || s="0" A_TITLES+=("$t") A_VERSIONS+=("$v") A_SIZES+=("$s") done <<<"$list_raw" [[ ${#A_TITLES[@]} -gt 0 ]] } # Fuellt die globalen Arrays L_DIRS/L_TITLES/L_VERSIONS/L_MODES/L_SIZES/ # L_FILENAMES mit den vollstaendigen Eintraegen der lokalen Bibliothek. # $1 = Pfad zur Bibliothek. Gibt 1 zurueck wenn nichts gefunden wurde. fetch_library_list() { local lib_dir="$1" L_DIRS=() L_TITLES=() L_VERSIONS=() L_MODES=() L_SIZES=() L_FILENAMES=() [[ -d "$lib_dir" ]] || return 1 local entry_dir meta complete et ev emode esize efn for entry_dir in "$lib_dir"/*/; do [[ -d "$entry_dir" ]] || continue meta="${entry_dir}meta.json" [[ -f "$meta" ]] || continue complete="$(json_field "$meta" complete)" [[ "$complete" == "true" ]] || continue et="$(json_field "$meta" title)" ev="$(json_field "$meta" version)" emode="$(json_field "$meta" mode)" esize="$(json_field "$meta" size_bytes)" efn="$(json_field "$meta" filename)" [[ "$esize" =~ ^[0-9]+$ ]] || esize="0" L_DIRS+=("$entry_dir") L_TITLES+=("$et") L_VERSIONS+=("$ev") L_MODES+=("$emode") L_SIZES+=("$esize") L_FILENAMES+=("$efn") done [[ ${#L_DIRS[@]} -gt 0 ]] } # ====================================================================== # Payload-Beschaffung fuer EINEN gewaehlten Eintrag (von Apple oder aus # der Bibliothek). Setzt bei Erfolg die globalen Variablen: # RESOLVED_WRITE_MODE ("cim" oder "asr") # RESOLVED_APP_PATH (bei cim) # RESOLVED_DMG_PATH (bei asr) # RESOLVED_SIZE_BYTES (reale Groesse nach Beschaffung, fuer Partition) # Gibt bei Fehler != 0 zurueck (Aufrufer entscheidet ueber Abbruch). # # Argumente: resolve_payload <version> \ # [lib_dir lib_mode lib_filename] # ====================================================================== resolve_payload() { local src="$1" title="$2" version="$3" local lib_dir="${4:-}" lib_mode="${5:-}" lib_filename="${6:-}" RESOLVED_WRITE_MODE="" RESOLVED_APP_PATH="" RESOLVED_DMG_PATH="" RESOLVED_SIZE_BYTES="0" if [[ "$src" == "apple" ]]; then local app_path="/Applications/Install ${title}.app" RESOLVED_WRITE_MODE="cim" if [[ -d "$app_path" ]]; then ok "'${app_path}' existiert bereits — Download wird uebersprungen." else warn "Das laedt mehrere GB von Apple und braucht sudo-Rechte (${title}, ${version})." read -r -p "Download jetzt starten? [j/N]: " GO if [[ ! "$GO" =~ ^[jJ]$ ]]; then err "Abgebrochen fuer ${title}." return 1 fi echo echo "Starte Download (kann je nach Version 30-90 Minuten dauern)..." sudo softwareupdate --fetch-full-installer --full-installer-version "$version" if [[ $? -ne 0 || ! -d "$app_path" ]]; then err "Download fehlgeschlagen oder App nicht am erwarteten Ort. Erwartet: ${app_path}" return 1 fi ok "Download abgeschlossen: ${app_path}" fi RESOLVED_APP_PATH="$app_path" RESOLVED_SIZE_BYTES="$(du -sk "$app_path" 2>/dev/null | awk '{print $1*1024}')" [[ "$RESOLVED_SIZE_BYTES" =~ ^[0-9]+$ ]] || RESOLVED_SIZE_BYTES="0" local cim_bin="${app_path}/Contents/Resources/createinstallmedia" if [[ ! -x "$cim_bin" ]]; then err "createinstallmedia nicht gefunden unter: ${cim_bin}" return 1 fi return 0 fi # src == "library" local payload_path="${lib_dir}${lib_filename}" if [[ ! -f "$payload_path" ]]; then err "Payload-Datei nicht gefunden: ${payload_path}" return 1 fi if [[ "$lib_mode" == "recovery" ]]; then RESOLVED_WRITE_MODE="cim" local app_path="/Applications/Install ${title}.app" if [[ -d "$app_path" ]]; then ok "'${app_path}' existiert bereits — Installer-Lauf wird uebersprungen." else echo "Entpacke ${payload_path} nach /Applications (installer -pkg, braucht sudo)..." sudo installer -pkg "$payload_path" -target / if [[ $? -ne 0 ]]; then err "'installer -pkg' fehlgeschlagen fuer ${title}." return 1 fi if [[ ! -d "$app_path" ]]; then local fallback fallback="$(ls -dt /Applications/Install\ macOS*.app 2>/dev/null | head -n1)" [[ -n "$fallback" ]] && app_path="$fallback" fi if [[ ! -d "$app_path" ]]; then err "Konnte die installierte App nach 'installer -pkg' nicht finden (${title})." return 1 fi ok "Installiert: ${app_path}" fi RESOLVED_APP_PATH="$app_path" RESOLVED_SIZE_BYTES="$(du -sk "$app_path" 2>/dev/null | awk '{print $1*1024}')" [[ "$RESOLVED_SIZE_BYTES" =~ ^[0-9]+$ ]] || RESOLVED_SIZE_BYTES="0" local cim_bin="${app_path}/Contents/Resources/createinstallmedia" if [[ ! -x "$cim_bin" ]]; then err "createinstallmedia nicht gefunden unter: ${cim_bin}" return 1 fi else RESOLVED_WRITE_MODE="asr" RESOLVED_DMG_PATH="$payload_path" RESOLVED_SIZE_BYTES="$(stat -f%z "$payload_path" 2>/dev/null)" [[ "$RESOLVED_SIZE_BYTES" =~ ^[0-9]+$ ]] || RESOLVED_SIZE_BYTES="0" ok "Bibliothek liefert bereits ein fertiges, bootfaehiges Image (${payload_path}) -- kein App-Download noetig." fi return 0 } # ====================================================================== # Externe physische Laufwerke auflisten. Fuellt D_IDS/D_DESCS/D_SIZE_BYTES. # ====================================================================== list_external_disks() { D_IDS=() D_DESCS=() D_SIZE_BYTES=() local plist_tmp plist_tmp="$(mktemp /tmp/diskutil_ext.XXXXXX.plist)" diskutil list -plist external physical > "$plist_tmp" 2>/dev/null local i=0 did info name size_bytes size_gb while :; do did="$(/usr/libexec/PlistBuddy -c "Print :AllDisksAndPartitions:${i}:DeviceIdentifier" "$plist_tmp" 2>/dev/null)" [[ -z "$did" ]] && break info="$(diskutil info -plist "$did" 2>/dev/null)" name="$(echo "$info" | /usr/libexec/PlistBuddy -c "Print :MediaName" /dev/stdin 2>/dev/null)" size_bytes="$(echo "$info" | /usr/libexec/PlistBuddy -c "Print :TotalSize" /dev/stdin 2>/dev/null)" [[ "$size_bytes" =~ ^[0-9]+$ ]] || size_bytes="0" size_gb="$(bytes_to_gb_ceil "$size_bytes")" D_IDS+=("$did") D_DESCS+=("/dev/${did} — ${name:-unbekannt} (~${size_gb} GB)") D_SIZE_BYTES+=("$size_bytes") i=$((i+1)) done rm -f "$plist_tmp" [[ ${#D_IDS[@]} -gt 0 ]] } # ====================================================================== # MODUS-Wahl # ====================================================================== title "macOS USB-Stick-Creator (nativ) — Modus waehlen" echo "Was soll erstellt werden?" echo echo " ${BOLD}1)${RESET} Einzelner Stick (eine macOS-Version)" echo " ${BOLD}2)${RESET} Multiboot-Stick (mehrere macOS-Versionen auf einem Stick)" echo printf "%s0)%s Abbrechen\n" "$BOLD" "$RESET" echo read -r -p "Modus waehlen [Nummer]: " MODE_CHOICE if [[ "$MODE_CHOICE" == "0" || -z "$MODE_CHOICE" ]]; then echo "Abgebrochen." exit 0 fi if [[ "$MODE_CHOICE" != "1" && "$MODE_CHOICE" != "2" ]]; then err "Ungueltige Auswahl." exit 1 fi # ====================================================================== # ================ MODUS 1: EINZELNER STICK ========================== # ====================================================================== if [[ "$MODE_CHOICE" == "1" ]]; then title "macOS USB-Stick-Creator (nativ) — Schritt 1/4: Quelle + Version" echo "Woher soll der macOS-Installer kommen?" echo echo " ${BOLD}1)${RESET} Von Apple herunterladen (braucht Internet, mehrere GB)" echo " ${BOLD}2)${RESET} Aus lokaler Downloads-Bibliothek (Docker-Tool) verwenden -- kein Internet noetig" echo printf "%s0)%s Abbrechen\n" "$BOLD" "$RESET" echo read -r -p "Quelle waehlen [Nummer]: " SRC_CHOICE if [[ "$SRC_CHOICE" == "0" || -z "$SRC_CHOICE" ]]; then echo "Abgebrochen." exit 0 fi case "$SRC_CHOICE" in 1) SOURCE="apple" ;; 2) SOURCE="library" ;; *) err "Ungueltige Auswahl."; exit 1 ;; esac SEL_TITLE="" SEL_VERSION="" SEL_LIB_DIR="" SEL_LIB_MODE="" SEL_LIB_FILENAME="" if [[ "$SOURCE" == "apple" ]]; then if ! command -v softwareupdate >/dev/null 2>&1; then err "'softwareupdate' nicht gefunden — ungewoehnliches macOS-Setup, Abbruch." exit 1 fi MACOS_VER="$(sw_vers -productVersion)" if [[ "$MACOS_VER" == 10.* ]]; then MINOR="${MACOS_VER#10.}" MINOR="${MINOR%%.*}" if [[ "$MINOR" -lt 15 ]]; then err "macOS ${MACOS_VER} erkannt — 'softwareupdate --fetch-full-installer' braucht mind. Catalina (10.15)." exit 1 fi fi echo echo "Frage Apples Software-Update-Server nach verfuegbaren kompletten Installern ab..." echo if ! fetch_apple_list; then err "Keine Installer gefunden. Internetverbindung pruefen, oder Apple hat gerade ein Problem." exit 1 fi echo "Verfuegbar:" echo for i in "${!A_TITLES[@]}"; do printf " %s%2d)%s %s (%s)\n" "$BOLD" "$((i+1))" "$RESET" "${A_TITLES[$i]}" "${A_VERSIONS[$i]}" done echo printf "%s0)%s Abbrechen\n" "$BOLD" "$RESET" echo read -r -p "Welche Version soll heruntergeladen/benutzt werden? [Nummer]: " CHOICE if [[ "$CHOICE" == "0" || -z "$CHOICE" ]]; then echo "Abgebrochen." exit 0 fi if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || (( CHOICE < 1 || CHOICE > ${#A_TITLES[@]} )); then err "Ungueltige Auswahl." exit 1 fi SEL_TITLE="${A_TITLES[$((CHOICE-1))]}" SEL_VERSION="${A_VERSIONS[$((CHOICE-1))]}" echo ok "Gewaehlt: ${SEL_TITLE} (${SEL_VERSION}) -- Quelle: Apple-Server" else echo echo "Pfad zur lokalen Downloads-Bibliothek des Docker-Tools (Ordner mit einem" echo "Unterordner pro macOS-Version, je mit meta.json + Installer-Datei)." echo read -r -p "Pfad [Enter fuer ${DEFAULT_LIB_DIR}]: " LIB_DIR_IN LIB_DIR="${LIB_DIR_IN:-$DEFAULT_LIB_DIR}" [[ "$LIB_DIR" == */ ]] || LIB_DIR="${LIB_DIR}/" if ! fetch_library_list "$LIB_DIR"; then err "Keine vollstaendigen Installer in ${LIB_DIR} gefunden." exit 1 fi echo echo "In der Bibliothek gefunden:" echo for i in "${!L_DIRS[@]}"; do esize_gb="$(bytes_to_gb_ceil "${L_SIZES[$i]}")" mode_desc="bis Catalina, robust" [[ "${L_MODES[$i]}" == "recovery" ]] && mode_desc="Big Sur+, ueber installer-pkg" printf " %s%2d)%s %s (%s) -- ~%s GB, %s\n" "$BOLD" "$((i+1))" "$RESET" "${L_TITLES[$i]}" "${L_VERSIONS[$i]}" "$esize_gb" "$mode_desc" done echo printf "%s0)%s Abbrechen\n" "$BOLD" "$RESET" echo read -r -p "Welche Version verwenden? [Nummer]: " LCHOICE if [[ "$LCHOICE" == "0" || -z "$LCHOICE" ]]; then echo "Abgebrochen." exit 0 fi if ! [[ "$LCHOICE" =~ ^[0-9]+$ ]] || (( LCHOICE < 1 || LCHOICE > ${#L_DIRS[@]} )); then err "Ungueltige Auswahl." exit 1 fi idx=$((LCHOICE-1)) SEL_TITLE="${L_TITLES[$idx]}" SEL_VERSION="${L_VERSIONS[$idx]}" SEL_LIB_DIR="${L_DIRS[$idx]}" SEL_LIB_MODE="${L_MODES[$idx]}" SEL_LIB_FILENAME="${L_FILENAMES[$idx]}" echo ok "Gewaehlt: ${SEL_TITLE} (${SEL_VERSION}) -- Quelle: lokale Bibliothek" fi # ---------- Schritt 2: Installer-Payload besorgen ---------- title "Schritt 2/4: Installer-Payload besorgen" if ! resolve_payload "$SOURCE" "$SEL_TITLE" "$SEL_VERSION" "$SEL_LIB_DIR" "$SEL_LIB_MODE" "$SEL_LIB_FILENAME"; then exit 1 fi WRITE_MODE="$RESOLVED_WRITE_MODE" APP_PATH="$RESOLVED_APP_PATH" DMG_PATH="$RESOLVED_DMG_PATH" # ---------- Schritt 3: Ziel-USB-Stick waehlen ---------- title "Schritt 3/4: USB-Stick auswaehlen" echo "Externe, physische Laufwerke (nur ganze Platten, keine Partitionen):" echo if ! list_external_disks; then err "Keine externen physischen Laufwerke gefunden. Stick eingesteckt?" exit 1 fi for i in "${!D_IDS[@]}"; do printf " %s%2d)%s %s\n" "$BOLD" "$((i+1))" "$RESET" "${D_DESCS[$i]}" done echo printf "%s0)%s Abbrechen\n" "$BOLD" "$RESET" echo warn "ACHTUNG: Der gewaehlte Stick wird KOMPLETT GELOESCHT." echo read -r -p "Welcher Stick? [Nummer]: " DCHOICE if [[ "$DCHOICE" == "0" || -z "$DCHOICE" ]]; then echo "Abgebrochen." exit 0 fi if ! [[ "$DCHOICE" =~ ^[0-9]+$ ]] || (( DCHOICE < 1 || DCHOICE > ${#D_IDS[@]} )); then err "Ungueltige Auswahl." exit 1 fi TARGET_DISK="${D_IDS[$((DCHOICE-1))]}" TARGET_DESC="${D_DESCS[$((DCHOICE-1))]}" echo warn "Letzte Sicherheitsabfrage — dieser Stick wird geloescht:" echo " ${TARGET_DESC}" echo read -r -p "Tippe exakt '/dev/${TARGET_DISK}' zum Bestaetigen: " CONFIRM if [[ "$CONFIRM" != "/dev/${TARGET_DISK}" ]]; then err "Eingabe stimmt nicht ueberein — abgebrochen, nichts wurde veraendert." exit 1 fi # ---------- Schritt 4: Stick schreiben ---------- title "Schritt 4/4: Stick wird erstellt" if [[ "$WRITE_MODE" == "cim" ]]; then CIM_BIN="${APP_PATH}/Contents/Resources/createinstallmedia" echo "App: ${APP_PATH}" echo "Ziel: /dev/${TARGET_DISK}" echo echo "createinstallmedia laeuft jetzt (braucht sudo, dauert typischerweise 10-30 Minuten)..." echo "Der Stick wird zwischendurch umbenannt/neu gemountet — das ist normal." echo sudo "$CIM_BIN" --volume "/dev/${TARGET_DISK}" --nointeraction RC=$? echo if [[ $RC -eq 0 ]]; then ok "Fertig! Der Stick ist bootfaehig." echo "Am Ziel-Mac: beim Start Option/Alt gedrueckt halten, den Stick im Boot-Picker waehlen." else err "createinstallmedia ist mit Fehlercode ${RC} beendet — siehe Ausgabe oben." exit $RC fi echo read -r -p "Die installierte App (mehrere GB) jetzt wieder loeschen um Platz zu sparen? [j/N]: " CLEAN if [[ "$CLEAN" =~ ^[jJ]$ ]]; then sudo rm -rf "$APP_PATH" ok "Geloescht: ${APP_PATH}" fi else echo "Quelle: ${DMG_PATH}" echo "Ziel: /dev/${TARGET_DISK}" echo echo "Formatiere /dev/${TARGET_DISK} neu (HFS+) als Vorbereitung (braucht sudo)..." sudo diskutil eraseDisk JHFS+ INSTALL "/dev/${TARGET_DISK}" if [[ $? -ne 0 ]]; then err "diskutil eraseDisk fehlgeschlagen." exit 1 fi VOL_MOUNT="/Volumes/INSTALL" if [[ ! -d "$VOL_MOUNT" ]]; then err "Erwarteter Mountpunkt ${VOL_MOUNT} nicht gefunden nach dem Formatieren." echo "Tipp: 'diskutil list /dev/${TARGET_DISK}' zeigt den tatsaechlichen Zustand." exit 1 fi echo echo "asr restore laeuft jetzt (braucht sudo, dauert typischerweise 10-20 Minuten)..." echo "Der Stick wird dabei nochmal komplett neu formatiert -- das ist normal." echo sudo asr restore --source "$DMG_PATH" --target "$VOL_MOUNT" --erase --noprompt RC=$? echo if [[ $RC -eq 0 ]]; then ok "Fertig! Der Stick ist bootfaehig." echo "Am Ziel-Mac: beim Start Option/Alt gedrueckt halten, den Stick im Boot-Picker waehlen." else err "asr restore ist mit Fehlercode ${RC} beendet — siehe Ausgabe oben." exit $RC fi fi echo ok "Alles erledigt." exit 0 fi # ====================================================================== # ================ MODUS 2: MULTIBOOT-STICK ========================== # ====================================================================== MAX_MULTIBOOT=8 title "Multiboot-Stick — Schritt 1/5: Versionen auswaehlen" echo "Baue die Auswahlliste (Apple-Server + lokale Bibliothek)..." echo HAVE_APPLE=0 if command -v softwareupdate >/dev/null 2>&1 && fetch_apple_list; then HAVE_APPLE=1 fi HAVE_LIB=0 echo read -r -p "Pfad zur lokalen Downloads-Bibliothek pruefen [Enter fuer ${DEFAULT_LIB_DIR}, '-' zum Ueberspringen]: " LIB_DIR_IN if [[ "$LIB_DIR_IN" != "-" ]]; then LIB_DIR="${LIB_DIR_IN:-$DEFAULT_LIB_DIR}" [[ "$LIB_DIR" == */ ]] || LIB_DIR="${LIB_DIR}/" if fetch_library_list "$LIB_DIR"; then HAVE_LIB=1 fi fi if [[ $HAVE_APPLE -eq 0 && $HAVE_LIB -eq 0 ]]; then err "Weder Apple-Server noch lokale Bibliothek haben Installer geliefert. Abbruch." exit 1 fi # Kombinierte Liste aufbauen: C_SRC/C_TITLE/C_VERSION/C_SIZE/C_LIBIDX C_SRC=() C_TITLE=() C_VERSION=() C_SIZE=() C_LIBIDX=() if [[ $HAVE_APPLE -eq 1 ]]; then for i in "${!A_TITLES[@]}"; do C_SRC+=("apple") C_TITLE+=("${A_TITLES[$i]}") C_VERSION+=("${A_VERSIONS[$i]}") C_SIZE+=("${A_SIZES[$i]}") C_LIBIDX+=("-1") done fi if [[ $HAVE_LIB -eq 1 ]]; then for i in "${!L_DIRS[@]}"; do C_SRC+=("library") C_TITLE+=("${L_TITLES[$i]}") C_VERSION+=("${L_VERSIONS[$i]}") C_SIZE+=("${L_SIZES[$i]}") C_LIBIDX+=("$i") done fi echo echo "Verfuegbare Versionen (Mehrfachauswahl):" echo for i in "${!C_TITLE[@]}"; do tag="[Apple, Download]" mode_desc="" if [[ "${C_SRC[$i]}" == "library" ]]; then tag="[Bibliothek, offline]" lidx="${C_LIBIDX[$i]}" mode_desc=" -- bis Catalina, robust" [[ "${L_MODES[$lidx]}" == "recovery" ]] && mode_desc=" -- Big Sur+, ueber installer-pkg" fi sgb="$(bytes_to_gb_ceil "${C_SIZE[$i]}")" printf " %s%2d)%s %-22s %s (%s) -- ~%s GB%s\n" "$BOLD" "$((i+1))" "$RESET" "$tag" "${C_TITLE[$i]}" "${C_VERSION[$i]}" "$sgb" "$mode_desc" done echo printf "%s0)%s Abbrechen\n" "$BOLD" "$RESET" echo echo "Mehrere Nummern durch Komma trennen, z.B. 1,3,4 (mind. 2, max. ${MAX_MULTIBOOT})." read -r -p "Welche Versionen fuer den Multiboot-Stick? " MCHOICE_RAW if [[ "$MCHOICE_RAW" == "0" || -z "$MCHOICE_RAW" ]]; then echo "Abgebrochen." exit 0 fi IFS=',' read -r -a MCHOICE_PARTS <<<"$MCHOICE_RAW" SEL_IDX=() for part in "${MCHOICE_PARTS[@]}"; do part="$(echo "$part" | tr -d '[:space:]')" [[ -z "$part" ]] && continue if ! [[ "$part" =~ ^[0-9]+$ ]] || (( part < 1 || part > ${#C_TITLE[@]} )); then err "Ungueltige Nummer: '${part}'." exit 1 fi SEL_IDX+=("$((part-1))") done if (( ${#SEL_IDX[@]} < 2 )); then err "Fuer nur eine Version reicht der Einzel-Modus (Option 1 im Startmenue)." exit 1 fi if (( ${#SEL_IDX[@]} > MAX_MULTIBOOT )); then err "Zu viele Versionen gewaehlt (${#SEL_IDX[@]}), Maximum ist ${MAX_MULTIBOOT}." exit 1 fi echo ok "Gewaehlt: ${#SEL_IDX[@]} Versionen fuer den Multiboot-Stick." # ---------- Schritt 2/5: Payload je Version besorgen ---------- title "Schritt 2/5: Installer-Payloads besorgen" M_WRITE_MODE=() M_APP_PATH=() M_DMG_PATH=() M_SIZE_BYTES=() M_TITLE=() M_LABEL=() n=1 for ci in "${SEL_IDX[@]}"; do t="${C_TITLE[$ci]}" v="${C_VERSION[$ci]}" src="${C_SRC[$ci]}" echo echo "${BOLD}--- (${n}/${#SEL_IDX[@]}) ${t} (${v}) ---${RESET}" lib_dir="" lib_mode="" lib_fn="" if [[ "$src" == "library" ]]; then lidx="${C_LIBIDX[$ci]}" lib_dir="${L_DIRS[$lidx]}" lib_mode="${L_MODES[$lidx]}" lib_fn="${L_FILENAMES[$lidx]}" fi if ! resolve_payload "$src" "$t" "$v" "$lib_dir" "$lib_mode" "$lib_fn"; then err "Konnte Payload fuer '${t}' nicht beschaffen — Auswahl wird komplett abgebrochen (noch nichts am Stick veraendert)." exit 1 fi M_WRITE_MODE+=("$RESOLVED_WRITE_MODE") M_APP_PATH+=("$RESOLVED_APP_PATH") M_DMG_PATH+=("$RESOLVED_DMG_PATH") M_SIZE_BYTES+=("$RESOLVED_SIZE_BYTES") M_TITLE+=("$t") M_LABEL+=("$(make_label "$n" "$t")") n=$((n+1)) done # ---------- Schritt 3/5: Ziel-USB-Stick waehlen ---------- title "Schritt 3/5: USB-Stick auswaehlen" echo "Externe, physische Laufwerke (nur ganze Platten, keine Partitionen):" echo if ! list_external_disks; then err "Keine externen physischen Laufwerke gefunden. Stick eingesteckt?" exit 1 fi for i in "${!D_IDS[@]}"; do printf " %s%2d)%s %s\n" "$BOLD" "$((i+1))" "$RESET" "${D_DESCS[$i]}" done echo printf "%s0)%s Abbrechen\n" "$BOLD" "$RESET" echo warn "ACHTUNG: Der gewaehlte Stick wird KOMPLETT GELOESCHT (alle Partitionen)." echo read -r -p "Welcher Stick? [Nummer]: " DCHOICE if [[ "$DCHOICE" == "0" || -z "$DCHOICE" ]]; then echo "Abgebrochen." exit 0 fi if ! [[ "$DCHOICE" =~ ^[0-9]+$ ]] || (( DCHOICE < 1 || DCHOICE > ${#D_IDS[@]} )); then err "Ungueltige Auswahl." exit 1 fi TARGET_DISK="${D_IDS[$((DCHOICE-1))]}" TARGET_DESC="${D_DESCS[$((DCHOICE-1))]}" TARGET_SIZE_BYTES="${D_SIZE_BYTES[$((DCHOICE-1))]}" # ---------- Groessen-Check ---------- # Puffer je Partition: cim braucht etwas Kopfraum ueber der reinen App- # Groesse (createinstallmedia kopiert BaseSystem etc. dazu), asr braucht # kaum mehr als die Quellgroesse. Zusaetzlich Mindestgroessen, damit # kleine/kaputte Groessenangaben nicht zu einer zu kleinen Partition fuehren. TOTAL_NEEDED=0 declare -a PART_SIZE_BYTES=() for i in "${!M_TITLE[@]}"; do raw="${M_SIZE_BYTES[$i]}" [[ "$raw" =~ ^[0-9]+$ ]] || raw=0 if [[ "${M_WRITE_MODE[$i]}" == "cim" ]]; then need=$(( raw + raw / 3 )) # +33% Puffer min=$(( 14 * 1000 * 1000 * 1000 )) else need=$(( raw + raw / 20 )) # +5% Puffer min=$(( 6 * 1000 * 1000 * 1000 )) fi (( need < min )) && need=$min PART_SIZE_BYTES+=("$need") TOTAL_NEEDED=$(( TOTAL_NEEDED + need )) done # ~1GB Reserve fuer EFI-Partition + GPT-Tabellen. GPT_OVERHEAD=$(( 1 * 1000 * 1000 * 1000 )) TOTAL_NEEDED_WITH_OVERHEAD=$(( TOTAL_NEEDED + GPT_OVERHEAD )) echo echo "Benoetigter Platz (mit Puffer):" for i in "${!M_TITLE[@]}"; do printf " - %-30s ~%s GB (%s)\n" "${M_TITLE[$i]}" "$(bytes_to_gb_ceil "${PART_SIZE_BYTES[$i]}")" "${M_WRITE_MODE[$i]}" done echo " Gesamt (inkl. EFI-Reserve): ~$(bytes_to_gb_ceil "$TOTAL_NEEDED_WITH_OVERHEAD") GB" echo " Stick-Kapazitaet: ~$(bytes_to_gb_ceil "$TARGET_SIZE_BYTES") GB" echo if (( TOTAL_NEEDED_WITH_OVERHEAD > TARGET_SIZE_BYTES )); then err "Der gewaehlte Stick ist zu klein fuer diese Auswahl. Kleinere/weniger Versionen waehlen oder groesseren Stick nehmen." echo "Es wurde NICHTS am Stick veraendert." exit 1 fi echo warn "Letzte Sicherheitsabfrage — dieser Stick wird GELOESCHT und in ${#M_TITLE[@]} Partitionen aufgeteilt:" echo " ${TARGET_DESC}" for i in "${!M_TITLE[@]}"; do echo " -> ${M_LABEL[$i]} (${M_TITLE[$i]})" done echo read -r -p "Tippe exakt '/dev/${TARGET_DISK}' zum Bestaetigen: " CONFIRM if [[ "$CONFIRM" != "/dev/${TARGET_DISK}" ]]; then err "Eingabe stimmt nicht ueberein — abgebrochen, nichts wurde veraendert." exit 1 fi # ---------- Schritt 4/5: Partitionieren ---------- title "Schritt 4/5: Stick wird partitioniert" # diskutil partitionDisk <device> <#Partitionen> GPT [Format Name Groesse] ... # Letzte Partition bekommt 'R' (restlicher Platz) statt einer festen # Groesse -- vermeidet Rundungsverluste und nutzt automatisch den vollen # verbleibenden Stick fuer die letzte gewaehlte Version. PART_ARGS=() n_parts=${#M_TITLE[@]} for i in "${!M_TITLE[@]}"; do if (( i == n_parts - 1 )); then PART_ARGS+=("JHFS+" "${M_LABEL[$i]}" "R") else gb="$(bytes_to_gb_ceil "${PART_SIZE_BYTES[$i]}")" PART_ARGS+=("JHFS+" "${M_LABEL[$i]}" "${gb}G") fi done echo "Partitioniere /dev/${TARGET_DISK} in ${n_parts} Partitionen (braucht sudo)..." echo sudo diskutil partitionDisk "/dev/${TARGET_DISK}" "${n_parts}" GPT "${PART_ARGS[@]}" if [[ $? -ne 0 ]]; then err "diskutil partitionDisk fehlgeschlagen — siehe Ausgabe oben." exit 1 fi ok "Partitionierung abgeschlossen." # ---------- Schritt 5/5: Jede Partition beschreiben ---------- title "Schritt 5/5: Partitionen werden beschrieben" FAILED_TITLES=() OK_TITLES=() for i in "${!M_TITLE[@]}"; do t="${M_TITLE[$i]}" label="${M_LABEL[$i]}" vol="/Volumes/${label}" echo echo "${BOLD}--- $((i+1))/${n_parts}: ${t} -> ${label} ---${RESET}" if [[ ! -d "$vol" ]]; then err "Erwartetes Volume ${vol} nicht gefunden — ueberspringe ${t}." FAILED_TITLES+=("$t") continue fi if [[ "${M_WRITE_MODE[$i]}" == "cim" ]]; then cim_bin="${M_APP_PATH[$i]}/Contents/Resources/createinstallmedia" echo "createinstallmedia laeuft gegen ${vol} (braucht sudo, kann 10-30 Minuten dauern)..." sudo "$cim_bin" --volume "$vol" --nointeraction rc=$? else echo "asr restore laeuft gegen ${vol} (braucht sudo, kann 10-20 Minuten dauern)..." sudo asr restore --source "${M_DMG_PATH[$i]}" --target "$vol" --erase --noprompt rc=$? fi if [[ $rc -eq 0 ]]; then ok "${t}: fertig." OK_TITLES+=("$t") else err "${t}: fehlgeschlagen (Code ${rc})." FAILED_TITLES+=("$t") echo read -r -p "Trotzdem mit den restlichen Versionen fortfahren? [j/N]: " CONT if [[ ! "$CONT" =~ ^[jJ]$ ]]; then err "Abgebrochen — restliche Partitionen wurden NICHT beschrieben." break fi fi done echo hr if (( ${#OK_TITLES[@]} > 0 )); then ok "Erfolgreich geschrieben: ${OK_TITLES[*]}" fi if (( ${#FAILED_TITLES[@]} > 0 )); then err "Fehlgeschlagen/uebersprungen: ${FAILED_TITLES[*]}" fi echo echo "Am Ziel-Mac: beim Start Option/Alt gedrueckt halten — alle erfolgreich" echo "geschriebenen Versionen erscheinen einzeln im Boot-Picker." # Aufraeumen: heruntergeladene/entpackte Apps (mehrere GB je Version). CLEAN_APPS=() for i in "${!M_TITLE[@]}"; do [[ "${M_WRITE_MODE[$i]}" == "cim" ]] || continue [[ -n "${M_APP_PATH[$i]}" ]] || continue CLEAN_APPS+=("${M_APP_PATH[$i]}") done if (( ${#CLEAN_APPS[@]} > 0 )); then echo read -r -p "Heruntergeladene/entpackte Installer-Apps (${#CLEAN_APPS[@]} Stueck, mehrere GB) jetzt loeschen? [j/N]: " CLEAN if [[ "$CLEAN" =~ ^[jJ]$ ]]; then for app in "${CLEAN_APPS[@]}"; do sudo rm -rf "$app" done ok "Aufgeraeumt." fi fi echo if (( ${#FAILED_TITLES[@]} > 0 )); then exit 1 fi ok "Alles erledigt." exit 0