Merge pull request #7865 from lovon-spec/apple/vmnet-networking

apple: let virtual machines connect to each other
This commit is contained in:
osy
2026-09-19 08:56:03 -05:00
committed by GitHub
9 changed files with 205 additions and 16 deletions
+4 -4
View File
@@ -241,7 +241,7 @@ extension UTMAppleConfiguration {
@available(iOS, unavailable, message: "Apple Virtualization not available on iOS")
@MainActor extension UTMAppleConfiguration {
func appleVZConfiguration(ignoringDrives: Bool = false) throws -> VZVirtualMachineConfiguration {
func appleVZConfiguration(forValidation: Bool = false) throws -> VZVirtualMachineConfiguration {
let vzconfig = VZVirtualMachineConfiguration()
try system.fillVZConfiguration(vzconfig)
if !sharedDirectories.isEmpty {
@@ -249,7 +249,7 @@ extension UTMAppleConfiguration {
fsConfig.share = UTMAppleConfigurationSharedDirectory.makeDirectoryShare(from: sharedDirectories)
vzconfig.directorySharingDevices.append(fsConfig)
}
if !ignoringDrives {
if !forValidation {
vzconfig.storageDevices = try drives.compactMap { drive in
guard let attachment = try drive.vzDiskImage(useFsWorkAround: system.boot.operatingSystem == .linux) else {
return nil
@@ -271,7 +271,7 @@ extension UTMAppleConfiguration {
}
}
}
vzconfig.networkDevices.append(contentsOf: networks.compactMap({ $0.vzNetworking() }))
vzconfig.networkDevices.append(contentsOf: try networks.compactMap({ try $0.vzNetworking(forValidation: forValidation) }))
vzconfig.serialPorts.append(contentsOf: serials.compactMap({ $0.vzSerial() }))
// add remaining devices
try virtualization.fillVZConfiguration(vzconfig, isMacOSGuest: system.boot.operatingSystem == .macOS)
@@ -350,7 +350,7 @@ private extension String {
#endif
// validate before we copy and create drive images
try appleVZConfiguration(ignoringDrives: true).validate()
try appleVZConfiguration(forValidation: true).validate()
for i in 0..<drives.count {
existingDataURLs += try await _drives[i].saveData(to: dataURL)
@@ -21,14 +21,31 @@ import Virtualization
struct UTMAppleConfigurationNetwork: Codable, Identifiable {
enum NetworkMode: String, CaseIterable, QEMUConstant {
case shared = "Shared"
case natNetwork = "NATNetwork"
case host = "Host"
case bridged = "Bridged"
var prettyValue: String {
switch self {
case .shared: return NSLocalizedString("Shared Network", comment: "UTMAppleConfigurationNetwork")
case .natNetwork: return NSLocalizedString("NAT Network", comment: "UTMAppleConfigurationNetwork")
case .host: return NSLocalizedString("Host Only", comment: "UTMAppleConfigurationNetwork")
case .bridged: return NSLocalizedString("Bridged (Advanced)", comment: "UTMAppleConfigurationNetwork")
}
}
var isHidden: Bool {
switch self {
case .natNetwork, .host:
if #available(macOS 26, *) {
return false
} else {
return true
}
default:
return false
}
}
}
var mode: NetworkMode = .shared
@@ -81,7 +98,7 @@ struct UTMAppleConfigurationNetwork: Codable, Identifiable {
}
}
func vzNetworking() -> VZNetworkDeviceConfiguration? {
@MainActor func vzNetworking(forValidation: Bool) throws -> VZNetworkDeviceConfiguration? {
let config = VZVirtioNetworkDeviceConfiguration()
guard let macAddress = VZMACAddress(string: macAddress) else {
return nil
@@ -108,6 +125,14 @@ struct UTMAppleConfigurationNetwork: Codable, Identifiable {
let attachment = VZBridgedNetworkDeviceAttachment(interface: found)
config.attachment = attachment
}
case .natNetwork, .host:
// a live network is only created when starting a VM, validation leaves the device unattached
if !forValidation {
guard #available(macOS 26, *) else {
throw UTMAppleConfigurationError.featureNotSupported
}
config.attachment = try UTMAppleVmnetNetworkManager.shared.attachment(for: mode == .host ? .VMNET_HOST_MODE : .VMNET_SHARED_MODE)
}
}
return config
}
@@ -24,7 +24,13 @@ struct VMConfigAppleNetworkingView: View {
var body: some View {
Form {
VMConfigConstantPicker("Network Mode", selection: $config.mode)
if let modeDescription = modeDescription {
DetailedSection("", description: modeDescription) {
VMConfigConstantPicker("Network Mode", selection: $config.mode)
}
} else {
VMConfigConstantPicker("Network Mode", selection: $config.mode)
}
HStack {
TextField("MAC Address", text: $newMacAddress.bound, onCommit: {
commitMacAddress()
@@ -53,6 +59,14 @@ struct VMConfigAppleNetworkingView: View {
}
}
private var modeDescription: LocalizedStringKey? {
switch config.mode {
case .natNetwork: return "Virtual machines using this mode in the same UTM app can connect to each other and access the internet through your Mac."
case .host: return "Virtual machines using this mode in the same UTM app can connect to each other and your Mac. This network does not provide internet access."
default: return nil
}
}
private func commitMacAddress() {
guard let macAddress = newMacAddress else {
return
+2
View File
@@ -756,6 +756,8 @@
<enumeration name="apple network mode" code="ApNm" description="Mode for networking device.">
<enumerator name="shared" code="ShRd" description="NAT based sharing with the host."/>
<enumerator name="nat network" code="NaNw" description="NAT based sharing with the host on a network common to other virtual machines (macOS 26+)."/>
<enumerator name="host" code="HoSt" description="Network shared with the host and other virtual machines with no WAN routing (macOS 26+)."/>
<enumerator name="bridged" code="BrGd" description="Bridged to a host interface."/>
</enumeration>
+2
View File
@@ -141,6 +141,8 @@ import ScriptingBridge
// MARK: UTMScriptingAppleNetworkMode
@objc public enum UTMScriptingAppleNetworkMode : AEKeyword {
case shared = 0x53685264 /* 'ShRd' */
case natNetwork = 0x4e614e77 /* 'NaNw' */
case host = 0x486f5374 /* 'HoSt' */
case bridged = 0x42724764 /* 'BrGd' */
}
+5 -2
View File
@@ -259,6 +259,8 @@ extension UTMScriptingConfigImpl {
private func appleNetworkMode(from mode: UTMAppleConfigurationNetwork.NetworkMode) -> UTMScriptingAppleNetworkMode {
switch mode {
case .shared: return .shared
case .natNetwork: return .natNetwork
case .host: return .host
case .bridged: return .bridged
}
}
@@ -717,13 +719,14 @@ extension UTMScriptingConfigImpl {
}
private func parseAppleNetworkMode(_ value: AEKeyword?) -> UTMAppleConfigurationNetwork.NetworkMode? {
guard let value = value, let parsed = UTMScriptingQemuNetworkMode(rawValue: value) else {
guard let value = value, let parsed = UTMScriptingAppleNetworkMode(rawValue: value) else {
return Optional.none
}
switch parsed {
case .shared: return .shared
case .natNetwork: return .natNetwork
case .host: return .host
case .bridged: return .bridged
default: return Optional.none
}
}
+36 -8
View File
@@ -235,6 +235,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
if #available(macOS 27, *) {
await stopUsbPassthrough()
}
await releaseFailedAppleVM()
state = .stopped
try? await deleteSnapshot()
throw error
@@ -566,6 +567,39 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
}
}
@MainActor private func closeSerialPorts() {
for i in config.serials.indices {
if let serialPort = config.serials[i].interface {
serialPort.close()
config.serials[i].interface = nil
config.serials[i].fileHandleForReading = nil
config.serials[i].fileHandleForWriting = nil
}
}
}
/// Release what `createAppleVM` set up when the virtual machine did not come up.
///
/// No delegate callback follows a failed start, so without this the virtual machine would hold on
/// to its devices (and any network they reserve) and the serial ports would stay open.
private func releaseFailedAppleVM() async {
let isReleased = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
vmQueue.async { [self] in
if let apple = apple, apple.state != .stopped && apple.state != .error {
// the guest is alive, everything is released once it stops
continuation.resume(returning: false)
return
}
apple = nil
snapshotUnsupportedError = nil
continuation.resume(returning: true)
}
}
if isReleased {
await closeSerialPorts()
}
}
private func updateSharedDirectories(with newShares: [UTMAppleConfigurationSharedDirectory], tag: String) {
guard let fsConfig = apple?.directorySharingDevices.first(where: { device in
if let device = device as? VZVirtioFileSystemDevice {
@@ -622,6 +656,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
progressObserver = nil
installProgress = nil
await stopAccesingResources()
await releaseFailedAppleVM()
delegate?.virtualMachine(self, didCompleteInstallation: false)
state = .stopped
let error = error as NSError
@@ -754,14 +789,7 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
if #available(macOS 27, *) {
stopUsbPassthrough()
}
for i in config.serials.indices {
if let serialPort = config.serials[i].interface {
serialPort.close()
config.serials[i].interface = nil
config.serials[i].fileHandleForReading = nil
config.serials[i].fileHandleForWriting = nil
}
}
closeSerialPorts()
}
try? saveScreenshot()
state = .stopped
+111
View File
@@ -0,0 +1,111 @@
//
// Copyright © 2026 Turing Software, LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Virtualization
import vmnet
/// Manages the vmnet networks used by Apple Virtualization guests.
///
/// Configurations and runtime devices retain their attachments, which own the native networks.
/// Keeping a weak attachment per mode lets guests share a network for its actual lifetime,
/// including when a failed operation leaves a virtual machine alive after UTM reports it stopped.
@available(macOS 26, *)
@MainActor final class UTMAppleVmnetNetworkManager {
static let shared = UTMAppleVmnetNetworkManager()
private struct Entry {
weak var attachment: VZVmnetNetworkDeviceAttachment?
}
private var entries: [vmnet_mode_t: Entry] = [:]
private init() {
}
/// Returns an owning attachment, sharing the network with any existing users of `mode`.
func attachment(for mode: vmnet_mode_t) throws -> VZVmnetNetworkDeviceAttachment {
if let attachment = entries[mode]?.attachment {
return attachment
}
let network = try createNetwork(mode: mode)
defer {
Unmanaged<CFTypeRef>.fromOpaque(UnsafeRawPointer(network)).release()
}
let attachment = VZVmnetNetworkDeviceAttachment(network: network)
entries[mode] = Entry(attachment: attachment)
return attachment
}
/// Subnet to request for a mode.
///
/// Left alone, vmnet hands out the lowest free subnet from the ranges that NAT attachments and
/// QEMU also draw from, so addresses would depend on what else is running when the network is
/// (re)created. A subnet outside of those ranges keeps guest addresses stable.
private func preferredSubnet(for mode: vmnet_mode_t) -> in_addr? {
switch mode {
case .VMNET_SHARED_MODE: return in_addr(s_addr: inet_addr("192.168.96.1"))
case .VMNET_HOST_MODE: return in_addr(s_addr: inet_addr("192.168.160.1"))
default: return nil
}
}
private func createNetwork(mode: vmnet_mode_t) throws -> vmnet_network_ref {
if let subnet = preferredSubnet(for: mode) {
do {
return try createNetwork(mode: mode, subnet: subnet)
} catch {
// the subnet is taken by something else, let vmnet pick a free one
logger.debug("vmnet subnet \(String(cString: inet_ntoa(subnet))) unavailable: \(error.localizedDescription)")
}
}
return try createNetwork(mode: mode, subnet: nil)
}
private func createNetwork(mode: vmnet_mode_t, subnet: in_addr?) throws -> vmnet_network_ref {
var status = vmnet_return_t.VMNET_SUCCESS
guard let configuration = vmnet_network_configuration_create(mode, &status) else {
throw UTMAppleVmnetNetworkError.creationFailed(status)
}
defer {
Unmanaged<CFTypeRef>.fromOpaque(UnsafeRawPointer(configuration)).release()
}
if var subnet = subnet {
var mask = in_addr(s_addr: inet_addr("255.255.255.0"))
status = vmnet_network_configuration_set_ipv4_subnet(configuration, &subnet, &mask)
guard status == .VMNET_SUCCESS else {
throw UTMAppleVmnetNetworkError.creationFailed(status)
}
}
guard let network = vmnet_network_create(configuration, &status) else {
throw UTMAppleVmnetNetworkError.creationFailed(status)
}
return network
}
}
enum UTMAppleVmnetNetworkError: Error {
case creationFailed(vmnet_return_t)
}
extension UTMAppleVmnetNetworkError: LocalizedError {
var errorDescription: String? {
switch self {
case .creationFailed(let status):
return String.localizedStringWithFormat(NSLocalizedString("Failed to create the virtual network (vmnet error %@).", comment: "UTMAppleVmnetNetworkManager"), String(status.rawValue))
}
}
}
+4
View File
@@ -723,6 +723,7 @@
CE89CB102B8B1B6A006B2CC2 /* VisionKeyboardKit in Frameworks */ = {isa = PBXBuildFile; platformFilters = (xros, ); productRef = CE89CB0F2B8B1B6A006B2CC2 /* VisionKeyboardKit */; };
CE89CB122B8B1B7A006B2CC2 /* VisionKeyboardKit in Frameworks */ = {isa = PBXBuildFile; platformFilters = (xros, ); productRef = CE89CB112B8B1B7A006B2CC2 /* VisionKeyboardKit */; };
CE928C2A26ABE6690099F293 /* UTMAppleVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */; };
5A3966538C7644CE9E598CFE /* UTMAppleVmnetNetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F9F544E438D4BE8A5CA9360 /* UTMAppleVmnetNetworkManager.swift */; };
CE928C3126ACCDEA0099F293 /* VMAppleRemovableDrivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE928C3026ACCDEA0099F293 /* VMAppleRemovableDrivesView.swift */; };
CE93758924B930270074066F /* BusyOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7D972B24B2B17D0080CB69 /* BusyOverlay.swift */; };
CE93759924BB821F0074066F /* IQKeyboardManagerSwift in Frameworks */ = {isa = PBXBuildFile; platformFilter = ios; productRef = CE93759824BB821F0074066F /* IQKeyboardManagerSwift */; };
@@ -2070,6 +2071,7 @@
CE88A1632E24E4C000EAA28E /* VMKeyboardMap.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = VMKeyboardMap.h; sourceTree = "<group>"; };
CE88A1642E24E4C000EAA28E /* VMKeyboardMap.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = VMKeyboardMap.m; sourceTree = "<group>"; };
CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleVirtualMachine.swift; sourceTree = "<group>"; };
4F9F544E438D4BE8A5CA9360 /* UTMAppleVmnetNetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleVmnetNetworkManager.swift; sourceTree = "<group>"; };
CE928C3026ACCDEA0099F293 /* VMAppleRemovableDrivesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMAppleRemovableDrivesView.swift; sourceTree = "<group>"; };
CE9375A024BBDDD10074066F /* VMConfigDriveDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigDriveDetailsView.swift; sourceTree = "<group>"; };
CE95877426D74C2A0086BDE8 /* iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iOS.entitlements; sourceTree = "<group>"; };
@@ -3000,6 +3002,7 @@
CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */,
CE020BB524B14F8400B44AB6 /* UTMVirtualMachine.swift */,
CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */,
4F9F544E438D4BE8A5CA9360 /* UTMAppleVmnetNetworkManager.swift */,
841E999728AC817D003C6CB6 /* UTMQemuVirtualMachine.swift */,
CEF01DB12B6724A300725A0F /* UTMSpiceVirtualMachine.swift */,
);
@@ -4095,6 +4098,7 @@
CEF0300826A25A6900667B63 /* VMWizardView.swift in Sources */,
CE88A15A2E247D0100EAA28E /* UTMActionIntent.swift in Sources */,
CE928C2A26ABE6690099F293 /* UTMAppleVirtualMachine.swift in Sources */,
5A3966538C7644CE9E598CFE /* UTMAppleVmnetNetworkManager.swift in Sources */,
CE0B6CF524AD568400FE012D /* UTMLegacyQemuConfiguration+Miscellaneous.m in Sources */,
CE25125129C806AF000790AB /* UTMScriptingDeleteCommand.swift in Sources */,
CE0B6CFB24AD568400FE012D /* UTMLegacyQemuConfiguration+Networking.m in Sources */,