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
This commit is contained in:
Git Sagar 2026-09-08 16:03:52 -03:00
parent 6a62a649bd
commit 242e48a5fc
35 changed files with 1842 additions and 36 deletions

136
cli.nix
View file

@ -1,5 +1,9 @@
# vmix CLI — build, copy, and run Windows images
{ pkgs, self, system }:
# vmix CLI — build, copy, and run Windows / macOS images
{ pkgs, self, system, vmixLib }:
let
macosQemu = vmixLib.macos.qemu;
macserial = vmixLib.macos.macserial;
in
pkgs.writeShellScriptBin "vmix" ''
set -euo pipefail
@ -8,23 +12,30 @@ pkgs.writeShellScriptBin "vmix" ''
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-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 "Commands:"
echo " build Build a vmix image (optionally generalized)"
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 " build Build a vmix image (optionally generalized)"
echo " copy Build a vmix image and write it to a disk"
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 "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 " username=User password= hostname=PC"
echo " timezone=UTC bgColor=8e8cd8"
echo " delay-oobe-run=true (OOBE + activation on real hardware)"
echo " timezone=UTC bgColor=8e8cd8 (Windows only)"
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-remote-disk SSH:DEV Stream to remote disk via SSH and expand partitions"
echo " e.g. root@10.10.10.100:/dev/sda"
echo " --ahci Use AHCI storage for vmix run (for laptop images)"
echo " --macos macOS image for vmix run (OpenCore/AppleSMC flags, AHCI)"
echo " --vnc DISPLAY VNC instead of SDL for vmix run, e.g. :10 (port 5910) or 0.0.0.0:10"
echo " --mac ADDR NIC MAC for vmix run (macOS: read from the image's ESP by default)"
echo " -y, --yes Skip disk write confirmation"
echo " --out-link PATH Symlink for the build result (default: ./result)"
echo ""
@ -32,9 +43,9 @@ pkgs.writeShellScriptBin "vmix" ''
echo " vmix build --image windows.images.win10.laptop \\"
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP"
echo ""
echo " vmix copy --image windows.images.win10.laptop \\"
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\"
echo " --to-disk /dev/sda"
echo " vmix build --image macos.images.tahoe.basic \\"
echo " --generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich"
echo " vmix run ./result --macos --vnc :10 --mem 8192"
echo ""
echo " vmix copy --image windows.images.win10.laptop \\"
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\"
@ -49,24 +60,49 @@ pkgs.writeShellScriptBin "vmix" ''
COMMAND="$1"; shift
case "$COMMAND" in
build|copy|run) ;;
build|copy|run|macserial) ;;
--help|-h) usage ;;
*) echo "Unknown command: $COMMAND"; usage ;;
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 ---
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_MEM=4096
RUN_SMP=4
RUN_AHCI=false
RUN_MACOS=false
RUN_VNC=""
RUN_MAC=""
while [[ ''${#} -gt 0 ]]; do
case "$1" in
--mem) RUN_MEM="$2"; shift 2 ;;
--smp) RUN_SMP="$2"; shift 2 ;;
--ahci) RUN_AHCI=true; shift ;;
*) echo "Unknown option: $1"; exit 1 ;;
--mem) RUN_MEM="$2"; shift 2 ;;
--smp) RUN_SMP="$2"; shift 2 ;;
--ahci) RUN_AHCI=true; shift ;;
--macos) RUN_MACOS=true; shift ;;
--vnc) RUN_VNC="$2"; shift 2 ;;
--mac) RUN_MAC="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
@ -74,7 +110,9 @@ pkgs.writeShellScriptBin "vmix" ''
[[ ! -f "$RUN_IMAGE" ]] && { echo "Error: file not found: $RUN_IMAGE"; exit 1; }
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"
fi
@ -87,6 +125,30 @@ pkgs.writeShellScriptBin "vmix" ''
echo "Memory: $RUN_MEM MB"
echo "CPUs: $RUN_SMP"
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)"
echo ""
exec ${pkgs.qemu}/bin/qemu-system-x86_64 \
$VMIX_DISPLAY \
${macosQemu.deviceArgs} ${macosQemu.vgaArgs} \
-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 ""
@ -106,7 +168,7 @@ pkgs.writeShellScriptBin "vmix" ''
else
echo "-drive file=$RUN_IMAGE,format=qcow2,if=virtio,snapshot=on"
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 \
-chardev spicevmc,id=vdagent,debug=0,name=vdagent \
-device virtserialport,chardev=vdagent,name=com.redhat.spice.0
@ -161,8 +223,16 @@ pkgs.writeShellScriptBin "vmix" ''
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 "Image: $IMAGE_NAME"
echo "Image: $IMAGE_NAME ($OS_TYPE)"
[[ -n "$GENERALIZE" ]] && echo "Generalize: $GENERALIZE"
[[ -n "$TO_DISK" ]] && echo "To disk: $TO_DISK"
[[ -n "$TO_REMOTE_DISK" ]] && echo "To remote: $TO_REMOTE_DISK"
@ -191,6 +261,10 @@ pkgs.writeShellScriptBin "vmix" ''
IMAGE_FILE=$(readlink -f "$OUT_LINK")
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 ---
if [[ -n "$TO_DISK" ]]; then
echo ""
@ -224,6 +298,15 @@ pkgs.writeShellScriptBin "vmix" ''
echo "[3/5] Fixing GPT backup header..."
${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
${pkgs.gptfdisk}/bin/sgdisk -d 4 "$TO_DISK" 2>/dev/null || true
@ -281,13 +364,22 @@ pkgs.writeShellScriptBin "vmix" ''
kill $NBD_PID 2>/dev/null || true
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
REMOTE_WIN_PART="''${REMOTE_DISK}p3"
else
REMOTE_WIN_PART="''${REMOTE_DISK}3"
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'"
echo "[4/5] Expanding Windows partition (partition 3)..."

View file

@ -24,7 +24,7 @@
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 = {
type = "app";

View file

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

View file

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

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

@ -0,0 +1,134 @@
# vmix macOS images
Unattended macOS (Tahoe / 26) VM images, built the same way as the Windows
images: `makeImage` installs the OS once, templates customize it by booting it,
`.generalize` creates the user and seals the image.
```
vmix build --image macos.images.tahoe.basic \
--generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich
vmix run ./result --macos --vnc :10 --mem 8192 # VNC on port 5910
```
## How it works
| step | what happens |
|---|---|
| `fetchRecovery` | BaseSystem.dmg from Apple's recovery servers (fixed-output, pinned by sha256) |
| `installerPayload` | takes the App Store `InstallAssistant.pkg` (18 GB, pinned) apart on Linux: the app skeleton (pbzx/cpio) and the byte offset of `SharedSupport.dmg` |
| `makeOpenCore` | OSX-KVM's OpenCore ESP with a config.plist rewritten for this image: SMBIOS model, serial + MLB (`macserial`), UUID and ROM = NIC MAC (derived from a seed), NIC marked built-in |
| `makeImage` | one QEMU session: Recovery boots via OpenCore → `vm-driver.py` opens Terminal with keystrokes (Ctrl-F2 menu navigation, screen-settle detection + OCR of the menu bar) and types `sh /Volumes/VMIX/run.sh``vmix-install.sh` erases the disk, rebuilds `Install macOS Tahoe.app` (skeleton + `SharedSupport.dmg` copied from a raw disk mapped straight out of the pkg), runs `startosinstall --installpackage vmix-agent.pkg` → installer reboots through its phases → first boot runs the **vmix agent** which powers off. OpenCore is then copied into the image's EFI partition, so it boots standalone with OVMF |
| `customizeImage` | boots the image with a FAT volume `VMIX`; the agent (LaunchDaemon `ch.vmix.agent`) runs `vmix-run.sh` as root, writes `vmix-run.status`/`.log` back and shuts down |
| `templates.generalize` | user (admin) + auto-login (`/etc/kcpassword`), Setup Assistant suppressed, hostname, timezone, no sleep, APFS grown to the disk, then the agent removes itself; a fresh SMBIOS identity is written to the ESP |
The vmix agent replaces Windows' Audit Mode RunOnce; `.AppleSetupDone` replaces
the OOBE unattend. Everything on the host side runs inside `__noChroot`
derivations (KVM + `/tmp`), exactly like the Windows builders.
## Generalize options
`username password fullName autoLogon hostname locale timezone delayOobeRun`
as for Windows (`bgColor` is accepted but ignored), plus the SMBIOS identity:
`model serial mlb uuid mac seed`. Anything unset is generated: serial/MLB by
macserial (random per build), MAC and UUID deterministically from `seed`
(default `hostname-username`). `vmix macserial --model MacPro7,1` prints a
ready-to-paste set.
`delay-oobe-run=true` creates no user and re-arms Setup Assistant for the first
real boot.
## Apple ID / iMessage
The image satisfies what Dortania lists for iServices: unique serial + MLB for a
Tahoe-supported model (`MacPro7,1` by default; `iMac20,1/2`,
`MacBookPro16,x` also work), SystemUUID, ROM equal to en0's MAC, and en0 marked
built-in (the NIC is pinned to `PciRoot(0x0)/Pci(0x12,0x0)`). The NixOS module
and `vmix run --macos` use the MAC recorded in the image (`EFI/vmix/vmix.json`).
Give each deployed VM its own generalized image (different `seed`, or explicit
`serial=`/`mlb=`) — two VMs with the same identity will be blocked.
## Runtime
* `vmix run <qcow2> --macos [--vnc :N] [--mac ..]`
* NixOS module: `disks.os.file = vmixLib.macos.images.tahoe.basic.generalize {...}`
is auto-detected (`_vmixOsType = "macos"`): Skylake-Client CPU spoof, AppleSMC,
USB keyboard/tablet, AHCI system disk, VMware SVGA, pinned NIC with the image's MAC.
`macos.cpu`, `macos.mac`, `macos.enable` override the defaults.
* `vmix copy` writes the image to a disk but cannot grow APFS from Linux
(`diskutil apfs resizeContainer disk0s2 0` in macOS afterwards).
## Debugging a build
Screenshots (`NNN-<state>.png`), `driver.log` and the QMP socket of every VM
session are in `/tmp/vmix-macos/<image name>/` on the build host. The guest logs
(`install.log`, `vmix-run.log`, `vmix-agent.log`) are printed at the end of the
build. Pass `vncDisplay = ":10"` to `makeImage`/`customizeImage` (or
`--generalize vncDisplay=:10`) to watch live; with a `DISPLAY` an SDL window
is used as for Windows.
## Updating pins (`upstream.json`)
* installer: URL + SRI hash of a newer `InstallAssistant.pkg`
(`nix store prefetch-file --name InstallAssistant.pkg <url>`; Mr. Macintosh's
database lists Apple's URLs)
* recovery: Apple serves the current build for the board id, so the sha256
changes with each point release — copy the "got:" hash from the failed build
* opencore: OSX-KVM `OpenCore.qcow2` at a commit; OpenCorePkg release zip (macserial/ocvalidate)
## Known limits
* The Recovery bootstrap depends on keyboard navigation of the Recovery UI
(Ctrl-F2 → Utilities → Terminal). It self-corrects with screenshots + OCR and
falls back to a blind sequence, but a Recovery UI change would need
`vm-driver.py` adjusted.
* Hosts must run KVM with an AVX2-capable CPU (Intel or AMD; the guest sees a
Skylake). `sandbox = relaxed` and the `kvm` system feature, as for Windows.
* Software updates inside the VM are disabled by the `noUpdates` template
(OTA updates in a VM need the RestrictEvents kext).
## Current status (2026-09-08)
Everything up to and including the macOS Installer's *prepare* phase is working and
proven end to end on the `root@daku.home` KVM host:
* OpenCore ESP per-image SMBIOS (serial/MLB via macserial, ROM=MAC, UUID), boot
disk, `.contentVisibility` to hide the OC self-entry — **works** (`ocvalidate` clean).
* Tahoe recovery boots via OpenCore; `vm-driver.py` drives it entirely by
screenshots + OCR: handles the OpenCore picker, opens Terminal (Ctrl-F2 →
Utilities → Terminal), types the bootstrap command — **works**.
* HFS+ `VMIX` volume mounts in Recovery; the install script erases the disk as
APFS "Macintosh HD", lays down the host-extracted `Install macOS Tahoe.app`
skeleton and `dd`s a **byte-exact** `SharedSupport.dmg` from a raw disk mapped
to that byte range of the pkg (verified `sha256` identical to Apple's) — **works**.
* `startosinstall` runs, `osinstallersetupd` **mounts SharedSupport.dmg**, reads
the MobileAsset bundle (`IA OS Version: 26.6.2, Build 25G83`), loads the
641-product SU catalog from swscan.apple.com (so guest networking works), and
logs `Machine is VM, will assume APFS is supported`.
### Blocker 1 — `OSISVerifyBaseSystemOperation: pkgdmg is missing a footer`
After mounting the dmg, `osinstallersetupd` runs a verify step that treats
`SharedSupport.dmg` as a *pkgdmg* (`Getting offset for dmg in pkg`) and fails
`pkgdmg is missing a footer` → `Installation cannot proceed because the installer
is damaged` (Code 255). But this InstallAssistant `SharedSupport.dmg` is a **plain
UDIF** image (first bytes `eb 58 90 …`, not `xar!`), and it is **byte-identical to
Apple's** — so this is not truncation or corruption (earlier truncation, from
Recovery's `xar` mishandling an 18 GB member, was fixed by the byte-range `dd`).
It is a macOS-internal verification that rejects the plain-UDIF SharedSupport
when running Tahoe's `startosinstall` in this recovery/VM. Network is fine (the
catalog loaded), so it is not the firewall case commonly cited for this error.
Leads not yet tried: driving the Tahoe recovery's **network "Reinstall macOS"**
(GUI, downloads assets at install time — sidesteps the local-dmg verify); a
different SMBIOS/board; or a newer OpenCore/kext combo. This is cutting-edge
(Tahoe shipped 2025-09) and the hackintosh community is still working it out.
### Blocker 2 — Tahoe recovery availability on Apple's CDN
`osrecovery.apple.com` is load-balancing `latest` across CDN nodes during the
Tahoe rollout: most requests return the **Sequoia** 15.4.1 BaseSystem
(`082-33203`), some return **Tahoe** 26.6.2 (`140-93589`). `fetchRecovery`
retries until it gets the pinned Tahoe hash, but that can exhaust its attempts
when Tahoe is rare. The robust fix is to self-host the verified Tahoe
`BaseSystem.dmg` (960530321 bytes, `sha256 edddd0d5…`, confirmed 26.6.2) the way
the Win10 ISO is hosted on git.sagar.ch, and point `fetchRecovery` at it.

View file

@ -0,0 +1,44 @@
{ 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; };
makeVmixVolume = import ./helpers/makeVmixVolume.nix { inherit pkgs lib; };
makeAgentPkg = import ./helpers/makeAgentPkg.nix { inherit pkgs lib; };
installBootloader = import ./helpers/installBootloader.nix { inherit pkgs lib; };
vmixReadback = import ./helpers/vmix-readback.nix { inherit pkgs lib; };
vmDriver = ./helpers/vm-driver.py;
makeImage = import ./helpers/makeImage.nix {
inherit pkgs lib qemu ident installerPayload makeOpenCore makeVmixVolume makeAgentPkg installBootloader vmixReadback vmDriver;
};
customizeImage = import ./helpers/customizeImage.nix {
inherit pkgs lib qemu ident makeVmixVolume makeOpenCore installBootloader vmixReadback vmDriver;
};
customizeImageFold = builtins.foldl' customizeImage;
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,30 @@
#!/bin/sh
# vmix agent: LaunchDaemon that runs at every boot as root. If a volume named
# VMIX carrying vmix-run.sh is attached, run it, record the result on the
# volume and power off. Without the volume it is a no-op (normal boot).
# This is the macOS counterpart of the Windows Audit Mode RunOnce script.
LOG=/var/log/vmix-agent.log
exec >>"$LOG" 2>&1
echo "=== vmix agent: $(date) ==="
V=/Volumes/VMIX
i=0
while [ ! -f "$V/vmix-run.sh" ] && [ $i -lt 60 ]; do
diskutil mount VMIX >/dev/null 2>&1
sleep 2
i=$((i + 1))
done
if [ ! -f "$V/vmix-run.sh" ]; then
echo "vmix agent: no VMIX volume, normal boot"
exit 0
fi
echo "vmix agent: running vmix-run.sh"
cd "$V" || exit 1
sh "$V/vmix-run.sh" >"$V/vmix-run.log" 2>&1
rc=$?
echo "vmix agent: vmix-run.sh exited $rc"
echo "$rc" >"$V/vmix-run.status"
cp "$LOG" "$V/vmix-agent.log" 2>/dev/null
sync
sleep 2
diskutil unmount force "$V" >/dev/null 2>&1
shutdown -h now

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.agent</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>/Library/vmix/agent.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/vmix-agent.log</string>
<key>StandardErrorPath</key>
<string>/var/log/vmix-agent.log</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,117 @@
#!/bin/sh
# vmix: automated macOS install. Runs inside macOS Recovery's Terminal, started
# by vm-driver.py which types "sh /Volumes/VMIX/run.sh" for us.
#
# 1. erase the target disk (found by size) as APFS "Macintosh HD"
# 2. rebuild "Install macOS <name>.app": app skeleton from installer-app.tar
# (host-extracted Payload) + SharedSupport.dmg copied byte-exact by dd from a
# raw disk that maps that byte range of the pkg (Recovery's xar truncates an
# 18 GB member, which makes startosinstall report "pkgdmg is missing a footer")
# 3. startosinstall unattended, with the vmix agent pkg as --installpackage
# 4. when the prepare phase is done (SIGUSR1) also drop the agent + .AppleSetupDone
# onto the volume, then let the installer reboot
#
# On first boot of the installed system the agent runs /Volumes/VMIX/vmix-run.sh
# and powers off, which ends the QEMU session on the host.
# macOS Recovery invokes us as `sh` (bash in POSIX mode, no process substitution);
# re-exec once under bash so `>(tee ...)` and other bashisms work.
if [ -z "${VMIX_REEXEC:-}" ]; then VMIX_REEXEC=1 exec bash "$0" "$@"; fi
V="/Volumes/VMIX"
# tee to the Terminal (visible in host screenshots) and to a log on the volume
exec > >(tee "$V/install.log") 2>&1
set -x
. "$V/vmix.conf"
fail() {
echo "vmix-install: FAIL: $*"
cp /var/log/install.log "$V/system-install.log" 2>/dev/null || true
echo 1 >"$V/install.status"
sync
sleep 2
shutdown -h now 2>/dev/null || halt 2>/dev/null || true
exit 1
}
# whole-disk identifier (diskN) whose size in bytes is exactly $1
disk_by_size() {
for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+'); do
s=$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9][0-9]*\) Bytes).*/\1/p')
[ "$s" = "$1" ] && { echo "${d#/dev/}"; return 0; }
done
return 1
}
echo "vmix-install: $(date) app=$APP_NAME volume=$VOLUME_NAME"
TARGET=$(disk_by_size "$TARGET_BYTES") || fail "target disk ($TARGET_BYTES bytes) not found"
SSDISK=$(disk_by_size "$SS_DISK_BYTES") || fail "SharedSupport disk ($SS_DISK_BYTES bytes) not found"
echo "vmix-install: target=$TARGET sharedsupport=$SSDISK"
# --- 1. erase the target disk as an APFS volume
diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk $TARGET"
VOL="/Volumes/$VOLUME_NAME"
[ -d "$VOL" ] || fail "$VOL not mounted"
# --- 2. rebuild the installer app on the target volume
tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar installer-app.tar"
APP="$VOL/$APP_NAME"
SOI="$APP/Contents/Resources/startosinstall"
[ -x "$SOI" ] || fail "startosinstall missing in $APP"
SS="$APP/Contents/SharedSupport/SharedSupport.dmg"
mkdir -p "$APP/Contents/SharedSupport"
FULL=$((SS_LEN / 1048576))
REM=$((SS_LEN % 1048576))
dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport.dmg"
if [ "$REM" -gt 0 ]; then
dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" || fail "dd SharedSupport.dmg tail"
fi
[ "$(stat -f %z "$SS")" = "$SS_LEN" ] || fail "SharedSupport.dmg size mismatch: $(stat -f %z "$SS") != $SS_LEN"
tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no UDIF koly footer"
chflags -h norestricted "$SS" 2>/dev/null || true
echo "vmix-install: app=$APP SharedSupport.dmg=$(stat -f %z "$SS") bytes"
# macOS certificate validation needs a sane clock; a fresh VM RTC can be wrong.
echo "vmix-install: guest clock is $(date) (UTC $(date -u))"
if [ -n "${BUILD_DATE:-}" ]; then
date -u "$BUILD_DATE" && echo "vmix-install: set clock to $(date)"
fi
# --- 3. unattended install
PREPARED=0
trap 'PREPARED=1' USR1
run_install() {
"$SOI" --volume "$VOL" --agreetolicense --nointeraction --pidtosignal $$ "$@" &
INSTALL_PID=$!
while :; do
wait $INSTALL_PID
rc=$?
[ "$PREPARED" = 1 ] && return 0
kill -0 $INSTALL_PID 2>/dev/null || return $rc
done
}
run_install --rebootdelay 300 --installpackage "$V/vmix-agent.pkg" \
|| { echo "vmix-install: retry without --rebootdelay"; run_install --installpackage "$V/vmix-agent.pkg"; } \
|| { echo "vmix-install: retry without --installpackage"; run_install; } \
|| fail "startosinstall"
# --- 4. prepare phase done: also drop the agent onto the volume directly.
T="$VOL"
if [ -d "$T" ]; then
mkdir -p "$T/Library/LaunchDaemons" "$T/Library/vmix" "$T/private/var/db"
cp "$V/agent/agent.sh" "$T/Library/vmix/agent.sh"
cp "$V/agent/ch.vmix.agent.plist" "$T/Library/LaunchDaemons/ch.vmix.agent.plist"
chmod 755 "$T/Library/vmix/agent.sh"
chmod 644 "$T/Library/LaunchDaemons/ch.vmix.agent.plist"
chown -R root:wheel "$T/Library/vmix" "$T/Library/LaunchDaemons/ch.vmix.agent.plist"
touch "$T/private/var/db/.AppleSetupDone"
chown root:wheel "$T/private/var/db/.AppleSetupDone"
else
echo "vmix-install: WARNING: $T not mounted after prepare, relying on --installpackage"
fi
echo 0 >"$V/install.status"
sync
kill -USR1 $INSTALL_PID
wait $INSTALL_PID
exit 0

View file

@ -0,0 +1,105 @@
# Customize a macOS image by booting it with a VMIX volume: the vmix agent
# (LaunchDaemon installed by makeImage) runs `script` as root, records the exit
# status on the volume and powers off. Optionally re-installs OpenCore with a new
# SMBIOS identity (`smbios`). Counterpart of the Windows auditScript flow.
#
# Templates provide:
# script — sh script run as root on the booted system
# files — [{ source; name; }] extra files placed next to it on /Volumes/VMIX
# smbios — { model? serial? mlb? uuid? mac? seed? } → fresh OpenCore config in the ESP
{ pkgs, lib, qemu, ident, makeVmixVolume, makeOpenCore, installBootloader, vmixReadback, vmDriver, ... }:
originalImage: {
name ? "",
script ? "",
files ? [],
smbios ? null,
diskSize ? "",
impure ? true,
vncDisplay ? null,
smp ? 4,
memSize ? 4096,
cpu ? qemu.defaultCpu,
timeout ? 3600,
}:
let
originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name);
customImageName = (if name != "" then name else "custom") + "-${originalImageName}-vmix.qcow2";
resultImg = "./disk.qcow2";
hasScript = script != "";
hasSmbios = smbios != null;
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;
runScript = pkgs.writeText "${name}-vmix-run.sh" ''
#!/bin/sh
echo "=== vmix: ${name} ==="
${script}
'';
vmixVol = makeVmixVolume {
name = "${name}-${originalImageName}";
files = [ { source = runScript; name = "vmix-run.sh"; } ] ++ files;
};
driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
bootCommands = lib.optionalString hasScript ''
cp ${vmixVol} vmix.img
chmod +w vmix.img
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd
chmod +w vars.fd
VMIX_DISPLAY="-display none"
${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''}
${lib.optionalString (vncDisplay == null) ''
VMIX_DF=$(ls -t /tmp/.vmix-display-* 2>/dev/null | head -1)
if [ -n "$VMIX_DF" ] && [ "$(stat -c %s "$VMIX_DF")" -lt 256 ] && ! grep -q -P '[^\x20-\x7e\n]' "$VMIX_DF"; then
export DISPLAY=$(tr -d '\n' < "$VMIX_DF")
export HOME=$(mktemp -d)
export XDG_RUNTIME_DIR=$HOME
export SDL_VIDEODRIVER=x11
VMIX_DISPLAY="-display sdl"
fi
''}
echo "=== vmix: booting ${originalImageName} for ${name} ==="
python3 ${vmDriver} --mode boot --name "${name}-${originalImageName}" --timeout ${toString timeout} -- \
qemu-system-x86_64 $VMIX_DISPLAY \
${qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.firmwareArgs "vars.fd"} \
${qemu.sataDrive { id = "system"; port = 0; file = resultImg; }} \
${qemu.sataDrive { id = "vmix"; port = 1; file = "vmix.img"; format = "raw"; }} \
${qemu.netArgs { mac = originalImage.macAddress; }} \
|| { echo "vmix: VM failed during ${name} (see /tmp/vmix-macos/${name}-${originalImageName})"; exit 1; }
${vmixReadback "vmix.img"}
[ "$STATUS" = "0" ] || { echo "vmix: ${name} script failed (status '$STATUS')"; exit 1; }
echo "=== vmix: ${name} complete ==="
'';
builtImage = pkgs.runCommand customImageName ({
nativeBuildInputs = with pkgs; [ pkgs.qemu mtools driverPython libguestfs-with-appliance ];
requiredSystemFeatures = [ "kvm" ];
} // lib.optionalAttrs impure { __noChroot = true; }) ''
qemu-img create -q -f qcow2 -b ${originalImage} -F qcow2 ${resultImg}
[ -n "${diskSize}" ] && qemu-img resize ${resultImg} ${diskSize}
${bootCommands}
${lib.optionalString hasSmbios (installBootloader { inherit esp; image = resultImg; })}
mv ${resultImg} $out
'';
in
builtImage // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; model = esp.model or model; }

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,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,75 @@
# Distribution-style flat package (xar + bom + cpio, built on Linux) for
# `startosinstall --installpackage`: installs the vmix agent LaunchDaemon and
# marks Setup Assistant as done so the first boot lands on loginwindow.
{ pkgs, lib, ... }:
{ version ? "1.0" }:
let
id = "ch.vmix.agent";
# nixpkgs' bomutils aborts under _FORTIFY_SOURCE
bomutils = pkgs.bomutils.overrideAttrs (_: { hardeningDisable = [ "fortify" ]; });
postinstall = pkgs.writeText "postinstall" ''
#!/bin/sh
# $3 = target volume
T="$3"
mkdir -p "$T/private/var/db"
touch "$T/private/var/db/.AppleSetupDone"
chown root:wheel "$T/private/var/db/.AppleSetupDone"
chmod 755 "$T/Library/vmix/agent.sh"
chown -R root:wheel "$T/Library/vmix" "$T/Library/LaunchDaemons/${id}.plist"
exit 0
'';
in
pkgs.runCommand "vmix-agent-${version}.pkg" {
nativeBuildInputs = [ pkgs.xar bomutils pkgs.cpio pkgs.gzip ];
} ''
mkdir -p root/Library/LaunchDaemons root/Library/vmix scripts flat/vmix-agent.pkg
cp ${../guest/agent.sh} root/Library/vmix/agent.sh
cp ${../guest/ch.vmix.agent.plist} root/Library/LaunchDaemons/${id}.plist
chmod 755 root/Library/vmix/agent.sh
chmod 644 root/Library/LaunchDaemons/${id}.plist
cp ${postinstall} scripts/postinstall
chmod 755 scripts/postinstall
NFILES=$(find root | wc -l)
KBYTES=$(du -sk root | cut -f1)
(cd root && find . | cpio -o --format odc --owner 0:0 --quiet | gzip -c > ../flat/vmix-agent.pkg/Payload)
(cd scripts && find . | cpio -o --format odc --owner 0:0 --quiet | gzip -c > ../flat/vmix-agent.pkg/Scripts)
mkbom -u 0 -g 80 root flat/vmix-agent.pkg/Bom
cat > flat/vmix-agent.pkg/PackageInfo <<XML
<?xml version="1.0" encoding="utf-8"?>
<pkg-info overwrite-permissions="true" relocatable="false" identifier="${id}" postinstall-action="none" version="${version}" format-version="2" generated-by="vmix" auth="root">
<payload installKBytes="$KBYTES" numberOfFiles="$NFILES"/>
<bundle-version/>
<upgrade-bundle/>
<update-bundle/>
<atomic-update-bundle/>
<strict-identifier/>
<relocate/>
<scripts>
<postinstall file="./postinstall"/>
</scripts>
</pkg-info>
XML
cat > flat/Distribution <<XML
<?xml version="1.0" encoding="utf-8"?>
<installer-gui-script minSpecVersion="1">
<title>vmix agent</title>
<options customize="never" require-scripts="false" hostArchitectures="x86_64,arm64" rootVolumeOnly="true"/>
<product id="${id}" version="${version}"/>
<choices-outline>
<line choice="default">
<line choice="${id}"/>
</line>
</choices-outline>
<choice id="default"/>
<choice id="${id}" visible="false">
<pkg-ref id="${id}"/>
</choice>
<pkg-ref id="${id}" version="${version}" onConclusion="none" installKBytes="$KBYTES">#vmix-agent.pkg</pkg-ref>
</installer-gui-script>
XML
sed -i 's/^ //' flat/vmix-agent.pkg/PackageInfo flat/Distribution
(cd flat && xar --compression none -cf $out Distribution vmix-agent.pkg)
xar -t -f $out
''

View file

@ -0,0 +1,132 @@
# Build a pre-installed macOS qcow2 with an unattended QEMU install.
#
# One QEMU session, driven by vm-driver.py:
# Recovery (BaseSystem) boots via OpenCore → driver opens Terminal with
# keystrokes and types "sh /Volumes/VMIX/run.sh" → vmix-install.sh erases the
# disk, rebuilds the installer app from installer-app.tar + the SharedSupport.dmg
# raw disk, runs startosinstall (--installpackage vmix-agent.pkg) → the installer
# reboots through its phases → first boot of macOS runs the vmix agent, which
# executes vmix-run.sh and powers off → QEMU exits.
# Afterwards OpenCore is copied into the image's EFI partition so the result
# boots standalone with plain OVMF. Apply templates with customizeImageFold,
# then .generalize to create the user and set a fresh SMBIOS identity.
{ pkgs, lib, qemu, ident, installerPayload, makeOpenCore, makeVmixVolume, makeAgentPkg, installBootloader, vmixReadback, vmDriver, ... }:
{
name ? "macos",
installer, # InstallAssistant.pkg (fetchurl)
recovery, # BaseSystem.dmg (fetchRecovery)
diskSize ? "128G",
volumeName ? "Macintosh HD",
smp ? 4,
memSize ? 8192,
cpu ? qemu.defaultCpu,
model ? "MacPro7,1", # SMBIOS model; must be Tahoe-supported (MacPro7,1, iMac20,1/2, MacBookPro16,x)
seed ? name, # MAC address + SystemUUID are derived from this
bootArgs ? "keepsyms=1",
vncDisplay ? null, # e.g. ":10" to watch the install on port 5910
timeout ? 4 * 3600, # seconds for the whole install
extraOpenCoreConfig ? {}, # merged into config.plist
}:
let
mac = ident.macFromSeed seed;
uuid = ident.uuidFromSeed seed;
esp = makeOpenCore { name = "${name}-opencore"; inherit model mac uuid bootArgs; extraConfig = extraOpenCoreConfig; };
payload = installerPayload { inherit name; pkg = installer; };
recoveryImg = pkgs.runCommand "${name}-BaseSystem.img" { nativeBuildInputs = [ pkgs.dmg2img ]; } ''
dmg2img -s ${recovery} $out
'';
agentPkg = makeAgentPkg { };
agentDir = pkgs.runCommand "vmix-agent-files" { } ''
mkdir -p $out
cp ${../guest/agent.sh} $out/agent.sh
cp ${../guest/ch.vmix.agent.plist} $out/ch.vmix.agent.plist
'';
firstBoot = pkgs.writeText "vmix-run.sh" ''
echo "vmix: first boot of the installed system"
sw_vers
exit 0
'';
vmixVol = makeVmixVolume {
inherit name;
size = "512M";
files = [
{ source = ../guest/vmix-install.sh; name = "run.sh"; }
{ source = "${payload}/installer-app.tar"; name = "installer-app.tar"; }
{ source = agentPkg; name = "vmix-agent.pkg"; }
{ source = agentDir; name = "agent"; }
{ source = firstBoot; name = "vmix-run.sh"; }
];
};
driverPython = pkgs.python3.withPackages (p: [ p.pillow p.pytesseract ]);
tesseract = pkgs.tesseract.override { enableLanguages = [ "eng" ]; };
drv = pkgs.runCommand "${name}-vmix.qcow2" {
__noChroot = true;
requiredSystemFeatures = [ "kvm" ];
nativeBuildInputs = with pkgs; [ pkgs.qemu mtools jq driverPython tesseract libguestfs-with-appliance ];
} ''
echo "=== vmix: creating ${diskSize} disk ==="
qemu-img create -f qcow2 disk.qcow2 ${diskSize}
# store files are read-only and AHCI needs writable nodes: qcow2 overlays
qemu-img create -q -f qcow2 -F raw -b ${recoveryImg} recovery.qcow2
qemu-img create -q -f qcow2 -F raw -b ${esp}/boot.img ocboot.qcow2
# SharedSupport.dmg is exposed as its own raw disk mapped to that byte range of
# the pkg (zero host copy). qemu accepts a non-512-aligned raw offset, so the
# disk starts exactly at the dmg; the guest dd's SS_LEN bytes into the app.
SS_OFF=$(${pkgs.jq}/bin/jq '."SharedSupport.dmg".offset' ${payload}/installer.json)
SS_LEN=$(${pkgs.jq}/bin/jq '."SharedSupport.dmg".length' ${payload}/installer.json)
SS_DISK=$(( (SS_LEN + 511) / 512 * 512 ))
qemu-img create -q -f qcow2 -F raw -b "json:{\"driver\":\"raw\",\"offset\":$SS_OFF,\"size\":$SS_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
SS_LEN=$SS_LEN
SS_DISK_BYTES=$SS_DISK
APP_NAME="$(cat ${payload}/app-name)"
VOLUME_NAME="${volumeName}"
BUILD_DATE="$(date -u +%Y%m%d%H%M.%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}"''}
${lib.optionalString (vncDisplay == null) ''
VMIX_DF=$(ls -t /tmp/.vmix-display-* 2>/dev/null | head -1)
if [ -n "$VMIX_DF" ] && [ "$(stat -c %s "$VMIX_DF")" -lt 256 ] && ! grep -q -P '[^\x20-\x7e\n]' "$VMIX_DF"; then
export DISPLAY=$(tr -d '\n' < "$VMIX_DF")
export HOME=$(mktemp -d)
export XDG_RUNTIME_DIR=$HOME
export SDL_VIDEODRIVER=x11
VMIX_DISPLAY="-display sdl"
fi
''}
echo "=== vmix: installing ${name} (unattended, 1-2 h; screenshots in /tmp/vmix-macos/${name}) ==="
python3 ${vmDriver} --mode install --name ${name} --timeout ${toString timeout} -- \
qemu-system-x86_64 $VMIX_DISPLAY \
${qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.firmwareArgs "vars.fd"} \
${qemu.sataDrive { id = "opencore"; port = 0; file = "ocboot.qcow2"; }} \
${qemu.sataDrive { id = "recovery"; port = 1; file = "recovery.qcow2"; }} \
${qemu.sataDrive { id = "system"; port = 2; file = "disk.qcow2"; }} \
${qemu.sataDrive { id = "vmix"; port = 3; file = "vmix.img"; format = "raw"; }} \
${qemu.sataDrive { id = "sharedsupport"; port = 4; file = "sharedsupport.qcow2"; }} \
${qemu.netArgs { inherit mac; }} \
|| { echo "vmix: install VM failed (see /tmp/vmix-macos/${name})"; exit 1; }
${vmixReadback "vmix.img"}
[ "$STATUS" = "0" ] || { echo "vmix: first boot did not complete (status '$STATUS'), see /tmp/vmix-macos/${name}"; exit 1; }
${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; }

View file

@ -0,0 +1,68 @@
# 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",
resolution ? "1024x768",
showPicker ? true,
pickerTimeout ? 2,
extraConfig ? {},
}:
let
ocImage = pkgs.fetchurl { inherit (upstream.opencore.image) url sha256; name = "OSX-KVM-OpenCore.qcow2"; };
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
# 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)}
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,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,99 @@
#!/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='{}')
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
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,35 @@
# 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)
deviceArgs = ''-device isa-applesmc,osk="${osk}" -smbios type=2 -device qemu-xhci,id=xhci -device usb-kbd,bus=xhci.0 -device usb-tablet,bus=xhci.0 -device usb-ehci,id=ehci -device ich9-intel-hda -device hda-duplex -device ich9-ahci,id=sata -global ICH9-LPC.disable_s3=1'';
# macOS has no QXL/virtio-gpu driver; VMware SVGA gives a plain framebuffer
vgaArgs = "-vga vmware";
machineArgs = { cpu ? defaultCpu, smp ? 4, memSize ? 4096 }:
"-accel kvm -machine type=q35 -cpu ${cpu} -smp ${toString smp},sockets=1,cores=${toString smp},threads=1 -m ${toString memSize} ${deviceArgs} ${vgaArgs}";
# SATA disk on a given port. Store files are read-only: callers create qcow2 overlays.
sataDrive = { id, port, file, format ? "qcow2", extra ? "" }:
"-drive id=${id},if=none,format=${format},file=${file}${extra} -device ide-hd,bus=sata.${toString port},drive=${id}";
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,415 @@
#!/usr/bin/env python3
"""vmix macOS VM driver.
Launches QEMU with a QMP socket and either
--mode install drives macOS Recovery to a Terminal with keystrokes (screen
settle detection + OCR of the menu bar), types the bootstrap
command and waits for the VM to power itself off
--mode boot waits for the VM to power itself off (customize steps)
Everything after `--` is the QEMU command line. Screenshots and a log are
written to --debug-dir (default /tmp/vmix-macos/<name>) for troubleshooting.
"""
import argparse
import hashlib
import io
import json
import os
import socket
import subprocess
import sys
import time
try:
from PIL import Image
except ImportError: # pragma: no cover
Image = None
try:
import pytesseract
except ImportError: # pragma: no cover
pytesseract = None
# QEMU qcodes for characters that are not plain alphanumerics
PLAIN = {' ': 'spc', '/': 'slash', '-': 'minus', '.': 'dot', ';': 'semicolon', ',': 'comma',
'=': 'equal', "'": 'apostrophe', '`': 'grave_accent', '[': 'bracket_left',
']': 'bracket_right', '\\': 'backslash', '\n': 'ret', '\t': 'tab'}
SHIFTED = {'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7', '*': '8',
'(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left',
'}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe',
'<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent'}
class Log:
def __init__(self, path):
self.f = open(path, 'a')
self.t0 = time.time()
def __call__(self, msg):
line = f'[{time.time() - self.t0:7.1f}s] {msg}'
print(f'vmix driver: {line}', flush=True)
self.f.write(line + '\n')
self.f.flush()
class QMP:
def __init__(self, path):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.connect(path)
self.f = self.sock.makefile('rwb', buffering=0)
self._read()
self.cmd('qmp_capabilities')
def _read(self):
while True:
line = self.f.readline()
if not line:
raise EOFError('QMP connection closed')
msg = json.loads(line)
if 'event' in msg:
continue
return msg
def cmd(self, name, **args):
self.f.write((json.dumps({'execute': name, 'arguments': args}) + '\n').encode())
r = self._read()
if 'error' in r:
raise RuntimeError(f'QMP {name}: {r["error"]}')
return r.get('return')
def screendump(self, path):
self.cmd('screendump', filename=path)
def send_key(self, *keys, hold=80):
self.cmd('send-key', keys=[{'type': 'qcode', 'data': k} for k in keys], **{'hold-time': hold})
time.sleep(0.15)
def type_text(self, text):
for ch in text:
if ch.isascii() and ch.isalnum():
if ch.isupper():
self.send_key('shift', ch.lower())
else:
self.send_key(ch)
elif ch in PLAIN:
self.send_key(PLAIN[ch])
elif ch in SHIFTED:
self.send_key('shift', SHIFTED[ch])
else:
raise ValueError(f'cannot type {ch!r}')
class Screen:
"""Screenshot helper: settle detection via hashing, OCR of regions."""
def __init__(self, qmp, debug_dir, log):
self.qmp = qmp
self.debug_dir = debug_dir
self.log = log
import tempfile
fd, self.tmp = tempfile.mkstemp(prefix='.shot-', suffix='.ppm', dir=debug_dir)
os.close(fd)
os.chmod(self.tmp, 0o666)
self.n = 0
self.last_hash = None
self.stable_since = time.time()
self.img = None
def grab(self):
self.qmp.screendump(self.tmp)
with open(self.tmp, 'rb') as f:
data = f.read()
h = hashlib.sha256(data).hexdigest()
if h != self.last_hash:
self.last_hash = h
self.stable_since = time.time()
self.img = Image.open(io.BytesIO(data)) if Image else None
return self.img
def stable_for(self):
return time.time() - self.stable_since
def save(self, tag):
self.n += 1
path = os.path.join(self.debug_dir, f'{self.n:03d}-{tag}.png')
try:
if self.img is not None:
self.img.save(path)
else:
os.link(self.tmp, path.replace('.png', '.ppm'))
except Exception as e: # noqa: BLE001
self.log(f'could not save screenshot: {e}')
return path
def ocr(self, region=None, scale=3, psm=6):
if self.img is None or pytesseract is None:
return ''
img = self.img
if region:
img = img.crop(region)
img = img.convert('L').resize((img.width * scale, img.height * scale), Image.LANCZOS)
try:
return pytesseract.image_to_string(img, config=f'--psm {psm}').lower()
except Exception as e: # noqa: BLE001
self.log(f'ocr failed: {e}')
return ''
def is_blank(self):
# black/uniform screen (firmware, boot): nothing to act on
if self.img is None:
return False
lo, hi = self.img.convert('L').resize((64, 48)).getextrema()
return hi - lo < 24
def menubar_text(self):
w = self.img.width if self.img else 1024
return self.ocr((0, 0, w, 40), scale=4, psm=7)
def prepare_debug_dir(path):
# nix builds run as different nixbld users: keep the shared dirs world-writable
try:
for d in (os.path.dirname(path), path):
os.makedirs(d, exist_ok=True)
try:
os.chmod(d, 0o1777 if d != path else 0o777)
except OSError:
pass
probe = os.path.join(path, '.probe')
open(probe, 'w').close()
os.unlink(probe)
# a rebuild reuses this dir but runs as a different nixbld user; drop stale
# files so screendumps/PNGs are not blocked by another owner's 0644 files
import glob
for f in glob.glob(os.path.join(path, '*')) + glob.glob(os.path.join(path, '.current*')):
try:
os.unlink(f)
except OSError:
pass
return path
except OSError:
import tempfile
alt = tempfile.mkdtemp(prefix='vmix-macos-')
print(f'vmix driver: {path} not writable, using {alt}', flush=True)
return alt
def launch(qemu_args, qmp_sock, log):
if os.path.exists(qmp_sock):
os.unlink(qmp_sock)
args = list(qemu_args) + ['-qmp', f'unix:{qmp_sock},server,nowait']
log('launching: ' + ' '.join(args))
proc = subprocess.Popen(args)
deadline = time.time() + 60
while not os.path.exists(qmp_sock):
if proc.poll() is not None:
return proc, None
if time.time() > deadline:
proc.kill()
raise RuntimeError('QEMU did not create the QMP socket')
time.sleep(0.2)
time.sleep(0.5)
return proc, QMP(qmp_sock)
def open_terminal(qmp, log):
# Ctrl-F2 focuses the menu bar; typing jumps to the menu whose title starts
# with that letter (Utilities), Down opens it, "t" jumps to Terminal.
log('opening Terminal via menu bar (ctrl-f2, u, down, t, ret)')
qmp.send_key('ctrl', 'f2')
time.sleep(1.0)
qmp.send_key('u')
time.sleep(0.7)
qmp.send_key('down')
time.sleep(0.7)
qmp.send_key('t')
time.sleep(0.7)
qmp.send_key('ret')
def run_install(args, proc, qmp, log):
"""Drive the install VM to completion.
OpenCore shows a boot picker on every (re)boot and does not always auto-boot,
so on any settled picker we press Return to boot the highlighted macOS entry
(aux entries are hidden; during the install phases startosinstall blesses the
right default). That runs on EVERY iteration, because the install reboots
several times after we hand off to startosinstall. Before we have typed the
bootstrap command we also drive Recovery: language/welcome -> Return, the
Recovery window -> open Terminal, Terminal -> type the command.
"""
RECOVERY_BODY = ('reinstall', 'disk utility', 'restore from', 'recovery assistant',
'macos utilities')
PICKER_BODY = ('base system', 'macos installer', 'rel-1', 'rel-0') # OpenCore picker
LANG_BODY = ('language', 'select your', 'main language', 'country or region',
'welcome', 'get started', 'choose your')
screen = Screen(qmp, args.debug_dir, log)
start = time.time()
typed_at = None
terminal_attempts = 0
last_periodic = 0
last_progress = start
blind_done = False
while True:
rc = proc.poll()
if rc is not None:
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)
try:
screen.grab()
except Exception as e: # noqa: BLE001
log(f'screendump failed ({e}), assuming QEMU is exiting')
time.sleep(2)
continue
if now - last_periodic > args.periodic:
last_periodic = now
screen.save('periodic')
if now - start < args.min_boot or screen.stable_for() < args.settle:
continue
if screen.is_blank():
last_progress = now
continue
top = screen.menubar_text()
body = screen.ocr()
log(f'settled: menubar={top.strip()!r} body~={" ".join(body.split())[:80]!r}')
# OpenCore boot picker — always handle it (the install reboots many times)
if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY):
screen.save('picker')
log('OpenCore boot picker, pressing Return to boot the default macOS entry')
qmp.send_key('ret')
last_progress = now
screen.stable_since = time.time()
continue
# once the bootstrap command is typed, only the picker (above) matters
if typed_at is not None:
continue
if 'terminal' in top:
screen.save('terminal')
log(f'typing bootstrap command: {args.command!r}')
qmp.type_text(args.command + '\n')
typed_at = time.time()
continue
acted = False
if 'utilities' in top or 'recovery' in top or any(k in body for k in RECOVERY_BODY):
screen.save('recovery')
terminal_attempts += 1
log(f'recovery window (attempt {terminal_attempts}), opening Terminal')
open_terminal(qmp, log)
if terminal_attempts >= 3:
time.sleep(8)
log('typing bootstrap command (Terminal assumed open)')
qmp.type_text(args.command + '\n')
typed_at = time.time()
continue
acted = True
elif any(k in body for k in LANG_BODY):
screen.save('language')
log('language/welcome screen, pressing Return')
qmp.send_key('ret')
acted = True
if acted:
last_progress = now
screen.stable_since = time.time()
elif now - last_progress > args.settle * args.max_actions and not blind_done:
blind_done = True
screen.save('blind')
log('nothing recognised for a long time, blind sequence')
qmp.send_key('ret')
time.sleep(20)
open_terminal(qmp, log)
time.sleep(10)
qmp.type_text(args.command + '\n')
typed_at = time.time()
def run_boot(args, proc, qmp, log):
screen = Screen(qmp, args.debug_dir, log)
start = time.time()
last_periodic = 0
while True:
rc = proc.poll()
if rc is not None:
return rc
if time.time() - start > args.timeout:
try:
screen.grab()
screen.save('timeout')
except Exception: # noqa: BLE001
pass
log('timeout reached, killing QEMU')
proc.kill()
return 124
time.sleep(args.interval)
if time.time() - last_periodic > args.periodic:
last_periodic = time.time()
try:
screen.grab()
screen.save('periodic')
except Exception as e: # noqa: BLE001
log(f'screendump failed ({e})')
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument('--mode', choices=['install', 'boot'], required=True)
p.add_argument('--name', default='macos')
p.add_argument('--debug-dir', default=None)
p.add_argument('--timeout', type=int, default=4 * 3600, help='seconds before QEMU is killed')
p.add_argument('--interval', type=float, default=5.0, help='seconds between screenshots')
p.add_argument('--periodic', type=float, default=120.0, help='seconds between saved debug screenshots')
p.add_argument('--settle', type=float, default=12.0, help='seconds a screen must be unchanged to act on it')
p.add_argument('--min-boot', type=float, default=45.0, help='seconds before the first action')
p.add_argument('--max-actions', type=int, default=8)
p.add_argument('--command', default='diskutil mount VMIX;sh /Volumes/VMIX/run.sh')
p.add_argument('qemu', nargs=argparse.REMAINDER)
args = p.parse_args()
qemu_args = args.qemu[1:] if args.qemu and args.qemu[0] == '--' else args.qemu
if not qemu_args:
p.error('QEMU command line required after --')
args.debug_dir = prepare_debug_dir(args.debug_dir or f'/tmp/vmix-macos/{args.name}')
log = Log(os.path.join(args.debug_dir, 'driver.log'))
log(f'mode={args.mode} debug-dir={args.debug_dir} ocr={"yes" if pytesseract else "no"}')
qmp_sock = os.path.join(args.debug_dir, 'qmp.sock')
proc, qmp = launch(qemu_args, qmp_sock, log)
if qmp is None and '-display' in qemu_args and 'sdl' in qemu_args:
# SDL could not open a window: retry headless
log(f'QEMU exited early ({proc.returncode}) with SDL, retrying headless')
i = qemu_args.index('-display')
qemu_args = qemu_args[:i] + ['-display', 'none'] + qemu_args[i + 2:]
proc, qmp = launch(qemu_args, qmp_sock, log)
if qmp is None:
log(f'QEMU exited immediately with {proc.returncode}')
return proc.returncode or 1
try:
if args.mode == 'install':
rc = run_install(args, proc, qmp, log)
else:
rc = run_boot(args, proc, qmp, log)
finally:
if proc.poll() is None:
proc.kill()
log(f'QEMU exited with {rc}')
return rc
if __name__ == '__main__':
sys.exit(main())

View file

@ -0,0 +1,14 @@
# Shell snippet: read the vmix agent's result off the VMIX HFS+ volume of a raw
# disk image (${image}). Prints the guest logs and sets STATUS to the contents
# of vmix-run.status ("0" on success). Uses libguestfs (mtools is FAT-only). The
# agent unmounts VMIX cleanly before shutdown, so a read-only mount is safe.
{ ... }:
image:
''
echo "=== vmix: reading result from ${image} ==="
for f in install.log system-install.log vmix-run.log vmix-agent.log; do
C=$(guestfish --ro -a ${image} -m /dev/sda1 cat /$f 2>/dev/null || true)
[ -n "$C" ] && { echo "--- $f ---"; printf '%s\n' "$C"; }
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,8 @@
{ 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 = macos.fetchRecovery { inherit (up.recovery) shortname sha256; };
in
import ./images.nix { inherit pkgs lib system macos installer recovery; }

View file

@ -0,0 +1,14 @@
# Pre-built macOS Tahoe (26) images
# Pipeline: makeImage (unattended install, vmix agent) → templates → generalize
{ pkgs, lib, system, macos, installer, recovery, ... }:
with macos;
rec {
upstream = makeImage {
name = "macos-tahoe";
inherit installer recovery;
};
basic = customizeImageFold upstream templates.bundles.basic;
remote = customizeImageFold upstream templates.bundles.remote;
}

View file

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

View file

@ -0,0 +1,15 @@
# Disable automatic macOS / App Store updates (an OTA update would also need
# the RestrictEvents kext to work in a VM)
{ ... }:
{
name = "no-updates";
script = ''
softwareupdate --schedule off || true
defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool false
defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -bool false
defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticallyInstallMacOSUpdates -bool false
defaults write /Library/Preferences/com.apple.SoftwareUpdate ConfigDataInstall -bool false
defaults write /Library/Preferences/com.apple.SoftwareUpdate CriticalUpdateInstall -bool false
defaults write /Library/Preferences/com.apple.commerce AutoUpdate -bool false
'';
}

View file

@ -0,0 +1,11 @@
# Less background work in a VM: no Spotlight indexing, no Time Machine, no sleep
{ ... }:
{
name = "performance";
script = ''
mdutil -a -i off || true
tmutil disable || true
pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 womp 0 || true
defaults write /Library/Preferences/com.apple.loginwindow DisableScreenLockImmediate -bool true
'';
}

View file

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

View file

@ -0,0 +1,103 @@
# Generalize a macOS image: create the user, auto-login, hostname, timezone,
# suppress Setup Assistant prompts, then remove the vmix agent. Also gives the
# image a fresh SMBIOS identity (serial/MLB from macserial, MAC + UUID from
# `seed`) so every generalized VM looks like a distinct Mac to Apple ID/iMessage.
# Usage: (templates.generalize { username = "User"; password = ""; hostname = "MAC"; })
# delayOobeRun = true: no user, Setup Assistant runs on first real boot (like Windows OOBE)
{ pkgs, lib, ... }:
{
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}",
# 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" "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"''}
${lib.optionalString (!delayOobeRun) ''
# --- user account (admin)
if ! id "${username}" >/dev/null 2>&1; then
sysadminctl -addUser "${username}" -fullName ${lib.escapeShellArg fullName} \
-password ${lib.escapeShellArg (if password == "" then tempPassword else password)} \
-admin -home "/Users/${username}" || exit 1
${lib.optionalString (password == "") ''
dscl . -passwd "/Users/${username}" "${tempPassword}" "" || echo "vmix: WARNING: could not set an empty password, password is '${tempPassword}'"
''}
fi
${lib.optionalString autoLogon ''
defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser "${username}"
cp /Volumes/VMIX/kcpassword /etc/kcpassword
chmod 600 /etc/kcpassword
chown root:wheel /etc/kcpassword
''}
# --- no Setup Assistant / "What's new" prompts at first login
P="/Users/${username}/Library/Preferences/com.apple.SetupAssistant"
VER=$(sw_vers -productVersion)
BUILD=$(sw_vers -buildVersion)
for k in ${lib.concatStringsSep " " setupKeys}; do
defaults write "$P" "$k" -bool true
done
defaults write "$P" GestureMovieSeen none
defaults write "$P" LastSeenCloudProductVersion "$VER"
defaults write "$P" LastSeenBuddyBuildVersion "$BUILD"
defaults write "$P" LastSeenSiriProductVersion "$VER"
defaults write "$P" LastPreLoginTasksPerformedVersion "$VER"
defaults write "/Users/${username}/Library/Preferences/.GlobalPreferences" AppleLocale "${macLocale}"
chown -R "${username}" "/Users/${username}/Library/Preferences"
''}
# --- machine identity
scutil --set ComputerName "${hostname}"
scutil --set HostName "${hostname}"
scutil --set LocalHostName "${hostname}"
defaults write /Library/Preferences/.GlobalPreferences AppleLocale "${macLocale}"
systemsetup -settimezone "${timezone}" >/dev/null 2>&1 || ln -sfn "/usr/share/zoneinfo/${timezone}" /etc/localtime
# --- never sleep (VM)
pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 || true
# --- use the whole (possibly grown) disk
STORE=$(diskutil info / | awk '/APFS Physical Store/ {print $NF}')
[ -n "$STORE" ] && diskutil apfs resizeContainer "$STORE" 0 || true
${lib.optionalString delayOobeRun ''
# Setup Assistant will run on the next boot
rm -f /var/db/.AppleSetupDone
''}
# --- the agent's job is done: remove it (this is the last vmix step)
rm -f /Library/LaunchDaemons/ch.vmix.agent.plist
rm -rf /Library/vmix
'';
}

View file

@ -0,0 +1,31 @@
{
"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"
}
},
"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"
}
}
}
}

View file

@ -77,13 +77,23 @@ let
hasOsDisk = vmCfg.disks.os.file != null;
# Auto-detect Windows from _vmixOsType marker on the disk image
isWindows = vmCfg.windows.enable || (hasOsDisk && (vmCfg.disks.os.file._vmixOsType or "linux") == "windows");
# Auto-detect Windows / macOS from _vmixOsType marker on the disk image
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,
# so viommu wins over the full in-kernel irqchip hideVirtualized asks for.
# (macOS keeps the default irqchip: hideVirtualized does not apply to it.)
machineIrqchipArg =
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
# 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
@ -108,9 +118,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
else if isWindows then vmCfg.disks.os.file
else if isWindows || isMacos then vmCfg.disks.os.file
else linuxOsImage;
# When persist = true, QEMU needs a mutable disk outside /nix/store.
@ -208,7 +218,7 @@ let
-m ${toString vmCfg.mem.size} \
${optionalString vmCfg.mem.balloon "-device virtio-balloon-pci"} \
-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} \
${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"} \
@ -218,7 +228,13 @@ let
-device qemu-xhci -device usb-tablet \
-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: AppleSMC + OSK, USB keyboard/tablet, AHCI system disk, VMware SVGA (no SPICE display device)
optionalString isMacos ''
${vmixLib.macos.qemu.deviceArgs} ${optionalString (!vmCfg.spice.enable) vmixLib.macos.qemu.vgaArgs} \
''} \
${optionalString hasOsDisk (if isMacos
then "-drive id=os,if=none,file=${osDiskPath},format=qcow2${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"} -device ide-hd,bus=sata.0,drive=os"
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"} \
${concatMapStrings (diskCfg: ''
-drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \
@ -228,12 +244,12 @@ let
'') vmCfg.shares)} \
${optionalString cfg.networks.user.enable "
-netdev user,id=user \
-device ${vmCfg.nicModel},netdev=user \
-device ${vmCfg.nicModel},netdev=user${optionalString isMacos ",mac=${macosMac}${macosNicPlacement}"} \
"} \
${concatMapStrings (tapCfg: ''
-device ${vmCfg.nicModel},netdev=lan-${tapCfg.name},mac=${tapCfg.mac} \
${concatStrings (imap1 (i: tapCfg: ''
-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 \
'') allTaps} \
'') allTaps)} \
${concatStrings (imap1 (i: macvtap: ''
-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}') \

View file

@ -249,6 +249,24 @@ 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).";
};
};
tpm = {
stateDir = mkOption {
type = types.str;