diff --git a/README.md b/README.md index 1bdea635..54420a1f 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ Command Options: --no-display Do not start the VNC client app --shared-dir Share directory with VM (format: path[:ro|rw]) --mount For Linux VMs only, attach a read-only disk image + --registry Container registry URL (default: ghcr.io) + --organization Organization to pull from (default: trycua) + --vnc-port Port to use for the VNC server (default: 0 for auto-assign) set: --cpu New number of CPU cores (e.g., 4) diff --git a/src/Commands/Run.swift b/src/Commands/Run.swift index f8faaf4a..a8c9f3ae 100644 --- a/src/Commands/Run.swift +++ b/src/Commands/Run.swift @@ -25,6 +25,9 @@ struct Run: AsyncParsableCommand { @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)") + var vncPort: Int = 0 + private var parsedSharedDirectories: [SharedDirectory] { get throws { try sharedDirectories.map { dirString -> SharedDirectory in @@ -75,7 +78,8 @@ struct Run: AsyncParsableCommand { sharedDirectories: dirs, mount: mount, registry: registry, - organization: organization + organization: organization, + vncPort: vncPort ) } } \ No newline at end of file diff --git a/src/Errors/Errors.swift b/src/Errors/Errors.swift index 15f77954..d52a8e47 100644 --- a/src/Errors/Errors.swift +++ b/src/Errors/Errors.swift @@ -125,6 +125,7 @@ enum VMError: Error, LocalizedError { case stopTimeout(String) case resizeTooSmall(current: UInt64, requested: UInt64) case vncNotConfigured + case vncPortBindingFailed(requested: Int, actual: Int) case internalError(String) case unsupportedOS(String) case invalidDisplayResolution(String) @@ -148,6 +149,11 @@ enum VMError: Error, LocalizedError { return "Cannot resize disk to \(requested) bytes, current size is \(current) bytes" case .vncNotConfigured: return "VNC is not configured for this virtual machine" + case .vncPortBindingFailed(let requested, let actual): + if actual == -1 { + return "Could not bind to VNC port \(requested) (port already in use). Try a different port or use port 0 for auto-assign." + } + return "Could not bind to VNC port \(requested) (port already in use). System assigned port \(actual) instead. Try a different port or use port 0 for auto-assign." case .internalError(let message): return "Internal error: \(message)" case .unsupportedOS(let os): diff --git a/src/FileSystem/VMConfig.swift b/src/FileSystem/VMConfig.swift index 98d03a33..7a7144d0 100644 --- a/src/FileSystem/VMConfig.swift +++ b/src/FileSystem/VMConfig.swift @@ -25,7 +25,6 @@ struct VMConfig: Codable { private var _display: VMDisplayResolution private var _hardwareModel: Data? private var _machineIdentifier: Data? - private var _vncPort: Int? // MARK: - Initialization init( @@ -36,8 +35,7 @@ struct VMConfig: Codable { macAddress: String? = nil, display: String, hardwareModel: Data? = nil, - machineIdentifier: Data? = nil, - vncPort: Int? = nil + machineIdentifier: Data? = nil ) throws { self.os = os self._cpuCount = cpuCount @@ -47,7 +45,6 @@ struct VMConfig: Codable { self._display = VMDisplayResolution(string: display) ?? VMDisplayResolution(string: "1024x768")! self._hardwareModel = hardwareModel self._machineIdentifier = machineIdentifier - self._vncPort = vncPort } var display: VMDisplayResolution { @@ -84,11 +81,6 @@ struct VMConfig: Codable { get { _macAddress } set { _macAddress = newValue } } - - var vncPort: Int? { - get { _vncPort } - set { _vncPort = newValue } - } mutating func setCpuCount(_ count: Int) { _cpuCount = count @@ -118,10 +110,6 @@ struct VMConfig: Codable { self._display = newDisplay } - mutating func setVNCPort(_ port: Int) { - _vncPort = port - } - // MARK: - Codable enum CodingKeys: String, CodingKey { case _cpuCount = "cpuCount" @@ -132,7 +120,6 @@ struct VMConfig: Codable { case _hardwareModel = "hardwareModel" case _machineIdentifier = "machineIdentifier" case os - case _vncPort = "vncPort" } init(from decoder: Decoder) throws { @@ -146,7 +133,6 @@ struct VMConfig: Codable { _display = VMDisplayResolution(string: try container.decode(String.self, forKey: .display))! _hardwareModel = try container.decodeIfPresent(Data.self, forKey: ._hardwareModel) _machineIdentifier = try container.decodeIfPresent(Data.self, forKey: ._machineIdentifier) - _vncPort = try container.decodeIfPresent(Int.self, forKey: ._vncPort) } func encode(to encoder: Encoder) throws { @@ -160,6 +146,5 @@ struct VMConfig: Codable { try container.encode(display.string, forKey: .display) try container.encodeIfPresent(_hardwareModel, forKey: ._hardwareModel) try container.encodeIfPresent(_machineIdentifier, forKey: ._machineIdentifier) - try container.encodeIfPresent(_vncPort, forKey: ._vncPort) } } diff --git a/src/LumeController.swift b/src/LumeController.swift index 74d22859..b4b04106 100644 --- a/src/LumeController.swift +++ b/src/LumeController.swift @@ -255,7 +255,8 @@ final class LumeController { sharedDirectories: [SharedDirectory] = [], mount: Path? = nil, registry: String = "ghcr.io", - organization: String = "trycua" + organization: String = "trycua", + vncPort: Int = 0 ) async throws { let normalizedName = normalizeVMName(name: name) Logger.info( @@ -265,6 +266,7 @@ final class LumeController { "no_display": "\(noDisplay)", "shared_directories": "\(sharedDirectories.map( { $0.string } ).joined(separator: ", "))", "mount": mount?.path ?? "none", + "vnc_port": "\(vncPort)", ]) do { @@ -284,7 +286,7 @@ final class LumeController { let vm = try get(name: normalizedName) SharedVM.shared.setVM(name: normalizedName, vm: vm) - try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount) + try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, vncPort: vncPort) Logger.info("VM started successfully", metadata: ["name": normalizedName]) } catch { SharedVM.shared.removeVM(name: normalizedName) diff --git a/src/VM/VM.swift b/src/VM/VM.swift index 8e73ed3c..8f79cead 100644 --- a/src/VM/VM.swift +++ b/src/VM/VM.swift @@ -88,7 +88,7 @@ class VM { // MARK: - VM Lifecycle Management - func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?) async throws { + func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0) async throws { guard vmDirContext.initialized else { throw VMError.notInitialized(vmDirContext.name) } @@ -111,7 +111,8 @@ class VM { "diskSize": "\(vmDirContext.config.diskSize ?? 0)", "sharedDirectories": sharedDirectories.map( { $0.string } - ).joined(separator: ", ") + ).joined(separator: ", "), + "vncPort": "\(vncPort)" ]) // Create and configure the VM @@ -125,7 +126,7 @@ class VM { ) virtualizationService = try virtualizationServiceFactory(config) - let vncInfo = try await setupVNC(noDisplay: noDisplay) + let vncInfo = try await setupVNC(noDisplay: noDisplay, port: vncPort) Logger.info("VNC info", metadata: ["vncInfo": vncInfo]) // Start the VM @@ -337,13 +338,11 @@ class VM { return vncService.url } - private func setupVNC(noDisplay: Bool) async throws -> String { + private func setupVNC(noDisplay: Bool, port: Int = 0) async throws -> String { guard let service = virtualizationService else { throw VMError.internalError("Virtualization service not initialized") } - // Use configured port or default to 0 (auto-assign) - let port = vmDirContext.config.vncPort ?? 0 try await vncService.start(port: port, virtualMachine: service.getVirtualMachine()) guard let url = vncService.url else { @@ -401,13 +400,4 @@ class VM { func finalize(to name: String, home: Home) throws { try vmDirContext.finalize(to: name) } - - // Add method to set VNC port - func setVNCPort(_ port: Int) throws { - guard !isRunning else { - throw VMError.alreadyRunning(vmDirContext.name) - } - vmDirContext.config.setVNCPort(port) - try vmDirContext.saveConfig() - } } \ No newline at end of file diff --git a/src/VNC/PassphraseGenerator.swift b/src/VNC/PassphraseGenerator.swift index 9f54ad36..089e97d3 100644 --- a/src/VNC/PassphraseGenerator.swift +++ b/src/VNC/PassphraseGenerator.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit final class PassphraseGenerator { private let words: [String] @@ -9,11 +10,39 @@ final class PassphraseGenerator { func prefix(_ count: Int) -> [String] { guard count > 0 else { return [] } - return (0..= maxAttempts { + // If we've timed out and we requested a specific port, it likely means binding failed + vncServer = nil + if port != 0 { + throw VMError.vncPortBindingFailed(requested: port, actual: -1) + } + throw VMError.internalError("Timeout waiting for VNC server to start") + } + try await Task.sleep(nanoseconds: 50_000_000) // 50ms delay between checks } } diff --git a/tests/Mocks/MockVM.swift b/tests/Mocks/MockVM.swift index cad394d2..3ff2d0e7 100644 --- a/tests/Mocks/MockVM.swift +++ b/tests/Mocks/MockVM.swift @@ -18,9 +18,9 @@ class MockVM: VM { try vmDirContext.saveConfig() } - override func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?) async throws { + override func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0) async throws { mockIsRunning = true - try await super.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount) + try await super.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, vncPort: vncPort) } override func stop() async throws { diff --git a/tests/VMTests.swift b/tests/VMTests.swift index d197806e..50961a9a 100644 --- a/tests/VMTests.swift +++ b/tests/VMTests.swift @@ -95,7 +95,7 @@ func testVMRunAndStop() async throws { // Test running VM let runTask = Task { - try await vm.run(noDisplay: false, sharedDirectories: [], mount: nil) + try await vm.run(noDisplay: false, sharedDirectories: [], mount: nil, vncPort: 0) } // Give the VM time to start