<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Eric Trenkel</title>
    <description>The latest articles on DEV Community by Eric Trenkel (@bostrot).</description>
    <link>https://dev.to/bostrot</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F870384%2Fa2bae727-94dd-438e-89cf-10d1a40813ac.jpg</url>
      <title>DEV Community: Eric Trenkel</title>
      <link>https://dev.to/bostrot</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bostrot"/>
    <language>en</language>
    <item>
      <title>Putting Apple's Virtualization framework under a Flutter app</title>
      <dc:creator>Eric Trenkel</dc:creator>
      <pubDate>Wed, 16 Sep 2026 20:03:26 +0000</pubDate>
      <link>https://dev.to/bostrot/putting-apples-virtualization-framework-under-a-flutter-app-3d13</link>
      <guid>https://dev.to/bostrot/putting-apples-virtualization-framework-under-a-flutter-app-3d13</guid>
      <description>&lt;p&gt;I maintain &lt;a href="https://github.com/bostrot/wsl2-distro-manager" rel="noopener noreferrer"&gt;WSL Manager&lt;/a&gt;, a Flutter desktop app from 2021 that saves you from typing &lt;code&gt;wsl.exe&lt;/code&gt; flags. Version 2 runs the same app on Apple silicon, where it manages native Linux and macOS VMs through Apple's Virtualization framework.&lt;/p&gt;

&lt;p&gt;This post is about what that took. Almost none of it was in the Flutter part.&lt;/p&gt;

&lt;p&gt;Install on macOS with &lt;code&gt;brew install --cask wsl-manager&lt;/code&gt;. The macOS side is still beta.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;VZVirtualMachine&lt;/code&gt; 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 &lt;code&gt;vmctl&lt;/code&gt;, shipped inside the app bundle.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is a plain CLI.&lt;/strong&gt; Every subcommand prints JSON on stdout. The Dart code spawns it and parses the result. No XPC, no sockets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One directory per VM:&lt;/strong&gt; &lt;code&gt;config.json&lt;/code&gt;, a sparse raw &lt;code&gt;disk.img&lt;/code&gt;, an EFI variable store, the cloud-init seed ISO, and a &lt;code&gt;run/&lt;/code&gt; folder with pid file and logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One backend interface in Dart&lt;/strong&gt; 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.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vmctl &lt;span class="nt"&gt;--store&lt;/span&gt; ~/Library/Application&lt;span class="se"&gt;\ &lt;/span&gt;Support/WSLManager/vms create ubuntu &lt;span class="nt"&gt;--image&lt;/span&gt; noble-server-cloudimg-arm64.img
vmctl &lt;span class="nt"&gt;--store&lt;/span&gt; ... start ubuntu
vmctl &lt;span class="nt"&gt;--store&lt;/span&gt; ... &lt;span class="nb"&gt;exec &lt;/span&gt;ubuntu &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Keeping the VM alive after the command returns
&lt;/h2&gt;

&lt;p&gt;Because the VM object dies with its process, &lt;code&gt;vmctl start&lt;/code&gt; cannot simply return.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Cloud images and cloud-init
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  Finding the guest's IP
&lt;/h2&gt;

&lt;p&gt;There is no guest agent. The VM gets a stable random MAC, and &lt;code&gt;vmctl ip&lt;/code&gt; reads macOS's own &lt;code&gt;/var/db/dhcpd_leases&lt;/code&gt;.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  SSH without a wedged terminal
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  The serial console
&lt;/h2&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The serial log explains failed boots.&lt;/strong&gt; &lt;code&gt;vmctl start&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alpine floods the console.&lt;/strong&gt; Its cloud images run a getty on &lt;code&gt;ttyAMA0&lt;/code&gt;, 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 &lt;code&gt;hvc0&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Things that bit
&lt;/h2&gt;

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

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

&lt;h2&gt;
  
  
  What else is in 2.x
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free:&lt;/strong&gt; remote WSL management over SSH, &lt;code&gt;.wsl&lt;/code&gt; distro packaging, a rewritten &lt;code&gt;.wslconfig&lt;/code&gt; editor, and a full keyboard and accessibility pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pro, one-time purchase:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Launch offer: the first 100 people get Pro for free with the code &lt;code&gt;START100&lt;/code&gt; at &lt;a href="https://wslmanager.com/buy/" rel="noopener noreferrer"&gt;wslmanager.com/buy&lt;/a&gt;, one licence per person.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>macos</category>
      <category>swift</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
