diff --git a/.ci/create-pkg.sh b/.ci/create-pkg.sh index bae223eb..660f5308 100755 --- a/.ci/create-pkg.sh +++ b/.ci/create-pkg.sh @@ -6,6 +6,6 @@ export VERSION="${CIRRUS_TAG:-0}" mkdir -p .ci/pkg/ cp .build/arm64-apple-macosx/debug/tart .ci/pkg/ -pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Fedor Korotkov (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg" +pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Cirrus Labs, Inc. (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg" xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait xcrun stapler staple "./dist/Tart-$VERSION.pkg" diff --git a/.cirrus.yml b/.cirrus.yml index 5095c7d6..6162c83a 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -22,7 +22,7 @@ task: macos_instance: image: ghcr.io/cirruslabs/macos-ventura-xcode:latest env: - MACOS_CERTIFICATE: ENCRYPTED[8a6930a8c1286e7e536ea41b7647ea40e99174ad15e9cfcc753754fea55a619b355415629dff515b54a8921643e314e5] + MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2] AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2] GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!] GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!] diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..a1ac82db --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [cirruslabs] diff --git a/README.md b/README.md index e881f23a..72dc9268 100644 --- a/README.md +++ b/README.md @@ -238,8 +238,8 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
Why Tart is free and open sourced? - Tart is a relatively small project, and it didn't feel right to try to monetize it. - Apple did all the heavy lifting with their `Virtualization.Framework`. + Apple did all the heavy lifting with their `Virtualization.Framework` and it just felt right to develop Tart in the open. + Please consider [becoming a sponsor](https://github.com/sponsors/cirruslabs) if you find Tart saving a substantial amount of money on licensing and engineering hours for your company.
diff --git a/Resources/tart.entitlements b/Resources/tart.entitlements index f7f5d7ce..dccbe21c 100644 --- a/Resources/tart.entitlements +++ b/Resources/tart.entitlements @@ -4,5 +4,7 @@ com.apple.security.virtualization + com.apple.vm.networking + - \ No newline at end of file + diff --git a/Sources/tart/Commands/Rename.swift b/Sources/tart/Commands/Rename.swift new file mode 100644 index 00000000..8caccfe0 --- /dev/null +++ b/Sources/tart/Commands/Rename.swift @@ -0,0 +1,40 @@ +import ArgumentParser +import Foundation + +struct Rename: AsyncParsableCommand { + static var configuration = CommandConfiguration(abstract: "Rename a VM") + + @Argument(help: "VM name") + var name: String + + @Argument(help: "new VM name") + var newName: String + + func validate() throws { + if newName.contains("/") { + throw ValidationError(" should be a local name") + } + } + + func run() async throws { + do { + let localStorage = VMStorageLocal() + + if !localStorage.exists(name) { + throw ValidationError("failed to rename a non-existent VM: \(name)") + } + + if localStorage.exists(newName) { + throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!") + } + + try localStorage.rename(name, newName) + + Foundation.exit(0) + } catch { + print(error) + + Foundation.exit(1) + } + } +} diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index b1d58b10..ce829a75 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -54,10 +54,21 @@ struct Run: AsyncParsableCommand { """, valueName: "name:path[:ro]")) var dir: [String] = [] + @Option(help: ArgumentHelp(""" + Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\") + """, discussion: """ + Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces. + """, valueName: "interface name")) + var netBridged: String? + func validate() throws { if vnc && vncExperimental { throw ValidationError("--vnc and --vnc-experimental are mutually exclusive") } + + if withSoftnet && netBridged != nil { + throw ValidationError("--with-softnet and --net-bridged are mutually exclusive") + } } @MainActor @@ -65,7 +76,7 @@ struct Run: AsyncParsableCommand { let vmDir = try VMStorageLocal().open(name) vm = try VM( vmDir: vmDir, - withSoftnet: withSoftnet, + network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(), additionalDiskAttachments: additionalDiskAttachments(), directoryShares: directoryShares() ) @@ -125,6 +136,47 @@ struct Run: AsyncParsableCommand { } } + func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? { + if withSoftnet { + let config = try VMConfig.init(fromURL: vmDir.configURL) + + return try Softnet(vmMACAddress: config.macAddress.string) + } + + if let netBridged = netBridged { + let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in + interface.identifier == netBridged || interface.localizedDisplayName == netBridged + } + + if matchingInterfaces.isEmpty { + let available = bridgeInterfaces().joined(separator: ", ") + throw ValidationError("no bridge interfaces matched \"\(netBridged)\", " + + "available interfaces: \(available)") + } + + if matchingInterfaces.count > 1 { + throw ValidationError("more than one bridge interface matched \"\(netBridged)\", " + + "consider refining the search criteria") + } + + return NetworkBridged(interface: matchingInterfaces.first!) + } + + return nil + } + + func bridgeInterfaces() -> [String] { + VZBridgedNetworkInterface.networkInterfaces.map { interface in + var bridgeDescription = interface.identifier + + if let localizedDisplayName = interface.localizedDisplayName { + bridgeDescription += " (or \"\(localizedDisplayName)\")" + } + + return bridgeDescription + } + } + func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] { var result: [VZDiskImageStorageDeviceAttachment] = [] let readOnlySuffix = ":ro" diff --git a/Sources/tart/Network/Network.swift b/Sources/tart/Network/Network.swift new file mode 100644 index 00000000..71496249 --- /dev/null +++ b/Sources/tart/Network/Network.swift @@ -0,0 +1,7 @@ +import Virtualization + +protocol Network { + func attachment() -> VZNetworkDeviceAttachment + func run() throws + func stop() throws +} diff --git a/Sources/tart/Network/NetworkBridged.swift b/Sources/tart/Network/NetworkBridged.swift new file mode 100644 index 00000000..6fe7fb12 --- /dev/null +++ b/Sources/tart/Network/NetworkBridged.swift @@ -0,0 +1,22 @@ +import Foundation +import Virtualization + +class NetworkBridged: Network { + let interface: VZBridgedNetworkInterface + + init(interface: VZBridgedNetworkInterface) { + self.interface = interface + } + + func attachment() -> VZNetworkDeviceAttachment { + VZBridgedNetworkDeviceAttachment(interface: interface) + } + + func run() throws { + // no-op, only used for Softnet + } + + func stop() throws { + // no-op, only used for Softnet + } +} diff --git a/Sources/tart/Network/NetworkShared.swift b/Sources/tart/Network/NetworkShared.swift new file mode 100644 index 00000000..9abacc62 --- /dev/null +++ b/Sources/tart/Network/NetworkShared.swift @@ -0,0 +1,16 @@ +import Foundation +import Virtualization + +class NetworkShared: Network { + func attachment() -> VZNetworkDeviceAttachment { + VZNATNetworkDeviceAttachment() + } + + func run() throws { + // no-op, only used for Softnet + } + + func stop() throws { + // no-op, only used for Softnet + } +} diff --git a/Sources/tart/Softnet.swift b/Sources/tart/Network/Softnet.swift similarity index 88% rename from Sources/tart/Softnet.swift rename to Sources/tart/Network/Softnet.swift index 5f4b5c41..11446886 100644 --- a/Sources/tart/Softnet.swift +++ b/Sources/tart/Network/Softnet.swift @@ -1,10 +1,11 @@ import Foundation +import Virtualization enum SoftnetError: Error { case InitializationFailed(why: String) } -class Softnet { +class Softnet: Network { private let process = Process() let vmFD: Int32 @@ -57,4 +58,9 @@ class Softnet { throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)") } } + + func attachment() -> VZNetworkDeviceAttachment { + let fh = FileHandle.init(fileDescriptor: vmFD) + return VZFileHandleNetworkDeviceAttachment(fileHandle: fh) + } } diff --git a/Sources/tart/Root.swift b/Sources/tart/Root.swift index 530afbdc..ea554721 100644 --- a/Sources/tart/Root.swift +++ b/Sources/tart/Root.swift @@ -17,6 +17,7 @@ struct Root: AsyncParsableCommand { Pull.self, Push.self, Prune.self, + Rename.self, Delete.self, ]) diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index ee07b831..5607ca23 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -34,10 +34,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // VM's config var config: VMConfig - var softnet: Softnet? = nil + var network: Network init(vmDir: VMDirectory, - withSoftnet: Bool = false, + network: Network = NetworkShared(), additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [], directoryShares: [DirectoryShare] = [] ) throws { @@ -49,13 +49,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { } // Initialize the virtual machine and its configuration - if withSoftnet { - softnet = try Softnet(vmMACAddress: config.macAddress.string) - } - + self.network = network let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, vmConfig: config, - softnet: softnet, additionalDiskAttachments: additionalDiskAttachments, + network: network, additionalDiskAttachments: additionalDiskAttachments, directoryShares: directoryShares) virtualMachine = VZVirtualMachine(configuration: configuration) @@ -114,7 +111,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt16, - withSoftnet: Bool = false, + network: Network = NetworkShared(), additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [] ) async throws { let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW(); @@ -149,12 +146,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { try config.save(toURL: vmDir.configURL) // Initialize the virtual machine and its configuration - if withSoftnet { - softnet = try Softnet(vmMACAddress: config.macAddress.string) - } - + self.network = network let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, - vmConfig: config, softnet: softnet, + vmConfig: config, network: network, additionalDiskAttachments: additionalDiskAttachments, directoryShares: []) virtualMachine = VZVirtualMachine(configuration: configuration) @@ -193,9 +187,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { } func run(_ recovery: Bool) async throws { - if let softnet = softnet { - try softnet.run() - } + try network.run() DispatchQueue.main.sync { Task { @@ -225,16 +217,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { } } - if let softnet = softnet { - try softnet.stop(); - } + try network.stop() } static func craftConfiguration( diskURL: URL, nvramURL: URL, vmConfig: VMConfig, - softnet: Softnet? = nil, + network: Network = NetworkShared(), additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment], directoryShares: [DirectoryShare] ) throws -> VZVirtualMachineConfiguration { @@ -268,13 +258,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Networking let vio = VZVirtioNetworkDeviceConfiguration() - - if let softnet = softnet { - let fh = FileHandle.init(fileDescriptor: softnet.vmFD) - vio.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fh) - } else { - vio.attachment = VZNATNetworkDeviceAttachment() - } + vio.attachment = network.attachment() vio.macAddress = vmConfig.macAddress configuration.networkDevices = [vio] diff --git a/Sources/tart/VMStorageLocal.swift b/Sources/tart/VMStorageLocal.swift index b94f9425..811a33ba 100644 --- a/Sources/tart/VMStorageLocal.swift +++ b/Sources/tart/VMStorageLocal.swift @@ -32,6 +32,10 @@ class VMStorageLocal { _ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL) } + func rename(_ name: String, _ newName: String) throws { + _ = try FileManager.default.replaceItemAt(vmURL(newName), withItemAt: vmURL(name)) + } + func delete(_ name: String) throws { try FileManager.default.removeItem(at: vmURL(name)) } diff --git a/gon.hcl b/gon.hcl index a4e866d8..aa8c9cba 100644 --- a/gon.hcl +++ b/gon.hcl @@ -7,6 +7,6 @@ apple_id { } sign { - application_identity = "Developer ID Application: Fedor Korotkov" + application_identity = "Developer ID Application: Cirrus Labs, Inc." entitlements_file = "Resources/tart.entitlements" }