Imported from IngmarStein/havm (
AGENTS.md). Install upstream withnpx skills add IngmarStein/havm. Copyright stays with the author.
CLAUDE.md
Project
havm — Zero-config CLI for running Home Assistant OS on Apple Silicon using the native Virtualization framework. macOS 15 minimum (USB accessory passthrough requires macOS 27). Swift 6.4, built with Xcode 27+ — see Build & Test.
Build & Test
./scripts/build.sh release # Release build: -O + strip → ~2.1 MB binary
swift test # 39 tests in HavmCoreTests
./.build/release/havm run # Run the VM (blocks; Ctrl+C to stop)
./.build/release/havm run --console # Interactive serial console (hvc0)
Binary size is reduced via strip (removes ~2.4 MB of symbol tables from LINKEDIT)
before codesigning. Default -O is kept — -Osize only saves ~300 KB more.
Building needs Xcode 27+ for the Swift 6.4 toolchain and the macOS 27 SDK, but
the host need not run macOS 27: the deployment target is macOS 15 and the
macOS 27 APIs are weakly linked. CI builds and tests on GitHub's xcode-27
image, which is macOS 26. scripts/select-xcode.sh picks the toolchain by the
version each bundle reports — $DEVELOPER_DIR if it qualifies, else the newest
stable install, else the newest beta — and both workflows plus publish.sh
use it.
Release Process
- Bump
HavmVersion.currentinSources/Havm/main.swift(CI also auto-bumps from tag). - Tag:
git tag -a v0.1.4 -m "v0.1.4" && git push --tags - CI picks up the
v*tag, builds + notarizes, publishes a GitHub release withgh release create --generate-notes. The auto-generated notes are a starting point — edit the release on GitHub to add a curated changelog.
Architecture
Havm (CLI, AsyncParsableCommand)
├── RunCommand → HAOSSetup → VMController → ServiceRuntime
├── CleanupCommand → FileManager
├── ImportUTMCommand → UTMImport
└── VersionCommand
HavmCore
├── Config YAML config, paths, parsing (Yams)
├── HAOSSetup GitHub release fetch, download .img.xz, xz decompress (CXZ/libzma),
│ copy+resize disk, SSH CONFIG disk
├── VMController VZEFIBootLoader + VZEFIVariableStore, storage, network, USB,
│ machine identifier persistence, console mode
│ (VZVirtioConsoleDeviceSerialPortConfiguration,
│ VZFileHandleSerialPortAttachment),
│ @MainActor on start()
├── ServiceRuntime SIGTERM/SIGINT → SSH shutdown (port 22222/22) →
│ force-stop fallback, DHCP lease guest IP detection,
│ console mode: raw terminal, skip SIGINT, restore on exit,
│ AAUSBAccessoryListener + VZUSBPassthroughDevice for USB
├── CONFIGDiskBuilder MBR + FAT16 with VFAT LFN, volume label "CONFIG",
│ authorized_keys file — HA OS auto-imports for SSH
├── Metrics Prometheus metrics: MetricsServer (NWListener HTTP),
│ bootstrap, process gauges
└── Config/MemorySize Human-readable sizes ("4 GiB" → bytes)
CXZ (C target)
└── xz_decompress Statically links liblzma (-llzma) for XZ decompression (no external tools)
Key Design Decisions
- VZEFIBootLoader — boots directly from GPT disk via UEFI. No kernel extraction, no kernel command line, no initrd. Just point at the disk image.
- Bridge networking by default — LAN-reachable IP for Home Assistant discovery. Falls back to NAT at runtime if the binary lacks the
com.apple.vm.networkingentitlement (e.g. self-compiled). Explicitnetwork.type: natavailable for manual override. - @MainActor on VM start —
VZVirtualMachine.start()hasdispatch_assert_queuerequiring the main queue. - APFS sparse files — disk resize uses
ftruncate(seek + write zero byte). APFS automatically hole-punches. - Stable machine ID — persists
VZGenericMachineIdentifierfor consistent MAC addresses across reboots. - EFI variable store — persists NVRAM file for GRUB boot state survival across reboots.
- SSH key import — creates a 2 MB MBR + FAT16 disk with VFAT LFN entries for
authorized_keys. HA OS auto-imports from USB mass storage on boot for root SSH on port 22222. - Graceful shutdown chain — on Ctrl+C/SIGTERM:
POST /api/services/hassio/host_shutdownon port 8123 (REST API service call, requiresha.api_token)ssh root@<ip> -p 22222 shutdown -h now(debug SSH, requiresssh.authorized_keys)ssh root@<ip> -p 22 ha host shutdown(SSH add-on)vm.stop()— force-stop fallback All four share a single deadline (shutdown.timeout_seconds, default 90) rather than each getting a fresh timeout: the request attempts are capped inside it (10 s REST, 5 s SSH connect) and the wait for.stoppedconsumes the rest. Restarting the budget per method used to force-stop a guest that was still halting cleanly after an accepted shutdown request (issue #11), and grew the total tomin(budget, 10) + 3 x budget. Budget + force-stop + cleanup must stay under the Homebrew formula'sstop_timeout 120(launchdExitTimeOut), or launchdSIGKILLs havm mid-shutdown — and withKeepAlive truerestarts it, booting a guest whose disk was yanked mid-halt. ACPIrequestStop()is not used — HA OS on aarch64 uses PSCI and ignores ACPI power button events.
- Guest addressed by name, not by a resolved address — readiness checks, SSH shutdown,
and HA API calls all target
network.hostname(bridge-mode defaulthomeassistant.local) and let the resolver try a name's addresses at connect time. Resolving once and committing to the first result pins a stale address when mDNS serves records from an earlier DHCP lease (issue #10). NAT mode has no name and parses/var/db/dhcpd_leasesby MAC address instead (no ping/ARP scanning). - VFAT LFN — the
0x40(LAST_LONG_ENTRY) flag must be on the highest sequence number (end of filename), not the lowest (beginning). Getting this wrong causes both macOS and Linux to truncate the filename. --consoleinteractive mode —VZVirtioConsoleDeviceSerialPortConfigurationwithVZFileHandleSerialPortAttachment(stdin, stdout)maps the host terminal to the guest's/dev/hvc0virtio console. Terminal is set to raw mode (cfmakeraw+ONLCRfor CR-LF translation) after VM start, restored on all exit paths. SIGINT is not intercepted — raw mode's cleared ISIG means Ctrl+C passes0x03to the guest. Shutdown viapoweroffin guest, or SIGTERM from outside. Forces text log format (stderr) to keep stdout clean. Primarily a debugging tool, not a headline feature.
Entitlements
Three tiers map to account types. Select via ENTITLEMENTS_TIER in build.xcconfig.
| Tier | File | Account | USB | Bridge |
|---|---|---|---|---|
| 1 | entitlements-tier1.plist |
Free | No | No |
| 2 | entitlements-tier2.plist |
Paid | Yes | No |
| 3 | entitlements.plist |
Paid + Apple approval | Yes | Yes |
All tiers include com.apple.security.network.server for metrics HTTP serving.
| Entitlement | Restriction |
|---|---|
com.apple.security.virtualization |
Unrestricted |
com.apple.security.hypervisor |
Unrestricted |
com.apple.security.network.server |
Unrestricted — present in all tiers for metrics HTTP serving |
com.apple.security.device.usb |
Unrestricted (Hardened Runtime) |
com.apple.developer.accessory-access.usb |
Restricted — provisioning profile required. Works with Personal Team. |
com.apple.vm.networking |
Restricted — requires Apple approval. Tier 3 only. |
havm-profile/entitlements-helper.plist has device.usb + accessory-access.usb.
Open havm.xcodeproj and build once to generate the provisioning profile
for ch.ingmar.havm — the CLI build script picks it up automatically.
Data Layout
~/Library/Caches/havm/ Cached downloads (can be deleted)
~/Library/Application Support/havm/vm/
haos.img 32 GiB raw GPT disk (APFS sparse)
config.img 2 MB FAT16 SSH key import disk (optional)
NVRAM EFI variable store
MachineIdentifier Stable machine ID
~/.config/havm/config.yml Optional overrides
USB Accessories
USB accessory passthrough uses AAUSBAccessoryManager (macOS 27 only). When havm run
starts with ENABLE_USB_ACCESSORY=YES on macOS 27+, it registers a listener and macOS shows
a menu bar item. On macOS 15–26, USB discovery is skipped with a log message. The user
selects which devices to attach — they are hot-attached to the running VM via
VZUSBPassthroughDevice.
Architecture:
- All AccessoryAccess/
VZUSBPassthroughDevicecode lives inUSBAccessorySupport.swift(USBAccessoryCoordinator), annotated@available(macOS 27.0, *); AccessoryAccess is imported@_weakLinkedso the binary runs on macOS 15+. ServiceRuntime.setupUSBDiscovery()gates on#available(macOS 27.0, *), thenUSBAccessoryCoordinator.start()bootsNSApplication.accessoryand registersAAUSBAccessoryListener. The menu bar item is the user's selection UI.- On connect: listener hot-attaches via
VZUSBPassthroughDevice+usbControllers.first?.attach(device:)with fresh registryIDs. - On boot: listener registers after VM start, hot-attaches discovery results.
- The CLI builds as a minimal
Havm.appbundle so Xcode's provisioning profile covers the restrictedaccessory-access.usbentitlement.
Entitlements:
com.apple.security.device.usb— standard Hardened Runtime entitlementcom.apple.developer.accessory-access.usb— restricted, requires provisioning profile
havm.xcodeproj is a minimal command-line tool target whose sole purpose is
to generate a provisioning profile for ch.ingmar.havm. Build once (⌘B),
then scripts/build.sh picks up the profile automatically.
Known Issues
- macOS 27:
Data(count: 67108864)crashes the process. Our CONFIG disk builder uses 2 MB instead of 64 MB to work around this. - ACPI shutdown ignored: HA OS on aarch64 uses PSCI, not ACPI.
VZVirtualMachine.requestStop()(ACPI power button) is silently ignored. Use SSH-based shutdown instead. ha host shutdown: Only works if the SSH add-on is installed and running on port 22. The debug SSH on port 22222 runsshutdown -h nowdirectly as root on the host.