mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-21 09:42:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9001304c8 | ||
|
|
4e20ea8f72 | ||
|
|
678ce0a55a | ||
|
|
8273ae66e1 | ||
|
|
4a9316a377 |
+1
-1
@@ -6,6 +6,6 @@ export VERSION="${CIRRUS_TAG:-0}"
|
|||||||
|
|
||||||
mkdir -p .ci/pkg/
|
mkdir -p .ci/pkg/
|
||||||
cp .build/arm64-apple-macosx/debug/tart .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 notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
|
||||||
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
|
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ task:
|
|||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
env:
|
env:
|
||||||
MACOS_CERTIFICATE: ENCRYPTED[8a6930a8c1286e7e536ea41b7647ea40e99174ad15e9cfcc753754fea55a619b355415629dff515b54a8921643e314e5]
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [cirruslabs]
|
||||||
@@ -238,8 +238,8 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
|
|||||||
<details>
|
<details>
|
||||||
<summary>Why Tart is free and open sourced?</summary>
|
<summary>Why Tart is free and open sourced?</summary>
|
||||||
|
|
||||||
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` and it just felt right to develop Tart in the open.
|
||||||
Apple did all the heavy lifting with their `Virtualization.Framework`.
|
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.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.virtualization</key>
|
<key>com.apple.security.virtualization</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.vm.networking</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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("<new-name> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,10 +54,21 @@ struct Run: AsyncParsableCommand {
|
|||||||
""", valueName: "name:path[:ro]"))
|
""", valueName: "name:path[:ro]"))
|
||||||
var dir: [String] = []
|
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 {
|
func validate() throws {
|
||||||
if vnc && vncExperimental {
|
if vnc && vncExperimental {
|
||||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if withSoftnet && netBridged != nil {
|
||||||
|
throw ValidationError("--with-softnet and --net-bridged are mutually exclusive")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -65,7 +76,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
vm = try VM(
|
vm = try VM(
|
||||||
vmDir: vmDir,
|
vmDir: vmDir,
|
||||||
withSoftnet: withSoftnet,
|
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||||
additionalDiskAttachments: additionalDiskAttachments(),
|
additionalDiskAttachments: additionalDiskAttachments(),
|
||||||
directoryShares: directoryShares()
|
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] {
|
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
|
||||||
var result: [VZDiskImageStorageDeviceAttachment] = []
|
var result: [VZDiskImageStorageDeviceAttachment] = []
|
||||||
let readOnlySuffix = ":ro"
|
let readOnlySuffix = ":ro"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Virtualization
|
||||||
|
|
||||||
|
protocol Network {
|
||||||
|
func attachment() -> VZNetworkDeviceAttachment
|
||||||
|
func run() throws
|
||||||
|
func stop() throws
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
enum SoftnetError: Error {
|
enum SoftnetError: Error {
|
||||||
case InitializationFailed(why: String)
|
case InitializationFailed(why: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
class Softnet {
|
class Softnet: Network {
|
||||||
private let process = Process()
|
private let process = Process()
|
||||||
|
|
||||||
let vmFD: Int32
|
let vmFD: Int32
|
||||||
@@ -57,4 +58,9 @@ class Softnet {
|
|||||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachment() -> VZNetworkDeviceAttachment {
|
||||||
|
let fh = FileHandle.init(fileDescriptor: vmFD)
|
||||||
|
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
Pull.self,
|
Pull.self,
|
||||||
Push.self,
|
Push.self,
|
||||||
Prune.self,
|
Prune.self,
|
||||||
|
Rename.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
+11
-27
@@ -34,10 +34,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// VM's config
|
// VM's config
|
||||||
var config: VMConfig
|
var config: VMConfig
|
||||||
|
|
||||||
var softnet: Softnet? = nil
|
var network: Network
|
||||||
|
|
||||||
init(vmDir: VMDirectory,
|
init(vmDir: VMDirectory,
|
||||||
withSoftnet: Bool = false,
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||||
directoryShares: [DirectoryShare] = []
|
directoryShares: [DirectoryShare] = []
|
||||||
) throws {
|
) throws {
|
||||||
@@ -49,13 +49,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the virtual machine and its configuration
|
// Initialize the virtual machine and its configuration
|
||||||
if withSoftnet {
|
self.network = network
|
||||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
|
||||||
}
|
|
||||||
|
|
||||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||||
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments,
|
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
||||||
directoryShares: directoryShares)
|
directoryShares: directoryShares)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
@@ -114,7 +111,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
vmDir: VMDirectory,
|
vmDir: VMDirectory,
|
||||||
ipswURL: URL?,
|
ipswURL: URL?,
|
||||||
diskSizeGB: UInt16,
|
diskSizeGB: UInt16,
|
||||||
withSoftnet: Bool = false,
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
||||||
) async throws {
|
) async throws {
|
||||||
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
||||||
@@ -149,12 +146,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
try config.save(toURL: vmDir.configURL)
|
try config.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
// Initialize the virtual machine and its configuration
|
// Initialize the virtual machine and its configuration
|
||||||
if withSoftnet {
|
self.network = network
|
||||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
|
||||||
}
|
|
||||||
|
|
||||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||||
vmConfig: config, softnet: softnet,
|
vmConfig: config, network: network,
|
||||||
additionalDiskAttachments: additionalDiskAttachments,
|
additionalDiskAttachments: additionalDiskAttachments,
|
||||||
directoryShares: [])
|
directoryShares: [])
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
@@ -193,9 +187,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(_ recovery: Bool) async throws {
|
func run(_ recovery: Bool) async throws {
|
||||||
if let softnet = softnet {
|
try network.run()
|
||||||
try softnet.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.sync {
|
||||||
Task {
|
Task {
|
||||||
@@ -225,16 +217,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let softnet = softnet {
|
try network.stop()
|
||||||
try softnet.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func craftConfiguration(
|
static func craftConfiguration(
|
||||||
diskURL: URL,
|
diskURL: URL,
|
||||||
nvramURL: URL,
|
nvramURL: URL,
|
||||||
vmConfig: VMConfig,
|
vmConfig: VMConfig,
|
||||||
softnet: Softnet? = nil,
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
||||||
directoryShares: [DirectoryShare]
|
directoryShares: [DirectoryShare]
|
||||||
) throws -> VZVirtualMachineConfiguration {
|
) throws -> VZVirtualMachineConfiguration {
|
||||||
@@ -268,13 +258,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
// Networking
|
// Networking
|
||||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||||
|
vio.attachment = network.attachment()
|
||||||
if let softnet = softnet {
|
|
||||||
let fh = FileHandle.init(fileDescriptor: softnet.vmFD)
|
|
||||||
vio.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
|
||||||
} else {
|
|
||||||
vio.attachment = VZNATNetworkDeviceAttachment()
|
|
||||||
}
|
|
||||||
vio.macAddress = vmConfig.macAddress
|
vio.macAddress = vmConfig.macAddress
|
||||||
configuration.networkDevices = [vio]
|
configuration.networkDevices = [vio]
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ class VMStorageLocal {
|
|||||||
_ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
|
_ = 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 {
|
func delete(_ name: String) throws {
|
||||||
try FileManager.default.removeItem(at: vmURL(name))
|
try FileManager.default.removeItem(at: vmURL(name))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ apple_id {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sign {
|
sign {
|
||||||
application_identity = "Developer ID Application: Fedor Korotkov"
|
application_identity = "Developer ID Application: Cirrus Labs, Inc."
|
||||||
entitlements_file = "Resources/tart.entitlements"
|
entitlements_file = "Resources/tart.entitlements"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user