# 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 installNetwork ? false, # attach a NIC during install (default: offline — startosinstall # otherwise hangs on Apple personalization through a flaky NAT) }: 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 # 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 </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 -- \ 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"; }} \ ${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. ${vmixReadback "vmix.img"} [ "$STATUS" = "0" ] && echo "vmix: first-boot agent completed (status 0)" \ || echo "vmix: install reached loginwindow (agent status '$STATUS'); image is installed" ${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; }