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