vmix.nix/lib/images/macos/README.md
Git Sagar 8dc8f4265d macOS: drive the install and all customization from a Recovery "PE", no GUI
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
2026-09-10 13:33:38 -03:00

5.3 KiB

macOS images (Tahoe 26)

Pre-installed, Apple-ID-capable macOS VM images built the same way as the Windows ones: makeImage (unattended install) → templates → .generalize (user, hostname, fresh SMBIOS identity). Runs on QEMU/KVM with OpenCore.

vmix build --image macos.images.tahoe.basic --generalize username=sagar,password=secret,hostname=MAC
vmix run ./result --macos --vnc :10 --mem 8192

Nix: macos.images.tahoe.{pe,upstream,basic,remote} and <image>.generalize { username; password; hostname; timezone; locale; seed; … }.

How it works: the vmix "PE"

Apple's Recovery (BaseSystem.dmg, a plain journaled HFS+ volume) with one LaunchDaemon added (makeRecoveryPE): at boot it mounts a VMIX volume and runs run.sh from it as root, records the exit status and powers off. That is the whole automation surface — the equivalent of Windows PE + Autounattend:

  • no GUI is driven: no OCR, no keystrokes, no screen layouts to learn per macOS version; the hook is a launchd plist, stable across releases (same idea as AutoNBI/Imagr NetBoot images).
  • observable: the guest prints VMIX-* markers to /dev/console, which the build reads from QEMU's serial log (boot-args serial=3 -v). Kernel panics and reboots show up there too. Screenshots are still taken for debugging.
  • offline: no NIC during the install, and the guest blackholes Apple's install/verify endpoints so startosinstall never waits on the network. The only inputs are the pinned InstallAssistant.pkg and BaseSystem.dmg.
  • everything else happens offline from the PE too: templates and generalize mount the image's Data volume (rw) and System volume (ro) and edit them (dscl -f for users, plutil for preferences) — the installed macOS is never booted for customization, so nothing depends on launchd/BTM approval, first-boot agents or auto-login inside the guest. One PE boot ≈ 30 s.

Pipeline

  1. makeRecoveryPE — BaseSystem.dmg → raw HFS+ image + ch.vmix.pe daemon.
  2. makeImage — QEMU with: OpenCore boot disk (build variant with serial console), the PE, the empty target disk, the VMIX volume (vmix-install.sh, installer app skeleton) and the whole InstallAssistant.pkg mapped as a raw disk. The guest script erases the target as APFS, unpacks the app and dds the pkg into it as SharedSupport.dmg (it is a "pkgdmg": xar + koly footer; the bare xar member fails with "pkgdmg is missing a footer"), then runs startosinstall, which reboots itself through the install phases. The installed system's first boot ends at the loginwindow: the driver detects the bright screen and powers the VM down. OpenCore is then copied into the image's own ESP so it boots with plain OVMF.
  3. customizeImage — boots the PE with the image attached (OpenCore ScanPolicy restricted to HFS+ on SATA, so only the PE can boot) and runs the template script with $SYS/$DATA mounted. pe-lib.sh has the helpers.
  4. templates/generalize.nix — user (dscl, admin, home from the user template), auto-login (kcpassword), Setup Assistant suppression, hostname, locale, timezone, keyboard type, container resize, fresh SMBIOS via a new OpenCore ESP (serial/mlb from macserial, MAC + UUID from seed).

Recovery source

recovery.file in upstream.json points at a content-addressed store path for the verified Tahoe BaseSystem.dmg (Apple's CDN load-balances Sequoia/Tahoe during the rollout, so a plain fetch is non-deterministic). Reproduce it on any host with nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg. Drop recovery.file to fetch from Apple instead (fetchRecovery retries until the pinned hash matches).

Reliability

Things QEMU does intermittently, and what handles each (all in vm-driver.py and vmix-install.sh; every event is logged with a reason):

  • startosinstall prepare stalls or crawls — the guest kills and retries it on a freshly erased target (free-space watchdog + time cap).
  • the installer comes back to the PE instead of the install phase — the PE counts boots and simply re-runs the install (max 3).
  • the installed system hangs at the Apple logo on first boot — a system_reset is issued only when the screen is dark and frozen and disk and serial console are idle, so a slow-but-working boot is never interrupted.
  • macOS shutdown -h halts to a black screen without an ACPI power-off — an idle black screen counts as a completed halt.
  • a kernel panic (seen on the serial console) resets the VM.
  • a wedged run fails at the 4 h timeout instead of hanging.

tools/soak.sh <flake> macos.images.tahoe.upstream 3 rebuilds an image N times and tabulates outcome, duration, boots, resets, panics and retries.

Debugging

/tmp/vmix-macos/<name>/ on the build host: driver.log, serial.log (kernel + VMIX-* markers), periodic PNG screenshots, qmp.sock. vmix-run.log / system-install.log from the VMIX volume are printed at the end of the build. Add vncDisplay = ":10" to watch.

QEMU profile

helpers/qemu.nix: q35, Skylake-Client CPU spoof (works on AMD), AppleSMC with the OSK, XHCI keyboard/tablet, AHCI disks, VMware SVGA, virtio-net pinned to PciRoot(0x0)/Pci(0x12,0x0) so OpenCore marks it built-in (en0, required for Apple ID / iMessage). SMBIOS MacPro7,1 with four DIMMs described (avoids the "Memory Modules Misconfigured" warning).