#!/bin/bash # vmix unattended macOS install, run by the PE hook (pe.sh) as root in the # Recovery with /Volumes/VMIX mounted (V). Needs vmix.conf: TARGET_BYTES, # PKG_BYTES, PKG_DISK_BYTES, APP_NAME, VOLUME_NAME. # 1. find the target disk and the SharedSupport (InstallAssistant.pkg) disk by size # 2. erase the target as APFS, unpack the installer app, dd the whole pkg into it # as SharedSupport.dmg (a "pkgdmg", startosinstall checks its koly footer) # 3. startosinstall prepares, then reboots itself into the install phase; the # installed system's first boot ends at the loginwindow (the host powers off) # Never returns on success; a return means failure (the PE records the status). set -x . "$V/pe-lib.sh" fail() { echo "VMIX-FAIL: $*" cp /var/log/install.log "$V/system-install.log" 2>/dev/null sync exit 1 } # each boot into the PE with the install still pending is one attempt ATTEMPT=$(( $(cat "$V/install.attempt" 2>/dev/null || echo 0) + 1 )) echo "$ATTEMPT" > "$V/install.attempt"; sync echo "VMIX-INSTALL: attempt $ATTEMPT (boot into the PE)" [ "$ATTEMPT" -le 3 ] || fail "the installer keeps coming back to the PE ($ATTEMPT boots)" # --- 1. disks by exact size disk_by_size() { for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+' | sort -u); do if [ "$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9]*\) Bytes).*/\1/p')" = "$1" ]; then echo "${d#/dev/}"; return 0 fi done return 1 } TARGET=$(disk_by_size "$TARGET_BYTES") || fail "target disk of $TARGET_BYTES bytes not found" SSDISK=$(disk_by_size "$PKG_DISK_BYTES") || fail "SharedSupport disk of $PKG_DISK_BYTES bytes not found" echo "VMIX-INSTALL: target=$TARGET sharedsupport=$SSDISK" # --- 2. target volume + installer app (the pkg payload skeleton + SharedSupport.dmg) VOL="/Volumes/$VOLUME_NAME" APP="$VOL/$APP_NAME" SS="$APP/Contents/SharedSupport/SharedSupport.dmg" prepare_target() { diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk" [ -d "$VOL" ] || fail "$VOL not mounted after erase" tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar installer app" [ -x "$APP/Contents/Resources/startosinstall" ] || fail "startosinstall missing from $APP" mkdir -p "$APP/Contents/SharedSupport" FULL=$(( PKG_BYTES / 1048576 )); REM=$(( PKG_BYTES % 1048576 )) echo "VMIX-INSTALL: copying SharedSupport.dmg ($PKG_BYTES bytes) from /dev/r$SSDISK" dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport" [ "$REM" -gt 0 ] && { dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 | dd bs=1 count=$REM >> "$SS"; } || true [ "$(stat -f %z "$SS")" = "$PKG_BYTES" ] || fail "SharedSupport.dmg size $(stat -f %z "$SS") != $PKG_BYTES" tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no koly footer" chflags -h norestricted "$SS" 2>/dev/null || true sync } prepare_target SOI="$APP/Contents/Resources/startosinstall" echo "VMIX-INSTALL: app ready, clock $(date -u)" # Offline install: no NIC is attached. Blackhole Apple's install/verify endpoints # too, so osinstallersetupd's requests fail immediately instead of timing out. for d in swscan.apple.com swcdn.apple.com swdist.apple.com swquery.apple.com \ gs.apple.com gsa.apple.com gdmf.apple.com mesu.apple.com xp.apple.com \ albert.apple.com captive.apple.com deviceservices-external.apple.com \ identity.apple.com ppq.apple.com crl.apple.com ocsp.apple.com \ ocsp2.apple.com valid.apple.com; do echo "127.0.0.1 $d" >> /etc/hosts done # --- 3. startosinstall prepares (~5 min) then reboots the machine itself into the # install phase; it never returns on success. Prepare is intermittently slow in # QEMU, so an attempt that stalls or runs too long is killed and retried on a # freshly erased target. run_soi() { "$SOI" --volume "$VOL" --agreetolicense --nointeraction --rebootdelay 5 "$@"; } free_kb() { df -k "$VOL" 2>/dev/null | awk 'NR==2 {print $4}'; } try=0 while [ "$try" -lt 6 ]; do try=$((try + 1)) [ "$try" -gt 1 ] && prepare_target echo "VMIX-INSTALL: startosinstall try $try" run_soi 2>&1 & SOI_PID=$! last=$(free_kb); stalled=0; elapsed=0 while kill -0 "$SOI_PID" 2>/dev/null; do sleep 30; elapsed=$((elapsed + 30)) now=$(free_kb) if [ "$now" = "$last" ]; then stalled=$((stalled + 30)); else stalled=0; last=$now; fi [ $((elapsed % 120)) -eq 0 ] && echo "VMIX-INSTALL: prepare running ${elapsed}s (stalled ${stalled}s)" if [ "$stalled" -ge 240 ] || [ "$elapsed" -ge 600 ]; then echo "VMIX-INSTALL: prepare too slow (stalled=${stalled}s elapsed=${elapsed}s), killing to retry" kill -9 "$SOI_PID" 2>/dev/null; pkill -9 -f startosinstall 2>/dev/null break fi done wait "$SOI_PID" 2>/dev/null echo "VMIX-INSTALL: startosinstall try $try ended without rebooting" sleep 3 done fail "startosinstall did not complete after $try tries"