From ae7441d3b5a6af79a36341387f990a149cba6929 Mon Sep 17 00:00:00 2001 From: f-trycua Date: Fri, 4 Apr 2025 23:50:30 -0700 Subject: [PATCH 1/2] Add flexible location commands --- libs/lume/src/Commands/Config.swift | 167 ++++++++++ libs/lume/src/Commands/Create.swift | 39 ++- libs/lume/src/Commands/Pull.swift | 23 +- libs/lume/src/Commands/Run.swift | 58 ++-- .../ImageContainerRegistry.swift | 132 ++++---- libs/lume/src/FileSystem/Home.swift | 295 +++++++++++++----- libs/lume/src/FileSystem/Settings.swift | 249 +++++++++++++++ libs/lume/src/FileSystem/VMLocation.swift | 69 ++++ libs/lume/src/LumeController.swift | 260 +++++++++++---- libs/lume/src/Server/Handlers.swift | 128 ++++---- libs/lume/src/Server/Requests.swift | 33 +- libs/lume/src/Utils/CommandRegistry.swift | 3 +- libs/lume/src/Utils/CommandUtils.swift | 4 +- libs/lume/src/VM/VM.swift | 168 +++++----- 14 files changed, 1244 insertions(+), 384 deletions(-) create mode 100644 libs/lume/src/Commands/Config.swift create mode 100644 libs/lume/src/FileSystem/Settings.swift create mode 100644 libs/lume/src/FileSystem/VMLocation.swift diff --git a/libs/lume/src/Commands/Config.swift b/libs/lume/src/Commands/Config.swift new file mode 100644 index 00000000..a3de1aac --- /dev/null +++ b/libs/lume/src/Commands/Config.swift @@ -0,0 +1,167 @@ +import ArgumentParser +import Foundation + +struct Config: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "config", + abstract: "Get or set lume configuration", + subcommands: [Get.self, Location.self, Cache.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 location: \(settings.defaultLocationName) (\(settings.defaultLocation?.path ?? "not set"))" + ) + + // Display cache directory + print("Cache directory: \(settings.cacheDirectory)") + + // Display all locations + if !settings.vmLocations.isEmpty { + print("\nConfigured VM locations:") + for location in settings.sortedLocations { + let isDefault = location.name == settings.defaultLocationName + let defaultMark = isDefault ? " (default)" : "" + print(" - \(location.name): \(location.path)\(defaultMark)") + } + } + } + } + + // 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: - Location Management Subcommands + + struct Location: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "location", + abstract: "Manage VM locations", + subcommands: [Add.self, Remove.self, List.self, Default.self] + ) + + struct Add: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "add", + abstract: "Add a new VM location" + ) + + @Argument(help: "Location name (alphanumeric with dashes/underscores)") + var name: String + + @Argument(help: "Path to VM location directory") + var path: String + + func run() throws { + let controller = LumeController() + try controller.addLocation(name: name, path: path) + print("Added VM location: \(name) at \(path)") + } + } + + struct Remove: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "remove", + abstract: "Remove a VM location" + ) + + @Argument(help: "Location name to remove") + var name: String + + func run() throws { + let controller = LumeController() + try controller.removeLocation(name: name) + print("Removed VM location: \(name)") + } + } + + struct List: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List all VM locations" + ) + + func run() throws { + let controller = LumeController() + let settings = controller.getSettings() + + if settings.vmLocations.isEmpty { + print("No VM locations configured") + return + } + + print("VM 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 location" + ) + + @Argument(help: "Location name to set as default") + var name: String + + func run() throws { + let controller = LumeController() + try controller.setDefaultLocation(name: name) + print("Set default VM location to: \(name)") + } + } + } +} diff --git a/libs/lume/src/Commands/Create.swift b/libs/lume/src/Commands/Create.swift index 2e0f0150..8ca51b99 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("location"), help: "VM location to use") + var location: 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, + locationName: location ) } -} \ No newline at end of file +} diff --git a/libs/lume/src/Commands/Pull.swift b/libs/lume/src/Commands/Pull.swift index 98d74eed..d56f1653 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("location"), help: "VM location to use") + var location: 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, + locationName: location + ) } } diff --git a/libs/lume/src/Commands/Run.swift b/libs/lume/src/Commands/Run.swift index e94ae7d3..c2f026ff 100644 --- a/libs/lume/src/Commands/Run.swift +++ b/libs/lume/src/Commands/Run.swift @@ -6,37 +6,51 @@ 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(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("location"), help: "VM location to use") + var location: 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 +59,7 @@ struct Run: AsyncParsableCommand { readOnly: false ) } - + // Parse the tag if provided let tag = String(components[1]) let readOnly: Bool @@ -55,9 +69,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 +82,22 @@ struct Run: AsyncParsableCommand { } } } - + 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, + locationName: location ) } } diff --git a/libs/lume/src/ContainerRegistry/ImageContainerRegistry.swift b/libs/lume/src/ContainerRegistry/ImageContainerRegistry.swift index 83d1d996..95e72cec 100644 --- a/libs/lume/src/ContainerRegistry/ImageContainerRegistry.swift +++ b/libs/lume/src/ContainerRegistry/ImageContainerRegistry.swift @@ -81,9 +81,12 @@ class ImageContainerRegistry: @unchecked Sendable { 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") + try? FileManager.default.createDirectory( at: cacheDirectory, withIntermediateDirectories: true) @@ -241,22 +244,39 @@ class ImageContainerRegistry: @unchecked Sendable { } } - func pull(image: String, name: String?, noCache: Bool = false) async throws { - // Validate home directory + 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, locationName: locationName) // 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") @@ -266,7 +286,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 ) @@ -290,30 +310,24 @@ class ImageContainerRegistry: @unchecked Sendable { // 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) { + if 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) - } + try cleanupOldVersions(currentManifestId: manifestId, image: imageName) - if noCache { - Logger.info("Skipping cache setup due to noCache option") - } else { - Logger.info("Cache miss or invalid cache, setting up new cache") - // Setup new cache directory - try setupImageCache(manifestId: manifestId) - // Save new manifest - try saveManifest(manifest, manifestId: manifestId) + Logger.info("Cache miss or invalid cache, setting up new cache") + // Setup new cache directory + try setupImageCache(manifestId: manifestId) + // Save new manifest + try saveManifest(manifest, manifestId: manifestId) - // Save image metadata - try saveImageMetadata( - image: imageName, - manifestId: manifestId - ) - } + // Save image metadata + try saveImageMetadata( + image: imageName, + manifestId: manifestId + ) // Create temporary directory for new downloads let tempDownloadDir = FileManager.default.temporaryDirectory.appendingPathComponent( @@ -372,7 +386,7 @@ 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 @@ -394,14 +408,12 @@ class ImageContainerRegistry: @unchecked Sendable { group.addTask { @Sendable [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) @@ -414,9 +426,7 @@ class ImageContainerRegistry: @unchecked Sendable { } // Start new download - if !noCache { - markDownloadStarted(digest) - } + markDownloadStarted(digest) try await self.downloadLayer( repository: "\(self.organization)/\(imageName)", @@ -429,15 +439,12 @@ class ImageContainerRegistry: @unchecked Sendable { ) // Cache the downloaded layer if not in noCache mode - if !noCache { - if FileManager.default.fileExists(atPath: cachedLayer.path) - { - try FileManager.default.removeItem(at: cachedLayer) - } - try FileManager.default.copyItem( - at: partURL, to: cachedLayer) - markDownloadComplete(digest) + if FileManager.default.fileExists(atPath: cachedLayer.path) { + try FileManager.default.removeItem(at: cachedLayer) } + try FileManager.default.copyItem( + at: partURL, to: cachedLayer) + markDownloadComplete(digest) } await counter.decrement() @@ -468,13 +475,12 @@ class ImageContainerRegistry: @unchecked Sendable { 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) { @@ -486,9 +492,7 @@ class ImageContainerRegistry: @unchecked Sendable { } // Start new download - if !noCache { - markDownloadStarted(digest) - } + markDownloadStarted(digest) try await self.downloadLayer( repository: "\(self.organization)/\(imageName)", @@ -501,13 +505,11 @@ class ImageContainerRegistry: @unchecked Sendable { ) // Cache the downloaded layer if not in noCache mode - if !noCache { - if FileManager.default.fileExists(atPath: cachedLayer.path) { - try FileManager.default.removeItem(at: cachedLayer) - } - try FileManager.default.copyItem(at: outputURL, to: cachedLayer) - markDownloadComplete(digest) + if FileManager.default.fileExists(atPath: cachedLayer.path) { + try FileManager.default.removeItem(at: cachedLayer) } + try FileManager.default.copyItem(at: outputURL, to: cachedLayer) + markDownloadComplete(digest) } await counter.decrement() @@ -548,7 +550,7 @@ class ImageContainerRegistry: @unchecked Sendable { defer { try? inputHandle.close() // Don't delete the part file if we're in cache mode and the part is from cache - if noCache || !partURL.path.contains(cacheDirectory.path) { + if !partURL.path.contains(cacheDirectory.path) { try? FileManager.default.removeItem(at: partURL) } } @@ -632,9 +634,21 @@ 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)") diff --git a/libs/lume/src/FileSystem/Home.swift b/libs/lume/src/FileSystem/Home.swift index 571a7f92..79476407 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,233 @@ final class Home { throw HomeError.directoryCreationFailed(path: tempDir.path) } } - - /// Returns a VMDirectory instance for the given name - /// - Parameter name: Name of the VM directory + + /// Returns a VMDirectory instance for the given name and location + /// - Parameters: + /// - name: Name of the VM directory + /// - locationName: 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, locationName: String? = nil) throws -> VMDirectory { + let location: VMLocation + + if let locationName = locationName { + // Get a specific location + guard let loc = locations[locationName] else { + throw VMLocationError.locationNotFound(name: locationName) + } + 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, locationName: sourceLocation) + let destDir = try getVMDirectory(destName, locationName: 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..96a5dedc --- /dev/null +++ b/libs/lume/src/FileSystem/Settings.swift @@ -0,0 +1,249 @@ +import Foundation + +/// Manages the application settings using a config file +struct LumeSettings: Codable, Sendable { + var vmLocations: [VMLocation] + var defaultLocationName: String + var cacheDirectory: String + + 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" + ) + + /// 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 { + static let xdgConfigDir = "~/.config/lume" + static let configFileName = "config.json" + } + + // MARK: - Properties + + static let shared = SettingsManager() + private let fileManager: FileManager + + // Path to XDG config file + private var configFilePath: String { + let configDir = (Constants.xdgConfigDir as NSString).expandingTildeInPath + 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.defaultSettings + + // Try to save default settings + try? saveSettings(defaultSettings) + + return defaultSettings + } + + func saveSettings(_ settings: LumeSettings) throws { + let configDir = (Constants.xdgConfigDir as NSString).expandingTildeInPath + try fileManager.createDirectory(atPath: configDir, withIntermediateDirectories: true) + + let data = try JSONEncoder().encode(settings) + try data.write(to: URL(fileURLWithPath: configFilePath)) + } + + // 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 + } + + // MARK: - Private Helpers + + private func ensureConfigDirectoryExists() { + let configDir = (Constants.xdgConfigDir as NSString).expandingTildeInPath + try? fileManager.createDirectory(atPath: configDir, withIntermediateDirectories: true) + } + + private func readSettingsFromFile() -> LumeSettings? { + guard fileExists(at: configFilePath) else { return nil } + + do { + let data = try Data(contentsOf: URL(fileURLWithPath: configFilePath)) + return try JSONDecoder().decode(LumeSettings.self, from: data) + } catch { + Logger.error( + "Failed to read settings from file", metadata: ["error": error.localizedDescription] + ) + return nil + } + } + + 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..12859ba4 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, locationName: vmWithLoc.locationName) return vm.details } return statuses @@ -62,22 +64,38 @@ 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) - + try self.validateVMExists(normalizedName, locationName: 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, locationName: 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 +103,12 @@ final class LumeController { } @MainActor - public func get(name: String) throws -> VM { + public func get(name: String, locationName: String? = nil) throws -> VM { let normalizedName = normalizeVMName(name: name) do { - try self.validateVMExists(normalizedName) + try self.validateVMExists(normalizedName, locationName: locationName) - let vm = try self.loadVM(name: normalizedName) + let vm = try self.loadVM(name: normalizedName, locationName: locationName) return vm } catch { Logger.error("Failed to get VM", metadata: ["error": error.localizedDescription]) @@ -107,13 +125,15 @@ final class LumeController { cpuCount: Int, memorySize: UInt64, display: String, - ipsw: String? + ipsw: String?, + locationName: String? = nil ) async throws { Logger.info( "Creating VM", metadata: [ "name": name, "os": os, + "location": locationName ?? "default", "disk_size": "\(diskSize / 1024 / 1024)MB", "cpu_count": "\(cpuCount)", "memory_size": "\(memorySize / 1024 / 1024)MB", @@ -122,7 +142,7 @@ final class LumeController { ]) do { - try validateCreateParameters(name: name, os: os, ipsw: ipsw) + try validateCreateParameters(name: name, os: os, ipsw: ipsw, locationName: locationName) let vm = try await createTempVMConfig( os: os, @@ -140,7 +160,7 @@ final class LumeController { display: display ) - try vm.finalize(to: name, home: home) + try vm.finalize(to: name, home: home, locationName: locationName) Logger.info("VM created successfully", metadata: ["name": name]) } catch { @@ -150,19 +170,24 @@ final class LumeController { } @MainActor - public func delete(name: String) async throws { + public func delete(name: String, locationName: String? = nil) async throws { let normalizedName = normalizeVMName(name: name) - Logger.info("Deleting VM", metadata: ["name": normalizedName]) + Logger.info( + "Deleting VM", + metadata: [ + "name": normalizedName, + "location": locationName ?? "default", + ]) do { - try self.validateVMExists(normalizedName) + try self.validateVMExists(normalizedName, locationName: locationName) // 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, locationName: locationName) try vmDir.delete() Logger.info("VM deleted successfully", metadata: ["name": normalizedName]) @@ -181,22 +206,24 @@ final class LumeController { cpu: Int? = nil, memory: UInt64? = nil, diskSize: UInt64? = nil, - display: String? = nil + display: String? = nil, + locationName: String? = nil ) throws { let normalizedName = normalizeVMName(name: name) Logger.info( "Updating VM settings", metadata: [ "name": normalizedName, + "location": locationName ?? "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) + try self.validateVMExists(normalizedName, locationName: locationName) - let vm = try get(name: normalizedName) + let vm = try get(name: normalizedName, locationName: locationName) // Apply settings in order if let cpu = cpu { @@ -257,15 +284,18 @@ final class LumeController { registry: String = "ghcr.io", organization: String = "trycua", vncPort: Int = 0, - recoveryMode: Bool = false + recoveryMode: Bool = false, + locationName: String? = nil ) async throws { let normalizedName = normalizeVMName(name: name) Logger.info( "Running VM", metadata: [ "name": normalizedName, + "location": locationName ?? "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)", @@ -276,19 +306,31 @@ final class LumeController { let components = name.split(separator: ":") if components.count == 2 { do { - try self.validateVMExists(normalizedName) + try self.validateVMExists(normalizedName, locationName: locationName) } 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, + locationName: locationName + ) } } try validateRunParameters( - name: normalizedName, sharedDirectories: sharedDirectories, mount: mount) + name: normalizedName, + sharedDirectories: sharedDirectories, + mount: mount, + locationName: locationName + ) - let vm = try get(name: normalizedName) + let vm = try get(name: normalizedName, locationName: locationName) 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) Logger.info("VM started successfully", metadata: ["name": normalizedName]) } catch { SharedVM.shared.removeVM(name: normalizedName) @@ -316,9 +358,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, + locationName: String? = nil + ) async throws { do { let vmName: String = name ?? normalizeVMName(name: image) @@ -329,19 +375,31 @@ final class LumeController { "name": name ?? "default", "registry": registry, "organization": organization, + "location": locationName ?? "default", ]) try self.validatePullParameters( - image: image, name: vmName, registry: registry, organization: organization) + image: image, + name: vmName, + registry: registry, + organization: organization, + locationName: locationName + ) 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: locationName) - Logger.info("Setting new VM mac address") + Logger.info( + "Setting new VM mac address", + metadata: [ + "vm_name": vmName, + "location": locationName ?? "default", + ]) // Update MAC address in the cloned VM to ensure uniqueness - let vm = try get(name: vmName) + let vm = try get(name: vmName, locationName: locationName) try vm.setMacAddress(VZMACAddress.randomLocallyAdministered().string) Logger.info( @@ -351,6 +409,7 @@ final class LumeController { "name": vmName, "registry": registry, "organization": organization, + "location": locationName ?? "default", ]) } catch { Logger.error("Failed to pull image", metadata: ["error": error.localizedDescription]) @@ -361,14 +420,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 +454,82 @@ 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() + } + // MARK: - Private Helper Methods /// Normalizes a VM name by replacing colons with underscores @@ -443,8 +566,8 @@ final class LumeController { } @MainActor - private func loadVM(name: String) throws -> VM { - let vmDir = home.getVMDirectory(name) + private func loadVM(name: String, locationName: String? = nil) throws -> VM { + let vmDir = try home.getVMDirectory(name, locationName: locationName) guard vmDir.initialized() else { throw VMError.notInitialized(name) } @@ -459,7 +582,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?, locationName: String? + ) throws { if os.lowercased() == "macos" { guard let ipsw = ipsw else { throw ValidationError("IPSW path required for macOS VM") @@ -475,7 +600,7 @@ final class LumeController { throw ValidationError("Unsupported OS type: \(os)") } - let vmDir = home.getVMDirectory(name) + let vmDir = try home.getVMDirectory(name, locationName: locationName) if vmDir.exists() { throw VMError.alreadyExists(name) } @@ -493,15 +618,19 @@ final class LumeController { } } - public func validateVMExists(_ name: String) throws { - let vmDir = home.getVMDirectory(name) + public func validateVMExists(_ name: String, locationName: String? = nil) throws { + let vmDir = try home.getVMDirectory(name, locationName: locationName) guard vmDir.initialized() else { throw VMError.notFound(name) } } private func validatePullParameters( - image: String, name: String, registry: String, organization: String + image: String, + name: String, + registry: String, + organization: String, + locationName: String? = nil ) throws { guard !image.isEmpty else { throw ValidationError("Image name cannot be empty") @@ -516,20 +645,21 @@ final class LumeController { throw ValidationError("Organization cannot be empty") } - let vmDir = home.getVMDirectory(name) + let vmDir = try home.getVMDirectory(name, locationName: locationName) if vmDir.exists() { throw VMError.alreadyExists(name) } } private func validateRunParameters( - name: String, sharedDirectories: [SharedDirectory]?, mount: Path? + name: String, sharedDirectories: [SharedDirectory]?, mount: Path?, + locationName: String? = nil ) throws { - try self.validateVMExists(name) - if let dirs: [SharedDirectory] = sharedDirectories { + try self.validateVMExists(name, locationName: locationName) + if let dirs = sharedDirectories { try self.validateSharedDirectories(dirs) } - let vmConfig = try home.getVMDirectory(name).loadConfig() + let vmConfig = try home.getVMDirectory(name, locationName: locationName).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..9c00038e 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,7 +15,7 @@ extension Server { return .badRequest(message: error.localizedDescription) } } - + func handleGetVM(name: String) async throws -> HTTPResponse { do { let vmController = LumeController() @@ -25,17 +25,18 @@ extension Server { 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() @@ -48,11 +49,13 @@ extension Server { display: request.display, ipsw: request.ipsw ) - + return HTTPResponse( statusCode: .ok, headers: ["Content-Type": "application/json"], - body: try JSONEncoder().encode(["message": "VM created successfully", "name": request.name]) + body: try JSONEncoder().encode([ + "message": "VM created successfully", "name": request.name, + ]) ) } catch { return HTTPResponse( @@ -67,33 +70,37 @@ extension Server { do { let vmController = LumeController() try await vmController.delete(name: name) - return HTTPResponse(statusCode: .ok, headers: ["Content-Type": "application/json"], body: Data()) + 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))) + 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 { + 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 + "destination": request.newName, ]) ) } catch { @@ -104,19 +111,20 @@ extension Server { ) } } - + // 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 { + 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() @@ -127,7 +135,7 @@ extension Server { diskSize: sizes.diskSize, display: sizes.display?.string ) - + return HTTPResponse( statusCode: .ok, headers: ["Content-Type": "application/json"], @@ -141,7 +149,7 @@ extension Server { ) } } - + func handleStopVM(name: String) async throws -> HTTPResponse { do { let vmController = LumeController() @@ -161,11 +169,13 @@ 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) + do { let dirs = try request.parse() - + // Start VM in background startVM( name: name, @@ -173,7 +183,7 @@ extension Server { sharedDirectories: dirs, recoveryMode: request.recoveryMode ?? false ) - + // Return response immediately return HTTPResponse( statusCode: .accepted, @@ -181,7 +191,7 @@ extension Server { body: try JSONEncoder().encode([ "message": "VM start initiated", "name": name, - "status": "pending" + "status": "pending", ]) ) } catch { @@ -192,9 +202,9 @@ extension Server { ) } } - + // MARK: - Image Management Handlers - + func handleIPSW() async throws -> HTTPResponse { do { let vmController = LumeController() @@ -215,7 +225,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,7 +236,12 @@ 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 + ) return HTTPResponse( statusCode: .ok, headers: ["Content-Type": "application/json"], @@ -257,30 +273,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,9 +314,9 @@ extension Server { ) } } - + // MARK: - Private Helper Methods - + nonisolated private func startVM( name: String, noDisplay: Bool, @@ -315,10 +335,12 @@ extension Server { ) 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..429ad752 100644 --- a/libs/lume/src/Server/Requests.swift +++ b/libs/lume/src/Server/Requests.swift @@ -1,28 +1,30 @@ -import Foundation import ArgumentParser +import Foundation import Virtualization struct RunVMRequest: Codable { let noDisplay: Bool? let sharedDirectories: [SharedDirectoryRequest]? let recoveryMode: Bool? - + 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 +39,17 @@ struct PullRequest: Codable { let name: String? var registry: String var organization: String - var noCache: Bool - + enum CodingKeys: String, CodingKey { - case image, name, registry, organization, noCache + case image, name, registry, organization } - + 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 } } @@ -61,7 +61,7 @@ struct CreateVMRequest: Codable { let diskSize: String let display: String let ipsw: String? - + func parse() throws -> (memory: UInt64, diskSize: UInt64) { return ( memory: try parseSize(memory), @@ -75,14 +75,15 @@ struct SetVMRequest: Codable { let memory: String? let diskSize: String? let display: 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 } 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..abf74de8 100644 --- a/libs/lume/src/VM/VM.swift +++ b/libs/lume/src/VM/VM.swift @@ -7,22 +7,22 @@ struct VMDirContext { let dir: VMDirectory var config: VMConfig let home: Home - + 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 +33,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 +62,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 +77,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 +87,24 @@ 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 ) } // 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 + ) 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 +115,18 @@ 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)", + ]) // Create and configure the VM do { @@ -127,16 +139,16 @@ class VM { recoveryMode: recoveryMode ) 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)) } @@ -164,13 +176,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 +194,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 +276,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 +360,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,11 +385,11 @@ class VM { } // MARK: - Platform-specific Methods - + func getOSType() -> String { fatalError("Must be implemented by subclass") - } - + } + func createVMVirtualizationServiceContext( cpuCount: Int, memorySize: UInt64, @@ -399,9 +424,10 @@ 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, locationName: String? = nil) throws { + let vmDir = try home.getVMDirectory(name, locationName: locationName) + try FileManager.default.moveItem(at: vmDirContext.dir.dir.url, to: vmDir.dir.url) } } From 4cb4278c47bd6e9270ba24e843a54a9c0a36736e Mon Sep 17 00:00:00 2001 From: f-trycua Date: Sun, 13 Apr 2025 17:47:34 -0700 Subject: [PATCH 2/2] Introduce storage param, usb device --- libs/lume/README.md | 31 ++ libs/lume/docs/API-Reference.md | 135 ++++- libs/lume/docs/FAQ.md | 61 ++- libs/lume/src/Commands/Clone.swift | 23 +- libs/lume/src/Commands/Config.swift | 97 +++- libs/lume/src/Commands/Create.swift | 6 +- libs/lume/src/Commands/Delete.swift | 24 +- libs/lume/src/Commands/Get.swift | 13 +- libs/lume/src/Commands/Pull.swift | 6 +- libs/lume/src/Commands/Run.swift | 17 +- libs/lume/src/Commands/Set.swift | 6 +- libs/lume/src/Commands/Stop.swift | 9 +- .../ImageContainerRegistry.swift | 240 ++++++--- libs/lume/src/FileSystem/Home.swift | 17 +- libs/lume/src/FileSystem/Settings.swift | 215 +++++++- libs/lume/src/LumeController.swift | 182 +++++-- libs/lume/src/Server/Handlers.swift | 188 ++++++- libs/lume/src/Server/Requests.swift | 9 +- libs/lume/src/Server/Server.swift | 487 +++++++++++------- libs/lume/src/VM/VM.swift | 161 +++++- libs/lume/src/VM/VMDetails.swift | 17 +- libs/lume/src/VM/VMDetailsPrinter.swift | 61 ++- .../VMVirtualizationService.swift | 175 +++++-- 23 files changed, 1699 insertions(+), 481 deletions(-) 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 index a3de1aac..af7f02c3 100644 --- a/libs/lume/src/Commands/Config.swift +++ b/libs/lume/src/Commands/Config.swift @@ -5,7 +5,7 @@ struct Config: ParsableCommand { static let configuration = CommandConfiguration( commandName: "config", abstract: "Get or set lume configuration", - subcommands: [Get.self, Location.self, Cache.self], + subcommands: [Get.self, Storage.self, Cache.self, Caching.self], defaultSubcommand: Get.self ) @@ -23,15 +23,18 @@ struct Config: ParsableCommand { // Display default location print( - "Default VM location: \(settings.defaultLocationName) (\(settings.defaultLocation?.path ?? "not set"))" + "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 locations:") + print("\nConfigured VM storage locations:") for location in settings.sortedLocations { let isDefault = location.name == settings.defaultLocationName let defaultMark = isDefault ? " (default)" : "" @@ -41,6 +44,60 @@ struct Config: ParsableCommand { } } + // 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 { @@ -80,54 +137,54 @@ struct Config: ParsableCommand { } } - // MARK: - Location Management Subcommands + // MARK: - Storage Management Subcommands - struct Location: ParsableCommand { + struct Storage: ParsableCommand { static let configuration = CommandConfiguration( - commandName: "location", - abstract: "Manage VM locations", + 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 location" + abstract: "Add a new VM storage location" ) - @Argument(help: "Location name (alphanumeric with dashes/underscores)") + @Argument(help: "Storage name (alphanumeric with dashes/underscores)") var name: String - @Argument(help: "Path to VM location directory") + @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 location: \(name) at \(path)") + print("Added VM storage location: \(name) at \(path)") } } struct Remove: ParsableCommand { static let configuration = CommandConfiguration( commandName: "remove", - abstract: "Remove a VM location" + abstract: "Remove a VM storage location" ) - @Argument(help: "Location name to remove") + @Argument(help: "Storage name to remove") var name: String func run() throws { let controller = LumeController() try controller.removeLocation(name: name) - print("Removed VM location: \(name)") + print("Removed VM storage location: \(name)") } } struct List: ParsableCommand { static let configuration = CommandConfiguration( commandName: "list", - abstract: "List all VM locations" + abstract: "List all VM storage locations" ) func run() throws { @@ -135,11 +192,11 @@ struct Config: ParsableCommand { let settings = controller.getSettings() if settings.vmLocations.isEmpty { - print("No VM locations configured") + print("No VM storage locations configured") return } - print("VM Locations:") + print("VM Storage Locations:") for location in settings.sortedLocations { let isDefault = location.name == settings.defaultLocationName let defaultMark = isDefault ? " (default)" : "" @@ -151,16 +208,16 @@ struct Config: ParsableCommand { struct Default: ParsableCommand { static let configuration = CommandConfiguration( commandName: "default", - abstract: "Set the default VM location" + abstract: "Set the default VM storage location" ) - @Argument(help: "Location name to set as default") + @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 location to: \(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 8ca51b99..b4f02633 100644 --- a/libs/lume/src/Commands/Create.swift +++ b/libs/lume/src/Commands/Create.swift @@ -40,8 +40,8 @@ struct Create: AsyncParsableCommand { ) var ipsw: String? - @Option(name: .customLong("location"), help: "VM location to use") - var location: String? + @Option(name: .customLong("storage"), help: "VM storage location to use") + var storage: String? init() { } @@ -57,7 +57,7 @@ struct Create: AsyncParsableCommand { memorySize: memory, display: display.string, ipsw: ipsw, - locationName: location + storage: storage ) } } 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 d56f1653..074e0fac 100644 --- a/libs/lume/src/Commands/Pull.swift +++ b/libs/lume/src/Commands/Pull.swift @@ -19,8 +19,8 @@ struct Pull: AsyncParsableCommand { @Option(help: "Organization to pull from. Defaults to trycua") var organization: String = "trycua" - @Option(name: .customLong("location"), help: "VM location to use") - var location: String? + @Option(name: .customLong("storage"), help: "VM storage location to use") + var storage: String? init() {} @@ -32,7 +32,7 @@ struct Pull: AsyncParsableCommand { name: name, registry: registry, organization: organization, - locationName: location + storage: storage ) } } diff --git a/libs/lume/src/Commands/Run.swift b/libs/lume/src/Commands/Run.swift index c2f026ff..bc659769 100644 --- a/libs/lume/src/Commands/Run.swift +++ b/libs/lume/src/Commands/Run.swift @@ -28,6 +28,12 @@ struct Run: AsyncParsableCommand { 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" @@ -42,8 +48,8 @@ struct Run: AsyncParsableCommand { @Option(help: "For MacOS VMs only, boot into the VM in recovery mode") var recoveryMode: Bool = false - @Option(name: .customLong("location"), help: "VM location to use") - var location: String? + @Option(name: .customLong("storage"), help: "VM storage location to use") + var storage: String? private var parsedSharedDirectories: [SharedDirectory] { get throws { @@ -83,6 +89,10 @@ struct Run: AsyncParsableCommand { } } + private var parsedUSBStorageDevices: [Path] { + usbStorageDevices.map { Path($0) } + } + init() { } @@ -97,7 +107,8 @@ struct Run: AsyncParsableCommand { organization: organization, vncPort: vncPort, recoveryMode: recoveryMode, - locationName: location + 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 1a4f12e9..cd0f8fb8 100644 --- a/libs/lume/src/ContainerRegistry/ImageContainerRegistry.swift +++ b/libs/lume/src/ContainerRegistry/ImageContainerRegistry.swift @@ -57,11 +57,16 @@ actor ProgressTracker { 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) { @@ -69,9 +74,11 @@ actor ProgressTracker { 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 @@ -82,6 +89,13 @@ actor ProgressTracker { // 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() @@ -94,6 +108,7 @@ actor ProgressTracker { current: progress, currentSpeed: currentSpeed, averageSpeed: recentAvgSpeed, + smoothedSpeed: smoothedSpeed, overallSpeed: overallAvgSpeed, peakSpeed: peakSpeed, context: "Downloading Image" @@ -108,9 +123,19 @@ actor ProgressTracker { 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 { @@ -128,6 +153,7 @@ actor ProgressTracker { current: Double, currentSpeed: Double, averageSpeed: Double, + smoothedSpeed: Double, overallSpeed: Double, peakSpeed: Double, context: String @@ -137,9 +163,11 @@ actor ProgressTracker { 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) @@ -249,6 +277,7 @@ 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 @@ -260,6 +289,9 @@ class ImageContainerRegistry: @unchecked Sendable { self.cacheDirectory = URL(fileURLWithPath: expandedCacheDir) .appendingPathComponent("ghcr") + // Get caching enabled setting + self.cachingEnabled = SettingsManager.shared.isCachingEnabled() + try? FileManager.default.createDirectory( at: cacheDirectory, withIntermediateDirectories: true) @@ -316,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 @@ -335,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) } @@ -369,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( @@ -380,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: [ @@ -417,6 +469,17 @@ class ImageContainerRegistry: @unchecked Sendable { } } + 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?, @@ -430,7 +493,10 @@ class ImageContainerRegistry: @unchecked Sendable { // Use provided name or derive from image let vmName = name ?? image.split(separator: ":").first.map(String.init) ?? "" - let vmDir = try home.getVMDirectory(vmName, locationName: locationName) + 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: ":") @@ -481,26 +547,34 @@ 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 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 - try cleanupOldVersions(currentManifestId: manifestId, image: imageName) + // 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") + } - Logger.info("Cache miss or invalid cache, setting up new cache") - // Setup new cache directory - try setupImageCache(manifestId: manifestId) - // Save new manifest - try saveManifest(manifest, manifestId: manifestId) + // Clean up old versions of this repository before setting up new cache if caching is enabled + if cachingEnabled { + try cleanupOldVersions(currentManifestId: manifestId, image: imageName) - // Save image metadata - try saveImageMetadata( - image: imageName, - manifestId: manifestId - ) + // Setup new cache directory + try setupImageCache(manifestId: manifestId) + // Save new manifest + try saveManifest(manifest, manifestId: manifestId) + + // Save image metadata + try saveImageMetadata( + image: imageName, + manifestId: manifestId + ) + } // Create temporary directory for new downloads let tempDownloadDir = FileManager.default.temporaryDirectory.appendingPathComponent( @@ -516,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 @@ -526,6 +597,12 @@ 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 @@ -569,7 +646,7 @@ class ImageContainerRegistry: @unchecked Sendable { 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() @@ -581,7 +658,7 @@ 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 FileManager.default.fileExists(atPath: cachedLayer.path) { @@ -611,15 +688,19 @@ 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 FileManager.default.fileExists(atPath: cachedLayer.path) { - try FileManager.default.removeItem(at: cachedLayer) + // 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) } - try FileManager.default.copyItem( - at: partURL, to: cachedLayer) markDownloadComplete(digest) } @@ -645,7 +726,7 @@ class ImageContainerRegistry: @unchecked Sendable { continue } - group.addTask { @Sendable [self] in + group.addTask { [self] in await counter.increment() let cachedLayer = getCachedLayerPath( @@ -677,14 +758,17 @@ 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 FileManager.default.fileExists(atPath: cachedLayer.path) { - try FileManager.default.removeItem(at: cachedLayer) + // 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) } - try FileManager.default.copyItem(at: outputURL, to: cachedLayer) markDownloadComplete(digest) } @@ -721,7 +805,7 @@ class ImageContainerRegistry: @unchecked Sendable { { $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 @@ -819,12 +903,14 @@ class ImageContainerRegistry: @unchecked Sendable { (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" ) } @@ -874,6 +960,9 @@ class ImageContainerRegistry: @unchecked Sendable { 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" ) @@ -938,7 +1027,7 @@ class ImageContainerRegistry: @unchecked Sendable { 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 @@ -1028,12 +1117,12 @@ class ImageContainerRegistry: @unchecked Sendable { (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" ) } @@ -1086,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( @@ -1098,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 @@ -1128,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 { diff --git a/libs/lume/src/FileSystem/Home.swift b/libs/lume/src/FileSystem/Home.swift index 79476407..cca14062 100644 --- a/libs/lume/src/FileSystem/Home.swift +++ b/libs/lume/src/FileSystem/Home.swift @@ -64,19 +64,20 @@ final class Home { } } - /// Returns a VMDirectory instance for the given name and location + /// Gets a VM directory for a specific VM name and optional location + /// /// - Parameters: /// - name: Name of the VM directory - /// - locationName: Optional name of the VM location (default: default location) + /// - storage: Optional name of the VM location (default: default location) /// - Returns: A VMDirectory instance /// - Throws: HomeError if location not found - func getVMDirectory(_ name: String, locationName: String? = nil) throws -> VMDirectory { + func getVMDirectory(_ name: String, storage: String? = nil) throws -> VMDirectory { let location: VMLocation - if let locationName = locationName { + if let storage = storage { // Get a specific location - guard let loc = locations[locationName] else { - throw VMLocationError.locationNotFound(name: locationName) + guard let loc = locations[storage] else { + throw VMLocationError.locationNotFound(name: storage) } location = loc } else { @@ -154,8 +155,8 @@ final class Home { sourceLocation: String? = nil, destLocation: String? = nil ) throws { - let sourceDir = try getVMDirectory(sourceName, locationName: sourceLocation) - let destDir = try getVMDirectory(destName, locationName: destLocation) + let sourceDir = try getVMDirectory(sourceName, storage: sourceLocation) + let destDir = try getVMDirectory(destName, storage: destLocation) if destDir.initialized() { throw HomeError.directoryAlreadyExists(path: destDir.dir.path) diff --git a/libs/lume/src/FileSystem/Settings.swift b/libs/lume/src/FileSystem/Settings.swift index 96a5dedc..2025774b 100644 --- a/libs/lume/src/FileSystem/Settings.swift +++ b/libs/lume/src/FileSystem/Settings.swift @@ -5,6 +5,7 @@ struct LumeSettings: Codable, Sendable { var vmLocations: [VMLocation] var defaultLocationName: String var cacheDirectory: String + var cachingEnabled: Bool var defaultLocation: VMLocation? { vmLocations.first { $0.name == defaultLocationName } @@ -20,7 +21,8 @@ struct LumeSettings: Codable, Sendable { VMLocation(name: "default", path: "~/.lume") ], defaultLocationName: "default", - cacheDirectory: "~/.lume/cache" + cacheDirectory: "~/.lume/cache", + cachingEnabled: true ) /// Gets all locations sorted by name @@ -33,8 +35,9 @@ final class SettingsManager: @unchecked Sendable { // MARK: - Constants private enum Constants { - static let xdgConfigDir = "~/.config/lume" - static let configFileName = "config.json" + // Default path for config + static let fallbackConfigDir = "~/.config/lume" + static let configFileName = "config.yaml" } // MARK: - Properties @@ -42,9 +45,18 @@ final class SettingsManager: @unchecked Sendable { static let shared = SettingsManager() private let fileManager: FileManager - // Path to XDG config file + // 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 { - let configDir = (Constants.xdgConfigDir as NSString).expandingTildeInPath return "\(configDir)/\(Constants.configFileName)" } @@ -63,7 +75,14 @@ final class SettingsManager: @unchecked Sendable { } // No settings file found, use defaults - let defaultSettings = LumeSettings.defaultSettings + let defaultSettings = LumeSettings( + vmLocations: [ + VMLocation(name: "default", path: "~/.lume") + ], + defaultLocationName: "default", + cacheDirectory: "~/.lume/cache", + cachingEnabled: true + ) // Try to save default settings try? saveSettings(defaultSettings) @@ -72,11 +91,30 @@ final class SettingsManager: @unchecked Sendable { } func saveSettings(_ settings: LumeSettings) throws { - let configDir = (Constants.xdgConfigDir as NSString).expandingTildeInPath try fileManager.createDirectory(atPath: configDir, withIntermediateDirectories: true) - let data = try JSONEncoder().encode(settings) - try data.write(to: URL(fileURLWithPath: configFilePath)) + // 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 @@ -203,25 +241,162 @@ final class SettingsManager: @unchecked Sendable { 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() { - let configDir = (Constants.xdgConfigDir as NSString).expandingTildeInPath try? fileManager.createDirectory(atPath: configDir, withIntermediateDirectories: true) } private func readSettingsFromFile() -> LumeSettings? { - guard fileExists(at: configFilePath) else { return nil } - - do { - let data = try Data(contentsOf: URL(fileURLWithPath: configFilePath)) - return try JSONDecoder().decode(LumeSettings.self, from: data) - } catch { - Logger.error( - "Failed to read settings from file", metadata: ["error": error.localizedDescription] - ) - return nil + // 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 { diff --git a/libs/lume/src/LumeController.swift b/libs/lume/src/LumeController.swift index 12859ba4..4cb8253d 100644 --- a/libs/lume/src/LumeController.swift +++ b/libs/lume/src/LumeController.swift @@ -53,7 +53,7 @@ final class LumeController { let vmLocations = try home.getAllVMDirectories() let statuses = try vmLocations.map { vmWithLoc in let vm = try self.get( - name: vmWithLoc.directory.name, locationName: vmWithLoc.locationName) + name: vmWithLoc.directory.name, storage: vmWithLoc.locationName) return vm.details } return statuses @@ -79,7 +79,8 @@ final class LumeController { ]) do { - try self.validateVMExists(normalizedName, locationName: sourceLocation) + // Validate source VM exists + _ = try self.validateVMExists(normalizedName, storage: sourceLocation) // Copy the VM directory try home.copyVMDirectory( @@ -90,7 +91,7 @@ final class LumeController { ) // Update MAC address in the cloned VM to ensure uniqueness - let clonedVM = try get(name: normalizedNewName, locationName: destLocation) + let clonedVM = try get(name: normalizedNewName, storage: destLocation) try clonedVM.setMacAddress(VZMACAddress.randomLocallyAdministered().string) Logger.info( @@ -103,12 +104,15 @@ final class LumeController { } @MainActor - public func get(name: String, locationName: String? = nil) throws -> VM { + public func get(name: String, storage: String? = nil) throws -> VM { let normalizedName = normalizeVMName(name: name) do { - try self.validateVMExists(normalizedName, locationName: locationName) + // 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, locationName: locationName) + // 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]) @@ -126,14 +130,14 @@ final class LumeController { memorySize: UInt64, display: String, ipsw: String?, - locationName: String? = nil + storage: String? = nil ) async throws { Logger.info( "Creating VM", metadata: [ "name": name, "os": os, - "location": locationName ?? "default", + "location": storage ?? "default", "disk_size": "\(diskSize / 1024 / 1024)MB", "cpu_count": "\(cpuCount)", "memory_size": "\(memorySize / 1024 / 1024)MB", @@ -142,7 +146,7 @@ final class LumeController { ]) do { - try validateCreateParameters(name: name, os: os, ipsw: ipsw, locationName: locationName) + try validateCreateParameters(name: name, os: os, ipsw: ipsw, storage: storage) let vm = try await createTempVMConfig( os: os, @@ -160,7 +164,7 @@ final class LumeController { display: display ) - try vm.finalize(to: name, home: home, locationName: locationName) + try vm.finalize(to: name, home: home, storage: storage) Logger.info("VM created successfully", metadata: ["name": name]) } catch { @@ -170,24 +174,26 @@ final class LumeController { } @MainActor - public func delete(name: String, locationName: String? = nil) async throws { + public func delete(name: String, storage: String? = nil) async throws { let normalizedName = normalizeVMName(name: name) Logger.info( "Deleting VM", metadata: [ "name": normalizedName, - "location": locationName ?? "default", + "location": storage ?? "default", ]) do { - try self.validateVMExists(normalizedName, locationName: locationName) + // 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 = try home.getVMDirectory(normalizedName, locationName: locationName) + let vmDir = try home.getVMDirectory(normalizedName, storage: actualLocation) try vmDir.delete() Logger.info("VM deleted successfully", metadata: ["name": normalizedName]) @@ -207,23 +213,25 @@ final class LumeController { memory: UInt64? = nil, diskSize: UInt64? = nil, display: String? = nil, - locationName: String? = nil + storage: String? = nil ) throws { let normalizedName = normalizeVMName(name: name) Logger.info( "Updating VM settings", metadata: [ "name": normalizedName, - "location": locationName ?? "default", + "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, locationName: locationName) + // Find the actual location of the VM + let actualLocation = try self.validateVMExists( + normalizedName, storage: storage) - let vm = try get(name: normalizedName, locationName: locationName) + let vm = try get(name: normalizedName, storage: actualLocation) // Apply settings in order if let cpu = cpu { @@ -248,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() @@ -285,20 +295,23 @@ final class LumeController { organization: String = "trycua", vncPort: Int = 0, recoveryMode: Bool = false, - locationName: String? = nil + storage: String? = nil, + usbMassStoragePaths: [Path]? = nil ) async throws { let normalizedName = normalizeVMName(name: name) Logger.info( "Running VM", metadata: [ "name": normalizedName, - "location": locationName ?? "default", + "location": storage ?? "default", "no_display": "\(noDisplay)", "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 { @@ -306,7 +319,7 @@ final class LumeController { let components = name.split(separator: ":") if components.count == 2 { do { - try self.validateVMExists(normalizedName, locationName: locationName) + _ = try self.validateVMExists(normalizedName, storage: storage) } catch { // If the VM doesn't exist, try to pull the image try await pullImage( @@ -314,23 +327,43 @@ final class LumeController { name: nil, registry: registry, organization: organization, - locationName: locationName + storage: storage ) } } + // 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, - locationName: locationName + storage: actualLocation, + usbMassStoragePaths: usbMassStoragePaths ) - let vm = try get(name: normalizedName, locationName: locationName) + // Use the actual VM location that we found + let vm = try get(name: normalizedName, storage: actualLocation) + SharedVM.shared.setVM(name: normalizedName, vm: vm) try await vm.run( - noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, - vncPort: vncPort, recoveryMode: recoveryMode) + 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) @@ -363,7 +396,7 @@ final class LumeController { name: String?, registry: String, organization: String, - locationName: String? = nil + storage: String? = nil ) async throws { do { let vmName: String = name ?? normalizeVMName(name: image) @@ -375,7 +408,7 @@ final class LumeController { "name": name ?? "default", "registry": registry, "organization": organization, - "location": locationName ?? "default", + "location": storage ?? "default", ]) try self.validatePullParameters( @@ -383,23 +416,25 @@ final class LumeController { name: vmName, registry: registry, organization: organization, - locationName: locationName + storage: storage ) let imageContainerRegistry = ImageContainerRegistry( registry: registry, organization: organization) try await imageContainerRegistry.pull( - image: image, name: vmName, locationName: locationName) + image: image, + name: vmName, + locationName: storage) Logger.info( "Setting new VM mac address", metadata: [ "vm_name": vmName, - "location": locationName ?? "default", + "location": storage ?? "default", ]) // Update MAC address in the cloned VM to ensure uniqueness - let vm = try get(name: vmName, locationName: locationName) + let vm = try get(name: vmName, storage: storage) try vm.setMacAddress(VZMACAddress.randomLocallyAdministered().string) Logger.info( @@ -409,7 +444,7 @@ final class LumeController { "name": vmName, "registry": registry, "organization": organization, - "location": locationName ?? "default", + "location": storage ?? "default", ]) } catch { Logger.error("Failed to pull image", metadata: ["error": error.localizedDescription]) @@ -530,6 +565,18 @@ final class LumeController { 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 @@ -558,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 @@ -566,14 +614,15 @@ final class LumeController { } @MainActor - private func loadVM(name: String, locationName: String? = nil) throws -> VM { - let vmDir = try home.getVMDirectory(name, locationName: locationName) + 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 @@ -583,7 +632,7 @@ final class LumeController { // MARK: - Validation Methods private func validateCreateParameters( - name: String, os: String, ipsw: String?, locationName: String? + name: String, os: String, ipsw: String?, storage: String? ) throws { if os.lowercased() == "macos" { guard let ipsw = ipsw else { @@ -600,7 +649,7 @@ final class LumeController { throw ValidationError("Unsupported OS type: \(os)") } - let vmDir = try home.getVMDirectory(name, locationName: locationName) + let vmDir = try home.getVMDirectory(name, storage: storage) if vmDir.exists() { throw VMError.alreadyExists(name) } @@ -618,11 +667,25 @@ final class LumeController { } } - public func validateVMExists(_ name: String, locationName: String? = nil) throws { - let vmDir = try home.getVMDirectory(name, locationName: locationName) - 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( @@ -630,7 +693,7 @@ final class LumeController { name: String, registry: String, organization: String, - locationName: String? = nil + storage: String? = nil ) throws { guard !image.isEmpty else { throw ValidationError("Image name cannot be empty") @@ -645,7 +708,7 @@ final class LumeController { throw ValidationError("Organization cannot be empty") } - let vmDir = try home.getVMDirectory(name, locationName: locationName) + let vmDir = try home.getVMDirectory(name, storage: storage) if vmDir.exists() { throw VMError.alreadyExists(name) } @@ -653,13 +716,30 @@ final class LumeController { private func validateRunParameters( name: String, sharedDirectories: [SharedDirectory]?, mount: Path?, - locationName: String? = nil + storage: String? = nil, usbMassStoragePaths: [Path]? = nil ) throws { - try self.validateVMExists(name, locationName: locationName) + _ = try self.validateVMExists(name, storage: storage) if let dirs = sharedDirectories { try self.validateSharedDirectories(dirs) } - let vmConfig = try home.getVMDirectory(name, locationName: locationName).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 9c00038e..aac16e80 100644 --- a/libs/lume/src/Server/Handlers.swift +++ b/libs/lume/src/Server/Handlers.swift @@ -16,10 +16,10 @@ extension Server { } } - 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) @@ -47,7 +47,8 @@ extension Server { cpuCount: request.cpu, memorySize: sizes.memory, display: request.display, - ipsw: request.ipsw + ipsw: request.ipsw, + storage: request.storage ) return HTTPResponse( @@ -66,10 +67,10 @@ extension Server { } } - func handleDeleteVM(name: String) async throws -> HTTPResponse { + func handleDeleteVM(name: String, storage: String? = nil) async throws -> HTTPResponse { do { let vmController = LumeController() - try await vmController.delete(name: name) + try await vmController.delete(name: name, storage: storage) return HTTPResponse( statusCode: .ok, headers: ["Content-Type": "application/json"], body: Data()) } catch { @@ -92,7 +93,12 @@ extension Server { do { let vmController = LumeController() - try vmController.clone(name: request.name, newName: request.newName) + try vmController.clone( + name: request.name, + newName: request.newName, + sourceLocation: request.sourceLocation, + destLocation: request.destLocation + ) return HTTPResponse( statusCode: .ok, @@ -133,7 +139,8 @@ extension Server { cpu: request.cpu, memory: sizes.memory, diskSize: sizes.diskSize, - display: sizes.display?.string + display: sizes.display?.string, + storage: request.storage ) return HTTPResponse( @@ -150,10 +157,10 @@ 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"], @@ -171,7 +178,7 @@ 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) + ?? RunVMRequest(noDisplay: nil, sharedDirectories: nil, recoveryMode: nil, storage: nil) do { let dirs = try request.parse() @@ -181,7 +188,8 @@ extension Server { name: name, noDisplay: request.noDisplay ?? false, sharedDirectories: dirs, - recoveryMode: request.recoveryMode ?? false + recoveryMode: request.recoveryMode ?? false, + storage: request.storage ) // Return response immediately @@ -240,12 +248,18 @@ extension Server { image: request.image, name: request.name, registry: request.registry, - organization: request.organization + 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( @@ -315,13 +329,156 @@ 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]) @@ -331,7 +488,8 @@ 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 { diff --git a/libs/lume/src/Server/Requests.swift b/libs/lume/src/Server/Requests.swift index 429ad752..19291072 100644 --- a/libs/lume/src/Server/Requests.swift +++ b/libs/lume/src/Server/Requests.swift @@ -6,6 +6,7 @@ struct RunVMRequest: Codable { let noDisplay: Bool? let sharedDirectories: [SharedDirectoryRequest]? let recoveryMode: Bool? + let storage: String? struct SharedDirectoryRequest: Codable { let hostPath: String @@ -39,9 +40,10 @@ struct PullRequest: Codable { let name: String? var registry: String var organization: String + let storage: String? enum CodingKeys: String, CodingKey { - case image, name, registry, organization + case image, name, registry, organization, storage } init(from decoder: Decoder) throws { @@ -50,6 +52,7 @@ struct PullRequest: Codable { 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" + storage = try container.decodeIfPresent(String.self, forKey: .storage) } } @@ -61,6 +64,7 @@ struct CreateVMRequest: Codable { let diskSize: String let display: String let ipsw: String? + let storage: String? func parse() throws -> (memory: UInt64, diskSize: UInt64) { return ( @@ -75,6 +79,7 @@ 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 ( @@ -94,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/VM/VM.swift b/libs/lume/src/VM/VM.swift index abf74de8..17429af8 100644 --- a/libs/lume/src/VM/VM.swift +++ b/libs/lume/src/VM/VM.swift @@ -7,6 +7,7 @@ struct VMDirContext { let dir: VMDirectory var config: VMConfig let home: Home + let storage: String? func saveConfig() throws { try dir.saveConfig(config) @@ -88,7 +89,8 @@ class VM { status: isRunning ? "running" : "stopped", vncUrl: vncUrl, ipAddress: isRunning - ? DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) : nil + ? DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) : nil, + locationName: vmDirContext.storage ?? "default" ) } @@ -96,7 +98,7 @@ class VM { func run( noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0, - recoveryMode: Bool = false + recoveryMode: Bool = false, usbMassStoragePaths: [Path]? = nil ) async throws { guard vmDirContext.initialized else { throw VMError.notInitialized(vmDirContext.name) @@ -126,6 +128,23 @@ class VM { ).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 @@ -136,7 +155,8 @@ class VM { display: vmDirContext.config.display.string, sharedDirectories: sharedDirectories, mount: mount, - recoveryMode: recoveryMode + recoveryMode: recoveryMode, + usbMassStoragePaths: usbMassStoragePaths ) virtualizationService = try virtualizationServiceFactory(config) @@ -153,6 +173,12 @@ class VM { 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 @@ -396,8 +422,12 @@ class VM { 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, @@ -409,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, @@ -426,8 +500,81 @@ class VM { // MARK: - Finalization /// Post-installation step to move the VM directory to the home directory - func finalize(to name: String, home: Home, locationName: String? = nil) throws { - let vmDir = try home.getVMDirectory(name, locationName: locationName) + 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))