From 75b5d63387060b5b401fbf34c979aa4c97519cca Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Wed, 13 Apr 2022 16:42:27 -0400 Subject: [PATCH] Refactored UI (#22) * Simplified crafting of a VM config * Configure display * Propagate VM name to the title --- Sources/tart/Commands/Run.swift | 20 +++++- Sources/tart/Commands/Set.swift | 28 ++++++++ Sources/tart/Utils.swift | 7 ++ Sources/tart/VM.swift | 121 ++++++++++++++------------------ Sources/tart/VMConfig.swift | 60 +++++++++------- Sources/tart/VMDirectory.swift | 7 +- Sources/tart/VMStorage.swift | 4 +- 7 files changed, 145 insertions(+), 102 deletions(-) create mode 100644 Sources/tart/Utils.swift diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 70aa462..d9a037d 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -39,8 +39,20 @@ struct Run: AsyncParsableCommand { struct MainApp: App { var body: some Scene { - WindowGroup { - VMView(vm: vm!) + WindowGroup(vm!.name) { + Group { + VMView(vm: vm!).onAppear { + NSWindow.allowsAutomaticWindowTabbing = false + } + }.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height)) + }.commands { + // Remove some standard menu options + CommandGroup(replacing: .help, addition: {}) + CommandGroup(replacing: .newItem, addition: {}) + CommandGroup(replacing: .pasteboard, addition: {}) + CommandGroup(replacing: .textEditing, addition: {}) + CommandGroup(replacing: .undoRedo, addition: {}) + CommandGroup(replacing: .windowSize, addition: {}) } } } @@ -56,7 +68,9 @@ struct VMView: NSViewRepresentable { @ObservedObject var vm: VM func makeNSView(context: Context) -> NSViewType { - VZVirtualMachineView() + let machineView = VZVirtualMachineView() + machineView.capturesSystemKeys = true + return machineView } func updateNSView(_ nsView: NSViewType, context: Context) { diff --git a/Sources/tart/Commands/Set.swift b/Sources/tart/Commands/Set.swift index fff7adb..c6f82ce 100644 --- a/Sources/tart/Commands/Set.swift +++ b/Sources/tart/Commands/Set.swift @@ -13,6 +13,9 @@ struct Set: AsyncParsableCommand { @Option(help: "VM memory size in megabytes") var memory: UInt16? + @Option(help: "VM display settings in a format of x(x)?. For example, 1200x800 or 1200x800x72") + var display: VMDisplayConfig? + func run() async throws { do { let vmStorage = VMStorage() @@ -27,6 +30,18 @@ struct Set: AsyncParsableCommand { try vmConfig.setMemory(memorySize: UInt64(memory) * 1024 * 1024) } + if let display = display { + if (display.width > 0) { + vmConfig.display.width = display.width + } + if (display.height > 0) { + vmConfig.display.height = display.height + } + if (display.dpi > 0) { + vmConfig.display.dpi = display.dpi + } + } + try vmConfig.save(toURL: vmDir.configURL) Foundation.exit(0) @@ -37,3 +52,16 @@ struct Set: AsyncParsableCommand { } } } + +extension VMDisplayConfig: ExpressibleByArgument { + public init(argument: String) { + let parts = argument.components(separatedBy: "x").map { + Int($0) ?? 0 + } + self = VMDisplayConfig( + width: parts[safe: 0] ?? 0, + height: parts[safe: 1] ?? 0, + dpi: parts[safe: 2] ?? 0 + ) + } +} diff --git a/Sources/tart/Utils.swift b/Sources/tart/Utils.swift new file mode 100644 index 0000000..b64461a --- /dev/null +++ b/Sources/tart/Utils.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Collection { + subscript (safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index ce93423..c1668b9 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -18,36 +18,31 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { var sema = DispatchSemaphore(value: 0) // VM's config - var vmConfig: VMConfig + var name: String + + // VM's config + var config: VMConfig init(vmDir: VMDirectory) throws { let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL) - self.vmConfig = try VMConfig.init(fromURL: vmDir.configURL) + name = vmDir.name + config = try VMConfig.init(fromURL: vmDir.configURL) - let configuration = try VM.craftConfiguration( - diskURL: vmDir.diskURL, - ecid: vmConfig.ecid, - auxStorage: auxStorage, - hardwareModel: vmConfig.hardwareModel, - cpuCount: vmConfig.cpuCount, - memorySize: vmConfig.memorySize, - macAddress: vmConfig.macAddress - ) - - self.virtualMachine = VZVirtualMachine(configuration: configuration) + let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config) + virtualMachine = VZVirtualMachine(configuration: configuration) super.init() - self.virtualMachine.delegate = self + virtualMachine.delegate = self } static func retrieveLatestIPSW() async throws -> URL { defaultLogger.appendNewLine("Looking up the latest supported IPSW...") let image = try await withCheckedThrowingContinuation { continuation in VZMacOSRestoreImage.fetchLatestSupported() { result in - continuation.resume(with: result) - } + continuation.resume(with: result) + } } @@ -65,16 +60,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { let data: Data = try await withCheckedThrowingContinuation { continuation in let downloadedTask = URLSession.shared.dataTask(with: image.url) { data, response, error in - if error != nil { - continuation.resume(throwing: error!) - return - } - if (data == nil) { - continuation.resume(throwing: DownloadFailed()) - return - } - continuation.resume(returning: data!) - } + if error != nil { + continuation.resume(throwing: error!) + return + } + if (data == nil) { + continuation.resume(throwing: DownloadFailed()) + return + } + continuation.resume(returning: data!) + } ProgressObserver(downloadedTask.progress).log(defaultLogger) downloadedTask.resume() } @@ -90,8 +85,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // that match both the image and our platform let image = try await withCheckedThrowingContinuation { continuation in VZMacOSRestoreImage.load(from: ipswURL) { result in - continuation.resume(with: result) - } + continuation.resume(with: result) + } } guard let requirements = image.mostFeaturefulSupportedConfiguration else { @@ -107,91 +102,77 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { try diskFileHandle.truncate(atOffset: diskSize) try diskFileHandle.close() + name = vmDir.name // Create config - self.vmConfig = VMConfig( + config = VMConfig( hardwareModel: requirements.hardwareModel, cpuCountMin: requirements.minimumSupportedCPUCount, memorySizeMin: requirements.minimumSupportedMemorySize ) - try self.vmConfig.save(toURL: vmDir.configURL) + try config.save(toURL: vmDir.configURL) // Initialize the virtual machine and its configuration - let configuration = try VM.craftConfiguration( - diskURL: vmDir.diskURL, - ecid: self.vmConfig.ecid, - auxStorage: auxStorage, - hardwareModel: requirements.hardwareModel, - cpuCount: self.vmConfig.cpuCount, - memorySize: self.vmConfig.memorySize, - macAddress: self.vmConfig.macAddress - ) - self.virtualMachine = VZVirtualMachine(configuration: configuration) + let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config) + virtualMachine = VZVirtualMachine(configuration: configuration) super.init() - self.virtualMachine.delegate = self + virtualMachine.delegate = self // Run automated installation try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in DispatchQueue.main.async { - let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL) + let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL) - defaultLogger.appendNewLine("Installing OS...") - ProgressObserver(installer.progress).log(defaultLogger) + defaultLogger.appendNewLine("Installing OS...") + ProgressObserver(installer.progress).log(defaultLogger) - installer.install { result in - continuation.resume(with: result) - } - } + installer.install { result in + continuation.resume(with: result) + } + } } } func run() async throws { try await withCheckedThrowingContinuation { continuation in DispatchQueue.main.async { - self.virtualMachine.start(completionHandler: { result in - continuation.resume(with: result) - }) - } + self.virtualMachine.start(completionHandler: { result in + continuation.resume(with: result) + }) + } } sema.wait() } - static func craftConfiguration( - diskURL: URL, - ecid: VZMacMachineIdentifier, - auxStorage: VZMacAuxiliaryStorage, - hardwareModel: VZMacHardwareModel, - cpuCount: Int, - memorySize: UInt64, - macAddress: VZMACAddress - ) throws -> VZVirtualMachineConfiguration { + static func craftConfiguration(diskURL: URL, auxStorage: VZMacAuxiliaryStorage, vmConfig: VMConfig) throws -> VZVirtualMachineConfiguration { let configuration = VZVirtualMachineConfiguration() // Boot loader configuration.bootLoader = VZMacOSBootLoader() // CPU and memory - configuration.cpuCount = cpuCount - configuration.memorySize = memorySize + configuration.cpuCount = vmConfig.cpuCount + configuration.memorySize = vmConfig.memorySize // Platform let platform = VZMacPlatformConfiguration() - platform.machineIdentifier = ecid + platform.machineIdentifier = vmConfig.ecid platform.auxiliaryStorage = auxStorage - platform.hardwareModel = hardwareModel + platform.hardwareModel = vmConfig.hardwareModel configuration.platform = platform // Display let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration() - guard let mainScreen = NSScreen.main else { - throw NoMainScreenFoundError() - } graphicsDeviceConfiguration.displays = [ - VZMacGraphicsDisplayConfiguration(for: mainScreen, sizeInPoints: mainScreen.frame.size) + VZMacGraphicsDisplayConfiguration( + widthInPixels: vmConfig.display.width, + heightInPixels: vmConfig.display.height, + pixelsPerInch: vmConfig.display.dpi + ) ] configuration.graphicsDevices = [graphicsDeviceConfiguration] @@ -202,7 +183,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Networking let vio = VZVirtioNetworkDeviceConfiguration() vio.attachment = VZNATNetworkDeviceAttachment() - vio.macAddress = macAddress + vio.macAddress = vmConfig.macAddress configuration.networkDevices = [vio] // Storage diff --git a/Sources/tart/VMConfig.swift b/Sources/tart/VMConfig.swift index 345a43e..fbba131 100644 --- a/Sources/tart/VMConfig.swift +++ b/Sources/tart/VMConfig.swift @@ -9,7 +9,7 @@ class LessThanMinimalResourcesError: NSObject, LocalizedError { override var description: String { get { - return "LessThanMinimalResourcesError: \(self.userExplanation)" + "LessThanMinimalResourcesError: \(userExplanation)" } } } @@ -23,9 +23,16 @@ enum CodingKeys: String, CodingKey { case memorySizeMin case memorySize case macAddress + case display } -struct VMConfig: Encodable, Decodable { +struct VMDisplayConfig: Codable { + var width: Int = 1024 + var height: Int = 768 + var dpi: Int = 72 +} + +struct VMConfig: Codable { var version: Int = 1 var ecid: VZMacMachineIdentifier var hardwareModel: VZMacHardwareModel @@ -34,6 +41,8 @@ struct VMConfig: Encodable, Decodable { var memorySizeMin: UInt64 private(set) var memorySize: UInt64 var macAddress: VZMACAddress + + var display: VMDisplayConfig = VMDisplayConfig() init( ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(), @@ -44,11 +53,11 @@ struct VMConfig: Encodable, Decodable { ) { self.ecid = ecid self.hardwareModel = hardwareModel - self.cpuCountMin = cpuCountMin - self.cpuCount = cpuCountMin - self.memorySizeMin = memorySizeMin - self.memorySize = memorySizeMin self.macAddress = macAddress + self.cpuCountMin = cpuCountMin + self.memorySizeMin = memorySizeMin + cpuCount = cpuCountMin + memorySize = memorySizeMin } init(fromURL: URL) throws { @@ -65,7 +74,7 @@ struct VMConfig: Encodable, Decodable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.version = try container.decode(Int.self, forKey: .version) + version = try container.decode(Int.self, forKey: .version) let encodedECID = try container.decode(String.self, forKey: .ecid) guard let data = Data.init(base64Encoded: encodedECID) else { @@ -89,10 +98,10 @@ struct VMConfig: Encodable, Decodable { } self.hardwareModel = hardwareModel - self.cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin) - self.cpuCount = try container.decode(Int.self, forKey: .cpuCount) - self.memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin) - self.memorySize = try container.decode(UInt64.self, forKey: .memorySize) + cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin) + cpuCount = try container.decode(Int.self, forKey: .cpuCount) + memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin) + memorySize = try container.decode(UInt64.self, forKey: .memorySize) let encodedMacAddress = try container.decode(String.self, forKey: .macAddress) guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else { @@ -102,24 +111,27 @@ struct VMConfig: Encodable, Decodable { debugDescription: "failed to initialize VZMacAddress using the provided value") } self.macAddress = macAddress + + display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig() } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.version, forKey: .version) - try container.encode(self.ecid.dataRepresentation.base64EncodedString(), forKey: .ecid) - try container.encode(self.hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel) - try container.encode(self.cpuCountMin, forKey: .cpuCountMin) - try container.encode(self.cpuCount, forKey: .cpuCount) - try container.encode(self.memorySizeMin, forKey: .memorySizeMin) - try container.encode(self.memorySize, forKey: .memorySize) - try container.encode(self.macAddress.string, forKey: .macAddress) + try container.encode(version, forKey: .version) + try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid) + try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel) + try container.encode(cpuCountMin, forKey: .cpuCountMin) + try container.encode(cpuCount, forKey: .cpuCount) + try container.encode(memorySizeMin, forKey: .memorySizeMin) + try container.encode(memorySize, forKey: .memorySize) + try container.encode(macAddress.string, forKey: .macAddress) + try container.encode(display, forKey: .display) } mutating func setCPU(cpuCount: Int) throws { - if cpuCount < self.cpuCountMin { - throw LessThanMinimalResourcesError("VM should have \(self.cpuCountMin) CPU cores" + if cpuCount < cpuCountMin { + throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores" + " at minimum (requested \(cpuCount))") } @@ -127,9 +139,9 @@ struct VMConfig: Encodable, Decodable { } mutating func setMemory(memorySize: UInt64) throws { - if memorySize < self.memorySizeMin { - throw LessThanMinimalResourcesError("VM should have \(self.memorySizeMin) bytes" - + " of memory at minimum (requested \(self.memorySizeMin))") + if memorySize < memorySizeMin { + throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes" + + " of memory at minimum (requested \(memorySizeMin))") } self.memorySize = memorySize diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index acfc4c4..6f25e59 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -7,16 +7,17 @@ struct AlreadyInitializedVMDirectoryError: Error { } struct VMDirectory { + var name: String var baseURL: URL var configURL: URL { - self.baseURL.appendingPathComponent("config.json") + baseURL.appendingPathComponent("config.json") } var diskURL: URL { - self.baseURL.appendingPathComponent("disk.bin") + baseURL.appendingPathComponent("disk.bin") } var nvramURL: URL { - self.baseURL.appendingPathComponent("nvram.bin") + baseURL.appendingPathComponent("nvram.bin") } var initialized: Bool { diff --git a/Sources/tart/VMStorage.swift b/Sources/tart/VMStorage.swift index 546ddf7..b8b0b02 100644 --- a/Sources/tart/VMStorage.swift +++ b/Sources/tart/VMStorage.swift @@ -9,7 +9,7 @@ struct VMStorage { public static let tartCacheDir: URL = tartHomeDir.appendingPathComponent("cache", isDirectory: true) func create(_ name: String) throws -> VMDirectory { - let vmDir = VMDirectory(baseURL: vmURL(name)) + let vmDir = VMDirectory(name: name, baseURL: vmURL(name)) try vmDir.initialize() @@ -17,7 +17,7 @@ struct VMStorage { } func read(_ name: String) throws -> VMDirectory { - let vmDir = VMDirectory(baseURL: vmURL(name)) + let vmDir = VMDirectory(name: name, baseURL: vmURL(name)) try vmDir.validate()