feat(native-macos): Multiboot-Stick-Modus fuer createinstallmedia/asr

Ein Stick, mehrere macOS-Versionen, frei mischbar aus Apple-Server- und
Bibliotheks-Quelle. Partitioniert den Stick per diskutil partitionDisk
in eine eigene HFS+-Partition je Version (eindeutige Labels), schreibt
danach pro Partition gezielt per createinstallmedia --volume bzw.
asr restore --target -- analog zur bereits vorhandenen Multiboot-Technik
im Docker/Linux-Tool. Inkl. Groessen-Check mit Sicherheitspuffer vor
jeder Aenderung am Stick, robustem Fehlerpfad je Partition (Weiter/Abbruch
statt Stillschweigen) und End-to-End-Test der kompletten Ablauflogik
unter Linux mit gestubbten macOS-Bordmitteln (Einzel-Modus + Multiboot,
reine Apple-Quelle + gemischte Apple/Bibliotheks-Quelle).
This commit is contained in:
ARIA
2026-07-18 12:12:36 +02:00
parent 0d0c137de3
commit 3956484246
2 changed files with 717 additions and 210 deletions
+659 -196
View File
@@ -32,6 +32,21 @@
# 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
@@ -57,13 +72,264 @@ json_field() {
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
# ---------- Schritt 1: Quelle + Version waehlen ----------
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 <src:"apple"|"library"> <title> <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?"
@@ -80,26 +346,18 @@ if [[ "$SRC_CHOICE" == "0" || -z "$SRC_CHOICE" ]]; then
exit 0
fi
SOURCE=""
WRITE_MODE="" # "cim" (createinstallmedia) oder "asr" (direktes Image-Restore)
APP_PATH=""
DMG_PATH=""
LIB_TITLE=""
LIB_VERSION=""
case "$SRC_CHOICE" in
1)
SOURCE="apple"
;;
2)
SOURCE="library"
;;
*)
err "Ungueltige Auswahl."
exit 1
;;
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."
@@ -107,7 +365,6 @@ if [[ "$SOURCE" == "apple" ]]; then
fi
MACOS_VER="$(sw_vers -productVersion)"
# fetch-full-installer gibt es erst ab Catalina (10.15)
if [[ "$MACOS_VER" == 10.* ]]; then
MINOR="${MACOS_VER#10.}"
MINOR="${MINOR%%.*}"
@@ -121,33 +378,15 @@ if [[ "$SOURCE" == "apple" ]]; then
echo "Frage Apples Software-Update-Server nach verfuegbaren kompletten Installern ab..."
echo
LIST_RAW="$(softwareupdate --list-full-installers 2>/dev/null)"
if [[ -z "$LIST_RAW" ]] || ! grep -q "Title:" <<<"$LIST_RAW"; then
if ! fetch_apple_list; then
err "Keine Installer gefunden. Internetverbindung pruefen, oder Apple hat gerade ein Problem."
exit 1
fi
# Zeilenformat: "* Title: macOS Sonoma, Version: 14.6.1, Size: 12345678900, Build: 23G93"
declare -a TITLES=()
declare -a VERSIONS=()
while IFS= read -r line; do
[[ "$line" == *"Title:"* ]] || continue
t="$(sed -E 's/^\* Title: ([^,]+),.*/\1/' <<<"$line")"
v="$(sed -E 's/.*Version: ([^,]+),.*/\1/' <<<"$line")"
TITLES+=("$t")
VERSIONS+=("$v")
done <<<"$LIST_RAW"
if [[ ${#TITLES[@]} -eq 0 ]]; then
err "Konnte die Liste nicht parsen. Rohausgabe:"
echo "$LIST_RAW"
exit 1
fi
echo "Verfuegbar:"
echo
for i in "${!TITLES[@]}"; do
printf " %s%2d)%s %s (%s)\n" "$BOLD" "$((i+1))" "$RESET" "${TITLES[$i]}" "${VERSIONS[$i]}"
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"
@@ -158,56 +397,25 @@ if [[ "$SOURCE" == "apple" ]]; then
echo "Abgebrochen."
exit 0
fi
if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || (( CHOICE < 1 || CHOICE > ${#TITLES[@]} )); then
if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || (( CHOICE < 1 || CHOICE > ${#A_TITLES[@]} )); then
err "Ungueltige Auswahl."
exit 1
fi
SEL_TITLE="${TITLES[$((CHOICE-1))]}"
SEL_VERSION="${VERSIONS[$((CHOICE-1))]}"
WRITE_MODE="cim"
# Apples Titel-Feld liefert z.B. "macOS Sonoma" -> App heisst "Install macOS Sonoma.app"
APP_PATH="/Applications/Install ${SEL_TITLE}.app"
SEL_TITLE="${A_TITLES[$((CHOICE-1))]}"
SEL_VERSION="${A_VERSIONS[$((CHOICE-1))]}"
echo
ok "Gewaehlt: ${SEL_TITLE} (${SEL_VERSION}) -- Quelle: Apple-Server"
else
echo
DEFAULT_LIB_DIR="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)/downloads/library"
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
LIB_DIR="${LIB_DIR:-$DEFAULT_LIB_DIR}"
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 [[ ! -d "$LIB_DIR" ]]; then
err "Ordner nicht gefunden: ${LIB_DIR}"
exit 1
fi
declare -a LIB_ENTRY_DIRS=()
declare -a LIB_ENTRY_DESC=()
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)"
esize_gb="?"
if [[ "$esize" =~ ^[0-9]+$ ]]; then
esize_gb="$(( esize / 1000 / 1000 / 1000 ))"
fi
mode_desc="bis Catalina, robust"
[[ "$emode" == "recovery" ]] && mode_desc="Big Sur+, ueber installer-pkg"
LIB_ENTRY_DIRS+=("$entry_dir")
LIB_ENTRY_DESC+=("${et} (${ev}) -- ~${esize_gb} GB, ${mode_desc}")
done
if [[ ${#LIB_ENTRY_DIRS[@]} -eq 0 ]]; then
if ! fetch_library_list "$LIB_DIR"; then
err "Keine vollstaendigen Installer in ${LIB_DIR} gefunden."
exit 1
fi
@@ -215,8 +423,11 @@ else
echo
echo "In der Bibliothek gefunden:"
echo
for i in "${!LIB_ENTRY_DIRS[@]}"; do
printf " %s%2d)%s %s\n" "$BOLD" "$((i+1))" "$RESET" "${LIB_ENTRY_DESC[$i]}"
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"
@@ -227,134 +438,43 @@ else
echo "Abgebrochen."
exit 0
fi
if ! [[ "$LCHOICE" =~ ^[0-9]+$ ]] || (( LCHOICE < 1 || LCHOICE > ${#LIB_ENTRY_DIRS[@]} )); then
if ! [[ "$LCHOICE" =~ ^[0-9]+$ ]] || (( LCHOICE < 1 || LCHOICE > ${#L_DIRS[@]} )); then
err "Ungueltige Auswahl."
exit 1
fi
SEL_ENTRY_DIR="${LIB_ENTRY_DIRS[$((LCHOICE-1))]}"
SEL_META="${SEL_ENTRY_DIR}meta.json"
LIB_TITLE="$(json_field "$SEL_META" title)"
LIB_VERSION="$(json_field "$SEL_META" version)"
LIB_MODE="$(json_field "$SEL_META" mode)"
LIB_FILENAME="$(json_field "$SEL_META" filename)"
LIB_PAYLOAD_PATH="${SEL_ENTRY_DIR}${LIB_FILENAME}"
if [[ ! -f "$LIB_PAYLOAD_PATH" ]]; then
err "Payload-Datei nicht gefunden: ${LIB_PAYLOAD_PATH}"
exit 1
fi
if [[ "$LIB_MODE" == "recovery" ]]; then
WRITE_MODE="cim"
else
WRITE_MODE="asr"
DMG_PATH="$LIB_PAYLOAD_PATH"
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: ${LIB_TITLE} (${LIB_VERSION}) -- Quelle: lokale Bibliothek (${LIB_PAYLOAD_PATH})"
ok "Gewaehlt: ${SEL_TITLE} (${SEL_VERSION}) -- Quelle: lokale Bibliothek"
fi
# ---------- Schritt 2: Installer-Payload besorgen ----------
title "Schritt 2/4: Installer-Payload besorgen"
if [[ "$SOURCE" == "apple" ]]; then
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."
echo "Zielort: ${APP_PATH}"
read -r -p "Download jetzt starten? [j/N]: " GO
if [[ ! "$GO" =~ ^[jJ]$ ]]; then
echo "Abgebrochen."
exit 0
fi
echo
echo "Starte Download (kann je nach Version 30-90 Minuten dauern)..."
sudo softwareupdate --fetch-full-installer --full-installer-version "$SEL_VERSION"
RC=$?
if [[ $RC -ne 0 || ! -d "$APP_PATH" ]]; then
err "Download fehlgeschlagen oder App nicht am erwarteten Ort. Erwartet: ${APP_PATH}"
echo "Tipp: 'ls /Applications | grep Install' zeigt den tatsaechlichen Namen."
exit 1
fi
ok "Download abgeschlossen: ${APP_PATH}"
fi
elif [[ "$WRITE_MODE" == "cim" ]]; then
# Bibliothek, Big Sur+: InstallAssistant.pkg -> komplette Install-App
# nach /Applications entpacken (Apples Standardverhalten von 'installer').
if [[ -d "$APP_PATH" ]]; then
ok "'${APP_PATH}' existiert bereits — Installer-Lauf wird uebersprungen."
else
echo "Entpacke ${LIB_PAYLOAD_PATH} nach /Applications (installer -pkg, braucht sudo)..."
echo "Das dauert je nach Groesse ein paar Minuten."
echo
sudo installer -pkg "$LIB_PAYLOAD_PATH" -target /
RC=$?
if [[ $RC -ne 0 ]]; then
err "'installer -pkg' fehlgeschlagen ($RC)."
exit 1
fi
if [[ ! -d "$APP_PATH" ]]; then
# Fallback: Apples interner App-Name wich vom Bibliotheks-Titel ab --
# nimm das zuletzt angelegte "Install macOS *.app"-Bundle.
FALLBACK_APP="$(ls -dt /Applications/Install\ macOS*.app 2>/dev/null | head -n1)"
[[ -n "$FALLBACK_APP" ]] && APP_PATH="$FALLBACK_APP"
fi
if [[ ! -d "$APP_PATH" ]]; then
err "Konnte die installierte App nach 'installer -pkg' nicht finden."
echo "Tipp: 'ls /Applications | grep Install' zeigt was tatsaechlich da ist."
exit 1
fi
ok "Installiert: ${APP_PATH}"
fi
else
ok "Bibliothek liefert bereits ein fertiges, bootfaehiges Image (${DMG_PATH}) -- kein App-Download noetig."
fi
if [[ "$WRITE_MODE" == "cim" ]]; then
CIM_BIN="${APP_PATH}/Contents/Resources/createinstallmedia"
if [[ ! -x "$CIM_BIN" ]]; then
err "createinstallmedia nicht gefunden unter: ${CIM_BIN}"
exit 1
fi
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
PLIST_TMP="$(mktemp /tmp/diskutil_ext.XXXXXX.plist)"
diskutil list -plist external physical > "$PLIST_TMP" 2>/dev/null
declare -a DISKS=()
declare -a DISK_DESC=()
i=0
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_GB="?"
if [[ "$SIZE_BYTES" =~ ^[0-9]+$ ]]; then
SIZE_GB="$(( SIZE_BYTES / 1000 / 1000 / 1000 ))"
fi
DISKS+=("$DID")
DISK_DESC+=("/dev/${DID}${NAME:-unbekannt} (~${SIZE_GB} GB)")
i=$((i+1))
done
rm -f "$PLIST_TMP"
if [[ ${#DISKS[@]} -eq 0 ]]; then
if ! list_external_disks; then
err "Keine externen physischen Laufwerke gefunden. Stick eingesteckt?"
exit 1
fi
for i in "${!DISKS[@]}"; do
printf " %s%2d)%s %s\n" "$BOLD" "$((i+1))" "$RESET" "${DISK_DESC[$i]}"
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"
@@ -367,13 +487,13 @@ if [[ "$DCHOICE" == "0" || -z "$DCHOICE" ]]; then
echo "Abgebrochen."
exit 0
fi
if ! [[ "$DCHOICE" =~ ^[0-9]+$ ]] || (( DCHOICE < 1 || DCHOICE > ${#DISKS[@]} )); then
if ! [[ "$DCHOICE" =~ ^[0-9]+$ ]] || (( DCHOICE < 1 || DCHOICE > ${#D_IDS[@]} )); then
err "Ungueltige Auswahl."
exit 1
fi
TARGET_DISK="${DISKS[$((DCHOICE-1))]}"
TARGET_DESC="${DISK_DESC[$((DCHOICE-1))]}"
TARGET_DISK="${D_IDS[$((DCHOICE-1))]}"
TARGET_DESC="${D_DESCS[$((DCHOICE-1))]}"
echo
warn "Letzte Sicherheitsabfrage — dieser Stick wird geloescht:"
@@ -389,6 +509,7 @@ fi
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
@@ -415,12 +536,6 @@ if [[ "$WRITE_MODE" == "cim" ]]; then
ok "Geloescht: ${APP_PATH}"
fi
else
# asr-Pfad: BaseSystem.dmg/InstallESD.dmg ist schon ein fertiges,
# bootfaehiges Volume. Erst Stick neu formatieren (liefert einen
# Mountpunkt), dann Apples 'asr restore' das eigentliche Image drauf
# kopieren lassen -- das re-formatiert dabei nochmal komplett nach dem
# Schema des Quell-Images (--erase). Etablierte Community-Technik fuer
# macOS-Installer bis Catalina ohne createinstallmedia.
echo "Quelle: ${DMG_PATH}"
echo "Ziel: /dev/${TARGET_DISK}"
echo
@@ -458,3 +573,351 @@ 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