Replace the screenshot/OCR/keystroke driving of Apple's Recovery with a "PE": BaseSystem.dmg (a journaled HFS+ volume, writable from Linux) with one LaunchDaemon added (makeRecoveryPE) that runs /Volumes/VMIX/run.sh as root at boot, records the status and powers off. launchd loads it alongside its signed cache (verified on Tahoe 26.6.2); same idea as AutoNBI/Imagr NetBoot images. - makeImage: the PE runs vmix-install.sh (erase, installer app, SharedSupport pkgdmg, startosinstall). Progress is read from the serial console (boot-args serial=3 -v, VMIX-* markers) and screenshots (brightness only). Fully offline; prepare now takes ~5 min instead of ~10. - customizeImage: boots the PE with the image attached and runs the template offline against the mounted System/Data volumes; OpenCore ScanPolicy restricted to HFS+/SATA so only the PE can boot. One PE boot ~30 s. The installed macOS is never booted for customization, so nothing depends on launchd/BTM approval or a first-boot agent (removed). - templates rewritten for offline use: generalize creates the user with dscl -f (admin, home, auto-login kcpassword, Setup Assistant suppression, hostname, locale, timezone, keyboard type, container resize); remote-access, no-updates, performance edit the target's plists. - makeBootDisk: build-time OpenCore variant (serial console, ScanPolicy). - vm-driver.py rewritten: passive observation only (serial markers, kernel boots, panics, brightness), disk+serial-aware hang watchdog, reboot-death reset, halt/loginwindow detection. No OCR/tesseract. - OpenCore: four SMBIOS DIMMs for MacPro7,1 (no "Memory Modules Misconfigured" warning). - tools/soak.sh: repeatability harness. Verified on daku: base install 23 min end to end; basic + generalize in three ~30 s PE boots; the result auto-logs into the desktop with the created user. Root cause of the "first-boot hang" (from the serial log): the guest's restart path panics (IOPlatformHaltRestartAction -> AppleSMC, SMCWDT smcWriteKey kSMCBadCommand, nested panic) because the pinned OSX-KVM Lilu disables itself on macOS 26, so VirtualSMC never loads. Handled by the driver (reset within 60 s); kext update to follow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
100 lines
4.8 KiB
Bash
100 lines
4.8 KiB
Bash
#!/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"
|