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 Pre-installed, Apple-ID-capable macOS VM images built the same way as the
images: `makeImage` installs the OS once, templates customize it by booting it, Windows ones: `makeImage` (unattended install) → templates → `.generalize`
`.generalize` creates the user and seals the image. (user, hostname, fresh SMBIOS identity). Runs on QEMU/KVM with OpenCore.
``` ```
vmix build --image macos.images.tahoe.basic \ vmix build --image macos.images.tahoe.basic --generalize username=sagar,password=secret,hostname=MAC
--generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich vmix run ./result --macos --vnc :10 --mem 8192
vmix run ./result --macos --vnc :10 --mem 8192 # VNC on port 5910
``` ```
## How it works Nix: `macos.images.tahoe.{pe,upstream,basic,remote}` and
`<image>.generalize { username; password; hostname; timezone; locale; seed; … }`.
| step | what happens | ## How it works: the vmix "PE"
|---|---|
| `fetchRecovery` | BaseSystem.dmg from Apple's recovery servers (fixed-output, pinned by sha256) |
| `installerPayload` | takes the App Store `InstallAssistant.pkg` (18 GB, pinned) apart on Linux: the app skeleton (pbzx/cpio) and the byte offset of `SharedSupport.dmg` |
| `makeOpenCore` | OSX-KVM's OpenCore ESP with a config.plist rewritten for this image: SMBIOS model, serial + MLB (`macserial`), UUID and ROM = NIC MAC (derived from a seed), NIC marked built-in |
| `makeImage` | one QEMU session: Recovery boots via OpenCore → `vm-driver.py` opens Terminal with keystrokes (Ctrl-F2 menu navigation, screen-settle detection + OCR of the menu bar) and types `sh /Volumes/VMIX/run.sh``vmix-install.sh` erases the disk, rebuilds `Install macOS Tahoe.app` (skeleton + `SharedSupport.dmg` copied from a raw disk mapped straight out of the pkg), runs `startosinstall --installpackage vmix-agent.pkg` → installer reboots through its phases → first boot runs the **vmix agent** which powers off. OpenCore is then copied into the image's EFI partition, so it boots standalone with OVMF |
| `customizeImage` | boots the image with a FAT volume `VMIX`; the agent (LaunchDaemon `ch.vmix.agent`) runs `vmix-run.sh` as root, writes `vmix-run.status`/`.log` back and shuts down |
| `templates.generalize` | user (admin) + auto-login (`/etc/kcpassword`), Setup Assistant suppressed, hostname, timezone, no sleep, APFS grown to the disk, then the agent removes itself; a fresh SMBIOS identity is written to the ESP |
The vmix agent replaces Windows' Audit Mode RunOnce; `.AppleSetupDone` replaces Apple's Recovery (`BaseSystem.dmg`, a plain journaled HFS+ volume) with **one
the OOBE unattend. Everything on the host side runs inside `__noChroot` LaunchDaemon added** (`makeRecoveryPE`): at boot it mounts a `VMIX` volume and
derivations (KVM + `/tmp`), exactly like the Windows builders. 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` ### Pipeline
as for Windows (`bgColor` is accepted but ignored), plus the SMBIOS identity:
`model serial mlb uuid mac seed`. Anything unset is generated: serial/MLB by
macserial (random per build), MAC and UUID deterministically from `seed`
(default `hostname-username`). `vmix macserial --model MacPro7,1` prints a
ready-to-paste set.
`delay-oobe-run=true` creates no user and re-arms Setup Assistant for the first 1. `makeRecoveryPE` — BaseSystem.dmg → raw HFS+ image + `ch.vmix.pe` daemon.
real boot. 2. `makeImage` — QEMU with: OpenCore boot disk (build variant with serial
console), the PE, the empty target disk, the VMIX volume (`vmix-install.sh`,
## Apple ID / iMessage 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 image satisfies what Dortania lists for iServices: unique serial + MLB for a the pkg into it as `SharedSupport.dmg` (it is a "pkgdmg": xar + koly footer;
Tahoe-supported model (`MacPro7,1` by default; `iMac20,1/2`, the bare xar member fails with "pkgdmg is missing a footer"), then runs
`MacBookPro16,x` also work), SystemUUID, ROM equal to en0's MAC, and en0 marked `startosinstall`, which reboots itself through the install phases. The
built-in (the NIC is pinned to `PciRoot(0x0)/Pci(0x12,0x0)`). The NixOS module installed system's first boot ends at the loginwindow: the driver detects the
and `vmix run --macos` use the MAC recorded in the image (`EFI/vmix/vmix.json`). bright screen and powers the VM down. OpenCore is then copied into the image's
Give each deployed VM its own generalized image (different `seed`, or explicit own ESP so it boots with plain OVMF.
`serial=`/`mlb=`) — two VMs with the same identity will be blocked. 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
## Runtime template script with `$SYS`/`$DATA` mounted. `pe-lib.sh` has the helpers.
4. `templates/generalize.nix` — user (dscl, admin, home from the user template),
* `vmix run <qcow2> --macos [--vnc :N] [--mac ..]` auto-login (`kcpassword`), Setup Assistant suppression, hostname, locale,
* NixOS module: `disks.os.file = vmixLib.macos.images.tahoe.basic.generalize {...}` timezone, keyboard type, container resize, fresh SMBIOS via a new OpenCore
is auto-detected (`_vmixOsType = "macos"`): Skylake-Client CPU spoof, AppleSMC, ESP (`serial`/`mlb` from macserial, MAC + UUID from `seed`).
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.
### Recovery source ### Recovery source
`recovery.file` in `upstream.json` points at a content-addressed store path for the `recovery.file` in `upstream.json` points at a content-addressed store path for
verified Tahoe `BaseSystem.dmg` (Apple's CDN load-balances Sequoia/Tahoe during the the verified Tahoe `BaseSystem.dmg` (Apple's CDN load-balances Sequoia/Tahoe
rollout, so a plain fetch is non-deterministic). Reproduce it on any host with during the rollout, so a plain fetch is non-deterministic). Reproduce it on any
`nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg` (same path host with `nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg`.
from the same bytes). Set `recovery.sha256` and remove `recovery.file` to fetch it Drop `recovery.file` to fetch from Apple instead (`fetchRecovery` retries until
from Apple instead (subject to the CDN rollout). the pinned hash matches).
### Not yet done: generalize / user creation ## Reliability
The base image installs and boots to loginwindow. `.generalize` (user creation, Things QEMU does intermittently, and what handles each (all in `vm-driver.py`
auto-login, hostname) relies on the vmix agent LaunchDaemon running on first boot, and `vmix-install.sh`; every event is logged with a reason):
but macOS Ventura+ Background Task Management does not auto-run a headless
third-party daemon, and neither the pkg `launchctl bootstrap` (installer domain * `startosinstall` prepare stalls or crawls — the guest kills and retries it on a
only) nor a cron `@reboot` reliably triggered it. The robust next step is to inject freshly erased target (free-space watchdog + time cap).
the user record + settings offline from the agent pkg's postinstall (which runs as * the installer comes back to the PE instead of the install phase — the PE
root on the target during install), instead of a first-boot daemon. 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; }; fetchRecovery = import ./helpers/fetchRecovery.nix { inherit pkgs upstream; };
installerPayload = import ./helpers/installerPayload.nix { inherit pkgs lib; }; installerPayload = import ./helpers/installerPayload.nix { inherit pkgs lib; };
makeOpenCore = import ./helpers/makeOpenCore.nix { inherit pkgs lib upstream macserial qemu; }; 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; }; makeVmixVolume = import ./helpers/makeVmixVolume.nix { inherit pkgs lib; };
makeAgentPkg = import ./helpers/makeAgentPkg.nix { inherit pkgs lib; };
installBootloader = import ./helpers/installBootloader.nix { inherit pkgs lib; }; installBootloader = import ./helpers/installBootloader.nix { inherit pkgs lib; };
vmixReadback = import ./helpers/vmix-readback.nix { inherit pkgs lib; }; vmixReadback = import ./helpers/vmix-readback.nix { inherit pkgs lib; };
vmDriver = ./helpers/vm-driver.py; vmDriver = ./helpers/vm-driver.py;
makeImage = import ./helpers/makeImage.nix { 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 { 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; customizeImageFold = builtins.foldl' customizeImage;
templates = import ./templates { inherit pkgs lib; }; 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"> <plist version="1.0">
<dict> <dict>
<key>Label</key> <key>Label</key>
<string>ch.vmix.agent</string> <string>ch.vmix.pe</string>
<key>ProgramArguments</key> <key>ProgramArguments</key>
<array> <array>
<string>/bin/sh</string> <string>/bin/bash</string>
<string>/Library/vmix/agent.sh</string> <string>/usr/libexec/vmix/pe.sh</string>
</array> </array>
<key>RunAtLoad</key> <key>RunAtLoad</key>
<true/> <true/>
<key>StandardOutPath</key> <key>StandardOutPath</key>
<string>/var/log/vmix-agent.log</string> <string>/dev/console</string>
<key>StandardErrorPath</key> <key>StandardErrorPath</key>
<string>/var/log/vmix-agent.log</string> <string>/dev/console</string>
</dict> </dict>
</plist> </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 #!/bin/bash
# vmix: automated macOS install. Runs inside macOS Recovery's Terminal, started # vmix unattended macOS install, run by the PE hook (pe.sh) as root in the
# by vm-driver.py which types "sh /Volumes/VMIX/run.sh" for us. # Recovery with /Volumes/VMIX mounted (V). Needs vmix.conf: TARGET_BYTES,
# # PKG_BYTES, PKG_DISK_BYTES, APP_NAME, VOLUME_NAME.
# 1. erase the target disk (found by size) as APFS "Macintosh HD" # 1. find the target disk and the SharedSupport (InstallAssistant.pkg) disk by size
# 2. rebuild "Install macOS <name>.app": app skeleton from installer-app.tar # 2. erase the target as APFS, unpack the installer app, dd the whole pkg into it
# (host-extracted Payload) + SharedSupport.dmg = the WHOLE InstallAssistant.pkg # as SharedSupport.dmg (a "pkgdmg", startosinstall checks its koly footer)
# dd'd byte-exact from a raw disk (Apple's own postinstall hardlinks the pkg # 3. startosinstall prepares, then reboots itself into the install phase; the
# there: it is a "pkgdmg" whose koly footer points at the dmg inside; the bare # installed system's first boot ends at the loginwindow (the host powers off)
# xar member fails startosinstall with "pkgdmg is missing a footer") # Never returns on success; a return means failure (the PE records the status).
# 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
set -x set -x
. "$V/vmix.conf" . "$V/pe-lib.sh"
# 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
fail() { fail() {
echo "vmix-install: FAIL: $*" echo "VMIX-FAIL: $*"
cp /var/log/install.log "$V/system-install.log" 2>/dev/null || true cp /var/log/install.log "$V/system-install.log" 2>/dev/null
echo 1 >"$V/install.status"
sync sync
sleep 2
shutdown -h now 2>/dev/null || halt 2>/dev/null || true
exit 1 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() { disk_by_size() {
for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+'); do for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+' | sort -u); do
s=$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9][0-9]*\) Bytes).*/\1/p') if [ "$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9]*\) Bytes).*/\1/p')" = "$1" ]; then
[ "$s" = "$1" ] && { echo "${d#/dev/}"; return 0; } echo "${d#/dev/}"; return 0
fi
done done
return 1 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" # --- 2. target volume + installer app (the pkg payload skeleton + SharedSupport.dmg)
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"
VOL="/Volumes/$VOLUME_NAME" 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" APP="$VOL/$APP_NAME"
SOI="$APP/Contents/Resources/startosinstall"
[ -x "$SOI" ] || fail "startosinstall missing in $APP"
SS="$APP/Contents/SharedSupport/SharedSupport.dmg" 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" mkdir -p "$APP/Contents/SharedSupport"
FULL=$((PKG_BYTES / 1048576)) FULL=$(( PKG_BYTES / 1048576 )); REM=$(( 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.dmg" dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport"
if [ "$REM" -gt 0 ]; then [ "$REM" -gt 0 ] && { dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 | dd bs=1 count=$REM >> "$SS"; } || true
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" [ "$(stat -f %z "$SS")" = "$PKG_BYTES" ] || fail "SharedSupport.dmg size $(stat -f %z "$SS") != $PKG_BYTES"
fi tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no koly footer"
[ "$(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"
chflags -h norestricted "$SS" 2>/dev/null || true 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. # Offline install: no NIC is attached. Blackhole Apple's install/verify endpoints
echo "vmix-install: guest clock is $(date) (UTC $(date -u))" # too, so osinstallersetupd's requests fail immediately instead of timing out.
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.
for d in swscan.apple.com swcdn.apple.com swdist.apple.com swquery.apple.com \ 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 \ 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 \ 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 ocsp2.apple.com valid.apple.com; do
echo "127.0.0.1 $d" >> /etc/hosts echo "127.0.0.1 $d" >> /etc/hosts
done done
echo "vmix-install: blackholed Apple install endpoints for a fast offline prepare"
# --- 3. unattended install. startosinstall prepares then reboots the machine # --- 3. startosinstall prepares (~5 min) then reboots the machine itself into the
# itself into the install phase. Prepare intermittently stalls (~46% — an online # install phase; it never returns on success. Prepare is intermittently slow in
# verify/personalization step through the VM's NAT), so a watchdog kills and # QEMU, so an attempt that stalls or runs too long is killed and retried on a
# retries startosinstall if the target volume makes no write progress for a while. # freshly erased target.
# The vmix agent pkg installs during the install phase and runs on first boot.
# quote args properly — $VOL contains a space ("Macintosh HD")
run_soi() { "$SOI" --volume "$VOL" --agreetolicense --nointeraction --rebootdelay 5 "$@"; } run_soi() { "$SOI" --volume "$VOL" --agreetolicense --nointeraction --rebootdelay 5 "$@"; }
free_kb() { df -k "$VOL" 2>/dev/null | awk 'NR==2 {print $4}'; } free_kb() { df -k "$VOL" 2>/dev/null | awk 'NR==2 {print $4}'; }
try=0
attempt=0 while [ "$try" -lt 6 ]; do
while [ "$attempt" -lt 10 ]; do try=$((try + 1))
attempt=$((attempt + 1)) [ "$try" -gt 1 ] && prepare_target
echo "vmix-install: startosinstall attempt $attempt" echo "VMIX-INSTALL: startosinstall try $try"
if [ "$attempt" -eq 1 ]; then run_soi 2>&1 &
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
SOI_PID=$! 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 last=$(free_kb); stalled=0; elapsed=0
while kill -0 "$SOI_PID" 2>/dev/null; do while kill -0 "$SOI_PID" 2>/dev/null; do
sleep 30; elapsed=$((elapsed + 30)) sleep 30; elapsed=$((elapsed + 30))
now=$(free_kb) now=$(free_kb)
if [ "$now" = "$last" ]; then stalled=$((stalled + 30)); else stalled=0; last=$now; fi if [ "$now" = "$last" ]; then stalled=$((stalled + 30)); else stalled=0; last=$now; fi
if [ "$stalled" -ge 240 ] || [ "$elapsed" -ge 540 ]; then [ $((elapsed % 120)) -eq 0 ] && echo "VMIX-INSTALL: prepare running ${elapsed}s (stalled ${stalled}s)"
echo "vmix-install: prepare too slow (stalled=${stalled}s elapsed=${elapsed}s), killing to retry" 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 kill -9 "$SOI_PID" 2>/dev/null; pkill -9 -f startosinstall 2>/dev/null
break break
fi fi
done done
wait "$SOI_PID" 2>/dev/null wait "$SOI_PID" 2>/dev/null
# on success startosinstall reboots the machine and we never get here echo "VMIX-INSTALL: startosinstall try $try ended without rebooting"
echo "vmix-install: startosinstall attempt $attempt ended without rebooting"
sleep 3 sleep 3
done 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 # Customize a macOS image offline from the vmix PE: the recovery boots with the
# (LaunchDaemon installed by makeImage) runs `script` as root, records the exit # image and a VMIX volume attached, its hook runs `script` as root with the
# status on the volume and powers off. Optionally re-installs OpenCore with a new # image's System (read-only) and Data (rw) volumes mounted at $SYS / $DATA, then
# SMBIOS identity (`smbios`). Counterpart of the Windows auditScript flow. # 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: # 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 # 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 # 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: { originalImage: {
name ? "", name ? "",
script ? "", script ? "",
@ -19,7 +22,7 @@ originalImage: {
smp ? 4, smp ? 4,
memSize ? 4096, memSize ? 4096,
cpu ? qemu.defaultCpu, cpu ? qemu.defaultCpu,
timeout ? 3600, timeout ? 1800,
}: }:
let let
originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name); originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name);
@ -27,6 +30,8 @@ let
resultImg = "./disk.qcow2"; resultImg = "./disk.qcow2";
hasScript = script != ""; hasScript = script != "";
hasSmbios = smbios != null; 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"; model = originalImage.model or "MacPro7,1";
seed = if hasSmbios && (smbios.seed or null) != null then smbios.seed else null; seed = if hasSmbios && (smbios.seed or null) != null then smbios.seed else null;
@ -45,46 +50,60 @@ let
inherit mac uuid; inherit mac uuid;
} // builtins.removeAttrs smbios [ "seed" "mac" "uuid" "model" ]) } // builtins.removeAttrs smbios [ "seed" "mac" "uuid" "model" ])
else originalImage.opencore; 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" '' runScript = pkgs.writeText "${name}-run.sh" ''
#!/bin/sh #!/bin/bash
. /Volumes/VMIX/pe-lib.sh
echo "=== vmix: ${name} ===" echo "=== vmix: ${name} ==="
pe_mount_target || pe_fail "could not mount the target volumes"
${script} ${script}
pe_unmount_target
''; '';
vmixVol = makeVmixVolume { vmixVol = makeVmixVolume {
name = "${name}-${originalImageName}"; 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 ]); driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
bootCommands = lib.optionalString hasScript '' bootCommands = lib.optionalString hasScript ''
cp ${vmixVol} vmix.img cp ${vmixVol} vmix.img
chmod +w 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 cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd
chmod +w vars.fd chmod +w vars.fd
VMIX_DISPLAY="-display none" VMIX_DISPLAY="-display none"
${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''} ${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} ===" echo "=== vmix: running ${name} in the PE against ${originalImageName} ==="
python3 ${vmDriver} --mode boot --name "${name}-${originalImageName}" --timeout ${toString timeout} --progress-file ${resultImg} -- \ python3 ${vmDriver} --mode pe --name "${name}-${originalImageName}" --timeout ${toString timeout} \
--serial-log serial.log --progress-file ${resultImg} -- \
qemu-system-x86_64 $VMIX_DISPLAY \ qemu-system-x86_64 $VMIX_DISPLAY \
${qemu.machineArgs { inherit cpu smp memSize; }} \ ${qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.firmwareArgs "vars.fd"} \ ${qemu.firmwareArgs "vars.fd"} \
${qemu.sataDrive { id = "system"; port = 0; file = resultImg; }} \ ${qemu.serialArgs "serial.log"} \
${qemu.sataDrive { id = "vmix"; port = 1; file = "vmix.img"; format = "raw"; }} \ ${qemu.sataDrive { id = "opencore"; port = 0; file = "ocboot.qcow2"; }} \
${qemu.netArgs { mac = originalImage.macAddress; }} \ ${qemu.sataDrive { id = "pe"; port = 1; file = "pe.qcow2"; }} \
|| { echo "vmix: VM failed during ${name} (see /tmp/vmix-macos/${name}-${originalImageName})"; exit 1; } ${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"} ${vmixReadback "vmix.img"}
[ "$STATUS" = "0" ] || { echo "vmix: ${name} script failed (status '$STATUS')"; exit 1; } [ "$STATUS" = "0" ] || { echo "vmix: ${name} script failed (status '$STATUS')"; exit 1; }
@ -92,7 +111,7 @@ let
''; '';
builtImage = pkgs.runCommand customImageName ({ 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" ]; requiredSystemFeatures = [ "kvm" ];
} // lib.optionalAttrs impure { __noChroot = true; }) '' } // lib.optionalAttrs impure { __noChroot = true; }) ''
qemu-img create -q -f qcow2 -b ${originalImage} -F qcow2 ${resultImg} qemu-img create -q -f qcow2 -b ${originalImage} -F qcow2 ${resultImg}
@ -102,4 +121,4 @@ let
mv ${resultImg} $out mv ${resultImg} $out
''; '';
in 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. # Build a pre-installed macOS qcow2 with an unattended install driven from the
# # vmix PE (Apple's Recovery + one LaunchDaemon, see makeRecoveryPE):
# One QEMU session, driven by vm-driver.py: # OpenCore boots the PE → its hook runs /Volumes/VMIX/run.sh (vmix-install.sh)
# Recovery (BaseSystem) boots via OpenCore → driver opens Terminal with # → erase the disk, rebuild the installer app from installer-app.tar + the
# keystrokes and types "sh /Volumes/VMIX/run.sh" → vmix-install.sh erases the # SharedSupport raw disk, startosinstall → the installer reboots through its
# disk, rebuilds the installer app from installer-app.tar + the SharedSupport.dmg # phases → the installed system's first boot reaches the loginwindow → the
# raw disk, runs startosinstall (--installpackage vmix-agent.pkg) → the installer # driver powers it off. No GUI is driven; progress is read from the serial
# reboots through its phases → first boot of macOS runs the vmix agent, which # console and screenshots (brightness). Fully offline: no NIC is attached.
# executes vmix-run.sh and powers off → QEMU exits. # Then OpenCore is copied into the image's own ESP so it boots with plain OVMF.
# Afterwards OpenCore is copied into the image's EFI partition so the result # Apply templates with customizeImageFold, then .generalize.
# boots standalone with plain OVMF. Apply templates with customizeImageFold, { pkgs, lib, qemu, ident, installerPayload, makeOpenCore, makeBootDisk, makeVmixVolume, installBootloader, vmixReadback, vmDriver, ... }:
# then .generalize to create the user and set a fresh SMBIOS identity.
{ pkgs, lib, qemu, ident, installerPayload, makeOpenCore, makeVmixVolume, makeAgentPkg, installBootloader, vmixReadback, vmDriver, ... }:
{ {
name ? "macos", name ? "macos",
installer, # InstallAssistant.pkg (fetchurl) installer, # InstallAssistant.pkg
recovery, # BaseSystem.dmg (fetchRecovery) pe, # makeRecoveryPE output for the same macOS version
diskSize ? "128G", diskSize ? "128G",
volumeName ? "Macintosh HD", volumeName ? "Macintosh HD",
smp ? 4, smp ? 4,
memSize ? 8192, memSize ? 8192,
cpu ? qemu.defaultCpu, 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 seed ? name, # MAC address + SystemUUID are derived from this
bootArgs ? "keepsyms=1", bootArgs ? "keepsyms=1",
vncDisplay ? null, # e.g. ":10" to watch the install on port 5910 vncDisplay ? null, # e.g. ":10" to watch the install on port 5910
timeout ? 4 * 3600, # seconds for the whole install timeout ? 4 * 3600, # seconds for the whole install
extraOpenCoreConfig ? {}, # merged into config.plist extraOpenCoreConfig ? {}, # merged into config.plist
installNetwork ? false, # attach a NIC during install (default: offline — startosinstall installNetwork ? false, # attach a NIC during the install (default: offline)
# otherwise hangs on Apple personalization through a flaky NAT)
}: }:
let let
mac = ident.macFromSeed seed; mac = ident.macFromSeed seed;
uuid = ident.uuidFromSeed 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; }; 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 { vmixVol = makeVmixVolume {
inherit name; inherit name;
size = "512M"; size = "512M";
files = [ files = [
{ source = ../guest/vmix-install.sh; name = "run.sh"; } { 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 = "${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 ]); driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
tesseract = pkgs.tesseract.override { enableLanguages = [ "eng" ]; };
drv = pkgs.runCommand "${name}-vmix.qcow2" { drv = pkgs.runCommand "${name}-vmix.qcow2" {
__noChroot = true; __noChroot = true;
requiredSystemFeatures = [ "kvm" ]; 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 ===" echo "=== vmix: creating ${diskSize} disk ==="
qemu-img create -f qcow2 disk.qcow2 ${diskSize} qemu-img create -f qcow2 disk.qcow2 ${diskSize}
# store files are read-only and AHCI needs writable nodes: qcow2 overlays # 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 ${pe} pe.qcow2
qemu-img create -q -f qcow2 -F raw -b ${esp}/boot.img ocboot.qcow2 qemu-img create -q -f qcow2 -F raw -b ${bootDisk}/boot.img ocboot.qcow2
# Apple's postinstall hardlinks the WHOLE InstallAssistant.pkg as # Apple's postinstall hardlinks the WHOLE InstallAssistant.pkg as
# Contents/SharedSupport/SharedSupport.dmg: the pkg is a "pkgdmg" (xar + koly # 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 cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd
chmod +w vars.fd chmod +w vars.fd
VMIX_DISPLAY="-display none" VMIX_DISPLAY="-display none"
${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''} ${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}) ===" echo "=== vmix: installing ${name} (unattended, ~1 h; logs and screenshots in /tmp/vmix-macos/${name}) ==="
python3 ${vmDriver} --mode install --name ${name} --timeout ${toString timeout} --progress-file disk.qcow2 -- \ python3 ${vmDriver} --mode install --name ${name} --timeout ${toString timeout} \
--serial-log serial.log --progress-file disk.qcow2 -- \
qemu-system-x86_64 $VMIX_DISPLAY \ qemu-system-x86_64 $VMIX_DISPLAY \
${qemu.machineArgs { inherit cpu smp memSize; }} \ ${qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.firmwareArgs "vars.fd"} \ ${qemu.firmwareArgs "vars.fd"} \
${qemu.serialArgs "serial.log"} \
${qemu.sataDrive { id = "opencore"; port = 0; file = "ocboot.qcow2"; }} \ ${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 = "system"; port = 2; file = "disk.qcow2"; }} \
${qemu.sataDrive { id = "vmix"; port = 3; file = "vmix.img"; format = "raw"; }} \ ${qemu.sataDrive { id = "vmix"; port = 3; file = "vmix.img"; format = "raw"; }} \
${qemu.sataDrive { id = "sharedsupport"; port = 4; file = "sharedsupport.qcow2"; }} \ ${qemu.sataDrive { id = "sharedsupport"; port = 4; file = "sharedsupport.qcow2"; }} \
${lib.optionalString installNetwork (qemu.netArgs { inherit mac; })} \ ${lib.optionalString installNetwork (qemu.netArgs { inherit mac; })} \
|| { echo "vmix: install VM failed (see /tmp/vmix-macos/${name})"; exit 1; } || { 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 # The PE records a status only if run.sh returned, i.e. the install failed
# completed/powered-off state, so reaching here means the OS is installed. # before the installer took over and rebooted.
# vmix-run.status is written only when the agent ran (cron/daemon); log it.
${vmixReadback "vmix.img"} ${vmixReadback "vmix.img"}
[ "$STATUS" = "0" ] && echo "vmix: first-boot agent completed (status 0)" \ if [ -n "$STATUS" ] && [ "$STATUS" != "0" ]; then
|| echo "vmix: install reached loginwindow (agent status '$STATUS'); image is installed" echo "vmix: install script failed (status $STATUS), see /tmp/vmix-macos/${name}"; exit 1
fi
${installBootloader { inherit esp; image = "disk.qcow2"; }} ${installBootloader { inherit esp; image = "disk.qcow2"; }}
echo "=== vmix: ${name} install complete (serial $(jq -r .serial ${esp}/vmix.json), mac ${mac}) ===" echo "=== vmix: ${name} install complete (serial $(jq -r .serial ${esp}/vmix.json), mac ${mac}) ==="
mv disk.qcow2 $out 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, showPicker ? true,
pickerTimeout ? 2, pickerTimeout ? 2,
extraConfig ? {}, extraConfig ? {},
memSize ? 8192, # RAM described in SMBIOS (MacPro7,1 wants 4 DIMMs)
}: }:
let let
ocImage = pkgs.fetchurl { inherit (upstream.opencore.image) url sha256; name = "OSX-KVM-OpenCore.qcow2"; }; 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}" \ --model "${model}" --serial "$SERIAL" --mlb "$MLB" --uuid "${uuid}" --mac "${mac}" \
--nic-path "${qemu.nicDevicePath}" --boot-args "${bootArgs}" --resolution "${resolution}" \ --nic-path "${qemu.nicDevicePath}" --boot-args "${bootArgs}" --resolution "${resolution}" \
--show-picker "${lib.boolToString showPicker}" --timeout ${toString pickerTimeout} \ --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" 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}" \ 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('--show-picker', default='true')
p.add_argument('--timeout', type=int, default=2) p.add_argument('--timeout', type=int, default=2)
p.add_argument('--extra-json', default='{}') 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() a = p.parse_args()
with open(a.base, 'rb') as f: with open(a.base, 'rb') as f:
@ -84,6 +85,21 @@ def main():
cfg['Misc']['Boot']['Timeout'] = a.timeout cfg['Misc']['Boot']['Timeout'] = a.timeout
cfg['Misc']['Boot']['HideAuxiliary'] = True cfg['Misc']['Boot']['HideAuxiliary'] = True
cfg['Misc']['Security']['ScanPolicy'] = 0 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']['SecureBootModel'] = 'Disabled'
cfg['Misc']['Security']['AllowSetDefault'] = True cfg['Misc']['Security']['AllowSetDefault'] = True
cfg['Misc']['Debug']['Target'] = 0 cfg['Misc']['Debug']['Target'] = 0

View file

@ -30,6 +30,9 @@ rec {
sataDrive = { id, port, file, format ? "qcow2", extra ? "" }: 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}"; "-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: firmwareArgs = varsFile:
"-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd -drive if=pflash,format=raw,file=${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 #!/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 Observation is passive: the serial console (boot-args serial=3: the PE's
settle detection + OCR of the menu bar), types the bootstrap "VMIX-*" markers, kernel boots, panics) and screenshots over QMP (mean
command and waits for the VM to power itself off brightness + a coarse change fingerprint). No OCR, no keystrokes. A boot hang
--mode boot waits for the VM to power itself off (customize steps) (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.
Everything after `--` is the QEMU command line. Screenshots and a log are
written to --debug-dir (default /tmp/vmix-macos/<name>) for troubleshooting.
""" """
import argparse import argparse
import hashlib import hashlib
import io import io
import json import json
import os import os
import shutil
import socket import socket
import subprocess import subprocess
import sys import sys
@ -23,60 +29,66 @@ import time
try: try:
from PIL import Image from PIL import Image
except ImportError: # pragma: no cover except ImportError: # screenshots then only serve as debug files
Image = None Image = None
try:
import pytesseract
except ImportError: # pragma: no cover
pytesseract = None
PANIC_MARKS = ('panic(cpu', 'Kernel Extensions in backtrace', 'Debugger called: <panic>', 'Nested panic detected', 'panic string:')
# QEMU qcodes for characters that are not plain alphanumerics REBOOT_MARK = 'MACH Reboot'
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'}
class Log: class Log:
def __init__(self, path): def __init__(self, path):
self.f = open(path, 'a') self.f = open(path, 'a') if path else None
self.t0 = time.time() self.t0 = time.time()
def __call__(self, msg): def __call__(self, msg):
line = f'[{time.time() - self.t0:7.1f}s] {msg}' line = f'[{time.time() - self.t0:7.1f}s] {msg}'
print(f'vmix driver: {line}', flush=True) print(f'vmix driver: {line}', flush=True)
if self.f:
self.f.write(line + '\n') self.f.write(line + '\n')
self.f.flush() self.f.flush()
class QMP: class QMP:
def __init__(self, path): def __init__(self, path):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.path = path
self.sock.connect(path) self.s = None
self.f = self.sock.makefile('rwb', buffering=0) self.buf = b''
self._read()
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') self.cmd('qmp_capabilities')
return
except (OSError, ValueError):
if time.time() - t0 > timeout:
raise
time.sleep(1)
def _read(self): def _read(self):
while True: while b'\n' not in self.buf:
line = self.f.readline() d = self.s.recv(65536)
if not line: if not d:
raise EOFError('QMP connection closed') raise OSError('QMP socket closed')
msg = json.loads(line) self.buf += d
if 'event' in msg: line, self.buf = self.buf.split(b'\n', 1)
continue return json.loads(line)
return msg
def cmd(self, name, **args): 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() r = self._read()
if 'return' in r:
return r['return']
if 'error' in r: if 'error' in r:
raise RuntimeError(f'QMP {name}: {r["error"]}') raise RuntimeError(r['error'])
return r.get('return')
def screendump(self, path): def screendump(self, path):
self.cmd('screendump', filename=path) self.cmd('screendump', filename=path)
@ -87,182 +99,117 @@ class QMP:
def system_powerdown(self): def system_powerdown(self):
self.cmd('system_powerdown') 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: 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): def __init__(self, qmp, debug_dir, log):
self.qmp = qmp self.qmp = qmp
self.debug_dir = debug_dir self.dir = debug_dir
self.log = log self.log = log
import tempfile self.tmp = os.path.join(debug_dir, f'.grab-{os.getpid()}.ppm')
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.img = None self.img = None
self.last_fp = None
self.stable_since = time.time()
self.n = 0
def grab(self): def grab(self):
self.qmp.screendump(self.tmp) self.qmp.screendump(self.tmp)
with open(self.tmp, 'rb') as f: with open(self.tmp, 'rb') as f:
data = f.read() data = f.read()
self.img = Image.open(io.BytesIO(data)) if Image else None if Image is None:
# fingerprint from a coarse, quantized grayscale thumbnail so the moving fp = hashlib.sha256(data).hexdigest()
# 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()
else: else:
h = hashlib.sha256(data).hexdigest() self.img = Image.open(io.BytesIO(data))
if h != self.last_hash: small = self.img.convert('L').resize((48, 36))
self.last_hash = h 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() self.stable_since = time.time()
return self.img return self.img
def stable_for(self): def stable_for(self):
return time.time() - self.stable_since 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): def save(self, tag):
self.n += 1 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: try:
if self.img is not None: if self.img is not None:
self.img.save(path) self.img.save(path)
else: else:
os.link(self.tmp, path.replace('.png', '.ppm')) shutil.copy(self.tmp, path.replace('.png', '.ppm'))
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
self.log(f'could not save screenshot: {e}') self.log(f'could not save screenshot: {e}')
return path 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): class Serial:
if self.img is None: """Tail the serial console file QEMU writes (-serial file:...)."""
return 128
px = self.img.convert('L').resize((32, 24))
d = list(px.getdata())
return sum(d) / len(d)
def is_blank(self): def __init__(self, path):
# black/uniform screen (firmware, boot): nothing to act on self.path = path
if self.img is None: self.pos = 0
return False self.last_activity = time.time()
lo, hi = self.img.convert('L').resize((64, 48)).getextrema() self.boots = 0
return hi - lo < 24 self.reboot_at = None
def menubar_text(self): def poll(self):
w = self.img.width if self.img else 1024 if not self.path or not os.path.exists(self.path):
return self.ocr((0, 0, w, 40), scale=4, psm=7) 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): 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: try:
for d in (os.path.dirname(path), path): os.chmod(path, 0o777)
os.makedirs(d, exist_ok=True)
try:
os.chmod(d, 0o1777 if d != path else 0o777)
except OSError: except OSError:
pass pass
probe = os.path.join(path, '.probe') for f in os.listdir(path):
open(probe, 'w').close() if f.endswith(('.png', '.ppm', '.log')) or f.startswith('.grab-') or f == 'qmp.sock':
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*')):
try: try:
os.unlink(f) os.remove(os.path.join(path, f))
except OSError: except OSError:
pass 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): def launch(qemu_args, qmp_sock, log):
if os.path.exists(qmp_sock): if os.path.exists(qmp_sock):
os.unlink(qmp_sock) os.remove(qmp_sock)
args = list(qemu_args) + ['-qmp', f'unix:{qmp_sock},server,nowait'] cmd = list(qemu_args) + ['-qmp', f'unix:{qmp_sock},server,nowait']
log('launching: ' + ' '.join(args)) log('launching: ' + ' '.join(cmd))
proc = subprocess.Popen(args) return subprocess.Popen(cmd)
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')
def disk_idle(args): def disk_idle(args):
"""True if the system disk has had no writes recently (guest not doing I/O)."""
if not args.progress_file: if not args.progress_file:
return True return True
try: try:
@ -271,289 +218,150 @@ def disk_idle(args):
return True return True
def run_install(args, proc, qmp, log): def drive(args, proc, qmp, screen, serial, 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)
start = time.time() start = time.time()
typed_at = None
terminal_attempts = 0
last_periodic = 0 last_periodic = 0
last_progress = start
blind_done = False
resets = 0 resets = 0
panics = 0
started = args.mode == 'boot' # pe/install: wait for the PE marker first
blank_since = None blank_since = None
login_since = None login_since = None
recovery_start = None
last_term_action = 0
while True: while True:
rc = proc.poll() rc = proc.poll()
if rc is not None: if rc is not None:
log(f'QEMU exited with {rc}')
return rc return rc
now = time.time() now = time.time()
if now - start > args.timeout: if now - start > args.timeout:
try: try:
screen.grab(); screen.save('timeout') screen.grab()
screen.save('timeout')
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass pass
log('timeout reached, killing QEMU') log('timeout reached, killing QEMU')
proc.kill() proc.kill()
return 124 return 124
time.sleep(args.interval) 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: try:
screen.grab() screen.grab()
except Exception as e: # noqa: BLE001 screen.save('panic')
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)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass pass
return 0 if args.mode == 'pe' or panics > args.max_resets:
continue log(f'kernel panic #{panics}, giving up')
blank_since = None proc.kill()
return 3
top = screen.menubar_text() log(f'kernel panic #{panics}, system_reset')
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:
qmp.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() screen.stable_since = time.time()
continue continue
# After the install, the loginwindow/desktop is a BRIGHT gray screen, unlike # The guest asked for a reboot but no kernel came back: macOS' restart
# the dark install/boot screens (Apple logo). The vmix agent powers the VM # path panics in QEMU (AppleSMC watchdog keys, see README); reset now
# off if it runs (cron/daemon); if BTM blocks it, we power down here so the # instead of waiting for the frozen-screen watchdog.
# build still completes with a bootable, installed image. Brightness is a if serial.reboot_at and now - serial.reboot_at > args.reboot_timeout and args.mode != 'pe':
# far more reliable signal than OCR of the faint "password" text. resets += 1
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)')
try: try:
qmp.system_powerdown() screen.grab()
except Exception as e: # noqa: BLE001 screen.save('reboot-dead')
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')
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass 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() proc.kill()
return 124 return 3
time.sleep(args.interval) 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: try:
screen.grab() screen.grab()
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log(f'screendump failed ({e})') log(f'screendump failed ({e})')
time.sleep(2) time.sleep(2)
continue continue
now = time.time()
if now - last_periodic > args.periodic: if now - last_periodic > args.periodic:
last_periodic = now last_periodic = now
screen.save('periodic') 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 continue
idle = disk_idle(args) and serial.idle_for() > args.disk_idle
if screen.is_blank(): if screen.is_blank():
if blank_since is None: blank_since = blank_since or now
blank_since = now # macOS `shutdown -h` halts to a black screen without an ACPI power-off
elif now - blank_since > args.halt_timeout and disk_idle(args): if now - blank_since > args.halt_timeout and idle:
screen.save('halt') 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() proc.kill()
try:
proc.wait(timeout=10)
except Exception: # noqa: BLE001
pass
return 0 return 0
continue continue
blank_since = None blank_since = None
top = screen.menubar_text()
body = screen.ocr() if screen.mean() > args.bright:
if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY): # loginwindow / Setup Assistant: the OS is installed and booted
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 login_since is None: if login_since is None:
login_since = now 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: elif now - login_since > args.login_grace:
screen.save('loginwindow') screen.save('loginwindow')
log(f'loginwindow persisted {now - login_since:.0f}s, powering down') log('powering down (boot complete)')
try: try:
qmp.system_powerdown() qmp.system_powerdown()
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log(f'powerdown failed: {e}') log(f'powerdown failed: {e}')
for _ in range(90): for _ in range(120):
if proc.poll() is not None: if proc.poll() is not None:
log('QEMU exited after powerdown')
return 0 return 0
time.sleep(1) time.sleep(1)
log('guest ignored powerdown, killing QEMU')
proc.kill() proc.kill()
return 0 return 0
continue continue
else:
login_since = None 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 resets += 1
screen.save('stall-reset') 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: try:
qmp.system_reset() qmp.system_reset()
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
@ -562,54 +370,60 @@ def run_boot(args, proc, qmp, log):
def main(): def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p = argparse.ArgumentParser()
p.add_argument('--mode', choices=['install', 'boot'], required=True) p.add_argument('--mode', choices=['pe', 'install', 'boot'], required=True)
p.add_argument('--name', default='macos') p.add_argument('--name', default='macos')
p.add_argument('--debug-dir', default=None) 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('--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('--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('--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('--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('--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-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('--max-resets', type=int, default=6) 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('--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('--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('--halt-timeout', type=float, default=150.0, help='a pure-black, idle screen this long means the guest halted')
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('--progress-file', default=None, help='the system disk; its mtime shows guest disk activity')
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('--disk-idle', type=float, default=90.0, help='seconds without disk/serial activity that count as idle')
p.add_argument('--command', default='diskutil mount VMIX;sh /Volumes/VMIX/run.sh') 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) p.add_argument('qemu', nargs=argparse.REMAINDER)
args = p.parse_args() 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: if not qemu_args:
p.error('QEMU command line required after --') p.error('QEMU command line required after --')
args.debug_dir = prepare_debug_dir(args.debug_dir or f'/tmp/vmix-macos/{args.name}') debug_dir = args.debug_dir or f'/tmp/vmix-macos/{args.name}'
log = Log(os.path.join(args.debug_dir, 'driver.log')) prepare_debug_dir(debug_dir)
log(f'mode={args.mode} debug-dir={args.debug_dir} ocr={"yes" if pytesseract else "no"}') log = Log(os.path.join(debug_dir, 'driver.log'))
qmp_sock = os.path.join(args.debug_dir, 'qmp.sock') log(f'mode={args.mode} debug-dir={debug_dir} serial={args.serial_log}')
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
qmp_sock = os.path.join(debug_dir, 'qmp.sock')
proc = launch(qemu_args, qmp_sock, log)
qmp = QMP(qmp_sock)
try: try:
if args.mode == 'install': qmp.connect()
rc = run_install(args, proc, qmp, log) except Exception as e: # noqa: BLE001
else: log(f'QMP connect failed: {e}')
rc = run_boot(args, proc, qmp, log) 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: 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: if proc.poll() is None:
proc.kill() proc.kill()
log(f'QEMU exited with {rc}') except Exception: # noqa: BLE001
pass
log(f'done rc={rc}')
return 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 # Shell snippet: read the PE's result off the VMIX HFS+ volume of a raw disk
# disk image (${image}). Prints the guest logs and sets STATUS to the contents # image (${image}). Prints the guest logs and sets STATUS to the contents of
# of vmix-run.status ("0" on success). Uses libguestfs (mtools is FAT-only). The # vmix-run.status ("0" on success, empty if run.sh never returned, e.g. the
# agent unmounts VMIX cleanly before shutdown, so a read-only mount is safe. # installer rebooted). The PE unmounts VMIX before powering off.
{ ... }: { ... }:
image: image:
'' ''
echo "=== vmix: reading result from ${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) 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 done
STATUS=$(guestfish --ro -a ${image} -m /dev/sda1 cat /vmix-run.status 2>/dev/null | tr -d '[:space:]' || true) 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 # 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, ... }: { pkgs, lib, system, macos, installer, recovery, ... }:
with macos; with macos;
rec { rec {
pe = makeRecoveryPE { name = "macos-tahoe"; inherit recovery; };
upstream = makeImage { upstream = makeImage {
name = "macos-tahoe"; name = "macos-tahoe";
inherit installer recovery; inherit installer pe;
}; };
basic = customizeImageFold upstream templates.bundles.basic; basic = customizeImageFold upstream templates.bundles.basic;

View file

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

View file

@ -4,12 +4,10 @@
{ {
name = "no-updates"; name = "no-updates";
script = '' script = ''
softwareupdate --schedule off || true SU="$DATA/Library/Preferences/com.apple.SoftwareUpdate.plist"
defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool false for k in AutomaticCheckEnabled AutomaticDownload AutomaticallyInstallMacOSUpdates ConfigDataInstall CriticalUpdateInstall; do
defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -bool false pe_plist_set "$SU" "$k" bool false
defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticallyInstallMacOSUpdates -bool false done
defaults write /Library/Preferences/com.apple.SoftwareUpdate ConfigDataInstall -bool false pe_plist_set "$DATA/Library/Preferences/com.apple.commerce.plist" AutoUpdate bool false
defaults write /Library/Preferences/com.apple.SoftwareUpdate CriticalUpdateInstall -bool false
defaults write /Library/Preferences/com.apple.commerce 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"; name = "performance";
files = [ { source = power; name = "com.apple.PowerManagement.plist"; } ];
script = '' script = ''
mdutil -a -i off || true touch "$DATA/.metadata_never_index"
tmutil disable || true pe_plist_set "$DATA/Library/Preferences/com.apple.TimeMachine.plist" AutoBackup bool false
pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 womp 0 || true pe_plist_set "$DATA/Library/Preferences/com.apple.loginwindow.plist" DisableScreenLockImmediate bool true
defaults write /Library/Preferences/com.apple.loginwindow 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) # 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"; name = "remote-access";
script = '' script = ''
systemsetup -setremotelogin on >/dev/null 2>&1 || launchctl load -w /System/Library/LaunchDaemons/ssh.plist pe_service_disabled com.apple.openssh.sshd false
launchctl load -w /System/Library/LaunchDaemons/com.apple.screensharing.plist pe_service_disabled com.apple.screensharing false
# 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
''; '';
} }

View file

@ -1,7 +1,8 @@
# Generalize a macOS image: create the user, auto-login, hostname, timezone, # Generalize a macOS image, offline from the PE: create the (admin) user on the
# suppress Setup Assistant prompts, then remove the vmix agent. Also gives the # image's Data volume with dscl, auto-login, suppress the first-login Setup
# image a fresh SMBIOS identity (serial/MLB from macserial, MAC + UUID from # Assistant, hostname, locale, timezone, use the whole disk, and give the image
# `seed`) so every generalized VM looks like a distinct Mac to Apple ID/iMessage. # 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"; }) # Usage: (templates.generalize { username = "User"; password = ""; hostname = "MAC"; })
# delayOobeRun = true: no user, Setup Assistant runs on first real boot (like Windows OOBE) # delayOobeRun = true: no user, Setup Assistant runs on first real boot (like Windows OOBE)
{ pkgs, lib, ... }: { pkgs, lib, ... }:
@ -34,7 +35,8 @@ let
"DidSeeCloudSetup" "DidSeeSiriSetup" "DidSeePrivacy" "DidSeeTouchIDSetup" "DidSeeAppearanceSetup" "DidSeeCloudSetup" "DidSeeSiriSetup" "DidSeePrivacy" "DidSeeTouchIDSetup" "DidSeeAppearanceSetup"
"DidSeeScreenTime" "DidSeeAccessibility" "DidSeeTrueTonePrivacy" "DidSeeActivationLock" "DidSeeScreenTime" "DidSeeAccessibility" "DidSeeTrueTonePrivacy" "DidSeeActivationLock"
"DidSeeiCloudLoginForStorageServices" "DidSeeSyncSetup" "DidSeeSyncSetup2" "DidSeeAppleIDSyncSetup" "DidSeeiCloudLoginForStorageServices" "DidSeeSyncSetup" "DidSeeSyncSetup2" "DidSeeAppleIDSyncSetup"
"DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "SkipFirstLoginOptimization" "DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "DidSeeUpdateMacAutomatically"
"DidSeeSoftwareUpdate" "SkipFirstLoginOptimization"
]; ];
in in
{ {
@ -44,60 +46,81 @@ in
script = '' script = ''
set -x set -x
${lib.optionalString (bgColor != null) ''echo "vmix: bgColor is not supported on macOS, ignoring"''} ${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) '' ${lib.optionalString (!delayOobeRun) ''
# --- user account (admin) # --- user account (admin), created directly in the local directory node
if ! id "${username}" >/dev/null 2>&1; then U="${username}"; HOME_DIR="$DATA/Users/$U"
sysadminctl -addUser "${username}" -fullName ${lib.escapeShellArg fullName} \ if ! D -read "/Local/Default/Users/$U" >/dev/null 2>&1; then
-password ${lib.escapeShellArg (if password == "" then tempPassword else password)} \ UID_NEW=$(D -list /Local/Default/Users UniqueID | awk '$2 >= 501 && $2 < 1000 && $2 > m {m = $2} END {print (m ? m + 1 : 501)}')
-admin -home "/Users/${username}" || exit 1 D -create "/Local/Default/Users/$U" || pe_fail "dscl create user"
${lib.optionalString (password == "") '' D -create "/Local/Default/Users/$U" UserShell /bin/zsh
dscl . -passwd "/Users/${username}" "${tempPassword}" "" || echo "vmix: WARNING: could not set an empty password, password is '${tempPassword}'" 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 fi
${lib.optionalString autoLogon '' ${lib.optionalString autoLogon ''
defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser "${username}" pe_plist_set "$DATA/Library/Preferences/com.apple.loginwindow.plist" autoLoginUser string "$U"
cp /Volumes/VMIX/kcpassword /etc/kcpassword cp "$V/kcpassword" "$DATA/private/etc/kcpassword"
chmod 600 /etc/kcpassword chmod 600 "$DATA/private/etc/kcpassword"; chown 0:0 "$DATA/private/etc/kcpassword"
chown root:wheel /etc/kcpassword
''} ''}
# --- no Setup Assistant / "What's new" prompts at first login # --- no Setup Assistant / "What's new" prompts at first login
P="/Users/${username}/Library/Preferences/com.apple.SetupAssistant" mkdir -p "$HOME_DIR/Library/Preferences"
VER=$(sw_vers -productVersion) P="$HOME_DIR/Library/Preferences/com.apple.SetupAssistant.plist"
BUILD=$(sw_vers -buildVersion) for k in ${lib.concatStringsSep " " setupKeys}; do pe_plist_set "$P" "$k" bool true; done
for k in ${lib.concatStringsSep " " setupKeys}; do pe_plist_set "$P" GestureMovieSeen string none
defaults write "$P" "$k" -bool true pe_plist_set "$P" LastSeenCloudProductVersion string "$VER"
done pe_plist_set "$P" LastSeenBuddyBuildVersion string "$BUILD"
defaults write "$P" GestureMovieSeen none pe_plist_set "$P" LastSeenSiriProductVersion string "$VER"
defaults write "$P" LastSeenCloudProductVersion "$VER" pe_plist_set "$P" LastPreLoginTasksPerformedVersion string "$VER"
defaults write "$P" LastSeenBuddyBuildVersion "$BUILD" pe_plist_set "$P" LastPreLoginTasksPerformedBuild string "$BUILD"
defaults write "$P" LastSeenSiriProductVersion "$VER" pe_plist_set "$HOME_DIR/Library/Preferences/.GlobalPreferences.plist" AppleLocale string "${macLocale}"
defaults write "$P" LastPreLoginTasksPerformedVersion "$VER" chown -R "$UID_NEW:20" "$HOME_DIR"
defaults write "/Users/${username}/Library/Preferences/.GlobalPreferences" AppleLocale "${macLocale}" touch "$DATA/private/var/db/.AppleSetupDone"
chown -R "${username}" "/Users/${username}/Library/Preferences" ''}
${lib.optionalString delayOobeRun ''
rm -f "$DATA/private/var/db/.AppleSetupDone"
''} ''}
# --- machine identity # --- machine identity
scutil --set ComputerName "${hostname}" PF="$DATA/Library/Preferences/SystemConfiguration/preferences.plist"
scutil --set HostName "${hostname}" mkdir -p "$(dirname "$PF")"
scutil --set LocalHostName "${hostname}" pe_plist_dict "$PF" System
defaults write /Library/Preferences/.GlobalPreferences AppleLocale "${macLocale}" pe_plist_dict "$PF" System.System
systemsetup -settimezone "${timezone}" >/dev/null 2>&1 || ln -sfn "/usr/share/zoneinfo/${timezone}" /etc/localtime 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) # --- QEMU's USB keyboard (vendor 0x0627, product 0x0001) is unknown to macOS,
pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 || true # 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 # --- 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 [ -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"