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
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue