From 0f9373263d5f6d154e8a355a98b0a2906532a278 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Wed, 9 Sep 2026 21:47:41 -0300 Subject: [PATCH] macOS: guest agent, online templates, virtio-fs shares, persistent home volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-.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 Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF --- cli.nix | 44 +++++ lib/images/macos/README.md | 43 +++++ lib/images/macos/default.nix | 1 + lib/images/macos/helpers/customizeImage.nix | 60 ++++++- lib/images/macos/helpers/formatVolume.nix | 56 +++++++ lib/images/macos/helpers/qemu.nix | 21 +++ lib/images/macos/helpers/vm-driver.py | 156 +++++++++++++++++- lib/images/macos/templates/default.nix | 3 + lib/images/macos/templates/generalize.nix | 20 +++ .../macos/templates/profile/default.nix | 58 +++++++ .../macos/templates/software/default.nix | 51 ++++++ nixos/vms/config.nix | 66 +++++++- nixos/vms/submoduleOptions.nix | 40 ++++- 13 files changed, 600 insertions(+), 19 deletions(-) create mode 100644 lib/images/macos/helpers/formatVolume.nix create mode 100644 lib/images/macos/templates/profile/default.nix create mode 100644 lib/images/macos/templates/software/default.nix diff --git a/cli.nix b/cli.nix index 82882d0..b3d9f8f 100644 --- a/cli.nix +++ b/cli.nix @@ -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-.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 /Volumes/ + 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}"') \ diff --git a/lib/images/macos/README.md b/lib/images/macos/README.md index 037a403..94e1d45 100644 --- a/lib/images/macos/README.md +++ b/lib/images/macos/README.md @@ -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-.sock`, `/run/vmix/qga-.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:`. +* **online templates** (`bootScript`): `customizeImage` boots the image with the + agent, runs the script as root (network available, `as_user ` 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 ` — the module does that through the guest agent + for every `shares.` 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. diff --git a/lib/images/macos/default.nix b/lib/images/macos/default.nix index 46c957d..c6dd8bd 100644 --- a/lib/images/macos/default.nix +++ b/lib/images/macos/default.nix @@ -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; }; }; diff --git a/lib/images/macos/helpers/customizeImage.nix b/lib/images/macos/helpers/customizeImage.nix index fd267eb..6f4dc11 100644 --- a/lib/images/macos/helpers/customizeImage.nix +++ b/lib/images/macos/helpers/customizeImage.nix @@ -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 ''; diff --git a/lib/images/macos/helpers/formatVolume.nix b/lib/images/macos/helpers/formatVolume.nix new file mode 100644 index 0000000..d0d9e1a --- /dev/null +++ b/lib/images/macos/helpers/formatVolume.nix @@ -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=