Compare commits

..

No commits in common. "3f797af9159b4cbee7a1fc0c9448449c3935c225" and "58a317f5d2cb74dba0996ca69e9cac6d1413810d" have entirely different histories.

31 changed files with 987 additions and 1519 deletions

49
cli.nix
View file

@ -33,11 +33,7 @@ pkgs.writeShellScriptBin "vmix" ''
echo " --to-remote-disk SSH:DEV Stream to remote disk via SSH and expand partitions"
echo " e.g. root@10.10.10.100:/dev/sda"
echo " --ahci Use AHCI storage for vmix run (for laptop images)"
echo " --macos macOS image for vmix run (OpenCore/VirtualSMC flags, AHCI)"
echo " --applesmc with --macos: add QEMU's isa-applesmc (images built before 2026-09-09)"
echo " --share DIR with --macos: virtio-fs share (first: /Volumes/My Shared Files); repeatable"
echo " --home FILE with --macos: persistent home volume (qcow2, created+formatted if missing)"
echo " --qga PATH with --macos: guest agent socket path (default /tmp/vmix-qga-<pid>.sock)"
echo " --macos macOS image for vmix run (OpenCore/AppleSMC flags, AHCI)"
echo " --vnc DISPLAY VNC instead of SDL for vmix run, e.g. :10 (port 5910) or 0.0.0.0:10"
echo " --mac ADDR NIC MAC for vmix run (macOS: read from the image's ESP by default)"
echo " -y, --yes Skip disk write confirmation"
@ -98,21 +94,12 @@ pkgs.writeShellScriptBin "vmix" ''
RUN_MACOS=false
RUN_VNC=""
RUN_MAC=""
RUN_SHARES=()
RUN_HOME=""
RUN_HOME_IMAGE="macos.images.tahoe.upstream"
RUN_QGA=""
while [[ ''${#} -gt 0 ]]; do
case "$1" in
--mem) RUN_MEM="$2"; shift 2 ;;
--smp) RUN_SMP="$2"; shift 2 ;;
--ahci) RUN_AHCI=true; shift ;;
--macos) RUN_MACOS=true; shift ;;
--applesmc) RUN_APPLESMC=true; shift ;;
--share) RUN_SHARES+=("$2"); shift 2 ;;
--home) RUN_HOME="$2"; shift 2 ;;
--home-image) RUN_HOME_IMAGE="$2"; shift 2 ;;
--qga) RUN_QGA="$2"; shift 2 ;;
--vnc) RUN_VNC="$2"; shift 2 ;;
--mac) RUN_MAC="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
@ -147,44 +134,10 @@ pkgs.writeShellScriptBin "vmix" ''
[[ -z "$RUN_MAC" || "$RUN_MAC" == "null" ]] && { RUN_MAC="52:54:00:c9:18:27"; echo "Warning: could not read MAC from image ESP, using $RUN_MAC"; }
fi
echo "macOS: yes (MAC $RUN_MAC)"
# Apple's built-in QEMU guest agent: guest-exec as root over this socket
[[ -z "$RUN_QGA" ]] && RUN_QGA="/tmp/vmix-qga-$$.sock"
rm -f "$RUN_QGA"
echo "Agent: $RUN_QGA (guest-exec as root)"
# virtio-fs shares: the first one auto-mounts at /Volumes/My Shared Files, the
# others are mounted with: mount -t virtiofs <tag> /Volumes/<tag>
MACOS_SHARE_ARGS=""
MACOS_MEM_ARGS=""
i=0
for SHARE in "''${RUN_SHARES[@]}"; do
i=$((i + 1)); SOCK="/tmp/vmix-vfs-$$-$i.sock"; rm -f "$SOCK"
TAG=$([[ $i -eq 1 ]] && echo "${macosQemu.automountTag}" || echo "share$i")
${pkgs.virtiofsd}/bin/virtiofsd --socket-path="$SOCK" --shared-dir "$SHARE" --cache auto --sandbox none >/dev/null 2>&1 &
for t in $(seq 1 50); do [[ -S "$SOCK" ]] && break; sleep 0.2; done
MACOS_SHARE_ARGS="$MACOS_SHARE_ARGS -chardev socket,id=vfs$i,path=$SOCK -device vhost-user-fs-pci,chardev=vfs$i,tag=$TAG"
MACOS_MEM_ARGS="-object memory-backend-memfd,id=vmix-mem,size=''${RUN_MEM}M,share=on -numa node,memdev=vmix-mem"
echo "Share: $SHARE -> $([[ $i -eq 1 ]] && echo '/Volumes/My Shared Files' || echo "mount -t virtiofs $TAG ...")"
done
# persistent home volume (virtio-blk); created + formatted APFS by the PE if missing
MACOS_HOME_ARGS=""
if [[ -n "$RUN_HOME" ]]; then
if [[ ! -e "$RUN_HOME" ]]; then
echo "Home: creating $RUN_HOME (64G qcow2) and formatting it as APFS 'vmix-home' via the PE of $RUN_HOME_IMAGE ..."
${pkgs.qemu}/bin/qemu-img create -q -f qcow2 "$RUN_HOME" 64G
FMT=$(${pkgs.nix}/bin/nix build --no-link --print-out-paths --impure --expr "let l = (builtins.getFlake \"${self}\").lib.${system}; in l.macos.formatVolume { image = l.$RUN_HOME_IMAGE; }") || { echo "Error: could not build the formatter"; exit 1; }
"$FMT" "$RUN_HOME" qcow2 || exit 1
fi
HOME_FMT=$(${pkgs.qemu}/bin/qemu-img info --output=json "$RUN_HOME" | ${pkgs.jq}/bin/jq -r .format)
MACOS_HOME_ARGS="-drive id=home,if=none,format=$HOME_FMT,file=$RUN_HOME -device virtio-blk-pci,drive=home"
echo "Home: $RUN_HOME (mounted at /Users by images generalized with persistHome)"
fi
echo ""
exec ${pkgs.qemu}/bin/qemu-system-x86_64 \
$MACOS_MEM_ARGS $MACOS_SHARE_ARGS $MACOS_HOME_ARGS \
-device virtio-serial-pci,id=vmix-vser -chardev socket,path="$RUN_QGA",server=on,wait=off,id=vmix-qga -device virtserialport,chardev=vmix-qga,name=org.qemu.guest_agent.0 \
$VMIX_DISPLAY \
${macosQemu.deviceArgs} ${macosQemu.vgaArgs} \
$([[ "$RUN_APPLESMC" == true ]] && echo '-device isa-applesmc,osk="${macosQemu.osk}"') \
-accel kvm \
-machine type=q35 \
-cpu ${macosQemu.defaultCpu} \

View file

@ -1,177 +1,138 @@
# macOS images (Tahoe 26)
# vmix macOS images
Pre-installed, Apple-ID-capable macOS VM images built the same way as the
Windows ones: `makeImage` (unattended install) → templates → `.generalize`
(user, hostname, fresh SMBIOS identity). Runs on QEMU/KVM with OpenCore.
Unattended macOS (Tahoe / 26) VM images, built the same way as the Windows
images: `makeImage` installs the OS once, templates customize it by booting it,
`.generalize` creates the user and seals the image.
```
vmix build --image macos.images.tahoe.basic --generalize username=sagar,password=secret,hostname=MAC
vmix run ./result --macos --vnc :10 --mem 8192
vmix build --image macos.images.tahoe.basic \
--generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich
vmix run ./result --macos --vnc :10 --mem 8192 # VNC on port 5910
```
Nix: `macos.images.tahoe.{pe,upstream,basic,remote}` and
`<image>.generalize { username; password; hostname; timezone; locale; seed; … }`.
## How it works
## How it works: the vmix "PE"
| step | what happens |
|---|---|
| `fetchRecovery` | BaseSystem.dmg from Apple's recovery servers (fixed-output, pinned by sha256) |
| `installerPayload` | takes the App Store `InstallAssistant.pkg` (18 GB, pinned) apart on Linux: the app skeleton (pbzx/cpio) and the byte offset of `SharedSupport.dmg` |
| `makeOpenCore` | OSX-KVM's OpenCore ESP with a config.plist rewritten for this image: SMBIOS model, serial + MLB (`macserial`), UUID and ROM = NIC MAC (derived from a seed), NIC marked built-in |
| `makeImage` | one QEMU session: Recovery boots via OpenCore → `vm-driver.py` opens Terminal with keystrokes (Ctrl-F2 menu navigation, screen-settle detection + OCR of the menu bar) and types `sh /Volumes/VMIX/run.sh``vmix-install.sh` erases the disk, rebuilds `Install macOS Tahoe.app` (skeleton + `SharedSupport.dmg` copied from a raw disk mapped straight out of the pkg), runs `startosinstall --installpackage vmix-agent.pkg` → installer reboots through its phases → first boot runs the **vmix agent** which powers off. OpenCore is then copied into the image's EFI partition, so it boots standalone with OVMF |
| `customizeImage` | boots the image with a FAT volume `VMIX`; the agent (LaunchDaemon `ch.vmix.agent`) runs `vmix-run.sh` as root, writes `vmix-run.status`/`.log` back and shuts down |
| `templates.generalize` | user (admin) + auto-login (`/etc/kcpassword`), Setup Assistant suppressed, hostname, timezone, no sleep, APFS grown to the disk, then the agent removes itself; a fresh SMBIOS identity is written to the ESP |
Apple's Recovery (`BaseSystem.dmg`, a plain journaled HFS+ volume) with **one
LaunchDaemon added** (`makeRecoveryPE`): at boot it mounts a `VMIX` volume and
runs `run.sh` from it as root, records the exit status and powers off. That is
the whole automation surface — the equivalent of Windows PE + Autounattend:
The vmix agent replaces Windows' Audit Mode RunOnce; `.AppleSetupDone` replaces
the OOBE unattend. Everything on the host side runs inside `__noChroot`
derivations (KVM + `/tmp`), exactly like the Windows builders.
* **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.
## Generalize options
### Pipeline
`username password fullName autoLogon hostname locale timezone delayOobeRun`
as for Windows (`bgColor` is accepted but ignored), plus the SMBIOS identity:
`model serial mlb uuid mac seed`. Anything unset is generated: serial/MLB by
macserial (random per build), MAC and UUID deterministically from `seed`
(default `hostname-username`). `vmix macserial --model MacPro7,1` prints a
ready-to-paste set.
1. `makeRecoveryPE` — BaseSystem.dmg → raw HFS+ image + `ch.vmix.pe` daemon.
2. `makeImage` — QEMU with: OpenCore boot disk (build variant with serial
console), the PE, the empty target disk, the VMIX volume (`vmix-install.sh`,
installer app skeleton) and the whole `InstallAssistant.pkg` mapped as a raw
disk. The guest script erases the target as APFS, unpacks the app and `dd`s
the pkg into it as `SharedSupport.dmg` (it is a "pkgdmg": xar + koly footer;
the bare xar member fails with "pkgdmg is missing a footer"), then runs
`startosinstall`, which reboots itself through the install phases. The
installed system's first boot ends at the loginwindow: the driver detects the
bright screen and powers the VM down. OpenCore is then copied into the image's
own ESP so it boots with plain OVMF.
3. `customizeImage` — boots the PE with the image attached (OpenCore
`ScanPolicy` restricted to HFS+ on SATA, so only the PE can boot) and runs the
template script with `$SYS`/`$DATA` mounted. `pe-lib.sh` has the helpers.
4. `templates/generalize.nix` — user (dscl, admin, home from the user template),
auto-login (`kcpassword`), Setup Assistant suppression, hostname, locale,
timezone, keyboard type, container resize, fresh SMBIOS via a new OpenCore
ESP (`serial`/`mlb` from macserial, MAC + UUID from `seed`).
`delay-oobe-run=true` creates no user and re-arms Setup Assistant for the first
real boot.
## Apple ID / iMessage
The image satisfies what Dortania lists for iServices: unique serial + MLB for a
Tahoe-supported model (`MacPro7,1` by default; `iMac20,1/2`,
`MacBookPro16,x` also work), SystemUUID, ROM equal to en0's MAC, and en0 marked
built-in (the NIC is pinned to `PciRoot(0x0)/Pci(0x12,0x0)`). The NixOS module
and `vmix run --macos` use the MAC recorded in the image (`EFI/vmix/vmix.json`).
Give each deployed VM its own generalized image (different `seed`, or explicit
`serial=`/`mlb=`) — two VMs with the same identity will be blocked.
## Runtime
* `vmix run <qcow2> --macos [--vnc :N] [--mac ..]`
* NixOS module: `disks.os.file = vmixLib.macos.images.tahoe.basic.generalize {...}`
is auto-detected (`_vmixOsType = "macos"`): Skylake-Client CPU spoof, AppleSMC,
USB keyboard/tablet, AHCI system disk, VMware SVGA, pinned NIC with the image's MAC.
`macos.cpu`, `macos.mac`, `macos.enable` override the defaults.
* `vmix copy` writes the image to a disk but cannot grow APFS from Linux
(`diskutil apfs resizeContainer disk0s2 0` in macOS afterwards).
## Debugging a build
Screenshots (`NNN-<state>.png`), `driver.log` and the QMP socket of every VM
session are in `/tmp/vmix-macos/<image name>/` on the build host. The guest logs
(`install.log`, `vmix-run.log`, `vmix-agent.log`) are printed at the end of the
build. Pass `vncDisplay = ":10"` to `makeImage`/`customizeImage` (or
`--generalize vncDisplay=:10`) to watch live; with a `DISPLAY` an SDL window
is used as for Windows.
## Updating pins (`upstream.json`)
* installer: URL + SRI hash of a newer `InstallAssistant.pkg`
(`nix store prefetch-file --name InstallAssistant.pkg <url>`; Mr. Macintosh's
database lists Apple's URLs)
* recovery: Apple serves the current build for the board id, so the sha256
changes with each point release — copy the "got:" hash from the failed build
* opencore: OSX-KVM `OpenCore.qcow2` at a commit; OpenCorePkg release zip (macserial/ocvalidate)
## Known limits
* The Recovery bootstrap depends on keyboard navigation of the Recovery UI
(Ctrl-F2 → Utilities → Terminal). It self-corrects with screenshots + OCR and
falls back to a blind sequence, but a Recovery UI change would need
`vm-driver.py` adjusted.
* Hosts must run KVM with an AVX2-capable CPU (Intel or AMD; the guest sees a
Skylake). `sandbox = relaxed` and the `kvm` system feature, as for Windows.
* Software updates inside the VM are disabled by the `noUpdates` template
(OTA updates in a VM need the RestrictEvents kext).
## Current status (2026-09-09): working offline install
`macos.images.tahoe.upstream` builds a bootable, installed macOS Tahoe 26.6.2
qcow2 **fully offline** on the KVM host — no dependency on Apple's servers at build
time, just the pinned local `InstallAssistant.pkg` and `BaseSystem.dmg`. The
finished image boots standalone (OpenCore from its own ESP) to the macOS
loginwindow. Serial/MLB/UUID/ROM are per-image for Apple ID / iMessage.
How the install is driven (`vm-driver.py`, all by screenshot + OCR over QMP):
* The whole `InstallAssistant.pkg` is mapped as a raw disk (it is a "pkgdmg":
xar + koly footer) and `dd`'d byte-exact into the app as `SharedSupport.dmg`
extracting the bare xar member fails startosinstall with "pkgdmg missing a footer".
* No NIC during install + `/etc/hosts` blackhole of Apple's install/verify
endpoints, so `startosinstall`'s network calls fail fast instead of hanging —
offline prepare, no external dependency. `SecureBootModel=Disabled` lets the
sealed volume install without online personalization.
* The recovery display is kept awake with a tiny mouse jiggle (a lone keypress
does not reset display sleep, and the sleeping display swallows the menu-nav
keystrokes); the settle detector uses a coarse fingerprint so the jiggling
cursor is not seen as a screen change.
* startosinstall prepare is intermittently slow/stalls; a guest watchdog kills and
re-erases/retries an attempt that stalls or runs > 9 min.
* First boot in QEMU intermittently hangs at the Apple logo; a disk-aware watchdog
(`--progress-file`) issues a QMP `system_reset` only when the screen is dark AND
the disk is idle, so a slow-but-working boot is never interrupted.
* The install reaching the (bright) loginwindow is detected by brightness (the
faint gray "password" text does not OCR) and the driver powers the VM down —
the image is installed. macOS `shutdown -h now` halts to black without an ACPI
power-off, so a black+disk-idle screen is also treated as a completed halt.
* OpenCore is then copied into the image's own ESP so it boots standalone with OVMF.
### Recovery source
`recovery.file` in `upstream.json` points at a content-addressed store path for
the verified Tahoe `BaseSystem.dmg` (Apple's CDN load-balances Sequoia/Tahoe
during the rollout, so a plain fetch is non-deterministic). Reproduce it on any
host with `nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg`.
Drop `recovery.file` to fetch from Apple instead (`fetchRecovery` retries until
the pinned hash matches).
`recovery.file` in `upstream.json` points at a content-addressed store path for the
verified Tahoe `BaseSystem.dmg` (Apple's CDN load-balances Sequoia/Tahoe during the
rollout, so a plain fetch is non-deterministic). Reproduce it on any host with
`nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg` (same path
from the same bytes). Set `recovery.sha256` and remove `recovery.file` to fetch it
from Apple instead (subject to the CDN rollout).
## Reliability
### Not yet done: generalize / user creation
Things QEMU does intermittently, and what handles each (all in `vm-driver.py`
and `vmix-install.sh`; every event is logged with a reason):
* `startosinstall` prepare stalls or crawls — the guest kills and retries it on a
freshly erased target (free-space watchdog + time cap).
* the installer comes back to the PE instead of the install phase — the PE
counts boots and simply re-runs the install (max 3).
* the installed system hangs at the Apple logo on first boot — a `system_reset`
is issued only when the screen is dark and frozen **and** disk and serial
console are idle, so a slow-but-working boot is never interrupted.
* macOS `shutdown -h` halts to a black screen without an ACPI power-off — an
idle black screen counts as a completed halt.
* a kernel panic (seen on the serial console) resets the VM.
* a wedged run fails at the 4 h timeout instead of hanging.
`tools/soak.sh <flake> macos.images.tahoe.upstream 3` rebuilds an image N
times and tabulates outcome, duration, boots, resets, panics and retries.
Measured 2026-09-09 on the build host (Ryzen 7 7840HS, ZFS), Tahoe 26.6.2,
VirtualSMC-only, PE install — 3 of 3 builds completed:
| run | minutes | kernel boots | prepare tries | panics (self-recovered) | reboot deaths |
|-----|---------|--------------|---------------|-------------------------|---------------|
| 1 | 30 | 8 | 1 | 2 | 0 |
| 2 | 26 | 7 | 1 | 1 | 0 |
| 3 | 26 | 7 | 1 | 1 | 0 |
What still happens: at roughly one in ten guest-initiated reboots the guest
either panics (GPF in launchd/kernel_task context shortly after `MACH Reboot`
or within the first 15 s of the next boot — tmpfs/APFS/zone corruption
signatures, i.e. memory or register state, not one driver) or never comes back
(dead after `IOPlatformHaltRestartAction`). XNU reboots itself after a panic;
the driver resets a dead guest after 60 s, so builds complete. A device bisect
(`tools`-style 1030 PE reboots per variant: VMware SVGA vs std VGA, no HDA,
EHCI input, 1 vCPU) showed the rate is independent of the emulated devices and
of SMP; Haswell-noTSX does not boot Tahoe. Host: AMD Zen 4, kvm_amd, Intel
Skylake-Client vCPU model — the FPU-context-switch panic points at XSAVE state
handling on that combination. Not fixed; a `vmix run` VM that hangs on Restart
must be reset from the host.
## Debugging
`/tmp/vmix-macos/<name>/` on the build host: `driver.log`, `serial.log`
(kernel + `VMIX-*` markers), periodic PNG screenshots, `qmp.sock`.
`vmix-run.log` / `system-install.log` from the VMIX volume are printed at the
end of the build. Add `vncDisplay = ":10"` to watch.
## QEMU profile
`helpers/qemu.nix`: q35, `Skylake-Client` CPU spoof (works on AMD),
AppleSMC with the OSK, XHCI keyboard/tablet, AHCI disks, VMware SVGA,
virtio-net pinned to `PciRoot(0x0)/Pci(0x12,0x0)` so OpenCore marks it built-in
(en0, required for Apple ID / iMessage). SMBIOS `MacPro7,1` with four DIMMs
described (avoids the "Memory Modules Misconfigured" warning).
OpenCore comes from OSX-KVM's proven ESP, with Lilu / VirtualSMC /
WhateverGreen replaced by current releases (`upstream.json``opencore.kexts`):
the versions OSX-KVM ships disable themselves on macOS 26, and without
VirtualSMC the guest's restart path panics on QEMU's SMC stub
(`SMCWDT smcWriteKey kSMCBadCommand`, nested panic after `MACH Reboot`).
For the same reason QEMU's `isa-applesmc` is not used any more: its presence
makes VirtualSMC step aside ("multiple devices present"); VirtualSMC carries
the OSK itself. Images built before this change still need the stub:
`vmix run --macos --applesmc`. RestrictEvents (`revpatch=memtab`) silences
MacPro7,1's "Memory Modules Misconfigured" at login.
## Guest agent, shares, persistent home, online templates
macOS 13+ ships **Apple's own QEMU guest agent** (`/usr/libexec/AppleQEMUGuestAgent`,
started by launchd when a virtio console port named `org.qemu.guest_agent.0`
appears). It is Apple-signed, needs no approval, and offers `guest-exec` as
root plus `guest-file-*`. vmix uses it everywhere an in-guest agent is needed:
* `vmix run --macos` and the NixOS module attach it by default
(`/tmp/vmix-qga-<pid>.sock`, `/run/vmix/qga-<name>.sock`); talk to it with any
QGA client, e.g. `printf '{"execute":"guest-exec","arguments":{"path":"/usr/bin/id","capture-output":true}}\n' | socat - UNIX-CONNECT:<sock>`.
* **online templates** (`bootScript`): `customizeImage` boots the image with the
agent, runs the script as root (network available, `as_user <cmd>` runs inside
the logged-in user's session), then shuts down through the agent.
`templates.software.script { name; script; }`,
`templates.software.homebrew { formulae; casks; }`,
`templates.profile.settings { hideWidgets; wallpaper; dockApps; dockAutohide;
darkMode; showHiddenFiles; }` (wallpaper via the pinned `desktoppr`; Apple
Events / `osascript` do not work headless — TCC automation consent).
* **offline software templates** run in the PE: `templates.software.pkg { name;
src; }` (`installer -target`), `templates.software.app { name; src; }`.
`AppleVirtIO.kext` (x86 Tahoe) drives virtio-fs, 9p, block, console, input,
net, sound, balloon, vsock — QEMU's modern virtio-pci devices work as-is:
* **shared folders**: virtio-fs (`virtiofsd` + `vhost-user-fs-pci`, shared
memory backend). The tag `com.apple.virtio-fs.automount` is mounted by macOS
itself at `/Volumes/My Shared Files`; further tags are mounted with
`mount -t virtiofs <tag> <dir>` — the module does that through the guest agent
for every `shares.<name>` beyond the first. `vmix run --macos --share DIR`.
(9p does not automount on macOS; the Linux `-virtfs` path is not used.)
* **ephemeral OS disk + persistent home**: `generalize { persistHome = true; }`
gives the account its home directory on an APFS volume labelled `vmix-home`
(`NFSHomeDirectory = /Volumes/vmix-home/<user>`; macOS refuses mounts over
`/Users`, which is a firmlink). The host provides a virtio-blk disk
(`macos.homeDisk` in the module, `--home FILE` in the CLI: qcow2/raw file or
zvol) that `formatVolume` formats as APFS `vmix-home` by booting the PE for
~35 s on first use; diskarbitrationd mounts it before login and loginwindow
creates the home directory there on first login. The OS disk can then run
with `snapshot=on` (`disks.os.persist = false`).
* **SPICE**: `-vga vmware` (or `std`) is kept as the display device — macOS has
no QXL/virtio-gpu driver; USB redirection channels work as for other guests
(`spice.usbRedir`); there is no vdagent for macOS (no clipboard sharing).
virtio keyboard/tablet (`AppleVirtIOInput`) are available as
`qemu.virtioInputArgs` but the USB HID pair is the default.
The base image installs and boots to loginwindow. `.generalize` (user creation,
auto-login, hostname) relies on the vmix agent LaunchDaemon running on first boot,
but macOS Ventura+ Background Task Management does not auto-run a headless
third-party daemon, and neither the pkg `launchctl bootstrap` (installer domain
only) nor a cron `@reboot` reliably triggered it. The robust next step is to inject
the user record + settings offline from the agent pkg's postinstall (which runs as
root on the target during install), instead of a first-boot daemon.

View file

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

View file

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

View file

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

View file

@ -1,51 +0,0 @@
# 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; }

View file

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

View file

@ -1,24 +1,16 @@
# Customize a macOS image offline from the vmix PE: the recovery boots with the
# image and a VMIX volume attached, its hook runs `script` as root with the
# image's System (read-only) and Data (rw) volumes mounted at $SYS / $DATA, then
# powers off. The installed macOS itself is never booted, so nothing depends on
# launchd/BTM approval inside the guest. Counterpart of the Windows
# registry/audit flow. Optionally re-installs OpenCore with a new SMBIOS
# identity (`smbios`).
# Customize a macOS image by booting it with a VMIX volume: the vmix agent
# (LaunchDaemon installed by makeImage) runs `script` as root, records the exit
# status on the volume and powers off. Optionally re-installs OpenCore with a new
# SMBIOS identity (`smbios`). Counterpart of the Windows auditScript flow.
#
# Templates provide:
# script — sh script run as root in the PE (pe-lib.sh helpers available)
# bootScript — sh script run as root on the BOOTED image through Apple's QEMU
# guest agent (network, user session available; run after `script`)
# script — sh script run as root on the booted system
# 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
# network — attach a user-mode NIC for bootScript (default true)
{ pkgs, lib, qemu, ident, makeVmixVolume, makeOpenCore, makeBootDisk, installBootloader, vmixReadback, vmDriver, ... }:
{ pkgs, lib, qemu, ident, makeVmixVolume, makeOpenCore, installBootloader, vmixReadback, vmDriver, ... }:
originalImage: {
name ? "",
script ? "",
bootScript ? "",
network ? true,
files ? [],
smbios ? null,
diskSize ? "",
@ -27,18 +19,14 @@ originalImage: {
smp ? 4,
memSize ? 4096,
cpu ? qemu.defaultCpu,
timeout ? 1800,
machineArgs ? null, # override qemu.machineArgs (device experiments)
timeout ? 3600,
}:
let
originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name);
customImageName = (if name != "" then name else "custom") + "-${originalImageName}-vmix.qcow2";
resultImg = "./disk.qcow2";
hasScript = script != "";
hasBootScript = bootScript != "";
hasSmbios = smbios != null;
pe = originalImage.pe or (throw "vmix: image ${originalImage.name} carries no PE (built by an older makeImage?)");
volumeName = originalImage.volumeName or "Macintosh HD";
model = originalImage.model or "MacPro7,1";
seed = if hasSmbios && (smbios.seed or null) != null then smbios.seed else null;
@ -57,120 +45,61 @@ let
inherit mac uuid;
} // builtins.removeAttrs smbios [ "seed" "mac" "uuid" "model" ])
else originalImage.opencore;
# PE boot disk: serial console, and an OpenCore ScanPolicy that only allows
# HFS+ volumes on SATA (= the PE), so the image's own macOS is never booted.
# 0x10203 = FILE_SYSTEM_LOCK | DEVICE_LOCK | ALLOW_FS_HFS | ALLOW_DEVICE_SATA
bootDisk = makeBootDisk {
name = "${name}-${originalImageName}-pe";
esp = originalImage.opencore;
bootArgs = "keepsyms=1 serial=3 -v";
scanPolicy = 66051;
};
runScript = pkgs.writeText "${name}-run.sh" ''
#!/bin/bash
. /Volumes/VMIX/pe-lib.sh
runScript = pkgs.writeText "${name}-vmix-run.sh" ''
#!/bin/sh
echo "=== vmix: ${name} ==="
pe_mount_target || pe_fail "could not mount the target volumes"
${script}
pe_unmount_target
'';
vmixVol = makeVmixVolume {
name = "${name}-${originalImageName}";
files = [
{ source = runScript; name = "run.sh"; }
{ source = ../guest/pe-lib.sh; name = "pe-lib.sh"; }
] ++ files;
};
bootRunScript = pkgs.writeText "${name}-boot.sh" ''
#!/bin/bash
# runs as root on the booted system (guest-exec); VMIX is mounted at $V
V=/Volumes/VMIX
echo "=== vmix (online): ${name} ==="
CONSOLE_USER=$(stat -f %Su /dev/console 2>/dev/null)
CONSOLE_UID=$(id -u "$CONSOLE_USER" 2>/dev/null)
export V CONSOLE_USER CONSOLE_UID
# run something inside the logged-in user's GUI session
as_user() { launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" "$@"; }
${bootScript}
'';
bootVol = makeVmixVolume {
name = "${name}-${originalImageName}-boot";
files = [ { source = bootRunScript; name = "run.sh"; } ] ++ files;
files = [ { source = runScript; name = "vmix-run.sh"; } ] ++ files;
};
driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
bootCommands = lib.optionalString hasScript ''
cp ${vmixVol} vmix.img
chmod +w vmix.img
cat > vmix.conf <<CONF
VOLUME_NAME="${volumeName}"
BUILD_DATE="$(date -u +%m%d%H%M%Y.%S)"
CONF
guestfish -a vmix.img -m /dev/sda1 upload vmix.conf /vmix.conf
qemu-img create -q -f qcow2 -F raw -b ${pe} pe.qcow2
qemu-img create -q -f qcow2 -F raw -b ${bootDisk}/boot.img ocboot.qcow2
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd
chmod +w vars.fd
VMIX_DISPLAY="-display none"
${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''}
${lib.optionalString (vncDisplay == null) ''
VMIX_DF=$(ls -t /tmp/.vmix-display-* 2>/dev/null | head -1)
if [ -n "$VMIX_DF" ] && [ "$(stat -c %s "$VMIX_DF")" -lt 256 ] && ! grep -q -P '[^\x20-\x7e\n]' "$VMIX_DF"; then
export DISPLAY=$(tr -d '\n' < "$VMIX_DF")
export HOME=$(mktemp -d)
export XDG_RUNTIME_DIR=$HOME
export SDL_VIDEODRIVER=x11
VMIX_DISPLAY="-display sdl"
fi
''}
echo "=== vmix: running ${name} in the PE against ${originalImageName} ==="
python3 ${vmDriver} --mode pe --name "${name}-${originalImageName}" --timeout ${toString timeout} \
--serial-log serial.log --progress-file ${resultImg} -- \
echo "=== vmix: booting ${originalImageName} for ${name} ==="
python3 ${vmDriver} --mode boot --name "${name}-${originalImageName}" --timeout ${toString timeout} --progress-file ${resultImg} -- \
qemu-system-x86_64 $VMIX_DISPLAY \
${if machineArgs != null then machineArgs else qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.firmwareArgs "vars.fd"} \
${qemu.serialArgs "serial.log"} \
${qemu.sataDrive { id = "opencore"; port = 0; file = "ocboot.qcow2"; }} \
${qemu.sataDrive { id = "pe"; port = 1; file = "pe.qcow2"; }} \
${qemu.sataDrive { id = "system"; port = 2; file = resultImg; }} \
${qemu.sataDrive { id = "vmix"; port = 3; file = "vmix.img"; format = "raw"; }} \
|| { echo "vmix: PE failed during ${name} (see /tmp/vmix-macos/${name}-${originalImageName})"; exit 1; }
${qemu.sataDrive { id = "system"; port = 0; file = resultImg; }} \
${qemu.sataDrive { id = "vmix"; port = 1; file = "vmix.img"; format = "raw"; }} \
${qemu.netArgs { mac = originalImage.macAddress; }} \
|| { echo "vmix: VM failed during ${name} (see /tmp/vmix-macos/${name}-${originalImageName})"; exit 1; }
${vmixReadback "vmix.img"}
[ "$STATUS" = "0" ] || { echo "vmix: ${name} script failed (status '$STATUS')"; exit 1; }
echo "=== vmix: ${name} complete ==="
'';
onlineCommands = lib.optionalString hasBootScript ''
cp ${bootVol} vmix-boot.img
chmod +w vmix-boot.img
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars-boot.fd
chmod +w vars-boot.fd
VMIX_DISPLAY="-display none"
${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''}
QGA_SOCK=$(mktemp -u /tmp/vmix-qga-XXXXXX.sock)
echo "=== vmix: booting ${originalImageName} for ${name} (guest agent) ==="
python3 ${vmDriver} --mode qga --name "${name}-${originalImageName}-online" --timeout ${toString timeout} \
--serial-log serial-boot.log --qga-sock "$QGA_SOCK" \
--qga-command 'for i in $(seq 1 30); do diskutil mount VMIX >/dev/null 2>&1; [ -f /Volumes/VMIX/run.sh ] && break; sleep 2; done; [ -f /Volumes/VMIX/run.sh ] || { echo "no VMIX volume"; exit 9; }; bash /Volumes/VMIX/run.sh > /Volumes/VMIX/vmix-run.log 2>&1; rc=$?; echo $rc > /Volumes/VMIX/vmix-run.status; sync; cat /Volumes/VMIX/vmix-run.log; diskutil unmount force /Volumes/VMIX >/dev/null 2>&1; exit $rc' -- \
qemu-system-x86_64 $VMIX_DISPLAY \
${if machineArgs != null then machineArgs else qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.firmwareArgs "vars-boot.fd"} \
${qemu.serialArgs "serial-boot.log"} \
${qemu.guestAgentArgs "$QGA_SOCK"} \
${qemu.sataDrive { id = "system"; port = 0; file = resultImg; }} \
${qemu.sataDrive { id = "vmix"; port = 1; file = "vmix-boot.img"; format = "raw"; }} \
${lib.optionalString network (qemu.netArgs { mac = originalImage.macAddress; })} \
|| { echo "vmix: online step failed during ${name} (see /tmp/vmix-macos/${name}-${originalImageName}-online)"; exit 1; }
rm -f "$QGA_SOCK"
${vmixReadback "vmix-boot.img"}
[ "$STATUS" = "0" ] || { echo "vmix: ${name} bootScript failed (status '$STATUS')"; exit 1; }
echo "=== vmix: ${name} (online) complete ==="
'';
builtImage = pkgs.runCommand customImageName ({
nativeBuildInputs = with pkgs; [ pkgs.qemu driverPython libguestfs-with-appliance ];
nativeBuildInputs = with pkgs; [ pkgs.qemu mtools driverPython libguestfs-with-appliance ];
requiredSystemFeatures = [ "kvm" ];
} // lib.optionalAttrs impure { __noChroot = true; }) ''
qemu-img create -q -f qcow2 -b ${originalImage} -F qcow2 ${resultImg}
[ -n "${diskSize}" ] && qemu-img resize ${resultImg} ${diskSize}
${bootCommands}
${onlineCommands}
${lib.optionalString hasSmbios (installBootloader { inherit esp; image = resultImg; })}
mv ${resultImg} $out
'';
in
builtImage // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; model = esp.model or model; inherit pe volumeName; }
builtImage // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; model = esp.model or model; }

View file

@ -1,56 +0,0 @@
# Host-side script that formats a blank disk image as an APFS volume with a
# given label by booting the image's PE headless for ~30 s (Linux cannot write
# APFS). Used for the persistent home volume (`generalize { persistHome = true; }`
# mounts LABEL=<label> at /Users) by the NixOS module and `vmix run --home`.
# ${formatVolume { inherit image; label = "vmix-home"; }} <disk-file> <raw|qcow2>
# The disk is found inside the PE as the only one without a partition table.
{ pkgs, lib, qemu, makeVmixVolume, makeBootDisk, vmDriver, ... }:
{ image, label ? "vmix-home", memSize ? 4096 }:
let
pe = image.pe or (throw "vmix: image ${image.name} carries no PE");
bootDisk = makeBootDisk { name = "${label}-format"; esp = image.opencore; bootArgs = "keepsyms=1 serial=3"; scanPolicy = 66051; };
runScript = pkgs.writeText "format-${label}.sh" ''
. /Volumes/VMIX/pe-lib.sh
LIST=$(diskutil list)
# idempotent: a run.sh re-run (e.g. after a guest reboot) finds the volume done
if echo "$LIST" | grep -q "APFS Volume ${label} "; then echo "vmix: volume '${label}' already exists"; exit 0; fi
# blank disk: a whole-disk line followed by no partition entries
TARGET=$(echo "$LIST" | awk '/^\/dev\/disk[0-9]+ / {d=$1; n=0; next} /^ +[0-9]+:/ {n++} /^$/ {if (d != "" && n <= 1) print d; d=""} END {if (d != "" && n <= 1) print d}' | grep -vE "synthesized" | head -1)
[ -n "$TARGET" ] || { echo "$LIST"; pe_fail "no blank disk found"; }
echo "vmix: formatting $TARGET as APFS '${label}'"
diskutil eraseDisk APFS "${label}" GPT "$TARGET" || pe_fail "eraseDisk $TARGET"
diskutil unmount "/Volumes/${label}" >/dev/null 2>&1 || true
'';
vmixVol = makeVmixVolume {
name = "format-${label}";
files = [ { source = runScript; name = "run.sh"; } { source = ../guest/pe-lib.sh; name = "pe-lib.sh"; } ];
};
driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
in
pkgs.writeShellScript "vmix-format-${label}" ''
set -eu
DISK="$1"; FMT="''${2:-qcow2}"
T=$(mktemp -d /tmp/vmix-format-XXXXXX)
cleanup() { if [ "''${OK:-0}" = 1 ]; then rm -rf "$T"; else echo "vmix: logs kept in $T"; fi; }
trap cleanup EXIT
cp ${vmixVol} "$T/vmix.img"; chmod +w "$T/vmix.img"
${pkgs.qemu}/bin/qemu-img create -q -f qcow2 -F raw -b ${pe} "$T/pe.qcow2"
${pkgs.qemu}/bin/qemu-img create -q -f qcow2 -F raw -b ${bootDisk}/boot.img "$T/ocboot.qcow2"
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd "$T/vars.fd"; chmod +w "$T/vars.fd"
echo "vmix: formatting $DISK as APFS '${label}' (PE boot)"
${driverPython}/bin/python3 ${vmDriver} --mode pe --name "format-${label}" --debug-dir "$T/debug" --timeout 600 \
--serial-log "$T/serial.log" -- \
${pkgs.qemu}/bin/qemu-system-x86_64 -display none \
${qemu.machineArgs { smp = 2; inherit memSize; }} \
${qemu.firmwareArgs "$T/vars.fd"} \
${qemu.serialArgs "$T/serial.log"} \
${qemu.sataDrive { id = "opencore"; port = 0; file = "$T/ocboot.qcow2"; }} \
${qemu.sataDrive { id = "pe"; port = 1; file = "$T/pe.qcow2"; }} \
${qemu.sataDrive { id = "vmix"; port = 2; file = "$T/vmix.img"; format = "raw"; }} \
${qemu.virtioBlkArgs { id = "target"; file = "$DISK"; format = "$FMT"; }} \
>/dev/null
STATUS=$(${pkgs.libguestfs-with-appliance}/bin/guestfish --ro -a "$T/vmix.img" -m /dev/sda1 cat /vmix-run.status 2>/dev/null | tr -d '[:space:]' || true)
[ "$STATUS" = "0" ] || { echo "vmix: formatting failed (status '$STATUS')"; ${pkgs.libguestfs-with-appliance}/bin/guestfish --ro -a "$T/vmix.img" -m /dev/sda1 cat /vmix-run.log 2>/dev/null | tail -20; exit 1; }
OK=1
echo "vmix: $DISK formatted"
''

View file

@ -0,0 +1,105 @@
# 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

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

View file

@ -13,21 +13,14 @@
uuid,
serial ? null,
mlb ? null,
bootArgs ? "keepsyms=1 revpatch=memtab",
bootArgs ? "keepsyms=1",
resolution ? "1024x768",
showPicker ? true,
pickerTimeout ? 2,
extraConfig ? {},
memSize ? 8192, # RAM described in SMBIOS (MacPro7,1 wants 4 DIMMs)
}:
let
ocImage = pkgs.fetchurl { inherit (upstream.opencore.image) url sha256; name = "OSX-KVM-OpenCore.qcow2"; };
# OSX-KVM's ESP ships Lilu 1.6.8 / VirtualSMC 1.3.3 / WhateverGreen 1.6.7, which
# disable themselves on macOS 26 ("unsupported operating system"); without
# VirtualSMC, macOS' restart path panics on QEMU's SMC stub. Overlay the
# current releases (upstream.opencore.kexts, pinned).
kextZips = lib.mapAttrsToList (n: k: { name = n; zip = pkgs.fetchurl { inherit (k) url hash; }; })
(upstream.opencore.kexts or {});
in
pkgs.runCommand "${name}-esp" {
nativeBuildInputs = with pkgs; [ _7zz python3 mtools dosfstools gptfdisk jq macserial ];
@ -51,23 +44,13 @@ pkgs.runCommand "${name}-esp" {
mkdir -p $out/EFI/vmix
cp -r esp/EFI/BOOT esp/EFI/OC $out/EFI/
chmod -R u+w $out/EFI
${lib.concatMapStringsSep "\n" (k: ''
echo "=== vmix: updating ${k.name}.kext from ${k.zip.name} ==="
rm -rf kext-${k.name}; mkdir kext-${k.name}
${pkgs.unzip}/bin/unzip -q -o ${k.zip} -d kext-${k.name}
K=$(find kext-${k.name} -type d -name "${k.name}.kext" | head -1)
[ -n "$K" ] || { echo "${k.name}.kext not found in ${k.zip.name}"; exit 1; }
rm -rf "$out/EFI/OC/Kexts/${k.name}.kext"
cp -r "$K" "$out/EFI/OC/Kexts/${k.name}.kext"
grep -A1 CFBundleVersion "$out/EFI/OC/Kexts/${k.name}.kext/Contents/Info.plist" | tail -1
'') kextZips}
# hide OpenCore's own launcher from its picker (otherwise it is the default entry and loops)
echo -n Disabled > $out/EFI/BOOT/.contentVisibility
python3 ${./oc-config.py} --base esp/EFI/OC/config.plist --esp esp --out $out/EFI/OC/config.plist \
--model "${model}" --serial "$SERIAL" --mlb "$MLB" --uuid "${uuid}" --mac "${mac}" \
--nic-path "${qemu.nicDevicePath}" --boot-args "${bootArgs}" --resolution "${resolution}" \
--show-picker "${lib.boolToString showPicker}" --timeout ${toString pickerTimeout} \
--extra-json ${lib.escapeShellArg (builtins.toJSON extraConfig)} --memory-mb ${toString memSize} --add-kexts ${lib.concatStringsSep "," (map (k: k.name) kextZips)}
--extra-json ${lib.escapeShellArg (builtins.toJSON extraConfig)}
ocvalidate $out/EFI/OC/config.plist || echo "vmix: ocvalidate reported issues (OpenCore version may differ from validator), continuing"
jq -n --arg model "${model}" --arg serial "$SERIAL" --arg mlb "$MLB" --arg uuid "${uuid}" --arg mac "${mac}" \

View file

@ -1,30 +0,0 @@
# 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,8 +43,6 @@ def main():
p.add_argument('--show-picker', default='true')
p.add_argument('--timeout', type=int, default=2)
p.add_argument('--extra-json', default='{}')
p.add_argument('--memory-mb', type=int, default=8192, help='VM RAM, described as 4 DIMMs')
p.add_argument('--add-kexts', default='', help='comma-separated kext names (without .kext) that need a Kernel.Add entry')
a = p.parse_args()
with open(a.base, 'rb') as f:
@ -86,33 +84,6 @@ def main():
cfg['Misc']['Boot']['Timeout'] = a.timeout
cfg['Misc']['Boot']['HideAuxiliary'] = True
cfg['Misc']['Security']['ScanPolicy'] = 0
# kexts overlaid into the ESP that the OSX-KVM config does not list yet
# (RestrictEvents: silences MacPro7,1's "Memory Modules Misconfigured", revpatch=memtab)
listed = {k['BundlePath'] for k in cfg['Kernel']['Add']}
for kext in [k + '.kext' for k in a.add_kexts.split(',') if k]:
if kext not in listed:
name = kext[:-5]
cfg['Kernel']['Add'].append({
'Arch': 'Any', 'BundlePath': kext, 'Comment': f'{name} (vmix)', 'Enabled': True,
'ExecutablePath': f'Contents/MacOS/{name}', 'MaxKernel': '', 'MinKernel': '',
'PlistPath': 'Contents/Info.plist'})
print('Kernel.Add +', kext)
# MacPro7,1 firmware expects DIMMs in pairs (>= 4); with QEMU's single SMBIOS
# module macOS shows "Memory Modules Misconfigured" at every login. Describe
# the VM's RAM as four DDR4 modules instead.
if a.model.startswith('MacPro7'):
size = max(1024, a.memory_mb // 4)
cfg['PlatformInfo']['CustomMemory'] = True
cfg['PlatformInfo']['Memory'] = {
'DataWidth': 64, 'ErrorCorrection': 3, 'FormFactor': 9, 'MaxCapacity': 1536 * 1024 * 1024 * 1024,
'TotalWidth': 64, 'Type': 26, 'TypeDetail': 128,
'Devices': [{
'AssetTag': '', 'BankLocator': f'BANK {i}', 'DeviceLocator': f'DIMM{i + 1}',
'Manufacturer': 'Apple', 'PartNumber': f'VMIX{size}', 'SerialNumber': f'VMIX{i:04d}',
'Size': size, 'Speed': 2666,
} for i in range(4)],
}
cfg['Misc']['Security']['SecureBootModel'] = 'Disabled'
cfg['Misc']['Security']['AllowSetDefault'] = True
cfg['Misc']['Debug']['Target'] = 0

View file

@ -17,49 +17,19 @@ rec {
netArgs = { mac, netdev ? "user,id=net0", extra ? "" }:
"-netdev ${netdev} -device virtio-net-pci,netdev=net0,mac=${mac},bus=pcie.0,addr=${nicAddr}${extra}";
# Devices macOS needs (no accel, disks, display adapter or display server here).
# No isa-applesmc: QEMU's stub only answers the OSK keys, and its presence makes
# VirtualSMC (which carries the OSK itself) step aside, leaving Apple's SMC
# driver on the stub — whose missing watchdog keys panic the restart path on
# macOS 26. VirtualSMC alone is the standard Hackintosh setup.
deviceArgsFor = { appleSmc ? false }:
''${lib.optionalString appleSmc ''-device isa-applesmc,osk="${osk}" ''}-smbios type=2 -device qemu-xhci,id=xhci -device usb-kbd,bus=xhci.0 -device usb-tablet,bus=xhci.0 -device usb-ehci,id=ehci -device ich9-intel-hda -device hda-duplex -device ich9-ahci,id=sata -global ICH9-LPC.disable_s3=1'';
deviceArgs = deviceArgsFor { };
# Devices macOS needs (no accel, disks, display adapter or display server here)
deviceArgs = ''-device isa-applesmc,osk="${osk}" -smbios type=2 -device qemu-xhci,id=xhci -device usb-kbd,bus=xhci.0 -device usb-tablet,bus=xhci.0 -device usb-ehci,id=ehci -device ich9-intel-hda -device hda-duplex -device ich9-ahci,id=sata -global ICH9-LPC.disable_s3=1'';
# macOS has no QXL/virtio-gpu driver; VMware SVGA gives a plain framebuffer
vgaArgs = "-vga vmware";
machineArgs = { cpu ? defaultCpu, smp ? 4, memSize ? 4096, appleSmc ? false }:
"-accel kvm -machine type=q35 -cpu ${cpu} -smp ${toString smp},sockets=1,cores=${toString smp},threads=1 -m ${toString memSize} ${deviceArgsFor { inherit appleSmc; }} ${vgaArgs}";
machineArgs = { cpu ? defaultCpu, smp ? 4, memSize ? 4096 }:
"-accel kvm -machine type=q35 -cpu ${cpu} -smp ${toString smp},sockets=1,cores=${toString smp},threads=1 -m ${toString memSize} ${deviceArgs} ${vgaArgs}";
# SATA disk on a given port. Store files are read-only: callers create qcow2 overlays.
sataDrive = { id, port, file, format ? "qcow2", extra ? "" }:
"-drive id=${id},if=none,format=${format},file=${file}${extra} -device ide-hd,bus=sata.${toString port},drive=${id}";
# XNU logs to COM1 with boot-args serial=3; the build drivers read this file
serialArgs = file: "-serial file:${file}";
# Apple's own QEMU guest agent (/usr/libexec/AppleQEMUGuestAgent, macOS 13+)
# attaches to a virtio console port named org.qemu.guest_agent.0 and offers
# guest-exec (as root), guest-file-* etc. over this unix socket.
guestAgentArgs = sock:
"-device virtio-serial-pci,id=vmix-vser -chardev socket,path=${sock},server=on,wait=off,id=vmix-qga -device virtserialport,chardev=vmix-qga,name=org.qemu.guest_agent.0";
# virtio-fs (vhost-user, virtiofsd on the host). macOS auto-mounts the tag
# "com.apple.virtio-fs.automount" at /Volumes/My Shared Files; other tags are
# mounted with `mount -t virtiofs <tag> <dir>`. Needs a shared memory backend.
automountTag = "com.apple.virtio-fs.automount";
memBackendArgs = memSize: "-object memory-backend-memfd,id=vmix-mem,size=${toString memSize}M,share=on -numa node,memdev=vmix-mem";
virtioFsArgs = { tag, sock, id ? tag }:
"-chardev socket,id=vmix-vfs-${id},path=${sock} -device vhost-user-fs-pci,chardev=vmix-vfs-${id},tag=${tag}";
# virtio-blk data disk (AppleVirtIOBlock), e.g. the persistent home volume
virtioBlkArgs = { id, file, format ? "qcow2", extra ? "" }:
"-drive id=${id},if=none,format=${format},file=${file}${extra} -device virtio-blk-pci,drive=${id}";
# virtio keyboard/tablet (AppleVirtIOInput), optional alternative to the USB HID pair
virtioInputArgs = "-device virtio-keyboard-pci -device virtio-tablet-pci";
firmwareArgs = varsFile:
"-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd -drive if=pflash,format=raw,file=${varsFile}";
}

View file

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

View file

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

View file

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

View file

@ -2,13 +2,10 @@
rec {
generalize = import ./generalize.nix { inherit pkgs lib; };
software = import ./software { inherit pkgs lib; };
profile = import ./profile { inherit pkgs lib; };
essentials = {
remoteAccess = import ./essentials/remote-access.nix { };
noUpdates = import ./essentials/no-updates.nix { };
performance = import ./essentials/performance.nix { inherit pkgs; };
performance = import ./essentials/performance.nix { };
};
bundles = {

View file

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

View file

@ -1,32 +1,11 @@
# 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
# Less background work in a VM: no Spotlight indexing, no Time Machine, no sleep
{ ... }:
{
name = "performance";
files = [ { source = power; name = "com.apple.PowerManagement.plist"; } ];
script = ''
touch "$DATA/.metadata_never_index"
pe_plist_set "$DATA/Library/Preferences/com.apple.TimeMachine.plist" AutoBackup bool false
pe_plist_set "$DATA/Library/Preferences/com.apple.loginwindow.plist" DisableScreenLockImmediate bool true
cp "$V/com.apple.PowerManagement.plist" "$DATA/Library/Preferences/com.apple.PowerManagement.plist"
chown 0:0 "$DATA/Library/Preferences/com.apple.PowerManagement.plist"
mdutil -a -i off || true
tmutil disable || true
pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 womp 0 || true
defaults write /Library/Preferences/com.apple.loginwindow DisableScreenLockImmediate -bool true
'';
}

View file

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

View file

@ -1,8 +1,7 @@
# Generalize a macOS image, offline from the PE: create the (admin) user on the
# image's Data volume with dscl, auto-login, suppress the first-login Setup
# Assistant, hostname, locale, timezone, use the whole disk, and give the image
# a fresh SMBIOS identity (serial/MLB from macserial, MAC + UUID from `seed`) so
# every generalized VM looks like a distinct Mac to Apple ID/iMessage.
# Generalize a macOS image: create the user, auto-login, hostname, timezone,
# suppress Setup Assistant prompts, then remove the vmix agent. Also gives the
# image a fresh SMBIOS identity (serial/MLB from macserial, MAC + UUID from
# `seed`) so every generalized VM looks like a distinct Mac to Apple ID/iMessage.
# Usage: (templates.generalize { username = "User"; password = ""; hostname = "MAC"; })
# delayOobeRun = true: no user, Setup Assistant runs on first real boot (like Windows OOBE)
{ pkgs, lib, ... }:
@ -22,14 +21,6 @@
uuid ? null,
mac ? null,
seed ? "${hostname}-${username}",
# keep the user's home on an APFS volume labelled vmix-home (a virtio-blk disk
# the host provides, formatted by the PE on first start). macOS refuses mounts
# over /Users (firmlink), so the volume automounts at /Volumes/<label> and the
# account's home directory lives there: ephemeral OS disk, persistent home.
persistHome ? false,
homeVolumeLabel ? "vmix-home",
# no desktop widgets for the created user (Sonoma+)
hideWidgets ? true,
# accepted for CLI parity with Windows, not supported on macOS
bgColor ? null,
}:
@ -43,8 +34,7 @@ let
"DidSeeCloudSetup" "DidSeeSiriSetup" "DidSeePrivacy" "DidSeeTouchIDSetup" "DidSeeAppearanceSetup"
"DidSeeScreenTime" "DidSeeAccessibility" "DidSeeTrueTonePrivacy" "DidSeeActivationLock"
"DidSeeiCloudLoginForStorageServices" "DidSeeSyncSetup" "DidSeeSyncSetup2" "DidSeeAppleIDSyncSetup"
"DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "DidSeeUpdateMacAutomatically"
"DidSeeSoftwareUpdate" "SkipFirstLoginOptimization"
"DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "SkipFirstLoginOptimization"
];
in
{
@ -54,93 +44,60 @@ in
script = ''
set -x
${lib.optionalString (bgColor != null) ''echo "vmix: bgColor is not supported on macOS, ignoring"''}
VER=$(pe_target_version); BUILD=$(pe_target_build)
echo "vmix: target macOS $VER ($BUILD)"
N="$DATA/private/var/db/dslocal/nodes/Default"
D() { dscl -f "$N" localhost "$@"; }
${lib.optionalString (!delayOobeRun) ''
# --- user account (admin), created directly in the local directory node
U="${username}"; HOME_DIR="$DATA/Users/$U"
${lib.optionalString persistHome ''HOME_PATH="/Volumes/${homeVolumeLabel}/$U"''}
if ! D -read "/Local/Default/Users/$U" >/dev/null 2>&1; then
UID_NEW=$(D -list /Local/Default/Users UniqueID | awk '$2 >= 501 && $2 < 1000 && $2 > m {m = $2} END {print (m ? m + 1 : 501)}')
D -create "/Local/Default/Users/$U" || pe_fail "dscl create user"
D -create "/Local/Default/Users/$U" UserShell /bin/zsh
D -create "/Local/Default/Users/$U" RealName ${lib.escapeShellArg fullName}
D -create "/Local/Default/Users/$U" UniqueID "$UID_NEW"
D -create "/Local/Default/Users/$U" PrimaryGroupID 20
D -create "/Local/Default/Users/$U" NFSHomeDirectory "${if persistHome then "/Volumes/${homeVolumeLabel}/$U" else "/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}')
# --- user account (admin)
if ! id "${username}" >/dev/null 2>&1; then
sysadminctl -addUser "${username}" -fullName ${lib.escapeShellArg fullName} \
-password ${lib.escapeShellArg (if password == "" then tempPassword else password)} \
-admin -home "/Users/${username}" || exit 1
${lib.optionalString (password == "") ''
dscl . -passwd "/Users/${username}" "${tempPassword}" "" || echo "vmix: WARNING: could not set an empty password, password is '${tempPassword}'"
''}
fi
${lib.optionalString autoLogon ''
pe_plist_set "$DATA/Library/Preferences/com.apple.loginwindow.plist" autoLoginUser string "$U"
cp "$V/kcpassword" "$DATA/private/etc/kcpassword"
chmod 600 "$DATA/private/etc/kcpassword"; chown 0:0 "$DATA/private/etc/kcpassword"
defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser "${username}"
cp /Volumes/VMIX/kcpassword /etc/kcpassword
chmod 600 /etc/kcpassword
chown root:wheel /etc/kcpassword
''}
# --- no Setup Assistant / "What's new" prompts at first login
mkdir -p "$HOME_DIR/Library/Preferences"
P="$HOME_DIR/Library/Preferences/com.apple.SetupAssistant.plist"
for k in ${lib.concatStringsSep " " setupKeys}; do pe_plist_set "$P" "$k" bool true; done
pe_plist_set "$P" GestureMovieSeen string none
pe_plist_set "$P" LastSeenCloudProductVersion string "$VER"
pe_plist_set "$P" LastSeenBuddyBuildVersion string "$BUILD"
pe_plist_set "$P" LastSeenSiriProductVersion string "$VER"
pe_plist_set "$P" LastPreLoginTasksPerformedVersion string "$VER"
pe_plist_set "$P" LastPreLoginTasksPerformedBuild string "$BUILD"
pe_plist_set "$HOME_DIR/Library/Preferences/.GlobalPreferences.plist" AppleLocale string "${macLocale}"
${lib.optionalString hideWidgets ''
WM="$HOME_DIR/Library/Preferences/com.apple.WindowManager.plist"
pe_plist_set "$WM" StandardHideWidgets integer 1
pe_plist_set "$WM" StageManagerHideWidgets integer 1
''}
chown -R "$UID_NEW:20" "$HOME_DIR"
touch "$DATA/private/var/db/.AppleSetupDone"
''}
${lib.optionalString delayOobeRun ''
rm -f "$DATA/private/var/db/.AppleSetupDone"
P="/Users/${username}/Library/Preferences/com.apple.SetupAssistant"
VER=$(sw_vers -productVersion)
BUILD=$(sw_vers -buildVersion)
for k in ${lib.concatStringsSep " " setupKeys}; do
defaults write "$P" "$k" -bool true
done
defaults write "$P" GestureMovieSeen none
defaults write "$P" LastSeenCloudProductVersion "$VER"
defaults write "$P" LastSeenBuddyBuildVersion "$BUILD"
defaults write "$P" LastSeenSiriProductVersion "$VER"
defaults write "$P" LastPreLoginTasksPerformedVersion "$VER"
defaults write "/Users/${username}/Library/Preferences/.GlobalPreferences" AppleLocale "${macLocale}"
chown -R "${username}" "/Users/${username}/Library/Preferences"
''}
# --- machine identity
PF="$DATA/Library/Preferences/SystemConfiguration/preferences.plist"
mkdir -p "$(dirname "$PF")"
pe_plist_dict "$PF" System
pe_plist_dict "$PF" System.System
pe_plist_dict "$PF" System.Network
pe_plist_dict "$PF" System.Network.HostNames
pe_plist_set "$PF" System.System.ComputerName string "${hostname}"
pe_plist_set "$PF" System.System.HostName string "${hostname}"
pe_plist_set "$PF" System.Network.HostNames.LocalHostName string "${hostname}"
pe_plist_set "$DATA/Library/Preferences/.GlobalPreferences.plist" AppleLocale string "${macLocale}"
ln -sfn "/var/db/timezone/zoneinfo/${timezone}" "$DATA/private/etc/localtime"
pe_plist_set "$DATA/Library/Preferences/com.apple.timezone.auto.plist" Active bool false
scutil --set ComputerName "${hostname}"
scutil --set HostName "${hostname}"
scutil --set LocalHostName "${hostname}"
defaults write /Library/Preferences/.GlobalPreferences AppleLocale "${macLocale}"
systemsetup -settimezone "${timezone}" >/dev/null 2>&1 || ln -sfn "/usr/share/zoneinfo/${timezone}" /etc/localtime
# --- QEMU's USB keyboard (vendor 0x0627, product 0x0001) is unknown to macOS,
# which would open the Keyboard Setup Assistant at every login: declare it ANSI
KT="$DATA/Library/Preferences/com.apple.keyboardtype.plist"
pe_plist_dict "$KT" keyboardtype
pe_plist_set "$KT" keyboardtype.1-1575-0 integer 40
${lib.optionalString persistHome ''
# --- persistent home: the seeded home directory on the Data volume is the
# template loginwindow copies to /Volumes/${homeVolumeLabel}/$U at first login
# (the volume is automounted by diskarbitrationd before the login)
''}
# --- never sleep (VM)
pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 || true
# --- use the whole (possibly grown) disk
STORE=$(diskutil info "$SYS_ID" | sed -n 's/.*APFS Physical Store: *//p' | awk '{print $1}')
STORE=$(diskutil info / | awk '/APFS Physical Store/ {print $NF}')
[ -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
'';
}

View file

@ -1,76 +0,0 @@
# User profile templates, applied on the booted image inside the logged-in
# user's session through the guest agent (after generalize with autoLogon).
# settings — { hideWidgets, wallpaper, dockApps, dockAutohide, showHiddenFiles }
{ pkgs, lib, ... }:
let
# Apple Events (osascript → System Events) need per-app automation consent that a
# headless session cannot grant; desktoppr sets the wallpaper through NSWorkspace
# inside the user's session instead (scriptingosx/desktoppr, pinned).
desktoppr = pkgs.fetchurl {
url = "https://github.com/scriptingosx/desktoppr/releases/download/v0.5/desktoppr-0.5-218.pkg";
hash = "sha256-HPtn1wI7xrx7HjyMz1yJGhutIodt938/vHfSeHfMe50=";
};
in
rec {
settings = {
hideWidgets ? true, # no desktop widgets (Sonoma+)
wallpaper ? null, # image file (drv/path) set as the desktop picture
dockApps ? null, # list of app paths, e.g. [ "/System/Applications/Utilities/Terminal.app" ]; null = untouched
dockAutohide ? false,
showHiddenFiles ? false,
darkMode ? null, # true/false/null
}: {
name = "profile";
files = lib.optionals (wallpaper != null) [
{ source = wallpaper; name = "wallpaper.${lib.last (lib.splitString "." (baseNameOf (toString wallpaper)))}"; }
{ source = desktoppr; name = "desktoppr.pkg"; }
];
bootScript = ''
set -x
[ -n "$CONSOLE_USER" ] || { echo "vmix: profile needs a logged-in user (generalize with autoLogon)"; exit 1; }
H=$(dscl . -read "/Users/$CONSOLE_USER" NFSHomeDirectory | awk '{print $2}')
D() { as_user defaults write "$@"; }
${lib.optionalString hideWidgets ''
D com.apple.WindowManager StandardHideWidgets -int 1
D com.apple.WindowManager StageManagerHideWidgets -int 1
D com.apple.widgets widgetAppearance -int 0
''}
${lib.optionalString (dockApps != null) ''
D com.apple.dock persistent-apps -array
${lib.concatMapStringsSep "\n" (a: ''
D com.apple.dock persistent-apps -array-add "<dict><key>tile-type</key><string>file-tile</string><key>tile-data</key><dict><key>file-data</key><dict><key>_CFURLString</key><string>file://${a}/</string><key>_CFURLStringType</key><integer>15</integer></dict></dict></dict>"
'') dockApps}
''}
${lib.optionalString dockAutohide ''D com.apple.dock autohide -bool true''}
${lib.optionalString showHiddenFiles ''D com.apple.finder AppleShowAllFiles -bool true''}
${lib.optionalString (darkMode != null) (if darkMode
then ''D -g AppleInterfaceStyle Dark''
else ''as_user defaults delete -g AppleInterfaceStyle 2>/dev/null || true'')}
as_user killall Dock Finder WindowManager 2>/dev/null || true
sleep 10
# wallpaper last: WindowManager re-applies its desktop configuration when the
# widget/dock settings above change, which reverts a choice made before it
${lib.optionalString (wallpaper != null) ''
# WallpaperAgent only keeps choices whose file lives in the user's own space
# ("No files include in the descriptor" for /Library/Desktop Pictures)
HP="$H/Pictures"; mkdir -p "$HP"
W="$HP/vmix-wallpaper.${lib.last (lib.splitString "." (baseNameOf (toString wallpaper)))}"
cp "$V/wallpaper".* "$W"; chown "$CONSOLE_USER" "$HP" "$W"; chmod 644 "$W"
installer -pkg "$V/desktoppr.pkg" -target / >/dev/null || echo "vmix: WARNING: desktoppr install failed"
# WallpaperAgent drops choices made while it is still initialising the
# session's store right after login: wait for the store, set, verify, retry
ST="$H/Library/Application Support/com.apple.wallpaper/Store/Index.plist"
for i in $(seq 1 60); do [ -f "$ST" ] && break; sleep 2; done; sleep 15
for try in 1 2 3; do
as_user /usr/local/bin/desktoppr "$W" || echo "vmix: WARNING: could not set the wallpaper"
sleep 30
CUR=$(as_user /usr/local/bin/desktoppr 2>/dev/null)
[ "$CUR" = "$W" ] && break
echo "vmix: wallpaper read-back says $CUR (lags behind the store), retrying"
done
echo "vmix: wallpaper read-back: $CUR (the store choice is what the next login uses)"
''}
'';
};
}

View file

@ -1,51 +0,0 @@
# Software installation templates.
# pkg — install a flat/distribution .pkg offline from the PE (`installer -target`)
# app — copy an .app bundle (from a directory or zip) into /Applications offline
# script — run a shell script as root on the booted image (network available)
{ pkgs, lib, ... }:
rec {
pkg = { name, src, choices ? null }: {
name = "pkg-${name}";
files = [ { source = src; name = "${name}.pkg"; } ]
++ lib.optional (choices != null) { source = choices; name = "${name}.choices.xml"; };
script = ''
echo "vmix: installing ${name}.pkg into $SYS"
installer -verboseR -pkg "$V/${name}.pkg" -target "$SYS" \
${lib.optionalString (choices != null) ''-applyChoiceChangesXML "$V/${name}.choices.xml"''} \
|| pe_fail "installer ${name}.pkg"
'';
};
app = { name, src }: {
name = "app-${name}";
files = [ { source = src; name = "${name}.app"; } ];
script = ''
echo "vmix: copying ${name}.app to $DATA/Applications"
mkdir -p "$DATA/Applications"
rm -rf "$DATA/Applications/${name}.app"
ditto "$V/${name}.app" "$DATA/Applications/${name}.app" || pe_fail "ditto ${name}.app"
chown -R 0:80 "$DATA/Applications/${name}.app"
xattr -dr com.apple.quarantine "$DATA/Applications/${name}.app" 2>/dev/null || true
'';
};
script = { name, script, files ? [], network ? true }: {
name = "script-${name}";
inherit files network;
bootScript = script;
};
# Homebrew (needs network; installs for the console user or the given user)
homebrew = { user ? null, formulae ? [], casks ? [] }: {
name = "homebrew";
bootScript = ''
U=${if user == null then "$CONSOLE_USER" else user}
[ -n "$U" ] || { echo "vmix: no user to install Homebrew for"; exit 1; }
launchctl asuser "$(id -u "$U")" sudo -u "$U" env NONINTERACTIVE=1 \
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || exit 1
B=/usr/local/bin/brew
${lib.optionalString (formulae != []) ''launchctl asuser "$(id -u "$U")" sudo -u "$U" $B install ${lib.escapeShellArgs formulae} || exit 1''}
${lib.optionalString (casks != []) ''launchctl asuser "$(id -u "$U")" sudo -u "$U" $B install --cask ${lib.escapeShellArgs casks} || exit 1''}
'';
};
}

View file

@ -1,31 +0,0 @@
#!/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 --option sandbox relaxed "$DRV^*" > "$D/build.log" 2>&1; rc=$?
else nix build --no-link -L --option sandbox relaxed --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 ' ')
# --rebuild makes nix exit non-zero when the (byte-wise different) qcow2 does not
# match the earlier output; judge the run by the builder's own completion line
if grep -q "install complete" "$D/build.log"; then res=OK; else res=FAIL; fi
printf '%-4s %-8s %-9s %-6s %-7s %-7s %-6s %s\n' "$i" "$res" "$(( (t1 - t0) / 60 ))" "$boots" "$resets" "$panics" "$tries" "$note" | tee -a "$OUT/summary.txt"
done
echo "logs: $OUT"

View file

@ -28,28 +28,6 @@
"fetchRecoveryScript": {
"url": "https://raw.githubusercontent.com/kholia/OSX-KVM/4c378a4b5e0b219783683012bec680325eb40719/fetch-macOS-v2.py",
"sha256": "39ac6d26bd265f5d32198062f515ad15ef93afb7a74e702be2b008090d5bd5f3"
},
"kexts": {
"Lilu": {
"version": "1.7.2",
"url": "https://github.com/acidanthera/Lilu/releases/download/1.7.2/Lilu-1.7.2-RELEASE.zip",
"hash": "sha256-U5Z9fc+qsBAjoz3y6WmolSLxPWZUpqVqxHEbYtq/Org="
},
"VirtualSMC": {
"version": "1.3.7",
"url": "https://github.com/acidanthera/VirtualSMC/releases/download/1.3.7/VirtualSMC-1.3.7-RELEASE.zip",
"hash": "sha256-EvHTeZafkmMG+pLZTdvzOzKzEXZYncQgidhkomsxtwA="
},
"WhateverGreen": {
"version": "1.7.0",
"url": "https://github.com/acidanthera/WhateverGreen/releases/download/1.7.0/WhateverGreen-1.7.0-RELEASE.zip",
"hash": "sha256-bW/+gzStYPeEpmJ5TmeyVgt511fVBoQdyMqZlKs5l5s="
},
"RestrictEvents": {
"version": "1.1.6",
"url": "https://github.com/acidanthera/RestrictEvents/releases/download/1.1.6/RestrictEvents-1.1.6-RELEASE.zip",
"hash": "sha256-mBcN+uGV3dKLXZXj8EASWhPKeDvLm9HluMWI4hexTuY="
}
}
}
}

View file

@ -107,42 +107,6 @@ let
(unique (map pciDeviceOf vmCfg.pci.passthrough));
# --- macOS: guest agent, virtio-fs shares, persistent home volume
macosQemu = vmixLib.macos.qemu;
qgaSock = "/run/vmix/qga-${vmCfg.name}.sock";
macosGuestAgent = isMacos && vmCfg.macos.guestAgent.enable;
macosShares = if isMacos then vmCfg.shares else {};
macosShareNames = attrNames macosShares;
macosAutomountShare = if macosShares ? automount then "automount"
else if macosShareNames != [] then head macosShareNames else null;
macosShareTag = n: if n == macosAutomountShare then macosQemu.automountTag else n;
macosShareSock = n: "/run/vmix/vfs-${vmCfg.name}-${n}.sock";
macosHome = isMacos && vmCfg.macos.homeDisk.enable;
macosFormatHome = if macosHome then vmixLib.macos.formatVolume {
image = vmCfg.disks.os.file; label = vmCfg.macos.homeDisk.label;
} else null;
# shares beyond the automounted one are mounted through the guest agent once it answers
macosMountSharesScript = pkgs.writeShellScript "${vmCfg.name}-macos-shares-vmix" ''
for i in $(seq 1 120); do
[ -S ${qgaSock} ] && printf '{"execute":"guest-ping"}\n' | ${pkgs.socat}/bin/socat -T5 - UNIX-CONNECT:${qgaSock} 2>/dev/null | grep -q return && break
sleep 5
done
${concatMapStrings (n: optionalString (n != macosAutomountShare) ''
printf '%s\n' '{"execute":"guest-exec","arguments":{"path":"/bin/bash","arg":["-c","mkdir -p ${macosShares.${n}.target}; mount -t virtiofs ${n} ${macosShares.${n}.target}"]}}' \
| ${pkgs.socat}/bin/socat -T10 - UNIX-CONNECT:${qgaSock} >/dev/null 2>&1 || true
'') macosShareNames}
'';
seedHomeDiskScript = pkgs.writeShellScript "${vmCfg.name}-home-disk-vmix" ''
F="${vmCfg.macos.homeDisk.file}"
if [ ! -e "$F" ]; then
echo "Creating persistent home volume $F (${vmCfg.macos.homeDisk.size}, ${vmCfg.macos.homeDisk.format})..."
mkdir -p "$(dirname "$F")"
${pkgs.qemu}/bin/qemu-img create -q -f ${vmCfg.macos.homeDisk.format} "$F" ${vmCfg.macos.homeDisk.size}
chmod 600 "$F"
${macosFormatHome} "$F" ${vmCfg.macos.homeDisk.format}
fi
'';
# Linux VMs: apply customizeImage with 9p fstab and machine-id setup
linuxOsImage = vmixLib.linux.customizeImage vmCfg.disks.os.file {
name = vmCfg.name;
@ -176,8 +140,7 @@ let
fi
'';
persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript
++ lib.optional macosHome seedHomeDiskScript;
persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript;
# QEMU expects single-letter boot codes (e.g. c,d,n), while vmix uses readable names.
bootOrderQemu =
@ -212,16 +175,6 @@ let
);
qemuStartVMScript = pkgs.writeShellScript "${vmCfg.name}-qemu-vmix" ''
${optionalString (isMacos && macosShareNames != []) ''
mkdir -p /run/vmix
${concatMapStrings (n: ''
rm -f ${macosShareSock n}
${pkgs.virtiofsd}/bin/virtiofsd --socket-path=${macosShareSock n} --shared-dir ${toString macosShares.${n}.source} --cache auto --sandbox none &
'') macosShareNames}
for i in $(seq 1 50); do ${concatMapStringsSep " && " (n: "[ -S ${macosShareSock n} ]") macosShareNames} && break; sleep 0.2; done
${optionalString macosGuestAgent "${macosMountSharesScript} &"}
''}
${optionalString macosGuestAgent "mkdir -p /run/vmix; rm -f ${qgaSock}"}
${optionalString vmCfg.vnc.enable ''
${optionalString (vmCfg.vnc.passwordFile != null) ''
if [ ! -r ${escapeShellArg vmCfg.vnc.passwordFile} ]; then
@ -255,7 +208,7 @@ let
${optionalString vmCfg.vnc.enable "-vnc ${vncArgs}"} \
${optionalString (vmCfg.spice.enable && vmCfg.spice.passwordFile != null) "-object secret,id=spice-pass-${vmCfg.name},file=${escapeShellArg vmCfg.spice.passwordFile}"} \
${optionalString vmCfg.spice.enable "-spice addr=${vmCfg.spice.addr},port=${toString vmCfg.spice.port}${optionalString (vmCfg.spice.passwordFile == null) ",disable-ticketing=on"}${optionalString (vmCfg.spice.passwordFile != null) ",password-secret=spice-pass-${vmCfg.name}"}"} \
${optionalString (vmCfg.spice.enable && !isMacos) (if vmCfg.spice.displayDevice == "qxl" && vmCfg.spice.vgamem != null then "-vga none -device qxl-vga,vgamem_mb=${toString vmCfg.spice.vgamem}" else "-vga ${vmCfg.spice.displayDevice}")} \
${optionalString vmCfg.spice.enable (if vmCfg.spice.displayDevice == "qxl" && vmCfg.spice.vgamem != null then "-vga none -device qxl-vga,vgamem_mb=${toString vmCfg.spice.vgamem}" else "-vga ${vmCfg.spice.displayDevice}")} \
${optionalString (vmCfg.spice.enable && vmCfg.spice.agent.enable) "-device virtio-serial-pci -chardev spicevmc,id=vdagent,debug=0,name=vdagent -device virtserialport,chardev=vdagent,name=com.redhat.spice.0"} \
${# Guest agent channel — prevents qemu-ga from spinning when virtio-win guest tools are installed
optionalString isWindows "${optionalString (!vmCfg.spice.enable || !vmCfg.spice.agent.enable) "-device virtio-serial-pci"} -chardev socket,path=/tmp/qga-${vmCfg.name}.sock,server=on,wait=off,id=qga0 -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0"} \
@ -275,14 +228,9 @@ let
-device qemu-xhci -device usb-tablet \
-global ICH9-LMB.disable_s3=1 -global ICH9-LMB.disable_s4=1 \
''} \
${# macOS: VirtualSMC, USB keyboard/tablet, AHCI system disk, VMware SVGA (also under SPICE),
# Apple's guest agent, virtio-fs shares (shared memory backend), virtio-blk home volume
${# macOS: AppleSMC + OSK, USB keyboard/tablet, AHCI system disk, VMware SVGA (no SPICE display device)
optionalString isMacos ''
${macosQemu.deviceArgs} ${if vmCfg.spice.enable && vmCfg.spice.displayDevice == "std" then "-vga std" else macosQemu.vgaArgs} \
${optionalString macosGuestAgent (macosQemu.guestAgentArgs qgaSock)} \
${optionalString (macosShareNames != []) (macosQemu.memBackendArgs vmCfg.mem.size)} \
${concatMapStrings (n: "${macosQemu.virtioFsArgs { tag = macosShareTag n; sock = macosShareSock n; id = n; }} \\\n ") macosShareNames} \
${optionalString macosHome (macosQemu.virtioBlkArgs { id = "home"; file = vmCfg.macos.homeDisk.file; format = vmCfg.macos.homeDisk.format; })} \
${vmixLib.macos.qemu.deviceArgs} ${optionalString (!vmCfg.spice.enable) vmixLib.macos.qemu.vgaArgs} \
''} \
${optionalString hasOsDisk (if isMacos
then "-drive id=os,if=none,file=${osDiskPath},format=qcow2${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"} -device ide-hd,bus=sata.0,drive=os"
@ -291,9 +239,9 @@ let
${concatMapStrings (diskCfg: ''
-drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \
'') (attrValues vmCfg.disks.add)} \
${optionalString (!isMacos) (concatStrings (mapAttrsToList (shareName: shareCfg: ''
${concatStrings (mapAttrsToList (shareName: shareCfg: ''
-virtfs local,path=${toString shareCfg.source},security_model=passthrough,mount_tag=${shareName} \
'') vmCfg.shares))} \
'') vmCfg.shares)} \
${optionalString cfg.networks.user.enable "
-netdev user,id=user \
-device ${vmCfg.nicModel},netdev=user${optionalString isMacos ",mac=${macosMac}${macosNicPlacement}"} \
@ -332,8 +280,6 @@ let
ProtectSystem = true;
ProtectHome = true;
PrivateNetwork = true;
RuntimeDirectory = "vmix";
RuntimeDirectoryPreserve = "yes";
} // lib.optionalAttrs (vmCfg.pci.passthrough != []) {
# VFIO passthrough needs raw device access — relax sandboxing
ProtectSystem = lib.mkForce false;

View file

@ -93,9 +93,9 @@ with lib;
};
};
displayDevice = mkOption {
type = types.enum [ "virtio" "qxl" "std" "vmware" "none" ];
type = types.enum [ "virtio" "qxl" "std" "none" ];
default = "qxl";
description = "QEMU -vga type to use with SPICE (qxl, virtio, std, vmware, none). macOS has no QXL/virtio-gpu driver: it always uses vmware (or std).";
description = "QEMU -vga type to use with SPICE (qxl, virtio, std, none).";
};
vgamem = mkOption {
type = types.nullOr types.int;
@ -212,11 +212,11 @@ with lib;
};
target = mkOption {
type = types.str;
description = "Target path inside the VM for the shared directory. macOS: the share named `automount` (or the first one) appears at /Volumes/My Shared Files; others are mounted at target through the guest agent.";
description = "Target path inside the VM for the shared directory.";
};
};
});
description = "Shared directories (9p for Linux, virtio-fs via virtiofsd for macOS).";
description = "Shared directories.";
};
disks.bus = mkOption {
@ -265,38 +265,6 @@ with lib;
default = null;
description = "MAC address of en0. Defaults to the image's macAddress (must match OpenCore's ROM for Apple ID / iMessage).";
};
guestAgent.enable = mkOption {
type = types.bool;
default = true;
description = "Attach Apple's built-in QEMU guest agent (virtio console port org.qemu.guest_agent.0). Socket: /run/vmix/qga-<name>.sock; guest-exec runs as root.";
};
homeDisk = {
enable = mkOption {
type = types.bool;
default = false;
description = "Persistent home volume: a host disk image attached as virtio-blk, formatted APFS with label `label` by the PE on first start. The image must be generalized with persistHome = true (the user's home is /Volumes/<label>/<user>), which makes the OS disk safely ephemeral (disks.os.persist = false).";
};
file = mkOption {
type = types.str;
default = "";
description = "Path of the home disk image, e.g. /storage/vms/mac/home.qcow2 (created if missing).";
};
format = mkOption {
type = types.enum [ "qcow2" "raw" ];
default = "qcow2";
description = "Image format; use raw for a zvol/block device (created only for files).";
};
size = mkOption {
type = types.str;
default = "64G";
description = "Size when the image is created.";
};
label = mkOption {
type = types.str;
default = "vmix-home";
description = "APFS volume label (must match generalize's homeVolumeLabel).";
};
};
};
tpm = {