macOS: guest agent, online templates, virtio-fs shares, persistent home volume

Apple's built-in QEMU guest agent (AppleQEMUGuestAgent, launched by launchd
when a virtio console port org.qemu.guest_agent.0 appears; guest-exec as root)
is attached by vmix run --macos and the NixOS module. AppleVirtIO.kext on x86
Tahoe drives virtio-fs, block, console, input, net — verified in QEMU.

- customizeImage: `bootScript` — online step through the guest agent (driver
  mode qga): boot the image, run the script as root with the VMIX volume, shut
  down through the agent. `as_user` runs commands in the logged-in session.
- templates.software: pkg/app (offline in the PE), script/homebrew (online).
- templates.profile.settings: widgets, wallpaper (pinned desktoppr — Apple
  Events need TCC consent that a headless session cannot give), dock apps,
  autohide, dark mode, hidden files.
- generalize: persistHome (fstab LABEL=vmix-home /Users), hideWidgets offline.
- formatVolume: formats a blank disk image as APFS by booting the PE (~35 s);
  idempotent.
- NixOS module: macos.guestAgent (/run/vmix/qga-<name>.sock), shares via
  virtiofsd + vhost-user-fs (Apple automount tag for the first share, others
  mounted through the agent), macos.homeDisk (created + formatted on first
  start, virtio-blk), SPICE keeps -vga vmware for macOS.
- CLI: vmix run --macos --share DIR --home FILE --qga PATH.
- qemu.nix helpers; README section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
This commit is contained in:
Git Sagar 2026-09-09 21:47:41 -03:00
parent 50ad8d521c
commit 0f9373263d
13 changed files with 600 additions and 19 deletions

44
cli.nix
View file

@ -35,6 +35,9 @@ pkgs.writeShellScriptBin "vmix" ''
echo " --ahci Use AHCI storage for vmix run (for laptop images)"
echo " --macos macOS image for vmix run (OpenCore/VirtualSMC flags, AHCI)"
echo " --applesmc with --macos: add QEMU's isa-applesmc (images built before 2026-09-09)"
echo " --share DIR with --macos: virtio-fs share (first: /Volumes/My Shared Files); repeatable"
echo " --home FILE with --macos: persistent home volume (qcow2, created+formatted if missing)"
echo " --qga PATH with --macos: guest agent socket path (default /tmp/vmix-qga-<pid>.sock)"
echo " --vnc DISPLAY VNC instead of SDL for vmix run, e.g. :10 (port 5910) or 0.0.0.0:10"
echo " --mac ADDR NIC MAC for vmix run (macOS: read from the image's ESP by default)"
echo " -y, --yes Skip disk write confirmation"
@ -95,6 +98,10 @@ pkgs.writeShellScriptBin "vmix" ''
RUN_MACOS=false
RUN_VNC=""
RUN_MAC=""
RUN_SHARES=()
RUN_HOME=""
RUN_HOME_IMAGE="macos.images.tahoe.upstream"
RUN_QGA=""
while [[ ''${#} -gt 0 ]]; do
case "$1" in
--mem) RUN_MEM="$2"; shift 2 ;;
@ -102,6 +109,10 @@ pkgs.writeShellScriptBin "vmix" ''
--ahci) RUN_AHCI=true; shift ;;
--macos) RUN_MACOS=true; shift ;;
--applesmc) RUN_APPLESMC=true; shift ;;
--share) RUN_SHARES+=("$2"); shift 2 ;;
--home) RUN_HOME="$2"; shift 2 ;;
--home-image) RUN_HOME_IMAGE="$2"; shift 2 ;;
--qga) RUN_QGA="$2"; shift 2 ;;
--vnc) RUN_VNC="$2"; shift 2 ;;
--mac) RUN_MAC="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
@ -136,8 +147,41 @@ pkgs.writeShellScriptBin "vmix" ''
[[ -z "$RUN_MAC" || "$RUN_MAC" == "null" ]] && { RUN_MAC="52:54:00:c9:18:27"; echo "Warning: could not read MAC from image ESP, using $RUN_MAC"; }
fi
echo "macOS: yes (MAC $RUN_MAC)"
# Apple's built-in QEMU guest agent: guest-exec as root over this socket
[[ -z "$RUN_QGA" ]] && RUN_QGA="/tmp/vmix-qga-$$.sock"
rm -f "$RUN_QGA"
echo "Agent: $RUN_QGA (guest-exec as root)"
# virtio-fs shares: the first one auto-mounts at /Volumes/My Shared Files, the
# others are mounted with: mount -t virtiofs <tag> /Volumes/<tag>
MACOS_SHARE_ARGS=""
MACOS_MEM_ARGS=""
i=0
for SHARE in "''${RUN_SHARES[@]}"; do
i=$((i + 1)); SOCK="/tmp/vmix-vfs-$$-$i.sock"; rm -f "$SOCK"
TAG=$([[ $i -eq 1 ]] && echo "${macosQemu.automountTag}" || echo "share$i")
${pkgs.virtiofsd}/bin/virtiofsd --socket-path="$SOCK" --shared-dir "$SHARE" --cache auto --sandbox none >/dev/null 2>&1 &
for t in $(seq 1 50); do [[ -S "$SOCK" ]] && break; sleep 0.2; done
MACOS_SHARE_ARGS="$MACOS_SHARE_ARGS -chardev socket,id=vfs$i,path=$SOCK -device vhost-user-fs-pci,chardev=vfs$i,tag=$TAG"
MACOS_MEM_ARGS="-object memory-backend-memfd,id=vmix-mem,size=''${RUN_MEM}M,share=on -numa node,memdev=vmix-mem"
echo "Share: $SHARE -> $([[ $i -eq 1 ]] && echo '/Volumes/My Shared Files' || echo "mount -t virtiofs $TAG ...")"
done
# persistent home volume (virtio-blk); created + formatted APFS by the PE if missing
MACOS_HOME_ARGS=""
if [[ -n "$RUN_HOME" ]]; then
if [[ ! -e "$RUN_HOME" ]]; then
echo "Home: creating $RUN_HOME (64G qcow2) and formatting it as APFS 'vmix-home' via the PE of $RUN_HOME_IMAGE ..."
${pkgs.qemu}/bin/qemu-img create -q -f qcow2 "$RUN_HOME" 64G
FMT=$(${pkgs.nix}/bin/nix build --no-link --print-out-paths --impure --expr "let l = (builtins.getFlake \"${self}\").lib.${system}; in l.macos.formatVolume { image = l.$RUN_HOME_IMAGE; }") || { echo "Error: could not build the formatter"; exit 1; }
"$FMT" "$RUN_HOME" qcow2 || exit 1
fi
HOME_FMT=$(${pkgs.qemu}/bin/qemu-img info --output=json "$RUN_HOME" | ${pkgs.jq}/bin/jq -r .format)
MACOS_HOME_ARGS="-drive id=home,if=none,format=$HOME_FMT,file=$RUN_HOME -device virtio-blk-pci,drive=home"
echo "Home: $RUN_HOME (mounted at /Users by images generalized with persistHome)"
fi
echo ""
exec ${pkgs.qemu}/bin/qemu-system-x86_64 \
$MACOS_MEM_ARGS $MACOS_SHARE_ARGS $MACOS_HOME_ARGS \
-device virtio-serial-pci,id=vmix-vser -chardev socket,path="$RUN_QGA",server=on,wait=off,id=vmix-qga -device virtserialport,chardev=vmix-qga,name=org.qemu.guest_agent.0 \
$VMIX_DISPLAY \
${macosQemu.deviceArgs} ${macosQemu.vgaArgs} \
$([[ "$RUN_APPLESMC" == true ]] && echo '-device isa-applesmc,osk="${macosQemu.osk}"') \

View file

@ -122,3 +122,46 @@ makes VirtualSMC step aside ("multiple devices present"); VirtualSMC carries
the OSK itself. Images built before this change still need the stub:
`vmix run --macos --applesmc`. RestrictEvents (`revpatch=memtab`) silences
MacPro7,1's "Memory Modules Misconfigured" at login.
## Guest agent, shares, persistent home, online templates
macOS 13+ ships **Apple's own QEMU guest agent** (`/usr/libexec/AppleQEMUGuestAgent`,
started by launchd when a virtio console port named `org.qemu.guest_agent.0`
appears). It is Apple-signed, needs no approval, and offers `guest-exec` as
root plus `guest-file-*`. vmix uses it everywhere an in-guest agent is needed:
* `vmix run --macos` and the NixOS module attach it by default
(`/tmp/vmix-qga-<pid>.sock`, `/run/vmix/qga-<name>.sock`); talk to it with any
QGA client, e.g. `printf '{"execute":"guest-exec","arguments":{"path":"/usr/bin/id","capture-output":true}}\n' | socat - UNIX-CONNECT:<sock>`.
* **online templates** (`bootScript`): `customizeImage` boots the image with the
agent, runs the script as root (network available, `as_user <cmd>` runs inside
the logged-in user's session), then shuts down through the agent.
`templates.software.script { name; script; }`,
`templates.software.homebrew { formulae; casks; }`,
`templates.profile.settings { hideWidgets; wallpaper; dockApps; dockAutohide;
darkMode; showHiddenFiles; }` (wallpaper via the pinned `desktoppr`; Apple
Events / `osascript` do not work headless — TCC automation consent).
* **offline software templates** run in the PE: `templates.software.pkg { name;
src; }` (`installer -target`), `templates.software.app { name; src; }`.
`AppleVirtIO.kext` (x86 Tahoe) drives virtio-fs, 9p, block, console, input,
net, sound, balloon, vsock — QEMU's modern virtio-pci devices work as-is:
* **shared folders**: virtio-fs (`virtiofsd` + `vhost-user-fs-pci`, shared
memory backend). The tag `com.apple.virtio-fs.automount` is mounted by macOS
itself at `/Volumes/My Shared Files`; further tags are mounted with
`mount -t virtiofs <tag> <dir>` — the module does that through the guest agent
for every `shares.<name>` beyond the first. `vmix run --macos --share DIR`.
(9p does not automount on macOS; the Linux `-virtfs` path is not used.)
* **ephemeral OS disk + persistent home**: `generalize { persistHome = true; }`
adds `LABEL=vmix-home /Users apfs rw 0 2` to the image's fstab. The host
provides a virtio-blk disk (`macos.homeDisk` in the module, `--home FILE` in
the CLI: qcow2/raw file or zvol) that `formatVolume` formats as APFS
`vmix-home` by booting the PE for ~35 s on first use. macOS mounts it at
`/Users` before login, home directories are created there; the OS disk can run
with `snapshot=on` (`disks.os.persist = false`).
* **SPICE**: `-vga vmware` (or `std`) is kept as the display device — macOS has
no QXL/virtio-gpu driver; USB redirection channels work as for other guests
(`spice.usbRedir`); there is no vdagent for macOS (no clipboard sharing).
virtio keyboard/tablet (`AppleVirtIOInput`) are available as
`qemu.virtioInputArgs` but the USB HID pair is the default.

View file

@ -22,6 +22,7 @@ let
inherit pkgs lib qemu ident makeVmixVolume makeOpenCore makeBootDisk installBootloader vmixReadback vmDriver;
};
customizeImageFold = builtins.foldl' customizeImage;
formatVolume = import ./helpers/formatVolume.nix { inherit pkgs lib qemu makeVmixVolume makeBootDisk vmDriver; };
templates = import ./templates { inherit pkgs lib; };
};

View file

@ -7,13 +7,18 @@
# identity (`smbios`).
#
# Templates provide:
# 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
# script — sh script run as root in the PE (pe-lib.sh helpers available)
# bootScript — sh script run as root on the BOOTED image through Apple's QEMU
# guest agent (network, user session available; run after `script`)
# 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
# network — attach a user-mode NIC for bootScript (default true)
{ pkgs, lib, qemu, ident, makeVmixVolume, makeOpenCore, makeBootDisk, installBootloader, vmixReadback, vmDriver, ... }:
originalImage: {
name ? "",
script ? "",
bootScript ? "",
network ? true,
files ? [],
smbios ? null,
diskSize ? "",
@ -23,12 +28,14 @@ originalImage: {
memSize ? 4096,
cpu ? qemu.defaultCpu,
timeout ? 1800,
machineArgs ? null, # override qemu.machineArgs (device experiments)
}:
let
originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name);
customImageName = (if name != "" then name else "custom") + "-${originalImageName}-vmix.qcow2";
resultImg = "./disk.qcow2";
hasScript = script != "";
hasBootScript = bootScript != "";
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";
@ -75,6 +82,22 @@ let
{ source = ../guest/pe-lib.sh; name = "pe-lib.sh"; }
] ++ files;
};
bootRunScript = pkgs.writeText "${name}-boot.sh" ''
#!/bin/bash
# runs as root on the booted system (guest-exec); VMIX is mounted at $V
V=/Volumes/VMIX
echo "=== vmix (online): ${name} ==="
CONSOLE_USER=$(stat -f %Su /dev/console 2>/dev/null)
CONSOLE_UID=$(id -u "$CONSOLE_USER" 2>/dev/null)
export V CONSOLE_USER CONSOLE_UID
# run something inside the logged-in user's GUI session
as_user() { launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" "$@"; }
${bootScript}
'';
bootVol = makeVmixVolume {
name = "${name}-${originalImageName}-boot";
files = [ { source = bootRunScript; name = "run.sh"; } ] ++ files;
};
driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
bootCommands = lib.optionalString hasScript ''
@ -96,7 +119,7 @@ let
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; }} \
${if machineArgs != null then machineArgs else qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.firmwareArgs "vars.fd"} \
${qemu.serialArgs "serial.log"} \
${qemu.sataDrive { id = "opencore"; port = 0; file = "ocboot.qcow2"; }} \
@ -110,6 +133,34 @@ let
echo "=== vmix: ${name} complete ==="
'';
onlineCommands = lib.optionalString hasBootScript ''
cp ${bootVol} vmix-boot.img
chmod +w vmix-boot.img
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars-boot.fd
chmod +w vars-boot.fd
VMIX_DISPLAY="-display none"
${lib.optionalString (vncDisplay != null) ''VMIX_DISPLAY="-display none -vnc ${vncDisplay}"''}
QGA_SOCK=$(mktemp -u /tmp/vmix-qga-XXXXXX.sock)
echo "=== vmix: booting ${originalImageName} for ${name} (guest agent) ==="
python3 ${vmDriver} --mode qga --name "${name}-${originalImageName}-online" --timeout ${toString timeout} \
--serial-log serial-boot.log --qga-sock "$QGA_SOCK" \
--qga-command 'for i in $(seq 1 30); do diskutil mount VMIX >/dev/null 2>&1; [ -f /Volumes/VMIX/run.sh ] && break; sleep 2; done; [ -f /Volumes/VMIX/run.sh ] || { echo "no VMIX volume"; exit 9; }; bash /Volumes/VMIX/run.sh > /Volumes/VMIX/vmix-run.log 2>&1; rc=$?; echo $rc > /Volumes/VMIX/vmix-run.status; sync; cat /Volumes/VMIX/vmix-run.log; diskutil unmount force /Volumes/VMIX >/dev/null 2>&1; exit $rc' -- \
qemu-system-x86_64 $VMIX_DISPLAY \
${if machineArgs != null then machineArgs else qemu.machineArgs { inherit cpu smp memSize; }} \
${qemu.firmwareArgs "vars-boot.fd"} \
${qemu.serialArgs "serial-boot.log"} \
${qemu.guestAgentArgs "$QGA_SOCK"} \
${qemu.sataDrive { id = "system"; port = 0; file = resultImg; }} \
${qemu.sataDrive { id = "vmix"; port = 1; file = "vmix-boot.img"; format = "raw"; }} \
${lib.optionalString network (qemu.netArgs { mac = originalImage.macAddress; })} \
|| { echo "vmix: online step failed during ${name} (see /tmp/vmix-macos/${name}-${originalImageName}-online)"; exit 1; }
rm -f "$QGA_SOCK"
${vmixReadback "vmix-boot.img"}
[ "$STATUS" = "0" ] || { echo "vmix: ${name} bootScript failed (status '$STATUS')"; exit 1; }
echo "=== vmix: ${name} (online) complete ==="
'';
builtImage = pkgs.runCommand customImageName ({
nativeBuildInputs = with pkgs; [ pkgs.qemu driverPython libguestfs-with-appliance ];
requiredSystemFeatures = [ "kvm" ];
@ -117,6 +168,7 @@ let
qemu-img create -q -f qcow2 -b ${originalImage} -F qcow2 ${resultImg}
[ -n "${diskSize}" ] && qemu-img resize ${resultImg} ${diskSize}
${bootCommands}
${onlineCommands}
${lib.optionalString hasSmbios (installBootloader { inherit esp; image = resultImg; })}
mv ${resultImg} $out
'';

View file

@ -0,0 +1,56 @@
# Host-side script that formats a blank disk image as an APFS volume with a
# given label by booting the image's PE headless for ~30 s (Linux cannot write
# APFS). Used for the persistent home volume (`generalize { persistHome = true; }`
# mounts LABEL=<label> at /Users) by the NixOS module and `vmix run --home`.
# ${formatVolume { inherit image; label = "vmix-home"; }} <disk-file> <raw|qcow2>
# The disk is found inside the PE as the only one without a partition table.
{ pkgs, lib, qemu, makeVmixVolume, makeBootDisk, vmDriver, ... }:
{ image, label ? "vmix-home", memSize ? 4096 }:
let
pe = image.pe or (throw "vmix: image ${image.name} carries no PE");
bootDisk = makeBootDisk { name = "${label}-format"; esp = image.opencore; bootArgs = "keepsyms=1 serial=3"; scanPolicy = 66051; };
runScript = pkgs.writeText "format-${label}.sh" ''
. /Volumes/VMIX/pe-lib.sh
LIST=$(diskutil list)
# idempotent: a run.sh re-run (e.g. after a guest reboot) finds the volume done
if echo "$LIST" | grep -q "APFS Volume ${label} "; then echo "vmix: volume '${label}' already exists"; exit 0; fi
# blank disk: a whole-disk line followed by no partition entries
TARGET=$(echo "$LIST" | awk '/^\/dev\/disk[0-9]+ / {d=$1; n=0; next} /^ +[0-9]+:/ {n++} /^$/ {if (d != "" && n <= 1) print d; d=""} END {if (d != "" && n <= 1) print d}' | grep -vE "synthesized" | head -1)
[ -n "$TARGET" ] || { echo "$LIST"; pe_fail "no blank disk found"; }
echo "vmix: formatting $TARGET as APFS '${label}'"
diskutil eraseDisk APFS "${label}" GPT "$TARGET" || pe_fail "eraseDisk $TARGET"
diskutil unmount "/Volumes/${label}" >/dev/null 2>&1 || true
'';
vmixVol = makeVmixVolume {
name = "format-${label}";
files = [ { source = runScript; name = "run.sh"; } { source = ../guest/pe-lib.sh; name = "pe-lib.sh"; } ];
};
driverPython = pkgs.python3.withPackages (p: [ p.pillow ]);
in
pkgs.writeShellScript "vmix-format-${label}" ''
set -eu
DISK="$1"; FMT="''${2:-qcow2}"
T=$(mktemp -d /tmp/vmix-format-XXXXXX)
cleanup() { if [ "''${OK:-0}" = 1 ]; then rm -rf "$T"; else echo "vmix: logs kept in $T"; fi; }
trap cleanup EXIT
cp ${vmixVol} "$T/vmix.img"; chmod +w "$T/vmix.img"
${pkgs.qemu}/bin/qemu-img create -q -f qcow2 -F raw -b ${pe} "$T/pe.qcow2"
${pkgs.qemu}/bin/qemu-img create -q -f qcow2 -F raw -b ${bootDisk}/boot.img "$T/ocboot.qcow2"
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd "$T/vars.fd"; chmod +w "$T/vars.fd"
echo "vmix: formatting $DISK as APFS '${label}' (PE boot)"
${driverPython}/bin/python3 ${vmDriver} --mode pe --name "format-${label}" --debug-dir "$T/debug" --timeout 600 \
--serial-log "$T/serial.log" -- \
${pkgs.qemu}/bin/qemu-system-x86_64 -display none \
${qemu.machineArgs { smp = 2; inherit memSize; }} \
${qemu.firmwareArgs "$T/vars.fd"} \
${qemu.serialArgs "$T/serial.log"} \
${qemu.sataDrive { id = "opencore"; port = 0; file = "$T/ocboot.qcow2"; }} \
${qemu.sataDrive { id = "pe"; port = 1; file = "$T/pe.qcow2"; }} \
${qemu.sataDrive { id = "vmix"; port = 2; file = "$T/vmix.img"; format = "raw"; }} \
${qemu.virtioBlkArgs { id = "target"; file = "$DISK"; format = "$FMT"; }} \
>/dev/null
STATUS=$(${pkgs.libguestfs-with-appliance}/bin/guestfish --ro -a "$T/vmix.img" -m /dev/sda1 cat /vmix-run.status 2>/dev/null | tr -d '[:space:]' || true)
[ "$STATUS" = "0" ] || { echo "vmix: formatting failed (status '$STATUS')"; ${pkgs.libguestfs-with-appliance}/bin/guestfish --ro -a "$T/vmix.img" -m /dev/sda1 cat /vmix-run.log 2>/dev/null | tail -20; exit 1; }
OK=1
echo "vmix: $DISK formatted"
''

View file

@ -39,6 +39,27 @@ rec {
# XNU logs to COM1 with boot-args serial=3; the build drivers read this file
serialArgs = file: "-serial file:${file}";
# Apple's own QEMU guest agent (/usr/libexec/AppleQEMUGuestAgent, macOS 13+)
# attaches to a virtio console port named org.qemu.guest_agent.0 and offers
# guest-exec (as root), guest-file-* etc. over this unix socket.
guestAgentArgs = sock:
"-device virtio-serial-pci,id=vmix-vser -chardev socket,path=${sock},server=on,wait=off,id=vmix-qga -device virtserialport,chardev=vmix-qga,name=org.qemu.guest_agent.0";
# virtio-fs (vhost-user, virtiofsd on the host). macOS auto-mounts the tag
# "com.apple.virtio-fs.automount" at /Volumes/My Shared Files; other tags are
# mounted with `mount -t virtiofs <tag> <dir>`. Needs a shared memory backend.
automountTag = "com.apple.virtio-fs.automount";
memBackendArgs = memSize: "-object memory-backend-memfd,id=vmix-mem,size=${toString memSize}M,share=on -numa node,memdev=vmix-mem";
virtioFsArgs = { tag, sock, id ? tag }:
"-chardev socket,id=vmix-vfs-${id},path=${sock} -device vhost-user-fs-pci,chardev=vmix-vfs-${id},tag=${tag}";
# virtio-blk data disk (AppleVirtIOBlock), e.g. the persistent home volume
virtioBlkArgs = { id, file, format ? "qcow2", extra ? "" }:
"-drive id=${id},if=none,format=${format},file=${file}${extra} -device virtio-blk-pci,drive=${id}";
# virtio keyboard/tablet (AppleVirtIOInput), optional alternative to the USB HID pair
virtioInputArgs = "-device virtio-keyboard-pci -device virtio-tablet-pci";
firmwareArgs = varsFile:
"-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd -drive if=pflash,format=raw,file=${varsFile}";
}

View file

@ -9,6 +9,9 @@ Modes
(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.
qga boot an installed image with Apple's QEMU guest agent attached
(--qga-sock), wait for it, run --qga-command as root through it
(guest-exec), then shut the guest down. Used for online templates.
Observation is passive: the serial console (boot-args serial=3: the PE's
"VMIX-*" markers, kernel boots, panics) and screenshots over QMP (mean
@ -101,6 +104,44 @@ class QMP:
self.cmd('system_powerdown')
class QGA:
"""Minimal QEMU guest agent client over the unix socket QEMU serves."""
def __init__(self, path):
self.path = path
def cmd(self, name, timeout=30, **args):
s = socket.socket(socket.AF_UNIX)
s.settimeout(timeout)
try:
s.connect(self.path)
s.sendall((json.dumps({'execute': name, 'arguments': args}) + '\n').encode())
buf = b''
while b'\n' not in buf:
d = s.recv(65536)
if not d:
raise OSError('guest agent closed the connection')
buf += d
finally:
s.close()
r = json.loads(buf.split(b'\n', 1)[0])
if 'error' in r:
raise RuntimeError(r['error'])
return r.get('return')
def ping(self):
try:
return self.cmd('guest-ping', timeout=5) == {}
except Exception: # noqa: BLE001
return False
def exec_start(self, command):
return self.cmd('guest-exec', path='/bin/bash', arg=['-c', command], **{'capture-output': True})['pid']
def exec_status(self, pid):
return self.cmd('guest-exec-status', pid=pid)
class Screen:
"""Screenshots over QMP with a coarse change fingerprint (cursor-insensitive)."""
@ -227,6 +268,104 @@ def disk_idle(args):
return True
def run_qga(args, proc, qmp, screen, serial, log):
"""Online template: wait for the guest agent, run the command, shut down."""
import base64
qga = QGA(args.qga_sock)
start = time.time()
last_periodic = 0
while not qga.ping():
rc = proc.poll()
if rc is not None:
log(f'QEMU exited with {rc} before the guest agent came up')
return rc or 3
now = time.time()
if now - start > args.start_timeout:
try:
screen.grab()
screen.save('no-agent')
except Exception: # noqa: BLE001
pass
log(f'guest agent not reachable within {args.start_timeout:.0f}s')
proc.kill()
return 3
for line in serial.poll():
if any(m in line for m in PANIC_MARKS):
log('serial: ' + line.strip()[:200])
if now - last_periodic > args.periodic:
last_periodic = now
try:
screen.grab()
screen.save('periodic')
except Exception: # noqa: BLE001
pass
time.sleep(3)
log(f'guest agent up after {time.time() - start:.0f}s: {qga.cmd("guest-info").get("version")}')
time.sleep(args.qga_settle) # let the login session / volumes settle
try:
pid = qga.exec_start(args.qga_command)
except Exception as e: # noqa: BLE001
log(f'guest-exec failed: {e}')
proc.kill()
return 3
log(f'running command as root (pid {pid}): {args.qga_command[:160]}')
t0 = time.time()
while True:
rc = proc.poll()
if rc is not None:
log(f'QEMU exited with {rc} while the command was running')
return rc or 3
if time.time() - start > args.timeout:
log('timeout reached, killing QEMU')
proc.kill()
return 124
try:
st = qga.exec_status(pid)
except Exception as e: # noqa: BLE001
log(f'guest-exec-status failed: {e}')
time.sleep(5)
continue
if st.get('exited'):
break
now = time.time()
if now - last_periodic > args.periodic:
last_periodic = now
try:
screen.grab()
screen.save('periodic')
except Exception: # noqa: BLE001
pass
time.sleep(3)
out = base64.b64decode(st.get('out-data', '')).decode('utf-8', 'replace')
err = base64.b64decode(st.get('err-data', '')).decode('utf-8', 'replace')
code = st.get('exitcode', st.get('signal'))
log(f'command finished in {time.time() - t0:.0f}s, exit {code}')
for line in (out + err).splitlines()[-200:]:
log('guest: ' + line[:220])
try:
screen.grab()
screen.save('after-command')
except Exception: # noqa: BLE001
pass
log('shutting the guest down')
try:
qga.exec_start('sync; /sbin/shutdown -h now')
except Exception as e: # noqa: BLE001
log(f'guest shutdown failed ({e}), ACPI powerdown')
try:
qmp.system_powerdown()
except Exception: # noqa: BLE001
pass
for _ in range(180):
if proc.poll() is not None:
log('QEMU exited')
return 0 if code == 0 else 4
time.sleep(1)
log('guest did not power off, killing QEMU')
proc.kill()
return 0 if code == 0 else 4
def drive(args, proc, qmp, screen, serial, log):
start = time.time()
last_periodic = 0
@ -275,11 +414,12 @@ def drive(args, proc, qmp, screen, serial, log):
screen.save('panic')
except Exception: # noqa: BLE001
pass
if args.mode == 'pe' or panics > args.max_resets:
if panics > args.max_resets:
log(f'kernel panic #{panics}, giving up')
proc.kill()
return 3
# XNU reboots by itself after a panic; only reset if no kernel comes back
# XNU reboots by itself after a panic; only reset if no kernel comes back.
# (In pe mode the PE then runs run.sh again — templates are idempotent.)
log(f'kernel panic #{panics}, waiting for the guest to reboot')
serial.reboot_at = now
continue
@ -287,7 +427,7 @@ def drive(args, proc, qmp, screen, serial, log):
# 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':
if serial.reboot_at and now - serial.reboot_at > args.reboot_timeout:
resets += 1
try:
screen.grab()
@ -382,7 +522,10 @@ def drive(args, proc, qmp, screen, serial, log):
def main():
p = argparse.ArgumentParser()
p.add_argument('--mode', choices=['pe', 'install', 'boot'], required=True)
p.add_argument('--mode', choices=['pe', 'install', 'boot', 'qga'], required=True)
p.add_argument('--qga-sock', default=None, help='guest agent unix socket (mode qga)')
p.add_argument('--qga-command', default=None, help='bash command to run as root through the guest agent (mode qga)')
p.add_argument('--qga-settle', type=float, default=20.0, help='seconds to wait after the agent answers before running the command')
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')
@ -422,7 +565,10 @@ def main():
screen = Screen(qmp, debug_dir, log)
serial = Serial(args.serial_log)
try:
rc = drive(args, proc, qmp, screen, serial, log)
if args.mode == 'qga':
rc = run_qga(args, proc, qmp, screen, serial, log)
else:
rc = drive(args, proc, qmp, screen, serial, log)
finally:
if args.serial_log and os.path.exists(args.serial_log):
try:

View file

@ -2,6 +2,9 @@
rec {
generalize = import ./generalize.nix { inherit pkgs lib; };
software = import ./software { inherit pkgs lib; };
profile = import ./profile { inherit pkgs lib; };
essentials = {
remoteAccess = import ./essentials/remote-access.nix { };
noUpdates = import ./essentials/no-updates.nix { };

View file

@ -22,6 +22,13 @@
uuid ? null,
mac ? null,
seed ? "${hostname}-${username}",
# mount an APFS volume labelled vmix-home (a virtio-blk/AHCI disk the host
# provides, formatted by the PE on first start) at /Users: ephemeral OS disk,
# persistent home directories
persistHome ? false,
homeVolumeLabel ? "vmix-home",
# no desktop widgets for the created user (Sonoma+)
hideWidgets ? true,
# accepted for CLI parity with Windows, not supported on macOS
bgColor ? null,
}:
@ -92,6 +99,11 @@ in
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}"
${lib.optionalString hideWidgets ''
WM="$HOME_DIR/Library/Preferences/com.apple.WindowManager.plist"
pe_plist_set "$WM" StandardHideWidgets integer 1
pe_plist_set "$WM" StageManagerHideWidgets integer 1
''}
chown -R "$UID_NEW:20" "$HOME_DIR"
touch "$DATA/private/var/db/.AppleSetupDone"
''}
@ -119,6 +131,14 @@ in
pe_plist_dict "$KT" keyboardtype
pe_plist_set "$KT" keyboardtype.1-1575-0 integer 40
${lib.optionalString persistHome ''
# --- home directories on the host-provided persistent volume (fstab by label;
# diskarbitrationd mounts it at /Users when a volume named ${homeVolumeLabel} exists)
F="$DATA/private/etc/fstab"
grep -q "LABEL=${homeVolumeLabel}" "$F" 2>/dev/null || echo "LABEL=${homeVolumeLabel} /Users apfs rw 0 2" >> "$F"
chmod 644 "$F"; chown 0:0 "$F"
''}
# --- use the whole (possibly grown) disk
STORE=$(diskutil info "$SYS_ID" | sed -n 's/.*APFS Physical Store: *//p' | awk '{print $1}')
[ -n "$STORE" ] && diskutil apfs resizeContainer "$STORE" 0 || true

View file

@ -0,0 +1,58 @@
# User profile templates, applied on the booted image inside the logged-in
# user's session through the guest agent (after generalize with autoLogon).
# settings — { hideWidgets, wallpaper, dockApps, dockAutohide, showHiddenFiles }
{ pkgs, lib, ... }:
let
# Apple Events (osascript → System Events) need per-app automation consent that a
# headless session cannot grant; desktoppr sets the wallpaper through NSWorkspace
# inside the user's session instead (scriptingosx/desktoppr, pinned).
desktoppr = pkgs.fetchurl {
url = "https://github.com/scriptingosx/desktoppr/releases/download/v0.5/desktoppr-0.5-218.pkg";
hash = "sha256-HPtn1wI7xrx7HjyMz1yJGhutIodt938/vHfSeHfMe50=";
};
in
rec {
settings = {
hideWidgets ? true, # no desktop widgets (Sonoma+)
wallpaper ? null, # image file (drv/path) set as the desktop picture
dockApps ? null, # list of app paths, e.g. [ "/System/Applications/Utilities/Terminal.app" ]; null = untouched
dockAutohide ? false,
showHiddenFiles ? false,
darkMode ? null, # true/false/null
}: {
name = "profile";
files = lib.optionals (wallpaper != null) [
{ source = wallpaper; name = "wallpaper.${lib.last (lib.splitString "." (baseNameOf (toString wallpaper)))}"; }
{ source = desktoppr; name = "desktoppr.pkg"; }
];
bootScript = ''
[ -n "$CONSOLE_USER" ] || { echo "vmix: profile needs a logged-in user (generalize with autoLogon)"; exit 1; }
H=$(dscl . -read "/Users/$CONSOLE_USER" NFSHomeDirectory | awk '{print $2}')
D() { as_user defaults write "$@"; }
${lib.optionalString hideWidgets ''
D com.apple.WindowManager StandardHideWidgets -int 1
D com.apple.WindowManager StageManagerHideWidgets -int 1
D com.apple.widgets widgetAppearance -int 0
''}
${lib.optionalString (wallpaper != null) ''
W="/Library/Desktop Pictures/vmix-wallpaper.${lib.last (lib.splitString "." (baseNameOf (toString wallpaper)))}"
mkdir -p "/Library/Desktop Pictures"; cp "$V/wallpaper".* "$W"; chmod 644 "$W"
installer -pkg "$V/desktoppr.pkg" -target / >/dev/null || echo "vmix: WARNING: desktoppr install failed"
as_user /usr/local/bin/desktoppr "$W" || echo "vmix: WARNING: could not set the wallpaper"
''}
${lib.optionalString (dockApps != null) ''
D com.apple.dock persistent-apps -array
${lib.concatMapStringsSep "\n" (a: ''
D com.apple.dock persistent-apps -array-add "<dict><key>tile-data</key><dict><key>file-data</key><dict><key>_CFURLString</key><string>${a}</string><key>_CFURLStringType</key><integer>0</integer></dict></dict></dict>"
'') dockApps}
''}
${lib.optionalString dockAutohide ''D com.apple.dock autohide -bool true''}
${lib.optionalString showHiddenFiles ''D com.apple.finder AppleShowAllFiles -bool true''}
${lib.optionalString (darkMode != null) (if darkMode
then ''D -g AppleInterfaceStyle Dark''
else ''as_user defaults delete -g AppleInterfaceStyle 2>/dev/null || true'')}
as_user killall Dock Finder WindowManager 2>/dev/null || true
sleep 3
'';
};
}

View file

@ -0,0 +1,51 @@
# Software installation templates.
# pkg — install a flat/distribution .pkg offline from the PE (`installer -target`)
# app — copy an .app bundle (from a directory or zip) into /Applications offline
# script — run a shell script as root on the booted image (network available)
{ pkgs, lib, ... }:
rec {
pkg = { name, src, choices ? null }: {
name = "pkg-${name}";
files = [ { source = src; name = "${name}.pkg"; } ]
++ lib.optional (choices != null) { source = choices; name = "${name}.choices.xml"; };
script = ''
echo "vmix: installing ${name}.pkg into $SYS"
installer -verboseR -pkg "$V/${name}.pkg" -target "$SYS" \
${lib.optionalString (choices != null) ''-applyChoiceChangesXML "$V/${name}.choices.xml"''} \
|| pe_fail "installer ${name}.pkg"
'';
};
app = { name, src }: {
name = "app-${name}";
files = [ { source = src; name = "${name}.app"; } ];
script = ''
echo "vmix: copying ${name}.app to $DATA/Applications"
mkdir -p "$DATA/Applications"
rm -rf "$DATA/Applications/${name}.app"
ditto "$V/${name}.app" "$DATA/Applications/${name}.app" || pe_fail "ditto ${name}.app"
chown -R 0:80 "$DATA/Applications/${name}.app"
xattr -dr com.apple.quarantine "$DATA/Applications/${name}.app" 2>/dev/null || true
'';
};
script = { name, script, files ? [], network ? true }: {
name = "script-${name}";
inherit files network;
bootScript = script;
};
# Homebrew (needs network; installs for the console user or the given user)
homebrew = { user ? null, formulae ? [], casks ? [] }: {
name = "homebrew";
bootScript = ''
U=${if user == null then "$CONSOLE_USER" else user}
[ -n "$U" ] || { echo "vmix: no user to install Homebrew for"; exit 1; }
launchctl asuser "$(id -u "$U")" sudo -u "$U" env NONINTERACTIVE=1 \
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || exit 1
B=/usr/local/bin/brew
${lib.optionalString (formulae != []) ''launchctl asuser "$(id -u "$U")" sudo -u "$U" $B install ${lib.escapeShellArgs formulae} || exit 1''}
${lib.optionalString (casks != []) ''launchctl asuser "$(id -u "$U")" sudo -u "$U" $B install --cask ${lib.escapeShellArgs casks} || exit 1''}
'';
};
}

View file

@ -107,6 +107,42 @@ let
(unique (map pciDeviceOf vmCfg.pci.passthrough));
# --- macOS: guest agent, virtio-fs shares, persistent home volume
macosQemu = vmixLib.macos.qemu;
qgaSock = "/run/vmix/qga-${vmCfg.name}.sock";
macosGuestAgent = isMacos && vmCfg.macos.guestAgent.enable;
macosShares = if isMacos then vmCfg.shares else {};
macosShareNames = attrNames macosShares;
macosAutomountShare = if macosShares ? automount then "automount"
else if macosShareNames != [] then head macosShareNames else null;
macosShareTag = n: if n == macosAutomountShare then macosQemu.automountTag else n;
macosShareSock = n: "/run/vmix/vfs-${vmCfg.name}-${n}.sock";
macosHome = isMacos && vmCfg.macos.homeDisk.enable;
macosFormatHome = if macosHome then vmixLib.macos.formatVolume {
image = vmCfg.disks.os.file; label = vmCfg.macos.homeDisk.label;
} else null;
# shares beyond the automounted one are mounted through the guest agent once it answers
macosMountSharesScript = pkgs.writeShellScript "${vmCfg.name}-macos-shares-vmix" ''
for i in $(seq 1 120); do
[ -S ${qgaSock} ] && printf '{"execute":"guest-ping"}\n' | ${pkgs.socat}/bin/socat -T5 - UNIX-CONNECT:${qgaSock} 2>/dev/null | grep -q return && break
sleep 5
done
${concatMapStrings (n: optionalString (n != macosAutomountShare) ''
printf '%s\n' '{"execute":"guest-exec","arguments":{"path":"/bin/bash","arg":["-c","mkdir -p ${macosShares.${n}.target}; mount -t virtiofs ${n} ${macosShares.${n}.target}"]}}' \
| ${pkgs.socat}/bin/socat -T10 - UNIX-CONNECT:${qgaSock} >/dev/null 2>&1 || true
'') macosShareNames}
'';
seedHomeDiskScript = pkgs.writeShellScript "${vmCfg.name}-home-disk-vmix" ''
F="${vmCfg.macos.homeDisk.file}"
if [ ! -e "$F" ]; then
echo "Creating persistent home volume $F (${vmCfg.macos.homeDisk.size}, ${vmCfg.macos.homeDisk.format})..."
mkdir -p "$(dirname "$F")"
${pkgs.qemu}/bin/qemu-img create -q -f ${vmCfg.macos.homeDisk.format} "$F" ${vmCfg.macos.homeDisk.size}
chmod 600 "$F"
${macosFormatHome} "$F" ${vmCfg.macos.homeDisk.format}
fi
'';
# Linux VMs: apply customizeImage with 9p fstab and machine-id setup
linuxOsImage = vmixLib.linux.customizeImage vmCfg.disks.os.file {
name = vmCfg.name;
@ -140,7 +176,8 @@ let
fi
'';
persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript;
persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript
++ lib.optional macosHome seedHomeDiskScript;
# QEMU expects single-letter boot codes (e.g. c,d,n), while vmix uses readable names.
bootOrderQemu =
@ -175,6 +212,16 @@ let
);
qemuStartVMScript = pkgs.writeShellScript "${vmCfg.name}-qemu-vmix" ''
${optionalString (isMacos && macosShareNames != []) ''
mkdir -p /run/vmix
${concatMapStrings (n: ''
rm -f ${macosShareSock n}
${pkgs.virtiofsd}/bin/virtiofsd --socket-path=${macosShareSock n} --shared-dir ${toString macosShares.${n}.source} --cache auto --sandbox none &
'') macosShareNames}
for i in $(seq 1 50); do ${concatMapStringsSep " && " (n: "[ -S ${macosShareSock n} ]") macosShareNames} && break; sleep 0.2; done
${optionalString macosGuestAgent "${macosMountSharesScript} &"}
''}
${optionalString macosGuestAgent "mkdir -p /run/vmix; rm -f ${qgaSock}"}
${optionalString vmCfg.vnc.enable ''
${optionalString (vmCfg.vnc.passwordFile != null) ''
if [ ! -r ${escapeShellArg vmCfg.vnc.passwordFile} ]; then
@ -208,7 +255,7 @@ let
${optionalString vmCfg.vnc.enable "-vnc ${vncArgs}"} \
${optionalString (vmCfg.spice.enable && vmCfg.spice.passwordFile != null) "-object secret,id=spice-pass-${vmCfg.name},file=${escapeShellArg vmCfg.spice.passwordFile}"} \
${optionalString vmCfg.spice.enable "-spice addr=${vmCfg.spice.addr},port=${toString vmCfg.spice.port}${optionalString (vmCfg.spice.passwordFile == null) ",disable-ticketing=on"}${optionalString (vmCfg.spice.passwordFile != null) ",password-secret=spice-pass-${vmCfg.name}"}"} \
${optionalString vmCfg.spice.enable (if vmCfg.spice.displayDevice == "qxl" && vmCfg.spice.vgamem != null then "-vga none -device qxl-vga,vgamem_mb=${toString vmCfg.spice.vgamem}" else "-vga ${vmCfg.spice.displayDevice}")} \
${optionalString (vmCfg.spice.enable && !isMacos) (if vmCfg.spice.displayDevice == "qxl" && vmCfg.spice.vgamem != null then "-vga none -device qxl-vga,vgamem_mb=${toString vmCfg.spice.vgamem}" else "-vga ${vmCfg.spice.displayDevice}")} \
${optionalString (vmCfg.spice.enable && vmCfg.spice.agent.enable) "-device virtio-serial-pci -chardev spicevmc,id=vdagent,debug=0,name=vdagent -device virtserialport,chardev=vdagent,name=com.redhat.spice.0"} \
${# Guest agent channel — prevents qemu-ga from spinning when virtio-win guest tools are installed
optionalString isWindows "${optionalString (!vmCfg.spice.enable || !vmCfg.spice.agent.enable) "-device virtio-serial-pci"} -chardev socket,path=/tmp/qga-${vmCfg.name}.sock,server=on,wait=off,id=qga0 -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0"} \
@ -228,9 +275,14 @@ let
-device qemu-xhci -device usb-tablet \
-global ICH9-LMB.disable_s3=1 -global ICH9-LMB.disable_s4=1 \
''} \
${# macOS: AppleSMC + OSK, USB keyboard/tablet, AHCI system disk, VMware SVGA (no SPICE display device)
${# macOS: VirtualSMC, USB keyboard/tablet, AHCI system disk, VMware SVGA (also under SPICE),
# Apple's guest agent, virtio-fs shares (shared memory backend), virtio-blk home volume
optionalString isMacos ''
${vmixLib.macos.qemu.deviceArgs} ${optionalString (!vmCfg.spice.enable) vmixLib.macos.qemu.vgaArgs} \
${macosQemu.deviceArgs} ${if vmCfg.spice.enable && vmCfg.spice.displayDevice == "std" then "-vga std" else macosQemu.vgaArgs} \
${optionalString macosGuestAgent (macosQemu.guestAgentArgs qgaSock)} \
${optionalString (macosShareNames != []) (macosQemu.memBackendArgs vmCfg.mem.size)} \
${concatMapStrings (n: "${macosQemu.virtioFsArgs { tag = macosShareTag n; sock = macosShareSock n; id = n; }} \\\n ") macosShareNames} \
${optionalString macosHome (macosQemu.virtioBlkArgs { id = "home"; file = vmCfg.macos.homeDisk.file; format = vmCfg.macos.homeDisk.format; })} \
''} \
${optionalString hasOsDisk (if isMacos
then "-drive id=os,if=none,file=${osDiskPath},format=qcow2${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"} -device ide-hd,bus=sata.0,drive=os"
@ -239,9 +291,9 @@ let
${concatMapStrings (diskCfg: ''
-drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \
'') (attrValues vmCfg.disks.add)} \
${concatStrings (mapAttrsToList (shareName: shareCfg: ''
${optionalString (!isMacos) (concatStrings (mapAttrsToList (shareName: shareCfg: ''
-virtfs local,path=${toString shareCfg.source},security_model=passthrough,mount_tag=${shareName} \
'') vmCfg.shares)} \
'') vmCfg.shares))} \
${optionalString cfg.networks.user.enable "
-netdev user,id=user \
-device ${vmCfg.nicModel},netdev=user${optionalString isMacos ",mac=${macosMac}${macosNicPlacement}"} \
@ -280,6 +332,8 @@ let
ProtectSystem = true;
ProtectHome = true;
PrivateNetwork = true;
RuntimeDirectory = "vmix";
RuntimeDirectoryPreserve = "yes";
} // lib.optionalAttrs (vmCfg.pci.passthrough != []) {
# VFIO passthrough needs raw device access — relax sandboxing
ProtectSystem = lib.mkForce false;

View file

@ -93,9 +93,9 @@ with lib;
};
};
displayDevice = mkOption {
type = types.enum [ "virtio" "qxl" "std" "none" ];
type = types.enum [ "virtio" "qxl" "std" "vmware" "none" ];
default = "qxl";
description = "QEMU -vga type to use with SPICE (qxl, virtio, std, none).";
description = "QEMU -vga type to use with SPICE (qxl, virtio, std, vmware, none). macOS has no QXL/virtio-gpu driver: it always uses vmware (or std).";
};
vgamem = mkOption {
type = types.nullOr types.int;
@ -212,11 +212,11 @@ with lib;
};
target = mkOption {
type = types.str;
description = "Target path inside the VM for the shared directory.";
description = "Target path inside the VM for the shared directory. macOS: the share named `automount` (or the first one) appears at /Volumes/My Shared Files; others are mounted at target through the guest agent.";
};
};
});
description = "Shared directories.";
description = "Shared directories (9p for Linux, virtio-fs via virtiofsd for macOS).";
};
disks.bus = mkOption {
@ -265,6 +265,38 @@ with lib;
default = null;
description = "MAC address of en0. Defaults to the image's macAddress (must match OpenCore's ROM for Apple ID / iMessage).";
};
guestAgent.enable = mkOption {
type = types.bool;
default = true;
description = "Attach Apple's built-in QEMU guest agent (virtio console port org.qemu.guest_agent.0). Socket: /run/vmix/qga-<name>.sock; guest-exec runs as root.";
};
homeDisk = {
enable = mkOption {
type = types.bool;
default = false;
description = "Persistent home volume: a host disk image attached as virtio-blk, formatted APFS with label `label` by the PE on first start. The image must be generalized with persistHome = true (fstab mounts it at /Users), which makes the OS disk safely ephemeral (disks.os.persist = false).";
};
file = mkOption {
type = types.str;
default = "";
description = "Path of the home disk image, e.g. /storage/vms/mac/home.qcow2 (created if missing).";
};
format = mkOption {
type = types.enum [ "qcow2" "raw" ];
default = "qcow2";
description = "Image format; use raw for a zvol/block device (created only for files).";
};
size = mkOption {
type = types.str;
default = "64G";
description = "Size when the image is created.";
};
label = mkOption {
type = types.str;
default = "vmix-home";
description = "APFS volume label (must match generalize's homeVolumeLabel).";
};
};
};
tpm = {