Putting Apple's Virtualization framework under a Flutter app

작성자

카테고리:

← 피드로
DEV Community · Eric Trenkel · 2026-09-17 개발(SW)
Cover image for Putting Apple's Virtualization framework under a Flutter app

Eric Trenkel

I maintain WSL Manager, a Flutter desktop app from 2021 that saves you from typing wsl.exe flags. Version 2 runs the same app on Apple silicon, where it manages native Linux and macOS VMs through Apple’s Virtualization framework.

This post is about what that took. Almost none of it was in the Flutter part.

Install on macOS with brew install --cask wsl-manager. The macOS side is still beta.

The shape of it

A VZVirtualMachine only lives inside the process that created it, and Flutter has no business holding one. So the macOS side is a separate Swift helper called vmctl, shipped inside the app bundle.

  • It is a plain CLI. Every subcommand prints JSON on stdout. The Dart code spawns it and parses the result. No XPC, no sockets.
  • One directory per VM: config.json, a sparse raw disk.img, an EFI variable store, the cloud-init seed ISO, and a run/ folder with pid file and logs.
  • One backend interface in Dart that both WSL and the Apple helper implement, plus a capabilities record the UI reads instead of checking the platform. That is what lets one codebase carry both.
vmctl --store ~/Library/Application\ Support/WSLManager/vms create ubuntu --image noble-server-cloudimg-arm64.img
vmctl --store ... start ubuntu
vmctl --store ... exec ubuntu -- uname -a

Enter fullscreen mode Exit fullscreen mode

Keeping the VM alive after the command returns

Because the VM object dies with its process, vmctl start cannot simply return.

  • It spawns a detached copy of itself as vmctl __run and polls for the pid file for five seconds, so a broken config fails at the command line instead of silently.
  • The daemon is a headless NSApplication. SIGTERM maps to requestStop(), an ACPI shutdown, with a hard stop after thirty seconds.
  • SIGUSR1 shows the screen. It opens a VZVirtualMachineView window and promotes the process to a regular app. Closing the window demotes it again. The VM never notices.
  • Every VM gets a virtio graphics device at creation, even headless ones. Devices cannot be added to a running VM, and someone will ask for the screen later.

Cloud images and cloud-init

  • The NoCloud seed is built with hdiutil makehybrid. No third party tooling.
  • It is attached as virtio-blk, not USB. Alpine’s linux-virt kernel ships no USB drivers and would never see it.
  • Root gets a * password hash rather than lock_passwd. cloud-init writes ! for locked accounts, and Alpine’s sshd refuses those even for public key auth.
  • Your own cloud-init document is merged in as multipart user-data. A final pinned part re-asserts the SSH settings so a user document cannot accidentally open password login.
  • qcow2 is converted to sparse raw in-process, which removed the qemu-img dependency. Backing files, encryption and zstd are refused rather than guessed at.

Finding the guest’s IP

There is no guest agent. The VM gets a stable random MAC, and vmctl ip reads macOS’s own /var/db/dhcpd_leases.

  • MAC match first, then a suffix match for dhcpcd’s RFC 4361 client IDs, then hostname for systemd-networkd, whose DUID contains no MAC at all.
  • The network-config pins dhcp-identifier: mac and names eth0 and enp0s1 explicitly, because a match glob rendered into Alpine’s ENI format can name no interface at all.

SSH without a wedged terminal

  • vmctl exec and shell execv into ssh rather than spawning it. As a child process the session was never the foreground process group, took SIGTTIN on its first read and sat in state T.
  • IdentitiesOnly=yes, because an agent full of keys exhausts Alpine’s MaxAuthTries before the right one is offered.
  • Port forwards run cat >/dev/null on the remote end so the tunnel dies with the app’s stdin pipe.

The serial console

The daemon holds one end of a socketpair on VZFileHandleSerialPortAttachment, tees the other into serial.log, and relays it to one client at a time over a Unix socket.

  • The serial log explains failed boots. vmctl start succeeds even when EFI finds nothing bootable, so the app re-checks after four seconds and appends the tail of the log to the error.
  • Alpine floods the console. Its cloud images run a getty on ttyAMA0, a UART that QEMU has and Virtualization.framework does not. A bootcmd in the seed removes gettys whose tty is missing and adds one on hvc0.

Things that bit

  • Overwriting a signed executable in place poisons the kernel’s signature cache and every later exec is killed. The build script copies and renames.
  • codesign --deep would re-sign the helper with the app’s entitlements. The helper needs only com.apple.security.virtualization, so they are signed separately.
  • macOS cannot mount ext4. “Open in Finder” attaches the raw disk read-only, and start refuses while it is attached. Finder plus a running guest is corruption.

macOS guests work too on Apple silicon: load a restore image, persist the hardware model and machine identifier, run VZMacOSInstaller, and after that it is a VM like any other.

What else is in 2.x

  • Free: remote WSL management over SSH, .wsl distro packaging, a rewritten .wslconfig editor, and a full keyboard and accessibility pass.
  • Pro, one-time purchase: a tool-using AI assistant that operates your distros, a sandboxed throwaway distro for AI, an MCP server for Claude Desktop, Claude Code and other clients, and a web dashboard. It runs on your own OpenAI-compatible API key. The AI tools’ settings dialogs are generated at runtime from whatever JSON Schema the tool publishes.

Managing distros and VMs is free and the app is GPLv3. If you try the macOS side, the serial log and the issue tracker are where I look first.

Launch offer: the first 100 people get Pro for free with the code START100 at wslmanager.com/buy, one licence per person.

원문에서 계속 ↗