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:
Git Sagar 2026-09-08 16:03:52 -03:00
parent 6a62a649bd
commit 242e48a5fc
35 changed files with 1842 additions and 36 deletions

View 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; }

View 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
''

View 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}";
}

View 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
''

View 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
''

View 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
'';
}

View 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
''

View 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; }

View 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
''

View 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
''

View 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()

View 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}";
}

View 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())

View 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)
''

View 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)