From bd735c3b464ec0870f2dc5c73df92519acb7d302 Mon Sep 17 00:00:00 2001 From: Ori <1646245+orikad@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:47:53 +0300 Subject: [PATCH] apple: add host-only networking mode Use vmnet host mode on macOS 26 and later so Apple Virtualization VMs can communicate with the Mac without internet routing. Expose the mode in the Apple network configuration UI and scripting bridge while keeping availability checks for older SDKs and hosts. Assisted-by: Codex:gpt5.5 Assisted-by: Codex:gpt-5 --- 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 }