Compare commits
12 commits
master
...
macos-taho
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f797af915 | |||
| 90cf1c64c8 | |||
| 9936b92012 | |||
| e6e2e9f18d | |||
| e241364ce9 | |||
| 0f9373263d | |||
| 50ad8d521c | |||
| 779675f87e | |||
| 22daf7720c | |||
| 8dc8f4265d | |||
| 58a317f5d2 | |||
| 242e48a5fc |
45 changed files with 2692 additions and 629 deletions
183
cli.nix
183
cli.nix
|
|
@ -1,5 +1,9 @@
|
||||||
# vmix CLI — build, copy, and run Windows images
|
# vmix CLI — build, copy, and run Windows / macOS images
|
||||||
{ pkgs, self, system }:
|
{ pkgs, self, system, vmixLib }:
|
||||||
|
let
|
||||||
|
macosQemu = vmixLib.macos.qemu;
|
||||||
|
macserial = vmixLib.macos.macserial;
|
||||||
|
in
|
||||||
pkgs.writeShellScriptBin "vmix" ''
|
pkgs.writeShellScriptBin "vmix" ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
@ -8,23 +12,34 @@ pkgs.writeShellScriptBin "vmix" ''
|
||||||
echo " vmix build --image <path> [--generalize key=val,...] [--out-link PATH]"
|
echo " vmix build --image <path> [--generalize key=val,...] [--out-link PATH]"
|
||||||
echo " vmix copy --image <path> [--generalize key=val,...] --to-disk /dev/sdX"
|
echo " vmix copy --image <path> [--generalize key=val,...] --to-disk /dev/sdX"
|
||||||
echo " vmix copy --image <path> [--generalize key=val,...] --to-remote-disk user@host:/dev/sdX"
|
echo " vmix copy --image <path> [--generalize key=val,...] --to-remote-disk user@host:/dev/sdX"
|
||||||
echo " vmix run <qcow2-file> [--mem 4096] [--smp 4] [--ahci]"
|
echo " vmix run <qcow2-file> [--mem 4096] [--smp 4] [--ahci] [--macos] [--vnc :N] [--mac XX:..]"
|
||||||
|
echo " vmix macserial [--model MacPro7,1]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Commands:"
|
echo "Commands:"
|
||||||
echo " build Build a vmix image (optionally generalized)"
|
echo " build Build a vmix image (optionally generalized)"
|
||||||
echo " copy Build a vmix image and write it to a disk"
|
echo " copy Build a vmix image and write it to a disk"
|
||||||
echo " run Boot a qcow2 image with QEMU (SDL if DISPLAY available)"
|
echo " run Boot a qcow2 image with QEMU (SDL if DISPLAY available, or --vnc)"
|
||||||
|
echo " macserial Generate a SMBIOS identity (serial, MLB, UUID, MAC) for --generalize"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " --image PATH Image path in vmixLib (e.g. windows.images.win10.laptop)"
|
echo " --image PATH Image path in vmixLib (e.g. windows.images.win10.laptop,"
|
||||||
|
echo " macos.images.tahoe.basic)"
|
||||||
echo " --generalize KEY=VAL,... Finalize image with comma-separated options:"
|
echo " --generalize KEY=VAL,... Finalize image with comma-separated options:"
|
||||||
echo " username=User password= hostname=PC"
|
echo " username=User password= hostname=PC"
|
||||||
echo " timezone=UTC bgColor=8e8cd8"
|
echo " timezone=UTC bgColor=8e8cd8 (Windows only)"
|
||||||
echo " delay-oobe-run=true (OOBE + activation on real hardware)"
|
echo " delay-oobe-run=true (OOBE/Setup Assistant on real hardware)"
|
||||||
|
echo " macOS SMBIOS: model=MacPro7,1 serial=... mlb=... uuid=... mac=... seed=..."
|
||||||
echo " --to-disk DEVICE Write to local disk and expand partitions"
|
echo " --to-disk DEVICE Write to local disk and expand partitions"
|
||||||
echo " --to-remote-disk SSH:DEV Stream to remote disk via SSH and expand partitions"
|
echo " --to-remote-disk SSH:DEV Stream to remote disk via SSH and expand partitions"
|
||||||
echo " e.g. root@10.10.10.100:/dev/sda"
|
echo " e.g. root@10.10.10.100:/dev/sda"
|
||||||
echo " --ahci Use AHCI storage for vmix run (for laptop images)"
|
echo " --ahci Use AHCI storage for vmix run (for laptop images)"
|
||||||
|
echo " --macos macOS image for vmix run (OpenCore/VirtualSMC flags, AHCI)"
|
||||||
|
echo " --applesmc with --macos: add QEMU's isa-applesmc (images built before 2026-09-09)"
|
||||||
|
echo " --share DIR with --macos: virtio-fs share (first: /Volumes/My Shared Files); repeatable"
|
||||||
|
echo " --home FILE with --macos: persistent home volume (qcow2, created+formatted if missing)"
|
||||||
|
echo " --qga PATH with --macos: guest agent socket path (default /tmp/vmix-qga-<pid>.sock)"
|
||||||
|
echo " --vnc DISPLAY VNC instead of SDL for vmix run, e.g. :10 (port 5910) or 0.0.0.0:10"
|
||||||
|
echo " --mac ADDR NIC MAC for vmix run (macOS: read from the image's ESP by default)"
|
||||||
echo " -y, --yes Skip disk write confirmation"
|
echo " -y, --yes Skip disk write confirmation"
|
||||||
echo " --out-link PATH Symlink for the build result (default: ./result)"
|
echo " --out-link PATH Symlink for the build result (default: ./result)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -32,9 +47,9 @@ pkgs.writeShellScriptBin "vmix" ''
|
||||||
echo " vmix build --image windows.images.win10.laptop \\"
|
echo " vmix build --image windows.images.win10.laptop \\"
|
||||||
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP"
|
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP"
|
||||||
echo ""
|
echo ""
|
||||||
echo " vmix copy --image windows.images.win10.laptop \\"
|
echo " vmix build --image macos.images.tahoe.basic \\"
|
||||||
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\"
|
echo " --generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich"
|
||||||
echo " --to-disk /dev/sda"
|
echo " vmix run ./result --macos --vnc :10 --mem 8192"
|
||||||
echo ""
|
echo ""
|
||||||
echo " vmix copy --image windows.images.win10.laptop \\"
|
echo " vmix copy --image windows.images.win10.laptop \\"
|
||||||
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\"
|
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\"
|
||||||
|
|
@ -49,24 +64,58 @@ pkgs.writeShellScriptBin "vmix" ''
|
||||||
COMMAND="$1"; shift
|
COMMAND="$1"; shift
|
||||||
|
|
||||||
case "$COMMAND" in
|
case "$COMMAND" in
|
||||||
build|copy|run) ;;
|
build|copy|run|macserial) ;;
|
||||||
--help|-h) usage ;;
|
--help|-h) usage ;;
|
||||||
*) echo "Unknown command: $COMMAND"; usage ;;
|
*) echo "Unknown command: $COMMAND"; usage ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# --- macserial command ---
|
||||||
|
if [[ "$COMMAND" == "macserial" ]]; then
|
||||||
|
MODEL="MacPro7,1"
|
||||||
|
while [[ ''${#} -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--model) MODEL="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
LINE=$(${macserial}/bin/macserial --num 1 --model "$MODEL" 2>/dev/null | grep '|' | tail -1)
|
||||||
|
[[ -z "$LINE" ]] && { echo "Error: macserial produced nothing for model $MODEL"; exit 1; }
|
||||||
|
SERIAL=$(echo "$LINE" | awk -F' *\\| *' '{print $1}')
|
||||||
|
MLB=$(echo "$LINE" | awk -F' *\\| *' '{print $2}')
|
||||||
|
UUID=$(cat /proc/sys/kernel/random/uuid | tr a-f A-F)
|
||||||
|
MAC=$(printf '52:54:00:%02x:%02x:%02x' $((RANDOM % 256)) $((RANDOM % 256)) $((RANDOM % 256)))
|
||||||
|
echo "model=$MODEL,serial=$SERIAL,mlb=$MLB,uuid=$UUID,mac=$MAC"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# --- run command ---
|
# --- run command ---
|
||||||
if [[ "$COMMAND" == "run" ]]; then
|
if [[ "$COMMAND" == "run" ]]; then
|
||||||
[[ ''${#} -lt 1 ]] && { echo "Error: vmix run <qcow2-file> [--mem 4096] [--smp 4] [--ahci]"; exit 1; }
|
[[ ''${#} -lt 1 ]] && { echo "Error: vmix run <qcow2-file> [--mem 4096] [--smp 4] [--ahci] [--macos] [--vnc :N] [--mac XX:XX:XX:XX:XX:XX]"; exit 1; }
|
||||||
RUN_INPUT="$1"; shift
|
RUN_INPUT="$1"; shift
|
||||||
RUN_MEM=4096
|
RUN_MEM=4096
|
||||||
RUN_SMP=4
|
RUN_SMP=4
|
||||||
RUN_AHCI=false
|
RUN_AHCI=false
|
||||||
|
RUN_MACOS=false
|
||||||
|
RUN_VNC=""
|
||||||
|
RUN_MAC=""
|
||||||
|
RUN_SHARES=()
|
||||||
|
RUN_HOME=""
|
||||||
|
RUN_HOME_IMAGE="macos.images.tahoe.upstream"
|
||||||
|
RUN_QGA=""
|
||||||
while [[ ''${#} -gt 0 ]]; do
|
while [[ ''${#} -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--mem) RUN_MEM="$2"; shift 2 ;;
|
--mem) RUN_MEM="$2"; shift 2 ;;
|
||||||
--smp) RUN_SMP="$2"; shift 2 ;;
|
--smp) RUN_SMP="$2"; shift 2 ;;
|
||||||
--ahci) RUN_AHCI=true; shift ;;
|
--ahci) RUN_AHCI=true; shift ;;
|
||||||
*) echo "Unknown option: $1"; exit 1 ;;
|
--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
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -74,7 +123,9 @@ pkgs.writeShellScriptBin "vmix" ''
|
||||||
[[ ! -f "$RUN_IMAGE" ]] && { echo "Error: file not found: $RUN_IMAGE"; exit 1; }
|
[[ ! -f "$RUN_IMAGE" ]] && { echo "Error: file not found: $RUN_IMAGE"; exit 1; }
|
||||||
|
|
||||||
VMIX_DISPLAY="-nographic"
|
VMIX_DISPLAY="-nographic"
|
||||||
if [[ -n "''${DISPLAY:-}" ]]; then
|
if [[ -n "$RUN_VNC" ]]; then
|
||||||
|
VMIX_DISPLAY="-display none -vnc $RUN_VNC"
|
||||||
|
elif [[ -n "''${DISPLAY:-}" ]]; then
|
||||||
VMIX_DISPLAY="-display sdl"
|
VMIX_DISPLAY="-display sdl"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -87,6 +138,64 @@ pkgs.writeShellScriptBin "vmix" ''
|
||||||
echo "Memory: $RUN_MEM MB"
|
echo "Memory: $RUN_MEM MB"
|
||||||
echo "CPUs: $RUN_SMP"
|
echo "CPUs: $RUN_SMP"
|
||||||
echo "Display: $VMIX_DISPLAY"
|
echo "Display: $VMIX_DISPLAY"
|
||||||
|
|
||||||
|
if [[ "$RUN_MACOS" == "true" ]]; then
|
||||||
|
# OpenCore's ROM must match en0's MAC: the image records it in its ESP
|
||||||
|
if [[ -z "$RUN_MAC" ]]; then
|
||||||
|
RUN_MAC=$(${pkgs.libguestfs-with-appliance}/bin/guestfish --ro -a "$RUN_IMAGE" -m /dev/sda1 cat /EFI/vmix/vmix.json 2>/dev/null \
|
||||||
|
| ${pkgs.jq}/bin/jq -r .mac 2>/dev/null || true)
|
||||||
|
[[ -z "$RUN_MAC" || "$RUN_MAC" == "null" ]] && { RUN_MAC="52:54:00:c9:18:27"; echo "Warning: could not read MAC from image ESP, using $RUN_MAC"; }
|
||||||
|
fi
|
||||||
|
echo "macOS: yes (MAC $RUN_MAC)"
|
||||||
|
# Apple's built-in QEMU guest agent: guest-exec as root over this socket
|
||||||
|
[[ -z "$RUN_QGA" ]] && RUN_QGA="/tmp/vmix-qga-$$.sock"
|
||||||
|
rm -f "$RUN_QGA"
|
||||||
|
echo "Agent: $RUN_QGA (guest-exec as root)"
|
||||||
|
# virtio-fs shares: the first one auto-mounts at /Volumes/My Shared Files, the
|
||||||
|
# others are mounted with: mount -t virtiofs <tag> /Volumes/<tag>
|
||||||
|
MACOS_SHARE_ARGS=""
|
||||||
|
MACOS_MEM_ARGS=""
|
||||||
|
i=0
|
||||||
|
for SHARE in "''${RUN_SHARES[@]}"; do
|
||||||
|
i=$((i + 1)); SOCK="/tmp/vmix-vfs-$$-$i.sock"; rm -f "$SOCK"
|
||||||
|
TAG=$([[ $i -eq 1 ]] && echo "${macosQemu.automountTag}" || echo "share$i")
|
||||||
|
${pkgs.virtiofsd}/bin/virtiofsd --socket-path="$SOCK" --shared-dir "$SHARE" --cache auto --sandbox none >/dev/null 2>&1 &
|
||||||
|
for t in $(seq 1 50); do [[ -S "$SOCK" ]] && break; sleep 0.2; done
|
||||||
|
MACOS_SHARE_ARGS="$MACOS_SHARE_ARGS -chardev socket,id=vfs$i,path=$SOCK -device vhost-user-fs-pci,chardev=vfs$i,tag=$TAG"
|
||||||
|
MACOS_MEM_ARGS="-object memory-backend-memfd,id=vmix-mem,size=''${RUN_MEM}M,share=on -numa node,memdev=vmix-mem"
|
||||||
|
echo "Share: $SHARE -> $([[ $i -eq 1 ]] && echo '/Volumes/My Shared Files' || echo "mount -t virtiofs $TAG ...")"
|
||||||
|
done
|
||||||
|
# persistent home volume (virtio-blk); created + formatted APFS by the PE if missing
|
||||||
|
MACOS_HOME_ARGS=""
|
||||||
|
if [[ -n "$RUN_HOME" ]]; then
|
||||||
|
if [[ ! -e "$RUN_HOME" ]]; then
|
||||||
|
echo "Home: creating $RUN_HOME (64G qcow2) and formatting it as APFS 'vmix-home' via the PE of $RUN_HOME_IMAGE ..."
|
||||||
|
${pkgs.qemu}/bin/qemu-img create -q -f qcow2 "$RUN_HOME" 64G
|
||||||
|
FMT=$(${pkgs.nix}/bin/nix build --no-link --print-out-paths --impure --expr "let l = (builtins.getFlake \"${self}\").lib.${system}; in l.macos.formatVolume { image = l.$RUN_HOME_IMAGE; }") || { echo "Error: could not build the formatter"; exit 1; }
|
||||||
|
"$FMT" "$RUN_HOME" qcow2 || exit 1
|
||||||
|
fi
|
||||||
|
HOME_FMT=$(${pkgs.qemu}/bin/qemu-img info --output=json "$RUN_HOME" | ${pkgs.jq}/bin/jq -r .format)
|
||||||
|
MACOS_HOME_ARGS="-drive id=home,if=none,format=$HOME_FMT,file=$RUN_HOME -device virtio-blk-pci,drive=home"
|
||||||
|
echo "Home: $RUN_HOME (mounted at /Users by images generalized with persistHome)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
exec ${pkgs.qemu}/bin/qemu-system-x86_64 \
|
||||||
|
$MACOS_MEM_ARGS $MACOS_SHARE_ARGS $MACOS_HOME_ARGS \
|
||||||
|
-device virtio-serial-pci,id=vmix-vser -chardev socket,path="$RUN_QGA",server=on,wait=off,id=vmix-qga -device virtserialport,chardev=vmix-qga,name=org.qemu.guest_agent.0 \
|
||||||
|
$VMIX_DISPLAY \
|
||||||
|
${macosQemu.deviceArgs} ${macosQemu.vgaArgs} \
|
||||||
|
$([[ "$RUN_APPLESMC" == true ]] && echo '-device isa-applesmc,osk="${macosQemu.osk}"') \
|
||||||
|
-accel kvm \
|
||||||
|
-machine type=q35 \
|
||||||
|
-cpu ${macosQemu.defaultCpu} \
|
||||||
|
-smp "$RUN_SMP",sockets=1,cores="$RUN_SMP",threads=1 \
|
||||||
|
-m "$RUN_MEM" \
|
||||||
|
-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd \
|
||||||
|
-drive if=pflash,format=raw,file=/tmp/vmix-run-vars-$$.fd \
|
||||||
|
-drive id=os,if=none,format=qcow2,file="$RUN_IMAGE",snapshot=on -device ide-hd,bus=sata.0,drive=os \
|
||||||
|
-netdev user,id=net0 -device virtio-net-pci,netdev=net0,mac="$RUN_MAC",bus=pcie.0,addr=${macosQemu.nicAddr}
|
||||||
|
fi
|
||||||
|
|
||||||
echo "AHCI: $RUN_AHCI"
|
echo "AHCI: $RUN_AHCI"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|
@ -106,7 +215,7 @@ pkgs.writeShellScriptBin "vmix" ''
|
||||||
else
|
else
|
||||||
echo "-drive file=$RUN_IMAGE,format=qcow2,if=virtio,snapshot=on"
|
echo "-drive file=$RUN_IMAGE,format=qcow2,if=virtio,snapshot=on"
|
||||||
fi) \
|
fi) \
|
||||||
-nic user,model=$(if [[ "$RUN_AHCI" == "true" ]]; then echo e1000; else echo virtio-net-pci; fi) \
|
-nic user,model=$(if [[ "$RUN_AHCI" == "true" ]]; then echo e1000; else echo virtio-net-pci; fi)$(if [[ -n "$RUN_MAC" ]]; then echo ",mac=$RUN_MAC"; fi) \
|
||||||
-device virtio-serial-pci \
|
-device virtio-serial-pci \
|
||||||
-chardev spicevmc,id=vdagent,debug=0,name=vdagent \
|
-chardev spicevmc,id=vdagent,debug=0,name=vdagent \
|
||||||
-device virtserialport,chardev=vdagent,name=com.redhat.spice.0
|
-device virtserialport,chardev=vdagent,name=com.redhat.spice.0
|
||||||
|
|
@ -161,8 +270,16 @@ pkgs.writeShellScriptBin "vmix" ''
|
||||||
|
|
||||||
FLAKE_DIR="${self}"
|
FLAKE_DIR="${self}"
|
||||||
|
|
||||||
|
# OS type of the image (windows / macos / linux) decides the disk-writing steps
|
||||||
|
OS_TYPE=$(${pkgs.nix}/bin/nix eval --raw --impure --expr "
|
||||||
|
let
|
||||||
|
vmixLib = (builtins.getFlake \"$FLAKE_DIR\").lib.${system};
|
||||||
|
image = vmixLib.$IMAGE_NAME;
|
||||||
|
in image._vmixOsType or \"linux\"
|
||||||
|
" 2>/dev/null || echo linux)
|
||||||
|
|
||||||
echo "=== vmix $COMMAND ==="
|
echo "=== vmix $COMMAND ==="
|
||||||
echo "Image: $IMAGE_NAME"
|
echo "Image: $IMAGE_NAME ($OS_TYPE)"
|
||||||
[[ -n "$GENERALIZE" ]] && echo "Generalize: $GENERALIZE"
|
[[ -n "$GENERALIZE" ]] && echo "Generalize: $GENERALIZE"
|
||||||
[[ -n "$TO_DISK" ]] && echo "To disk: $TO_DISK"
|
[[ -n "$TO_DISK" ]] && echo "To disk: $TO_DISK"
|
||||||
[[ -n "$TO_REMOTE_DISK" ]] && echo "To remote: $TO_REMOTE_DISK"
|
[[ -n "$TO_REMOTE_DISK" ]] && echo "To remote: $TO_REMOTE_DISK"
|
||||||
|
|
@ -191,6 +308,10 @@ pkgs.writeShellScriptBin "vmix" ''
|
||||||
IMAGE_FILE=$(readlink -f "$OUT_LINK")
|
IMAGE_FILE=$(readlink -f "$OUT_LINK")
|
||||||
echo "Built: $IMAGE_FILE"
|
echo "Built: $IMAGE_FILE"
|
||||||
|
|
||||||
|
if [[ "$OS_TYPE" == "macos" ]]; then
|
||||||
|
echo "Run it with: vmix run $OUT_LINK --macos --vnc :10"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- write to local disk ---
|
# --- write to local disk ---
|
||||||
if [[ -n "$TO_DISK" ]]; then
|
if [[ -n "$TO_DISK" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -224,6 +345,15 @@ pkgs.writeShellScriptBin "vmix" ''
|
||||||
|
|
||||||
echo "[3/5] Fixing GPT backup header..."
|
echo "[3/5] Fixing GPT backup header..."
|
||||||
${pkgs.gptfdisk}/bin/sgdisk -e "$TO_DISK"
|
${pkgs.gptfdisk}/bin/sgdisk -e "$TO_DISK"
|
||||||
|
|
||||||
|
if [[ "$OS_TYPE" == "macos" ]]; then
|
||||||
|
echo "[4/5] APFS container cannot be grown from Linux — skipped"
|
||||||
|
echo "[5/5] Grow it from macOS with: diskutil apfs resizeContainer disk0s2 0"
|
||||||
|
echo ""
|
||||||
|
echo "Done. $TO_DISK is ready to boot (OpenCore in the EFI partition; real Macs need no OpenCore)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# delete recovery partition if present, then resize Windows partition
|
# delete recovery partition if present, then resize Windows partition
|
||||||
${pkgs.gptfdisk}/bin/sgdisk -d 4 "$TO_DISK" 2>/dev/null || true
|
${pkgs.gptfdisk}/bin/sgdisk -d 4 "$TO_DISK" 2>/dev/null || true
|
||||||
|
|
||||||
|
|
@ -281,13 +411,22 @@ pkgs.writeShellScriptBin "vmix" ''
|
||||||
kill $NBD_PID 2>/dev/null || true
|
kill $NBD_PID 2>/dev/null || true
|
||||||
rm -f "$NBD_SOCK"
|
rm -f "$NBD_SOCK"
|
||||||
|
|
||||||
|
echo "[3/5] Fixing GPT backup header..."
|
||||||
|
if [[ "$OS_TYPE" == "macos" ]]; then
|
||||||
|
ssh "$REMOTE_HOST" "nix-shell -p gptfdisk --run 'sgdisk -e $REMOTE_DISK'"
|
||||||
|
echo "[4/5] APFS container cannot be grown from Linux — skipped"
|
||||||
|
echo "[5/5] Grow it from macOS with: diskutil apfs resizeContainer disk0s2 0"
|
||||||
|
echo ""
|
||||||
|
echo "Done. $REMOTE_HOST:$REMOTE_DISK is ready to boot."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$REMOTE_DISK" == *nvme* ]] || [[ "$REMOTE_DISK" == *mmcblk* ]]; then
|
if [[ "$REMOTE_DISK" == *nvme* ]] || [[ "$REMOTE_DISK" == *mmcblk* ]]; then
|
||||||
REMOTE_WIN_PART="''${REMOTE_DISK}p3"
|
REMOTE_WIN_PART="''${REMOTE_DISK}p3"
|
||||||
else
|
else
|
||||||
REMOTE_WIN_PART="''${REMOTE_DISK}3"
|
REMOTE_WIN_PART="''${REMOTE_DISK}3"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[3/5] Fixing GPT backup header..."
|
|
||||||
ssh "$REMOTE_HOST" "nix-shell -p gptfdisk --run 'sgdisk -e $REMOTE_DISK && sgdisk -d 4 $REMOTE_DISK 2>/dev/null || true'"
|
ssh "$REMOTE_HOST" "nix-shell -p gptfdisk --run 'sgdisk -e $REMOTE_DISK && sgdisk -d 4 $REMOTE_DISK 2>/dev/null || true'"
|
||||||
|
|
||||||
echo "[4/5] Expanding Windows partition (partition 3)..."
|
echo "[4/5] Expanding Windows partition (partition 3)..."
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
lib.${system} = vmixLib;
|
lib.${system} = vmixLib;
|
||||||
|
|
||||||
packages.${system}.default = import ./cli.nix { inherit pkgs self system; };
|
packages.${system}.default = import ./cli.nix { inherit pkgs self system vmixLib; };
|
||||||
|
|
||||||
apps.${system}.default = {
|
apps.${system}.default = {
|
||||||
type = "app";
|
type = "app";
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@ let
|
||||||
network = import ./network.nix { inherit pkgs lib; };
|
network = import ./network.nix { inherit pkgs lib; };
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
inherit (images) linux windows;
|
inherit (images) linux windows macos;
|
||||||
inherit network;
|
inherit network;
|
||||||
}
|
}
|
||||||
|
|
@ -2,4 +2,5 @@
|
||||||
{
|
{
|
||||||
linux = (import ./linux) { inherit pkgs lib system; };
|
linux = (import ./linux) { inherit pkgs lib system; };
|
||||||
windows = (import ./windows) { inherit pkgs lib system; };
|
windows = (import ./windows) { inherit pkgs lib system; };
|
||||||
}
|
macos = (import ./macos) { inherit pkgs lib system; };
|
||||||
|
}
|
||||||
|
|
|
||||||
177
lib/images/macos/README.md
Normal file
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="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@ let
|
||||||
windows = rec {
|
windows = rec {
|
||||||
drivers = import ./drivers { inherit pkgs system; };
|
drivers = import ./drivers { inherit pkgs system; };
|
||||||
makeFilesISO = (import ./helpers/makeFilesISO.nix) { inherit pkgs; };
|
makeFilesISO = (import ./helpers/makeFilesISO.nix) { inherit pkgs; };
|
||||||
makeConfigMedium = (import ./helpers/makeConfigMedium.nix) { inherit pkgs lib makeFilesISO; };
|
|
||||||
customizeImage = (import ./helpers/customizeImage.nix) { inherit pkgs lib; };
|
customizeImage = (import ./helpers/customizeImage.nix) { inherit pkgs lib; };
|
||||||
customizeImageFold = builtins.foldl' customizeImage;
|
customizeImageFold = builtins.foldl' customizeImage;
|
||||||
templates = (import ./templates) { inherit pkgs lib system drivers makeFilesISO; };
|
templates = (import ./templates) { inherit pkgs lib system drivers makeFilesISO; };
|
||||||
|
|
@ -15,20 +14,14 @@ let
|
||||||
win10 = (import ./win10) { inherit pkgs lib system windows; };
|
win10 = (import ./win10) { inherit pkgs lib system windows; };
|
||||||
win11 = (import ./win11) { inherit pkgs lib system windows; };
|
win11 = (import ./win11) { inherit pkgs lib system windows; };
|
||||||
|
|
||||||
# Recursively add .generalize and .seal to every derivation leaf in the tree
|
# Recursively add .generalize to every derivation leaf in the image tree
|
||||||
addGeneralize = val:
|
addGeneralize = val:
|
||||||
if val ? _vmixOsType then
|
if val ? _vmixOsType then
|
||||||
val // {
|
val // { generalize = args:
|
||||||
generalize = args:
|
let
|
||||||
let
|
templateArgs = builtins.removeAttrs args [ "vncDisplay" ];
|
||||||
templateArgs = builtins.removeAttrs args [ "vncDisplay" ];
|
displayArgs = lib.optionalAttrs (args ? vncDisplay) { inherit (args) vncDisplay; };
|
||||||
displayArgs = lib.optionalAttrs (args ? vncDisplay) { inherit (args) vncDisplay; };
|
in windows.customizeImage val (windows.templates.generalize templateArgs // displayArgs);
|
||||||
in windows.customizeImage val (windows.templates.generalize templateArgs // displayArgs);
|
|
||||||
seal = args:
|
|
||||||
let
|
|
||||||
templateArgs = builtins.removeAttrs args [ "vncDisplay" ];
|
|
||||||
displayArgs = lib.optionalAttrs (args ? vncDisplay) { inherit (args) vncDisplay; };
|
|
||||||
in windows.customizeImage val (windows.templates.seal templateArgs // displayArgs);
|
|
||||||
}
|
}
|
||||||
else if builtins.isAttrs val then
|
else if builtins.isAttrs val then
|
||||||
lib.mapAttrs (_: addGeneralize) val
|
lib.mapAttrs (_: addGeneralize) val
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
# Per-VM config medium for a sealed Windows image (see templates.seal).
|
|
||||||
#
|
|
||||||
# A sealed image carries no per-VM data. The values that differ between VMs --
|
|
||||||
# hostname, the static address the guest asserts, timezone, desktop tint -- are
|
|
||||||
# written here as a PowerShell data file and packed into a tiny ISO. config.nix
|
|
||||||
# attaches it as a read-only CD-ROM; the image's baked first-boot scripts
|
|
||||||
# (vmix-load-config.cmd finds it, then dot-source it) apply the values. So one
|
|
||||||
# sealed store path is shared by every VM, and only this cheap ISO is per-VM.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# makeConfigMedium {
|
|
||||||
# name = "win-config";
|
|
||||||
# hostname = "panda-win";
|
|
||||||
# staticIP = { address = "10.10.10.26"; prefixLength = 24;
|
|
||||||
# gateway = "10.10.10.1"; dns = [ "10.10.10.1" ]; };
|
|
||||||
# timezone = "E. South America Standard Time";
|
|
||||||
# bgColor = "#856558";
|
|
||||||
# }
|
|
||||||
{ pkgs, lib, makeFilesISO, ... }:
|
|
||||||
{
|
|
||||||
name ? "vmix-config",
|
|
||||||
hostname ? "",
|
|
||||||
# The per-VM account. The sealed image carries a generic bootstrap account
|
|
||||||
# (only there to carry OOBE); on first boot this real account is created from
|
|
||||||
# here, gets the SID-bound profile on D:\Users\<username>, and the bootstrap
|
|
||||||
# is retired. Distinct per VM -- nothing about the account is shared/baked.
|
|
||||||
username ? "",
|
|
||||||
password ? "",
|
|
||||||
# { address; prefixLength; gateway; dns = [ ... ]; }
|
|
||||||
staticIP ? null,
|
|
||||||
timezone ? null,
|
|
||||||
# Solid desktop background as a hex string, e.g. "#856558". Converted to the
|
|
||||||
# registry's decimal "R G B" on the target, in vmix-apply-config.ps1.
|
|
||||||
bgColor ? null,
|
|
||||||
}:
|
|
||||||
let
|
|
||||||
dnsList = lib.optionalString (staticIP != null)
|
|
||||||
(lib.concatMapStringsSep "," (s: "'${s}'") staticIP.dns);
|
|
||||||
|
|
||||||
# Consumed by dot-sourcing (. C:\vmix-config.ps1), so it only assigns
|
|
||||||
# variables. Anything not set here is simply absent, and the baked scripts
|
|
||||||
# guard on that ($VmixIpAddress being null skips the static-IP assignment).
|
|
||||||
configPs1 = pkgs.writeText "vmix-config.ps1" ''
|
|
||||||
# vmix per-VM config -- generated, read by the sealed image's baked scripts.
|
|
||||||
$VmixHostname = '${hostname}'
|
|
||||||
${lib.optionalString (username != "") "$VmixUsername = '${username}'"}
|
|
||||||
${lib.optionalString (username != "") "$VmixPassword = '${password}'"}
|
|
||||||
${lib.optionalString (staticIP != null) ''
|
|
||||||
$VmixIpAddress = '${staticIP.address}'
|
|
||||||
$VmixPrefixLength = ${toString staticIP.prefixLength}
|
|
||||||
$VmixGateway = '${staticIP.gateway}'
|
|
||||||
$VmixDns = @(${dnsList})''}
|
|
||||||
${lib.optionalString (timezone != null) "$VmixTimeZone = '${timezone}'"}
|
|
||||||
${lib.optionalString (bgColor != null) "$VmixBgColor = '${bgColor}'"}
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
# makeFilesISO strips the store-hash prefix, so this lands at the ISO root as
|
|
||||||
# exactly vmix-config.ps1 -- which is what vmix-load-config.cmd scans for.
|
|
||||||
makeFilesISO { inherit name; files = [ configPs1 ]; }
|
|
||||||
|
|
@ -39,24 +39,6 @@ in rec {
|
||||||
# Generalize (sysprep + OOBE). Pass seal=true for hardware deployment.
|
# Generalize (sysprep + OOBE). Pass seal=true for hardware deployment.
|
||||||
generalize = import ./generalize.nix args;
|
generalize = import ./generalize.nix args;
|
||||||
|
|
||||||
# Seal: a generic OOBE-deferred base whose per-VM data is not baked but
|
|
||||||
# delivered at deploy time on a config medium (helpers/makeConfigMedium.nix).
|
|
||||||
# One sealed store path is shared by every VM; each VM's first boot mints its
|
|
||||||
# own SID and builds the whole profile on the relocated data volume (D:).
|
|
||||||
#
|
|
||||||
# The baked account is a generic bootstrap that only exists to carry OOBE to a
|
|
||||||
# logon -- the real, per-VM account (username/password) comes from the config
|
|
||||||
# medium, and the bootstrap is retired on the target. So nothing per-VM is
|
|
||||||
# baked. RDP and locale stay caller args.
|
|
||||||
seal = templateArgs: generalize ({
|
|
||||||
delayOobeRun = true;
|
|
||||||
configMedium = true;
|
|
||||||
username = "vmixsetup";
|
|
||||||
password = "vmixsetup";
|
|
||||||
profilesDirectory = "D:\\Users";
|
|
||||||
dataDisk = { driveLetter = "D"; label = "data"; };
|
|
||||||
} // templateArgs);
|
|
||||||
|
|
||||||
# Offline registry templates
|
# Offline registry templates
|
||||||
reg = import ./registry args;
|
reg = import ./registry args;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,30 +42,6 @@ in
|
||||||
# delayOobeRun = true: sysprep only, OOBE + activation on real hardware
|
# delayOobeRun = true: sysprep only, OOBE + activation on real hardware
|
||||||
# delayOobeRun = false: sysprep + OOBE + activation in build VM
|
# delayOobeRun = false: sysprep + OOBE + activation in build VM
|
||||||
delayOobeRun ? false,
|
delayOobeRun ? false,
|
||||||
# configMedium = true: this is a generic sealed base whose per-VM data
|
|
||||||
# (hostname, static IP, timezone, desktop tint) is NOT baked. The target's
|
|
||||||
# first boot reads it off a small removable config CD (see makeConfigMedium)
|
|
||||||
# via baked finder/apply scripts. Implies the OOBE is deferred to the target,
|
|
||||||
# so it is only meaningful together with delayOobeRun = true. Lets one sealed
|
|
||||||
# store path be shared by every VM built from it.
|
|
||||||
configMedium ? false,
|
|
||||||
# Skip sysprep's SID reset (use /oobe without /generalize), so every rebuild
|
|
||||||
# of the layers above the cached base install carries the same machine SID --
|
|
||||||
# and therefore the same account SID. A profile kept on a persistent disk then
|
|
||||||
# matches on file ACLs, its NTUSER.DAT hive, and ProfileList across rebuilds,
|
|
||||||
# with no ownership fixups. As a side effect MountedDevices survives too, so
|
|
||||||
# the data disk keeps its drive letter without a boot-time reassign.
|
|
||||||
#
|
|
||||||
# Correct only for an image that is always this one machine; a fleet that
|
|
||||||
# deploys the same image to many hosts wants the default generalization.
|
|
||||||
keepMachineSid ? false,
|
|
||||||
# Known-Folder redirection: keep the SID-bound profile on C: (so /generalize
|
|
||||||
# can still randomize the SID per machine) but point the user's data folders
|
|
||||||
# at the persistent data disk, so files -- not per-user registry settings --
|
|
||||||
# survive an OS rebuild. e.g. { base = "D:\\UserData"; folders = [ "Desktop"
|
|
||||||
# "Documents" "Downloads" ]; }. Needs dataDisk to provide the volume, and is
|
|
||||||
# mutually exclusive with profilesDirectory.
|
|
||||||
folderRedirect ? null,
|
|
||||||
}: let
|
}: let
|
||||||
# Convert "8e8cd8" hex to "142 140 216" decimal RGB for Windows registry
|
# Convert "8e8cd8" hex to "142 140 216" decimal RGB for Windows registry
|
||||||
hexToRgbStr = hex: let
|
hexToRgbStr = hex: let
|
||||||
|
|
@ -117,161 +93,12 @@ in
|
||||||
# interface arrives DHCP-managed -- assigning an address without turning DHCP
|
# interface arrives DHCP-managed -- assigning an address without turning DHCP
|
||||||
# off first does not stick, which is how a VM meant to be at a fixed address
|
# off first does not stick, which is how a VM meant to be at a fixed address
|
||||||
# ended up holding a lease instead.
|
# ended up holding a lease instead.
|
||||||
# PowerShell in its own file: it grew a wait loop and a retry, which are no
|
|
||||||
# fun to keep correct inside a cmd one-liner.
|
|
||||||
#
|
|
||||||
# Two things it must survive. DHCP is turned off before the address is set,
|
|
||||||
# so any failure to set it strands the box with no address at all -- which is
|
|
||||||
# exactly what happened after an internal reboot, where a stale ARP entry for
|
|
||||||
# the address from the previous instance tripped duplicate-address detection
|
|
||||||
# and New-NetIPAddress threw. DadTransmits 0 turns that detection off so the
|
|
||||||
# static always binds, and the assignment is retried rather than fatal.
|
|
||||||
# Two shapes. Baked: the address is a build-time literal. configMedium: the
|
|
||||||
# address is read from C:\vmix-config.ps1 (dot-sourced), which the finder
|
|
||||||
# dropped there off the config CD -- so the same sealed script serves every
|
|
||||||
# VM. The wait/retry logic is identical either way.
|
|
||||||
staticIPAssign = if configMedium
|
|
||||||
then { addr = "$VmixIpAddress"; prefix = "$VmixPrefixLength"; gw = "$VmixGateway"; dns = "$VmixDns"; }
|
|
||||||
else { addr = "'${staticIP.address}'"; prefix = toString staticIP.prefixLength; gw = "'${staticIP.gateway}'"; dns = staticDnsList; };
|
|
||||||
staticIPScriptPs1 = pkgs.writeText "vmix-static-ip.ps1" ''
|
|
||||||
${lib.optionalString configMedium ''
|
|
||||||
if (-not (Test-Path C:\vmix-config.ps1)) { Write-Output 'vmix: no config yet'; exit 0 }
|
|
||||||
. C:\vmix-config.ps1
|
|
||||||
if (-not $VmixIpAddress) { Write-Output 'vmix: no static address in config'; exit 0 }
|
|
||||||
''}
|
|
||||||
$a = $null
|
|
||||||
for ($n = 0; $n -lt 30; $n++) {
|
|
||||||
$a = Get-NetAdapter -Physical | Where-Object Status -eq 'Up' | Sort-Object ifIndex | Select-Object -First 1
|
|
||||||
if ($a) { break }
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
}
|
|
||||||
if (-not $a) { Write-Output 'vmix: no adapter came up'; exit 1 }
|
|
||||||
$i = $a.ifIndex
|
|
||||||
Set-NetIPInterface -InterfaceIndex $i -Dhcp Disabled -DadTransmits 0 -ErrorAction SilentlyContinue
|
|
||||||
Remove-NetIPAddress -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue
|
|
||||||
Remove-NetRoute -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue
|
|
||||||
$ok = $false
|
|
||||||
for ($k = 0; $k -lt 5 -and -not $ok; $k++) {
|
|
||||||
try {
|
|
||||||
New-NetIPAddress -InterfaceIndex $i -IPAddress ${staticIPAssign.addr} -PrefixLength ${staticIPAssign.prefix} -DefaultGateway ${staticIPAssign.gw} -ErrorAction Stop | Out-Null
|
|
||||||
$ok = $true
|
|
||||||
} catch {
|
|
||||||
Write-Output ('vmix: assign attempt ' + $k + ' failed: ' + $_.Exception.Message)
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
Remove-NetIPAddress -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (-not $ok) { Write-Output 'vmix: could not set static address'; exit 1 }
|
|
||||||
Set-DnsClientServerAddress -InterfaceIndex $i -ServerAddresses ${staticIPAssign.dns}
|
|
||||||
New-NetFirewallRule -DisplayName 'ICMPv4 Echo' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -Enabled True -ErrorAction SilentlyContinue | Out-Null
|
|
||||||
Write-Output ('vmix: set ' + ${staticIPAssign.addr} + ' on ifIndex ' + $i)
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Finder: the config CD's drive letter is unknown, so scan for the marker file
|
|
||||||
# and stage it on C: where the baked scripts expect it. Runs on the target's
|
|
||||||
# first boot (post-oobe), before the per-boot static-IP task needs it.
|
|
||||||
loadConfigScript = pkgs.writeText "vmix-load-config.cmd" ''
|
|
||||||
@echo off
|
|
||||||
for %%D in (E F G H I J K L M N O P Q R S T U V W X Y Z D) do (
|
|
||||||
if exist %%D:\vmix-config.ps1 (
|
|
||||||
copy /y %%D:\vmix-config.ps1 C:\vmix-config.ps1 >nul
|
|
||||||
goto :done
|
|
||||||
)
|
|
||||||
)
|
|
||||||
:done
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Applies the per-VM config that is not an answer-file field: timezone, the
|
|
||||||
# desktop tint (per user, so run under the created account in post-oobe), and
|
|
||||||
# the machine rename. Rename is pending until the post-oobe reboot.
|
|
||||||
applyConfigScript = pkgs.writeText "vmix-apply-config.ps1" ''
|
|
||||||
if (-not (Test-Path C:\vmix-config.ps1)) { exit 0 }
|
|
||||||
. C:\vmix-config.ps1
|
|
||||||
if ($VmixTimeZone) { & tzutil /s "$VmixTimeZone" }
|
|
||||||
if ($VmixBgColor) {
|
|
||||||
$hex = ([string]$VmixBgColor).TrimStart('#')
|
|
||||||
$r = [Convert]::ToInt32($hex.Substring(0,2),16)
|
|
||||||
$g = [Convert]::ToInt32($hex.Substring(2,2),16)
|
|
||||||
$b = [Convert]::ToInt32($hex.Substring(4,2),16)
|
|
||||||
Set-ItemProperty 'HKCU:\Control Panel\Colors' -Name Background -Value "$r $g $b"
|
|
||||||
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name WallPaper -Value ""
|
|
||||||
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name WallpaperStyle -Value '0'
|
|
||||||
}
|
|
||||||
if ($VmixHostname -and $env:COMPUTERNAME -ne $VmixHostname) {
|
|
||||||
Rename-Computer -NewName $VmixHostname -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Creates the real per-VM account from the config and hands the machine over
|
|
||||||
# to it. The sealed image bakes only a generic bootstrap account (${username})
|
|
||||||
# -- enough to carry OOBE to a logon so this can run -- and the real account
|
|
||||||
# is made here, on the target, from the CD. Autologon is switched to it and a
|
|
||||||
# one-shot cleanup is armed; the post-oobe reboot then lets the real account
|
|
||||||
# log in and build its own SID-bound profile on D:\Users\<username>, after
|
|
||||||
# which the bootstrap is retired. So the account, like the SID, is per-VM and
|
|
||||||
# nothing about it is shared or baked. Runs as the bootstrap user in post-oobe.
|
|
||||||
createUserScript = pkgs.writeText "vmix-create-user.ps1" ''
|
|
||||||
if (-not (Test-Path C:\vmix-config.ps1)) { exit 0 }
|
|
||||||
. C:\vmix-config.ps1
|
|
||||||
if (-not $VmixUsername) { exit 0 }
|
|
||||||
if ($VmixUsername -ieq '${username}') { exit 0 }
|
|
||||||
& net user $VmixUsername $VmixPassword /add
|
|
||||||
& net localgroup Administrators $VmixUsername /add
|
|
||||||
$w = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
|
|
||||||
Set-ItemProperty $w -Name AutoAdminLogon -Value '1'
|
|
||||||
Set-ItemProperty $w -Name DefaultUserName -Value $VmixUsername
|
|
||||||
Set-ItemProperty $w -Name DefaultPassword -Value $VmixPassword
|
|
||||||
Remove-ItemProperty $w -Name DefaultDomainName -ErrorAction SilentlyContinue
|
|
||||||
# Fires at the real account's first logon (HKLM RunOnce = next user to log
|
|
||||||
# on), i.e. after the reboot below, once the bootstrap is no longer in use.
|
|
||||||
Set-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce' `
|
|
||||||
-Name vmixFinalize -Value 'cmd /c C:\vmix-finalize.cmd' -Type String
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Runs once as the real account (RunOnce, after the hand-over reboot), so the
|
|
||||||
# fresh machine SID and the real profile already exist. This is where activation
|
|
||||||
# belongs -- on that fresh SID, not the throwaway bootstrap's. It then finishes
|
|
||||||
# per-user setup, retires the bootstrap, unmounts the config CD for good, and
|
|
||||||
# wipes every vmix artifact off C:\ so the running machine carries no leftover
|
|
||||||
# setup files. Self-deletes last.
|
|
||||||
finalizeScript = pkgs.writeText "vmix-finalize.cmd" ''
|
|
||||||
@echo off
|
|
||||||
:: 1) Activate Windows on the real account's fresh SID (TSforge, offline).
|
|
||||||
cscript //nologo C:\Windows\System32\slmgr.vbs /ipk M7XTQ-FN8P6-TTKYV-9D4CC-J462D
|
|
||||||
cscript //nologo C:\Windows\System32\slmgr.vbs /rilc
|
|
||||||
net stop sppsvc /y 2>nul
|
|
||||||
net start sppsvc
|
|
||||||
ping -n 8 127.0.0.1 >nul
|
|
||||||
if exist C:\MAS_AIO.cmd ( echo. | call C:\MAS_AIO.cmd /Z-Windows )
|
|
||||||
if exist "C:\Program Files\Microsoft Office\root\Office16\WINWORD.EXE" (
|
|
||||||
if exist C:\MAS_AIO.cmd ( echo. | call C:\MAS_AIO.cmd /Ohook )
|
|
||||||
)
|
|
||||||
:: 2) Per-user desktop tint, now that the real account is logged in.
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-apply-config.ps1 >nul 2>&1
|
|
||||||
:: 3) Retire the bootstrap account and its profile (idle now).
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_UserProfile | Where-Object { $_.LocalPath -like '*\${username}' } | Remove-CimInstance -ErrorAction SilentlyContinue"
|
|
||||||
net user ${username} /delete >nul 2>&1
|
|
||||||
:: 4) Unmount the config CD and keep it unmounted -- drop its letter and stop
|
|
||||||
:: the mount manager auto-lettering it (D: keeps its explicit assignment).
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Volume -Filter 'DriveType=5 AND DriveLetter IS NOT NULL' | ForEach-Object { $_.DriveLetter=$null; [void]($_ | Set-CimInstance) }"
|
|
||||||
> C:\Windows\Temp\vmix-am.txt echo automount disable
|
|
||||||
>> C:\Windows\Temp\vmix-am.txt echo automount scrub
|
|
||||||
diskpart /s C:\Windows\Temp\vmix-am.txt >nul 2>&1
|
|
||||||
del /q C:\Windows\Temp\vmix-am.txt 2>nul
|
|
||||||
:: 5) Wipe every vmix setup artifact from C:\.
|
|
||||||
del /q C:\MAS_AIO.cmd C:\vmix-config.ps1 C:\vmix-data-initialized 2>nul
|
|
||||||
del /q C:\vmix-load-config.cmd C:\vmix-apply-config.ps1 C:\vmix-create-user.ps1 2>nul
|
|
||||||
del /q C:\vmix-init-data-disk.cmd C:\vmix-data-profile.cmd C:\vmix-heal-profile.ps1 2>nul
|
|
||||||
del /q C:\vmix-static-ip.cmd C:\vmix-static-ip.ps1 2>nul
|
|
||||||
del /q C:\oobe-unattend.xml C:\post-oobe.cmd 2>nul
|
|
||||||
:: 6) Self-delete.
|
|
||||||
(goto) 2>nul & del "%~f0"
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Thin launcher, so the scheduled task has a cmd to point at.
|
|
||||||
staticIPScript = pkgs.writeText "vmix-static-ip.cmd" ''
|
staticIPScript = pkgs.writeText "vmix-static-ip.cmd" ''
|
||||||
@echo off
|
@echo off
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-static-ip.ps1 > C:\Windows\Temp\vmix-static-ip.log 2>&1
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "$a = $null; for ($n = 0; $n -lt 30; $n++) { $a = Get-NetAdapter -Physical | Where-Object Status -eq 'Up' | Sort-Object ifIndex | Select-Object -First 1; if ($a) { break }; Start-Sleep -Seconds 2 }; if (-not $a) { Write-Output 'vmix: no adapter came up'; exit 1 }; $i = $a.ifIndex; Set-NetIPInterface -InterfaceIndex $i -Dhcp Disabled -ErrorAction SilentlyContinue; Remove-NetIPAddress -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; Remove-NetRoute -InterfaceIndex $i -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; New-NetIPAddress -InterfaceIndex $i -IPAddress '${staticIP.address}' -PrefixLength ${toString staticIP.prefixLength} -DefaultGateway '${staticIP.gateway}' -ErrorAction Stop | Out-Null; Set-DnsClientServerAddress -InterfaceIndex $i -ServerAddresses ${staticDnsList}; Write-Output ('vmix: set ' + '${staticIP.address}' + ' on ifIndex ' + $i)" > C:\Windows\Temp\vmix-static-ip.log 2>&1
|
||||||
|
:: Answer pings. Windows blocks ICMP by default, which makes a box with a
|
||||||
|
:: fixed address look dead to everything that checks it the obvious way.
|
||||||
|
powershell -NoProfile -Command "New-NetFirewallRule -DisplayName 'ICMPv4 Echo' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -Enabled True | Out-Null" > nul 2>&1
|
||||||
'';
|
'';
|
||||||
|
|
||||||
dataDriveLetter = if dataDisk != null then (dataDisk.driveLetter or "D") else "D";
|
dataDriveLetter = if dataDisk != null then (dataDisk.driveLetter or "D") else "D";
|
||||||
|
|
@ -285,59 +112,7 @@ in
|
||||||
# a GPT label, one full-size NTFS partition and the drive letter, while a disk
|
# a GPT label, one full-size NTFS partition and the drive letter, while a disk
|
||||||
# that already holds data keeps it and only has its letter re-asserted. The
|
# that already holds data keeps it and only has its letter re-asserted. The
|
||||||
# OS disk is added to QEMU first and so is always disk 0.
|
# OS disk is added to QEMU first and so is always disk 0.
|
||||||
initDataDiskScript = pkgs.writeText "vmix-init-data-disk.cmd" (if configMedium then ''
|
initDataDiskScript = pkgs.writeText "vmix-init-data-disk.cmd" ''
|
||||||
@echo off
|
|
||||||
:: Sealed-image variant. Two extra hazards over the baked path:
|
|
||||||
::
|
|
||||||
:: 1. The per-VM config rides an optical drive, and on the target's first
|
|
||||||
:: boot the raw data disk has no volume yet -- so Windows letters the CD
|
|
||||||
:: as ${dataDriveLetter}:, exactly where the profile volume must go. The
|
|
||||||
:: plain `if exist ${dataDriveLetter}:\` guard would then see the CD and
|
|
||||||
:: skip, leaving ProfilesDirectory pointed at read-only media. So a first
|
|
||||||
:: boot is tracked by a marker, not by the letter, and any occupant of
|
|
||||||
:: ${dataDriveLetter}: is moved aside before the data disk claims it.
|
|
||||||
:: 2. Left to Shell-Setup's FolderLocations, ProfilesDirectory can be
|
|
||||||
:: evaluated before this disk exists (unordered within specialize) and
|
|
||||||
:: silently fall back to C:. Setting it here, in the same step that just
|
|
||||||
:: created the volume, removes that race.
|
|
||||||
if exist C:\vmix-data-initialized goto :ensure
|
|
||||||
|
|
||||||
:: First boot: park whatever holds ${dataDriveLetter}: (the config CD) on Y:
|
|
||||||
:: so the data disk can take the letter. Harmless if the letter is free.
|
|
||||||
> C:\Windows\Temp\vmix-cd.txt echo select volume ${dataDriveLetter}
|
|
||||||
>> C:\Windows\Temp\vmix-cd.txt echo assign letter=Y noerr
|
|
||||||
diskpart /s C:\Windows\Temp\vmix-cd.txt > nul 2>&1
|
|
||||||
|
|
||||||
:: Lay disk 1 (the host zvol) out from scratch and give it the letter.
|
|
||||||
> C:\Windows\Temp\vmix-dd-init.txt echo select disk 1
|
|
||||||
>> C:\Windows\Temp\vmix-dd-init.txt echo clean
|
|
||||||
>> C:\Windows\Temp\vmix-dd-init.txt echo convert gpt
|
|
||||||
>> C:\Windows\Temp\vmix-dd-init.txt echo create partition primary
|
|
||||||
>> C:\Windows\Temp\vmix-dd-init.txt echo format fs=ntfs quick label="${dataLabel}"
|
|
||||||
>> C:\Windows\Temp\vmix-dd-init.txt echo assign letter=${dataDriveLetter}
|
|
||||||
diskpart /s C:\Windows\Temp\vmix-dd-init.txt
|
|
||||||
echo initialized > C:\vmix-data-initialized
|
|
||||||
goto :ensure
|
|
||||||
|
|
||||||
:ensure
|
|
||||||
:: The letter normally persists via MountedDevices; re-assert if it is gone.
|
|
||||||
if exist ${dataDriveLetter}:\ goto :profiledir
|
|
||||||
> C:\Windows\Temp\vmix-dd-assign.txt echo select disk 1
|
|
||||||
>> C:\Windows\Temp\vmix-dd-assign.txt echo select partition 1
|
|
||||||
>> C:\Windows\Temp\vmix-dd-assign.txt echo assign letter=${dataDriveLetter}
|
|
||||||
diskpart /s C:\Windows\Temp\vmix-dd-assign.txt > nul 2>&1
|
|
||||||
|
|
||||||
:profiledir
|
|
||||||
${lib.optionalString (profilesDirectory != null) ''
|
|
||||||
:: Point new profiles at the data volume, now that it exists. REG_EXPAND_SZ
|
|
||||||
:: to match Windows' own ProfilesDirectory type.
|
|
||||||
if exist ${dataDriveLetter}:\ (
|
|
||||||
if not exist "${profilesDirectory}" mkdir "${profilesDirectory}"
|
|
||||||
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" /v ProfilesDirectory /t REG_EXPAND_SZ /d "${profilesDirectory}" /f > nul 2>&1
|
|
||||||
)''}
|
|
||||||
del /q C:\Windows\Temp\vmix-cd.txt C:\Windows\Temp\vmix-dd-init.txt C:\Windows\Temp\vmix-dd-assign.txt 2>nul
|
|
||||||
:done
|
|
||||||
'' else ''
|
|
||||||
@echo off
|
@echo off
|
||||||
:: diskpart rather than the Storage cmdlets. New-Partition and
|
:: diskpart rather than the Storage cmdlets. New-Partition and
|
||||||
:: Format-Volume need services that are not up yet this early in
|
:: Format-Volume need services that are not up yet this early in
|
||||||
|
|
@ -366,102 +141,6 @@ in
|
||||||
:cleanup
|
:cleanup
|
||||||
del /q C:\Windows\Temp\vmix-dd-assign.txt C:\Windows\Temp\vmix-dd-init.txt 2>nul
|
del /q C:\Windows\Temp\vmix-dd-assign.txt C:\Windows\Temp\vmix-dd-init.txt 2>nul
|
||||||
:done
|
:done
|
||||||
'');
|
|
||||||
|
|
||||||
# PowerShell in its own file, so neither cmd quoting nor Nix's '' need to be
|
|
||||||
# fought. If the account's real profile got backed up to a .bak key (the
|
|
||||||
# temporary-profile fallback), put it back: drop the temp key, rename .bak to
|
|
||||||
# the live SID, remove the temp directory, and drop a flag so the caller
|
|
||||||
# knows to reboot.
|
|
||||||
# Known-Folder GUIDs for the redirectable user folders.
|
|
||||||
knownFolderGuids = {
|
|
||||||
Desktop = "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}";
|
|
||||||
Documents = "{FDD39AD0-238F-46AF-ADB4-6C85480369C7}";
|
|
||||||
Downloads = "{374DE290-123F-4565-9164-39C4925E467B}";
|
|
||||||
Pictures = "{33E28130-4E1E-4676-835A-98395C3BC476}";
|
|
||||||
Music = "{4BD8D571-6D19-48D3-BE97-422220080E43}";
|
|
||||||
Videos = "{18989B1D-99B5-455B-841C-AB7C74E4DDFC}";
|
|
||||||
Favorites = "{1777F761-68AD-4D8A-87BD-30B759FA33DD}";
|
|
||||||
};
|
|
||||||
redirectFolders = if folderRedirect != null
|
|
||||||
then (folderRedirect.folders or [ "Desktop" "Documents" "Downloads" "Pictures" "Music" "Videos" ])
|
|
||||||
else [ ];
|
|
||||||
redirectBase = if folderRedirect != null then (folderRedirect.base or "D:\\UserData") else "D:\\UserData";
|
|
||||||
|
|
||||||
# Per-user, run once per profile via Active Setup: point each known folder at
|
|
||||||
# its directory under the data volume. SHSetKnownFolderPath updates both the
|
|
||||||
# registration and the shell-folder registry; it does not move files, so a
|
|
||||||
# freshly created profile's empty C: folder is simply repointed at the D: one,
|
|
||||||
# which already holds this user's accumulated files after a rebuild.
|
|
||||||
folderRedirectPs1 = pkgs.writeText "vmix-redirect-folders.ps1" (lib.optionalString (folderRedirect != null) ''
|
|
||||||
$sig = @'
|
|
||||||
[DllImport("shell32.dll", CharSet=CharSet.Unicode)]
|
|
||||||
public static extern int SHSetKnownFolderPath(ref System.Guid rfid, uint dwFlags, System.IntPtr hToken, string pszPath);
|
|
||||||
'@
|
|
||||||
$kf = Add-Type -MemberDefinition $sig -Name KFP -Namespace Vmix -PassThru
|
|
||||||
$map = @{
|
|
||||||
${lib.concatMapStringsSep "\n " (f: "'${f}' = '${knownFolderGuids.${f}}'") redirectFolders}
|
|
||||||
}
|
|
||||||
foreach ($name in $map.Keys) {
|
|
||||||
$target = Join-Path '${redirectBase}' $name
|
|
||||||
New-Item -ItemType Directory -Force -Path $target -ErrorAction SilentlyContinue | Out-Null
|
|
||||||
$guid = [System.Guid]$map[$name]
|
|
||||||
[void]$kf::SHSetKnownFolderPath([ref]$guid, 0, [System.IntPtr]::Zero, $target)
|
|
||||||
}
|
|
||||||
'');
|
|
||||||
|
|
||||||
# Active Setup fires StubPath once per user at first logon -- including the
|
|
||||||
# fresh profile each generalized rebuild creates -- which is exactly when the
|
|
||||||
# redirection needs re-applying. Backslashes doubled for .reg.
|
|
||||||
activeSetupRegistry = lib.optionalString (folderRedirect != null) ''
|
|
||||||
Windows Registry Editor Version 5.00
|
|
||||||
|
|
||||||
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Active Setup\Installed Components\{6f3b8c2a-1d4e-4f9a-b8c1-0a1b2c3d4e5f}]
|
|
||||||
@="vmix folder redirection"
|
|
||||||
"StubPath"="powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\\vmix-redirect-folders.ps1"
|
|
||||||
"Version"="1"
|
|
||||||
'';
|
|
||||||
|
|
||||||
healProfileScript = pkgs.writeText "vmix-heal-profile.ps1" (lib.optionalString (profilesDirectory != null) ''
|
|
||||||
$pl = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
|
|
||||||
$bak = Get-ChildItem $pl -ErrorAction SilentlyContinue | Where-Object {
|
|
||||||
$_.PSChildName -like '*.bak' -and
|
|
||||||
(Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).ProfileImagePath -eq '${profilesDirectory}\${username}'
|
|
||||||
} | Select-Object -First 1
|
|
||||||
if ($bak) {
|
|
||||||
$sid = $bak.PSChildName -replace '\.bak$'
|
|
||||||
Remove-Item (Join-Path $pl $sid) -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
Rename-Item $bak.PSPath $sid -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item '${profilesDirectory}\TEMP' -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
New-Item -Path C:\Windows\Temp\vmix-profile-healed -ItemType File -Force | Out-Null
|
|
||||||
}
|
|
||||||
'');
|
|
||||||
|
|
||||||
# One onstart / SYSTEM script for whatever the data disk needs before logon:
|
|
||||||
# assign its letter, and then either heal a relocated profile that went
|
|
||||||
# temporary (profilesDirectory) or make the redirected data folders reachable
|
|
||||||
# by whatever account this rebuild created (folderRedirect). Both cannot apply
|
|
||||||
# at once -- a profile is either wholly on D: or only its data folders are.
|
|
||||||
bootDataProfileScript = pkgs.writeText "vmix-data-profile.cmd" ''
|
|
||||||
@echo off
|
|
||||||
${lib.optionalString (dataDisk != null) "call C:\\vmix-init-data-disk.cmd"}
|
|
||||||
${lib.optionalString (profilesDirectory != null) ''
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-heal-profile.ps1 > C:\Windows\Temp\vmix-data-profile.log 2>&1
|
|
||||||
if exist C:\Windows\Temp\vmix-profile-healed (
|
|
||||||
del /q C:\Windows\Temp\vmix-profile-healed
|
|
||||||
shutdown /r /t 5 /c "vmix: repaired relocated profile, restarting"
|
|
||||||
)
|
|
||||||
''}
|
|
||||||
${lib.optionalString (folderRedirect != null) ''
|
|
||||||
:: The redirected folders live under a per-user account whose SID changes on
|
|
||||||
:: every generalized rebuild, so grant the well-known Users group -- which
|
|
||||||
:: any account joins and which is SID-stable across machines -- inheritable
|
|
||||||
:: full control, and let the per-user redirect (Active Setup) point the known
|
|
||||||
:: folders here. Runs as SYSTEM, before any logon.
|
|
||||||
if not exist "${redirectBase}" mkdir "${redirectBase}"
|
|
||||||
${lib.concatMapStringsSep "\n " (f: ''if not exist "${redirectBase}\${f}" mkdir "${redirectBase}\${f}"'') redirectFolders}
|
|
||||||
icacls "${redirectBase}" /grant *S-1-5-32-545:(OI)(CI)F /t > C:\Windows\Temp\vmix-redirect-prep.log 2>&1
|
|
||||||
''}
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
folderLocationsXml = lib.optionalString (profilesDirectory != null) ''
|
folderLocationsXml = lib.optionalString (profilesDirectory != null) ''
|
||||||
|
|
@ -471,17 +150,6 @@ in
|
||||||
<ProfilesDirectory>${profilesDirectory}</ProfilesDirectory>
|
<ProfilesDirectory>${profilesDirectory}</ProfilesDirectory>
|
||||||
</FolderLocations>'';
|
</FolderLocations>'';
|
||||||
|
|
||||||
# ProfilesDirectory as an offline .reg merge, for the keepMachineSid path
|
|
||||||
# where the specialize pass (and its FolderLocations) does not run. virt-win-reg
|
|
||||||
# applies this before the Audit Mode boot, so it is in place when OOBE creates
|
|
||||||
# the account. Backslashes are doubled for .reg syntax.
|
|
||||||
profileListRegistry = lib.optionalString (keepMachineSid && profilesDirectory != null) ''
|
|
||||||
Windows Registry Editor Version 5.00
|
|
||||||
|
|
||||||
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList]
|
|
||||||
"ProfilesDirectory"="${builtins.replaceStrings [''\''] [''\\''] profilesDirectory}"
|
|
||||||
'';
|
|
||||||
|
|
||||||
dataDiskXml = lib.optionalString (dataDisk != null) ''
|
dataDiskXml = lib.optionalString (dataDisk != null) ''
|
||||||
<!-- Runs during specialize, before the first profile is created -->
|
<!-- Runs during specialize, before the first profile is created -->
|
||||||
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64"
|
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64"
|
||||||
|
|
@ -498,13 +166,6 @@ in
|
||||||
# Post-OOBE script: runs as the created user via FirstLogonCommands.
|
# Post-OOBE script: runs as the created user via FirstLogonCommands.
|
||||||
postOobeScript = pkgs.writeText "post-oobe.cmd" ''
|
postOobeScript = pkgs.writeText "post-oobe.cmd" ''
|
||||||
@echo off
|
@echo off
|
||||||
${lib.optionalString configMedium ''
|
|
||||||
:: Stage the per-VM config off the removable CD, then apply the parts that
|
|
||||||
:: are not answer-file fields (timezone, desktop tint, machine rename). The
|
|
||||||
:: static address is left to the per-boot task registered below.
|
|
||||||
call C:\vmix-load-config.cmd
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-apply-config.ps1 > C:\Windows\Temp\vmix-apply-config.log 2>&1
|
|
||||||
''}
|
|
||||||
${lib.optionalString (!autoLogon) ''
|
${lib.optionalString (!autoLogon) ''
|
||||||
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoAdminLogon /f 2>nul
|
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoAdminLogon /f 2>nul
|
||||||
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultUserName /f 2>nul
|
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultUserName /f 2>nul
|
||||||
|
|
@ -541,7 +202,6 @@ in
|
||||||
powershell -Command "Get-AppxPackage *MicrosoftEdgeDevToolsClient* | Remove-AppxPackage -ErrorAction SilentlyContinue"
|
powershell -Command "Get-AppxPackage *MicrosoftEdgeDevToolsClient* | Remove-AppxPackage -ErrorAction SilentlyContinue"
|
||||||
|
|
||||||
|
|
||||||
${lib.optionalString (!configMedium) ''
|
|
||||||
:: Re-install product key and licenses to restore activation IDs after sysprep
|
:: Re-install product key and licenses to restore activation IDs after sysprep
|
||||||
cscript //nologo C:\Windows\System32\slmgr.vbs /ipk M7XTQ-FN8P6-TTKYV-9D4CC-J462D
|
cscript //nologo C:\Windows\System32\slmgr.vbs /ipk M7XTQ-FN8P6-TTKYV-9D4CC-J462D
|
||||||
cscript //nologo C:\Windows\System32\slmgr.vbs /rilc
|
cscript //nologo C:\Windows\System32\slmgr.vbs /rilc
|
||||||
|
|
@ -560,9 +220,6 @@ in
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
del /q C:\MAS_AIO.cmd 2>nul
|
del /q C:\MAS_AIO.cmd 2>nul
|
||||||
''}
|
|
||||||
:: configMedium: activation is deferred to the boot-2 finalize, so it runs
|
|
||||||
:: on the real account's fresh SID. MAS_AIO.cmd is kept for it until then.
|
|
||||||
|
|
||||||
${lib.optionalString enableRDP ''
|
${lib.optionalString enableRDP ''
|
||||||
:: Enable RDP
|
:: Enable RDP
|
||||||
|
|
@ -576,19 +233,9 @@ in
|
||||||
reg add "HKLM\SYSTEM\CurrentControlSet\Services\SessionEnv" /v Start /t REG_DWORD /d 2 /f
|
reg add "HKLM\SYSTEM\CurrentControlSet\Services\SessionEnv" /v Start /t REG_DWORD /d 2 /f
|
||||||
reg add "HKLM\SYSTEM\CurrentControlSet\Services\UmRdpService" /v Start /t REG_DWORD /d 2 /f
|
reg add "HKLM\SYSTEM\CurrentControlSet\Services\UmRdpService" /v Start /t REG_DWORD /d 2 /f
|
||||||
reg add "HKLM\SYSTEM\CurrentControlSet\Services\TermService" /v Start /t REG_DWORD /d 2 /f
|
reg add "HKLM\SYSTEM\CurrentControlSet\Services\TermService" /v Start /t REG_DWORD /d 2 /f
|
||||||
:: A VM reached over RDP must never suspend itself off the network. The
|
|
||||||
:: default Balanced plan sleeps after 15 min idle; switch to High
|
|
||||||
:: Performance and zero every idle timeout, and turn hibernate off.
|
|
||||||
powercfg /setactive SCHEME_MIN
|
|
||||||
powercfg /change standby-timeout-ac 0
|
|
||||||
powercfg /change standby-timeout-dc 0
|
|
||||||
powercfg /change hibernate-timeout-ac 0
|
|
||||||
powercfg /change hibernate-timeout-dc 0
|
|
||||||
powercfg /change monitor-timeout-ac 0
|
|
||||||
powercfg /hibernate off
|
|
||||||
''}
|
''}
|
||||||
|
|
||||||
${lib.optionalString (staticIP != null && !configMedium) ''
|
${lib.optionalString (staticIP != null) ''
|
||||||
:: This VM's only NIC sits on a macvtap bridged to the host's LAN, so its
|
:: This VM's only NIC sits on a macvtap bridged to the host's LAN, so its
|
||||||
:: address is a LAN address that nothing hands out -- the guest asserts it.
|
:: address is a LAN address that nothing hands out -- the guest asserts it.
|
||||||
:: Registered to run at every boot rather than applied here. OOBE runs in
|
:: Registered to run at every boot rather than applied here. OOBE runs in
|
||||||
|
|
@ -600,23 +247,6 @@ in
|
||||||
schtasks /create /tn "vmix-static-ip" /tr "C:\vmix-static-ip.cmd" /sc onstart /ru SYSTEM /rl HIGHEST /f > nul 2>&1
|
schtasks /create /tn "vmix-static-ip" /tr "C:\vmix-static-ip.cmd" /sc onstart /ru SYSTEM /rl HIGHEST /f > nul 2>&1
|
||||||
''}
|
''}
|
||||||
|
|
||||||
${lib.optionalString configMedium ''
|
|
||||||
:: configMedium (sealed images) run OOBE on the real target NIC, so the
|
|
||||||
:: address is set here once and left in the persistent store -- it survives
|
|
||||||
:: reboots on its own, no per-boot task and no script left on disk. Runs on
|
|
||||||
:: this bootstrap boot so the real account is already reachable on boot 2.
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-static-ip.ps1 > C:\Windows\Temp\vmix-static-ip.log 2>&1
|
|
||||||
''}
|
|
||||||
|
|
||||||
${lib.optionalString (dataDisk != null && !configMedium) ''
|
|
||||||
:: Ensures D: is assigned on every boot -- the image ships without a
|
|
||||||
:: persisted letter for the data disk -- and heals a profile that went
|
|
||||||
:: temporary before D: was ready. Onstart / SYSTEM, like the address task.
|
|
||||||
:: configMedium does not need this: the letter persists via MountedDevices
|
|
||||||
:: in the overlay after the first boot, so there is nothing to re-assert.
|
|
||||||
schtasks /create /tn "vmix-data-profile" /tr "C:\vmix-data-profile.cmd" /sc onstart /ru SYSTEM /rl HIGHEST /f > nul 2>&1
|
|
||||||
''}
|
|
||||||
|
|
||||||
${lib.optionalString (writeFilter != null) ''
|
${lib.optionalString (writeFilter != null) ''
|
||||||
:: Install the feature now, but defer configuring it: uwfmgr does not exist
|
:: Install the feature now, but defer configuring it: uwfmgr does not exist
|
||||||
:: until this has been through a reboot, and the swapfile belongs on the
|
:: until this has been through a reboot, and the swapfile belongs on the
|
||||||
|
|
@ -625,31 +255,12 @@ in
|
||||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" /v "vmixUwf" /t REG_SZ /d "C:\vmix-uwf-config.cmd" /f
|
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" /v "vmixUwf" /t REG_SZ /d "C:\vmix-uwf-config.cmd" /f
|
||||||
''}
|
''}
|
||||||
|
|
||||||
|
|
||||||
${lib.optionalString keepMachineSid ''
|
|
||||||
:: /oobe without /generalize leaves the system set to re-run windeploy (OOBE)
|
|
||||||
:: on every boot -- shutting down here in a FirstLogonCommand cuts OOBE off
|
|
||||||
:: before it resets that state itself. Clear it, or the target boots into a
|
|
||||||
:: Setup with no unattend left to consume and hangs on a black screen.
|
|
||||||
reg add "HKLM\SYSTEM\Setup" /v SetupType /t REG_DWORD /d 0 /f
|
|
||||||
reg delete "HKLM\SYSTEM\Setup" /v OOBEInProgress /f 2>nul
|
|
||||||
reg delete "HKLM\SYSTEM\Setup" /v CmdLine /f 2>nul
|
|
||||||
''}
|
|
||||||
${lib.optionalString configMedium ''
|
|
||||||
:: Runs last, as the generic bootstrap account: create the real per-VM
|
|
||||||
:: account from the config, switch autologon to it and arm the cleanup. The
|
|
||||||
:: reboot below then logs the real account in for the first time, building
|
|
||||||
:: its SID-bound profile on D:\Users\<username>.
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-create-user.ps1 > C:\Windows\Temp\vmix-create-user.log 2>&1
|
|
||||||
''}
|
|
||||||
:: Clean up
|
:: Clean up
|
||||||
del /q C:\oobe-unattend.xml 2>nul
|
del /q C:\oobe-unattend.xml 2>nul
|
||||||
del /q C:\vmix-audit-script.cmd 2>nul
|
del /q C:\vmix-audit-script.cmd 2>nul
|
||||||
del /q C:\vmix-audit-wrapper.cmd 2>nul
|
del /q C:\vmix-audit-wrapper.cmd 2>nul
|
||||||
|
|
||||||
${if configMedium then "shutdown /r /t 5 /c \"vmix: applying per-VM config\""
|
${if delayOobeRun then "" else "shutdown /s /t 5 /c \"vmix generalize complete\""}
|
||||||
else if delayOobeRun then ""
|
|
||||||
else "shutdown /s /t 5 /c \"vmix generalize complete\""}
|
|
||||||
del /q C:\post-oobe.cmd 2>nul
|
del /q C:\post-oobe.cmd 2>nul
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|
@ -735,46 +346,20 @@ ${folderLocationsXml}
|
||||||
</unattend>
|
</unattend>
|
||||||
'';
|
'';
|
||||||
in {
|
in {
|
||||||
name = if configMedium then "seal" else if delayOobeRun then "generalize-delay-oobe" else "generalize";
|
name = if delayOobeRun then "generalize-delay-oobe" else "generalize";
|
||||||
inherit nicModel;
|
inherit nicModel;
|
||||||
# With keepMachineSid the specialize pass never runs (see the sysprep line),
|
|
||||||
# so the profile relocation cannot ride the unattend there. It is written to
|
|
||||||
# the registry offline instead, before the build's OOBE creates the profile,
|
|
||||||
# so the account still lands on the data volume. Empty otherwise.
|
|
||||||
windowsRegistry = profileListRegistry + activeSetupRegistry;
|
|
||||||
# The blank disk is attached for the Audit Mode boot itself, so the disk-init
|
# The blank disk is attached for the Audit Mode boot itself, so the disk-init
|
||||||
# command and the profile relocation both happen under OOBE in the build VM.
|
# command and the profile relocation both happen under OOBE in the build VM.
|
||||||
# That is what makes delayOobeRun unnecessary: nothing is left to do on real
|
# That is what makes delayOobeRun unnecessary: nothing is left to do on real
|
||||||
# hardware. The written disk comes back as this derivation's `data` output.
|
# hardware. The written disk comes back as this derivation's `data` output.
|
||||||
#
|
extraDisk = if dataDisk != null then { size = dataDisk.size or "100G"; } else null;
|
||||||
# Under delayOobeRun there is no build-VM OOBE to relocate into, and the real
|
|
||||||
# data volume is the host's zvol attached at deploy time -- so building an
|
|
||||||
# empty throwaway disk here would be pure waste. Gated off: the target's
|
|
||||||
# specialize formats the real disk (dataDiskXml) and OOBE creates the profile
|
|
||||||
# on it. This is what lets a sealed image ship without a `data` output.
|
|
||||||
extraDisk = if (dataDisk != null && !delayOobeRun) then { size = dataDisk.size or "100G"; } else null;
|
|
||||||
uploads = [
|
uploads = [
|
||||||
{ source = oobeXml; dest = "/oobe-unattend.xml"; }
|
{ source = oobeXml; dest = "/oobe-unattend.xml"; }
|
||||||
{ source = postOobeScript; dest = "/post-oobe.cmd"; }
|
{ source = postOobeScript; dest = "/post-oobe.cmd"; }
|
||||||
{ source = masScript; dest = "/MAS_AIO.cmd"; }
|
{ source = masScript; dest = "/MAS_AIO.cmd"; }
|
||||||
] ++ lib.optionals (dataDisk != null) (
|
] ++ lib.optional (dataDisk != null) { source = initDataDiskScript; dest = "/vmix-init-data-disk.cmd"; }
|
||||||
[ { source = initDataDiskScript; dest = "/vmix-init-data-disk.cmd"; }
|
|
||||||
{ source = bootDataProfileScript; dest = "/vmix-data-profile.cmd"; }
|
|
||||||
]
|
|
||||||
++ lib.optional (profilesDirectory != null) { source = healProfileScript; dest = "/vmix-heal-profile.ps1"; }
|
|
||||||
++ lib.optional (folderRedirect != null) { source = folderRedirectPs1; dest = "/vmix-redirect-folders.ps1"; }
|
|
||||||
)
|
|
||||||
++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; }
|
++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; }
|
||||||
++ lib.optionals configMedium [
|
++ lib.optional (staticIP != null) { source = staticIPScript; dest = "/vmix-static-ip.cmd"; };
|
||||||
{ source = loadConfigScript; dest = "/vmix-load-config.cmd"; }
|
|
||||||
{ source = applyConfigScript; dest = "/vmix-apply-config.ps1"; }
|
|
||||||
{ source = createUserScript; dest = "/vmix-create-user.ps1"; }
|
|
||||||
{ source = finalizeScript; dest = "/vmix-finalize.cmd"; }
|
|
||||||
]
|
|
||||||
++ lib.optionals (staticIP != null || configMedium) [
|
|
||||||
{ source = staticIPScript; dest = "/vmix-static-ip.cmd"; }
|
|
||||||
{ source = staticIPScriptPs1; dest = "/vmix-static-ip.ps1"; }
|
|
||||||
];
|
|
||||||
# delayOobeRun: sysprep + shutdown — OOBE runs on real hardware
|
# delayOobeRun: sysprep + shutdown — OOBE runs on real hardware
|
||||||
# generalize: sysprep + reboot into OOBE in the same QEMU session
|
# generalize: sysprep + reboot into OOBE in the same QEMU session
|
||||||
auditScript = ''
|
auditScript = ''
|
||||||
|
|
@ -783,7 +368,7 @@ in {
|
||||||
del /q C:\Windows\Panther\unattend.xml 2>nul
|
del /q C:\Windows\Panther\unattend.xml 2>nul
|
||||||
del /q C:\Windows\Panther\Unattend\unattend.xml 2>nul
|
del /q C:\Windows\Panther\Unattend\unattend.xml 2>nul
|
||||||
del /q C:\Windows\System32\Sysprep\Panther\unattend.xml 2>nul
|
del /q C:\Windows\System32\Sysprep\Panther\unattend.xml 2>nul
|
||||||
${lib.optionalString (dataDisk != null && !delayOobeRun) ''
|
${lib.optionalString (dataDisk != null) ''
|
||||||
:: Lay the data disk out here, in Audit Mode, rather than leaving it to the
|
:: Lay the data disk out here, in Audit Mode, rather than leaving it to the
|
||||||
:: specialize pass alone. Component order within a pass is not guaranteed,
|
:: specialize pass alone. Component order within a pass is not guaranteed,
|
||||||
:: and FolderLocations is applied by Shell-Setup while the disk is prepared
|
:: and FolderLocations is applied by Shell-Setup while the disk is prepared
|
||||||
|
|
@ -792,13 +377,9 @@ in {
|
||||||
:: fully booted OS with the disk already attached, so this always works.
|
:: fully booted OS with the disk already attached, so this always works.
|
||||||
:: The specialize copy stays as a letter re-assertion after generalize
|
:: The specialize copy stays as a letter re-assertion after generalize
|
||||||
:: clears MountedDevices.
|
:: clears MountedDevices.
|
||||||
::
|
|
||||||
:: Only when there is a build disk to lay out. Under delayOobeRun (sealed
|
|
||||||
:: images) the disk is the host's zvol, present only on the target, so this
|
|
||||||
:: is left to the target's specialize pass alone.
|
|
||||||
call C:\vmix-init-data-disk.cmd
|
call C:\vmix-init-data-disk.cmd
|
||||||
''}
|
''}
|
||||||
C:\Windows\System32\Sysprep\sysprep.exe ${lib.optionalString (!keepMachineSid) "/generalize "}/oobe ${if delayOobeRun then "/shutdown" else "/reboot"} /quiet /unattend:C:\oobe-unattend.xml
|
C:\Windows\System32\Sysprep\sysprep.exe /generalize /oobe ${if delayOobeRun then "/shutdown" else "/reboot"} /quiet /unattend:C:\oobe-unattend.xml
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,13 +77,23 @@ let
|
||||||
|
|
||||||
hasOsDisk = vmCfg.disks.os.file != null;
|
hasOsDisk = vmCfg.disks.os.file != null;
|
||||||
|
|
||||||
# Auto-detect Windows from _vmixOsType marker on the disk image
|
# Auto-detect Windows / macOS from _vmixOsType marker on the disk image
|
||||||
isWindows = vmCfg.windows.enable || (hasOsDisk && (vmCfg.disks.os.file._vmixOsType or "linux") == "windows");
|
osType = if hasOsDisk then vmCfg.disks.os.file._vmixOsType or "linux" else "linux";
|
||||||
|
isWindows = vmCfg.windows.enable || osType == "windows";
|
||||||
|
isMacos = vmCfg.macos.enable || osType == "macos";
|
||||||
|
macosMac = if vmCfg.macos.mac != null then vmCfg.macos.mac
|
||||||
|
else if hasOsDisk then vmCfg.disks.os.file.macAddress or "52:54:00:c9:18:27"
|
||||||
|
else "52:54:00:c9:18:27";
|
||||||
|
# OpenCore marks this PCI slot built-in (en0)
|
||||||
|
macosNicPlacement = ",bus=pcie.0,addr=${vmixLib.macos.qemu.nicAddr}";
|
||||||
|
cpuArg = if isMacos && vmCfg.cpu.model == "host" then vmCfg.macos.cpu
|
||||||
|
else "${vmCfg.cpu.model}${optionalString (vmCfg.cpu.hideVirtualized && !isMacos) ",kvm=off,hv_vendor_id=1234567890ab,-hypervisor"}";
|
||||||
# Interrupt remapping in the virtual IOMMU only works on a split irqchip,
|
# Interrupt remapping in the virtual IOMMU only works on a split irqchip,
|
||||||
# so viommu wins over the full in-kernel irqchip hideVirtualized asks for.
|
# so viommu wins over the full in-kernel irqchip hideVirtualized asks for.
|
||||||
|
# (macOS keeps the default irqchip: hideVirtualized does not apply to it.)
|
||||||
machineIrqchipArg =
|
machineIrqchipArg =
|
||||||
if vmCfg.pci.viommu.enable then ",kernel-irqchip=split"
|
if vmCfg.pci.viommu.enable then ",kernel-irqchip=split"
|
||||||
else optionalString vmCfg.cpu.hideVirtualized ",kernel_irqchip=on";
|
else optionalString (vmCfg.cpu.hideVirtualized && !isMacos) ",kernel_irqchip=on";
|
||||||
# Functions of one physical device have to reach the guest as functions
|
# Functions of one physical device have to reach the guest as functions
|
||||||
# of one device too. Giving each address its own root port splits a GPU
|
# of one device too. Giving each address its own root port splits a GPU
|
||||||
# from its own HDMI audio, and Navi cannot then reset or power-manage
|
# from its own HDMI audio, and Navi cannot then reset or power-manage
|
||||||
|
|
@ -97,6 +107,42 @@ let
|
||||||
(unique (map pciDeviceOf vmCfg.pci.passthrough));
|
(unique (map pciDeviceOf vmCfg.pci.passthrough));
|
||||||
|
|
||||||
|
|
||||||
|
# --- macOS: guest agent, virtio-fs shares, persistent home volume
|
||||||
|
macosQemu = vmixLib.macos.qemu;
|
||||||
|
qgaSock = "/run/vmix/qga-${vmCfg.name}.sock";
|
||||||
|
macosGuestAgent = isMacos && vmCfg.macos.guestAgent.enable;
|
||||||
|
macosShares = if isMacos then vmCfg.shares else {};
|
||||||
|
macosShareNames = attrNames macosShares;
|
||||||
|
macosAutomountShare = if macosShares ? automount then "automount"
|
||||||
|
else if macosShareNames != [] then head macosShareNames else null;
|
||||||
|
macosShareTag = n: if n == macosAutomountShare then macosQemu.automountTag else n;
|
||||||
|
macosShareSock = n: "/run/vmix/vfs-${vmCfg.name}-${n}.sock";
|
||||||
|
macosHome = isMacos && vmCfg.macos.homeDisk.enable;
|
||||||
|
macosFormatHome = if macosHome then vmixLib.macos.formatVolume {
|
||||||
|
image = vmCfg.disks.os.file; label = vmCfg.macos.homeDisk.label;
|
||||||
|
} else null;
|
||||||
|
# shares beyond the automounted one are mounted through the guest agent once it answers
|
||||||
|
macosMountSharesScript = pkgs.writeShellScript "${vmCfg.name}-macos-shares-vmix" ''
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
[ -S ${qgaSock} ] && printf '{"execute":"guest-ping"}\n' | ${pkgs.socat}/bin/socat -T5 - UNIX-CONNECT:${qgaSock} 2>/dev/null | grep -q return && break
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
${concatMapStrings (n: optionalString (n != macosAutomountShare) ''
|
||||||
|
printf '%s\n' '{"execute":"guest-exec","arguments":{"path":"/bin/bash","arg":["-c","mkdir -p ${macosShares.${n}.target}; mount -t virtiofs ${n} ${macosShares.${n}.target}"]}}' \
|
||||||
|
| ${pkgs.socat}/bin/socat -T10 - UNIX-CONNECT:${qgaSock} >/dev/null 2>&1 || true
|
||||||
|
'') macosShareNames}
|
||||||
|
'';
|
||||||
|
seedHomeDiskScript = pkgs.writeShellScript "${vmCfg.name}-home-disk-vmix" ''
|
||||||
|
F="${vmCfg.macos.homeDisk.file}"
|
||||||
|
if [ ! -e "$F" ]; then
|
||||||
|
echo "Creating persistent home volume $F (${vmCfg.macos.homeDisk.size}, ${vmCfg.macos.homeDisk.format})..."
|
||||||
|
mkdir -p "$(dirname "$F")"
|
||||||
|
${pkgs.qemu}/bin/qemu-img create -q -f ${vmCfg.macos.homeDisk.format} "$F" ${vmCfg.macos.homeDisk.size}
|
||||||
|
chmod 600 "$F"
|
||||||
|
${macosFormatHome} "$F" ${vmCfg.macos.homeDisk.format}
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
|
||||||
# Linux VMs: apply customizeImage with 9p fstab and machine-id setup
|
# Linux VMs: apply customizeImage with 9p fstab and machine-id setup
|
||||||
linuxOsImage = vmixLib.linux.customizeImage vmCfg.disks.os.file {
|
linuxOsImage = vmixLib.linux.customizeImage vmCfg.disks.os.file {
|
||||||
name = vmCfg.name;
|
name = vmCfg.name;
|
||||||
|
|
@ -108,9 +154,9 @@ let
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
# Windows VMs: use disk image as-is (customization done at image build time)
|
# Windows/macOS VMs: use disk image as-is (customization done at image build time)
|
||||||
storeImage = if !hasOsDisk then null
|
storeImage = if !hasOsDisk then null
|
||||||
else if isWindows then vmCfg.disks.os.file
|
else if isWindows || isMacos then vmCfg.disks.os.file
|
||||||
else linuxOsImage;
|
else linuxOsImage;
|
||||||
|
|
||||||
# When persist = true, QEMU needs a mutable disk outside /nix/store.
|
# When persist = true, QEMU needs a mutable disk outside /nix/store.
|
||||||
|
|
@ -125,33 +171,13 @@ let
|
||||||
if [ ! -f "$PERSIST_PATH" ]; then
|
if [ ! -f "$PERSIST_PATH" ]; then
|
||||||
echo "Seeding persistent disk from store image..."
|
echo "Seeding persistent disk from store image..."
|
||||||
mkdir -p "$(dirname "$PERSIST_PATH")"
|
mkdir -p "$(dirname "$PERSIST_PATH")"
|
||||||
${if vmCfg.disks.os.persistMode == "backing"
|
cp --no-preserve=mode "${toString storeImage}" "$PERSIST_PATH"
|
||||||
then ''qemu-img create -f qcow2 -F qcow2 -b "${toString storeImage}" "$PERSIST_PATH"''
|
|
||||||
else ''cp --no-preserve=mode "${toString storeImage}" "$PERSIST_PATH"''}
|
|
||||||
chmod 600 "$PERSIST_PATH"
|
chmod 600 "$PERSIST_PATH"
|
||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
|
|
||||||
persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript;
|
persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript
|
||||||
|
++ lib.optional macosHome seedHomeDiskScript;
|
||||||
# A GC root pinning the OS overlay's ACTUAL backing store path, so
|
|
||||||
# nix-collect-garbage cannot delete the store image the disk reads through.
|
|
||||||
gcrootLink = "/nix/var/nix/gcroots/vmix-${vmCfg.name}-osbacking";
|
|
||||||
gcrootScript = pkgs.writeShellScript "${vmCfg.name}-gcroot-vmix" ''
|
|
||||||
# Read the live overlay's backing (not the config's current image, which
|
|
||||||
# drifts to a new store path after a rebuild while the overlay keeps
|
|
||||||
# backing the old one). Pinning the top of the chain transitively keeps
|
|
||||||
# the whole chain -- qcow2 backing_file paths are registered nix refs.
|
|
||||||
BACK=""
|
|
||||||
if [ -f "${vmCfg.disks.os.persistPath}" ]; then
|
|
||||||
BACK=$(qemu-img info "${vmCfg.disks.os.persistPath}" 2>/dev/null | awk '/^backing file:/ {print $3; exit}')
|
|
||||||
fi
|
|
||||||
[ -z "$BACK" ] && BACK="${toString storeImage}"
|
|
||||||
if [ -n "$BACK" ]; then
|
|
||||||
mkdir -p /nix/var/nix/gcroots
|
|
||||||
ln -sfn "$BACK" "${gcrootLink}"
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
|
|
||||||
# QEMU expects single-letter boot codes (e.g. c,d,n), while vmix uses readable names.
|
# QEMU expects single-letter boot codes (e.g. c,d,n), while vmix uses readable names.
|
||||||
bootOrderQemu =
|
bootOrderQemu =
|
||||||
|
|
@ -186,6 +212,16 @@ let
|
||||||
);
|
);
|
||||||
|
|
||||||
qemuStartVMScript = pkgs.writeShellScript "${vmCfg.name}-qemu-vmix" ''
|
qemuStartVMScript = pkgs.writeShellScript "${vmCfg.name}-qemu-vmix" ''
|
||||||
|
${optionalString (isMacos && macosShareNames != []) ''
|
||||||
|
mkdir -p /run/vmix
|
||||||
|
${concatMapStrings (n: ''
|
||||||
|
rm -f ${macosShareSock n}
|
||||||
|
${pkgs.virtiofsd}/bin/virtiofsd --socket-path=${macosShareSock n} --shared-dir ${toString macosShares.${n}.source} --cache auto --sandbox none &
|
||||||
|
'') macosShareNames}
|
||||||
|
for i in $(seq 1 50); do ${concatMapStringsSep " && " (n: "[ -S ${macosShareSock n} ]") macosShareNames} && break; sleep 0.2; done
|
||||||
|
${optionalString macosGuestAgent "${macosMountSharesScript} &"}
|
||||||
|
''}
|
||||||
|
${optionalString macosGuestAgent "mkdir -p /run/vmix; rm -f ${qgaSock}"}
|
||||||
${optionalString vmCfg.vnc.enable ''
|
${optionalString vmCfg.vnc.enable ''
|
||||||
${optionalString (vmCfg.vnc.passwordFile != null) ''
|
${optionalString (vmCfg.vnc.passwordFile != null) ''
|
||||||
if [ ! -r ${escapeShellArg vmCfg.vnc.passwordFile} ]; then
|
if [ ! -r ${escapeShellArg vmCfg.vnc.passwordFile} ]; then
|
||||||
|
|
@ -219,7 +255,7 @@ let
|
||||||
${optionalString vmCfg.vnc.enable "-vnc ${vncArgs}"} \
|
${optionalString vmCfg.vnc.enable "-vnc ${vncArgs}"} \
|
||||||
${optionalString (vmCfg.spice.enable && vmCfg.spice.passwordFile != null) "-object secret,id=spice-pass-${vmCfg.name},file=${escapeShellArg vmCfg.spice.passwordFile}"} \
|
${optionalString (vmCfg.spice.enable && vmCfg.spice.passwordFile != null) "-object secret,id=spice-pass-${vmCfg.name},file=${escapeShellArg vmCfg.spice.passwordFile}"} \
|
||||||
${optionalString vmCfg.spice.enable "-spice addr=${vmCfg.spice.addr},port=${toString vmCfg.spice.port}${optionalString (vmCfg.spice.passwordFile == null) ",disable-ticketing=on"}${optionalString (vmCfg.spice.passwordFile != null) ",password-secret=spice-pass-${vmCfg.name}"}"} \
|
${optionalString vmCfg.spice.enable "-spice addr=${vmCfg.spice.addr},port=${toString vmCfg.spice.port}${optionalString (vmCfg.spice.passwordFile == null) ",disable-ticketing=on"}${optionalString (vmCfg.spice.passwordFile != null) ",password-secret=spice-pass-${vmCfg.name}"}"} \
|
||||||
${optionalString vmCfg.spice.enable (if vmCfg.spice.displayDevice == "qxl" && vmCfg.spice.vgamem != null then "-vga none -device qxl-vga,vgamem_mb=${toString vmCfg.spice.vgamem}" else "-vga ${vmCfg.spice.displayDevice}")} \
|
${optionalString (vmCfg.spice.enable && !isMacos) (if vmCfg.spice.displayDevice == "qxl" && vmCfg.spice.vgamem != null then "-vga none -device qxl-vga,vgamem_mb=${toString vmCfg.spice.vgamem}" else "-vga ${vmCfg.spice.displayDevice}")} \
|
||||||
${optionalString (vmCfg.spice.enable && vmCfg.spice.agent.enable) "-device virtio-serial-pci -chardev spicevmc,id=vdagent,debug=0,name=vdagent -device virtserialport,chardev=vdagent,name=com.redhat.spice.0"} \
|
${optionalString (vmCfg.spice.enable && vmCfg.spice.agent.enable) "-device virtio-serial-pci -chardev spicevmc,id=vdagent,debug=0,name=vdagent -device virtserialport,chardev=vdagent,name=com.redhat.spice.0"} \
|
||||||
${# Guest agent channel — prevents qemu-ga from spinning when virtio-win guest tools are installed
|
${# Guest agent channel — prevents qemu-ga from spinning when virtio-win guest tools are installed
|
||||||
optionalString isWindows "${optionalString (!vmCfg.spice.enable || !vmCfg.spice.agent.enable) "-device virtio-serial-pci"} -chardev socket,path=/tmp/qga-${vmCfg.name}.sock,server=on,wait=off,id=qga0 -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0"} \
|
optionalString isWindows "${optionalString (!vmCfg.spice.enable || !vmCfg.spice.agent.enable) "-device virtio-serial-pci"} -chardev socket,path=/tmp/qga-${vmCfg.name}.sock,server=on,wait=off,id=qga0 -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0"} \
|
||||||
|
|
@ -229,36 +265,43 @@ let
|
||||||
-m ${toString vmCfg.mem.size} \
|
-m ${toString vmCfg.mem.size} \
|
||||||
${optionalString vmCfg.mem.balloon "-device virtio-balloon-pci"} \
|
${optionalString vmCfg.mem.balloon "-device virtio-balloon-pci"} \
|
||||||
-smp cores=${toString vmCfg.cpu.cores} \
|
-smp cores=${toString vmCfg.cpu.cores} \
|
||||||
-cpu ${vmCfg.cpu.model}${optionalString vmCfg.cpu.hideVirtualized ",kvm=off,hv_vendor_id=1234567890ab,-hypervisor"} \
|
-cpu ${cpuArg} \
|
||||||
-machine type=${vmCfg.pc.type}${machineIrqchipArg} \
|
-machine type=${vmCfg.pc.type}${machineIrqchipArg} \
|
||||||
${optionalString vmCfg.bios.efi "-bios ${pkgs.OVMF.fd}/FV/OVMF.fd"} \
|
${optionalString vmCfg.bios.efi "-bios ${pkgs.OVMF.fd}/FV/OVMF.fd"} \
|
||||||
${optionalString vmCfg.bios.tpm "-chardev socket,id=chrtpm,path=/tmp/mytpm-sock -tpmdev emulator,id=tpm0,chardev=chrtpm -device tpm-tis,tpmdev=tpm0"} \
|
${optionalString vmCfg.bios.tpm "-chardev socket,id=chrtpm,path=/tmp/mytpm-sock -tpmdev emulator,id=tpm0,chardev=chrtpm -device tpm-tis,tpmdev=tpm0"} \
|
||||||
${# Windows: localtime RTC, USB tablet for mouse, disable S3/S4 sleep.
|
${# Windows: localtime RTC, USB tablet for mouse, disable S3/S4 sleep
|
||||||
# The q35 power/LPC bridge is ICH9-LPC -- ICH9-LMB was a typo qemu
|
|
||||||
# rejects ("invalid class name"), so the sleep states stayed offered
|
|
||||||
# and an idle guest could suspend itself right off the network.
|
|
||||||
optionalString isWindows ''
|
optionalString isWindows ''
|
||||||
-rtc base=localtime,clock=host \
|
-rtc base=localtime,clock=host \
|
||||||
-device qemu-xhci -device usb-tablet \
|
-device qemu-xhci -device usb-tablet \
|
||||||
-global ICH9-LPC.disable_s3=1 -global ICH9-LPC.disable_s4=1 \
|
-global ICH9-LMB.disable_s3=1 -global ICH9-LMB.disable_s4=1 \
|
||||||
''} \
|
''} \
|
||||||
${optionalString hasOsDisk "-drive file=${osDiskPath},format=qcow2,if=virtio${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"}"} \
|
${# macOS: VirtualSMC, USB keyboard/tablet, AHCI system disk, VMware SVGA (also under SPICE),
|
||||||
|
# Apple's guest agent, virtio-fs shares (shared memory backend), virtio-blk home volume
|
||||||
|
optionalString isMacos ''
|
||||||
|
${macosQemu.deviceArgs} ${if vmCfg.spice.enable && vmCfg.spice.displayDevice == "std" then "-vga std" else macosQemu.vgaArgs} \
|
||||||
|
${optionalString macosGuestAgent (macosQemu.guestAgentArgs qgaSock)} \
|
||||||
|
${optionalString (macosShareNames != []) (macosQemu.memBackendArgs vmCfg.mem.size)} \
|
||||||
|
${concatMapStrings (n: "${macosQemu.virtioFsArgs { tag = macosShareTag n; sock = macosShareSock n; id = n; }} \\\n ") macosShareNames} \
|
||||||
|
${optionalString macosHome (macosQemu.virtioBlkArgs { id = "home"; file = vmCfg.macos.homeDisk.file; format = vmCfg.macos.homeDisk.format; })} \
|
||||||
|
''} \
|
||||||
|
${optionalString hasOsDisk (if isMacos
|
||||||
|
then "-drive id=os,if=none,file=${osDiskPath},format=qcow2${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"} -device ide-hd,bus=sata.0,drive=os"
|
||||||
|
else "-drive file=${osDiskPath},format=qcow2,if=virtio${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"}")} \
|
||||||
${optionalString (vmCfg.disks.iso.file != null) "-drive file=${toString vmCfg.disks.iso.file},media=cdrom,readonly=on"} \
|
${optionalString (vmCfg.disks.iso.file != null) "-drive file=${toString vmCfg.disks.iso.file},media=cdrom,readonly=on"} \
|
||||||
${optionalString (vmCfg.disks.config.file != null) "-drive file=${toString vmCfg.disks.config.file},media=cdrom,readonly=on"} \
|
|
||||||
${concatMapStrings (diskCfg: ''
|
${concatMapStrings (diskCfg: ''
|
||||||
-drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \
|
-drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \
|
||||||
'') (attrValues vmCfg.disks.add)} \
|
'') (attrValues vmCfg.disks.add)} \
|
||||||
${concatStrings (mapAttrsToList (shareName: shareCfg: ''
|
${optionalString (!isMacos) (concatStrings (mapAttrsToList (shareName: shareCfg: ''
|
||||||
-virtfs local,path=${toString shareCfg.source},security_model=passthrough,mount_tag=${shareName} \
|
-virtfs local,path=${toString shareCfg.source},security_model=passthrough,mount_tag=${shareName} \
|
||||||
'') vmCfg.shares)} \
|
'') vmCfg.shares))} \
|
||||||
${optionalString cfg.networks.user.enable "
|
${optionalString cfg.networks.user.enable "
|
||||||
-netdev user,id=user \
|
-netdev user,id=user \
|
||||||
-device ${vmCfg.nicModel},netdev=user \
|
-device ${vmCfg.nicModel},netdev=user${optionalString isMacos ",mac=${macosMac}${macosNicPlacement}"} \
|
||||||
"} \
|
"} \
|
||||||
${concatMapStrings (tapCfg: ''
|
${concatStrings (imap1 (i: tapCfg: ''
|
||||||
-device ${vmCfg.nicModel},netdev=lan-${tapCfg.name},mac=${tapCfg.mac} \
|
-device ${vmCfg.nicModel},netdev=lan-${tapCfg.name},mac=${tapCfg.mac}${optionalString (isMacos && i == 1 && !cfg.networks.user.enable) macosNicPlacement} \
|
||||||
-netdev tap,id=lan-${tapCfg.name},ifname=${tapCfg.iface},script=no,downscript=no \
|
-netdev tap,id=lan-${tapCfg.name},ifname=${tapCfg.iface},script=no,downscript=no \
|
||||||
'') allTaps} \
|
'') allTaps)} \
|
||||||
${concatStrings (imap1 (i: macvtap: ''
|
${concatStrings (imap1 (i: macvtap: ''
|
||||||
-device ${vmCfg.nicModel},netdev=macvtap-${macvtap.name},mac=$(ip l show ${macvtap.iface} | awk '/link\/ether/{print $2}') \
|
-device ${vmCfg.nicModel},netdev=macvtap-${macvtap.name},mac=$(ip l show ${macvtap.iface} | awk '/link\/ether/{print $2}') \
|
||||||
-netdev tap,id=macvtap-${macvtap.name},fd=${toString (i+2)} ${toString (i+2)}<>/dev/tap$(ip l show ${macvtap.iface} | awk -F':' '/${macvtap.iface}/{print $1}') \
|
-netdev tap,id=macvtap-${macvtap.name},fd=${toString (i+2)} ${toString (i+2)}<>/dev/tap$(ip l show ${macvtap.iface} | awk -F':' '/${macvtap.iface}/{print $1}') \
|
||||||
|
|
@ -279,8 +322,7 @@ let
|
||||||
"vm.vmix@${vmCfg.name}" = rec {
|
"vm.vmix@${vmCfg.name}" = rec {
|
||||||
bindsTo = [ "net.vmix@${spaceName}.target" ] ++ lib.optional (allMacvtaps != []) "macvtaps.vm.vmix@${vmCfg.name}.service";
|
bindsTo = [ "net.vmix@${spaceName}.target" ] ++ lib.optional (allMacvtaps != []) "macvtaps.vm.vmix@${vmCfg.name}.service";
|
||||||
unitConfig.JoinsNamespaceOf = "ns.net.vmix@${spaceName}.service";
|
unitConfig.JoinsNamespaceOf = "ns.net.vmix@${spaceName}.service";
|
||||||
after = bindsTo ++ lib.optional (hasOsDisk && vmCfg.disks.os.persist) "vm.vmix-gcroot@${vmCfg.name}.service";
|
after = bindsTo;
|
||||||
wants = lib.optional (hasOsDisk && vmCfg.disks.os.persist) "vm.vmix-gcroot@${vmCfg.name}.service";
|
|
||||||
path = with pkgs; [ iproute2 qemu gawk coreutils ];
|
path = with pkgs; [ iproute2 qemu gawk coreutils ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStartPre = persistExecStartPre ++ [ createTapsforLansScript ];
|
ExecStartPre = persistExecStartPre ++ [ createTapsforLansScript ];
|
||||||
|
|
@ -290,6 +332,8 @@ let
|
||||||
ProtectSystem = true;
|
ProtectSystem = true;
|
||||||
ProtectHome = true;
|
ProtectHome = true;
|
||||||
PrivateNetwork = true;
|
PrivateNetwork = true;
|
||||||
|
RuntimeDirectory = "vmix";
|
||||||
|
RuntimeDirectoryPreserve = "yes";
|
||||||
} // lib.optionalAttrs (vmCfg.pci.passthrough != []) {
|
} // lib.optionalAttrs (vmCfg.pci.passthrough != []) {
|
||||||
# VFIO passthrough needs raw device access — relax sandboxing
|
# VFIO passthrough needs raw device access — relax sandboxing
|
||||||
ProtectSystem = lib.mkForce false;
|
ProtectSystem = lib.mkForce false;
|
||||||
|
|
@ -310,18 +354,6 @@ let
|
||||||
ExecStop = deleteMacvTapsScript;
|
ExecStop = deleteMacvTapsScript;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
|
||||||
// lib.optionalAttrs (cfg.enable && hasOsDisk && vmCfg.disks.os.persist) {
|
|
||||||
"vm.vmix-gcroot@${vmCfg.name}" = {
|
|
||||||
before = [ "vm.vmix@${vmCfg.name}.service" ];
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
path = with pkgs; [ qemu coreutils ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
ExecStart = gcrootScript;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
vmServices = concatMapAttrs (spaceName: namespaceCfg: (concatMapAttrs (mkServices4aVMInNamespace spaceName) namespaceCfg.vms)) vmixCfg.namespaces;
|
vmServices = concatMapAttrs (spaceName: namespaceCfg: (concatMapAttrs (mkServices4aVMInNamespace spaceName) namespaceCfg.vms)) vmixCfg.namespaces;
|
||||||
|
|
|
||||||
|
|
@ -93,9 +93,9 @@ with lib;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
displayDevice = mkOption {
|
displayDevice = mkOption {
|
||||||
type = types.enum [ "virtio" "qxl" "std" "none" ];
|
type = types.enum [ "virtio" "qxl" "std" "vmware" "none" ];
|
||||||
default = "qxl";
|
default = "qxl";
|
||||||
description = "QEMU -vga type to use with SPICE (qxl, virtio, std, none).";
|
description = "QEMU -vga type to use with SPICE (qxl, virtio, std, vmware, none). macOS has no QXL/virtio-gpu driver: it always uses vmware (or std).";
|
||||||
};
|
};
|
||||||
vgamem = mkOption {
|
vgamem = mkOption {
|
||||||
type = types.nullOr types.int;
|
type = types.nullOr types.int;
|
||||||
|
|
@ -173,32 +173,11 @@ with lib;
|
||||||
default = "";
|
default = "";
|
||||||
description = "Mutable path for the persistent OS disk (e.g. /storage/vms/myvm/os.qcow2). Required when persist = true.";
|
description = "Mutable path for the persistent OS disk (e.g. /storage/vms/myvm/os.qcow2). Required when persist = true.";
|
||||||
};
|
};
|
||||||
disks.os.persistMode = mkOption {
|
|
||||||
type = types.enum [ "copy" "backing" ];
|
|
||||||
default = "copy";
|
|
||||||
description = ''
|
|
||||||
How the persistent OS disk is seeded from the store image (persist = true).
|
|
||||||
copy: a full cp of the store image; the mutable disk holds everything.
|
|
||||||
backing: a thin qcow2 overlay backing onto the shared store image, so many
|
|
||||||
VMs share one base and each holds only its own deltas. The store image (and
|
|
||||||
its backing chain) must then survive GC -- vmix pins it via a per-VM gcroot.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
disks.iso.file = mkOption {
|
disks.iso.file = mkOption {
|
||||||
type = types.nullOr (types.either types.path types.str);
|
type = types.nullOr (types.either types.path types.str);
|
||||||
description = "Path to the ISO file. Can be a Nix store path or a string path to a local file.";
|
description = "Path to the ISO file. Can be a Nix store path or a string path to a local file.";
|
||||||
default = null;
|
default = null;
|
||||||
};
|
};
|
||||||
disks.config.file = mkOption {
|
|
||||||
type = types.nullOr (types.either types.path types.str);
|
|
||||||
default = null;
|
|
||||||
description = ''
|
|
||||||
A small read-only config medium attached as a second CD-ROM. For Windows
|
|
||||||
sealed images (templates.seal) this is the per-VM ISO from
|
|
||||||
vmixLib.windows.makeConfigMedium, carrying hostname/static-IP/etc. that
|
|
||||||
the image's baked first-boot scripts consume. Null to attach nothing.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
disks.add = mkOption {
|
disks.add = mkOption {
|
||||||
default = {};
|
default = {};
|
||||||
type = types.attrsOf (types.submodule {
|
type = types.attrsOf (types.submodule {
|
||||||
|
|
@ -233,11 +212,11 @@ with lib;
|
||||||
};
|
};
|
||||||
target = mkOption {
|
target = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
description = "Target path inside the VM for the shared directory.";
|
description = "Target path inside the VM for the shared directory. macOS: the share named `automount` (or the first one) appears at /Volumes/My Shared Files; others are mounted at target through the guest agent.";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
description = "Shared directories.";
|
description = "Shared directories (9p for Linux, virtio-fs via virtiofsd for macOS).";
|
||||||
};
|
};
|
||||||
|
|
||||||
disks.bus = mkOption {
|
disks.bus = mkOption {
|
||||||
|
|
@ -270,6 +249,56 @@ with lib;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
macos = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable macOS QEMU flags (OpenCore/AppleSMC, Skylake CPU spoof, AHCI disk, pinned NIC). Auto-enabled when disks.os.file carries _vmixOsType = \"macos\" metadata.";
|
||||||
|
};
|
||||||
|
cpu = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = vmixLib.macos.qemu.defaultCpu;
|
||||||
|
description = "QEMU -cpu string used for macOS VMs when cpu.model is \"host\".";
|
||||||
|
};
|
||||||
|
mac = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "MAC address of en0. Defaults to the image's macAddress (must match OpenCore's ROM for Apple ID / iMessage).";
|
||||||
|
};
|
||||||
|
guestAgent.enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Attach Apple's built-in QEMU guest agent (virtio console port org.qemu.guest_agent.0). Socket: /run/vmix/qga-<name>.sock; guest-exec runs as root.";
|
||||||
|
};
|
||||||
|
homeDisk = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Persistent home volume: a host disk image attached as virtio-blk, formatted APFS with label `label` by the PE on first start. The image must be generalized with persistHome = true (the user's home is /Volumes/<label>/<user>), which makes the OS disk safely ephemeral (disks.os.persist = false).";
|
||||||
|
};
|
||||||
|
file = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Path of the home disk image, e.g. /storage/vms/mac/home.qcow2 (created if missing).";
|
||||||
|
};
|
||||||
|
format = mkOption {
|
||||||
|
type = types.enum [ "qcow2" "raw" ];
|
||||||
|
default = "qcow2";
|
||||||
|
description = "Image format; use raw for a zvol/block device (created only for files).";
|
||||||
|
};
|
||||||
|
size = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "64G";
|
||||||
|
description = "Size when the image is created.";
|
||||||
|
};
|
||||||
|
label = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "vmix-home";
|
||||||
|
description = "APFS volume label (must match generalize's homeVolumeLabel).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
tpm = {
|
tpm = {
|
||||||
stateDir = mkOption {
|
stateDir = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue