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
134 lines
8 KiB
Markdown
134 lines
8 KiB
Markdown
# 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.sh` → `vmix-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 `dd`s 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`.
|
|
|
|
### Blocker 1 — `OSISVerifyBaseSystemOperation: pkgdmg is missing a footer`
|
|
|
|
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 footer` → `Installation 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.
|