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