vmix.nix/lib/images/macos/README.md
Git Sagar 242e48a5fc macOS Tahoe VM images (OpenCore/QEMU), Apple-ID compatible, VNC
Add a macOS image pipeline mirroring the Windows one: unattended install,
generalization and user creation, driven end to end in QEMU on a KVM host.

lib/images/macos:
- makeOpenCore: OSX-KVM OpenCore ESP with a config.plist rewritten per image —
  SMBIOS model + serial/MLB (macserial) + UUID + ROM=en0 MAC (built-in NIC pinned
  to PciRoot(0x0)/Pci(0x12,0x0)) for Apple ID / iMessage / App Store; boot disk;
  OpenCore self-entry hidden. ident.nix derives MAC+UUID from a seed so the NixOS
  module and CLI know the NIC MAC at eval time.
- makeImage: one QEMU session driven by vm-driver.py (QMP + screenshot settle
  detection + OCR of the menu bar) — boots the recovery via OpenCore, opens
  Terminal (Ctrl-F2 -> Utilities -> Terminal), types the bootstrap command;
  vmix-install.sh erases the disk as APFS, lays down the host-extracted installer
  app skeleton + a byte-exact SharedSupport.dmg (raw disk mapped to that byte range
  of the pkg, dd'd in — Recovery's xar truncates an 18 GB member), runs
  startosinstall with the vmix agent pkg. OpenCore is then copied into the image's
  ESP so it boots standalone with OVMF.
- customizeImage / templates: boot the image with a FAT-then-HFS+ VMIX volume; the
  vmix agent (LaunchDaemon) runs a script as root, records status and powers off —
  the macOS counterpart of Windows Audit Mode. generalize creates the admin user +
  auto-login (kcpassword), suppresses Setup Assistant, sets hostname/timezone,
  grows APFS, and assigns a fresh SMBIOS identity. Templates: noUpdates,
  performance, remoteAccess (ssh + screen sharing).
- fetchRecovery: Apple recovery BaseSystem, retried until the pinned Tahoe build
  (osrecovery load-balances Sequoia/Tahoe during the rollout). makeAgentPkg builds
  a distribution flat pkg on Linux (xar+bom+cpio) for startosinstall --installpackage.

CLI: vmix build/copy/run for macOS (run --macos --vnc, reads the image's MAC from
its ESP), and a `vmix macserial` helper. NixOS module: disks.os.file carrying
_vmixOsType="macos" auto-enables the macOS QEMU profile (AppleSMC+OSK, Skylake
CPU spoof, AHCI system disk, VMware SVGA, pinned NIC); macos.{enable,cpu,mac}.

Status: proven through the installer prepare phase (SharedSupport.dmg mounts,
version 26.6.2 read, SU catalog loads). Two blockers remain, documented in
lib/images/macos/README.md: (1) startosinstall's OSISVerifyBaseSystemOperation
rejects the byte-identical plain-UDIF SharedSupport as "pkgdmg missing a footer"
in this Tahoe recovery/VM; (2) Apple's CDN unreliably serves the Tahoe recovery
during rollout (self-hosting the verified BaseSystem is the robust fix).

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

8 KiB

vmix macOS images

Unattended macOS (Tahoe / 26) VM images, built the same way as the Windows images: makeImage installs the OS once, templates customize it by booting it, .generalize creates the user and seals the image.

vmix build --image macos.images.tahoe.basic \
     --generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich
vmix run ./result --macos --vnc :10 --mem 8192      # VNC on port 5910

How it works

step what happens
fetchRecovery BaseSystem.dmg from Apple's recovery servers (fixed-output, pinned by sha256)
installerPayload takes the App Store InstallAssistant.pkg (18 GB, pinned) apart on Linux: the app skeleton (pbzx/cpio) and the byte offset of SharedSupport.dmg
makeOpenCore OSX-KVM's OpenCore ESP with a config.plist rewritten for this image: SMBIOS model, serial + MLB (macserial), UUID and ROM = NIC MAC (derived from a seed), NIC marked built-in
makeImage one QEMU session: Recovery boots via OpenCore → vm-driver.py opens Terminal with keystrokes (Ctrl-F2 menu navigation, screen-settle detection + OCR of the menu bar) and types sh /Volumes/VMIX/run.shvmix-install.sh erases the disk, rebuilds Install macOS Tahoe.app (skeleton + SharedSupport.dmg copied from a raw disk mapped straight out of the pkg), runs startosinstall --installpackage vmix-agent.pkg → installer reboots through its phases → first boot runs the vmix agent which powers off. OpenCore is then copied into the image's EFI partition, so it boots standalone with OVMF
customizeImage boots the image with a FAT volume VMIX; the agent (LaunchDaemon ch.vmix.agent) runs vmix-run.sh as root, writes vmix-run.status/.log back and shuts down
templates.generalize user (admin) + auto-login (/etc/kcpassword), Setup Assistant suppressed, hostname, timezone, no sleep, APFS grown to the disk, then the agent removes itself; a fresh SMBIOS identity is written to the ESP

The vmix agent replaces Windows' Audit Mode RunOnce; .AppleSetupDone replaces the OOBE unattend. Everything on the host side runs inside __noChroot derivations (KVM + /tmp), exactly like the Windows builders.

Generalize options

username password fullName autoLogon hostname locale timezone delayOobeRun as for Windows (bgColor is accepted but ignored), plus the SMBIOS identity: model serial mlb uuid mac seed. Anything unset is generated: serial/MLB by macserial (random per build), MAC and UUID deterministically from seed (default hostname-username). vmix macserial --model MacPro7,1 prints a ready-to-paste set.

delay-oobe-run=true creates no user and re-arms Setup Assistant for the first real boot.

Apple ID / iMessage

The image satisfies what Dortania lists for iServices: unique serial + MLB for a Tahoe-supported model (MacPro7,1 by default; iMac20,1/2, MacBookPro16,x also work), SystemUUID, ROM equal to en0's MAC, and en0 marked built-in (the NIC is pinned to PciRoot(0x0)/Pci(0x12,0x0)). The NixOS module and vmix run --macos use the MAC recorded in the image (EFI/vmix/vmix.json). Give each deployed VM its own generalized image (different seed, or explicit serial=/mlb=) — two VMs with the same identity will be blocked.

Runtime

  • vmix run <qcow2> --macos [--vnc :N] [--mac ..]
  • NixOS module: disks.os.file = vmixLib.macos.images.tahoe.basic.generalize {...} is auto-detected (_vmixOsType = "macos"): Skylake-Client CPU spoof, AppleSMC, USB keyboard/tablet, AHCI system disk, VMware SVGA, pinned NIC with the image's MAC. macos.cpu, macos.mac, macos.enable override the defaults.
  • vmix copy writes the image to a disk but cannot grow APFS from Linux (diskutil apfs resizeContainer disk0s2 0 in macOS afterwards).

Debugging a build

Screenshots (NNN-<state>.png), driver.log and the QMP socket of every VM session are in /tmp/vmix-macos/<image name>/ on the build host. The guest logs (install.log, vmix-run.log, vmix-agent.log) are printed at the end of the build. Pass vncDisplay = ":10" to makeImage/customizeImage (or --generalize vncDisplay=:10) to watch live; with a DISPLAY an SDL window is used as for Windows.

Updating pins (upstream.json)

  • installer: URL + SRI hash of a newer InstallAssistant.pkg (nix store prefetch-file --name InstallAssistant.pkg <url>; Mr. Macintosh's database lists Apple's URLs)
  • recovery: Apple serves the current build for the board id, so the sha256 changes with each point release — copy the "got:" hash from the failed build
  • opencore: OSX-KVM OpenCore.qcow2 at a commit; OpenCorePkg release zip (macserial/ocvalidate)

Known limits

  • The Recovery bootstrap depends on keyboard navigation of the Recovery UI (Ctrl-F2 → Utilities → Terminal). It self-corrects with screenshots + OCR and falls back to a blind sequence, but a Recovery UI change would need vm-driver.py adjusted.
  • Hosts must run KVM with an AVX2-capable CPU (Intel or AMD; the guest sees a Skylake). sandbox = relaxed and the kvm system feature, as for Windows.
  • Software updates inside the VM are disabled by the noUpdates template (OTA updates in a VM need the RestrictEvents kext).

Current status (2026-09-08)

Everything up to and including the macOS Installer's prepare phase is working and proven end to end on the root@daku.home KVM host:

  • OpenCore ESP per-image SMBIOS (serial/MLB via macserial, ROM=MAC, UUID), boot disk, .contentVisibility to hide the OC self-entry — works (ocvalidate clean).
  • Tahoe recovery boots via OpenCore; vm-driver.py drives it entirely by screenshots + OCR: handles the OpenCore picker, opens Terminal (Ctrl-F2 → Utilities → Terminal), types the bootstrap command — works.
  • HFS+ VMIX volume mounts in Recovery; the install script erases the disk as APFS "Macintosh HD", lays down the host-extracted Install macOS Tahoe.app skeleton and dds a byte-exact SharedSupport.dmg from a raw disk mapped to that byte range of the pkg (verified sha256 identical to Apple's) — works.
  • startosinstall runs, osinstallersetupd mounts SharedSupport.dmg, reads the MobileAsset bundle (IA OS Version: 26.6.2, Build 25G83), loads the 641-product SU catalog from swscan.apple.com (so guest networking works), and logs Machine is VM, will assume APFS is supported.

After mounting the dmg, osinstallersetupd runs a verify step that treats SharedSupport.dmg as a pkgdmg (Getting offset for dmg in pkg) and fails pkgdmg is missing a footerInstallation cannot proceed because the installer is damaged (Code 255). But this InstallAssistant SharedSupport.dmg is a plain UDIF image (first bytes eb 58 90 …, not xar!), and it is byte-identical to Apple's — so this is not truncation or corruption (earlier truncation, from Recovery's xar mishandling an 18 GB member, was fixed by the byte-range dd). It is a macOS-internal verification that rejects the plain-UDIF SharedSupport when running Tahoe's startosinstall in this recovery/VM. Network is fine (the catalog loaded), so it is not the firewall case commonly cited for this error.

Leads not yet tried: driving the Tahoe recovery's network "Reinstall macOS" (GUI, downloads assets at install time — sidesteps the local-dmg verify); a different SMBIOS/board; or a newer OpenCore/kext combo. This is cutting-edge (Tahoe shipped 2025-09) and the hackintosh community is still working it out.

Blocker 2 — Tahoe recovery availability on Apple's CDN

osrecovery.apple.com is load-balancing latest across CDN nodes during the Tahoe rollout: most requests return the Sequoia 15.4.1 BaseSystem (082-33203), some return Tahoe 26.6.2 (140-93589). fetchRecovery retries until it gets the pinned Tahoe hash, but that can exhaust its attempts when Tahoe is rare. The robust fix is to self-host the verified Tahoe BaseSystem.dmg (960530321 bytes, sha256 edddd0d5…, confirmed 26.6.2) the way the Win10 ISO is hosted on git.sagar.ch, and point fetchRecovery at it.