macOS Tahoe VM images (OpenCore/QEMU), Apple-ID compatible, VNC
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
This commit is contained in:
parent
6a62a649bd
commit
242e48a5fc
35 changed files with 1842 additions and 36 deletions
136
cli.nix
136
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 <path> [--generalize key=val,...] [--out-link PATH]"
|
||||
echo " vmix copy --image <path> [--generalize key=val,...] --to-disk /dev/sdX"
|
||||
echo " vmix copy --image <path> [--generalize key=val,...] --to-remote-disk user@host:/dev/sdX"
|
||||
echo " vmix run <qcow2-file> [--mem 4096] [--smp 4] [--ahci]"
|
||||
echo " vmix run <qcow2-file> [--mem 4096] [--smp 4] [--ahci] [--macos] [--vnc :N] [--mac XX:..]"
|
||||
echo " vmix macserial [--model MacPro7,1]"
|
||||
echo ""
|
||||
echo "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 <qcow2-file> [--mem 4096] [--smp 4] [--ahci]"; exit 1; }
|
||||
[[ ''${#} -lt 1 ]] && { echo "Error: vmix run <qcow2-file> [--mem 4096] [--smp 4] [--ahci] [--macos] [--vnc :N] [--mac XX:XX:XX:XX:XX:XX]"; exit 1; }
|
||||
RUN_INPUT="$1"; shift
|
||||
RUN_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)..."
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ let
|
|||
network = import ./network.nix { inherit pkgs lib; };
|
||||
in
|
||||
{
|
||||
inherit (images) linux windows;
|
||||
inherit (images) linux windows macos;
|
||||
inherit network;
|
||||
}
|
||||
|
|
@ -2,4 +2,5 @@
|
|||
{
|
||||
linux = (import ./linux) { inherit pkgs lib system; };
|
||||
windows = (import ./windows) { inherit pkgs lib system; };
|
||||
}
|
||||
macos = (import ./macos) { inherit pkgs lib system; };
|
||||
}
|
||||
|
|
|
|||
134
lib/images/macos/README.md
Normal file
134
lib/images/macos/README.md
Normal file
|
|
@ -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 <qcow2> --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-<state>.png`), `driver.log` and the QMP socket of every VM
|
||||
session are in `/tmp/vmix-macos/<image name>/` 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 <url>`; 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.
|
||||
44
lib/images/macos/default.nix
Normal file
44
lib/images/macos/default.nix
Normal file
|
|
@ -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; };
|
||||
}
|
||||
30
lib/images/macos/guest/agent.sh
Normal file
30
lib/images/macos/guest/agent.sh
Normal file
|
|
@ -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
|
||||
19
lib/images/macos/guest/ch.vmix.agent.plist
Normal file
19
lib/images/macos/guest/ch.vmix.agent.plist
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ch.vmix.agent</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/sh</string>
|
||||
<string>/Library/vmix/agent.sh</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/vmix-agent.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/vmix-agent.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
11
lib/images/macos/guest/kcpassword.py
Executable file
11
lib/images/macos/guest/kcpassword.py
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env python3
|
||||
# Encode a password as /etc/kcpassword (macOS auto-login). XOR with Apple's
|
||||
# fixed key, zero padded to a multiple of 12 so a key byte terminates it.
|
||||
import sys
|
||||
|
||||
KEY = [0x7D, 0x89, 0x52, 0x23, 0xD2, 0xBC, 0xDD, 0xEA, 0xA3, 0xB9, 0x1F]
|
||||
|
||||
pw = list(sys.argv[1].encode()) if len(sys.argv) > 1 else []
|
||||
pw += [0] * (12 - len(pw) % 12)
|
||||
out = bytes(b ^ KEY[i % len(KEY)] for i, b in enumerate(pw))
|
||||
sys.stdout.buffer.write(out)
|
||||
117
lib/images/macos/guest/vmix-install.sh
Normal file
117
lib/images/macos/guest/vmix-install.sh
Normal file
|
|
@ -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 <name>.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
|
||||
105
lib/images/macos/helpers/customizeImage.nix
Normal file
105
lib/images/macos/helpers/customizeImage.nix
Normal file
|
|
@ -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; }
|
||||
40
lib/images/macos/helpers/fetchRecovery.nix
Normal file
40
lib/images/macos/helpers/fetchRecovery.nix
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# macOS Recovery BaseSystem.dmg from Apple's recovery servers (osrecovery.apple.com)
|
||||
# via OSX-KVM's fetch-macOS-v2.py. The server has no stable URL (session-based) and,
|
||||
# during a macOS rollout, "latest" is load-balanced across CDN nodes serving DIFFERENT
|
||||
# builds (e.g. Sequoia and Tahoe at once). So the download is non-deterministic: the
|
||||
# builder retries until it gets the exact build pinned by sha256. The recovery MUST
|
||||
# match the installer's major version or startosinstall rejects it as "damaged".
|
||||
# When Apple retires this build, update recovery.sha256 (download once, check
|
||||
# /System/Library/CoreServices/SystemVersion.plist reports the wanted version).
|
||||
{ pkgs, upstream, ... }:
|
||||
{ shortname, sha256 }:
|
||||
let
|
||||
script = pkgs.fetchurl { inherit (upstream.opencore.fetchRecoveryScript) url sha256; };
|
||||
in
|
||||
pkgs.runCommand "macos-${shortname}-BaseSystem.dmg" {
|
||||
nativeBuildInputs = [ pkgs.python3 pkgs.coreutils ];
|
||||
outputHashMode = "flat";
|
||||
outputHashAlgo = "sha256";
|
||||
outputHash = sha256;
|
||||
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||
expected = sha256;
|
||||
} ''
|
||||
cp ${script} fetch-macOS-v2.py
|
||||
sed -i 's/os.get_terminal_size().columns/80/' fetch-macOS-v2.py
|
||||
want=$(nix-hash --type sha256 --to-base16 "$expected" 2>/dev/null || echo "$expected")
|
||||
for attempt in $(seq 1 40); do
|
||||
rm -f BaseSystem.dmg BaseSystem.chunklist
|
||||
echo "=== vmix: fetching ${shortname} recovery (attempt $attempt) ==="
|
||||
python3 fetch-macOS-v2.py --action download -s ${shortname} -o . -n BaseSystem || true
|
||||
if [ -f BaseSystem.dmg ]; then
|
||||
got=$(sha256sum BaseSystem.dmg | cut -d' ' -f1)
|
||||
echo "got $got (want $want)"
|
||||
[ "$got" = "$want" ] && { mv BaseSystem.dmg $out; exit 0; }
|
||||
echo "=== vmix: wrong build (Apple is rotating builds during rollout), retrying ==="
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "vmix: could not fetch the pinned ${shortname} recovery after 40 attempts."
|
||||
echo "Apple may have retired build $want; download it manually and update recovery.sha256."
|
||||
exit 1
|
||||
''
|
||||
17
lib/images/macos/helpers/ident.nix
Normal file
17
lib/images/macos/helpers/ident.nix
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Deterministic per-image identity derived from a seed string, so the NixOS
|
||||
# module and the CLI know the NIC MAC at evaluation time (OpenCore's ROM must
|
||||
# match the MAC of en0 for Apple ID / iMessage).
|
||||
{ lib, ... }:
|
||||
rec {
|
||||
hash = seed: builtins.hashString "sha256" seed;
|
||||
|
||||
# locally administered QEMU-style MAC
|
||||
macFromSeed = seed:
|
||||
let h = hash "${seed}-mac"; s = i: builtins.substring i 2 h;
|
||||
in "52:54:00:${s 0}:${s 2}:${s 4}";
|
||||
|
||||
# RFC 4122 v4 layout
|
||||
uuidFromSeed = seed:
|
||||
let h = hash "${seed}-uuid"; s = a: b: builtins.substring a b h;
|
||||
in lib.toUpper "${s 0 8}-${s 8 4}-4${s 13 3}-8${s 17 3}-${s 20 12}";
|
||||
}
|
||||
17
lib/images/macos/helpers/installBootloader.nix
Normal file
17
lib/images/macos/helpers/installBootloader.nix
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Shell snippet: copy an OpenCore ESP (makeOpenCore output) into the EFI System
|
||||
# Partition of a macOS qcow2 so the image boots standalone with plain OVMF.
|
||||
# libguestfs follows qcow2 backing chains and writes into the top overlay.
|
||||
{ pkgs, lib, ... }:
|
||||
{ esp, image }:
|
||||
''
|
||||
echo "=== vmix: installing OpenCore into the ESP of ${image} ==="
|
||||
ESP_DEV=$(guestfish --ro -a ${image} run : list-filesystems | awk -F': ' '$2 == "vfat" {print $1; exit}')
|
||||
[ -n "$ESP_DEV" ] || { echo "vmix: no FAT EFI partition found in ${image}"; guestfish --ro -a ${image} run : list-filesystems; exit 1; }
|
||||
guestfish -a ${image} -m "$ESP_DEV" <<GFS
|
||||
rm-rf /EFI/OC
|
||||
rm-rf /EFI/BOOT
|
||||
rm-rf /EFI/vmix
|
||||
copy-in ${esp}/EFI /
|
||||
ls /EFI/OC
|
||||
GFS
|
||||
''
|
||||
28
lib/images/macos/helpers/installerPayload.nix
Normal file
28
lib/images/macos/helpers/installerPayload.nix
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Take apart InstallAssistant.pkg on Linux:
|
||||
# installer-app.tar "Install macOS <name>.app" skeleton (Payload, pbzx+cpio)
|
||||
# installer.json byte offsets of SharedSupport.dmg inside the pkg
|
||||
# app-name e.g. "Install macOS Tahoe.app"
|
||||
# The 18 GB SharedSupport.dmg is never copied on the host; makeImage exposes that
|
||||
# byte range of the pkg as a raw disk and the recovery script copies it into the app.
|
||||
{ pkgs, lib, ... }:
|
||||
{ name, pkg }:
|
||||
pkgs.runCommand "${name}-installer-payload" {
|
||||
nativeBuildInputs = with pkgs; [ xar pbzx cpio python3 gnutar ];
|
||||
} ''
|
||||
mkdir -p $out
|
||||
xar --dump-toc=toc.xml -f ${pkg}
|
||||
python3 ${./xar-toc.py} toc.xml ${pkg} > $out/installer.json
|
||||
grep -q '"SharedSupport.dmg"' $out/installer.json || { echo "no SharedSupport.dmg in pkg"; exit 1; }
|
||||
grep -A4 '"SharedSupport.dmg"' $out/installer.json | grep -q '"encoding": "application/octet-stream"' \
|
||||
|| { echo "SharedSupport.dmg is compressed inside the xar, cannot map it as a disk"; exit 1; }
|
||||
|
||||
echo "=== vmix: extracting installer app skeleton ==="
|
||||
xar -x -f ${pkg} Payload
|
||||
mkdir root
|
||||
(cd root && pbzx -n ../Payload | cpio -idm --quiet)
|
||||
APP=$(ls root/Applications | grep -E '^Install macOS.*\.app$' | head -1)
|
||||
[ -n "$APP" ] || { echo "installer app not found in Payload"; ls -R root | head; exit 1; }
|
||||
echo "$APP" > $out/app-name
|
||||
tar -C root/Applications -cf $out/installer-app.tar "$APP"
|
||||
ls -la $out
|
||||
''
|
||||
18
lib/images/macos/helpers/macserial.nix
Normal file
18
lib/images/macos/helpers/macserial.nix
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# macserial + ocvalidate from the pinned OpenCorePkg release (static Linux binaries)
|
||||
{ pkgs, upstream, ... }:
|
||||
pkgs.stdenv.mkDerivation {
|
||||
name = "opencore-utilities-${upstream.opencore.pkg.version}";
|
||||
src = pkgs.fetchurl { inherit (upstream.opencore.pkg) url sha256; };
|
||||
nativeBuildInputs = [ pkgs.unzip ];
|
||||
dontStrip = true;
|
||||
dontPatchELF = true;
|
||||
unpackPhase = ''
|
||||
unzip -q $src 'Utilities/macserial/*' 'Utilities/ocvalidate/*'
|
||||
'';
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin $out/share/doc
|
||||
install -m755 Utilities/macserial/macserial.linux $out/bin/macserial
|
||||
install -m755 Utilities/ocvalidate/ocvalidate.linux $out/bin/ocvalidate
|
||||
cp Utilities/macserial/FORMAT.md $out/share/doc/macserial-FORMAT.md
|
||||
'';
|
||||
}
|
||||
75
lib/images/macos/helpers/makeAgentPkg.nix
Normal file
75
lib/images/macos/helpers/makeAgentPkg.nix
Normal file
|
|
@ -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
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<pkg-info overwrite-permissions="true" relocatable="false" identifier="${id}" postinstall-action="none" version="${version}" format-version="2" generated-by="vmix" auth="root">
|
||||
<payload installKBytes="$KBYTES" numberOfFiles="$NFILES"/>
|
||||
<bundle-version/>
|
||||
<upgrade-bundle/>
|
||||
<update-bundle/>
|
||||
<atomic-update-bundle/>
|
||||
<strict-identifier/>
|
||||
<relocate/>
|
||||
<scripts>
|
||||
<postinstall file="./postinstall"/>
|
||||
</scripts>
|
||||
</pkg-info>
|
||||
XML
|
||||
cat > flat/Distribution <<XML
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<installer-gui-script minSpecVersion="1">
|
||||
<title>vmix agent</title>
|
||||
<options customize="never" require-scripts="false" hostArchitectures="x86_64,arm64" rootVolumeOnly="true"/>
|
||||
<product id="${id}" version="${version}"/>
|
||||
<choices-outline>
|
||||
<line choice="default">
|
||||
<line choice="${id}"/>
|
||||
</line>
|
||||
</choices-outline>
|
||||
<choice id="default"/>
|
||||
<choice id="${id}" visible="false">
|
||||
<pkg-ref id="${id}"/>
|
||||
</choice>
|
||||
<pkg-ref id="${id}" version="${version}" onConclusion="none" installKBytes="$KBYTES">#vmix-agent.pkg</pkg-ref>
|
||||
</installer-gui-script>
|
||||
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
|
||||
''
|
||||
132
lib/images/macos/helpers/makeImage.nix
Normal file
132
lib/images/macos/helpers/makeImage.nix
Normal file
|
|
@ -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 <<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; }
|
||||
68
lib/images/macos/helpers/makeOpenCore.nix
Normal file
68
lib/images/macos/helpers/makeOpenCore.nix
Normal file
|
|
@ -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
|
||||
''
|
||||
28
lib/images/macos/helpers/makeVmixVolume.nix
Normal file
28
lib/images/macos/helpers/makeVmixVolume.nix
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# GPT disk with a single HFS+ partition named VMIX. macOS mounts it natively by
|
||||
# name at /Volumes/VMIX (its FAT driver rejects Linux-made FAT volumes as
|
||||
# "damaged"; HFS+ made by mkfs.hfsplus mounts cleanly). The partition starts at
|
||||
# 1 MiB. Files are staged with their target names and copied in via libguestfs.
|
||||
# files: list of { source = <path|drv>; name = "dest name"; } — directories copied recursively.
|
||||
{ pkgs, lib, ... }:
|
||||
{ name ? "vmix", files, size ? "64M" }:
|
||||
pkgs.runCommand "${name}-vmix-volume.img" {
|
||||
nativeBuildInputs = with pkgs; [ hfsprogs gptfdisk gnutar libguestfs-with-appliance ];
|
||||
} ''
|
||||
mkdir stage
|
||||
${lib.concatMapStringsSep "\n" (f: ''
|
||||
mkdir -p "$(dirname "stage/${f.name}")"
|
||||
cp -r --no-preserve=mode ${f.source} "stage/${f.name}"
|
||||
'') files}
|
||||
tar -C stage -cf stage.tar .
|
||||
|
||||
truncate -s ${size} $out
|
||||
sgdisk -n 1:2048:0 -t 1:AF00 -c 1:VMIX $out >/dev/null
|
||||
FIRST=$(sgdisk -i 1 $out | sed -n 's/First sector: \([0-9]*\).*/\1/p')
|
||||
LAST=$(sgdisk -i 1 $out | sed -n 's/Last sector: \([0-9]*\).*/\1/p')
|
||||
truncate -s $(( (LAST - FIRST + 1) * 512 )) hfs.part
|
||||
mkfs.hfsplus -v VMIX hfs.part >/dev/null
|
||||
|
||||
# tar-in takes a host-side tarball and unpacks it into the mounted volume
|
||||
guestfish -a hfs.part run : mount /dev/sda / : tar-in stage.tar / : ls / : umount /
|
||||
dd if=hfs.part of=$out bs=512 seek=$FIRST conv=notrunc status=none
|
||||
''
|
||||
99
lib/images/macos/helpers/oc-config.py
Normal file
99
lib/images/macos/helpers/oc-config.py
Normal file
|
|
@ -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()
|
||||
35
lib/images/macos/helpers/qemu.nix
Normal file
35
lib/images/macos/helpers/qemu.nix
Normal file
|
|
@ -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}";
|
||||
}
|
||||
415
lib/images/macos/helpers/vm-driver.py
Normal file
415
lib/images/macos/helpers/vm-driver.py
Normal file
|
|
@ -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/<name>) 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())
|
||||
14
lib/images/macos/helpers/vmix-readback.nix
Normal file
14
lib/images/macos/helpers/vmix-readback.nix
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Shell snippet: read the 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)
|
||||
''
|
||||
25
lib/images/macos/helpers/xar-toc.py
Normal file
25
lib/images/macos/helpers/xar-toc.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env python3
|
||||
# Print heap offsets of the entries of a xar archive (InstallAssistant.pkg) as JSON,
|
||||
# so SharedSupport.dmg can be exposed to the VM as a raw disk without extracting 18 GB.
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
toc_xml, archive = sys.argv[1], sys.argv[2]
|
||||
with open(archive, 'rb') as f:
|
||||
magic, hsize, ver, toc_c, toc_u, cksum = struct.unpack('>4sHHQQI', f.read(28))
|
||||
assert magic == b'xar!', 'not a xar archive'
|
||||
heap = hsize + toc_c
|
||||
out = {}
|
||||
for entry in ET.parse(toc_xml).getroot().iter('file'):
|
||||
data = entry.find('data')
|
||||
if data is None:
|
||||
continue
|
||||
out[entry.findtext('name')] = {
|
||||
'offset': heap + int(data.findtext('offset')),
|
||||
'length': int(data.findtext('length')),
|
||||
'size': int(data.findtext('size')),
|
||||
'encoding': data.find('encoding').get('style'),
|
||||
}
|
||||
json.dump(out, sys.stdout, indent=2)
|
||||
8
lib/images/macos/tahoe/default.nix
Normal file
8
lib/images/macos/tahoe/default.nix
Normal file
|
|
@ -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; }
|
||||
14
lib/images/macos/tahoe/images.nix
Normal file
14
lib/images/macos/tahoe/images.nix
Normal file
|
|
@ -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;
|
||||
}
|
||||
15
lib/images/macos/templates/default.nix
Normal file
15
lib/images/macos/templates/default.nix
Normal file
|
|
@ -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 ];
|
||||
};
|
||||
}
|
||||
15
lib/images/macos/templates/essentials/no-updates.nix
Normal file
15
lib/images/macos/templates/essentials/no-updates.nix
Normal file
|
|
@ -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
|
||||
'';
|
||||
}
|
||||
11
lib/images/macos/templates/essentials/performance.nix
Normal file
11
lib/images/macos/templates/essentials/performance.nix
Normal file
|
|
@ -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
|
||||
'';
|
||||
}
|
||||
11
lib/images/macos/templates/essentials/remote-access.nix
Normal file
11
lib/images/macos/templates/essentials/remote-access.nix
Normal file
|
|
@ -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
|
||||
'';
|
||||
}
|
||||
103
lib/images/macos/templates/generalize.nix
Normal file
103
lib/images/macos/templates/generalize.nix
Normal file
|
|
@ -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
|
||||
'';
|
||||
}
|
||||
31
lib/images/macos/upstream.json
Normal file
31
lib/images/macos/upstream.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}') \
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue