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