From 31b431f8b5ae3c64a2c8800fc6212b074048f088 Mon Sep 17 00:00:00 2001 From: lovon-spec <257083181+lovon-spec@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:52:20 -0400 Subject: [PATCH 1/3] 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 --- Configuration/UTMAppleConfiguration.swift | 6 +- .../UTMAppleConfigurationNetwork.swift | 42 ++++++- .../macOS/VMConfigAppleNetworkingView.swift | 5 + Scripting/UTM.sdef | 2 + Scripting/UTMScripting.swift | 2 + Scripting/UTMScriptingConfigImpl.swift | 7 +- Services/UTMAppleVirtualMachine.swift | 2 +- Services/UTMAppleVmnetNetworkManager.swift | 107 ++++++++++++++++++ UTM.xcodeproj/project.pbxproj | 4 + 9 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 Services/UTMAppleVmnetNetworkManager.swift diff --git a/Configuration/UTMAppleConfiguration.swift b/Configuration/UTMAppleConfiguration.swift index 607e05fe..b23adfe7 100644 --- a/Configuration/UTMAppleConfiguration.swift +++ b/Configuration/UTMAppleConfiguration.swift @@ -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.. 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 } diff --git a/Platform/macOS/VMConfigAppleNetworkingView.swift b/Platform/macOS/VMConfigAppleNetworkingView.swift index 03b8b036..25e65708 100644 --- a/Platform/macOS/VMConfigAppleNetworkingView.swift +++ b/Platform/macOS/VMConfigAppleNetworkingView.swift @@ -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) { diff --git a/Scripting/UTM.sdef b/Scripting/UTM.sdef index 81547f67..fbc68895 100644 --- a/Scripting/UTM.sdef +++ b/Scripting/UTM.sdef @@ -756,6 +756,8 @@ + + diff --git a/Scripting/UTMScripting.swift b/Scripting/UTMScripting.swift index 1b8dfb3f..4bed0379 100644 --- a/Scripting/UTMScripting.swift +++ b/Scripting/UTMScripting.swift @@ -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' */ } diff --git a/Scripting/UTMScriptingConfigImpl.swift b/Scripting/UTMScriptingConfigImpl.swift index 6fc1811d..6cf3f9d3 100644 --- a/Scripting/UTMScriptingConfigImpl.swift +++ b/Scripting/UTMScriptingConfigImpl.swift @@ -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 } } diff --git a/Services/UTMAppleVirtualMachine.swift b/Services/UTMAppleVirtualMachine.swift index db55d46d..d4fe1a9c 100644 --- a/Services/UTMAppleVirtualMachine.swift +++ b/Services/UTMAppleVirtualMachine.swift @@ -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 { diff --git a/Services/UTMAppleVmnetNetworkManager.swift b/Services/UTMAppleVmnetNetworkManager.swift new file mode 100644 index 00000000..04c91ae2 --- /dev/null +++ b/Services/UTMAppleVmnetNetworkManager.swift @@ -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.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.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) + } + } +} diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index d8fa89d2..9f8b3729 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -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 = ""; }; CE88A1642E24E4C000EAA28E /* VMKeyboardMap.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = VMKeyboardMap.m; sourceTree = ""; }; CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleVirtualMachine.swift; sourceTree = ""; }; + 4F9F544E438D4BE8A5CA9360 /* UTMAppleVmnetNetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleVmnetNetworkManager.swift; sourceTree = ""; }; CE928C3026ACCDEA0099F293 /* VMAppleRemovableDrivesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMAppleRemovableDrivesView.swift; sourceTree = ""; }; CE9375A024BBDDD10074066F /* VMConfigDriveDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigDriveDetailsView.swift; sourceTree = ""; }; CE95877426D74C2A0086BDE8 /* iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iOS.entitlements; sourceTree = ""; }; @@ -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 */, From fe74b1d8cce5c23cbeaea30b324a7a94f5b36954 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:39:50 +0200 Subject: [PATCH 2/3] apple: release the virtual machine when it fails to start No delegate callback follows a failed start, restore, or install, so the VZVirtualMachine created for the attempt was kept until the next start and the serial port PTYs stayed open and advertised for a stopped VM. With vmnet networking this also kept the network and its subnet reserved with nothing running. Drop the virtual machine and close the serial ports in that case, unless the guest is still alive and will report its own stop. Assisted-by: Claude:claude-fable-5-1 --- Services/UTMAppleVirtualMachine.swift | 46 +++++++++++++++++++++------ 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/Services/UTMAppleVirtualMachine.swift b/Services/UTMAppleVirtualMachine.swift index d4fe1a9c..ced0f34e 100644 --- a/Services/UTMAppleVirtualMachine.swift +++ b/Services/UTMAppleVirtualMachine.swift @@ -235,6 +235,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { if #available(macOS 27, *) { await stopUsbPassthrough() } + await releaseFailedAppleVM() state = .stopped try? await deleteSnapshot() throw error @@ -537,7 +538,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(forValidation: false) + let vzConfig = try config.appleVZConfiguration() var usbControllerDelegate: AnyObject? if #available(macOS 27, *), hasUsbRedirection { if isRestoring { @@ -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) 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 From 47eadda150b70b3c4354eb9e693c5d3dd0dae5ea Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:39:51 +0200 Subject: [PATCH 3/3] apple: make the vmnet modes predictable and simpler Follow-up to the vmnet networking modes: - Rename "Shared Network (VM to VM)" to "NAT Network" (config value "NATNetwork", scripting term "nat network") so the picker does not offer two "Shared" modes that differ only by a suffix. - Request a fixed subnet per mode outside of the ranges vmnet hands out by default. Those ranges are also used by NAT attachments and QEMU, so guest addresses depended on what else was running whenever the network was (re)created. Fall back to a vmnet chosen subnet when ours is taken. - Show the mode description with DetailedSection. The bare Text row widened the form and clipped the labels and controls in the settings window. - Only require macOS 26 when a network is actually created, so a VM using one of these modes can still be edited and saved on older hosts. - Do not apply locale digit grouping to the vmnet error code. - Fold the validation flag into the existing one that skipped drives and leave the device unattached instead of using a NAT placeholder, remove the unused reverse mapping, key the networks by vmnet_mode_t, and isolate the manager to the main actor in place of a lock with no concurrent users. Use .1 as the host-side address within each preferred /24. As reproduced in PR #7865, vmnet accepts .0 during network creation but the interface fails later at startup. Assisted-by: Claude:claude-fable-5-1 Assisted-by: ChatGPT:GPT-6-Astra-Pro --- Configuration/UTMAppleConfiguration.swift | 6 +- .../UTMAppleConfigurationNetwork.swift | 37 +++------- .../macOS/VMConfigAppleNetworkingView.swift | 21 ++++-- Scripting/UTM.sdef | 2 +- Scripting/UTMScripting.swift | 2 +- Scripting/UTMScriptingConfigImpl.swift | 4 +- Services/UTMAppleVmnetNetworkManager.swift | 74 ++++++++++--------- 7 files changed, 72 insertions(+), 74 deletions(-) diff --git a/Configuration/UTMAppleConfiguration.swift b/Configuration/UTMAppleConfiguration.swift index b23adfe7..39b993b5 100644 --- a/Configuration/UTMAppleConfiguration.swift +++ b/Configuration/UTMAppleConfiguration.swift @@ -241,7 +241,7 @@ extension UTMAppleConfiguration { @available(iOS, unavailable, message: "Apple Virtualization not available on iOS") @MainActor extension UTMAppleConfiguration { - func appleVZConfiguration(forValidation: Bool, 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 @@ -350,7 +350,7 @@ private extension String { #endif // validate before we copy and create drive images - try appleVZConfiguration(forValidation: true, ignoringDrives: true).validate() + try appleVZConfiguration(forValidation: true).validate() for i in 0.. VZNetworkDeviceConfiguration? { + @MainActor func vzNetworking(forValidation: Bool) throws -> VZNetworkDeviceConfiguration? { let config = VZVirtioNetworkDeviceConfiguration() guard let macAddress = VZMACAddress(string: macAddress) else { return nil @@ -137,16 +125,13 @@ 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) + 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 diff --git a/Platform/macOS/VMConfigAppleNetworkingView.swift b/Platform/macOS/VMConfigAppleNetworkingView.swift index 25e65708..bf4e44e1 100644 --- a/Platform/macOS/VMConfigAppleNetworkingView.swift +++ b/Platform/macOS/VMConfigAppleNetworkingView.swift @@ -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() @@ -38,11 +44,6 @@ 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) { @@ -58,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 diff --git a/Scripting/UTM.sdef b/Scripting/UTM.sdef index fbc68895..64a2a0c6 100644 --- a/Scripting/UTM.sdef +++ b/Scripting/UTM.sdef @@ -756,7 +756,7 @@ - + diff --git a/Scripting/UTMScripting.swift b/Scripting/UTMScripting.swift index 4bed0379..909d3084 100644 --- a/Scripting/UTMScripting.swift +++ b/Scripting/UTMScripting.swift @@ -141,7 +141,7 @@ import ScriptingBridge // MARK: UTMScriptingAppleNetworkMode @objc public enum UTMScriptingAppleNetworkMode : AEKeyword { case shared = 0x53685264 /* 'ShRd' */ - case sharedVmnet = 0x5368566d /* 'ShVm' */ + case natNetwork = 0x4e614e77 /* 'NaNw' */ case host = 0x486f5374 /* 'HoSt' */ case bridged = 0x42724764 /* 'BrGd' */ } diff --git a/Scripting/UTMScriptingConfigImpl.swift b/Scripting/UTMScriptingConfigImpl.swift index 6cf3f9d3..acf090c8 100644 --- a/Scripting/UTMScriptingConfigImpl.swift +++ b/Scripting/UTMScriptingConfigImpl.swift @@ -259,7 +259,7 @@ extension UTMScriptingConfigImpl { private func appleNetworkMode(from mode: UTMAppleConfigurationNetwork.NetworkMode) -> UTMScriptingAppleNetworkMode { switch mode { case .shared: return .shared - case .sharedVmnet: return .sharedVmnet + case .natNetwork: return .natNetwork case .host: return .host case .bridged: return .bridged } @@ -724,7 +724,7 @@ extension UTMScriptingConfigImpl { } switch parsed { case .shared: return .shared - case .sharedVmnet: return .sharedVmnet + case .natNetwork: return .natNetwork case .host: return .host case .bridged: return .bridged } diff --git a/Services/UTMAppleVmnetNetworkManager.swift b/Services/UTMAppleVmnetNetworkManager.swift index 04c91ae2..ebe85f33 100644 --- a/Services/UTMAppleVmnetNetworkManager.swift +++ b/Services/UTMAppleVmnetNetworkManager.swift @@ -1,5 +1,5 @@ // -// Copyright © 2026 osy. All rights reserved. +// 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. @@ -24,48 +24,20 @@ import vmnet /// 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 { +@MainActor 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 var entries: [vmnet_mode_t: 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() } + func attachment(for mode: vmnet_mode_t) throws -> VZVmnetNetworkDeviceAttachment { if let attachment = entries[mode]?.attachment { return attachment } @@ -78,14 +50,46 @@ final class UTMAppleVmnetNetworkManager { return attachment } - private func createNetwork(mode: Mode) throws -> vmnet_network_ref { + /// 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.operatingMode, &status) else { + guard let configuration = vmnet_network_configuration_create(mode, &status) else { throw UTMAppleVmnetNetworkError.creationFailed(status) } defer { Unmanaged.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) } @@ -101,7 +105,7 @@ 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) + return String.localizedStringWithFormat(NSLocalizedString("Failed to create the virtual network (vmnet error %@).", comment: "UTMAppleVmnetNetworkManager"), String(status.rawValue)) } } }