From 242e48a5fc6762e3257f9ca12e754691735f38fb Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Tue, 8 Sep 2026 16:03:52 -0300 Subject: [PATCH 01/23] macOS Tahoe VM images (OpenCore/QEMU), Apple-ID compatible, VNC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a macOS image pipeline mirroring the Windows one: unattended install, generalization and user creation, driven end to end in QEMU on a KVM host. lib/images/macos: - makeOpenCore: OSX-KVM OpenCore ESP with a config.plist rewritten per image — SMBIOS model + serial/MLB (macserial) + UUID + ROM=en0 MAC (built-in NIC pinned to PciRoot(0x0)/Pci(0x12,0x0)) for Apple ID / iMessage / App Store; boot disk; OpenCore self-entry hidden. ident.nix derives MAC+UUID from a seed so the NixOS module and CLI know the NIC MAC at eval time. - makeImage: one QEMU session driven by vm-driver.py (QMP + screenshot settle detection + OCR of the menu bar) — boots the recovery via OpenCore, opens Terminal (Ctrl-F2 -> Utilities -> Terminal), types the bootstrap command; vmix-install.sh erases the disk as APFS, lays down the host-extracted installer app skeleton + a byte-exact SharedSupport.dmg (raw disk mapped to that byte range of the pkg, dd'd in — Recovery's xar truncates an 18 GB member), runs startosinstall with the vmix agent pkg. OpenCore is then copied into the image's ESP so it boots standalone with OVMF. - customizeImage / templates: boot the image with a FAT-then-HFS+ VMIX volume; the vmix agent (LaunchDaemon) runs a script as root, records status and powers off — the macOS counterpart of Windows Audit Mode. generalize creates the admin user + auto-login (kcpassword), suppresses Setup Assistant, sets hostname/timezone, grows APFS, and assigns a fresh SMBIOS identity. Templates: noUpdates, performance, remoteAccess (ssh + screen sharing). - fetchRecovery: Apple recovery BaseSystem, retried until the pinned Tahoe build (osrecovery load-balances Sequoia/Tahoe during the rollout). makeAgentPkg builds a distribution flat pkg on Linux (xar+bom+cpio) for startosinstall --installpackage. CLI: vmix build/copy/run for macOS (run --macos --vnc, reads the image's MAC from its ESP), and a `vmix macserial` helper. NixOS module: disks.os.file carrying _vmixOsType="macos" auto-enables the macOS QEMU profile (AppleSMC+OSK, Skylake CPU spoof, AHCI system disk, VMware SVGA, pinned NIC); macos.{enable,cpu,mac}. Status: proven through the installer prepare phase (SharedSupport.dmg mounts, version 26.6.2 read, SU catalog loads). Two blockers remain, documented in lib/images/macos/README.md: (1) startosinstall's OSISVerifyBaseSystemOperation rejects the byte-identical plain-UDIF SharedSupport as "pkgdmg missing a footer" in this Tahoe recovery/VM; (2) Apple's CDN unreliably serves the Tahoe recovery during rollout (self-hosting the verified BaseSystem is the robust fix). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF --- cli.nix | 136 +++++- flake.nix | 2 +- lib/default.nix | 2 +- lib/images/default.nix | 3 +- lib/images/macos/README.md | 134 ++++++ lib/images/macos/default.nix | 44 ++ lib/images/macos/guest/agent.sh | 30 ++ lib/images/macos/guest/ch.vmix.agent.plist | 19 + lib/images/macos/guest/kcpassword.py | 11 + lib/images/macos/guest/vmix-install.sh | 117 +++++ lib/images/macos/helpers/customizeImage.nix | 105 +++++ lib/images/macos/helpers/fetchRecovery.nix | 40 ++ lib/images/macos/helpers/ident.nix | 17 + .../macos/helpers/installBootloader.nix | 17 + lib/images/macos/helpers/installerPayload.nix | 28 ++ lib/images/macos/helpers/macserial.nix | 18 + lib/images/macos/helpers/makeAgentPkg.nix | 75 ++++ lib/images/macos/helpers/makeImage.nix | 132 ++++++ lib/images/macos/helpers/makeOpenCore.nix | 68 +++ lib/images/macos/helpers/makeVmixVolume.nix | 28 ++ lib/images/macos/helpers/oc-config.py | 99 +++++ lib/images/macos/helpers/qemu.nix | 35 ++ lib/images/macos/helpers/vm-driver.py | 415 ++++++++++++++++++ lib/images/macos/helpers/vmix-readback.nix | 14 + lib/images/macos/helpers/xar-toc.py | 25 ++ lib/images/macos/tahoe/default.nix | 8 + lib/images/macos/tahoe/images.nix | 14 + lib/images/macos/templates/default.nix | 15 + .../macos/templates/essentials/no-updates.nix | 15 + .../templates/essentials/performance.nix | 11 + .../templates/essentials/remote-access.nix | 11 + lib/images/macos/templates/generalize.nix | 103 +++++ lib/images/macos/upstream.json | 31 ++ nixos/vms/config.nix | 38 +- nixos/vms/submoduleOptions.nix | 18 + 35 files changed, 1842 insertions(+), 36 deletions(-) create mode 100644 lib/images/macos/README.md create mode 100644 lib/images/macos/default.nix create mode 100644 lib/images/macos/guest/agent.sh create mode 100644 lib/images/macos/guest/ch.vmix.agent.plist create mode 100755 lib/images/macos/guest/kcpassword.py create mode 100644 lib/images/macos/guest/vmix-install.sh create mode 100644 lib/images/macos/helpers/customizeImage.nix create mode 100644 lib/images/macos/helpers/fetchRecovery.nix create mode 100644 lib/images/macos/helpers/ident.nix create mode 100644 lib/images/macos/helpers/installBootloader.nix create mode 100644 lib/images/macos/helpers/installerPayload.nix create mode 100644 lib/images/macos/helpers/macserial.nix create mode 100644 lib/images/macos/helpers/makeAgentPkg.nix create mode 100644 lib/images/macos/helpers/makeImage.nix create mode 100644 lib/images/macos/helpers/makeOpenCore.nix create mode 100644 lib/images/macos/helpers/makeVmixVolume.nix create mode 100644 lib/images/macos/helpers/oc-config.py create mode 100644 lib/images/macos/helpers/qemu.nix create mode 100644 lib/images/macos/helpers/vm-driver.py create mode 100644 lib/images/macos/helpers/vmix-readback.nix create mode 100644 lib/images/macos/helpers/xar-toc.py create mode 100644 lib/images/macos/tahoe/default.nix create mode 100644 lib/images/macos/tahoe/images.nix create mode 100644 lib/images/macos/templates/default.nix create mode 100644 lib/images/macos/templates/essentials/no-updates.nix create mode 100644 lib/images/macos/templates/essentials/performance.nix create mode 100644 lib/images/macos/templates/essentials/remote-access.nix create mode 100644 lib/images/macos/templates/generalize.nix create mode 100644 lib/images/macos/upstream.json diff --git a/cli.nix b/cli.nix index d1a234a..e220dbb 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,30 @@ 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/AppleSMC flags, AHCI)" + echo " --vnc DISPLAY VNC instead of SDL for vmix run, e.g. :10 (port 5910) or 0.0.0.0:10" + echo " --mac ADDR NIC MAC for vmix run (macOS: read from the image's ESP by default)" echo " -y, --yes Skip disk write confirmation" echo " --out-link PATH Symlink for the build result (default: ./result)" echo "" @@ -32,9 +43,9 @@ pkgs.writeShellScriptBin "vmix" '' echo " vmix build --image windows.images.win10.laptop \\" echo " --generalize username=Sagar,password=secret,hostname=LAPTOP" echo "" - echo " vmix copy --image windows.images.win10.laptop \\" - echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\" - echo " --to-disk /dev/sda" + echo " vmix build --image macos.images.tahoe.basic \\" + echo " --generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich" + echo " vmix run ./result --macos --vnc :10 --mem 8192" echo "" echo " vmix copy --image windows.images.win10.laptop \\" echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\" @@ -49,24 +60,49 @@ pkgs.writeShellScriptBin "vmix" '' COMMAND="$1"; shift case "$COMMAND" in - build|copy|run) ;; + build|copy|run|macserial) ;; --help|-h) usage ;; *) echo "Unknown command: $COMMAND"; usage ;; esac + # --- macserial command --- + if [[ "$COMMAND" == "macserial" ]]; then + MODEL="MacPro7,1" + while [[ ''${#} -gt 0 ]]; do + case "$1" in + --model) MODEL="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac + done + LINE=$(${macserial}/bin/macserial --num 1 --model "$MODEL" 2>/dev/null | grep '|' | tail -1) + [[ -z "$LINE" ]] && { echo "Error: macserial produced nothing for model $MODEL"; exit 1; } + SERIAL=$(echo "$LINE" | awk -F' *\\| *' '{print $1}') + MLB=$(echo "$LINE" | awk -F' *\\| *' '{print $2}') + UUID=$(cat /proc/sys/kernel/random/uuid | tr a-f A-F) + MAC=$(printf '52:54:00:%02x:%02x:%02x' $((RANDOM % 256)) $((RANDOM % 256)) $((RANDOM % 256))) + echo "model=$MODEL,serial=$SERIAL,mlb=$MLB,uuid=$UUID,mac=$MAC" + exit 0 + fi + # --- run command --- if [[ "$COMMAND" == "run" ]]; then - [[ ''${#} -lt 1 ]] && { echo "Error: vmix run [--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="" while [[ ''${#} -gt 0 ]]; do case "$1" in - --mem) RUN_MEM="$2"; shift 2 ;; - --smp) RUN_SMP="$2"; shift 2 ;; - --ahci) RUN_AHCI=true; shift ;; - *) echo "Unknown option: $1"; exit 1 ;; + --mem) RUN_MEM="$2"; shift 2 ;; + --smp) RUN_SMP="$2"; shift 2 ;; + --ahci) RUN_AHCI=true; shift ;; + --macos) RUN_MACOS=true; shift ;; + --vnc) RUN_VNC="$2"; shift 2 ;; + --mac) RUN_MAC="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; esac done @@ -74,7 +110,9 @@ pkgs.writeShellScriptBin "vmix" '' [[ ! -f "$RUN_IMAGE" ]] && { echo "Error: file not found: $RUN_IMAGE"; exit 1; } VMIX_DISPLAY="-nographic" - if [[ -n "''${DISPLAY:-}" ]]; then + if [[ -n "$RUN_VNC" ]]; then + VMIX_DISPLAY="-display none -vnc $RUN_VNC" + elif [[ -n "''${DISPLAY:-}" ]]; then VMIX_DISPLAY="-display sdl" fi @@ -87,6 +125,30 @@ pkgs.writeShellScriptBin "vmix" '' echo "Memory: $RUN_MEM MB" echo "CPUs: $RUN_SMP" echo "Display: $VMIX_DISPLAY" + + if [[ "$RUN_MACOS" == "true" ]]; then + # OpenCore's ROM must match en0's MAC: the image records it in its ESP + if [[ -z "$RUN_MAC" ]]; then + RUN_MAC=$(${pkgs.libguestfs-with-appliance}/bin/guestfish --ro -a "$RUN_IMAGE" -m /dev/sda1 cat /EFI/vmix/vmix.json 2>/dev/null \ + | ${pkgs.jq}/bin/jq -r .mac 2>/dev/null || true) + [[ -z "$RUN_MAC" || "$RUN_MAC" == "null" ]] && { RUN_MAC="52:54:00:c9:18:27"; echo "Warning: could not read MAC from image ESP, using $RUN_MAC"; } + fi + echo "macOS: yes (MAC $RUN_MAC)" + echo "" + exec ${pkgs.qemu}/bin/qemu-system-x86_64 \ + $VMIX_DISPLAY \ + ${macosQemu.deviceArgs} ${macosQemu.vgaArgs} \ + -accel kvm \ + -machine type=q35 \ + -cpu ${macosQemu.defaultCpu} \ + -smp "$RUN_SMP",sockets=1,cores="$RUN_SMP",threads=1 \ + -m "$RUN_MEM" \ + -drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd \ + -drive if=pflash,format=raw,file=/tmp/vmix-run-vars-$$.fd \ + -drive id=os,if=none,format=qcow2,file="$RUN_IMAGE",snapshot=on -device ide-hd,bus=sata.0,drive=os \ + -netdev user,id=net0 -device virtio-net-pci,netdev=net0,mac="$RUN_MAC",bus=pcie.0,addr=${macosQemu.nicAddr} + fi + echo "AHCI: $RUN_AHCI" echo "" @@ -106,7 +168,7 @@ pkgs.writeShellScriptBin "vmix" '' else echo "-drive file=$RUN_IMAGE,format=qcow2,if=virtio,snapshot=on" fi) \ - -nic user,model=$(if [[ "$RUN_AHCI" == "true" ]]; then echo e1000; else echo virtio-net-pci; fi) \ + -nic user,model=$(if [[ "$RUN_AHCI" == "true" ]]; then echo e1000; else echo virtio-net-pci; fi)$(if [[ -n "$RUN_MAC" ]]; then echo ",mac=$RUN_MAC"; fi) \ -device virtio-serial-pci \ -chardev spicevmc,id=vdagent,debug=0,name=vdagent \ -device virtserialport,chardev=vdagent,name=com.redhat.spice.0 @@ -161,8 +223,16 @@ pkgs.writeShellScriptBin "vmix" '' FLAKE_DIR="${self}" + # OS type of the image (windows / macos / linux) decides the disk-writing steps + OS_TYPE=$(${pkgs.nix}/bin/nix eval --raw --impure --expr " + let + vmixLib = (builtins.getFlake \"$FLAKE_DIR\").lib.${system}; + image = vmixLib.$IMAGE_NAME; + in image._vmixOsType or \"linux\" + " 2>/dev/null || echo linux) + echo "=== vmix $COMMAND ===" - echo "Image: $IMAGE_NAME" + echo "Image: $IMAGE_NAME ($OS_TYPE)" [[ -n "$GENERALIZE" ]] && echo "Generalize: $GENERALIZE" [[ -n "$TO_DISK" ]] && echo "To disk: $TO_DISK" [[ -n "$TO_REMOTE_DISK" ]] && echo "To remote: $TO_REMOTE_DISK" @@ -191,6 +261,10 @@ pkgs.writeShellScriptBin "vmix" '' IMAGE_FILE=$(readlink -f "$OUT_LINK") echo "Built: $IMAGE_FILE" + if [[ "$OS_TYPE" == "macos" ]]; then + echo "Run it with: vmix run $OUT_LINK --macos --vnc :10" + fi + # --- write to local disk --- if [[ -n "$TO_DISK" ]]; then echo "" @@ -224,6 +298,15 @@ pkgs.writeShellScriptBin "vmix" '' echo "[3/5] Fixing GPT backup header..." ${pkgs.gptfdisk}/bin/sgdisk -e "$TO_DISK" + + if [[ "$OS_TYPE" == "macos" ]]; then + echo "[4/5] APFS container cannot be grown from Linux — skipped" + echo "[5/5] Grow it from macOS with: diskutil apfs resizeContainer disk0s2 0" + echo "" + echo "Done. $TO_DISK is ready to boot (OpenCore in the EFI partition; real Macs need no OpenCore)." + exit 0 + fi + # delete recovery partition if present, then resize Windows partition ${pkgs.gptfdisk}/bin/sgdisk -d 4 "$TO_DISK" 2>/dev/null || true @@ -281,13 +364,22 @@ pkgs.writeShellScriptBin "vmix" '' kill $NBD_PID 2>/dev/null || true rm -f "$NBD_SOCK" + echo "[3/5] Fixing GPT backup header..." + if [[ "$OS_TYPE" == "macos" ]]; then + ssh "$REMOTE_HOST" "nix-shell -p gptfdisk --run 'sgdisk -e $REMOTE_DISK'" + echo "[4/5] APFS container cannot be grown from Linux — skipped" + echo "[5/5] Grow it from macOS with: diskutil apfs resizeContainer disk0s2 0" + echo "" + echo "Done. $REMOTE_HOST:$REMOTE_DISK is ready to boot." + exit 0 + fi + if [[ "$REMOTE_DISK" == *nvme* ]] || [[ "$REMOTE_DISK" == *mmcblk* ]]; then REMOTE_WIN_PART="''${REMOTE_DISK}p3" else REMOTE_WIN_PART="''${REMOTE_DISK}3" fi - echo "[3/5] Fixing GPT backup header..." ssh "$REMOTE_HOST" "nix-shell -p gptfdisk --run 'sgdisk -e $REMOTE_DISK && sgdisk -d 4 $REMOTE_DISK 2>/dev/null || true'" echo "[4/5] Expanding Windows partition (partition 3)..." 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..8fd3f15 --- /dev/null +++ b/lib/images/macos/README.md @@ -0,0 +1,134 @@ +# vmix macOS images + +Unattended macOS (Tahoe / 26) VM images, built the same way as the Windows +images: `makeImage` installs the OS once, templates customize it by booting it, +`.generalize` creates the user and seals the image. + +``` +vmix build --image macos.images.tahoe.basic \ + --generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich +vmix run ./result --macos --vnc :10 --mem 8192 # VNC on port 5910 +``` + +## How it works + +| step | what happens | +|---|---| +| `fetchRecovery` | BaseSystem.dmg from Apple's recovery servers (fixed-output, pinned by sha256) | +| `installerPayload` | takes the App Store `InstallAssistant.pkg` (18 GB, pinned) apart on Linux: the app skeleton (pbzx/cpio) and the byte offset of `SharedSupport.dmg` | +| `makeOpenCore` | OSX-KVM's OpenCore ESP with a config.plist rewritten for this image: SMBIOS model, serial + MLB (`macserial`), UUID and ROM = NIC MAC (derived from a seed), NIC marked built-in | +| `makeImage` | one QEMU session: Recovery boots via OpenCore → `vm-driver.py` opens Terminal with keystrokes (Ctrl-F2 menu navigation, screen-settle detection + OCR of the menu bar) and types `sh /Volumes/VMIX/run.sh` → `vmix-install.sh` erases the disk, rebuilds `Install macOS Tahoe.app` (skeleton + `SharedSupport.dmg` copied from a raw disk mapped straight out of the pkg), runs `startosinstall --installpackage vmix-agent.pkg` → installer reboots through its phases → first boot runs the **vmix agent** which powers off. OpenCore is then copied into the image's EFI partition, so it boots standalone with OVMF | +| `customizeImage` | boots the image with a FAT volume `VMIX`; the agent (LaunchDaemon `ch.vmix.agent`) runs `vmix-run.sh` as root, writes `vmix-run.status`/`.log` back and shuts down | +| `templates.generalize` | user (admin) + auto-login (`/etc/kcpassword`), Setup Assistant suppressed, hostname, timezone, no sleep, APFS grown to the disk, then the agent removes itself; a fresh SMBIOS identity is written to the ESP | + +The vmix agent replaces Windows' Audit Mode RunOnce; `.AppleSetupDone` replaces +the OOBE unattend. Everything on the host side runs inside `__noChroot` +derivations (KVM + `/tmp`), exactly like the Windows builders. + +## Generalize options + +`username password fullName autoLogon hostname locale timezone delayOobeRun` +as for Windows (`bgColor` is accepted but ignored), plus the SMBIOS identity: +`model serial mlb uuid mac seed`. Anything unset is generated: serial/MLB by +macserial (random per build), MAC and UUID deterministically from `seed` +(default `hostname-username`). `vmix macserial --model MacPro7,1` prints a +ready-to-paste set. + +`delay-oobe-run=true` creates no user and re-arms Setup Assistant for the first +real boot. + +## Apple ID / iMessage + +The image satisfies what Dortania lists for iServices: unique serial + MLB for a +Tahoe-supported model (`MacPro7,1` by default; `iMac20,1/2`, +`MacBookPro16,x` also work), SystemUUID, ROM equal to en0's MAC, and en0 marked +built-in (the NIC is pinned to `PciRoot(0x0)/Pci(0x12,0x0)`). The NixOS module +and `vmix run --macos` use the MAC recorded in the image (`EFI/vmix/vmix.json`). +Give each deployed VM its own generalized image (different `seed`, or explicit +`serial=`/`mlb=`) — two VMs with the same identity will be blocked. + +## Runtime + +* `vmix run --macos [--vnc :N] [--mac ..]` +* NixOS module: `disks.os.file = vmixLib.macos.images.tahoe.basic.generalize {...}` + is auto-detected (`_vmixOsType = "macos"`): Skylake-Client CPU spoof, AppleSMC, + USB keyboard/tablet, AHCI system disk, VMware SVGA, pinned NIC with the image's MAC. + `macos.cpu`, `macos.mac`, `macos.enable` override the defaults. +* `vmix copy` writes the image to a disk but cannot grow APFS from Linux + (`diskutil apfs resizeContainer disk0s2 0` in macOS afterwards). + +## Debugging a build + +Screenshots (`NNN-.png`), `driver.log` and the QMP socket of every VM +session are in `/tmp/vmix-macos//` on the build host. The guest logs +(`install.log`, `vmix-run.log`, `vmix-agent.log`) are printed at the end of the +build. Pass `vncDisplay = ":10"` to `makeImage`/`customizeImage` (or +`--generalize vncDisplay=:10`) to watch live; with a `DISPLAY` an SDL window +is used as for Windows. + +## Updating pins (`upstream.json`) + +* installer: URL + SRI hash of a newer `InstallAssistant.pkg` + (`nix store prefetch-file --name InstallAssistant.pkg `; Mr. Macintosh's + database lists Apple's URLs) +* recovery: Apple serves the current build for the board id, so the sha256 + changes with each point release — copy the "got:" hash from the failed build +* opencore: OSX-KVM `OpenCore.qcow2` at a commit; OpenCorePkg release zip (macserial/ocvalidate) + +## Known limits + +* The Recovery bootstrap depends on keyboard navigation of the Recovery UI + (Ctrl-F2 → Utilities → Terminal). It self-corrects with screenshots + OCR and + falls back to a blind sequence, but a Recovery UI change would need + `vm-driver.py` adjusted. +* Hosts must run KVM with an AVX2-capable CPU (Intel or AMD; the guest sees a + Skylake). `sandbox = relaxed` and the `kvm` system feature, as for Windows. +* Software updates inside the VM are disabled by the `noUpdates` template + (OTA updates in a VM need the RestrictEvents kext). + +## Current status (2026-09-08) + +Everything up to and including the macOS Installer's *prepare* phase is working and +proven end to end on the `root@daku.home` KVM host: + +* OpenCore ESP per-image SMBIOS (serial/MLB via macserial, ROM=MAC, UUID), boot + disk, `.contentVisibility` to hide the OC self-entry — **works** (`ocvalidate` clean). +* Tahoe recovery boots via OpenCore; `vm-driver.py` drives it entirely by + screenshots + OCR: handles the OpenCore picker, opens Terminal (Ctrl-F2 → + Utilities → Terminal), types the bootstrap command — **works**. +* HFS+ `VMIX` volume mounts in Recovery; the install script erases the disk as + APFS "Macintosh HD", lays down the host-extracted `Install macOS Tahoe.app` + skeleton and `dd`s a **byte-exact** `SharedSupport.dmg` from a raw disk mapped + to that byte range of the pkg (verified `sha256` identical to Apple's) — **works**. +* `startosinstall` runs, `osinstallersetupd` **mounts SharedSupport.dmg**, reads + the MobileAsset bundle (`IA OS Version: 26.6.2, Build 25G83`), loads the + 641-product SU catalog from swscan.apple.com (so guest networking works), and + logs `Machine is VM, will assume APFS is supported`. + +### Blocker 1 — `OSISVerifyBaseSystemOperation: pkgdmg is missing a footer` + +After mounting the dmg, `osinstallersetupd` runs a verify step that treats +`SharedSupport.dmg` as a *pkgdmg* (`Getting offset for dmg in pkg`) and fails +`pkgdmg is missing a footer` → `Installation cannot proceed because the installer +is damaged` (Code 255). But this InstallAssistant `SharedSupport.dmg` is a **plain +UDIF** image (first bytes `eb 58 90 …`, not `xar!`), and it is **byte-identical to +Apple's** — so this is not truncation or corruption (earlier truncation, from +Recovery's `xar` mishandling an 18 GB member, was fixed by the byte-range `dd`). +It is a macOS-internal verification that rejects the plain-UDIF SharedSupport +when running Tahoe's `startosinstall` in this recovery/VM. Network is fine (the +catalog loaded), so it is not the firewall case commonly cited for this error. + +Leads not yet tried: driving the Tahoe recovery's **network "Reinstall macOS"** +(GUI, downloads assets at install time — sidesteps the local-dmg verify); a +different SMBIOS/board; or a newer OpenCore/kext combo. This is cutting-edge +(Tahoe shipped 2025-09) and the hackintosh community is still working it out. + +### Blocker 2 — Tahoe recovery availability on Apple's CDN + +`osrecovery.apple.com` is load-balancing `latest` across CDN nodes during the +Tahoe rollout: most requests return the **Sequoia** 15.4.1 BaseSystem +(`082-33203`), some return **Tahoe** 26.6.2 (`140-93589`). `fetchRecovery` +retries until it gets the pinned Tahoe hash, but that can exhaust its attempts +when Tahoe is rare. The robust fix is to self-host the verified Tahoe +`BaseSystem.dmg` (960530321 bytes, `sha256 edddd0d5…`, confirmed 26.6.2) the way +the Win10 ISO is hosted on git.sagar.ch, and point `fetchRecovery` at it. diff --git a/lib/images/macos/default.nix b/lib/images/macos/default.nix new file mode 100644 index 0000000..6b36d2f --- /dev/null +++ b/lib/images/macos/default.nix @@ -0,0 +1,44 @@ +{ pkgs, lib, system, ... }: +let + upstream = (lib.importJSON ./upstream.json).${system}; + macos = rec { + inherit upstream; + qemu = import ./helpers/qemu.nix { inherit pkgs lib; }; + ident = import ./helpers/ident.nix { inherit lib; }; + macserial = import ./helpers/macserial.nix { inherit pkgs upstream; }; + fetchRecovery = import ./helpers/fetchRecovery.nix { inherit pkgs upstream; }; + installerPayload = import ./helpers/installerPayload.nix { inherit pkgs lib; }; + makeOpenCore = import ./helpers/makeOpenCore.nix { inherit pkgs lib upstream macserial qemu; }; + makeVmixVolume = import ./helpers/makeVmixVolume.nix { inherit pkgs lib; }; + makeAgentPkg = import ./helpers/makeAgentPkg.nix { inherit pkgs lib; }; + installBootloader = import ./helpers/installBootloader.nix { inherit pkgs lib; }; + vmixReadback = import ./helpers/vmix-readback.nix { inherit pkgs lib; }; + vmDriver = ./helpers/vm-driver.py; + makeImage = import ./helpers/makeImage.nix { + inherit pkgs lib qemu ident installerPayload makeOpenCore makeVmixVolume makeAgentPkg installBootloader vmixReadback vmDriver; + }; + customizeImage = import ./helpers/customizeImage.nix { + inherit pkgs lib qemu ident makeVmixVolume makeOpenCore installBootloader vmixReadback vmDriver; + }; + customizeImageFold = builtins.foldl' customizeImage; + templates = import ./templates { inherit pkgs lib; }; + }; + + tahoe = import ./tahoe { inherit pkgs lib system macos; }; + + # Recursively add .generalize to every image leaf (same shape as windows) + addGeneralize = val: + if val ? _vmixOsType then + val // { generalize = args: + let + templateArgs = builtins.removeAttrs args [ "vncDisplay" ]; + displayArgs = lib.optionalAttrs (args ? vncDisplay) { inherit (args) vncDisplay; }; + in macos.customizeImage val (macos.templates.generalize templateArgs // displayArgs); + } + else if builtins.isAttrs val then + lib.mapAttrs (_: addGeneralize) val + else val; +in +macos // { + images = addGeneralize { inherit tahoe; }; +} diff --git a/lib/images/macos/guest/agent.sh b/lib/images/macos/guest/agent.sh new file mode 100644 index 0000000..e89146e --- /dev/null +++ b/lib/images/macos/guest/agent.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# vmix agent: LaunchDaemon that runs at every boot as root. If a volume named +# VMIX carrying vmix-run.sh is attached, run it, record the result on the +# volume and power off. Without the volume it is a no-op (normal boot). +# This is the macOS counterpart of the Windows Audit Mode RunOnce script. +LOG=/var/log/vmix-agent.log +exec >>"$LOG" 2>&1 +echo "=== vmix agent: $(date) ===" +V=/Volumes/VMIX +i=0 +while [ ! -f "$V/vmix-run.sh" ] && [ $i -lt 60 ]; do + diskutil mount VMIX >/dev/null 2>&1 + sleep 2 + i=$((i + 1)) +done +if [ ! -f "$V/vmix-run.sh" ]; then + echo "vmix agent: no VMIX volume, normal boot" + exit 0 +fi +echo "vmix agent: running vmix-run.sh" +cd "$V" || exit 1 +sh "$V/vmix-run.sh" >"$V/vmix-run.log" 2>&1 +rc=$? +echo "vmix agent: vmix-run.sh exited $rc" +echo "$rc" >"$V/vmix-run.status" +cp "$LOG" "$V/vmix-agent.log" 2>/dev/null +sync +sleep 2 +diskutil unmount force "$V" >/dev/null 2>&1 +shutdown -h now diff --git a/lib/images/macos/guest/ch.vmix.agent.plist b/lib/images/macos/guest/ch.vmix.agent.plist new file mode 100644 index 0000000..c518fa4 --- /dev/null +++ b/lib/images/macos/guest/ch.vmix.agent.plist @@ -0,0 +1,19 @@ + + + + + Label + ch.vmix.agent + ProgramArguments + + /bin/sh + /Library/vmix/agent.sh + + RunAtLoad + + StandardOutPath + /var/log/vmix-agent.log + StandardErrorPath + /var/log/vmix-agent.log + + 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/vmix-install.sh b/lib/images/macos/guest/vmix-install.sh new file mode 100644 index 0000000..d3b648d --- /dev/null +++ b/lib/images/macos/guest/vmix-install.sh @@ -0,0 +1,117 @@ +#!/bin/sh +# vmix: automated macOS install. Runs inside macOS Recovery's Terminal, started +# by vm-driver.py which types "sh /Volumes/VMIX/run.sh" for us. +# +# 1. erase the target disk (found by size) as APFS "Macintosh HD" +# 2. rebuild "Install macOS .app": app skeleton from installer-app.tar +# (host-extracted Payload) + SharedSupport.dmg copied byte-exact by dd from a +# raw disk that maps that byte range of the pkg (Recovery's xar truncates an +# 18 GB member, which makes startosinstall report "pkgdmg is missing a footer") +# 3. startosinstall unattended, with the vmix agent pkg as --installpackage +# 4. when the prepare phase is done (SIGUSR1) also drop the agent + .AppleSetupDone +# onto the volume, then let the installer reboot +# +# On first boot of the installed system the agent runs /Volumes/VMIX/vmix-run.sh +# and powers off, which ends the QEMU session on the host. + +# macOS Recovery invokes us as `sh` (bash in POSIX mode, no process substitution); +# re-exec once under bash so `>(tee ...)` and other bashisms work. +if [ -z "${VMIX_REEXEC:-}" ]; then VMIX_REEXEC=1 exec bash "$0" "$@"; fi + +V="/Volumes/VMIX" +# tee to the Terminal (visible in host screenshots) and to a log on the volume +exec > >(tee "$V/install.log") 2>&1 +set -x +. "$V/vmix.conf" + +fail() { + echo "vmix-install: FAIL: $*" + cp /var/log/install.log "$V/system-install.log" 2>/dev/null || true + echo 1 >"$V/install.status" + sync + sleep 2 + shutdown -h now 2>/dev/null || halt 2>/dev/null || true + exit 1 +} + +# whole-disk identifier (diskN) whose size in bytes is exactly $1 +disk_by_size() { + for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+'); do + s=$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9][0-9]*\) Bytes).*/\1/p') + [ "$s" = "$1" ] && { echo "${d#/dev/}"; return 0; } + done + return 1 +} + +echo "vmix-install: $(date) app=$APP_NAME volume=$VOLUME_NAME" +TARGET=$(disk_by_size "$TARGET_BYTES") || fail "target disk ($TARGET_BYTES bytes) not found" +SSDISK=$(disk_by_size "$SS_DISK_BYTES") || fail "SharedSupport disk ($SS_DISK_BYTES bytes) not found" +echo "vmix-install: target=$TARGET sharedsupport=$SSDISK" + +# --- 1. erase the target disk as an APFS volume +diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk $TARGET" +VOL="/Volumes/$VOLUME_NAME" +[ -d "$VOL" ] || fail "$VOL not mounted" + +# --- 2. rebuild the installer app on the target volume +tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar installer-app.tar" +APP="$VOL/$APP_NAME" +SOI="$APP/Contents/Resources/startosinstall" +[ -x "$SOI" ] || fail "startosinstall missing in $APP" +SS="$APP/Contents/SharedSupport/SharedSupport.dmg" +mkdir -p "$APP/Contents/SharedSupport" +FULL=$((SS_LEN / 1048576)) +REM=$((SS_LEN % 1048576)) +dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport.dmg" +if [ "$REM" -gt 0 ]; then + dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" || fail "dd SharedSupport.dmg tail" +fi +[ "$(stat -f %z "$SS")" = "$SS_LEN" ] || fail "SharedSupport.dmg size mismatch: $(stat -f %z "$SS") != $SS_LEN" +tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no UDIF koly footer" +chflags -h norestricted "$SS" 2>/dev/null || true +echo "vmix-install: app=$APP SharedSupport.dmg=$(stat -f %z "$SS") bytes" + +# macOS certificate validation needs a sane clock; a fresh VM RTC can be wrong. +echo "vmix-install: guest clock is $(date) (UTC $(date -u))" +if [ -n "${BUILD_DATE:-}" ]; then + date -u "$BUILD_DATE" && echo "vmix-install: set clock to $(date)" +fi + +# --- 3. unattended install +PREPARED=0 +trap 'PREPARED=1' USR1 +run_install() { + "$SOI" --volume "$VOL" --agreetolicense --nointeraction --pidtosignal $$ "$@" & + INSTALL_PID=$! + while :; do + wait $INSTALL_PID + rc=$? + [ "$PREPARED" = 1 ] && return 0 + kill -0 $INSTALL_PID 2>/dev/null || return $rc + done +} +run_install --rebootdelay 300 --installpackage "$V/vmix-agent.pkg" \ + || { echo "vmix-install: retry without --rebootdelay"; run_install --installpackage "$V/vmix-agent.pkg"; } \ + || { echo "vmix-install: retry without --installpackage"; run_install; } \ + || fail "startosinstall" + +# --- 4. prepare phase done: also drop the agent onto the volume directly. +T="$VOL" +if [ -d "$T" ]; then + mkdir -p "$T/Library/LaunchDaemons" "$T/Library/vmix" "$T/private/var/db" + cp "$V/agent/agent.sh" "$T/Library/vmix/agent.sh" + cp "$V/agent/ch.vmix.agent.plist" "$T/Library/LaunchDaemons/ch.vmix.agent.plist" + chmod 755 "$T/Library/vmix/agent.sh" + chmod 644 "$T/Library/LaunchDaemons/ch.vmix.agent.plist" + chown -R root:wheel "$T/Library/vmix" "$T/Library/LaunchDaemons/ch.vmix.agent.plist" + touch "$T/private/var/db/.AppleSetupDone" + chown root:wheel "$T/private/var/db/.AppleSetupDone" +else + echo "vmix-install: WARNING: $T not mounted after prepare, relying on --installpackage" +fi + +echo 0 >"$V/install.status" +sync +kill -USR1 $INSTALL_PID +wait $INSTALL_PID +exit 0 diff --git a/lib/images/macos/helpers/customizeImage.nix b/lib/images/macos/helpers/customizeImage.nix new file mode 100644 index 0000000..7ff849e --- /dev/null +++ b/lib/images/macos/helpers/customizeImage.nix @@ -0,0 +1,105 @@ +# Customize a macOS image by booting it with a VMIX volume: the vmix agent +# (LaunchDaemon installed by makeImage) runs `script` as root, records the exit +# status on the volume and powers off. Optionally re-installs OpenCore with a new +# SMBIOS identity (`smbios`). Counterpart of the Windows auditScript flow. +# +# Templates provide: +# script — sh script run as root on the booted system +# files — [{ source; name; }] extra files placed next to it on /Volumes/VMIX +# smbios — { model? serial? mlb? uuid? mac? seed? } → fresh OpenCore config in the ESP +{ pkgs, lib, qemu, ident, makeVmixVolume, makeOpenCore, installBootloader, vmixReadback, vmDriver, ... }: +originalImage: { + name ? "", + script ? "", + files ? [], + smbios ? null, + diskSize ? "", + impure ? true, + vncDisplay ? null, + smp ? 4, + memSize ? 4096, + cpu ? qemu.defaultCpu, + timeout ? 3600, +}: +let + originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name); + customImageName = (if name != "" then name else "custom") + "-${originalImageName}-vmix.qcow2"; + resultImg = "./disk.qcow2"; + hasScript = script != ""; + hasSmbios = smbios != null; + + model = originalImage.model or "MacPro7,1"; + seed = if hasSmbios && (smbios.seed or null) != null then smbios.seed else null; + mac = if !hasSmbios then originalImage.macAddress + else if (smbios.mac or null) != null then smbios.mac + else if seed != null then ident.macFromSeed seed + else originalImage.macAddress; + uuid = if !hasSmbios then null + else if (smbios.uuid or null) != null then smbios.uuid + else if seed != null then ident.uuidFromSeed seed + else originalImage.opencore.uuid; + esp = if hasSmbios + then makeOpenCore ({ + name = "${name}-${originalImageName}-opencore"; + model = smbios.model or model; + inherit mac uuid; + } // builtins.removeAttrs smbios [ "seed" "mac" "uuid" "model" ]) + else originalImage.opencore; + + runScript = pkgs.writeText "${name}-vmix-run.sh" '' + #!/bin/sh + echo "=== vmix: ${name} ===" + ${script} + ''; + vmixVol = makeVmixVolume { + name = "${name}-${originalImageName}"; + files = [ { source = runScript; name = "vmix-run.sh"; } ] ++ files; + }; + driverPython = pkgs.python3.withPackages (p: [ p.pillow ]); + + bootCommands = lib.optionalString hasScript '' + cp ${vmixVol} vmix.img + chmod +w vmix.img + cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd + chmod +w vars.fd + + VMIX_DISPLAY="-display none" + ${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''} + ${lib.optionalString (vncDisplay == null) '' + VMIX_DF=$(ls -t /tmp/.vmix-display-* 2>/dev/null | head -1) + if [ -n "$VMIX_DF" ] && [ "$(stat -c %s "$VMIX_DF")" -lt 256 ] && ! grep -q -P '[^\x20-\x7e\n]' "$VMIX_DF"; then + export DISPLAY=$(tr -d '\n' < "$VMIX_DF") + export HOME=$(mktemp -d) + export XDG_RUNTIME_DIR=$HOME + export SDL_VIDEODRIVER=x11 + VMIX_DISPLAY="-display sdl" + fi + ''} + + echo "=== vmix: booting ${originalImageName} for ${name} ===" + python3 ${vmDriver} --mode boot --name "${name}-${originalImageName}" --timeout ${toString timeout} -- \ + qemu-system-x86_64 $VMIX_DISPLAY \ + ${qemu.machineArgs { inherit cpu smp memSize; }} \ + ${qemu.firmwareArgs "vars.fd"} \ + ${qemu.sataDrive { id = "system"; port = 0; file = resultImg; }} \ + ${qemu.sataDrive { id = "vmix"; port = 1; file = "vmix.img"; format = "raw"; }} \ + ${qemu.netArgs { mac = originalImage.macAddress; }} \ + || { echo "vmix: VM failed during ${name} (see /tmp/vmix-macos/${name}-${originalImageName})"; exit 1; } + + ${vmixReadback "vmix.img"} + [ "$STATUS" = "0" ] || { echo "vmix: ${name} script failed (status '$STATUS')"; exit 1; } + echo "=== vmix: ${name} complete ===" + ''; + + builtImage = pkgs.runCommand customImageName ({ + nativeBuildInputs = with pkgs; [ pkgs.qemu mtools driverPython libguestfs-with-appliance ]; + requiredSystemFeatures = [ "kvm" ]; + } // lib.optionalAttrs impure { __noChroot = true; }) '' + qemu-img create -q -f qcow2 -b ${originalImage} -F qcow2 ${resultImg} + [ -n "${diskSize}" ] && qemu-img resize ${resultImg} ${diskSize} + ${bootCommands} + ${lib.optionalString hasSmbios (installBootloader { inherit esp; image = resultImg; })} + mv ${resultImg} $out + ''; +in + builtImage // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; model = esp.model or model; } 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/ident.nix b/lib/images/macos/helpers/ident.nix new file mode 100644 index 0000000..b6d6ce8 --- /dev/null +++ b/lib/images/macos/helpers/ident.nix @@ -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}"; +} diff --git a/lib/images/macos/helpers/installBootloader.nix b/lib/images/macos/helpers/installBootloader.nix new file mode 100644 index 0000000..83e0406 --- /dev/null +++ b/lib/images/macos/helpers/installBootloader.nix @@ -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" <.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 +'' diff --git a/lib/images/macos/helpers/macserial.nix b/lib/images/macos/helpers/macserial.nix new file mode 100644 index 0000000..1360ef2 --- /dev/null +++ b/lib/images/macos/helpers/macserial.nix @@ -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 + ''; +} diff --git a/lib/images/macos/helpers/makeAgentPkg.nix b/lib/images/macos/helpers/makeAgentPkg.nix new file mode 100644 index 0000000..18f5a33 --- /dev/null +++ b/lib/images/macos/helpers/makeAgentPkg.nix @@ -0,0 +1,75 @@ +# Distribution-style flat package (xar + bom + cpio, built on Linux) for +# `startosinstall --installpackage`: installs the vmix agent LaunchDaemon and +# marks Setup Assistant as done so the first boot lands on loginwindow. +{ pkgs, lib, ... }: +{ version ? "1.0" }: +let + id = "ch.vmix.agent"; + # nixpkgs' bomutils aborts under _FORTIFY_SOURCE + bomutils = pkgs.bomutils.overrideAttrs (_: { hardeningDisable = [ "fortify" ]; }); + postinstall = pkgs.writeText "postinstall" '' + #!/bin/sh + # $3 = target volume + T="$3" + mkdir -p "$T/private/var/db" + touch "$T/private/var/db/.AppleSetupDone" + chown root:wheel "$T/private/var/db/.AppleSetupDone" + chmod 755 "$T/Library/vmix/agent.sh" + chown -R root:wheel "$T/Library/vmix" "$T/Library/LaunchDaemons/${id}.plist" + exit 0 + ''; +in +pkgs.runCommand "vmix-agent-${version}.pkg" { + nativeBuildInputs = [ pkgs.xar bomutils pkgs.cpio pkgs.gzip ]; +} '' + mkdir -p root/Library/LaunchDaemons root/Library/vmix scripts flat/vmix-agent.pkg + cp ${../guest/agent.sh} root/Library/vmix/agent.sh + cp ${../guest/ch.vmix.agent.plist} root/Library/LaunchDaemons/${id}.plist + chmod 755 root/Library/vmix/agent.sh + chmod 644 root/Library/LaunchDaemons/${id}.plist + cp ${postinstall} scripts/postinstall + chmod 755 scripts/postinstall + + NFILES=$(find root | wc -l) + KBYTES=$(du -sk root | cut -f1) + (cd root && find . | cpio -o --format odc --owner 0:0 --quiet | gzip -c > ../flat/vmix-agent.pkg/Payload) + (cd scripts && find . | cpio -o --format odc --owner 0:0 --quiet | gzip -c > ../flat/vmix-agent.pkg/Scripts) + mkbom -u 0 -g 80 root flat/vmix-agent.pkg/Bom + + cat > flat/vmix-agent.pkg/PackageInfo < + + + + + + + + + + + + + XML + cat > flat/Distribution < + + vmix agent + + + + + + + + + + + + #vmix-agent.pkg + + XML + sed -i 's/^ //' flat/vmix-agent.pkg/PackageInfo flat/Distribution + (cd flat && xar --compression none -cf $out Distribution vmix-agent.pkg) + xar -t -f $out +'' diff --git a/lib/images/macos/helpers/makeImage.nix b/lib/images/macos/helpers/makeImage.nix new file mode 100644 index 0000000..ed7d3ad --- /dev/null +++ b/lib/images/macos/helpers/makeImage.nix @@ -0,0 +1,132 @@ +# Build a pre-installed macOS qcow2 with an unattended QEMU install. +# +# One QEMU session, driven by vm-driver.py: +# Recovery (BaseSystem) boots via OpenCore → driver opens Terminal with +# keystrokes and types "sh /Volumes/VMIX/run.sh" → vmix-install.sh erases the +# disk, rebuilds the installer app from installer-app.tar + the SharedSupport.dmg +# raw disk, runs startosinstall (--installpackage vmix-agent.pkg) → the installer +# reboots through its phases → first boot of macOS runs the vmix agent, which +# executes vmix-run.sh and powers off → QEMU exits. +# Afterwards OpenCore is copied into the image's EFI partition so the result +# boots standalone with plain OVMF. Apply templates with customizeImageFold, +# then .generalize to create the user and set a fresh SMBIOS identity. +{ pkgs, lib, qemu, ident, installerPayload, makeOpenCore, makeVmixVolume, makeAgentPkg, installBootloader, vmixReadback, vmDriver, ... }: +{ + name ? "macos", + installer, # InstallAssistant.pkg (fetchurl) + recovery, # BaseSystem.dmg (fetchRecovery) + diskSize ? "128G", + volumeName ? "Macintosh HD", + smp ? 4, + memSize ? 8192, + cpu ? qemu.defaultCpu, + model ? "MacPro7,1", # SMBIOS model; must be Tahoe-supported (MacPro7,1, iMac20,1/2, MacBookPro16,x) + seed ? name, # MAC address + SystemUUID are derived from this + bootArgs ? "keepsyms=1", + vncDisplay ? null, # e.g. ":10" to watch the install on port 5910 + timeout ? 4 * 3600, # seconds for the whole install + extraOpenCoreConfig ? {}, # merged into config.plist +}: +let + mac = ident.macFromSeed seed; + uuid = ident.uuidFromSeed seed; + esp = makeOpenCore { name = "${name}-opencore"; inherit model mac uuid bootArgs; extraConfig = extraOpenCoreConfig; }; + payload = installerPayload { inherit name; pkg = installer; }; + recoveryImg = pkgs.runCommand "${name}-BaseSystem.img" { nativeBuildInputs = [ pkgs.dmg2img ]; } '' + dmg2img -s ${recovery} $out + ''; + agentPkg = makeAgentPkg { }; + agentDir = pkgs.runCommand "vmix-agent-files" { } '' + mkdir -p $out + cp ${../guest/agent.sh} $out/agent.sh + cp ${../guest/ch.vmix.agent.plist} $out/ch.vmix.agent.plist + ''; + firstBoot = pkgs.writeText "vmix-run.sh" '' + echo "vmix: first boot of the installed system" + sw_vers + exit 0 + ''; + vmixVol = makeVmixVolume { + inherit name; + size = "512M"; + files = [ + { source = ../guest/vmix-install.sh; name = "run.sh"; } + { source = "${payload}/installer-app.tar"; name = "installer-app.tar"; } + { source = agentPkg; name = "vmix-agent.pkg"; } + { source = agentDir; name = "agent"; } + { source = firstBoot; name = "vmix-run.sh"; } + ]; + }; + driverPython = pkgs.python3.withPackages (p: [ p.pillow p.pytesseract ]); + tesseract = pkgs.tesseract.override { enableLanguages = [ "eng" ]; }; + + drv = pkgs.runCommand "${name}-vmix.qcow2" { + __noChroot = true; + requiredSystemFeatures = [ "kvm" ]; + nativeBuildInputs = with pkgs; [ pkgs.qemu mtools jq driverPython tesseract libguestfs-with-appliance ]; + } '' + echo "=== vmix: creating ${diskSize} disk ===" + qemu-img create -f qcow2 disk.qcow2 ${diskSize} + # store files are read-only and AHCI needs writable nodes: qcow2 overlays + qemu-img create -q -f qcow2 -F raw -b ${recoveryImg} recovery.qcow2 + qemu-img create -q -f qcow2 -F raw -b ${esp}/boot.img ocboot.qcow2 + + # SharedSupport.dmg is exposed as its own raw disk mapped to that byte range of + # the pkg (zero host copy). qemu accepts a non-512-aligned raw offset, so the + # disk starts exactly at the dmg; the guest dd's SS_LEN bytes into the app. + SS_OFF=$(${pkgs.jq}/bin/jq '."SharedSupport.dmg".offset' ${payload}/installer.json) + SS_LEN=$(${pkgs.jq}/bin/jq '."SharedSupport.dmg".length' ${payload}/installer.json) + SS_DISK=$(( (SS_LEN + 511) / 512 * 512 )) + qemu-img create -q -f qcow2 -F raw -b "json:{\"driver\":\"raw\",\"offset\":$SS_OFF,\"size\":$SS_DISK,\"file\":{\"driver\":\"file\",\"filename\":\"${installer}\"}}" sharedsupport.qcow2 + + cp ${vmixVol} vmix.img + chmod +w vmix.img + TARGET_BYTES=$(qemu-img info --output=json disk.qcow2 | jq '."virtual-size"') + cat > vmix.conf </dev/null | head -1) + if [ -n "$VMIX_DF" ] && [ "$(stat -c %s "$VMIX_DF")" -lt 256 ] && ! grep -q -P '[^\x20-\x7e\n]' "$VMIX_DF"; then + export DISPLAY=$(tr -d '\n' < "$VMIX_DF") + export HOME=$(mktemp -d) + export XDG_RUNTIME_DIR=$HOME + export SDL_VIDEODRIVER=x11 + VMIX_DISPLAY="-display sdl" + fi + ''} + + echo "=== vmix: installing ${name} (unattended, 1-2 h; screenshots in /tmp/vmix-macos/${name}) ===" + python3 ${vmDriver} --mode install --name ${name} --timeout ${toString timeout} -- \ + qemu-system-x86_64 $VMIX_DISPLAY \ + ${qemu.machineArgs { inherit cpu smp memSize; }} \ + ${qemu.firmwareArgs "vars.fd"} \ + ${qemu.sataDrive { id = "opencore"; port = 0; file = "ocboot.qcow2"; }} \ + ${qemu.sataDrive { id = "recovery"; port = 1; file = "recovery.qcow2"; }} \ + ${qemu.sataDrive { id = "system"; port = 2; file = "disk.qcow2"; }} \ + ${qemu.sataDrive { id = "vmix"; port = 3; file = "vmix.img"; format = "raw"; }} \ + ${qemu.sataDrive { id = "sharedsupport"; port = 4; file = "sharedsupport.qcow2"; }} \ + ${qemu.netArgs { inherit mac; }} \ + || { echo "vmix: install VM failed (see /tmp/vmix-macos/${name})"; exit 1; } + + ${vmixReadback "vmix.img"} + [ "$STATUS" = "0" ] || { echo "vmix: first boot did not complete (status '$STATUS'), see /tmp/vmix-macos/${name}"; exit 1; } + + ${installBootloader { inherit esp; image = "disk.qcow2"; }} + echo "=== vmix: ${name} install complete (serial $(jq -r .serial ${esp}/vmix.json), mac ${mac}) ===" + mv disk.qcow2 $out + ''; +in drv // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; inherit model; } diff --git a/lib/images/macos/helpers/makeOpenCore.nix b/lib/images/macos/helpers/makeOpenCore.nix new file mode 100644 index 0000000..f0a120b --- /dev/null +++ b/lib/images/macos/helpers/makeOpenCore.nix @@ -0,0 +1,68 @@ +# OpenCore EFI for the VM: OSX-KVM's proven ESP (OpenCore + Lilu/VirtualSMC/... kexts) +# with a config.plist rewritten for this image's SMBIOS identity. +# Output: +# $out/EFI/ BOOT/BOOTx64.efi + OC/ — copied into the image's ESP +# $out/boot.img raw GPT disk with that ESP, used to boot the installer +# $out/vmix.json model, serial, mlb, uuid, mac (also copied to EFI/vmix/) +# Serial + MLB come from macserial when not given (random per build); everything +# derived from `seed` (MAC, UUID) is deterministic so the NixOS module knows it. +{ pkgs, lib, upstream, macserial, qemu, ... }: +{ name ? "opencore", + model ? "MacPro7,1", + mac, + uuid, + serial ? null, + mlb ? null, + bootArgs ? "keepsyms=1", + resolution ? "1024x768", + showPicker ? true, + pickerTimeout ? 2, + extraConfig ? {}, +}: +let + ocImage = pkgs.fetchurl { inherit (upstream.opencore.image) url sha256; name = "OSX-KVM-OpenCore.qcow2"; }; +in +pkgs.runCommand "${name}-esp" { + nativeBuildInputs = with pkgs; [ _7zz python3 mtools dosfstools gptfdisk jq macserial ]; + passthru = { inherit model mac uuid; }; +} '' + echo "=== vmix: extracting OSX-KVM OpenCore ESP ===" + 7zz x -y -oparts ${ocImage} 0.primary.img >/dev/null + 7zz x -y -oesp parts/0.primary.img >/dev/null + [ -f esp/EFI/OC/config.plist ] || { echo "config.plist not found in OpenCore image"; exit 1; } + + SERIAL="${toString serial}"; MLB="${toString mlb}" + if [ -z "$SERIAL" ] || [ -z "$MLB" ]; then + echo "=== vmix: generating serial/MLB for ${model} with macserial ===" + LINE=$(macserial --num 1 --model "${model}" 2>/dev/null | grep '|' | tail -1) + [ -n "$LINE" ] || { echo "macserial produced nothing for ${model}"; exit 1; } + [ -z "$SERIAL" ] && SERIAL=$(echo "$LINE" | awk -F' *\\| *' '{print $1}') + [ -z "$MLB" ] && MLB=$(echo "$LINE" | awk -F' *\\| *' '{print $2}') + fi + echo "model=${model} serial=$SERIAL mlb=$MLB uuid=${uuid} mac=${mac}" + + mkdir -p $out/EFI/vmix + cp -r esp/EFI/BOOT esp/EFI/OC $out/EFI/ + chmod -R u+w $out/EFI + # hide OpenCore's own launcher from its picker (otherwise it is the default entry and loops) + echo -n Disabled > $out/EFI/BOOT/.contentVisibility + python3 ${./oc-config.py} --base esp/EFI/OC/config.plist --esp esp --out $out/EFI/OC/config.plist \ + --model "${model}" --serial "$SERIAL" --mlb "$MLB" --uuid "${uuid}" --mac "${mac}" \ + --nic-path "${qemu.nicDevicePath}" --boot-args "${bootArgs}" --resolution "${resolution}" \ + --show-picker "${lib.boolToString showPicker}" --timeout ${toString pickerTimeout} \ + --extra-json ${lib.escapeShellArg (builtins.toJSON extraConfig)} + ocvalidate $out/EFI/OC/config.plist || echo "vmix: ocvalidate reported issues (OpenCore version may differ from validator), continuing" + + jq -n --arg model "${model}" --arg serial "$SERIAL" --arg mlb "$MLB" --arg uuid "${uuid}" --arg mac "${mac}" \ + '{model:$model, serial:$serial, mlb:$mlb, uuid:$uuid, mac:$mac}' > $out/vmix.json + cp $out/vmix.json $out/EFI/vmix/vmix.json + + echo "=== vmix: building OpenCore boot disk ===" + # 64 MiB raw GPT disk, ESP from sector 2048 to the end (minus backup GPT) + truncate -s 64M $out/boot.img + sgdisk -n 1:2048:0 -t 1:EF00 -c 1:EFI $out/boot.img >/dev/null + SECTORS=$(( 64*1024*1024/512 - 2048 - 34 )) + mkfs.vfat -F 32 -n OPENCORE --offset 2048 $out/boot.img $(( SECTORS / 2 )) >/dev/null + mcopy -i $out/boot.img@@1M -s $out/EFI :: + mdir -i $out/boot.img@@1M ::EFI/OC >/dev/null +'' diff --git a/lib/images/macos/helpers/makeVmixVolume.nix b/lib/images/macos/helpers/makeVmixVolume.nix new file mode 100644 index 0000000..17bd7d0 --- /dev/null +++ b/lib/images/macos/helpers/makeVmixVolume.nix @@ -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 = ; 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 +'' diff --git a/lib/images/macos/helpers/oc-config.py b/lib/images/macos/helpers/oc-config.py new file mode 100644 index 0000000..c8ca2e1 --- /dev/null +++ b/lib/images/macos/helpers/oc-config.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Derive a VM config.plist from OSX-KVM's OpenCore config. + +- keep only kexts/ACPI/drivers whose files exist in the ESP +- SMBIOS (model, serial, MLB, UUID, ROM=MAC) for Apple ID / iMessage +- mark the vmix NIC PCI path built-in, drop iMac19,1 GPU/audio properties +- boot-args, picker, resolution; optional JSON merged on top +""" +import argparse +import json +import os +import plistlib + +APPLE_NVRAM = '7C436110-AB2A-4BBB-A880-FE41995C9F82' +MCE_KEXT = { + 'Arch': 'Any', 'BundlePath': 'MCEReporterDisabler.kext', + 'Comment': 'Fix kernel panic on MacPro/iMacPro SMBIOS (vmix)', 'Enabled': True, + 'ExecutablePath': '', 'MaxKernel': '', 'MinKernel': '', 'PlistPath': 'Contents/Info.plist', +} + + +def merge(dst, src): + for k, v in src.items(): + if isinstance(v, dict) and isinstance(dst.get(k), dict): + merge(dst[k], v) + else: + dst[k] = v + + +def main(): + p = argparse.ArgumentParser() + p.add_argument('--base', required=True) + p.add_argument('--esp', required=True, help='directory containing EFI/OC') + p.add_argument('--out', required=True) + p.add_argument('--model', required=True) + p.add_argument('--serial', required=True) + p.add_argument('--mlb', required=True) + p.add_argument('--uuid', required=True) + p.add_argument('--mac', required=True) + p.add_argument('--nic-path', required=True) + p.add_argument('--boot-args', default='keepsyms=1') + p.add_argument('--resolution', default='1024x768') + p.add_argument('--show-picker', default='true') + p.add_argument('--timeout', type=int, default=2) + p.add_argument('--extra-json', default='{}') + a = p.parse_args() + + with open(a.base, 'rb') as f: + cfg = plistlib.load(f) + oc = os.path.join(a.esp, 'EFI', 'OC') + + def exists(sub, path): + return os.path.exists(os.path.join(oc, sub, path)) + + kexts = [k for k in cfg['Kernel']['Add'] if exists('Kexts', k['BundlePath'])] + if a.model.startswith(('MacPro', 'iMacPro')) and exists('Kexts', MCE_KEXT['BundlePath']) \ + and not any(k['BundlePath'] == MCE_KEXT['BundlePath'] for k in kexts): + kexts.append(dict(MCE_KEXT)) + cfg['Kernel']['Add'] = kexts + cfg['ACPI']['Add'] = [x for x in cfg['ACPI']['Add'] if exists('ACPI', x['Path'])] + cfg['UEFI']['Drivers'] = [d for d in cfg['UEFI']['Drivers'] if exists('Drivers', d['Path'])] + cfg['Misc']['Tools'] = [t for t in cfg['Misc'].get('Tools', []) if exists('Tools', t['Path'])] + cfg['Misc']['Entries'] = [] + + g = cfg['PlatformInfo']['Generic'] + g['SystemProductName'] = a.model + g['SystemSerialNumber'] = a.serial + g['MLB'] = a.mlb + g['SystemUUID'] = a.uuid.upper() + g['ROM'] = bytes.fromhex(a.mac.replace(':', '').replace('-', '')) + g['SpoofVendor'] = True + g['AdviseFeatures'] = False + + cfg['DeviceProperties']['Add'] = {a.nic_path: {'built-in': b'\x01'}} + cfg['DeviceProperties']['Delete'] = {} + + nv = cfg['NVRAM']['Add'].setdefault(APPLE_NVRAM, {}) + nv['boot-args'] = a.boot_args + nv['prev-lang:kbd'] = b'en-US:0' + nv['csr-active-config'] = b'\x00\x00\x00\x00' + + cfg['UEFI']['Output']['Resolution'] = a.resolution + cfg['Misc']['Boot']['ShowPicker'] = a.show_picker.lower() == 'true' + cfg['Misc']['Boot']['Timeout'] = a.timeout + cfg['Misc']['Boot']['HideAuxiliary'] = True + cfg['Misc']['Security']['ScanPolicy'] = 0 + cfg['Misc']['Security']['SecureBootModel'] = 'Disabled' + cfg['Misc']['Security']['AllowSetDefault'] = True + cfg['Misc']['Debug']['Target'] = 0 + + merge(cfg, json.loads(a.extra_json)) + with open(a.out, 'wb') as f: + plistlib.dump(cfg, f, sort_keys=True) + print('kexts:', ', '.join(k['BundlePath'] for k in kexts)) + print('acpi:', ', '.join(x['Path'] for x in cfg['ACPI']['Add'])) + + +if __name__ == '__main__': + main() diff --git a/lib/images/macos/helpers/qemu.nix b/lib/images/macos/helpers/qemu.nix new file mode 100644 index 0000000..c8e8474 --- /dev/null +++ b/lib/images/macos/helpers/qemu.nix @@ -0,0 +1,35 @@ +# QEMU pieces shared by the macOS image builders, the vmix CLI and the NixOS module. +# Mirrors OSX-KVM's OpenCore-Boot.sh: q35, Skylake-Client CPU spoof (works on AMD +# hosts too), AppleSMC with the OSK, XHCI keyboard/tablet, AHCI disks, VMware SVGA. +{ pkgs, lib, ... }: +rec { + osk = "ourhardworkbythesewordsguardedpleasedontsteal(c)AppleComputerInc"; + + # OSX-KVM's CPU line for Sequoia/Tahoe; AVX2 capable, Intel vendor for the kernel + defaultCpu = "Skylake-Client,-hle,-rtm,kvm=on,vendor=GenuineIntel,+invtsc,vmware-cpuid-freq=on,+ssse3,+sse4.2,+popcnt,+avx,+aes,+xsave,+xsaveopt,check"; + + # The NIC is pinned to a fixed PCI slot so OpenCore can mark it built-in + # (required for en0 / Apple ID, iMessage, App Store). + nicAddr = "0x12"; + nicDevicePath = "PciRoot(0x0)/Pci(0x12,0x0)"; + + # `-nic user` cannot pin a PCI address, so netdev + device + netArgs = { mac, netdev ? "user,id=net0", extra ? "" }: + "-netdev ${netdev} -device virtio-net-pci,netdev=net0,mac=${mac},bus=pcie.0,addr=${nicAddr}${extra}"; + + # Devices macOS needs (no accel, disks, display adapter or display server here) + deviceArgs = ''-device isa-applesmc,osk="${osk}" -smbios type=2 -device qemu-xhci,id=xhci -device usb-kbd,bus=xhci.0 -device usb-tablet,bus=xhci.0 -device usb-ehci,id=ehci -device ich9-intel-hda -device hda-duplex -device ich9-ahci,id=sata -global ICH9-LPC.disable_s3=1''; + + # macOS has no QXL/virtio-gpu driver; VMware SVGA gives a plain framebuffer + vgaArgs = "-vga vmware"; + + machineArgs = { cpu ? defaultCpu, smp ? 4, memSize ? 4096 }: + "-accel kvm -machine type=q35 -cpu ${cpu} -smp ${toString smp},sockets=1,cores=${toString smp},threads=1 -m ${toString memSize} ${deviceArgs} ${vgaArgs}"; + + # SATA disk on a given port. Store files are read-only: callers create qcow2 overlays. + sataDrive = { id, port, file, format ? "qcow2", extra ? "" }: + "-drive id=${id},if=none,format=${format},file=${file}${extra} -device ide-hd,bus=sata.${toString port},drive=${id}"; + + firmwareArgs = varsFile: + "-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd -drive if=pflash,format=raw,file=${varsFile}"; +} diff --git a/lib/images/macos/helpers/vm-driver.py b/lib/images/macos/helpers/vm-driver.py new file mode 100644 index 0000000..5554073 --- /dev/null +++ b/lib/images/macos/helpers/vm-driver.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +"""vmix macOS VM driver. + +Launches QEMU with a QMP socket and either + + --mode install drives macOS Recovery to a Terminal with keystrokes (screen + settle detection + OCR of the menu bar), types the bootstrap + command and waits for the VM to power itself off + --mode boot waits for the VM to power itself off (customize steps) + +Everything after `--` is the QEMU command line. Screenshots and a log are +written to --debug-dir (default /tmp/vmix-macos/) for troubleshooting. +""" +import argparse +import hashlib +import io +import json +import os +import socket +import subprocess +import sys +import time + +try: + from PIL import Image +except ImportError: # pragma: no cover + Image = None +try: + import pytesseract +except ImportError: # pragma: no cover + pytesseract = None + + +# QEMU qcodes for characters that are not plain alphanumerics +PLAIN = {' ': 'spc', '/': 'slash', '-': 'minus', '.': 'dot', ';': 'semicolon', ',': 'comma', + '=': 'equal', "'": 'apostrophe', '`': 'grave_accent', '[': 'bracket_left', + ']': 'bracket_right', '\\': 'backslash', '\n': 'ret', '\t': 'tab'} +SHIFTED = {'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7', '*': '8', + '(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left', + '}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe', + '<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent'} + + +class Log: + def __init__(self, path): + self.f = open(path, 'a') + self.t0 = time.time() + + def __call__(self, msg): + line = f'[{time.time() - self.t0:7.1f}s] {msg}' + print(f'vmix driver: {line}', flush=True) + self.f.write(line + '\n') + self.f.flush() + + +class QMP: + def __init__(self, path): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(path) + self.f = self.sock.makefile('rwb', buffering=0) + self._read() + self.cmd('qmp_capabilities') + + def _read(self): + while True: + line = self.f.readline() + if not line: + raise EOFError('QMP connection closed') + msg = json.loads(line) + if 'event' in msg: + continue + return msg + + def cmd(self, name, **args): + self.f.write((json.dumps({'execute': name, 'arguments': args}) + '\n').encode()) + r = self._read() + if 'error' in r: + raise RuntimeError(f'QMP {name}: {r["error"]}') + return r.get('return') + + def screendump(self, path): + self.cmd('screendump', filename=path) + + def send_key(self, *keys, hold=80): + self.cmd('send-key', keys=[{'type': 'qcode', 'data': k} for k in keys], **{'hold-time': hold}) + time.sleep(0.15) + + def type_text(self, text): + for ch in text: + if ch.isascii() and ch.isalnum(): + if ch.isupper(): + self.send_key('shift', ch.lower()) + else: + self.send_key(ch) + elif ch in PLAIN: + self.send_key(PLAIN[ch]) + elif ch in SHIFTED: + self.send_key('shift', SHIFTED[ch]) + else: + raise ValueError(f'cannot type {ch!r}') + + +class Screen: + """Screenshot helper: settle detection via hashing, OCR of regions.""" + + def __init__(self, qmp, debug_dir, log): + self.qmp = qmp + self.debug_dir = debug_dir + self.log = log + import tempfile + fd, self.tmp = tempfile.mkstemp(prefix='.shot-', suffix='.ppm', dir=debug_dir) + os.close(fd) + os.chmod(self.tmp, 0o666) + self.n = 0 + self.last_hash = None + self.stable_since = time.time() + self.img = None + + def grab(self): + self.qmp.screendump(self.tmp) + with open(self.tmp, 'rb') as f: + data = f.read() + h = hashlib.sha256(data).hexdigest() + if h != self.last_hash: + self.last_hash = h + self.stable_since = time.time() + self.img = Image.open(io.BytesIO(data)) if Image else None + return self.img + + def stable_for(self): + return time.time() - self.stable_since + + def save(self, tag): + self.n += 1 + path = os.path.join(self.debug_dir, f'{self.n:03d}-{tag}.png') + try: + if self.img is not None: + self.img.save(path) + else: + os.link(self.tmp, path.replace('.png', '.ppm')) + except Exception as e: # noqa: BLE001 + self.log(f'could not save screenshot: {e}') + return path + + def ocr(self, region=None, scale=3, psm=6): + if self.img is None or pytesseract is None: + return '' + img = self.img + if region: + img = img.crop(region) + img = img.convert('L').resize((img.width * scale, img.height * scale), Image.LANCZOS) + try: + return pytesseract.image_to_string(img, config=f'--psm {psm}').lower() + except Exception as e: # noqa: BLE001 + self.log(f'ocr failed: {e}') + return '' + + def is_blank(self): + # black/uniform screen (firmware, boot): nothing to act on + if self.img is None: + return False + lo, hi = self.img.convert('L').resize((64, 48)).getextrema() + return hi - lo < 24 + + def menubar_text(self): + w = self.img.width if self.img else 1024 + return self.ocr((0, 0, w, 40), scale=4, psm=7) + + +def prepare_debug_dir(path): + # nix builds run as different nixbld users: keep the shared dirs world-writable + try: + for d in (os.path.dirname(path), path): + os.makedirs(d, exist_ok=True) + try: + os.chmod(d, 0o1777 if d != path else 0o777) + except OSError: + pass + probe = os.path.join(path, '.probe') + open(probe, 'w').close() + os.unlink(probe) + # a rebuild reuses this dir but runs as a different nixbld user; drop stale + # files so screendumps/PNGs are not blocked by another owner's 0644 files + import glob + for f in glob.glob(os.path.join(path, '*')) + glob.glob(os.path.join(path, '.current*')): + try: + os.unlink(f) + except OSError: + pass + return path + except OSError: + import tempfile + alt = tempfile.mkdtemp(prefix='vmix-macos-') + print(f'vmix driver: {path} not writable, using {alt}', flush=True) + return alt + + +def launch(qemu_args, qmp_sock, log): + if os.path.exists(qmp_sock): + os.unlink(qmp_sock) + args = list(qemu_args) + ['-qmp', f'unix:{qmp_sock},server,nowait'] + log('launching: ' + ' '.join(args)) + proc = subprocess.Popen(args) + deadline = time.time() + 60 + while not os.path.exists(qmp_sock): + if proc.poll() is not None: + return proc, None + if time.time() > deadline: + proc.kill() + raise RuntimeError('QEMU did not create the QMP socket') + time.sleep(0.2) + time.sleep(0.5) + return proc, QMP(qmp_sock) + + +def open_terminal(qmp, log): + # Ctrl-F2 focuses the menu bar; typing jumps to the menu whose title starts + # with that letter (Utilities), Down opens it, "t" jumps to Terminal. + log('opening Terminal via menu bar (ctrl-f2, u, down, t, ret)') + qmp.send_key('ctrl', 'f2') + time.sleep(1.0) + qmp.send_key('u') + time.sleep(0.7) + qmp.send_key('down') + time.sleep(0.7) + qmp.send_key('t') + time.sleep(0.7) + qmp.send_key('ret') + + +def run_install(args, proc, qmp, log): + """Drive the install VM to completion. + + OpenCore shows a boot picker on every (re)boot and does not always auto-boot, + so on any settled picker we press Return to boot the highlighted macOS entry + (aux entries are hidden; during the install phases startosinstall blesses the + right default). That runs on EVERY iteration, because the install reboots + several times after we hand off to startosinstall. Before we have typed the + bootstrap command we also drive Recovery: language/welcome -> Return, the + Recovery window -> open Terminal, Terminal -> type the command. + """ + RECOVERY_BODY = ('reinstall', 'disk utility', 'restore from', 'recovery assistant', + 'macos utilities') + PICKER_BODY = ('base system', 'macos installer', 'rel-1', 'rel-0') # OpenCore picker + LANG_BODY = ('language', 'select your', 'main language', 'country or region', + 'welcome', 'get started', 'choose your') + screen = Screen(qmp, args.debug_dir, log) + start = time.time() + typed_at = None + terminal_attempts = 0 + last_periodic = 0 + last_progress = start + blind_done = False + while True: + rc = proc.poll() + if rc is not None: + return rc + now = time.time() + if now - start > args.timeout: + try: + screen.grab(); screen.save('timeout') + except Exception: # noqa: BLE001 + pass + log('timeout reached, killing QEMU') + proc.kill() + return 124 + time.sleep(args.interval) + try: + screen.grab() + except Exception as e: # noqa: BLE001 + log(f'screendump failed ({e}), assuming QEMU is exiting') + time.sleep(2) + continue + if now - last_periodic > args.periodic: + last_periodic = now + screen.save('periodic') + if now - start < args.min_boot or screen.stable_for() < args.settle: + continue + if screen.is_blank(): + last_progress = now + continue + + top = screen.menubar_text() + body = screen.ocr() + log(f'settled: menubar={top.strip()!r} body~={" ".join(body.split())[:80]!r}') + + # OpenCore boot picker — always handle it (the install reboots many times) + if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY): + screen.save('picker') + log('OpenCore boot picker, pressing Return to boot the default macOS entry') + qmp.send_key('ret') + last_progress = now + screen.stable_since = time.time() + continue + + # once the bootstrap command is typed, only the picker (above) matters + if typed_at is not None: + continue + + if 'terminal' in top: + screen.save('terminal') + log(f'typing bootstrap command: {args.command!r}') + qmp.type_text(args.command + '\n') + typed_at = time.time() + continue + + acted = False + if 'utilities' in top or 'recovery' in top or any(k in body for k in RECOVERY_BODY): + screen.save('recovery') + terminal_attempts += 1 + log(f'recovery window (attempt {terminal_attempts}), opening Terminal') + open_terminal(qmp, log) + if terminal_attempts >= 3: + time.sleep(8) + log('typing bootstrap command (Terminal assumed open)') + qmp.type_text(args.command + '\n') + typed_at = time.time() + continue + acted = True + elif any(k in body for k in LANG_BODY): + screen.save('language') + log('language/welcome screen, pressing Return') + qmp.send_key('ret') + acted = True + + if acted: + last_progress = now + screen.stable_since = time.time() + elif now - last_progress > args.settle * args.max_actions and not blind_done: + blind_done = True + screen.save('blind') + log('nothing recognised for a long time, blind sequence') + qmp.send_key('ret') + time.sleep(20) + open_terminal(qmp, log) + time.sleep(10) + qmp.type_text(args.command + '\n') + typed_at = time.time() + + +def run_boot(args, proc, qmp, log): + screen = Screen(qmp, args.debug_dir, log) + start = time.time() + last_periodic = 0 + while True: + rc = proc.poll() + if rc is not None: + return rc + if time.time() - start > args.timeout: + try: + screen.grab() + screen.save('timeout') + except Exception: # noqa: BLE001 + pass + log('timeout reached, killing QEMU') + proc.kill() + return 124 + time.sleep(args.interval) + if time.time() - last_periodic > args.periodic: + last_periodic = time.time() + try: + screen.grab() + screen.save('periodic') + except Exception as e: # noqa: BLE001 + log(f'screendump failed ({e})') + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument('--mode', choices=['install', 'boot'], required=True) + p.add_argument('--name', default='macos') + p.add_argument('--debug-dir', default=None) + p.add_argument('--timeout', type=int, default=4 * 3600, help='seconds before QEMU is killed') + p.add_argument('--interval', type=float, default=5.0, help='seconds between screenshots') + p.add_argument('--periodic', type=float, default=120.0, help='seconds between saved debug screenshots') + p.add_argument('--settle', type=float, default=12.0, help='seconds a screen must be unchanged to act on it') + p.add_argument('--min-boot', type=float, default=45.0, help='seconds before the first action') + p.add_argument('--max-actions', type=int, default=8) + p.add_argument('--command', default='diskutil mount VMIX;sh /Volumes/VMIX/run.sh') + p.add_argument('qemu', nargs=argparse.REMAINDER) + args = p.parse_args() + qemu_args = args.qemu[1:] if args.qemu and args.qemu[0] == '--' else args.qemu + if not qemu_args: + p.error('QEMU command line required after --') + + args.debug_dir = prepare_debug_dir(args.debug_dir or f'/tmp/vmix-macos/{args.name}') + log = Log(os.path.join(args.debug_dir, 'driver.log')) + log(f'mode={args.mode} debug-dir={args.debug_dir} ocr={"yes" if pytesseract else "no"}') + qmp_sock = os.path.join(args.debug_dir, 'qmp.sock') + + proc, qmp = launch(qemu_args, qmp_sock, log) + if qmp is None and '-display' in qemu_args and 'sdl' in qemu_args: + # SDL could not open a window: retry headless + log(f'QEMU exited early ({proc.returncode}) with SDL, retrying headless') + i = qemu_args.index('-display') + qemu_args = qemu_args[:i] + ['-display', 'none'] + qemu_args[i + 2:] + proc, qmp = launch(qemu_args, qmp_sock, log) + if qmp is None: + log(f'QEMU exited immediately with {proc.returncode}') + return proc.returncode or 1 + + try: + if args.mode == 'install': + rc = run_install(args, proc, qmp, log) + else: + rc = run_boot(args, proc, qmp, log) + finally: + if proc.poll() is None: + proc.kill() + log(f'QEMU exited with {rc}') + return rc + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/lib/images/macos/helpers/vmix-readback.nix b/lib/images/macos/helpers/vmix-readback.nix new file mode 100644 index 0000000..1121c6c --- /dev/null +++ b/lib/images/macos/helpers/vmix-readback.nix @@ -0,0 +1,14 @@ +# Shell snippet: read the vmix agent's result off the VMIX HFS+ volume of a raw +# disk image (${image}). Prints the guest logs and sets STATUS to the contents +# of vmix-run.status ("0" on success). Uses libguestfs (mtools is FAT-only). The +# agent unmounts VMIX cleanly before shutdown, so a read-only mount is safe. +{ ... }: +image: +'' + echo "=== vmix: reading result from ${image} ===" + for f in install.log system-install.log vmix-run.log vmix-agent.log; do + C=$(guestfish --ro -a ${image} -m /dev/sda1 cat /$f 2>/dev/null || true) + [ -n "$C" ] && { echo "--- $f ---"; printf '%s\n' "$C"; } + done + STATUS=$(guestfish --ro -a ${image} -m /dev/sda1 cat /vmix-run.status 2>/dev/null | tr -d '[:space:]' || true) +'' diff --git a/lib/images/macos/helpers/xar-toc.py b/lib/images/macos/helpers/xar-toc.py new file mode 100644 index 0000000..42e1297 --- /dev/null +++ b/lib/images/macos/helpers/xar-toc.py @@ -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) diff --git a/lib/images/macos/tahoe/default.nix b/lib/images/macos/tahoe/default.nix new file mode 100644 index 0000000..cd9fa7c --- /dev/null +++ b/lib/images/macos/tahoe/default.nix @@ -0,0 +1,8 @@ +{ pkgs, lib, system, macos, ... }: +let + up = macos.upstream.tahoe; + # 18 GB full installer (App Store InstallAssistant.pkg, pinned) + installer = pkgs.fetchurl { inherit (up.installer) url hash; name = "InstallAssistant.pkg"; }; + recovery = macos.fetchRecovery { inherit (up.recovery) shortname sha256; }; +in +import ./images.nix { inherit pkgs lib system macos installer recovery; } diff --git a/lib/images/macos/tahoe/images.nix b/lib/images/macos/tahoe/images.nix new file mode 100644 index 0000000..a3e6868 --- /dev/null +++ b/lib/images/macos/tahoe/images.nix @@ -0,0 +1,14 @@ +# Pre-built macOS Tahoe (26) images +# Pipeline: makeImage (unattended install, vmix agent) → templates → generalize +{ pkgs, lib, system, macos, installer, recovery, ... }: +with macos; +rec { + upstream = makeImage { + name = "macos-tahoe"; + inherit installer recovery; + }; + + basic = customizeImageFold upstream templates.bundles.basic; + + remote = customizeImageFold upstream templates.bundles.remote; +} diff --git a/lib/images/macos/templates/default.nix b/lib/images/macos/templates/default.nix new file mode 100644 index 0000000..1edb923 --- /dev/null +++ b/lib/images/macos/templates/default.nix @@ -0,0 +1,15 @@ +{ pkgs, lib, ... }: +rec { + generalize = import ./generalize.nix { inherit pkgs lib; }; + + essentials = { + remoteAccess = import ./essentials/remote-access.nix { }; + noUpdates = import ./essentials/no-updates.nix { }; + performance = import ./essentials/performance.nix { }; + }; + + bundles = { + basic = with essentials; [ noUpdates performance ]; + remote = with essentials; [ noUpdates performance remoteAccess ]; + }; +} diff --git a/lib/images/macos/templates/essentials/no-updates.nix b/lib/images/macos/templates/essentials/no-updates.nix new file mode 100644 index 0000000..736f824 --- /dev/null +++ b/lib/images/macos/templates/essentials/no-updates.nix @@ -0,0 +1,15 @@ +# Disable automatic macOS / App Store updates (an OTA update would also need +# the RestrictEvents kext to work in a VM) +{ ... }: +{ + name = "no-updates"; + script = '' + softwareupdate --schedule off || true + defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool false + defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -bool false + defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticallyInstallMacOSUpdates -bool false + defaults write /Library/Preferences/com.apple.SoftwareUpdate ConfigDataInstall -bool false + defaults write /Library/Preferences/com.apple.SoftwareUpdate CriticalUpdateInstall -bool false + defaults write /Library/Preferences/com.apple.commerce AutoUpdate -bool false + ''; +} diff --git a/lib/images/macos/templates/essentials/performance.nix b/lib/images/macos/templates/essentials/performance.nix new file mode 100644 index 0000000..f6d3a99 --- /dev/null +++ b/lib/images/macos/templates/essentials/performance.nix @@ -0,0 +1,11 @@ +# Less background work in a VM: no Spotlight indexing, no Time Machine, no sleep +{ ... }: +{ + name = "performance"; + script = '' + mdutil -a -i off || true + tmutil disable || true + pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 womp 0 || true + defaults write /Library/Preferences/com.apple.loginwindow DisableScreenLockImmediate -bool true + ''; +} diff --git a/lib/images/macos/templates/essentials/remote-access.nix b/lib/images/macos/templates/essentials/remote-access.nix new file mode 100644 index 0000000..51bb432 --- /dev/null +++ b/lib/images/macos/templates/essentials/remote-access.nix @@ -0,0 +1,11 @@ +# Enable SSH (Remote Login) and Screen Sharing (VNC on 5900 inside the guest) +{ ... }: +{ + name = "remote-access"; + script = '' + systemsetup -setremotelogin on >/dev/null 2>&1 || launchctl load -w /System/Library/LaunchDaemons/ssh.plist + launchctl load -w /System/Library/LaunchDaemons/com.apple.screensharing.plist + # allow all local users to screen share + defaults write /var/db/launchd.db/com.apple.launchd/overrides.plist com.apple.screensharing -dict Disabled -bool false 2>/dev/null || true + ''; +} diff --git a/lib/images/macos/templates/generalize.nix b/lib/images/macos/templates/generalize.nix new file mode 100644 index 0000000..ac59348 --- /dev/null +++ b/lib/images/macos/templates/generalize.nix @@ -0,0 +1,103 @@ +# Generalize a macOS image: create the user, auto-login, hostname, timezone, +# suppress Setup Assistant prompts, then remove the vmix agent. Also gives the +# image a fresh SMBIOS identity (serial/MLB from macserial, MAC + UUID from +# `seed`) so every generalized VM looks like a distinct Mac to Apple ID/iMessage. +# Usage: (templates.generalize { username = "User"; password = ""; hostname = "MAC"; }) +# delayOobeRun = true: no user, Setup Assistant runs on first real boot (like Windows OOBE) +{ pkgs, lib, ... }: +{ + username ? "User", + password ? "", + fullName ? username, + autoLogon ? true, + hostname ? "MAC-VM", + locale ? "en-US", + timezone ? "UTC", + delayOobeRun ? false, + # SMBIOS identity; anything unset is generated + model ? null, + serial ? null, + mlb ? null, + uuid ? null, + mac ? null, + seed ? "${hostname}-${username}", + # accepted for CLI parity with Windows, not supported on macOS + bgColor ? null, +}: +let + kcpasswordFile = pkgs.runCommand "kcpassword" { nativeBuildInputs = [ pkgs.python3 ]; } '' + python3 ${../guest/kcpassword.py} ${lib.escapeShellArg password} > $out + ''; + macLocale = builtins.replaceStrings [ "-" ] [ "_" ] locale; + tempPassword = "vmix-temp-password"; + setupKeys = [ + "DidSeeCloudSetup" "DidSeeSiriSetup" "DidSeePrivacy" "DidSeeTouchIDSetup" "DidSeeAppearanceSetup" + "DidSeeScreenTime" "DidSeeAccessibility" "DidSeeTrueTonePrivacy" "DidSeeActivationLock" + "DidSeeiCloudLoginForStorageServices" "DidSeeSyncSetup" "DidSeeSyncSetup2" "DidSeeAppleIDSyncSetup" + "DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "SkipFirstLoginOptimization" + ]; +in +{ + name = if delayOobeRun then "generalize-delay-oobe" else "generalize"; + files = [ { source = kcpasswordFile; name = "kcpassword"; } ]; + smbios = { inherit seed; } // lib.filterAttrs (_: v: v != null) { inherit model serial mlb uuid mac; }; + script = '' + set -x + ${lib.optionalString (bgColor != null) ''echo "vmix: bgColor is not supported on macOS, ignoring"''} + + ${lib.optionalString (!delayOobeRun) '' + # --- user account (admin) + if ! id "${username}" >/dev/null 2>&1; then + sysadminctl -addUser "${username}" -fullName ${lib.escapeShellArg fullName} \ + -password ${lib.escapeShellArg (if password == "" then tempPassword else password)} \ + -admin -home "/Users/${username}" || exit 1 + ${lib.optionalString (password == "") '' + dscl . -passwd "/Users/${username}" "${tempPassword}" "" || echo "vmix: WARNING: could not set an empty password, password is '${tempPassword}'" + ''} + fi + ${lib.optionalString autoLogon '' + defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser "${username}" + cp /Volumes/VMIX/kcpassword /etc/kcpassword + chmod 600 /etc/kcpassword + chown root:wheel /etc/kcpassword + ''} + # --- no Setup Assistant / "What's new" prompts at first login + P="/Users/${username}/Library/Preferences/com.apple.SetupAssistant" + VER=$(sw_vers -productVersion) + BUILD=$(sw_vers -buildVersion) + for k in ${lib.concatStringsSep " " setupKeys}; do + defaults write "$P" "$k" -bool true + done + defaults write "$P" GestureMovieSeen none + defaults write "$P" LastSeenCloudProductVersion "$VER" + defaults write "$P" LastSeenBuddyBuildVersion "$BUILD" + defaults write "$P" LastSeenSiriProductVersion "$VER" + defaults write "$P" LastPreLoginTasksPerformedVersion "$VER" + defaults write "/Users/${username}/Library/Preferences/.GlobalPreferences" AppleLocale "${macLocale}" + chown -R "${username}" "/Users/${username}/Library/Preferences" + ''} + + # --- machine identity + scutil --set ComputerName "${hostname}" + scutil --set HostName "${hostname}" + scutil --set LocalHostName "${hostname}" + defaults write /Library/Preferences/.GlobalPreferences AppleLocale "${macLocale}" + systemsetup -settimezone "${timezone}" >/dev/null 2>&1 || ln -sfn "/usr/share/zoneinfo/${timezone}" /etc/localtime + + # --- never sleep (VM) + pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 || true + + # --- use the whole (possibly grown) disk + STORE=$(diskutil info / | awk '/APFS Physical Store/ {print $NF}') + [ -n "$STORE" ] && diskutil apfs resizeContainer "$STORE" 0 || true + + ${lib.optionalString delayOobeRun '' + # Setup Assistant will run on the next boot + rm -f /var/db/.AppleSetupDone + ''} + + # --- the agent's job is done: remove it (this is the last vmix step) + rm -f /Library/LaunchDaemons/ch.vmix.agent.plist + rm -rf /Library/vmix + ''; +} diff --git a/lib/images/macos/upstream.json b/lib/images/macos/upstream.json new file mode 100644 index 0000000..a67c3bd --- /dev/null +++ b/lib/images/macos/upstream.json @@ -0,0 +1,31 @@ +{ + "x86_64-linux": { + "tahoe": { + "installer": { + "version": "26.6.2", + "build": "25G83", + "url": "https://swcdn.apple.com/content/downloads/37/33/140-93587-A_GRFFH93NOL/f944yaqo1cjhh2m0kxrl0zhcpg9yb9qphv/InstallAssistant.pkg", + "hash": "sha256-N2kj10lM+jK2nzg7z9zkau7bYJ/mokLqEqbgFb+3e6Q=" + }, + "recovery": { + "shortname": "tahoe", + "sha256": "edddd0d5869caaa12e29e6996a04f11590280580976a119dbd42c24fa62fe18e" + } + }, + "opencore": { + "image": { + "url": "https://raw.githubusercontent.com/kholia/OSX-KVM/4c378a4b5e0b219783683012bec680325eb40719/OpenCore/OpenCore.qcow2", + "sha256": "6ed36c0c2a4206ccc695f6b1a734a1cc6f94d288b0517c705d351c63cb92a6f3" + }, + "pkg": { + "version": "1.0.7", + "url": "https://github.com/acidanthera/OpenCorePkg/releases/download/1.0.7/OpenCore-1.0.7-RELEASE.zip", + "sha256": "2ffab6ebf58c7aefb0bcb3a1a385d207746823d6dd87d44bd666e1286939943e" + }, + "fetchRecoveryScript": { + "url": "https://raw.githubusercontent.com/kholia/OSX-KVM/4c378a4b5e0b219783683012bec680325eb40719/fetch-macOS-v2.py", + "sha256": "39ac6d26bd265f5d32198062f515ad15ef93afb7a74e702be2b008090d5bd5f3" + } + } + } +} diff --git a/nixos/vms/config.nix b/nixos/vms/config.nix index 4dc86a7..6cde994 100644 --- a/nixos/vms/config.nix +++ b/nixos/vms/config.nix @@ -77,13 +77,23 @@ let hasOsDisk = vmCfg.disks.os.file != null; - # Auto-detect Windows from _vmixOsType marker on the disk image - isWindows = vmCfg.windows.enable || (hasOsDisk && (vmCfg.disks.os.file._vmixOsType or "linux") == "windows"); + # Auto-detect Windows / macOS from _vmixOsType marker on the disk image + osType = if hasOsDisk then vmCfg.disks.os.file._vmixOsType or "linux" else "linux"; + isWindows = vmCfg.windows.enable || osType == "windows"; + isMacos = vmCfg.macos.enable || osType == "macos"; + macosMac = if vmCfg.macos.mac != null then vmCfg.macos.mac + else if hasOsDisk then vmCfg.disks.os.file.macAddress or "52:54:00:c9:18:27" + else "52:54:00:c9:18:27"; + # OpenCore marks this PCI slot built-in (en0) + macosNicPlacement = ",bus=pcie.0,addr=${vmixLib.macos.qemu.nicAddr}"; + cpuArg = if isMacos && vmCfg.cpu.model == "host" then vmCfg.macos.cpu + else "${vmCfg.cpu.model}${optionalString (vmCfg.cpu.hideVirtualized && !isMacos) ",kvm=off,hv_vendor_id=1234567890ab,-hypervisor"}"; # Interrupt remapping in the virtual IOMMU only works on a split irqchip, # so viommu wins over the full in-kernel irqchip hideVirtualized asks for. + # (macOS keeps the default irqchip: hideVirtualized does not apply to it.) machineIrqchipArg = if vmCfg.pci.viommu.enable then ",kernel-irqchip=split" - else optionalString vmCfg.cpu.hideVirtualized ",kernel_irqchip=on"; + else optionalString (vmCfg.cpu.hideVirtualized && !isMacos) ",kernel_irqchip=on"; # Functions of one physical device have to reach the guest as functions # of one device too. Giving each address its own root port splits a GPU # from its own HDMI audio, and Navi cannot then reset or power-manage @@ -108,9 +118,9 @@ let ''; }; - # Windows VMs: use disk image as-is (customization done at image build time) + # Windows/macOS VMs: use disk image as-is (customization done at image build time) storeImage = if !hasOsDisk then null - else if isWindows then vmCfg.disks.os.file + else if isWindows || isMacos then vmCfg.disks.os.file else linuxOsImage; # When persist = true, QEMU needs a mutable disk outside /nix/store. @@ -208,7 +218,7 @@ let -m ${toString vmCfg.mem.size} \ ${optionalString vmCfg.mem.balloon "-device virtio-balloon-pci"} \ -smp cores=${toString vmCfg.cpu.cores} \ - -cpu ${vmCfg.cpu.model}${optionalString vmCfg.cpu.hideVirtualized ",kvm=off,hv_vendor_id=1234567890ab,-hypervisor"} \ + -cpu ${cpuArg} \ -machine type=${vmCfg.pc.type}${machineIrqchipArg} \ ${optionalString vmCfg.bios.efi "-bios ${pkgs.OVMF.fd}/FV/OVMF.fd"} \ ${optionalString vmCfg.bios.tpm "-chardev socket,id=chrtpm,path=/tmp/mytpm-sock -tpmdev emulator,id=tpm0,chardev=chrtpm -device tpm-tis,tpmdev=tpm0"} \ @@ -218,7 +228,13 @@ let -device qemu-xhci -device usb-tablet \ -global ICH9-LMB.disable_s3=1 -global ICH9-LMB.disable_s4=1 \ ''} \ - ${optionalString hasOsDisk "-drive file=${osDiskPath},format=qcow2,if=virtio${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"}"} \ + ${# macOS: AppleSMC + OSK, USB keyboard/tablet, AHCI system disk, VMware SVGA (no SPICE display device) + optionalString isMacos '' + ${vmixLib.macos.qemu.deviceArgs} ${optionalString (!vmCfg.spice.enable) vmixLib.macos.qemu.vgaArgs} \ + ''} \ + ${optionalString hasOsDisk (if isMacos + then "-drive id=os,if=none,file=${osDiskPath},format=qcow2${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"} -device ide-hd,bus=sata.0,drive=os" + else "-drive file=${osDiskPath},format=qcow2,if=virtio${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"}")} \ ${optionalString (vmCfg.disks.iso.file != null) "-drive file=${toString vmCfg.disks.iso.file},media=cdrom,readonly=on"} \ ${concatMapStrings (diskCfg: '' -drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \ @@ -228,12 +244,12 @@ let '') vmCfg.shares)} \ ${optionalString cfg.networks.user.enable " -netdev user,id=user \ - -device ${vmCfg.nicModel},netdev=user \ + -device ${vmCfg.nicModel},netdev=user${optionalString isMacos ",mac=${macosMac}${macosNicPlacement}"} \ "} \ - ${concatMapStrings (tapCfg: '' - -device ${vmCfg.nicModel},netdev=lan-${tapCfg.name},mac=${tapCfg.mac} \ + ${concatStrings (imap1 (i: tapCfg: '' + -device ${vmCfg.nicModel},netdev=lan-${tapCfg.name},mac=${tapCfg.mac}${optionalString (isMacos && i == 1 && !cfg.networks.user.enable) macosNicPlacement} \ -netdev tap,id=lan-${tapCfg.name},ifname=${tapCfg.iface},script=no,downscript=no \ - '') allTaps} \ + '') allTaps)} \ ${concatStrings (imap1 (i: macvtap: '' -device ${vmCfg.nicModel},netdev=macvtap-${macvtap.name},mac=$(ip l show ${macvtap.iface} | awk '/link\/ether/{print $2}') \ -netdev tap,id=macvtap-${macvtap.name},fd=${toString (i+2)} ${toString (i+2)}<>/dev/tap$(ip l show ${macvtap.iface} | awk -F':' '/${macvtap.iface}/{print $1}') \ diff --git a/nixos/vms/submoduleOptions.nix b/nixos/vms/submoduleOptions.nix index c7cbd1a..60f6cbe 100644 --- a/nixos/vms/submoduleOptions.nix +++ b/nixos/vms/submoduleOptions.nix @@ -249,6 +249,24 @@ with lib; }; }; + macos = { + enable = mkOption { + type = types.bool; + default = false; + description = "Enable macOS QEMU flags (OpenCore/AppleSMC, Skylake CPU spoof, AHCI disk, pinned NIC). Auto-enabled when disks.os.file carries _vmixOsType = \"macos\" metadata."; + }; + cpu = mkOption { + type = types.str; + default = vmixLib.macos.qemu.defaultCpu; + description = "QEMU -cpu string used for macOS VMs when cpu.model is \"host\"."; + }; + mac = mkOption { + type = types.nullOr types.str; + default = null; + description = "MAC address of en0. Defaults to the image's macAddress (must match OpenCore's ROM for Apple ID / iMessage)."; + }; + }; + tpm = { stateDir = mkOption { type = types.str; From 58a317f5d2cb74dba0996ca69e9cac6d1413810d Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 9 Sep 2026 02:49:21 -0300 Subject: [PATCH 02/23] macOS Tahoe: working fully-offline unattended install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base image (macos.images.tahoe.upstream) now installs and boots end to end with no dependency on Apple's servers — proven on the KVM host: the finished qcow2 boots standalone (OpenCore from its own ESP) to the macOS 26.6.2 loginwindow. Install driving (vm-driver.py, screenshot + OCR over QMP): - map the whole InstallAssistant.pkg as a raw disk and dd it byte-exact into the app as SharedSupport.dmg (it is a pkgdmg: xar + koly footer — the bare xar member fails startosinstall with "pkgdmg is missing a footer") - offline install: no NIC + /etc/hosts blackhole of Apple install/verify endpoints so startosinstall's calls fail fast instead of hanging (SecureBootModel=Disabled allows the sealed-volume install offline) - keep the recovery display awake with a tiny mouse jiggle; coarse settle fingerprint so the cursor is not seen as a change - guest watchdog re-erases/retries a startosinstall attempt that stalls or runs too long (prepare is intermittently slow) - disk-aware boot watchdog: QMP system_reset only when the screen is dark AND the disk is idle (never interrupts a slow-but-working boot); recovery-restart if a post-prepare reboot lands back on recovery - detect the bright loginwindow and power the VM down (install complete); a black + disk-idle screen is treated as a completed halt - copy OpenCore into the image ESP so it boots standalone recovery.file pins a content-addressed local BaseSystem.dmg (Apple's CDN load-balances Sequoia/Tahoe during the rollout). fetchRecovery retries to the pinned hash when used instead. Not yet done: .generalize (user creation) — the first-boot agent LaunchDaemon is blocked by Ventura+ Background Task Management on headless boots; next step is offline user injection from the agent pkg postinstall. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF --- lib/images/macos/README.md | 82 +++---- lib/images/macos/guest/agent.sh | 22 +- lib/images/macos/guest/vmix-install.sh | 114 ++++++---- lib/images/macos/helpers/customizeImage.nix | 2 +- lib/images/macos/helpers/makeAgentPkg.nix | 52 ++++- lib/images/macos/helpers/makeImage.nix | 34 +-- lib/images/macos/helpers/vm-driver.py | 224 +++++++++++++++++++- lib/images/macos/tahoe/default.nix | 8 +- lib/images/macos/upstream.json | 5 +- 9 files changed, 415 insertions(+), 128 deletions(-) diff --git a/lib/images/macos/README.md b/lib/images/macos/README.md index 8fd3f15..9672842 100644 --- a/lib/images/macos/README.md +++ b/lib/images/macos/README.md @@ -86,49 +86,53 @@ is used as for Windows. * Software updates inside the VM are disabled by the `noUpdates` template (OTA updates in a VM need the RestrictEvents kext). -## Current status (2026-09-08) +## Current status (2026-09-09): working offline install -Everything up to and including the macOS Installer's *prepare* phase is working and -proven end to end on the `root@daku.home` KVM host: +`macos.images.tahoe.upstream` builds a bootable, installed macOS Tahoe 26.6.2 +qcow2 **fully offline** on the KVM host — no dependency on Apple's servers at build +time, just the pinned local `InstallAssistant.pkg` and `BaseSystem.dmg`. The +finished image boots standalone (OpenCore from its own ESP) to the macOS +loginwindow. Serial/MLB/UUID/ROM are per-image for Apple ID / iMessage. -* OpenCore ESP per-image SMBIOS (serial/MLB via macserial, ROM=MAC, UUID), boot - disk, `.contentVisibility` to hide the OC self-entry — **works** (`ocvalidate` clean). -* Tahoe recovery boots via OpenCore; `vm-driver.py` drives it entirely by - screenshots + OCR: handles the OpenCore picker, opens Terminal (Ctrl-F2 → - Utilities → Terminal), types the bootstrap command — **works**. -* HFS+ `VMIX` volume mounts in Recovery; the install script erases the disk as - APFS "Macintosh HD", lays down the host-extracted `Install macOS Tahoe.app` - skeleton and `dd`s a **byte-exact** `SharedSupport.dmg` from a raw disk mapped - to that byte range of the pkg (verified `sha256` identical to Apple's) — **works**. -* `startosinstall` runs, `osinstallersetupd` **mounts SharedSupport.dmg**, reads - the MobileAsset bundle (`IA OS Version: 26.6.2, Build 25G83`), loads the - 641-product SU catalog from swscan.apple.com (so guest networking works), and - logs `Machine is VM, will assume APFS is supported`. +How the install is driven (`vm-driver.py`, all by screenshot + OCR over QMP): -### Blocker 1 — `OSISVerifyBaseSystemOperation: pkgdmg is missing a footer` +* The whole `InstallAssistant.pkg` is mapped as a raw disk (it is a "pkgdmg": + xar + koly footer) and `dd`'d byte-exact into the app as `SharedSupport.dmg` — + extracting the bare xar member fails startosinstall with "pkgdmg missing a footer". +* No NIC during install + `/etc/hosts` blackhole of Apple's install/verify + endpoints, so `startosinstall`'s network calls fail fast instead of hanging — + offline prepare, no external dependency. `SecureBootModel=Disabled` lets the + sealed volume install without online personalization. +* The recovery display is kept awake with a tiny mouse jiggle (a lone keypress + does not reset display sleep, and the sleeping display swallows the menu-nav + keystrokes); the settle detector uses a coarse fingerprint so the jiggling + cursor is not seen as a screen change. +* startosinstall prepare is intermittently slow/stalls; a guest watchdog kills and + re-erases/retries an attempt that stalls or runs > 9 min. +* First boot in QEMU intermittently hangs at the Apple logo; a disk-aware watchdog + (`--progress-file`) issues a QMP `system_reset` only when the screen is dark AND + the disk is idle, so a slow-but-working boot is never interrupted. +* The install reaching the (bright) loginwindow is detected by brightness (the + faint gray "password" text does not OCR) and the driver powers the VM down — + the image is installed. macOS `shutdown -h now` halts to black without an ACPI + power-off, so a black+disk-idle screen is also treated as a completed halt. +* OpenCore is then copied into the image's own ESP so it boots standalone with OVMF. -After mounting the dmg, `osinstallersetupd` runs a verify step that treats -`SharedSupport.dmg` as a *pkgdmg* (`Getting offset for dmg in pkg`) and fails -`pkgdmg is missing a footer` → `Installation cannot proceed because the installer -is damaged` (Code 255). But this InstallAssistant `SharedSupport.dmg` is a **plain -UDIF** image (first bytes `eb 58 90 …`, not `xar!`), and it is **byte-identical to -Apple's** — so this is not truncation or corruption (earlier truncation, from -Recovery's `xar` mishandling an 18 GB member, was fixed by the byte-range `dd`). -It is a macOS-internal verification that rejects the plain-UDIF SharedSupport -when running Tahoe's `startosinstall` in this recovery/VM. Network is fine (the -catalog loaded), so it is not the firewall case commonly cited for this error. +### Recovery source -Leads not yet tried: driving the Tahoe recovery's **network "Reinstall macOS"** -(GUI, downloads assets at install time — sidesteps the local-dmg verify); a -different SMBIOS/board; or a newer OpenCore/kext combo. This is cutting-edge -(Tahoe shipped 2025-09) and the hackintosh community is still working it out. +`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` (same path +from the same bytes). Set `recovery.sha256` and remove `recovery.file` to fetch it +from Apple instead (subject to the CDN rollout). -### Blocker 2 — Tahoe recovery availability on Apple's CDN +### Not yet done: generalize / user creation -`osrecovery.apple.com` is load-balancing `latest` across CDN nodes during the -Tahoe rollout: most requests return the **Sequoia** 15.4.1 BaseSystem -(`082-33203`), some return **Tahoe** 26.6.2 (`140-93589`). `fetchRecovery` -retries until it gets the pinned Tahoe hash, but that can exhaust its attempts -when Tahoe is rare. The robust fix is to self-host the verified Tahoe -`BaseSystem.dmg` (960530321 bytes, `sha256 edddd0d5…`, confirmed 26.6.2) the way -the Win10 ISO is hosted on git.sagar.ch, and point `fetchRecovery` at it. +The base image installs and boots to loginwindow. `.generalize` (user creation, +auto-login, hostname) relies on the vmix agent LaunchDaemon running on first boot, +but macOS Ventura+ Background Task Management does not auto-run a headless +third-party daemon, and neither the pkg `launchctl bootstrap` (installer domain +only) nor a cron `@reboot` reliably triggered it. The robust next step is to inject +the user record + settings offline from the agent pkg's postinstall (which runs as +root on the target during install), instead of a first-boot daemon. diff --git a/lib/images/macos/guest/agent.sh b/lib/images/macos/guest/agent.sh index e89146e..3e2ba02 100644 --- a/lib/images/macos/guest/agent.sh +++ b/lib/images/macos/guest/agent.sh @@ -1,14 +1,25 @@ #!/bin/sh -# vmix agent: LaunchDaemon that runs at every boot as root. If a volume named -# VMIX carrying vmix-run.sh is attached, run it, record the result on the -# volume and power off. Without the volume it is a no-op (normal boot). -# This is the macOS counterpart of the Windows Audit Mode RunOnce script. +# vmix agent: LaunchDaemon that runs at every boot as root (installed by the vmix +# agent pkg via startosinstall --installpackage). If a volume named VMIX carrying +# vmix-run.sh is attached, run it, record the result on the volume and power off. +# Without the volume it is a no-op (normal boot). Counterpart of the Windows Audit +# Mode RunOnce script; the generalize step removes it once the image is sealed. LOG=/var/log/vmix-agent.log exec >>"$LOG" 2>&1 echo "=== vmix agent: $(date) ===" +# The agent pkg bootstraps this daemon during the OS install (to approve it past +# Background Task Management, so launchd runs it at first boot). Don't do the job +# in that installer environment — only on the installed system's first boot. +if pgrep -x bootinstalld >/dev/null 2>&1 || pgrep -qx "Installer Progress" 2>/dev/null \ + || [ -d /System/Volumes/Update/mnt1 ]; then + echo "vmix agent: OS installer is running, skipping" + exit 0 +fi +# let DiskArbitration settle so the VMIX volume is mountable +sleep 5 V=/Volumes/VMIX i=0 -while [ ! -f "$V/vmix-run.sh" ] && [ $i -lt 60 ]; do +while [ ! -f "$V/vmix-run.sh" ] && [ $i -lt 30 ]; do diskutil mount VMIX >/dev/null 2>&1 sleep 2 i=$((i + 1)) @@ -24,6 +35,7 @@ rc=$? echo "vmix agent: vmix-run.sh exited $rc" echo "$rc" >"$V/vmix-run.status" cp "$LOG" "$V/vmix-agent.log" 2>/dev/null +cp /var/log/vmix-agent-install.log "$V/vmix-agent-install.log" 2>/dev/null sync sleep 2 diskutil unmount force "$V" >/dev/null 2>&1 diff --git a/lib/images/macos/guest/vmix-install.sh b/lib/images/macos/guest/vmix-install.sh index d3b648d..486f28d 100644 --- a/lib/images/macos/guest/vmix-install.sh +++ b/lib/images/macos/guest/vmix-install.sh @@ -4,12 +4,13 @@ # # 1. erase the target disk (found by size) as APFS "Macintosh HD" # 2. rebuild "Install macOS .app": app skeleton from installer-app.tar -# (host-extracted Payload) + SharedSupport.dmg copied byte-exact by dd from a -# raw disk that maps that byte range of the pkg (Recovery's xar truncates an -# 18 GB member, which makes startosinstall report "pkgdmg is missing a footer") +# (host-extracted Payload) + SharedSupport.dmg = the WHOLE InstallAssistant.pkg +# dd'd byte-exact from a raw disk (Apple's own postinstall hardlinks the pkg +# there: it is a "pkgdmg" whose koly footer points at the dmg inside; the bare +# xar member fails startosinstall with "pkgdmg is missing a footer") # 3. startosinstall unattended, with the vmix agent pkg as --installpackage -# 4. when the prepare phase is done (SIGUSR1) also drop the agent + .AppleSetupDone -# onto the volume, then let the installer reboot +# 4. startosinstall reboots itself into the install phase; the vmix agent pkg +# installs during that phase and runs on the installed system's first boot # # On first boot of the installed system the agent runs /Volumes/VMIX/vmix-run.sh # and powers off, which ends the QEMU session on the host. @@ -23,6 +24,9 @@ V="/Volumes/VMIX" exec > >(tee "$V/install.log") 2>&1 set -x . "$V/vmix.conf" +# keep the recovery display awake so the host driver can watch the screen +caffeinate -dimsu -t 86400 >/dev/null 2>&1 & +pmset -a displaysleep 0 sleep 0 >/dev/null 2>&1 || true fail() { echo "vmix-install: FAIL: $*" @@ -45,7 +49,7 @@ disk_by_size() { echo "vmix-install: $(date) app=$APP_NAME volume=$VOLUME_NAME" TARGET=$(disk_by_size "$TARGET_BYTES") || fail "target disk ($TARGET_BYTES bytes) not found" -SSDISK=$(disk_by_size "$SS_DISK_BYTES") || fail "SharedSupport disk ($SS_DISK_BYTES bytes) not found" +SSDISK=$(disk_by_size "$PKG_DISK_BYTES") || fail "installer pkg disk ($PKG_DISK_BYTES bytes) not found" echo "vmix-install: target=$TARGET sharedsupport=$SSDISK" # --- 1. erase the target disk as an APFS volume @@ -60,13 +64,13 @@ SOI="$APP/Contents/Resources/startosinstall" [ -x "$SOI" ] || fail "startosinstall missing in $APP" SS="$APP/Contents/SharedSupport/SharedSupport.dmg" mkdir -p "$APP/Contents/SharedSupport" -FULL=$((SS_LEN / 1048576)) -REM=$((SS_LEN % 1048576)) +FULL=$((PKG_BYTES / 1048576)) +REM=$((PKG_BYTES % 1048576)) dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport.dmg" if [ "$REM" -gt 0 ]; then dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" || fail "dd SharedSupport.dmg tail" fi -[ "$(stat -f %z "$SS")" = "$SS_LEN" ] || fail "SharedSupport.dmg size mismatch: $(stat -f %z "$SS") != $SS_LEN" +[ "$(stat -f %z "$SS")" = "$PKG_BYTES" ] || fail "SharedSupport.dmg size mismatch: $(stat -f %z "$SS") != $PKG_BYTES" tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no UDIF koly footer" chflags -h norestricted "$SS" 2>/dev/null || true echo "vmix-install: app=$APP SharedSupport.dmg=$(stat -f %z "$SS") bytes" @@ -77,41 +81,59 @@ if [ -n "${BUILD_DATE:-}" ]; then date -u "$BUILD_DATE" && echo "vmix-install: set clock to $(date)" fi -# --- 3. unattended install -PREPARED=0 -trap 'PREPARED=1' USR1 -run_install() { - "$SOI" --volume "$VOL" --agreetolicense --nointeraction --pidtosignal $$ "$@" & - INSTALL_PID=$! - while :; do - wait $INSTALL_PID - rc=$? - [ "$PREPARED" = 1 ] && return 0 - kill -0 $INSTALL_PID 2>/dev/null || return $rc +# Blackhole Apple's install/verify endpoints so osinstallersetupd's network calls +# fail immediately instead of timing out (prepare otherwise crawls). Fully offline. +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 +echo "vmix-install: blackholed Apple install endpoints for a fast offline prepare" + +# --- 3. unattended install. startosinstall prepares then reboots the machine +# itself into the install phase. Prepare intermittently stalls (~46% — an online +# verify/personalization step through the VM's NAT), so a watchdog kills and +# retries startosinstall if the target volume makes no write progress for a while. +# The vmix agent pkg installs during the install phase and runs on first boot. +# quote args properly — $VOL contains a space ("Macintosh HD") +run_soi() { "$SOI" --volume "$VOL" --agreetolicense --nointeraction --rebootdelay 5 "$@"; } +free_kb() { df -k "$VOL" 2>/dev/null | awk 'NR==2 {print $4}'; } + +attempt=0 +while [ "$attempt" -lt 10 ]; do + attempt=$((attempt + 1)) + echo "vmix-install: startosinstall attempt $attempt" + if [ "$attempt" -eq 1 ]; then + run_soi --installpackage "$V/vmix-agent.pkg" 2>&1 & + else + # a stalled attempt leaves the volume dirty; re-erase and rebuild for a clean retry + diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk on retry" + tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar on retry" + mkdir -p "$APP/Contents/SharedSupport" + dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL 2>/dev/null + [ "$REM" -gt 0 ] && dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" 2>/dev/null + chflags -h norestricted "$SS" 2>/dev/null || true + run_soi --installpackage "$V/vmix-agent.pkg" 2>&1 & + fi + SOI_PID=$! + # watchdog: kill startosinstall if free space stalls for ~4 min OR the attempt + # simply takes too long (prepare is intermittently slow; healthy = a few minutes) + 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 + if [ "$stalled" -ge 240 ] || [ "$elapsed" -ge 540 ]; 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 -} -run_install --rebootdelay 300 --installpackage "$V/vmix-agent.pkg" \ - || { echo "vmix-install: retry without --rebootdelay"; run_install --installpackage "$V/vmix-agent.pkg"; } \ - || { echo "vmix-install: retry without --installpackage"; run_install; } \ - || fail "startosinstall" - -# --- 4. prepare phase done: also drop the agent onto the volume directly. -T="$VOL" -if [ -d "$T" ]; then - mkdir -p "$T/Library/LaunchDaemons" "$T/Library/vmix" "$T/private/var/db" - cp "$V/agent/agent.sh" "$T/Library/vmix/agent.sh" - cp "$V/agent/ch.vmix.agent.plist" "$T/Library/LaunchDaemons/ch.vmix.agent.plist" - chmod 755 "$T/Library/vmix/agent.sh" - chmod 644 "$T/Library/LaunchDaemons/ch.vmix.agent.plist" - chown -R root:wheel "$T/Library/vmix" "$T/Library/LaunchDaemons/ch.vmix.agent.plist" - touch "$T/private/var/db/.AppleSetupDone" - chown root:wheel "$T/private/var/db/.AppleSetupDone" -else - echo "vmix-install: WARNING: $T not mounted after prepare, relying on --installpackage" -fi - -echo 0 >"$V/install.status" -sync -kill -USR1 $INSTALL_PID -wait $INSTALL_PID -exit 0 + wait "$SOI_PID" 2>/dev/null + # on success startosinstall reboots the machine and we never get here + echo "vmix-install: startosinstall attempt $attempt ended without rebooting" + sleep 3 +done +fail "startosinstall did not complete after $attempt attempts" diff --git a/lib/images/macos/helpers/customizeImage.nix b/lib/images/macos/helpers/customizeImage.nix index 7ff849e..df8df32 100644 --- a/lib/images/macos/helpers/customizeImage.nix +++ b/lib/images/macos/helpers/customizeImage.nix @@ -77,7 +77,7 @@ let ''} echo "=== vmix: booting ${originalImageName} for ${name} ===" - python3 ${vmDriver} --mode boot --name "${name}-${originalImageName}" --timeout ${toString timeout} -- \ + python3 ${vmDriver} --mode boot --name "${name}-${originalImageName}" --timeout ${toString timeout} --progress-file ${resultImg} -- \ qemu-system-x86_64 $VMIX_DISPLAY \ ${qemu.machineArgs { inherit cpu smp memSize; }} \ ${qemu.firmwareArgs "vars.fd"} \ diff --git a/lib/images/macos/helpers/makeAgentPkg.nix b/lib/images/macos/helpers/makeAgentPkg.nix index 18f5a33..77dcf19 100644 --- a/lib/images/macos/helpers/makeAgentPkg.nix +++ b/lib/images/macos/helpers/makeAgentPkg.nix @@ -1,6 +1,11 @@ # Distribution-style flat package (xar + bom + cpio, built on Linux) for -# `startosinstall --installpackage`: installs the vmix agent LaunchDaemon and -# marks Setup Assistant as done so the first boot lands on loginwindow. +# `startosinstall --installpackage`. macOS installs it during the first boot of the +# installed system (bootinstalld, "Installer Progress"): it places the vmix agent +# LaunchDaemon, marks Setup Assistant as done, starts the agent, and schedules a +# reboot as a fallback so the daemon runs even if bootstrapping failed. +# +# The files are shipped inside Scripts and copied by postinstall: installd unpacks +# our Scripts archive fine, but "shoves 0 items" from a Linux-made Payload. { pkgs, lib, ... }: { version ? "1.0" }: let @@ -9,36 +14,61 @@ let bomutils = pkgs.bomutils.overrideAttrs (_: { hardeningDisable = [ "fortify" ]; }); postinstall = pkgs.writeText "postinstall" '' #!/bin/sh - # $3 = target volume - T="$3" - mkdir -p "$T/private/var/db" + # Runs during the OS install (bootinstalld) with $3 = the target system root. + # Only place files; the ch.vmix.agent LaunchDaemon then runs on the installed + # system's first boot via RunAtLoad (confirmed loading on Tahoe). + T="''${3%/}" + HERE="$(cd "$(dirname "$0")" && pwd)" + LOG="$T/private/var/log/vmix-agent-install.log" + mkdir -p "$T/private/var/log" + exec >>"$LOG" 2>&1 + echo "=== vmix agent pkg postinstall $(date) target=[$3] ===" + mkdir -p "$T/Library/LaunchDaemons" "$T/Library/vmix" "$T/private/var/db" + cp "$HERE/agent.sh" "$T/Library/vmix/agent.sh" + cp "$HERE/${id}.plist" "$T/Library/LaunchDaemons/${id}.plist" + chmod 755 "$T/Library/vmix/agent.sh" + chmod 644 "$T/Library/LaunchDaemons/${id}.plist" + chown -R root:wheel "$T/Library/vmix" "$T/Library/LaunchDaemons/${id}.plist" touch "$T/private/var/db/.AppleSetupDone" chown root:wheel "$T/private/var/db/.AppleSetupDone" - chmod 755 "$T/Library/vmix/agent.sh" - chown -R root:wheel "$T/Library/vmix" "$T/Library/LaunchDaemons/${id}.plist" + ls -la "$T/Library/vmix/agent.sh" "$T/Library/LaunchDaemons/${id}.plist" + # A pkg LaunchDaemon is registered with Background Task Management but stays + # pending approval, so it will not auto-run headless. Two BTM-exempt triggers: + # - bootstrap it now (starts it in the installer env; the agent no-ops there) + # - a root cron @reboot job (Apple's cron daemon is trusted, runs it at boot) + launchctl bootstrap system "$T/Library/LaunchDaemons/${id}.plist" 2>&1 && echo "bootstrapped" || echo "bootstrap returned $?" + mkdir -p "$T/usr/lib/cron/tabs" + printf '@reboot /bin/sh /Library/vmix/agent.sh\n' > "$T/usr/lib/cron/tabs/root" + chmod 600 "$T/usr/lib/cron/tabs/root" + chown root:wheel "$T/usr/lib/cron/tabs/root" + echo "cron @reboot installed" exit 0 ''; in pkgs.runCommand "vmix-agent-${version}.pkg" { - nativeBuildInputs = [ pkgs.xar bomutils pkgs.cpio pkgs.gzip ]; + nativeBuildInputs = [ pkgs.xar bomutils pkgs.cpio pkgs.libarchive pkgs.gzip ]; } '' mkdir -p root/Library/LaunchDaemons root/Library/vmix scripts flat/vmix-agent.pkg cp ${../guest/agent.sh} root/Library/vmix/agent.sh cp ${../guest/ch.vmix.agent.plist} root/Library/LaunchDaemons/${id}.plist chmod 755 root/Library/vmix/agent.sh chmod 644 root/Library/LaunchDaemons/${id}.plist + # the same files ride along in Scripts, which is what postinstall installs from cp ${postinstall} scripts/postinstall - chmod 755 scripts/postinstall + cp ${../guest/agent.sh} scripts/agent.sh + cp ${../guest/ch.vmix.agent.plist} scripts/${id}.plist + chmod 755 scripts/postinstall scripts/agent.sh NFILES=$(find root | wc -l) KBYTES=$(du -sk root | cut -f1) - (cd root && find . | cpio -o --format odc --owner 0:0 --quiet | gzip -c > ../flat/vmix-agent.pkg/Payload) + # bsdcpio keeps the "./" prefix the Bom uses (GNU cpio strips it and installd then extracts nothing) + (cd root && find . | bsdcpio -o --format odc --quiet | gzip -c > ../flat/vmix-agent.pkg/Payload) (cd scripts && find . | cpio -o --format odc --owner 0:0 --quiet | gzip -c > ../flat/vmix-agent.pkg/Scripts) mkbom -u 0 -g 80 root flat/vmix-agent.pkg/Bom cat > flat/vmix-agent.pkg/PackageInfo < - + diff --git a/lib/images/macos/helpers/makeImage.nix b/lib/images/macos/helpers/makeImage.nix index ed7d3ad..fdafac2 100644 --- a/lib/images/macos/helpers/makeImage.nix +++ b/lib/images/macos/helpers/makeImage.nix @@ -26,6 +26,8 @@ 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 install (default: offline — startosinstall + # otherwise hangs on Apple personalization through a flaky NAT) }: let mac = ident.macFromSeed seed; @@ -71,24 +73,26 @@ let qemu-img create -q -f qcow2 -F raw -b ${recoveryImg} recovery.qcow2 qemu-img create -q -f qcow2 -F raw -b ${esp}/boot.img ocboot.qcow2 - # SharedSupport.dmg is exposed as its own raw disk mapped to that byte range of - # the pkg (zero host copy). qemu accepts a non-512-aligned raw offset, so the - # disk starts exactly at the dmg; the guest dd's SS_LEN bytes into the app. - SS_OFF=$(${pkgs.jq}/bin/jq '."SharedSupport.dmg".offset' ${payload}/installer.json) - SS_LEN=$(${pkgs.jq}/bin/jq '."SharedSupport.dmg".length' ${payload}/installer.json) - SS_DISK=$(( (SS_LEN + 511) / 512 * 512 )) - qemu-img create -q -f qcow2 -F raw -b "json:{\"driver\":\"raw\",\"offset\":$SS_OFF,\"size\":$SS_DISK,\"file\":{\"driver\":\"file\",\"filename\":\"${installer}\"}}" sharedsupport.qcow2 + # 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 < args.disk_idle + except OSError: + return True + + def run_install(args, proc, qmp, log): """Drive the install VM to completion. @@ -251,6 +294,11 @@ def run_install(args, proc, qmp, log): last_periodic = 0 last_progress = start blind_done = False + resets = 0 + blank_since = None + login_since = None + recovery_start = None + last_term_action = 0 while True: rc = proc.poll() if rc is not None: @@ -274,16 +322,54 @@ def run_install(args, proc, qmp, log): if now - last_periodic > args.periodic: last_periodic = now screen.save('periodic') + # keep the recovery display awake until the command is typed (mouse jiggle) + if typed_at is None and now - last_term_action > 8: + qmp.jiggle() if now - start < args.min_boot or screen.stable_for() < args.settle: continue if screen.is_blank(): last_progress = now + if blank_since is None: + blank_since = now + if typed_at is None: + # recovery display asleep — jiggle the mouse to wake it, wait for UI + qmp.jiggle() + continue + # macOS `shutdown -h now` halts the guest to a black screen without an + # ACPI power-off, so QEMU never exits. Once we have handed off (command + # typed), a long pure-black screen means the agent finished and halted. + elif typed_at is not None and now - blank_since > args.halt_timeout and disk_idle(args): + screen.save('halt') + log(f'guest halted (black {now - blank_since:.0f}s, disk idle); killing QEMU, readback will validate') + proc.kill() + try: + proc.wait(timeout=10) + except Exception: # noqa: BLE001 + pass + return 0 continue + blank_since = None top = screen.menubar_text() body = screen.ocr() log(f'settled: menubar={top.strip()!r} body~={" ".join(body.split())[:80]!r}') + # Boot-hang watchdog: a dark screen (Apple logo / black) frozen for a long + # time with no menu bar is a stuck (re)boot — kick it with a system reset. + # Never fires on the bright, static Terminal of the prepare phase. + if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \ + and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets: + resets += 1 + screen.save('stall-reset') + log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, disk 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() + last_progress = now + continue + # OpenCore boot picker — always handle it (the install reboots many times) if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY): screen.save('picker') @@ -293,9 +379,53 @@ def run_install(args, proc, qmp, log): screen.stable_since = time.time() continue - # once the bootstrap command is typed, only the picker (above) matters - if typed_at is not None: + # After the install, the loginwindow/desktop is a BRIGHT gray screen, unlike + # the dark install/boot screens (Apple logo). The vmix agent powers the VM + # off if it runs (cron/daemon); if BTM blocks it, we power down here so the + # build still completes with a bootable, installed image. Brightness is a + # far more reliable signal than OCR of the faint "password" text. + bright = screen.mean() > 80 + loginish = (typed_at is not None and bright and 'terminal' not in top + and 'utilities' not in top and not any(k in body for k in PICKER_BODY)) + if loginish: + if login_since is None: + login_since = now + log('bright post-install screen (loginwindow/desktop) — OS installed; grace before powerdown') + elif now - login_since > args.login_grace: + screen.save('loginwindow') + log(f'loginwindow persisted {now - login_since:.0f}s, powering down (install complete)') + try: + qmp.system_powerdown() + except Exception as e: # noqa: BLE001 + log(f'powerdown failed: {e}') + for _ in range(90): + if proc.poll() is not None: + return 0 + time.sleep(1) + proc.kill() + return 0 continue + else: + login_since = None + + # once the bootstrap command is typed, only the picker (above) and an + # unexpected return to Recovery matter (post-prepare reboot landed on the + # recovery instead of the installer — restart the install then). + if typed_at is not None: + if ('utilities' in top or 'recovery' in top): + if recovery_start is None: + recovery_start = now + if now - typed_at > 120 and now - recovery_start > 45: + log('unexpectedly back at Recovery after install started — restarting install') + typed_at = None + terminal_attempts = 0 + recovery_start = None + # fall through to the recovery/terminal handling below + else: + continue + else: + recovery_start = None + continue if 'terminal' in top: screen.save('terminal') @@ -309,6 +439,7 @@ def run_install(args, proc, qmp, log): screen.save('recovery') terminal_attempts += 1 log(f'recovery window (attempt {terminal_attempts}), opening Terminal') + last_term_action = now open_terminal(qmp, log) if terminal_attempts >= 3: time.sleep(8) @@ -339,30 +470,95 @@ def run_install(args, proc, qmp, log): def run_boot(args, proc, qmp, log): + """Wait for the VM to power itself off (customize/generalize/first-boot), + handling the OpenCore picker and kicking a hung boot with a system reset.""" + PICKER_BODY = ('base system', 'macos installer', 'macintosh hd', 'rel-1', 'rel-0') screen = Screen(qmp, args.debug_dir, log) start = time.time() last_periodic = 0 + resets = 0 + blank_since = None + login_since = None while True: rc = proc.poll() if rc is not None: return rc if time.time() - start > args.timeout: try: - screen.grab() - screen.save('timeout') + screen.grab(); screen.save('timeout') except Exception: # noqa: BLE001 pass log('timeout reached, killing QEMU') proc.kill() return 124 time.sleep(args.interval) - if time.time() - last_periodic > args.periodic: - last_periodic = time.time() + try: + screen.grab() + except Exception as e: # noqa: BLE001 + log(f'screendump failed ({e})') + time.sleep(2) + continue + now = time.time() + if now - last_periodic > args.periodic: + last_periodic = now + screen.save('periodic') + if now - start < args.min_boot or screen.stable_for() < args.settle: + continue + if screen.is_blank(): + if blank_since is None: + blank_since = now + elif now - blank_since > args.halt_timeout and disk_idle(args): + screen.save('halt') + log(f'guest halted (black {now - blank_since:.0f}s, disk idle); killing QEMU') + proc.kill() + try: + proc.wait(timeout=10) + except Exception: # noqa: BLE001 + pass + return 0 + continue + blank_since = None + top = screen.menubar_text() + body = screen.ocr() + if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY): + screen.save('picker') + log('OpenCore boot picker, pressing Return') + qmp.send_key('ret') + screen.stable_since = time.time() + login_since = None + continue + # bright post-boot screen (loginwindow/desktop) => booted; power down if the + # agent did not (so customize/generalize completes even if BTM blocks it) + if screen.mean() > 80 and 'terminal' not in top and 'utilities' not in top: + if login_since is None: + login_since = now + log('bright screen (loginwindow/desktop) after boot; grace before powerdown') + elif now - login_since > args.login_grace: + screen.save('loginwindow') + log(f'loginwindow persisted {now - login_since:.0f}s, powering down') + try: + qmp.system_powerdown() + except Exception as e: # noqa: BLE001 + log(f'powerdown failed: {e}') + for _ in range(90): + if proc.poll() is not None: + return 0 + time.sleep(1) + proc.kill() + return 0 + continue + else: + login_since = None + if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \ + and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets: + resets += 1 + screen.save('stall-reset') + log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, disk idle), system_reset #{resets}') try: - screen.grab() - screen.save('periodic') + qmp.system_reset() except Exception as e: # noqa: BLE001 - log(f'screendump failed ({e})') + log(f'system_reset failed: {e}') + screen.stable_since = time.time() def main(): @@ -376,6 +572,12 @@ def main(): p.add_argument('--settle', type=float, default=12.0, help='seconds a screen must be unchanged to act on it') p.add_argument('--min-boot', type=float, default=45.0, help='seconds before the first action') p.add_argument('--max-actions', type=int, default=8) + p.add_argument('--stall-reset', type=float, default=360.0, help='reset the VM if a non-Terminal screen is frozen this long (boot hang)') + p.add_argument('--max-resets', type=int, default=6) + p.add_argument('--halt-timeout', type=float, default=150.0, help='after the bootstrap, a pure-black screen this long means the guest halted (macOS shutdown does not ACPI-power-off QEMU)') + p.add_argument('--progress-file', default=None, help='a file (the system disk) whose mtime shows guest activity; resets/halt only fire when it is also idle, so a slow-but-working boot is never interrupted') + p.add_argument('--disk-idle', type=float, default=90.0, help='seconds of no writes to --progress-file that count as idle') + p.add_argument('--login-grace', type=float, default=240.0, help='seconds to wait at the loginwindow for the agent to power off before the driver powers down itself') p.add_argument('--command', default='diskutil mount VMIX;sh /Volumes/VMIX/run.sh') p.add_argument('qemu', nargs=argparse.REMAINDER) args = p.parse_args() diff --git a/lib/images/macos/tahoe/default.nix b/lib/images/macos/tahoe/default.nix index cd9fa7c..7bb6d48 100644 --- a/lib/images/macos/tahoe/default.nix +++ b/lib/images/macos/tahoe/default.nix @@ -3,6 +3,12 @@ let up = macos.upstream.tahoe; # 18 GB full installer (App Store InstallAssistant.pkg, pinned) installer = pkgs.fetchurl { inherit (up.installer) url hash; name = "InstallAssistant.pkg"; }; - recovery = macos.fetchRecovery { inherit (up.recovery) shortname sha256; }; + # 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; } diff --git a/lib/images/macos/upstream.json b/lib/images/macos/upstream.json index a67c3bd..7730b94 100644 --- a/lib/images/macos/upstream.json +++ b/lib/images/macos/upstream.json @@ -9,7 +9,10 @@ }, "recovery": { "shortname": "tahoe", - "sha256": "edddd0d5869caaa12e29e6996a04f11590280580976a119dbd42c24fa62fe18e" + "sha256": "edddd0d5869caaa12e29e6996a04f11590280580976a119dbd42c24fa62fe18e", + "file": "/nix/store/fqpih1dmg826cfxcyyxn4qm08pvcxgz4-macos-tahoe-BaseSystem.dmg", + "version": "26.6.2", + "build": "25G83" } }, "opencore": { From 8dc8f4265dd172d4ba9aa24487c1c22a552d4668 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 9 Sep 2026 11:21:24 -0300 Subject: [PATCH 03/23] macOS: drive the install and all customization from a Recovery "PE", no GUI Replace the screenshot/OCR/keystroke driving of Apple's Recovery with a "PE": BaseSystem.dmg (a journaled HFS+ volume, writable from Linux) with one LaunchDaemon added (makeRecoveryPE) that runs /Volumes/VMIX/run.sh as root at boot, records the status and powers off. launchd loads it alongside its signed cache (verified on Tahoe 26.6.2); same idea as AutoNBI/Imagr NetBoot images. - makeImage: the PE runs vmix-install.sh (erase, installer app, SharedSupport pkgdmg, startosinstall). Progress is read from the serial console (boot-args serial=3 -v, VMIX-* markers) and screenshots (brightness only). Fully offline; prepare now takes ~5 min instead of ~10. - customizeImage: boots the PE with the image attached and runs the template offline against the mounted System/Data volumes; OpenCore ScanPolicy restricted to HFS+/SATA so only the PE can boot. One PE boot ~30 s. The installed macOS is never booted for customization, so nothing depends on launchd/BTM approval or a first-boot agent (removed). - templates rewritten for offline use: generalize creates the user with dscl -f (admin, home, auto-login kcpassword, Setup Assistant suppression, hostname, locale, timezone, keyboard type, container resize); remote-access, no-updates, performance edit the target's plists. - makeBootDisk: build-time OpenCore variant (serial console, ScanPolicy). - vm-driver.py rewritten: passive observation only (serial markers, kernel boots, panics, brightness), disk+serial-aware hang watchdog, reboot-death reset, halt/loginwindow detection. No OCR/tesseract. - OpenCore: four SMBIOS DIMMs for MacPro7,1 (no "Memory Modules Misconfigured" warning). - tools/soak.sh: repeatability harness. Verified on daku: base install 23 min end to end; basic + generalize in three ~30 s PE boots; the result auto-logs into the desktop with the created user. Root cause of the "first-boot hang" (from the serial log): the guest's restart path panics (IOPlatformHaltRestartAction -> AppleSMC, SMCWDT smcWriteKey kSMCBadCommand, nested panic) because the pinned OSX-KVM Lilu disables itself on macOS 26, so VirtualSMC never loads. Handled by the driver (reset within 60 s); kext update to follow. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF --- lib/images/macos/README.md | 208 +++--- lib/images/macos/default.nix | 7 +- lib/images/macos/guest/agent.sh | 42 -- .../{ch.vmix.agent.plist => ch.vmix.pe.plist} | 10 +- lib/images/macos/guest/pe-lib.sh | 51 ++ lib/images/macos/guest/pe.sh | 40 + lib/images/macos/guest/vmix-install.sh | 163 ++--- lib/images/macos/helpers/customizeImage.nix | 77 +- lib/images/macos/helpers/makeAgentPkg.nix | 105 --- lib/images/macos/helpers/makeBootDisk.nix | 29 + lib/images/macos/helpers/makeImage.nix | 95 +-- lib/images/macos/helpers/makeOpenCore.nix | 3 +- lib/images/macos/helpers/makeRecoveryPE.nix | 30 + lib/images/macos/helpers/oc-config.py | 16 + lib/images/macos/helpers/qemu.nix | 3 + lib/images/macos/helpers/vm-driver.py | 682 +++++++----------- lib/images/macos/helpers/vmix-readback.nix | 12 +- lib/images/macos/tahoe/images.nix | 7 +- lib/images/macos/templates/default.nix | 2 +- .../macos/templates/essentials/no-updates.nix | 12 +- .../templates/essentials/performance.nix | 33 +- .../templates/essentials/remote-access.nix | 7 +- lib/images/macos/templates/generalize.nix | 117 +-- lib/images/macos/tools/soak.sh | 28 + 24 files changed, 802 insertions(+), 977 deletions(-) delete mode 100644 lib/images/macos/guest/agent.sh rename lib/images/macos/guest/{ch.vmix.agent.plist => ch.vmix.pe.plist} (64%) create mode 100644 lib/images/macos/guest/pe-lib.sh create mode 100755 lib/images/macos/guest/pe.sh delete mode 100644 lib/images/macos/helpers/makeAgentPkg.nix create mode 100644 lib/images/macos/helpers/makeBootDisk.nix create mode 100644 lib/images/macos/helpers/makeRecoveryPE.nix create mode 100755 lib/images/macos/tools/soak.sh diff --git a/lib/images/macos/README.md b/lib/images/macos/README.md index 9672842..db8f90b 100644 --- a/lib/images/macos/README.md +++ b/lib/images/macos/README.md @@ -1,138 +1,100 @@ -# vmix macOS images +# macOS images (Tahoe 26) -Unattended macOS (Tahoe / 26) VM images, built the same way as the Windows -images: `makeImage` installs the OS once, templates customize it by booting it, -`.generalize` creates the user and seals the image. +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,timezone=Europe/Zurich -vmix run ./result --macos --vnc :10 --mem 8192 # VNC on port 5910 +vmix build --image macos.images.tahoe.basic --generalize username=sagar,password=secret,hostname=MAC +vmix run ./result --macos --vnc :10 --mem 8192 ``` -## How it works +Nix: `macos.images.tahoe.{pe,upstream,basic,remote}` and +`.generalize { username; password; hostname; timezone; locale; seed; … }`. -| step | what happens | -|---|---| -| `fetchRecovery` | BaseSystem.dmg from Apple's recovery servers (fixed-output, pinned by sha256) | -| `installerPayload` | takes the App Store `InstallAssistant.pkg` (18 GB, pinned) apart on Linux: the app skeleton (pbzx/cpio) and the byte offset of `SharedSupport.dmg` | -| `makeOpenCore` | OSX-KVM's OpenCore ESP with a config.plist rewritten for this image: SMBIOS model, serial + MLB (`macserial`), UUID and ROM = NIC MAC (derived from a seed), NIC marked built-in | -| `makeImage` | one QEMU session: Recovery boots via OpenCore → `vm-driver.py` opens Terminal with keystrokes (Ctrl-F2 menu navigation, screen-settle detection + OCR of the menu bar) and types `sh /Volumes/VMIX/run.sh` → `vmix-install.sh` erases the disk, rebuilds `Install macOS Tahoe.app` (skeleton + `SharedSupport.dmg` copied from a raw disk mapped straight out of the pkg), runs `startosinstall --installpackage vmix-agent.pkg` → installer reboots through its phases → first boot runs the **vmix agent** which powers off. OpenCore is then copied into the image's EFI partition, so it boots standalone with OVMF | -| `customizeImage` | boots the image with a FAT volume `VMIX`; the agent (LaunchDaemon `ch.vmix.agent`) runs `vmix-run.sh` as root, writes `vmix-run.status`/`.log` back and shuts down | -| `templates.generalize` | user (admin) + auto-login (`/etc/kcpassword`), Setup Assistant suppressed, hostname, timezone, no sleep, APFS grown to the disk, then the agent removes itself; a fresh SMBIOS identity is written to the ESP | +## How it works: the vmix "PE" -The vmix agent replaces Windows' Audit Mode RunOnce; `.AppleSetupDone` replaces -the OOBE unattend. Everything on the host side runs inside `__noChroot` -derivations (KVM + `/tmp`), exactly like the Windows builders. +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: -## Generalize options +* **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. -`username password fullName autoLogon hostname locale timezone delayOobeRun` -as for Windows (`bgColor` is accepted but ignored), plus the SMBIOS identity: -`model serial mlb uuid mac seed`. Anything unset is generated: serial/MLB by -macserial (random per build), MAC and UUID deterministically from `seed` -(default `hostname-username`). `vmix macserial --model MacPro7,1` prints a -ready-to-paste set. +### Pipeline -`delay-oobe-run=true` creates no user and re-arms Setup Assistant for the first -real boot. - -## Apple ID / iMessage - -The image satisfies what Dortania lists for iServices: unique serial + MLB for a -Tahoe-supported model (`MacPro7,1` by default; `iMac20,1/2`, -`MacBookPro16,x` also work), SystemUUID, ROM equal to en0's MAC, and en0 marked -built-in (the NIC is pinned to `PciRoot(0x0)/Pci(0x12,0x0)`). The NixOS module -and `vmix run --macos` use the MAC recorded in the image (`EFI/vmix/vmix.json`). -Give each deployed VM its own generalized image (different `seed`, or explicit -`serial=`/`mlb=`) — two VMs with the same identity will be blocked. - -## Runtime - -* `vmix run --macos [--vnc :N] [--mac ..]` -* NixOS module: `disks.os.file = vmixLib.macos.images.tahoe.basic.generalize {...}` - is auto-detected (`_vmixOsType = "macos"`): Skylake-Client CPU spoof, AppleSMC, - USB keyboard/tablet, AHCI system disk, VMware SVGA, pinned NIC with the image's MAC. - `macos.cpu`, `macos.mac`, `macos.enable` override the defaults. -* `vmix copy` writes the image to a disk but cannot grow APFS from Linux - (`diskutil apfs resizeContainer disk0s2 0` in macOS afterwards). - -## Debugging a build - -Screenshots (`NNN-.png`), `driver.log` and the QMP socket of every VM -session are in `/tmp/vmix-macos//` on the build host. The guest logs -(`install.log`, `vmix-run.log`, `vmix-agent.log`) are printed at the end of the -build. Pass `vncDisplay = ":10"` to `makeImage`/`customizeImage` (or -`--generalize vncDisplay=:10`) to watch live; with a `DISPLAY` an SDL window -is used as for Windows. - -## Updating pins (`upstream.json`) - -* installer: URL + SRI hash of a newer `InstallAssistant.pkg` - (`nix store prefetch-file --name InstallAssistant.pkg `; Mr. Macintosh's - database lists Apple's URLs) -* recovery: Apple serves the current build for the board id, so the sha256 - changes with each point release — copy the "got:" hash from the failed build -* opencore: OSX-KVM `OpenCore.qcow2` at a commit; OpenCorePkg release zip (macserial/ocvalidate) - -## Known limits - -* The Recovery bootstrap depends on keyboard navigation of the Recovery UI - (Ctrl-F2 → Utilities → Terminal). It self-corrects with screenshots + OCR and - falls back to a blind sequence, but a Recovery UI change would need - `vm-driver.py` adjusted. -* Hosts must run KVM with an AVX2-capable CPU (Intel or AMD; the guest sees a - Skylake). `sandbox = relaxed` and the `kvm` system feature, as for Windows. -* Software updates inside the VM are disabled by the `noUpdates` template - (OTA updates in a VM need the RestrictEvents kext). - -## Current status (2026-09-09): working offline install - -`macos.images.tahoe.upstream` builds a bootable, installed macOS Tahoe 26.6.2 -qcow2 **fully offline** on the KVM host — no dependency on Apple's servers at build -time, just the pinned local `InstallAssistant.pkg` and `BaseSystem.dmg`. The -finished image boots standalone (OpenCore from its own ESP) to the macOS -loginwindow. Serial/MLB/UUID/ROM are per-image for Apple ID / iMessage. - -How the install is driven (`vm-driver.py`, all by screenshot + OCR over QMP): - -* The whole `InstallAssistant.pkg` is mapped as a raw disk (it is a "pkgdmg": - xar + koly footer) and `dd`'d byte-exact into the app as `SharedSupport.dmg` — - extracting the bare xar member fails startosinstall with "pkgdmg missing a footer". -* No NIC during install + `/etc/hosts` blackhole of Apple's install/verify - endpoints, so `startosinstall`'s network calls fail fast instead of hanging — - offline prepare, no external dependency. `SecureBootModel=Disabled` lets the - sealed volume install without online personalization. -* The recovery display is kept awake with a tiny mouse jiggle (a lone keypress - does not reset display sleep, and the sleeping display swallows the menu-nav - keystrokes); the settle detector uses a coarse fingerprint so the jiggling - cursor is not seen as a screen change. -* startosinstall prepare is intermittently slow/stalls; a guest watchdog kills and - re-erases/retries an attempt that stalls or runs > 9 min. -* First boot in QEMU intermittently hangs at the Apple logo; a disk-aware watchdog - (`--progress-file`) issues a QMP `system_reset` only when the screen is dark AND - the disk is idle, so a slow-but-working boot is never interrupted. -* The install reaching the (bright) loginwindow is detected by brightness (the - faint gray "password" text does not OCR) and the driver powers the VM down — - the image is installed. macOS `shutdown -h now` halts to black without an ACPI - power-off, so a black+disk-idle screen is also treated as a completed halt. -* OpenCore is then copied into the image's own ESP so it boots standalone with OVMF. +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` (same path -from the same bytes). Set `recovery.sha256` and remove `recovery.file` to fetch it -from Apple instead (subject to the CDN rollout). +`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). -### Not yet done: generalize / user creation +## Reliability -The base image installs and boots to loginwindow. `.generalize` (user creation, -auto-login, hostname) relies on the vmix agent LaunchDaemon running on first boot, -but macOS Ventura+ Background Task Management does not auto-run a headless -third-party daemon, and neither the pkg `launchctl bootstrap` (installer domain -only) nor a cron `@reboot` reliably triggered it. The robust next step is to inject -the user record + settings offline from the agent pkg's postinstall (which runs as -root on the target during install), instead of a first-boot daemon. +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. + +## 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). diff --git a/lib/images/macos/default.nix b/lib/images/macos/default.nix index 6b36d2f..46c957d 100644 --- a/lib/images/macos/default.nix +++ b/lib/images/macos/default.nix @@ -9,16 +9,17 @@ let 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; }; - makeAgentPkg = import ./helpers/makeAgentPkg.nix { inherit pkgs lib; }; installBootloader = import ./helpers/installBootloader.nix { inherit pkgs lib; }; vmixReadback = import ./helpers/vmix-readback.nix { inherit pkgs lib; }; vmDriver = ./helpers/vm-driver.py; makeImage = import ./helpers/makeImage.nix { - inherit pkgs lib qemu ident installerPayload makeOpenCore makeVmixVolume makeAgentPkg installBootloader vmixReadback vmDriver; + inherit pkgs lib qemu ident installerPayload makeOpenCore makeBootDisk makeVmixVolume installBootloader vmixReadback vmDriver; }; customizeImage = import ./helpers/customizeImage.nix { - inherit pkgs lib qemu ident makeVmixVolume makeOpenCore installBootloader vmixReadback vmDriver; + inherit pkgs lib qemu ident makeVmixVolume makeOpenCore makeBootDisk installBootloader vmixReadback vmDriver; }; customizeImageFold = builtins.foldl' customizeImage; templates = import ./templates { inherit pkgs lib; }; diff --git a/lib/images/macos/guest/agent.sh b/lib/images/macos/guest/agent.sh deleted file mode 100644 index 3e2ba02..0000000 --- a/lib/images/macos/guest/agent.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/sh -# vmix agent: LaunchDaemon that runs at every boot as root (installed by the vmix -# agent pkg via startosinstall --installpackage). If a volume named VMIX carrying -# vmix-run.sh is attached, run it, record the result on the volume and power off. -# Without the volume it is a no-op (normal boot). Counterpart of the Windows Audit -# Mode RunOnce script; the generalize step removes it once the image is sealed. -LOG=/var/log/vmix-agent.log -exec >>"$LOG" 2>&1 -echo "=== vmix agent: $(date) ===" -# The agent pkg bootstraps this daemon during the OS install (to approve it past -# Background Task Management, so launchd runs it at first boot). Don't do the job -# in that installer environment — only on the installed system's first boot. -if pgrep -x bootinstalld >/dev/null 2>&1 || pgrep -qx "Installer Progress" 2>/dev/null \ - || [ -d /System/Volumes/Update/mnt1 ]; then - echo "vmix agent: OS installer is running, skipping" - exit 0 -fi -# let DiskArbitration settle so the VMIX volume is mountable -sleep 5 -V=/Volumes/VMIX -i=0 -while [ ! -f "$V/vmix-run.sh" ] && [ $i -lt 30 ]; do - diskutil mount VMIX >/dev/null 2>&1 - sleep 2 - i=$((i + 1)) -done -if [ ! -f "$V/vmix-run.sh" ]; then - echo "vmix agent: no VMIX volume, normal boot" - exit 0 -fi -echo "vmix agent: running vmix-run.sh" -cd "$V" || exit 1 -sh "$V/vmix-run.sh" >"$V/vmix-run.log" 2>&1 -rc=$? -echo "vmix agent: vmix-run.sh exited $rc" -echo "$rc" >"$V/vmix-run.status" -cp "$LOG" "$V/vmix-agent.log" 2>/dev/null -cp /var/log/vmix-agent-install.log "$V/vmix-agent-install.log" 2>/dev/null -sync -sleep 2 -diskutil unmount force "$V" >/dev/null 2>&1 -shutdown -h now diff --git a/lib/images/macos/guest/ch.vmix.agent.plist b/lib/images/macos/guest/ch.vmix.pe.plist similarity index 64% rename from lib/images/macos/guest/ch.vmix.agent.plist rename to lib/images/macos/guest/ch.vmix.pe.plist index c518fa4..a83065a 100644 --- a/lib/images/macos/guest/ch.vmix.agent.plist +++ b/lib/images/macos/guest/ch.vmix.pe.plist @@ -3,17 +3,17 @@ Label - ch.vmix.agent + ch.vmix.pe ProgramArguments - /bin/sh - /Library/vmix/agent.sh + /bin/bash + /usr/libexec/vmix/pe.sh RunAtLoad StandardOutPath - /var/log/vmix-agent.log + /dev/console StandardErrorPath - /var/log/vmix-agent.log + /dev/console 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 index 486f28d..8374331 100644 --- a/lib/images/macos/guest/vmix-install.sh +++ b/lib/images/macos/guest/vmix-install.sh @@ -1,88 +1,65 @@ -#!/bin/sh -# vmix: automated macOS install. Runs inside macOS Recovery's Terminal, started -# by vm-driver.py which types "sh /Volumes/VMIX/run.sh" for us. -# -# 1. erase the target disk (found by size) as APFS "Macintosh HD" -# 2. rebuild "Install macOS .app": app skeleton from installer-app.tar -# (host-extracted Payload) + SharedSupport.dmg = the WHOLE InstallAssistant.pkg -# dd'd byte-exact from a raw disk (Apple's own postinstall hardlinks the pkg -# there: it is a "pkgdmg" whose koly footer points at the dmg inside; the bare -# xar member fails startosinstall with "pkgdmg is missing a footer") -# 3. startosinstall unattended, with the vmix agent pkg as --installpackage -# 4. startosinstall reboots itself into the install phase; the vmix agent pkg -# installs during that phase and runs on the installed system's first boot -# -# On first boot of the installed system the agent runs /Volumes/VMIX/vmix-run.sh -# and powers off, which ends the QEMU session on the host. - -# macOS Recovery invokes us as `sh` (bash in POSIX mode, no process substitution); -# re-exec once under bash so `>(tee ...)` and other bashisms work. -if [ -z "${VMIX_REEXEC:-}" ]; then VMIX_REEXEC=1 exec bash "$0" "$@"; fi - -V="/Volumes/VMIX" -# tee to the Terminal (visible in host screenshots) and to a log on the volume -exec > >(tee "$V/install.log") 2>&1 +#!/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/vmix.conf" -# keep the recovery display awake so the host driver can watch the screen -caffeinate -dimsu -t 86400 >/dev/null 2>&1 & -pmset -a displaysleep 0 sleep 0 >/dev/null 2>&1 || true - +. "$V/pe-lib.sh" fail() { - echo "vmix-install: FAIL: $*" - cp /var/log/install.log "$V/system-install.log" 2>/dev/null || true - echo 1 >"$V/install.status" + echo "VMIX-FAIL: $*" + cp /var/log/install.log "$V/system-install.log" 2>/dev/null sync - sleep 2 - shutdown -h now 2>/dev/null || halt 2>/dev/null || true 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)" -# whole-disk identifier (diskN) whose size in bytes is exactly $1 +# --- 1. disks by exact size disk_by_size() { - for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+'); do - s=$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9][0-9]*\) Bytes).*/\1/p') - [ "$s" = "$1" ] && { echo "${d#/dev/}"; return 0; } + 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" -echo "vmix-install: $(date) app=$APP_NAME volume=$VOLUME_NAME" -TARGET=$(disk_by_size "$TARGET_BYTES") || fail "target disk ($TARGET_BYTES bytes) not found" -SSDISK=$(disk_by_size "$PKG_DISK_BYTES") || fail "installer pkg disk ($PKG_DISK_BYTES bytes) not found" -echo "vmix-install: target=$TARGET sharedsupport=$SSDISK" - -# --- 1. erase the target disk as an APFS volume -diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk $TARGET" +# --- 2. target volume + installer app (the pkg payload skeleton + SharedSupport.dmg) VOL="/Volumes/$VOLUME_NAME" -[ -d "$VOL" ] || fail "$VOL not mounted" - -# --- 2. rebuild the installer app on the target volume -tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar installer-app.tar" APP="$VOL/$APP_NAME" -SOI="$APP/Contents/Resources/startosinstall" -[ -x "$SOI" ] || fail "startosinstall missing in $APP" SS="$APP/Contents/SharedSupport/SharedSupport.dmg" -mkdir -p "$APP/Contents/SharedSupport" -FULL=$((PKG_BYTES / 1048576)) -REM=$((PKG_BYTES % 1048576)) -dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport.dmg" -if [ "$REM" -gt 0 ]; then - dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" || fail "dd SharedSupport.dmg tail" -fi -[ "$(stat -f %z "$SS")" = "$PKG_BYTES" ] || fail "SharedSupport.dmg size mismatch: $(stat -f %z "$SS") != $PKG_BYTES" -tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no UDIF koly footer" -chflags -h norestricted "$SS" 2>/dev/null || true -echo "vmix-install: app=$APP SharedSupport.dmg=$(stat -f %z "$SS") bytes" +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)" -# macOS certificate validation needs a sane clock; a fresh VM RTC can be wrong. -echo "vmix-install: guest clock is $(date) (UTC $(date -u))" -if [ -n "${BUILD_DATE:-}" ]; then - date -u "$BUILD_DATE" && echo "vmix-install: set clock to $(date)" -fi - -# Blackhole Apple's install/verify endpoints so osinstallersetupd's network calls -# fail immediately instead of timing out (prepare otherwise crawls). Fully offline. +# 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 \ @@ -90,50 +67,34 @@ for d in swscan.apple.com swcdn.apple.com swdist.apple.com swquery.apple.com \ ocsp2.apple.com valid.apple.com; do echo "127.0.0.1 $d" >> /etc/hosts done -echo "vmix-install: blackholed Apple install endpoints for a fast offline prepare" -# --- 3. unattended install. startosinstall prepares then reboots the machine -# itself into the install phase. Prepare intermittently stalls (~46% — an online -# verify/personalization step through the VM's NAT), so a watchdog kills and -# retries startosinstall if the target volume makes no write progress for a while. -# The vmix agent pkg installs during the install phase and runs on first boot. -# quote args properly — $VOL contains a space ("Macintosh HD") +# --- 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}'; } - -attempt=0 -while [ "$attempt" -lt 10 ]; do - attempt=$((attempt + 1)) - echo "vmix-install: startosinstall attempt $attempt" - if [ "$attempt" -eq 1 ]; then - run_soi --installpackage "$V/vmix-agent.pkg" 2>&1 & - else - # a stalled attempt leaves the volume dirty; re-erase and rebuild for a clean retry - diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk on retry" - tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar on retry" - mkdir -p "$APP/Contents/SharedSupport" - dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL 2>/dev/null - [ "$REM" -gt 0 ] && dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" 2>/dev/null - chflags -h norestricted "$SS" 2>/dev/null || true - run_soi --installpackage "$V/vmix-agent.pkg" 2>&1 & - fi +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=$! - # watchdog: kill startosinstall if free space stalls for ~4 min OR the attempt - # simply takes too long (prepare is intermittently slow; healthy = a few minutes) 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 - if [ "$stalled" -ge 240 ] || [ "$elapsed" -ge 540 ]; then - echo "vmix-install: prepare too slow (stalled=${stalled}s elapsed=${elapsed}s), killing to retry" + [ $((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 - # on success startosinstall reboots the machine and we never get here - echo "vmix-install: startosinstall attempt $attempt ended without rebooting" + echo "VMIX-INSTALL: startosinstall try $try ended without rebooting" sleep 3 done -fail "startosinstall did not complete after $attempt attempts" +fail "startosinstall did not complete after $try tries" diff --git a/lib/images/macos/helpers/customizeImage.nix b/lib/images/macos/helpers/customizeImage.nix index df8df32..fd267eb 100644 --- a/lib/images/macos/helpers/customizeImage.nix +++ b/lib/images/macos/helpers/customizeImage.nix @@ -1,13 +1,16 @@ -# Customize a macOS image by booting it with a VMIX volume: the vmix agent -# (LaunchDaemon installed by makeImage) runs `script` as root, records the exit -# status on the volume and powers off. Optionally re-installs OpenCore with a new -# SMBIOS identity (`smbios`). Counterpart of the Windows auditScript flow. +# 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 on the booted system +# script — sh script run as root in the PE (pe-lib.sh helpers available) # files — [{ source; name; }] extra files placed next to it on /Volumes/VMIX # smbios — { model? serial? mlb? uuid? mac? seed? } → fresh OpenCore config in the ESP -{ pkgs, lib, qemu, ident, makeVmixVolume, makeOpenCore, installBootloader, vmixReadback, vmDriver, ... }: +{ pkgs, lib, qemu, ident, makeVmixVolume, makeOpenCore, makeBootDisk, installBootloader, vmixReadback, vmDriver, ... }: originalImage: { name ? "", script ? "", @@ -19,7 +22,7 @@ originalImage: { smp ? 4, memSize ? 4096, cpu ? qemu.defaultCpu, - timeout ? 3600, + timeout ? 1800, }: let originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name); @@ -27,6 +30,8 @@ let resultImg = "./disk.qcow2"; hasScript = script != ""; 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; @@ -45,46 +50,60 @@ let 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}-vmix-run.sh" '' - #!/bin/sh + 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 = "vmix-run.sh"; } ] ++ files; + files = [ + { source = runScript; name = "run.sh"; } + { source = ../guest/pe-lib.sh; name = "pe-lib.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 | head -1) - if [ -n "$VMIX_DF" ] && [ "$(stat -c %s "$VMIX_DF")" -lt 256 ] && ! grep -q -P '[^\x20-\x7e\n]' "$VMIX_DF"; then - export DISPLAY=$(tr -d '\n' < "$VMIX_DF") - export HOME=$(mktemp -d) - export XDG_RUNTIME_DIR=$HOME - export SDL_VIDEODRIVER=x11 - VMIX_DISPLAY="-display sdl" - fi - ''} - echo "=== vmix: booting ${originalImageName} for ${name} ===" - python3 ${vmDriver} --mode boot --name "${name}-${originalImageName}" --timeout ${toString timeout} --progress-file ${resultImg} -- \ + 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 \ ${qemu.machineArgs { inherit cpu smp memSize; }} \ ${qemu.firmwareArgs "vars.fd"} \ - ${qemu.sataDrive { id = "system"; port = 0; file = resultImg; }} \ - ${qemu.sataDrive { id = "vmix"; port = 1; file = "vmix.img"; format = "raw"; }} \ - ${qemu.netArgs { mac = originalImage.macAddress; }} \ - || { echo "vmix: VM failed during ${name} (see /tmp/vmix-macos/${name}-${originalImageName})"; exit 1; } + ${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; } @@ -92,7 +111,7 @@ let ''; builtImage = pkgs.runCommand customImageName ({ - nativeBuildInputs = with pkgs; [ pkgs.qemu mtools driverPython libguestfs-with-appliance ]; + 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} @@ -102,4 +121,4 @@ let mv ${resultImg} $out ''; in - builtImage // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; model = esp.model or model; } + builtImage // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; model = esp.model or model; inherit pe volumeName; } diff --git a/lib/images/macos/helpers/makeAgentPkg.nix b/lib/images/macos/helpers/makeAgentPkg.nix deleted file mode 100644 index 77dcf19..0000000 --- a/lib/images/macos/helpers/makeAgentPkg.nix +++ /dev/null @@ -1,105 +0,0 @@ -# Distribution-style flat package (xar + bom + cpio, built on Linux) for -# `startosinstall --installpackage`. macOS installs it during the first boot of the -# installed system (bootinstalld, "Installer Progress"): it places the vmix agent -# LaunchDaemon, marks Setup Assistant as done, starts the agent, and schedules a -# reboot as a fallback so the daemon runs even if bootstrapping failed. -# -# The files are shipped inside Scripts and copied by postinstall: installd unpacks -# our Scripts archive fine, but "shoves 0 items" from a Linux-made Payload. -{ pkgs, lib, ... }: -{ version ? "1.0" }: -let - id = "ch.vmix.agent"; - # nixpkgs' bomutils aborts under _FORTIFY_SOURCE - bomutils = pkgs.bomutils.overrideAttrs (_: { hardeningDisable = [ "fortify" ]; }); - postinstall = pkgs.writeText "postinstall" '' - #!/bin/sh - # Runs during the OS install (bootinstalld) with $3 = the target system root. - # Only place files; the ch.vmix.agent LaunchDaemon then runs on the installed - # system's first boot via RunAtLoad (confirmed loading on Tahoe). - T="''${3%/}" - HERE="$(cd "$(dirname "$0")" && pwd)" - LOG="$T/private/var/log/vmix-agent-install.log" - mkdir -p "$T/private/var/log" - exec >>"$LOG" 2>&1 - echo "=== vmix agent pkg postinstall $(date) target=[$3] ===" - mkdir -p "$T/Library/LaunchDaemons" "$T/Library/vmix" "$T/private/var/db" - cp "$HERE/agent.sh" "$T/Library/vmix/agent.sh" - cp "$HERE/${id}.plist" "$T/Library/LaunchDaemons/${id}.plist" - chmod 755 "$T/Library/vmix/agent.sh" - chmod 644 "$T/Library/LaunchDaemons/${id}.plist" - chown -R root:wheel "$T/Library/vmix" "$T/Library/LaunchDaemons/${id}.plist" - touch "$T/private/var/db/.AppleSetupDone" - chown root:wheel "$T/private/var/db/.AppleSetupDone" - ls -la "$T/Library/vmix/agent.sh" "$T/Library/LaunchDaemons/${id}.plist" - # A pkg LaunchDaemon is registered with Background Task Management but stays - # pending approval, so it will not auto-run headless. Two BTM-exempt triggers: - # - bootstrap it now (starts it in the installer env; the agent no-ops there) - # - a root cron @reboot job (Apple's cron daemon is trusted, runs it at boot) - launchctl bootstrap system "$T/Library/LaunchDaemons/${id}.plist" 2>&1 && echo "bootstrapped" || echo "bootstrap returned $?" - mkdir -p "$T/usr/lib/cron/tabs" - printf '@reboot /bin/sh /Library/vmix/agent.sh\n' > "$T/usr/lib/cron/tabs/root" - chmod 600 "$T/usr/lib/cron/tabs/root" - chown root:wheel "$T/usr/lib/cron/tabs/root" - echo "cron @reboot installed" - exit 0 - ''; -in -pkgs.runCommand "vmix-agent-${version}.pkg" { - nativeBuildInputs = [ pkgs.xar bomutils pkgs.cpio pkgs.libarchive pkgs.gzip ]; -} '' - mkdir -p root/Library/LaunchDaemons root/Library/vmix scripts flat/vmix-agent.pkg - cp ${../guest/agent.sh} root/Library/vmix/agent.sh - cp ${../guest/ch.vmix.agent.plist} root/Library/LaunchDaemons/${id}.plist - chmod 755 root/Library/vmix/agent.sh - chmod 644 root/Library/LaunchDaemons/${id}.plist - # the same files ride along in Scripts, which is what postinstall installs from - cp ${postinstall} scripts/postinstall - cp ${../guest/agent.sh} scripts/agent.sh - cp ${../guest/ch.vmix.agent.plist} scripts/${id}.plist - chmod 755 scripts/postinstall scripts/agent.sh - - NFILES=$(find root | wc -l) - KBYTES=$(du -sk root | cut -f1) - # bsdcpio keeps the "./" prefix the Bom uses (GNU cpio strips it and installd then extracts nothing) - (cd root && find . | bsdcpio -o --format odc --quiet | gzip -c > ../flat/vmix-agent.pkg/Payload) - (cd scripts && find . | cpio -o --format odc --owner 0:0 --quiet | gzip -c > ../flat/vmix-agent.pkg/Scripts) - mkbom -u 0 -g 80 root flat/vmix-agent.pkg/Bom - - cat > flat/vmix-agent.pkg/PackageInfo < - - - - - - - - - - - - - XML - cat > flat/Distribution < - - vmix agent - - - - - - - - - - - - #vmix-agent.pkg - - XML - sed -i 's/^ //' flat/vmix-agent.pkg/PackageInfo flat/Distribution - (cd flat && xar --compression none -cf $out Distribution vmix-agent.pkg) - xar -t -f $out -'' diff --git a/lib/images/macos/helpers/makeBootDisk.nix b/lib/images/macos/helpers/makeBootDisk.nix new file mode 100644 index 0000000..9eaba7e --- /dev/null +++ b/lib/images/macos/helpers/makeBootDisk.nix @@ -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 +'' diff --git a/lib/images/macos/helpers/makeImage.nix b/lib/images/macos/helpers/makeImage.nix index fdafac2..0d64426 100644 --- a/lib/images/macos/helpers/makeImage.nix +++ b/lib/images/macos/helpers/makeImage.nix @@ -1,77 +1,59 @@ -# Build a pre-installed macOS qcow2 with an unattended QEMU install. -# -# One QEMU session, driven by vm-driver.py: -# Recovery (BaseSystem) boots via OpenCore → driver opens Terminal with -# keystrokes and types "sh /Volumes/VMIX/run.sh" → vmix-install.sh erases the -# disk, rebuilds the installer app from installer-app.tar + the SharedSupport.dmg -# raw disk, runs startosinstall (--installpackage vmix-agent.pkg) → the installer -# reboots through its phases → first boot of macOS runs the vmix agent, which -# executes vmix-run.sh and powers off → QEMU exits. -# Afterwards OpenCore is copied into the image's EFI partition so the result -# boots standalone with plain OVMF. Apply templates with customizeImageFold, -# then .generalize to create the user and set a fresh SMBIOS identity. -{ pkgs, lib, qemu, ident, installerPayload, makeOpenCore, makeVmixVolume, makeAgentPkg, installBootloader, vmixReadback, vmDriver, ... }: +# 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 (fetchurl) - recovery, # BaseSystem.dmg (fetchRecovery) + 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 Tahoe-supported (MacPro7,1, iMac20,1/2, MacBookPro16,x) + model ? "MacPro7,1", # SMBIOS model; must be supported by the installed macOS seed ? name, # MAC address + SystemUUID are derived from this bootArgs ? "keepsyms=1", vncDisplay ? null, # e.g. ":10" to watch the install on port 5910 timeout ? 4 * 3600, # seconds for the whole install extraOpenCoreConfig ? {}, # merged into config.plist - installNetwork ? false, # attach a NIC during install (default: offline — startosinstall - # otherwise hangs on Apple personalization through a flaky NAT) + 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; extraConfig = extraOpenCoreConfig; }; + 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; }; - recoveryImg = pkgs.runCommand "${name}-BaseSystem.img" { nativeBuildInputs = [ pkgs.dmg2img ]; } '' - dmg2img -s ${recovery} $out - ''; - agentPkg = makeAgentPkg { }; - agentDir = pkgs.runCommand "vmix-agent-files" { } '' - mkdir -p $out - cp ${../guest/agent.sh} $out/agent.sh - cp ${../guest/ch.vmix.agent.plist} $out/ch.vmix.agent.plist - ''; - firstBoot = pkgs.writeText "vmix-run.sh" '' - echo "vmix: first boot of the installed system" - sw_vers - exit 0 - ''; vmixVol = makeVmixVolume { inherit name; size = "512M"; files = [ { source = ../guest/vmix-install.sh; name = "run.sh"; } + { source = ../guest/pe-lib.sh; name = "pe-lib.sh"; } { source = "${payload}/installer-app.tar"; name = "installer-app.tar"; } - { source = agentPkg; name = "vmix-agent.pkg"; } - { source = agentDir; name = "agent"; } - { source = firstBoot; name = "vmix-run.sh"; } ]; }; - driverPython = pkgs.python3.withPackages (p: [ p.pillow p.pytesseract ]); - tesseract = pkgs.tesseract.override { enableLanguages = [ "eng" ]; }; + driverPython = pkgs.python3.withPackages (p: [ p.pillow ]); drv = pkgs.runCommand "${name}-vmix.qcow2" { __noChroot = true; requiredSystemFeatures = [ "kvm" ]; - nativeBuildInputs = with pkgs; [ pkgs.qemu mtools jq driverPython tesseract libguestfs-with-appliance ]; + 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 ${recoveryImg} recovery.qcow2 - qemu-img create -q -f qcow2 -F raw -b ${esp}/boot.img ocboot.qcow2 + 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 @@ -99,42 +81,33 @@ let cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd chmod +w vars.fd - VMIX_DISPLAY="-display none" ${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''} - ${lib.optionalString (vncDisplay == null) '' - VMIX_DF=$(ls -t /tmp/.vmix-display-* 2>/dev/null | head -1) - if [ -n "$VMIX_DF" ] && [ "$(stat -c %s "$VMIX_DF")" -lt 256 ] && ! grep -q -P '[^\x20-\x7e\n]' "$VMIX_DF"; then - export DISPLAY=$(tr -d '\n' < "$VMIX_DF") - export HOME=$(mktemp -d) - export XDG_RUNTIME_DIR=$HOME - export SDL_VIDEODRIVER=x11 - VMIX_DISPLAY="-display sdl" - fi - ''} - echo "=== vmix: installing ${name} (unattended, 1-2 h; screenshots in /tmp/vmix-macos/${name}) ===" - python3 ${vmDriver} --mode install --name ${name} --timeout ${toString timeout} --progress-file disk.qcow2 -- \ + 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 = "recovery"; port = 1; file = "recovery.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 driver exits non-zero (handled above) if the install did not reach a - # completed/powered-off state, so reaching here means the OS is installed. - # vmix-run.status is written only when the agent ran (cron/daemon); log it. + # 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"} - [ "$STATUS" = "0" ] && echo "vmix: first-boot agent completed (status 0)" \ - || echo "vmix: install reached loginwindow (agent status '$STATUS'); image is installed" + 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; } +in drv // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; inherit model pe volumeName; } diff --git a/lib/images/macos/helpers/makeOpenCore.nix b/lib/images/macos/helpers/makeOpenCore.nix index f0a120b..5ae1475 100644 --- a/lib/images/macos/helpers/makeOpenCore.nix +++ b/lib/images/macos/helpers/makeOpenCore.nix @@ -18,6 +18,7 @@ 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"; }; @@ -50,7 +51,7 @@ pkgs.runCommand "${name}-esp" { --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)} + --extra-json ${lib.escapeShellArg (builtins.toJSON extraConfig)} --memory-mb ${toString memSize} 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}" \ diff --git a/lib/images/macos/helpers/makeRecoveryPE.nix b/lib/images/macos/helpers/makeRecoveryPE.nix new file mode 100644 index 0000000..6e6694a --- /dev/null +++ b/lib/images/macos/helpers/makeRecoveryPE.nix @@ -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 <= 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 diff --git a/lib/images/macos/helpers/qemu.nix b/lib/images/macos/helpers/qemu.nix index c8e8474..86a48d3 100644 --- a/lib/images/macos/helpers/qemu.nix +++ b/lib/images/macos/helpers/qemu.nix @@ -30,6 +30,9 @@ rec { 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}"; + firmwareArgs = varsFile: "-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd -drive if=pflash,format=raw,file=${varsFile}"; } diff --git a/lib/images/macos/helpers/vm-driver.py b/lib/images/macos/helpers/vm-driver.py index e4d038c..f27ba33 100644 --- a/lib/images/macos/helpers/vm-driver.py +++ b/lib/images/macos/helpers/vm-driver.py @@ -1,21 +1,27 @@ #!/usr/bin/env python3 -"""vmix macOS VM driver. +"""vmix macOS VM driver: runs QEMU and decides when a build boot is finished. -Launches QEMU with a QMP socket and either +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. - --mode install drives macOS Recovery to a Terminal with keystrokes (screen - settle detection + OCR of the menu bar), types the bootstrap - command and waits for the VM to power itself off - --mode boot waits for the VM to power itself off (customize steps) - -Everything after `--` is the QEMU command line. Screenshots and a log are -written to --debug-dir (default /tmp/vmix-macos/) for troubleshooting. +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 @@ -23,60 +29,66 @@ import time try: from PIL import Image -except ImportError: # pragma: no cover +except ImportError: # screenshots then only serve as debug files Image = None -try: - import pytesseract -except ImportError: # pragma: no cover - pytesseract = None - -# QEMU qcodes for characters that are not plain alphanumerics -PLAIN = {' ': 'spc', '/': 'slash', '-': 'minus', '.': 'dot', ';': 'semicolon', ',': 'comma', - '=': 'equal', "'": 'apostrophe', '`': 'grave_accent', '[': 'bracket_left', - ']': 'bracket_right', '\\': 'backslash', '\n': 'ret', '\t': 'tab'} -SHIFTED = {'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7', '*': '8', - '(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left', - '}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe', - '<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent'} +PANIC_MARKS = ('panic(cpu', 'Kernel Extensions in backtrace', 'Debugger called: ', 'Nested panic detected', 'panic string:') +REBOOT_MARK = 'MACH Reboot' class Log: def __init__(self, path): - self.f = open(path, 'a') + 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) - self.f.write(line + '\n') - self.f.flush() + if self.f: + self.f.write(line + '\n') + self.f.flush() class QMP: def __init__(self, path): - self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self.sock.connect(path) - self.f = self.sock.makefile('rwb', buffering=0) - self._read() - self.cmd('qmp_capabilities') + 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 True: - line = self.f.readline() - if not line: - raise EOFError('QMP connection closed') - msg = json.loads(line) - if 'event' in msg: - continue - return msg + 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.f.write((json.dumps({'execute': name, 'arguments': args}) + '\n').encode()) - r = self._read() - if 'error' in r: - raise RuntimeError(f'QMP {name}: {r["error"]}') - return r.get('return') + 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) @@ -87,182 +99,117 @@ class QMP: def system_powerdown(self): self.cmd('system_powerdown') - _jig = 0 - - def jiggle(self): - # tiny absolute (usb-tablet) pointer move to keep the display awake: a real - # HID event, but < 2 screen px so it does not change the settle fingerprint - self._jig = 16060 if self._jig < 16030 else 16000 - try: - self.cmd('input-send-event', events=[ - {'type': 'abs', 'data': {'axis': 'x', 'value': self._jig}}, - {'type': 'abs', 'data': {'axis': 'y', 'value': 16000}}]) - except Exception: # noqa: BLE001 - self.send_key('shift') - - def send_key(self, *keys, hold=80): - self.cmd('send-key', keys=[{'type': 'qcode', 'data': k} for k in keys], **{'hold-time': hold}) - time.sleep(0.15) - - def type_text(self, text): - for ch in text: - if ch.isascii() and ch.isalnum(): - if ch.isupper(): - self.send_key('shift', ch.lower()) - else: - self.send_key(ch) - elif ch in PLAIN: - self.send_key(PLAIN[ch]) - elif ch in SHIFTED: - self.send_key('shift', SHIFTED[ch]) - else: - raise ValueError(f'cannot type {ch!r}') - class Screen: - """Screenshot helper: settle detection via hashing, OCR of regions.""" + """Screenshots over QMP with a coarse change fingerprint (cursor-insensitive).""" def __init__(self, qmp, debug_dir, log): self.qmp = qmp - self.debug_dir = debug_dir + self.dir = debug_dir self.log = log - import tempfile - fd, self.tmp = tempfile.mkstemp(prefix='.shot-', suffix='.ppm', dir=debug_dir) - os.close(fd) - os.chmod(self.tmp, 0o666) - self.n = 0 - self.last_hash = None - self.stable_since = time.time() + self.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() - self.img = Image.open(io.BytesIO(data)) if Image else None - # fingerprint from a coarse, quantized grayscale thumbnail so the moving - # mouse cursor (keepalive jiggle) does not count as a screen change - if self.img is not None: - px = self.img.convert('L').resize((48, 36)) - fp = bytes(b & 0xF0 for b in px.getdata()) - h = hashlib.sha256(fp).hexdigest() + if Image is None: + fp = hashlib.sha256(data).hexdigest() else: - h = hashlib.sha256(data).hexdigest() - if h != self.last_hash: - self.last_hash = h + 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.debug_dir, f'{self.n:03d}-{tag}.png') + path = os.path.join(self.dir, f'{self.n:03d}-{tag}.png') try: if self.img is not None: self.img.save(path) else: - os.link(self.tmp, path.replace('.png', '.ppm')) + shutil.copy(self.tmp, path.replace('.png', '.ppm')) except Exception as e: # noqa: BLE001 self.log(f'could not save screenshot: {e}') return path - def ocr(self, region=None, scale=3, psm=6): - if self.img is None or pytesseract is None: - return '' - img = self.img - if region: - img = img.crop(region) - img = img.convert('L').resize((img.width * scale, img.height * scale), Image.LANCZOS) - try: - return pytesseract.image_to_string(img, config=f'--psm {psm}').lower() - except Exception as e: # noqa: BLE001 - self.log(f'ocr failed: {e}') - return '' - def mean(self): - if self.img is None: - return 128 - px = self.img.convert('L').resize((32, 24)) - d = list(px.getdata()) - return sum(d) / len(d) +class Serial: + """Tail the serial console file QEMU writes (-serial file:...).""" - def is_blank(self): - # black/uniform screen (firmware, boot): nothing to act on - if self.img is None: - return False - lo, hi = self.img.convert('L').resize((64, 48)).getextrema() - return hi - lo < 24 + def __init__(self, path): + self.path = path + self.pos = 0 + self.last_activity = time.time() + self.boots = 0 + self.reboot_at = None - def menubar_text(self): - w = self.img.width if self.img else 1024 - return self.ocr((0, 0, w, 40), scale=4, psm=7) + def 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): - # nix builds run as different nixbld users: keep the shared dirs world-writable + os.makedirs(path, exist_ok=True) try: - for d in (os.path.dirname(path), path): - os.makedirs(d, exist_ok=True) - try: - os.chmod(d, 0o1777 if d != path else 0o777) - except OSError: - pass - probe = os.path.join(path, '.probe') - open(probe, 'w').close() - os.unlink(probe) - # a rebuild reuses this dir but runs as a different nixbld user; drop stale - # files so screendumps/PNGs are not blocked by another owner's 0644 files - import glob - for f in glob.glob(os.path.join(path, '*')) + glob.glob(os.path.join(path, '.current*')): - try: - os.unlink(f) - except OSError: - pass - return path + os.chmod(path, 0o777) except OSError: - import tempfile - alt = tempfile.mkdtemp(prefix='vmix-macos-') - print(f'vmix driver: {path} not writable, using {alt}', flush=True) - return alt + pass + 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 def launch(qemu_args, qmp_sock, log): if os.path.exists(qmp_sock): - os.unlink(qmp_sock) - args = list(qemu_args) + ['-qmp', f'unix:{qmp_sock},server,nowait'] - log('launching: ' + ' '.join(args)) - proc = subprocess.Popen(args) - deadline = time.time() + 60 - while not os.path.exists(qmp_sock): - if proc.poll() is not None: - return proc, None - if time.time() > deadline: - proc.kill() - raise RuntimeError('QEMU did not create the QMP socket') - time.sleep(0.2) - time.sleep(0.5) - return proc, QMP(qmp_sock) - - -def open_terminal(qmp, log): - # Ctrl-F2 focuses the menu bar; typing jumps to the menu whose title starts - # with that letter (Utilities), Down opens it, "t" jumps to Terminal. - log('opening Terminal via menu bar (ctrl-f2, u, down, t, ret)') - qmp.send_key('ctrl', 'f2') - time.sleep(1.0) - qmp.send_key('u') - time.sleep(0.7) - qmp.send_key('down') - time.sleep(0.7) - qmp.send_key('t') - time.sleep(0.7) - qmp.send_key('ret') + 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): - """True if the system disk has had no writes recently (guest not doing I/O).""" if not args.progress_file: return True try: @@ -271,289 +218,150 @@ def disk_idle(args): return True -def run_install(args, proc, qmp, log): - """Drive the install VM to completion. - - OpenCore shows a boot picker on every (re)boot and does not always auto-boot, - so on any settled picker we press Return to boot the highlighted macOS entry - (aux entries are hidden; during the install phases startosinstall blesses the - right default). That runs on EVERY iteration, because the install reboots - several times after we hand off to startosinstall. Before we have typed the - bootstrap command we also drive Recovery: language/welcome -> Return, the - Recovery window -> open Terminal, Terminal -> type the command. - """ - RECOVERY_BODY = ('reinstall', 'disk utility', 'restore from', 'recovery assistant', - 'macos utilities') - PICKER_BODY = ('base system', 'macos installer', 'rel-1', 'rel-0') # OpenCore picker - LANG_BODY = ('language', 'select your', 'main language', 'country or region', - 'welcome', 'get started', 'choose your') - screen = Screen(qmp, args.debug_dir, log) +def drive(args, proc, qmp, screen, serial, log): start = time.time() - typed_at = None - terminal_attempts = 0 last_periodic = 0 - last_progress = start - blind_done = False resets = 0 + panics = 0 + started = args.mode == 'boot' # pe/install: wait for the PE marker first blank_since = None login_since = None - recovery_start = None - last_term_action = 0 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') + screen.grab() + screen.save('timeout') except Exception: # noqa: BLE001 pass log('timeout reached, killing QEMU') proc.kill() return 124 time.sleep(args.interval) - try: - screen.grab() - except Exception as e: # noqa: BLE001 - log(f'screendump failed ({e}), assuming QEMU is exiting') - time.sleep(2) - continue - if now - last_periodic > args.periodic: - last_periodic = now - screen.save('periodic') - # keep the recovery display awake until the command is typed (mouse jiggle) - if typed_at is None and now - last_term_action > 8: - qmp.jiggle() - if now - start < args.min_boot or screen.stable_for() < args.settle: - continue - if screen.is_blank(): - last_progress = now - if blank_since is None: - blank_since = now - if typed_at is None: - # recovery display asleep — jiggle the mouse to wake it, wait for UI - qmp.jiggle() - continue - # macOS `shutdown -h now` halts the guest to a black screen without an - # ACPI power-off, so QEMU never exits. Once we have handed off (command - # typed), a long pure-black screen means the agent finished and halted. - elif typed_at is not None and now - blank_since > args.halt_timeout and disk_idle(args): - screen.save('halt') - log(f'guest halted (black {now - blank_since:.0f}s, disk idle); killing QEMU, readback will validate') + + 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 args.mode == 'pe' or panics > args.max_resets: + log(f'kernel panic #{panics}, giving up') proc.kill() - try: - proc.wait(timeout=10) - except Exception: # noqa: BLE001 - pass - return 0 + return 3 + log(f'kernel panic #{panics}, system_reset') + qmp.system_reset() + screen.stable_since = time.time() continue - blank_since = None - top = screen.menubar_text() - body = screen.ocr() - log(f'settled: menubar={top.strip()!r} body~={" ".join(body.split())[:80]!r}') - - # Boot-hang watchdog: a dark screen (Apple logo / black) frozen for a long - # time with no menu bar is a stuck (re)boot — kick it with a system reset. - # Never fires on the bright, static Terminal of the prepare phase. - if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \ - and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets: + # 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 and args.mode != 'pe': resets += 1 - screen.save('stall-reset') - log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, disk 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() - last_progress = now - continue - - # OpenCore boot picker — always handle it (the install reboots many times) - if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY): - screen.save('picker') - log('OpenCore boot picker, pressing Return to boot the default macOS entry') - qmp.send_key('ret') - last_progress = now - screen.stable_since = time.time() - continue - - # After the install, the loginwindow/desktop is a BRIGHT gray screen, unlike - # the dark install/boot screens (Apple logo). The vmix agent powers the VM - # off if it runs (cron/daemon); if BTM blocks it, we power down here so the - # build still completes with a bootable, installed image. Brightness is a - # far more reliable signal than OCR of the faint "password" text. - bright = screen.mean() > 80 - loginish = (typed_at is not None and bright and 'terminal' not in top - and 'utilities' not in top and not any(k in body for k in PICKER_BODY)) - if loginish: - if login_since is None: - login_since = now - log('bright post-install screen (loginwindow/desktop) — OS installed; grace before powerdown') - elif now - login_since > args.login_grace: - screen.save('loginwindow') - log(f'loginwindow persisted {now - login_since:.0f}s, powering down (install complete)') - try: - qmp.system_powerdown() - except Exception as e: # noqa: BLE001 - log(f'powerdown failed: {e}') - for _ in range(90): - if proc.poll() is not None: - return 0 - time.sleep(1) - proc.kill() - return 0 - continue - else: - login_since = None - - # once the bootstrap command is typed, only the picker (above) and an - # unexpected return to Recovery matter (post-prepare reboot landed on the - # recovery instead of the installer — restart the install then). - if typed_at is not None: - if ('utilities' in top or 'recovery' in top): - if recovery_start is None: - recovery_start = now - if now - typed_at > 120 and now - recovery_start > 45: - log('unexpectedly back at Recovery after install started — restarting install') - typed_at = None - terminal_attempts = 0 - recovery_start = None - # fall through to the recovery/terminal handling below - else: - continue - else: - recovery_start = None - continue - - if 'terminal' in top: - screen.save('terminal') - log(f'typing bootstrap command: {args.command!r}') - qmp.type_text(args.command + '\n') - typed_at = time.time() - continue - - acted = False - if 'utilities' in top or 'recovery' in top or any(k in body for k in RECOVERY_BODY): - screen.save('recovery') - terminal_attempts += 1 - log(f'recovery window (attempt {terminal_attempts}), opening Terminal') - last_term_action = now - open_terminal(qmp, log) - if terminal_attempts >= 3: - time.sleep(8) - log('typing bootstrap command (Terminal assumed open)') - qmp.type_text(args.command + '\n') - typed_at = time.time() - continue - acted = True - elif any(k in body for k in LANG_BODY): - screen.save('language') - log('language/welcome screen, pressing Return') - qmp.send_key('ret') - acted = True - - if acted: - last_progress = now - screen.stable_since = time.time() - elif now - last_progress > args.settle * args.max_actions and not blind_done: - blind_done = True - screen.save('blind') - log('nothing recognised for a long time, blind sequence') - qmp.send_key('ret') - time.sleep(20) - open_terminal(qmp, log) - time.sleep(10) - qmp.type_text(args.command + '\n') - typed_at = time.time() - - -def run_boot(args, proc, qmp, log): - """Wait for the VM to power itself off (customize/generalize/first-boot), - handling the OpenCore picker and kicking a hung boot with a system reset.""" - PICKER_BODY = ('base system', 'macos installer', 'macintosh hd', 'rel-1', 'rel-0') - screen = Screen(qmp, args.debug_dir, log) - start = time.time() - last_periodic = 0 - resets = 0 - blank_since = None - login_since = None - while True: - rc = proc.poll() - if rc is not None: - return rc - if time.time() - start > args.timeout: - try: - screen.grab(); screen.save('timeout') + screen.grab() + screen.save('reboot-dead') except Exception: # noqa: BLE001 pass - log('timeout reached, killing QEMU') + 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 124 - time.sleep(args.interval) + return 3 + try: screen.grab() except Exception as e: # noqa: BLE001 log(f'screendump failed ({e})') time.sleep(2) continue - now = time.time() if now - last_periodic > args.periodic: last_periodic = now screen.save('periodic') - if now - start < args.min_boot or screen.stable_for() < args.settle: + + 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(): - if blank_since is None: - blank_since = now - elif now - blank_since > args.halt_timeout and disk_idle(args): + 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, disk idle); killing QEMU') + log(f'guest halted (black {now - blank_since:.0f}s, idle); killing QEMU') proc.kill() - try: - proc.wait(timeout=10) - except Exception: # noqa: BLE001 - pass return 0 continue blank_since = None - top = screen.menubar_text() - body = screen.ocr() - if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY): - screen.save('picker') - log('OpenCore boot picker, pressing Return') - qmp.send_key('ret') - screen.stable_since = time.time() - login_since = None - continue - # bright post-boot screen (loginwindow/desktop) => booted; power down if the - # agent did not (so customize/generalize completes even if BTM blocks it) - if screen.mean() > 80 and 'terminal' not in top and 'utilities' not in top: + + if screen.mean() > args.bright: + # loginwindow / Setup Assistant: the OS is installed and booted if login_since is None: login_since = now - log('bright screen (loginwindow/desktop) after boot; grace before powerdown') + 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(f'loginwindow persisted {now - login_since:.0f}s, powering down') + log('powering down (boot complete)') try: qmp.system_powerdown() except Exception as e: # noqa: BLE001 log(f'powerdown failed: {e}') - for _ in range(90): + for _ in range(120): if proc.poll() is not None: + log('QEMU exited after powerdown') return 0 time.sleep(1) + log('guest ignored powerdown, killing QEMU') proc.kill() return 0 continue - else: - login_since = None - if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \ - and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets: + 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, disk idle), system_reset #{resets}') + 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 @@ -562,54 +370,60 @@ def run_boot(args, proc, qmp, log): def main(): - p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - p.add_argument('--mode', choices=['install', 'boot'], required=True) + p = argparse.ArgumentParser() + p.add_argument('--mode', choices=['pe', 'install', 'boot'], required=True) 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('--min-boot', type=float, default=45.0, help='seconds before the first action') - p.add_argument('--max-actions', type=int, default=8) - p.add_argument('--stall-reset', type=float, default=360.0, help='reset the VM if a non-Terminal screen is frozen this long (boot hang)') + 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('--halt-timeout', type=float, default=150.0, help='after the bootstrap, a pure-black screen this long means the guest halted (macOS shutdown does not ACPI-power-off QEMU)') - p.add_argument('--progress-file', default=None, help='a file (the system disk) whose mtime shows guest activity; resets/halt only fire when it is also idle, so a slow-but-working boot is never interrupted') - p.add_argument('--disk-idle', type=float, default=90.0, help='seconds of no writes to --progress-file that count as idle') - p.add_argument('--login-grace', type=float, default=240.0, help='seconds to wait at the loginwindow for the agent to power off before the driver powers down itself') - p.add_argument('--command', default='diskutil mount VMIX;sh /Volumes/VMIX/run.sh') + 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 = args.qemu[1:] if args.qemu and args.qemu[0] == '--' else args.qemu + qemu_args = [a for a in args.qemu if a != '--'] if not qemu_args: p.error('QEMU command line required after --') - args.debug_dir = prepare_debug_dir(args.debug_dir or f'/tmp/vmix-macos/{args.name}') - log = Log(os.path.join(args.debug_dir, 'driver.log')) - log(f'mode={args.mode} debug-dir={args.debug_dir} ocr={"yes" if pytesseract else "no"}') - qmp_sock = os.path.join(args.debug_dir, 'qmp.sock') - - proc, qmp = launch(qemu_args, qmp_sock, log) - if qmp is None and '-display' in qemu_args and 'sdl' in qemu_args: - # SDL could not open a window: retry headless - log(f'QEMU exited early ({proc.returncode}) with SDL, retrying headless') - i = qemu_args.index('-display') - qemu_args = qemu_args[:i] + ['-display', 'none'] + qemu_args[i + 2:] - proc, qmp = launch(qemu_args, qmp_sock, log) - if qmp is None: - log(f'QEMU exited immediately with {proc.returncode}') - return proc.returncode or 1 + debug_dir = args.debug_dir or f'/tmp/vmix-macos/{args.name}' + 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: - if args.mode == 'install': - rc = run_install(args, proc, qmp, log) - else: - rc = run_boot(args, proc, qmp, log) + 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: + rc = drive(args, proc, qmp, screen, serial, log) finally: - if proc.poll() is None: - proc.kill() - log(f'QEMU exited with {rc}') + 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 diff --git a/lib/images/macos/helpers/vmix-readback.nix b/lib/images/macos/helpers/vmix-readback.nix index 1121c6c..23c6a91 100644 --- a/lib/images/macos/helpers/vmix-readback.nix +++ b/lib/images/macos/helpers/vmix-readback.nix @@ -1,14 +1,14 @@ -# Shell snippet: read the vmix agent's result off the VMIX HFS+ volume of a raw -# disk image (${image}). Prints the guest logs and sets STATUS to the contents -# of vmix-run.status ("0" on success). Uses libguestfs (mtools is FAT-only). The -# agent unmounts VMIX cleanly before shutdown, so a read-only mount is safe. +# 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 install.log system-install.log vmix-run.log vmix-agent.log; do + 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"; } + [ -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) '' diff --git a/lib/images/macos/tahoe/images.nix b/lib/images/macos/tahoe/images.nix index a3e6868..e848fcd 100644 --- a/lib/images/macos/tahoe/images.nix +++ b/lib/images/macos/tahoe/images.nix @@ -1,11 +1,14 @@ # Pre-built macOS Tahoe (26) images -# Pipeline: makeImage (unattended install, vmix agent) → templates → generalize +# 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 recovery; + inherit installer pe; }; basic = customizeImageFold upstream templates.bundles.basic; diff --git a/lib/images/macos/templates/default.nix b/lib/images/macos/templates/default.nix index 1edb923..b800c8f 100644 --- a/lib/images/macos/templates/default.nix +++ b/lib/images/macos/templates/default.nix @@ -5,7 +5,7 @@ rec { essentials = { remoteAccess = import ./essentials/remote-access.nix { }; noUpdates = import ./essentials/no-updates.nix { }; - performance = import ./essentials/performance.nix { }; + performance = import ./essentials/performance.nix { inherit pkgs; }; }; bundles = { diff --git a/lib/images/macos/templates/essentials/no-updates.nix b/lib/images/macos/templates/essentials/no-updates.nix index 736f824..1e4008f 100644 --- a/lib/images/macos/templates/essentials/no-updates.nix +++ b/lib/images/macos/templates/essentials/no-updates.nix @@ -4,12 +4,10 @@ { name = "no-updates"; script = '' - softwareupdate --schedule off || true - defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool false - defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -bool false - defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticallyInstallMacOSUpdates -bool false - defaults write /Library/Preferences/com.apple.SoftwareUpdate ConfigDataInstall -bool false - defaults write /Library/Preferences/com.apple.SoftwareUpdate CriticalUpdateInstall -bool false - defaults write /Library/Preferences/com.apple.commerce AutoUpdate -bool false + 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 ''; } diff --git a/lib/images/macos/templates/essentials/performance.nix b/lib/images/macos/templates/essentials/performance.nix index f6d3a99..805f01a 100644 --- a/lib/images/macos/templates/essentials/performance.nix +++ b/lib/images/macos/templates/essentials/performance.nix @@ -1,11 +1,32 @@ -# Less background work in a VM: no Spotlight indexing, no Time Machine, no sleep -{ ... }: +# 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" '' + + + + + ActivePowerProfilesAC Power-1 + Custom ProfileAC Power + Display Sleep Timer0 + System Sleep Timer0 + Disk Sleep Timer0 + Wake On LAN0 + hibernatemode0 + + + + ''; +in { name = "performance"; + files = [ { source = power; name = "com.apple.PowerManagement.plist"; } ]; script = '' - mdutil -a -i off || true - tmutil disable || true - pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 womp 0 || true - defaults write /Library/Preferences/com.apple.loginwindow DisableScreenLockImmediate -bool true + 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" ''; } diff --git a/lib/images/macos/templates/essentials/remote-access.nix b/lib/images/macos/templates/essentials/remote-access.nix index 51bb432..eb80941 100644 --- a/lib/images/macos/templates/essentials/remote-access.nix +++ b/lib/images/macos/templates/essentials/remote-access.nix @@ -1,11 +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 = '' - systemsetup -setremotelogin on >/dev/null 2>&1 || launchctl load -w /System/Library/LaunchDaemons/ssh.plist - launchctl load -w /System/Library/LaunchDaemons/com.apple.screensharing.plist - # allow all local users to screen share - defaults write /var/db/launchd.db/com.apple.launchd/overrides.plist com.apple.screensharing -dict Disabled -bool false 2>/dev/null || true + pe_service_disabled com.apple.openssh.sshd false + pe_service_disabled com.apple.screensharing false ''; } diff --git a/lib/images/macos/templates/generalize.nix b/lib/images/macos/templates/generalize.nix index ac59348..620aa6a 100644 --- a/lib/images/macos/templates/generalize.nix +++ b/lib/images/macos/templates/generalize.nix @@ -1,7 +1,8 @@ -# Generalize a macOS image: create the user, auto-login, hostname, timezone, -# suppress Setup Assistant prompts, then remove the vmix agent. Also gives the -# image a fresh SMBIOS identity (serial/MLB from macserial, MAC + UUID from -# `seed`) so every generalized VM looks like a distinct Mac to Apple ID/iMessage. +# 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, ... }: @@ -34,7 +35,8 @@ let "DidSeeCloudSetup" "DidSeeSiriSetup" "DidSeePrivacy" "DidSeeTouchIDSetup" "DidSeeAppearanceSetup" "DidSeeScreenTime" "DidSeeAccessibility" "DidSeeTrueTonePrivacy" "DidSeeActivationLock" "DidSeeiCloudLoginForStorageServices" "DidSeeSyncSetup" "DidSeeSyncSetup2" "DidSeeAppleIDSyncSetup" - "DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "SkipFirstLoginOptimization" + "DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "DidSeeUpdateMacAutomatically" + "DidSeeSoftwareUpdate" "SkipFirstLoginOptimization" ]; in { @@ -44,60 +46,81 @@ in 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) - if ! id "${username}" >/dev/null 2>&1; then - sysadminctl -addUser "${username}" -fullName ${lib.escapeShellArg fullName} \ - -password ${lib.escapeShellArg (if password == "" then tempPassword else password)} \ - -admin -home "/Users/${username}" || exit 1 - ${lib.optionalString (password == "") '' - dscl . -passwd "/Users/${username}" "${tempPassword}" "" || echo "vmix: WARNING: could not set an empty password, password is '${tempPassword}'" - ''} + # --- user account (admin), created directly in the local directory node + U="${username}"; HOME_DIR="$DATA/Users/$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 "/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 '' - defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser "${username}" - cp /Volumes/VMIX/kcpassword /etc/kcpassword - chmod 600 /etc/kcpassword - chown root:wheel /etc/kcpassword + 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 - P="/Users/${username}/Library/Preferences/com.apple.SetupAssistant" - VER=$(sw_vers -productVersion) - BUILD=$(sw_vers -buildVersion) - for k in ${lib.concatStringsSep " " setupKeys}; do - defaults write "$P" "$k" -bool true - done - defaults write "$P" GestureMovieSeen none - defaults write "$P" LastSeenCloudProductVersion "$VER" - defaults write "$P" LastSeenBuddyBuildVersion "$BUILD" - defaults write "$P" LastSeenSiriProductVersion "$VER" - defaults write "$P" LastPreLoginTasksPerformedVersion "$VER" - defaults write "/Users/${username}/Library/Preferences/.GlobalPreferences" AppleLocale "${macLocale}" - chown -R "${username}" "/Users/${username}/Library/Preferences" + 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}" + 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 - scutil --set ComputerName "${hostname}" - scutil --set HostName "${hostname}" - scutil --set LocalHostName "${hostname}" - defaults write /Library/Preferences/.GlobalPreferences AppleLocale "${macLocale}" - systemsetup -settimezone "${timezone}" >/dev/null 2>&1 || ln -sfn "/usr/share/zoneinfo/${timezone}" /etc/localtime + 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 - # --- never sleep (VM) - pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 || true + # --- 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 # --- use the whole (possibly grown) disk - STORE=$(diskutil info / | awk '/APFS Physical Store/ {print $NF}') + STORE=$(diskutil info "$SYS_ID" | sed -n 's/.*APFS Physical Store: *//p' | awk '{print $1}') [ -n "$STORE" ] && diskutil apfs resizeContainer "$STORE" 0 || true - - ${lib.optionalString delayOobeRun '' - # Setup Assistant will run on the next boot - rm -f /var/db/.AppleSetupDone - ''} - - # --- the agent's job is done: remove it (this is the last vmix step) - rm -f /Library/LaunchDaemons/ch.vmix.agent.plist - rm -rf /Library/vmix ''; } diff --git a/lib/images/macos/tools/soak.sh b/lib/images/macos/tools/soak.sh new file mode 100755 index 0000000..bdb1435 --- /dev/null +++ b/lib/images/macos/tools/soak.sh @@ -0,0 +1,28 @@ +#!/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 [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 "$DRV^*" > "$D/build.log" 2>&1; rc=$? + else nix build --no-link -L --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 ' ') + printf '%-4s %-8s %-9s %-6s %-7s %-7s %-6s %s\n' "$i" "$([ $rc -eq 0 ] && echo OK || echo FAIL)" "$(( (t1 - t0) / 60 ))" "$boots" "$resets" "$panics" "$tries" "$note" | tee -a "$OUT/summary.txt" +done +echo "logs: $OUT" From 22daf7720ceef0199bd203148ff00b85f4cf4095 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 9 Sep 2026 12:33:59 -0300 Subject: [PATCH 04/23] macOS: current Lilu/VirtualSMC/WhateverGreen/RestrictEvents, no isa-applesmc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OSX-KVM ESP ships Lilu 1.6.8 / VirtualSMC 1.3.3 / WhateverGreen 1.6.7, which disable themselves on macOS 26; Apple's SMC driver then runs on QEMU's isa-applesmc stub and the restart path panics (SMCWDT smcWriteKey kSMCBadCommand → nested panic after MACH Reboot). Overlay pinned current releases (upstream.json opencore.kexts) and drop isa-applesmc: with the stub present VirtualSMC steps aside ("multiple devices present"); alone it carries the OSK and reboots work (PE restart test: 10 s, clean). RestrictEvents with revpatch=memtab silences MacPro7,1's "Memory Modules Misconfigured". - makeOpenCore: kext overlay + Kernel.Add entries for overlaid kexts, --memory-mb (4 DIMMs), bootArgs default revpatch=memtab - qemu.nix: deviceArgsFor { appleSmc } (default false); cli: --applesmc for images built before this change - vm-driver: reboot-death detection (reset 60 s after a guest reboot request that never comes back), panics wait for XNU's own auto-reboot, debug dir works across nixbld users - generalize: QEMU USB keyboard declared ANSI (no Keyboard Setup Assistant) - README: architecture, reliability handling, debugging Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF --- cli.nix | 5 ++++- lib/images/macos/README.md | 11 ++++++++++ lib/images/macos/helpers/makeImage.nix | 2 +- lib/images/macos/helpers/makeOpenCore.nix | 20 ++++++++++++++++-- lib/images/macos/helpers/oc-config.py | 13 ++++++++++++ lib/images/macos/helpers/qemu.nix | 14 +++++++++---- lib/images/macos/helpers/vm-driver.py | 25 ++++++++++++++++------- lib/images/macos/upstream.json | 22 ++++++++++++++++++++ 8 files changed, 97 insertions(+), 15 deletions(-) diff --git a/cli.nix b/cli.nix index e220dbb..82882d0 100644 --- a/cli.nix +++ b/cli.nix @@ -33,7 +33,8 @@ pkgs.writeShellScriptBin "vmix" '' echo " --to-remote-disk SSH:DEV Stream to remote disk via SSH and expand partitions" echo " e.g. root@10.10.10.100:/dev/sda" echo " --ahci Use AHCI storage for vmix run (for laptop images)" - echo " --macos macOS image for vmix run (OpenCore/AppleSMC flags, AHCI)" + echo " --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 " --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" @@ -100,6 +101,7 @@ pkgs.writeShellScriptBin "vmix" '' --smp) RUN_SMP="$2"; shift 2 ;; --ahci) RUN_AHCI=true; shift ;; --macos) RUN_MACOS=true; shift ;; + --applesmc) RUN_APPLESMC=true; shift ;; --vnc) RUN_VNC="$2"; shift 2 ;; --mac) RUN_MAC="$2"; shift 2 ;; *) echo "Unknown option: $1"; exit 1 ;; @@ -138,6 +140,7 @@ pkgs.writeShellScriptBin "vmix" '' exec ${pkgs.qemu}/bin/qemu-system-x86_64 \ $VMIX_DISPLAY \ ${macosQemu.deviceArgs} ${macosQemu.vgaArgs} \ + $([[ "$RUN_APPLESMC" == true ]] && echo '-device isa-applesmc,osk="${macosQemu.osk}"') \ -accel kvm \ -machine type=q35 \ -cpu ${macosQemu.defaultCpu} \ diff --git a/lib/images/macos/README.md b/lib/images/macos/README.md index db8f90b..6525f20 100644 --- a/lib/images/macos/README.md +++ b/lib/images/macos/README.md @@ -98,3 +98,14 @@ 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. diff --git a/lib/images/macos/helpers/makeImage.nix b/lib/images/macos/helpers/makeImage.nix index 0d64426..70ff78c 100644 --- a/lib/images/macos/helpers/makeImage.nix +++ b/lib/images/macos/helpers/makeImage.nix @@ -20,7 +20,7 @@ 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", + 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 diff --git a/lib/images/macos/helpers/makeOpenCore.nix b/lib/images/macos/helpers/makeOpenCore.nix index 5ae1475..59e777d 100644 --- a/lib/images/macos/helpers/makeOpenCore.nix +++ b/lib/images/macos/helpers/makeOpenCore.nix @@ -13,7 +13,7 @@ uuid, serial ? null, mlb ? null, - bootArgs ? "keepsyms=1", + bootArgs ? "keepsyms=1 revpatch=memtab", resolution ? "1024x768", showPicker ? true, pickerTimeout ? 2, @@ -22,6 +22,12 @@ }: 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 ]; @@ -45,13 +51,23 @@ pkgs.runCommand "${name}-esp" { 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} + --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}" \ diff --git a/lib/images/macos/helpers/oc-config.py b/lib/images/macos/helpers/oc-config.py index 307ce87..fc5b50d 100644 --- a/lib/images/macos/helpers/oc-config.py +++ b/lib/images/macos/helpers/oc-config.py @@ -44,6 +44,7 @@ def main(): 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: @@ -85,6 +86,18 @@ def main(): 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. diff --git a/lib/images/macos/helpers/qemu.nix b/lib/images/macos/helpers/qemu.nix index 86a48d3..60ebb4d 100644 --- a/lib/images/macos/helpers/qemu.nix +++ b/lib/images/macos/helpers/qemu.nix @@ -17,14 +17,20 @@ rec { netArgs = { mac, netdev ? "user,id=net0", extra ? "" }: "-netdev ${netdev} -device virtio-net-pci,netdev=net0,mac=${mac},bus=pcie.0,addr=${nicAddr}${extra}"; - # Devices macOS needs (no accel, disks, display adapter or display server here) - deviceArgs = ''-device isa-applesmc,osk="${osk}" -smbios type=2 -device qemu-xhci,id=xhci -device usb-kbd,bus=xhci.0 -device usb-tablet,bus=xhci.0 -device usb-ehci,id=ehci -device ich9-intel-hda -device hda-duplex -device ich9-ahci,id=sata -global ICH9-LPC.disable_s3=1''; + # 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 }: - "-accel kvm -machine type=q35 -cpu ${cpu} -smp ${toString smp},sockets=1,cores=${toString smp},threads=1 -m ${toString memSize} ${deviceArgs} ${vgaArgs}"; + 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 ? "" }: diff --git a/lib/images/macos/helpers/vm-driver.py b/lib/images/macos/helpers/vm-driver.py index f27ba33..21d207f 100644 --- a/lib/images/macos/helpers/vm-driver.py +++ b/lib/images/macos/helpers/vm-driver.py @@ -25,6 +25,7 @@ import shutil import socket import subprocess import sys +import tempfile import time try: @@ -188,17 +189,25 @@ class Serial: def prepare_debug_dir(path): - os.makedirs(path, exist_ok=True) + """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: - pass + 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): @@ -270,9 +279,9 @@ def drive(args, proc, qmp, screen, serial, log): log(f'kernel panic #{panics}, giving up') proc.kill() return 3 - log(f'kernel panic #{panics}, system_reset') - qmp.system_reset() - screen.stable_since = time.time() + # XNU reboots by itself after a panic; only reset if no kernel comes back + 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 @@ -351,7 +360,9 @@ def drive(args, proc, qmp, screen, serial, log): log('QEMU exited after powerdown') return 0 time.sleep(1) - log('guest ignored powerdown, killing QEMU') + # 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 @@ -395,7 +406,7 @@ def main(): p.error('QEMU command line required after --') debug_dir = args.debug_dir or f'/tmp/vmix-macos/{args.name}' - prepare_debug_dir(debug_dir) + 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}') diff --git a/lib/images/macos/upstream.json b/lib/images/macos/upstream.json index 7730b94..afb5f32 100644 --- a/lib/images/macos/upstream.json +++ b/lib/images/macos/upstream.json @@ -28,6 +28,28 @@ "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=" + } } } } From 779675f87e261827ce5bbebe8f8fc61e3760e8ba Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 9 Sep 2026 13:13:30 -0300 Subject: [PATCH 05/23] macOS soak harness: judge runs by the builder's completion line nix build --rebuild exits non-zero when the byte-wise different qcow2 does not match the previous output; that is not a failed install. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF --- lib/images/macos/tools/soak.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/images/macos/tools/soak.sh b/lib/images/macos/tools/soak.sh index bdb1435..663e897 100755 --- a/lib/images/macos/tools/soak.sh +++ b/lib/images/macos/tools/soak.sh @@ -23,6 +23,9 @@ for i in $(seq 1 "$RUNS"); do 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 ' ') - printf '%-4s %-8s %-9s %-6s %-7s %-7s %-6s %s\n' "$i" "$([ $rc -eq 0 ] && echo OK || echo FAIL)" "$(( (t1 - t0) / 60 ))" "$boots" "$resets" "$panics" "$tries" "$note" | tee -a "$OUT/summary.txt" + # --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" From 50ad8d521c9fe773bee6b592096840c1841c1177 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 9 Sep 2026 13:27:40 -0300 Subject: [PATCH 06/23] macOS README: soak results Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF --- lib/images/macos/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/images/macos/README.md b/lib/images/macos/README.md index 6525f20..037a403 100644 --- a/lib/images/macos/README.md +++ b/lib/images/macos/README.md @@ -83,6 +83,19 @@ and `vmix-install.sh`; every event is logged with a reason): `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: a kernel panic at one of the guest's own reboots during the +install (GPF in launchd's context right after `MACH Reboot`). XNU reboots +itself within seconds and the install continues; the driver only intervenes +if no kernel comes back within 60 s. Known, logged, not yet root-caused. ## Debugging From 0f9373263d5f6d154e8a355a98b0a2906532a278 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 9 Sep 2026 21:47:41 -0300 Subject: [PATCH 07/23] macOS: guest agent, online templates, virtio-fs shares, persistent home volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple's built-in QEMU guest agent (AppleQEMUGuestAgent, launched by launchd when a virtio console port org.qemu.guest_agent.0 appears; guest-exec as root) is attached by vmix run --macos and the NixOS module. AppleVirtIO.kext on x86 Tahoe drives virtio-fs, block, console, input, net — verified in QEMU. - customizeImage: `bootScript` — online step through the guest agent (driver mode qga): boot the image, run the script as root with the VMIX volume, shut down through the agent. `as_user` runs commands in the logged-in session. - templates.software: pkg/app (offline in the PE), script/homebrew (online). - templates.profile.settings: widgets, wallpaper (pinned desktoppr — Apple Events need TCC consent that a headless session cannot give), dock apps, autohide, dark mode, hidden files. - generalize: persistHome (fstab LABEL=vmix-home /Users), hideWidgets offline. - formatVolume: formats a blank disk image as APFS by booting the PE (~35 s); idempotent. - NixOS module: macos.guestAgent (/run/vmix/qga-.sock), shares via virtiofsd + vhost-user-fs (Apple automount tag for the first share, others mounted through the agent), macos.homeDisk (created + formatted on first start, virtio-blk), SPICE keeps -vga vmware for macOS. - CLI: vmix run --macos --share DIR --home FILE --qga PATH. - qemu.nix helpers; README section. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF --- cli.nix | 44 +++++ lib/images/macos/README.md | 43 +++++ lib/images/macos/default.nix | 1 + lib/images/macos/helpers/customizeImage.nix | 60 ++++++- lib/images/macos/helpers/formatVolume.nix | 56 +++++++ lib/images/macos/helpers/qemu.nix | 21 +++ lib/images/macos/helpers/vm-driver.py | 156 +++++++++++++++++- lib/images/macos/templates/default.nix | 3 + lib/images/macos/templates/generalize.nix | 20 +++ .../macos/templates/profile/default.nix | 58 +++++++ .../macos/templates/software/default.nix | 51 ++++++ nixos/vms/config.nix | 66 +++++++- nixos/vms/submoduleOptions.nix | 40 ++++- 13 files changed, 600 insertions(+), 19 deletions(-) create mode 100644 lib/images/macos/helpers/formatVolume.nix create mode 100644 lib/images/macos/templates/profile/default.nix create mode 100644 lib/images/macos/templates/software/default.nix diff --git a/cli.nix b/cli.nix index 82882d0..b3d9f8f 100644 --- a/cli.nix +++ b/cli.nix @@ -35,6 +35,9 @@ pkgs.writeShellScriptBin "vmix" '' 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" @@ -95,6 +98,10 @@ pkgs.writeShellScriptBin "vmix" '' 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 ;; @@ -102,6 +109,10 @@ pkgs.writeShellScriptBin "vmix" '' --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 ;; @@ -136,8 +147,41 @@ pkgs.writeShellScriptBin "vmix" '' [[ -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}"') \ diff --git a/lib/images/macos/README.md b/lib/images/macos/README.md index 037a403..94e1d45 100644 --- a/lib/images/macos/README.md +++ b/lib/images/macos/README.md @@ -122,3 +122,46 @@ 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; }` + adds `LABEL=vmix-home /Users apfs rw 0 2` to the image's fstab. 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. macOS mounts it at + `/Users` before login, home directories are created there; the OS disk can 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 index 46c957d..c6dd8bd 100644 --- a/lib/images/macos/default.nix +++ b/lib/images/macos/default.nix @@ -22,6 +22,7 @@ let 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; }; }; diff --git a/lib/images/macos/helpers/customizeImage.nix b/lib/images/macos/helpers/customizeImage.nix index fd267eb..6f4dc11 100644 --- a/lib/images/macos/helpers/customizeImage.nix +++ b/lib/images/macos/helpers/customizeImage.nix @@ -7,13 +7,18 @@ # identity (`smbios`). # # Templates provide: -# script — sh script run as root in the PE (pe-lib.sh helpers available) -# 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 +# 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 ? "", @@ -23,12 +28,14 @@ originalImage: { 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"; @@ -75,6 +82,22 @@ let { 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 '' @@ -96,7 +119,7 @@ let python3 ${vmDriver} --mode pe --name "${name}-${originalImageName}" --timeout ${toString timeout} \ --serial-log serial.log --progress-file ${resultImg} -- \ qemu-system-x86_64 $VMIX_DISPLAY \ - ${qemu.machineArgs { inherit cpu smp memSize; }} \ + ${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"; }} \ @@ -110,6 +133,34 @@ let 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" ]; @@ -117,6 +168,7 @@ let 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 ''; 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=