From 1c01b3573f44ddd51d1f7cdaf44581a5dfa5f69d Mon Sep 17 00:00:00 2001 From: Trey T Date: Thu, 16 Apr 2026 20:07:54 -0500 Subject: [PATCH] Phases 1-4: full VNC client implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../VNCCore/Clipboard/ClipboardBridge.swift | 21 +- .../VNCCore/Discovery/DiscoveryService.swift | 151 +++++- .../Session/NetworkPathProviding.swift | 97 ++++ .../VNCCore/Session/PasswordProvider.swift | 29 + .../VNCCore/Session/PointerButton.swift | 14 + .../VNCCore/Session/ReconnectPolicy.swift | 43 ++ .../VNCCore/Session/RemoteScreen.swift | 12 + .../VNCCore/Session/SendableValueBox.swift | 28 + .../VNCCore/Session/SessionController.swift | 504 +++++++++++++++++- .../VNCCore/Storage/SavedConnection.swift | 23 +- .../VNCCoreTests/SessionStateTests.swift | 72 +++ .../VNCUI/Edit/AddConnectionView.swift | 66 ++- .../Sources/VNCUI/List/ConnectionCard.swift | 9 +- .../VNCUI/List/ConnectionListView.swift | 175 ++++-- .../VNCUI/Session/FramebufferUIView.swift | 465 +++++++++++++++- .../VNCUI/Session/FramebufferView.swift | 41 +- .../Sources/VNCUI/Session/InputMapper.swift | 83 ++- .../Sources/VNCUI/Session/KeyMapping.swift | 114 ++++ .../VNCUI/Session/SessionToolbar.swift | 64 +++ .../Sources/VNCUI/Session/SessionView.swift | 218 +++++++- .../VNCUI/Session/SoftKeyboardBar.swift | 66 +++ .../VNCUI/Session/TrackpadCursorOverlay.swift | 29 + .../Sources/VNCUI/Settings/SettingsView.swift | 37 +- .../Tests/VNCUITests/InputMapperTests.swift | 50 +- Project.yml | 6 + Screens/App/VNCApp.swift | 49 +- Screens/Resources/Info.plist | 12 +- Screens/Resources/PrivacyInfo.xcprivacy | 39 ++ 28 files changed, 2359 insertions(+), 158 deletions(-) create mode 100644 Packages/VNCCore/Sources/VNCCore/Session/NetworkPathProviding.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Session/PasswordProvider.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Session/PointerButton.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Session/ReconnectPolicy.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Session/RemoteScreen.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Session/SendableValueBox.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/Session/KeyMapping.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/Session/TrackpadCursorOverlay.swift create mode 100644 Screens/Resources/PrivacyInfo.xcprivacy diff --git a/Packages/VNCCore/Sources/VNCCore/Clipboard/ClipboardBridge.swift b/Packages/VNCCore/Sources/VNCCore/Clipboard/ClipboardBridge.swift index a353e09..46de0d3 100644 --- a/Packages/VNCCore/Sources/VNCCore/Clipboard/ClipboardBridge.swift +++ b/Packages/VNCCore/Sources/VNCCore/Clipboard/ClipboardBridge.swift @@ -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) } } diff --git a/Packages/VNCCore/Sources/VNCCore/Discovery/DiscoveryService.swift b/Packages/VNCCore/Sources/VNCCore/Discovery/DiscoveryService.swift index 2da9ae2..70d0f21 100644 --- a/Packages/VNCCore/Sources/VNCCore/Discovery/DiscoveryService.swift +++ b/Packages/VNCCore/Sources/VNCCore/Discovery/DiscoveryService.swift @@ -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, 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 + } + } } diff --git a/Packages/VNCCore/Sources/VNCCore/Session/NetworkPathProviding.swift b/Packages/VNCCore/Sources/VNCCore/Session/NetworkPathProviding.swift new file mode 100644 index 0000000..c706c35 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Session/NetworkPathProviding.swift @@ -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 { 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 { + 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 { + let snapshot = self.snapshot + return AsyncStream { continuation in + continuation.yield(snapshot) + continuation.finish() + } + } +} diff --git a/Packages/VNCCore/Sources/VNCCore/Session/PasswordProvider.swift b/Packages/VNCCore/Sources/VNCCore/Session/PasswordProvider.swift new file mode 100644 index 0000000..0d6dd53 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Session/PasswordProvider.swift @@ -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 + } +} diff --git a/Packages/VNCCore/Sources/VNCCore/Session/PointerButton.swift b/Packages/VNCCore/Sources/VNCCore/Session/PointerButton.swift new file mode 100644 index 0000000..8de1c5e --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Session/PointerButton.swift @@ -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 +} diff --git a/Packages/VNCCore/Sources/VNCCore/Session/ReconnectPolicy.swift b/Packages/VNCCore/Sources/VNCCore/Session/ReconnectPolicy.swift new file mode 100644 index 0000000..efa5a15 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Session/ReconnectPolicy.swift @@ -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) + } +} diff --git a/Packages/VNCCore/Sources/VNCCore/Session/RemoteScreen.swift b/Packages/VNCCore/Sources/VNCCore/Session/RemoteScreen.swift new file mode 100644 index 0000000..bd1747b --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Session/RemoteScreen.swift @@ -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 + } +} diff --git a/Packages/VNCCore/Sources/VNCCore/Session/SendableValueBox.swift b/Packages/VNCCore/Sources/VNCCore/Session/SendableValueBox.swift new file mode 100644 index 0000000..3c41595 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Session/SendableValueBox.swift @@ -0,0 +1,28 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#endif + +struct SendableValueBox: @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 + } +} diff --git a/Packages/VNCCore/Sources/VNCCore/Session/SessionController.swift b/Packages/VNCCore/Sources/VNCCore/Session/SessionController.swift index c5f32d7..c9ea917 100644 --- a/Packages/VNCCore/Sources/VNCCore/Session/SessionController.swift +++ b/Packages/VNCCore/Sources/VNCCore/Session/SessionController.swift @@ -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? + 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? + private var explicitlyStopped = false + private var pathObserver: Task? + + 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 } } } diff --git a/Packages/VNCCore/Sources/VNCCore/Storage/SavedConnection.swift b/Packages/VNCCore/Sources/VNCCore/Storage/SavedConnection.swift index ea71611..e016b67 100644 --- a/Packages/VNCCore/Sources/VNCCore/Storage/SavedConnection.swift +++ b/Packages/VNCCore/Sources/VNCCore/Storage/SavedConnection.swift @@ -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 +} diff --git a/Packages/VNCCore/Tests/VNCCoreTests/SessionStateTests.swift b/Packages/VNCCore/Tests/VNCCoreTests/SessionStateTests.swift index 39f5044..8ca64bc 100644 --- a/Packages/VNCCore/Tests/VNCCoreTests/SessionStateTests.swift +++ b/Packages/VNCCore/Tests/VNCCoreTests/SessionStateTests.swift @@ -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") + } +} diff --git a/Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift b/Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift index 4d80e88..1a6909a 100644 --- a/Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift +++ b/Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift @@ -2,17 +2,38 @@ import SwiftUI import SwiftData import VNCCore +public struct AddConnectionPrefill: Equatable, Sendable { + public let displayName: String + public let host: String + public let port: Int + + public init(displayName: String = "", host: String = "", port: Int = 5900) { + self.displayName = displayName + self.host = host + self.port = port + } +} + public struct AddConnectionView: View { @Environment(\.modelContext) private var context @Environment(\.dismiss) private var dismiss + let prefill: AddConnectionPrefill? + @State private var displayName = "" @State private var host = "" @State private var port = "5900" @State private var password = "" @State private var colorTag: ColorTag = .blue + @State private var quality: QualityPreset = .adaptive + @State private var inputMode: InputModePreference = .touch + @State private var clipboardSync = true + @State private var viewOnly = false + @State private var notes = "" - public init() {} + public init(prefill: AddConnectionPrefill? = nil) { + self.prefill = prefill + } public var body: some View { NavigationStack { @@ -31,14 +52,39 @@ public struct AddConnectionView: View { } Section("Authentication") { SecureField("VNC password", text: $password) + Text("Stored in iOS Keychain (this device only).") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Defaults") { + Picker("Default input", selection: $inputMode) { + ForEach(InputModePreference.allCases, id: \.self) { mode in + Text(mode == .touch ? "Touch" : "Trackpad").tag(mode) + } + } + Picker("Quality", selection: $quality) { + Text("Adaptive").tag(QualityPreset.adaptive) + Text("High").tag(QualityPreset.high) + Text("Low (slow links)").tag(QualityPreset.low) + } + Toggle("Sync clipboard", isOn: $clipboardSync) + Toggle("View only", isOn: $viewOnly) } Section("Appearance") { Picker("Color tag", selection: $colorTag) { ForEach(ColorTag.allCases, id: \.self) { tag in - Text(tag.rawValue.capitalized).tag(tag) + HStack { + Circle().fill(tag.color).frame(width: 14, height: 14) + Text(tag.rawValue.capitalized) + } + .tag(tag) } } } + Section("Notes") { + TextField("Optional", text: $notes, axis: .vertical) + .lineLimit(2...6) + } } .navigationTitle("New Connection") #if os(iOS) @@ -53,16 +99,30 @@ public struct AddConnectionView: View { .disabled(displayName.isEmpty || host.isEmpty) } } + .onAppear { applyPrefillIfNeeded() } } } + private func applyPrefillIfNeeded() { + guard let prefill, displayName.isEmpty, host.isEmpty else { return } + displayName = prefill.displayName + host = prefill.host + port = String(prefill.port) + } + private func save() { let portInt = Int(port) ?? 5900 let connection = SavedConnection( displayName: displayName, host: host, port: portInt, - colorTag: colorTag + colorTag: colorTag, + quality: quality, + inputMode: inputMode, + viewOnly: viewOnly, + curtainMode: false, + clipboardSyncEnabled: clipboardSync, + notes: notes ) context.insert(connection) if !password.isEmpty { diff --git a/Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift b/Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift index 6b0e636..e6fa74c 100644 --- a/Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift +++ b/Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift @@ -8,13 +8,18 @@ struct ConnectionCard: View { HStack(spacing: 12) { Circle() .fill(connection.colorTag.color) - .frame(width: 12, height: 12) + .frame(width: 14, height: 14) VStack(alignment: .leading, spacing: 2) { Text(connection.displayName) .font(.headline) Text("\(connection.host):\(connection.port)") .font(.caption) .foregroundStyle(.secondary) + if connection.viewOnly { + Text("View only") + .font(.caption2) + .foregroundStyle(.tertiary) + } } Spacer() if let last = connection.lastConnectedAt { @@ -27,7 +32,7 @@ struct ConnectionCard: View { } } -private extension ColorTag { +extension ColorTag { var color: Color { switch self { case .red: .red diff --git a/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift b/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift index 6903f95..d485f23 100644 --- a/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift +++ b/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift @@ -4,59 +4,71 @@ import VNCCore public struct ConnectionListView: View { @Environment(\.modelContext) private var modelContext + @Environment(\.openWindow) private var openWindow @Query(sort: \SavedConnection.displayName) private var connections: [SavedConnection] @State private var discovery = DiscoveryService() @State private var showingAdd = false - @State private var selectedConnection: SavedConnection? + @State private var showingSettings = false + @State private var addPrefill: AddConnectionPrefill? + @State private var path: [SessionRoute] = [] + @State private var resolvingHostID: String? public init() {} public var body: some View { - NavigationStack { + NavigationStack(path: $path) { List { if !discovery.hosts.isEmpty { - Section("Discovered on this network") { - ForEach(discovery.hosts) { host in - Button { - // Phase 1: resolve host to SavedConnection draft - } label: { - Label(host.displayName, systemImage: "bonjour") - } - } - } - } - Section("Saved") { - if connections.isEmpty { - ContentUnavailableView( - "No saved connections", - systemImage: "display", - description: Text("Tap + to add a computer to connect to.") - ) - } else { - ForEach(connections) { connection in - ConnectionCard(connection: connection) - .onTapGesture { - selectedConnection = connection - } - } - } + discoveredSection } + savedSection } + #if os(iOS) + .listStyle(.insetGrouped) + #endif .navigationTitle("Screens") .toolbar { ToolbarItem(placement: .primaryAction) { Button { + addPrefill = nil showingAdd = true } label: { Image(systemName: "plus") } + .accessibilityLabel("Add connection") } + #if os(iOS) + ToolbarItem(placement: .topBarLeading) { + Button { + showingSettings = true + } label: { + Image(systemName: "gear") + } + .accessibilityLabel("Settings") + } + #else + ToolbarItem { + Button { + showingSettings = true + } label: { + Image(systemName: "gear") + } + } + #endif } .sheet(isPresented: $showingAdd) { - AddConnectionView() + AddConnectionView(prefill: addPrefill) } - .navigationDestination(item: $selectedConnection) { connection in - SessionView(connection: connection) + .sheet(isPresented: $showingSettings) { + SettingsView() + } + .navigationDestination(for: SessionRoute.self) { route in + if let connection = connection(with: route.connectionID) { + SessionView(connection: connection) + } else { + ContentUnavailableView("Connection unavailable", + systemImage: "exclamationmark.triangle") + } } .task { discovery.start() @@ -66,4 +78,107 @@ public struct ConnectionListView: View { } } } + + private var discoveredSection: some View { + Section { + ForEach(discovery.hosts) { host in + Button { + Task { await prepareDiscoveredHost(host) } + } label: { + HStack { + Image(systemName: "bonjour") + .foregroundStyle(.secondary) + VStack(alignment: .leading) { + Text(host.displayName) + Text(host.serviceType) + .font(.caption2) + .foregroundStyle(.tertiary) + } + Spacer() + if resolvingHostID == host.id { + ProgressView() + } + } + } + .disabled(resolvingHostID != nil) + } + } header: { + Text("Discovered on this network") + } + } + + private var savedSection: some View { + Section { + if connections.isEmpty { + ContentUnavailableView( + "No saved connections", + systemImage: "display", + description: Text("Tap + to add a computer to control.") + ) + } else { + ForEach(connections) { connection in + NavigationLink(value: SessionRoute(connectionID: connection.id)) { + ConnectionCard(connection: connection) + } + .contextMenu { + #if os(iOS) + Button { + openWindow(value: connection.id) + } label: { + Label("Open in New Window", systemImage: "rectangle.stack.badge.plus") + } + #endif + Button(role: .destructive) { + delete(connection) + } label: { + Label("Delete", systemImage: "trash") + } + } + .swipeActions { + Button(role: .destructive) { + delete(connection) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } header: { + Text("Saved") + } + } + + private func prepareDiscoveredHost(_ host: DiscoveredHost) async { + resolvingHostID = host.id + defer { resolvingHostID = nil } + do { + let resolved = try await discovery.resolve(host) + addPrefill = AddConnectionPrefill( + displayName: host.displayName, + host: resolved.host, + port: resolved.port + ) + } catch { + addPrefill = AddConnectionPrefill( + displayName: host.displayName, + host: "", + port: 5900 + ) + } + showingAdd = true + } + + private func connection(with id: UUID) -> SavedConnection? { + connections.first(where: { $0.id == id }) + } + + private func delete(_ connection: SavedConnection) { + try? KeychainService().deletePassword(account: connection.keychainTag) + modelContext.delete(connection) + try? modelContext.save() + } +} + +struct SessionRoute: Hashable { + let connectionID: UUID } diff --git a/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift index e1d4da1..5e9bfb2 100644 --- a/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift +++ b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift @@ -2,35 +2,470 @@ import UIKit import VNCCore -final class FramebufferUIView: UIView { - weak var coordinator: FramebufferView.Coordinator? - private let contentLayer = CALayer() +@MainActor +final class FramebufferUIView: UIView, + UIGestureRecognizerDelegate, + UIPointerInteractionDelegate { + weak var controller: SessionController? + var inputMode: InputMode = .touch + var selectedScreen: RemoteScreen? { + didSet { setNeedsLayout() } + } + var zoomScale: CGFloat = 1.0 { + didSet { setNeedsLayout() } + } + var contentTranslation: CGPoint = .zero { + didSet { setNeedsLayout() } + } + var trackpadCursorNormalized: CGPoint = CGPoint(x: 0.5, y: 0.5) + var onTrackpadCursorChanged: ((CGPoint) -> Void)? + + private let imageLayer = CALayer() + private let inputMapper = InputMapper() + + // Touch-mode pan with implicit left-button drag + private var touchPanActiveButtonDown = false + private var touchPanLastNormalized: CGPoint? + + // Trackpad-mode pan moves the soft cursor + private var trackpadPanStartCursor: CGPoint? + private var trackpadPanStartPoint: CGPoint? + + // Two-finger scroll accumulator + private var scrollAccumulator: CGPoint = .zero + private static let scrollStepPoints: CGFloat = 28 + + // Pinch zoom anchor + private var pinchStartScale: CGFloat = 1.0 + private var pinchStartTranslation: CGPoint = .zero + + // Indirect pointer (trackpad/mouse via UIPointerInteraction) + private var indirectPointerNormalized: CGPoint? override init(frame: CGRect) { super.init(frame: frame) isOpaque = true backgroundColor = .black - contentLayer.magnificationFilter = .nearest - contentLayer.minificationFilter = .linear - layer.addSublayer(contentLayer) + isMultipleTouchEnabled = true + clipsToBounds = true + + imageLayer.magnificationFilter = .nearest + imageLayer.minificationFilter = .linear + imageLayer.contentsGravity = .resize + layer.addSublayer(imageLayer) + + configureGestureRecognizers() + configurePointerInteraction() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - override func layoutSubviews() { - super.layoutSubviews() - contentLayer.frame = bounds + override var canBecomeFirstResponder: Bool { true } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { _ = becomeFirstResponder() } } - func apply(state: SessionState) { - switch state { - case .connected(let size): - contentLayer.backgroundColor = UIColor.darkGray.cgColor - _ = size + // MARK: Layout / image + + override func layoutSubviews() { + super.layoutSubviews() + applyLayerFrame() + } + + func apply(image: CGImage?, framebufferSize: CGSize) { + imageLayer.contents = image + applyLayerFrame() + } + + private func applyLayerFrame() { + let bounds = self.bounds + let fbSize = framebufferContentSize() + guard fbSize.width > 0, fbSize.height > 0, + bounds.width > 0, bounds.height > 0, + let displayed = inputMapper.displayedRect(for: fbSize, in: bounds.size) else { + imageLayer.frame = bounds + imageLayer.contentsRect = CGRect(x: 0, y: 0, width: 1, height: 1) + return + } + + let scaled = displayed.applying( + CGAffineTransform(translationX: -bounds.midX, y: -bounds.midY) + .concatenating(CGAffineTransform(scaleX: zoomScale, y: zoomScale)) + .concatenating(CGAffineTransform(translationX: bounds.midX + contentTranslation.x, + y: bounds.midY + contentTranslation.y)) + ) + imageLayer.frame = scaled + + if let screen = selectedScreen { + let totalWidth = framebufferAbsoluteSize().width + let totalHeight = framebufferAbsoluteSize().height + if totalWidth > 0, totalHeight > 0 { + imageLayer.contentsRect = CGRect( + x: screen.frame.origin.x / totalWidth, + y: screen.frame.origin.y / totalHeight, + width: screen.frame.width / totalWidth, + height: screen.frame.height / totalHeight + ) + } else { + imageLayer.contentsRect = CGRect(x: 0, y: 0, width: 1, height: 1) + } + } else { + imageLayer.contentsRect = CGRect(x: 0, y: 0, width: 1, height: 1) + } + } + + private func framebufferAbsoluteSize() -> CGSize { + guard let size = controller?.framebufferSize else { return .zero } + return CGSize(width: CGFloat(size.width), height: CGFloat(size.height)) + } + + private func framebufferContentSize() -> CGSize { + if let screen = selectedScreen { + return CGSize(width: screen.frame.width, height: screen.frame.height) + } + return framebufferAbsoluteSize() + } + + // MARK: Gesture recognition + + private func configureGestureRecognizers() { + let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap(_:))) + singleTap.numberOfTapsRequired = 1 + singleTap.numberOfTouchesRequired = 1 + addGestureRecognizer(singleTap) + + let twoFingerTap = UITapGestureRecognizer(target: self, action: #selector(handleTwoFingerTap(_:))) + twoFingerTap.numberOfTapsRequired = 1 + twoFingerTap.numberOfTouchesRequired = 2 + addGestureRecognizer(twoFingerTap) + + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) + longPress.minimumPressDuration = 0.55 + longPress.delegate = self + addGestureRecognizer(longPress) + + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + pan.minimumNumberOfTouches = 1 + pan.maximumNumberOfTouches = 1 + pan.delegate = self + pan.allowedTouchTypes = [ + NSNumber(value: UITouch.TouchType.direct.rawValue), + NSNumber(value: UITouch.TouchType.pencil.rawValue) + ] + addGestureRecognizer(pan) + + let twoFingerPan = UIPanGestureRecognizer(target: self, action: #selector(handleTwoFingerPan(_:))) + twoFingerPan.minimumNumberOfTouches = 2 + twoFingerPan.maximumNumberOfTouches = 2 + twoFingerPan.delegate = self + addGestureRecognizer(twoFingerPan) + + let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:))) + pinch.delegate = self + addGestureRecognizer(pinch) + + let indirectScroll = UIPanGestureRecognizer(target: self, action: #selector(handleIndirectScroll(_:))) + indirectScroll.allowedScrollTypesMask = .all + indirectScroll.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)] + indirectScroll.delegate = self + addGestureRecognizer(indirectScroll) + + let hover = UIHoverGestureRecognizer(target: self, action: #selector(handleHover(_:))) + addGestureRecognizer(hover) + } + + // MARK: UIGestureRecognizerDelegate + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { + if gestureRecognizer is UIPinchGestureRecognizer || other is UIPinchGestureRecognizer { + return true + } + return false + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldRequireFailureOf other: UIGestureRecognizer) -> Bool { + if let tap = gestureRecognizer as? UITapGestureRecognizer, + tap.numberOfTouchesRequired == 1, + let otherTap = other as? UITapGestureRecognizer, + otherTap.numberOfTouchesRequired == 2 { + return true + } + return false + } + + // MARK: Pointer Interaction + + private func configurePointerInteraction() { + let interaction = UIPointerInteraction(delegate: self) + addInteraction(interaction) + } + + func pointerInteraction(_ interaction: UIPointerInteraction, + styleFor region: UIPointerRegion) -> UIPointerStyle? { + UIPointerStyle.hidden() + } + + func pointerInteraction(_ interaction: UIPointerInteraction, + regionFor request: UIPointerRegionRequest, + defaultRegion: UIPointerRegion) -> UIPointerRegion? { + let viewPoint = request.location + if let normalized = inputMapper.normalize(viewPoint: viewPoint, + in: bounds.size, + framebufferSize: framebufferContentSize()) { + indirectPointerNormalized = normalized + controller?.pointerMove(toNormalized: mapToFullFramebuffer(normalized: normalized)) + } + return defaultRegion + } + + // MARK: Gesture Handlers + + @objc private func handleSingleTap(_ recognizer: UITapGestureRecognizer) { + let location = recognizer.location(in: self) + switch inputMode { + case .touch: + if let normalized = normalizedFor(location) { + controller?.pointerClick(.left, + atNormalized: mapToFullFramebuffer(normalized: normalized)) + } + case .trackpad: + controller?.pointerClick(.left, + atNormalized: mapToFullFramebuffer(normalized: trackpadCursorNormalized)) + } + if !isFirstResponder { _ = becomeFirstResponder() } + } + + @objc private func handleTwoFingerTap(_ recognizer: UITapGestureRecognizer) { + let location = recognizer.location(in: self) + switch inputMode { + case .touch: + if let normalized = normalizedFor(location) { + controller?.pointerClick(.right, + atNormalized: mapToFullFramebuffer(normalized: normalized)) + } + case .trackpad: + controller?.pointerClick(.right, + atNormalized: mapToFullFramebuffer(normalized: trackpadCursorNormalized)) + } + } + + @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { + guard recognizer.state == .began else { return } + let location = recognizer.location(in: self) + switch inputMode { + case .touch: + if let normalized = normalizedFor(location) { + controller?.pointerClick(.right, + atNormalized: mapToFullFramebuffer(normalized: normalized)) + } + case .trackpad: + controller?.pointerClick(.right, + atNormalized: mapToFullFramebuffer(normalized: trackpadCursorNormalized)) + } + } + + @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { + switch inputMode { + case .touch: + handleTouchPan(recognizer) + case .trackpad: + handleTrackpadPan(recognizer) + } + } + + private func handleTouchPan(_ recognizer: UIPanGestureRecognizer) { + let location = recognizer.location(in: self) + guard let normalized = normalizedFor(location) else { return } + let mapped = mapToFullFramebuffer(normalized: normalized) + switch recognizer.state { + case .began: + touchPanLastNormalized = mapped + touchPanActiveButtonDown = true + controller?.pointerDown(.left, atNormalized: mapped) + case .changed: + touchPanLastNormalized = mapped + controller?.pointerMove(toNormalized: mapped) + case .ended, .cancelled, .failed: + if touchPanActiveButtonDown { + touchPanActiveButtonDown = false + let endNormalized = touchPanLastNormalized ?? mapped + controller?.pointerUp(.left, atNormalized: endNormalized) + } + touchPanLastNormalized = nil default: - contentLayer.backgroundColor = UIColor.black.cgColor + break + } + } + + private func handleTrackpadPan(_ recognizer: UIPanGestureRecognizer) { + let translation = recognizer.translation(in: self) + switch recognizer.state { + case .began: + trackpadPanStartCursor = trackpadCursorNormalized + trackpadPanStartPoint = .zero + case .changed: + guard let start = trackpadPanStartCursor else { return } + let fbSize = framebufferContentSize() + guard let displayed = inputMapper.displayedRect(for: fbSize, in: bounds.size), + displayed.width > 0, displayed.height > 0 else { return } + let dx = translation.x / displayed.width + let dy = translation.y / displayed.height + let newCursor = CGPoint( + x: max(0, min(1, start.x + dx)), + y: max(0, min(1, start.y + dy)) + ) + trackpadCursorNormalized = newCursor + onTrackpadCursorChanged?(newCursor) + controller?.pointerMove(toNormalized: mapToFullFramebuffer(normalized: newCursor)) + case .ended, .cancelled, .failed: + trackpadPanStartCursor = nil + trackpadPanStartPoint = nil + default: break + } + } + + @objc private func handleTwoFingerPan(_ recognizer: UIPanGestureRecognizer) { + let translation = recognizer.translation(in: self) + recognizer.setTranslation(.zero, in: self) + scrollAccumulator.x += translation.x + scrollAccumulator.y += translation.y + + emitScrollEvents() + if recognizer.state == .ended || recognizer.state == .cancelled { + scrollAccumulator = .zero + } + } + + @objc private func handleIndirectScroll(_ recognizer: UIPanGestureRecognizer) { + let translation = recognizer.translation(in: self) + recognizer.setTranslation(.zero, in: self) + scrollAccumulator.x += translation.x + scrollAccumulator.y += translation.y + emitScrollEvents() + if recognizer.state == .ended || recognizer.state == .cancelled { + scrollAccumulator = .zero + } + } + + private func emitScrollEvents() { + guard let controller else { return } + let cursor: CGPoint = inputMode == .trackpad + ? trackpadCursorNormalized + : (indirectPointerNormalized ?? CGPoint(x: 0.5, y: 0.5)) + let mapped = mapToFullFramebuffer(normalized: cursor) + let stepsY = Int((scrollAccumulator.y / Self.scrollStepPoints).rounded(.towardZero)) + if stepsY != 0 { + let dir: ScrollDirection = stepsY > 0 ? .down : .up + controller.pointerScroll(dir, steps: UInt32(abs(stepsY)), atNormalized: mapped) + scrollAccumulator.y -= CGFloat(stepsY) * Self.scrollStepPoints + } + let stepsX = Int((scrollAccumulator.x / Self.scrollStepPoints).rounded(.towardZero)) + if stepsX != 0 { + let dir: ScrollDirection = stepsX > 0 ? .right : .left + controller.pointerScroll(dir, steps: UInt32(abs(stepsX)), atNormalized: mapped) + scrollAccumulator.x -= CGFloat(stepsX) * Self.scrollStepPoints + } + } + + @objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) { + switch recognizer.state { + case .began: + pinchStartScale = zoomScale + pinchStartTranslation = contentTranslation + case .changed: + let newScale = max(0.5, min(8.0, pinchStartScale * recognizer.scale)) + zoomScale = newScale + case .ended, .cancelled, .failed: + // snap back if zoomed too much + if zoomScale < 1.01 { + zoomScale = 1.0 + contentTranslation = .zero + } + default: break + } + } + + @objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) { + let location = recognizer.location(in: self) + guard let normalized = inputMapper.normalize(viewPoint: location, + in: bounds.size, + framebufferSize: framebufferContentSize()) else { return } + indirectPointerNormalized = normalized + if recognizer.state == .changed || recognizer.state == .began { + controller?.pointerMove(toNormalized: mapToFullFramebuffer(normalized: normalized)) + } + } + + private func normalizedFor(_ point: CGPoint) -> CGPoint? { + inputMapper.normalize(viewPoint: point, + in: bounds.size, + framebufferSize: framebufferContentSize()) + } + + private func mapToFullFramebuffer(normalized: CGPoint) -> CGPoint { + guard let screen = selectedScreen, + let totalSize = controller?.framebufferSize, + totalSize.width > 0, totalSize.height > 0 else { + return normalized + } + let totalWidth = CGFloat(totalSize.width) + let totalHeight = CGFloat(totalSize.height) + let x = (screen.frame.origin.x + normalized.x * screen.frame.width) / totalWidth + let y = (screen.frame.origin.y + normalized.y * screen.frame.height) / totalHeight + return CGPoint(x: max(0, min(1, x)), y: max(0, min(1, y))) + } + + // MARK: Hardware keyboard + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + var handled = false + for press in presses { + guard let key = press.key else { continue } + handleKey(key, isDown: true) + handled = true + } + if !handled { super.pressesBegan(presses, with: event) } + } + + override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + var handled = false + for press in presses { + guard let key = press.key else { continue } + handleKey(key, isDown: false) + handled = true + } + if !handled { super.pressesEnded(presses, with: event) } + } + + override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { + for press in presses { + guard let key = press.key else { continue } + handleKey(key, isDown: false) + } + super.pressesCancelled(presses, with: event) + } + + private func handleKey(_ key: UIKey, isDown: Bool) { + guard let controller else { return } + let modifiers = KeyMapping.modifierKeysyms(for: key.modifierFlags) + if isDown { + for sym in modifiers { controller.keyDown(keysym: sym) } + } + if let sym = KeyMapping.keysym(for: key) { + if isDown { + controller.keyDown(keysym: sym) + } else { + controller.keyUp(keysym: sym) + } + } + if !isDown { + for sym in modifiers.reversed() { controller.keyUp(keysym: sym) } } } } diff --git a/Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift index 5d02324..0ab995f 100644 --- a/Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift +++ b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift @@ -1,36 +1,57 @@ #if canImport(UIKit) import SwiftUI import VNCCore +import CoreGraphics struct FramebufferView: UIViewRepresentable { let controller: SessionController + let inputMode: InputMode + let selectedScreen: RemoteScreen? + @Binding var trackpadCursor: CGPoint func makeUIView(context: Context) -> FramebufferUIView { let view = FramebufferUIView() - view.coordinator = context.coordinator + view.controller = controller + view.inputMode = inputMode + view.selectedScreen = selectedScreen + view.trackpadCursorNormalized = trackpadCursor + view.onTrackpadCursorChanged = { [binding = $trackpadCursor] new in + binding.wrappedValue = new + } return view } func updateUIView(_ uiView: FramebufferUIView, context: Context) { - uiView.apply(state: controller.state) + uiView.controller = controller + uiView.inputMode = inputMode + uiView.selectedScreen = selectedScreen + if uiView.trackpadCursorNormalized != trackpadCursor { + uiView.trackpadCursorNormalized = trackpadCursor + } + uiView.apply(image: controller.currentImage, + framebufferSize: framebufferSize) + // Touch the revision so SwiftUI re-runs us when frames arrive + _ = controller.imageRevision } - func makeCoordinator() -> Coordinator { - Coordinator(inputMapper: InputMapper()) - } - - @MainActor - final class Coordinator { - let inputMapper: InputMapper - init(inputMapper: InputMapper) { self.inputMapper = inputMapper } + private var framebufferSize: CGSize { + if let size = controller.framebufferSize { + return CGSize(width: CGFloat(size.width), height: CGFloat(size.height)) + } + return .zero } } #else import SwiftUI import VNCCore +import CoreGraphics struct FramebufferView: View { let controller: SessionController + let inputMode: InputMode + let selectedScreen: RemoteScreen? + @Binding var trackpadCursor: CGPoint + var body: some View { Color.black } } #endif diff --git a/Packages/VNCUI/Sources/VNCUI/Session/InputMapper.swift b/Packages/VNCUI/Sources/VNCUI/Session/InputMapper.swift index de5449a..e0be6cf 100644 --- a/Packages/VNCUI/Sources/VNCUI/Session/InputMapper.swift +++ b/Packages/VNCUI/Sources/VNCUI/Session/InputMapper.swift @@ -1,30 +1,73 @@ import CoreGraphics import Foundation -public enum InputMode: Sendable { +public enum InputMode: String, Sendable, CaseIterable, Hashable { case touch case trackpad } -public struct PointerEvent: Sendable, Equatable { - public let location: CGPoint - public let buttonMask: UInt8 -} +public struct DisplayedRect: Equatable, Sendable { + public let rect: CGRect -public struct KeyEvent: Sendable, Equatable { - public let keysym: UInt32 - public let down: Bool -} - -@MainActor -public final class InputMapper { - public var mode: InputMode = .touch - - public init() {} - - public func pointerFromTap(at point: CGPoint, in framebuffer: CGSize, viewBounds: CGSize) -> PointerEvent { - let x = point.x / viewBounds.width * framebuffer.width - let y = point.y / viewBounds.height * framebuffer.height - return PointerEvent(location: CGPoint(x: x, y: y), buttonMask: 0b1) + public init(rect: CGRect) { + self.rect = rect + } +} + +public struct InputMapper: Sendable { + public init() {} + + /// Return the rect where a framebuffer of `framebufferSize` is drawn inside + /// `viewSize` using aspect-fit (`.resizeAspect`) gravity. + public func displayedRect(for framebufferSize: CGSize, + in viewSize: CGSize) -> CGRect? { + guard framebufferSize.width > 0, framebufferSize.height > 0, + viewSize.width > 0, viewSize.height > 0 else { + return nil + } + let viewAspect = viewSize.width / viewSize.height + let fbAspect = framebufferSize.width / framebufferSize.height + if viewAspect > fbAspect { + let displayedWidth = viewSize.height * fbAspect + let xOffset = (viewSize.width - displayedWidth) / 2 + return CGRect(x: xOffset, y: 0, + width: displayedWidth, + height: viewSize.height) + } else { + let displayedHeight = viewSize.width / fbAspect + let yOffset = (viewSize.height - displayedHeight) / 2 + return CGRect(x: 0, y: yOffset, + width: viewSize.width, + height: displayedHeight) + } + } + + /// Convert a point in view coordinates to normalized framebuffer coordinates [0,1]. + /// Returns nil if either size is empty. + public func normalize(viewPoint: CGPoint, + in viewSize: CGSize, + framebufferSize: CGSize) -> CGPoint? { + guard let displayed = displayedRect(for: framebufferSize, in: viewSize) else { + return nil + } + let nx = (viewPoint.x - displayed.origin.x) / displayed.width + let ny = (viewPoint.y - displayed.origin.y) / displayed.height + return CGPoint(x: clamp(nx), y: clamp(ny)) + } + + /// Convert a normalized framebuffer point [0,1] to view coordinates. + public func viewPoint(forNormalized normalized: CGPoint, + in viewSize: CGSize, + framebufferSize: CGSize) -> CGPoint? { + guard let displayed = displayedRect(for: framebufferSize, in: viewSize) else { + return nil + } + let x = displayed.origin.x + normalized.x * displayed.width + let y = displayed.origin.y + normalized.y * displayed.height + return CGPoint(x: x, y: y) + } + + private func clamp(_ value: CGFloat) -> CGFloat { + max(0, min(1, value)) } } diff --git a/Packages/VNCUI/Sources/VNCUI/Session/KeyMapping.swift b/Packages/VNCUI/Sources/VNCUI/Session/KeyMapping.swift new file mode 100644 index 0000000..c52229a --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/Session/KeyMapping.swift @@ -0,0 +1,114 @@ +#if canImport(UIKit) +import UIKit + +enum KeyMapping { + /// X11 keysym values for special keys. + enum X11 { + static let backspace: UInt32 = 0xFF08 + static let tab: UInt32 = 0xFF09 + static let `return`: UInt32 = 0xFF0D + static let escape: UInt32 = 0xFF1B + static let delete: UInt32 = 0xFFFF + + static let home: UInt32 = 0xFF50 + static let leftArrow: UInt32 = 0xFF51 + static let upArrow: UInt32 = 0xFF52 + static let rightArrow: UInt32 = 0xFF53 + static let downArrow: UInt32 = 0xFF54 + static let pageUp: UInt32 = 0xFF55 + static let pageDown: UInt32 = 0xFF56 + static let end: UInt32 = 0xFF57 + static let insert: UInt32 = 0xFF63 + + static let f1: UInt32 = 0xFFBE + static let f2: UInt32 = 0xFFBF + static let f3: UInt32 = 0xFFC0 + static let f4: UInt32 = 0xFFC1 + static let f5: UInt32 = 0xFFC2 + static let f6: UInt32 = 0xFFC3 + static let f7: UInt32 = 0xFFC4 + static let f8: UInt32 = 0xFFC5 + static let f9: UInt32 = 0xFFC6 + static let f10: UInt32 = 0xFFC7 + static let f11: UInt32 = 0xFFC8 + static let f12: UInt32 = 0xFFC9 + + static let leftShift: UInt32 = 0xFFE1 + static let rightShift: UInt32 = 0xFFE2 + static let leftControl: UInt32 = 0xFFE3 + static let rightControl: UInt32 = 0xFFE4 + static let capsLock: UInt32 = 0xFFE5 + static let leftAlt: UInt32 = 0xFFE9 + static let rightAlt: UInt32 = 0xFFEA + static let leftMeta: UInt32 = 0xFFE7 + static let rightMeta: UInt32 = 0xFFE8 + static let leftCommand: UInt32 = 0xFFEB + static let rightCommand: UInt32 = 0xFFEC + + static let space: UInt32 = 0x0020 + } + + /// Convert a UIKey into one or more VNC keysyms (UInt32 X11 values). + /// Returns nil if no mapping is found. + static func keysym(for key: UIKey) -> UInt32? { + let usage = key.keyCode + if let mapped = mapHIDUsage(usage) { + return mapped + } + let chars = key.charactersIgnoringModifiers + guard let scalar = chars.unicodeScalars.first else { return nil } + return scalar.value + } + + static func modifierKeysyms(for flags: UIKeyModifierFlags) -> [UInt32] { + var out: [UInt32] = [] + if flags.contains(.shift) { out.append(X11.leftShift) } + if flags.contains(.control) { out.append(X11.leftControl) } + if flags.contains(.alternate) { out.append(X11.leftAlt) } + if flags.contains(.command) { out.append(X11.leftCommand) } + return out + } + + private static func mapHIDUsage(_ usage: UIKeyboardHIDUsage) -> UInt32? { + switch usage { + case .keyboardReturnOrEnter, .keypadEnter: return X11.return + case .keyboardEscape: return X11.escape + case .keyboardDeleteOrBackspace: return X11.backspace + case .keyboardTab: return X11.tab + case .keyboardSpacebar: return X11.space + case .keyboardLeftArrow: return X11.leftArrow + case .keyboardRightArrow: return X11.rightArrow + case .keyboardUpArrow: return X11.upArrow + case .keyboardDownArrow: return X11.downArrow + case .keyboardHome: return X11.home + case .keyboardEnd: return X11.end + case .keyboardPageUp: return X11.pageUp + case .keyboardPageDown: return X11.pageDown + case .keyboardInsert: return X11.insert + case .keyboardDeleteForward: return X11.delete + case .keyboardCapsLock: return X11.capsLock + case .keyboardLeftShift: return X11.leftShift + case .keyboardRightShift: return X11.rightShift + case .keyboardLeftControl: return X11.leftControl + case .keyboardRightControl: return X11.rightControl + case .keyboardLeftAlt: return X11.leftAlt + case .keyboardRightAlt: return X11.rightAlt + case .keyboardLeftGUI: return X11.leftCommand + case .keyboardRightGUI: return X11.rightCommand + case .keyboardF1: return X11.f1 + case .keyboardF2: return X11.f2 + case .keyboardF3: return X11.f3 + case .keyboardF4: return X11.f4 + case .keyboardF5: return X11.f5 + case .keyboardF6: return X11.f6 + case .keyboardF7: return X11.f7 + case .keyboardF8: return X11.f8 + case .keyboardF9: return X11.f9 + case .keyboardF10: return X11.f10 + case .keyboardF11: return X11.f11 + case .keyboardF12: return X11.f12 + default: return nil + } + } +} +#endif diff --git a/Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift b/Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift new file mode 100644 index 0000000..8938b7e --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift @@ -0,0 +1,64 @@ +import SwiftUI +import VNCCore + +struct SessionToolbar: View { + let controller: SessionController + @Binding var inputMode: InputMode + @Binding var showKeyboardBar: Bool + @Binding var selectedScreenID: UInt32? + var onScreenshot: () -> Void + var onDisconnect: () -> Void + + var body: some View { + HStack(spacing: 12) { + Picker("Input Mode", selection: $inputMode) { + Text("Touch").tag(InputMode.touch) + Text("Trackpad").tag(InputMode.trackpad) + } + .pickerStyle(.segmented) + .fixedSize() + + Button { + showKeyboardBar.toggle() + } label: { + Image(systemName: "keyboard") + } + .accessibilityLabel("Toggle keyboard bar") + + if controller.screens.count > 1 { + Menu { + Button("All screens") { selectedScreenID = nil } + ForEach(controller.screens) { screen in + Button { + selectedScreenID = screen.id + } label: { + Text("Screen \(screen.id) (\(Int(screen.frame.width))×\(Int(screen.frame.height)))") + } + } + } label: { + Image(systemName: "rectangle.on.rectangle") + } + .accessibilityLabel("Choose monitor") + } + + Button { + onScreenshot() + } label: { + Image(systemName: "camera") + } + .accessibilityLabel("Screenshot") + + Spacer() + + Button(role: .destructive) { + onDisconnect() + } label: { + Label("Disconnect", systemImage: "xmark.circle.fill") + .labelStyle(.iconOnly) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} diff --git a/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift b/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift index 5a0121b..4856340 100644 --- a/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift +++ b/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift @@ -1,9 +1,26 @@ import SwiftUI +import SwiftData import VNCCore +import CoreGraphics +#if canImport(UIKit) +import UIKit +#endif public struct SessionView: View { let connection: SavedConnection + + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase + @AppStorage("defaultInputMode") private var defaultInputModeRaw = "touch" + @State private var controller: SessionController? + @State private var inputMode: InputMode = .touch + @State private var showKeyboardBar = false + @State private var showFunctionRow = false + @State private var trackpadCursor: CGPoint = CGPoint(x: 0.5, y: 0.5) + @State private var selectedScreenID: UInt32? + @State private var screenshotItem: ScreenshotShareItem? public init(connection: SavedConnection) { self.connection = connection @@ -12,57 +29,212 @@ public struct SessionView: View { public var body: some View { ZStack { Color.black.ignoresSafeArea() + if let controller { - FramebufferView(controller: controller) - statusOverlay(for: controller.state) + FramebufferView( + controller: controller, + inputMode: inputMode, + selectedScreen: selectedScreen(for: controller), + trackpadCursor: $trackpadCursor + ) + .ignoresSafeArea() + + if inputMode == .trackpad, + let size = controller.framebufferSize { + TrackpadCursorOverlay( + normalizedPosition: trackpadCursor, + framebufferSize: CGSize(width: CGFloat(size.width), + height: CGFloat(size.height)), + isVisible: true + ) + } + + statusOverlay(for: controller.state, controller: controller) + overlayChrome(controller: controller) } else { ProgressView("Preparing session…") .tint(.white) .foregroundStyle(.white) } } - .navigationTitle(connection.displayName) + .navigationTitle(controller?.desktopName ?? connection.displayName) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + #if os(iOS) + .toolbar(.hidden, for: .tabBar) + #endif .task(id: connection.id) { await startSession() } + .onChange(of: scenePhase) { _, phase in + if phase == .background { + controller?.stop() + } + } + .onDisappear { + controller?.stop() + persistLastConnected() + } + .sheet(item: $screenshotItem) { item in + ShareSheet(items: [item.image]) + } } @ViewBuilder - private func statusOverlay(for state: SessionState) -> some View { + private func overlayChrome(controller: SessionController) -> some View { + VStack { + SessionToolbar( + controller: controller, + inputMode: $inputMode, + showKeyboardBar: $showKeyboardBar, + selectedScreenID: $selectedScreenID, + onScreenshot: { takeScreenshot(controller: controller) }, + onDisconnect: { stopAndDismiss() } + ) + .padding(.horizontal, 12) + .padding(.top, 6) + Spacer() + if showKeyboardBar { + SoftKeyboardBar(controller: controller, isExpanded: $showFunctionRow) + .padding(.horizontal, 12) + .padding(.bottom, 12) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + } + + @ViewBuilder + private func statusOverlay(for state: SessionState, + controller: SessionController) -> some View { switch state { case .connecting: - VStack { + messageOverlay { ProgressView("Connecting…").tint(.white).foregroundStyle(.white) } case .authenticating: - VStack { + messageOverlay { ProgressView("Authenticating…").tint(.white).foregroundStyle(.white) } case .disconnected(let reason): - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.largeTitle) - Text("Disconnected") - .font(.headline) - Text(String(describing: reason)) - .font(.caption) - .foregroundStyle(.secondary) - } - .foregroundStyle(.white) - default: + disconnectedOverlay(reason: reason, controller: controller) + case .idle, .connected: EmptyView() } } - @MainActor + @ViewBuilder + private func disconnectedOverlay(reason: DisconnectReason, + controller: SessionController) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.largeTitle) + .foregroundStyle(.yellow) + Text("Disconnected") + .font(.headline) + .foregroundStyle(.white) + Text(humanReadable(reason: reason, lastError: controller.lastErrorMessage)) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + HStack { + Button { + controller.reconnectNow() + } label: { + Label("Reconnect", systemImage: "arrow.clockwise") + } + .buttonStyle(.borderedProminent) + Button("Close") { + stopAndDismiss() + } + .buttonStyle(.bordered) + } + if controller.isReconnecting { + ProgressView("Reconnecting attempt \(controller.reconnectAttempt)…") + .tint(.white) + .foregroundStyle(.white) + } + } + .padding(24) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + + @ViewBuilder + private func messageOverlay(@ViewBuilder _ content: () -> V) -> some View { + content() + .padding(20) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + + private func selectedScreen(for controller: SessionController) -> RemoteScreen? { + guard let id = selectedScreenID else { return nil } + return controller.screens.first { $0.id == id } + } + private func startSession() async { - let endpoint = TransportEndpoint(host: connection.host, port: connection.port) - let transport = DirectTransport(endpoint: endpoint) - let controller = SessionController(transport: transport) - self.controller = controller - controller.start() + if controller == nil { + inputMode = InputMode(rawValue: defaultInputModeRaw) ?? .touch + let provider = DefaultPasswordProvider() + let controller = SessionController(connection: connection, + passwordProvider: provider) + self.controller = controller + controller.start() + } + } + + private func stopAndDismiss() { + controller?.stop() + persistLastConnected() + dismiss() + } + + private func persistLastConnected() { + connection.lastConnectedAt = .now + try? modelContext.save() + } + + private func humanReadable(reason: DisconnectReason, lastError: String?) -> String { + switch reason { + case .userRequested: return "You ended this session." + case .authenticationFailed: return "Authentication failed. Check the password and try again." + case .networkError(let detail): return lastError ?? detail + case .protocolError(let detail): return lastError ?? detail + case .remoteClosed: return "The remote computer closed the session." + } + } + + private func takeScreenshot(controller: SessionController) { + #if canImport(UIKit) + guard let cgImage = controller.currentImage else { return } + let image = UIImage(cgImage: cgImage) + screenshotItem = ScreenshotShareItem(image: image) + #endif } } + +private struct ScreenshotShareItem: Identifiable { + let id = UUID() + #if canImport(UIKit) + let image: UIImage + #else + let image: Any + #endif +} + +#if canImport(UIKit) +private struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} +#else +private struct ShareSheet: View { + let items: [Any] + var body: some View { EmptyView() } +} +#endif diff --git a/Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift b/Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift new file mode 100644 index 0000000..8d0af11 --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift @@ -0,0 +1,66 @@ +import SwiftUI +import VNCCore + +struct SoftKeyboardBar: View { + let controller: SessionController + @Binding var isExpanded: Bool + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 12) { + Button { + withAnimation { isExpanded.toggle() } + } label: { + Label("Function keys", + systemImage: isExpanded ? "chevron.down" : "chevron.up") + .labelStyle(.iconOnly) + } + Button("Esc") { controller.sendEscape() } + Button("Tab") { controller.sendTab() } + Button("⏎") { controller.sendReturn() } + Button(action: { controller.sendBackspace() }) { + Image(systemName: "delete.left") + } + Spacer() + arrowButtons + } + .buttonStyle(.bordered) + .controlSize(.small) + if isExpanded { + HStack { + ForEach(1...12, id: \.self) { idx in + Button("F\(idx)") { + controller.sendFunctionKey(idx) + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private var arrowButtons: some View { + HStack(spacing: 4) { + Button(action: { controller.sendArrow(.left) }) { + Image(systemName: "arrow.left") + } + VStack(spacing: 2) { + Button(action: { controller.sendArrow(.up) }) { + Image(systemName: "arrow.up") + } + Button(action: { controller.sendArrow(.down) }) { + Image(systemName: "arrow.down") + } + } + Button(action: { controller.sendArrow(.right) }) { + Image(systemName: "arrow.right") + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } +} diff --git a/Packages/VNCUI/Sources/VNCUI/Session/TrackpadCursorOverlay.swift b/Packages/VNCUI/Sources/VNCUI/Session/TrackpadCursorOverlay.swift new file mode 100644 index 0000000..6bd3710 --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/Session/TrackpadCursorOverlay.swift @@ -0,0 +1,29 @@ +import SwiftUI +import VNCCore + +struct TrackpadCursorOverlay: View { + let normalizedPosition: CGPoint + let framebufferSize: CGSize + let isVisible: Bool + + var body: some View { + GeometryReader { proxy in + let mapper = InputMapper() + if isVisible, + let displayed = mapper.displayedRect(for: framebufferSize, in: proxy.size), + displayed.width > 0 && displayed.height > 0 { + let x = displayed.origin.x + normalizedPosition.x * displayed.width + let y = displayed.origin.y + normalizedPosition.y * displayed.height + Image(systemName: "cursorarrow") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(.white, .black) + .shadow(radius: 1) + .position(x: x, y: y) + .allowsHitTesting(false) + .accessibilityHidden(true) + .transition(.opacity) + } + } + .ignoresSafeArea() + } +} diff --git a/Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift b/Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift index 5757a11..8eca557 100644 --- a/Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift +++ b/Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift @@ -2,8 +2,12 @@ import SwiftUI import VNCCore public struct SettingsView: View { - @AppStorage("clipboardSyncEnabled") private var clipboardSync = true + @Environment(\.dismiss) private var dismiss + + @AppStorage("clipboardSyncEnabled") private var clipboardSyncDefault = true @AppStorage("defaultInputMode") private var defaultInputModeRaw = "touch" + @AppStorage("autoReconnectEnabled") private var autoReconnect = true + @AppStorage("reduceMotionInSession") private var reduceMotion = false public init() {} @@ -16,14 +20,41 @@ public struct SettingsView: View { Text("Trackpad").tag("trackpad") } } + Section("Connection") { + Toggle("Auto-reconnect on drop", isOn: $autoReconnect) + } Section("Privacy") { - Toggle("Sync clipboard with remote", isOn: $clipboardSync) + Toggle("Sync clipboard with remote (default)", isOn: $clipboardSyncDefault) + Text("Each connection can override this; secrets never sync to iCloud.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Display") { + Toggle("Reduce motion in session", isOn: $reduceMotion) } Section("About") { - LabeledContent("Version", value: "0.1 (Phase 0)") + LabeledContent("Version", value: Self.shortVersion) + LabeledContent("Build", value: Self.buildNumber) + Link("Privacy policy", destination: URL(string: "https://example.com/privacy")!) } } .navigationTitle("Settings") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } } } + + private static var shortVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0" + } + + private static var buildNumber: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" + } } diff --git a/Packages/VNCUI/Tests/VNCUITests/InputMapperTests.swift b/Packages/VNCUI/Tests/VNCUITests/InputMapperTests.swift index 28eabfa..4eb0bca 100644 --- a/Packages/VNCUI/Tests/VNCUITests/InputMapperTests.swift +++ b/Packages/VNCUI/Tests/VNCUITests/InputMapperTests.swift @@ -3,13 +3,53 @@ import Testing import CoreGraphics @Suite struct InputMapperTests { - @Test @MainActor func tapInMiddleMapsToFramebufferCenter() { + @Test func centerOfViewMapsToCenterOfFramebuffer() { let mapper = InputMapper() let fb = CGSize(width: 1920, height: 1080) let view = CGSize(width: 192, height: 108) - let event = mapper.pointerFromTap(at: CGPoint(x: 96, y: 54), in: fb, viewBounds: view) - #expect(event.location.x == 960) - #expect(event.location.y == 540) - #expect(event.buttonMask == 0b1) + let normalized = mapper.normalize(viewPoint: CGPoint(x: 96, y: 54), + in: view, + framebufferSize: fb) + #expect(normalized != nil) + #expect(abs((normalized?.x ?? 0) - 0.5) < 0.001) + #expect(abs((normalized?.y ?? 0) - 0.5) < 0.001) + } + + @Test func aspectFitLetterboxesTallerView() { + let mapper = InputMapper() + let fb = CGSize(width: 1600, height: 900) // 16:9 + let view = CGSize(width: 800, height: 800) // 1:1 + let displayed = mapper.displayedRect(for: fb, in: view) + #expect(displayed != nil) + #expect(abs((displayed?.width ?? 0) - 800) < 0.001) + #expect(abs((displayed?.height ?? 0) - 450) < 0.001) + #expect(abs((displayed?.origin.y ?? 0) - 175) < 0.001) + } + + @Test func aspectFitPillarboxesWiderView() { + let mapper = InputMapper() + let fb = CGSize(width: 800, height: 800) + let view = CGSize(width: 1600, height: 900) + let displayed = mapper.displayedRect(for: fb, in: view) + #expect(displayed != nil) + #expect(abs((displayed?.height ?? 0) - 900) < 0.001) + #expect(abs((displayed?.width ?? 0) - 900) < 0.001) + #expect(abs((displayed?.origin.x ?? 0) - 350) < 0.001) + } + + @Test func roundTripNormalizationIsStable() { + let mapper = InputMapper() + let fb = CGSize(width: 1920, height: 1080) + let view = CGSize(width: 800, height: 600) + let target = CGPoint(x: 0.25, y: 0.75) + let viewPoint = mapper.viewPoint(forNormalized: target, + in: view, + framebufferSize: fb) + let normalized = mapper.normalize(viewPoint: viewPoint ?? .zero, + in: view, + framebufferSize: fb) + #expect(normalized != nil) + #expect(abs((normalized?.x ?? 0) - target.x) < 0.001) + #expect(abs((normalized?.y ?? 0) - target.y) < 0.001) } } diff --git a/Project.yml b/Project.yml index 2105694..1e38cec 100644 --- a/Project.yml +++ b/Project.yml @@ -25,6 +25,11 @@ targets: deploymentTarget: "18.0" sources: - path: Screens + excludes: + - Resources/Info.plist + - path: Screens/Resources/PrivacyInfo.xcprivacy + type: file + buildPhase: resources settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.example.screens @@ -32,6 +37,7 @@ targets: TARGETED_DEVICE_FAMILY: "1,2" GENERATE_INFOPLIST_FILE: NO INFOPLIST_FILE: Screens/Resources/Info.plist + SWIFT_EMIT_LOC_STRINGS: YES dependencies: - package: VNCCore product: VNCCore diff --git a/Screens/App/VNCApp.swift b/Screens/App/VNCApp.swift index 4f2188a..5ce272b 100644 --- a/Screens/App/VNCApp.swift +++ b/Screens/App/VNCApp.swift @@ -1,17 +1,64 @@ import SwiftUI import SwiftData import VNCCore +import VNCUI @main struct VNCApp: App { @State private var appState = AppStateController() + private let sharedContainer: ModelContainer = { + let schema = Schema([SavedConnection.self]) + let cloudKitConfiguration = ModelConfiguration( + "CloudConnections", + schema: schema, + cloudKitDatabase: .automatic + ) + if let container = try? ModelContainer(for: schema, + configurations: [cloudKitConfiguration]) { + return container + } + let local = ModelConfiguration( + "LocalConnections", + schema: schema, + cloudKitDatabase: .none + ) + return (try? ModelContainer(for: schema, configurations: [local])) + ?? (try! ModelContainer(for: SavedConnection.self)) + }() + var body: some Scene { WindowGroup { RootView() .environment(appState) .task { await appState.initialize() } } - .modelContainer(for: SavedConnection.self) + .modelContainer(sharedContainer) + + WindowGroup("Session", for: UUID.self) { $connectionID in + DetachedSessionWindow(connectionID: connectionID) + } + .modelContainer(sharedContainer) + } +} + +private struct DetachedSessionWindow: View { + let connectionID: UUID? + @Environment(\.modelContext) private var modelContext + @Query private var connections: [SavedConnection] + + var body: some View { + if let id = connectionID, + let match = connections.first(where: { $0.id == id }) { + NavigationStack { + SessionView(connection: match) + } + } else { + ContentUnavailableView( + "Connection unavailable", + systemImage: "questionmark.app", + description: Text("This window's connection is no longer available.") + ) + } } } diff --git a/Screens/Resources/Info.plist b/Screens/Resources/Info.plist index c9b9c63..4a8d7e3 100644 --- a/Screens/Resources/Info.plist +++ b/Screens/Resources/Info.plist @@ -17,13 +17,17 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1 + 0.4 CFBundleVersion - 1 + 4 LSRequiresIPhoneOS NSLocalNetworkUsageDescription Discover computers on your network that you can control remotely. + NSPasteboardUsageDescription + Sync the clipboard between this device and the remote computer when you opt in. + NSCameraUsageDescription + Optionally capture a frame of the remote screen. NSBonjourServices _rfb._tcp @@ -34,6 +38,8 @@ UIApplicationSupportsMultipleScenes + UISupportsDocumentBrowser + UIRequiredDeviceCapabilities arm64 @@ -51,5 +57,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIBackgroundModes + diff --git a/Screens/Resources/PrivacyInfo.xcprivacy b/Screens/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..ad4c428 --- /dev/null +++ b/Screens/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,39 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + +