Compare commits

...

10 commits

Author SHA1 Message Date
3f797af915 macOS soak harness: pass --option sandbox relaxed like vmix build does
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:36:55 -03:00
90cf1c64c8 macOS profile: set the wallpaper after the WindowManager restart
Verified after a reboot: desktoppr reports the configured image. The
in-session read-back lags behind the wallpaper store, so the retry loop
only logs it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:34:28 -03:00
9936b92012 macOS profile: stage the wallpaper in the user's ~/Pictures
WallpaperAgent drops choices whose file is outside the user's space
("No files include in the descriptor" for /Library/Desktop Pictures); from
~/Pictures the choice is written to the wallpaper store and persists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:34:28 -03:00
e6e2e9f18d macOS README: restart-path bisect result
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:34:28 -03:00
e241364ce9 macOS: persistent home lives at /Volumes/vmix-home/<user> (no mount over /Users)
macOS refuses to mount a volume over /Users (firmlink), and a failing fstab
entry also suppresses the automount. Point NFSHomeDirectory at the automounted
volume instead. Profile: Dock entries need tile-type/file URL; wait for the
wallpaper store before shutting down.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:34:28 -03:00
0f9373263d macOS: guest agent, online templates, virtio-fs shares, persistent home volume
Apple's built-in QEMU guest agent (AppleQEMUGuestAgent, launched by launchd
when a virtio console port org.qemu.guest_agent.0 appears; guest-exec as root)
is attached by vmix run --macos and the NixOS module. AppleVirtIO.kext on x86
Tahoe drives virtio-fs, block, console, input, net — verified in QEMU.

- customizeImage: `bootScript` — online step through the guest agent (driver
  mode qga): boot the image, run the script as root with the VMIX volume, shut
  down through the agent. `as_user` runs commands in the logged-in session.
- templates.software: pkg/app (offline in the PE), script/homebrew (online).
- templates.profile.settings: widgets, wallpaper (pinned desktoppr — Apple
  Events need TCC consent that a headless session cannot give), dock apps,
  autohide, dark mode, hidden files.
- generalize: persistHome (fstab LABEL=vmix-home /Users), hideWidgets offline.
- formatVolume: formats a blank disk image as APFS by booting the PE (~35 s);
  idempotent.
- NixOS module: macos.guestAgent (/run/vmix/qga-<name>.sock), shares via
  virtiofsd + vhost-user-fs (Apple automount tag for the first share, others
  mounted through the agent), macos.homeDisk (created + formatted on first
  start, virtio-blk), SPICE keeps -vga vmware for macOS.
- CLI: vmix run --macos --share DIR --home FILE --qga PATH.
- qemu.nix helpers; README section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:34:28 -03:00
50ad8d521c macOS README: soak results
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:33:38 -03:00
779675f87e macOS soak harness: judge runs by the builder's completion line
nix build --rebuild exits non-zero when the byte-wise different qcow2 does not
match the previous output; that is not a failed install.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:33:38 -03:00
22daf7720c macOS: current Lilu/VirtualSMC/WhateverGreen/RestrictEvents, no isa-applesmc
The OSX-KVM ESP ships Lilu 1.6.8 / VirtualSMC 1.3.3 / WhateverGreen 1.6.7,
which disable themselves on macOS 26; Apple's SMC driver then runs on QEMU's
isa-applesmc stub and the restart path panics (SMCWDT smcWriteKey
kSMCBadCommand → nested panic after MACH Reboot). Overlay pinned current
releases (upstream.json opencore.kexts) and drop isa-applesmc: with the stub
present VirtualSMC steps aside ("multiple devices present"); alone it carries
the OSK and reboots work (PE restart test: 10 s, clean). RestrictEvents with
revpatch=memtab silences MacPro7,1's "Memory Modules Misconfigured".

- makeOpenCore: kext overlay + Kernel.Add entries for overlaid kexts,
  --memory-mb (4 DIMMs), bootArgs default revpatch=memtab
- qemu.nix: deviceArgsFor { appleSmc } (default false); cli: --applesmc for
  images built before this change
- vm-driver: reboot-death detection (reset 60 s after a guest reboot request
  that never comes back), panics wait for XNU's own auto-reboot, debug dir
  works across nixbld users
- generalize: QEMU USB keyboard declared ANSI (no Keyboard Setup Assistant)
- README: architecture, reliability handling, debugging

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:33:38 -03:00
8dc8f4265d macOS: drive the install and all customization from a Recovery "PE", no GUI
Replace the screenshot/OCR/keystroke driving of Apple's Recovery with a
"PE": BaseSystem.dmg (a journaled HFS+ volume, writable from Linux) with one
LaunchDaemon added (makeRecoveryPE) that runs /Volumes/VMIX/run.sh as root at
boot, records the status and powers off. launchd loads it alongside its signed
cache (verified on Tahoe 26.6.2); same idea as AutoNBI/Imagr NetBoot images.

- makeImage: the PE runs vmix-install.sh (erase, installer app, SharedSupport
  pkgdmg, startosinstall). Progress is read from the serial console
  (boot-args serial=3 -v, VMIX-* markers) and screenshots (brightness only).
  Fully offline; prepare now takes ~5 min instead of ~10.
- customizeImage: boots the PE with the image attached and runs the template
  offline against the mounted System/Data volumes; OpenCore ScanPolicy
  restricted to HFS+/SATA so only the PE can boot. One PE boot ~30 s. The
  installed macOS is never booted for customization, so nothing depends on
  launchd/BTM approval or a first-boot agent (removed).
- templates rewritten for offline use: generalize creates the user with
  dscl -f (admin, home, auto-login kcpassword, Setup Assistant suppression,
  hostname, locale, timezone, keyboard type, container resize); remote-access,
  no-updates, performance edit the target's plists.
- makeBootDisk: build-time OpenCore variant (serial console, ScanPolicy).
- vm-driver.py rewritten: passive observation only (serial markers, kernel
  boots, panics, brightness), disk+serial-aware hang watchdog, reboot-death
  reset, halt/loginwindow detection. No OCR/tesseract.
- OpenCore: four SMBIOS DIMMs for MacPro7,1 (no "Memory Modules
  Misconfigured" warning).
- tools/soak.sh: repeatability harness.

Verified on daku: base install 23 min end to end; basic + generalize in three
~30 s PE boots; the result auto-logs into the desktop with the created user.

Root cause of the "first-boot hang" (from the serial log): the guest's restart
path panics (IOPlatformHaltRestartAction -> AppleSMC, SMCWDT smcWriteKey
kSMCBadCommand, nested panic) because the pinned OSX-KVM Lilu disables itself
on macOS 26, so VirtualSMC never loads. Handled by the driver (reset within
60 s); kext update to follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:33:38 -03:00
31 changed files with 1525 additions and 993 deletions

49
cli.nix
View file

@ -33,7 +33,11 @@ pkgs.writeShellScriptBin "vmix" ''
echo " --to-remote-disk SSH:DEV Stream to remote disk via SSH and expand partitions" 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 " e.g. root@10.10.10.100:/dev/sda"
echo " --ahci Use AHCI storage for vmix run (for laptop images)" echo " --ahci Use AHCI storage for vmix run (for laptop images)"
echo " --macos macOS image for vmix run (OpenCore/AppleSMC flags, AHCI)" 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 " --vnc DISPLAY VNC instead of SDL for vmix run, e.g. :10 (port 5910) or 0.0.0.0:10" 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 " --mac ADDR NIC MAC for vmix run (macOS: read from the image's ESP by default)"
echo " -y, --yes Skip disk write confirmation" echo " -y, --yes Skip disk write confirmation"
@ -94,12 +98,21 @@ pkgs.writeShellScriptBin "vmix" ''
RUN_MACOS=false RUN_MACOS=false
RUN_VNC="" RUN_VNC=""
RUN_MAC="" RUN_MAC=""
RUN_SHARES=()
RUN_HOME=""
RUN_HOME_IMAGE="macos.images.tahoe.upstream"
RUN_QGA=""
while [[ ''${#} -gt 0 ]]; do while [[ ''${#} -gt 0 ]]; do
case "$1" in case "$1" in
--mem) RUN_MEM="$2"; shift 2 ;; --mem) RUN_MEM="$2"; shift 2 ;;
--smp) RUN_SMP="$2"; shift 2 ;; --smp) RUN_SMP="$2"; shift 2 ;;
--ahci) RUN_AHCI=true; shift ;; --ahci) RUN_AHCI=true; shift ;;
--macos) RUN_MACOS=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 ;; --vnc) RUN_VNC="$2"; shift 2 ;;
--mac) RUN_MAC="$2"; shift 2 ;; --mac) RUN_MAC="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;; *) echo "Unknown option: $1"; exit 1 ;;
@ -134,10 +147,44 @@ 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"; } [[ -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 fi
echo "macOS: yes (MAC $RUN_MAC)" 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 "" echo ""
exec ${pkgs.qemu}/bin/qemu-system-x86_64 \ 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 \ $VMIX_DISPLAY \
${macosQemu.deviceArgs} ${macosQemu.vgaArgs} \ ${macosQemu.deviceArgs} ${macosQemu.vgaArgs} \
$([[ "$RUN_APPLESMC" == true ]] && echo '-device isa-applesmc,osk="${macosQemu.osk}"') \
-accel kvm \ -accel kvm \
-machine type=q35 \ -machine type=q35 \
-cpu ${macosQemu.defaultCpu} \ -cpu ${macosQemu.defaultCpu} \

View file

@ -1,138 +1,177 @@
# vmix macOS images # macOS images (Tahoe 26)
Unattended macOS (Tahoe / 26) VM images, built the same way as the Windows Pre-installed, Apple-ID-capable macOS VM images built the same way as the
images: `makeImage` installs the OS once, templates customize it by booting it, Windows ones: `makeImage` (unattended install) → templates → `.generalize`
`.generalize` creates the user and seals the image. (user, hostname, fresh SMBIOS identity). Runs on QEMU/KVM with OpenCore.
``` ```
vmix build --image macos.images.tahoe.basic \ vmix build --image macos.images.tahoe.basic --generalize username=sagar,password=secret,hostname=MAC
--generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich vmix run ./result --macos --vnc :10 --mem 8192
vmix run ./result --macos --vnc :10 --mem 8192 # VNC on port 5910
``` ```
## How it works Nix: `macos.images.tahoe.{pe,upstream,basic,remote}` and
`<image>.generalize { username; password; hostname; timezone; locale; seed; … }`.
| step | what happens | ## How it works: the vmix "PE"
|---|---|
| `fetchRecovery` | BaseSystem.dmg from Apple's recovery servers (fixed-output, pinned by sha256) |
| `installerPayload` | takes the App Store `InstallAssistant.pkg` (18 GB, pinned) apart on Linux: the app skeleton (pbzx/cpio) and the byte offset of `SharedSupport.dmg` |
| `makeOpenCore` | OSX-KVM's OpenCore ESP with a config.plist rewritten for this image: SMBIOS model, serial + MLB (`macserial`), UUID and ROM = NIC MAC (derived from a seed), NIC marked built-in |
| `makeImage` | one QEMU session: Recovery boots via OpenCore → `vm-driver.py` opens Terminal with keystrokes (Ctrl-F2 menu navigation, screen-settle detection + OCR of the menu bar) and types `sh /Volumes/VMIX/run.sh``vmix-install.sh` erases the disk, rebuilds `Install macOS Tahoe.app` (skeleton + `SharedSupport.dmg` copied from a raw disk mapped straight out of the pkg), runs `startosinstall --installpackage vmix-agent.pkg` → installer reboots through its phases → first boot runs the **vmix agent** which powers off. OpenCore is then copied into the image's EFI partition, so it boots standalone with OVMF |
| `customizeImage` | boots the image with a FAT volume `VMIX`; the agent (LaunchDaemon `ch.vmix.agent`) runs `vmix-run.sh` as root, writes `vmix-run.status`/`.log` back and shuts down |
| `templates.generalize` | user (admin) + auto-login (`/etc/kcpassword`), Setup Assistant suppressed, hostname, timezone, no sleep, APFS grown to the disk, then the agent removes itself; a fresh SMBIOS identity is written to the ESP |
The vmix agent replaces Windows' Audit Mode RunOnce; `.AppleSetupDone` replaces Apple's Recovery (`BaseSystem.dmg`, a plain journaled HFS+ volume) with **one
the OOBE unattend. Everything on the host side runs inside `__noChroot` LaunchDaemon added** (`makeRecoveryPE`): at boot it mounts a `VMIX` volume and
derivations (KVM + `/tmp`), exactly like the Windows builders. runs `run.sh` from it as root, records the exit status and powers off. That is
the whole automation surface — the equivalent of Windows PE + Autounattend:
## Generalize options * **no GUI is driven**: no OCR, no keystrokes, no screen layouts to learn per
macOS version; the hook is a launchd plist, stable across releases (same idea
as AutoNBI/Imagr NetBoot images).
* **observable**: the guest prints `VMIX-*` markers to `/dev/console`, which the
build reads from QEMU's serial log (`boot-args serial=3 -v`). Kernel panics and
reboots show up there too. Screenshots are still taken for debugging.
* **offline**: no NIC during the install, and the guest blackholes Apple's
install/verify endpoints so `startosinstall` never waits on the network. The
only inputs are the pinned `InstallAssistant.pkg` and `BaseSystem.dmg`.
* **everything else happens offline from the PE too**: templates and generalize
mount the image's Data volume (rw) and System volume (ro) and edit them
(`dscl -f` for users, `plutil` for preferences) — the installed macOS is
never booted for customization, so nothing depends on launchd/BTM approval,
first-boot agents or auto-login inside the guest. One PE boot ≈ 30 s.
`username password fullName autoLogon hostname locale timezone delayOobeRun` ### Pipeline
as for Windows (`bgColor` is accepted but ignored), plus the SMBIOS identity:
`model serial mlb uuid mac seed`. Anything unset is generated: serial/MLB by
macserial (random per build), MAC and UUID deterministically from `seed`
(default `hostname-username`). `vmix macserial --model MacPro7,1` prints a
ready-to-paste set.
`delay-oobe-run=true` creates no user and re-arms Setup Assistant for the first 1. `makeRecoveryPE` — BaseSystem.dmg → raw HFS+ image + `ch.vmix.pe` daemon.
real boot. 2. `makeImage` — QEMU with: OpenCore boot disk (build variant with serial
console), the PE, the empty target disk, the VMIX volume (`vmix-install.sh`,
## Apple ID / iMessage installer app skeleton) and the whole `InstallAssistant.pkg` mapped as a raw
disk. The guest script erases the target as APFS, unpacks the app and `dd`s
The image satisfies what Dortania lists for iServices: unique serial + MLB for a the pkg into it as `SharedSupport.dmg` (it is a "pkgdmg": xar + koly footer;
Tahoe-supported model (`MacPro7,1` by default; `iMac20,1/2`, the bare xar member fails with "pkgdmg is missing a footer"), then runs
`MacBookPro16,x` also work), SystemUUID, ROM equal to en0's MAC, and en0 marked `startosinstall`, which reboots itself through the install phases. The
built-in (the NIC is pinned to `PciRoot(0x0)/Pci(0x12,0x0)`). The NixOS module installed system's first boot ends at the loginwindow: the driver detects the
and `vmix run --macos` use the MAC recorded in the image (`EFI/vmix/vmix.json`). bright screen and powers the VM down. OpenCore is then copied into the image's
Give each deployed VM its own generalized image (different `seed`, or explicit own ESP so it boots with plain OVMF.
`serial=`/`mlb=`) — two VMs with the same identity will be blocked. 3. `customizeImage` — boots the PE with the image attached (OpenCore
`ScanPolicy` restricted to HFS+ on SATA, so only the PE can boot) and runs the
## Runtime template script with `$SYS`/`$DATA` mounted. `pe-lib.sh` has the helpers.
4. `templates/generalize.nix` — user (dscl, admin, home from the user template),
* `vmix run <qcow2> --macos [--vnc :N] [--mac ..]` auto-login (`kcpassword`), Setup Assistant suppression, hostname, locale,
* NixOS module: `disks.os.file = vmixLib.macos.images.tahoe.basic.generalize {...}` timezone, keyboard type, container resize, fresh SMBIOS via a new OpenCore
is auto-detected (`_vmixOsType = "macos"`): Skylake-Client CPU spoof, AppleSMC, ESP (`serial`/`mlb` from macserial, MAC + UUID from `seed`).
USB keyboard/tablet, AHCI system disk, VMware SVGA, pinned NIC with the image's MAC.
`macos.cpu`, `macos.mac`, `macos.enable` override the defaults.
* `vmix copy` writes the image to a disk but cannot grow APFS from Linux
(`diskutil apfs resizeContainer disk0s2 0` in macOS afterwards).
## Debugging a build
Screenshots (`NNN-<state>.png`), `driver.log` and the QMP socket of every VM
session are in `/tmp/vmix-macos/<image name>/` on the build host. The guest logs
(`install.log`, `vmix-run.log`, `vmix-agent.log`) are printed at the end of the
build. Pass `vncDisplay = ":10"` to `makeImage`/`customizeImage` (or
`--generalize vncDisplay=:10`) to watch live; with a `DISPLAY` an SDL window
is used as for Windows.
## Updating pins (`upstream.json`)
* installer: URL + SRI hash of a newer `InstallAssistant.pkg`
(`nix store prefetch-file --name InstallAssistant.pkg <url>`; Mr. Macintosh's
database lists Apple's URLs)
* recovery: Apple serves the current build for the board id, so the sha256
changes with each point release — copy the "got:" hash from the failed build
* opencore: OSX-KVM `OpenCore.qcow2` at a commit; OpenCorePkg release zip (macserial/ocvalidate)
## Known limits
* The Recovery bootstrap depends on keyboard navigation of the Recovery UI
(Ctrl-F2 → Utilities → Terminal). It self-corrects with screenshots + OCR and
falls back to a blind sequence, but a Recovery UI change would need
`vm-driver.py` adjusted.
* Hosts must run KVM with an AVX2-capable CPU (Intel or AMD; the guest sees a
Skylake). `sandbox = relaxed` and the `kvm` system feature, as for Windows.
* Software updates inside the VM are disabled by the `noUpdates` template
(OTA updates in a VM need the RestrictEvents kext).
## Current status (2026-09-09): working offline install
`macos.images.tahoe.upstream` builds a bootable, installed macOS Tahoe 26.6.2
qcow2 **fully offline** on the KVM host — no dependency on Apple's servers at build
time, just the pinned local `InstallAssistant.pkg` and `BaseSystem.dmg`. The
finished image boots standalone (OpenCore from its own ESP) to the macOS
loginwindow. Serial/MLB/UUID/ROM are per-image for Apple ID / iMessage.
How the install is driven (`vm-driver.py`, all by screenshot + OCR over QMP):
* The whole `InstallAssistant.pkg` is mapped as a raw disk (it is a "pkgdmg":
xar + koly footer) and `dd`'d byte-exact into the app as `SharedSupport.dmg`
extracting the bare xar member fails startosinstall with "pkgdmg missing a footer".
* No NIC during install + `/etc/hosts` blackhole of Apple's install/verify
endpoints, so `startosinstall`'s network calls fail fast instead of hanging —
offline prepare, no external dependency. `SecureBootModel=Disabled` lets the
sealed volume install without online personalization.
* The recovery display is kept awake with a tiny mouse jiggle (a lone keypress
does not reset display sleep, and the sleeping display swallows the menu-nav
keystrokes); the settle detector uses a coarse fingerprint so the jiggling
cursor is not seen as a screen change.
* startosinstall prepare is intermittently slow/stalls; a guest watchdog kills and
re-erases/retries an attempt that stalls or runs > 9 min.
* First boot in QEMU intermittently hangs at the Apple logo; a disk-aware watchdog
(`--progress-file`) issues a QMP `system_reset` only when the screen is dark AND
the disk is idle, so a slow-but-working boot is never interrupted.
* The install reaching the (bright) loginwindow is detected by brightness (the
faint gray "password" text does not OCR) and the driver powers the VM down —
the image is installed. macOS `shutdown -h now` halts to black without an ACPI
power-off, so a black+disk-idle screen is also treated as a completed halt.
* OpenCore is then copied into the image's own ESP so it boots standalone with OVMF.
### Recovery source ### Recovery source
`recovery.file` in `upstream.json` points at a content-addressed store path for the `recovery.file` in `upstream.json` points at a content-addressed store path for
verified Tahoe `BaseSystem.dmg` (Apple's CDN load-balances Sequoia/Tahoe during the the verified Tahoe `BaseSystem.dmg` (Apple's CDN load-balances Sequoia/Tahoe
rollout, so a plain fetch is non-deterministic). Reproduce it on any host with during the rollout, so a plain fetch is non-deterministic). Reproduce it on any
`nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg` (same path host with `nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg`.
from the same bytes). Set `recovery.sha256` and remove `recovery.file` to fetch it Drop `recovery.file` to fetch from Apple instead (`fetchRecovery` retries until
from Apple instead (subject to the CDN rollout). the pinned hash matches).
### Not yet done: generalize / user creation ## Reliability
The base image installs and boots to loginwindow. `.generalize` (user creation, Things QEMU does intermittently, and what handles each (all in `vm-driver.py`
auto-login, hostname) relies on the vmix agent LaunchDaemon running on first boot, and `vmix-install.sh`; every event is logged with a reason):
but macOS Ventura+ Background Task Management does not auto-run a headless
third-party daemon, and neither the pkg `launchctl bootstrap` (installer domain * `startosinstall` prepare stalls or crawls — the guest kills and retries it on a
only) nor a cron `@reboot` reliably triggered it. The robust next step is to inject freshly erased target (free-space watchdog + time cap).
the user record + settings offline from the agent pkg's postinstall (which runs as * the installer comes back to the PE instead of the install phase — the PE
root on the target during install), instead of a first-boot daemon. counts boots and simply re-runs the install (max 3).
* the installed system hangs at the Apple logo on first boot — a `system_reset`
is issued only when the screen is dark and frozen **and** disk and serial
console are idle, so a slow-but-working boot is never interrupted.
* macOS `shutdown -h` halts to a black screen without an ACPI power-off — an
idle black screen counts as a completed halt.
* a kernel panic (seen on the serial console) resets the VM.
* a wedged run fails at the 4 h timeout instead of hanging.
`tools/soak.sh <flake> macos.images.tahoe.upstream 3` rebuilds an image N
times and tabulates outcome, duration, boots, resets, panics and retries.
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.

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -13,14 +13,21 @@
uuid, uuid,
serial ? null, serial ? null,
mlb ? null, mlb ? null,
bootArgs ? "keepsyms=1", bootArgs ? "keepsyms=1 revpatch=memtab",
resolution ? "1024x768", resolution ? "1024x768",
showPicker ? true, showPicker ? true,
pickerTimeout ? 2, pickerTimeout ? 2,
extraConfig ? {}, extraConfig ? {},
memSize ? 8192, # RAM described in SMBIOS (MacPro7,1 wants 4 DIMMs)
}: }:
let let
ocImage = pkgs.fetchurl { inherit (upstream.opencore.image) url sha256; name = "OSX-KVM-OpenCore.qcow2"; }; ocImage = pkgs.fetchurl { inherit (upstream.opencore.image) url sha256; name = "OSX-KVM-OpenCore.qcow2"; };
# 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 in
pkgs.runCommand "${name}-esp" { pkgs.runCommand "${name}-esp" {
nativeBuildInputs = with pkgs; [ _7zz python3 mtools dosfstools gptfdisk jq macserial ]; nativeBuildInputs = with pkgs; [ _7zz python3 mtools dosfstools gptfdisk jq macserial ];
@ -44,13 +51,23 @@ pkgs.runCommand "${name}-esp" {
mkdir -p $out/EFI/vmix mkdir -p $out/EFI/vmix
cp -r esp/EFI/BOOT esp/EFI/OC $out/EFI/ cp -r esp/EFI/BOOT esp/EFI/OC $out/EFI/
chmod -R u+w $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) # hide OpenCore's own launcher from its picker (otherwise it is the default entry and loops)
echo -n Disabled > $out/EFI/BOOT/.contentVisibility 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 \ 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}" \ --model "${model}" --serial "$SERIAL" --mlb "$MLB" --uuid "${uuid}" --mac "${mac}" \
--nic-path "${qemu.nicDevicePath}" --boot-args "${bootArgs}" --resolution "${resolution}" \ --nic-path "${qemu.nicDevicePath}" --boot-args "${bootArgs}" --resolution "${resolution}" \
--show-picker "${lib.boolToString showPicker}" --timeout ${toString pickerTimeout} \ --show-picker "${lib.boolToString showPicker}" --timeout ${toString pickerTimeout} \
--extra-json ${lib.escapeShellArg (builtins.toJSON extraConfig)} --extra-json ${lib.escapeShellArg (builtins.toJSON extraConfig)} --memory-mb ${toString memSize} --add-kexts ${lib.concatStringsSep "," (map (k: k.name) kextZips)}
ocvalidate $out/EFI/OC/config.plist || echo "vmix: ocvalidate reported issues (OpenCore version may differ from validator), continuing" ocvalidate $out/EFI/OC/config.plist || echo "vmix: ocvalidate reported issues (OpenCore version may differ from validator), continuing"
jq -n --arg model "${model}" --arg serial "$SERIAL" --arg mlb "$MLB" --arg uuid "${uuid}" --arg mac "${mac}" \ jq -n --arg model "${model}" --arg serial "$SERIAL" --arg mlb "$MLB" --arg uuid "${uuid}" --arg mac "${mac}" \

View file

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

View file

@ -43,6 +43,8 @@ def main():
p.add_argument('--show-picker', default='true') p.add_argument('--show-picker', default='true')
p.add_argument('--timeout', type=int, default=2) p.add_argument('--timeout', type=int, default=2)
p.add_argument('--extra-json', default='{}') p.add_argument('--extra-json', default='{}')
p.add_argument('--memory-mb', type=int, default=8192, help='VM RAM, described as 4 DIMMs')
p.add_argument('--add-kexts', default='', help='comma-separated kext names (without .kext) that need a Kernel.Add entry')
a = p.parse_args() a = p.parse_args()
with open(a.base, 'rb') as f: with open(a.base, 'rb') as f:
@ -84,6 +86,33 @@ def main():
cfg['Misc']['Boot']['Timeout'] = a.timeout cfg['Misc']['Boot']['Timeout'] = a.timeout
cfg['Misc']['Boot']['HideAuxiliary'] = True cfg['Misc']['Boot']['HideAuxiliary'] = True
cfg['Misc']['Security']['ScanPolicy'] = 0 cfg['Misc']['Security']['ScanPolicy'] = 0
# 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']['SecureBootModel'] = 'Disabled'
cfg['Misc']['Security']['AllowSetDefault'] = True cfg['Misc']['Security']['AllowSetDefault'] = True
cfg['Misc']['Debug']['Target'] = 0 cfg['Misc']['Debug']['Target'] = 0

View file

@ -17,19 +17,49 @@ rec {
netArgs = { mac, netdev ? "user,id=net0", extra ? "" }: netArgs = { mac, netdev ? "user,id=net0", extra ? "" }:
"-netdev ${netdev} -device virtio-net-pci,netdev=net0,mac=${mac},bus=pcie.0,addr=${nicAddr}${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) # 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''; # 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 { };
# macOS has no QXL/virtio-gpu driver; VMware SVGA gives a plain framebuffer # macOS has no QXL/virtio-gpu driver; VMware SVGA gives a plain framebuffer
vgaArgs = "-vga vmware"; vgaArgs = "-vga vmware";
machineArgs = { cpu ? defaultCpu, smp ? 4, memSize ? 4096 }: 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} ${deviceArgs} ${vgaArgs}"; "-accel kvm -machine type=q35 -cpu ${cpu} -smp ${toString smp},sockets=1,cores=${toString smp},threads=1 -m ${toString memSize} ${deviceArgsFor { inherit appleSmc; }} ${vgaArgs}";
# SATA disk on a given port. Store files are read-only: callers create qcow2 overlays. # SATA disk on a given port. Store files are read-only: callers create qcow2 overlays.
sataDrive = { id, port, file, format ? "qcow2", extra ? "" }: sataDrive = { id, port, file, format ? "qcow2", extra ? "" }:
"-drive id=${id},if=none,format=${format},file=${file}${extra} -device ide-hd,bus=sata.${toString port},drive=${id}"; "-drive id=${id},if=none,format=${format},file=${file}${extra} -device ide-hd,bus=sata.${toString port},drive=${id}";
# XNU logs to COM1 with boot-args serial=3; the build drivers read this file
serialArgs = file: "-serial file:${file}";
# 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: firmwareArgs = varsFile:
"-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd -drive if=pflash,format=raw,file=${varsFile}"; "-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd -drive if=pflash,format=raw,file=${varsFile}";
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

@ -0,0 +1,31 @@
#!/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,6 +28,28 @@
"fetchRecoveryScript": { "fetchRecoveryScript": {
"url": "https://raw.githubusercontent.com/kholia/OSX-KVM/4c378a4b5e0b219783683012bec680325eb40719/fetch-macOS-v2.py", "url": "https://raw.githubusercontent.com/kholia/OSX-KVM/4c378a4b5e0b219783683012bec680325eb40719/fetch-macOS-v2.py",
"sha256": "39ac6d26bd265f5d32198062f515ad15ef93afb7a74e702be2b008090d5bd5f3" "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,6 +107,42 @@ let
(unique (map pciDeviceOf vmCfg.pci.passthrough)); (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 # Linux VMs: apply customizeImage with 9p fstab and machine-id setup
linuxOsImage = vmixLib.linux.customizeImage vmCfg.disks.os.file { linuxOsImage = vmixLib.linux.customizeImage vmCfg.disks.os.file {
name = vmCfg.name; name = vmCfg.name;
@ -140,7 +176,8 @@ let
fi fi
''; '';
persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript; persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript
++ lib.optional macosHome seedHomeDiskScript;
# QEMU expects single-letter boot codes (e.g. c,d,n), while vmix uses readable names. # QEMU expects single-letter boot codes (e.g. c,d,n), while vmix uses readable names.
bootOrderQemu = bootOrderQemu =
@ -175,6 +212,16 @@ let
); );
qemuStartVMScript = pkgs.writeShellScript "${vmCfg.name}-qemu-vmix" '' 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.enable ''
${optionalString (vmCfg.vnc.passwordFile != null) '' ${optionalString (vmCfg.vnc.passwordFile != null) ''
if [ ! -r ${escapeShellArg vmCfg.vnc.passwordFile} ]; then if [ ! -r ${escapeShellArg vmCfg.vnc.passwordFile} ]; then
@ -208,7 +255,7 @@ let
${optionalString vmCfg.vnc.enable "-vnc ${vncArgs}"} \ ${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 && 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 "-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 (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 && !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 && 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"} \ ${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 ${# 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"} \ 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"} \
@ -228,9 +275,14 @@ let
-device qemu-xhci -device usb-tablet \ -device qemu-xhci -device usb-tablet \
-global ICH9-LMB.disable_s3=1 -global ICH9-LMB.disable_s4=1 \ -global ICH9-LMB.disable_s3=1 -global ICH9-LMB.disable_s4=1 \
''} \ ''} \
${# macOS: AppleSMC + OSK, USB keyboard/tablet, AHCI system disk, VMware SVGA (no SPICE display device) ${# 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
optionalString isMacos '' optionalString isMacos ''
${vmixLib.macos.qemu.deviceArgs} ${optionalString (!vmCfg.spice.enable) vmixLib.macos.qemu.vgaArgs} \ ${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; })} \
''} \ ''} \
${optionalString hasOsDisk (if isMacos ${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" 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"
@ -239,9 +291,9 @@ let
${concatMapStrings (diskCfg: '' ${concatMapStrings (diskCfg: ''
-drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \ -drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \
'') (attrValues vmCfg.disks.add)} \ '') (attrValues vmCfg.disks.add)} \
${concatStrings (mapAttrsToList (shareName: shareCfg: '' ${optionalString (!isMacos) (concatStrings (mapAttrsToList (shareName: shareCfg: ''
-virtfs local,path=${toString shareCfg.source},security_model=passthrough,mount_tag=${shareName} \ -virtfs local,path=${toString shareCfg.source},security_model=passthrough,mount_tag=${shareName} \
'') vmCfg.shares)} \ '') vmCfg.shares))} \
${optionalString cfg.networks.user.enable " ${optionalString cfg.networks.user.enable "
-netdev user,id=user \ -netdev user,id=user \
-device ${vmCfg.nicModel},netdev=user${optionalString isMacos ",mac=${macosMac}${macosNicPlacement}"} \ -device ${vmCfg.nicModel},netdev=user${optionalString isMacos ",mac=${macosMac}${macosNicPlacement}"} \
@ -280,6 +332,8 @@ let
ProtectSystem = true; ProtectSystem = true;
ProtectHome = true; ProtectHome = true;
PrivateNetwork = true; PrivateNetwork = true;
RuntimeDirectory = "vmix";
RuntimeDirectoryPreserve = "yes";
} // lib.optionalAttrs (vmCfg.pci.passthrough != []) { } // lib.optionalAttrs (vmCfg.pci.passthrough != []) {
# VFIO passthrough needs raw device access — relax sandboxing # VFIO passthrough needs raw device access — relax sandboxing
ProtectSystem = lib.mkForce false; ProtectSystem = lib.mkForce false;

View file

@ -93,9 +93,9 @@ with lib;
}; };
}; };
displayDevice = mkOption { displayDevice = mkOption {
type = types.enum [ "virtio" "qxl" "std" "none" ]; type = types.enum [ "virtio" "qxl" "std" "vmware" "none" ];
default = "qxl"; default = "qxl";
description = "QEMU -vga type to use with SPICE (qxl, virtio, std, none)."; 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).";
}; };
vgamem = mkOption { vgamem = mkOption {
type = types.nullOr types.int; type = types.nullOr types.int;
@ -212,11 +212,11 @@ with lib;
}; };
target = mkOption { target = mkOption {
type = types.str; type = types.str;
description = "Target path inside the VM for the shared directory."; 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 = "Shared directories."; description = "Shared directories (9p for Linux, virtio-fs via virtiofsd for macOS).";
}; };
disks.bus = mkOption { disks.bus = mkOption {
@ -265,6 +265,38 @@ with lib;
default = null; default = null;
description = "MAC address of en0. Defaults to the image's macAddress (must match OpenCore's ROM for Apple ID / iMessage)."; 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 = { tpm = {