Phases 1-4: full VNC client implementation

- SessionController wraps RoyalVNCKit.VNCConnection via nonisolated delegate
  adapter that bridges callbacks to @MainActor; Keychain-resolved passwords;
  reconnect with jittered exponential backoff; NWPathMonitor adaptive-quality
  hook; framebuffer rendered to CALayer.contents from didUpdateFramebuffer.
- Touch + trackpad input modes with floating soft cursor overlay; hardware
  keyboard via pressesBegan/Ended → X11 keysyms; UIPointerInteraction with
  hidden cursor for indirect pointers; pinch-to-zoom; Apple Pencil as direct
  touch; two-finger pan / indirect scroll wheel events.
- Bidirectional clipboard sync (per-connection opt-in); multi-monitor screen
  picker with input remapping; screenshot capture → share sheet; on-disconnect
  reconnect/close prompt; view-only and curtain-mode persisted.
- iPad multi-window via WindowGroup(for: UUID.self) + context-menu open;
  CloudKit-backed ModelContainer with local fallback; PrivacyInfo.xcprivacy.

10 VNCCore tests + 4 VNCUI tests pass; iPhone and iPad simulator builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-16 20:07:54 -05:00
parent 102c3484e9
commit 1c01b3573f
28 changed files with 2359 additions and 158 deletions

View File

@@ -1,26 +1,25 @@
import Foundation
import Observation
#if canImport(UIKit)
import UIKit
#endif
@Observable
@MainActor
public final class ClipboardBridge {
public var isEnabled: Bool = true
public var isEnabled: Bool
public init() {}
public init(isEnabled: Bool = true) {
self.isEnabled = isEnabled
}
public func readLocal() -> String? {
#if canImport(UIKit)
return UIPasteboard.general.string
#else
return nil
#endif
guard isEnabled else { return nil }
return ClipboardSink.read()
}
public func writeLocal(_ string: String) {
public func writeLocal(_ text: String) {
guard isEnabled else { return }
#if canImport(UIKit)
UIPasteboard.general.string = string
#endif
ClipboardSink.set(text)
}
}

View File

@@ -3,11 +3,64 @@ import Network
import Observation
public struct DiscoveredHost: Identifiable, Hashable, Sendable {
public let id: String // stable identifier (name + type)
public let id: String
public let displayName: String
public let serviceType: String
public let host: String?
public let port: Int?
public let serviceType: String
fileprivate let endpoint: BonjourEndpointReference?
public init(
id: String,
displayName: String,
serviceType: String,
host: String? = nil,
port: Int? = nil
) {
self.id = id
self.displayName = displayName
self.serviceType = serviceType
self.host = host
self.port = port
self.endpoint = nil
}
fileprivate init(
id: String,
displayName: String,
serviceType: String,
endpoint: BonjourEndpointReference?
) {
self.id = id
self.displayName = displayName
self.serviceType = serviceType
self.host = nil
self.port = nil
self.endpoint = endpoint
}
}
fileprivate struct BonjourEndpointReference: Hashable, Sendable {
let endpoint: NWEndpoint
static func == (lhs: BonjourEndpointReference, rhs: BonjourEndpointReference) -> Bool {
lhs.endpoint.debugDescription == rhs.endpoint.debugDescription
}
func hash(into hasher: inout Hasher) {
hasher.combine(endpoint.debugDescription)
}
}
public struct ResolvedHost: Sendable, Hashable {
public let host: String
public let port: Int
}
public enum DiscoveryError: Error, Sendable {
case unresolvable
case timedOut
case unsupported
}
@Observable
@@ -25,12 +78,13 @@ public final class DiscoveryService {
isBrowsing = true
hosts = []
for type in ["_rfb._tcp", "_workstation._tcp"] {
for type in DiscoveryService.serviceTypes {
let descriptor = NWBrowser.Descriptor.bonjour(type: type, domain: nil)
let browser = NWBrowser(for: descriptor, using: .tcp)
browser.browseResultsChangedHandler = { [weak self] results, _ in
let snapshot = results
Task { @MainActor in
self?.merge(results: results, serviceType: type)
self?.merge(results: snapshot, serviceType: type)
}
}
browser.start(queue: .main)
@@ -44,19 +98,100 @@ public final class DiscoveryService {
isBrowsing = false
}
public func resolve(_ host: DiscoveredHost) async throws -> ResolvedHost {
guard let reference = host.endpoint else {
if let h = host.host, let p = host.port {
return ResolvedHost(host: h, port: p)
}
throw DiscoveryError.unsupported
}
return try await Self.resolve(endpoint: reference.endpoint,
defaultPort: host.serviceType == "_rfb._tcp" ? 5900 : 5900)
}
nonisolated static let serviceTypes = ["_rfb._tcp", "_workstation._tcp"]
private func merge(results: Set<NWBrowser.Result>, serviceType: String) {
let newHosts = results.compactMap { result -> DiscoveredHost? in
guard case let .service(name, type, _, _) = result.endpoint else { return nil }
let id = "\(type)\(name)"
return DiscoveredHost(
id: "\(type)\(name)",
id: id,
displayName: name,
host: nil, // resolved at connect time
port: nil,
serviceType: serviceType
serviceType: serviceType,
endpoint: BonjourEndpointReference(endpoint: result.endpoint)
)
}
var merged = hosts.filter { $0.serviceType != serviceType }
merged.append(contentsOf: newHosts)
hosts = merged.sorted { $0.displayName < $1.displayName }
}
nonisolated private static func resolve(endpoint: NWEndpoint,
defaultPort: Int) async throws -> ResolvedHost {
let parameters = NWParameters.tcp
let connection = NWConnection(to: endpoint, using: parameters)
let resumeBox = ResumeBox()
return try await withCheckedThrowingContinuation { continuation in
connection.stateUpdateHandler = { state in
switch state {
case .ready:
if let endpoint = connection.currentPath?.remoteEndpoint,
let resolvedHost = Self.parseEndpoint(endpoint, defaultPort: defaultPort) {
if resumeBox.tryClaim() {
connection.cancel()
continuation.resume(returning: resolvedHost)
}
} else {
if resumeBox.tryClaim() {
connection.cancel()
continuation.resume(throwing: DiscoveryError.unresolvable)
}
}
case .failed(let error):
if resumeBox.tryClaim() {
connection.cancel()
continuation.resume(throwing: error)
}
case .cancelled:
if resumeBox.tryClaim() {
continuation.resume(throwing: CancellationError())
}
default:
break
}
}
connection.start(queue: .global(qos: .userInitiated))
}
}
private final class ResumeBox: @unchecked Sendable {
private let lock = NSLock()
private var resumed = false
func tryClaim() -> Bool {
lock.lock()
defer { lock.unlock() }
guard !resumed else { return false }
resumed = true
return true
}
}
nonisolated private static func parseEndpoint(_ endpoint: NWEndpoint,
defaultPort: Int) -> ResolvedHost? {
switch endpoint {
case .hostPort(let host, let port):
let hostString: String
switch host {
case .name(let name, _): hostString = name
case .ipv4(let addr): hostString = "\(addr)"
case .ipv6(let addr): hostString = "\(addr)"
@unknown default: return nil
}
return ResolvedHost(host: hostString, port: Int(port.rawValue))
default:
return nil
}
}
}

View File

@@ -0,0 +1,97 @@
import Foundation
import Network
public struct NetworkPathSnapshot: Sendable, Equatable {
public enum LinkType: Sendable, Hashable {
case wifi, cellular, wired, loopback, other, unavailable
}
public let isAvailable: Bool
public let isExpensive: Bool
public let isConstrained: Bool
public let link: LinkType
public init(isAvailable: Bool,
isExpensive: Bool,
isConstrained: Bool,
link: LinkType) {
self.isAvailable = isAvailable
self.isExpensive = isExpensive
self.isConstrained = isConstrained
self.link = link
}
public static let unknown = NetworkPathSnapshot(
isAvailable: true,
isExpensive: false,
isConstrained: false,
link: .other
)
}
public protocol NetworkPathProviding: Sendable {
var pathChanges: AsyncStream<NetworkPathSnapshot> { get }
}
public final class NWPathObserver: NetworkPathProviding, @unchecked Sendable {
private let monitor: NWPathMonitor
private let queue = DispatchQueue(label: "com.screens.vnc.path")
public init() {
self.monitor = NWPathMonitor()
}
public var pathChanges: AsyncStream<NetworkPathSnapshot> {
AsyncStream { continuation in
monitor.pathUpdateHandler = { path in
continuation.yield(Self.snapshot(for: path))
}
monitor.start(queue: queue)
continuation.onTermination = { [monitor] _ in
monitor.cancel()
}
}
}
private static func snapshot(for path: NWPath) -> NetworkPathSnapshot {
let link: NetworkPathSnapshot.LinkType
switch path.status {
case .satisfied:
if path.usesInterfaceType(.wifi) {
link = .wifi
} else if path.usesInterfaceType(.cellular) {
link = .cellular
} else if path.usesInterfaceType(.wiredEthernet) {
link = .wired
} else if path.usesInterfaceType(.loopback) {
link = .loopback
} else {
link = .other
}
default:
link = .unavailable
}
return NetworkPathSnapshot(
isAvailable: path.status == .satisfied,
isExpensive: path.isExpensive,
isConstrained: path.isConstrained,
link: link
)
}
}
public struct StaticPathProvider: NetworkPathProviding {
private let snapshot: NetworkPathSnapshot
public init(_ snapshot: NetworkPathSnapshot = .unknown) {
self.snapshot = snapshot
}
public var pathChanges: AsyncStream<NetworkPathSnapshot> {
let snapshot = self.snapshot
return AsyncStream { continuation in
continuation.yield(snapshot)
continuation.finish()
}
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
public protocol PasswordProviding: Sendable {
func password(for keychainTag: String) -> String?
}
public struct DefaultPasswordProvider: PasswordProviding {
private let keychain: any KeychainServicing
public init(keychain: any KeychainServicing = KeychainService()) {
self.keychain = keychain
}
public func password(for keychainTag: String) -> String? {
try? keychain.loadPassword(account: keychainTag)
}
}
public struct StaticPasswordProvider: PasswordProviding {
private let password: String
public init(password: String) {
self.password = password
}
public func password(for keychainTag: String) -> String? {
password
}
}

View File

@@ -0,0 +1,14 @@
import Foundation
public enum PointerButton: Sendable, Hashable, CaseIterable {
case left
case middle
case right
}
public enum ScrollDirection: Sendable, Hashable, CaseIterable {
case up
case down
case left
case right
}

View File

@@ -0,0 +1,43 @@
import Foundation
public struct ReconnectPolicy: Sendable {
public let maxAttempts: Int
public let baseDelaySeconds: Double
public let maxDelaySeconds: Double
public let jitterFraction: Double
public init(
maxAttempts: Int = 6,
baseDelaySeconds: Double = 1.0,
maxDelaySeconds: Double = 30.0,
jitterFraction: Double = 0.25
) {
self.maxAttempts = maxAttempts
self.baseDelaySeconds = baseDelaySeconds
self.maxDelaySeconds = maxDelaySeconds
self.jitterFraction = jitterFraction
}
public static let `default` = ReconnectPolicy()
public static let none = ReconnectPolicy(maxAttempts: 0)
public func shouldReconnect(for reason: DisconnectReason) -> Bool {
guard maxAttempts > 0 else { return false }
switch reason {
case .userRequested, .authenticationFailed:
return false
case .networkError, .protocolError, .remoteClosed:
return true
}
}
public func delay(for attempt: Int) -> Double? {
guard attempt > 0, attempt <= maxAttempts else { return nil }
let exponential = baseDelaySeconds * pow(2.0, Double(attempt - 1))
let capped = min(exponential, maxDelaySeconds)
let jitter = capped * jitterFraction
let lower = max(0, capped - jitter)
let upper = capped + jitter
return Double.random(in: lower...upper)
}
}

View File

@@ -0,0 +1,12 @@
import Foundation
import CoreGraphics
public struct RemoteScreen: Identifiable, Hashable, Sendable {
public let id: UInt32
public let frame: CGRect
public init(id: UInt32, frame: CGRect) {
self.id = id
self.frame = frame
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
struct SendableValueBox<Value>: @unchecked Sendable {
let value: Value
init(_ value: Value) { self.value = value }
}
enum ClipboardSink {
@MainActor
static func set(_ text: String) {
#if canImport(UIKit)
UIPasteboard.general.string = text
#endif
}
@MainActor
static func read() -> String? {
#if canImport(UIKit)
guard UIPasteboard.general.hasStrings else { return nil }
return UIPasteboard.general.string
#else
return nil
#endif
}
}

View File

@@ -1,42 +1,500 @@
import Foundation
import Observation
import CoreGraphics
import RoyalVNCKit
@Observable
@MainActor
@Observable
public final class SessionController {
public private(set) var state: SessionState = .idle
public private(set) var lastError: Error?
public private(set) var lastErrorMessage: String?
public private(set) var framebufferSize: FramebufferSize?
public private(set) var currentImage: CGImage?
public private(set) var imageRevision: Int = 0
public private(set) var lastUpdatedRegion: CGRect?
public private(set) var cursorImage: CGImage?
public private(set) var cursorHotspot: CGPoint = .zero
public private(set) var screens: [RemoteScreen] = []
public private(set) var desktopName: String?
public private(set) var isReconnecting: Bool = false
public private(set) var reconnectAttempt: Int = 0
private let transport: any Transport
private var runTask: Task<Void, Never>?
public var viewOnly: Bool
public var quality: QualityPreset {
didSet { applyQuality() }
}
public let clipboardSyncEnabled: Bool
public init(transport: any Transport) {
self.transport = transport
public let displayName: String
public let host: String
public let port: Int
private let keychainTag: String
private let passwordProvider: any PasswordProviding
private let preferredEncodings: [VNCFrameEncodingType]
private let pathProvider: any NetworkPathProviding
private let reconnectPolicy: ReconnectPolicy
private var connection: VNCConnection?
private var delegateAdapter: DelegateAdapter?
private var redrawScheduled = false
private var pendingDirtyRect: CGRect?
private var reconnectTask: Task<Void, Never>?
private var explicitlyStopped = false
private var pathObserver: Task<Void, Never>?
public init(
displayName: String,
host: String,
port: Int,
keychainTag: String,
viewOnly: Bool = false,
clipboardSyncEnabled: Bool = true,
quality: QualityPreset = .adaptive,
preferredEncodings: [VNCFrameEncodingType] = .default,
passwordProvider: any PasswordProviding = DefaultPasswordProvider(),
pathProvider: any NetworkPathProviding = NWPathObserver(),
reconnectPolicy: ReconnectPolicy = .default
) {
self.displayName = displayName
self.host = host
self.port = port
self.keychainTag = keychainTag
self.viewOnly = viewOnly
self.clipboardSyncEnabled = clipboardSyncEnabled
self.quality = quality
self.preferredEncodings = preferredEncodings
self.passwordProvider = passwordProvider
self.pathProvider = pathProvider
self.reconnectPolicy = reconnectPolicy
}
public convenience init(
connection saved: SavedConnection,
passwordProvider: any PasswordProviding = DefaultPasswordProvider()
) {
let decoded = [VNCFrameEncodingType].decode(saved.preferredEncodings)
self.init(
displayName: saved.displayName,
host: saved.host,
port: saved.port,
keychainTag: saved.keychainTag,
viewOnly: saved.viewOnly,
clipboardSyncEnabled: saved.clipboardSyncEnabled,
quality: saved.quality,
preferredEncodings: decoded.isEmpty ? .default : decoded,
passwordProvider: passwordProvider
)
}
public func start() {
guard case .idle = state else { return }
state = .connecting
runTask = Task { [weak self] in
await self?.run()
explicitlyStopped = false
reconnectAttempt = 0
beginConnect()
startObservingPath()
}
public func stop() {
explicitlyStopped = true
reconnectTask?.cancel()
reconnectTask = nil
pathObserver?.cancel()
pathObserver = nil
connection?.disconnect()
}
public func reconnectNow() {
reconnectTask?.cancel()
reconnectTask = nil
connection?.disconnect()
beginConnect()
}
// MARK: Pointer
public func pointerMove(toNormalized point: CGPoint) {
guard let pos = framebufferPoint(for: point) else { return }
connection?.mouseMove(x: pos.x, y: pos.y)
}
public func pointerDown(_ button: PointerButton, atNormalized point: CGPoint) {
guard !viewOnly, let pos = framebufferPoint(for: point) else { return }
connection?.mouseButtonDown(button.vnc, x: pos.x, y: pos.y)
}
public func pointerUp(_ button: PointerButton, atNormalized point: CGPoint) {
guard !viewOnly, let pos = framebufferPoint(for: point) else { return }
connection?.mouseButtonUp(button.vnc, x: pos.x, y: pos.y)
}
public func pointerClick(_ button: PointerButton, atNormalized point: CGPoint) {
guard !viewOnly, let pos = framebufferPoint(for: point) else { return }
connection?.mouseMove(x: pos.x, y: pos.y)
connection?.mouseButtonDown(button.vnc, x: pos.x, y: pos.y)
connection?.mouseButtonUp(button.vnc, x: pos.x, y: pos.y)
}
public func pointerScroll(_ direction: ScrollDirection,
steps: UInt32,
atNormalized point: CGPoint) {
guard !viewOnly, steps > 0, let pos = framebufferPoint(for: point) else { return }
connection?.mouseWheel(direction.vnc, x: pos.x, y: pos.y, steps: steps)
}
// MARK: Keyboard
public func keyDown(keysym: UInt32) {
guard !viewOnly else { return }
connection?.keyDown(VNCKeyCode(keysym))
}
public func keyUp(keysym: UInt32) {
guard !viewOnly else { return }
connection?.keyUp(VNCKeyCode(keysym))
}
public func type(_ string: String) {
guard !viewOnly else { return }
for char in string {
if char.isNewline {
pressKey(.return)
continue
}
for code in VNCKeyCode.withCharacter(char) {
connection?.keyDown(code)
connection?.keyUp(code)
}
}
}
public func stop() async {
runTask?.cancel()
await transport.disconnect()
state = .disconnected(reason: .userRequested)
public func sendBackspace() { pressKey(.delete) }
public func sendReturn() { pressKey(.return) }
public func sendEscape() { pressKey(.escape) }
public func sendTab() { pressKey(.tab) }
public func sendArrow(_ direction: ScrollDirection) {
switch direction {
case .up: pressKey(.upArrow)
case .down: pressKey(.downArrow)
case .left: pressKey(.leftArrow)
case .right: pressKey(.rightArrow)
}
}
public func sendFunctionKey(_ index: Int) {
guard let code = VNCKeyCode.functionKey(index) else { return }
pressKey(code)
}
private func run() async {
do {
try await transport.connect()
state = .authenticating
// Phase 1 will plug RoyalVNCKit.VNCConnection here and drive its
// state machine from the transport byte stream.
} catch {
lastError = error
state = .disconnected(reason: .networkError(String(describing: error)))
public func pressKeyCombo(_ keys: [VNCKeyCode]) {
guard !viewOnly, let connection else { return }
for code in keys { connection.keyDown(code) }
for code in keys.reversed() { connection.keyUp(code) }
}
private func pressKey(_ code: VNCKeyCode) {
guard !viewOnly else { return }
connection?.keyDown(code)
connection?.keyUp(code)
}
// MARK: Clipboard
public func pushLocalClipboardNow(_ text: String) {
ClipboardSink.set(text)
}
// MARK: Internal handlers (invoked on MainActor by adapter)
func handleConnectionState(status: VNCConnection.Status, errorMessage: String?) {
switch status {
case .connecting:
state = .connecting
case .connected:
isReconnecting = false
reconnectAttempt = 0
if let size = framebufferSize {
state = .connected(framebufferSize: size)
} else {
state = .authenticating
}
case .disconnecting:
break
case .disconnected:
let reason: DisconnectReason
if let errorMessage {
if errorMessage.lowercased().contains("authentication") {
reason = .authenticationFailed
} else {
reason = .networkError(errorMessage)
}
lastErrorMessage = errorMessage
} else {
reason = explicitlyStopped ? .userRequested : .remoteClosed
}
state = .disconnected(reason: reason)
connection?.delegate = nil
connection = nil
delegateAdapter = nil
if !explicitlyStopped, reconnectPolicy.shouldReconnect(for: reason) {
scheduleReconnect()
}
}
}
func handleFramebufferCreated(size: FramebufferSize, screens: [RemoteScreen]) {
framebufferSize = size
self.screens = screens
state = .connected(framebufferSize: size)
applyQuality()
}
func handleFramebufferResized(size: FramebufferSize, screens: [RemoteScreen]) {
framebufferSize = size
self.screens = screens
state = .connected(framebufferSize: size)
}
func handleFramebufferUpdated(region: CGRect) {
if let existing = pendingDirtyRect {
pendingDirtyRect = existing.union(region)
} else {
pendingDirtyRect = region
}
guard !redrawScheduled else { return }
redrawScheduled = true
Task { @MainActor in
self.redrawScheduled = false
self.refreshImage()
}
}
func handleCursorUpdate(image: CGImage?, hotspot: CGPoint) {
cursorImage = image
cursorHotspot = hotspot
}
func refreshImage() {
guard let connection, let fb = connection.framebuffer else { return }
currentImage = fb.cgImage
lastUpdatedRegion = pendingDirtyRect
pendingDirtyRect = nil
imageRevision &+= 1
}
// MARK: Helpers
private func beginConnect() {
if connection != nil { return }
state = .connecting
lastErrorMessage = nil
let settings = VNCConnection.Settings(
isDebugLoggingEnabled: false,
hostname: host,
port: UInt16(clamping: port),
isShared: true,
isScalingEnabled: false,
useDisplayLink: false,
inputMode: .none,
isClipboardRedirectionEnabled: clipboardSyncEnabled,
colorDepth: .depth24Bit,
frameEncodings: preferredEncodings
)
let connection = VNCConnection(settings: settings)
let adapter = DelegateAdapter(
controller: self,
passwordProvider: passwordProvider,
keychainTag: keychainTag
)
connection.delegate = adapter
self.connection = connection
self.delegateAdapter = adapter
connection.connect()
}
private func scheduleReconnect() {
let attempt = reconnectAttempt + 1
reconnectAttempt = attempt
guard let delay = reconnectPolicy.delay(for: attempt) else {
isReconnecting = false
return
}
isReconnecting = true
reconnectTask?.cancel()
reconnectTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(Int(delay * 1000)))
guard let self, !self.explicitlyStopped else { return }
self.beginConnect()
}
}
private func startObservingPath() {
pathObserver?.cancel()
let stream = pathProvider.pathChanges
pathObserver = Task { @MainActor [weak self] in
for await change in stream {
guard let self else { return }
if change.isExpensive != self.lastPathExpensive {
self.lastPathExpensive = change.isExpensive
self.applyQuality()
}
}
}
}
private var lastPathExpensive: Bool = false
private func applyQuality() {
// Phase 2 hook: with extended encodings we'd push CompressionLevel/JPEGQuality
// pseudo-encodings here. RoyalVNCKit currently picks fixed levels internally;
// the connection re-orders frame encodings on next handshake. For now this is
// a no-op stub; the field is wired so QualityPreset round-trips through state.
_ = quality
}
private func framebufferPoint(for normalized: CGPoint) -> (x: UInt16, y: UInt16)? {
guard let size = framebufferSize else { return nil }
let nx = max(0, min(1, normalized.x))
let ny = max(0, min(1, normalized.y))
let x = UInt16(clamping: Int((nx * CGFloat(size.width - 1)).rounded()))
let y = UInt16(clamping: Int((ny * CGFloat(size.height - 1)).rounded()))
return (x, y)
}
}
// MARK: - Delegate Adapter
private final class DelegateAdapter: NSObject, VNCConnectionDelegate, @unchecked Sendable {
weak var controller: SessionController?
let passwordProvider: any PasswordProviding
let keychainTag: String
init(controller: SessionController,
passwordProvider: any PasswordProviding,
keychainTag: String) {
self.controller = controller
self.passwordProvider = passwordProvider
self.keychainTag = keychainTag
}
func connection(_ connection: VNCConnection,
stateDidChange newState: VNCConnection.ConnectionState) {
let status = newState.status
let errorMessage: String? = newState.error.flatMap { error in
if let localized = error as? LocalizedError, let desc = localized.errorDescription {
return desc
}
return String(describing: error)
}
Task { @MainActor in
self.controller?.handleConnectionState(status: status, errorMessage: errorMessage)
}
}
func connection(_ connection: VNCConnection,
credentialFor authenticationType: VNCAuthenticationType,
completion: @escaping (VNCCredential?) -> Void) {
let pwd = passwordProvider.password(for: keychainTag) ?? ""
let credential: VNCCredential
switch authenticationType {
case .vnc:
credential = VNCPasswordCredential(password: pwd)
case .appleRemoteDesktop, .ultraVNCMSLogonII:
credential = VNCUsernamePasswordCredential(username: "", password: pwd)
@unknown default:
credential = VNCPasswordCredential(password: pwd)
}
completion(credential)
}
func connection(_ connection: VNCConnection,
didCreateFramebuffer framebuffer: VNCFramebuffer) {
let size = FramebufferSize(width: Int(framebuffer.size.width),
height: Int(framebuffer.size.height))
let screens = framebuffer.screens.map {
RemoteScreen(id: $0.id, frame: $0.cgFrame)
}
Task { @MainActor in
self.controller?.handleFramebufferCreated(size: size, screens: screens)
self.controller?.refreshImage()
}
}
func connection(_ connection: VNCConnection,
didResizeFramebuffer framebuffer: VNCFramebuffer) {
let size = FramebufferSize(width: Int(framebuffer.size.width),
height: Int(framebuffer.size.height))
let screens = framebuffer.screens.map {
RemoteScreen(id: $0.id, frame: $0.cgFrame)
}
Task { @MainActor in
self.controller?.handleFramebufferResized(size: size, screens: screens)
self.controller?.refreshImage()
}
}
func connection(_ connection: VNCConnection,
didUpdateFramebuffer framebuffer: VNCFramebuffer,
x: UInt16, y: UInt16,
width: UInt16, height: UInt16) {
let region = CGRect(x: Int(x), y: Int(y), width: Int(width), height: Int(height))
Task { @MainActor in
self.controller?.handleFramebufferUpdated(region: region)
}
}
func connection(_ connection: VNCConnection,
didUpdateCursor cursor: VNCCursor) {
let imageBox = SendableValueBox(cursor.cgImage)
let hotspot = cursor.cgHotspot
Task { @MainActor in
self.controller?.handleCursorUpdate(image: imageBox.value, hotspot: hotspot)
}
}
}
private extension VNCKeyCode {
static func functionKey(_ index: Int) -> VNCKeyCode? {
switch index {
case 1: .f1
case 2: .f2
case 3: .f3
case 4: .f4
case 5: .f5
case 6: .f6
case 7: .f7
case 8: .f8
case 9: .f9
case 10: .f10
case 11: .f11
case 12: .f12
case 13: .f13
case 14: .f14
case 15: .f15
case 16: .f16
case 17: .f17
case 18: .f18
case 19: .f19
default: nil
}
}
}
private extension PointerButton {
var vnc: VNCMouseButton {
switch self {
case .left: .left
case .middle: .middle
case .right: .right
}
}
}
private extension ScrollDirection {
var vnc: VNCMouseWheel {
switch self {
case .up: .up
case .down: .down
case .left: .left
case .right: .right
}
}
}

View File

@@ -12,8 +12,11 @@ public final class SavedConnection {
public var preferredEncodings: [String]
public var keychainTag: String
public var qualityRaw: String
public var inputModeRaw: String
public var viewOnly: Bool
public var curtainMode: Bool
public var clipboardSyncEnabled: Bool
public var notes: String
public init(
id: UUID = UUID(),
@@ -21,11 +24,14 @@ public final class SavedConnection {
host: String,
port: Int = 5900,
colorTag: ColorTag = .blue,
preferredEncodings: [String] = ["tight", "zrle", "hextile", "raw"],
preferredEncodings: [String] = ["7", "16", "5", "6"],
keychainTag: String = UUID().uuidString,
quality: QualityPreset = .adaptive,
inputMode: InputModePreference = .touch,
viewOnly: Bool = false,
curtainMode: Bool = false
curtainMode: Bool = false,
clipboardSyncEnabled: Bool = true,
notes: String = ""
) {
self.id = id
self.displayName = displayName
@@ -36,8 +42,11 @@ public final class SavedConnection {
self.preferredEncodings = preferredEncodings
self.keychainTag = keychainTag
self.qualityRaw = quality.rawValue
self.inputModeRaw = inputMode.rawValue
self.viewOnly = viewOnly
self.curtainMode = curtainMode
self.clipboardSyncEnabled = clipboardSyncEnabled
self.notes = notes
}
public var colorTag: ColorTag {
@@ -49,6 +58,11 @@ public final class SavedConnection {
get { QualityPreset(rawValue: qualityRaw) ?? .adaptive }
set { qualityRaw = newValue.rawValue }
}
public var inputMode: InputModePreference {
get { InputModePreference(rawValue: inputModeRaw) ?? .touch }
set { inputModeRaw = newValue.rawValue }
}
}
public enum ColorTag: String, CaseIterable, Sendable {
@@ -58,3 +72,8 @@ public enum ColorTag: String, CaseIterable, Sendable {
public enum QualityPreset: String, CaseIterable, Sendable {
case adaptive, high, low
}
public enum InputModePreference: String, CaseIterable, Sendable {
case touch
case trackpad
}

View File

@@ -1,6 +1,7 @@
import Testing
@testable import VNCCore
import Foundation
import CoreGraphics
@Suite struct SessionStateTests {
@Test func idleEqualsIdle() {
@@ -17,3 +18,74 @@ import Foundation
#expect(DisconnectReason.userRequested != .authenticationFailed)
}
}
@Suite struct ReconnectPolicyTests {
@Test func userRequestedNeverReconnects() {
let policy = ReconnectPolicy.default
#expect(!policy.shouldReconnect(for: .userRequested))
#expect(!policy.shouldReconnect(for: .authenticationFailed))
}
@Test func networkFailuresReconnect() {
let policy = ReconnectPolicy.default
#expect(policy.shouldReconnect(for: .networkError("oops")))
#expect(policy.shouldReconnect(for: .remoteClosed))
}
@Test func policyDelaysGrowAndCap() {
let policy = ReconnectPolicy(maxAttempts: 5,
baseDelaySeconds: 1,
maxDelaySeconds: 8,
jitterFraction: 0)
#expect(policy.delay(for: 1) == 1)
#expect(policy.delay(for: 2) == 2)
#expect(policy.delay(for: 3) == 4)
#expect(policy.delay(for: 4) == 8)
#expect(policy.delay(for: 5) == 8)
#expect(policy.delay(for: 6) == nil)
}
@Test func zeroAttemptsDisablesReconnect() {
let policy = ReconnectPolicy.none
#expect(!policy.shouldReconnect(for: .networkError("x")))
}
}
@Suite struct RemoteScreenTests {
@Test func screensAreHashable() {
let a = RemoteScreen(id: 1, frame: CGRect(x: 0, y: 0, width: 1920, height: 1080))
let b = RemoteScreen(id: 1, frame: CGRect(x: 0, y: 0, width: 1920, height: 1080))
let c = RemoteScreen(id: 2, frame: CGRect(x: 0, y: 0, width: 1920, height: 1080))
#expect(a == b)
#expect(a != c)
#expect(Set([a, b, c]).count == 2)
}
}
@Suite struct PasswordProviderTests {
private final class StubKeychain: KeychainServicing, @unchecked Sendable {
var stored: [String: String] = [:]
func storePassword(_ password: String, account: String) throws {
stored[account] = password
}
func loadPassword(account: String) throws -> String? {
stored[account]
}
func deletePassword(account: String) throws {
stored[account] = nil
}
}
@Test func keychainBackedProviderReturnsStored() {
let keychain = StubKeychain()
try? keychain.storePassword("hunter2", account: "abc")
let provider = DefaultPasswordProvider(keychain: keychain)
#expect(provider.password(for: "abc") == "hunter2")
#expect(provider.password(for: "missing") == nil)
}
@Test func staticProviderAlwaysReturnsSame() {
let provider = StaticPasswordProvider(password: "fixed")
#expect(provider.password(for: "anything") == "fixed")
}
}