apple: let virtual machines connect to each other

Apple Virtualization's existing Shared Network uses a separate NAT
attachment per VM, so guests cannot use it to reach each other. macOS 26
adds VZVmnetNetworkDeviceAttachment, which lets multiple VMs attach to the
same process-owned vmnet network.

Add two macOS 26+ modes:

- "Shared Network (VM to VM)": guests share a NAT-backed vmnet network and
  can reach each other, the host, and external networks.
- "Host Only": guests share a host-only vmnet network and can reach each
  other and the host without external routing.

UTMAppleVmnetNetworkManager keeps a weak attachment cache per mode while
Virtualization configuration and runtime objects own the attachment and its
native network. The network therefore follows the actual VZ object lifetime
rather than UTM's presentation state, including failure paths where a VZ VM
can remain alive after UTM reports it stopped. Configuration validation uses
a side-effect-free placeholder so saving a VM does not create a live vmnet
network.

Hide both modes before macOS 26 and add matching scripting enumerators and
round-trip parsing.

Resolves #6975.
Related to #7418 and #7380.

Assisted-by: Claude:claude-fable-5-1
Assisted-by: ChatGPT:GPT-6-Pro
Assisted-by: ChatGPT:GPT-5.6-Sol
This commit is contained in:
lovon-spec
2026-09-18 13:26:24 +02:00
committed by osy
parent 5534ffab7d
commit 31b431f8b5
9 changed files with 170 additions and 7 deletions
+3 -3
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, ignoringDrives: Bool = false) throws -> VZVirtualMachineConfiguration {
let vzconfig = VZVirtualMachineConfiguration()
try system.fillVZConfiguration(vzconfig)
if !sharedDirectories.isEmpty {
@@ -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, ignoringDrives: true).validate()
for i in 0..<drives.count {
existingDataURLs += try await _drives[i].saveData(to: dataURL)
@@ -21,14 +21,41 @@ import Virtualization
struct UTMAppleConfigurationNetwork: Codable, Identifiable {
enum NetworkMode: String, CaseIterable, QEMUConstant {
case shared = "Shared"
case sharedVmnet = "SharedVmnet"
case host = "Host"
case bridged = "Bridged"
var prettyValue: String {
switch self {
case .shared: return NSLocalizedString("Shared Network", comment: "UTMAppleConfigurationNetwork")
case .sharedVmnet: return NSLocalizedString("Shared Network (VM to VM)", comment: "UTMAppleConfigurationNetwork")
case .host: return NSLocalizedString("Host Only", comment: "UTMAppleConfigurationNetwork")
case .bridged: return NSLocalizedString("Bridged (Advanced)", comment: "UTMAppleConfigurationNetwork")
}
}
var isHidden: Bool {
switch self {
case .sharedVmnet, .host:
if #available(macOS 26, *) {
return false
} else {
return true
}
default:
return false
}
}
/// The vmnet network backing this mode, or nil when Virtualization.framework provides the network itself.
@available(macOS 26, *)
var vmnetMode: UTMAppleVmnetNetworkManager.Mode? {
switch self {
case .sharedVmnet: return .shared
case .host: return .host
default: return nil
}
}
}
var mode: NetworkMode = .shared
@@ -76,12 +103,14 @@ struct UTMAppleConfigurationNetwork: Codable, Identifiable {
bridgeInterface = attachment.interface.identifier
} else if let _ = virtioConfig.attachment as? VZNATNetworkDeviceAttachment {
mode = .shared
} else if #available(macOS 26, *), let attachment = virtioConfig.attachment as? VZVmnetNetworkDeviceAttachment, let vmnetMode = UTMAppleVmnetNetworkManager.shared.mode(of: attachment.network) {
mode = vmnetMode == .host ? .host : .sharedVmnet
} else {
return nil
}
}
func vzNetworking() -> VZNetworkDeviceConfiguration? {
func vzNetworking(forValidation: Bool) throws -> VZNetworkDeviceConfiguration? {
let config = VZVirtioNetworkDeviceConfiguration()
guard let macAddress = VZMACAddress(string: macAddress) else {
return nil
@@ -108,6 +137,17 @@ struct UTMAppleConfigurationNetwork: Codable, Identifiable {
let attachment = VZBridgedNetworkDeviceAttachment(interface: found)
config.attachment = attachment
}
case .sharedVmnet, .host:
guard #available(macOS 26, *), let vmnetMode = mode.vmnetMode else {
throw UTMAppleConfigurationError.featureNotSupported
}
if forValidation {
// Validate the device configuration without creating or borrowing a live network.
// Network creation and its entitlement checks happen only when starting a VM.
config.attachment = VZNATNetworkDeviceAttachment()
} else {
config.attachment = try UTMAppleVmnetNetworkManager.shared.attachment(for: vmnetMode)
}
}
return config
}
@@ -38,6 +38,11 @@ struct VMConfigAppleNetworkingView: View {
commitMacAddress()
}
}
if config.mode == .sharedVmnet {
Text("Virtual machines using this mode in the same UTM app can connect to each other and access the internet through your Mac.")
} else if config.mode == .host {
Text("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.")
}
if config.mode == .bridged {
Section(header: Text("Bridged Settings")) {
Picker("Interface", selection: $config.bridgeInterface) {
+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="shared vmnet" code="ShVm" description="NAT based sharing with the host on a network shared with 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 sharedVmnet = 0x5368566d /* 'ShVm' */
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 .sharedVmnet: return .sharedVmnet
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 .sharedVmnet: return .sharedVmnet
case .host: return .host
case .bridged: return .bridged
default: return Optional.none
}
}
+1 -1
View File
@@ -537,7 +537,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
let serialPort = UTMSerialPort(portNamed: name, readFileHandle: slaveTtyHandle, writeFileHandle: slaveTtyHandle, terminalFileHandle: terminalTtyHandle)
config.serials[i].interface = serialPort
}
let vzConfig = try config.appleVZConfiguration()
let vzConfig = try config.appleVZConfiguration(forValidation: false)
var usbControllerDelegate: AnyObject?
if #available(macOS 27, *), hasUsbRedirection {
if isRestoring {
+107
View File
@@ -0,0 +1,107 @@
//
// Copyright © 2026 osy. 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, *)
final class UTMAppleVmnetNetworkManager {
static let shared = UTMAppleVmnetNetworkManager()
/// Operating mode of a managed network.
enum Mode {
/// Guests share a network and reach the outside world through the host (NAT).
case shared
/// Guests share a network that only the host can reach.
case host
fileprivate var operatingMode: vmnet_mode_t {
switch self {
case .shared: return .VMNET_SHARED_MODE
case .host: return .VMNET_HOST_MODE
}
}
}
private struct Entry {
weak var attachment: VZVmnetNetworkDeviceAttachment?
}
private let lock = NSLock()
private var entries: [Mode: Entry] = [:]
private init() {
}
/// Mode of a network managed here, or nil for any other network.
func mode(of network: vmnet_network_ref) -> Mode? {
lock.lock()
defer { lock.unlock() }
return entries.first(where: { $0.value.attachment?.network == network })?.key
}
/// Returns an owning attachment, sharing the network with any existing users of `mode`.
///
/// The weak reference is promoted while locked, so a concurrent teardown cannot invalidate
/// the returned attachment. No explicit release or virtual machine state bookkeeping is needed.
func attachment(for mode: Mode) throws -> VZVmnetNetworkDeviceAttachment {
lock.lock()
defer { lock.unlock() }
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
}
private func createNetwork(mode: Mode) throws -> vmnet_network_ref {
var status = vmnet_return_t.VMNET_SUCCESS
guard let configuration = vmnet_network_configuration_create(mode.operatingMode, &status) else {
throw UTMAppleVmnetNetworkError.creationFailed(status)
}
defer {
Unmanaged<CFTypeRef>.fromOpaque(UnsafeRawPointer(configuration)).release()
}
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 %d).", comment: "UTMAppleVmnetNetworkManager"), 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 */,