mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-12 02:24:01 +00:00
Merge pull request #7865 from lovon-spec/apple/vmnet-networking
apple: let virtual machines connect to each other
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 = 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
|
||||
@@ -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).validate()
|
||||
|
||||
for i in 0..<drives.count {
|
||||
existingDataURLs += try await _drives[i].saveData(to: dataURL)
|
||||
|
||||
@@ -21,14 +21,31 @@ import Virtualization
|
||||
struct UTMAppleConfigurationNetwork: Codable, Identifiable {
|
||||
enum NetworkMode: String, CaseIterable, QEMUConstant {
|
||||
case shared = "Shared"
|
||||
case natNetwork = "NATNetwork"
|
||||
case host = "Host"
|
||||
case bridged = "Bridged"
|
||||
|
||||
var prettyValue: String {
|
||||
switch self {
|
||||
case .shared: return NSLocalizedString("Shared Network", comment: "UTMAppleConfigurationNetwork")
|
||||
case .natNetwork: return NSLocalizedString("NAT Network", comment: "UTMAppleConfigurationNetwork")
|
||||
case .host: return NSLocalizedString("Host Only", comment: "UTMAppleConfigurationNetwork")
|
||||
case .bridged: return NSLocalizedString("Bridged (Advanced)", comment: "UTMAppleConfigurationNetwork")
|
||||
}
|
||||
}
|
||||
|
||||
var isHidden: Bool {
|
||||
switch self {
|
||||
case .natNetwork, .host:
|
||||
if #available(macOS 26, *) {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mode: NetworkMode = .shared
|
||||
@@ -81,7 +98,7 @@ struct UTMAppleConfigurationNetwork: Codable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
func vzNetworking() -> VZNetworkDeviceConfiguration? {
|
||||
@MainActor func vzNetworking(forValidation: Bool) throws -> VZNetworkDeviceConfiguration? {
|
||||
let config = VZVirtioNetworkDeviceConfiguration()
|
||||
guard let macAddress = VZMACAddress(string: macAddress) else {
|
||||
return nil
|
||||
@@ -108,6 +125,14 @@ struct UTMAppleConfigurationNetwork: Codable, Identifiable {
|
||||
let attachment = VZBridgedNetworkDeviceAttachment(interface: found)
|
||||
config.attachment = attachment
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -53,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
|
||||
|
||||
@@ -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="nat network" code="NaNw" description="NAT based sharing with the host on a network common to 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 natNetwork = 0x4e614e77 /* 'NaNw' */
|
||||
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 .natNetwork: return .natNetwork
|
||||
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 .natNetwork: return .natNetwork
|
||||
case .host: return .host
|
||||
case .bridged: return .bridged
|
||||
default: return Optional.none
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
if #available(macOS 27, *) {
|
||||
await stopUsbPassthrough()
|
||||
}
|
||||
await releaseFailedAppleVM()
|
||||
state = .stopped
|
||||
try? await deleteSnapshot()
|
||||
throw error
|
||||
@@ -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<Bool, Never>) 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
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// 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.
|
||||
// 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, *)
|
||||
@MainActor final class UTMAppleVmnetNetworkManager {
|
||||
static let shared = UTMAppleVmnetNetworkManager()
|
||||
|
||||
private struct Entry {
|
||||
weak var attachment: VZVmnetNetworkDeviceAttachment?
|
||||
}
|
||||
|
||||
private var entries: [vmnet_mode_t: Entry] = [:]
|
||||
|
||||
private init() {
|
||||
}
|
||||
|
||||
/// Returns an owning attachment, sharing the network with any existing users of `mode`.
|
||||
func attachment(for mode: vmnet_mode_t) throws -> VZVmnetNetworkDeviceAttachment {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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, &status) else {
|
||||
throw UTMAppleVmnetNetworkError.creationFailed(status)
|
||||
}
|
||||
defer {
|
||||
Unmanaged<CFTypeRef>.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)
|
||||
}
|
||||
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 %@).", comment: "UTMAppleVmnetNetworkManager"), String(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