Compare commits
12 commits
master
...
macos-taho
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f797af915 | |||
| 90cf1c64c8 | |||
| 9936b92012 | |||
| e6e2e9f18d | |||
| e241364ce9 | |||
| 0f9373263d | |||
| 50ad8d521c | |||
| 779675f87e | |||
| 22daf7720c | |||
| 8dc8f4265d | |||
| 58a317f5d2 | |||
| 242e48a5fc |
41 changed files with 2669 additions and 44 deletions
183
cli.nix
183
cli.nix
|
|
@ -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,34 @@ 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/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 " --out-link PATH Symlink for the build result (default: ./result)"
|
||||
echo ""
|
||||
|
|
@ -32,9 +47,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 +64,58 @@ 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=""
|
||||
RUN_SHARES=()
|
||||
RUN_HOME=""
|
||||
RUN_HOME_IMAGE="macos.images.tahoe.upstream"
|
||||
RUN_QGA=""
|
||||
while [[ ''${#} -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mem) RUN_MEM="$2"; shift 2 ;;
|
||||
--smp) RUN_SMP="$2"; shift 2 ;;
|
||||
--ahci) RUN_AHCI=true; shift ;;
|
||||
*) 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 ;;
|
||||
--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 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
|
@ -74,7 +123,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 +138,64 @@ 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)"
|
||||
# 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 ""
|
||||
|
||||
|
|
@ -106,7 +215,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 +270,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 +308,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 +345,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 +411,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)..."
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ let
|
|||
network = import ./network.nix { inherit pkgs lib; };
|
||||
in
|
||||
{
|
||||
inherit (images) linux windows;
|
||||
inherit (images) linux windows macos;
|
||||
inherit network;
|
||||
}
|
||||
|
|
@ -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; };
|
||||
}
|
||||
|
|
|
|||
177
lib/images/macos/README.md
Normal file
177
lib/images/macos/README.md
Normal 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 10–30 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.
|
||||
46
lib/images/macos/default.nix
Normal file
46
lib/images/macos/default.nix
Normal 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; };
|
||||
}
|
||||
19
lib/images/macos/guest/ch.vmix.pe.plist
Normal file
19
lib/images/macos/guest/ch.vmix.pe.plist
Normal 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>
|
||||
11
lib/images/macos/guest/kcpassword.py
Executable file
11
lib/images/macos/guest/kcpassword.py
Executable 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)
|
||||
51
lib/images/macos/guest/pe-lib.sh
Normal file
51
lib/images/macos/guest/pe-lib.sh
Normal 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
40
lib/images/macos/guest/pe.sh
Executable 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
|
||||
100
lib/images/macos/guest/vmix-install.sh
Normal file
100
lib/images/macos/guest/vmix-install.sh
Normal 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"
|
||||
176
lib/images/macos/helpers/customizeImage.nix
Normal file
176
lib/images/macos/helpers/customizeImage.nix
Normal 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; }
|
||||
40
lib/images/macos/helpers/fetchRecovery.nix
Normal file
40
lib/images/macos/helpers/fetchRecovery.nix
Normal 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
|
||||
''
|
||||
56
lib/images/macos/helpers/formatVolume.nix
Normal file
56
lib/images/macos/helpers/formatVolume.nix
Normal 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"
|
||||
''
|
||||
17
lib/images/macos/helpers/ident.nix
Normal file
17
lib/images/macos/helpers/ident.nix
Normal 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}";
|
||||
}
|
||||
17
lib/images/macos/helpers/installBootloader.nix
Normal file
17
lib/images/macos/helpers/installBootloader.nix
Normal 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
|
||||
''
|
||||
28
lib/images/macos/helpers/installerPayload.nix
Normal file
28
lib/images/macos/helpers/installerPayload.nix
Normal 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
|
||||
''
|
||||
18
lib/images/macos/helpers/macserial.nix
Normal file
18
lib/images/macos/helpers/macserial.nix
Normal 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
|
||||
'';
|
||||
}
|
||||
29
lib/images/macos/helpers/makeBootDisk.nix
Normal file
29
lib/images/macos/helpers/makeBootDisk.nix
Normal 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
|
||||
''
|
||||
113
lib/images/macos/helpers/makeImage.nix
Normal file
113
lib/images/macos/helpers/makeImage.nix
Normal 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; }
|
||||
85
lib/images/macos/helpers/makeOpenCore.nix
Normal file
85
lib/images/macos/helpers/makeOpenCore.nix
Normal 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
|
||||
''
|
||||
30
lib/images/macos/helpers/makeRecoveryPE.nix
Normal file
30
lib/images/macos/helpers/makeRecoveryPE.nix
Normal 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; }
|
||||
''
|
||||
28
lib/images/macos/helpers/makeVmixVolume.nix
Normal file
28
lib/images/macos/helpers/makeVmixVolume.nix
Normal 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
|
||||
''
|
||||
128
lib/images/macos/helpers/oc-config.py
Normal file
128
lib/images/macos/helpers/oc-config.py
Normal 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()
|
||||
65
lib/images/macos/helpers/qemu.nix
Normal file
65
lib/images/macos/helpers/qemu.nix
Normal 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}";
|
||||
}
|
||||
588
lib/images/macos/helpers/vm-driver.py
Normal file
588
lib/images/macos/helpers/vm-driver.py
Normal 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())
|
||||
14
lib/images/macos/helpers/vmix-readback.nix
Normal file
14
lib/images/macos/helpers/vmix-readback.nix
Normal 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)
|
||||
''
|
||||
25
lib/images/macos/helpers/xar-toc.py
Normal file
25
lib/images/macos/helpers/xar-toc.py
Normal 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)
|
||||
14
lib/images/macos/tahoe/default.nix
Normal file
14
lib/images/macos/tahoe/default.nix
Normal 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; }
|
||||
17
lib/images/macos/tahoe/images.nix
Normal file
17
lib/images/macos/tahoe/images.nix
Normal 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;
|
||||
}
|
||||
18
lib/images/macos/templates/default.nix
Normal file
18
lib/images/macos/templates/default.nix
Normal 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 ];
|
||||
};
|
||||
}
|
||||
13
lib/images/macos/templates/essentials/no-updates.nix
Normal file
13
lib/images/macos/templates/essentials/no-updates.nix
Normal 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
|
||||
'';
|
||||
}
|
||||
32
lib/images/macos/templates/essentials/performance.nix
Normal file
32
lib/images/macos/templates/essentials/performance.nix
Normal 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"
|
||||
'';
|
||||
}
|
||||
10
lib/images/macos/templates/essentials/remote-access.nix
Normal file
10
lib/images/macos/templates/essentials/remote-access.nix
Normal 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
|
||||
'';
|
||||
}
|
||||
146
lib/images/macos/templates/generalize.nix
Normal file
146
lib/images/macos/templates/generalize.nix
Normal 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
|
||||
'';
|
||||
}
|
||||
76
lib/images/macos/templates/profile/default.nix
Normal file
76
lib/images/macos/templates/profile/default.nix
Normal 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)"
|
||||
''}
|
||||
'';
|
||||
};
|
||||
}
|
||||
51
lib/images/macos/templates/software/default.nix
Normal file
51
lib/images/macos/templates/software/default.nix
Normal 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
31
lib/images/macos/tools/soak.sh
Executable 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"
|
||||
56
lib/images/macos/upstream.json
Normal file
56
lib/images/macos/upstream.json
Normal 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="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -97,6 +107,42 @@ let
|
|||
(unique (map pciDeviceOf vmCfg.pci.passthrough));
|
||||
|
||||
|
||||
# --- macOS: guest agent, virtio-fs shares, persistent home volume
|
||||
macosQemu = vmixLib.macos.qemu;
|
||||
qgaSock = "/run/vmix/qga-${vmCfg.name}.sock";
|
||||
macosGuestAgent = isMacos && vmCfg.macos.guestAgent.enable;
|
||||
macosShares = if isMacos then vmCfg.shares else {};
|
||||
macosShareNames = attrNames macosShares;
|
||||
macosAutomountShare = if macosShares ? automount then "automount"
|
||||
else if macosShareNames != [] then head macosShareNames else null;
|
||||
macosShareTag = n: if n == macosAutomountShare then macosQemu.automountTag else n;
|
||||
macosShareSock = n: "/run/vmix/vfs-${vmCfg.name}-${n}.sock";
|
||||
macosHome = isMacos && vmCfg.macos.homeDisk.enable;
|
||||
macosFormatHome = if macosHome then vmixLib.macos.formatVolume {
|
||||
image = vmCfg.disks.os.file; label = vmCfg.macos.homeDisk.label;
|
||||
} else null;
|
||||
# shares beyond the automounted one are mounted through the guest agent once it answers
|
||||
macosMountSharesScript = pkgs.writeShellScript "${vmCfg.name}-macos-shares-vmix" ''
|
||||
for i in $(seq 1 120); do
|
||||
[ -S ${qgaSock} ] && printf '{"execute":"guest-ping"}\n' | ${pkgs.socat}/bin/socat -T5 - UNIX-CONNECT:${qgaSock} 2>/dev/null | grep -q return && break
|
||||
sleep 5
|
||||
done
|
||||
${concatMapStrings (n: optionalString (n != macosAutomountShare) ''
|
||||
printf '%s\n' '{"execute":"guest-exec","arguments":{"path":"/bin/bash","arg":["-c","mkdir -p ${macosShares.${n}.target}; mount -t virtiofs ${n} ${macosShares.${n}.target}"]}}' \
|
||||
| ${pkgs.socat}/bin/socat -T10 - UNIX-CONNECT:${qgaSock} >/dev/null 2>&1 || true
|
||||
'') macosShareNames}
|
||||
'';
|
||||
seedHomeDiskScript = pkgs.writeShellScript "${vmCfg.name}-home-disk-vmix" ''
|
||||
F="${vmCfg.macos.homeDisk.file}"
|
||||
if [ ! -e "$F" ]; then
|
||||
echo "Creating persistent home volume $F (${vmCfg.macos.homeDisk.size}, ${vmCfg.macos.homeDisk.format})..."
|
||||
mkdir -p "$(dirname "$F")"
|
||||
${pkgs.qemu}/bin/qemu-img create -q -f ${vmCfg.macos.homeDisk.format} "$F" ${vmCfg.macos.homeDisk.size}
|
||||
chmod 600 "$F"
|
||||
${macosFormatHome} "$F" ${vmCfg.macos.homeDisk.format}
|
||||
fi
|
||||
'';
|
||||
|
||||
# Linux VMs: apply customizeImage with 9p fstab and machine-id setup
|
||||
linuxOsImage = vmixLib.linux.customizeImage vmCfg.disks.os.file {
|
||||
name = vmCfg.name;
|
||||
|
|
@ -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
|
||||
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.
|
||||
|
|
@ -130,7 +176,8 @@ let
|
|||
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.
|
||||
bootOrderQemu =
|
||||
|
|
@ -165,6 +212,16 @@ let
|
|||
);
|
||||
|
||||
qemuStartVMScript = pkgs.writeShellScript "${vmCfg.name}-qemu-vmix" ''
|
||||
${optionalString (isMacos && macosShareNames != []) ''
|
||||
mkdir -p /run/vmix
|
||||
${concatMapStrings (n: ''
|
||||
rm -f ${macosShareSock n}
|
||||
${pkgs.virtiofsd}/bin/virtiofsd --socket-path=${macosShareSock n} --shared-dir ${toString macosShares.${n}.source} --cache auto --sandbox none &
|
||||
'') macosShareNames}
|
||||
for i in $(seq 1 50); do ${concatMapStringsSep " && " (n: "[ -S ${macosShareSock n} ]") macosShareNames} && break; sleep 0.2; done
|
||||
${optionalString macosGuestAgent "${macosMountSharesScript} &"}
|
||||
''}
|
||||
${optionalString macosGuestAgent "mkdir -p /run/vmix; rm -f ${qgaSock}"}
|
||||
${optionalString vmCfg.vnc.enable ''
|
||||
${optionalString (vmCfg.vnc.passwordFile != null) ''
|
||||
if [ ! -r ${escapeShellArg vmCfg.vnc.passwordFile} ]; then
|
||||
|
|
@ -198,7 +255,7 @@ let
|
|||
${optionalString vmCfg.vnc.enable "-vnc ${vncArgs}"} \
|
||||
${optionalString (vmCfg.spice.enable && vmCfg.spice.passwordFile != null) "-object secret,id=spice-pass-${vmCfg.name},file=${escapeShellArg vmCfg.spice.passwordFile}"} \
|
||||
${optionalString vmCfg.spice.enable "-spice addr=${vmCfg.spice.addr},port=${toString vmCfg.spice.port}${optionalString (vmCfg.spice.passwordFile == null) ",disable-ticketing=on"}${optionalString (vmCfg.spice.passwordFile != null) ",password-secret=spice-pass-${vmCfg.name}"}"} \
|
||||
${optionalString vmCfg.spice.enable (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"} \
|
||||
${# 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"} \
|
||||
|
|
@ -208,7 +265,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,22 +275,33 @@ 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: 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"} \
|
||||
${concatMapStrings (diskCfg: ''
|
||||
-drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \
|
||||
'') (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} \
|
||||
'') vmCfg.shares)} \
|
||||
'') 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}') \
|
||||
|
|
@ -264,6 +332,8 @@ let
|
|||
ProtectSystem = true;
|
||||
ProtectHome = true;
|
||||
PrivateNetwork = true;
|
||||
RuntimeDirectory = "vmix";
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
} // lib.optionalAttrs (vmCfg.pci.passthrough != []) {
|
||||
# VFIO passthrough needs raw device access — relax sandboxing
|
||||
ProtectSystem = lib.mkForce false;
|
||||
|
|
|
|||
|
|
@ -93,9 +93,9 @@ with lib;
|
|||
};
|
||||
};
|
||||
displayDevice = mkOption {
|
||||
type = types.enum [ "virtio" "qxl" "std" "none" ];
|
||||
type = types.enum [ "virtio" "qxl" "std" "vmware" "none" ];
|
||||
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 {
|
||||
type = types.nullOr types.int;
|
||||
|
|
@ -212,11 +212,11 @@ with lib;
|
|||
};
|
||||
target = mkOption {
|
||||
type = types.str;
|
||||
description = "Target path inside the VM for the shared directory.";
|
||||
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 {
|
||||
|
|
@ -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 = {
|
||||
stateDir = mkOption {
|
||||
type = types.str;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue