The OSX-KVM ESP ships Lilu 1.6.8 / VirtualSMC 1.3.3 / WhateverGreen 1.6.7,
which disable themselves on macOS 26; Apple's SMC driver then runs on QEMU's
isa-applesmc stub and the restart path panics (SMCWDT smcWriteKey
kSMCBadCommand → nested panic after MACH Reboot). Overlay pinned current
releases (upstream.json opencore.kexts) and drop isa-applesmc: with the stub
present VirtualSMC steps aside ("multiple devices present"); alone it carries
the OSK and reboots work (PE restart test: 10 s, clean). RestrictEvents with
revpatch=memtab silences MacPro7,1's "Memory Modules Misconfigured".
- makeOpenCore: kext overlay + Kernel.Add entries for overlaid kexts,
--memory-mb (4 DIMMs), bootArgs default revpatch=memtab
- qemu.nix: deviceArgsFor { appleSmc } (default false); cli: --applesmc for
images built before this change
- vm-driver: reboot-death detection (reset 60 s after a guest reboot request
that never comes back), panics wait for XNU's own auto-reboot, debug dir
works across nixbld users
- generalize: QEMU USB keyboard declared ANSI (no Keyboard Setup Assistant)
- README: architecture, reliability handling, debugging
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
111 lines
6 KiB
Markdown
111 lines
6 KiB
Markdown
# 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 `dd`s
|
|
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).
|
|
|
|
OpenCore comes from OSX-KVM's proven ESP, with Lilu / VirtualSMC /
|
|
WhateverGreen replaced by current releases (`upstream.json` → `opencore.kexts`):
|
|
the versions OSX-KVM ships disable themselves on macOS 26, and without
|
|
VirtualSMC the guest's restart path panics on QEMU's SMC stub
|
|
(`SMCWDT smcWriteKey kSMCBadCommand`, nested panic after `MACH Reboot`).
|
|
For the same reason QEMU's `isa-applesmc` is not used any more: its presence
|
|
makes VirtualSMC step aside ("multiple devices present"); VirtualSMC carries
|
|
the OSK itself. Images built before this change still need the stub:
|
|
`vmix run --macos --applesmc`. RestrictEvents (`revpatch=memtab`) silences
|
|
MacPro7,1's "Memory Modules Misconfigured" at login.
|