Reference · Architecture
Architecture
Every module, every actor boundary, every singleton, every notification — the map a contributor needs in their head before changing anything.
On this page
Layered overview
┌────────────────────────────────────────────────────────────────┐
│ UI layer (AppKit, NSWindowController-based, @MainActor) │
│ - VMLibraryWindowController (main window, ~2600 LOC) │
│ - PacketAnalysisWindowController (filter bar, table, hex) │
│ - VM display windows (one per running guest) │
└────────────────────────┬───────────────────────────────────────┘
│ Notifications + Combine subscriptions
▼
┌────────────────────────────────────────────────────────────────┐
│ Domain layer (singletons, mostly @MainActor) │
│ - VMManager.shared │
│ - VirtualNetworkSwitch.shared │
│ - PacketCaptureManager.shared │
│ - ISOCacheManager.shared │
│ - MacOSVMInstaller.shared │
│ - VMSecurityMonitor.shared │
└────────────────────────┬───────────────────────────────────────┘
│ Reads/writes on background queues
▼
┌────────────────────────────────────────────────────────────────┐
│ Persistence + system layer │
│ - ~/.avf/ bundle filesystem (FileManager) │
│ - Apple Virtualization framework (VZ*) │
│ - URLSession (ISO/IPSW download) │
│ - tshark subprocess (Process + Pipe + FIFO) │
│ - Endpoint Security Framework (Network Extension target) │
└────────────────────────────────────────────────────────────────┘
Three layers, well-defined responsibilities. The UI never talks to URLSession directly; the domain layer never owns NSView. Notifications and Combine are the glue.
Module map
Core
AppDelegate.swift— application lifecycle, menu bar, NotificationCenter handlers, multi-window VM management. Owns the menu wiring and routes user actions to the domain layer.VMManager.swift— VM CRUD, bundle scanning, async initialization on background threads, transitions toVZVirtualMachine. The factory and the lifecycle authority.VMConfiguration.swift— Codable model. The source of truth formetadata.json. Version-bump-safe.
Network stack
VirtualNetworkSwitch.swift— L2/L3 switch in Swift. Holds the MAC table, runs the forwarding loop, publishes frames.PacketCaptureManager.swift— owns the tshark subprocess, the FIFO, the JSON-EK parser. PublishesDecodedFramevia Combine.PacketAnalysisWindowController.swift— the Wireshark-style UI. Subscribes toPacketCaptureManager, drives the table, hex view, decoded-layers tree.
UI
VMLibraryWindowController.swift— main window. Table view, packet log panel, Active VMs sidebar. By far the biggest file (~2600 LOC) because it's the central hub.
AI sandbox
AISandboxMacVMConfiguration.swift— builds theVZVirtualMachineConfigurationfor the ephemeral macOS guest, including the vsock device on port 2222 and the VirtioFS workspace share.AISandboxVMSession.swift— host-side session API:cloneBase(),boot(),run(),destroy().AISandboxMacVMInstaller.swift— provisions the base bundle (IPSW download → restore → setup).
Security
VMSecurityMonitor.swift— FSEvents-based observer. Filesystem deltas, resource pressure, severity-graded events.SecVFError.swift— typed error enum (11 categories) withLocalizedErrorconformance and recovery suggestions. Mirrors toerror-audit.log.
Supporting
ISOCacheManager.swift— ~1000 LOC. ISO download, SHA-256 verification, cache scanning. The provenance enforcer.DistroVersionFetcher.swift— discovers live distro versions from mirrors. Distro metadata inResources/distros.json.MacOSVMInstaller.swift— IPSW download from Apple CDN with security validation.ScriptsUSBManager.swift— builds and attaches the Scripts USB image for guest scripts (router setup, FakeNet, etc.).
Protocols (DI seams)
VMManagerProtocol.swiftNetworkSwitchProtocol.swiftPacketCaptureProtocol.swift
Each abstracts a singleton so tests can substitute a mock. Production code holds any VMManagerProtocol at the boundary.
Threading & actor model
Three execution domains:
| Domain | Where it runs | What's on it |
|---|---|---|
| Main actor | @MainActor Swift Concurrency | Every UI class, every window controller, the singletons (their public API is @MainActor). |
| Capture queue | DispatchQueue(label: "secvf.capture", qos: .userInitiated) | Frame forwarding, JSON-EK parsing. Never blocks the main thread. |
| Async tasks | Implicit Task contexts | VZ boot, IPSW restore, ISO download. await-resumed on the calling actor. |
The rules:
- All UI work is on the main actor. There is no exception.
- All publicly mutating domain methods are on the main actor. Internal mutation (e.g. the MAC table update inside the switch) happens on the switch's serial queue and is re-published on main.
- Long-running work — VZ boot, IPSW download — is an
asyncfunction that suspends. The UI shows a progress indicator; the framework's callback completes on the main actor. - The capture pipeline is the one true "background" path. tshark output is parsed off-main and Combine-published to UI subscribers, which run on main by default.
@MainActor guarantees serial access to mutable shared state without locks; the queue is the framework's main run loop.
NotificationCenter contract
The cross-cutting glue. Most of the UI subscribes to a small set of named notifications:
| Notification | Posted by | userInfo |
|---|---|---|
Notification.Name.startVM | UI (menu, button) or AppDelegate | ["name": String] |
Notification.Name.stopVM | UI | ["name": String, "force": Bool] |
Notification.Name.pauseVM | UI | ["name": String] |
Notification.Name.vmStateChanged | VMManager | ["name": String, "state": VMState] |
Notification.Name.packetCaptured | PacketCaptureManager | ["frame": DecodedFrame] |
Notification.Name.securityEvent | VMSecurityMonitor | ["event": SecurityEvent] |
Why NotificationCenter instead of Combine for these? Three reasons:
- The CLI (via
DistributedNotificationCenter) posts the same notifications. NSDistributedNotification is the only cross-process channel that doesn't require XPC plumbing — and we want minimal IPC surface for security. - Multiple subscribers, no backpressure semantics needed (it's UI events, not high-rate data).
- Easy to bridge to AppKit's existing notification idioms (NSWindow events, etc.).
Combine pipelines
Combine is used where backpressure-aware streaming matters:
Packet pipeline
VirtualNetworkSwitch.frames
: PassthroughSubject<EthernetFrame, Never>
│
├──▶ PacketCaptureManager (writes to FIFO)
│ │
│ ▼
│ tshark subprocess
│ │
│ ▼
│ PacketCaptureManager.decodedFrames
│ : PassthroughSubject<DecodedFrame, Never>
│
└──▶ VMSecurityMonitor (looks for suspicious patterns)
Switch statistics
VirtualNetworkSwitch.stats
: CurrentValueSubject<SwitchStats, Never>
│
├──▶ Switch Statistics window (subscribes, renders)
├──▶ Status bar item (live counter in menu bar)
└──▶ VMSecurityMonitor (drop-rate alerts)
Why CurrentValueSubject for stats but PassthroughSubject for frames?
Stats need a "current" — a new subscriber should see the latest counters immediately. Frames are events; if you subscribe mid-flight, you missed earlier frames and that's correct.
Singletons & their boundaries
Five domain singletons. Their boundaries are deliberate:
| Singleton | Owns | Talks to |
|---|---|---|
VMManager.shared | VM CRUD, bundle filesystem, VZVirtualMachine instances | The framework, persistence, posts notifications |
VirtualNetworkSwitch.shared | Port assignments, MAC table, forwarding loop | VZNetworkDevice attachments, frame publisher |
PacketCaptureManager.shared | tshark subprocess, FIFO, decoded frames | VirtualNetworkSwitch (subscribes to frames) |
ISOCacheManager.shared | ISO downloads, SHA-256, cache directory | URLSession, FileManager, audit log |
VMSecurityMonitor.shared | FSEvents, resource pressure, severity classification | Audit log writer, optional notification UI |
Cross-singleton calls are deliberate and few — chiefly PacketCaptureManager subscribing to VirtualNetworkSwitch.frames, and VMSecurityMonitor watching both. Anything else goes through notifications.
Protocols & dependency injection
Each singleton has a matching protocol. Production code holds any VMManagerProtocol at the boundary, not VMManager directly:
// production
final class VMLibraryWindowController: NSWindowController {
private let vmManager: any VMManagerProtocol
init(vmManager: any VMManagerProtocol = VMManager.shared) {
self.vmManager = vmManager
super.init(window: nil)
}
}
// tests
let mockVMManager = MockVMManager()
let controller = VMLibraryWindowController(vmManager: mockVMManager)
// drive the controller against the mock
This makes the entire UI testable in isolation. TestHelpers.swift provides factory functions for mocks.
Data flow: a capture session
Walk one frame from wire to UI to demonstrate the whole stack:
- Guest sends a packet. The guest's virtio-net driver writes a frame to its TX ring.
- VZ delivers it to
VirtualNetworkSwitch's port for that VM, off-main, on the switch's serial queue. - Switch forwards. MAC lookup → destination port → write to that port's RX queue. Stats counters bump.
- Switch publishes. The
framesPassthroughSubjectemits anEthernetFramewith timestamp and port metadata. - PacketCaptureManager subscribes. It packs the frame into a libpcap-format record and writes to the FIFO. tshark reads it.
- tshark emits JSON-EK. PacketCaptureManager's stdout reader splits on newlines, parses each line into a
DecodedFrame. - Decoded frame publishes. The
decodedFramessubject emits. - UI subscribers receive. The packet table view appends a row (on main); the protocol stats widget bumps its counter; the hex panel (if selected) renders.
- VMSecurityMonitor inspects. If the frame matches a suspicious pattern (e.g. SYN scan, DNS exfil), it raises a WARNING event.
Total path from wire to UI: ~1–3 ms in the common case. The throttle is tshark's parse speed, not Swift's.
Test architecture
Tests live in SecVF/Tests/, organized by component:
| File | Covers |
|---|---|
TestHelpers.swift | Mock VM bundle creation, async assertion helpers, XCTest extensions. |
VMConfigurationTests.swift | Codable encode/decode, schema versioning. |
VMManagerTests.swift | CRUD lifecycle, transitions, bundle scanning. Uses the VMManagerProtocol seam. |
VirtualNetworkSwitchTests.swift | MAC learning, broadcast, drop policy, stats counters. |
ISOCacheManagerTests.swift | Provenance enforcement, SHA-256 mismatch handling, hostname allowlist. |
VMLibraryUITests.swift | UI smoke tests through the protocol mocks. |
IntegrationTests.swift | End-to-end paths (skipped in CI; require physical Apple Silicon). |
Convention: Given-When-Then. Each test name reads as a sentence. test_givenStoppedVM_whenStarted_thenStateBecomesRunning() — that style.