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
This commit is contained in:
Git Sagar 2026-09-09 11:21:24 -03:00
parent 58a317f5d2
commit 8dc8f4265d
24 changed files with 802 additions and 977 deletions

View file

@ -1,138 +1,100 @@
# vmix macOS images
# macOS images (Tahoe 26)
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.
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,timezone=Europe/Zurich
vmix run ./result --macos --vnc :10 --mem 8192 # VNC on port 5910
vmix build --image macos.images.tahoe.basic --generalize username=sagar,password=secret,hostname=MAC
vmix run ./result --macos --vnc :10 --mem 8192
```
## How it works
Nix: `macos.images.tahoe.{pe,upstream,basic,remote}` and
`<image>.generalize { username; password; hostname; timezone; locale; seed; … }`.
| 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 |
## How it works: the vmix "PE"
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.
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:
## Generalize options
* **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.
`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.
### Pipeline
`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-09): working offline install
`macos.images.tahoe.upstream` builds a bootable, installed macOS Tahoe 26.6.2
qcow2 **fully offline** on the KVM host — no dependency on Apple's servers at build
time, just the pinned local `InstallAssistant.pkg` and `BaseSystem.dmg`. The
finished image boots standalone (OpenCore from its own ESP) to the macOS
loginwindow. Serial/MLB/UUID/ROM are per-image for Apple ID / iMessage.
How the install is driven (`vm-driver.py`, all by screenshot + OCR over QMP):
* The whole `InstallAssistant.pkg` is mapped as a raw disk (it is a "pkgdmg":
xar + koly footer) and `dd`'d byte-exact into the app as `SharedSupport.dmg`
extracting the bare xar member fails startosinstall with "pkgdmg missing a footer".
* No NIC during install + `/etc/hosts` blackhole of Apple's install/verify
endpoints, so `startosinstall`'s network calls fail fast instead of hanging —
offline prepare, no external dependency. `SecureBootModel=Disabled` lets the
sealed volume install without online personalization.
* The recovery display is kept awake with a tiny mouse jiggle (a lone keypress
does not reset display sleep, and the sleeping display swallows the menu-nav
keystrokes); the settle detector uses a coarse fingerprint so the jiggling
cursor is not seen as a screen change.
* startosinstall prepare is intermittently slow/stalls; a guest watchdog kills and
re-erases/retries an attempt that stalls or runs > 9 min.
* First boot in QEMU intermittently hangs at the Apple logo; a disk-aware watchdog
(`--progress-file`) issues a QMP `system_reset` only when the screen is dark AND
the disk is idle, so a slow-but-working boot is never interrupted.
* The install reaching the (bright) loginwindow is detected by brightness (the
faint gray "password" text does not OCR) and the driver powers the VM down —
the image is installed. macOS `shutdown -h now` halts to black without an ACPI
power-off, so a black+disk-idle screen is also treated as a completed halt.
* OpenCore is then copied into the image's own ESP so it boots standalone with OVMF.
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` (same path
from the same bytes). Set `recovery.sha256` and remove `recovery.file` to fetch it
from Apple instead (subject to the CDN rollout).
`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).
### Not yet done: generalize / user creation
## Reliability
The base image installs and boots to loginwindow. `.generalize` (user creation,
auto-login, hostname) relies on the vmix agent LaunchDaemon running on first boot,
but macOS Ventura+ Background Task Management does not auto-run a headless
third-party daemon, and neither the pkg `launchctl bootstrap` (installer domain
only) nor a cron `@reboot` reliably triggered it. The robust next step is to inject
the user record + settings offline from the agent pkg's postinstall (which runs as
root on the target during install), instead of a first-boot daemon.
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).

View file

@ -9,16 +9,17 @@ let
fetchRecovery = import ./helpers/fetchRecovery.nix { inherit pkgs upstream; };
installerPayload = import ./helpers/installerPayload.nix { inherit pkgs lib; };
makeOpenCore = import ./helpers/makeOpenCore.nix { inherit pkgs lib upstream macserial qemu; };
makeBootDisk = import ./helpers/makeBootDisk.nix { inherit pkgs lib; };
makeRecoveryPE = import ./helpers/makeRecoveryPE.nix { inherit pkgs lib; };
makeVmixVolume = import ./helpers/makeVmixVolume.nix { inherit pkgs lib; };
makeAgentPkg = import ./helpers/makeAgentPkg.nix { inherit pkgs lib; };
installBootloader = import ./helpers/installBootloader.nix { inherit pkgs lib; };
vmixReadback = import ./helpers/vmix-readback.nix { inherit pkgs lib; };
vmDriver = ./helpers/vm-driver.py;
makeImage = import ./helpers/makeImage.nix {
inherit pkgs lib qemu ident installerPayload makeOpenCore makeVmixVolume makeAgentPkg installBootloader vmixReadback vmDriver;
inherit pkgs lib qemu ident installerPayload makeOpenCore makeBootDisk makeVmixVolume installBootloader vmixReadback vmDriver;
};
customizeImage = import ./helpers/customizeImage.nix {
inherit pkgs lib qemu ident makeVmixVolume makeOpenCore installBootloader vmixReadback vmDriver;
inherit pkgs lib qemu ident makeVmixVolume makeOpenCore makeBootDisk installBootloader vmixReadback vmDriver;
};
customizeImageFold = builtins.foldl' customizeImage;
templates = import ./templates { inherit pkgs lib; };

View file

@ -1,42 +0,0 @@
#!/bin/sh
# vmix agent: LaunchDaemon that runs at every boot as root (installed by the vmix
# agent pkg via startosinstall --installpackage). If a volume named VMIX carrying
# vmix-run.sh is attached, run it, record the result on the volume and power off.
# Without the volume it is a no-op (normal boot). Counterpart of the Windows Audit
# Mode RunOnce script; the generalize step removes it once the image is sealed.
LOG=/var/log/vmix-agent.log
exec >>"$LOG" 2>&1
echo "=== vmix agent: $(date) ==="
# The agent pkg bootstraps this daemon during the OS install (to approve it past
# Background Task Management, so launchd runs it at first boot). Don't do the job
# in that installer environment — only on the installed system's first boot.
if pgrep -x bootinstalld >/dev/null 2>&1 || pgrep -qx "Installer Progress" 2>/dev/null \
|| [ -d /System/Volumes/Update/mnt1 ]; then
echo "vmix agent: OS installer is running, skipping"
exit 0
fi
# let DiskArbitration settle so the VMIX volume is mountable
sleep 5
V=/Volumes/VMIX
i=0
while [ ! -f "$V/vmix-run.sh" ] && [ $i -lt 30 ]; do
diskutil mount VMIX >/dev/null 2>&1
sleep 2
i=$((i + 1))
done
if [ ! -f "$V/vmix-run.sh" ]; then
echo "vmix agent: no VMIX volume, normal boot"
exit 0
fi
echo "vmix agent: running vmix-run.sh"
cd "$V" || exit 1
sh "$V/vmix-run.sh" >"$V/vmix-run.log" 2>&1
rc=$?
echo "vmix agent: vmix-run.sh exited $rc"
echo "$rc" >"$V/vmix-run.status"
cp "$LOG" "$V/vmix-agent.log" 2>/dev/null
cp /var/log/vmix-agent-install.log "$V/vmix-agent-install.log" 2>/dev/null
sync
sleep 2
diskutil unmount force "$V" >/dev/null 2>&1
shutdown -h now

View file

@ -3,17 +3,17 @@
<plist version="1.0">
<dict>
<key>Label</key>
<string>ch.vmix.agent</string>
<string>ch.vmix.pe</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>/Library/vmix/agent.sh</string>
<string>/bin/bash</string>
<string>/usr/libexec/vmix/pe.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/vmix-agent.log</string>
<string>/dev/console</string>
<key>StandardErrorPath</key>
<string>/var/log/vmix-agent.log</string>
<string>/dev/console</string>
</dict>
</plist>

View file

@ -0,0 +1,51 @@
# vmix PE helpers, sourced by run.sh scripts running in the recovery.
# Expects V=/Volumes/VMIX (set by pe.sh) and VOLUME_NAME from vmix.conf.
V=${V:-/Volumes/VMIX}
[ -f "$V/vmix.conf" ] && . "$V/vmix.conf"
VOLUME_NAME=${VOLUME_NAME:-Macintosh HD}
pe_log() { echo "VMIX: $*"; }
pe_fail() { echo "VMIX-FAIL: $*"; exit 1; }
# Mount the installed system's APFS volume group (System read-only, Data rw) and
# export SYS / DATA mount points plus SYS_ID / DATA_ID device identifiers.
pe_mount_target() {
local list; list=$(diskutil list)
DATA_ID=$(echo "$list" | awk -v n="APFS Volume $VOLUME_NAME - Data" 'index($0, n) {print $NF; exit}')
SYS_ID=$(echo "$list" | awk -v n="APFS Volume $VOLUME_NAME " '!/ - Data/ && index($0, n) {print $NF; exit}')
[ -n "$DATA_ID" ] && [ -n "$SYS_ID" ] || { pe_log "target volumes not found"; echo "$list"; return 1; }
diskutil mount "$SYS_ID" >/dev/null 2>&1 || true
diskutil mount "$DATA_ID" >/dev/null 2>&1 || true
SYS=$(diskutil info "$SYS_ID" | sed -n 's/^ *Mount Point: *//p')
DATA=$(diskutil info "$DATA_ID" | sed -n 's/^ *Mount Point: *//p')
[ -d "$DATA/private/var/db" ] || { pe_log "Data volume not mounted (SYS=[$SYS] DATA=[$DATA])"; return 1; }
pe_log "target mounted: SYS=[$SYS] DATA=[$DATA]"
export SYS DATA SYS_ID DATA_ID
}
pe_unmount_target() {
sync
diskutil unmount "$DATA_ID" >/dev/null 2>&1 || true
diskutil unmount "$SYS_ID" >/dev/null 2>&1 || true
}
# plist helpers on files of the (offline) target: create the file if missing.
pe_plist_set() { # FILE KEYPATH TYPE VALUE (TYPE: string|bool|integer|float)
local f=$1 k=$2 t=$3 v=$4
[ -f "$f" ] || plutil -create xml1 "$f"
plutil -replace "$k" "-$t" "$v" "$f"
}
pe_plist_dict() { # FILE KEYPATH — make sure a dictionary exists at KEYPATH
local f=$1 k=$2
[ -f "$f" ] || plutil -create xml1 "$f"
plutil -extract "$k" xml1 -o /dev/null "$f" >/dev/null 2>&1 || plutil -insert "$k" -dictionary "$f"
}
# launchd service override on the target (disabled.plist): pe_service LABEL true|false
pe_service_disabled() {
local f="$DATA/private/var/db/com.apple.xpc.launchd/disabled.plist"
mkdir -p "$(dirname "$f")"
pe_plist_set "$f" "$1" bool "$2"
}
# version of the installed system
pe_target_version() { plutil -extract ProductVersion raw -o - "$SYS/System/Library/CoreServices/SystemVersion.plist" 2>/dev/null; }
pe_target_build() { plutil -extract ProductBuildVersion raw -o - "$SYS/System/Library/CoreServices/SystemVersion.plist" 2>/dev/null; }

40
lib/images/macos/guest/pe.sh Executable file
View file

@ -0,0 +1,40 @@
#!/bin/bash
# vmix PE hook. Runs as root from launchd when the patched Recovery boots
# (injected by makeRecoveryPE). If a VMIX volume is attached it runs
# /Volumes/VMIX/run.sh, records the exit status on the volume and powers off;
# without one it does nothing and the recovery behaves normally.
# Everything printed here goes to /dev/console, i.e. the host's serial log.
exec >/dev/console 2>&1
echo "VMIX-PE: hook started $(date) uid=$(id -u)"
V=/Volumes/VMIX
i=0
while [ ! -f "$V/run.sh" ] && [ $i -lt 90 ]; do
diskutil mount VMIX >/dev/null 2>&1
sleep 2; i=$((i + 1))
done
if [ ! -f "$V/run.sh" ]; then
echo "VMIX-PE: no VMIX volume, leaving the recovery alone"
exit 0
fi
echo "VMIX-PE: VMIX mounted after $i retries"
caffeinate -dimsu -t 86400 >/dev/null 2>&1 &
[ -f "$V/vmix.conf" ] && . "$V/vmix.conf"
# certificate checks need a sane clock; a fresh VM RTC can be off
[ -n "${BUILD_DATE:-}" ] && date -u "$BUILD_DATE" >/dev/null 2>&1 && echo "VMIX-PE: clock set to $(date -u)"
export V
cd "$V"
echo "VMIX-PE: running run.sh"
/bin/bash "$V/run.sh" 2>&1 | tee "$V/vmix-run.log"
rc=${PIPESTATUS[0]}
echo "$rc" > "$V/vmix-run.status"
echo "VMIX-PE: run.sh exited $rc"
if [ -f "$V/vmix-reboot" ]; then
rm -f "$V/vmix-reboot"; sync
echo "VMIX-PE: rebooting as requested"
reboot
exit 0
fi
sync; sleep 1
diskutil unmount force "$V" >/dev/null 2>&1
echo "VMIX-PE-DONE rc=$rc"
shutdown -h now

View file

@ -1,88 +1,65 @@
#!/bin/sh
# vmix: automated macOS install. Runs inside macOS Recovery's Terminal, started
# by vm-driver.py which types "sh /Volumes/VMIX/run.sh" for us.
#
# 1. erase the target disk (found by size) as APFS "Macintosh HD"
# 2. rebuild "Install macOS <name>.app": app skeleton from installer-app.tar
# (host-extracted Payload) + SharedSupport.dmg = the WHOLE InstallAssistant.pkg
# dd'd byte-exact from a raw disk (Apple's own postinstall hardlinks the pkg
# there: it is a "pkgdmg" whose koly footer points at the dmg inside; the bare
# xar member fails startosinstall with "pkgdmg is missing a footer")
# 3. startosinstall unattended, with the vmix agent pkg as --installpackage
# 4. startosinstall reboots itself into the install phase; the vmix agent pkg
# installs during that phase and runs on the installed system's first boot
#
# On first boot of the installed system the agent runs /Volumes/VMIX/vmix-run.sh
# and powers off, which ends the QEMU session on the host.
# macOS Recovery invokes us as `sh` (bash in POSIX mode, no process substitution);
# re-exec once under bash so `>(tee ...)` and other bashisms work.
if [ -z "${VMIX_REEXEC:-}" ]; then VMIX_REEXEC=1 exec bash "$0" "$@"; fi
V="/Volumes/VMIX"
# tee to the Terminal (visible in host screenshots) and to a log on the volume
exec > >(tee "$V/install.log") 2>&1
#!/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/vmix.conf"
# keep the recovery display awake so the host driver can watch the screen
caffeinate -dimsu -t 86400 >/dev/null 2>&1 &
pmset -a displaysleep 0 sleep 0 >/dev/null 2>&1 || true
. "$V/pe-lib.sh"
fail() {
echo "vmix-install: FAIL: $*"
cp /var/log/install.log "$V/system-install.log" 2>/dev/null || true
echo 1 >"$V/install.status"
echo "VMIX-FAIL: $*"
cp /var/log/install.log "$V/system-install.log" 2>/dev/null
sync
sleep 2
shutdown -h now 2>/dev/null || halt 2>/dev/null || true
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)"
# whole-disk identifier (diskN) whose size in bytes is exactly $1
# --- 1. disks by exact size
disk_by_size() {
for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+'); do
s=$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9][0-9]*\) Bytes).*/\1/p')
[ "$s" = "$1" ] && { echo "${d#/dev/}"; return 0; }
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"
echo "vmix-install: $(date) app=$APP_NAME volume=$VOLUME_NAME"
TARGET=$(disk_by_size "$TARGET_BYTES") || fail "target disk ($TARGET_BYTES bytes) not found"
SSDISK=$(disk_by_size "$PKG_DISK_BYTES") || fail "installer pkg disk ($PKG_DISK_BYTES bytes) not found"
echo "vmix-install: target=$TARGET sharedsupport=$SSDISK"
# --- 1. erase the target disk as an APFS volume
diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk $TARGET"
# --- 2. target volume + installer app (the pkg payload skeleton + SharedSupport.dmg)
VOL="/Volumes/$VOLUME_NAME"
[ -d "$VOL" ] || fail "$VOL not mounted"
# --- 2. rebuild the installer app on the target volume
tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar installer-app.tar"
APP="$VOL/$APP_NAME"
SOI="$APP/Contents/Resources/startosinstall"
[ -x "$SOI" ] || fail "startosinstall missing in $APP"
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))
dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport.dmg"
if [ "$REM" -gt 0 ]; then
dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" || fail "dd SharedSupport.dmg tail"
fi
[ "$(stat -f %z "$SS")" = "$PKG_BYTES" ] || fail "SharedSupport.dmg size mismatch: $(stat -f %z "$SS") != $PKG_BYTES"
tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no UDIF koly footer"
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
echo "vmix-install: app=$APP SharedSupport.dmg=$(stat -f %z "$SS") bytes"
sync
}
prepare_target
SOI="$APP/Contents/Resources/startosinstall"
echo "VMIX-INSTALL: app ready, clock $(date -u)"
# macOS certificate validation needs a sane clock; a fresh VM RTC can be wrong.
echo "vmix-install: guest clock is $(date) (UTC $(date -u))"
if [ -n "${BUILD_DATE:-}" ]; then
date -u "$BUILD_DATE" && echo "vmix-install: set clock to $(date)"
fi
# Blackhole Apple's install/verify endpoints so osinstallersetupd's network calls
# fail immediately instead of timing out (prepare otherwise crawls). Fully offline.
# 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 \
@ -90,50 +67,34 @@ for d in swscan.apple.com swcdn.apple.com swdist.apple.com swquery.apple.com \
ocsp2.apple.com valid.apple.com; do
echo "127.0.0.1 $d" >> /etc/hosts
done
echo "vmix-install: blackholed Apple install endpoints for a fast offline prepare"
# --- 3. unattended install. startosinstall prepares then reboots the machine
# itself into the install phase. Prepare intermittently stalls (~46% — an online
# verify/personalization step through the VM's NAT), so a watchdog kills and
# retries startosinstall if the target volume makes no write progress for a while.
# The vmix agent pkg installs during the install phase and runs on first boot.
# quote args properly — $VOL contains a space ("Macintosh HD")
# --- 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}'; }
attempt=0
while [ "$attempt" -lt 10 ]; do
attempt=$((attempt + 1))
echo "vmix-install: startosinstall attempt $attempt"
if [ "$attempt" -eq 1 ]; then
run_soi --installpackage "$V/vmix-agent.pkg" 2>&1 &
else
# a stalled attempt leaves the volume dirty; re-erase and rebuild for a clean retry
diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk on retry"
tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar on retry"
mkdir -p "$APP/Contents/SharedSupport"
dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL 2>/dev/null
[ "$REM" -gt 0 ] && dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" 2>/dev/null
chflags -h norestricted "$SS" 2>/dev/null || true
run_soi --installpackage "$V/vmix-agent.pkg" 2>&1 &
fi
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=$!
# watchdog: kill startosinstall if free space stalls for ~4 min OR the attempt
# simply takes too long (prepare is intermittently slow; healthy = a few minutes)
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
if [ "$stalled" -ge 240 ] || [ "$elapsed" -ge 540 ]; then
echo "vmix-install: prepare too slow (stalled=${stalled}s elapsed=${elapsed}s), killing to retry"
[ $((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
# on success startosinstall reboots the machine and we never get here
echo "vmix-install: startosinstall attempt $attempt ended without rebooting"
echo "VMIX-INSTALL: startosinstall try $try ended without rebooting"
sleep 3
done
fail "startosinstall did not complete after $attempt attempts"
fail "startosinstall did not complete after $try tries"

View file

@ -1,13 +1,16 @@
# Customize a macOS image by booting it with a VMIX volume: the vmix agent
# (LaunchDaemon installed by makeImage) runs `script` as root, records the exit
# status on the volume and powers off. Optionally re-installs OpenCore with a new
# SMBIOS identity (`smbios`). Counterpart of the Windows auditScript flow.
# Customize a macOS image offline from the vmix PE: the recovery boots with the
# image and a VMIX volume attached, its hook runs `script` as root with the
# image's System (read-only) and Data (rw) volumes mounted at $SYS / $DATA, then
# powers off. The installed macOS itself is never booted, so nothing depends on
# launchd/BTM approval inside the guest. Counterpart of the Windows
# registry/audit flow. Optionally re-installs OpenCore with a new SMBIOS
# identity (`smbios`).
#
# Templates provide:
# script — sh script run as root on the booted system
# script — sh script run as root in the PE (pe-lib.sh helpers available)
# files — [{ source; name; }] extra files placed next to it on /Volumes/VMIX
# smbios — { model? serial? mlb? uuid? mac? seed? } → fresh OpenCore config in the ESP
{ pkgs, lib, qemu, ident, makeVmixVolume, makeOpenCore, installBootloader, vmixReadback, vmDriver, ... }:
{ pkgs, lib, qemu, ident, makeVmixVolume, makeOpenCore, makeBootDisk, installBootloader, vmixReadback, vmDriver, ... }:
originalImage: {
name ? "",
script ? "",
@ -19,7 +22,7 @@ originalImage: {
smp ? 4,
memSize ? 4096,
cpu ? qemu.defaultCpu,
timeout ? 3600,
timeout ? 1800,
}:
let
originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name);
@ -27,6 +30,8 @@ let
resultImg = "./disk.qcow2";
hasScript = script != "";
hasSmbios = smbios != null;
pe = originalImage.pe or (throw "vmix: image ${originalImage.name} carries no PE (built by an older makeImage?)");
volumeName = originalImage.volumeName or "Macintosh HD";
model = originalImage.model or "MacPro7,1";
seed = if hasSmbios && (smbios.seed or null) != null then smbios.seed else null;
@ -45,46 +50,60 @@ let
inherit mac uuid;
} // builtins.removeAttrs smbios [ "seed" "mac" "uuid" "model" ])
else originalImage.opencore;
# PE boot disk: serial console, and an OpenCore ScanPolicy that only allows
# HFS+ volumes on SATA (= the PE), so the image's own macOS is never booted.
# 0x10203 = FILE_SYSTEM_LOCK | DEVICE_LOCK | ALLOW_FS_HFS | ALLOW_DEVICE_SATA
bootDisk = makeBootDisk {
name = "${name}-${originalImageName}-pe";
esp = originalImage.opencore;
bootArgs = "keepsyms=1 serial=3 -v";
scanPolicy = 66051;
};
runScript = pkgs.writeText "${name}-vmix-run.sh" ''
#!/bin/sh
runScript = pkgs.writeText "${name}-run.sh" ''
#!/bin/bash
. /Volumes/VMIX/pe-lib.sh
echo "=== vmix: ${name} ==="
pe_mount_target || pe_fail "could not mount the target volumes"
${script}
pe_unmount_target
'';
vmixVol = makeVmixVolume {
name = "${name}-${originalImageName}";
files = [ { source = runScript; name = "vmix-run.sh"; } ] ++ files;
files = [
{ source = runScript; name = "run.sh"; }
{ source = ../guest/pe-lib.sh; name = "pe-lib.sh"; }
] ++ files;
};
driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
bootCommands = lib.optionalString hasScript ''
cp ${vmixVol} vmix.img
chmod +w vmix.img
cat > vmix.conf <<CONF
VOLUME_NAME="${volumeName}"
BUILD_DATE="$(date -u +%m%d%H%M%Y.%S)"
CONF
guestfish -a vmix.img -m /dev/sda1 upload vmix.conf /vmix.conf
qemu-img create -q -f qcow2 -F raw -b ${pe} pe.qcow2
qemu-img create -q -f qcow2 -F raw -b ${bootDisk}/boot.img ocboot.qcow2
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd
chmod +w vars.fd
VMIX_DISPLAY="-display none"
${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''}
${lib.optionalString (vncDisplay == null) ''
VMIX_DF=$(ls -t /tmp/.vmix-display-* 2>/dev/null | head -1)
if [ -n "$VMIX_DF" ] && [ "$(stat -c %s "$VMIX_DF")" -lt 256 ] && ! grep -q -P '[^\x20-\x7e\n]' "$VMIX_DF"; then
export DISPLAY=$(tr -d '\n' < "$VMIX_DF")
export HOME=$(mktemp -d)
export XDG_RUNTIME_DIR=$HOME
export SDL_VIDEODRIVER=x11
VMIX_DISPLAY="-display sdl"
fi
''}
echo "=== vmix: booting ${originalImageName} for ${name} ==="
python3 ${vmDriver} --mode boot --name "${name}-${originalImageName}" --timeout ${toString timeout} --progress-file ${resultImg} -- \
echo "=== vmix: running ${name} in the PE against ${originalImageName} ==="
python3 ${vmDriver} --mode pe --name "${name}-${originalImageName}" --timeout ${toString timeout} \
--serial-log serial.log --progress-file ${resultImg} -- \
qemu-system-x86_64 $VMIX_DISPLAY \
${qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.firmwareArgs "vars.fd"} \
${qemu.sataDrive { id = "system"; port = 0; file = resultImg; }} \
${qemu.sataDrive { id = "vmix"; port = 1; file = "vmix.img"; format = "raw"; }} \
${qemu.netArgs { mac = originalImage.macAddress; }} \
|| { echo "vmix: VM failed during ${name} (see /tmp/vmix-macos/${name}-${originalImageName})"; exit 1; }
${qemu.serialArgs "serial.log"} \
${qemu.sataDrive { id = "opencore"; port = 0; file = "ocboot.qcow2"; }} \
${qemu.sataDrive { id = "pe"; port = 1; file = "pe.qcow2"; }} \
${qemu.sataDrive { id = "system"; port = 2; file = resultImg; }} \
${qemu.sataDrive { id = "vmix"; port = 3; file = "vmix.img"; format = "raw"; }} \
|| { echo "vmix: PE failed during ${name} (see /tmp/vmix-macos/${name}-${originalImageName})"; exit 1; }
${vmixReadback "vmix.img"}
[ "$STATUS" = "0" ] || { echo "vmix: ${name} script failed (status '$STATUS')"; exit 1; }
@ -92,7 +111,7 @@ let
'';
builtImage = pkgs.runCommand customImageName ({
nativeBuildInputs = with pkgs; [ pkgs.qemu mtools driverPython libguestfs-with-appliance ];
nativeBuildInputs = with pkgs; [ pkgs.qemu driverPython libguestfs-with-appliance ];
requiredSystemFeatures = [ "kvm" ];
} // lib.optionalAttrs impure { __noChroot = true; }) ''
qemu-img create -q -f qcow2 -b ${originalImage} -F qcow2 ${resultImg}
@ -102,4 +121,4 @@ let
mv ${resultImg} $out
'';
in
builtImage // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; model = esp.model or model; }
builtImage // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; model = esp.model or model; inherit pe volumeName; }

View file

@ -1,105 +0,0 @@
# Distribution-style flat package (xar + bom + cpio, built on Linux) for
# `startosinstall --installpackage`. macOS installs it during the first boot of the
# installed system (bootinstalld, "Installer Progress"): it places the vmix agent
# LaunchDaemon, marks Setup Assistant as done, starts the agent, and schedules a
# reboot as a fallback so the daemon runs even if bootstrapping failed.
#
# The files are shipped inside Scripts and copied by postinstall: installd unpacks
# our Scripts archive fine, but "shoves 0 items" from a Linux-made Payload.
{ pkgs, lib, ... }:
{ version ? "1.0" }:
let
id = "ch.vmix.agent";
# nixpkgs' bomutils aborts under _FORTIFY_SOURCE
bomutils = pkgs.bomutils.overrideAttrs (_: { hardeningDisable = [ "fortify" ]; });
postinstall = pkgs.writeText "postinstall" ''
#!/bin/sh
# Runs during the OS install (bootinstalld) with $3 = the target system root.
# Only place files; the ch.vmix.agent LaunchDaemon then runs on the installed
# system's first boot via RunAtLoad (confirmed loading on Tahoe).
T="''${3%/}"
HERE="$(cd "$(dirname "$0")" && pwd)"
LOG="$T/private/var/log/vmix-agent-install.log"
mkdir -p "$T/private/var/log"
exec >>"$LOG" 2>&1
echo "=== vmix agent pkg postinstall $(date) target=[$3] ==="
mkdir -p "$T/Library/LaunchDaemons" "$T/Library/vmix" "$T/private/var/db"
cp "$HERE/agent.sh" "$T/Library/vmix/agent.sh"
cp "$HERE/${id}.plist" "$T/Library/LaunchDaemons/${id}.plist"
chmod 755 "$T/Library/vmix/agent.sh"
chmod 644 "$T/Library/LaunchDaemons/${id}.plist"
chown -R root:wheel "$T/Library/vmix" "$T/Library/LaunchDaemons/${id}.plist"
touch "$T/private/var/db/.AppleSetupDone"
chown root:wheel "$T/private/var/db/.AppleSetupDone"
ls -la "$T/Library/vmix/agent.sh" "$T/Library/LaunchDaemons/${id}.plist"
# A pkg LaunchDaemon is registered with Background Task Management but stays
# pending approval, so it will not auto-run headless. Two BTM-exempt triggers:
# - bootstrap it now (starts it in the installer env; the agent no-ops there)
# - a root cron @reboot job (Apple's cron daemon is trusted, runs it at boot)
launchctl bootstrap system "$T/Library/LaunchDaemons/${id}.plist" 2>&1 && echo "bootstrapped" || echo "bootstrap returned $?"
mkdir -p "$T/usr/lib/cron/tabs"
printf '@reboot /bin/sh /Library/vmix/agent.sh\n' > "$T/usr/lib/cron/tabs/root"
chmod 600 "$T/usr/lib/cron/tabs/root"
chown root:wheel "$T/usr/lib/cron/tabs/root"
echo "cron @reboot installed"
exit 0
'';
in
pkgs.runCommand "vmix-agent-${version}.pkg" {
nativeBuildInputs = [ pkgs.xar bomutils pkgs.cpio pkgs.libarchive pkgs.gzip ];
} ''
mkdir -p root/Library/LaunchDaemons root/Library/vmix scripts flat/vmix-agent.pkg
cp ${../guest/agent.sh} root/Library/vmix/agent.sh
cp ${../guest/ch.vmix.agent.plist} root/Library/LaunchDaemons/${id}.plist
chmod 755 root/Library/vmix/agent.sh
chmod 644 root/Library/LaunchDaemons/${id}.plist
# the same files ride along in Scripts, which is what postinstall installs from
cp ${postinstall} scripts/postinstall
cp ${../guest/agent.sh} scripts/agent.sh
cp ${../guest/ch.vmix.agent.plist} scripts/${id}.plist
chmod 755 scripts/postinstall scripts/agent.sh
NFILES=$(find root | wc -l)
KBYTES=$(du -sk root | cut -f1)
# bsdcpio keeps the "./" prefix the Bom uses (GNU cpio strips it and installd then extracts nothing)
(cd root && find . | bsdcpio -o --format odc --quiet | gzip -c > ../flat/vmix-agent.pkg/Payload)
(cd scripts && find . | cpio -o --format odc --owner 0:0 --quiet | gzip -c > ../flat/vmix-agent.pkg/Scripts)
mkbom -u 0 -g 80 root flat/vmix-agent.pkg/Bom
cat > flat/vmix-agent.pkg/PackageInfo <<XML
<?xml version="1.0" encoding="utf-8"?>
<pkg-info overwrite-permissions="true" relocatable="false" identifier="${id}" postinstall-action="none" version="${version}" format-version="2" generated-by="vmix" auth="root" install-location="/">
<payload installKBytes="$KBYTES" numberOfFiles="$NFILES"/>
<bundle-version/>
<upgrade-bundle/>
<update-bundle/>
<atomic-update-bundle/>
<strict-identifier/>
<relocate/>
<scripts>
<postinstall file="./postinstall"/>
</scripts>
</pkg-info>
XML
cat > flat/Distribution <<XML
<?xml version="1.0" encoding="utf-8"?>
<installer-gui-script minSpecVersion="1">
<title>vmix agent</title>
<options customize="never" require-scripts="false" hostArchitectures="x86_64,arm64" rootVolumeOnly="true"/>
<product id="${id}" version="${version}"/>
<choices-outline>
<line choice="default">
<line choice="${id}"/>
</line>
</choices-outline>
<choice id="default"/>
<choice id="${id}" visible="false">
<pkg-ref id="${id}"/>
</choice>
<pkg-ref id="${id}" version="${version}" onConclusion="none" installKBytes="$KBYTES">#vmix-agent.pkg</pkg-ref>
</installer-gui-script>
XML
sed -i 's/^ //' flat/vmix-agent.pkg/PackageInfo flat/Distribution
(cd flat && xar --compression none -cf $out Distribution vmix-agent.pkg)
xar -t -f $out
''

View file

@ -0,0 +1,29 @@
# OpenCore boot disk for build-time boots, derived from an image's ESP
# (makeOpenCore output) with build-only settings: extra boot-args (serial
# console, verbose) and optionally an OpenCore ScanPolicy so that only the PE
# (an HFS+ volume on SATA) is bootable — the build never lands on the wrong OS.
{ pkgs, lib, ... }:
{ esp, bootArgs ? null, scanPolicy ? null, name ? "boot" }:
pkgs.runCommand "${name}-bootdisk" {
nativeBuildInputs = with pkgs; [ python3 mtools dosfstools gptfdisk ];
} ''
cp -r ${esp}/EFI EFI
chmod -R u+w EFI
python3 - <<'PY'
import plistlib
p = 'EFI/OC/config.plist'
cfg = plistlib.load(open(p, 'rb'))
nv = cfg['NVRAM']['Add']['7C436110-AB2A-4BBB-A880-FE41995C9F82']
${lib.optionalString (bootArgs != null) ''nv['boot-args'] = ${builtins.toJSON bootArgs}''}
${lib.optionalString (scanPolicy != null) ''cfg['Misc']['Security']['ScanPolicy'] = ${toString scanPolicy}''}
plistlib.dump(cfg, open(p, 'wb'))
print('boot-args:', nv['boot-args'], 'ScanPolicy:', cfg['Misc']['Security']['ScanPolicy'])
PY
mkdir -p $out
truncate -s 64M $out/boot.img
sgdisk -n 1:2048:0 -t 1:EF00 -c 1:EFI $out/boot.img >/dev/null
SECTORS=$(( 64*1024*1024/512 - 2048 - 34 ))
mkfs.vfat -F 32 -n OPENCORE --offset 2048 $out/boot.img $(( SECTORS / 2 )) >/dev/null
mcopy -i $out/boot.img@@1M -s EFI ::
mdir -i $out/boot.img@@1M ::EFI/OC >/dev/null
''

View file

@ -1,77 +1,59 @@
# Build a pre-installed macOS qcow2 with an unattended QEMU install.
#
# One QEMU session, driven by vm-driver.py:
# Recovery (BaseSystem) boots via OpenCore → driver opens Terminal with
# keystrokes and types "sh /Volumes/VMIX/run.sh" → vmix-install.sh erases the
# disk, rebuilds the installer app from installer-app.tar + the SharedSupport.dmg
# raw disk, runs startosinstall (--installpackage vmix-agent.pkg) → the installer
# reboots through its phases → first boot of macOS runs the vmix agent, which
# executes vmix-run.sh and powers off → QEMU exits.
# Afterwards OpenCore is copied into the image's EFI partition so the result
# boots standalone with plain OVMF. Apply templates with customizeImageFold,
# then .generalize to create the user and set a fresh SMBIOS identity.
{ pkgs, lib, qemu, ident, installerPayload, makeOpenCore, makeVmixVolume, makeAgentPkg, installBootloader, vmixReadback, vmDriver, ... }:
# Build a pre-installed macOS qcow2 with an unattended install driven from the
# vmix PE (Apple's Recovery + one LaunchDaemon, see makeRecoveryPE):
# OpenCore boots the PE → its hook runs /Volumes/VMIX/run.sh (vmix-install.sh)
# → erase the disk, rebuild the installer app from installer-app.tar + the
# SharedSupport raw disk, startosinstall → the installer reboots through its
# phases → the installed system's first boot reaches the loginwindow → the
# driver powers it off. No GUI is driven; progress is read from the serial
# console and screenshots (brightness). Fully offline: no NIC is attached.
# Then OpenCore is copied into the image's own ESP so it boots with plain OVMF.
# Apply templates with customizeImageFold, then .generalize.
{ pkgs, lib, qemu, ident, installerPayload, makeOpenCore, makeBootDisk, makeVmixVolume, installBootloader, vmixReadback, vmDriver, ... }:
{
name ? "macos",
installer, # InstallAssistant.pkg (fetchurl)
recovery, # BaseSystem.dmg (fetchRecovery)
installer, # InstallAssistant.pkg
pe, # makeRecoveryPE output for the same macOS version
diskSize ? "128G",
volumeName ? "Macintosh HD",
smp ? 4,
memSize ? 8192,
cpu ? qemu.defaultCpu,
model ? "MacPro7,1", # SMBIOS model; must be Tahoe-supported (MacPro7,1, iMac20,1/2, MacBookPro16,x)
model ? "MacPro7,1", # SMBIOS model; must be supported by the installed macOS
seed ? name, # MAC address + SystemUUID are derived from this
bootArgs ? "keepsyms=1",
vncDisplay ? null, # e.g. ":10" to watch the install on port 5910
timeout ? 4 * 3600, # seconds for the whole install
extraOpenCoreConfig ? {}, # merged into config.plist
installNetwork ? false, # attach a NIC during install (default: offline — startosinstall
# otherwise hangs on Apple personalization through a flaky NAT)
installNetwork ? false, # attach a NIC during the install (default: offline)
}:
let
mac = ident.macFromSeed seed;
uuid = ident.uuidFromSeed seed;
esp = makeOpenCore { name = "${name}-opencore"; inherit model mac uuid bootArgs; extraConfig = extraOpenCoreConfig; };
esp = makeOpenCore { name = "${name}-opencore"; inherit model mac uuid bootArgs memSize; extraConfig = extraOpenCoreConfig; };
# build-time boot disk: same identity, plus serial console + verbose boot
bootDisk = makeBootDisk { name = "${name}-install"; inherit esp; bootArgs = "${bootArgs} serial=3 -v"; };
payload = installerPayload { inherit name; pkg = installer; };
recoveryImg = pkgs.runCommand "${name}-BaseSystem.img" { nativeBuildInputs = [ pkgs.dmg2img ]; } ''
dmg2img -s ${recovery} $out
'';
agentPkg = makeAgentPkg { };
agentDir = pkgs.runCommand "vmix-agent-files" { } ''
mkdir -p $out
cp ${../guest/agent.sh} $out/agent.sh
cp ${../guest/ch.vmix.agent.plist} $out/ch.vmix.agent.plist
'';
firstBoot = pkgs.writeText "vmix-run.sh" ''
echo "vmix: first boot of the installed system"
sw_vers
exit 0
'';
vmixVol = makeVmixVolume {
inherit name;
size = "512M";
files = [
{ source = ../guest/vmix-install.sh; name = "run.sh"; }
{ source = ../guest/pe-lib.sh; name = "pe-lib.sh"; }
{ source = "${payload}/installer-app.tar"; name = "installer-app.tar"; }
{ source = agentPkg; name = "vmix-agent.pkg"; }
{ source = agentDir; name = "agent"; }
{ source = firstBoot; name = "vmix-run.sh"; }
];
};
driverPython = pkgs.python3.withPackages (p: [ p.pillow p.pytesseract ]);
tesseract = pkgs.tesseract.override { enableLanguages = [ "eng" ]; };
driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
drv = pkgs.runCommand "${name}-vmix.qcow2" {
__noChroot = true;
requiredSystemFeatures = [ "kvm" ];
nativeBuildInputs = with pkgs; [ pkgs.qemu mtools jq driverPython tesseract libguestfs-with-appliance ];
nativeBuildInputs = with pkgs; [ pkgs.qemu jq driverPython libguestfs-with-appliance ];
} ''
echo "=== vmix: creating ${diskSize} disk ==="
qemu-img create -f qcow2 disk.qcow2 ${diskSize}
# store files are read-only and AHCI needs writable nodes: qcow2 overlays
qemu-img create -q -f qcow2 -F raw -b ${recoveryImg} recovery.qcow2
qemu-img create -q -f qcow2 -F raw -b ${esp}/boot.img ocboot.qcow2
qemu-img create -q -f qcow2 -F raw -b ${pe} pe.qcow2
qemu-img create -q -f qcow2 -F raw -b ${bootDisk}/boot.img ocboot.qcow2
# Apple's postinstall hardlinks the WHOLE InstallAssistant.pkg as
# Contents/SharedSupport/SharedSupport.dmg: the pkg is a "pkgdmg" (xar + koly
@ -99,42 +81,33 @@ let
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd
chmod +w vars.fd
VMIX_DISPLAY="-display none"
${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''}
${lib.optionalString (vncDisplay == null) ''
VMIX_DF=$(ls -t /tmp/.vmix-display-* 2>/dev/null | head -1)
if [ -n "$VMIX_DF" ] && [ "$(stat -c %s "$VMIX_DF")" -lt 256 ] && ! grep -q -P '[^\x20-\x7e\n]' "$VMIX_DF"; then
export DISPLAY=$(tr -d '\n' < "$VMIX_DF")
export HOME=$(mktemp -d)
export XDG_RUNTIME_DIR=$HOME
export SDL_VIDEODRIVER=x11
VMIX_DISPLAY="-display sdl"
fi
''}
echo "=== vmix: installing ${name} (unattended, 1-2 h; screenshots in /tmp/vmix-macos/${name}) ==="
python3 ${vmDriver} --mode install --name ${name} --timeout ${toString timeout} --progress-file disk.qcow2 -- \
echo "=== vmix: installing ${name} (unattended, ~1 h; logs and screenshots in /tmp/vmix-macos/${name}) ==="
python3 ${vmDriver} --mode install --name ${name} --timeout ${toString timeout} \
--serial-log serial.log --progress-file disk.qcow2 -- \
qemu-system-x86_64 $VMIX_DISPLAY \
${qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.firmwareArgs "vars.fd"} \
${qemu.serialArgs "serial.log"} \
${qemu.sataDrive { id = "opencore"; port = 0; file = "ocboot.qcow2"; }} \
${qemu.sataDrive { id = "recovery"; port = 1; file = "recovery.qcow2"; }} \
${qemu.sataDrive { id = "pe"; port = 1; file = "pe.qcow2"; }} \
${qemu.sataDrive { id = "system"; port = 2; file = "disk.qcow2"; }} \
${qemu.sataDrive { id = "vmix"; port = 3; file = "vmix.img"; format = "raw"; }} \
${qemu.sataDrive { id = "sharedsupport"; port = 4; file = "sharedsupport.qcow2"; }} \
${lib.optionalString installNetwork (qemu.netArgs { inherit mac; })} \
|| { echo "vmix: install VM failed (see /tmp/vmix-macos/${name})"; exit 1; }
# The driver exits non-zero (handled above) if the install did not reach a
# completed/powered-off state, so reaching here means the OS is installed.
# vmix-run.status is written only when the agent ran (cron/daemon); log it.
# The PE records a status only if run.sh returned, i.e. the install failed
# before the installer took over and rebooted.
${vmixReadback "vmix.img"}
[ "$STATUS" = "0" ] && echo "vmix: first-boot agent completed (status 0)" \
|| echo "vmix: install reached loginwindow (agent status '$STATUS'); image is installed"
if [ -n "$STATUS" ] && [ "$STATUS" != "0" ]; then
echo "vmix: install script failed (status $STATUS), see /tmp/vmix-macos/${name}"; exit 1
fi
${installBootloader { inherit esp; image = "disk.qcow2"; }}
echo "=== vmix: ${name} install complete (serial $(jq -r .serial ${esp}/vmix.json), mac ${mac}) ==="
mv disk.qcow2 $out
'';
in drv // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; inherit model; }
in drv // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; inherit model pe volumeName; }

View file

@ -18,6 +18,7 @@
showPicker ? true,
pickerTimeout ? 2,
extraConfig ? {},
memSize ? 8192, # RAM described in SMBIOS (MacPro7,1 wants 4 DIMMs)
}:
let
ocImage = pkgs.fetchurl { inherit (upstream.opencore.image) url sha256; name = "OSX-KVM-OpenCore.qcow2"; };
@ -50,7 +51,7 @@ pkgs.runCommand "${name}-esp" {
--model "${model}" --serial "$SERIAL" --mlb "$MLB" --uuid "${uuid}" --mac "${mac}" \
--nic-path "${qemu.nicDevicePath}" --boot-args "${bootArgs}" --resolution "${resolution}" \
--show-picker "${lib.boolToString showPicker}" --timeout ${toString pickerTimeout} \
--extra-json ${lib.escapeShellArg (builtins.toJSON extraConfig)}
--extra-json ${lib.escapeShellArg (builtins.toJSON extraConfig)} --memory-mb ${toString memSize}
ocvalidate $out/EFI/OC/config.plist || echo "vmix: ocvalidate reported issues (OpenCore version may differ from validator), continuing"
jq -n --arg model "${model}" --arg serial "$SERIAL" --arg mlb "$MLB" --arg uuid "${uuid}" --arg mac "${mac}" \

View file

@ -0,0 +1,30 @@
# The vmix "PE": Apple's Recovery (BaseSystem.dmg) with one LaunchDaemon added
# that runs /Volumes/VMIX/run.sh as root at boot and powers off afterwards.
# BaseSystem is a plain (journaled) HFS+ volume that Linux can write with the
# hfsplus driver's force option — the pristine image's journal is empty, so this
# is safe. The kernel and boot.efi are untouched; launchd loads the extra plist
# from /System/Library/LaunchDaemons alongside its signed cache (verified on
# Tahoe 26.6.2). Same idea as AutoNBI/Imagr NetBoot images.
# Output: raw disk image (HFS+ volume with a partition table) that OpenCore boots.
{ pkgs, lib, ... }:
{ name ? "macos", recovery }:
pkgs.runCommand "${name}-pe.img" {
nativeBuildInputs = with pkgs; [ dmg2img libguestfs-with-appliance ];
} ''
echo "=== vmix: building the recovery PE from BaseSystem.dmg ==="
dmg2img -s ${recovery} $out
chmod +w $out
guestfish -a $out <<GFS
run
mount-options force /dev/sda1 /
mkdir-p /usr/libexec/vmix
upload ${../guest/pe.sh} /usr/libexec/vmix/pe.sh
chmod 0755 /usr/libexec/vmix/pe.sh
upload ${../guest/ch.vmix.pe.plist} /System/Library/LaunchDaemons/ch.vmix.pe.plist
chmod 0644 /System/Library/LaunchDaemons/ch.vmix.pe.plist
ls /usr/libexec/vmix
umount /
GFS
guestfish --ro -a $out -m /dev/sda1 ls /System/Library/LaunchDaemons | grep -q '^ch.vmix.pe.plist$' \
|| { echo "vmix: PE hook not installed"; exit 1; }
''

View file

@ -43,6 +43,7 @@ def main():
p.add_argument('--show-picker', default='true')
p.add_argument('--timeout', type=int, default=2)
p.add_argument('--extra-json', default='{}')
p.add_argument('--memory-mb', type=int, default=8192, help='VM RAM, described as 4 DIMMs')
a = p.parse_args()
with open(a.base, 'rb') as f:
@ -84,6 +85,21 @@ def main():
cfg['Misc']['Boot']['Timeout'] = a.timeout
cfg['Misc']['Boot']['HideAuxiliary'] = True
cfg['Misc']['Security']['ScanPolicy'] = 0
# MacPro7,1 firmware expects DIMMs in pairs (>= 4); with QEMU's single SMBIOS
# module macOS shows "Memory Modules Misconfigured" at every login. Describe
# the VM's RAM as four DDR4 modules instead.
if a.model.startswith('MacPro7'):
size = max(1024, a.memory_mb // 4)
cfg['PlatformInfo']['CustomMemory'] = True
cfg['PlatformInfo']['Memory'] = {
'DataWidth': 64, 'ErrorCorrection': 3, 'FormFactor': 9, 'MaxCapacity': 1536 * 1024 * 1024 * 1024,
'TotalWidth': 64, 'Type': 26, 'TypeDetail': 128,
'Devices': [{
'AssetTag': '', 'BankLocator': f'BANK {i}', 'DeviceLocator': f'DIMM{i + 1}',
'Manufacturer': 'Apple', 'PartNumber': f'VMIX{size}', 'SerialNumber': f'VMIX{i:04d}',
'Size': size, 'Speed': 2666,
} for i in range(4)],
}
cfg['Misc']['Security']['SecureBootModel'] = 'Disabled'
cfg['Misc']['Security']['AllowSetDefault'] = True
cfg['Misc']['Debug']['Target'] = 0

View file

@ -30,6 +30,9 @@ rec {
sataDrive = { id, port, file, format ? "qcow2", extra ? "" }:
"-drive id=${id},if=none,format=${format},file=${file}${extra} -device ide-hd,bus=sata.${toString port},drive=${id}";
# XNU logs to COM1 with boot-args serial=3; the build drivers read this file
serialArgs = file: "-serial file:${file}";
firmwareArgs = varsFile:
"-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd -drive if=pflash,format=raw,file=${varsFile}";
}

View file

@ -1,21 +1,27 @@
#!/usr/bin/env python3
"""vmix macOS VM driver.
"""vmix macOS VM driver: runs QEMU and decides when a build boot is finished.
Launches QEMU with a QMP socket and either
Modes
pe the recovery PE runs /Volumes/VMIX/run.sh and powers off. Success is
QEMU exiting on its own; the caller checks vmix-run.status.
install the PE starts the macOS installer, which reboots through its phases
into the installed system. Finished when that system reaches the
(bright) loginwindow / Setup Assistant the driver powers it down
or halts on its own.
boot boot an installed image and wait for it to halt or reach the loginwindow.
--mode install drives macOS Recovery to a Terminal with keystrokes (screen
settle detection + OCR of the menu bar), types the bootstrap
command and waits for the VM to power itself off
--mode boot waits for the VM to power itself off (customize steps)
Everything after `--` is the QEMU command line. Screenshots and a log are
written to --debug-dir (default /tmp/vmix-macos/<name>) for troubleshooting.
Observation is passive: the serial console (boot-args serial=3: the PE's
"VMIX-*" markers, kernel boots, panics) and screenshots over QMP (mean
brightness + a coarse change fingerprint). No OCR, no keystrokes. A boot hang
(dark, frozen screen, disk and serial idle) is retried with a system_reset.
Screenshots, the driver log and the serial log are kept in --debug-dir.
"""
import argparse
import hashlib
import io
import json
import os
import shutil
import socket
import subprocess
import sys
@ -23,60 +29,66 @@ import time
try:
from PIL import Image
except ImportError: # pragma: no cover
except ImportError: # screenshots then only serve as debug files
Image = None
try:
import pytesseract
except ImportError: # pragma: no cover
pytesseract = None
# QEMU qcodes for characters that are not plain alphanumerics
PLAIN = {' ': 'spc', '/': 'slash', '-': 'minus', '.': 'dot', ';': 'semicolon', ',': 'comma',
'=': 'equal', "'": 'apostrophe', '`': 'grave_accent', '[': 'bracket_left',
']': 'bracket_right', '\\': 'backslash', '\n': 'ret', '\t': 'tab'}
SHIFTED = {'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7', '*': '8',
'(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left',
'}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe',
'<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent'}
PANIC_MARKS = ('panic(cpu', 'Kernel Extensions in backtrace', 'Debugger called: <panic>', 'Nested panic detected', 'panic string:')
REBOOT_MARK = 'MACH Reboot'
class Log:
def __init__(self, path):
self.f = open(path, 'a')
self.f = open(path, 'a') if path else None
self.t0 = time.time()
def __call__(self, msg):
line = f'[{time.time() - self.t0:7.1f}s] {msg}'
print(f'vmix driver: {line}', flush=True)
if self.f:
self.f.write(line + '\n')
self.f.flush()
class QMP:
def __init__(self, path):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.connect(path)
self.f = self.sock.makefile('rwb', buffering=0)
self._read()
self.path = path
self.s = None
self.buf = b''
def connect(self, timeout=90):
t0 = time.time()
while True:
try:
s = socket.socket(socket.AF_UNIX)
s.settimeout(60)
s.connect(self.path)
self.s = s
self.buf = b''
self._read() # greeting
self.cmd('qmp_capabilities')
return
except (OSError, ValueError):
if time.time() - t0 > timeout:
raise
time.sleep(1)
def _read(self):
while True:
line = self.f.readline()
if not line:
raise EOFError('QMP connection closed')
msg = json.loads(line)
if 'event' in msg:
continue
return msg
while b'\n' not in self.buf:
d = self.s.recv(65536)
if not d:
raise OSError('QMP socket closed')
self.buf += d
line, self.buf = self.buf.split(b'\n', 1)
return json.loads(line)
def cmd(self, name, **args):
self.f.write((json.dumps({'execute': name, 'arguments': args}) + '\n').encode())
self.s.sendall(json.dumps({'execute': name, 'arguments': args}).encode() + b'\n')
while True:
r = self._read()
if 'return' in r:
return r['return']
if 'error' in r:
raise RuntimeError(f'QMP {name}: {r["error"]}')
return r.get('return')
raise RuntimeError(r['error'])
def screendump(self, path):
self.cmd('screendump', filename=path)
@ -87,182 +99,117 @@ class QMP:
def system_powerdown(self):
self.cmd('system_powerdown')
_jig = 0
def jiggle(self):
# tiny absolute (usb-tablet) pointer move to keep the display awake: a real
# HID event, but < 2 screen px so it does not change the settle fingerprint
self._jig = 16060 if self._jig < 16030 else 16000
try:
self.cmd('input-send-event', events=[
{'type': 'abs', 'data': {'axis': 'x', 'value': self._jig}},
{'type': 'abs', 'data': {'axis': 'y', 'value': 16000}}])
except Exception: # noqa: BLE001
self.send_key('shift')
def send_key(self, *keys, hold=80):
self.cmd('send-key', keys=[{'type': 'qcode', 'data': k} for k in keys], **{'hold-time': hold})
time.sleep(0.15)
def type_text(self, text):
for ch in text:
if ch.isascii() and ch.isalnum():
if ch.isupper():
self.send_key('shift', ch.lower())
else:
self.send_key(ch)
elif ch in PLAIN:
self.send_key(PLAIN[ch])
elif ch in SHIFTED:
self.send_key('shift', SHIFTED[ch])
else:
raise ValueError(f'cannot type {ch!r}')
class Screen:
"""Screenshot helper: settle detection via hashing, OCR of regions."""
"""Screenshots over QMP with a coarse change fingerprint (cursor-insensitive)."""
def __init__(self, qmp, debug_dir, log):
self.qmp = qmp
self.debug_dir = debug_dir
self.dir = debug_dir
self.log = log
import tempfile
fd, self.tmp = tempfile.mkstemp(prefix='.shot-', suffix='.ppm', dir=debug_dir)
os.close(fd)
os.chmod(self.tmp, 0o666)
self.n = 0
self.last_hash = None
self.stable_since = time.time()
self.tmp = os.path.join(debug_dir, f'.grab-{os.getpid()}.ppm')
self.img = None
self.last_fp = None
self.stable_since = time.time()
self.n = 0
def grab(self):
self.qmp.screendump(self.tmp)
with open(self.tmp, 'rb') as f:
data = f.read()
self.img = Image.open(io.BytesIO(data)) if Image else None
# fingerprint from a coarse, quantized grayscale thumbnail so the moving
# mouse cursor (keepalive jiggle) does not count as a screen change
if self.img is not None:
px = self.img.convert('L').resize((48, 36))
fp = bytes(b & 0xF0 for b in px.getdata())
h = hashlib.sha256(fp).hexdigest()
if Image is None:
fp = hashlib.sha256(data).hexdigest()
else:
h = hashlib.sha256(data).hexdigest()
if h != self.last_hash:
self.last_hash = h
self.img = Image.open(io.BytesIO(data))
small = self.img.convert('L').resize((48, 36))
fp = hashlib.sha256(bytes(b & 0xF0 for b in small.tobytes())).hexdigest()
if fp != self.last_fp:
self.last_fp = fp
self.stable_since = time.time()
return self.img
def stable_for(self):
return time.time() - self.stable_since
def mean(self):
if self.img is None:
return 0
g = self.img.convert('L').resize((64, 48))
px = g.tobytes()
return sum(px) / len(px)
def is_blank(self):
return self.img is not None and self.mean() < 3
def save(self, tag):
self.n += 1
path = os.path.join(self.debug_dir, f'{self.n:03d}-{tag}.png')
path = os.path.join(self.dir, f'{self.n:03d}-{tag}.png')
try:
if self.img is not None:
self.img.save(path)
else:
os.link(self.tmp, path.replace('.png', '.ppm'))
shutil.copy(self.tmp, path.replace('.png', '.ppm'))
except Exception as e: # noqa: BLE001
self.log(f'could not save screenshot: {e}')
return path
def ocr(self, region=None, scale=3, psm=6):
if self.img is None or pytesseract is None:
return ''
img = self.img
if region:
img = img.crop(region)
img = img.convert('L').resize((img.width * scale, img.height * scale), Image.LANCZOS)
try:
return pytesseract.image_to_string(img, config=f'--psm {psm}').lower()
except Exception as e: # noqa: BLE001
self.log(f'ocr failed: {e}')
return ''
def mean(self):
if self.img is None:
return 128
px = self.img.convert('L').resize((32, 24))
d = list(px.getdata())
return sum(d) / len(d)
class Serial:
"""Tail the serial console file QEMU writes (-serial file:...)."""
def is_blank(self):
# black/uniform screen (firmware, boot): nothing to act on
if self.img is None:
return False
lo, hi = self.img.convert('L').resize((64, 48)).getextrema()
return hi - lo < 24
def __init__(self, path):
self.path = path
self.pos = 0
self.last_activity = time.time()
self.boots = 0
self.reboot_at = None
def menubar_text(self):
w = self.img.width if self.img else 1024
return self.ocr((0, 0, w, 40), scale=4, psm=7)
def poll(self):
if not self.path or not os.path.exists(self.path):
return []
with open(self.path, 'rb') as f:
f.seek(self.pos)
data = f.read()
self.pos = f.tell()
if not data:
return []
self.last_activity = time.time()
lines = data.decode('utf-8', 'replace').replace('\r', '').split('\n')
for l in lines:
if l.startswith('Darwin Kernel Version'):
self.boots += 1
self.reboot_at = None
elif REBOOT_MARK in l:
self.reboot_at = time.time()
return lines
def idle_for(self):
return time.time() - self.last_activity
def prepare_debug_dir(path):
# nix builds run as different nixbld users: keep the shared dirs world-writable
os.makedirs(path, exist_ok=True)
try:
for d in (os.path.dirname(path), path):
os.makedirs(d, exist_ok=True)
try:
os.chmod(d, 0o1777 if d != path else 0o777)
os.chmod(path, 0o777)
except OSError:
pass
probe = os.path.join(path, '.probe')
open(probe, 'w').close()
os.unlink(probe)
# a rebuild reuses this dir but runs as a different nixbld user; drop stale
# files so screendumps/PNGs are not blocked by another owner's 0644 files
import glob
for f in glob.glob(os.path.join(path, '*')) + glob.glob(os.path.join(path, '.current*')):
for f in os.listdir(path):
if f.endswith(('.png', '.ppm', '.log')) or f.startswith('.grab-') or f == 'qmp.sock':
try:
os.unlink(f)
os.remove(os.path.join(path, f))
except OSError:
pass
return path
except OSError:
import tempfile
alt = tempfile.mkdtemp(prefix='vmix-macos-')
print(f'vmix driver: {path} not writable, using {alt}', flush=True)
return alt
def launch(qemu_args, qmp_sock, log):
if os.path.exists(qmp_sock):
os.unlink(qmp_sock)
args = list(qemu_args) + ['-qmp', f'unix:{qmp_sock},server,nowait']
log('launching: ' + ' '.join(args))
proc = subprocess.Popen(args)
deadline = time.time() + 60
while not os.path.exists(qmp_sock):
if proc.poll() is not None:
return proc, None
if time.time() > deadline:
proc.kill()
raise RuntimeError('QEMU did not create the QMP socket')
time.sleep(0.2)
time.sleep(0.5)
return proc, QMP(qmp_sock)
def open_terminal(qmp, log):
# Ctrl-F2 focuses the menu bar; typing jumps to the menu whose title starts
# with that letter (Utilities), Down opens it, "t" jumps to Terminal.
log('opening Terminal via menu bar (ctrl-f2, u, down, t, ret)')
qmp.send_key('ctrl', 'f2')
time.sleep(1.0)
qmp.send_key('u')
time.sleep(0.7)
qmp.send_key('down')
time.sleep(0.7)
qmp.send_key('t')
time.sleep(0.7)
qmp.send_key('ret')
os.remove(qmp_sock)
cmd = list(qemu_args) + ['-qmp', f'unix:{qmp_sock},server,nowait']
log('launching: ' + ' '.join(cmd))
return subprocess.Popen(cmd)
def disk_idle(args):
"""True if the system disk has had no writes recently (guest not doing I/O)."""
if not args.progress_file:
return True
try:
@ -271,289 +218,150 @@ def disk_idle(args):
return True
def run_install(args, proc, qmp, log):
"""Drive the install VM to completion.
OpenCore shows a boot picker on every (re)boot and does not always auto-boot,
so on any settled picker we press Return to boot the highlighted macOS entry
(aux entries are hidden; during the install phases startosinstall blesses the
right default). That runs on EVERY iteration, because the install reboots
several times after we hand off to startosinstall. Before we have typed the
bootstrap command we also drive Recovery: language/welcome -> Return, the
Recovery window -> open Terminal, Terminal -> type the command.
"""
RECOVERY_BODY = ('reinstall', 'disk utility', 'restore from', 'recovery assistant',
'macos utilities')
PICKER_BODY = ('base system', 'macos installer', 'rel-1', 'rel-0') # OpenCore picker
LANG_BODY = ('language', 'select your', 'main language', 'country or region',
'welcome', 'get started', 'choose your')
screen = Screen(qmp, args.debug_dir, log)
def drive(args, proc, qmp, screen, serial, log):
start = time.time()
typed_at = None
terminal_attempts = 0
last_periodic = 0
last_progress = start
blind_done = False
resets = 0
panics = 0
started = args.mode == 'boot' # pe/install: wait for the PE marker first
blank_since = None
login_since = None
recovery_start = None
last_term_action = 0
while True:
rc = proc.poll()
if rc is not None:
log(f'QEMU exited with {rc}')
return rc
now = time.time()
if now - start > args.timeout:
try:
screen.grab(); screen.save('timeout')
screen.grab()
screen.save('timeout')
except Exception: # noqa: BLE001
pass
log('timeout reached, killing QEMU')
proc.kill()
return 124
time.sleep(args.interval)
panic = False
for line in serial.poll():
if 'VMIX' in line:
log('serial: ' + line.strip()[:220])
if 'VMIX-PE: running run.sh' in line and not started:
started = True
log('PE started run.sh')
if 'VMIX-PE: no VMIX volume' in line and args.mode != 'boot':
log('PE did not find the VMIX volume')
proc.kill()
return 3
if line.startswith('Darwin Kernel Version'):
log(f'guest kernel boot #{serial.boots}')
if any(m in line for m in PANIC_MARKS):
panic = True
log('serial: ' + line.strip()[:220])
if panic:
panics += 1
try:
screen.grab()
except Exception as e: # noqa: BLE001
log(f'screendump failed ({e}), assuming QEMU is exiting')
time.sleep(2)
continue
if now - last_periodic > args.periodic:
last_periodic = now
screen.save('periodic')
# keep the recovery display awake until the command is typed (mouse jiggle)
if typed_at is None and now - last_term_action > 8:
qmp.jiggle()
if now - start < args.min_boot or screen.stable_for() < args.settle:
continue
if screen.is_blank():
last_progress = now
if blank_since is None:
blank_since = now
if typed_at is None:
# recovery display asleep — jiggle the mouse to wake it, wait for UI
qmp.jiggle()
continue
# macOS `shutdown -h now` halts the guest to a black screen without an
# ACPI power-off, so QEMU never exits. Once we have handed off (command
# typed), a long pure-black screen means the agent finished and halted.
elif typed_at is not None and now - blank_since > args.halt_timeout and disk_idle(args):
screen.save('halt')
log(f'guest halted (black {now - blank_since:.0f}s, disk idle); killing QEMU, readback will validate')
proc.kill()
try:
proc.wait(timeout=10)
screen.save('panic')
except Exception: # noqa: BLE001
pass
return 0
continue
blank_since = None
top = screen.menubar_text()
body = screen.ocr()
log(f'settled: menubar={top.strip()!r} body~={" ".join(body.split())[:80]!r}')
# Boot-hang watchdog: a dark screen (Apple logo / black) frozen for a long
# time with no menu bar is a stuck (re)boot — kick it with a system reset.
# Never fires on the bright, static Terminal of the prepare phase.
if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \
and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets:
resets += 1
screen.save('stall-reset')
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, disk idle), system_reset #{resets}')
try:
if args.mode == 'pe' or panics > args.max_resets:
log(f'kernel panic #{panics}, giving up')
proc.kill()
return 3
log(f'kernel panic #{panics}, system_reset')
qmp.system_reset()
except Exception as e: # noqa: BLE001
log(f'system_reset failed: {e}')
screen.stable_since = time.time()
last_progress = now
continue
# OpenCore boot picker — always handle it (the install reboots many times)
if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY):
screen.save('picker')
log('OpenCore boot picker, pressing Return to boot the default macOS entry')
qmp.send_key('ret')
last_progress = now
screen.stable_since = time.time()
continue
# After the install, the loginwindow/desktop is a BRIGHT gray screen, unlike
# the dark install/boot screens (Apple logo). The vmix agent powers the VM
# off if it runs (cron/daemon); if BTM blocks it, we power down here so the
# build still completes with a bootable, installed image. Brightness is a
# far more reliable signal than OCR of the faint "password" text.
bright = screen.mean() > 80
loginish = (typed_at is not None and bright and 'terminal' not in top
and 'utilities' not in top and not any(k in body for k in PICKER_BODY))
if loginish:
if login_since is None:
login_since = now
log('bright post-install screen (loginwindow/desktop) — OS installed; grace before powerdown')
elif now - login_since > args.login_grace:
screen.save('loginwindow')
log(f'loginwindow persisted {now - login_since:.0f}s, powering down (install complete)')
# The guest asked for a reboot but no kernel came back: macOS' restart
# path panics in QEMU (AppleSMC watchdog keys, see README); reset now
# instead of waiting for the frozen-screen watchdog.
if serial.reboot_at and now - serial.reboot_at > args.reboot_timeout and args.mode != 'pe':
resets += 1
try:
qmp.system_powerdown()
except Exception as e: # noqa: BLE001
log(f'powerdown failed: {e}')
for _ in range(90):
if proc.poll() is not None:
return 0
time.sleep(1)
proc.kill()
return 0
continue
else:
login_since = None
# once the bootstrap command is typed, only the picker (above) and an
# unexpected return to Recovery matter (post-prepare reboot landed on the
# recovery instead of the installer — restart the install then).
if typed_at is not None:
if ('utilities' in top or 'recovery' in top):
if recovery_start is None:
recovery_start = now
if now - typed_at > 120 and now - recovery_start > 45:
log('unexpectedly back at Recovery after install started — restarting install')
typed_at = None
terminal_attempts = 0
recovery_start = None
# fall through to the recovery/terminal handling below
else:
continue
else:
recovery_start = None
continue
if 'terminal' in top:
screen.save('terminal')
log(f'typing bootstrap command: {args.command!r}')
qmp.type_text(args.command + '\n')
typed_at = time.time()
continue
acted = False
if 'utilities' in top or 'recovery' in top or any(k in body for k in RECOVERY_BODY):
screen.save('recovery')
terminal_attempts += 1
log(f'recovery window (attempt {terminal_attempts}), opening Terminal')
last_term_action = now
open_terminal(qmp, log)
if terminal_attempts >= 3:
time.sleep(8)
log('typing bootstrap command (Terminal assumed open)')
qmp.type_text(args.command + '\n')
typed_at = time.time()
continue
acted = True
elif any(k in body for k in LANG_BODY):
screen.save('language')
log('language/welcome screen, pressing Return')
qmp.send_key('ret')
acted = True
if acted:
last_progress = now
screen.stable_since = time.time()
elif now - last_progress > args.settle * args.max_actions and not blind_done:
blind_done = True
screen.save('blind')
log('nothing recognised for a long time, blind sequence')
qmp.send_key('ret')
time.sleep(20)
open_terminal(qmp, log)
time.sleep(10)
qmp.type_text(args.command + '\n')
typed_at = time.time()
def run_boot(args, proc, qmp, log):
"""Wait for the VM to power itself off (customize/generalize/first-boot),
handling the OpenCore picker and kicking a hung boot with a system reset."""
PICKER_BODY = ('base system', 'macos installer', 'macintosh hd', 'rel-1', 'rel-0')
screen = Screen(qmp, args.debug_dir, log)
start = time.time()
last_periodic = 0
resets = 0
blank_since = None
login_since = None
while True:
rc = proc.poll()
if rc is not None:
return rc
if time.time() - start > args.timeout:
try:
screen.grab(); screen.save('timeout')
screen.grab()
screen.save('reboot-dead')
except Exception: # noqa: BLE001
pass
log('timeout reached, killing QEMU')
log(f'guest requested a reboot {now - serial.reboot_at:.0f}s ago and died, system_reset #{resets}')
serial.reboot_at = None
if resets > args.max_resets:
proc.kill()
return 124
time.sleep(args.interval)
return 3
qmp.system_reset()
screen.stable_since = time.time()
continue
if not started and now - start > args.start_timeout:
try:
screen.grab()
screen.save('no-start')
except Exception: # noqa: BLE001
pass
log(f'PE did not start run.sh within {args.start_timeout:.0f}s')
proc.kill()
return 3
try:
screen.grab()
except Exception as e: # noqa: BLE001
log(f'screendump failed ({e})')
time.sleep(2)
continue
now = time.time()
if now - last_periodic > args.periodic:
last_periodic = now
screen.save('periodic')
if now - start < args.min_boot or screen.stable_for() < args.settle:
if args.mode == 'pe':
continue # the PE powers off by itself; nothing to decide
# install: the PE phase (kernel boot #1) is protected by the guest's own
# retries; the checks below apply once the installer has rebooted.
in_os = args.mode == 'boot' or serial.boots >= 2
if not in_os or screen.stable_for() < args.settle:
continue
idle = disk_idle(args) and serial.idle_for() > args.disk_idle
if screen.is_blank():
if blank_since is None:
blank_since = now
elif now - blank_since > args.halt_timeout and disk_idle(args):
blank_since = blank_since or now
# macOS `shutdown -h` halts to a black screen without an ACPI power-off
if now - blank_since > args.halt_timeout and idle:
screen.save('halt')
log(f'guest halted (black {now - blank_since:.0f}s, disk idle); killing QEMU')
log(f'guest halted (black {now - blank_since:.0f}s, idle); killing QEMU')
proc.kill()
try:
proc.wait(timeout=10)
except Exception: # noqa: BLE001
pass
return 0
continue
blank_since = None
top = screen.menubar_text()
body = screen.ocr()
if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY):
screen.save('picker')
log('OpenCore boot picker, pressing Return')
qmp.send_key('ret')
screen.stable_since = time.time()
login_since = None
continue
# bright post-boot screen (loginwindow/desktop) => booted; power down if the
# agent did not (so customize/generalize completes even if BTM blocks it)
if screen.mean() > 80 and 'terminal' not in top and 'utilities' not in top:
if screen.mean() > args.bright:
# loginwindow / Setup Assistant: the OS is installed and booted
if login_since is None:
login_since = now
log('bright screen (loginwindow/desktop) after boot; grace before powerdown')
log(f'bright screen (mean {screen.mean():.0f}): loginwindow/desktop, grace {args.login_grace:.0f}s')
elif now - login_since > args.login_grace:
screen.save('loginwindow')
log(f'loginwindow persisted {now - login_since:.0f}s, powering down')
log('powering down (boot complete)')
try:
qmp.system_powerdown()
except Exception as e: # noqa: BLE001
log(f'powerdown failed: {e}')
for _ in range(90):
for _ in range(120):
if proc.poll() is not None:
log('QEMU exited after powerdown')
return 0
time.sleep(1)
log('guest ignored powerdown, killing QEMU')
proc.kill()
return 0
continue
else:
login_since = None
if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \
and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets:
# dark, frozen, nothing happening: a boot hang (seen at the Apple logo)
if screen.stable_for() > args.stall_reset and idle and resets < args.max_resets:
resets += 1
screen.save('stall-reset')
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, disk idle), system_reset #{resets}')
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, idle), system_reset #{resets}')
try:
qmp.system_reset()
except Exception as e: # noqa: BLE001
@ -562,54 +370,60 @@ def run_boot(args, proc, qmp, log):
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument('--mode', choices=['install', 'boot'], required=True)
p = argparse.ArgumentParser()
p.add_argument('--mode', choices=['pe', 'install', 'boot'], required=True)
p.add_argument('--name', default='macos')
p.add_argument('--debug-dir', default=None)
p.add_argument('--serial-log', default=None, help='file QEMU writes the serial console to')
p.add_argument('--timeout', type=int, default=4 * 3600, help='seconds before QEMU is killed')
p.add_argument('--start-timeout', type=float, default=600.0, help='seconds for the PE to start run.sh')
p.add_argument('--interval', type=float, default=5.0, help='seconds between screenshots')
p.add_argument('--periodic', type=float, default=120.0, help='seconds between saved debug screenshots')
p.add_argument('--settle', type=float, default=12.0, help='seconds a screen must be unchanged to act on it')
p.add_argument('--min-boot', type=float, default=45.0, help='seconds before the first action')
p.add_argument('--max-actions', type=int, default=8)
p.add_argument('--stall-reset', type=float, default=360.0, help='reset the VM if a non-Terminal screen is frozen this long (boot hang)')
p.add_argument('--stall-reset', type=float, default=360.0, help='reset the VM if a dark screen is frozen this long while disk and serial are idle')
p.add_argument('--max-resets', type=int, default=6)
p.add_argument('--halt-timeout', type=float, default=150.0, help='after the bootstrap, a pure-black screen this long means the guest halted (macOS shutdown does not ACPI-power-off QEMU)')
p.add_argument('--progress-file', default=None, help='a file (the system disk) whose mtime shows guest activity; resets/halt only fire when it is also idle, so a slow-but-working boot is never interrupted')
p.add_argument('--disk-idle', type=float, default=90.0, help='seconds of no writes to --progress-file that count as idle')
p.add_argument('--login-grace', type=float, default=240.0, help='seconds to wait at the loginwindow for the agent to power off before the driver powers down itself')
p.add_argument('--command', default='diskutil mount VMIX;sh /Volumes/VMIX/run.sh')
p.add_argument('--reboot-timeout', type=float, default=60.0, help='seconds after a guest reboot request without a new kernel boot before the VM is reset')
p.add_argument('--halt-timeout', type=float, default=150.0, help='a pure-black, idle screen this long means the guest halted')
p.add_argument('--progress-file', default=None, help='the system disk; its mtime shows guest disk activity')
p.add_argument('--disk-idle', type=float, default=90.0, help='seconds without disk/serial activity that count as idle')
p.add_argument('--bright', type=float, default=80.0, help='mean brightness above which a screen is the loginwindow/desktop')
p.add_argument('--login-grace', type=float, default=180.0, help='seconds a bright screen must persist before powering down')
p.add_argument('qemu', nargs=argparse.REMAINDER)
args = p.parse_args()
qemu_args = args.qemu[1:] if args.qemu and args.qemu[0] == '--' else args.qemu
qemu_args = [a for a in args.qemu if a != '--']
if not qemu_args:
p.error('QEMU command line required after --')
args.debug_dir = prepare_debug_dir(args.debug_dir or f'/tmp/vmix-macos/{args.name}')
log = Log(os.path.join(args.debug_dir, 'driver.log'))
log(f'mode={args.mode} debug-dir={args.debug_dir} ocr={"yes" if pytesseract else "no"}')
qmp_sock = os.path.join(args.debug_dir, 'qmp.sock')
proc, qmp = launch(qemu_args, qmp_sock, log)
if qmp is None and '-display' in qemu_args and 'sdl' in qemu_args:
# SDL could not open a window: retry headless
log(f'QEMU exited early ({proc.returncode}) with SDL, retrying headless')
i = qemu_args.index('-display')
qemu_args = qemu_args[:i] + ['-display', 'none'] + qemu_args[i + 2:]
proc, qmp = launch(qemu_args, qmp_sock, log)
if qmp is None:
log(f'QEMU exited immediately with {proc.returncode}')
return proc.returncode or 1
debug_dir = args.debug_dir or f'/tmp/vmix-macos/{args.name}'
prepare_debug_dir(debug_dir)
log = Log(os.path.join(debug_dir, 'driver.log'))
log(f'mode={args.mode} debug-dir={debug_dir} serial={args.serial_log}')
qmp_sock = os.path.join(debug_dir, 'qmp.sock')
proc = launch(qemu_args, qmp_sock, log)
qmp = QMP(qmp_sock)
try:
if args.mode == 'install':
rc = run_install(args, proc, qmp, log)
else:
rc = run_boot(args, proc, qmp, log)
qmp.connect()
except Exception as e: # noqa: BLE001
log(f'QMP connect failed: {e}')
proc.kill()
return 2
screen = Screen(qmp, debug_dir, log)
serial = Serial(args.serial_log)
try:
rc = drive(args, proc, qmp, screen, serial, log)
finally:
if args.serial_log and os.path.exists(args.serial_log):
try:
shutil.copy(args.serial_log, os.path.join(debug_dir, 'serial.log'))
except OSError:
pass
try:
if proc.poll() is None:
proc.kill()
log(f'QEMU exited with {rc}')
except Exception: # noqa: BLE001
pass
log(f'done rc={rc}')
return rc

View file

@ -1,14 +1,14 @@
# Shell snippet: read the vmix agent's result off the VMIX HFS+ volume of a raw
# disk image (${image}). Prints the guest logs and sets STATUS to the contents
# of vmix-run.status ("0" on success). Uses libguestfs (mtools is FAT-only). The
# agent unmounts VMIX cleanly before shutdown, so a read-only mount is safe.
# Shell snippet: read the PE's result off the VMIX HFS+ volume of a raw disk
# image (${image}). Prints the guest logs and sets STATUS to the contents of
# vmix-run.status ("0" on success, empty if run.sh never returned, e.g. the
# installer rebooted). The PE unmounts VMIX before powering off.
{ ... }:
image:
''
echo "=== vmix: reading result from ${image} ==="
for f in install.log system-install.log vmix-run.log vmix-agent.log; do
for f in vmix-run.log system-install.log; do
C=$(guestfish --ro -a ${image} -m /dev/sda1 cat /$f 2>/dev/null || true)
[ -n "$C" ] && { echo "--- $f ---"; printf '%s\n' "$C"; }
[ -n "$C" ] && { echo "--- $f ---"; printf '%s\n' "$C" | tail -400; }
done
STATUS=$(guestfish --ro -a ${image} -m /dev/sda1 cat /vmix-run.status 2>/dev/null | tr -d '[:space:]' || true)
''

View file

@ -1,11 +1,14 @@
# Pre-built macOS Tahoe (26) images
# Pipeline: makeImage (unattended install, vmix agent) → templates → generalize
# Pipeline: makeRecoveryPE (Recovery + vmix hook) → makeImage (unattended, offline
# install) → templates (applied offline from the PE) → generalize
{ pkgs, lib, system, macos, installer, recovery, ... }:
with macos;
rec {
pe = makeRecoveryPE { name = "macos-tahoe"; inherit recovery; };
upstream = makeImage {
name = "macos-tahoe";
inherit installer recovery;
inherit installer pe;
};
basic = customizeImageFold upstream templates.bundles.basic;

View file

@ -5,7 +5,7 @@ rec {
essentials = {
remoteAccess = import ./essentials/remote-access.nix { };
noUpdates = import ./essentials/no-updates.nix { };
performance = import ./essentials/performance.nix { };
performance = import ./essentials/performance.nix { inherit pkgs; };
};
bundles = {

View file

@ -4,12 +4,10 @@
{
name = "no-updates";
script = ''
softwareupdate --schedule off || true
defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool false
defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -bool false
defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticallyInstallMacOSUpdates -bool false
defaults write /Library/Preferences/com.apple.SoftwareUpdate ConfigDataInstall -bool false
defaults write /Library/Preferences/com.apple.SoftwareUpdate CriticalUpdateInstall -bool false
defaults write /Library/Preferences/com.apple.commerce AutoUpdate -bool false
SU="$DATA/Library/Preferences/com.apple.SoftwareUpdate.plist"
for k in AutomaticCheckEnabled AutomaticDownload AutomaticallyInstallMacOSUpdates ConfigDataInstall CriticalUpdateInstall; do
pe_plist_set "$SU" "$k" bool false
done
pe_plist_set "$DATA/Library/Preferences/com.apple.commerce.plist" AutoUpdate bool false
'';
}

View file

@ -1,11 +1,32 @@
# Less background work in a VM: no Spotlight indexing, no Time Machine, no sleep
{ ... }:
# Less background work in a VM: no Spotlight indexing, no Time Machine, no
# sleep, no immediate screen lock.
{ pkgs, ... }:
let
power = pkgs.writeText "com.apple.PowerManagement.plist" ''
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ActivePowerProfiles</key><dict><key>AC Power</key><integer>-1</integer></dict>
<key>Custom Profile</key><dict><key>AC Power</key><dict>
<key>Display Sleep Timer</key><integer>0</integer>
<key>System Sleep Timer</key><integer>0</integer>
<key>Disk Sleep Timer</key><integer>0</integer>
<key>Wake On LAN</key><integer>0</integer>
<key>hibernatemode</key><integer>0</integer>
</dict></dict>
</dict>
</plist>
'';
in
{
name = "performance";
files = [ { source = power; name = "com.apple.PowerManagement.plist"; } ];
script = ''
mdutil -a -i off || true
tmutil disable || true
pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 womp 0 || true
defaults write /Library/Preferences/com.apple.loginwindow DisableScreenLockImmediate -bool true
touch "$DATA/.metadata_never_index"
pe_plist_set "$DATA/Library/Preferences/com.apple.TimeMachine.plist" AutoBackup bool false
pe_plist_set "$DATA/Library/Preferences/com.apple.loginwindow.plist" DisableScreenLockImmediate bool true
cp "$V/com.apple.PowerManagement.plist" "$DATA/Library/Preferences/com.apple.PowerManagement.plist"
chown 0:0 "$DATA/Library/Preferences/com.apple.PowerManagement.plist"
'';
}

View file

@ -1,11 +1,10 @@
# Enable SSH (Remote Login) and Screen Sharing (VNC on 5900 inside the guest)
# by clearing their launchd overrides on the image's Data volume.
{ ... }:
{
name = "remote-access";
script = ''
systemsetup -setremotelogin on >/dev/null 2>&1 || launchctl load -w /System/Library/LaunchDaemons/ssh.plist
launchctl load -w /System/Library/LaunchDaemons/com.apple.screensharing.plist
# allow all local users to screen share
defaults write /var/db/launchd.db/com.apple.launchd/overrides.plist com.apple.screensharing -dict Disabled -bool false 2>/dev/null || true
pe_service_disabled com.apple.openssh.sshd false
pe_service_disabled com.apple.screensharing false
'';
}

View file

@ -1,7 +1,8 @@
# Generalize a macOS image: create the user, auto-login, hostname, timezone,
# suppress Setup Assistant prompts, then remove the vmix agent. Also gives the
# image a fresh SMBIOS identity (serial/MLB from macserial, MAC + UUID from
# `seed`) so every generalized VM looks like a distinct Mac to Apple ID/iMessage.
# Generalize a macOS image, offline from the PE: create the (admin) user on the
# image's Data volume with dscl, auto-login, suppress the first-login Setup
# Assistant, hostname, locale, timezone, use the whole disk, and give the image
# a fresh SMBIOS identity (serial/MLB from macserial, MAC + UUID from `seed`) so
# every generalized VM looks like a distinct Mac to Apple ID/iMessage.
# Usage: (templates.generalize { username = "User"; password = ""; hostname = "MAC"; })
# delayOobeRun = true: no user, Setup Assistant runs on first real boot (like Windows OOBE)
{ pkgs, lib, ... }:
@ -34,7 +35,8 @@ let
"DidSeeCloudSetup" "DidSeeSiriSetup" "DidSeePrivacy" "DidSeeTouchIDSetup" "DidSeeAppearanceSetup"
"DidSeeScreenTime" "DidSeeAccessibility" "DidSeeTrueTonePrivacy" "DidSeeActivationLock"
"DidSeeiCloudLoginForStorageServices" "DidSeeSyncSetup" "DidSeeSyncSetup2" "DidSeeAppleIDSyncSetup"
"DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "SkipFirstLoginOptimization"
"DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "DidSeeUpdateMacAutomatically"
"DidSeeSoftwareUpdate" "SkipFirstLoginOptimization"
];
in
{
@ -44,60 +46,81 @@ in
script = ''
set -x
${lib.optionalString (bgColor != null) ''echo "vmix: bgColor is not supported on macOS, ignoring"''}
VER=$(pe_target_version); BUILD=$(pe_target_build)
echo "vmix: target macOS $VER ($BUILD)"
N="$DATA/private/var/db/dslocal/nodes/Default"
D() { dscl -f "$N" localhost "$@"; }
${lib.optionalString (!delayOobeRun) ''
# --- user account (admin)
if ! id "${username}" >/dev/null 2>&1; then
sysadminctl -addUser "${username}" -fullName ${lib.escapeShellArg fullName} \
-password ${lib.escapeShellArg (if password == "" then tempPassword else password)} \
-admin -home "/Users/${username}" || exit 1
${lib.optionalString (password == "") ''
dscl . -passwd "/Users/${username}" "${tempPassword}" "" || echo "vmix: WARNING: could not set an empty password, password is '${tempPassword}'"
''}
# --- user account (admin), created directly in the local directory node
U="${username}"; HOME_DIR="$DATA/Users/$U"
if ! D -read "/Local/Default/Users/$U" >/dev/null 2>&1; then
UID_NEW=$(D -list /Local/Default/Users UniqueID | awk '$2 >= 501 && $2 < 1000 && $2 > m {m = $2} END {print (m ? m + 1 : 501)}')
D -create "/Local/Default/Users/$U" || pe_fail "dscl create user"
D -create "/Local/Default/Users/$U" UserShell /bin/zsh
D -create "/Local/Default/Users/$U" RealName ${lib.escapeShellArg fullName}
D -create "/Local/Default/Users/$U" UniqueID "$UID_NEW"
D -create "/Local/Default/Users/$U" PrimaryGroupID 20
D -create "/Local/Default/Users/$U" NFSHomeDirectory "/Users/$U"
if ! D -passwd "/Local/Default/Users/$U" ${lib.escapeShellArg password}; then
echo "vmix: WARNING: could not set the requested password, using '${tempPassword}'"
D -passwd "/Local/Default/Users/$U" "${tempPassword}" || pe_fail "dscl passwd"
fi
for g in admin _appserverusr _appserveradm _lpadmin; do
D -append "/Local/Default/Groups/$g" GroupMembership "$U" 2>/dev/null || true
done
mkdir -p "$HOME_DIR"
T="$SYS/System/Library/User Template/Non_localized"; [ -d "$T" ] || T="/System/Library/User Template/Non_localized"
ditto "$T" "$HOME_DIR" 2>/dev/null || true
L="$SYS/System/Library/User Template/English.lproj"; [ -d "$L" ] && ditto "$L" "$HOME_DIR" 2>/dev/null || true
else
UID_NEW=$(D -read "/Local/Default/Users/$U" UniqueID | awk '{print $2}')
fi
${lib.optionalString autoLogon ''
defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser "${username}"
cp /Volumes/VMIX/kcpassword /etc/kcpassword
chmod 600 /etc/kcpassword
chown root:wheel /etc/kcpassword
pe_plist_set "$DATA/Library/Preferences/com.apple.loginwindow.plist" autoLoginUser string "$U"
cp "$V/kcpassword" "$DATA/private/etc/kcpassword"
chmod 600 "$DATA/private/etc/kcpassword"; chown 0:0 "$DATA/private/etc/kcpassword"
''}
# --- no Setup Assistant / "What's new" prompts at first login
P="/Users/${username}/Library/Preferences/com.apple.SetupAssistant"
VER=$(sw_vers -productVersion)
BUILD=$(sw_vers -buildVersion)
for k in ${lib.concatStringsSep " " setupKeys}; do
defaults write "$P" "$k" -bool true
done
defaults write "$P" GestureMovieSeen none
defaults write "$P" LastSeenCloudProductVersion "$VER"
defaults write "$P" LastSeenBuddyBuildVersion "$BUILD"
defaults write "$P" LastSeenSiriProductVersion "$VER"
defaults write "$P" LastPreLoginTasksPerformedVersion "$VER"
defaults write "/Users/${username}/Library/Preferences/.GlobalPreferences" AppleLocale "${macLocale}"
chown -R "${username}" "/Users/${username}/Library/Preferences"
mkdir -p "$HOME_DIR/Library/Preferences"
P="$HOME_DIR/Library/Preferences/com.apple.SetupAssistant.plist"
for k in ${lib.concatStringsSep " " setupKeys}; do pe_plist_set "$P" "$k" bool true; done
pe_plist_set "$P" GestureMovieSeen string none
pe_plist_set "$P" LastSeenCloudProductVersion string "$VER"
pe_plist_set "$P" LastSeenBuddyBuildVersion string "$BUILD"
pe_plist_set "$P" LastSeenSiriProductVersion string "$VER"
pe_plist_set "$P" LastPreLoginTasksPerformedVersion string "$VER"
pe_plist_set "$P" LastPreLoginTasksPerformedBuild string "$BUILD"
pe_plist_set "$HOME_DIR/Library/Preferences/.GlobalPreferences.plist" AppleLocale string "${macLocale}"
chown -R "$UID_NEW:20" "$HOME_DIR"
touch "$DATA/private/var/db/.AppleSetupDone"
''}
${lib.optionalString delayOobeRun ''
rm -f "$DATA/private/var/db/.AppleSetupDone"
''}
# --- machine identity
scutil --set ComputerName "${hostname}"
scutil --set HostName "${hostname}"
scutil --set LocalHostName "${hostname}"
defaults write /Library/Preferences/.GlobalPreferences AppleLocale "${macLocale}"
systemsetup -settimezone "${timezone}" >/dev/null 2>&1 || ln -sfn "/usr/share/zoneinfo/${timezone}" /etc/localtime
PF="$DATA/Library/Preferences/SystemConfiguration/preferences.plist"
mkdir -p "$(dirname "$PF")"
pe_plist_dict "$PF" System
pe_plist_dict "$PF" System.System
pe_plist_dict "$PF" System.Network
pe_plist_dict "$PF" System.Network.HostNames
pe_plist_set "$PF" System.System.ComputerName string "${hostname}"
pe_plist_set "$PF" System.System.HostName string "${hostname}"
pe_plist_set "$PF" System.Network.HostNames.LocalHostName string "${hostname}"
pe_plist_set "$DATA/Library/Preferences/.GlobalPreferences.plist" AppleLocale string "${macLocale}"
ln -sfn "/var/db/timezone/zoneinfo/${timezone}" "$DATA/private/etc/localtime"
pe_plist_set "$DATA/Library/Preferences/com.apple.timezone.auto.plist" Active bool false
# --- never sleep (VM)
pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 || true
# --- QEMU's USB keyboard (vendor 0x0627, product 0x0001) is unknown to macOS,
# which would open the Keyboard Setup Assistant at every login: declare it ANSI
KT="$DATA/Library/Preferences/com.apple.keyboardtype.plist"
pe_plist_dict "$KT" keyboardtype
pe_plist_set "$KT" keyboardtype.1-1575-0 integer 40
# --- use the whole (possibly grown) disk
STORE=$(diskutil info / | awk '/APFS Physical Store/ {print $NF}')
STORE=$(diskutil info "$SYS_ID" | sed -n 's/.*APFS Physical Store: *//p' | awk '{print $1}')
[ -n "$STORE" ] && diskutil apfs resizeContainer "$STORE" 0 || true
${lib.optionalString delayOobeRun ''
# Setup Assistant will run on the next boot
rm -f /var/db/.AppleSetupDone
''}
# --- the agent's job is done: remove it (this is the last vmix step)
rm -f /Library/LaunchDaemons/ch.vmix.agent.plist
rm -rf /Library/vmix
'';
}

28
lib/images/macos/tools/soak.sh Executable file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Repeatability check for a macOS image build: build the same attribute N times
# (forcing a rebuild each time), keep every run's driver + serial logs, and print
# a table of outcome / duration / which recovery mechanisms fired.
# tools/soak.sh <flake-dir> <attr> [runs] [outdir]
# e.g. tools/soak.sh /root/vmix.nix macos.images.tahoe.upstream 3
set -u
FLAKE=${1:?flake dir}; ATTR=${2:?attribute}; RUNS=${3:-3}; OUT=${4:-/tmp/vmix-macos-soak}
NAME=$(nix eval --impure --raw --expr "(builtins.getFlake \"path:$FLAKE\").lib.x86_64-linux.$ATTR.name" | sed 's/-vmix\.qcow2$//')
DRV=$(nix eval --impure --raw --expr "(builtins.getFlake \"path:$FLAKE\").lib.x86_64-linux.$ATTR.drvPath")
mkdir -p "$OUT"
printf '%-4s %-8s %-9s %-6s %-7s %-7s %-6s %s\n' run result minutes boots resets panics tries note | tee "$OUT/summary.txt"
for i in $(seq 1 "$RUNS"); do
D="$OUT/run-$i"; rm -rf "$D"; mkdir -p "$D"
rm -rf "/tmp/vmix-macos/$NAME"
t0=$(date +%s)
if [ "$i" -eq 1 ]; then nix build --no-link -L "$DRV^*" > "$D/build.log" 2>&1; rc=$?
else nix build --no-link -L --rebuild "$DRV^*" > "$D/build.log" 2>&1; rc=$?; fi
t1=$(date +%s)
cp "/tmp/vmix-macos/$NAME"/driver.log "/tmp/vmix-macos/$NAME"/serial.log "$D/" 2>/dev/null
cp "/tmp/vmix-macos/$NAME"/*.png "$D/" 2>/dev/null
L="$D/driver.log"
boots=$(grep -c 'guest kernel boot' "$L" 2>/dev/null); resets=$(grep -c 'system_reset' "$L" 2>/dev/null)
panics=$(grep -c 'kernel panic' "$L" 2>/dev/null); tries=$(grep -c 'startosinstall try' "$L" 2>/dev/null)
note=$(grep -oE 'prepare too slow[^,]*|PE did not[^,]*|guest halted|powering down|timeout reached' "$L" 2>/dev/null | sort | uniq -c | tr '\n' ';' | tr -s ' ')
printf '%-4s %-8s %-9s %-6s %-7s %-7s %-6s %s\n' "$i" "$([ $rc -eq 0 ] && echo OK || echo FAIL)" "$(( (t1 - t0) / 60 ))" "$boots" "$resets" "$panics" "$tries" "$note" | tee -a "$OUT/summary.txt"
done
echo "logs: $OUT"