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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
14
Packages/VNCCore/Sources/VNCCore/Session/PointerButton.swift
Normal file
14
Packages/VNCCore/Sources/VNCCore/Session/PointerButton.swift
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
12
Packages/VNCCore/Sources/VNCCore/Session/RemoteScreen.swift
Normal file
12
Packages/VNCCore/Sources/VNCCore/Session/RemoteScreen.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user