Compare commits

...
Sign in to create a new pull request.

12 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
58a317f5d2 macOS Tahoe: working fully-offline unattended install
The base image (macos.images.tahoe.upstream) now installs and boots end to end
with no dependency on Apple's servers — proven on the KVM host: the finished
qcow2 boots standalone (OpenCore from its own ESP) to the macOS 26.6.2 loginwindow.

Install driving (vm-driver.py, screenshot + OCR over QMP):
- map the whole InstallAssistant.pkg as a raw disk and dd it byte-exact into the
  app as SharedSupport.dmg (it is a pkgdmg: xar + koly footer — the bare xar
  member fails startosinstall with "pkgdmg is missing a footer")
- offline install: no NIC + /etc/hosts blackhole of Apple install/verify
  endpoints so startosinstall's calls fail fast instead of hanging
  (SecureBootModel=Disabled allows the sealed-volume install offline)
- keep the recovery display awake with a tiny mouse jiggle; coarse settle
  fingerprint so the cursor is not seen as a change
- guest watchdog re-erases/retries a startosinstall attempt that stalls or runs
  too long (prepare is intermittently slow)
- disk-aware boot watchdog: QMP system_reset only when the screen is dark AND the
  disk is idle (never interrupts a slow-but-working boot); recovery-restart if a
  post-prepare reboot lands back on recovery
- detect the bright loginwindow and power the VM down (install complete); a
  black + disk-idle screen is treated as a completed halt
- copy OpenCore into the image ESP so it boots standalone

recovery.file pins a content-addressed local BaseSystem.dmg (Apple's CDN
load-balances Sequoia/Tahoe during the rollout). fetchRecovery retries to the
pinned hash when used instead.

Not yet done: .generalize (user creation) — the first-boot agent LaunchDaemon is
blocked by Ventura+ Background Task Management on headless boots; next step is
offline user injection from the agent pkg postinstall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:33:38 -03:00
242e48a5fc macOS Tahoe VM images (OpenCore/QEMU), Apple-ID compatible, VNC
Add a macOS image pipeline mirroring the Windows one: unattended install,
generalization and user creation, driven end to end in QEMU on a KVM host.

lib/images/macos:
- makeOpenCore: OSX-KVM OpenCore ESP with a config.plist rewritten per image —
  SMBIOS model + serial/MLB (macserial) + UUID + ROM=en0 MAC (built-in NIC pinned
  to PciRoot(0x0)/Pci(0x12,0x0)) for Apple ID / iMessage / App Store; boot disk;
  OpenCore self-entry hidden. ident.nix derives MAC+UUID from a seed so the NixOS
  module and CLI know the NIC MAC at eval time.
- makeImage: one QEMU session driven by vm-driver.py (QMP + screenshot settle
  detection + OCR of the menu bar) — boots the recovery via OpenCore, opens
  Terminal (Ctrl-F2 -> Utilities -> Terminal), types the bootstrap command;
  vmix-install.sh erases the disk as APFS, lays down the host-extracted installer
  app skeleton + a byte-exact SharedSupport.dmg (raw disk mapped to that byte range
  of the pkg, dd'd in — Recovery's xar truncates an 18 GB member), runs
  startosinstall with the vmix agent pkg. OpenCore is then copied into the image's
  ESP so it boots standalone with OVMF.
- customizeImage / templates: boot the image with a FAT-then-HFS+ VMIX volume; the
  vmix agent (LaunchDaemon) runs a script as root, records status and powers off —
  the macOS counterpart of Windows Audit Mode. generalize creates the admin user +
  auto-login (kcpassword), suppresses Setup Assistant, sets hostname/timezone,
  grows APFS, and assigns a fresh SMBIOS identity. Templates: noUpdates,
  performance, remoteAccess (ssh + screen sharing).
- fetchRecovery: Apple recovery BaseSystem, retried until the pinned Tahoe build
  (osrecovery load-balances Sequoia/Tahoe during the rollout). makeAgentPkg builds
  a distribution flat pkg on Linux (xar+bom+cpio) for startosinstall --installpackage.

CLI: vmix build/copy/run for macOS (run --macos --vnc, reads the image's MAC from
its ESP), and a `vmix macserial` helper. NixOS module: disks.os.file carrying
_vmixOsType="macos" auto-enables the macOS QEMU profile (AppleSMC+OSK, Skylake
CPU spoof, AHCI system disk, VMware SVGA, pinned NIC); macos.{enable,cpu,mac}.

Status: proven through the installer prepare phase (SharedSupport.dmg mounts,
version 26.6.2 read, SU catalog loads). Two blockers remain, documented in
lib/images/macos/README.md: (1) startosinstall's OSISVerifyBaseSystemOperation
rejects the byte-identical plain-UDIF SharedSupport as "pkgdmg missing a footer"
in this Tahoe recovery/VM; (2) Apple's CDN unreliably serves the Tahoe recovery
during rollout (self-hosting the verified BaseSystem is the robust fix).

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
41 changed files with 2669 additions and 44 deletions

171
cli.nix
View file

@ -1,5 +1,9 @@
# vmix CLI — build, copy, and run Windows images # vmix CLI — build, copy, and run Windows / macOS images
{ pkgs, self, system }: { pkgs, self, system, vmixLib }:
let
macosQemu = vmixLib.macos.qemu;
macserial = vmixLib.macos.macserial;
in
pkgs.writeShellScriptBin "vmix" '' pkgs.writeShellScriptBin "vmix" ''
set -euo pipefail set -euo pipefail
@ -8,23 +12,34 @@ pkgs.writeShellScriptBin "vmix" ''
echo " vmix build --image <path> [--generalize key=val,...] [--out-link PATH]" echo " vmix build --image <path> [--generalize key=val,...] [--out-link PATH]"
echo " vmix copy --image <path> [--generalize key=val,...] --to-disk /dev/sdX" echo " vmix copy --image <path> [--generalize key=val,...] --to-disk /dev/sdX"
echo " vmix copy --image <path> [--generalize key=val,...] --to-remote-disk user@host:/dev/sdX" echo " vmix copy --image <path> [--generalize key=val,...] --to-remote-disk user@host:/dev/sdX"
echo " vmix run <qcow2-file> [--mem 4096] [--smp 4] [--ahci]" echo " vmix run <qcow2-file> [--mem 4096] [--smp 4] [--ahci] [--macos] [--vnc :N] [--mac XX:..]"
echo " vmix macserial [--model MacPro7,1]"
echo "" echo ""
echo "Commands:" echo "Commands:"
echo " build Build a vmix image (optionally generalized)" echo " build Build a vmix image (optionally generalized)"
echo " copy Build a vmix image and write it to a disk" echo " copy Build a vmix image and write it to a disk"
echo " run Boot a qcow2 image with QEMU (SDL if DISPLAY available)" echo " run Boot a qcow2 image with QEMU (SDL if DISPLAY available, or --vnc)"
echo " macserial Generate a SMBIOS identity (serial, MLB, UUID, MAC) for --generalize"
echo "" echo ""
echo "Options:" echo "Options:"
echo " --image PATH Image path in vmixLib (e.g. windows.images.win10.laptop)" echo " --image PATH Image path in vmixLib (e.g. windows.images.win10.laptop,"
echo " macos.images.tahoe.basic)"
echo " --generalize KEY=VAL,... Finalize image with comma-separated options:" echo " --generalize KEY=VAL,... Finalize image with comma-separated options:"
echo " username=User password= hostname=PC" echo " username=User password= hostname=PC"
echo " timezone=UTC bgColor=8e8cd8" echo " timezone=UTC bgColor=8e8cd8 (Windows only)"
echo " delay-oobe-run=true (OOBE + activation on real hardware)" echo " delay-oobe-run=true (OOBE/Setup Assistant on real hardware)"
echo " macOS SMBIOS: model=MacPro7,1 serial=... mlb=... uuid=... mac=... seed=..."
echo " --to-disk DEVICE Write to local disk and expand partitions" echo " --to-disk DEVICE Write to local disk and expand partitions"
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/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 " --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"
echo " --out-link PATH Symlink for the build result (default: ./result)" echo " --out-link PATH Symlink for the build result (default: ./result)"
echo "" echo ""
@ -32,9 +47,9 @@ pkgs.writeShellScriptBin "vmix" ''
echo " vmix build --image windows.images.win10.laptop \\" echo " vmix build --image windows.images.win10.laptop \\"
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP" echo " --generalize username=Sagar,password=secret,hostname=LAPTOP"
echo "" echo ""
echo " vmix copy --image windows.images.win10.laptop \\" echo " vmix build --image macos.images.tahoe.basic \\"
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\" echo " --generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich"
echo " --to-disk /dev/sda" echo " vmix run ./result --macos --vnc :10 --mem 8192"
echo "" echo ""
echo " vmix copy --image windows.images.win10.laptop \\" echo " vmix copy --image windows.images.win10.laptop \\"
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\" echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\"
@ -49,23 +64,57 @@ pkgs.writeShellScriptBin "vmix" ''
COMMAND="$1"; shift COMMAND="$1"; shift
case "$COMMAND" in case "$COMMAND" in
build|copy|run) ;; build|copy|run|macserial) ;;
--help|-h) usage ;; --help|-h) usage ;;
*) echo "Unknown command: $COMMAND"; usage ;; *) echo "Unknown command: $COMMAND"; usage ;;
esac esac
# --- macserial command ---
if [[ "$COMMAND" == "macserial" ]]; then
MODEL="MacPro7,1"
while [[ ''${#} -gt 0 ]]; do
case "$1" in
--model) MODEL="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
LINE=$(${macserial}/bin/macserial --num 1 --model "$MODEL" 2>/dev/null | grep '|' | tail -1)
[[ -z "$LINE" ]] && { echo "Error: macserial produced nothing for model $MODEL"; exit 1; }
SERIAL=$(echo "$LINE" | awk -F' *\\| *' '{print $1}')
MLB=$(echo "$LINE" | awk -F' *\\| *' '{print $2}')
UUID=$(cat /proc/sys/kernel/random/uuid | tr a-f A-F)
MAC=$(printf '52:54:00:%02x:%02x:%02x' $((RANDOM % 256)) $((RANDOM % 256)) $((RANDOM % 256)))
echo "model=$MODEL,serial=$SERIAL,mlb=$MLB,uuid=$UUID,mac=$MAC"
exit 0
fi
# --- run command --- # --- run command ---
if [[ "$COMMAND" == "run" ]]; then if [[ "$COMMAND" == "run" ]]; then
[[ ''${#} -lt 1 ]] && { echo "Error: vmix run <qcow2-file> [--mem 4096] [--smp 4] [--ahci]"; exit 1; } [[ ''${#} -lt 1 ]] && { echo "Error: vmix run <qcow2-file> [--mem 4096] [--smp 4] [--ahci] [--macos] [--vnc :N] [--mac XX:XX:XX:XX:XX:XX]"; exit 1; }
RUN_INPUT="$1"; shift RUN_INPUT="$1"; shift
RUN_MEM=4096 RUN_MEM=4096
RUN_SMP=4 RUN_SMP=4
RUN_AHCI=false RUN_AHCI=false
RUN_MACOS=false
RUN_VNC=""
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 ;;
--applesmc) RUN_APPLESMC=true; shift ;;
--share) RUN_SHARES+=("$2"); shift 2 ;;
--home) RUN_HOME="$2"; shift 2 ;;
--home-image) RUN_HOME_IMAGE="$2"; shift 2 ;;
--qga) RUN_QGA="$2"; shift 2 ;;
--vnc) RUN_VNC="$2"; shift 2 ;;
--mac) RUN_MAC="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;; *) echo "Unknown option: $1"; exit 1 ;;
esac esac
done done
@ -74,7 +123,9 @@ pkgs.writeShellScriptBin "vmix" ''
[[ ! -f "$RUN_IMAGE" ]] && { echo "Error: file not found: $RUN_IMAGE"; exit 1; } [[ ! -f "$RUN_IMAGE" ]] && { echo "Error: file not found: $RUN_IMAGE"; exit 1; }
VMIX_DISPLAY="-nographic" VMIX_DISPLAY="-nographic"
if [[ -n "''${DISPLAY:-}" ]]; then if [[ -n "$RUN_VNC" ]]; then
VMIX_DISPLAY="-display none -vnc $RUN_VNC"
elif [[ -n "''${DISPLAY:-}" ]]; then
VMIX_DISPLAY="-display sdl" VMIX_DISPLAY="-display sdl"
fi fi
@ -87,6 +138,64 @@ pkgs.writeShellScriptBin "vmix" ''
echo "Memory: $RUN_MEM MB" echo "Memory: $RUN_MEM MB"
echo "CPUs: $RUN_SMP" echo "CPUs: $RUN_SMP"
echo "Display: $VMIX_DISPLAY" echo "Display: $VMIX_DISPLAY"
if [[ "$RUN_MACOS" == "true" ]]; then
# OpenCore's ROM must match en0's MAC: the image records it in its ESP
if [[ -z "$RUN_MAC" ]]; then
RUN_MAC=$(${pkgs.libguestfs-with-appliance}/bin/guestfish --ro -a "$RUN_IMAGE" -m /dev/sda1 cat /EFI/vmix/vmix.json 2>/dev/null \
| ${pkgs.jq}/bin/jq -r .mac 2>/dev/null || true)
[[ -z "$RUN_MAC" || "$RUN_MAC" == "null" ]] && { RUN_MAC="52:54:00:c9:18:27"; echo "Warning: could not read MAC from image ESP, using $RUN_MAC"; }
fi
echo "macOS: yes (MAC $RUN_MAC)"
# Apple's built-in QEMU guest agent: guest-exec as root over this socket
[[ -z "$RUN_QGA" ]] && RUN_QGA="/tmp/vmix-qga-$$.sock"
rm -f "$RUN_QGA"
echo "Agent: $RUN_QGA (guest-exec as root)"
# virtio-fs shares: the first one auto-mounts at /Volumes/My Shared Files, the
# others are mounted with: mount -t virtiofs <tag> /Volumes/<tag>
MACOS_SHARE_ARGS=""
MACOS_MEM_ARGS=""
i=0
for SHARE in "''${RUN_SHARES[@]}"; do
i=$((i + 1)); SOCK="/tmp/vmix-vfs-$$-$i.sock"; rm -f "$SOCK"
TAG=$([[ $i -eq 1 ]] && echo "${macosQemu.automountTag}" || echo "share$i")
${pkgs.virtiofsd}/bin/virtiofsd --socket-path="$SOCK" --shared-dir "$SHARE" --cache auto --sandbox none >/dev/null 2>&1 &
for t in $(seq 1 50); do [[ -S "$SOCK" ]] && break; sleep 0.2; done
MACOS_SHARE_ARGS="$MACOS_SHARE_ARGS -chardev socket,id=vfs$i,path=$SOCK -device vhost-user-fs-pci,chardev=vfs$i,tag=$TAG"
MACOS_MEM_ARGS="-object memory-backend-memfd,id=vmix-mem,size=''${RUN_MEM}M,share=on -numa node,memdev=vmix-mem"
echo "Share: $SHARE -> $([[ $i -eq 1 ]] && echo '/Volumes/My Shared Files' || echo "mount -t virtiofs $TAG ...")"
done
# persistent home volume (virtio-blk); created + formatted APFS by the PE if missing
MACOS_HOME_ARGS=""
if [[ -n "$RUN_HOME" ]]; then
if [[ ! -e "$RUN_HOME" ]]; then
echo "Home: creating $RUN_HOME (64G qcow2) and formatting it as APFS 'vmix-home' via the PE of $RUN_HOME_IMAGE ..."
${pkgs.qemu}/bin/qemu-img create -q -f qcow2 "$RUN_HOME" 64G
FMT=$(${pkgs.nix}/bin/nix build --no-link --print-out-paths --impure --expr "let l = (builtins.getFlake \"${self}\").lib.${system}; in l.macos.formatVolume { image = l.$RUN_HOME_IMAGE; }") || { echo "Error: could not build the formatter"; exit 1; }
"$FMT" "$RUN_HOME" qcow2 || exit 1
fi
HOME_FMT=$(${pkgs.qemu}/bin/qemu-img info --output=json "$RUN_HOME" | ${pkgs.jq}/bin/jq -r .format)
MACOS_HOME_ARGS="-drive id=home,if=none,format=$HOME_FMT,file=$RUN_HOME -device virtio-blk-pci,drive=home"
echo "Home: $RUN_HOME (mounted at /Users by images generalized with persistHome)"
fi
echo ""
exec ${pkgs.qemu}/bin/qemu-system-x86_64 \
$MACOS_MEM_ARGS $MACOS_SHARE_ARGS $MACOS_HOME_ARGS \
-device virtio-serial-pci,id=vmix-vser -chardev socket,path="$RUN_QGA",server=on,wait=off,id=vmix-qga -device virtserialport,chardev=vmix-qga,name=org.qemu.guest_agent.0 \
$VMIX_DISPLAY \
${macosQemu.deviceArgs} ${macosQemu.vgaArgs} \
$([[ "$RUN_APPLESMC" == true ]] && echo '-device isa-applesmc,osk="${macosQemu.osk}"') \
-accel kvm \
-machine type=q35 \
-cpu ${macosQemu.defaultCpu} \
-smp "$RUN_SMP",sockets=1,cores="$RUN_SMP",threads=1 \
-m "$RUN_MEM" \
-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd \
-drive if=pflash,format=raw,file=/tmp/vmix-run-vars-$$.fd \
-drive id=os,if=none,format=qcow2,file="$RUN_IMAGE",snapshot=on -device ide-hd,bus=sata.0,drive=os \
-netdev user,id=net0 -device virtio-net-pci,netdev=net0,mac="$RUN_MAC",bus=pcie.0,addr=${macosQemu.nicAddr}
fi
echo "AHCI: $RUN_AHCI" echo "AHCI: $RUN_AHCI"
echo "" echo ""
@ -106,7 +215,7 @@ pkgs.writeShellScriptBin "vmix" ''
else else
echo "-drive file=$RUN_IMAGE,format=qcow2,if=virtio,snapshot=on" echo "-drive file=$RUN_IMAGE,format=qcow2,if=virtio,snapshot=on"
fi) \ fi) \
-nic user,model=$(if [[ "$RUN_AHCI" == "true" ]]; then echo e1000; else echo virtio-net-pci; fi) \ -nic user,model=$(if [[ "$RUN_AHCI" == "true" ]]; then echo e1000; else echo virtio-net-pci; fi)$(if [[ -n "$RUN_MAC" ]]; then echo ",mac=$RUN_MAC"; fi) \
-device virtio-serial-pci \ -device virtio-serial-pci \
-chardev spicevmc,id=vdagent,debug=0,name=vdagent \ -chardev spicevmc,id=vdagent,debug=0,name=vdagent \
-device virtserialport,chardev=vdagent,name=com.redhat.spice.0 -device virtserialport,chardev=vdagent,name=com.redhat.spice.0
@ -161,8 +270,16 @@ pkgs.writeShellScriptBin "vmix" ''
FLAKE_DIR="${self}" FLAKE_DIR="${self}"
# OS type of the image (windows / macos / linux) decides the disk-writing steps
OS_TYPE=$(${pkgs.nix}/bin/nix eval --raw --impure --expr "
let
vmixLib = (builtins.getFlake \"$FLAKE_DIR\").lib.${system};
image = vmixLib.$IMAGE_NAME;
in image._vmixOsType or \"linux\"
" 2>/dev/null || echo linux)
echo "=== vmix $COMMAND ===" echo "=== vmix $COMMAND ==="
echo "Image: $IMAGE_NAME" echo "Image: $IMAGE_NAME ($OS_TYPE)"
[[ -n "$GENERALIZE" ]] && echo "Generalize: $GENERALIZE" [[ -n "$GENERALIZE" ]] && echo "Generalize: $GENERALIZE"
[[ -n "$TO_DISK" ]] && echo "To disk: $TO_DISK" [[ -n "$TO_DISK" ]] && echo "To disk: $TO_DISK"
[[ -n "$TO_REMOTE_DISK" ]] && echo "To remote: $TO_REMOTE_DISK" [[ -n "$TO_REMOTE_DISK" ]] && echo "To remote: $TO_REMOTE_DISK"
@ -191,6 +308,10 @@ pkgs.writeShellScriptBin "vmix" ''
IMAGE_FILE=$(readlink -f "$OUT_LINK") IMAGE_FILE=$(readlink -f "$OUT_LINK")
echo "Built: $IMAGE_FILE" echo "Built: $IMAGE_FILE"
if [[ "$OS_TYPE" == "macos" ]]; then
echo "Run it with: vmix run $OUT_LINK --macos --vnc :10"
fi
# --- write to local disk --- # --- write to local disk ---
if [[ -n "$TO_DISK" ]]; then if [[ -n "$TO_DISK" ]]; then
echo "" echo ""
@ -224,6 +345,15 @@ pkgs.writeShellScriptBin "vmix" ''
echo "[3/5] Fixing GPT backup header..." echo "[3/5] Fixing GPT backup header..."
${pkgs.gptfdisk}/bin/sgdisk -e "$TO_DISK" ${pkgs.gptfdisk}/bin/sgdisk -e "$TO_DISK"
if [[ "$OS_TYPE" == "macos" ]]; then
echo "[4/5] APFS container cannot be grown from Linux skipped"
echo "[5/5] Grow it from macOS with: diskutil apfs resizeContainer disk0s2 0"
echo ""
echo "Done. $TO_DISK is ready to boot (OpenCore in the EFI partition; real Macs need no OpenCore)."
exit 0
fi
# delete recovery partition if present, then resize Windows partition # delete recovery partition if present, then resize Windows partition
${pkgs.gptfdisk}/bin/sgdisk -d 4 "$TO_DISK" 2>/dev/null || true ${pkgs.gptfdisk}/bin/sgdisk -d 4 "$TO_DISK" 2>/dev/null || true
@ -281,13 +411,22 @@ pkgs.writeShellScriptBin "vmix" ''
kill $NBD_PID 2>/dev/null || true kill $NBD_PID 2>/dev/null || true
rm -f "$NBD_SOCK" rm -f "$NBD_SOCK"
echo "[3/5] Fixing GPT backup header..."
if [[ "$OS_TYPE" == "macos" ]]; then
ssh "$REMOTE_HOST" "nix-shell -p gptfdisk --run 'sgdisk -e $REMOTE_DISK'"
echo "[4/5] APFS container cannot be grown from Linux skipped"
echo "[5/5] Grow it from macOS with: diskutil apfs resizeContainer disk0s2 0"
echo ""
echo "Done. $REMOTE_HOST:$REMOTE_DISK is ready to boot."
exit 0
fi
if [[ "$REMOTE_DISK" == *nvme* ]] || [[ "$REMOTE_DISK" == *mmcblk* ]]; then if [[ "$REMOTE_DISK" == *nvme* ]] || [[ "$REMOTE_DISK" == *mmcblk* ]]; then
REMOTE_WIN_PART="''${REMOTE_DISK}p3" REMOTE_WIN_PART="''${REMOTE_DISK}p3"
else else
REMOTE_WIN_PART="''${REMOTE_DISK}3" REMOTE_WIN_PART="''${REMOTE_DISK}3"
fi fi
echo "[3/5] Fixing GPT backup header..."
ssh "$REMOTE_HOST" "nix-shell -p gptfdisk --run 'sgdisk -e $REMOTE_DISK && sgdisk -d 4 $REMOTE_DISK 2>/dev/null || true'" ssh "$REMOTE_HOST" "nix-shell -p gptfdisk --run 'sgdisk -e $REMOTE_DISK && sgdisk -d 4 $REMOTE_DISK 2>/dev/null || true'"
echo "[4/5] Expanding Windows partition (partition 3)..." echo "[4/5] Expanding Windows partition (partition 3)..."

View file

@ -24,7 +24,7 @@
lib.${system} = vmixLib; lib.${system} = vmixLib;
packages.${system}.default = import ./cli.nix { inherit pkgs self system; }; packages.${system}.default = import ./cli.nix { inherit pkgs self system vmixLib; };
apps.${system}.default = { apps.${system}.default = {
type = "app"; type = "app";

View file

@ -4,6 +4,6 @@ let
network = import ./network.nix { inherit pkgs lib; }; network = import ./network.nix { inherit pkgs lib; };
in in
{ {
inherit (images) linux windows; inherit (images) linux windows macos;
inherit network; inherit network;
} }

View file

@ -2,4 +2,5 @@
{ {
linux = (import ./linux) { inherit pkgs lib system; }; linux = (import ./linux) { inherit pkgs lib system; };
windows = (import ./windows) { inherit pkgs lib system; }; windows = (import ./windows) { inherit pkgs lib system; };
macos = (import ./macos) { inherit pkgs lib system; };
} }

177
lib/images/macos/README.md Normal file
View file

@ -0,0 +1,177 @@
# macOS images (Tahoe 26)
Pre-installed, Apple-ID-capable macOS VM images built the same way as the
Windows ones: `makeImage` (unattended install) → templates → `.generalize`
(user, hostname, fresh SMBIOS identity). Runs on QEMU/KVM with OpenCore.
```
vmix build --image macos.images.tahoe.basic --generalize username=sagar,password=secret,hostname=MAC
vmix run ./result --macos --vnc :10 --mem 8192
```
Nix: `macos.images.tahoe.{pe,upstream,basic,remote}` and
`<image>.generalize { username; password; hostname; timezone; locale; seed; … }`.
## How it works: the vmix "PE"
Apple's Recovery (`BaseSystem.dmg`, a plain journaled HFS+ volume) with **one
LaunchDaemon added** (`makeRecoveryPE`): at boot it mounts a `VMIX` volume and
runs `run.sh` from it as root, records the exit status and powers off. That is
the whole automation surface — the equivalent of Windows PE + Autounattend:
* **no GUI is driven**: no OCR, no keystrokes, no screen layouts to learn per
macOS version; the hook is a launchd plist, stable across releases (same idea
as AutoNBI/Imagr NetBoot images).
* **observable**: the guest prints `VMIX-*` markers to `/dev/console`, which the
build reads from QEMU's serial log (`boot-args serial=3 -v`). Kernel panics and
reboots show up there too. Screenshots are still taken for debugging.
* **offline**: no NIC during the install, and the guest blackholes Apple's
install/verify endpoints so `startosinstall` never waits on the network. The
only inputs are the pinned `InstallAssistant.pkg` and `BaseSystem.dmg`.
* **everything else happens offline from the PE too**: templates and generalize
mount the image's Data volume (rw) and System volume (ro) and edit them
(`dscl -f` for users, `plutil` for preferences) — the installed macOS is
never booted for customization, so nothing depends on launchd/BTM approval,
first-boot agents or auto-login inside the guest. One PE boot ≈ 30 s.
### Pipeline
1. `makeRecoveryPE` — BaseSystem.dmg → raw HFS+ image + `ch.vmix.pe` daemon.
2. `makeImage` — QEMU with: OpenCore boot disk (build variant with serial
console), the PE, the empty target disk, the VMIX volume (`vmix-install.sh`,
installer app skeleton) and the whole `InstallAssistant.pkg` mapped as a raw
disk. The guest script erases the target as APFS, unpacks the app and `dd`s
the pkg into it as `SharedSupport.dmg` (it is a "pkgdmg": xar + koly footer;
the bare xar member fails with "pkgdmg is missing a footer"), then runs
`startosinstall`, which reboots itself through the install phases. The
installed system's first boot ends at the loginwindow: the driver detects the
bright screen and powers the VM down. OpenCore is then copied into the image's
own ESP so it boots with plain OVMF.
3. `customizeImage` — boots the PE with the image attached (OpenCore
`ScanPolicy` restricted to HFS+ on SATA, so only the PE can boot) and runs the
template script with `$SYS`/`$DATA` mounted. `pe-lib.sh` has the helpers.
4. `templates/generalize.nix` — user (dscl, admin, home from the user template),
auto-login (`kcpassword`), Setup Assistant suppression, hostname, locale,
timezone, keyboard type, container resize, fresh SMBIOS via a new OpenCore
ESP (`serial`/`mlb` from macserial, MAC + UUID from `seed`).
### Recovery source
`recovery.file` in `upstream.json` points at a content-addressed store path for
the verified Tahoe `BaseSystem.dmg` (Apple's CDN load-balances Sequoia/Tahoe
during the rollout, so a plain fetch is non-deterministic). Reproduce it on any
host with `nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg`.
Drop `recovery.file` to fetch from Apple instead (`fetchRecovery` retries until
the pinned hash matches).
## Reliability
Things QEMU does intermittently, and what handles each (all in `vm-driver.py`
and `vmix-install.sh`; every event is logged with a reason):
* `startosinstall` prepare stalls or crawls — the guest kills and retries it on a
freshly erased target (free-space watchdog + time cap).
* the installer comes back to the PE instead of the install phase — the PE
counts boots and simply re-runs the install (max 3).
* the installed system hangs at the Apple logo on first boot — a `system_reset`
is issued only when the screen is dark and frozen **and** disk and serial
console are idle, so a slow-but-working boot is never interrupted.
* macOS `shutdown -h` halts to a black screen without an ACPI power-off — an
idle black screen counts as a completed halt.
* a kernel panic (seen on the serial console) resets the VM.
* a wedged run fails at the 4 h timeout instead of hanging.
`tools/soak.sh <flake> macos.images.tahoe.upstream 3` rebuilds an image N
times and tabulates outcome, duration, boots, resets, panics and retries.
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

@ -0,0 +1,46 @@
{ pkgs, lib, system, ... }:
let
upstream = (lib.importJSON ./upstream.json).${system};
macos = rec {
inherit upstream;
qemu = import ./helpers/qemu.nix { inherit pkgs lib; };
ident = import ./helpers/ident.nix { inherit lib; };
macserial = import ./helpers/macserial.nix { inherit pkgs upstream; };
fetchRecovery = import ./helpers/fetchRecovery.nix { inherit pkgs upstream; };
installerPayload = import ./helpers/installerPayload.nix { inherit pkgs lib; };
makeOpenCore = import ./helpers/makeOpenCore.nix { inherit pkgs lib upstream macserial qemu; };
makeBootDisk = import ./helpers/makeBootDisk.nix { inherit pkgs lib; };
makeRecoveryPE = import ./helpers/makeRecoveryPE.nix { inherit pkgs lib; };
makeVmixVolume = import ./helpers/makeVmixVolume.nix { inherit pkgs lib; };
installBootloader = import ./helpers/installBootloader.nix { inherit pkgs lib; };
vmixReadback = import ./helpers/vmix-readback.nix { inherit pkgs lib; };
vmDriver = ./helpers/vm-driver.py;
makeImage = import ./helpers/makeImage.nix {
inherit pkgs lib qemu ident installerPayload makeOpenCore makeBootDisk makeVmixVolume installBootloader vmixReadback vmDriver;
};
customizeImage = import ./helpers/customizeImage.nix {
inherit pkgs lib qemu ident makeVmixVolume makeOpenCore makeBootDisk installBootloader vmixReadback vmDriver;
};
customizeImageFold = builtins.foldl' customizeImage;
formatVolume = import ./helpers/formatVolume.nix { inherit pkgs lib qemu makeVmixVolume makeBootDisk vmDriver; };
templates = import ./templates { inherit pkgs lib; };
};
tahoe = import ./tahoe { inherit pkgs lib system macos; };
# Recursively add .generalize to every image leaf (same shape as windows)
addGeneralize = val:
if val ? _vmixOsType then
val // { generalize = args:
let
templateArgs = builtins.removeAttrs args [ "vncDisplay" ];
displayArgs = lib.optionalAttrs (args ? vncDisplay) { inherit (args) vncDisplay; };
in macos.customizeImage val (macos.templates.generalize templateArgs // displayArgs);
}
else if builtins.isAttrs val then
lib.mapAttrs (_: addGeneralize) val
else val;
in
macos // {
images = addGeneralize { inherit tahoe; };
}

View file

@ -0,0 +1,19 @@
<?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>Label</key>
<string>ch.vmix.pe</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/usr/libexec/vmix/pe.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/dev/console</string>
<key>StandardErrorPath</key>
<string>/dev/console</string>
</dict>
</plist>

View file

@ -0,0 +1,11 @@
#!/usr/bin/env python3
# Encode a password as /etc/kcpassword (macOS auto-login). XOR with Apple's
# fixed key, zero padded to a multiple of 12 so a key byte terminates it.
import sys
KEY = [0x7D, 0x89, 0x52, 0x23, 0xD2, 0xBC, 0xDD, 0xEA, 0xA3, 0xB9, 0x1F]
pw = list(sys.argv[1].encode()) if len(sys.argv) > 1 else []
pw += [0] * (12 - len(pw) % 12)
out = bytes(b ^ KEY[i % len(KEY)] for i, b in enumerate(pw))
sys.stdout.buffer.write(out)

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

@ -0,0 +1,100 @@
#!/bin/bash
# vmix unattended macOS install, run by the PE hook (pe.sh) as root in the
# Recovery with /Volumes/VMIX mounted (V). Needs vmix.conf: TARGET_BYTES,
# PKG_BYTES, PKG_DISK_BYTES, APP_NAME, VOLUME_NAME.
# 1. find the target disk and the SharedSupport (InstallAssistant.pkg) disk by size
# 2. erase the target as APFS, unpack the installer app, dd the whole pkg into it
# as SharedSupport.dmg (a "pkgdmg", startosinstall checks its koly footer)
# 3. startosinstall prepares, then reboots itself into the install phase; the
# installed system's first boot ends at the loginwindow (the host powers off)
# Never returns on success; a return means failure (the PE records the status).
set -x
. "$V/pe-lib.sh"
fail() {
echo "VMIX-FAIL: $*"
cp /var/log/install.log "$V/system-install.log" 2>/dev/null
sync
exit 1
}
# each boot into the PE with the install still pending is one attempt
ATTEMPT=$(( $(cat "$V/install.attempt" 2>/dev/null || echo 0) + 1 ))
echo "$ATTEMPT" > "$V/install.attempt"; sync
echo "VMIX-INSTALL: attempt $ATTEMPT (boot into the PE)"
[ "$ATTEMPT" -le 3 ] || fail "the installer keeps coming back to the PE ($ATTEMPT boots)"
# --- 1. disks by exact size
disk_by_size() {
for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+' | sort -u); do
if [ "$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9]*\) Bytes).*/\1/p')" = "$1" ]; then
echo "${d#/dev/}"; return 0
fi
done
return 1
}
TARGET=$(disk_by_size "$TARGET_BYTES") || fail "target disk of $TARGET_BYTES bytes not found"
SSDISK=$(disk_by_size "$PKG_DISK_BYTES") || fail "SharedSupport disk of $PKG_DISK_BYTES bytes not found"
echo "VMIX-INSTALL: target=$TARGET sharedsupport=$SSDISK"
# --- 2. target volume + installer app (the pkg payload skeleton + SharedSupport.dmg)
VOL="/Volumes/$VOLUME_NAME"
APP="$VOL/$APP_NAME"
SS="$APP/Contents/SharedSupport/SharedSupport.dmg"
prepare_target() {
diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk"
[ -d "$VOL" ] || fail "$VOL not mounted after erase"
tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar installer app"
[ -x "$APP/Contents/Resources/startosinstall" ] || fail "startosinstall missing from $APP"
mkdir -p "$APP/Contents/SharedSupport"
FULL=$(( PKG_BYTES / 1048576 )); REM=$(( PKG_BYTES % 1048576 ))
echo "VMIX-INSTALL: copying SharedSupport.dmg ($PKG_BYTES bytes) from /dev/r$SSDISK"
dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport"
[ "$REM" -gt 0 ] && { dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 | dd bs=1 count=$REM >> "$SS"; } || true
[ "$(stat -f %z "$SS")" = "$PKG_BYTES" ] || fail "SharedSupport.dmg size $(stat -f %z "$SS") != $PKG_BYTES"
tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no koly footer"
chflags -h norestricted "$SS" 2>/dev/null || true
sync
}
prepare_target
SOI="$APP/Contents/Resources/startosinstall"
echo "VMIX-INSTALL: app ready, clock $(date -u)"
# Offline install: no NIC is attached. Blackhole Apple's install/verify endpoints
# too, so osinstallersetupd's requests fail immediately instead of timing out.
for d in swscan.apple.com swcdn.apple.com swdist.apple.com swquery.apple.com \
gs.apple.com gsa.apple.com gdmf.apple.com mesu.apple.com xp.apple.com \
albert.apple.com captive.apple.com deviceservices-external.apple.com \
identity.apple.com ppq.apple.com crl.apple.com ocsp.apple.com \
ocsp2.apple.com valid.apple.com; do
echo "127.0.0.1 $d" >> /etc/hosts
done
# --- 3. startosinstall prepares (~5 min) then reboots the machine itself into the
# install phase; it never returns on success. Prepare is intermittently slow in
# QEMU, so an attempt that stalls or runs too long is killed and retried on a
# freshly erased target.
run_soi() { "$SOI" --volume "$VOL" --agreetolicense --nointeraction --rebootdelay 5 "$@"; }
free_kb() { df -k "$VOL" 2>/dev/null | awk 'NR==2 {print $4}'; }
try=0
while [ "$try" -lt 6 ]; do
try=$((try + 1))
[ "$try" -gt 1 ] && prepare_target
echo "VMIX-INSTALL: startosinstall try $try"
run_soi 2>&1 &
SOI_PID=$!
last=$(free_kb); stalled=0; elapsed=0
while kill -0 "$SOI_PID" 2>/dev/null; do
sleep 30; elapsed=$((elapsed + 30))
now=$(free_kb)
if [ "$now" = "$last" ]; then stalled=$((stalled + 30)); else stalled=0; last=$now; fi
[ $((elapsed % 120)) -eq 0 ] && echo "VMIX-INSTALL: prepare running ${elapsed}s (stalled ${stalled}s)"
if [ "$stalled" -ge 240 ] || [ "$elapsed" -ge 600 ]; then
echo "VMIX-INSTALL: prepare too slow (stalled=${stalled}s elapsed=${elapsed}s), killing to retry"
kill -9 "$SOI_PID" 2>/dev/null; pkill -9 -f startosinstall 2>/dev/null
break
fi
done
wait "$SOI_PID" 2>/dev/null
echo "VMIX-INSTALL: startosinstall try $try ended without rebooting"
sleep 3
done
fail "startosinstall did not complete after $try tries"

View file

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

View file

@ -0,0 +1,40 @@
# macOS Recovery BaseSystem.dmg from Apple's recovery servers (osrecovery.apple.com)
# via OSX-KVM's fetch-macOS-v2.py. The server has no stable URL (session-based) and,
# during a macOS rollout, "latest" is load-balanced across CDN nodes serving DIFFERENT
# builds (e.g. Sequoia and Tahoe at once). So the download is non-deterministic: the
# builder retries until it gets the exact build pinned by sha256. The recovery MUST
# match the installer's major version or startosinstall rejects it as "damaged".
# When Apple retires this build, update recovery.sha256 (download once, check
# /System/Library/CoreServices/SystemVersion.plist reports the wanted version).
{ pkgs, upstream, ... }:
{ shortname, sha256 }:
let
script = pkgs.fetchurl { inherit (upstream.opencore.fetchRecoveryScript) url sha256; };
in
pkgs.runCommand "macos-${shortname}-BaseSystem.dmg" {
nativeBuildInputs = [ pkgs.python3 pkgs.coreutils ];
outputHashMode = "flat";
outputHashAlgo = "sha256";
outputHash = sha256;
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
expected = sha256;
} ''
cp ${script} fetch-macOS-v2.py
sed -i 's/os.get_terminal_size().columns/80/' fetch-macOS-v2.py
want=$(nix-hash --type sha256 --to-base16 "$expected" 2>/dev/null || echo "$expected")
for attempt in $(seq 1 40); do
rm -f BaseSystem.dmg BaseSystem.chunklist
echo "=== vmix: fetching ${shortname} recovery (attempt $attempt) ==="
python3 fetch-macOS-v2.py --action download -s ${shortname} -o . -n BaseSystem || true
if [ -f BaseSystem.dmg ]; then
got=$(sha256sum BaseSystem.dmg | cut -d' ' -f1)
echo "got $got (want $want)"
[ "$got" = "$want" ] && { mv BaseSystem.dmg $out; exit 0; }
echo "=== vmix: wrong build (Apple is rotating builds during rollout), retrying ==="
fi
sleep 5
done
echo "vmix: could not fetch the pinned ${shortname} recovery after 40 attempts."
echo "Apple may have retired build $want; download it manually and update recovery.sha256."
exit 1
''

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

@ -0,0 +1,17 @@
# Deterministic per-image identity derived from a seed string, so the NixOS
# module and the CLI know the NIC MAC at evaluation time (OpenCore's ROM must
# match the MAC of en0 for Apple ID / iMessage).
{ lib, ... }:
rec {
hash = seed: builtins.hashString "sha256" seed;
# locally administered QEMU-style MAC
macFromSeed = seed:
let h = hash "${seed}-mac"; s = i: builtins.substring i 2 h;
in "52:54:00:${s 0}:${s 2}:${s 4}";
# RFC 4122 v4 layout
uuidFromSeed = seed:
let h = hash "${seed}-uuid"; s = a: b: builtins.substring a b h;
in lib.toUpper "${s 0 8}-${s 8 4}-4${s 13 3}-8${s 17 3}-${s 20 12}";
}

View file

@ -0,0 +1,17 @@
# Shell snippet: copy an OpenCore ESP (makeOpenCore output) into the EFI System
# Partition of a macOS qcow2 so the image boots standalone with plain OVMF.
# libguestfs follows qcow2 backing chains and writes into the top overlay.
{ pkgs, lib, ... }:
{ esp, image }:
''
echo "=== vmix: installing OpenCore into the ESP of ${image} ==="
ESP_DEV=$(guestfish --ro -a ${image} run : list-filesystems | awk -F': ' '$2 == "vfat" {print $1; exit}')
[ -n "$ESP_DEV" ] || { echo "vmix: no FAT EFI partition found in ${image}"; guestfish --ro -a ${image} run : list-filesystems; exit 1; }
guestfish -a ${image} -m "$ESP_DEV" <<GFS
rm-rf /EFI/OC
rm-rf /EFI/BOOT
rm-rf /EFI/vmix
copy-in ${esp}/EFI /
ls /EFI/OC
GFS
''

View file

@ -0,0 +1,28 @@
# Take apart InstallAssistant.pkg on Linux:
# installer-app.tar "Install macOS <name>.app" skeleton (Payload, pbzx+cpio)
# installer.json byte offsets of SharedSupport.dmg inside the pkg
# app-name e.g. "Install macOS Tahoe.app"
# The 18 GB SharedSupport.dmg is never copied on the host; makeImage exposes that
# byte range of the pkg as a raw disk and the recovery script copies it into the app.
{ pkgs, lib, ... }:
{ name, pkg }:
pkgs.runCommand "${name}-installer-payload" {
nativeBuildInputs = with pkgs; [ xar pbzx cpio python3 gnutar ];
} ''
mkdir -p $out
xar --dump-toc=toc.xml -f ${pkg}
python3 ${./xar-toc.py} toc.xml ${pkg} > $out/installer.json
grep -q '"SharedSupport.dmg"' $out/installer.json || { echo "no SharedSupport.dmg in pkg"; exit 1; }
grep -A4 '"SharedSupport.dmg"' $out/installer.json | grep -q '"encoding": "application/octet-stream"' \
|| { echo "SharedSupport.dmg is compressed inside the xar, cannot map it as a disk"; exit 1; }
echo "=== vmix: extracting installer app skeleton ==="
xar -x -f ${pkg} Payload
mkdir root
(cd root && pbzx -n ../Payload | cpio -idm --quiet)
APP=$(ls root/Applications | grep -E '^Install macOS.*\.app$' | head -1)
[ -n "$APP" ] || { echo "installer app not found in Payload"; ls -R root | head; exit 1; }
echo "$APP" > $out/app-name
tar -C root/Applications -cf $out/installer-app.tar "$APP"
ls -la $out
''

View file

@ -0,0 +1,18 @@
# macserial + ocvalidate from the pinned OpenCorePkg release (static Linux binaries)
{ pkgs, upstream, ... }:
pkgs.stdenv.mkDerivation {
name = "opencore-utilities-${upstream.opencore.pkg.version}";
src = pkgs.fetchurl { inherit (upstream.opencore.pkg) url sha256; };
nativeBuildInputs = [ pkgs.unzip ];
dontStrip = true;
dontPatchELF = true;
unpackPhase = ''
unzip -q $src 'Utilities/macserial/*' 'Utilities/ocvalidate/*'
'';
installPhase = ''
mkdir -p $out/bin $out/share/doc
install -m755 Utilities/macserial/macserial.linux $out/bin/macserial
install -m755 Utilities/ocvalidate/ocvalidate.linux $out/bin/ocvalidate
cp Utilities/macserial/FORMAT.md $out/share/doc/macserial-FORMAT.md
'';
}

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

@ -0,0 +1,113 @@
# Build a pre-installed macOS qcow2 with an unattended install driven from the
# vmix PE (Apple's Recovery + one LaunchDaemon, see makeRecoveryPE):
# OpenCore boots the PE → its hook runs /Volumes/VMIX/run.sh (vmix-install.sh)
# → erase the disk, rebuild the installer app from installer-app.tar + the
# SharedSupport raw disk, startosinstall → the installer reboots through its
# phases → the installed system's first boot reaches the loginwindow → the
# driver powers it off. No GUI is driven; progress is read from the serial
# console and screenshots (brightness). Fully offline: no NIC is attached.
# Then OpenCore is copied into the image's own ESP so it boots with plain OVMF.
# Apply templates with customizeImageFold, then .generalize.
{ pkgs, lib, qemu, ident, installerPayload, makeOpenCore, makeBootDisk, makeVmixVolume, installBootloader, vmixReadback, vmDriver, ... }:
{
name ? "macos",
installer, # InstallAssistant.pkg
pe, # makeRecoveryPE output for the same macOS version
diskSize ? "128G",
volumeName ? "Macintosh HD",
smp ? 4,
memSize ? 8192,
cpu ? qemu.defaultCpu,
model ? "MacPro7,1", # SMBIOS model; must be supported by the installed macOS
seed ? name, # MAC address + SystemUUID are derived from this
bootArgs ? "keepsyms=1 revpatch=memtab",
vncDisplay ? null, # e.g. ":10" to watch the install on port 5910
timeout ? 4 * 3600, # seconds for the whole install
extraOpenCoreConfig ? {}, # merged into config.plist
installNetwork ? false, # attach a NIC during the install (default: offline)
}:
let
mac = ident.macFromSeed seed;
uuid = ident.uuidFromSeed seed;
esp = makeOpenCore { name = "${name}-opencore"; inherit model mac uuid bootArgs memSize; extraConfig = extraOpenCoreConfig; };
# build-time boot disk: same identity, plus serial console + verbose boot
bootDisk = makeBootDisk { name = "${name}-install"; inherit esp; bootArgs = "${bootArgs} serial=3 -v"; };
payload = installerPayload { inherit name; pkg = installer; };
vmixVol = makeVmixVolume {
inherit name;
size = "512M";
files = [
{ source = ../guest/vmix-install.sh; name = "run.sh"; }
{ source = ../guest/pe-lib.sh; name = "pe-lib.sh"; }
{ source = "${payload}/installer-app.tar"; name = "installer-app.tar"; }
];
};
driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
drv = pkgs.runCommand "${name}-vmix.qcow2" {
__noChroot = true;
requiredSystemFeatures = [ "kvm" ];
nativeBuildInputs = with pkgs; [ pkgs.qemu jq driverPython libguestfs-with-appliance ];
} ''
echo "=== vmix: creating ${diskSize} disk ==="
qemu-img create -f qcow2 disk.qcow2 ${diskSize}
# store files are read-only and AHCI needs writable nodes: qcow2 overlays
qemu-img create -q -f qcow2 -F raw -b ${pe} pe.qcow2
qemu-img create -q -f qcow2 -F raw -b ${bootDisk}/boot.img ocboot.qcow2
# Apple's postinstall hardlinks the WHOLE InstallAssistant.pkg as
# Contents/SharedSupport/SharedSupport.dmg: the pkg is a "pkgdmg" (xar + koly
# trailer whose DataForkOffset points at the dmg inside). startosinstall's
# OSISVerifyBaseSystemOperation reads that footer, so the extracted xar member
# alone fails with "pkgdmg is missing a footer". Expose the whole pkg as a raw
# disk (zero host copy; qcow2 needs a 512-aligned size, the guest dd's PKG_BYTES).
PKG_BYTES=$(stat -c %s ${installer})
PKG_DISK=$(( (PKG_BYTES + 511) / 512 * 512 ))
qemu-img create -q -f qcow2 -F raw -b "json:{\"driver\":\"raw\",\"size\":$PKG_DISK,\"file\":{\"driver\":\"file\",\"filename\":\"${installer}\"}}" sharedsupport.qcow2
cp ${vmixVol} vmix.img
chmod +w vmix.img
TARGET_BYTES=$(qemu-img info --output=json disk.qcow2 | jq '."virtual-size"')
cat > vmix.conf <<CONF
TARGET_BYTES=$TARGET_BYTES
PKG_BYTES=$PKG_BYTES
PKG_DISK_BYTES=$PKG_DISK
APP_NAME="$(cat ${payload}/app-name)"
VOLUME_NAME="${volumeName}"
BUILD_DATE="$(date -u +%m%d%H%M%Y.%S)"
CONF
cat vmix.conf
guestfish -a vmix.img -m /dev/sda1 upload vmix.conf /vmix.conf
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd
chmod +w vars.fd
VMIX_DISPLAY="-display none"
${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''}
echo "=== vmix: installing ${name} (unattended, ~1 h; logs and screenshots in /tmp/vmix-macos/${name}) ==="
python3 ${vmDriver} --mode install --name ${name} --timeout ${toString timeout} \
--serial-log serial.log --progress-file disk.qcow2 -- \
qemu-system-x86_64 $VMIX_DISPLAY \
${qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.firmwareArgs "vars.fd"} \
${qemu.serialArgs "serial.log"} \
${qemu.sataDrive { id = "opencore"; port = 0; file = "ocboot.qcow2"; }} \
${qemu.sataDrive { id = "pe"; port = 1; file = "pe.qcow2"; }} \
${qemu.sataDrive { id = "system"; port = 2; file = "disk.qcow2"; }} \
${qemu.sataDrive { id = "vmix"; port = 3; file = "vmix.img"; format = "raw"; }} \
${qemu.sataDrive { id = "sharedsupport"; port = 4; file = "sharedsupport.qcow2"; }} \
${lib.optionalString installNetwork (qemu.netArgs { inherit mac; })} \
|| { echo "vmix: install VM failed (see /tmp/vmix-macos/${name})"; exit 1; }
# The PE records a status only if run.sh returned, i.e. the install failed
# before the installer took over and rebooted.
${vmixReadback "vmix.img"}
if [ -n "$STATUS" ] && [ "$STATUS" != "0" ]; then
echo "vmix: install script failed (status $STATUS), see /tmp/vmix-macos/${name}"; exit 1
fi
${installBootloader { inherit esp; image = "disk.qcow2"; }}
echo "=== vmix: ${name} install complete (serial $(jq -r .serial ${esp}/vmix.json), mac ${mac}) ==="
mv disk.qcow2 $out
'';
in drv // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; inherit model pe volumeName; }

View file

@ -0,0 +1,85 @@
# OpenCore EFI for the VM: OSX-KVM's proven ESP (OpenCore + Lilu/VirtualSMC/... kexts)
# with a config.plist rewritten for this image's SMBIOS identity.
# Output:
# $out/EFI/ BOOT/BOOTx64.efi + OC/ — copied into the image's ESP
# $out/boot.img raw GPT disk with that ESP, used to boot the installer
# $out/vmix.json model, serial, mlb, uuid, mac (also copied to EFI/vmix/)
# Serial + MLB come from macserial when not given (random per build); everything
# derived from `seed` (MAC, UUID) is deterministic so the NixOS module knows it.
{ pkgs, lib, upstream, macserial, qemu, ... }:
{ name ? "opencore",
model ? "MacPro7,1",
mac,
uuid,
serial ? null,
mlb ? null,
bootArgs ? "keepsyms=1 revpatch=memtab",
resolution ? "1024x768",
showPicker ? true,
pickerTimeout ? 2,
extraConfig ? {},
memSize ? 8192, # RAM described in SMBIOS (MacPro7,1 wants 4 DIMMs)
}:
let
ocImage = pkgs.fetchurl { inherit (upstream.opencore.image) url sha256; name = "OSX-KVM-OpenCore.qcow2"; };
# OSX-KVM's ESP ships Lilu 1.6.8 / VirtualSMC 1.3.3 / WhateverGreen 1.6.7, which
# disable themselves on macOS 26 ("unsupported operating system"); without
# VirtualSMC, macOS' restart path panics on QEMU's SMC stub. Overlay the
# current releases (upstream.opencore.kexts, pinned).
kextZips = lib.mapAttrsToList (n: k: { name = n; zip = pkgs.fetchurl { inherit (k) url hash; }; })
(upstream.opencore.kexts or {});
in
pkgs.runCommand "${name}-esp" {
nativeBuildInputs = with pkgs; [ _7zz python3 mtools dosfstools gptfdisk jq macserial ];
passthru = { inherit model mac uuid; };
} ''
echo "=== vmix: extracting OSX-KVM OpenCore ESP ==="
7zz x -y -oparts ${ocImage} 0.primary.img >/dev/null
7zz x -y -oesp parts/0.primary.img >/dev/null
[ -f esp/EFI/OC/config.plist ] || { echo "config.plist not found in OpenCore image"; exit 1; }
SERIAL="${toString serial}"; MLB="${toString mlb}"
if [ -z "$SERIAL" ] || [ -z "$MLB" ]; then
echo "=== vmix: generating serial/MLB for ${model} with macserial ==="
LINE=$(macserial --num 1 --model "${model}" 2>/dev/null | grep '|' | tail -1)
[ -n "$LINE" ] || { echo "macserial produced nothing for ${model}"; exit 1; }
[ -z "$SERIAL" ] && SERIAL=$(echo "$LINE" | awk -F' *\\| *' '{print $1}')
[ -z "$MLB" ] && MLB=$(echo "$LINE" | awk -F' *\\| *' '{print $2}')
fi
echo "model=${model} serial=$SERIAL mlb=$MLB uuid=${uuid} mac=${mac}"
mkdir -p $out/EFI/vmix
cp -r esp/EFI/BOOT esp/EFI/OC $out/EFI/
chmod -R u+w $out/EFI
${lib.concatMapStringsSep "\n" (k: ''
echo "=== vmix: updating ${k.name}.kext from ${k.zip.name} ==="
rm -rf kext-${k.name}; mkdir kext-${k.name}
${pkgs.unzip}/bin/unzip -q -o ${k.zip} -d kext-${k.name}
K=$(find kext-${k.name} -type d -name "${k.name}.kext" | head -1)
[ -n "$K" ] || { echo "${k.name}.kext not found in ${k.zip.name}"; exit 1; }
rm -rf "$out/EFI/OC/Kexts/${k.name}.kext"
cp -r "$K" "$out/EFI/OC/Kexts/${k.name}.kext"
grep -A1 CFBundleVersion "$out/EFI/OC/Kexts/${k.name}.kext/Contents/Info.plist" | tail -1
'') kextZips}
# hide OpenCore's own launcher from its picker (otherwise it is the default entry and loops)
echo -n Disabled > $out/EFI/BOOT/.contentVisibility
python3 ${./oc-config.py} --base esp/EFI/OC/config.plist --esp esp --out $out/EFI/OC/config.plist \
--model "${model}" --serial "$SERIAL" --mlb "$MLB" --uuid "${uuid}" --mac "${mac}" \
--nic-path "${qemu.nicDevicePath}" --boot-args "${bootArgs}" --resolution "${resolution}" \
--show-picker "${lib.boolToString showPicker}" --timeout ${toString pickerTimeout} \
--extra-json ${lib.escapeShellArg (builtins.toJSON extraConfig)} --memory-mb ${toString memSize} --add-kexts ${lib.concatStringsSep "," (map (k: k.name) kextZips)}
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}" \
'{model:$model, serial:$serial, mlb:$mlb, uuid:$uuid, mac:$mac}' > $out/vmix.json
cp $out/vmix.json $out/EFI/vmix/vmix.json
echo "=== vmix: building OpenCore boot disk ==="
# 64 MiB raw GPT disk, ESP from sector 2048 to the end (minus backup GPT)
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 $out/EFI ::
mdir -i $out/boot.img@@1M ::EFI/OC >/dev/null
''

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

@ -0,0 +1,28 @@
# GPT disk with a single HFS+ partition named VMIX. macOS mounts it natively by
# name at /Volumes/VMIX (its FAT driver rejects Linux-made FAT volumes as
# "damaged"; HFS+ made by mkfs.hfsplus mounts cleanly). The partition starts at
# 1 MiB. Files are staged with their target names and copied in via libguestfs.
# files: list of { source = <path|drv>; name = "dest name"; } — directories copied recursively.
{ pkgs, lib, ... }:
{ name ? "vmix", files, size ? "64M" }:
pkgs.runCommand "${name}-vmix-volume.img" {
nativeBuildInputs = with pkgs; [ hfsprogs gptfdisk gnutar libguestfs-with-appliance ];
} ''
mkdir stage
${lib.concatMapStringsSep "\n" (f: ''
mkdir -p "$(dirname "stage/${f.name}")"
cp -r --no-preserve=mode ${f.source} "stage/${f.name}"
'') files}
tar -C stage -cf stage.tar .
truncate -s ${size} $out
sgdisk -n 1:2048:0 -t 1:AF00 -c 1:VMIX $out >/dev/null
FIRST=$(sgdisk -i 1 $out | sed -n 's/First sector: \([0-9]*\).*/\1/p')
LAST=$(sgdisk -i 1 $out | sed -n 's/Last sector: \([0-9]*\).*/\1/p')
truncate -s $(( (LAST - FIRST + 1) * 512 )) hfs.part
mkfs.hfsplus -v VMIX hfs.part >/dev/null
# tar-in takes a host-side tarball and unpacks it into the mounted volume
guestfish -a hfs.part run : mount /dev/sda / : tar-in stage.tar / : ls / : umount /
dd if=hfs.part of=$out bs=512 seek=$FIRST conv=notrunc status=none
''

View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Derive a VM config.plist from OSX-KVM's OpenCore config.
- keep only kexts/ACPI/drivers whose files exist in the ESP
- SMBIOS (model, serial, MLB, UUID, ROM=MAC) for Apple ID / iMessage
- mark the vmix NIC PCI path built-in, drop iMac19,1 GPU/audio properties
- boot-args, picker, resolution; optional JSON merged on top
"""
import argparse
import json
import os
import plistlib
APPLE_NVRAM = '7C436110-AB2A-4BBB-A880-FE41995C9F82'
MCE_KEXT = {
'Arch': 'Any', 'BundlePath': 'MCEReporterDisabler.kext',
'Comment': 'Fix kernel panic on MacPro/iMacPro SMBIOS (vmix)', 'Enabled': True,
'ExecutablePath': '', 'MaxKernel': '', 'MinKernel': '', 'PlistPath': 'Contents/Info.plist',
}
def merge(dst, src):
for k, v in src.items():
if isinstance(v, dict) and isinstance(dst.get(k), dict):
merge(dst[k], v)
else:
dst[k] = v
def main():
p = argparse.ArgumentParser()
p.add_argument('--base', required=True)
p.add_argument('--esp', required=True, help='directory containing EFI/OC')
p.add_argument('--out', required=True)
p.add_argument('--model', required=True)
p.add_argument('--serial', required=True)
p.add_argument('--mlb', required=True)
p.add_argument('--uuid', required=True)
p.add_argument('--mac', required=True)
p.add_argument('--nic-path', required=True)
p.add_argument('--boot-args', default='keepsyms=1')
p.add_argument('--resolution', default='1024x768')
p.add_argument('--show-picker', default='true')
p.add_argument('--timeout', type=int, default=2)
p.add_argument('--extra-json', default='{}')
p.add_argument('--memory-mb', type=int, default=8192, help='VM RAM, described as 4 DIMMs')
p.add_argument('--add-kexts', default='', help='comma-separated kext names (without .kext) that need a Kernel.Add entry')
a = p.parse_args()
with open(a.base, 'rb') as f:
cfg = plistlib.load(f)
oc = os.path.join(a.esp, 'EFI', 'OC')
def exists(sub, path):
return os.path.exists(os.path.join(oc, sub, path))
kexts = [k for k in cfg['Kernel']['Add'] if exists('Kexts', k['BundlePath'])]
if a.model.startswith(('MacPro', 'iMacPro')) and exists('Kexts', MCE_KEXT['BundlePath']) \
and not any(k['BundlePath'] == MCE_KEXT['BundlePath'] for k in kexts):
kexts.append(dict(MCE_KEXT))
cfg['Kernel']['Add'] = kexts
cfg['ACPI']['Add'] = [x for x in cfg['ACPI']['Add'] if exists('ACPI', x['Path'])]
cfg['UEFI']['Drivers'] = [d for d in cfg['UEFI']['Drivers'] if exists('Drivers', d['Path'])]
cfg['Misc']['Tools'] = [t for t in cfg['Misc'].get('Tools', []) if exists('Tools', t['Path'])]
cfg['Misc']['Entries'] = []
g = cfg['PlatformInfo']['Generic']
g['SystemProductName'] = a.model
g['SystemSerialNumber'] = a.serial
g['MLB'] = a.mlb
g['SystemUUID'] = a.uuid.upper()
g['ROM'] = bytes.fromhex(a.mac.replace(':', '').replace('-', ''))
g['SpoofVendor'] = True
g['AdviseFeatures'] = False
cfg['DeviceProperties']['Add'] = {a.nic_path: {'built-in': b'\x01'}}
cfg['DeviceProperties']['Delete'] = {}
nv = cfg['NVRAM']['Add'].setdefault(APPLE_NVRAM, {})
nv['boot-args'] = a.boot_args
nv['prev-lang:kbd'] = b'en-US:0'
nv['csr-active-config'] = b'\x00\x00\x00\x00'
cfg['UEFI']['Output']['Resolution'] = a.resolution
cfg['Misc']['Boot']['ShowPicker'] = a.show_picker.lower() == 'true'
cfg['Misc']['Boot']['Timeout'] = a.timeout
cfg['Misc']['Boot']['HideAuxiliary'] = True
cfg['Misc']['Security']['ScanPolicy'] = 0
# kexts overlaid into the ESP that the OSX-KVM config does not list yet
# (RestrictEvents: silences MacPro7,1's "Memory Modules Misconfigured", revpatch=memtab)
listed = {k['BundlePath'] for k in cfg['Kernel']['Add']}
for kext in [k + '.kext' for k in a.add_kexts.split(',') if k]:
if kext not in listed:
name = kext[:-5]
cfg['Kernel']['Add'].append({
'Arch': 'Any', 'BundlePath': kext, 'Comment': f'{name} (vmix)', 'Enabled': True,
'ExecutablePath': f'Contents/MacOS/{name}', 'MaxKernel': '', 'MinKernel': '',
'PlistPath': 'Contents/Info.plist'})
print('Kernel.Add +', kext)
# MacPro7,1 firmware expects DIMMs in pairs (>= 4); with QEMU's single SMBIOS
# module macOS shows "Memory Modules Misconfigured" at every login. Describe
# the VM's RAM as four DDR4 modules instead.
if a.model.startswith('MacPro7'):
size = max(1024, a.memory_mb // 4)
cfg['PlatformInfo']['CustomMemory'] = True
cfg['PlatformInfo']['Memory'] = {
'DataWidth': 64, 'ErrorCorrection': 3, 'FormFactor': 9, 'MaxCapacity': 1536 * 1024 * 1024 * 1024,
'TotalWidth': 64, 'Type': 26, 'TypeDetail': 128,
'Devices': [{
'AssetTag': '', 'BankLocator': f'BANK {i}', 'DeviceLocator': f'DIMM{i + 1}',
'Manufacturer': 'Apple', 'PartNumber': f'VMIX{size}', 'SerialNumber': f'VMIX{i:04d}',
'Size': size, 'Speed': 2666,
} for i in range(4)],
}
cfg['Misc']['Security']['SecureBootModel'] = 'Disabled'
cfg['Misc']['Security']['AllowSetDefault'] = True
cfg['Misc']['Debug']['Target'] = 0
merge(cfg, json.loads(a.extra_json))
with open(a.out, 'wb') as f:
plistlib.dump(cfg, f, sort_keys=True)
print('kexts:', ', '.join(k['BundlePath'] for k in kexts))
print('acpi:', ', '.join(x['Path'] for x in cfg['ACPI']['Add']))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,65 @@
# QEMU pieces shared by the macOS image builders, the vmix CLI and the NixOS module.
# Mirrors OSX-KVM's OpenCore-Boot.sh: q35, Skylake-Client CPU spoof (works on AMD
# hosts too), AppleSMC with the OSK, XHCI keyboard/tablet, AHCI disks, VMware SVGA.
{ pkgs, lib, ... }:
rec {
osk = "ourhardworkbythesewordsguardedpleasedontsteal(c)AppleComputerInc";
# OSX-KVM's CPU line for Sequoia/Tahoe; AVX2 capable, Intel vendor for the kernel
defaultCpu = "Skylake-Client,-hle,-rtm,kvm=on,vendor=GenuineIntel,+invtsc,vmware-cpuid-freq=on,+ssse3,+sse4.2,+popcnt,+avx,+aes,+xsave,+xsaveopt,check";
# The NIC is pinned to a fixed PCI slot so OpenCore can mark it built-in
# (required for en0 / Apple ID, iMessage, App Store).
nicAddr = "0x12";
nicDevicePath = "PciRoot(0x0)/Pci(0x12,0x0)";
# `-nic user` cannot pin a PCI address, so netdev + device
netArgs = { mac, netdev ? "user,id=net0", extra ? "" }:
"-netdev ${netdev} -device virtio-net-pci,netdev=net0,mac=${mac},bus=pcie.0,addr=${nicAddr}${extra}";
# Devices macOS needs (no accel, disks, display adapter or display server here).
# No isa-applesmc: QEMU's stub only answers the OSK keys, and its presence makes
# VirtualSMC (which carries the OSK itself) step aside, leaving Apple's SMC
# driver on the stub — whose missing watchdog keys panic the restart path on
# macOS 26. VirtualSMC alone is the standard Hackintosh setup.
deviceArgsFor = { appleSmc ? false }:
''${lib.optionalString appleSmc ''-device isa-applesmc,osk="${osk}" ''}-smbios type=2 -device qemu-xhci,id=xhci -device usb-kbd,bus=xhci.0 -device usb-tablet,bus=xhci.0 -device usb-ehci,id=ehci -device ich9-intel-hda -device hda-duplex -device ich9-ahci,id=sata -global ICH9-LPC.disable_s3=1'';
deviceArgs = deviceArgsFor { };
# macOS has no QXL/virtio-gpu driver; VMware SVGA gives a plain framebuffer
vgaArgs = "-vga vmware";
machineArgs = { cpu ? defaultCpu, smp ? 4, memSize ? 4096, appleSmc ? false }:
"-accel kvm -machine type=q35 -cpu ${cpu} -smp ${toString smp},sockets=1,cores=${toString smp},threads=1 -m ${toString memSize} ${deviceArgsFor { inherit appleSmc; }} ${vgaArgs}";
# SATA disk on a given port. Store files are read-only: callers create qcow2 overlays.
sataDrive = { id, port, file, format ? "qcow2", extra ? "" }:
"-drive id=${id},if=none,format=${format},file=${file}${extra} -device ide-hd,bus=sata.${toString port},drive=${id}";
# XNU logs to COM1 with boot-args serial=3; the build drivers read this file
serialArgs = file: "-serial file:${file}";
# Apple's own QEMU guest agent (/usr/libexec/AppleQEMUGuestAgent, macOS 13+)
# attaches to a virtio console port named org.qemu.guest_agent.0 and offers
# guest-exec (as root), guest-file-* etc. over this unix socket.
guestAgentArgs = sock:
"-device virtio-serial-pci,id=vmix-vser -chardev socket,path=${sock},server=on,wait=off,id=vmix-qga -device virtserialport,chardev=vmix-qga,name=org.qemu.guest_agent.0";
# virtio-fs (vhost-user, virtiofsd on the host). macOS auto-mounts the tag
# "com.apple.virtio-fs.automount" at /Volumes/My Shared Files; other tags are
# mounted with `mount -t virtiofs <tag> <dir>`. Needs a shared memory backend.
automountTag = "com.apple.virtio-fs.automount";
memBackendArgs = memSize: "-object memory-backend-memfd,id=vmix-mem,size=${toString memSize}M,share=on -numa node,memdev=vmix-mem";
virtioFsArgs = { tag, sock, id ? tag }:
"-chardev socket,id=vmix-vfs-${id},path=${sock} -device vhost-user-fs-pci,chardev=vmix-vfs-${id},tag=${tag}";
# virtio-blk data disk (AppleVirtIOBlock), e.g. the persistent home volume
virtioBlkArgs = { id, file, format ? "qcow2", extra ? "" }:
"-drive id=${id},if=none,format=${format},file=${file}${extra} -device virtio-blk-pci,drive=${id}";
# virtio keyboard/tablet (AppleVirtIOInput), optional alternative to the USB HID pair
virtioInputArgs = "-device virtio-keyboard-pci -device virtio-tablet-pci";
firmwareArgs = varsFile:
"-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd -drive if=pflash,format=raw,file=${varsFile}";
}

View file

@ -0,0 +1,588 @@
#!/usr/bin/env python3
"""vmix macOS VM driver: runs QEMU and decides when a build boot is finished.
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.
Observation is passive: the serial console (boot-args serial=3: the PE's
"VMIX-*" markers, kernel boots, panics) and screenshots over QMP (mean
brightness + a coarse change fingerprint). No OCR, no keystrokes. A boot hang
(dark, frozen screen, disk and serial idle) is retried with a system_reset.
Screenshots, the driver log and the serial log are kept in --debug-dir.
"""
import argparse
import hashlib
import io
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
try:
from PIL import Image
except ImportError: # screenshots then only serve as debug files
Image = None
PANIC_MARKS = ('panic(cpu', 'Kernel Extensions in backtrace', 'Debugger called: <panic>', 'Nested panic detected', 'panic string:')
REBOOT_MARK = 'MACH Reboot'
class Log:
def __init__(self, path):
self.f = open(path, 'a') if path else None
self.t0 = time.time()
def __call__(self, msg):
line = f'[{time.time() - self.t0:7.1f}s] {msg}'
print(f'vmix driver: {line}', flush=True)
if self.f:
self.f.write(line + '\n')
self.f.flush()
class QMP:
def __init__(self, path):
self.path = path
self.s = None
self.buf = b''
def connect(self, timeout=90):
t0 = time.time()
while True:
try:
s = socket.socket(socket.AF_UNIX)
s.settimeout(60)
s.connect(self.path)
self.s = s
self.buf = b''
self._read() # greeting
self.cmd('qmp_capabilities')
return
except (OSError, ValueError):
if time.time() - t0 > timeout:
raise
time.sleep(1)
def _read(self):
while b'\n' not in self.buf:
d = self.s.recv(65536)
if not d:
raise OSError('QMP socket closed')
self.buf += d
line, self.buf = self.buf.split(b'\n', 1)
return json.loads(line)
def cmd(self, name, **args):
self.s.sendall(json.dumps({'execute': name, 'arguments': args}).encode() + b'\n')
while True:
r = self._read()
if 'return' in r:
return r['return']
if 'error' in r:
raise RuntimeError(r['error'])
def screendump(self, path):
self.cmd('screendump', filename=path)
def system_reset(self):
self.cmd('system_reset')
def system_powerdown(self):
self.cmd('system_powerdown')
class QGA:
"""Minimal QEMU guest agent client over the unix socket QEMU serves."""
def __init__(self, path):
self.path = path
def cmd(self, name, timeout=30, **args):
s = socket.socket(socket.AF_UNIX)
s.settimeout(timeout)
try:
s.connect(self.path)
s.sendall((json.dumps({'execute': name, 'arguments': args}) + '\n').encode())
buf = b''
while b'\n' not in buf:
d = s.recv(65536)
if not d:
raise OSError('guest agent closed the connection')
buf += d
finally:
s.close()
r = json.loads(buf.split(b'\n', 1)[0])
if 'error' in r:
raise RuntimeError(r['error'])
return r.get('return')
def ping(self):
try:
return self.cmd('guest-ping', timeout=5) == {}
except Exception: # noqa: BLE001
return False
def exec_start(self, command):
return self.cmd('guest-exec', path='/bin/bash', arg=['-c', command], **{'capture-output': True})['pid']
def exec_status(self, pid):
return self.cmd('guest-exec-status', pid=pid)
class Screen:
"""Screenshots over QMP with a coarse change fingerprint (cursor-insensitive)."""
def __init__(self, qmp, debug_dir, log):
self.qmp = qmp
self.dir = debug_dir
self.log = log
self.tmp = os.path.join(debug_dir, f'.grab-{os.getpid()}.ppm')
self.img = None
self.last_fp = None
self.stable_since = time.time()
self.n = 0
def grab(self):
self.qmp.screendump(self.tmp)
with open(self.tmp, 'rb') as f:
data = f.read()
if Image is None:
fp = hashlib.sha256(data).hexdigest()
else:
self.img = Image.open(io.BytesIO(data))
small = self.img.convert('L').resize((48, 36))
fp = hashlib.sha256(bytes(b & 0xF0 for b in small.tobytes())).hexdigest()
if fp != self.last_fp:
self.last_fp = fp
self.stable_since = time.time()
return self.img
def stable_for(self):
return time.time() - self.stable_since
def mean(self):
if self.img is None:
return 0
g = self.img.convert('L').resize((64, 48))
px = g.tobytes()
return sum(px) / len(px)
def is_blank(self):
return self.img is not None and self.mean() < 3
def save(self, tag):
self.n += 1
path = os.path.join(self.dir, f'{self.n:03d}-{tag}.png')
try:
if self.img is not None:
self.img.save(path)
else:
shutil.copy(self.tmp, path.replace('.png', '.ppm'))
except Exception as e: # noqa: BLE001
self.log(f'could not save screenshot: {e}')
return path
class Serial:
"""Tail the serial console file QEMU writes (-serial file:...)."""
def __init__(self, path):
self.path = path
self.pos = 0
self.last_activity = time.time()
self.boots = 0
self.reboot_at = None
def poll(self):
if not self.path or not os.path.exists(self.path):
return []
with open(self.path, 'rb') as f:
f.seek(self.pos)
data = f.read()
self.pos = f.tell()
if not data:
return []
self.last_activity = time.time()
lines = data.decode('utf-8', 'replace').replace('\r', '').split('\n')
for l in lines:
if l.startswith('Darwin Kernel Version'):
self.boots += 1
self.reboot_at = None
elif REBOOT_MARK in l:
self.reboot_at = time.time()
return lines
def idle_for(self):
return time.time() - self.last_activity
def prepare_debug_dir(path):
"""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:
if not os.path.isdir(parent):
os.makedirs(parent, exist_ok=True)
os.chmod(parent, 0o777)
os.makedirs(path, exist_ok=True)
os.chmod(path, 0o777)
except OSError:
path = tempfile.mkdtemp(prefix=os.path.basename(path) + '-', dir='/tmp')
os.chmod(path, 0o777)
for f in os.listdir(path):
if f.endswith(('.png', '.ppm', '.log')) or f.startswith('.grab-') or f == 'qmp.sock':
try:
os.remove(os.path.join(path, f))
except OSError:
pass
return path
def launch(qemu_args, qmp_sock, log):
if os.path.exists(qmp_sock):
os.remove(qmp_sock)
cmd = list(qemu_args) + ['-qmp', f'unix:{qmp_sock},server,nowait']
log('launching: ' + ' '.join(cmd))
return subprocess.Popen(cmd)
def disk_idle(args):
if not args.progress_file:
return True
try:
return (time.time() - os.path.getmtime(args.progress_file)) > args.disk_idle
except OSError:
return True
def run_qga(args, proc, qmp, screen, serial, log):
"""Online template: wait for the guest agent, run the command, shut down."""
import base64
qga = QGA(args.qga_sock)
start = time.time()
last_periodic = 0
while not qga.ping():
rc = proc.poll()
if rc is not None:
log(f'QEMU exited with {rc} before the guest agent came up')
return rc or 3
now = time.time()
if now - start > args.start_timeout:
try:
screen.grab()
screen.save('no-agent')
except Exception: # noqa: BLE001
pass
log(f'guest agent not reachable within {args.start_timeout:.0f}s')
proc.kill()
return 3
for line in serial.poll():
if any(m in line for m in PANIC_MARKS):
log('serial: ' + line.strip()[:200])
if now - last_periodic > args.periodic:
last_periodic = now
try:
screen.grab()
screen.save('periodic')
except Exception: # noqa: BLE001
pass
time.sleep(3)
log(f'guest agent up after {time.time() - start:.0f}s: {qga.cmd("guest-info").get("version")}')
time.sleep(args.qga_settle) # let the login session / volumes settle
try:
pid = qga.exec_start(args.qga_command)
except Exception as e: # noqa: BLE001
log(f'guest-exec failed: {e}')
proc.kill()
return 3
log(f'running command as root (pid {pid}): {args.qga_command[:160]}')
t0 = time.time()
while True:
rc = proc.poll()
if rc is not None:
log(f'QEMU exited with {rc} while the command was running')
return rc or 3
if time.time() - start > args.timeout:
log('timeout reached, killing QEMU')
proc.kill()
return 124
try:
st = qga.exec_status(pid)
except Exception as e: # noqa: BLE001
log(f'guest-exec-status failed: {e}')
time.sleep(5)
continue
if st.get('exited'):
break
now = time.time()
if now - last_periodic > args.periodic:
last_periodic = now
try:
screen.grab()
screen.save('periodic')
except Exception: # noqa: BLE001
pass
time.sleep(3)
out = base64.b64decode(st.get('out-data', '')).decode('utf-8', 'replace')
err = base64.b64decode(st.get('err-data', '')).decode('utf-8', 'replace')
code = st.get('exitcode', st.get('signal'))
log(f'command finished in {time.time() - t0:.0f}s, exit {code}')
for line in (out + err).splitlines()[-200:]:
log('guest: ' + line[:220])
try:
screen.grab()
screen.save('after-command')
except Exception: # noqa: BLE001
pass
log('shutting the guest down')
try:
qga.exec_start('sync; /sbin/shutdown -h now')
except Exception as e: # noqa: BLE001
log(f'guest shutdown failed ({e}), ACPI powerdown')
try:
qmp.system_powerdown()
except Exception: # noqa: BLE001
pass
for _ in range(180):
if proc.poll() is not None:
log('QEMU exited')
return 0 if code == 0 else 4
time.sleep(1)
log('guest did not power off, killing QEMU')
proc.kill()
return 0 if code == 0 else 4
def 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
now = time.time()
if now - start > args.timeout:
try:
screen.grab()
screen.save('timeout')
except Exception: # noqa: BLE001
pass
log('timeout reached, killing QEMU')
proc.kill()
return 124
time.sleep(args.interval)
panic = False
for line in serial.poll():
if 'VMIX' in line:
log('serial: ' + line.strip()[:220])
if 'VMIX-PE: running run.sh' in line and not started:
started = True
log('PE started run.sh')
if 'VMIX-PE: no VMIX volume' in line and args.mode != 'boot':
log('PE did not find the VMIX volume')
proc.kill()
return 3
if line.startswith('Darwin Kernel Version'):
log(f'guest kernel boot #{serial.boots}')
if any(m in line for m in PANIC_MARKS):
panic = True
log('serial: ' + line.strip()[:220])
if panic:
panics += 1
try:
screen.grab()
screen.save('panic')
except Exception: # noqa: BLE001
pass
if panics > args.max_resets:
log(f'kernel panic #{panics}, giving up')
proc.kill()
return 3
# XNU reboots by itself after a panic; only reset if no kernel comes back.
# (In pe mode the PE then runs run.sh again — templates are idempotent.)
log(f'kernel panic #{panics}, waiting for the guest to reboot')
serial.reboot_at = now
continue
# The guest asked for a reboot but no kernel came back: macOS' restart
# path panics in QEMU (AppleSMC watchdog keys, see README); reset now
# instead of waiting for the frozen-screen watchdog.
if serial.reboot_at and now - serial.reboot_at > args.reboot_timeout:
resets += 1
try:
screen.grab()
screen.save('reboot-dead')
except Exception: # noqa: BLE001
pass
log(f'guest requested a reboot {now - serial.reboot_at:.0f}s ago and died, system_reset #{resets}')
serial.reboot_at = None
if resets > args.max_resets:
proc.kill()
return 3
qmp.system_reset()
screen.stable_since = time.time()
continue
if not started and now - start > args.start_timeout:
try:
screen.grab()
screen.save('no-start')
except Exception: # noqa: BLE001
pass
log(f'PE did not start run.sh within {args.start_timeout:.0f}s')
proc.kill()
return 3
try:
screen.grab()
except Exception as e: # noqa: BLE001
log(f'screendump failed ({e})')
time.sleep(2)
continue
if now - last_periodic > args.periodic:
last_periodic = now
screen.save('periodic')
if args.mode == 'pe':
continue # the PE powers off by itself; nothing to decide
# install: the PE phase (kernel boot #1) is protected by the guest's own
# retries; the checks below apply once the installer has rebooted.
in_os = args.mode == 'boot' or serial.boots >= 2
if not in_os or screen.stable_for() < args.settle:
continue
idle = disk_idle(args) and serial.idle_for() > args.disk_idle
if screen.is_blank():
blank_since = blank_since or now
# macOS `shutdown -h` halts to a black screen without an ACPI power-off
if now - blank_since > args.halt_timeout and idle:
screen.save('halt')
log(f'guest halted (black {now - blank_since:.0f}s, idle); killing QEMU')
proc.kill()
return 0
continue
blank_since = None
if screen.mean() > args.bright:
# loginwindow / Setup Assistant: the OS is installed and booted
if login_since is None:
login_since = now
log(f'bright screen (mean {screen.mean():.0f}): loginwindow/desktop, grace {args.login_grace:.0f}s')
elif now - login_since > args.login_grace:
screen.save('loginwindow')
log('powering down (boot complete)')
try:
qmp.system_powerdown()
except Exception as e: # noqa: BLE001
log(f'powerdown failed: {e}')
for _ in range(120):
if proc.poll() is not None:
log('QEMU exited after powerdown')
return 0
time.sleep(1)
# macOS ignores the power button at the Setup Assistant; the
# volumes are journaled (APFS) and the PE mounts them cleanly next
log('guest ignores the ACPI power button here (Setup Assistant); stopping QEMU')
proc.kill()
return 0
continue
login_since = None
# dark, frozen, nothing happening: a boot hang (seen at the Apple logo)
if screen.stable_for() > args.stall_reset and idle and resets < args.max_resets:
resets += 1
screen.save('stall-reset')
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, idle), system_reset #{resets}')
try:
qmp.system_reset()
except Exception as e: # noqa: BLE001
log(f'system_reset failed: {e}')
screen.stable_since = time.time()
def main():
p = argparse.ArgumentParser()
p.add_argument('--mode', choices=['pe', 'install', 'boot', 'qga'], required=True)
p.add_argument('--qga-sock', default=None, help='guest agent unix socket (mode qga)')
p.add_argument('--qga-command', default=None, help='bash command to run as root through the guest agent (mode qga)')
p.add_argument('--qga-settle', type=float, default=20.0, help='seconds to wait after the agent answers before running the command')
p.add_argument('--name', default='macos')
p.add_argument('--debug-dir', default=None)
p.add_argument('--serial-log', default=None, help='file QEMU writes the serial console to')
p.add_argument('--timeout', type=int, default=4 * 3600, help='seconds before QEMU is killed')
p.add_argument('--start-timeout', type=float, default=600.0, help='seconds for the PE to start run.sh')
p.add_argument('--interval', type=float, default=5.0, help='seconds between screenshots')
p.add_argument('--periodic', type=float, default=120.0, help='seconds between saved debug screenshots')
p.add_argument('--settle', type=float, default=12.0, help='seconds a screen must be unchanged to act on it')
p.add_argument('--stall-reset', type=float, default=360.0, help='reset the VM if a dark screen is frozen this long while disk and serial are idle')
p.add_argument('--max-resets', type=int, default=6)
p.add_argument('--reboot-timeout', type=float, default=60.0, help='seconds after a guest reboot request without a new kernel boot before the VM is reset')
p.add_argument('--halt-timeout', type=float, default=150.0, help='a pure-black, idle screen this long means the guest halted')
p.add_argument('--progress-file', default=None, help='the system disk; its mtime shows guest disk activity')
p.add_argument('--disk-idle', type=float, default=90.0, help='seconds without disk/serial activity that count as idle')
p.add_argument('--bright', type=float, default=80.0, help='mean brightness above which a screen is the loginwindow/desktop')
p.add_argument('--login-grace', type=float, default=180.0, help='seconds a bright screen must persist before powering down')
p.add_argument('qemu', nargs=argparse.REMAINDER)
args = p.parse_args()
qemu_args = [a for a in args.qemu if a != '--']
if not qemu_args:
p.error('QEMU command line required after --')
debug_dir = args.debug_dir or f'/tmp/vmix-macos/{args.name}'
debug_dir = prepare_debug_dir(debug_dir)
log = Log(os.path.join(debug_dir, 'driver.log'))
log(f'mode={args.mode} debug-dir={debug_dir} serial={args.serial_log}')
qmp_sock = os.path.join(debug_dir, 'qmp.sock')
proc = launch(qemu_args, qmp_sock, log)
qmp = QMP(qmp_sock)
try:
qmp.connect()
except Exception as e: # noqa: BLE001
log(f'QMP connect failed: {e}')
proc.kill()
return 2
screen = Screen(qmp, debug_dir, log)
serial = Serial(args.serial_log)
try:
if args.mode == 'qga':
rc = run_qga(args, proc, qmp, screen, serial, log)
else:
rc = drive(args, proc, qmp, screen, serial, log)
finally:
if args.serial_log and os.path.exists(args.serial_log):
try:
shutil.copy(args.serial_log, os.path.join(debug_dir, 'serial.log'))
except OSError:
pass
try:
if proc.poll() is None:
proc.kill()
except Exception: # noqa: BLE001
pass
log(f'done rc={rc}')
return rc
if __name__ == '__main__':
sys.exit(main())

View file

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

View file

@ -0,0 +1,25 @@
#!/usr/bin/env python3
# Print heap offsets of the entries of a xar archive (InstallAssistant.pkg) as JSON,
# so SharedSupport.dmg can be exposed to the VM as a raw disk without extracting 18 GB.
import json
import struct
import sys
import xml.etree.ElementTree as ET
toc_xml, archive = sys.argv[1], sys.argv[2]
with open(archive, 'rb') as f:
magic, hsize, ver, toc_c, toc_u, cksum = struct.unpack('>4sHHQQI', f.read(28))
assert magic == b'xar!', 'not a xar archive'
heap = hsize + toc_c
out = {}
for entry in ET.parse(toc_xml).getroot().iter('file'):
data = entry.find('data')
if data is None:
continue
out[entry.findtext('name')] = {
'offset': heap + int(data.findtext('offset')),
'length': int(data.findtext('length')),
'size': int(data.findtext('size')),
'encoding': data.find('encoding').get('style'),
}
json.dump(out, sys.stdout, indent=2)

View file

@ -0,0 +1,14 @@
{ pkgs, lib, system, macos, ... }:
let
up = macos.upstream.tahoe;
# 18 GB full installer (App Store InstallAssistant.pkg, pinned)
installer = pkgs.fetchurl { inherit (up.installer) url hash; name = "InstallAssistant.pkg"; };
# Recovery BaseSystem.dmg: a pre-verified local store file when `recovery.file` is set
# (Apple's CDN rotates Sequoia/Tahoe during the rollout), else fetched + pinned.
# `nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg` yields the same
# content-addressed path on any host that has the file.
recovery = if up.recovery ? file
then builtins.storePath up.recovery.file
else macos.fetchRecovery { inherit (up.recovery) shortname sha256; };
in
import ./images.nix { inherit pkgs lib system macos installer recovery; }

View file

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

View file

@ -0,0 +1,18 @@
{ pkgs, lib, ... }:
rec {
generalize = import ./generalize.nix { inherit pkgs lib; };
software = import ./software { inherit pkgs lib; };
profile = import ./profile { inherit pkgs lib; };
essentials = {
remoteAccess = import ./essentials/remote-access.nix { };
noUpdates = import ./essentials/no-updates.nix { };
performance = import ./essentials/performance.nix { inherit pkgs; };
};
bundles = {
basic = with essentials; [ noUpdates performance ];
remote = with essentials; [ noUpdates performance remoteAccess ];
};
}

View file

@ -0,0 +1,13 @@
# Disable automatic macOS / App Store updates (an OTA update would also need
# the RestrictEvents kext to work in a VM)
{ ... }:
{
name = "no-updates";
script = ''
SU="$DATA/Library/Preferences/com.apple.SoftwareUpdate.plist"
for k in AutomaticCheckEnabled AutomaticDownload AutomaticallyInstallMacOSUpdates ConfigDataInstall CriticalUpdateInstall; do
pe_plist_set "$SU" "$k" bool false
done
pe_plist_set "$DATA/Library/Preferences/com.apple.commerce.plist" AutoUpdate bool false
'';
}

View file

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

View file

@ -0,0 +1,10 @@
# Enable SSH (Remote Login) and Screen Sharing (VNC on 5900 inside the guest)
# by clearing their launchd overrides on the image's Data volume.
{ ... }:
{
name = "remote-access";
script = ''
pe_service_disabled com.apple.openssh.sshd false
pe_service_disabled com.apple.screensharing false
'';
}

View file

@ -0,0 +1,146 @@
# Generalize a macOS image, offline from the PE: create the (admin) user on the
# image's Data volume with dscl, auto-login, suppress the first-login Setup
# Assistant, hostname, locale, timezone, use the whole disk, and give the image
# a fresh SMBIOS identity (serial/MLB from macserial, MAC + UUID from `seed`) so
# every generalized VM looks like a distinct Mac to Apple ID/iMessage.
# Usage: (templates.generalize { username = "User"; password = ""; hostname = "MAC"; })
# delayOobeRun = true: no user, Setup Assistant runs on first real boot (like Windows OOBE)
{ pkgs, lib, ... }:
{
username ? "User",
password ? "",
fullName ? username,
autoLogon ? true,
hostname ? "MAC-VM",
locale ? "en-US",
timezone ? "UTC",
delayOobeRun ? false,
# SMBIOS identity; anything unset is generated
model ? null,
serial ? null,
mlb ? null,
uuid ? null,
mac ? null,
seed ? "${hostname}-${username}",
# keep the user's home on an APFS volume labelled vmix-home (a virtio-blk disk
# the host provides, formatted by the PE on first start). macOS refuses mounts
# over /Users (firmlink), so the volume automounts at /Volumes/<label> and the
# account's home directory lives there: ephemeral OS disk, persistent home.
persistHome ? false,
homeVolumeLabel ? "vmix-home",
# no desktop widgets for the created user (Sonoma+)
hideWidgets ? true,
# accepted for CLI parity with Windows, not supported on macOS
bgColor ? null,
}:
let
kcpasswordFile = pkgs.runCommand "kcpassword" { nativeBuildInputs = [ pkgs.python3 ]; } ''
python3 ${../guest/kcpassword.py} ${lib.escapeShellArg password} > $out
'';
macLocale = builtins.replaceStrings [ "-" ] [ "_" ] locale;
tempPassword = "vmix-temp-password";
setupKeys = [
"DidSeeCloudSetup" "DidSeeSiriSetup" "DidSeePrivacy" "DidSeeTouchIDSetup" "DidSeeAppearanceSetup"
"DidSeeScreenTime" "DidSeeAccessibility" "DidSeeTrueTonePrivacy" "DidSeeActivationLock"
"DidSeeiCloudLoginForStorageServices" "DidSeeSyncSetup" "DidSeeSyncSetup2" "DidSeeAppleIDSyncSetup"
"DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "DidSeeUpdateMacAutomatically"
"DidSeeSoftwareUpdate" "SkipFirstLoginOptimization"
];
in
{
name = if delayOobeRun then "generalize-delay-oobe" else "generalize";
files = [ { source = kcpasswordFile; name = "kcpassword"; } ];
smbios = { inherit seed; } // lib.filterAttrs (_: v: v != null) { inherit model serial mlb uuid mac; };
script = ''
set -x
${lib.optionalString (bgColor != null) ''echo "vmix: bgColor is not supported on macOS, ignoring"''}
VER=$(pe_target_version); BUILD=$(pe_target_build)
echo "vmix: target macOS $VER ($BUILD)"
N="$DATA/private/var/db/dslocal/nodes/Default"
D() { dscl -f "$N" localhost "$@"; }
${lib.optionalString (!delayOobeRun) ''
# --- user account (admin), created directly in the local directory node
U="${username}"; HOME_DIR="$DATA/Users/$U"
${lib.optionalString persistHome ''HOME_PATH="/Volumes/${homeVolumeLabel}/$U"''}
if ! D -read "/Local/Default/Users/$U" >/dev/null 2>&1; then
UID_NEW=$(D -list /Local/Default/Users UniqueID | awk '$2 >= 501 && $2 < 1000 && $2 > m {m = $2} END {print (m ? m + 1 : 501)}')
D -create "/Local/Default/Users/$U" || pe_fail "dscl create user"
D -create "/Local/Default/Users/$U" UserShell /bin/zsh
D -create "/Local/Default/Users/$U" RealName ${lib.escapeShellArg fullName}
D -create "/Local/Default/Users/$U" UniqueID "$UID_NEW"
D -create "/Local/Default/Users/$U" PrimaryGroupID 20
D -create "/Local/Default/Users/$U" NFSHomeDirectory "${if persistHome then "/Volumes/${homeVolumeLabel}/$U" else "/Users/$U"}"
if ! D -passwd "/Local/Default/Users/$U" ${lib.escapeShellArg password}; then
echo "vmix: WARNING: could not set the requested password, using '${tempPassword}'"
D -passwd "/Local/Default/Users/$U" "${tempPassword}" || pe_fail "dscl passwd"
fi
for g in admin _appserverusr _appserveradm _lpadmin; do
D -append "/Local/Default/Groups/$g" GroupMembership "$U" 2>/dev/null || true
done
mkdir -p "$HOME_DIR"
T="$SYS/System/Library/User Template/Non_localized"; [ -d "$T" ] || T="/System/Library/User Template/Non_localized"
ditto "$T" "$HOME_DIR" 2>/dev/null || true
L="$SYS/System/Library/User Template/English.lproj"; [ -d "$L" ] && ditto "$L" "$HOME_DIR" 2>/dev/null || true
else
UID_NEW=$(D -read "/Local/Default/Users/$U" UniqueID | awk '{print $2}')
fi
${lib.optionalString autoLogon ''
pe_plist_set "$DATA/Library/Preferences/com.apple.loginwindow.plist" autoLoginUser string "$U"
cp "$V/kcpassword" "$DATA/private/etc/kcpassword"
chmod 600 "$DATA/private/etc/kcpassword"; chown 0:0 "$DATA/private/etc/kcpassword"
''}
# --- no Setup Assistant / "What's new" prompts at first login
mkdir -p "$HOME_DIR/Library/Preferences"
P="$HOME_DIR/Library/Preferences/com.apple.SetupAssistant.plist"
for k in ${lib.concatStringsSep " " setupKeys}; do pe_plist_set "$P" "$k" bool true; done
pe_plist_set "$P" GestureMovieSeen string none
pe_plist_set "$P" LastSeenCloudProductVersion string "$VER"
pe_plist_set "$P" LastSeenBuddyBuildVersion string "$BUILD"
pe_plist_set "$P" LastSeenSiriProductVersion string "$VER"
pe_plist_set "$P" LastPreLoginTasksPerformedVersion string "$VER"
pe_plist_set "$P" LastPreLoginTasksPerformedBuild string "$BUILD"
pe_plist_set "$HOME_DIR/Library/Preferences/.GlobalPreferences.plist" AppleLocale string "${macLocale}"
${lib.optionalString hideWidgets ''
WM="$HOME_DIR/Library/Preferences/com.apple.WindowManager.plist"
pe_plist_set "$WM" StandardHideWidgets integer 1
pe_plist_set "$WM" StageManagerHideWidgets integer 1
''}
chown -R "$UID_NEW:20" "$HOME_DIR"
touch "$DATA/private/var/db/.AppleSetupDone"
''}
${lib.optionalString delayOobeRun ''
rm -f "$DATA/private/var/db/.AppleSetupDone"
''}
# --- machine identity
PF="$DATA/Library/Preferences/SystemConfiguration/preferences.plist"
mkdir -p "$(dirname "$PF")"
pe_plist_dict "$PF" System
pe_plist_dict "$PF" System.System
pe_plist_dict "$PF" System.Network
pe_plist_dict "$PF" System.Network.HostNames
pe_plist_set "$PF" System.System.ComputerName string "${hostname}"
pe_plist_set "$PF" System.System.HostName string "${hostname}"
pe_plist_set "$PF" System.Network.HostNames.LocalHostName string "${hostname}"
pe_plist_set "$DATA/Library/Preferences/.GlobalPreferences.plist" AppleLocale string "${macLocale}"
ln -sfn "/var/db/timezone/zoneinfo/${timezone}" "$DATA/private/etc/localtime"
pe_plist_set "$DATA/Library/Preferences/com.apple.timezone.auto.plist" Active bool false
# --- QEMU's USB keyboard (vendor 0x0627, product 0x0001) is unknown to macOS,
# which would open the Keyboard Setup Assistant at every login: declare it ANSI
KT="$DATA/Library/Preferences/com.apple.keyboardtype.plist"
pe_plist_dict "$KT" keyboardtype
pe_plist_set "$KT" keyboardtype.1-1575-0 integer 40
${lib.optionalString persistHome ''
# --- persistent home: the seeded home directory on the Data volume is the
# template loginwindow copies to /Volumes/${homeVolumeLabel}/$U at first login
# (the volume is automounted by diskarbitrationd before the login)
''}
# --- use the whole (possibly grown) disk
STORE=$(diskutil info "$SYS_ID" | sed -n 's/.*APFS Physical Store: *//p' | awk '{print $1}')
[ -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

@ -0,0 +1,56 @@
{
"x86_64-linux": {
"tahoe": {
"installer": {
"version": "26.6.2",
"build": "25G83",
"url": "https://swcdn.apple.com/content/downloads/37/33/140-93587-A_GRFFH93NOL/f944yaqo1cjhh2m0kxrl0zhcpg9yb9qphv/InstallAssistant.pkg",
"hash": "sha256-N2kj10lM+jK2nzg7z9zkau7bYJ/mokLqEqbgFb+3e6Q="
},
"recovery": {
"shortname": "tahoe",
"sha256": "edddd0d5869caaa12e29e6996a04f11590280580976a119dbd42c24fa62fe18e",
"file": "/nix/store/fqpih1dmg826cfxcyyxn4qm08pvcxgz4-macos-tahoe-BaseSystem.dmg",
"version": "26.6.2",
"build": "25G83"
}
},
"opencore": {
"image": {
"url": "https://raw.githubusercontent.com/kholia/OSX-KVM/4c378a4b5e0b219783683012bec680325eb40719/OpenCore/OpenCore.qcow2",
"sha256": "6ed36c0c2a4206ccc695f6b1a734a1cc6f94d288b0517c705d351c63cb92a6f3"
},
"pkg": {
"version": "1.0.7",
"url": "https://github.com/acidanthera/OpenCorePkg/releases/download/1.0.7/OpenCore-1.0.7-RELEASE.zip",
"sha256": "2ffab6ebf58c7aefb0bcb3a1a385d207746823d6dd87d44bd666e1286939943e"
},
"fetchRecoveryScript": {
"url": "https://raw.githubusercontent.com/kholia/OSX-KVM/4c378a4b5e0b219783683012bec680325eb40719/fetch-macOS-v2.py",
"sha256": "39ac6d26bd265f5d32198062f515ad15ef93afb7a74e702be2b008090d5bd5f3"
},
"kexts": {
"Lilu": {
"version": "1.7.2",
"url": "https://github.com/acidanthera/Lilu/releases/download/1.7.2/Lilu-1.7.2-RELEASE.zip",
"hash": "sha256-U5Z9fc+qsBAjoz3y6WmolSLxPWZUpqVqxHEbYtq/Org="
},
"VirtualSMC": {
"version": "1.3.7",
"url": "https://github.com/acidanthera/VirtualSMC/releases/download/1.3.7/VirtualSMC-1.3.7-RELEASE.zip",
"hash": "sha256-EvHTeZafkmMG+pLZTdvzOzKzEXZYncQgidhkomsxtwA="
},
"WhateverGreen": {
"version": "1.7.0",
"url": "https://github.com/acidanthera/WhateverGreen/releases/download/1.7.0/WhateverGreen-1.7.0-RELEASE.zip",
"hash": "sha256-bW/+gzStYPeEpmJ5TmeyVgt511fVBoQdyMqZlKs5l5s="
},
"RestrictEvents": {
"version": "1.1.6",
"url": "https://github.com/acidanthera/RestrictEvents/releases/download/1.1.6/RestrictEvents-1.1.6-RELEASE.zip",
"hash": "sha256-mBcN+uGV3dKLXZXj8EASWhPKeDvLm9HluMWI4hexTuY="
}
}
}
}
}

View file

@ -77,13 +77,23 @@ let
hasOsDisk = vmCfg.disks.os.file != null; hasOsDisk = vmCfg.disks.os.file != null;
# Auto-detect Windows from _vmixOsType marker on the disk image # Auto-detect Windows / macOS from _vmixOsType marker on the disk image
isWindows = vmCfg.windows.enable || (hasOsDisk && (vmCfg.disks.os.file._vmixOsType or "linux") == "windows"); osType = if hasOsDisk then vmCfg.disks.os.file._vmixOsType or "linux" else "linux";
isWindows = vmCfg.windows.enable || osType == "windows";
isMacos = vmCfg.macos.enable || osType == "macos";
macosMac = if vmCfg.macos.mac != null then vmCfg.macos.mac
else if hasOsDisk then vmCfg.disks.os.file.macAddress or "52:54:00:c9:18:27"
else "52:54:00:c9:18:27";
# OpenCore marks this PCI slot built-in (en0)
macosNicPlacement = ",bus=pcie.0,addr=${vmixLib.macos.qemu.nicAddr}";
cpuArg = if isMacos && vmCfg.cpu.model == "host" then vmCfg.macos.cpu
else "${vmCfg.cpu.model}${optionalString (vmCfg.cpu.hideVirtualized && !isMacos) ",kvm=off,hv_vendor_id=1234567890ab,-hypervisor"}";
# Interrupt remapping in the virtual IOMMU only works on a split irqchip, # Interrupt remapping in the virtual IOMMU only works on a split irqchip,
# so viommu wins over the full in-kernel irqchip hideVirtualized asks for. # so viommu wins over the full in-kernel irqchip hideVirtualized asks for.
# (macOS keeps the default irqchip: hideVirtualized does not apply to it.)
machineIrqchipArg = machineIrqchipArg =
if vmCfg.pci.viommu.enable then ",kernel-irqchip=split" if vmCfg.pci.viommu.enable then ",kernel-irqchip=split"
else optionalString vmCfg.cpu.hideVirtualized ",kernel_irqchip=on"; else optionalString (vmCfg.cpu.hideVirtualized && !isMacos) ",kernel_irqchip=on";
# Functions of one physical device have to reach the guest as functions # Functions of one physical device have to reach the guest as functions
# of one device too. Giving each address its own root port splits a GPU # of one device too. Giving each address its own root port splits a GPU
# from its own HDMI audio, and Navi cannot then reset or power-manage # from its own HDMI audio, and Navi cannot then reset or power-manage
@ -97,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;
@ -108,9 +154,9 @@ let
''; '';
}; };
# Windows VMs: use disk image as-is (customization done at image build time) # Windows/macOS VMs: use disk image as-is (customization done at image build time)
storeImage = if !hasOsDisk then null storeImage = if !hasOsDisk then null
else if isWindows then vmCfg.disks.os.file else if isWindows || isMacos then vmCfg.disks.os.file
else linuxOsImage; else linuxOsImage;
# When persist = true, QEMU needs a mutable disk outside /nix/store. # When persist = true, QEMU needs a mutable disk outside /nix/store.
@ -130,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 =
@ -165,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
@ -198,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"} \
@ -208,7 +265,7 @@ let
-m ${toString vmCfg.mem.size} \ -m ${toString vmCfg.mem.size} \
${optionalString vmCfg.mem.balloon "-device virtio-balloon-pci"} \ ${optionalString vmCfg.mem.balloon "-device virtio-balloon-pci"} \
-smp cores=${toString vmCfg.cpu.cores} \ -smp cores=${toString vmCfg.cpu.cores} \
-cpu ${vmCfg.cpu.model}${optionalString vmCfg.cpu.hideVirtualized ",kvm=off,hv_vendor_id=1234567890ab,-hypervisor"} \ -cpu ${cpuArg} \
-machine type=${vmCfg.pc.type}${machineIrqchipArg} \ -machine type=${vmCfg.pc.type}${machineIrqchipArg} \
${optionalString vmCfg.bios.efi "-bios ${pkgs.OVMF.fd}/FV/OVMF.fd"} \ ${optionalString vmCfg.bios.efi "-bios ${pkgs.OVMF.fd}/FV/OVMF.fd"} \
${optionalString vmCfg.bios.tpm "-chardev socket,id=chrtpm,path=/tmp/mytpm-sock -tpmdev emulator,id=tpm0,chardev=chrtpm -device tpm-tis,tpmdev=tpm0"} \ ${optionalString vmCfg.bios.tpm "-chardev socket,id=chrtpm,path=/tmp/mytpm-sock -tpmdev emulator,id=tpm0,chardev=chrtpm -device tpm-tis,tpmdev=tpm0"} \
@ -218,22 +275,33 @@ 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 \
''} \ ''} \
${optionalString hasOsDisk "-drive file=${osDiskPath},format=qcow2,if=virtio${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"}"} \ ${# 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 ''
${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
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"
else "-drive file=${osDiskPath},format=qcow2,if=virtio${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"}")} \
${optionalString (vmCfg.disks.iso.file != null) "-drive file=${toString vmCfg.disks.iso.file},media=cdrom,readonly=on"} \ ${optionalString (vmCfg.disks.iso.file != null) "-drive file=${toString vmCfg.disks.iso.file},media=cdrom,readonly=on"} \
${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 \ -device ${vmCfg.nicModel},netdev=user${optionalString isMacos ",mac=${macosMac}${macosNicPlacement}"} \
"} \ "} \
${concatMapStrings (tapCfg: '' ${concatStrings (imap1 (i: tapCfg: ''
-device ${vmCfg.nicModel},netdev=lan-${tapCfg.name},mac=${tapCfg.mac} \ -device ${vmCfg.nicModel},netdev=lan-${tapCfg.name},mac=${tapCfg.mac}${optionalString (isMacos && i == 1 && !cfg.networks.user.enable) macosNicPlacement} \
-netdev tap,id=lan-${tapCfg.name},ifname=${tapCfg.iface},script=no,downscript=no \ -netdev tap,id=lan-${tapCfg.name},ifname=${tapCfg.iface},script=no,downscript=no \
'') allTaps} \ '') allTaps)} \
${concatStrings (imap1 (i: macvtap: '' ${concatStrings (imap1 (i: macvtap: ''
-device ${vmCfg.nicModel},netdev=macvtap-${macvtap.name},mac=$(ip l show ${macvtap.iface} | awk '/link\/ether/{print $2}') \ -device ${vmCfg.nicModel},netdev=macvtap-${macvtap.name},mac=$(ip l show ${macvtap.iface} | awk '/link\/ether/{print $2}') \
-netdev tap,id=macvtap-${macvtap.name},fd=${toString (i+2)} ${toString (i+2)}<>/dev/tap$(ip l show ${macvtap.iface} | awk -F':' '/${macvtap.iface}/{print $1}') \ -netdev tap,id=macvtap-${macvtap.name},fd=${toString (i+2)} ${toString (i+2)}<>/dev/tap$(ip l show ${macvtap.iface} | awk -F':' '/${macvtap.iface}/{print $1}') \
@ -264,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 {
@ -249,6 +249,56 @@ with lib;
}; };
}; };
macos = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable macOS QEMU flags (OpenCore/AppleSMC, Skylake CPU spoof, AHCI disk, pinned NIC). Auto-enabled when disks.os.file carries _vmixOsType = \"macos\" metadata.";
};
cpu = mkOption {
type = types.str;
default = vmixLib.macos.qemu.defaultCpu;
description = "QEMU -cpu string used for macOS VMs when cpu.model is \"host\".";
};
mac = mkOption {
type = types.nullOr types.str;
default = null;
description = "MAC address of en0. Defaults to the image's macAddress (must match OpenCore's ROM for Apple ID / iMessage).";
};
guestAgent.enable = mkOption {
type = types.bool;
default = true;
description = "Attach Apple's built-in QEMU guest agent (virtio console port org.qemu.guest_agent.0). Socket: /run/vmix/qga-<name>.sock; guest-exec runs as root.";
};
homeDisk = {
enable = mkOption {
type = types.bool;
default = false;
description = "Persistent home volume: a host disk image attached as virtio-blk, formatted APFS with label `label` by the PE on first start. The image must be generalized with persistHome = true (the user's home is /Volumes/<label>/<user>), which makes the OS disk safely ephemeral (disks.os.persist = false).";
};
file = mkOption {
type = types.str;
default = "";
description = "Path of the home disk image, e.g. /storage/vms/mac/home.qcow2 (created if missing).";
};
format = mkOption {
type = types.enum [ "qcow2" "raw" ];
default = "qcow2";
description = "Image format; use raw for a zvol/block device (created only for files).";
};
size = mkOption {
type = types.str;
default = "64G";
description = "Size when the image is created.";
};
label = mkOption {
type = types.str;
default = "vmix-home";
description = "APFS volume label (must match generalize's homeVolumeLabel).";
};
};
};
tpm = { tpm = {
stateDir = mkOption { stateDir = mkOption {
type = types.str; type = types.str;