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"
}