mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-12 02:24:01 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' */
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */,
|
||||
|
||||
Reference in New Issue
Block a user