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
This commit is contained in:
parent
58a317f5d2
commit
8dc8f4265d
24 changed files with 802 additions and 977 deletions
|
|
@ -1,138 +1,100 @@
|
|||
# vmix macOS images
|
||||
# macOS images (Tahoe 26)
|
||||
|
||||
Unattended macOS (Tahoe / 26) VM images, built the same way as the Windows
|
||||
images: `makeImage` installs the OS once, templates customize it by booting it,
|
||||
`.generalize` creates the user and seals the image.
|
||||
Pre-installed, Apple-ID-capable macOS VM images built the same way as the
|
||||
Windows ones: `makeImage` (unattended install) → templates → `.generalize`
|
||||
(user, hostname, fresh SMBIOS identity). Runs on QEMU/KVM with OpenCore.
|
||||
|
||||
```
|
||||
vmix build --image macos.images.tahoe.basic \
|
||||
--generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich
|
||||
vmix run ./result --macos --vnc :10 --mem 8192 # VNC on port 5910
|
||||
vmix build --image macos.images.tahoe.basic --generalize username=sagar,password=secret,hostname=MAC
|
||||
vmix run ./result --macos --vnc :10 --mem 8192
|
||||
```
|
||||
|
||||
## How it works
|
||||
Nix: `macos.images.tahoe.{pe,upstream,basic,remote}` and
|
||||
`<image>.generalize { username; password; hostname; timezone; locale; seed; … }`.
|
||||
|
||||
| step | what happens |
|
||||
|---|---|
|
||||
| `fetchRecovery` | BaseSystem.dmg from Apple's recovery servers (fixed-output, pinned by sha256) |
|
||||
| `installerPayload` | takes the App Store `InstallAssistant.pkg` (18 GB, pinned) apart on Linux: the app skeleton (pbzx/cpio) and the byte offset of `SharedSupport.dmg` |
|
||||
| `makeOpenCore` | OSX-KVM's OpenCore ESP with a config.plist rewritten for this image: SMBIOS model, serial + MLB (`macserial`), UUID and ROM = NIC MAC (derived from a seed), NIC marked built-in |
|
||||
| `makeImage` | one QEMU session: Recovery boots via OpenCore → `vm-driver.py` opens Terminal with keystrokes (Ctrl-F2 menu navigation, screen-settle detection + OCR of the menu bar) and types `sh /Volumes/VMIX/run.sh` → `vmix-install.sh` erases the disk, rebuilds `Install macOS Tahoe.app` (skeleton + `SharedSupport.dmg` copied from a raw disk mapped straight out of the pkg), runs `startosinstall --installpackage vmix-agent.pkg` → installer reboots through its phases → first boot runs the **vmix agent** which powers off. OpenCore is then copied into the image's EFI partition, so it boots standalone with OVMF |
|
||||
| `customizeImage` | boots the image with a FAT volume `VMIX`; the agent (LaunchDaemon `ch.vmix.agent`) runs `vmix-run.sh` as root, writes `vmix-run.status`/`.log` back and shuts down |
|
||||
| `templates.generalize` | user (admin) + auto-login (`/etc/kcpassword`), Setup Assistant suppressed, hostname, timezone, no sleep, APFS grown to the disk, then the agent removes itself; a fresh SMBIOS identity is written to the ESP |
|
||||
## How it works: the vmix "PE"
|
||||
|
||||
The vmix agent replaces Windows' Audit Mode RunOnce; `.AppleSetupDone` replaces
|
||||
the OOBE unattend. Everything on the host side runs inside `__noChroot`
|
||||
derivations (KVM + `/tmp`), exactly like the Windows builders.
|
||||
Apple's Recovery (`BaseSystem.dmg`, a plain journaled HFS+ volume) with **one
|
||||
LaunchDaemon added** (`makeRecoveryPE`): at boot it mounts a `VMIX` volume and
|
||||
runs `run.sh` from it as root, records the exit status and powers off. That is
|
||||
the whole automation surface — the equivalent of Windows PE + Autounattend:
|
||||
|
||||
## Generalize options
|
||||
* **no GUI is driven**: no OCR, no keystrokes, no screen layouts to learn per
|
||||
macOS version; the hook is a launchd plist, stable across releases (same idea
|
||||
as AutoNBI/Imagr NetBoot images).
|
||||
* **observable**: the guest prints `VMIX-*` markers to `/dev/console`, which the
|
||||
build reads from QEMU's serial log (`boot-args serial=3 -v`). Kernel panics and
|
||||
reboots show up there too. Screenshots are still taken for debugging.
|
||||
* **offline**: no NIC during the install, and the guest blackholes Apple's
|
||||
install/verify endpoints so `startosinstall` never waits on the network. The
|
||||
only inputs are the pinned `InstallAssistant.pkg` and `BaseSystem.dmg`.
|
||||
* **everything else happens offline from the PE too**: templates and generalize
|
||||
mount the image's Data volume (rw) and System volume (ro) and edit them
|
||||
(`dscl -f` for users, `plutil` for preferences) — the installed macOS is
|
||||
never booted for customization, so nothing depends on launchd/BTM approval,
|
||||
first-boot agents or auto-login inside the guest. One PE boot ≈ 30 s.
|
||||
|
||||
`username password fullName autoLogon hostname locale timezone delayOobeRun`
|
||||
as for Windows (`bgColor` is accepted but ignored), plus the SMBIOS identity:
|
||||
`model serial mlb uuid mac seed`. Anything unset is generated: serial/MLB by
|
||||
macserial (random per build), MAC and UUID deterministically from `seed`
|
||||
(default `hostname-username`). `vmix macserial --model MacPro7,1` prints a
|
||||
ready-to-paste set.
|
||||
### Pipeline
|
||||
|
||||
`delay-oobe-run=true` creates no user and re-arms Setup Assistant for the first
|
||||
real boot.
|
||||
|
||||
## Apple ID / iMessage
|
||||
|
||||
The image satisfies what Dortania lists for iServices: unique serial + MLB for a
|
||||
Tahoe-supported model (`MacPro7,1` by default; `iMac20,1/2`,
|
||||
`MacBookPro16,x` also work), SystemUUID, ROM equal to en0's MAC, and en0 marked
|
||||
built-in (the NIC is pinned to `PciRoot(0x0)/Pci(0x12,0x0)`). The NixOS module
|
||||
and `vmix run --macos` use the MAC recorded in the image (`EFI/vmix/vmix.json`).
|
||||
Give each deployed VM its own generalized image (different `seed`, or explicit
|
||||
`serial=`/`mlb=`) — two VMs with the same identity will be blocked.
|
||||
|
||||
## Runtime
|
||||
|
||||
* `vmix run <qcow2> --macos [--vnc :N] [--mac ..]`
|
||||
* NixOS module: `disks.os.file = vmixLib.macos.images.tahoe.basic.generalize {...}`
|
||||
is auto-detected (`_vmixOsType = "macos"`): Skylake-Client CPU spoof, AppleSMC,
|
||||
USB keyboard/tablet, AHCI system disk, VMware SVGA, pinned NIC with the image's MAC.
|
||||
`macos.cpu`, `macos.mac`, `macos.enable` override the defaults.
|
||||
* `vmix copy` writes the image to a disk but cannot grow APFS from Linux
|
||||
(`diskutil apfs resizeContainer disk0s2 0` in macOS afterwards).
|
||||
|
||||
## Debugging a build
|
||||
|
||||
Screenshots (`NNN-<state>.png`), `driver.log` and the QMP socket of every VM
|
||||
session are in `/tmp/vmix-macos/<image name>/` on the build host. The guest logs
|
||||
(`install.log`, `vmix-run.log`, `vmix-agent.log`) are printed at the end of the
|
||||
build. Pass `vncDisplay = ":10"` to `makeImage`/`customizeImage` (or
|
||||
`--generalize vncDisplay=:10`) to watch live; with a `DISPLAY` an SDL window
|
||||
is used as for Windows.
|
||||
|
||||
## Updating pins (`upstream.json`)
|
||||
|
||||
* installer: URL + SRI hash of a newer `InstallAssistant.pkg`
|
||||
(`nix store prefetch-file --name InstallAssistant.pkg <url>`; Mr. Macintosh's
|
||||
database lists Apple's URLs)
|
||||
* recovery: Apple serves the current build for the board id, so the sha256
|
||||
changes with each point release — copy the "got:" hash from the failed build
|
||||
* opencore: OSX-KVM `OpenCore.qcow2` at a commit; OpenCorePkg release zip (macserial/ocvalidate)
|
||||
|
||||
## Known limits
|
||||
|
||||
* The Recovery bootstrap depends on keyboard navigation of the Recovery UI
|
||||
(Ctrl-F2 → Utilities → Terminal). It self-corrects with screenshots + OCR and
|
||||
falls back to a blind sequence, but a Recovery UI change would need
|
||||
`vm-driver.py` adjusted.
|
||||
* Hosts must run KVM with an AVX2-capable CPU (Intel or AMD; the guest sees a
|
||||
Skylake). `sandbox = relaxed` and the `kvm` system feature, as for Windows.
|
||||
* Software updates inside the VM are disabled by the `noUpdates` template
|
||||
(OTA updates in a VM need the RestrictEvents kext).
|
||||
|
||||
## Current status (2026-09-09): working offline install
|
||||
|
||||
`macos.images.tahoe.upstream` builds a bootable, installed macOS Tahoe 26.6.2
|
||||
qcow2 **fully offline** on the KVM host — no dependency on Apple's servers at build
|
||||
time, just the pinned local `InstallAssistant.pkg` and `BaseSystem.dmg`. The
|
||||
finished image boots standalone (OpenCore from its own ESP) to the macOS
|
||||
loginwindow. Serial/MLB/UUID/ROM are per-image for Apple ID / iMessage.
|
||||
|
||||
How the install is driven (`vm-driver.py`, all by screenshot + OCR over QMP):
|
||||
|
||||
* The whole `InstallAssistant.pkg` is mapped as a raw disk (it is a "pkgdmg":
|
||||
xar + koly footer) and `dd`'d byte-exact into the app as `SharedSupport.dmg` —
|
||||
extracting the bare xar member fails startosinstall with "pkgdmg missing a footer".
|
||||
* No NIC during install + `/etc/hosts` blackhole of Apple's install/verify
|
||||
endpoints, so `startosinstall`'s network calls fail fast instead of hanging —
|
||||
offline prepare, no external dependency. `SecureBootModel=Disabled` lets the
|
||||
sealed volume install without online personalization.
|
||||
* The recovery display is kept awake with a tiny mouse jiggle (a lone keypress
|
||||
does not reset display sleep, and the sleeping display swallows the menu-nav
|
||||
keystrokes); the settle detector uses a coarse fingerprint so the jiggling
|
||||
cursor is not seen as a screen change.
|
||||
* startosinstall prepare is intermittently slow/stalls; a guest watchdog kills and
|
||||
re-erases/retries an attempt that stalls or runs > 9 min.
|
||||
* First boot in QEMU intermittently hangs at the Apple logo; a disk-aware watchdog
|
||||
(`--progress-file`) issues a QMP `system_reset` only when the screen is dark AND
|
||||
the disk is idle, so a slow-but-working boot is never interrupted.
|
||||
* The install reaching the (bright) loginwindow is detected by brightness (the
|
||||
faint gray "password" text does not OCR) and the driver powers the VM down —
|
||||
the image is installed. macOS `shutdown -h now` halts to black without an ACPI
|
||||
power-off, so a black+disk-idle screen is also treated as a completed halt.
|
||||
* OpenCore is then copied into the image's own ESP so it boots standalone with OVMF.
|
||||
1. `makeRecoveryPE` — BaseSystem.dmg → raw HFS+ image + `ch.vmix.pe` daemon.
|
||||
2. `makeImage` — QEMU with: OpenCore boot disk (build variant with serial
|
||||
console), the PE, the empty target disk, the VMIX volume (`vmix-install.sh`,
|
||||
installer app skeleton) and the whole `InstallAssistant.pkg` mapped as a raw
|
||||
disk. The guest script erases the target as APFS, unpacks the app and `dd`s
|
||||
the pkg into it as `SharedSupport.dmg` (it is a "pkgdmg": xar + koly footer;
|
||||
the bare xar member fails with "pkgdmg is missing a footer"), then runs
|
||||
`startosinstall`, which reboots itself through the install phases. The
|
||||
installed system's first boot ends at the loginwindow: the driver detects the
|
||||
bright screen and powers the VM down. OpenCore is then copied into the image's
|
||||
own ESP so it boots with plain OVMF.
|
||||
3. `customizeImage` — boots the PE with the image attached (OpenCore
|
||||
`ScanPolicy` restricted to HFS+ on SATA, so only the PE can boot) and runs the
|
||||
template script with `$SYS`/`$DATA` mounted. `pe-lib.sh` has the helpers.
|
||||
4. `templates/generalize.nix` — user (dscl, admin, home from the user template),
|
||||
auto-login (`kcpassword`), Setup Assistant suppression, hostname, locale,
|
||||
timezone, keyboard type, container resize, fresh SMBIOS via a new OpenCore
|
||||
ESP (`serial`/`mlb` from macserial, MAC + UUID from `seed`).
|
||||
|
||||
### Recovery source
|
||||
|
||||
`recovery.file` in `upstream.json` points at a content-addressed store path for the
|
||||
verified Tahoe `BaseSystem.dmg` (Apple's CDN load-balances Sequoia/Tahoe during the
|
||||
rollout, so a plain fetch is non-deterministic). Reproduce it on any host with
|
||||
`nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg` (same path
|
||||
from the same bytes). Set `recovery.sha256` and remove `recovery.file` to fetch it
|
||||
from Apple instead (subject to the CDN rollout).
|
||||
`recovery.file` in `upstream.json` points at a content-addressed store path for
|
||||
the verified Tahoe `BaseSystem.dmg` (Apple's CDN load-balances Sequoia/Tahoe
|
||||
during the rollout, so a plain fetch is non-deterministic). Reproduce it on any
|
||||
host with `nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg`.
|
||||
Drop `recovery.file` to fetch from Apple instead (`fetchRecovery` retries until
|
||||
the pinned hash matches).
|
||||
|
||||
### Not yet done: generalize / user creation
|
||||
## Reliability
|
||||
|
||||
The base image installs and boots to loginwindow. `.generalize` (user creation,
|
||||
auto-login, hostname) relies on the vmix agent LaunchDaemon running on first boot,
|
||||
but macOS Ventura+ Background Task Management does not auto-run a headless
|
||||
third-party daemon, and neither the pkg `launchctl bootstrap` (installer domain
|
||||
only) nor a cron `@reboot` reliably triggered it. The robust next step is to inject
|
||||
the user record + settings offline from the agent pkg's postinstall (which runs as
|
||||
root on the target during install), instead of a first-boot daemon.
|
||||
Things QEMU does intermittently, and what handles each (all in `vm-driver.py`
|
||||
and `vmix-install.sh`; every event is logged with a reason):
|
||||
|
||||
* `startosinstall` prepare stalls or crawls — the guest kills and retries it on a
|
||||
freshly erased target (free-space watchdog + time cap).
|
||||
* the installer comes back to the PE instead of the install phase — the PE
|
||||
counts boots and simply re-runs the install (max 3).
|
||||
* the installed system hangs at the Apple logo on first boot — a `system_reset`
|
||||
is issued only when the screen is dark and frozen **and** disk and serial
|
||||
console are idle, so a slow-but-working boot is never interrupted.
|
||||
* macOS `shutdown -h` halts to a black screen without an ACPI power-off — an
|
||||
idle black screen counts as a completed halt.
|
||||
* a kernel panic (seen on the serial console) resets the VM.
|
||||
* a wedged run fails at the 4 h timeout instead of hanging.
|
||||
|
||||
`tools/soak.sh <flake> macos.images.tahoe.upstream 3` rebuilds an image N
|
||||
times and tabulates outcome, duration, boots, resets, panics and retries.
|
||||
|
||||
## Debugging
|
||||
|
||||
`/tmp/vmix-macos/<name>/` on the build host: `driver.log`, `serial.log`
|
||||
(kernel + `VMIX-*` markers), periodic PNG screenshots, `qmp.sock`.
|
||||
`vmix-run.log` / `system-install.log` from the VMIX volume are printed at the
|
||||
end of the build. Add `vncDisplay = ":10"` to watch.
|
||||
|
||||
## QEMU profile
|
||||
|
||||
`helpers/qemu.nix`: q35, `Skylake-Client` CPU spoof (works on AMD),
|
||||
AppleSMC with the OSK, XHCI keyboard/tablet, AHCI disks, VMware SVGA,
|
||||
virtio-net pinned to `PciRoot(0x0)/Pci(0x12,0x0)` so OpenCore marks it built-in
|
||||
(en0, required for Apple ID / iMessage). SMBIOS `MacPro7,1` with four DIMMs
|
||||
described (avoids the "Memory Modules Misconfigured" warning).
|
||||
|
|
|
|||
|
|
@ -9,16 +9,17 @@ let
|
|||
fetchRecovery = import ./helpers/fetchRecovery.nix { inherit pkgs upstream; };
|
||||
installerPayload = import ./helpers/installerPayload.nix { inherit pkgs lib; };
|
||||
makeOpenCore = import ./helpers/makeOpenCore.nix { inherit pkgs lib upstream macserial qemu; };
|
||||
makeBootDisk = import ./helpers/makeBootDisk.nix { inherit pkgs lib; };
|
||||
makeRecoveryPE = import ./helpers/makeRecoveryPE.nix { inherit pkgs lib; };
|
||||
makeVmixVolume = import ./helpers/makeVmixVolume.nix { inherit pkgs lib; };
|
||||
makeAgentPkg = import ./helpers/makeAgentPkg.nix { inherit pkgs lib; };
|
||||
installBootloader = import ./helpers/installBootloader.nix { inherit pkgs lib; };
|
||||
vmixReadback = import ./helpers/vmix-readback.nix { inherit pkgs lib; };
|
||||
vmDriver = ./helpers/vm-driver.py;
|
||||
makeImage = import ./helpers/makeImage.nix {
|
||||
inherit pkgs lib qemu ident installerPayload makeOpenCore makeVmixVolume makeAgentPkg installBootloader vmixReadback vmDriver;
|
||||
inherit pkgs lib qemu ident installerPayload makeOpenCore makeBootDisk makeVmixVolume installBootloader vmixReadback vmDriver;
|
||||
};
|
||||
customizeImage = import ./helpers/customizeImage.nix {
|
||||
inherit pkgs lib qemu ident makeVmixVolume makeOpenCore installBootloader vmixReadback vmDriver;
|
||||
inherit pkgs lib qemu ident makeVmixVolume makeOpenCore makeBootDisk installBootloader vmixReadback vmDriver;
|
||||
};
|
||||
customizeImageFold = builtins.foldl' customizeImage;
|
||||
templates = import ./templates { inherit pkgs lib; };
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
#!/bin/sh
|
||||
# vmix agent: LaunchDaemon that runs at every boot as root (installed by the vmix
|
||||
# agent pkg via startosinstall --installpackage). If a volume named VMIX carrying
|
||||
# vmix-run.sh is attached, run it, record the result on the volume and power off.
|
||||
# Without the volume it is a no-op (normal boot). Counterpart of the Windows Audit
|
||||
# Mode RunOnce script; the generalize step removes it once the image is sealed.
|
||||
LOG=/var/log/vmix-agent.log
|
||||
exec >>"$LOG" 2>&1
|
||||
echo "=== vmix agent: $(date) ==="
|
||||
# The agent pkg bootstraps this daemon during the OS install (to approve it past
|
||||
# Background Task Management, so launchd runs it at first boot). Don't do the job
|
||||
# in that installer environment — only on the installed system's first boot.
|
||||
if pgrep -x bootinstalld >/dev/null 2>&1 || pgrep -qx "Installer Progress" 2>/dev/null \
|
||||
|| [ -d /System/Volumes/Update/mnt1 ]; then
|
||||
echo "vmix agent: OS installer is running, skipping"
|
||||
exit 0
|
||||
fi
|
||||
# let DiskArbitration settle so the VMIX volume is mountable
|
||||
sleep 5
|
||||
V=/Volumes/VMIX
|
||||
i=0
|
||||
while [ ! -f "$V/vmix-run.sh" ] && [ $i -lt 30 ]; do
|
||||
diskutil mount VMIX >/dev/null 2>&1
|
||||
sleep 2
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [ ! -f "$V/vmix-run.sh" ]; then
|
||||
echo "vmix agent: no VMIX volume, normal boot"
|
||||
exit 0
|
||||
fi
|
||||
echo "vmix agent: running vmix-run.sh"
|
||||
cd "$V" || exit 1
|
||||
sh "$V/vmix-run.sh" >"$V/vmix-run.log" 2>&1
|
||||
rc=$?
|
||||
echo "vmix agent: vmix-run.sh exited $rc"
|
||||
echo "$rc" >"$V/vmix-run.status"
|
||||
cp "$LOG" "$V/vmix-agent.log" 2>/dev/null
|
||||
cp /var/log/vmix-agent-install.log "$V/vmix-agent-install.log" 2>/dev/null
|
||||
sync
|
||||
sleep 2
|
||||
diskutil unmount force "$V" >/dev/null 2>&1
|
||||
shutdown -h now
|
||||
|
|
@ -3,17 +3,17 @@
|
|||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ch.vmix.agent</string>
|
||||
<string>ch.vmix.pe</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/sh</string>
|
||||
<string>/Library/vmix/agent.sh</string>
|
||||
<string>/bin/bash</string>
|
||||
<string>/usr/libexec/vmix/pe.sh</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/vmix-agent.log</string>
|
||||
<string>/dev/console</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/vmix-agent.log</string>
|
||||
<string>/dev/console</string>
|
||||
</dict>
|
||||
</plist>
|
||||
51
lib/images/macos/guest/pe-lib.sh
Normal file
51
lib/images/macos/guest/pe-lib.sh
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# vmix PE helpers, sourced by run.sh scripts running in the recovery.
|
||||
# Expects V=/Volumes/VMIX (set by pe.sh) and VOLUME_NAME from vmix.conf.
|
||||
V=${V:-/Volumes/VMIX}
|
||||
[ -f "$V/vmix.conf" ] && . "$V/vmix.conf"
|
||||
VOLUME_NAME=${VOLUME_NAME:-Macintosh HD}
|
||||
|
||||
pe_log() { echo "VMIX: $*"; }
|
||||
pe_fail() { echo "VMIX-FAIL: $*"; exit 1; }
|
||||
|
||||
# Mount the installed system's APFS volume group (System read-only, Data rw) and
|
||||
# export SYS / DATA mount points plus SYS_ID / DATA_ID device identifiers.
|
||||
pe_mount_target() {
|
||||
local list; list=$(diskutil list)
|
||||
DATA_ID=$(echo "$list" | awk -v n="APFS Volume $VOLUME_NAME - Data" 'index($0, n) {print $NF; exit}')
|
||||
SYS_ID=$(echo "$list" | awk -v n="APFS Volume $VOLUME_NAME " '!/ - Data/ && index($0, n) {print $NF; exit}')
|
||||
[ -n "$DATA_ID" ] && [ -n "$SYS_ID" ] || { pe_log "target volumes not found"; echo "$list"; return 1; }
|
||||
diskutil mount "$SYS_ID" >/dev/null 2>&1 || true
|
||||
diskutil mount "$DATA_ID" >/dev/null 2>&1 || true
|
||||
SYS=$(diskutil info "$SYS_ID" | sed -n 's/^ *Mount Point: *//p')
|
||||
DATA=$(diskutil info "$DATA_ID" | sed -n 's/^ *Mount Point: *//p')
|
||||
[ -d "$DATA/private/var/db" ] || { pe_log "Data volume not mounted (SYS=[$SYS] DATA=[$DATA])"; return 1; }
|
||||
pe_log "target mounted: SYS=[$SYS] DATA=[$DATA]"
|
||||
export SYS DATA SYS_ID DATA_ID
|
||||
}
|
||||
|
||||
pe_unmount_target() {
|
||||
sync
|
||||
diskutil unmount "$DATA_ID" >/dev/null 2>&1 || true
|
||||
diskutil unmount "$SYS_ID" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# plist helpers on files of the (offline) target: create the file if missing.
|
||||
pe_plist_set() { # FILE KEYPATH TYPE VALUE (TYPE: string|bool|integer|float)
|
||||
local f=$1 k=$2 t=$3 v=$4
|
||||
[ -f "$f" ] || plutil -create xml1 "$f"
|
||||
plutil -replace "$k" "-$t" "$v" "$f"
|
||||
}
|
||||
pe_plist_dict() { # FILE KEYPATH — make sure a dictionary exists at KEYPATH
|
||||
local f=$1 k=$2
|
||||
[ -f "$f" ] || plutil -create xml1 "$f"
|
||||
plutil -extract "$k" xml1 -o /dev/null "$f" >/dev/null 2>&1 || plutil -insert "$k" -dictionary "$f"
|
||||
}
|
||||
# launchd service override on the target (disabled.plist): pe_service LABEL true|false
|
||||
pe_service_disabled() {
|
||||
local f="$DATA/private/var/db/com.apple.xpc.launchd/disabled.plist"
|
||||
mkdir -p "$(dirname "$f")"
|
||||
pe_plist_set "$f" "$1" bool "$2"
|
||||
}
|
||||
# version of the installed system
|
||||
pe_target_version() { plutil -extract ProductVersion raw -o - "$SYS/System/Library/CoreServices/SystemVersion.plist" 2>/dev/null; }
|
||||
pe_target_build() { plutil -extract ProductBuildVersion raw -o - "$SYS/System/Library/CoreServices/SystemVersion.plist" 2>/dev/null; }
|
||||
40
lib/images/macos/guest/pe.sh
Executable file
40
lib/images/macos/guest/pe.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
|||
#!/bin/bash
|
||||
# vmix PE hook. Runs as root from launchd when the patched Recovery boots
|
||||
# (injected by makeRecoveryPE). If a VMIX volume is attached it runs
|
||||
# /Volumes/VMIX/run.sh, records the exit status on the volume and powers off;
|
||||
# without one it does nothing and the recovery behaves normally.
|
||||
# Everything printed here goes to /dev/console, i.e. the host's serial log.
|
||||
exec >/dev/console 2>&1
|
||||
echo "VMIX-PE: hook started $(date) uid=$(id -u)"
|
||||
V=/Volumes/VMIX
|
||||
i=0
|
||||
while [ ! -f "$V/run.sh" ] && [ $i -lt 90 ]; do
|
||||
diskutil mount VMIX >/dev/null 2>&1
|
||||
sleep 2; i=$((i + 1))
|
||||
done
|
||||
if [ ! -f "$V/run.sh" ]; then
|
||||
echo "VMIX-PE: no VMIX volume, leaving the recovery alone"
|
||||
exit 0
|
||||
fi
|
||||
echo "VMIX-PE: VMIX mounted after $i retries"
|
||||
caffeinate -dimsu -t 86400 >/dev/null 2>&1 &
|
||||
[ -f "$V/vmix.conf" ] && . "$V/vmix.conf"
|
||||
# certificate checks need a sane clock; a fresh VM RTC can be off
|
||||
[ -n "${BUILD_DATE:-}" ] && date -u "$BUILD_DATE" >/dev/null 2>&1 && echo "VMIX-PE: clock set to $(date -u)"
|
||||
export V
|
||||
cd "$V"
|
||||
echo "VMIX-PE: running run.sh"
|
||||
/bin/bash "$V/run.sh" 2>&1 | tee "$V/vmix-run.log"
|
||||
rc=${PIPESTATUS[0]}
|
||||
echo "$rc" > "$V/vmix-run.status"
|
||||
echo "VMIX-PE: run.sh exited $rc"
|
||||
if [ -f "$V/vmix-reboot" ]; then
|
||||
rm -f "$V/vmix-reboot"; sync
|
||||
echo "VMIX-PE: rebooting as requested"
|
||||
reboot
|
||||
exit 0
|
||||
fi
|
||||
sync; sleep 1
|
||||
diskutil unmount force "$V" >/dev/null 2>&1
|
||||
echo "VMIX-PE-DONE rc=$rc"
|
||||
shutdown -h now
|
||||
|
|
@ -1,88 +1,65 @@
|
|||
#!/bin/sh
|
||||
# vmix: automated macOS install. Runs inside macOS Recovery's Terminal, started
|
||||
# by vm-driver.py which types "sh /Volumes/VMIX/run.sh" for us.
|
||||
#
|
||||
# 1. erase the target disk (found by size) as APFS "Macintosh HD"
|
||||
# 2. rebuild "Install macOS <name>.app": app skeleton from installer-app.tar
|
||||
# (host-extracted Payload) + SharedSupport.dmg = the WHOLE InstallAssistant.pkg
|
||||
# dd'd byte-exact from a raw disk (Apple's own postinstall hardlinks the pkg
|
||||
# there: it is a "pkgdmg" whose koly footer points at the dmg inside; the bare
|
||||
# xar member fails startosinstall with "pkgdmg is missing a footer")
|
||||
# 3. startosinstall unattended, with the vmix agent pkg as --installpackage
|
||||
# 4. startosinstall reboots itself into the install phase; the vmix agent pkg
|
||||
# installs during that phase and runs on the installed system's first boot
|
||||
#
|
||||
# On first boot of the installed system the agent runs /Volumes/VMIX/vmix-run.sh
|
||||
# and powers off, which ends the QEMU session on the host.
|
||||
|
||||
# macOS Recovery invokes us as `sh` (bash in POSIX mode, no process substitution);
|
||||
# re-exec once under bash so `>(tee ...)` and other bashisms work.
|
||||
if [ -z "${VMIX_REEXEC:-}" ]; then VMIX_REEXEC=1 exec bash "$0" "$@"; fi
|
||||
|
||||
V="/Volumes/VMIX"
|
||||
# tee to the Terminal (visible in host screenshots) and to a log on the volume
|
||||
exec > >(tee "$V/install.log") 2>&1
|
||||
#!/bin/bash
|
||||
# vmix unattended macOS install, run by the PE hook (pe.sh) as root in the
|
||||
# Recovery with /Volumes/VMIX mounted (V). Needs vmix.conf: TARGET_BYTES,
|
||||
# PKG_BYTES, PKG_DISK_BYTES, APP_NAME, VOLUME_NAME.
|
||||
# 1. find the target disk and the SharedSupport (InstallAssistant.pkg) disk by size
|
||||
# 2. erase the target as APFS, unpack the installer app, dd the whole pkg into it
|
||||
# as SharedSupport.dmg (a "pkgdmg", startosinstall checks its koly footer)
|
||||
# 3. startosinstall prepares, then reboots itself into the install phase; the
|
||||
# installed system's first boot ends at the loginwindow (the host powers off)
|
||||
# Never returns on success; a return means failure (the PE records the status).
|
||||
set -x
|
||||
. "$V/vmix.conf"
|
||||
# keep the recovery display awake so the host driver can watch the screen
|
||||
caffeinate -dimsu -t 86400 >/dev/null 2>&1 &
|
||||
pmset -a displaysleep 0 sleep 0 >/dev/null 2>&1 || true
|
||||
|
||||
. "$V/pe-lib.sh"
|
||||
fail() {
|
||||
echo "vmix-install: FAIL: $*"
|
||||
cp /var/log/install.log "$V/system-install.log" 2>/dev/null || true
|
||||
echo 1 >"$V/install.status"
|
||||
echo "VMIX-FAIL: $*"
|
||||
cp /var/log/install.log "$V/system-install.log" 2>/dev/null
|
||||
sync
|
||||
sleep 2
|
||||
shutdown -h now 2>/dev/null || halt 2>/dev/null || true
|
||||
exit 1
|
||||
}
|
||||
# each boot into the PE with the install still pending is one attempt
|
||||
ATTEMPT=$(( $(cat "$V/install.attempt" 2>/dev/null || echo 0) + 1 ))
|
||||
echo "$ATTEMPT" > "$V/install.attempt"; sync
|
||||
echo "VMIX-INSTALL: attempt $ATTEMPT (boot into the PE)"
|
||||
[ "$ATTEMPT" -le 3 ] || fail "the installer keeps coming back to the PE ($ATTEMPT boots)"
|
||||
|
||||
# whole-disk identifier (diskN) whose size in bytes is exactly $1
|
||||
# --- 1. disks by exact size
|
||||
disk_by_size() {
|
||||
for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+'); do
|
||||
s=$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9][0-9]*\) Bytes).*/\1/p')
|
||||
[ "$s" = "$1" ] && { echo "${d#/dev/}"; return 0; }
|
||||
for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+' | sort -u); do
|
||||
if [ "$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9]*\) Bytes).*/\1/p')" = "$1" ]; then
|
||||
echo "${d#/dev/}"; return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
TARGET=$(disk_by_size "$TARGET_BYTES") || fail "target disk of $TARGET_BYTES bytes not found"
|
||||
SSDISK=$(disk_by_size "$PKG_DISK_BYTES") || fail "SharedSupport disk of $PKG_DISK_BYTES bytes not found"
|
||||
echo "VMIX-INSTALL: target=$TARGET sharedsupport=$SSDISK"
|
||||
|
||||
echo "vmix-install: $(date) app=$APP_NAME volume=$VOLUME_NAME"
|
||||
TARGET=$(disk_by_size "$TARGET_BYTES") || fail "target disk ($TARGET_BYTES bytes) not found"
|
||||
SSDISK=$(disk_by_size "$PKG_DISK_BYTES") || fail "installer pkg disk ($PKG_DISK_BYTES bytes) not found"
|
||||
echo "vmix-install: target=$TARGET sharedsupport=$SSDISK"
|
||||
|
||||
# --- 1. erase the target disk as an APFS volume
|
||||
diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk $TARGET"
|
||||
# --- 2. target volume + installer app (the pkg payload skeleton + SharedSupport.dmg)
|
||||
VOL="/Volumes/$VOLUME_NAME"
|
||||
[ -d "$VOL" ] || fail "$VOL not mounted"
|
||||
|
||||
# --- 2. rebuild the installer app on the target volume
|
||||
tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar installer-app.tar"
|
||||
APP="$VOL/$APP_NAME"
|
||||
SOI="$APP/Contents/Resources/startosinstall"
|
||||
[ -x "$SOI" ] || fail "startosinstall missing in $APP"
|
||||
SS="$APP/Contents/SharedSupport/SharedSupport.dmg"
|
||||
mkdir -p "$APP/Contents/SharedSupport"
|
||||
FULL=$((PKG_BYTES / 1048576))
|
||||
REM=$((PKG_BYTES % 1048576))
|
||||
dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport.dmg"
|
||||
if [ "$REM" -gt 0 ]; then
|
||||
dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" || fail "dd SharedSupport.dmg tail"
|
||||
fi
|
||||
[ "$(stat -f %z "$SS")" = "$PKG_BYTES" ] || fail "SharedSupport.dmg size mismatch: $(stat -f %z "$SS") != $PKG_BYTES"
|
||||
tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no UDIF koly footer"
|
||||
chflags -h norestricted "$SS" 2>/dev/null || true
|
||||
echo "vmix-install: app=$APP SharedSupport.dmg=$(stat -f %z "$SS") bytes"
|
||||
prepare_target() {
|
||||
diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk"
|
||||
[ -d "$VOL" ] || fail "$VOL not mounted after erase"
|
||||
tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar installer app"
|
||||
[ -x "$APP/Contents/Resources/startosinstall" ] || fail "startosinstall missing from $APP"
|
||||
mkdir -p "$APP/Contents/SharedSupport"
|
||||
FULL=$(( PKG_BYTES / 1048576 )); REM=$(( PKG_BYTES % 1048576 ))
|
||||
echo "VMIX-INSTALL: copying SharedSupport.dmg ($PKG_BYTES bytes) from /dev/r$SSDISK"
|
||||
dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport"
|
||||
[ "$REM" -gt 0 ] && { dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 | dd bs=1 count=$REM >> "$SS"; } || true
|
||||
[ "$(stat -f %z "$SS")" = "$PKG_BYTES" ] || fail "SharedSupport.dmg size $(stat -f %z "$SS") != $PKG_BYTES"
|
||||
tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no koly footer"
|
||||
chflags -h norestricted "$SS" 2>/dev/null || true
|
||||
sync
|
||||
}
|
||||
prepare_target
|
||||
SOI="$APP/Contents/Resources/startosinstall"
|
||||
echo "VMIX-INSTALL: app ready, clock $(date -u)"
|
||||
|
||||
# macOS certificate validation needs a sane clock; a fresh VM RTC can be wrong.
|
||||
echo "vmix-install: guest clock is $(date) (UTC $(date -u))"
|
||||
if [ -n "${BUILD_DATE:-}" ]; then
|
||||
date -u "$BUILD_DATE" && echo "vmix-install: set clock to $(date)"
|
||||
fi
|
||||
|
||||
# Blackhole Apple's install/verify endpoints so osinstallersetupd's network calls
|
||||
# fail immediately instead of timing out (prepare otherwise crawls). Fully offline.
|
||||
# Offline install: no NIC is attached. Blackhole Apple's install/verify endpoints
|
||||
# too, so osinstallersetupd's requests fail immediately instead of timing out.
|
||||
for d in swscan.apple.com swcdn.apple.com swdist.apple.com swquery.apple.com \
|
||||
gs.apple.com gsa.apple.com gdmf.apple.com mesu.apple.com xp.apple.com \
|
||||
albert.apple.com captive.apple.com deviceservices-external.apple.com \
|
||||
|
|
@ -90,50 +67,34 @@ for d in swscan.apple.com swcdn.apple.com swdist.apple.com swquery.apple.com \
|
|||
ocsp2.apple.com valid.apple.com; do
|
||||
echo "127.0.0.1 $d" >> /etc/hosts
|
||||
done
|
||||
echo "vmix-install: blackholed Apple install endpoints for a fast offline prepare"
|
||||
|
||||
# --- 3. unattended install. startosinstall prepares then reboots the machine
|
||||
# itself into the install phase. Prepare intermittently stalls (~46% — an online
|
||||
# verify/personalization step through the VM's NAT), so a watchdog kills and
|
||||
# retries startosinstall if the target volume makes no write progress for a while.
|
||||
# The vmix agent pkg installs during the install phase and runs on first boot.
|
||||
# quote args properly — $VOL contains a space ("Macintosh HD")
|
||||
# --- 3. startosinstall prepares (~5 min) then reboots the machine itself into the
|
||||
# install phase; it never returns on success. Prepare is intermittently slow in
|
||||
# QEMU, so an attempt that stalls or runs too long is killed and retried on a
|
||||
# freshly erased target.
|
||||
run_soi() { "$SOI" --volume "$VOL" --agreetolicense --nointeraction --rebootdelay 5 "$@"; }
|
||||
free_kb() { df -k "$VOL" 2>/dev/null | awk 'NR==2 {print $4}'; }
|
||||
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 10 ]; do
|
||||
attempt=$((attempt + 1))
|
||||
echo "vmix-install: startosinstall attempt $attempt"
|
||||
if [ "$attempt" -eq 1 ]; then
|
||||
run_soi --installpackage "$V/vmix-agent.pkg" 2>&1 &
|
||||
else
|
||||
# a stalled attempt leaves the volume dirty; re-erase and rebuild for a clean retry
|
||||
diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk on retry"
|
||||
tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar on retry"
|
||||
mkdir -p "$APP/Contents/SharedSupport"
|
||||
dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL 2>/dev/null
|
||||
[ "$REM" -gt 0 ] && dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" 2>/dev/null
|
||||
chflags -h norestricted "$SS" 2>/dev/null || true
|
||||
run_soi --installpackage "$V/vmix-agent.pkg" 2>&1 &
|
||||
fi
|
||||
try=0
|
||||
while [ "$try" -lt 6 ]; do
|
||||
try=$((try + 1))
|
||||
[ "$try" -gt 1 ] && prepare_target
|
||||
echo "VMIX-INSTALL: startosinstall try $try"
|
||||
run_soi 2>&1 &
|
||||
SOI_PID=$!
|
||||
# watchdog: kill startosinstall if free space stalls for ~4 min OR the attempt
|
||||
# simply takes too long (prepare is intermittently slow; healthy = a few minutes)
|
||||
last=$(free_kb); stalled=0; elapsed=0
|
||||
while kill -0 "$SOI_PID" 2>/dev/null; do
|
||||
sleep 30; elapsed=$((elapsed + 30))
|
||||
now=$(free_kb)
|
||||
if [ "$now" = "$last" ]; then stalled=$((stalled + 30)); else stalled=0; last=$now; fi
|
||||
if [ "$stalled" -ge 240 ] || [ "$elapsed" -ge 540 ]; then
|
||||
echo "vmix-install: prepare too slow (stalled=${stalled}s elapsed=${elapsed}s), killing to retry"
|
||||
[ $((elapsed % 120)) -eq 0 ] && echo "VMIX-INSTALL: prepare running ${elapsed}s (stalled ${stalled}s)"
|
||||
if [ "$stalled" -ge 240 ] || [ "$elapsed" -ge 600 ]; then
|
||||
echo "VMIX-INSTALL: prepare too slow (stalled=${stalled}s elapsed=${elapsed}s), killing to retry"
|
||||
kill -9 "$SOI_PID" 2>/dev/null; pkill -9 -f startosinstall 2>/dev/null
|
||||
break
|
||||
fi
|
||||
done
|
||||
wait "$SOI_PID" 2>/dev/null
|
||||
# on success startosinstall reboots the machine and we never get here
|
||||
echo "vmix-install: startosinstall attempt $attempt ended without rebooting"
|
||||
echo "VMIX-INSTALL: startosinstall try $try ended without rebooting"
|
||||
sleep 3
|
||||
done
|
||||
fail "startosinstall did not complete after $attempt attempts"
|
||||
fail "startosinstall did not complete after $try tries"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
# 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.
|
||||
# Customize a macOS image offline from the vmix PE: the recovery boots with the
|
||||
# image and a VMIX volume attached, its hook runs `script` as root with the
|
||||
# image's System (read-only) and Data (rw) volumes mounted at $SYS / $DATA, then
|
||||
# powers off. The installed macOS itself is never booted, so nothing depends on
|
||||
# launchd/BTM approval inside the guest. Counterpart of the Windows
|
||||
# registry/audit flow. Optionally re-installs OpenCore with a new SMBIOS
|
||||
# identity (`smbios`).
|
||||
#
|
||||
# Templates provide:
|
||||
# script — sh script run as root on the booted system
|
||||
# script — sh script run as root in the PE (pe-lib.sh helpers available)
|
||||
# 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, ... }:
|
||||
{ pkgs, lib, qemu, ident, makeVmixVolume, makeOpenCore, makeBootDisk, installBootloader, vmixReadback, vmDriver, ... }:
|
||||
originalImage: {
|
||||
name ? "",
|
||||
script ? "",
|
||||
|
|
@ -19,7 +22,7 @@ originalImage: {
|
|||
smp ? 4,
|
||||
memSize ? 4096,
|
||||
cpu ? qemu.defaultCpu,
|
||||
timeout ? 3600,
|
||||
timeout ? 1800,
|
||||
}:
|
||||
let
|
||||
originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name);
|
||||
|
|
@ -27,6 +30,8 @@ let
|
|||
resultImg = "./disk.qcow2";
|
||||
hasScript = script != "";
|
||||
hasSmbios = smbios != null;
|
||||
pe = originalImage.pe or (throw "vmix: image ${originalImage.name} carries no PE (built by an older makeImage?)");
|
||||
volumeName = originalImage.volumeName or "Macintosh HD";
|
||||
|
||||
model = originalImage.model or "MacPro7,1";
|
||||
seed = if hasSmbios && (smbios.seed or null) != null then smbios.seed else null;
|
||||
|
|
@ -45,46 +50,60 @@ let
|
|||
inherit mac uuid;
|
||||
} // builtins.removeAttrs smbios [ "seed" "mac" "uuid" "model" ])
|
||||
else originalImage.opencore;
|
||||
# PE boot disk: serial console, and an OpenCore ScanPolicy that only allows
|
||||
# HFS+ volumes on SATA (= the PE), so the image's own macOS is never booted.
|
||||
# 0x10203 = FILE_SYSTEM_LOCK | DEVICE_LOCK | ALLOW_FS_HFS | ALLOW_DEVICE_SATA
|
||||
bootDisk = makeBootDisk {
|
||||
name = "${name}-${originalImageName}-pe";
|
||||
esp = originalImage.opencore;
|
||||
bootArgs = "keepsyms=1 serial=3 -v";
|
||||
scanPolicy = 66051;
|
||||
};
|
||||
|
||||
runScript = pkgs.writeText "${name}-vmix-run.sh" ''
|
||||
#!/bin/sh
|
||||
runScript = pkgs.writeText "${name}-run.sh" ''
|
||||
#!/bin/bash
|
||||
. /Volumes/VMIX/pe-lib.sh
|
||||
echo "=== vmix: ${name} ==="
|
||||
pe_mount_target || pe_fail "could not mount the target volumes"
|
||||
${script}
|
||||
pe_unmount_target
|
||||
'';
|
||||
vmixVol = makeVmixVolume {
|
||||
name = "${name}-${originalImageName}";
|
||||
files = [ { source = runScript; name = "vmix-run.sh"; } ] ++ files;
|
||||
files = [
|
||||
{ source = runScript; name = "run.sh"; }
|
||||
{ source = ../guest/pe-lib.sh; name = "pe-lib.sh"; }
|
||||
] ++ files;
|
||||
};
|
||||
driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
|
||||
|
||||
bootCommands = lib.optionalString hasScript ''
|
||||
cp ${vmixVol} vmix.img
|
||||
chmod +w vmix.img
|
||||
cat > vmix.conf <<CONF
|
||||
VOLUME_NAME="${volumeName}"
|
||||
BUILD_DATE="$(date -u +%m%d%H%M%Y.%S)"
|
||||
CONF
|
||||
guestfish -a vmix.img -m /dev/sda1 upload vmix.conf /vmix.conf
|
||||
qemu-img create -q -f qcow2 -F raw -b ${pe} pe.qcow2
|
||||
qemu-img create -q -f qcow2 -F raw -b ${bootDisk}/boot.img ocboot.qcow2
|
||||
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} --progress-file ${resultImg} -- \
|
||||
echo "=== vmix: running ${name} in the PE against ${originalImageName} ==="
|
||||
python3 ${vmDriver} --mode pe --name "${name}-${originalImageName}" --timeout ${toString timeout} \
|
||||
--serial-log serial.log --progress-file ${resultImg} -- \
|
||||
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; }
|
||||
${qemu.serialArgs "serial.log"} \
|
||||
${qemu.sataDrive { id = "opencore"; port = 0; file = "ocboot.qcow2"; }} \
|
||||
${qemu.sataDrive { id = "pe"; port = 1; file = "pe.qcow2"; }} \
|
||||
${qemu.sataDrive { id = "system"; port = 2; file = resultImg; }} \
|
||||
${qemu.sataDrive { id = "vmix"; port = 3; file = "vmix.img"; format = "raw"; }} \
|
||||
|| { echo "vmix: PE 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; }
|
||||
|
|
@ -92,7 +111,7 @@ let
|
|||
'';
|
||||
|
||||
builtImage = pkgs.runCommand customImageName ({
|
||||
nativeBuildInputs = with pkgs; [ pkgs.qemu mtools driverPython libguestfs-with-appliance ];
|
||||
nativeBuildInputs = with pkgs; [ pkgs.qemu driverPython libguestfs-with-appliance ];
|
||||
requiredSystemFeatures = [ "kvm" ];
|
||||
} // lib.optionalAttrs impure { __noChroot = true; }) ''
|
||||
qemu-img create -q -f qcow2 -b ${originalImage} -F qcow2 ${resultImg}
|
||||
|
|
@ -102,4 +121,4 @@ let
|
|||
mv ${resultImg} $out
|
||||
'';
|
||||
in
|
||||
builtImage // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; model = esp.model or model; }
|
||||
builtImage // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; model = esp.model or model; inherit pe volumeName; }
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
# Distribution-style flat package (xar + bom + cpio, built on Linux) for
|
||||
# `startosinstall --installpackage`. macOS installs it during the first boot of the
|
||||
# installed system (bootinstalld, "Installer Progress"): it places the vmix agent
|
||||
# LaunchDaemon, marks Setup Assistant as done, starts the agent, and schedules a
|
||||
# reboot as a fallback so the daemon runs even if bootstrapping failed.
|
||||
#
|
||||
# The files are shipped inside Scripts and copied by postinstall: installd unpacks
|
||||
# our Scripts archive fine, but "shoves 0 items" from a Linux-made Payload.
|
||||
{ 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
|
||||
# Runs during the OS install (bootinstalld) with $3 = the target system root.
|
||||
# Only place files; the ch.vmix.agent LaunchDaemon then runs on the installed
|
||||
# system's first boot via RunAtLoad (confirmed loading on Tahoe).
|
||||
T="''${3%/}"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
LOG="$T/private/var/log/vmix-agent-install.log"
|
||||
mkdir -p "$T/private/var/log"
|
||||
exec >>"$LOG" 2>&1
|
||||
echo "=== vmix agent pkg postinstall $(date) target=[$3] ==="
|
||||
mkdir -p "$T/Library/LaunchDaemons" "$T/Library/vmix" "$T/private/var/db"
|
||||
cp "$HERE/agent.sh" "$T/Library/vmix/agent.sh"
|
||||
cp "$HERE/${id}.plist" "$T/Library/LaunchDaemons/${id}.plist"
|
||||
chmod 755 "$T/Library/vmix/agent.sh"
|
||||
chmod 644 "$T/Library/LaunchDaemons/${id}.plist"
|
||||
chown -R root:wheel "$T/Library/vmix" "$T/Library/LaunchDaemons/${id}.plist"
|
||||
touch "$T/private/var/db/.AppleSetupDone"
|
||||
chown root:wheel "$T/private/var/db/.AppleSetupDone"
|
||||
ls -la "$T/Library/vmix/agent.sh" "$T/Library/LaunchDaemons/${id}.plist"
|
||||
# A pkg LaunchDaemon is registered with Background Task Management but stays
|
||||
# pending approval, so it will not auto-run headless. Two BTM-exempt triggers:
|
||||
# - bootstrap it now (starts it in the installer env; the agent no-ops there)
|
||||
# - a root cron @reboot job (Apple's cron daemon is trusted, runs it at boot)
|
||||
launchctl bootstrap system "$T/Library/LaunchDaemons/${id}.plist" 2>&1 && echo "bootstrapped" || echo "bootstrap returned $?"
|
||||
mkdir -p "$T/usr/lib/cron/tabs"
|
||||
printf '@reboot /bin/sh /Library/vmix/agent.sh\n' > "$T/usr/lib/cron/tabs/root"
|
||||
chmod 600 "$T/usr/lib/cron/tabs/root"
|
||||
chown root:wheel "$T/usr/lib/cron/tabs/root"
|
||||
echo "cron @reboot installed"
|
||||
exit 0
|
||||
'';
|
||||
in
|
||||
pkgs.runCommand "vmix-agent-${version}.pkg" {
|
||||
nativeBuildInputs = [ pkgs.xar bomutils pkgs.cpio pkgs.libarchive 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
|
||||
# the same files ride along in Scripts, which is what postinstall installs from
|
||||
cp ${postinstall} scripts/postinstall
|
||||
cp ${../guest/agent.sh} scripts/agent.sh
|
||||
cp ${../guest/ch.vmix.agent.plist} scripts/${id}.plist
|
||||
chmod 755 scripts/postinstall scripts/agent.sh
|
||||
|
||||
NFILES=$(find root | wc -l)
|
||||
KBYTES=$(du -sk root | cut -f1)
|
||||
# bsdcpio keeps the "./" prefix the Bom uses (GNU cpio strips it and installd then extracts nothing)
|
||||
(cd root && find . | bsdcpio -o --format odc --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" install-location="/">
|
||||
<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
|
||||
''
|
||||
29
lib/images/macos/helpers/makeBootDisk.nix
Normal file
29
lib/images/macos/helpers/makeBootDisk.nix
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# OpenCore boot disk for build-time boots, derived from an image's ESP
|
||||
# (makeOpenCore output) with build-only settings: extra boot-args (serial
|
||||
# console, verbose) and optionally an OpenCore ScanPolicy so that only the PE
|
||||
# (an HFS+ volume on SATA) is bootable — the build never lands on the wrong OS.
|
||||
{ pkgs, lib, ... }:
|
||||
{ esp, bootArgs ? null, scanPolicy ? null, name ? "boot" }:
|
||||
pkgs.runCommand "${name}-bootdisk" {
|
||||
nativeBuildInputs = with pkgs; [ python3 mtools dosfstools gptfdisk ];
|
||||
} ''
|
||||
cp -r ${esp}/EFI EFI
|
||||
chmod -R u+w EFI
|
||||
python3 - <<'PY'
|
||||
import plistlib
|
||||
p = 'EFI/OC/config.plist'
|
||||
cfg = plistlib.load(open(p, 'rb'))
|
||||
nv = cfg['NVRAM']['Add']['7C436110-AB2A-4BBB-A880-FE41995C9F82']
|
||||
${lib.optionalString (bootArgs != null) ''nv['boot-args'] = ${builtins.toJSON bootArgs}''}
|
||||
${lib.optionalString (scanPolicy != null) ''cfg['Misc']['Security']['ScanPolicy'] = ${toString scanPolicy}''}
|
||||
plistlib.dump(cfg, open(p, 'wb'))
|
||||
print('boot-args:', nv['boot-args'], 'ScanPolicy:', cfg['Misc']['Security']['ScanPolicy'])
|
||||
PY
|
||||
mkdir -p $out
|
||||
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 EFI ::
|
||||
mdir -i $out/boot.img@@1M ::EFI/OC >/dev/null
|
||||
''
|
||||
|
|
@ -1,77 +1,59 @@
|
|||
# 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, ... }:
|
||||
# Build a pre-installed macOS qcow2 with an unattended install driven from the
|
||||
# vmix PE (Apple's Recovery + one LaunchDaemon, see makeRecoveryPE):
|
||||
# OpenCore boots the PE → its hook runs /Volumes/VMIX/run.sh (vmix-install.sh)
|
||||
# → erase the disk, rebuild the installer app from installer-app.tar + the
|
||||
# SharedSupport raw disk, startosinstall → the installer reboots through its
|
||||
# phases → the installed system's first boot reaches the loginwindow → the
|
||||
# driver powers it off. No GUI is driven; progress is read from the serial
|
||||
# console and screenshots (brightness). Fully offline: no NIC is attached.
|
||||
# Then OpenCore is copied into the image's own ESP so it boots with plain OVMF.
|
||||
# Apply templates with customizeImageFold, then .generalize.
|
||||
{ pkgs, lib, qemu, ident, installerPayload, makeOpenCore, makeBootDisk, makeVmixVolume, installBootloader, vmixReadback, vmDriver, ... }:
|
||||
{
|
||||
name ? "macos",
|
||||
installer, # InstallAssistant.pkg (fetchurl)
|
||||
recovery, # BaseSystem.dmg (fetchRecovery)
|
||||
installer, # InstallAssistant.pkg
|
||||
pe, # makeRecoveryPE output for the same macOS version
|
||||
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)
|
||||
model ? "MacPro7,1", # SMBIOS model; must be supported by the installed macOS
|
||||
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
|
||||
installNetwork ? false, # attach a NIC during install (default: offline — startosinstall
|
||||
# otherwise hangs on Apple personalization through a flaky NAT)
|
||||
installNetwork ? false, # attach a NIC during the install (default: offline)
|
||||
}:
|
||||
let
|
||||
mac = ident.macFromSeed seed;
|
||||
uuid = ident.uuidFromSeed seed;
|
||||
esp = makeOpenCore { name = "${name}-opencore"; inherit model mac uuid bootArgs; extraConfig = extraOpenCoreConfig; };
|
||||
esp = makeOpenCore { name = "${name}-opencore"; inherit model mac uuid bootArgs memSize; extraConfig = extraOpenCoreConfig; };
|
||||
# build-time boot disk: same identity, plus serial console + verbose boot
|
||||
bootDisk = makeBootDisk { name = "${name}-install"; inherit esp; bootArgs = "${bootArgs} serial=3 -v"; };
|
||||
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 = ../guest/pe-lib.sh; name = "pe-lib.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" ]; };
|
||||
driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
|
||||
|
||||
drv = pkgs.runCommand "${name}-vmix.qcow2" {
|
||||
__noChroot = true;
|
||||
requiredSystemFeatures = [ "kvm" ];
|
||||
nativeBuildInputs = with pkgs; [ pkgs.qemu mtools jq driverPython tesseract libguestfs-with-appliance ];
|
||||
nativeBuildInputs = with pkgs; [ pkgs.qemu jq driverPython 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
|
||||
qemu-img create -q -f qcow2 -F raw -b ${pe} pe.qcow2
|
||||
qemu-img create -q -f qcow2 -F raw -b ${bootDisk}/boot.img ocboot.qcow2
|
||||
|
||||
# Apple's postinstall hardlinks the WHOLE InstallAssistant.pkg as
|
||||
# Contents/SharedSupport/SharedSupport.dmg: the pkg is a "pkgdmg" (xar + koly
|
||||
|
|
@ -99,42 +81,33 @@ let
|
|||
|
||||
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} --progress-file disk.qcow2 -- \
|
||||
echo "=== vmix: installing ${name} (unattended, ~1 h; logs and screenshots in /tmp/vmix-macos/${name}) ==="
|
||||
python3 ${vmDriver} --mode install --name ${name} --timeout ${toString timeout} \
|
||||
--serial-log serial.log --progress-file disk.qcow2 -- \
|
||||
qemu-system-x86_64 $VMIX_DISPLAY \
|
||||
${qemu.machineArgs { inherit cpu smp memSize; }} \
|
||||
${qemu.firmwareArgs "vars.fd"} \
|
||||
${qemu.serialArgs "serial.log"} \
|
||||
${qemu.sataDrive { id = "opencore"; port = 0; file = "ocboot.qcow2"; }} \
|
||||
${qemu.sataDrive { id = "recovery"; port = 1; file = "recovery.qcow2"; }} \
|
||||
${qemu.sataDrive { id = "pe"; port = 1; file = "pe.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"; }} \
|
||||
${lib.optionalString installNetwork (qemu.netArgs { inherit mac; })} \
|
||||
|| { echo "vmix: install VM failed (see /tmp/vmix-macos/${name})"; exit 1; }
|
||||
|
||||
# The driver exits non-zero (handled above) if the install did not reach a
|
||||
# completed/powered-off state, so reaching here means the OS is installed.
|
||||
# vmix-run.status is written only when the agent ran (cron/daemon); log it.
|
||||
# The PE records a status only if run.sh returned, i.e. the install failed
|
||||
# before the installer took over and rebooted.
|
||||
${vmixReadback "vmix.img"}
|
||||
[ "$STATUS" = "0" ] && echo "vmix: first-boot agent completed (status 0)" \
|
||||
|| echo "vmix: install reached loginwindow (agent status '$STATUS'); image is installed"
|
||||
if [ -n "$STATUS" ] && [ "$STATUS" != "0" ]; then
|
||||
echo "vmix: install script failed (status $STATUS), see /tmp/vmix-macos/${name}"; exit 1
|
||||
fi
|
||||
|
||||
${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; }
|
||||
in drv // { _vmixOsType = "macos"; macAddress = mac; opencore = esp; inherit model pe volumeName; }
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
showPicker ? true,
|
||||
pickerTimeout ? 2,
|
||||
extraConfig ? {},
|
||||
memSize ? 8192, # RAM described in SMBIOS (MacPro7,1 wants 4 DIMMs)
|
||||
}:
|
||||
let
|
||||
ocImage = pkgs.fetchurl { inherit (upstream.opencore.image) url sha256; name = "OSX-KVM-OpenCore.qcow2"; };
|
||||
|
|
@ -50,7 +51,7 @@ pkgs.runCommand "${name}-esp" {
|
|||
--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)}
|
||||
--extra-json ${lib.escapeShellArg (builtins.toJSON extraConfig)} --memory-mb ${toString memSize}
|
||||
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}" \
|
||||
|
|
|
|||
30
lib/images/macos/helpers/makeRecoveryPE.nix
Normal file
30
lib/images/macos/helpers/makeRecoveryPE.nix
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# The vmix "PE": Apple's Recovery (BaseSystem.dmg) with one LaunchDaemon added
|
||||
# that runs /Volumes/VMIX/run.sh as root at boot and powers off afterwards.
|
||||
# BaseSystem is a plain (journaled) HFS+ volume that Linux can write with the
|
||||
# hfsplus driver's force option — the pristine image's journal is empty, so this
|
||||
# is safe. The kernel and boot.efi are untouched; launchd loads the extra plist
|
||||
# from /System/Library/LaunchDaemons alongside its signed cache (verified on
|
||||
# Tahoe 26.6.2). Same idea as AutoNBI/Imagr NetBoot images.
|
||||
# Output: raw disk image (HFS+ volume with a partition table) that OpenCore boots.
|
||||
{ pkgs, lib, ... }:
|
||||
{ name ? "macos", recovery }:
|
||||
pkgs.runCommand "${name}-pe.img" {
|
||||
nativeBuildInputs = with pkgs; [ dmg2img libguestfs-with-appliance ];
|
||||
} ''
|
||||
echo "=== vmix: building the recovery PE from BaseSystem.dmg ==="
|
||||
dmg2img -s ${recovery} $out
|
||||
chmod +w $out
|
||||
guestfish -a $out <<GFS
|
||||
run
|
||||
mount-options force /dev/sda1 /
|
||||
mkdir-p /usr/libexec/vmix
|
||||
upload ${../guest/pe.sh} /usr/libexec/vmix/pe.sh
|
||||
chmod 0755 /usr/libexec/vmix/pe.sh
|
||||
upload ${../guest/ch.vmix.pe.plist} /System/Library/LaunchDaemons/ch.vmix.pe.plist
|
||||
chmod 0644 /System/Library/LaunchDaemons/ch.vmix.pe.plist
|
||||
ls /usr/libexec/vmix
|
||||
umount /
|
||||
GFS
|
||||
guestfish --ro -a $out -m /dev/sda1 ls /System/Library/LaunchDaemons | grep -q '^ch.vmix.pe.plist$' \
|
||||
|| { echo "vmix: PE hook not installed"; exit 1; }
|
||||
''
|
||||
|
|
@ -43,6 +43,7 @@ def main():
|
|||
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:
|
||||
|
|
@ -84,6 +85,21 @@ def main():
|
|||
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
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ rec {
|
|||
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}";
|
||||
|
||||
# XNU logs to COM1 with boot-args serial=3; the build drivers read this file
|
||||
serialArgs = file: "-serial file:${file}";
|
||||
|
||||
firmwareArgs = varsFile:
|
||||
"-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd -drive if=pflash,format=raw,file=${varsFile}";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,27 @@
|
|||
#!/usr/bin/env python3
|
||||
"""vmix macOS VM driver.
|
||||
"""vmix macOS VM driver: runs QEMU and decides when a build boot is finished.
|
||||
|
||||
Launches QEMU with a QMP socket and either
|
||||
Modes
|
||||
pe the recovery PE runs /Volumes/VMIX/run.sh and powers off. Success is
|
||||
QEMU exiting on its own; the caller checks vmix-run.status.
|
||||
install the PE starts the macOS installer, which reboots through its phases
|
||||
into the installed system. Finished when that system reaches the
|
||||
(bright) loginwindow / Setup Assistant — the driver powers it down —
|
||||
or halts on its own.
|
||||
boot boot an installed image and wait for it to halt or reach the loginwindow.
|
||||
|
||||
--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.
|
||||
Observation is passive: the serial console (boot-args serial=3: the PE's
|
||||
"VMIX-*" markers, kernel boots, panics) and screenshots over QMP (mean
|
||||
brightness + a coarse change fingerprint). No OCR, no keystrokes. A boot hang
|
||||
(dark, frozen screen, disk and serial idle) is retried with a system_reset.
|
||||
Screenshots, the driver log and the serial log are kept in --debug-dir.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -23,60 +29,66 @@ import time
|
|||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError: # pragma: no cover
|
||||
except ImportError: # screenshots then only serve as debug files
|
||||
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'}
|
||||
PANIC_MARKS = ('panic(cpu', 'Kernel Extensions in backtrace', 'Debugger called: <panic>', 'Nested panic detected', 'panic string:')
|
||||
REBOOT_MARK = 'MACH Reboot'
|
||||
|
||||
|
||||
class Log:
|
||||
def __init__(self, path):
|
||||
self.f = open(path, 'a')
|
||||
self.f = open(path, 'a') if path else None
|
||||
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()
|
||||
if self.f:
|
||||
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')
|
||||
self.path = path
|
||||
self.s = None
|
||||
self.buf = b''
|
||||
|
||||
def connect(self, timeout=90):
|
||||
t0 = time.time()
|
||||
while True:
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.settimeout(60)
|
||||
s.connect(self.path)
|
||||
self.s = s
|
||||
self.buf = b''
|
||||
self._read() # greeting
|
||||
self.cmd('qmp_capabilities')
|
||||
return
|
||||
except (OSError, ValueError):
|
||||
if time.time() - t0 > timeout:
|
||||
raise
|
||||
time.sleep(1)
|
||||
|
||||
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
|
||||
while b'\n' not in self.buf:
|
||||
d = self.s.recv(65536)
|
||||
if not d:
|
||||
raise OSError('QMP socket closed')
|
||||
self.buf += d
|
||||
line, self.buf = self.buf.split(b'\n', 1)
|
||||
return json.loads(line)
|
||||
|
||||
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')
|
||||
self.s.sendall(json.dumps({'execute': name, 'arguments': args}).encode() + b'\n')
|
||||
while True:
|
||||
r = self._read()
|
||||
if 'return' in r:
|
||||
return r['return']
|
||||
if 'error' in r:
|
||||
raise RuntimeError(r['error'])
|
||||
|
||||
def screendump(self, path):
|
||||
self.cmd('screendump', filename=path)
|
||||
|
|
@ -87,182 +99,117 @@ class QMP:
|
|||
def system_powerdown(self):
|
||||
self.cmd('system_powerdown')
|
||||
|
||||
_jig = 0
|
||||
|
||||
def jiggle(self):
|
||||
# tiny absolute (usb-tablet) pointer move to keep the display awake: a real
|
||||
# HID event, but < 2 screen px so it does not change the settle fingerprint
|
||||
self._jig = 16060 if self._jig < 16030 else 16000
|
||||
try:
|
||||
self.cmd('input-send-event', events=[
|
||||
{'type': 'abs', 'data': {'axis': 'x', 'value': self._jig}},
|
||||
{'type': 'abs', 'data': {'axis': 'y', 'value': 16000}}])
|
||||
except Exception: # noqa: BLE001
|
||||
self.send_key('shift')
|
||||
|
||||
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."""
|
||||
"""Screenshots over QMP with a coarse change fingerprint (cursor-insensitive)."""
|
||||
|
||||
def __init__(self, qmp, debug_dir, log):
|
||||
self.qmp = qmp
|
||||
self.debug_dir = debug_dir
|
||||
self.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.tmp = os.path.join(debug_dir, f'.grab-{os.getpid()}.ppm')
|
||||
self.img = None
|
||||
self.last_fp = None
|
||||
self.stable_since = time.time()
|
||||
self.n = 0
|
||||
|
||||
def grab(self):
|
||||
self.qmp.screendump(self.tmp)
|
||||
with open(self.tmp, 'rb') as f:
|
||||
data = f.read()
|
||||
self.img = Image.open(io.BytesIO(data)) if Image else None
|
||||
# fingerprint from a coarse, quantized grayscale thumbnail so the moving
|
||||
# mouse cursor (keepalive jiggle) does not count as a screen change
|
||||
if self.img is not None:
|
||||
px = self.img.convert('L').resize((48, 36))
|
||||
fp = bytes(b & 0xF0 for b in px.getdata())
|
||||
h = hashlib.sha256(fp).hexdigest()
|
||||
if Image is None:
|
||||
fp = hashlib.sha256(data).hexdigest()
|
||||
else:
|
||||
h = hashlib.sha256(data).hexdigest()
|
||||
if h != self.last_hash:
|
||||
self.last_hash = h
|
||||
self.img = Image.open(io.BytesIO(data))
|
||||
small = self.img.convert('L').resize((48, 36))
|
||||
fp = hashlib.sha256(bytes(b & 0xF0 for b in small.tobytes())).hexdigest()
|
||||
if fp != self.last_fp:
|
||||
self.last_fp = fp
|
||||
self.stable_since = time.time()
|
||||
return self.img
|
||||
|
||||
def stable_for(self):
|
||||
return time.time() - self.stable_since
|
||||
|
||||
def mean(self):
|
||||
if self.img is None:
|
||||
return 0
|
||||
g = self.img.convert('L').resize((64, 48))
|
||||
px = g.tobytes()
|
||||
return sum(px) / len(px)
|
||||
|
||||
def is_blank(self):
|
||||
return self.img is not None and self.mean() < 3
|
||||
|
||||
def save(self, tag):
|
||||
self.n += 1
|
||||
path = os.path.join(self.debug_dir, f'{self.n:03d}-{tag}.png')
|
||||
path = os.path.join(self.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'))
|
||||
shutil.copy(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 mean(self):
|
||||
if self.img is None:
|
||||
return 128
|
||||
px = self.img.convert('L').resize((32, 24))
|
||||
d = list(px.getdata())
|
||||
return sum(d) / len(d)
|
||||
class Serial:
|
||||
"""Tail the serial console file QEMU writes (-serial file:...)."""
|
||||
|
||||
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 __init__(self, path):
|
||||
self.path = path
|
||||
self.pos = 0
|
||||
self.last_activity = time.time()
|
||||
self.boots = 0
|
||||
self.reboot_at = None
|
||||
|
||||
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 poll(self):
|
||||
if not self.path or not os.path.exists(self.path):
|
||||
return []
|
||||
with open(self.path, 'rb') as f:
|
||||
f.seek(self.pos)
|
||||
data = f.read()
|
||||
self.pos = f.tell()
|
||||
if not data:
|
||||
return []
|
||||
self.last_activity = time.time()
|
||||
lines = data.decode('utf-8', 'replace').replace('\r', '').split('\n')
|
||||
for l in lines:
|
||||
if l.startswith('Darwin Kernel Version'):
|
||||
self.boots += 1
|
||||
self.reboot_at = None
|
||||
elif REBOOT_MARK in l:
|
||||
self.reboot_at = time.time()
|
||||
return lines
|
||||
|
||||
def idle_for(self):
|
||||
return time.time() - self.last_activity
|
||||
|
||||
|
||||
def prepare_debug_dir(path):
|
||||
# nix builds run as different nixbld users: keep the shared dirs world-writable
|
||||
os.makedirs(path, exist_ok=True)
|
||||
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
|
||||
os.chmod(path, 0o777)
|
||||
except OSError:
|
||||
import tempfile
|
||||
alt = tempfile.mkdtemp(prefix='vmix-macos-')
|
||||
print(f'vmix driver: {path} not writable, using {alt}', flush=True)
|
||||
return alt
|
||||
pass
|
||||
for f in os.listdir(path):
|
||||
if f.endswith(('.png', '.ppm', '.log')) or f.startswith('.grab-') or f == 'qmp.sock':
|
||||
try:
|
||||
os.remove(os.path.join(path, f))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
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')
|
||||
os.remove(qmp_sock)
|
||||
cmd = list(qemu_args) + ['-qmp', f'unix:{qmp_sock},server,nowait']
|
||||
log('launching: ' + ' '.join(cmd))
|
||||
return subprocess.Popen(cmd)
|
||||
|
||||
|
||||
def disk_idle(args):
|
||||
"""True if the system disk has had no writes recently (guest not doing I/O)."""
|
||||
if not args.progress_file:
|
||||
return True
|
||||
try:
|
||||
|
|
@ -271,289 +218,150 @@ def disk_idle(args):
|
|||
return True
|
||||
|
||||
|
||||
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)
|
||||
def drive(args, proc, qmp, screen, serial, log):
|
||||
start = time.time()
|
||||
typed_at = None
|
||||
terminal_attempts = 0
|
||||
last_periodic = 0
|
||||
last_progress = start
|
||||
blind_done = False
|
||||
resets = 0
|
||||
panics = 0
|
||||
started = args.mode == 'boot' # pe/install: wait for the PE marker first
|
||||
blank_since = None
|
||||
login_since = None
|
||||
recovery_start = None
|
||||
last_term_action = 0
|
||||
while True:
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
log(f'QEMU exited with {rc}')
|
||||
return rc
|
||||
now = time.time()
|
||||
if now - start > args.timeout:
|
||||
try:
|
||||
screen.grab(); screen.save('timeout')
|
||||
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')
|
||||
# keep the recovery display awake until the command is typed (mouse jiggle)
|
||||
if typed_at is None and now - last_term_action > 8:
|
||||
qmp.jiggle()
|
||||
if now - start < args.min_boot or screen.stable_for() < args.settle:
|
||||
continue
|
||||
if screen.is_blank():
|
||||
last_progress = now
|
||||
if blank_since is None:
|
||||
blank_since = now
|
||||
if typed_at is None:
|
||||
# recovery display asleep — jiggle the mouse to wake it, wait for UI
|
||||
qmp.jiggle()
|
||||
continue
|
||||
# macOS `shutdown -h now` halts the guest to a black screen without an
|
||||
# ACPI power-off, so QEMU never exits. Once we have handed off (command
|
||||
# typed), a long pure-black screen means the agent finished and halted.
|
||||
elif typed_at is not None and now - blank_since > args.halt_timeout and disk_idle(args):
|
||||
screen.save('halt')
|
||||
log(f'guest halted (black {now - blank_since:.0f}s, disk idle); killing QEMU, readback will validate')
|
||||
|
||||
panic = False
|
||||
for line in serial.poll():
|
||||
if 'VMIX' in line:
|
||||
log('serial: ' + line.strip()[:220])
|
||||
if 'VMIX-PE: running run.sh' in line and not started:
|
||||
started = True
|
||||
log('PE started run.sh')
|
||||
if 'VMIX-PE: no VMIX volume' in line and args.mode != 'boot':
|
||||
log('PE did not find the VMIX volume')
|
||||
proc.kill()
|
||||
return 3
|
||||
if line.startswith('Darwin Kernel Version'):
|
||||
log(f'guest kernel boot #{serial.boots}')
|
||||
if any(m in line for m in PANIC_MARKS):
|
||||
panic = True
|
||||
log('serial: ' + line.strip()[:220])
|
||||
if panic:
|
||||
panics += 1
|
||||
try:
|
||||
screen.grab()
|
||||
screen.save('panic')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if args.mode == 'pe' or panics > args.max_resets:
|
||||
log(f'kernel panic #{panics}, giving up')
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return 0
|
||||
return 3
|
||||
log(f'kernel panic #{panics}, system_reset')
|
||||
qmp.system_reset()
|
||||
screen.stable_since = time.time()
|
||||
continue
|
||||
blank_since = None
|
||||
|
||||
top = screen.menubar_text()
|
||||
body = screen.ocr()
|
||||
log(f'settled: menubar={top.strip()!r} body~={" ".join(body.split())[:80]!r}')
|
||||
|
||||
# Boot-hang watchdog: a dark screen (Apple logo / black) frozen for a long
|
||||
# time with no menu bar is a stuck (re)boot — kick it with a system reset.
|
||||
# Never fires on the bright, static Terminal of the prepare phase.
|
||||
if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \
|
||||
and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets:
|
||||
# The guest asked for a reboot but no kernel came back: macOS' restart
|
||||
# path panics in QEMU (AppleSMC watchdog keys, see README); reset now
|
||||
# instead of waiting for the frozen-screen watchdog.
|
||||
if serial.reboot_at and now - serial.reboot_at > args.reboot_timeout and args.mode != 'pe':
|
||||
resets += 1
|
||||
screen.save('stall-reset')
|
||||
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, disk idle), system_reset #{resets}')
|
||||
try:
|
||||
qmp.system_reset()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'system_reset failed: {e}')
|
||||
screen.stable_since = time.time()
|
||||
last_progress = now
|
||||
continue
|
||||
|
||||
# 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
|
||||
|
||||
# After the install, the loginwindow/desktop is a BRIGHT gray screen, unlike
|
||||
# the dark install/boot screens (Apple logo). The vmix agent powers the VM
|
||||
# off if it runs (cron/daemon); if BTM blocks it, we power down here so the
|
||||
# build still completes with a bootable, installed image. Brightness is a
|
||||
# far more reliable signal than OCR of the faint "password" text.
|
||||
bright = screen.mean() > 80
|
||||
loginish = (typed_at is not None and bright and 'terminal' not in top
|
||||
and 'utilities' not in top and not any(k in body for k in PICKER_BODY))
|
||||
if loginish:
|
||||
if login_since is None:
|
||||
login_since = now
|
||||
log('bright post-install screen (loginwindow/desktop) — OS installed; grace before powerdown')
|
||||
elif now - login_since > args.login_grace:
|
||||
screen.save('loginwindow')
|
||||
log(f'loginwindow persisted {now - login_since:.0f}s, powering down (install complete)')
|
||||
try:
|
||||
qmp.system_powerdown()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'powerdown failed: {e}')
|
||||
for _ in range(90):
|
||||
if proc.poll() is not None:
|
||||
return 0
|
||||
time.sleep(1)
|
||||
proc.kill()
|
||||
return 0
|
||||
continue
|
||||
else:
|
||||
login_since = None
|
||||
|
||||
# once the bootstrap command is typed, only the picker (above) and an
|
||||
# unexpected return to Recovery matter (post-prepare reboot landed on the
|
||||
# recovery instead of the installer — restart the install then).
|
||||
if typed_at is not None:
|
||||
if ('utilities' in top or 'recovery' in top):
|
||||
if recovery_start is None:
|
||||
recovery_start = now
|
||||
if now - typed_at > 120 and now - recovery_start > 45:
|
||||
log('unexpectedly back at Recovery after install started — restarting install')
|
||||
typed_at = None
|
||||
terminal_attempts = 0
|
||||
recovery_start = None
|
||||
# fall through to the recovery/terminal handling below
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
recovery_start = 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')
|
||||
last_term_action = now
|
||||
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):
|
||||
"""Wait for the VM to power itself off (customize/generalize/first-boot),
|
||||
handling the OpenCore picker and kicking a hung boot with a system reset."""
|
||||
PICKER_BODY = ('base system', 'macos installer', 'macintosh hd', 'rel-1', 'rel-0')
|
||||
screen = Screen(qmp, args.debug_dir, log)
|
||||
start = time.time()
|
||||
last_periodic = 0
|
||||
resets = 0
|
||||
blank_since = None
|
||||
login_since = None
|
||||
while True:
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
return rc
|
||||
if time.time() - start > args.timeout:
|
||||
try:
|
||||
screen.grab(); screen.save('timeout')
|
||||
screen.grab()
|
||||
screen.save('reboot-dead')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
log('timeout reached, killing QEMU')
|
||||
log(f'guest requested a reboot {now - serial.reboot_at:.0f}s ago and died, system_reset #{resets}')
|
||||
serial.reboot_at = None
|
||||
if resets > args.max_resets:
|
||||
proc.kill()
|
||||
return 3
|
||||
qmp.system_reset()
|
||||
screen.stable_since = time.time()
|
||||
continue
|
||||
|
||||
if not started and now - start > args.start_timeout:
|
||||
try:
|
||||
screen.grab()
|
||||
screen.save('no-start')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
log(f'PE did not start run.sh within {args.start_timeout:.0f}s')
|
||||
proc.kill()
|
||||
return 124
|
||||
time.sleep(args.interval)
|
||||
return 3
|
||||
|
||||
try:
|
||||
screen.grab()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'screendump failed ({e})')
|
||||
time.sleep(2)
|
||||
continue
|
||||
now = time.time()
|
||||
if now - last_periodic > args.periodic:
|
||||
last_periodic = now
|
||||
screen.save('periodic')
|
||||
if now - start < args.min_boot or screen.stable_for() < args.settle:
|
||||
|
||||
if args.mode == 'pe':
|
||||
continue # the PE powers off by itself; nothing to decide
|
||||
# install: the PE phase (kernel boot #1) is protected by the guest's own
|
||||
# retries; the checks below apply once the installer has rebooted.
|
||||
in_os = args.mode == 'boot' or serial.boots >= 2
|
||||
if not in_os or screen.stable_for() < args.settle:
|
||||
continue
|
||||
idle = disk_idle(args) and serial.idle_for() > args.disk_idle
|
||||
|
||||
if screen.is_blank():
|
||||
if blank_since is None:
|
||||
blank_since = now
|
||||
elif now - blank_since > args.halt_timeout and disk_idle(args):
|
||||
blank_since = blank_since or now
|
||||
# macOS `shutdown -h` halts to a black screen without an ACPI power-off
|
||||
if now - blank_since > args.halt_timeout and idle:
|
||||
screen.save('halt')
|
||||
log(f'guest halted (black {now - blank_since:.0f}s, disk idle); killing QEMU')
|
||||
log(f'guest halted (black {now - blank_since:.0f}s, idle); killing QEMU')
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return 0
|
||||
continue
|
||||
blank_since = None
|
||||
top = screen.menubar_text()
|
||||
body = screen.ocr()
|
||||
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')
|
||||
qmp.send_key('ret')
|
||||
screen.stable_since = time.time()
|
||||
login_since = None
|
||||
continue
|
||||
# bright post-boot screen (loginwindow/desktop) => booted; power down if the
|
||||
# agent did not (so customize/generalize completes even if BTM blocks it)
|
||||
if screen.mean() > 80 and 'terminal' not in top and 'utilities' not in top:
|
||||
|
||||
if screen.mean() > args.bright:
|
||||
# loginwindow / Setup Assistant: the OS is installed and booted
|
||||
if login_since is None:
|
||||
login_since = now
|
||||
log('bright screen (loginwindow/desktop) after boot; grace before powerdown')
|
||||
log(f'bright screen (mean {screen.mean():.0f}): loginwindow/desktop, grace {args.login_grace:.0f}s')
|
||||
elif now - login_since > args.login_grace:
|
||||
screen.save('loginwindow')
|
||||
log(f'loginwindow persisted {now - login_since:.0f}s, powering down')
|
||||
log('powering down (boot complete)')
|
||||
try:
|
||||
qmp.system_powerdown()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'powerdown failed: {e}')
|
||||
for _ in range(90):
|
||||
for _ in range(120):
|
||||
if proc.poll() is not None:
|
||||
log('QEMU exited after powerdown')
|
||||
return 0
|
||||
time.sleep(1)
|
||||
log('guest ignored powerdown, killing QEMU')
|
||||
proc.kill()
|
||||
return 0
|
||||
continue
|
||||
else:
|
||||
login_since = None
|
||||
if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \
|
||||
and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets:
|
||||
login_since = None
|
||||
|
||||
# dark, frozen, nothing happening: a boot hang (seen at the Apple logo)
|
||||
if screen.stable_for() > args.stall_reset and idle and resets < args.max_resets:
|
||||
resets += 1
|
||||
screen.save('stall-reset')
|
||||
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, disk idle), system_reset #{resets}')
|
||||
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, idle), system_reset #{resets}')
|
||||
try:
|
||||
qmp.system_reset()
|
||||
except Exception as e: # noqa: BLE001
|
||||
|
|
@ -562,54 +370,60 @@ def run_boot(args, proc, qmp, log):
|
|||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument('--mode', choices=['install', 'boot'], required=True)
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--mode', choices=['pe', 'install', 'boot'], required=True)
|
||||
p.add_argument('--name', default='macos')
|
||||
p.add_argument('--debug-dir', default=None)
|
||||
p.add_argument('--serial-log', default=None, help='file QEMU writes the serial console to')
|
||||
p.add_argument('--timeout', type=int, default=4 * 3600, help='seconds before QEMU is killed')
|
||||
p.add_argument('--start-timeout', type=float, default=600.0, help='seconds for the PE to start run.sh')
|
||||
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('--stall-reset', type=float, default=360.0, help='reset the VM if a non-Terminal screen is frozen this long (boot hang)')
|
||||
p.add_argument('--stall-reset', type=float, default=360.0, help='reset the VM if a dark screen is frozen this long while disk and serial are idle')
|
||||
p.add_argument('--max-resets', type=int, default=6)
|
||||
p.add_argument('--halt-timeout', type=float, default=150.0, help='after the bootstrap, a pure-black screen this long means the guest halted (macOS shutdown does not ACPI-power-off QEMU)')
|
||||
p.add_argument('--progress-file', default=None, help='a file (the system disk) whose mtime shows guest activity; resets/halt only fire when it is also idle, so a slow-but-working boot is never interrupted')
|
||||
p.add_argument('--disk-idle', type=float, default=90.0, help='seconds of no writes to --progress-file that count as idle')
|
||||
p.add_argument('--login-grace', type=float, default=240.0, help='seconds to wait at the loginwindow for the agent to power off before the driver powers down itself')
|
||||
p.add_argument('--command', default='diskutil mount VMIX;sh /Volumes/VMIX/run.sh')
|
||||
p.add_argument('--reboot-timeout', type=float, default=60.0, help='seconds after a guest reboot request without a new kernel boot before the VM is reset')
|
||||
p.add_argument('--halt-timeout', type=float, default=150.0, help='a pure-black, idle screen this long means the guest halted')
|
||||
p.add_argument('--progress-file', default=None, help='the system disk; its mtime shows guest disk activity')
|
||||
p.add_argument('--disk-idle', type=float, default=90.0, help='seconds without disk/serial activity that count as idle')
|
||||
p.add_argument('--bright', type=float, default=80.0, help='mean brightness above which a screen is the loginwindow/desktop')
|
||||
p.add_argument('--login-grace', type=float, default=180.0, help='seconds a bright screen must persist before powering down')
|
||||
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
|
||||
qemu_args = [a for a in args.qemu if a != '--']
|
||||
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
|
||||
debug_dir = args.debug_dir or f'/tmp/vmix-macos/{args.name}'
|
||||
prepare_debug_dir(debug_dir)
|
||||
log = Log(os.path.join(debug_dir, 'driver.log'))
|
||||
log(f'mode={args.mode} debug-dir={debug_dir} serial={args.serial_log}')
|
||||
|
||||
qmp_sock = os.path.join(debug_dir, 'qmp.sock')
|
||||
proc = launch(qemu_args, qmp_sock, log)
|
||||
qmp = QMP(qmp_sock)
|
||||
try:
|
||||
if args.mode == 'install':
|
||||
rc = run_install(args, proc, qmp, log)
|
||||
else:
|
||||
rc = run_boot(args, proc, qmp, log)
|
||||
qmp.connect()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'QMP connect failed: {e}')
|
||||
proc.kill()
|
||||
return 2
|
||||
screen = Screen(qmp, debug_dir, log)
|
||||
serial = Serial(args.serial_log)
|
||||
try:
|
||||
rc = drive(args, proc, qmp, screen, serial, log)
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
log(f'QEMU exited with {rc}')
|
||||
if args.serial_log and os.path.exists(args.serial_log):
|
||||
try:
|
||||
shutil.copy(args.serial_log, os.path.join(debug_dir, 'serial.log'))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
log(f'done rc={rc}')
|
||||
return rc
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +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.
|
||||
# Shell snippet: read the PE'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, empty if run.sh never returned, e.g. the
|
||||
# installer rebooted). The PE unmounts VMIX before powering off.
|
||||
{ ... }:
|
||||
image:
|
||||
''
|
||||
echo "=== vmix: reading result from ${image} ==="
|
||||
for f in install.log system-install.log vmix-run.log vmix-agent.log; do
|
||||
for f in vmix-run.log system-install.log; do
|
||||
C=$(guestfish --ro -a ${image} -m /dev/sda1 cat /$f 2>/dev/null || true)
|
||||
[ -n "$C" ] && { echo "--- $f ---"; printf '%s\n' "$C"; }
|
||||
[ -n "$C" ] && { echo "--- $f ---"; printf '%s\n' "$C" | tail -400; }
|
||||
done
|
||||
STATUS=$(guestfish --ro -a ${image} -m /dev/sda1 cat /vmix-run.status 2>/dev/null | tr -d '[:space:]' || true)
|
||||
''
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
# Pre-built macOS Tahoe (26) images
|
||||
# Pipeline: makeImage (unattended install, vmix agent) → templates → generalize
|
||||
# Pipeline: makeRecoveryPE (Recovery + vmix hook) → makeImage (unattended, offline
|
||||
# install) → templates (applied offline from the PE) → generalize
|
||||
{ pkgs, lib, system, macos, installer, recovery, ... }:
|
||||
with macos;
|
||||
rec {
|
||||
pe = makeRecoveryPE { name = "macos-tahoe"; inherit recovery; };
|
||||
|
||||
upstream = makeImage {
|
||||
name = "macos-tahoe";
|
||||
inherit installer recovery;
|
||||
inherit installer pe;
|
||||
};
|
||||
|
||||
basic = customizeImageFold upstream templates.bundles.basic;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ rec {
|
|||
essentials = {
|
||||
remoteAccess = import ./essentials/remote-access.nix { };
|
||||
noUpdates = import ./essentials/no-updates.nix { };
|
||||
performance = import ./essentials/performance.nix { };
|
||||
performance = import ./essentials/performance.nix { inherit pkgs; };
|
||||
};
|
||||
|
||||
bundles = {
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@
|
|||
{
|
||||
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
|
||||
SU="$DATA/Library/Preferences/com.apple.SoftwareUpdate.plist"
|
||||
for k in AutomaticCheckEnabled AutomaticDownload AutomaticallyInstallMacOSUpdates ConfigDataInstall CriticalUpdateInstall; do
|
||||
pe_plist_set "$SU" "$k" bool false
|
||||
done
|
||||
pe_plist_set "$DATA/Library/Preferences/com.apple.commerce.plist" AutoUpdate bool false
|
||||
'';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,32 @@
|
|||
# Less background work in a VM: no Spotlight indexing, no Time Machine, no sleep
|
||||
{ ... }:
|
||||
# Less background work in a VM: no Spotlight indexing, no Time Machine, no
|
||||
# sleep, no immediate screen lock.
|
||||
{ pkgs, ... }:
|
||||
let
|
||||
power = pkgs.writeText "com.apple.PowerManagement.plist" ''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ActivePowerProfiles</key><dict><key>AC Power</key><integer>-1</integer></dict>
|
||||
<key>Custom Profile</key><dict><key>AC Power</key><dict>
|
||||
<key>Display Sleep Timer</key><integer>0</integer>
|
||||
<key>System Sleep Timer</key><integer>0</integer>
|
||||
<key>Disk Sleep Timer</key><integer>0</integer>
|
||||
<key>Wake On LAN</key><integer>0</integer>
|
||||
<key>hibernatemode</key><integer>0</integer>
|
||||
</dict></dict>
|
||||
</dict>
|
||||
</plist>
|
||||
'';
|
||||
in
|
||||
{
|
||||
name = "performance";
|
||||
files = [ { source = power; name = "com.apple.PowerManagement.plist"; } ];
|
||||
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
|
||||
touch "$DATA/.metadata_never_index"
|
||||
pe_plist_set "$DATA/Library/Preferences/com.apple.TimeMachine.plist" AutoBackup bool false
|
||||
pe_plist_set "$DATA/Library/Preferences/com.apple.loginwindow.plist" DisableScreenLockImmediate bool true
|
||||
cp "$V/com.apple.PowerManagement.plist" "$DATA/Library/Preferences/com.apple.PowerManagement.plist"
|
||||
chown 0:0 "$DATA/Library/Preferences/com.apple.PowerManagement.plist"
|
||||
'';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
# Enable SSH (Remote Login) and Screen Sharing (VNC on 5900 inside the guest)
|
||||
# by clearing their launchd overrides on the image's Data volume.
|
||||
{ ... }:
|
||||
{
|
||||
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
|
||||
pe_service_disabled com.apple.openssh.sshd false
|
||||
pe_service_disabled com.apple.screensharing false
|
||||
'';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# 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.
|
||||
# Generalize a macOS image, offline from the PE: create the (admin) user on the
|
||||
# image's Data volume with dscl, auto-login, suppress the first-login Setup
|
||||
# Assistant, hostname, locale, timezone, use the whole disk, and give 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, ... }:
|
||||
|
|
@ -34,7 +35,8 @@ let
|
|||
"DidSeeCloudSetup" "DidSeeSiriSetup" "DidSeePrivacy" "DidSeeTouchIDSetup" "DidSeeAppearanceSetup"
|
||||
"DidSeeScreenTime" "DidSeeAccessibility" "DidSeeTrueTonePrivacy" "DidSeeActivationLock"
|
||||
"DidSeeiCloudLoginForStorageServices" "DidSeeSyncSetup" "DidSeeSyncSetup2" "DidSeeAppleIDSyncSetup"
|
||||
"DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "SkipFirstLoginOptimization"
|
||||
"DidSeeApplePaySetup" "DidSeeIntelligence" "DidSeeLockdownMode" "DidSeeAppStore" "DidSeeUpdateMacAutomatically"
|
||||
"DidSeeSoftwareUpdate" "SkipFirstLoginOptimization"
|
||||
];
|
||||
in
|
||||
{
|
||||
|
|
@ -44,60 +46,81 @@ in
|
|||
script = ''
|
||||
set -x
|
||||
${lib.optionalString (bgColor != null) ''echo "vmix: bgColor is not supported on macOS, ignoring"''}
|
||||
VER=$(pe_target_version); BUILD=$(pe_target_build)
|
||||
echo "vmix: target macOS $VER ($BUILD)"
|
||||
N="$DATA/private/var/db/dslocal/nodes/Default"
|
||||
D() { dscl -f "$N" localhost "$@"; }
|
||||
|
||||
${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}'"
|
||||
''}
|
||||
# --- user account (admin), created directly in the local directory node
|
||||
U="${username}"; HOME_DIR="$DATA/Users/$U"
|
||||
if ! D -read "/Local/Default/Users/$U" >/dev/null 2>&1; then
|
||||
UID_NEW=$(D -list /Local/Default/Users UniqueID | awk '$2 >= 501 && $2 < 1000 && $2 > m {m = $2} END {print (m ? m + 1 : 501)}')
|
||||
D -create "/Local/Default/Users/$U" || pe_fail "dscl create user"
|
||||
D -create "/Local/Default/Users/$U" UserShell /bin/zsh
|
||||
D -create "/Local/Default/Users/$U" RealName ${lib.escapeShellArg fullName}
|
||||
D -create "/Local/Default/Users/$U" UniqueID "$UID_NEW"
|
||||
D -create "/Local/Default/Users/$U" PrimaryGroupID 20
|
||||
D -create "/Local/Default/Users/$U" NFSHomeDirectory "/Users/$U"
|
||||
if ! D -passwd "/Local/Default/Users/$U" ${lib.escapeShellArg password}; then
|
||||
echo "vmix: WARNING: could not set the requested password, using '${tempPassword}'"
|
||||
D -passwd "/Local/Default/Users/$U" "${tempPassword}" || pe_fail "dscl passwd"
|
||||
fi
|
||||
for g in admin _appserverusr _appserveradm _lpadmin; do
|
||||
D -append "/Local/Default/Groups/$g" GroupMembership "$U" 2>/dev/null || true
|
||||
done
|
||||
mkdir -p "$HOME_DIR"
|
||||
T="$SYS/System/Library/User Template/Non_localized"; [ -d "$T" ] || T="/System/Library/User Template/Non_localized"
|
||||
ditto "$T" "$HOME_DIR" 2>/dev/null || true
|
||||
L="$SYS/System/Library/User Template/English.lproj"; [ -d "$L" ] && ditto "$L" "$HOME_DIR" 2>/dev/null || true
|
||||
else
|
||||
UID_NEW=$(D -read "/Local/Default/Users/$U" UniqueID | awk '{print $2}')
|
||||
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
|
||||
pe_plist_set "$DATA/Library/Preferences/com.apple.loginwindow.plist" autoLoginUser string "$U"
|
||||
cp "$V/kcpassword" "$DATA/private/etc/kcpassword"
|
||||
chmod 600 "$DATA/private/etc/kcpassword"; chown 0:0 "$DATA/private/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"
|
||||
mkdir -p "$HOME_DIR/Library/Preferences"
|
||||
P="$HOME_DIR/Library/Preferences/com.apple.SetupAssistant.plist"
|
||||
for k in ${lib.concatStringsSep " " setupKeys}; do pe_plist_set "$P" "$k" bool true; done
|
||||
pe_plist_set "$P" GestureMovieSeen string none
|
||||
pe_plist_set "$P" LastSeenCloudProductVersion string "$VER"
|
||||
pe_plist_set "$P" LastSeenBuddyBuildVersion string "$BUILD"
|
||||
pe_plist_set "$P" LastSeenSiriProductVersion string "$VER"
|
||||
pe_plist_set "$P" LastPreLoginTasksPerformedVersion string "$VER"
|
||||
pe_plist_set "$P" LastPreLoginTasksPerformedBuild string "$BUILD"
|
||||
pe_plist_set "$HOME_DIR/Library/Preferences/.GlobalPreferences.plist" AppleLocale string "${macLocale}"
|
||||
chown -R "$UID_NEW:20" "$HOME_DIR"
|
||||
touch "$DATA/private/var/db/.AppleSetupDone"
|
||||
''}
|
||||
${lib.optionalString delayOobeRun ''
|
||||
rm -f "$DATA/private/var/db/.AppleSetupDone"
|
||||
''}
|
||||
|
||||
# --- 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
|
||||
PF="$DATA/Library/Preferences/SystemConfiguration/preferences.plist"
|
||||
mkdir -p "$(dirname "$PF")"
|
||||
pe_plist_dict "$PF" System
|
||||
pe_plist_dict "$PF" System.System
|
||||
pe_plist_dict "$PF" System.Network
|
||||
pe_plist_dict "$PF" System.Network.HostNames
|
||||
pe_plist_set "$PF" System.System.ComputerName string "${hostname}"
|
||||
pe_plist_set "$PF" System.System.HostName string "${hostname}"
|
||||
pe_plist_set "$PF" System.Network.HostNames.LocalHostName string "${hostname}"
|
||||
pe_plist_set "$DATA/Library/Preferences/.GlobalPreferences.plist" AppleLocale string "${macLocale}"
|
||||
ln -sfn "/var/db/timezone/zoneinfo/${timezone}" "$DATA/private/etc/localtime"
|
||||
pe_plist_set "$DATA/Library/Preferences/com.apple.timezone.auto.plist" Active bool false
|
||||
|
||||
# --- never sleep (VM)
|
||||
pmset -a sleep 0 displaysleep 0 disksleep 0 hibernatemode 0 || true
|
||||
# --- QEMU's USB keyboard (vendor 0x0627, product 0x0001) is unknown to macOS,
|
||||
# which would open the Keyboard Setup Assistant at every login: declare it ANSI
|
||||
KT="$DATA/Library/Preferences/com.apple.keyboardtype.plist"
|
||||
pe_plist_dict "$KT" keyboardtype
|
||||
pe_plist_set "$KT" keyboardtype.1-1575-0 integer 40
|
||||
|
||||
# --- use the whole (possibly grown) disk
|
||||
STORE=$(diskutil info / | awk '/APFS Physical Store/ {print $NF}')
|
||||
STORE=$(diskutil info "$SYS_ID" | sed -n 's/.*APFS Physical Store: *//p' | awk '{print $1}')
|
||||
[ -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
|
||||
'';
|
||||
}
|
||||
|
|
|
|||
28
lib/images/macos/tools/soak.sh
Executable file
28
lib/images/macos/tools/soak.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env bash
|
||||
# Repeatability check for a macOS image build: build the same attribute N times
|
||||
# (forcing a rebuild each time), keep every run's driver + serial logs, and print
|
||||
# a table of outcome / duration / which recovery mechanisms fired.
|
||||
# tools/soak.sh <flake-dir> <attr> [runs] [outdir]
|
||||
# e.g. tools/soak.sh /root/vmix.nix macos.images.tahoe.upstream 3
|
||||
set -u
|
||||
FLAKE=${1:?flake dir}; ATTR=${2:?attribute}; RUNS=${3:-3}; OUT=${4:-/tmp/vmix-macos-soak}
|
||||
NAME=$(nix eval --impure --raw --expr "(builtins.getFlake \"path:$FLAKE\").lib.x86_64-linux.$ATTR.name" | sed 's/-vmix\.qcow2$//')
|
||||
DRV=$(nix eval --impure --raw --expr "(builtins.getFlake \"path:$FLAKE\").lib.x86_64-linux.$ATTR.drvPath")
|
||||
mkdir -p "$OUT"
|
||||
printf '%-4s %-8s %-9s %-6s %-7s %-7s %-6s %s\n' run result minutes boots resets panics tries note | tee "$OUT/summary.txt"
|
||||
for i in $(seq 1 "$RUNS"); do
|
||||
D="$OUT/run-$i"; rm -rf "$D"; mkdir -p "$D"
|
||||
rm -rf "/tmp/vmix-macos/$NAME"
|
||||
t0=$(date +%s)
|
||||
if [ "$i" -eq 1 ]; then nix build --no-link -L "$DRV^*" > "$D/build.log" 2>&1; rc=$?
|
||||
else nix build --no-link -L --rebuild "$DRV^*" > "$D/build.log" 2>&1; rc=$?; fi
|
||||
t1=$(date +%s)
|
||||
cp "/tmp/vmix-macos/$NAME"/driver.log "/tmp/vmix-macos/$NAME"/serial.log "$D/" 2>/dev/null
|
||||
cp "/tmp/vmix-macos/$NAME"/*.png "$D/" 2>/dev/null
|
||||
L="$D/driver.log"
|
||||
boots=$(grep -c 'guest kernel boot' "$L" 2>/dev/null); resets=$(grep -c 'system_reset' "$L" 2>/dev/null)
|
||||
panics=$(grep -c 'kernel panic' "$L" 2>/dev/null); tries=$(grep -c 'startosinstall try' "$L" 2>/dev/null)
|
||||
note=$(grep -oE 'prepare too slow[^,]*|PE did not[^,]*|guest halted|powering down|timeout reached' "$L" 2>/dev/null | sort | uniq -c | tr '\n' ';' | tr -s ' ')
|
||||
printf '%-4s %-8s %-9s %-6s %-7s %-7s %-6s %s\n' "$i" "$([ $rc -eq 0 ] && echo OK || echo FAIL)" "$(( (t1 - t0) / 60 ))" "$boots" "$resets" "$panics" "$tries" "$note" | tee -a "$OUT/summary.txt"
|
||||
done
|
||||
echo "logs: $OUT"
|
||||
Loading…
Add table
Add a link
Reference in a new issue