vmix.nix/lib/images/macos/helpers/oc-config.py
Git Sagar 8dc8f4265d macOS: drive the install and all customization from a Recovery "PE", no GUI
Replace the screenshot/OCR/keystroke driving of Apple's Recovery with a
"PE": BaseSystem.dmg (a journaled HFS+ volume, writable from Linux) with one
LaunchDaemon added (makeRecoveryPE) that runs /Volumes/VMIX/run.sh as root at
boot, records the status and powers off. launchd loads it alongside its signed
cache (verified on Tahoe 26.6.2); same idea as AutoNBI/Imagr NetBoot images.

- makeImage: the PE runs vmix-install.sh (erase, installer app, SharedSupport
  pkgdmg, startosinstall). Progress is read from the serial console
  (boot-args serial=3 -v, VMIX-* markers) and screenshots (brightness only).
  Fully offline; prepare now takes ~5 min instead of ~10.
- customizeImage: boots the PE with the image attached and runs the template
  offline against the mounted System/Data volumes; OpenCore ScanPolicy
  restricted to HFS+/SATA so only the PE can boot. One PE boot ~30 s. The
  installed macOS is never booted for customization, so nothing depends on
  launchd/BTM approval or a first-boot agent (removed).
- templates rewritten for offline use: generalize creates the user with
  dscl -f (admin, home, auto-login kcpassword, Setup Assistant suppression,
  hostname, locale, timezone, keyboard type, container resize); remote-access,
  no-updates, performance edit the target's plists.
- makeBootDisk: build-time OpenCore variant (serial console, ScanPolicy).
- vm-driver.py rewritten: passive observation only (serial markers, kernel
  boots, panics, brightness), disk+serial-aware hang watchdog, reboot-death
  reset, halt/loginwindow detection. No OCR/tesseract.
- OpenCore: four SMBIOS DIMMs for MacPro7,1 (no "Memory Modules
  Misconfigured" warning).
- tools/soak.sh: repeatability harness.

Verified on daku: base install 23 min end to end; basic + generalize in three
~30 s PE boots; the result auto-logs into the desktop with the created user.

Root cause of the "first-boot hang" (from the serial log): the guest's restart
path panics (IOPlatformHaltRestartAction -> AppleSMC, SMCWDT smcWriteKey
kSMCBadCommand, nested panic) because the pinned OSX-KVM Lilu disables itself
on macOS 26, so VirtualSMC never loads. Handled by the driver (reset within
60 s); kext update to follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
2026-09-10 13:33:38 -03:00

115 lines
4.7 KiB
Python

#!/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='{}')
p.add_argument('--memory-mb', type=int, default=8192, help='VM RAM, described as 4 DIMMs')
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
# MacPro7,1 firmware expects DIMMs in pairs (>= 4); with QEMU's single SMBIOS
# module macOS shows "Memory Modules Misconfigured" at every login. Describe
# the VM's RAM as four DDR4 modules instead.
if a.model.startswith('MacPro7'):
size = max(1024, a.memory_mb // 4)
cfg['PlatformInfo']['CustomMemory'] = True
cfg['PlatformInfo']['Memory'] = {
'DataWidth': 64, 'ErrorCorrection': 3, 'FormFactor': 9, 'MaxCapacity': 1536 * 1024 * 1024 * 1024,
'TotalWidth': 64, 'Type': 26, 'TypeDetail': 128,
'Devices': [{
'AssetTag': '', 'BankLocator': f'BANK {i}', 'DeviceLocator': f'DIMM{i + 1}',
'Manufacturer': 'Apple', 'PartNumber': f'VMIX{size}', 'SerialNumber': f'VMIX{i:04d}',
'Size': size, 'Speed': 2666,
} for i in range(4)],
}
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()