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,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 ];
};
}

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

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

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

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