diff --git a/libs/lume/README.md b/libs/lume/README.md index c329cf42..a0da2f49 100644 --- a/libs/lume/README.md +++ b/libs/lume/README.md @@ -53,6 +53,7 @@ Commands: lume delete Delete a VM lume pull Pull a macOS image from container registry lume clone Clone an existing VM + lume config Get or set lume configuration lume images List available macOS images in local cache lume ipsw Get the latest macOS restore image URL lume prune Remove cached images @@ -70,6 +71,7 @@ Command Options: --disk-size Disk size, e.g., 50GB (default: 40GB) --display Display resolution (default: 1024x768) --ipsw Path to IPSW file or 'latest' for macOS VMs + --storage VM storage location to use run: --no-display Do not start the VNC client app @@ -79,19 +81,48 @@ Command Options: --organization Organization to pull from (default: trycua) --vnc-port Port to use for the VNC server (default: 0 for auto-assign) --recovery-mode For MacOS VMs only, start VM in recovery mode (default: false) + --storage VM storage location to use set: --cpu New number of CPU cores (e.g., 4) --memory New memory size (e.g., 8192MB or 8GB) --disk-size New disk size (e.g., 40960MB or 40GB) --display New display resolution in format WIDTHxHEIGHT (e.g., 1024x768) + --storage VM storage location to use delete: --force Force deletion without confirmation + --storage VM storage location to use pull: --registry Container registry URL (default: ghcr.io) --organization Organization to pull from (default: trycua) + --storage VM storage location to use + + get: + -f, --format Output format (json|text) + --storage VM storage location to use + + stop: + --storage VM storage location to use + + clone: + --source-storage Source VM storage location + --dest-storage Destination VM storage location + + config: + get Get current configuration + storage Manage VM storage locations + add Add a new VM storage location + remove Remove a VM storage location + list List all VM storage locations + default Set the default VM storage location + cache Manage cache settings + get Get current cache directory + set Set cache directory + caching Manage image caching settings + get Show current caching status + set Enable or disable image caching serve: --port Port to listen on (default: 3000) diff --git a/libs/lume/docs/API-Reference.md b/libs/lume/docs/API-Reference.md index 71447b36..67ed42a4 100644 --- a/libs/lume/docs/API-Reference.md +++ b/libs/lume/docs/API-Reference.md @@ -15,7 +15,8 @@ curl --connect-timeout 6000 \ "memory": "4GB", "diskSize": "64GB", "display": "1024x768", - "ipsw": "latest" + "ipsw": "latest", + "storage": "ssd" }' \ http://localhost:3000/lume/vms ``` @@ -44,7 +45,8 @@ curl --connect-timeout 6000 \ "readOnly": false } ], - "recoveryMode": false + "recoveryMode": false, + "storage": "ssd" }' \ http://localhost:3000/lume/vms/lume_vm/run ``` @@ -84,9 +86,15 @@ curl --connect-timeout 6000 \ Get VM Details - GET /vms/:name ```bash +# Basic get curl --connect-timeout 6000 \ --max-time 5000 \ - http://localhost:3000/lume/vms/lume_vm\ + http://localhost:3000/lume/vms/lume_vm + +# Get with storage location specified +curl --connect-timeout 6000 \ + --max-time 5000 \ + http://localhost:3000/lume/vms/lume_vm?storage=ssd ``` ``` { @@ -111,7 +119,8 @@ curl --connect-timeout 6000 \ -d '{ "cpu": 4, "memory": "8GB", - "diskSize": "128GB" + "diskSize": "128GB", + "storage": "ssd" }' \ http://localhost:3000/lume/vms/my-vm-name ``` @@ -121,10 +130,17 @@ curl --connect-timeout 6000 \ Stop VM - POST /vms/:name/stop ```bash +# Basic stop curl --connect-timeout 6000 \ --max-time 5000 \ -X POST \ http://localhost:3000/lume/vms/my-vm-name/stop + +# Stop with storage location specified +curl --connect-timeout 6000 \ + --max-time 5000 \ + -X POST \ + http://localhost:3000/lume/vms/my-vm-name/stop?storage=ssd ``` @@ -132,10 +148,17 @@ curl --connect-timeout 6000 \ Delete VM - DELETE /vms/:name ```bash +# Basic delete curl --connect-timeout 6000 \ --max-time 5000 \ -X DELETE \ http://localhost:3000/lume/vms/my-vm-name + +# Delete with storage location specified +curl --connect-timeout 6000 \ + --max-time 5000 \ + -X DELETE \ + http://localhost:3000/lume/vms/my-vm-name?storage=ssd ``` @@ -152,7 +175,7 @@ curl --connect-timeout 6000 \ "name": "my-vm-name", "registry": "ghcr.io", "organization": "trycua", - "noCache": false + "storage": "ssd" }' \ http://localhost:3000/lume/pull ``` @@ -180,7 +203,9 @@ curl --connect-timeout 6000 \ -H "Content-Type: application/json" \ -d '{ "name": "source-vm", - "newName": "cloned-vm" + "newName": "cloned-vm", + "sourceLocation": "default", + "destLocation": "ssd" }' \ http://localhost:3000/lume/vms/clone ``` @@ -226,3 +251,101 @@ curl --connect-timeout 6000 \ http://localhost:3000/lume/prune ``` + +
+Get Configuration - GET /lume/config + +```bash +curl --connect-timeout 6000 \ + --max-time 5000 \ + http://localhost:3000/lume/config +``` + +```json +{ + "homeDirectory": "~/.lume", + "cacheDirectory": "~/.lume/cache", + "cachingEnabled": true +} +``` +
+ +
+Update Configuration - POST /lume/config + +```bash +curl --connect-timeout 6000 \ + --max-time 5000 \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "homeDirectory": "~/custom/lume", + "cacheDirectory": "~/custom/lume/cache", + "cachingEnabled": true + }' \ + http://localhost:3000/lume/config +``` +
+ +
+Get VM Storage Locations - GET /lume/config/locations + +```bash +curl --connect-timeout 6000 \ + --max-time 5000 \ + http://localhost:3000/lume/config/locations +``` + +```json +[ + { + "name": "default", + "path": "~/.lume/vms", + "isDefault": true + }, + { + "name": "ssd", + "path": "/Volumes/SSD/lume/vms", + "isDefault": false + } +] +``` +
+ +
+Add VM Storage Location - POST /lume/config/locations + +```bash +curl --connect-timeout 6000 \ + --max-time 5000 \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "name": "ssd", + "path": "/Volumes/SSD/lume/vms" + }' \ + http://localhost:3000/lume/config/locations +``` +
+ +
+Remove VM Storage Location - DELETE /lume/config/locations/:name + +```bash +curl --connect-timeout 6000 \ + --max-time 5000 \ + -X DELETE \ + http://localhost:3000/lume/config/locations/ssd +``` +
+ +
+Set Default VM Storage Location - POST /lume/config/locations/default/:name + +```bash +curl --connect-timeout 6000 \ + --max-time 5000 \ + -X POST \ + http://localhost:3000/lume/config/locations/default/ssd +``` +
diff --git a/libs/lume/docs/FAQ.md b/libs/lume/docs/FAQ.md index 9150fbb5..21d0d287 100644 --- a/libs/lume/docs/FAQ.md +++ b/libs/lume/docs/FAQ.md @@ -2,12 +2,71 @@ ### Where are the VMs stored? -VMs are stored in `~/.lume`. +VMs are stored in `~/.lume` by default. You can configure additional storage locations using the `lume config` command. ### How are images cached? Images are cached in `~/.lume/cache`. When doing `lume pull `, it will check if the image is already cached. If not, it will download the image and cache it, removing any older versions. +### Where is the configuration file stored? + +Lume follows the XDG Base Directory specification for the configuration file: + +- Configuration is stored in `$XDG_CONFIG_HOME/lume/config.yaml` (defaults to `~/.config/lume/config.yaml`) + +By default, other data is stored in: +- VM data: `~/.lume` +- Cache files: `~/.lume/cache` + +The config file contains settings for: +- VM storage locations and the default location +- Cache directory location +- Whether caching is enabled + +You can view and modify these settings using the `lume config` commands: + +```bash +# View current configuration +lume config get + +# Manage VM storage locations +lume config storage list # List all VM storage locations +lume config storage add # Add a new VM storage location +lume config storage remove # Remove a VM storage location +lume config storage default # Set the default VM storage location + +# Manage cache settings +lume config cache get # Get current cache directory +lume config cache set # Set cache directory + +# Manage image caching settings +lume config caching get # Show current caching status +lume config caching set # Enable or disable image caching +``` + +### How do I use multiple VM storage locations? + +Lume supports storing VMs in different locations (e.g., internal drive, external SSD). After configuring storage locations, you can specify which location to use with the `--storage` parameter in various commands: + +```bash +# Create a VM in a specific storage location +lume create my-vm --os macos --ipsw latest --storage ssd + +# Run a VM from a specific storage location +lume run my-vm --storage ssd + +# Delete a VM from a specific storage location +lume delete my-vm --storage ssd + +# Pull an image to a specific storage location +lume pull macos-sequoia-vanilla:latest --name my-vm --storage ssd + +# Clone a VM between storage locations +lume clone source-vm cloned-vm --source-storage default --dest-storage ssd +``` + +If you don't specify a storage location, Lume will use the default one or search across all configured locations. + ### Are VM disks taking up all the disk space? No, macOS uses sparse files, which only allocate space as needed. For example, VM disks totaling 50 GB may only use 20 GB on disk. diff --git a/libs/lume/src/Commands/Clone.swift b/libs/lume/src/Commands/Clone.swift index 85bb4125..59649638 100644 --- a/libs/lume/src/Commands/Clone.swift +++ b/libs/lume/src/Commands/Clone.swift @@ -5,18 +5,29 @@ struct Clone: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Clone an existing virtual machine" ) - + @Argument(help: "Name of the source virtual machine", completion: .custom(completeVMName)) var name: String - + @Argument(help: "Name for the cloned virtual machine") var newName: String - + + @Option(name: .customLong("source-storage"), help: "Source VM storage location") + var sourceStorage: String? + + @Option(name: .customLong("dest-storage"), help: "Destination VM storage location") + var destStorage: String? + init() {} - + @MainActor func run() async throws { let vmController = LumeController() - try vmController.clone(name: name, newName: newName) + try vmController.clone( + name: name, + newName: newName, + sourceLocation: sourceStorage, + destLocation: destStorage + ) } -} \ No newline at end of file +} diff --git a/libs/lume/src/Commands/Config.swift b/libs/lume/src/Commands/Config.swift new file mode 100644 index 00000000..af7f02c3 --- /dev/null +++ b/libs/lume/src/Commands/Config.swift @@ -0,0 +1,224 @@ +import ArgumentParser +import Foundation + +struct Config: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "config", + abstract: "Get or set lume configuration", + subcommands: [Get.self, Storage.self, Cache.self, Caching.self], + defaultSubcommand: Get.self + ) + + // MARK: - Basic Configuration Subcommands + + struct Get: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "get", + abstract: "Get current configuration" + ) + + func run() throws { + let controller = LumeController() + let settings = controller.getSettings() + + // Display default location + print( + "Default VM storage: \(settings.defaultLocationName) (\(settings.defaultLocation?.path ?? "not set"))" + ) + + // Display cache directory + print("Cache directory: \(settings.cacheDirectory)") + + // Display caching enabled status + print("Caching enabled: \(settings.cachingEnabled)") + + // Display all locations + if !settings.vmLocations.isEmpty { + print("\nConfigured VM storage locations:") + for location in settings.sortedLocations { + let isDefault = location.name == settings.defaultLocationName + let defaultMark = isDefault ? " (default)" : "" + print(" - \(location.name): \(location.path)\(defaultMark)") + } + } + } + } + + // MARK: - Debug Command + + struct Debug: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "debug", + abstract: "Output detailed debug information about current configuration", + shouldDisplay: false + ) + + func run() throws { + let debugInfo = SettingsManager.shared.debugSettings() + print(debugInfo) + } + } + + // MARK: - Caching Management Subcommands + + struct Caching: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "caching", + abstract: "Manage image caching settings", + subcommands: [GetCaching.self, SetCaching.self] + ) + + struct GetCaching: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "get", + abstract: "Show current caching status" + ) + + func run() throws { + let controller = LumeController() + let cachingEnabled = controller.isCachingEnabled() + print("Caching enabled: \(cachingEnabled)") + } + } + + struct SetCaching: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Enable or disable image caching" + ) + + @Argument(help: "Enable or disable caching (true/false)") + var enabled: Bool + + func run() throws { + let controller = LumeController() + try controller.setCachingEnabled(enabled) + print("Caching \(enabled ? "enabled" : "disabled")") + } + } + } + + // MARK: - Cache Management Subcommands + + struct Cache: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "cache", + abstract: "Manage cache settings", + subcommands: [GetCache.self, SetCache.self] + ) + + struct GetCache: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "get", + abstract: "Get current cache directory" + ) + + func run() throws { + let controller = LumeController() + let cacheDir = controller.getCacheDirectory() + print("Cache directory: \(cacheDir)") + } + } + + struct SetCache: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Set cache directory" + ) + + @Argument(help: "Path to cache directory") + var path: String + + func run() throws { + let controller = LumeController() + try controller.setCacheDirectory(path: path) + print("Cache directory set to: \(path)") + } + } + } + + // MARK: - Storage Management Subcommands + + struct Storage: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "storage", + abstract: "Manage VM storage locations", + subcommands: [Add.self, Remove.self, List.self, Default.self] + ) + + struct Add: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "add", + abstract: "Add a new VM storage location" + ) + + @Argument(help: "Storage name (alphanumeric with dashes/underscores)") + var name: String + + @Argument(help: "Path to VM storage directory") + var path: String + + func run() throws { + let controller = LumeController() + try controller.addLocation(name: name, path: path) + print("Added VM storage location: \(name) at \(path)") + } + } + + struct Remove: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "remove", + abstract: "Remove a VM storage location" + ) + + @Argument(help: "Storage name to remove") + var name: String + + func run() throws { + let controller = LumeController() + try controller.removeLocation(name: name) + print("Removed VM storage location: \(name)") + } + } + + struct List: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List all VM storage locations" + ) + + func run() throws { + let controller = LumeController() + let settings = controller.getSettings() + + if settings.vmLocations.isEmpty { + print("No VM storage locations configured") + return + } + + print("VM Storage Locations:") + for location in settings.sortedLocations { + let isDefault = location.name == settings.defaultLocationName + let defaultMark = isDefault ? " (default)" : "" + print(" - \(location.name): \(location.path)\(defaultMark)") + } + } + } + + struct Default: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "default", + abstract: "Set the default VM storage location" + ) + + @Argument(help: "Storage name to set as default") + var name: String + + func run() throws { + let controller = LumeController() + try controller.setDefaultLocation(name: name) + print("Set default VM storage location to: \(name)") + } + } + } +} diff --git a/libs/lume/src/Commands/Create.swift b/libs/lume/src/Commands/Create.swift index 2e0f0150..b4f02633 100644 --- a/libs/lume/src/Commands/Create.swift +++ b/libs/lume/src/Commands/Create.swift @@ -8,45 +8,56 @@ struct Create: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Create a new virtual machine" ) - + @Argument(help: "Name for the virtual machine") var name: String - @Option(help: "Operating system to install. Defaults to macOS.", completion: .list(["macOS", "linux"])) + @Option( + help: "Operating system to install. Defaults to macOS.", + completion: .list(["macOS", "linux"])) var os: String = "macOS" - + @Option(help: "Number of CPU cores", transform: { Int($0) ?? 4 }) var cpu: Int = 4 - - @Option(help: "Memory size, e.g., 8192MB or 8GB. Defaults to 8GB.", transform: { try parseSize($0) }) + + @Option( + help: "Memory size, e.g., 8192MB or 8GB. Defaults to 8GB.", transform: { try parseSize($0) } + ) var memory: UInt64 = 8 * 1024 * 1024 * 1024 - - @Option(help: "Disk size, e.g., 20480MB or 20GB. Defaults to 50GB.", transform: { try parseSize($0) }) + + @Option( + help: "Disk size, e.g., 20480MB or 20GB. Defaults to 50GB.", + transform: { try parseSize($0) }) var diskSize: UInt64 = 50 * 1024 * 1024 * 1024 @Option(help: "Display resolution in format WIDTHxHEIGHT. Defaults to 1024x768.") var display: VMDisplayResolution = VMDisplayResolution(string: "1024x768")! @Option( - help: "Path to macOS restore image (IPSW), or 'latest' to download the latest supported version. Required for macOS VMs.", + help: + "Path to macOS restore image (IPSW), or 'latest' to download the latest supported version. Required for macOS VMs.", completion: .file(extensions: ["ipsw"]) ) var ipsw: String? - + + @Option(name: .customLong("storage"), help: "VM storage location to use") + var storage: String? + init() { } - + @MainActor func run() async throws { - let vmController = LumeController() - try await vmController.create( + let controller = LumeController() + try await controller.create( name: name, os: os, diskSize: diskSize, cpuCount: cpu, memorySize: memory, display: display.string, - ipsw: ipsw + ipsw: ipsw, + storage: storage ) } -} \ No newline at end of file +} diff --git a/libs/lume/src/Commands/Delete.swift b/libs/lume/src/Commands/Delete.swift index 59c44a0b..c3cd3653 100644 --- a/libs/lume/src/Commands/Delete.swift +++ b/libs/lume/src/Commands/Delete.swift @@ -5,27 +5,33 @@ struct Delete: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Delete a virtual machine" ) - + @Argument(help: "Name of the virtual machine to delete", completion: .custom(completeVMName)) var name: String - + @Flag(name: .long, help: "Force deletion without confirmation") var force = false - + + @Option(name: .customLong("storage"), help: "VM storage location to use") + var storage: String? + init() {} - + @MainActor func run() async throws { if !force { - print("Are you sure you want to delete the virtual machine '\(name)'? [y/N] ", terminator: "") + print( + "Are you sure you want to delete the virtual machine '\(name)'? [y/N] ", + terminator: "") guard let response = readLine()?.lowercased(), - response == "y" || response == "yes" else { + response == "y" || response == "yes" + else { print("Deletion cancelled") return } } - + let vmController = LumeController() - try await vmController.delete(name: name) + try await vmController.delete(name: name, storage: storage) } -} \ No newline at end of file +} diff --git a/libs/lume/src/Commands/Get.swift b/libs/lume/src/Commands/Get.swift index e3bcbbef..5ff34113 100644 --- a/libs/lume/src/Commands/Get.swift +++ b/libs/lume/src/Commands/Get.swift @@ -8,17 +8,20 @@ struct Get: AsyncParsableCommand { @Argument(help: "Name of the virtual machine", completion: .custom(completeVMName)) var name: String - + @Option(name: [.long, .customShort("f")], help: "Output format (json|text)") var format: FormatOption = .text - + + @Option(name: .customLong("storage"), help: "VM storage location to use") + var storage: String? + init() { } - + @MainActor func run() async throws { let vmController = LumeController() - let vm = try vmController.get(name: name) + let vm = try vmController.get(name: name, storage: storage) try VMDetailsPrinter.printStatus([vm.details], format: self.format) } -} +} diff --git a/libs/lume/src/Commands/Pull.swift b/libs/lume/src/Commands/Pull.swift index 98d74eed..074e0fac 100644 --- a/libs/lume/src/Commands/Pull.swift +++ b/libs/lume/src/Commands/Pull.swift @@ -5,11 +5,12 @@ struct Pull: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Pull a macOS image from GitHub Container Registry" ) - + @Argument(help: "Image to pull (format: name:tag)") var image: String - - @Argument(help: "Name for the VM (defaults to image name without tag)", transform: { Optional($0) }) + + @Argument( + help: "Name for the VM (defaults to image name without tag)", transform: { Optional($0) }) var name: String? @Option(help: "Github Container Registry to pull from. Defaults to ghcr.io") @@ -18,14 +19,20 @@ struct Pull: AsyncParsableCommand { @Option(help: "Organization to pull from. Defaults to trycua") var organization: String = "trycua" - @Flag(help: "Pull image without creating .cache. Defaults to false") - var noCache: Bool = false + @Option(name: .customLong("storage"), help: "VM storage location to use") + var storage: String? init() {} - + @MainActor func run() async throws { - let vmController = LumeController() - try await vmController.pullImage(image: image, name: name, registry: registry, organization: organization, noCache: noCache) + let controller = LumeController() + try await controller.pullImage( + image: image, + name: name, + registry: registry, + organization: organization, + storage: storage + ) } } diff --git a/libs/lume/src/Commands/Run.swift b/libs/lume/src/Commands/Run.swift index e94ae7d3..bc659769 100644 --- a/libs/lume/src/Commands/Run.swift +++ b/libs/lume/src/Commands/Run.swift @@ -6,37 +6,57 @@ struct Run: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Run a virtual machine" ) - - @Argument(help: "Name of the virtual machine or image to pull and run (format: name or name:tag)", completion: .custom(completeVMName)) + + @Argument( + help: "Name of the virtual machine or image to pull and run (format: name or name:tag)", + completion: .custom(completeVMName)) var name: String - + @Flag(name: [.short, .long], help: "Do not start the VNC client") var noDisplay: Bool = false - - @Option(name: [.customLong("shared-dir")], help: "Directory to share with the VM. Can be just a path for read-write access (e.g. ~/src) or path:tag where tag is 'ro' for read-only or 'rw' for read-write (e.g. ~/src:ro)") + + @Option( + name: [.customLong("shared-dir")], + help: + "Directory to share with the VM. Can be just a path for read-write access (e.g. ~/src) or path:tag where tag is 'ro' for read-only or 'rw' for read-write (e.g. ~/src:ro)" + ) var sharedDirectories: [String] = [] - @Option(help: "For Linux VMs only, a read-only disk image to attach to the VM (e.g. --mount=\"ubuntu.iso\")", completion: .file()) - var mount: Path? - + @Option( + help: + "For Linux VMs only, a read-only disk image to attach to the VM (e.g. --mount=\"ubuntu.iso\")", + completion: .file()) + var mount: String? + + @Option( + name: [.customLong("usb-storage")], + help: "Disk image to attach as a USB mass storage device (e.g. --usb-storage=\"disk.img\")", + completion: .file()) + var usbStorageDevices: [String] = [] + @Option(help: "Github Container Registry to pull the images from. Defaults to ghcr.io") var registry: String = "ghcr.io" @Option(help: "Organization to pull the images from. Defaults to trycua") var organization: String = "trycua" - - @Option(name: [.customLong("vnc-port")], help: "Port to use for the VNC server. Defaults to 0 (auto-assign)") + + @Option( + name: [.customLong("vnc-port")], + help: "Port to use for the VNC server. Defaults to 0 (auto-assign)") var vncPort: Int = 0 @Option(help: "For MacOS VMs only, boot into the VM in recovery mode") var recoveryMode: Bool = false + @Option(name: .customLong("storage"), help: "VM storage location to use") + var storage: String? + private var parsedSharedDirectories: [SharedDirectory] { get throws { try sharedDirectories.map { dirString -> SharedDirectory in let components = dirString.split(separator: ":", maxSplits: 1) let hostPath = String(components[0]) - + // If no tag is provided, default to read-write if components.count == 1 { return SharedDirectory( @@ -45,7 +65,7 @@ struct Run: AsyncParsableCommand { readOnly: false ) } - + // Parse the tag if provided let tag = String(components[1]) let readOnly: Bool @@ -55,9 +75,11 @@ struct Run: AsyncParsableCommand { case "rw": readOnly = false default: - throw ValidationError("Invalid tag value. Must be either 'ro' for read-only or 'rw' for read-write") + throw ValidationError( + "Invalid tag value. Must be either 'ro' for read-only or 'rw' for read-write" + ) } - + return SharedDirectory( hostPath: hostPath, tag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag, @@ -66,24 +88,27 @@ struct Run: AsyncParsableCommand { } } } - + + private var parsedUSBStorageDevices: [Path] { + usbStorageDevices.map { Path($0) } + } + init() { } @MainActor func run() async throws { - let vmController = LumeController() - let dirs = try parsedSharedDirectories - - try await vmController.runVM( + try await LumeController().runVM( name: name, noDisplay: noDisplay, - sharedDirectories: dirs, - mount: mount, + sharedDirectories: parsedSharedDirectories, + mount: mount.map { Path($0) }, registry: registry, organization: organization, vncPort: vncPort, - recoveryMode: recoveryMode + recoveryMode: recoveryMode, + storage: storage, + usbMassStoragePaths: parsedUSBStorageDevices.isEmpty ? nil : parsedUSBStorageDevices ) } } diff --git a/libs/lume/src/Commands/Set.swift b/libs/lume/src/Commands/Set.swift index b49e82b9..73bfe0c9 100644 --- a/libs/lume/src/Commands/Set.swift +++ b/libs/lume/src/Commands/Set.swift @@ -21,6 +21,9 @@ struct Set: AsyncParsableCommand { @Option(help: "New display resolution in format WIDTHxHEIGHT.") var display: VMDisplayResolution? + @Option(name: .customLong("storage"), help: "VM storage location to use") + var storage: String? + init() { } @@ -32,7 +35,8 @@ struct Set: AsyncParsableCommand { cpu: cpu, memory: memory, diskSize: diskSize, - display: display?.string + display: display?.string, + storage: storage ) } } diff --git a/libs/lume/src/Commands/Stop.swift b/libs/lume/src/Commands/Stop.swift index 8494d0ca..933019e5 100644 --- a/libs/lume/src/Commands/Stop.swift +++ b/libs/lume/src/Commands/Stop.swift @@ -8,13 +8,16 @@ struct Stop: AsyncParsableCommand { @Argument(help: "Name of the virtual machine", completion: .custom(completeVMName)) var name: String - + + @Option(name: .customLong("storage"), help: "VM storage location to use") + var storage: String? + init() { } @MainActor func run() async throws { let vmController = LumeController() - try await vmController.stopVM(name: name) + try await vmController.stopVM(name: name, storage: storage) } -} \ No newline at end of file +} diff --git a/libs/lume/src/ContainerRegistry/ImageContainerRegistry.swift b/libs/lume/src/ContainerRegistry/ImageContainerRegistry.swift index 880a8a0a..cd0f8fb8 100644 --- a/libs/lume/src/ContainerRegistry/ImageContainerRegistry.swift +++ b/libs/lume/src/ContainerRegistry/ImageContainerRegistry.swift @@ -48,7 +48,7 @@ actor ProgressTracker { private var progressLogger = ProgressLogger(threshold: 0.01) private var totalFiles: Int = 0 private var completedFiles: Int = 0 - + // Download speed tracking private var startTime: Date = Date() private var lastUpdateTime: Date = Date() @@ -56,63 +56,88 @@ actor ProgressTracker { private var speedSamples: [Double] = [] private var peakSpeed: Double = 0 private var totalElapsedTime: TimeInterval = 0 - + + // Smoothing factor for speed calculation + private var speedSmoothing: Double = 0.3 + private var smoothedSpeed: Double = 0 + func setTotal(_ total: Int64, files: Int) { totalBytes = total totalFiles = files startTime = Date() lastUpdateTime = startTime + smoothedSpeed = 0 } func addProgress(_ bytes: Int64) { downloadedBytes += bytes let now = Date() let elapsed = now.timeIntervalSince(lastUpdateTime) - - // Only update stats and display progress if enough time has passed (at least 0.5 seconds) - if elapsed >= 0.5 { - let currentSpeed = Double(downloadedBytes - lastUpdateBytes) / elapsed + + // Show first progress update immediately, then throttle updates + let shouldUpdate = (downloadedBytes <= bytes) || (elapsed >= 0.5) + + if shouldUpdate { + let currentSpeed = Double(downloadedBytes - lastUpdateBytes) / max(elapsed, 0.001) speedSamples.append(currentSpeed) - + // Cap samples array to prevent memory growth if speedSamples.count > 20 { speedSamples.removeFirst(speedSamples.count - 20) } - + // Update peak speed peakSpeed = max(peakSpeed, currentSpeed) - + + // Apply exponential smoothing to the speed + if smoothedSpeed == 0 { + smoothedSpeed = currentSpeed + } else { + smoothedSpeed = speedSmoothing * currentSpeed + (1 - speedSmoothing) * smoothedSpeed + } + // Calculate average speed over the last few samples let recentAvgSpeed = calculateAverageSpeed() - + // Calculate overall average let totalElapsed = now.timeIntervalSince(startTime) let overallAvgSpeed = totalElapsed > 0 ? Double(downloadedBytes) / totalElapsed : 0 - + let progress = Double(downloadedBytes) / Double(totalBytes) logSpeedProgress( current: progress, currentSpeed: currentSpeed, averageSpeed: recentAvgSpeed, + smoothedSpeed: smoothedSpeed, overallSpeed: overallAvgSpeed, peakSpeed: peakSpeed, context: "Downloading Image" ) - + // Update tracking variables lastUpdateTime = now lastUpdateBytes = downloadedBytes totalElapsedTime = totalElapsed } } - + private func calculateAverageSpeed() -> Double { guard !speedSamples.isEmpty else { return 0 } - // Use the most recent samples (up to last 5) - let samples = speedSamples.suffix(min(5, speedSamples.count)) - return samples.reduce(0, +) / Double(samples.count) + + // Use weighted average giving more emphasis to recent samples + var totalWeight = 0.0 + var weightedSum = 0.0 + + let samples = speedSamples.suffix(min(8, speedSamples.count)) + for (index, speed) in samples.enumerated() { + let weight = Double(index + 1) + weightedSum += speed * weight + totalWeight += weight + } + + return totalWeight > 0 ? weightedSum / totalWeight : 0 } - + func getDownloadStats() -> DownloadStats { let avgSpeed = totalElapsedTime > 0 ? Double(downloadedBytes) / totalElapsedTime : 0 return DownloadStats( @@ -123,11 +148,12 @@ actor ProgressTracker { peakSpeed: peakSpeed ) } - + private func logSpeedProgress( current: Double, currentSpeed: Double, averageSpeed: Double, + smoothedSpeed: Double, overallSpeed: Double, peakSpeed: Double, context: String @@ -136,50 +162,54 @@ actor ProgressTracker { let currentSpeedStr = formatByteSpeed(currentSpeed) let avgSpeedStr = formatByteSpeed(averageSpeed) let peakSpeedStr = formatByteSpeed(peakSpeed) - - // Calculate ETA based on overall average speed + + // Calculate ETA based on the smoothed speed which is more stable + // This provides a more realistic estimate that doesn't fluctuate as much let remainingBytes = totalBytes - downloadedBytes - let etaSeconds = overallSpeed > 0 ? Double(remainingBytes) / overallSpeed : 0 + let speedForEta = max(smoothedSpeed, averageSpeed * 0.8) // Use the higher of smoothed or 80% of avg + let etaSeconds = speedForEta > 0 ? Double(remainingBytes) / speedForEta : 0 let etaStr = formatTimeRemaining(etaSeconds) - + let progressBar = createProgressBar(progress: current) - - print("\r\(progressBar) \(progressPercent)% | Current: \(currentSpeedStr) | Avg: \(avgSpeedStr) | Peak: \(peakSpeedStr) | ETA: \(etaStr) ", terminator: "") + + print( + "\r\(progressBar) \(progressPercent)% | Current: \(currentSpeedStr) | Avg: \(avgSpeedStr) | Peak: \(peakSpeedStr) | ETA: \(etaStr) ", + terminator: "") fflush(stdout) } - + private func createProgressBar(progress: Double, width: Int = 20) -> String { let completedWidth = Int(progress * Double(width)) let remainingWidth = width - completedWidth - + let completed = String(repeating: "█", count: completedWidth) let remaining = String(repeating: "░", count: remainingWidth) - + return "[\(completed)\(remaining)]" } - + private func formatByteSpeed(_ bytesPerSecond: Double) -> String { let units = ["B/s", "KB/s", "MB/s", "GB/s"] var speed = bytesPerSecond var unitIndex = 0 - + while speed > 1024 && unitIndex < units.count - 1 { speed /= 1024 unitIndex += 1 } - + return String(format: "%.1f %@", speed, units[unitIndex]) } - + private func formatTimeRemaining(_ seconds: Double) -> String { if seconds.isNaN || seconds.isInfinite || seconds <= 0 { return "calculating..." } - + let hours = Int(seconds) / 3600 let minutes = (Int(seconds) % 3600) / 60 let secs = Int(seconds) % 60 - + if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, secs) } else { @@ -194,34 +224,34 @@ struct DownloadStats { let elapsedTime: TimeInterval let averageSpeed: Double let peakSpeed: Double - + func formattedSummary() -> String { let bytesStr = ByteCountFormatter.string(fromByteCount: downloadedBytes, countStyle: .file) let avgSpeedStr = formatSpeed(averageSpeed) let peakSpeedStr = formatSpeed(peakSpeed) let timeStr = formatTime(elapsedTime) - + return """ - Download Statistics: - - Total downloaded: \(bytesStr) - - Elapsed time: \(timeStr) - - Average speed: \(avgSpeedStr) - - Peak speed: \(peakSpeedStr) - """ + Download Statistics: + - Total downloaded: \(bytesStr) + - Elapsed time: \(timeStr) + - Average speed: \(avgSpeedStr) + - Peak speed: \(peakSpeedStr) + """ } - + private func formatSpeed(_ bytesPerSecond: Double) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .file let bytesStr = formatter.string(fromByteCount: Int64(bytesPerSecond)) return "\(bytesStr)/s" } - + private func formatTime(_ seconds: TimeInterval) -> String { let hours = Int(seconds) / 3600 let minutes = (Int(seconds) % 3600) / 60 let secs = Int(seconds) % 60 - + if hours > 0 { return String(format: "%d hours, %d minutes, %d seconds", hours, minutes, secs) } else if minutes > 0 { @@ -247,14 +277,21 @@ class ImageContainerRegistry: @unchecked Sendable { private let cacheDirectory: URL private let downloadLock = NSLock() private var activeDownloads: [String] = [] + private let cachingEnabled: Bool init(registry: String, organization: String) { self.registry = registry self.organization = organization - // Setup cache directory in user's home - let home = FileManager.default.homeDirectoryForCurrentUser - self.cacheDirectory = home.appendingPathComponent(".lume/cache/ghcr") + // Get cache directory from settings + let cacheDir = SettingsManager.shared.getCacheDirectory() + let expandedCacheDir = (cacheDir as NSString).expandingTildeInPath + self.cacheDirectory = URL(fileURLWithPath: expandedCacheDir) + .appendingPathComponent("ghcr") + + // Get caching enabled setting + self.cachingEnabled = SettingsManager.shared.isCachingEnabled() + try? FileManager.default.createDirectory( at: cacheDirectory, withIntermediateDirectories: true) @@ -311,6 +348,11 @@ class ImageContainerRegistry: @unchecked Sendable { } private func validateCache(manifest: Manifest, manifestId: String) -> Bool { + // Skip cache validation if caching is disabled + if !cachingEnabled { + return false + } + // First check if manifest exists and matches guard let cachedManifest = loadCachedManifest(manifestId: manifestId), cachedManifest.layers == manifest.layers @@ -330,6 +372,11 @@ class ImageContainerRegistry: @unchecked Sendable { } private func saveManifest(_ manifest: Manifest, manifestId: String) throws { + // Skip saving manifest if caching is disabled + if !cachingEnabled { + return + } + let manifestPath = getCachedManifestPath(manifestId: manifestId) try JSONEncoder().encode(manifest).write(to: manifestPath) } @@ -364,6 +411,11 @@ class ImageContainerRegistry: @unchecked Sendable { } private func saveImageMetadata(image: String, manifestId: String) throws { + // Skip saving metadata if caching is disabled + if !cachingEnabled { + return + } + let metadataPath = getImageCacheDirectory(manifestId: manifestId).appendingPathComponent( "metadata.json") let metadata = ImageMetadata( @@ -375,6 +427,11 @@ class ImageContainerRegistry: @unchecked Sendable { } private func cleanupOldVersions(currentManifestId: String, image: String) throws { + // Skip cleanup if caching is disabled + if !cachingEnabled { + return + } + Logger.info( "Checking for old versions of image to clean up", metadata: [ @@ -412,22 +469,53 @@ class ImageContainerRegistry: @unchecked Sendable { } } - func pull(image: String, name: String?, noCache: Bool = false) async throws { - // Validate home directory + private func optimizeNetworkSettings() { + // Set global URLSession configuration properties for better performance + URLSessionConfiguration.default.httpMaximumConnectionsPerHost = 10 + URLSessionConfiguration.default.httpShouldUsePipelining = true + URLSessionConfiguration.default.timeoutIntervalForResource = 3600 + + // Pre-warm DNS resolution + let preWarmTask = URLSession.shared.dataTask(with: URL(string: "https://\(self.registry)")!) + preWarmTask.resume() + } + + public func pull( + image: String, + name: String?, + locationName: String? = nil + ) async throws { + guard !image.isEmpty else { + throw ValidationError("Image name cannot be empty") + } + let home = Home() - try home.validateHomeDirectory() // Use provided name or derive from image let vmName = name ?? image.split(separator: ":").first.map(String.init) ?? "" - let vmDir = home.getVMDirectory(vmName) + let vmDir = try home.getVMDirectory(vmName, storage: locationName) + + // Optimize network early in the process + optimizeNetworkSettings() // Parse image name and tag let components = image.split(separator: ":") - guard components.count == 2 else { - throw PullError.invalidImageFormat + guard components.count == 2, let tag = components.last else { + throw ValidationError("Invalid image format. Expected format: name:tag") } - let imageName = String(components[0]) - let tag = String(components[1]) + + let imageName = String(components.first!) + let imageTag = String(tag) + + Logger.info( + "Pulling image", + metadata: [ + "image": image, + "name": vmName, + "location": locationName ?? "default", + "registry": registry, + "organization": organization, + ]) // Get anonymous token Logger.info("Getting registry authentication token") @@ -437,7 +525,7 @@ class ImageContainerRegistry: @unchecked Sendable { Logger.info("Fetching Image manifest") let (manifest, manifestDigest): (Manifest, String) = try await fetchManifest( repository: "\(self.organization)/\(imageName)", - tag: tag, + tag: imageTag, token: token ) @@ -459,21 +547,23 @@ class ImageContainerRegistry: @unchecked Sendable { try? FileManager.default.removeItem(at: tempVMDir) } - // Check if we have a valid cached version and noCache is false - Logger.info("Checking cache for manifest ID: \(manifestId)") - if !noCache && validateCache(manifest: manifest, manifestId: manifestId) { + // Check if caching is enabled and if we have a valid cached version + Logger.info("Caching enabled: \(cachingEnabled)") + if cachingEnabled && validateCache(manifest: manifest, manifestId: manifestId) { Logger.info("Using cached version of image") try await copyFromCache(manifest: manifest, manifestId: manifestId, to: tempVMDir) } else { - // Clean up old versions of this repository before setting up new cache - if !noCache { - try cleanupOldVersions(currentManifestId: manifestId, image: imageName) - } - - if noCache { - Logger.info("Skipping cache setup due to noCache option") + // If caching is disabled, log it + if !cachingEnabled { + Logger.info("Caching is disabled, downloading fresh copy") } else { Logger.info("Cache miss or invalid cache, setting up new cache") + } + + // Clean up old versions of this repository before setting up new cache if caching is enabled + if cachingEnabled { + try cleanupOldVersions(currentManifestId: manifestId, image: imageName) + // Setup new cache directory try setupImageCache(manifestId: manifestId) // Save new manifest @@ -500,9 +590,6 @@ class ImageContainerRegistry: @unchecked Sendable { $0.mediaType != "application/vnd.oci.empty.v1+json" }.count let totalSize = manifest.layers.reduce(0) { $0 + Int64($1.size) } - Logger.info( - "Total download size: \(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file))" - ) await progress.setTotal(totalSize, files: totalFiles) // Process layers with limited concurrency @@ -510,18 +597,25 @@ class ImageContainerRegistry: @unchecked Sendable { Logger.info( "This may take several minutes depending on the image size and your internet connection. Please wait..." ) + + // Add immediate progress indicator before starting downloads + print( + "[░░░░░░░░░░░░░░░░░░░░] 0% | Initializing downloads... | ETA: calculating... ") + fflush(stdout) + var diskParts: [(Int, URL)] = [] var totalParts = 0 - + // Adaptive concurrency based on system capabilities let memoryConstrained = determineIfMemoryConstrained() let networkQuality = determineNetworkQuality() - let maxConcurrentTasks = calculateOptimalConcurrency(memoryConstrained: memoryConstrained, networkQuality: networkQuality) - + let maxConcurrentTasks = calculateOptimalConcurrency( + memoryConstrained: memoryConstrained, networkQuality: networkQuality) + Logger.info( "Using adaptive download configuration: Concurrency=\(maxConcurrentTasks), Memory-optimized=\(memoryConstrained)" ) - + let counter = TaskCounter() try await withThrowingTaskGroup(of: Int64.self) { group in @@ -545,14 +639,14 @@ class ImageContainerRegistry: @unchecked Sendable { let size = layer.size // For memory-optimized mode - point directly to cache when possible - if !noCache && memoryConstrained + if memoryConstrained && FileManager.default.fileExists(atPath: cachedLayer.path) { // Use the cached file directly diskParts.append((partNum, cachedLayer)) // Still need to account for progress - group.addTask { @Sendable [self] in + group.addTask { [self] in await counter.increment() await progress.addProgress(Int64(size)) await counter.decrement() @@ -564,17 +658,15 @@ class ImageContainerRegistry: @unchecked Sendable { "disk.img.part.\(partNum)") diskParts.append((partNum, partURL)) - group.addTask { @Sendable [self] in + group.addTask { [self] in await counter.increment() - if !noCache - && FileManager.default.fileExists(atPath: cachedLayer.path) - { + if FileManager.default.fileExists(atPath: cachedLayer.path) { try FileManager.default.copyItem(at: cachedLayer, to: partURL) await progress.addProgress(Int64(size)) } else { // Check if this layer is already being downloaded and we're not skipping cache - if !noCache && isDownloading(digest) { + if isDownloading(digest) { try await waitForExistingDownload( digest, cachedLayer: cachedLayer) if FileManager.default.fileExists(atPath: cachedLayer.path) @@ -587,9 +679,7 @@ class ImageContainerRegistry: @unchecked Sendable { } // Start new download - if !noCache { - markDownloadStarted(digest) - } + markDownloadStarted(digest) try await self.downloadLayer( repository: "\(self.organization)/\(imageName)", @@ -598,19 +688,20 @@ class ImageContainerRegistry: @unchecked Sendable { token: token, to: partURL, maxRetries: 5, - progress: progress + progress: progress, + manifestId: manifestId ) - // Cache the downloaded layer if not in noCache mode - if !noCache { + // Cache the downloaded layer if caching is enabled + if cachingEnabled { if FileManager.default.fileExists(atPath: cachedLayer.path) { try FileManager.default.removeItem(at: cachedLayer) } try FileManager.default.copyItem( at: partURL, to: cachedLayer) - markDownloadComplete(digest) } + markDownloadComplete(digest) } await counter.decrement() @@ -635,19 +726,18 @@ class ImageContainerRegistry: @unchecked Sendable { continue } - group.addTask { @Sendable [self] in + group.addTask { [self] in await counter.increment() let cachedLayer = getCachedLayerPath( manifestId: manifestId, digest: digest) - if !noCache && FileManager.default.fileExists(atPath: cachedLayer.path) - { + if FileManager.default.fileExists(atPath: cachedLayer.path) { try FileManager.default.copyItem(at: cachedLayer, to: outputURL) await progress.addProgress(Int64(size)) } else { // Check if this layer is already being downloaded and we're not skipping cache - if !noCache && isDownloading(digest) { + if isDownloading(digest) { try await waitForExistingDownload( digest, cachedLayer: cachedLayer) if FileManager.default.fileExists(atPath: cachedLayer.path) { @@ -659,9 +749,7 @@ class ImageContainerRegistry: @unchecked Sendable { } // Start new download - if !noCache { - markDownloadStarted(digest) - } + markDownloadStarted(digest) try await self.downloadLayer( repository: "\(self.organization)/\(imageName)", @@ -670,17 +758,18 @@ class ImageContainerRegistry: @unchecked Sendable { token: token, to: outputURL, maxRetries: 5, - progress: progress + progress: progress, + manifestId: manifestId ) - // Cache the downloaded layer if not in noCache mode - if !noCache { + // Cache the downloaded layer if caching is enabled + if cachingEnabled { if FileManager.default.fileExists(atPath: cachedLayer.path) { try FileManager.default.removeItem(at: cachedLayer) } try FileManager.default.copyItem(at: outputURL, to: cachedLayer) - markDownloadComplete(digest) } + markDownloadComplete(digest) } await counter.decrement() @@ -709,51 +798,61 @@ class ImageContainerRegistry: @unchecked Sendable { if FileManager.default.fileExists(atPath: outputURL.path) { try FileManager.default.removeItem(at: outputURL) } - + // Calculate expected size from the manifest layers let expectedTotalSize = UInt64( - manifest.layers.filter { extractPartInfo(from: $0.mediaType) != nil }.reduce(0) { $0 + $1.size } + manifest.layers.filter { extractPartInfo(from: $0.mediaType) != nil }.reduce(0) + { $0 + $1.size } ) Logger.info( - "Expected final size: \(ByteCountFormatter.string(fromByteCount: Int64(expectedTotalSize), countStyle: .file))" + "Expected download size: \(ByteCountFormatter.string(fromByteCount: Int64(expectedTotalSize), countStyle: .file)) (actual disk usage will be significantly lower)" ) - + // Create sparse file of the required size FileManager.default.createFile(atPath: outputURL.path, contents: nil) let outputHandle = try FileHandle(forWritingTo: outputURL) - + // Set the file size without writing data (creates a sparse file) try outputHandle.truncate(atOffset: expectedTotalSize) - + var reassemblyProgressLogger = ProgressLogger(threshold: 0.05) var processedSize: UInt64 = 0 - + // Process each part in order for partNum in 1...totalParts { guard let (_, partURL) = diskParts.first(where: { $0.0 == partNum }) else { throw PullError.missingPart(partNum) } - - Logger.info("Processing part \(partNum) of \(totalParts): \(partURL.lastPathComponent)") - + + Logger.info( + "Processing part \(partNum) of \(totalParts): \(partURL.lastPathComponent)") + // Get part file size - let partAttributes = try FileManager.default.attributesOfItem(atPath: partURL.path) + let partAttributes = try FileManager.default.attributesOfItem( + atPath: partURL.path) let partSize = partAttributes[.size] as? UInt64 ?? 0 - + // Calculate the offset in the final file (parts are sequential) let partOffset = processedSize - + // Open input file let inputHandle = try FileHandle(forReadingFrom: partURL) - defer { try? inputHandle.close() } - + defer { + try? inputHandle.close() + // Don't delete the part file if it's from cache + if !partURL.path.contains(cacheDirectory.path) { + try? FileManager.default.removeItem(at: partURL) + } + } + // Seek to the appropriate offset in output file try outputHandle.seek(toOffset: partOffset) - + // Copy data in chunks to avoid memory issues - let chunkSize: UInt64 = determineIfMemoryConstrained() ? 256 * 1024 : 1024 * 1024 // Use smaller chunks (256KB-1MB) + let chunkSize: UInt64 = + determineIfMemoryConstrained() ? 256 * 1024 : 1024 * 1024 // Use smaller chunks (256KB-1MB) var bytesWritten: UInt64 = 0 - + while bytesWritten < partSize { // Use Foundation's autoreleasepool for proper memory management Foundation.autoreleasepool { @@ -762,48 +861,56 @@ class ImageContainerRegistry: @unchecked Sendable { if !chunk.isEmpty { try? outputHandle.write(contentsOf: chunk) bytesWritten += UInt64(chunk.count) - + // Update progress less frequently to reduce overhead - if bytesWritten % (chunkSize * 4) == 0 || bytesWritten == partSize { - let totalProgress = Double(processedSize + bytesWritten) / Double(expectedTotalSize) - reassemblyProgressLogger.logProgress(current: totalProgress, context: "Reassembling disk image") + if bytesWritten % (chunkSize * 4) == 0 + || bytesWritten == partSize + { + let totalProgress = + Double(processedSize + bytesWritten) + / Double(expectedTotalSize) + reassemblyProgressLogger.logProgress( + current: totalProgress, + context: "Reassembling disk image") } } } - + // Add a small delay every few MB to allow memory cleanup if bytesWritten % (chunkSize * 16) == 0 && bytesWritten > 0 { + // Use Thread.sleep for now, but ideally this would use a non-blocking approach + // that is appropriate for the context (sync/async) Thread.sleep(forTimeInterval: 0.01) } } } - + // Update processed size processedSize += partSize - - // Delete part file if it's not from cache to save space immediately - if noCache || !partURL.path.contains(cacheDirectory.path) { - try? FileManager.default.removeItem(at: partURL) - } } - + // Finalize progress - reassemblyProgressLogger.logProgress(current: 1.0, context: "Reassembling disk image") - Logger.info("") // Newline after progress - + reassemblyProgressLogger.logProgress( + current: 1.0, context: "Reassembling disk image") + Logger.info("") // Newline after progress + // Close the output file try outputHandle.synchronize() try outputHandle.close() - + // Verify final size - let finalSize = (try? FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? UInt64) ?? 0 + let finalSize = + (try? FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] + as? UInt64) ?? 0 Logger.info( - "Final disk image size: \(ByteCountFormatter.string(fromByteCount: Int64(finalSize), countStyle: .file))" + "Final disk image size (before sparse file optimization): \(ByteCountFormatter.string(fromByteCount: Int64(finalSize), countStyle: .file))" ) + Logger.info( + "Note: Actual disk usage will be much lower due to macOS sparse file system") if finalSize != expectedTotalSize { Logger.info( - "Warning: Final size (\(finalSize) bytes) differs from expected size (\(expectedTotalSize) bytes)" + "Warning: Final reported size (\(finalSize) bytes) differs from expected size (\(expectedTotalSize) bytes), but this doesn't affect functionality" ) } @@ -835,12 +942,27 @@ class ImageContainerRegistry: @unchecked Sendable { if FileManager.default.fileExists(atPath: vmDir.dir.path) { try FileManager.default.removeItem(at: URL(fileURLWithPath: vmDir.dir.path)) } + + // Ensure parent directory exists try FileManager.default.createDirectory( at: URL(fileURLWithPath: vmDir.dir.path).deletingLastPathComponent(), withIntermediateDirectories: true) + + // Log the final destination + Logger.info( + "Moving files to VM directory", + metadata: [ + "destination": vmDir.dir.path, + "location": locationName ?? "default", + ]) + + // Move files to final location try FileManager.default.moveItem(at: tempVMDir, to: URL(fileURLWithPath: vmDir.dir.path)) Logger.info("Download complete: Files extracted to \(vmDir.dir.path)") + Logger.info( + "Note: Actual disk usage is significantly lower than reported size due to macOS sparse file system" + ) Logger.info( "Run 'lume run \(vmName)' to reduce the disk image file size by using macOS sparse file system" ) @@ -895,52 +1017,58 @@ class ImageContainerRegistry: @unchecked Sendable { if FileManager.default.fileExists(atPath: outputURL.path) { try FileManager.default.removeItem(at: outputURL) } - + // Calculate expected total size from the cached files - let expectedTotalSize: UInt64 = diskPartSources.reduce(UInt64(0)) { (acc: UInt64, element) -> UInt64 in - let fileSize = (try? FileManager.default.attributesOfItem(atPath: element.1.path)[.size] as? UInt64 ?? 0) ?? 0 + let expectedTotalSize: UInt64 = diskPartSources.reduce(UInt64(0)) { + (acc: UInt64, element) -> UInt64 in + let fileSize = + (try? FileManager.default.attributesOfItem(atPath: element.1.path)[.size] + as? UInt64 ?? 0) ?? 0 return acc + fileSize } Logger.info( - "Expected final size from cache: \(ByteCountFormatter.string(fromByteCount: Int64(expectedTotalSize), countStyle: .file))" + "Expected download size from cache: \(ByteCountFormatter.string(fromByteCount: Int64(expectedTotalSize), countStyle: .file)) (actual disk usage will be lower)" ) // Create sparse file of the required size FileManager.default.createFile(atPath: outputURL.path, contents: nil) let outputHandle = try FileHandle(forWritingTo: outputURL) - + // Set the file size without writing data (creates a sparse file) try outputHandle.truncate(atOffset: expectedTotalSize) - + var reassemblyProgressLogger = ProgressLogger(threshold: 0.05) var processedSize: UInt64 = 0 - + // Process each part in order for partNum in 1...totalParts { guard let (_, sourceURL) = diskPartSources.first(where: { $0.0 == partNum }) else { throw PullError.missingPart(partNum) } - - Logger.info("Processing part \(partNum) of \(totalParts) from cache: \(sourceURL.lastPathComponent)") - + + Logger.info( + "Processing part \(partNum) of \(totalParts) from cache: \(sourceURL.lastPathComponent)" + ) + // Get part file size - let partAttributes = try FileManager.default.attributesOfItem(atPath: sourceURL.path) + let partAttributes = try FileManager.default.attributesOfItem( + atPath: sourceURL.path) let partSize = partAttributes[.size] as? UInt64 ?? 0 - + // Calculate the offset in the final file (parts are sequential) let partOffset = processedSize - + // Open input file let inputHandle = try FileHandle(forReadingFrom: sourceURL) defer { try? inputHandle.close() } - + // Seek to the appropriate offset in output file try outputHandle.seek(toOffset: partOffset) - + // Copy data in chunks to avoid memory issues - let chunkSize: UInt64 = determineIfMemoryConstrained() ? 256 * 1024 : 1024 * 1024 // Use smaller chunks (256KB-1MB) + let chunkSize: UInt64 = determineIfMemoryConstrained() ? 256 * 1024 : 1024 * 1024 // Use smaller chunks (256KB-1MB) var bytesWritten: UInt64 = 0 - + while bytesWritten < partSize { // Use Foundation's autoreleasepool for proper memory management Foundation.autoreleasepool { @@ -949,47 +1077,57 @@ class ImageContainerRegistry: @unchecked Sendable { if !chunk.isEmpty { try? outputHandle.write(contentsOf: chunk) bytesWritten += UInt64(chunk.count) - + // Update progress less frequently to reduce overhead if bytesWritten % (chunkSize * 4) == 0 || bytesWritten == partSize { - let totalProgress = Double(processedSize + bytesWritten) / Double(expectedTotalSize) - reassemblyProgressLogger.logProgress(current: totalProgress, context: "Reassembling disk image from cache") + let totalProgress = + Double(processedSize + bytesWritten) + / Double(expectedTotalSize) + reassemblyProgressLogger.logProgress( + current: totalProgress, + context: "Reassembling disk image from cache") } } } - + // Add a small delay every few MB to allow memory cleanup if bytesWritten % (chunkSize * 16) == 0 && bytesWritten > 0 { + // Use Thread.sleep for now, but ideally this would use a non-blocking approach + // that is appropriate for the context (sync/async) Thread.sleep(forTimeInterval: 0.01) } } } - + // Update processed size processedSize += partSize } - + // Finalize progress - reassemblyProgressLogger.logProgress(current: 1.0, context: "Reassembling disk image from cache") - Logger.info("") // Newline after progress - + reassemblyProgressLogger.logProgress( + current: 1.0, context: "Reassembling disk image from cache") + Logger.info("") // Newline after progress + // Close the output file try outputHandle.synchronize() try outputHandle.close() - + // Verify final size - let finalSize = (try? FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? UInt64) ?? 0 + let finalSize = + (try? FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? UInt64) + ?? 0 Logger.info( - "Final disk image size from cache: \(ByteCountFormatter.string(fromByteCount: Int64(finalSize), countStyle: .file))" + "Final disk image size from cache (before sparse file optimization): \(ByteCountFormatter.string(fromByteCount: Int64(finalSize), countStyle: .file))" ) if finalSize != expectedTotalSize { Logger.info( - "Warning: Final size (\(finalSize) bytes) differs from expected size (\(expectedTotalSize) bytes)" + "Warning: Final reported size (\(finalSize) bytes) differs from expected size (\(expectedTotalSize) bytes), but this doesn't affect functionality" ) } - Logger.info("Disk image reassembled successfully from cache using sparse file technique") + Logger.info( + "Disk image reassembled successfully from cache using sparse file technique") } Logger.info("Cache copy complete") @@ -1037,10 +1175,33 @@ class ImageContainerRegistry: @unchecked Sendable { token: String, to url: URL, maxRetries: Int = 5, - progress: isolated ProgressTracker + progress: isolated ProgressTracker, + manifestId: String? = nil ) async throws { var lastError: Error? + // Create a shared session configuration for all download attempts + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 3600 + config.waitsForConnectivity = true + config.httpMaximumConnectionsPerHost = 6 + config.httpShouldUsePipelining = true + config.requestCachePolicy = .reloadIgnoringLocalCacheData + + // Enable HTTP/2 when available + if #available(macOS 13.0, *) { + config.httpAdditionalHeaders = ["Connection": "keep-alive"] + } + + // Check for TCP window size and optimize if possible + if getTCPReceiveWindowSize() != nil { + config.networkServiceType = .responsiveData + } + + // Create one session to be reused across retries + let session = URLSession(configuration: config) + for attempt in 1...maxRetries { do { var request = URLRequest( @@ -1049,25 +1210,11 @@ class ImageContainerRegistry: @unchecked Sendable { request.addValue(mediaType, forHTTPHeaderField: "Accept") request.timeoutInterval = 60 - // Optimized session configuration for speed - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 60 - config.timeoutIntervalForResource = 3600 - config.waitsForConnectivity = true - - // Performance optimizations - config.httpMaximumConnectionsPerHost = 6 - config.httpShouldUsePipelining = true - config.requestCachePolicy = .reloadIgnoringLocalCacheData - - // Network service type optimization - if getTCPReceiveWindowSize() != nil { - // If we can get TCP window size, the system supports advanced networking - config.networkServiceType = .responsiveData + // Add Accept-Encoding for compressed transfer if content isn't already compressed + if !mediaType.contains("gzip") && !mediaType.contains("compressed") { + request.addValue("gzip, deflate", forHTTPHeaderField: "Accept-Encoding") } - let session = URLSession(configuration: config) - let (tempURL, response) = try await session.download(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 @@ -1079,6 +1226,18 @@ class ImageContainerRegistry: @unchecked Sendable { at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.moveItem(at: tempURL, to: url) progress.addProgress(Int64(httpResponse.expectedContentLength)) + + // Cache the downloaded layer if caching is enabled + if cachingEnabled, let manifestId = manifestId { + let cachedLayer = getCachedLayerPath(manifestId: manifestId, digest: digest) + if FileManager.default.fileExists(atPath: cachedLayer.path) { + try FileManager.default.removeItem(at: cachedLayer) + } + try FileManager.default.copyItem(at: url, to: cachedLayer) + } + + // Mark download as complete regardless of caching + markDownloadComplete(digest) return } catch { @@ -1089,7 +1248,7 @@ class ImageContainerRegistry: @unchecked Sendable { let jitter = Double.random(in: 0...1) let delay = baseDelay + jitter try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - + Logger.info("Retrying download (attempt \(attempt+1)/\(maxRetries)): \(digest)") } } @@ -1316,38 +1475,46 @@ class ImageContainerRegistry: @unchecked Sendable { } // Define chunk size parameters - let safeMinimumChunkSize = 128 * 1024 // Reduced minimum for constrained systems - let defaultChunkSize = 512 * 1024 // Standard default / minimum for non-constrained - let constrainedCap = 512 * 1024 // Lower cap for constrained systems - let standardCap = 2 * 1024 * 1024 // Standard cap for non-constrained systems + let safeMinimumChunkSize = 128 * 1024 // Reduced minimum for constrained systems + let defaultChunkSize = 512 * 1024 // Standard default / minimum for non-constrained + let constrainedCap = 512 * 1024 // Lower cap for constrained systems + let standardCap = 2 * 1024 * 1024 // Standard cap for non-constrained systems // If we can't get memory info, return a reasonable default guard result == KERN_SUCCESS else { - Logger.info("Could not get VM statistics, using default chunk size: \(defaultChunkSize) bytes") + Logger.info( + "Could not get VM statistics, using default chunk size: \(defaultChunkSize) bytes") return defaultChunkSize } // Calculate free memory in bytes - let pageSize = 4096 // Use a constant page size assumption + let pageSize = 4096 // Use a constant page size assumption let freeMemory = UInt64(stats.free_count) * UInt64(pageSize) - let isConstrained = determineIfMemoryConstrained() // Check if generally constrained + let isConstrained = determineIfMemoryConstrained() // Check if generally constrained // Extremely constrained (< 512MB free) -> use absolute minimum - if freeMemory < 536_870_912 { // 512MB - Logger.info("System extremely memory constrained (<512MB free), using minimum chunk size: \(safeMinimumChunkSize) bytes") + if freeMemory < 536_870_912 { // 512MB + Logger.info( + "System extremely memory constrained (<512MB free), using minimum chunk size: \(safeMinimumChunkSize) bytes" + ) return safeMinimumChunkSize } // Generally constrained -> use adaptive size with lower cap if isConstrained { - let adaptiveSize = min(max(Int(freeMemory / 1000), safeMinimumChunkSize), constrainedCap) - Logger.info("System memory constrained, using adaptive chunk size capped at \(constrainedCap) bytes: \(adaptiveSize) bytes") + let adaptiveSize = min( + max(Int(freeMemory / 1000), safeMinimumChunkSize), constrainedCap) + Logger.info( + "System memory constrained, using adaptive chunk size capped at \(constrainedCap) bytes: \(adaptiveSize) bytes" + ) return adaptiveSize } // Not constrained -> use original adaptive logic with standard cap let adaptiveSize = min(max(Int(freeMemory / 1000), defaultChunkSize), standardCap) - Logger.info("System has sufficient memory, using adaptive chunk size capped at \(standardCap) bytes: \(adaptiveSize) bytes") + Logger.info( + "System has sufficient memory, using adaptive chunk size capped at \(standardCap) bytes: \(adaptiveSize) bytes" + ) return adaptiveSize } @@ -1382,39 +1549,42 @@ class ImageContainerRegistry: @unchecked Sendable { private func determineNetworkQuality() -> Int { // Default quality is medium (3) var quality = 3 - + // A simple ping test to determine network quality let process = Process() process.executableURL = URL(fileURLWithPath: "/sbin/ping") process.arguments = ["-c", "3", "-q", self.registry] - + let outputPipe = Pipe() process.standardOutput = outputPipe process.standardError = outputPipe - + do { try process.run() process.waitUntilExit() - + let outputData = try outputPipe.fileHandleForReading.readToEnd() ?? Data() if let output = String(data: outputData, encoding: .utf8) { // Check for average ping time - if let avgTimeRange = output.range(of: "= [0-9.]+/([0-9.]+)/", options: .regularExpression) { + if let avgTimeRange = output.range( + of: "= [0-9.]+/([0-9.]+)/", options: .regularExpression) + { let avgSubstring = output[avgTimeRange] if let avgString = avgSubstring.split(separator: "/").dropFirst().first, - let avgTime = Double(avgString) { - + let avgTime = Double(avgString) + { + // Classify network quality based on ping time if avgTime < 50 { - quality = 5 // Excellent + quality = 5 // Excellent } else if avgTime < 100 { - quality = 4 // Good + quality = 4 // Good } else if avgTime < 200 { - quality = 3 // Average + quality = 3 // Average } else if avgTime < 300 { - quality = 2 // Poor + quality = 2 // Poor } else { - quality = 1 // Very poor + quality = 1 // Very poor } } } @@ -1423,55 +1593,57 @@ class ImageContainerRegistry: @unchecked Sendable { // Default to medium if ping fails Logger.info("Failed to determine network quality, using default settings") } - + return quality } - + // Helper method to calculate optimal concurrency based on system capabilities private func calculateOptimalConcurrency(memoryConstrained: Bool, networkQuality: Int) -> Int { // Base concurrency based on network quality (1-5) let baseThreads = min(networkQuality * 2, 8) - + if memoryConstrained { // Reduce concurrency for memory-constrained systems return max(2, baseThreads / 2) } - + // Physical cores available on the system let cores = ProcessInfo.processInfo.processorCount - + // Adaptive approach: 1-2 threads per core depending on network quality let threadsPerCore = (networkQuality >= 4) ? 2 : 1 let systemBasedThreads = min(cores * threadsPerCore, 12) - + // Take the larger of network-based and system-based concurrency return max(baseThreads, systemBasedThreads) } - + // Helper to get optimal TCP window size private func getTCPReceiveWindowSize() -> Int? { // Try to query system TCP window size let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/sbin/sysctl") process.arguments = ["net.inet.tcp.recvspace"] - + let outputPipe = Pipe() process.standardOutput = outputPipe - + do { try process.run() process.waitUntilExit() - + let outputData = try outputPipe.fileHandleForReading.readToEnd() ?? Data() if let output = String(data: outputData, encoding: .utf8), - let valueStr = output.split(separator: ":").last?.trimmingCharacters(in: .whitespacesAndNewlines), - let value = Int(valueStr) { + let valueStr = output.split(separator: ":").last?.trimmingCharacters( + in: .whitespacesAndNewlines), + let value = Int(valueStr) + { return value } } catch { // Ignore errors, we'll use defaults } - + return nil } } diff --git a/libs/lume/src/FileSystem/Home.swift b/libs/lume/src/FileSystem/Home.swift index 571a7f92..cca14062 100644 --- a/libs/lume/src/FileSystem/Home.swift +++ b/libs/lume/src/FileSystem/Home.swift @@ -4,35 +4,58 @@ import Foundation /// Responsible for creating, accessing, and validating the application's directory structure. final class Home { // MARK: - Constants - + private enum Constants { static let defaultDirectoryName = ".lume" static let homeDirPath = "~/\(defaultDirectoryName)" } - + // MARK: - Properties - - let homeDir: Path + + private var _homeDir: Path + private let settingsManager: SettingsManager private let fileManager: FileManager - - // MARK: - Initialization - - init(fileManager: FileManager = .default) { - self.fileManager = fileManager - self.homeDir = Path(Constants.homeDirPath) + private var locations: [String: VMLocation] = [:] + + // Current home directory based on default location + var homeDir: Path { + return _homeDir } - + + // MARK: - Initialization + + init( + settingsManager: SettingsManager = SettingsManager.shared, + fileManager: FileManager = .default + ) { + self.settingsManager = settingsManager + self.fileManager = fileManager + + // Get home directory path from settings or use default + let settings = settingsManager.getSettings() + guard let defaultLocation = settings.defaultLocation else { + fatalError("No default VM location found") + } + + self._homeDir = Path(defaultLocation.path) + + // Cache all locations + for location in settings.vmLocations { + locations[location.name] = location + } + } + // MARK: - VM Directory Management - + /// Creates a temporary VM directory with a unique identifier /// - Returns: A VMDirectory instance representing the created directory /// - Throws: HomeError if directory creation fails func createTempVMDirectory() throws -> VMDirectory { let uuid = UUID().uuidString let tempDir = homeDir.directory(uuid) - + Logger.info("Creating temporary directory", metadata: ["path": tempDir.path]) - + do { try createDirectory(at: tempDir.url) return VMDirectory(tempDir) @@ -40,107 +63,234 @@ final class Home { throw HomeError.directoryCreationFailed(path: tempDir.path) } } - - /// Returns a VMDirectory instance for the given name - /// - Parameter name: Name of the VM directory + + /// Gets a VM directory for a specific VM name and optional location + /// + /// - Parameters: + /// - name: Name of the VM directory + /// - storage: Optional name of the VM location (default: default location) /// - Returns: A VMDirectory instance - func getVMDirectory(_ name: String) -> VMDirectory { - VMDirectory(homeDir.directory(name)) - } - - /// Returns all initialized VM directories - /// - Returns: An array of VMDirectory instances - /// - Throws: HomeError if directory access is denied - func getAllVMDirectories() throws -> [VMDirectory] { - guard homeDir.exists() else { return [] } - - do { - let allFolders = try fileManager.contentsOfDirectory( - at: homeDir.url, - includingPropertiesForKeys: nil - ) - let folders = allFolders - .compactMap { url in - let sanitizedName = sanitizeFileName(url.lastPathComponent) - let dir = getVMDirectory(sanitizedName) - let dir1 = dir.initialized() ? dir : nil - return dir1 - } - return folders - } catch { - throw HomeError.directoryAccessDenied(path: homeDir.path) + /// - Throws: HomeError if location not found + func getVMDirectory(_ name: String, storage: String? = nil) throws -> VMDirectory { + let location: VMLocation + + if let storage = storage { + // Get a specific location + guard let loc = locations[storage] else { + throw VMLocationError.locationNotFound(name: storage) + } + location = loc + } else { + // Use default location + let settings = settingsManager.getSettings() + guard let defaultLocation = settings.defaultLocation else { + throw HomeError.invalidHomeDirectory + } + location = defaultLocation } + + let baseDir = Path(location.expandedPath) + return VMDirectory(baseDir.directory(name)) } - + + /// Returns all initialized VM directories across all locations + /// - Returns: An array of VMDirectory instances with location info + /// - Throws: HomeError if directory access is denied + func getAllVMDirectories() throws -> [VMDirectoryWithLocation] { + var results: [VMDirectoryWithLocation] = [] + + // Loop through all locations + let settings = settingsManager.getSettings() + for location in settings.vmLocations { + let locationPath = Path(location.expandedPath) + + // Skip non-existent locations + if !locationPath.exists() { + continue + } + + do { + let allFolders = try fileManager.contentsOfDirectory( + at: locationPath.url, + includingPropertiesForKeys: nil + ) + + let folders = + allFolders + .compactMap { url in + let sanitizedName = sanitizeFileName(url.lastPathComponent) + let dir = VMDirectory(locationPath.directory(sanitizedName)) + let dirWithLoc = + dir.initialized() + ? VMDirectoryWithLocation(directory: dir, locationName: location.name) + : nil + return dirWithLoc + } + + results.append(contentsOf: folders) + } catch { + Logger.error( + "Failed to access VM location", + metadata: [ + "location": location.name, + "error": error.localizedDescription, + ]) + // Continue to next location rather than failing completely + } + } + + return results + } + /// Copies a VM directory to a new location with a new name /// - Parameters: /// - sourceName: Name of the source VM /// - destName: Name for the destination VM + /// - sourceLocation: Optional name of the source location + /// - destLocation: Optional name of the destination location /// - Throws: HomeError if the copy operation fails - func copyVMDirectory(from sourceName: String, to destName: String) throws { - let sourceDir = getVMDirectory(sourceName) - let destDir = getVMDirectory(destName) - + func copyVMDirectory( + from sourceName: String, + to destName: String, + sourceLocation: String? = nil, + destLocation: String? = nil + ) throws { + let sourceDir = try getVMDirectory(sourceName, storage: sourceLocation) + let destDir = try getVMDirectory(destName, storage: destLocation) + if destDir.initialized() { throw HomeError.directoryAlreadyExists(path: destDir.dir.path) } - + do { try fileManager.copyItem(atPath: sourceDir.dir.path, toPath: destDir.dir.path) } catch { throw HomeError.directoryCreationFailed(path: destDir.dir.path) } } - - // MARK: - Directory Validation - - /// Validates and ensures the existence of the home directory - /// - Throws: HomeError if validation fails or directory creation fails - func validateHomeDirectory() throws { - if !homeDir.exists() { - try createHomeDirectory() - return + + // MARK: - Location Management + + /// Adds a new VM location + /// - Parameters: + /// - name: Location name + /// - path: Location path + /// - Throws: Error if location cannot be added + func addLocation(name: String, path: String) throws { + let location = VMLocation(name: name, path: path) + try settingsManager.addLocation(location) + + // Update cache + locations[name] = location + } + + /// Removes a VM location + /// - Parameter name: Location name + /// - Throws: Error if location cannot be removed + func removeLocation(name: String) throws { + try settingsManager.removeLocation(name: name) + + // Update cache + locations.removeValue(forKey: name) + } + + /// Sets the default VM location + /// - Parameter name: Location name + /// - Throws: Error if location cannot be set as default + func setDefaultLocation(name: String) throws { + try settingsManager.setDefaultLocation(name: name) + + // Update home directory + guard let location = locations[name] else { + throw VMLocationError.locationNotFound(name: name) } - - guard isValidDirectory(at: homeDir.path) else { + + // Update homeDir to reflect the new default + self._homeDir = Path(location.path) + } + + /// Gets all available VM locations + /// - Returns: Array of VM locations + func getLocations() -> [VMLocation] { + return settingsManager.getSettings().sortedLocations + } + + /// Gets the default VM location + /// - Returns: Default VM location + /// - Throws: HomeError if no default location + func getDefaultLocation() throws -> VMLocation { + guard let location = settingsManager.getSettings().defaultLocation else { throw HomeError.invalidHomeDirectory } + return location } - - // MARK: - Private Helpers - - private func createHomeDirectory() throws { - do { - try createDirectory(at: homeDir.url) - } catch { - throw HomeError.directoryCreationFailed(path: homeDir.path) + + // MARK: - Directory Validation + + /// Validates and ensures the existence of all VM locations + /// - Throws: HomeError if validation fails or directory creation fails + func validateHomeDirectory() throws { + let settings = settingsManager.getSettings() + + for location in settings.vmLocations { + let path = location.expandedPath + if !fileExists(at: path) { + try createVMLocation(at: path) + } else if !isValidDirectory(at: path) { + throw HomeError.invalidHomeDirectory + } } } - + + // MARK: - Private Helpers + + private func createVMLocation(at path: String) throws { + do { + try fileManager.createDirectory( + atPath: path, + withIntermediateDirectories: true + ) + } catch { + throw HomeError.directoryCreationFailed(path: path) + } + } + private func createDirectory(at url: URL) throws { try fileManager.createDirectory( at: url, withIntermediateDirectories: true ) } - + private func isValidDirectory(at path: String) -> Bool { var isDirectory: ObjCBool = false - return fileManager.fileExists(atPath: path, isDirectory: &isDirectory) - && isDirectory.boolValue + return fileManager.fileExists(atPath: path, isDirectory: &isDirectory) + && isDirectory.boolValue && Path(path).writable() } - + + private func fileExists(at path: String) -> Bool { + return fileManager.fileExists(atPath: path) + } + private func sanitizeFileName(_ name: String) -> String { // Only decode percent encoding (e.g., %20 for spaces) return name.removingPercentEncoding ?? name } } +// MARK: - VM Directory with Location + +/// Represents a VM directory with its location information +struct VMDirectoryWithLocation { + let directory: VMDirectory + let locationName: String +} + // MARK: - Home + CustomStringConvertible extension Home: CustomStringConvertible { var description: String { "Home(path: \(homeDir.path))" } -} \ No newline at end of file +} diff --git a/libs/lume/src/FileSystem/Settings.swift b/libs/lume/src/FileSystem/Settings.swift new file mode 100644 index 00000000..2025774b --- /dev/null +++ b/libs/lume/src/FileSystem/Settings.swift @@ -0,0 +1,424 @@ +import Foundation + +/// Manages the application settings using a config file +struct LumeSettings: Codable, Sendable { + var vmLocations: [VMLocation] + var defaultLocationName: String + var cacheDirectory: String + var cachingEnabled: Bool + + var defaultLocation: VMLocation? { + vmLocations.first { $0.name == defaultLocationName } + } + + // For backward compatibility + var homeDirectory: String { + defaultLocation?.path ?? "~/.lume" + } + + static let defaultSettings = LumeSettings( + vmLocations: [ + VMLocation(name: "default", path: "~/.lume") + ], + defaultLocationName: "default", + cacheDirectory: "~/.lume/cache", + cachingEnabled: true + ) + + /// Gets all locations sorted by name + var sortedLocations: [VMLocation] { + vmLocations.sorted { $0.name < $1.name } + } +} + +final class SettingsManager: @unchecked Sendable { + // MARK: - Constants + + private enum Constants { + // Default path for config + static let fallbackConfigDir = "~/.config/lume" + static let configFileName = "config.yaml" + } + + // MARK: - Properties + + static let shared = SettingsManager() + private let fileManager: FileManager + + // Get the config directory following XDG spec + private var configDir: String { + // Check XDG_CONFIG_HOME environment variable first + if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] { + return "\(xdgConfigHome)/lume" + } + // Fall back to default + return (Constants.fallbackConfigDir as NSString).expandingTildeInPath + } + + // Path to config file + private var configFilePath: String { + return "\(configDir)/\(Constants.configFileName)" + } + + // MARK: - Initialization + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + ensureConfigDirectoryExists() + } + + // MARK: - Settings Access + + func getSettings() -> LumeSettings { + if let settings = readSettingsFromFile() { + return settings + } + + // No settings file found, use defaults + let defaultSettings = LumeSettings( + vmLocations: [ + VMLocation(name: "default", path: "~/.lume") + ], + defaultLocationName: "default", + cacheDirectory: "~/.lume/cache", + cachingEnabled: true + ) + + // Try to save default settings + try? saveSettings(defaultSettings) + + return defaultSettings + } + + func saveSettings(_ settings: LumeSettings) throws { + try fileManager.createDirectory(atPath: configDir, withIntermediateDirectories: true) + + // Create a human-readable YAML-like configuration file + var yamlContent = "# Lume Configuration\n\n" + + // Default location + yamlContent += "defaultLocationName: \"\(settings.defaultLocationName)\"\n" + + // Cache directory + yamlContent += "cacheDirectory: \"\(settings.cacheDirectory)\"\n" + + // Caching enabled flag + yamlContent += "cachingEnabled: \(settings.cachingEnabled)\n" + + // VM locations + yamlContent += "\n# VM Locations\nvmLocations:\n" + for location in settings.vmLocations { + yamlContent += " - name: \"\(location.name)\"\n" + yamlContent += " path: \"\(location.path)\"\n" + } + + // Write YAML content to file + try yamlContent.write( + to: URL(fileURLWithPath: configFilePath), atomically: true, encoding: .utf8) + } + + // MARK: - VM Location Management + + func addLocation(_ location: VMLocation) throws { + var settings = getSettings() + + // Validate location name (alphanumeric, dash, underscore) + let nameRegex = try NSRegularExpression(pattern: "^[a-zA-Z0-9_-]+$") + let nameRange = NSRange(location.name.startIndex..., in: location.name) + if nameRegex.firstMatch(in: location.name, range: nameRange) == nil { + throw VMLocationError.invalidLocationName(name: location.name) + } + + // Check for duplicate name + if settings.vmLocations.contains(where: { $0.name == location.name }) { + throw VMLocationError.duplicateLocationName(name: location.name) + } + + // Validate location path + try location.validate() + + // Add location + settings.vmLocations.append(location) + try saveSettings(settings) + } + + func removeLocation(name: String) throws { + var settings = getSettings() + + // Check location exists + guard settings.vmLocations.contains(where: { $0.name == name }) else { + throw VMLocationError.locationNotFound(name: name) + } + + // Prevent removing default location + if name == settings.defaultLocationName { + throw VMLocationError.defaultLocationCannotBeRemoved(name: name) + } + + // Remove location + settings.vmLocations.removeAll(where: { $0.name == name }) + try saveSettings(settings) + } + + func setDefaultLocation(name: String) throws { + var settings = getSettings() + + // Check location exists + guard settings.vmLocations.contains(where: { $0.name == name }) else { + throw VMLocationError.locationNotFound(name: name) + } + + // Set default + settings.defaultLocationName = name + try saveSettings(settings) + } + + func getLocation(name: String) throws -> VMLocation { + let settings = getSettings() + + if let location = settings.vmLocations.first(where: { $0.name == name }) { + return location + } + + throw VMLocationError.locationNotFound(name: name) + } + + // MARK: - Legacy Home Directory Compatibility + + func setHomeDirectory(path: String) throws { + var settings = getSettings() + + let defaultLocation = VMLocation(name: "default", path: path) + try defaultLocation.validate() + + // Replace default location + if let index = settings.vmLocations.firstIndex(where: { $0.name == "default" }) { + settings.vmLocations[index] = defaultLocation + } else { + settings.vmLocations.append(defaultLocation) + settings.defaultLocationName = "default" + } + + try saveSettings(settings) + } + + // MARK: - Cache Directory Management + + func setCacheDirectory(path: String) throws { + var settings = getSettings() + + // Validate path + let expandedPath = (path as NSString).expandingTildeInPath + var isDir: ObjCBool = false + + // If directory exists, check if it's writable + if fileManager.fileExists(atPath: expandedPath, isDirectory: &isDir) { + if !isDir.boolValue { + throw SettingsError.notADirectory(path: expandedPath) + } + + if !fileManager.isWritableFile(atPath: expandedPath) { + throw SettingsError.directoryNotWritable(path: expandedPath) + } + } else { + // Try to create the directory + do { + try fileManager.createDirectory( + atPath: expandedPath, + withIntermediateDirectories: true + ) + } catch { + throw SettingsError.directoryCreationFailed(path: expandedPath, error: error) + } + } + + // Update settings + settings.cacheDirectory = path + try saveSettings(settings) + } + + func getCacheDirectory() -> String { + return getSettings().cacheDirectory + } + + func setCachingEnabled(_ enabled: Bool) throws { + var settings = getSettings() + settings.cachingEnabled = enabled + try saveSettings(settings) + } + + func isCachingEnabled() -> Bool { + return getSettings().cachingEnabled + } + + // MARK: - Private Helpers + + private func ensureConfigDirectoryExists() { + try? fileManager.createDirectory(atPath: configDir, withIntermediateDirectories: true) + } + + private func readSettingsFromFile() -> LumeSettings? { + // Read from YAML file + if fileExists(at: configFilePath) { + do { + let yamlString = try String( + contentsOf: URL(fileURLWithPath: configFilePath), encoding: .utf8) + return parseYamlSettings(yamlString) + } catch { + Logger.error( + "Failed to read settings from YAML file", + metadata: ["error": error.localizedDescription] + ) + } + } + return nil + } + + private func parseYamlSettings(_ yamlString: String) -> LumeSettings? { + // This is a very basic YAML parser for our specific config format + // A real implementation would use a proper YAML library + + var defaultLocationName = "default" + var cacheDirectory = "~/.lume/cache" + var cachingEnabled = true // default to true for backward compatibility + var vmLocations: [VMLocation] = [] + + var inLocationsSection = false + var currentLocation: (name: String?, path: String?) = (nil, nil) + + let lines = yamlString.split(separator: "\n") + + for (_, line) in lines.enumerated() { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + + // Skip comments and empty lines + if trimmedLine.hasPrefix("#") || trimmedLine.isEmpty { + continue + } + + // Check for section marker + if trimmedLine == "vmLocations:" { + inLocationsSection = true + continue + } + + // In the locations section, handle line indentation more carefully + if inLocationsSection { + if trimmedLine.hasPrefix("-") || trimmedLine.contains("- name:") { + // Process the previous location before starting a new one + if let name = currentLocation.name, let path = currentLocation.path { + vmLocations.append(VMLocation(name: name, path: path)) + } + currentLocation = (nil, nil) + } + + // Process the key-value pairs within a location + if let colonIndex = trimmedLine.firstIndex(of: ":") { + let key = trimmedLine[.. String { + if rawValue.hasPrefix("\"") && rawValue.hasSuffix("\"") && rawValue.count >= 2 { + // Remove the surrounding quotes + let startIndex = rawValue.index(after: rawValue.startIndex) + let endIndex = rawValue.index(before: rawValue.endIndex) + return String(rawValue[startIndex.. String { + let settings = getSettings() + + var output = "Current Settings:\n" + output += "- Default VM storage: \(settings.defaultLocationName)\n" + output += "- Cache directory: \(settings.cacheDirectory)\n" + output += "- VM Locations (\(settings.vmLocations.count)):\n" + + for (i, location) in settings.vmLocations.enumerated() { + let isDefault = location.name == settings.defaultLocationName + let defaultMark = isDefault ? " (default)" : "" + output += " \(i+1). \(location.name): \(location.path)\(defaultMark)\n" + } + + // Also add raw file content + if fileExists(at: configFilePath) { + if let content = try? String(contentsOf: URL(fileURLWithPath: configFilePath)) { + output += "\nRaw YAML file content:\n" + output += content + } + } + + return output + } + + private func fileExists(at path: String) -> Bool { + fileManager.fileExists(atPath: path) + } +} + +// MARK: - Errors + +enum SettingsError: Error, LocalizedError { + case notADirectory(path: String) + case directoryNotWritable(path: String) + case directoryCreationFailed(path: String, error: Error) + + var errorDescription: String? { + switch self { + case .notADirectory(let path): + return "Path is not a directory: \(path)" + case .directoryNotWritable(let path): + return "Directory is not writable: \(path)" + case .directoryCreationFailed(let path, let error): + return "Failed to create directory at \(path): \(error.localizedDescription)" + } + } +} diff --git a/libs/lume/src/FileSystem/VMLocation.swift b/libs/lume/src/FileSystem/VMLocation.swift new file mode 100644 index 00000000..f635f7b5 --- /dev/null +++ b/libs/lume/src/FileSystem/VMLocation.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Represents a location where VMs can be stored +struct VMLocation: Codable, Equatable, Sendable { + let name: String + let path: String + + var expandedPath: String { + (path as NSString).expandingTildeInPath + } + + /// Validates the location path exists and is writable + func validate() throws { + let fullPath = expandedPath + var isDir: ObjCBool = false + + if FileManager.default.fileExists(atPath: fullPath, isDirectory: &isDir) { + if !isDir.boolValue { + throw VMLocationError.notADirectory(path: fullPath) + } + + if !FileManager.default.isWritableFile(atPath: fullPath) { + throw VMLocationError.directoryNotWritable(path: fullPath) + } + } else { + // Try to create the directory + do { + try FileManager.default.createDirectory( + atPath: fullPath, + withIntermediateDirectories: true + ) + } catch { + throw VMLocationError.directoryCreationFailed(path: fullPath, error: error) + } + } + } +} + +// MARK: - Errors + +enum VMLocationError: Error, LocalizedError { + case notADirectory(path: String) + case directoryNotWritable(path: String) + case directoryCreationFailed(path: String, error: Error) + case locationNotFound(name: String) + case duplicateLocationName(name: String) + case invalidLocationName(name: String) + case defaultLocationCannotBeRemoved(name: String) + + var errorDescription: String? { + switch self { + case .notADirectory(let path): + return "Path is not a directory: \(path)" + case .directoryNotWritable(let path): + return "Directory is not writable: \(path)" + case .directoryCreationFailed(let path, let error): + return "Failed to create directory at \(path): \(error.localizedDescription)" + case .locationNotFound(let name): + return "VM location not found: \(name)" + case .duplicateLocationName(let name): + return "VM location with name '\(name)' already exists" + case .invalidLocationName(let name): + return + "Invalid location name: \(name). Names should be alphanumeric with underscores or dashes." + case .defaultLocationCannotBeRemoved(let name): + return "Cannot remove the default location '\(name)'. Set a new default location first." + } + } +} diff --git a/libs/lume/src/LumeController.swift b/libs/lume/src/LumeController.swift index 9350d007..4cb8253d 100644 --- a/libs/lume/src/LumeController.swift +++ b/libs/lume/src/LumeController.swift @@ -8,17 +8,17 @@ import Virtualization final class SharedVM { static let shared: SharedVM = SharedVM() private var runningVMs: [String: VM] = [:] - + private init() {} - + func getVM(name: String) -> VM? { return runningVMs[name] } - + func setVM(name: String, vm: VM) { runningVMs[name] = vm } - + func removeVM(name: String) { runningVMs.removeValue(forKey: name) } @@ -50,8 +50,10 @@ final class LumeController { @MainActor public func list() throws -> [VMDetails] { do { - let statuses = try home.getAllVMDirectories().map { directory in - let vm = try self.get(name: directory.name) + let vmLocations = try home.getAllVMDirectories() + let statuses = try vmLocations.map { vmWithLoc in + let vm = try self.get( + name: vmWithLoc.directory.name, storage: vmWithLoc.locationName) return vm.details } return statuses @@ -62,22 +64,39 @@ final class LumeController { } @MainActor - public func clone(name: String, newName: String) throws { + public func clone( + name: String, newName: String, sourceLocation: String? = nil, destLocation: String? = nil + ) throws { let normalizedName = normalizeVMName(name: name) let normalizedNewName = normalizeVMName(name: newName) - Logger.info("Cloning VM", metadata: ["source": normalizedName, "destination": normalizedNewName]) + Logger.info( + "Cloning VM", + metadata: [ + "source": normalizedName, + "destination": normalizedNewName, + "sourceLocation": sourceLocation ?? "default", + "destLocation": destLocation ?? "default", + ]) do { - try self.validateVMExists(normalizedName) - + // Validate source VM exists + _ = try self.validateVMExists(normalizedName, storage: sourceLocation) + // Copy the VM directory - try home.copyVMDirectory(from: normalizedName, to: normalizedNewName) - + try home.copyVMDirectory( + from: normalizedName, + to: normalizedNewName, + sourceLocation: sourceLocation, + destLocation: destLocation + ) + // Update MAC address in the cloned VM to ensure uniqueness - let clonedVM = try get(name: normalizedNewName) + let clonedVM = try get(name: normalizedNewName, storage: destLocation) try clonedVM.setMacAddress(VZMACAddress.randomLocallyAdministered().string) - - Logger.info("VM cloned successfully", metadata: ["source": normalizedName, "destination": normalizedNewName]) + + Logger.info( + "VM cloned successfully", + metadata: ["source": normalizedName, "destination": normalizedNewName]) } catch { Logger.error("Failed to clone VM", metadata: ["error": error.localizedDescription]) throw error @@ -85,12 +104,15 @@ final class LumeController { } @MainActor - public func get(name: String) throws -> VM { + public func get(name: String, storage: String? = nil) throws -> VM { let normalizedName = normalizeVMName(name: name) do { - try self.validateVMExists(normalizedName) + // Try to find the VM and get its actual location + let actualLocation = try self.validateVMExists( + normalizedName, storage: storage) - let vm = try self.loadVM(name: normalizedName) + // Load the VM from its actual location + let vm = try self.loadVM(name: normalizedName, storage: actualLocation) return vm } catch { Logger.error("Failed to get VM", metadata: ["error": error.localizedDescription]) @@ -107,13 +129,15 @@ final class LumeController { cpuCount: Int, memorySize: UInt64, display: String, - ipsw: String? + ipsw: String?, + storage: String? = nil ) async throws { Logger.info( "Creating VM", metadata: [ "name": name, "os": os, + "location": storage ?? "default", "disk_size": "\(diskSize / 1024 / 1024)MB", "cpu_count": "\(cpuCount)", "memory_size": "\(memorySize / 1024 / 1024)MB", @@ -122,7 +146,7 @@ final class LumeController { ]) do { - try validateCreateParameters(name: name, os: os, ipsw: ipsw) + try validateCreateParameters(name: name, os: os, ipsw: ipsw, storage: storage) let vm = try await createTempVMConfig( os: os, @@ -140,7 +164,7 @@ final class LumeController { display: display ) - try vm.finalize(to: name, home: home) + try vm.finalize(to: name, home: home, storage: storage) Logger.info("VM created successfully", metadata: ["name": name]) } catch { @@ -150,19 +174,26 @@ final class LumeController { } @MainActor - public func delete(name: String) async throws { + public func delete(name: String, storage: String? = nil) async throws { let normalizedName = normalizeVMName(name: name) - Logger.info("Deleting VM", metadata: ["name": normalizedName]) + Logger.info( + "Deleting VM", + metadata: [ + "name": normalizedName, + "location": storage ?? "default", + ]) do { - try self.validateVMExists(normalizedName) + // Find the actual location of the VM + let actualLocation = try self.validateVMExists( + normalizedName, storage: storage) // Stop VM if it's running if SharedVM.shared.getVM(name: normalizedName) != nil { try await stopVM(name: normalizedName) } - let vmDir = home.getVMDirectory(normalizedName) + let vmDir = try home.getVMDirectory(normalizedName, storage: actualLocation) try vmDir.delete() Logger.info("VM deleted successfully", metadata: ["name": normalizedName]) @@ -181,22 +212,26 @@ final class LumeController { cpu: Int? = nil, memory: UInt64? = nil, diskSize: UInt64? = nil, - display: String? = nil + display: String? = nil, + storage: String? = nil ) throws { let normalizedName = normalizeVMName(name: name) Logger.info( "Updating VM settings", metadata: [ "name": normalizedName, + "location": storage ?? "default", "cpu": cpu.map { "\($0)" } ?? "unchanged", "memory": memory.map { "\($0 / 1024 / 1024)MB" } ?? "unchanged", "disk_size": diskSize.map { "\($0 / 1024 / 1024)MB" } ?? "unchanged", "display": display ?? "unchanged", ]) do { - try self.validateVMExists(normalizedName) + // Find the actual location of the VM + let actualLocation = try self.validateVMExists( + normalizedName, storage: storage) - let vm = try get(name: normalizedName) + let vm = try get(name: normalizedName, storage: actualLocation) // Apply settings in order if let cpu = cpu { @@ -221,19 +256,21 @@ final class LumeController { } @MainActor - public func stopVM(name: String) async throws { + public func stopVM(name: String, storage: String? = nil) async throws { let normalizedName = normalizeVMName(name: name) Logger.info("Stopping VM", metadata: ["name": normalizedName]) do { - try self.validateVMExists(normalizedName) + // Find the actual location of the VM + let actualLocation = try self.validateVMExists( + normalizedName, storage: storage) // Try to get VM from cache first let vm: VM if let cachedVM = SharedVM.shared.getVM(name: normalizedName) { vm = cachedVM } else { - vm = try get(name: normalizedName) + vm = try get(name: normalizedName, storage: actualLocation) } try await vm.stop() @@ -257,18 +294,24 @@ final class LumeController { registry: String = "ghcr.io", organization: String = "trycua", vncPort: Int = 0, - recoveryMode: Bool = false + recoveryMode: Bool = false, + storage: String? = nil, + usbMassStoragePaths: [Path]? = nil ) async throws { let normalizedName = normalizeVMName(name: name) Logger.info( "Running VM", metadata: [ "name": normalizedName, + "location": storage ?? "default", "no_display": "\(noDisplay)", - "shared_directories": "\(sharedDirectories.map( { $0.string } ).joined(separator: ", "))", + "shared_directories": + "\(sharedDirectories.map( { $0.string } ).joined(separator: ", "))", "mount": mount?.path ?? "none", "vnc_port": "\(vncPort)", "recovery_mode": "\(recoveryMode)", + "storage_param": storage ?? "default", + "usb_storage_devices": "\(usbMassStoragePaths?.count ?? 0)", ]) do { @@ -276,19 +319,51 @@ final class LumeController { let components = name.split(separator: ":") if components.count == 2 { do { - try self.validateVMExists(normalizedName) + _ = try self.validateVMExists(normalizedName, storage: storage) } catch { // If the VM doesn't exist, try to pull the image - try await pullImage(image: name, name: nil, registry: registry, organization: organization) + try await pullImage( + image: name, + name: nil, + registry: registry, + organization: organization, + storage: storage + ) } } - try validateRunParameters( - name: normalizedName, sharedDirectories: sharedDirectories, mount: mount) + // Find VM and get its actual location + let actualLocation = try validateVMExists(normalizedName, storage: storage) + + // Log if we found the VM in a different location than default + if actualLocation != storage && actualLocation != nil { + Logger.info( + "Found VM in location", + metadata: [ + "name": normalizedName, + "location": actualLocation ?? "default", + ]) + } + + try validateRunParameters( + name: normalizedName, + sharedDirectories: sharedDirectories, + mount: mount, + storage: actualLocation, + usbMassStoragePaths: usbMassStoragePaths + ) + + // Use the actual VM location that we found + let vm = try get(name: normalizedName, storage: actualLocation) - let vm = try get(name: normalizedName) SharedVM.shared.setVM(name: normalizedName, vm: vm) - try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, vncPort: vncPort, recoveryMode: recoveryMode) + try await vm.run( + noDisplay: noDisplay, + sharedDirectories: sharedDirectories, + mount: mount, + vncPort: vncPort, + recoveryMode: recoveryMode, + usbMassStoragePaths: usbMassStoragePaths) Logger.info("VM started successfully", metadata: ["name": normalizedName]) } catch { SharedVM.shared.removeVM(name: normalizedName) @@ -316,9 +391,13 @@ final class LumeController { } @MainActor - public func pullImage(image: String, name: String?, registry: String, organization: String, noCache: Bool = false) - async throws - { + public func pullImage( + image: String, + name: String?, + registry: String, + organization: String, + storage: String? = nil + ) async throws { do { let vmName: String = name ?? normalizeVMName(name: image) @@ -329,19 +408,33 @@ final class LumeController { "name": name ?? "default", "registry": registry, "organization": organization, + "location": storage ?? "default", ]) try self.validatePullParameters( - image: image, name: vmName, registry: registry, organization: organization) + image: image, + name: vmName, + registry: registry, + organization: organization, + storage: storage + ) let imageContainerRegistry = ImageContainerRegistry( registry: registry, organization: organization) - try await imageContainerRegistry.pull(image: image, name: vmName, noCache: noCache) + try await imageContainerRegistry.pull( + image: image, + name: vmName, + locationName: storage) - Logger.info("Setting new VM mac address") + Logger.info( + "Setting new VM mac address", + metadata: [ + "vm_name": vmName, + "location": storage ?? "default", + ]) // Update MAC address in the cloned VM to ensure uniqueness - let vm = try get(name: vmName) + let vm = try get(name: vmName, storage: storage) try vm.setMacAddress(VZMACAddress.randomLocallyAdministered().string) Logger.info( @@ -351,6 +444,7 @@ final class LumeController { "name": vmName, "registry": registry, "organization": organization, + "location": storage ?? "default", ]) } catch { Logger.error("Failed to pull image", metadata: ["error": error.localizedDescription]) @@ -361,14 +455,17 @@ final class LumeController { @MainActor public func pruneImages() async throws { Logger.info("Pruning cached images") - + do { - let home = FileManager.default.homeDirectoryForCurrentUser - let cacheDir = home.appendingPathComponent(".lume/cache/ghcr") - - if FileManager.default.fileExists(atPath: cacheDir.path) { - try FileManager.default.removeItem(at: cacheDir) - try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + // Use configured cache directory + let cacheDir = (SettingsManager.shared.getCacheDirectory() as NSString) + .expandingTildeInPath + let ghcrDir = URL(fileURLWithPath: cacheDir).appendingPathComponent("ghcr") + + if FileManager.default.fileExists(atPath: ghcrDir.path) { + try FileManager.default.removeItem(at: ghcrDir) + try FileManager.default.createDirectory( + at: ghcrDir, withIntermediateDirectories: true) Logger.info("Successfully removed cached images") } else { Logger.info("No cached images found") @@ -392,21 +489,94 @@ final class LumeController { @MainActor public func getImages(organization: String = "trycua") async throws -> ImageList { Logger.info("Listing local images", metadata: ["organization": organization]) - - let imageContainerRegistry = ImageContainerRegistry(registry: "ghcr.io", organization: organization) + + let imageContainerRegistry = ImageContainerRegistry( + registry: "ghcr.io", organization: organization) let cachedImages = try await imageContainerRegistry.getImages() - + let imageInfos = cachedImages.map { image in ImageInfo( repository: image.repository, imageId: String(image.manifestId.prefix(12)) ) } - + ImagesPrinter.print(images: imageInfos.map { "\($0.repository):\($0.imageId)" }) return ImageList(local: imageInfos, remote: []) } + // MARK: - Settings Management + + public func getSettings() -> LumeSettings { + return SettingsManager.shared.getSettings() + } + + public func setHomeDirectory(_ path: String) throws { + // Try to set the home directory in settings + try SettingsManager.shared.setHomeDirectory(path: path) + + // Force recreate home instance to use the new path + try home.validateHomeDirectory() + + Logger.info("Home directory updated", metadata: ["path": path]) + } + + // MARK: - VM Location Management + + public func addLocation(name: String, path: String) throws { + Logger.info("Adding VM location", metadata: ["name": name, "path": path]) + + try home.addLocation(name: name, path: path) + + Logger.info("VM location added successfully", metadata: ["name": name]) + } + + public func removeLocation(name: String) throws { + Logger.info("Removing VM location", metadata: ["name": name]) + + try home.removeLocation(name: name) + + Logger.info("VM location removed successfully", metadata: ["name": name]) + } + + public func setDefaultLocation(name: String) throws { + Logger.info("Setting default VM location", metadata: ["name": name]) + + try home.setDefaultLocation(name: name) + + Logger.info("Default VM location set successfully", metadata: ["name": name]) + } + + public func getLocations() -> [VMLocation] { + return home.getLocations() + } + + // MARK: - Cache Directory Management + + public func setCacheDirectory(path: String) throws { + Logger.info("Setting cache directory", metadata: ["path": path]) + + try SettingsManager.shared.setCacheDirectory(path: path) + + Logger.info("Cache directory updated", metadata: ["path": path]) + } + + public func getCacheDirectory() -> String { + return SettingsManager.shared.getCacheDirectory() + } + + public func isCachingEnabled() -> Bool { + return SettingsManager.shared.isCachingEnabled() + } + + public func setCachingEnabled(_ enabled: Bool) throws { + Logger.info("Setting caching enabled", metadata: ["enabled": "\(enabled)"]) + + try SettingsManager.shared.setCachingEnabled(enabled) + + Logger.info("Caching setting updated", metadata: ["enabled": "\(enabled)"]) + } + // MARK: - Private Helper Methods /// Normalizes a VM name by replacing colons with underscores @@ -435,7 +605,8 @@ final class LumeController { let vmDirContext = VMDirContext( dir: try home.createTempVMDirectory(), config: config, - home: home + home: home, + storage: nil ) let imageLoader = os.lowercased() == "macos" ? imageLoaderFactory.createImageLoader() : nil @@ -443,14 +614,15 @@ final class LumeController { } @MainActor - private func loadVM(name: String) throws -> VM { - let vmDir = home.getVMDirectory(name) + private func loadVM(name: String, storage: String? = nil) throws -> VM { + let vmDir = try home.getVMDirectory(name, storage: storage) guard vmDir.initialized() else { throw VMError.notInitialized(name) } let config: VMConfig = try vmDir.loadConfig() - let vmDirContext = VMDirContext(dir: vmDir, config: config, home: home) + let vmDirContext = VMDirContext( + dir: vmDir, config: config, home: home, storage: storage) let imageLoader = config.os.lowercased() == "macos" ? imageLoaderFactory.createImageLoader() : nil @@ -459,7 +631,9 @@ final class LumeController { // MARK: - Validation Methods - private func validateCreateParameters(name: String, os: String, ipsw: String?) throws { + private func validateCreateParameters( + name: String, os: String, ipsw: String?, storage: String? + ) throws { if os.lowercased() == "macos" { guard let ipsw = ipsw else { throw ValidationError("IPSW path required for macOS VM") @@ -475,7 +649,7 @@ final class LumeController { throw ValidationError("Unsupported OS type: \(os)") } - let vmDir = home.getVMDirectory(name) + let vmDir = try home.getVMDirectory(name, storage: storage) if vmDir.exists() { throw VMError.alreadyExists(name) } @@ -493,15 +667,33 @@ final class LumeController { } } - public func validateVMExists(_ name: String) throws { - let vmDir = home.getVMDirectory(name) - guard vmDir.initialized() else { - throw VMError.notFound(name) + public func validateVMExists(_ name: String, storage: String? = nil) throws -> String? { + // If location is specified, only check that location + if let storage = storage { + let vmDir = try home.getVMDirectory(name, storage: storage) + guard vmDir.initialized() else { + throw VMError.notFound(name) + } + return storage } + + // If no location specified, try to find the VM in any location + let allVMs = try home.getAllVMDirectories() + if let foundVM = allVMs.first(where: { $0.directory.name == name }) { + // VM found, return its location + return foundVM.locationName + } + + // VM not found in any location + throw VMError.notFound(name) } private func validatePullParameters( - image: String, name: String, registry: String, organization: String + image: String, + name: String, + registry: String, + organization: String, + storage: String? = nil ) throws { guard !image.isEmpty else { throw ValidationError("Image name cannot be empty") @@ -516,20 +708,38 @@ final class LumeController { throw ValidationError("Organization cannot be empty") } - let vmDir = home.getVMDirectory(name) + let vmDir = try home.getVMDirectory(name, storage: storage) if vmDir.exists() { throw VMError.alreadyExists(name) } } private func validateRunParameters( - name: String, sharedDirectories: [SharedDirectory]?, mount: Path? + name: String, sharedDirectories: [SharedDirectory]?, mount: Path?, + storage: String? = nil, usbMassStoragePaths: [Path]? = nil ) throws { - try self.validateVMExists(name) - if let dirs: [SharedDirectory] = sharedDirectories { + _ = try self.validateVMExists(name, storage: storage) + if let dirs = sharedDirectories { try self.validateSharedDirectories(dirs) } - let vmConfig = try home.getVMDirectory(name).loadConfig() + + // Validate USB mass storage paths + if let usbPaths = usbMassStoragePaths { + for path in usbPaths { + if !FileManager.default.fileExists(atPath: path.path) { + throw ValidationError("USB mass storage image not found: \(path.path)") + } + } + + if #available(macOS 15.0, *) { + // USB mass storage is supported + } else { + Logger.info( + "USB mass storage devices require macOS 15.0 or later. They will be ignored.") + } + } + + let vmConfig = try home.getVMDirectory(name, storage: storage).loadConfig() switch vmConfig.os.lowercased() { case "macos": if mount != nil { diff --git a/libs/lume/src/Server/Handlers.swift b/libs/lume/src/Server/Handlers.swift index 3fe03565..aac16e80 100644 --- a/libs/lume/src/Server/Handlers.swift +++ b/libs/lume/src/Server/Handlers.swift @@ -1,11 +1,11 @@ +import ArgumentParser import Foundation import Virtualization -import ArgumentParser @MainActor extension Server { // MARK: - VM Management Handlers - + func handleListVMs() async throws -> HTTPResponse { do { let vmController = LumeController() @@ -15,27 +15,28 @@ extension Server { return .badRequest(message: error.localizedDescription) } } - - func handleGetVM(name: String) async throws -> HTTPResponse { + + func handleGetVM(name: String, storage: String? = nil) async throws -> HTTPResponse { do { let vmController = LumeController() - let vm = try vmController.get(name: name) + let vm = try vmController.get(name: name, storage: storage) return try .json(vm.details) } catch { return .badRequest(message: error.localizedDescription) } } - + func handleCreateVM(_ body: Data?) async throws -> HTTPResponse { guard let body = body, - let request = try? JSONDecoder().decode(CreateVMRequest.self, from: body) else { + let request = try? JSONDecoder().decode(CreateVMRequest.self, from: body) + else { return HTTPResponse( statusCode: .badRequest, headers: ["Content-Type": "application/json"], body: try JSONEncoder().encode(APIError(message: "Invalid request body")) ) } - + do { let sizes = try request.parse() let vmController = LumeController() @@ -46,54 +47,15 @@ extension Server { cpuCount: request.cpu, memorySize: sizes.memory, display: request.display, - ipsw: request.ipsw + ipsw: request.ipsw, + storage: request.storage ) - - return HTTPResponse( - statusCode: .ok, - headers: ["Content-Type": "application/json"], - body: try JSONEncoder().encode(["message": "VM created successfully", "name": request.name]) - ) - } catch { - return HTTPResponse( - statusCode: .badRequest, - headers: ["Content-Type": "application/json"], - body: try JSONEncoder().encode(APIError(message: error.localizedDescription)) - ) - } - } - func handleDeleteVM(name: String) async throws -> HTTPResponse { - do { - let vmController = LumeController() - try await vmController.delete(name: name) - return HTTPResponse(statusCode: .ok, headers: ["Content-Type": "application/json"], body: Data()) - } catch { - return HTTPResponse(statusCode: .badRequest, headers: ["Content-Type": "application/json"], body: try JSONEncoder().encode(APIError(message: error.localizedDescription))) - } - } - - func handleCloneVM(_ body: Data?) async throws -> HTTPResponse { - guard let body = body, - let request = try? JSONDecoder().decode(CloneRequest.self, from: body) else { - return HTTPResponse( - statusCode: .badRequest, - headers: ["Content-Type": "application/json"], - body: try JSONEncoder().encode(APIError(message: "Invalid request body")) - ) - } - - do { - let vmController = LumeController() - try vmController.clone(name: request.name, newName: request.newName) - return HTTPResponse( statusCode: .ok, headers: ["Content-Type": "application/json"], body: try JSONEncoder().encode([ - "message": "VM cloned successfully", - "source": request.name, - "destination": request.newName + "message": "VM created successfully", "name": request.name, ]) ) } catch { @@ -104,19 +66,71 @@ extension Server { ) } } - - // MARK: - VM Operation Handlers - - func handleSetVM(name: String, body: Data?) async throws -> HTTPResponse { + + func handleDeleteVM(name: String, storage: String? = nil) async throws -> HTTPResponse { + do { + let vmController = LumeController() + try await vmController.delete(name: name, storage: storage) + return HTTPResponse( + statusCode: .ok, headers: ["Content-Type": "application/json"], body: Data()) + } catch { + return HTTPResponse( + statusCode: .badRequest, headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(APIError(message: error.localizedDescription))) + } + } + + func handleCloneVM(_ body: Data?) async throws -> HTTPResponse { guard let body = body, - let request = try? JSONDecoder().decode(SetVMRequest.self, from: body) else { + let request = try? JSONDecoder().decode(CloneRequest.self, from: body) + else { return HTTPResponse( statusCode: .badRequest, headers: ["Content-Type": "application/json"], body: try JSONEncoder().encode(APIError(message: "Invalid request body")) ) } - + + do { + let vmController = LumeController() + try vmController.clone( + name: request.name, + newName: request.newName, + sourceLocation: request.sourceLocation, + destLocation: request.destLocation + ) + + return HTTPResponse( + statusCode: .ok, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode([ + "message": "VM cloned successfully", + "source": request.name, + "destination": request.newName, + ]) + ) + } catch { + return HTTPResponse( + statusCode: .badRequest, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(APIError(message: error.localizedDescription)) + ) + } + } + + // MARK: - VM Operation Handlers + + func handleSetVM(name: String, body: Data?) async throws -> HTTPResponse { + guard let body = body, + let request = try? JSONDecoder().decode(SetVMRequest.self, from: body) + else { + return HTTPResponse( + statusCode: .badRequest, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(APIError(message: "Invalid request body")) + ) + } + do { let vmController = LumeController() let sizes = try request.parse() @@ -125,9 +139,10 @@ extension Server { cpu: request.cpu, memory: sizes.memory, diskSize: sizes.diskSize, - display: sizes.display?.string + display: sizes.display?.string, + storage: request.storage ) - + return HTTPResponse( statusCode: .ok, headers: ["Content-Type": "application/json"], @@ -141,11 +156,11 @@ extension Server { ) } } - - func handleStopVM(name: String) async throws -> HTTPResponse { + + func handleStopVM(name: String, storage: String? = nil) async throws -> HTTPResponse { do { let vmController = LumeController() - try await vmController.stopVM(name: name) + try await vmController.stopVM(name: name, storage: storage) return HTTPResponse( statusCode: .ok, headers: ["Content-Type": "application/json"], @@ -161,19 +176,22 @@ extension Server { } func handleRunVM(name: String, body: Data?) async throws -> HTTPResponse { - let request = body.flatMap { try? JSONDecoder().decode(RunVMRequest.self, from: $0) } ?? RunVMRequest(noDisplay: nil, sharedDirectories: nil, recoveryMode: nil) - + let request = + body.flatMap { try? JSONDecoder().decode(RunVMRequest.self, from: $0) } + ?? RunVMRequest(noDisplay: nil, sharedDirectories: nil, recoveryMode: nil, storage: nil) + do { let dirs = try request.parse() - + // Start VM in background startVM( name: name, noDisplay: request.noDisplay ?? false, sharedDirectories: dirs, - recoveryMode: request.recoveryMode ?? false + recoveryMode: request.recoveryMode ?? false, + storage: request.storage ) - + // Return response immediately return HTTPResponse( statusCode: .accepted, @@ -181,7 +199,7 @@ extension Server { body: try JSONEncoder().encode([ "message": "VM start initiated", "name": name, - "status": "pending" + "status": "pending", ]) ) } catch { @@ -192,9 +210,9 @@ extension Server { ) } } - + // MARK: - Image Management Handlers - + func handleIPSW() async throws -> HTTPResponse { do { let vmController = LumeController() @@ -215,7 +233,8 @@ extension Server { func handlePull(_ body: Data?) async throws -> HTTPResponse { guard let body = body, - let request = try? JSONDecoder().decode(PullRequest.self, from: body) else { + let request = try? JSONDecoder().decode(PullRequest.self, from: body) + else { return HTTPResponse( statusCode: .badRequest, headers: ["Content-Type": "application/json"], @@ -225,11 +244,22 @@ extension Server { do { let vmController = LumeController() - try await vmController.pullImage(image: request.image, name: request.name, registry: request.registry, organization: request.organization, noCache: request.noCache) + try await vmController.pullImage( + image: request.image, + name: request.name, + registry: request.registry, + organization: request.organization, + storage: request.storage + ) + return HTTPResponse( statusCode: .ok, headers: ["Content-Type": "application/json"], - body: try JSONEncoder().encode(["message": "Image pulled successfully"]) + body: try JSONEncoder().encode([ + "message": "Image pulled successfully", + "image": request.image, + "name": request.name ?? "default", + ]) ) } catch { return HTTPResponse( @@ -257,30 +287,34 @@ extension Server { ) } } - + func handleGetImages(_ request: HTTPRequest) async throws -> HTTPResponse { let pathAndQuery = request.path.split(separator: "?", maxSplits: 1) - let queryParams = pathAndQuery.count > 1 ? pathAndQuery[1] - .split(separator: "&") - .reduce(into: [String: String]()) { dict, param in - let parts = param.split(separator: "=", maxSplits: 1) - if parts.count == 2 { - dict[String(parts[0])] = String(parts[1]) - } - } : [:] - + let queryParams = + pathAndQuery.count > 1 + ? pathAndQuery[1] + .split(separator: "&") + .reduce(into: [String: String]()) { dict, param in + let parts = param.split(separator: "=", maxSplits: 1) + if parts.count == 2 { + dict[String(parts[0])] = String(parts[1]) + } + } : [:] + let organization = queryParams["organization"] ?? "trycua" - + do { let vmController = LumeController() let imageList = try await vmController.getImages(organization: organization) - + // Create a response format that matches the CLI output - let response = imageList.local.map { [ - "repository": $0.repository, - "imageId": $0.imageId - ] } - + let response = imageList.local.map { + [ + "repository": $0.repository, + "imageId": $0.imageId, + ] + } + return HTTPResponse( statusCode: .ok, headers: ["Content-Type": "application/json"], @@ -294,14 +328,157 @@ extension Server { ) } } - + + // MARK: - Config Management Handlers + + func handleGetConfig() async throws -> HTTPResponse { + do { + let vmController = LumeController() + let settings = vmController.getSettings() + return try .json(settings) + } catch { + return .badRequest(message: error.localizedDescription) + } + } + + struct ConfigRequest: Codable { + let homeDirectory: String? + let cacheDirectory: String? + let cachingEnabled: Bool? + } + + func handleUpdateConfig(_ body: Data?) async throws -> HTTPResponse { + guard let body = body, + let request = try? JSONDecoder().decode(ConfigRequest.self, from: body) + else { + return HTTPResponse( + statusCode: .badRequest, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(APIError(message: "Invalid request body")) + ) + } + + do { + let vmController = LumeController() + + if let homeDir = request.homeDirectory { + try vmController.setHomeDirectory(homeDir) + } + + if let cacheDir = request.cacheDirectory { + try vmController.setCacheDirectory(path: cacheDir) + } + + if let cachingEnabled = request.cachingEnabled { + try vmController.setCachingEnabled(cachingEnabled) + } + + return HTTPResponse( + statusCode: .ok, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(["message": "Configuration updated successfully"]) + ) + } catch { + return HTTPResponse( + statusCode: .badRequest, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(APIError(message: error.localizedDescription)) + ) + } + } + + func handleGetLocations() async throws -> HTTPResponse { + do { + let vmController = LumeController() + let locations = vmController.getLocations() + return try .json(locations) + } catch { + return .badRequest(message: error.localizedDescription) + } + } + + struct LocationRequest: Codable { + let name: String + let path: String + } + + func handleAddLocation(_ body: Data?) async throws -> HTTPResponse { + guard let body = body, + let request = try? JSONDecoder().decode(LocationRequest.self, from: body) + else { + return HTTPResponse( + statusCode: .badRequest, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(APIError(message: "Invalid request body")) + ) + } + + do { + let vmController = LumeController() + try vmController.addLocation(name: request.name, path: request.path) + + return HTTPResponse( + statusCode: .ok, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode([ + "message": "Location added successfully", + "name": request.name, + "path": request.path, + ]) + ) + } catch { + return HTTPResponse( + statusCode: .badRequest, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(APIError(message: error.localizedDescription)) + ) + } + } + + func handleRemoveLocation(_ name: String) async throws -> HTTPResponse { + do { + let vmController = LumeController() + try vmController.removeLocation(name: name) + return HTTPResponse( + statusCode: .ok, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(["message": "Location removed successfully"]) + ) + } catch { + return HTTPResponse( + statusCode: .badRequest, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(APIError(message: error.localizedDescription)) + ) + } + } + + func handleSetDefaultLocation(_ name: String) async throws -> HTTPResponse { + do { + let vmController = LumeController() + try vmController.setDefaultLocation(name: name) + return HTTPResponse( + statusCode: .ok, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(["message": "Default location set successfully"]) + ) + } catch { + return HTTPResponse( + statusCode: .badRequest, + headers: ["Content-Type": "application/json"], + body: try JSONEncoder().encode(APIError(message: error.localizedDescription)) + ) + } + } + // MARK: - Private Helper Methods - + nonisolated private func startVM( name: String, noDisplay: Bool, sharedDirectories: [SharedDirectory] = [], - recoveryMode: Bool = false + recoveryMode: Bool = false, + storage: String? = nil ) { Task.detached { @MainActor @Sendable in Logger.info("Starting VM in background", metadata: ["name": name]) @@ -311,14 +488,17 @@ extension Server { name: name, noDisplay: noDisplay, sharedDirectories: sharedDirectories, - recoveryMode: recoveryMode + recoveryMode: recoveryMode, + storage: storage ) Logger.info("VM started successfully in background", metadata: ["name": name]) } catch { - Logger.error("Failed to start VM in background", metadata: [ - "name": name, - "error": error.localizedDescription - ]) + Logger.error( + "Failed to start VM in background", + metadata: [ + "name": name, + "error": error.localizedDescription, + ]) } } } diff --git a/libs/lume/src/Server/Requests.swift b/libs/lume/src/Server/Requests.swift index 3db114e6..19291072 100644 --- a/libs/lume/src/Server/Requests.swift +++ b/libs/lume/src/Server/Requests.swift @@ -1,28 +1,31 @@ -import Foundation import ArgumentParser +import Foundation import Virtualization struct RunVMRequest: Codable { let noDisplay: Bool? let sharedDirectories: [SharedDirectoryRequest]? let recoveryMode: Bool? - + let storage: String? + struct SharedDirectoryRequest: Codable { let hostPath: String let readOnly: Bool? } - + func parse() throws -> [SharedDirectory] { guard let sharedDirectories = sharedDirectories else { return [] } - + return try sharedDirectories.map { dir -> SharedDirectory in // Validate that the host path exists and is a directory var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: dir.hostPath, isDirectory: &isDirectory), - isDirectory.boolValue else { - throw ValidationError("Host path does not exist or is not a directory: \(dir.hostPath)") + isDirectory.boolValue + else { + throw ValidationError( + "Host path does not exist or is not a directory: \(dir.hostPath)") } - + return SharedDirectory( hostPath: dir.hostPath, tag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag, @@ -37,19 +40,19 @@ struct PullRequest: Codable { let name: String? var registry: String var organization: String - var noCache: Bool - + let storage: String? + enum CodingKeys: String, CodingKey { - case image, name, registry, organization, noCache + case image, name, registry, organization, storage } - + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) image = try container.decode(String.self, forKey: .image) name = try container.decodeIfPresent(String.self, forKey: .name) registry = try container.decodeIfPresent(String.self, forKey: .registry) ?? "ghcr.io" organization = try container.decodeIfPresent(String.self, forKey: .organization) ?? "trycua" - noCache = try container.decodeIfPresent(Bool.self, forKey: .noCache) ?? false + storage = try container.decodeIfPresent(String.self, forKey: .storage) } } @@ -61,7 +64,8 @@ struct CreateVMRequest: Codable { let diskSize: String let display: String let ipsw: String? - + let storage: String? + func parse() throws -> (memory: UInt64, diskSize: UInt64) { return ( memory: try parseSize(memory), @@ -75,14 +79,16 @@ struct SetVMRequest: Codable { let memory: String? let diskSize: String? let display: String? - + let storage: String? + func parse() throws -> (memory: UInt64?, diskSize: UInt64?, display: VMDisplayResolution?) { return ( memory: try memory.map { try parseSize($0) }, diskSize: try diskSize.map { try parseSize($0) }, - display: try display.map { + display: try display.map { guard let resolution = VMDisplayResolution(string: $0) else { - throw ValidationError("Invalid display resolution format: \($0). Expected format: WIDTHxHEIGHT") + throw ValidationError( + "Invalid display resolution format: \($0). Expected format: WIDTHxHEIGHT") } return resolution } @@ -93,4 +99,6 @@ struct SetVMRequest: Codable { struct CloneRequest: Codable { let name: String let newName: String + let sourceLocation: String? + let destLocation: String? } diff --git a/libs/lume/src/Server/Server.swift b/libs/lume/src/Server/Server.swift index a7e161ea..4ed671c5 100644 --- a/libs/lume/src/Server/Server.swift +++ b/libs/lume/src/Server/Server.swift @@ -1,11 +1,11 @@ +import Darwin import Foundation import Network -import Darwin // MARK: - Error Types enum PortError: Error, LocalizedError { case alreadyInUse(port: UInt16) - + var errorDescription: String? { switch self { case .alreadyInUse(let port): @@ -17,147 +17,263 @@ enum PortError: Error, LocalizedError { // MARK: - Server Class @MainActor final class Server { - + // MARK: - Route Type private struct Route { let method: String let path: String let handler: (HTTPRequest) async throws -> HTTPResponse - + func matches(_ request: HTTPRequest) -> Bool { if method != request.method { return false } - + // Handle path parameters let routeParts = path.split(separator: "/") let requestParts = request.path.split(separator: "/") - + if routeParts.count != requestParts.count { return false } - + for (routePart, requestPart) in zip(routeParts, requestParts) { if routePart.hasPrefix(":") { continue } // Path parameter if routePart != requestPart { return false } } - + return true } - + func extractParams(_ request: HTTPRequest) -> [String: String] { var params: [String: String] = [:] let routeParts = path.split(separator: "/") let requestParts = request.path.split(separator: "/") - + for (routePart, requestPart) in zip(routeParts, requestParts) { if routePart.hasPrefix(":") { let paramName = String(routePart.dropFirst()) params[paramName] = String(requestPart) } } - + return params } } - + // MARK: - Properties private let port: NWEndpoint.Port private let controller: LumeController private var isRunning = false private var listener: NWListener? private var routes: [Route] - + // MARK: - Initialization init(port: UInt16 = 3000) { self.port = NWEndpoint.Port(rawValue: port)! self.controller = LumeController() self.routes = [] - + // Define API routes after self is fully initialized self.setupRoutes() } - + // MARK: - Route Setup private func setupRoutes() { routes = [ - Route(method: "GET", path: "/lume/vms", handler: { [weak self] _ in - guard let self else { throw HTTPError.internalError } - return try await self.handleListVMs() - }), - Route(method: "GET", path: "/lume/vms/:name", handler: { [weak self] request in - guard let self else { throw HTTPError.internalError } - let params = Route(method: "GET", path: "/lume/vms/:name", handler: { _ in - HTTPResponse(statusCode: .ok, body: "") - }).extractParams(request) - guard let name = params["name"] else { - return HTTPResponse(statusCode: .badRequest, body: "Missing VM name") - } - return try await self.handleGetVM(name: name) - }), - Route(method: "DELETE", path: "/lume/vms/:name", handler: { [weak self] request in - guard let self else { throw HTTPError.internalError } - let params = Route(method: "DELETE", path: "/lume/vms/:name", handler: { _ in - HTTPResponse(statusCode: .ok, body: "") - }).extractParams(request) - guard let name = params["name"] else { - return HTTPResponse(statusCode: .badRequest, body: "Missing VM name") - } - return try await self.handleDeleteVM(name: name) - }), - Route(method: "POST", path: "/lume/vms", handler: { [weak self] request in - guard let self else { throw HTTPError.internalError } - return try await self.handleCreateVM(request.body) - }), - Route(method: "POST", path: "/lume/vms/clone", handler: { [weak self] request in - guard let self else { throw HTTPError.internalError } - return try await self.handleCloneVM(request.body) - }), - Route(method: "PATCH", path: "/lume/vms/:name", handler: { [weak self] request in - guard let self else { throw HTTPError.internalError } - let params = Route(method: "PATCH", path: "/lume/vms/:name", handler: { _ in - HTTPResponse(statusCode: .ok, body: "") - }).extractParams(request) - guard let name = params["name"] else { - return HTTPResponse(statusCode: .badRequest, body: "Missing VM name") - } - return try await self.handleSetVM(name: name, body: request.body) - }), - Route(method: "POST", path: "/lume/vms/:name/run", handler: { [weak self] request in - guard let self else { throw HTTPError.internalError } - let params = Route(method: "POST", path: "/lume/vms/:name/run", handler: { _ in - HTTPResponse(statusCode: .ok, body: "") - }).extractParams(request) - guard let name = params["name"] else { - return HTTPResponse(statusCode: .badRequest, body: "Missing VM name") - } - return try await self.handleRunVM(name: name, body: request.body) - }), - Route(method: "POST", path: "/lume/vms/:name/stop", handler: { [weak self] request in - guard let self else { throw HTTPError.internalError } - let params = Route(method: "POST", path: "/lume/vms/:name/stop", handler: { _ in - HTTPResponse(statusCode: .ok, body: "") - }).extractParams(request) - guard let name = params["name"] else { - return HTTPResponse(statusCode: .badRequest, body: "Missing VM name") - } - return try await self.handleStopVM(name: name) - }), - Route(method: "GET", path: "/lume/ipsw", handler: { [weak self] _ in - guard let self else { throw HTTPError.internalError } - return try await self.handleIPSW() - }), - Route(method: "POST", path: "/lume/pull", handler: { [weak self] request in - guard let self else { throw HTTPError.internalError } - return try await self.handlePull(request.body) - }), - Route(method: "POST", path: "/lume/prune", handler: { [weak self] _ in - guard let self else { throw HTTPError.internalError } - return try await self.handlePruneImages() - }), - Route(method: "GET", path: "/lume/images", handler: { [weak self] request in - guard let self else { throw HTTPError.internalError } - return try await self.handleGetImages(request) - }) + Route( + method: "GET", path: "/lume/vms", + handler: { [weak self] _ in + guard let self else { throw HTTPError.internalError } + return try await self.handleListVMs() + }), + Route( + method: "GET", path: "/lume/vms/:name", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + let params = Route( + method: "GET", path: "/lume/vms/:name", + handler: { _ in + HTTPResponse(statusCode: .ok, body: "") + } + ).extractParams(request) + guard let name = params["name"] else { + return HTTPResponse(statusCode: .badRequest, body: "Missing VM name") + } + + // Extract storage from query params if present + let storage = self.extractQueryParam(request: request, name: "storage") + + return try await self.handleGetVM(name: name, storage: storage) + }), + Route( + method: "DELETE", path: "/lume/vms/:name", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + let params = Route( + method: "DELETE", path: "/lume/vms/:name", + handler: { _ in + HTTPResponse(statusCode: .ok, body: "") + } + ).extractParams(request) + guard let name = params["name"] else { + return HTTPResponse(statusCode: .badRequest, body: "Missing VM name") + } + + // Extract storage from query params if present + let storage = self.extractQueryParam(request: request, name: "storage") + + return try await self.handleDeleteVM(name: name, storage: storage) + }), + Route( + method: "POST", path: "/lume/vms", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + return try await self.handleCreateVM(request.body) + }), + Route( + method: "POST", path: "/lume/vms/clone", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + return try await self.handleCloneVM(request.body) + }), + Route( + method: "PATCH", path: "/lume/vms/:name", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + let params = Route( + method: "PATCH", path: "/lume/vms/:name", + handler: { _ in + HTTPResponse(statusCode: .ok, body: "") + } + ).extractParams(request) + guard let name = params["name"] else { + return HTTPResponse(statusCode: .badRequest, body: "Missing VM name") + } + return try await self.handleSetVM(name: name, body: request.body) + }), + Route( + method: "POST", path: "/lume/vms/:name/run", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + let params = Route( + method: "POST", path: "/lume/vms/:name/run", + handler: { _ in + HTTPResponse(statusCode: .ok, body: "") + } + ).extractParams(request) + guard let name = params["name"] else { + return HTTPResponse(statusCode: .badRequest, body: "Missing VM name") + } + return try await self.handleRunVM(name: name, body: request.body) + }), + Route( + method: "POST", path: "/lume/vms/:name/stop", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + let params = Route( + method: "POST", path: "/lume/vms/:name/stop", + handler: { _ in + HTTPResponse(statusCode: .ok, body: "") + } + ).extractParams(request) + guard let name = params["name"] else { + return HTTPResponse(statusCode: .badRequest, body: "Missing VM name") + } + + // Extract storage from query params if present + let storage = self.extractQueryParam(request: request, name: "storage") + + return try await self.handleStopVM(name: name, storage: storage) + }), + Route( + method: "GET", path: "/lume/ipsw", + handler: { [weak self] _ in + guard let self else { throw HTTPError.internalError } + return try await self.handleIPSW() + }), + Route( + method: "POST", path: "/lume/pull", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + return try await self.handlePull(request.body) + }), + Route( + method: "POST", path: "/lume/prune", + handler: { [weak self] _ in + guard let self else { throw HTTPError.internalError } + return try await self.handlePruneImages() + }), + Route( + method: "GET", path: "/lume/images", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + return try await self.handleGetImages(request) + }), + // New config endpoint + Route( + method: "GET", path: "/lume/config", + handler: { [weak self] _ in + guard let self else { throw HTTPError.internalError } + return try await self.handleGetConfig() + }), + Route( + method: "POST", path: "/lume/config", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + return try await self.handleUpdateConfig(request.body) + }), + Route( + method: "GET", path: "/lume/config/locations", + handler: { [weak self] _ in + guard let self else { throw HTTPError.internalError } + return try await self.handleGetLocations() + }), + Route( + method: "POST", path: "/lume/config/locations", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + return try await self.handleAddLocation(request.body) + }), + Route( + method: "DELETE", path: "/lume/config/locations/:name", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + let params = Route( + method: "DELETE", path: "/lume/config/locations/:name", + handler: { _ in + HTTPResponse(statusCode: .ok, body: "") + } + ).extractParams(request) + guard let name = params["name"] else { + return HTTPResponse(statusCode: .badRequest, body: "Missing location name") + } + return try await self.handleRemoveLocation(name) + }), + Route( + method: "POST", path: "/lume/config/locations/default/:name", + handler: { [weak self] request in + guard let self else { throw HTTPError.internalError } + let params = Route( + method: "POST", path: "/lume/config/locations/default/:name", + handler: { _ in + HTTPResponse(statusCode: .ok, body: "") + } + ).extractParams(request) + guard let name = params["name"] else { + return HTTPResponse(statusCode: .badRequest, body: "Missing location name") + } + return try await self.handleSetDefaultLocation(name) + }), ] } - + + // Helper to extract query parameters from the URL + private func extractQueryParam(request: HTTPRequest, name: String) -> String? { + if let urlComponents = URLComponents(string: request.path), + let queryItems = urlComponents.queryItems + { + return queryItems.first(where: { $0.name == name })?.value + } + return nil + } + // MARK: - Port Utilities private func isPortAvailable(port: Int) async -> Bool { // Create a socket @@ -165,34 +281,36 @@ final class Server { if socketFD == -1 { return false } - + // Set socket options to allow reuse var value: Int32 = 1 - if setsockopt(socketFD, SOL_SOCKET, SO_REUSEADDR, &value, socklen_t(MemoryLayout.size)) == -1 { + if setsockopt( + socketFD, SOL_SOCKET, SO_REUSEADDR, &value, socklen_t(MemoryLayout.size)) == -1 + { close(socketFD) return false } - + // Set up the address structure var addr = sockaddr_in() addr.sin_family = sa_family_t(AF_INET) addr.sin_port = UInt16(port).bigEndian addr.sin_addr.s_addr = INADDR_ANY.bigEndian - + // Bind to the port let bindResult = withUnsafePointer(to: &addr) { addrPtr in addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { addrPtr in Darwin.bind(socketFD, addrPtr, socklen_t(MemoryLayout.size)) } } - + // Clean up close(socketFD) - + // If bind failed, the port is in use return bindResult == 0 } - + // MARK: - Server Lifecycle func start() async throws { // First check if the port is already in use @@ -200,31 +318,31 @@ final class Server { // Don't log anything here, just throw the error throw PortError.alreadyInUse(port: port.rawValue) } - + let parameters = NWParameters.tcp listener = try NWListener(using: parameters, on: port) - + // Create an actor to safely manage state transitions actor StartupState { var error: Error? var isComplete = false - + func setError(_ error: Error) { self.error = error self.isComplete = true } - + func setComplete() { self.isComplete = true } - + func checkStatus() -> (isComplete: Bool, error: Error?) { return (isComplete, error) } } - + let startupState = StartupState() - + // Set up a state update handler to detect port binding errors listener?.stateUpdateHandler = { state in Task { @@ -235,24 +353,29 @@ final class Server { break case .waiting(let error): // Log the full error details to see what we're getting - Logger.error("Listener waiting", metadata: [ - "error": error.localizedDescription, - "debugDescription": error.debugDescription, - "localizedDescription": error.localizedDescription, - "port": "\(self.port.rawValue)" - ]) - + Logger.error( + "Listener waiting", + metadata: [ + "error": error.localizedDescription, + "debugDescription": error.debugDescription, + "localizedDescription": error.localizedDescription, + "port": "\(self.port.rawValue)", + ]) + // Check for different port in use error messages - if error.debugDescription.contains("Address already in use") || - error.localizedDescription.contains("in use") || - error.localizedDescription.contains("address already in use") { - Logger.error("Port conflict detected", metadata: ["port": "\(self.port.rawValue)"]) - await startupState.setError(PortError.alreadyInUse(port: self.port.rawValue)) + if error.debugDescription.contains("Address already in use") + || error.localizedDescription.contains("in use") + || error.localizedDescription.contains("address already in use") + { + Logger.error( + "Port conflict detected", metadata: ["port": "\(self.port.rawValue)"]) + await startupState.setError( + PortError.alreadyInUse(port: self.port.rawValue)) } else { // Wait for a short period to see if the listener recovers // Some network errors are transient - try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second - + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + // If we're still waiting after delay, consider it an error if case .waiting = await self.listener?.state { await startupState.setError(error) @@ -260,11 +383,13 @@ final class Server { } case .failed(let error): // Log the full error details - Logger.error("Listener failed", metadata: [ - "error": error.localizedDescription, - "debugDescription": error.debugDescription, - "port": "\(self.port.rawValue)" - ]) + Logger.error( + "Listener failed", + metadata: [ + "error": error.localizedDescription, + "debugDescription": error.debugDescription, + "port": "\(self.port.rawValue)", + ]) await startupState.setError(error) case .ready: // Listener successfully bound to port @@ -275,49 +400,51 @@ final class Server { Logger.info("Listener cancelled", metadata: ["port": "\(self.port.rawValue)"]) break @unknown default: - Logger.info("Unknown listener state", metadata: ["state": "\(state)", "port": "\(self.port.rawValue)"]) + Logger.info( + "Unknown listener state", + metadata: ["state": "\(state)", "port": "\(self.port.rawValue)"]) break } } } - + listener?.newConnectionHandler = { [weak self] connection in Task { @MainActor [weak self] in guard let self else { return } self.handleConnection(connection) } } - + listener?.start(queue: .main) - + // Wait for either successful startup or an error var status: (isComplete: Bool, error: Error?) = (false, nil) repeat { - try await Task.sleep(nanoseconds: 100_000_000) // 100ms + try await Task.sleep(nanoseconds: 100_000_000) // 100ms status = await startupState.checkStatus() } while !status.isComplete - + // If there was a startup error, throw it if let error = status.error { self.stop() throw error } - + isRunning = true - + Logger.info("Server started", metadata: ["port": "\(port.rawValue)"]) - + // Keep the server running while isRunning { try await Task.sleep(nanoseconds: 1_000_000_000) } } - + func stop() { isRunning = false listener?.cancel() } - + // MARK: - Connection Handling private func handleConnection(_ connection: NWConnection) { connection.stateUpdateHandler = { [weak self] state in @@ -339,22 +466,23 @@ final class Server { } connection.start(queue: .main) } - + private func receiveData(_ connection: NWConnection) { - connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { + [weak self] content, _, isComplete, error in if let error = error { Logger.error("Receive error", metadata: ["error": error.localizedDescription]) connection.cancel() return } - + guard let data = content, !data.isEmpty else { if isComplete { connection.cancel() } return } - + Task { @MainActor [weak self] in guard let self else { return } do { @@ -367,55 +495,64 @@ final class Server { } } } - + private func send(_ response: HTTPResponse, on connection: NWConnection) { let data = response.serialize() - Logger.info("Serialized response", metadata: ["data": String(data: data, encoding: .utf8) ?? ""]) - connection.send(content: data, completion: .contentProcessed { [weak connection] error in - if let error = error { - Logger.error("Failed to send response", metadata: ["error": error.localizedDescription]) - } else { - Logger.info("Response sent successfully") - } - if connection?.state != .cancelled { - connection?.cancel() - } - }) + Logger.info( + "Serialized response", metadata: ["data": String(data: data, encoding: .utf8) ?? ""]) + connection.send( + content: data, + completion: .contentProcessed { [weak connection] error in + if let error = error { + Logger.error( + "Failed to send response", metadata: ["error": error.localizedDescription]) + } else { + Logger.info("Response sent successfully") + } + if connection?.state != .cancelled { + connection?.cancel() + } + }) } - + // MARK: - Request Handling private func handleRequest(_ data: Data) async throws -> HTTPResponse { - Logger.info("Received request data", metadata: ["data": String(data: data, encoding: .utf8) ?? ""]) - + Logger.info( + "Received request data", metadata: ["data": String(data: data, encoding: .utf8) ?? ""]) + guard let request = HTTPRequest(data: data) else { Logger.error("Failed to parse request") return HTTPResponse(statusCode: .badRequest, body: "Invalid request") } - - Logger.info("Parsed request", metadata: [ - "method": request.method, - "path": request.path, - "headers": "\(request.headers)", - "body": String(data: request.body ?? Data(), encoding: .utf8) ?? "" - ]) - + + Logger.info( + "Parsed request", + metadata: [ + "method": request.method, + "path": request.path, + "headers": "\(request.headers)", + "body": String(data: request.body ?? Data(), encoding: .utf8) ?? "", + ]) + // Find matching route guard let route = routes.first(where: { $0.matches(request) }) else { return HTTPResponse(statusCode: .notFound, body: "Not found") } - + // Handle the request let response = try await route.handler(request) - - Logger.info("Sending response", metadata: [ - "statusCode": "\(response.statusCode.rawValue)", - "headers": "\(response.headers)", - "body": String(data: response.body ?? Data(), encoding: .utf8) ?? "" - ]) - + + Logger.info( + "Sending response", + metadata: [ + "statusCode": "\(response.statusCode.rawValue)", + "headers": "\(response.headers)", + "body": String(data: response.body ?? Data(), encoding: .utf8) ?? "", + ]) + return response } - + private func errorResponse(_ error: Error) -> HTTPResponse { HTTPResponse( statusCode: .internalServerError, @@ -423,4 +560,4 @@ final class Server { body: try! JSONEncoder().encode(APIError(message: error.localizedDescription)) ) } -} \ No newline at end of file +} diff --git a/libs/lume/src/Utils/CommandRegistry.swift b/libs/lume/src/Utils/CommandRegistry.swift index f7ec317b..a7e2a7bc 100644 --- a/libs/lume/src/Utils/CommandRegistry.swift +++ b/libs/lume/src/Utils/CommandRegistry.swift @@ -15,7 +15,8 @@ enum CommandRegistry { IPSW.self, Serve.self, Delete.self, - Prune.self + Prune.self, + Config.self, ] } } diff --git a/libs/lume/src/Utils/CommandUtils.swift b/libs/lume/src/Utils/CommandUtils.swift index f400f383..7061a9b6 100644 --- a/libs/lume/src/Utils/CommandUtils.swift +++ b/libs/lume/src/Utils/CommandUtils.swift @@ -2,5 +2,5 @@ import ArgumentParser import Foundation func completeVMName(_ arguments: [String]) -> [String] { - (try? Home().getAllVMDirectories().map(\.name)) ?? [] -} \ No newline at end of file + (try? Home().getAllVMDirectories().map { $0.directory.name }) ?? [] +} diff --git a/libs/lume/src/VM/VM.swift b/libs/lume/src/VM/VM.swift index 8d736c62..17429af8 100644 --- a/libs/lume/src/VM/VM.swift +++ b/libs/lume/src/VM/VM.swift @@ -7,22 +7,23 @@ struct VMDirContext { let dir: VMDirectory var config: VMConfig let home: Home - + let storage: String? + func saveConfig() throws { try dir.saveConfig(config) } - + var name: String { dir.name } var initialized: Bool { dir.initialized() } var diskPath: Path { dir.diskPath } var nvramPath: Path { dir.nvramPath } - + func setDisk(_ size: UInt64) throws { try dir.setDisk(size) } - + func finalize(to name: String) throws { - let vmDir = home.getVMDirectory(name) + let vmDir = try home.getVMDirectory(name) try FileManager.default.moveItem(at: dir.dir.url, to: vmDir.dir.url) } } @@ -33,21 +34,25 @@ struct VMDirContext { @MainActor class VM { // MARK: - Properties - + var vmDirContext: VMDirContext @MainActor private var virtualizationService: VMVirtualizationService? private let vncService: VNCService - internal let virtualizationServiceFactory: (VMVirtualizationServiceContext) throws -> VMVirtualizationService + internal let virtualizationServiceFactory: + (VMVirtualizationServiceContext) throws -> VMVirtualizationService private let vncServiceFactory: (VMDirectory) -> VNCService // MARK: - Initialization - + init( vmDirContext: VMDirContext, - virtualizationServiceFactory: @escaping (VMVirtualizationServiceContext) throws -> VMVirtualizationService = { try DarwinVirtualizationService(configuration: $0) }, - vncServiceFactory: @escaping (VMDirectory) -> VNCService = { DefaultVNCService(vmDirectory: $0) } + virtualizationServiceFactory: @escaping (VMVirtualizationServiceContext) throws -> + VMVirtualizationService = { try DarwinVirtualizationService(configuration: $0) }, + vncServiceFactory: @escaping (VMDirectory) -> VNCService = { + DefaultVNCService(vmDirectory: $0) + } ) { self.vmDirContext = vmDirContext self.virtualizationServiceFactory = virtualizationServiceFactory @@ -58,13 +63,14 @@ class VM { } // MARK: - VM State Management - + private var isRunning: Bool { // First check if we have an IP address - guard let ipAddress = DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) else { + guard let ipAddress = DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) + else { return false } - + // Then check if it's reachable return NetworkUtils.isReachable(ipAddress: ipAddress) } @@ -72,7 +78,7 @@ class VM { var details: VMDetails { let isRunning: Bool = self.isRunning let vncUrl = isRunning ? getVNCUrl() : nil - + return VMDetails( name: vmDirContext.name, os: getOSType(), @@ -82,19 +88,25 @@ class VM { display: vmDirContext.config.display.string, status: isRunning ? "running" : "stopped", vncUrl: vncUrl, - ipAddress: isRunning ? DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) : nil + ipAddress: isRunning + ? DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) : nil, + locationName: vmDirContext.storage ?? "default" ) } // MARK: - VM Lifecycle Management - - func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0, recoveryMode: Bool = false) async throws { + + func run( + noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0, + recoveryMode: Bool = false, usbMassStoragePaths: [Path]? = nil + ) async throws { guard vmDirContext.initialized else { throw VMError.notInitialized(vmDirContext.name) } - + guard let cpuCount = vmDirContext.config.cpuCount, - let memorySize = vmDirContext.config.memorySize else { + let memorySize = vmDirContext.config.memorySize + else { throw VMError.notInitialized(vmDirContext.name) } @@ -105,16 +117,35 @@ class VM { throw VMError.alreadyRunning(vmDirContext.name) } - Logger.info("Running VM with configuration", metadata: [ - "cpuCount": "\(cpuCount)", - "memorySize": "\(memorySize)", - "diskSize": "\(vmDirContext.config.diskSize ?? 0)", - "sharedDirectories": sharedDirectories.map( - { $0.string } - ).joined(separator: ", "), - "vncPort": "\(vncPort)", - "recoveryMode": "\(recoveryMode)" - ]) + Logger.info( + "Running VM with configuration", + metadata: [ + "cpuCount": "\(cpuCount)", + "memorySize": "\(memorySize)", + "diskSize": "\(vmDirContext.config.diskSize ?? 0)", + "sharedDirectories": sharedDirectories.map( + { $0.string } + ).joined(separator: ", "), + "vncPort": "\(vncPort)", + "recoveryMode": "\(recoveryMode)", + "usbMassStorageDeviceCount": "\(usbMassStoragePaths?.count ?? 0)", + ]) + + // Log disk paths and existence for debugging + Logger.info( + "VM disk paths", + metadata: [ + "diskPath": vmDirContext.diskPath.path, + "diskExists": + "\(FileManager.default.fileExists(atPath: vmDirContext.diskPath.path))", + "nvramPath": vmDirContext.nvramPath.path, + "nvramExists": + "\(FileManager.default.fileExists(atPath: vmDirContext.nvramPath.path))", + "configPath": vmDirContext.dir.configPath.path, + "configExists": + "\(FileManager.default.fileExists(atPath: vmDirContext.dir.configPath.path))", + "locationName": vmDirContext.storage ?? "default", + ]) // Create and configure the VM do { @@ -124,23 +155,30 @@ class VM { display: vmDirContext.config.display.string, sharedDirectories: sharedDirectories, mount: mount, - recoveryMode: recoveryMode + recoveryMode: recoveryMode, + usbMassStoragePaths: usbMassStoragePaths ) virtualizationService = try virtualizationServiceFactory(config) - + let vncInfo = try await setupVNC(noDisplay: noDisplay, port: vncPort) Logger.info("VNC info", metadata: ["vncInfo": vncInfo]) - + // Start the VM guard let service = virtualizationService else { throw VMError.internalError("Virtualization service not initialized") } try await service.start() - + while true { try await Task.sleep(nanoseconds: UInt64(1e9)) } } catch { + Logger.error( + "Failed to create/start VM", + metadata: [ + "error": "\(error)", + "errorType": "\(type(of: error))", + ]) virtualizationService = nil vncService.stop() // Release lock @@ -164,13 +202,17 @@ class VM { try await service.stop() virtualizationService = nil vncService.stop() - Logger.info("VM stopped successfully via virtualization service", metadata: ["name": vmDirContext.name]) + Logger.info( + "VM stopped successfully via virtualization service", + metadata: ["name": vmDirContext.name]) return - } catch let error { - Logger.error("Failed to stop VM via virtualization service, falling back to process termination", metadata: [ - "name": vmDirContext.name, - "error": "\(error)" - ]) + } catch let error { + Logger.error( + "Failed to stop VM via virtualization service, falling back to process termination", + metadata: [ + "name": vmDirContext.name, + "error": "\(error)", + ]) // Fall through to process termination } } @@ -178,68 +220,76 @@ class VM { // Try to open config file to get file descriptor - note that this matches with the serve process - so this is only for the command line let fileHandle = try? FileHandle(forReadingFrom: vmDirContext.dir.configPath.url) guard let fileHandle = fileHandle else { - Logger.error("Failed to open config file - VM not running", metadata: ["name": vmDirContext.name]) + Logger.error( + "Failed to open config file - VM not running", metadata: ["name": vmDirContext.name] + ) throw VMError.notRunning(vmDirContext.name) } - + // Get the PID of the process holding the lock using lsof command let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") task.arguments = ["-F", "p", vmDirContext.dir.configPath.path] - + let outputPipe = Pipe() task.standardOutput = outputPipe - + try task.run() task.waitUntilExit() - + let outputData = try outputPipe.fileHandleForReading.readToEnd() ?? Data() guard let outputString = String(data: outputData, encoding: .utf8), - let pidString = outputString.split(separator: "\n").first?.dropFirst(), // Drop the 'p' prefix - let pid = pid_t(pidString) else { + let pidString = outputString.split(separator: "\n").first?.dropFirst(), // Drop the 'p' prefix + let pid = pid_t(pidString) + else { try? fileHandle.close() - Logger.error("Failed to find VM process - VM not running", metadata: ["name": vmDirContext.name]) + Logger.error( + "Failed to find VM process - VM not running", metadata: ["name": vmDirContext.name]) throw VMError.notRunning(vmDirContext.name) } // First try graceful shutdown with SIGINT if kill(pid, SIGINT) == 0 { - Logger.info("Sent SIGINT to VM process", metadata: ["name": vmDirContext.name, "pid": "\(pid)"]) + Logger.info( + "Sent SIGINT to VM process", metadata: ["name": vmDirContext.name, "pid": "\(pid)"]) } // Wait for process to stop with timeout var attempts = 0 while attempts < 10 { try await Task.sleep(nanoseconds: 1_000_000_000) - + // Check if process still exists if kill(pid, 0) != 0 { // Process is gone, do final cleanup virtualizationService = nil vncService.stop() try? fileHandle.close() - - Logger.info("VM stopped successfully via process termination", metadata: ["name": vmDirContext.name]) + + Logger.info( + "VM stopped successfully via process termination", + metadata: ["name": vmDirContext.name]) return } attempts += 1 } - + // If graceful shutdown failed, force kill the process - Logger.info("Graceful shutdown failed, forcing termination", metadata: ["name": vmDirContext.name]) + Logger.info( + "Graceful shutdown failed, forcing termination", metadata: ["name": vmDirContext.name]) if kill(pid, SIGKILL) == 0 { // Wait a moment for the process to be fully killed try await Task.sleep(nanoseconds: 2_000_000_000) - + // Do final cleanup virtualizationService = nil vncService.stop() try? fileHandle.close() - + Logger.info("VM forcefully stopped", metadata: ["name": vmDirContext.name]) return } - + // If we get here, something went very wrong try? fileHandle.close() Logger.error("Failed to stop VM", metadata: ["name": vmDirContext.name, "pid": "\(pid)"]) @@ -252,24 +302,25 @@ class VM { vmDirContext.config = vmConfig try vmDirContext.saveConfig() } - + private func getDiskSize() throws -> DiskSize { let resourceValues = try vmDirContext.diskPath.url.resourceValues(forKeys: [ .totalFileAllocatedSizeKey, - .totalFileSizeKey + .totalFileSizeKey, ]) - + guard let allocated = resourceValues.totalFileAllocatedSize, - let total = resourceValues.totalFileSize else { + let total = resourceValues.totalFileSize + else { throw VMConfigError.invalidDiskSize } - + return DiskSize(allocated: UInt64(allocated), total: UInt64(total)) } func resizeDisk(_ newSize: UInt64) throws { let currentSize = try getDiskSize() - + guard newSize >= currentSize.total else { throw VMError.resizeTooSmall(current: currentSize.total, requested: newSize) } @@ -335,18 +386,18 @@ class VM { } // MARK: - VNC Management - + func getVNCUrl() -> String? { return vncService.url } - + private func setupVNC(noDisplay: Bool, port: Int = 0) async throws -> String { guard let service = virtualizationService else { throw VMError.internalError("Virtualization service not initialized") } - + try await vncService.start(port: port, virtualMachine: service.getVirtualMachine()) - + guard let url = vncService.url else { throw VMError.vncNotConfigured } @@ -360,19 +411,23 @@ class VM { } // MARK: - Platform-specific Methods - + func getOSType() -> String { fatalError("Must be implemented by subclass") - } - + } + func createVMVirtualizationServiceContext( cpuCount: Int, memorySize: UInt64, display: String, sharedDirectories: [SharedDirectory] = [], mount: Path? = nil, - recoveryMode: Bool = false + recoveryMode: Bool = false, + usbMassStoragePaths: [Path]? = nil ) throws -> VMVirtualizationServiceContext { + // This is a diagnostic log to track actual file paths on disk for debugging + try validateDiskState() + return VMVirtualizationServiceContext( cpuCount: cpuCount, memorySize: memorySize, @@ -384,10 +439,54 @@ class VM { macAddress: vmDirContext.config.macAddress!, diskPath: vmDirContext.diskPath, nvramPath: vmDirContext.nvramPath, - recoveryMode: recoveryMode + recoveryMode: recoveryMode, + usbMassStoragePaths: usbMassStoragePaths ) } + /// Validates the disk state to help diagnose storage attachment issues + private func validateDiskState() throws { + // Check disk image state + let diskPath = vmDirContext.diskPath.path + let diskExists = FileManager.default.fileExists(atPath: diskPath) + var diskSize: UInt64 = 0 + var diskPermissions = "" + + if diskExists { + if let attrs = try? FileManager.default.attributesOfItem(atPath: diskPath) { + diskSize = attrs[.size] as? UInt64 ?? 0 + let posixPerms = attrs[.posixPermissions] as? Int ?? 0 + diskPermissions = String(format: "%o", posixPerms) + } + } + + // Check disk container directory permissions + let diskDir = (diskPath as NSString).deletingLastPathComponent + let dirPerms = + try? FileManager.default.attributesOfItem(atPath: diskDir)[.posixPermissions] as? Int + ?? 0 + let dirPermsString = dirPerms != nil ? String(format: "%o", dirPerms!) : "unknown" + + // Log detailed diagnostics + Logger.info( + "Validating VM disk state", + metadata: [ + "diskPath": diskPath, + "diskExists": "\(diskExists)", + "diskSize": + "\(ByteCountFormatter.string(fromByteCount: Int64(diskSize), countStyle: .file))", + "diskPermissions": diskPermissions, + "dirPermissions": dirPermsString, + "locationName": vmDirContext.storage ?? "default", + ]) + + if !diskExists { + Logger.error("VM disk image does not exist", metadata: ["diskPath": diskPath]) + } else if diskSize == 0 { + Logger.error("VM disk image exists but has zero size", metadata: ["diskPath": diskPath]) + } + } + func setup( ipswPath: String, cpuCount: Int, @@ -399,9 +498,83 @@ class VM { } // MARK: - Finalization - + /// Post-installation step to move the VM directory to the home directory - func finalize(to name: String, home: Home) throws { - try vmDirContext.finalize(to: name) + func finalize(to name: String, home: Home, storage: String? = nil) throws { + let vmDir = try home.getVMDirectory(name, storage: storage) + try FileManager.default.moveItem(at: vmDirContext.dir.dir.url, to: vmDir.dir.url) + } + + // Method to run VM with additional USB mass storage devices + func runWithUSBStorage( + noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0, + recoveryMode: Bool = false, usbImagePaths: [Path] + ) async throws { + guard vmDirContext.initialized else { + throw VMError.notInitialized(vmDirContext.name) + } + + guard let cpuCount = vmDirContext.config.cpuCount, + let memorySize = vmDirContext.config.memorySize + else { + throw VMError.notInitialized(vmDirContext.name) + } + + // Try to acquire lock on config file + let fileHandle = try FileHandle(forWritingTo: vmDirContext.dir.configPath.url) + guard flock(fileHandle.fileDescriptor, LOCK_EX | LOCK_NB) == 0 else { + try? fileHandle.close() + throw VMError.alreadyRunning(vmDirContext.name) + } + + Logger.info( + "Running VM with USB storage devices", + metadata: [ + "cpuCount": "\(cpuCount)", + "memorySize": "\(memorySize)", + "diskSize": "\(vmDirContext.config.diskSize ?? 0)", + "usbImageCount": "\(usbImagePaths.count)", + "recoveryMode": "\(recoveryMode)", + ]) + + // Create and configure the VM + do { + let config = try createVMVirtualizationServiceContext( + cpuCount: cpuCount, + memorySize: memorySize, + display: vmDirContext.config.display.string, + sharedDirectories: sharedDirectories, + mount: mount, + recoveryMode: recoveryMode, + usbMassStoragePaths: usbImagePaths + ) + virtualizationService = try virtualizationServiceFactory(config) + + let vncInfo = try await setupVNC(noDisplay: noDisplay, port: vncPort) + Logger.info("VNC info", metadata: ["vncInfo": vncInfo]) + + // Start the VM + guard let service = virtualizationService else { + throw VMError.internalError("Virtualization service not initialized") + } + try await service.start() + + while true { + try await Task.sleep(nanoseconds: UInt64(1e9)) + } + } catch { + Logger.error( + "Failed to create/start VM with USB storage", + metadata: [ + "error": "\(error)", + "errorType": "\(type(of: error))", + ]) + virtualizationService = nil + vncService.stop() + // Release lock + flock(fileHandle.fileDescriptor, LOCK_UN) + try? fileHandle.close() + throw error + } } } diff --git a/libs/lume/src/VM/VMDetails.swift b/libs/lume/src/VM/VMDetails.swift index 9336d0ae..3387390b 100644 --- a/libs/lume/src/VM/VMDetails.swift +++ b/libs/lume/src/VM/VMDetails.swift @@ -10,21 +10,21 @@ extension DiskSize { var formattedAllocated: String { formatBytes(allocated) } - + var formattedTotal: String { formatBytes(total) } - + private func formatBytes(_ bytes: UInt64) -> String { let units = ["B", "KB", "MB", "GB", "TB"] var size = Double(bytes) var unitIndex = 0 - + while size >= 1024 && unitIndex < units.count - 1 { size /= 1024 unitIndex += 1 } - + return String(format: "%.1f%@", size, units[unitIndex]) } } @@ -39,7 +39,8 @@ struct VMDetails: Codable { let status: String let vncUrl: String? let ipAddress: String? - + let locationName: String + init( name: String, os: String, @@ -49,7 +50,8 @@ struct VMDetails: Codable { display: String, status: String, vncUrl: String?, - ipAddress: String? + ipAddress: String?, + locationName: String ) { self.name = name self.os = os @@ -60,5 +62,6 @@ struct VMDetails: Codable { self.status = status self.vncUrl = vncUrl self.ipAddress = ipAddress + self.locationName = locationName } -} \ No newline at end of file +} diff --git a/libs/lume/src/VM/VMDetailsPrinter.swift b/libs/lume/src/VM/VMDetailsPrinter.swift index 3db08ce9..dc39308b 100644 --- a/libs/lume/src/VM/VMDetailsPrinter.swift +++ b/libs/lume/src/VM/VMDetailsPrinter.swift @@ -8,33 +8,46 @@ enum VMDetailsPrinter { let width: Int let getValue: @Sendable (VMDetails) -> String } - + /// Configuration for all columns in the status table private static let columns: [Column] = [ Column(header: "name", width: 34, getValue: { $0.name }), Column(header: "os", width: 8, getValue: { $0.os }), Column(header: "cpu", width: 8, getValue: { String($0.cpuCount) }), - Column(header: "memory", width: 8, getValue: { - String(format: "%.2fG", Float($0.memorySize) / (1024 * 1024 * 1024)) - }), - Column(header: "disk", width: 16, getValue: { - "\($0.diskSize.formattedAllocated)/\($0.diskSize.formattedTotal)" - }), + Column( + header: "memory", width: 8, + getValue: { + String(format: "%.2fG", Float($0.memorySize) / (1024 * 1024 * 1024)) + }), + Column( + header: "disk", width: 16, + getValue: { + "\($0.diskSize.formattedAllocated)/\($0.diskSize.formattedTotal)" + }), Column(header: "display", width: 12, getValue: { $0.display }), - Column(header: "status", width: 16, getValue: { - $0.status - }), - Column(header: "ip", width: 16, getValue: { - $0.ipAddress ?? "-" - }), - Column(header: "vnc", width: 50, getValue: { - $0.vncUrl ?? "-" - }) + Column( + header: "status", width: 16, + getValue: { + $0.status + }), + Column(header: "storage", width: 16, getValue: { $0.locationName }), + Column( + header: "ip", width: 16, + getValue: { + $0.ipAddress ?? "-" + }), + Column( + header: "vnc", width: 50, + getValue: { + $0.vncUrl ?? "-" + }), ] - + /// Prints the status of all VMs in a formatted table /// - Parameter vms: Array of VM status objects to display - static func printStatus(_ vms: [VMDetails], format: FormatOption, print: (String) -> () = { print($0) }) throws { + static func printStatus( + _ vms: [VMDetails], format: FormatOption, print: (String) -> Void = { print($0) } + ) throws { if format == .json { let jsonEncoder = JSONEncoder() jsonEncoder.outputFormatting = .prettyPrinted @@ -43,15 +56,15 @@ enum VMDetailsPrinter { print(jsonString) } else { printHeader(print: print) - vms.forEach({ printVM($0, print: print)}) + vms.forEach({ printVM($0, print: print) }) } } - - private static func printHeader(print: (String) -> () = { print($0) }) { + + private static func printHeader(print: (String) -> Void = { print($0) }) { let paddedHeaders = columns.map { $0.header.paddedToWidth($0.width) } print(paddedHeaders.joined()) } - + private static func printVM(_ vm: VMDetails, print: (String) -> Void = { print($0) }) { let paddedColumns = columns.map { column in column.getValue(vm).paddedToWidth(column.width) @@ -60,11 +73,11 @@ enum VMDetailsPrinter { } } -private extension String { +extension String { /// Pads the string to the specified width with spaces /// - Parameter width: Target width for padding /// - Returns: Padded string - func paddedToWidth(_ width: Int) -> String { + fileprivate func paddedToWidth(_ width: Int) -> String { padding(toLength: width, withPad: " ", startingAt: 0) } } diff --git a/libs/lume/src/Virtualization/VMVirtualizationService.swift b/libs/lume/src/Virtualization/VMVirtualizationService.swift index 7fad347b..93cb4db0 100644 --- a/libs/lume/src/Virtualization/VMVirtualizationService.swift +++ b/libs/lume/src/Virtualization/VMVirtualizationService.swift @@ -14,6 +14,7 @@ struct VMVirtualizationServiceContext { let diskPath: Path let nvramPath: Path let recoveryMode: Bool + let usbMassStoragePaths: [Path]? } /// Protocol defining the interface for virtualization operations @@ -32,18 +33,19 @@ protocol VMVirtualizationService { class BaseVirtualizationService: VMVirtualizationService { let virtualMachine: VZVirtualMachine let recoveryMode: Bool // Store whether we should start in recovery mode - + var state: VZVirtualMachine.State { virtualMachine.state } - + init(virtualMachine: VZVirtualMachine, recoveryMode: Bool = false) { self.virtualMachine = virtualMachine self.recoveryMode = recoveryMode } - + func start() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in Task { @MainActor in if #available(macOS 13, *) { let startOptions = VZMacOSVirtualMachineStartOptions() @@ -74,7 +76,8 @@ class BaseVirtualizationService: VMVirtualizationService { } func stop() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in virtualMachine.stop { error in if let error = error { continuation.resume(throwing: error) @@ -84,9 +87,10 @@ class BaseVirtualizationService: VMVirtualizationService { } } } - + func pause() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in virtualMachine.start { result in switch result { case .success: @@ -97,9 +101,10 @@ class BaseVirtualizationService: VMVirtualizationService { } } } - + func resume() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in virtualMachine.start { result in switch result { case .success: @@ -110,13 +115,15 @@ class BaseVirtualizationService: VMVirtualizationService { } } } - + func getVirtualMachine() -> Any { return virtualMachine } - + // Helper methods for creating common configurations - static func createStorageDeviceConfiguration(diskPath: Path, readOnly: Bool = false) throws -> VZStorageDeviceConfiguration { + static func createStorageDeviceConfiguration(diskPath: Path, readOnly: Bool = false) throws + -> VZStorageDeviceConfiguration + { return VZVirtioBlockDeviceConfiguration( attachment: try VZDiskImageStorageDeviceAttachment( url: diskPath.url, @@ -126,8 +133,29 @@ class BaseVirtualizationService: VMVirtualizationService { ) ) } - - static func createNetworkDeviceConfiguration(macAddress: String) throws -> VZNetworkDeviceConfiguration { + + static func createUSBMassStorageDeviceConfiguration(diskPath: Path, readOnly: Bool = false) + throws + -> VZStorageDeviceConfiguration + { + if #available(macOS 15.0, *) { + return VZUSBMassStorageDeviceConfiguration( + attachment: try VZDiskImageStorageDeviceAttachment( + url: diskPath.url, + readOnly: readOnly, + cachingMode: VZDiskImageCachingMode.automatic, + synchronizationMode: VZDiskImageSynchronizationMode.fsync + ) + ) + } else { + // Fallback to normal storage device if USB mass storage not available + return try createStorageDeviceConfiguration(diskPath: diskPath, readOnly: readOnly) + } + } + + static func createNetworkDeviceConfiguration(macAddress: String) throws + -> VZNetworkDeviceConfiguration + { let network = VZVirtioNetworkDeviceConfiguration() guard let vzMacAddress = VZMACAddress(string: macAddress) else { throw VMConfigError.invalidMachineIdentifier @@ -136,12 +164,15 @@ class BaseVirtualizationService: VMVirtualizationService { network.macAddress = vzMacAddress return network } - - static func createDirectorySharingDevices(sharedDirectories: [SharedDirectory]?) -> [VZDirectorySharingDeviceConfiguration] { + + static func createDirectorySharingDevices(sharedDirectories: [SharedDirectory]?) + -> [VZDirectorySharingDeviceConfiguration] + { return sharedDirectories?.map { sharedDir in let device = VZVirtioFileSystemDeviceConfiguration(tag: sharedDir.tag) let url = URL(fileURLWithPath: sharedDir.hostPath) - device.share = VZSingleDirectoryShare(directory: VZSharedDirectory(url: url, readOnly: sharedDir.readOnly)) + device.share = VZSingleDirectoryShare( + directory: VZSharedDirectory(url: url, readOnly: sharedDir.readOnly)) return device } ?? [] } @@ -150,7 +181,9 @@ class BaseVirtualizationService: VMVirtualizationService { /// macOS-specific virtualization service @MainActor final class DarwinVirtualizationService: BaseVirtualizationService { - static func createConfiguration(_ config: VMVirtualizationServiceContext) throws -> VZVirtualMachineConfiguration { + static func createConfiguration(_ config: VMVirtualizationServiceContext) throws + -> VZVirtualMachineConfiguration + { let vzConfig = VZVirtualMachineConfiguration() vzConfig.cpuCount = config.cpuCount vzConfig.memorySize = config.memorySize @@ -163,7 +196,7 @@ final class DarwinVirtualizationService: BaseVirtualizationService { guard let hardwareModel = config.hardwareModel else { throw VMConfigError.emptyHardwareModel } - + let platform = VZMacPlatformConfiguration() platform.auxiliaryStorage = VZMacAuxiliaryStorage(url: config.nvramPath.url) Logger.info("Pre-VZMacHardwareModel: hardwareModel=\(hardwareModel)") @@ -171,7 +204,9 @@ final class DarwinVirtualizationService: BaseVirtualizationService { throw VMConfigError.invalidHardwareModel } platform.hardwareModel = vzHardwareModel - guard let vzMachineIdentifier = VZMacMachineIdentifier(dataRepresentation: machineIdentifier) else { + guard + let vzMachineIdentifier = VZMacMachineIdentifier(dataRepresentation: machineIdentifier) + else { throw VMConfigError.invalidMachineIdentifier } platform.machineIdentifier = vzMachineIdentifier @@ -195,59 +230,83 @@ final class DarwinVirtualizationService: BaseVirtualizationService { vzConfig.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()] var storageDevices = [try createStorageDeviceConfiguration(diskPath: config.diskPath)] if let mount = config.mount { - storageDevices.append(try createStorageDeviceConfiguration(diskPath: mount, readOnly: true)) + storageDevices.append( + try createStorageDeviceConfiguration(diskPath: mount, readOnly: true)) + } + // Add USB mass storage devices if specified + if #available(macOS 15.0, *), let usbPaths = config.usbMassStoragePaths, !usbPaths.isEmpty { + for usbPath in usbPaths { + storageDevices.append( + try createUSBMassStorageDeviceConfiguration(diskPath: usbPath, readOnly: true)) + } } vzConfig.storageDevices = storageDevices - vzConfig.networkDevices = [try createNetworkDeviceConfiguration(macAddress: config.macAddress)] + vzConfig.networkDevices = [ + try createNetworkDeviceConfiguration(macAddress: config.macAddress) + ] vzConfig.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()] vzConfig.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] - + // Directory sharing - let directorySharingDevices = createDirectorySharingDevices(sharedDirectories: config.sharedDirectories) + let directorySharingDevices = createDirectorySharingDevices( + sharedDirectories: config.sharedDirectories) if !directorySharingDevices.isEmpty { vzConfig.directorySharingDevices = directorySharingDevices } + // USB Controller configuration + if #available(macOS 15.0, *) { + let usbControllerConfiguration = VZXHCIControllerConfiguration() + vzConfig.usbControllers = [usbControllerConfiguration] + } + try vzConfig.validate() return vzConfig } - + static func generateMacAddress() -> String { VZMACAddress.randomLocallyAdministered().string } - + static func generateMachineIdentifier() -> Data { VZMacMachineIdentifier().dataRepresentation } - + func createAuxiliaryStorage(at path: Path, hardwareModel: Data) throws { guard let vzHardwareModel = VZMacHardwareModel(dataRepresentation: hardwareModel) else { throw VMConfigError.invalidHardwareModel } _ = try VZMacAuxiliaryStorage(creatingStorageAt: path.url, hardwareModel: vzHardwareModel) } - + init(configuration: VMVirtualizationServiceContext) throws { let vzConfig = try Self.createConfiguration(configuration) - super.init(virtualMachine: VZVirtualMachine(configuration: vzConfig), recoveryMode: configuration.recoveryMode) + super.init( + virtualMachine: VZVirtualMachine(configuration: vzConfig), + recoveryMode: configuration.recoveryMode) } - - func installMacOS(imagePath: Path, progressHandler: (@Sendable (Double) -> Void)?) async throws { + + func installMacOS(imagePath: Path, progressHandler: (@Sendable (Double) -> Void)?) async throws + { var observers: [NSKeyValueObservation] = [] // must hold observer references during installation to print process - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in Task { - let installer = VZMacOSInstaller(virtualMachine: virtualMachine, restoringFromImageAt: imagePath.url) + let installer = VZMacOSInstaller( + virtualMachine: virtualMachine, restoringFromImageAt: imagePath.url) Logger.info("Starting macOS installation") - + if let progressHandler = progressHandler { - let observer = installer.progress.observe(\.fractionCompleted, options: [.initial, .new]) { (progress, change) in + let observer = installer.progress.observe( + \.fractionCompleted, options: [.initial, .new] + ) { (progress, change) in if let newValue = change.newValue { progressHandler(newValue) } } observers.append(observer) } - + installer.install { result in switch result { case .success: @@ -266,7 +325,9 @@ final class DarwinVirtualizationService: BaseVirtualizationService { /// Linux-specific virtualization service @MainActor final class LinuxVirtualizationService: BaseVirtualizationService { - static func createConfiguration(_ config: VMVirtualizationServiceContext) throws -> VZVirtualMachineConfiguration { + static func createConfiguration(_ config: VMVirtualizationServiceContext) throws + -> VZVirtualMachineConfiguration + { let vzConfig = VZVirtualMachineConfiguration() vzConfig.cpuCount = config.cpuCount vzConfig.memorySize = config.memorySize @@ -274,10 +335,11 @@ final class LinuxVirtualizationService: BaseVirtualizationService { // Platform configuration let platform = VZGenericPlatformConfiguration() if #available(macOS 15, *) { - platform.isNestedVirtualizationEnabled = VZGenericPlatformConfiguration.isNestedVirtualizationSupported + platform.isNestedVirtualizationEnabled = + VZGenericPlatformConfiguration.isNestedVirtualizationSupported } vzConfig.platform = platform - + let bootLoader = VZEFIBootLoader() bootLoader.variableStore = VZEFIVariableStore(url: config.nvramPath.url) vzConfig.bootLoader = bootLoader @@ -298,16 +360,27 @@ final class LinuxVirtualizationService: BaseVirtualizationService { vzConfig.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()] var storageDevices = [try createStorageDeviceConfiguration(diskPath: config.diskPath)] if let mount = config.mount { - storageDevices.append(try createStorageDeviceConfiguration(diskPath: mount, readOnly: true)) + storageDevices.append( + try createStorageDeviceConfiguration(diskPath: mount, readOnly: true)) + } + // Add USB mass storage devices if specified + if #available(macOS 15.0, *), let usbPaths = config.usbMassStoragePaths, !usbPaths.isEmpty { + for usbPath in usbPaths { + storageDevices.append( + try createUSBMassStorageDeviceConfiguration(diskPath: usbPath, readOnly: true)) + } } vzConfig.storageDevices = storageDevices - vzConfig.networkDevices = [try createNetworkDeviceConfiguration(macAddress: config.macAddress)] + vzConfig.networkDevices = [ + try createNetworkDeviceConfiguration(macAddress: config.macAddress) + ] vzConfig.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()] vzConfig.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] - + // Directory sharing - var directorySharingDevices = createDirectorySharingDevices(sharedDirectories: config.sharedDirectories) - + var directorySharingDevices = createDirectorySharingDevices( + sharedDirectories: config.sharedDirectories) + // Add Rosetta support if available if #available(macOS 13.0, *) { if VZLinuxRosettaDirectoryShare.availability == .installed { @@ -324,23 +397,29 @@ final class LinuxVirtualizationService: BaseVirtualizationService { Logger.info("Rosetta not installed, skipping Rosetta support") } } - + if !directorySharingDevices.isEmpty { vzConfig.directorySharingDevices = directorySharingDevices } + // USB Controller configuration + if #available(macOS 15.0, *) { + let usbControllerConfiguration = VZXHCIControllerConfiguration() + vzConfig.usbControllers = [usbControllerConfiguration] + } + try vzConfig.validate() return vzConfig } - + func generateMacAddress() -> String { VZMACAddress.randomLocallyAdministered().string } - + func createNVRAM(at path: Path) throws { _ = try VZEFIVariableStore(creatingVariableStoreAt: path.url) } - + init(configuration: VMVirtualizationServiceContext) throws { let vzConfig = try Self.createConfiguration(configuration) super.init(virtualMachine: VZVirtualMachine(configuration: vzConfig))