From 39470cba6f4fbee314be0f3ab2a3f7c4dd9c7543 Mon Sep 17 00:00:00 2001 From: Ori Date: Mon, 25 May 2026 20:29:48 +0300 Subject: [PATCH] feat(apple): host only networking for Apple Virtualization Co-authored-by: codex --- Configuration/UTMAppleConfiguration.swift | 5 +- .../UTMAppleConfigurationNetwork.swift | 71 ++++++++++++++++++- .../macOS/VMConfigAppleNetworkingView.swift | 31 +++++++- Scripting/UTM.sdef | 1 + Scripting/UTMScripting.swift | 2 +- Scripting/UTMScriptingConfigImpl.swift | 4 +- 6 files changed, 109 insertions(+), 5 deletions(-) diff --git a/Configuration/UTMAppleConfiguration.swift b/Configuration/UTMAppleConfiguration.swift index 5465842ec5..3d196bce76 100644 --- a/Configuration/UTMAppleConfiguration.swift +++ b/Configuration/UTMAppleConfiguration.swift @@ -101,6 +101,7 @@ enum UTMAppleConfigurationError: Error { case hardwareModelInvalid case rosettaNotSupported case featureNotSupported + case networkConfigurationFailed } extension UTMAppleConfigurationError: LocalizedError { @@ -118,6 +119,8 @@ extension UTMAppleConfigurationError: LocalizedError { return NSLocalizedString("Rosetta is not supported on the current host machine.", comment: "UTMAppleConfiguration") case .featureNotSupported: return NSLocalizedString("The host operating system needs to be updated to support one or more features requested by the guest.", comment: "UTMAppleConfiguration") + case .networkConfigurationFailed: + return NSLocalizedString("The requested network mode could not be configured on this host.", comment: "UTMAppleConfiguration") } } } @@ -279,7 +282,7 @@ extension UTMAppleConfiguration { } } } - vzconfig.networkDevices.append(contentsOf: networks.compactMap({ $0.vzNetworking() })) + vzconfig.networkDevices.append(contentsOf: try networks.compactMap({ try $0.vzNetworking() })) vzconfig.serialPorts.append(contentsOf: serials.compactMap({ $0.vzSerial() })) // add remaining devices try virtualization.fillVZConfiguration(vzconfig, isMacOSGuest: system.boot.operatingSystem == .macOS) diff --git a/Configuration/UTMAppleConfigurationNetwork.swift b/Configuration/UTMAppleConfigurationNetwork.swift index 5c67d4805b..5dbbf7069c 100644 --- a/Configuration/UTMAppleConfigurationNetwork.swift +++ b/Configuration/UTMAppleConfigurationNetwork.swift @@ -16,17 +16,23 @@ import Foundation import Virtualization +#if os(macOS) && compiler(>=6.3) +import ObjectiveC +import vmnet +#endif @available(iOS, unavailable, message: "Apple Virtualization not available on iOS") @available(macOS 11, *) struct UTMAppleConfigurationNetwork: Codable, Identifiable { enum NetworkMode: String, CaseIterable, QEMUConstant { case shared = "Shared" + case host = "Host" case bridged = "Bridged" var prettyValue: String { switch self { case .shared: return NSLocalizedString("Shared Network", comment: "UTMAppleConfigurationNetwork") + case .host: return NSLocalizedString("Host Only", comment: "UTMAppleConfigurationNetwork") case .bridged: return NSLocalizedString("Bridged (Advanced)", comment: "UTMAppleConfigurationNetwork") } } @@ -78,11 +84,17 @@ struct UTMAppleConfigurationNetwork: Codable, Identifiable { } else if let _ = virtioConfig.attachment as? VZNATNetworkDeviceAttachment { mode = .shared } else { + #if os(macOS) && compiler(>=6.3) + if #available(macOS 26, *), let _ = virtioConfig.attachment as? VZVmnetNetworkDeviceAttachment { + mode = .host + return + } + #endif return nil } } - func vzNetworking() -> VZNetworkDeviceConfiguration? { + func vzNetworking() throws -> VZNetworkDeviceConfiguration? { let config = VZVirtioNetworkDeviceConfiguration() guard let macAddress = VZMACAddress(string: macAddress) else { return nil @@ -92,6 +104,16 @@ struct UTMAppleConfigurationNetwork: Codable, Identifiable { case .shared: let attachment = VZNATNetworkDeviceAttachment() config.attachment = attachment + case .host: + #if os(macOS) && compiler(>=6.3) + if #available(macOS 26, *) { + config.attachment = try makeVmnetHostNetworkAttachment() + } else { + throw UTMAppleConfigurationError.featureNotSupported + } + #else + throw UTMAppleConfigurationError.featureNotSupported + #endif case .bridged: var found: VZBridgedNetworkInterface? if let bridgeInterface = bridgeInterface { @@ -114,6 +136,53 @@ struct UTMAppleConfigurationNetwork: Codable, Identifiable { } } +#if os(macOS) && compiler(>=6.3) +@available(iOS, unavailable, message: "Apple Virtualization not available on iOS") +@available(macOS 26, *) +private final class UTMAppleVmnetNetworkReference { + private let configuration: CFTypeRef + private let network: CFTypeRef + + init(configuration: CFTypeRef, network: vmnet_network_ref) { + self.configuration = configuration + self.network = Unmanaged.fromOpaque(UnsafeRawPointer(network)).takeRetainedValue() + } +} + +@available(iOS, unavailable, message: "Apple Virtualization not available on iOS") +@available(macOS 26, *) +private var vmnetNetworkReferenceAssociationKey: UInt8 = 0 + +@available(iOS, unavailable, message: "Apple Virtualization not available on iOS") +@available(macOS 26, *) +private extension UTMAppleConfigurationNetwork { + func makeVmnetHostNetworkAttachment() throws -> VZNetworkDeviceAttachment { + var status: vmnet_return_t = .VMNET_SUCCESS + guard let configuration = vmnet_network_configuration_create(.VMNET_HOST_MODE, &status) else { + throw UTMAppleConfigurationError.networkConfigurationFailed + } + let configurationReference = Unmanaged.fromOpaque(UnsafeRawPointer(configuration)).takeRetainedValue() + guard status == .VMNET_SUCCESS else { + throw UTMAppleConfigurationError.networkConfigurationFailed + } + + status = .VMNET_SUCCESS + guard let network = vmnet_network_create(configuration, &status) else { + throw UTMAppleConfigurationError.networkConfigurationFailed + } + let reference = UTMAppleVmnetNetworkReference(configuration: configurationReference, network: network) + guard status == .VMNET_SUCCESS else { + _ = reference + throw UTMAppleConfigurationError.networkConfigurationFailed + } + + let attachment = VZVmnetNetworkDeviceAttachment(network: network) + objc_setAssociatedObject(attachment, &vmnetNetworkReferenceAssociationKey, reference, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return attachment + } +} +#endif + // MARK: - Conversion of old config format @available(iOS, unavailable, message: "Apple Virtualization not available on iOS") diff --git a/Platform/macOS/VMConfigAppleNetworkingView.swift b/Platform/macOS/VMConfigAppleNetworkingView.swift index 03b8b03666..3626cdfc9b 100644 --- a/Platform/macOS/VMConfigAppleNetworkingView.swift +++ b/Platform/macOS/VMConfigAppleNetworkingView.swift @@ -22,9 +22,27 @@ struct VMConfigAppleNetworkingView: View { @EnvironmentObject private var data: UTMData @State private var newMacAddress: String? + private var isHostOnlySupported: Bool { + #if compiler(>=6.3) + if #available(macOS 26, *) { + return true + } else { + return false + } + #else + return false + #endif + } + var body: some View { Form { - VMConfigConstantPicker("Network Mode", selection: $config.mode) + Picker("Network Mode", selection: $config.mode) { + ForEach(UTMAppleConfigurationNetwork.NetworkMode.allCases, id: \.rawValue) { mode in + Text(mode.prettyValue) + .tag(mode) + .disabled(mode == .host && !isHostOnlySupported) + } + } HStack { TextField("MAC Address", text: $newMacAddress.bound, onCommit: { commitMacAddress() @@ -38,6 +56,17 @@ struct VMConfigAppleNetworkingView: View { commitMacAddress() } } + if config.mode == .host { + Section(header: Text("Host Only Settings")) { + if isHostOnlySupported { + Text("The guest can communicate with this Mac, but not the internet.") + .foregroundColor(.secondary) + } else { + Text("Host Only requires macOS 26 or later.") + .foregroundColor(.secondary) + } + } + } if config.mode == .bridged { Section(header: Text("Bridged Settings")) { Picker("Interface", selection: $config.bridgeInterface) { diff --git a/Scripting/UTM.sdef b/Scripting/UTM.sdef index 41af0f1e9c..eb20d35e11 100644 --- a/Scripting/UTM.sdef +++ b/Scripting/UTM.sdef @@ -689,6 +689,7 @@ + diff --git a/Scripting/UTMScripting.swift b/Scripting/UTMScripting.swift index 0121c4a6de..6d817c2b1a 100644 --- a/Scripting/UTMScripting.swift +++ b/Scripting/UTMScripting.swift @@ -141,6 +141,7 @@ import ScriptingBridge // MARK: UTMScriptingAppleNetworkMode @objc public enum UTMScriptingAppleNetworkMode : AEKeyword { case shared = 0x53685264 /* 'ShRd' */ + case host = 0x486f5374 /* 'HoSt' */ case bridged = 0x42724764 /* 'BrGd' */ } @@ -288,4 +289,3 @@ extension SBObject: UTMScriptingGuestProcess {} @objc optional func disconnect() // Disconnect a USB device from the guest and re-assign it to the host. } extension SBObject: UTMScriptingUsbDevice {} - diff --git a/Scripting/UTMScriptingConfigImpl.swift b/Scripting/UTMScriptingConfigImpl.swift index 5f24f1a50a..048f1b8f9c 100644 --- a/Scripting/UTMScriptingConfigImpl.swift +++ b/Scripting/UTMScriptingConfigImpl.swift @@ -254,6 +254,7 @@ extension UTMScriptingConfigImpl { private func appleNetworkMode(from mode: UTMAppleConfigurationNetwork.NetworkMode) -> UTMScriptingAppleNetworkMode { switch mode { case .shared: return .shared + case .host: return .host case .bridged: return .bridged } } @@ -697,11 +698,12 @@ 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 .host: return .host case .bridged: return .bridged default: return Optional.none }