Phases 1-4: full VNC client implementation

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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<UIPress>, 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<UIPress>, 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<UIPress>, 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) }
}
}
}

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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<V: View>(@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

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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"
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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.")
)
}
}
}

View File

@@ -17,13 +17,17 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1</string>
<string>0.4</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>4</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>Discover computers on your network that you can control remotely.</string>
<key>NSPasteboardUsageDescription</key>
<string>Sync the clipboard between this device and the remote computer when you opt in.</string>
<key>NSCameraUsageDescription</key>
<string>Optionally capture a frame of the remote screen.</string>
<key>NSBonjourServices</key>
<array>
<string>_rfb._tcp</string>
@@ -34,6 +38,8 @@
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UISupportsDocumentBrowser</key>
<false/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
@@ -51,5 +57,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIBackgroundModes</key>
<array/>
</dict>
</plist>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
</dict>
</plist>