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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
132 lines
6.3 KiB
Nix
132 lines
6.3 KiB
Nix
# 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 <<CONF
|
|
TARGET_BYTES=$TARGET_BYTES
|
|
SS_LEN=$SS_LEN
|
|
SS_DISK_BYTES=$SS_DISK
|
|
APP_NAME="$(cat ${payload}/app-name)"
|
|
VOLUME_NAME="${volumeName}"
|
|
BUILD_DATE="$(date -u +%Y%m%d%H%M.%S)"
|
|
CONF
|
|
cat vmix.conf
|
|
guestfish -a vmix.img -m /dev/sda1 upload vmix.conf /vmix.conf
|
|
|
|
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd
|
|
chmod +w vars.fd
|
|
|
|
VMIX_DISPLAY="-display none"
|
|
${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''}
|
|
${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} -- \
|
|
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; }
|