Phases 1-4: full VNC client implementation
- SessionController wraps RoyalVNCKit.VNCConnection via nonisolated delegate adapter that bridges callbacks to @MainActor; Keychain-resolved passwords; reconnect with jittered exponential backoff; NWPathMonitor adaptive-quality hook; framebuffer rendered to CALayer.contents from didUpdateFramebuffer. - Touch + trackpad input modes with floating soft cursor overlay; hardware keyboard via pressesBegan/Ended → X11 keysyms; UIPointerInteraction with hidden cursor for indirect pointers; pinch-to-zoom; Apple Pencil as direct touch; two-finger pan / indirect scroll wheel events. - Bidirectional clipboard sync (per-connection opt-in); multi-monitor screen picker with input remapping; screenshot capture → share sheet; on-disconnect reconnect/close prompt; view-only and curtain-mode persisted. - iPad multi-window via WindowGroup(for: UUID.self) + context-menu open; CloudKit-backed ModelContainer with local fallback; PrivacyInfo.xcprivacy. 10 VNCCore tests + 4 VNCUI tests pass; iPhone and iPad simulator builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,26 +1,25 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class ClipboardBridge {
|
||||
public var isEnabled: Bool = true
|
||||
public var isEnabled: Bool
|
||||
|
||||
public init() {}
|
||||
public init(isEnabled: Bool = true) {
|
||||
self.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
public func readLocal() -> String? {
|
||||
#if canImport(UIKit)
|
||||
return UIPasteboard.general.string
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
guard isEnabled else { return nil }
|
||||
return ClipboardSink.read()
|
||||
}
|
||||
|
||||
public func writeLocal(_ string: String) {
|
||||
public func writeLocal(_ text: String) {
|
||||
guard isEnabled else { return }
|
||||
#if canImport(UIKit)
|
||||
UIPasteboard.general.string = string
|
||||
#endif
|
||||
ClipboardSink.set(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,64 @@ import Network
|
||||
import Observation
|
||||
|
||||
public struct DiscoveredHost: Identifiable, Hashable, Sendable {
|
||||
public let id: String // stable identifier (name + type)
|
||||
public let id: String
|
||||
public let displayName: String
|
||||
public let serviceType: String
|
||||
public let host: String?
|
||||
public let port: Int?
|
||||
public let serviceType: String
|
||||
fileprivate let endpoint: BonjourEndpointReference?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
displayName: String,
|
||||
serviceType: String,
|
||||
host: String? = nil,
|
||||
port: Int? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.displayName = displayName
|
||||
self.serviceType = serviceType
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.endpoint = nil
|
||||
}
|
||||
|
||||
fileprivate init(
|
||||
id: String,
|
||||
displayName: String,
|
||||
serviceType: String,
|
||||
endpoint: BonjourEndpointReference?
|
||||
) {
|
||||
self.id = id
|
||||
self.displayName = displayName
|
||||
self.serviceType = serviceType
|
||||
self.host = nil
|
||||
self.port = nil
|
||||
self.endpoint = endpoint
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct BonjourEndpointReference: Hashable, Sendable {
|
||||
let endpoint: NWEndpoint
|
||||
|
||||
static func == (lhs: BonjourEndpointReference, rhs: BonjourEndpointReference) -> Bool {
|
||||
lhs.endpoint.debugDescription == rhs.endpoint.debugDescription
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(endpoint.debugDescription)
|
||||
}
|
||||
}
|
||||
|
||||
public struct ResolvedHost: Sendable, Hashable {
|
||||
public let host: String
|
||||
public let port: Int
|
||||
}
|
||||
|
||||
public enum DiscoveryError: Error, Sendable {
|
||||
case unresolvable
|
||||
case timedOut
|
||||
case unsupported
|
||||
}
|
||||
|
||||
@Observable
|
||||
@@ -25,12 +78,13 @@ public final class DiscoveryService {
|
||||
isBrowsing = true
|
||||
hosts = []
|
||||
|
||||
for type in ["_rfb._tcp", "_workstation._tcp"] {
|
||||
for type in DiscoveryService.serviceTypes {
|
||||
let descriptor = NWBrowser.Descriptor.bonjour(type: type, domain: nil)
|
||||
let browser = NWBrowser(for: descriptor, using: .tcp)
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
let snapshot = results
|
||||
Task { @MainActor in
|
||||
self?.merge(results: results, serviceType: type)
|
||||
self?.merge(results: snapshot, serviceType: type)
|
||||
}
|
||||
}
|
||||
browser.start(queue: .main)
|
||||
@@ -44,19 +98,100 @@ public final class DiscoveryService {
|
||||
isBrowsing = false
|
||||
}
|
||||
|
||||
public func resolve(_ host: DiscoveredHost) async throws -> ResolvedHost {
|
||||
guard let reference = host.endpoint else {
|
||||
if let h = host.host, let p = host.port {
|
||||
return ResolvedHost(host: h, port: p)
|
||||
}
|
||||
throw DiscoveryError.unsupported
|
||||
}
|
||||
return try await Self.resolve(endpoint: reference.endpoint,
|
||||
defaultPort: host.serviceType == "_rfb._tcp" ? 5900 : 5900)
|
||||
}
|
||||
|
||||
nonisolated static let serviceTypes = ["_rfb._tcp", "_workstation._tcp"]
|
||||
|
||||
private func merge(results: Set<NWBrowser.Result>, serviceType: String) {
|
||||
let newHosts = results.compactMap { result -> DiscoveredHost? in
|
||||
guard case let .service(name, type, _, _) = result.endpoint else { return nil }
|
||||
let id = "\(type)\(name)"
|
||||
return DiscoveredHost(
|
||||
id: "\(type)\(name)",
|
||||
id: id,
|
||||
displayName: name,
|
||||
host: nil, // resolved at connect time
|
||||
port: nil,
|
||||
serviceType: serviceType
|
||||
serviceType: serviceType,
|
||||
endpoint: BonjourEndpointReference(endpoint: result.endpoint)
|
||||
)
|
||||
}
|
||||
var merged = hosts.filter { $0.serviceType != serviceType }
|
||||
merged.append(contentsOf: newHosts)
|
||||
hosts = merged.sorted { $0.displayName < $1.displayName }
|
||||
}
|
||||
|
||||
nonisolated private static func resolve(endpoint: NWEndpoint,
|
||||
defaultPort: Int) async throws -> ResolvedHost {
|
||||
let parameters = NWParameters.tcp
|
||||
let connection = NWConnection(to: endpoint, using: parameters)
|
||||
let resumeBox = ResumeBox()
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
if let endpoint = connection.currentPath?.remoteEndpoint,
|
||||
let resolvedHost = Self.parseEndpoint(endpoint, defaultPort: defaultPort) {
|
||||
if resumeBox.tryClaim() {
|
||||
connection.cancel()
|
||||
continuation.resume(returning: resolvedHost)
|
||||
}
|
||||
} else {
|
||||
if resumeBox.tryClaim() {
|
||||
connection.cancel()
|
||||
continuation.resume(throwing: DiscoveryError.unresolvable)
|
||||
}
|
||||
}
|
||||
case .failed(let error):
|
||||
if resumeBox.tryClaim() {
|
||||
connection.cancel()
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
case .cancelled:
|
||||
if resumeBox.tryClaim() {
|
||||
continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
|
||||
private final class ResumeBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var resumed = false
|
||||
func tryClaim() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !resumed else { return false }
|
||||
resumed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func parseEndpoint(_ endpoint: NWEndpoint,
|
||||
defaultPort: Int) -> ResolvedHost? {
|
||||
switch endpoint {
|
||||
case .hostPort(let host, let port):
|
||||
let hostString: String
|
||||
switch host {
|
||||
case .name(let name, _): hostString = name
|
||||
case .ipv4(let addr): hostString = "\(addr)"
|
||||
case .ipv6(let addr): hostString = "\(addr)"
|
||||
@unknown default: return nil
|
||||
}
|
||||
return ResolvedHost(host: hostString, port: Int(port.rawValue))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
public struct NetworkPathSnapshot: Sendable, Equatable {
|
||||
public enum LinkType: Sendable, Hashable {
|
||||
case wifi, cellular, wired, loopback, other, unavailable
|
||||
}
|
||||
|
||||
public let isAvailable: Bool
|
||||
public let isExpensive: Bool
|
||||
public let isConstrained: Bool
|
||||
public let link: LinkType
|
||||
|
||||
public init(isAvailable: Bool,
|
||||
isExpensive: Bool,
|
||||
isConstrained: Bool,
|
||||
link: LinkType) {
|
||||
self.isAvailable = isAvailable
|
||||
self.isExpensive = isExpensive
|
||||
self.isConstrained = isConstrained
|
||||
self.link = link
|
||||
}
|
||||
|
||||
public static let unknown = NetworkPathSnapshot(
|
||||
isAvailable: true,
|
||||
isExpensive: false,
|
||||
isConstrained: false,
|
||||
link: .other
|
||||
)
|
||||
}
|
||||
|
||||
public protocol NetworkPathProviding: Sendable {
|
||||
var pathChanges: AsyncStream<NetworkPathSnapshot> { get }
|
||||
}
|
||||
|
||||
public final class NWPathObserver: NetworkPathProviding, @unchecked Sendable {
|
||||
private let monitor: NWPathMonitor
|
||||
private let queue = DispatchQueue(label: "com.screens.vnc.path")
|
||||
|
||||
public init() {
|
||||
self.monitor = NWPathMonitor()
|
||||
}
|
||||
|
||||
public var pathChanges: AsyncStream<NetworkPathSnapshot> {
|
||||
AsyncStream { continuation in
|
||||
monitor.pathUpdateHandler = { path in
|
||||
continuation.yield(Self.snapshot(for: path))
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
continuation.onTermination = { [monitor] _ in
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func snapshot(for path: NWPath) -> NetworkPathSnapshot {
|
||||
let link: NetworkPathSnapshot.LinkType
|
||||
switch path.status {
|
||||
case .satisfied:
|
||||
if path.usesInterfaceType(.wifi) {
|
||||
link = .wifi
|
||||
} else if path.usesInterfaceType(.cellular) {
|
||||
link = .cellular
|
||||
} else if path.usesInterfaceType(.wiredEthernet) {
|
||||
link = .wired
|
||||
} else if path.usesInterfaceType(.loopback) {
|
||||
link = .loopback
|
||||
} else {
|
||||
link = .other
|
||||
}
|
||||
default:
|
||||
link = .unavailable
|
||||
}
|
||||
return NetworkPathSnapshot(
|
||||
isAvailable: path.status == .satisfied,
|
||||
isExpensive: path.isExpensive,
|
||||
isConstrained: path.isConstrained,
|
||||
link: link
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct StaticPathProvider: NetworkPathProviding {
|
||||
private let snapshot: NetworkPathSnapshot
|
||||
|
||||
public init(_ snapshot: NetworkPathSnapshot = .unknown) {
|
||||
self.snapshot = snapshot
|
||||
}
|
||||
|
||||
public var pathChanges: AsyncStream<NetworkPathSnapshot> {
|
||||
let snapshot = self.snapshot
|
||||
return AsyncStream { continuation in
|
||||
continuation.yield(snapshot)
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
public protocol PasswordProviding: Sendable {
|
||||
func password(for keychainTag: String) -> String?
|
||||
}
|
||||
|
||||
public struct DefaultPasswordProvider: PasswordProviding {
|
||||
private let keychain: any KeychainServicing
|
||||
|
||||
public init(keychain: any KeychainServicing = KeychainService()) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
public func password(for keychainTag: String) -> String? {
|
||||
try? keychain.loadPassword(account: keychainTag)
|
||||
}
|
||||
}
|
||||
|
||||
public struct StaticPasswordProvider: PasswordProviding {
|
||||
private let password: String
|
||||
|
||||
public init(password: String) {
|
||||
self.password = password
|
||||
}
|
||||
|
||||
public func password(for keychainTag: String) -> String? {
|
||||
password
|
||||
}
|
||||
}
|
||||
14
Packages/VNCCore/Sources/VNCCore/Session/PointerButton.swift
Normal file
14
Packages/VNCCore/Sources/VNCCore/Session/PointerButton.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
public enum PointerButton: Sendable, Hashable, CaseIterable {
|
||||
case left
|
||||
case middle
|
||||
case right
|
||||
}
|
||||
|
||||
public enum ScrollDirection: Sendable, Hashable, CaseIterable {
|
||||
case up
|
||||
case down
|
||||
case left
|
||||
case right
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
public struct ReconnectPolicy: Sendable {
|
||||
public let maxAttempts: Int
|
||||
public let baseDelaySeconds: Double
|
||||
public let maxDelaySeconds: Double
|
||||
public let jitterFraction: Double
|
||||
|
||||
public init(
|
||||
maxAttempts: Int = 6,
|
||||
baseDelaySeconds: Double = 1.0,
|
||||
maxDelaySeconds: Double = 30.0,
|
||||
jitterFraction: Double = 0.25
|
||||
) {
|
||||
self.maxAttempts = maxAttempts
|
||||
self.baseDelaySeconds = baseDelaySeconds
|
||||
self.maxDelaySeconds = maxDelaySeconds
|
||||
self.jitterFraction = jitterFraction
|
||||
}
|
||||
|
||||
public static let `default` = ReconnectPolicy()
|
||||
public static let none = ReconnectPolicy(maxAttempts: 0)
|
||||
|
||||
public func shouldReconnect(for reason: DisconnectReason) -> Bool {
|
||||
guard maxAttempts > 0 else { return false }
|
||||
switch reason {
|
||||
case .userRequested, .authenticationFailed:
|
||||
return false
|
||||
case .networkError, .protocolError, .remoteClosed:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public func delay(for attempt: Int) -> Double? {
|
||||
guard attempt > 0, attempt <= maxAttempts else { return nil }
|
||||
let exponential = baseDelaySeconds * pow(2.0, Double(attempt - 1))
|
||||
let capped = min(exponential, maxDelaySeconds)
|
||||
let jitter = capped * jitterFraction
|
||||
let lower = max(0, capped - jitter)
|
||||
let upper = capped + jitter
|
||||
return Double.random(in: lower...upper)
|
||||
}
|
||||
}
|
||||
12
Packages/VNCCore/Sources/VNCCore/Session/RemoteScreen.swift
Normal file
12
Packages/VNCCore/Sources/VNCCore/Session/RemoteScreen.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
public struct RemoteScreen: Identifiable, Hashable, Sendable {
|
||||
public let id: UInt32
|
||||
public let frame: CGRect
|
||||
|
||||
public init(id: UInt32, frame: CGRect) {
|
||||
self.id = id
|
||||
self.frame = frame
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct SendableValueBox<Value>: @unchecked Sendable {
|
||||
let value: Value
|
||||
init(_ value: Value) { self.value = value }
|
||||
}
|
||||
|
||||
enum ClipboardSink {
|
||||
@MainActor
|
||||
static func set(_ text: String) {
|
||||
#if canImport(UIKit)
|
||||
UIPasteboard.general.string = text
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func read() -> String? {
|
||||
#if canImport(UIKit)
|
||||
guard UIPasteboard.general.hasStrings else { return nil }
|
||||
return UIPasteboard.general.string
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,500 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import CoreGraphics
|
||||
import RoyalVNCKit
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class SessionController {
|
||||
public private(set) var state: SessionState = .idle
|
||||
public private(set) var lastError: Error?
|
||||
public private(set) var lastErrorMessage: String?
|
||||
public private(set) var framebufferSize: FramebufferSize?
|
||||
public private(set) var currentImage: CGImage?
|
||||
public private(set) var imageRevision: Int = 0
|
||||
public private(set) var lastUpdatedRegion: CGRect?
|
||||
public private(set) var cursorImage: CGImage?
|
||||
public private(set) var cursorHotspot: CGPoint = .zero
|
||||
public private(set) var screens: [RemoteScreen] = []
|
||||
public private(set) var desktopName: String?
|
||||
public private(set) var isReconnecting: Bool = false
|
||||
public private(set) var reconnectAttempt: Int = 0
|
||||
|
||||
private let transport: any Transport
|
||||
private var runTask: Task<Void, Never>?
|
||||
public var viewOnly: Bool
|
||||
public var quality: QualityPreset {
|
||||
didSet { applyQuality() }
|
||||
}
|
||||
public let clipboardSyncEnabled: Bool
|
||||
|
||||
public init(transport: any Transport) {
|
||||
self.transport = transport
|
||||
public let displayName: String
|
||||
public let host: String
|
||||
public let port: Int
|
||||
|
||||
private let keychainTag: String
|
||||
private let passwordProvider: any PasswordProviding
|
||||
private let preferredEncodings: [VNCFrameEncodingType]
|
||||
private let pathProvider: any NetworkPathProviding
|
||||
private let reconnectPolicy: ReconnectPolicy
|
||||
|
||||
private var connection: VNCConnection?
|
||||
private var delegateAdapter: DelegateAdapter?
|
||||
private var redrawScheduled = false
|
||||
private var pendingDirtyRect: CGRect?
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var explicitlyStopped = false
|
||||
private var pathObserver: Task<Void, Never>?
|
||||
|
||||
public init(
|
||||
displayName: String,
|
||||
host: String,
|
||||
port: Int,
|
||||
keychainTag: String,
|
||||
viewOnly: Bool = false,
|
||||
clipboardSyncEnabled: Bool = true,
|
||||
quality: QualityPreset = .adaptive,
|
||||
preferredEncodings: [VNCFrameEncodingType] = .default,
|
||||
passwordProvider: any PasswordProviding = DefaultPasswordProvider(),
|
||||
pathProvider: any NetworkPathProviding = NWPathObserver(),
|
||||
reconnectPolicy: ReconnectPolicy = .default
|
||||
) {
|
||||
self.displayName = displayName
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.keychainTag = keychainTag
|
||||
self.viewOnly = viewOnly
|
||||
self.clipboardSyncEnabled = clipboardSyncEnabled
|
||||
self.quality = quality
|
||||
self.preferredEncodings = preferredEncodings
|
||||
self.passwordProvider = passwordProvider
|
||||
self.pathProvider = pathProvider
|
||||
self.reconnectPolicy = reconnectPolicy
|
||||
}
|
||||
|
||||
public convenience init(
|
||||
connection saved: SavedConnection,
|
||||
passwordProvider: any PasswordProviding = DefaultPasswordProvider()
|
||||
) {
|
||||
let decoded = [VNCFrameEncodingType].decode(saved.preferredEncodings)
|
||||
self.init(
|
||||
displayName: saved.displayName,
|
||||
host: saved.host,
|
||||
port: saved.port,
|
||||
keychainTag: saved.keychainTag,
|
||||
viewOnly: saved.viewOnly,
|
||||
clipboardSyncEnabled: saved.clipboardSyncEnabled,
|
||||
quality: saved.quality,
|
||||
preferredEncodings: decoded.isEmpty ? .default : decoded,
|
||||
passwordProvider: passwordProvider
|
||||
)
|
||||
}
|
||||
|
||||
public func start() {
|
||||
guard case .idle = state else { return }
|
||||
state = .connecting
|
||||
runTask = Task { [weak self] in
|
||||
await self?.run()
|
||||
explicitlyStopped = false
|
||||
reconnectAttempt = 0
|
||||
beginConnect()
|
||||
startObservingPath()
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
explicitlyStopped = true
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
pathObserver?.cancel()
|
||||
pathObserver = nil
|
||||
connection?.disconnect()
|
||||
}
|
||||
|
||||
public func reconnectNow() {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
connection?.disconnect()
|
||||
beginConnect()
|
||||
}
|
||||
|
||||
// MARK: Pointer
|
||||
|
||||
public func pointerMove(toNormalized point: CGPoint) {
|
||||
guard let pos = framebufferPoint(for: point) else { return }
|
||||
connection?.mouseMove(x: pos.x, y: pos.y)
|
||||
}
|
||||
|
||||
public func pointerDown(_ button: PointerButton, atNormalized point: CGPoint) {
|
||||
guard !viewOnly, let pos = framebufferPoint(for: point) else { return }
|
||||
connection?.mouseButtonDown(button.vnc, x: pos.x, y: pos.y)
|
||||
}
|
||||
|
||||
public func pointerUp(_ button: PointerButton, atNormalized point: CGPoint) {
|
||||
guard !viewOnly, let pos = framebufferPoint(for: point) else { return }
|
||||
connection?.mouseButtonUp(button.vnc, x: pos.x, y: pos.y)
|
||||
}
|
||||
|
||||
public func pointerClick(_ button: PointerButton, atNormalized point: CGPoint) {
|
||||
guard !viewOnly, let pos = framebufferPoint(for: point) else { return }
|
||||
connection?.mouseMove(x: pos.x, y: pos.y)
|
||||
connection?.mouseButtonDown(button.vnc, x: pos.x, y: pos.y)
|
||||
connection?.mouseButtonUp(button.vnc, x: pos.x, y: pos.y)
|
||||
}
|
||||
|
||||
public func pointerScroll(_ direction: ScrollDirection,
|
||||
steps: UInt32,
|
||||
atNormalized point: CGPoint) {
|
||||
guard !viewOnly, steps > 0, let pos = framebufferPoint(for: point) else { return }
|
||||
connection?.mouseWheel(direction.vnc, x: pos.x, y: pos.y, steps: steps)
|
||||
}
|
||||
|
||||
// MARK: Keyboard
|
||||
|
||||
public func keyDown(keysym: UInt32) {
|
||||
guard !viewOnly else { return }
|
||||
connection?.keyDown(VNCKeyCode(keysym))
|
||||
}
|
||||
|
||||
public func keyUp(keysym: UInt32) {
|
||||
guard !viewOnly else { return }
|
||||
connection?.keyUp(VNCKeyCode(keysym))
|
||||
}
|
||||
|
||||
public func type(_ string: String) {
|
||||
guard !viewOnly else { return }
|
||||
for char in string {
|
||||
if char.isNewline {
|
||||
pressKey(.return)
|
||||
continue
|
||||
}
|
||||
for code in VNCKeyCode.withCharacter(char) {
|
||||
connection?.keyDown(code)
|
||||
connection?.keyUp(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() async {
|
||||
runTask?.cancel()
|
||||
await transport.disconnect()
|
||||
state = .disconnected(reason: .userRequested)
|
||||
public func sendBackspace() { pressKey(.delete) }
|
||||
public func sendReturn() { pressKey(.return) }
|
||||
public func sendEscape() { pressKey(.escape) }
|
||||
public func sendTab() { pressKey(.tab) }
|
||||
public func sendArrow(_ direction: ScrollDirection) {
|
||||
switch direction {
|
||||
case .up: pressKey(.upArrow)
|
||||
case .down: pressKey(.downArrow)
|
||||
case .left: pressKey(.leftArrow)
|
||||
case .right: pressKey(.rightArrow)
|
||||
}
|
||||
}
|
||||
public func sendFunctionKey(_ index: Int) {
|
||||
guard let code = VNCKeyCode.functionKey(index) else { return }
|
||||
pressKey(code)
|
||||
}
|
||||
|
||||
private func run() async {
|
||||
do {
|
||||
try await transport.connect()
|
||||
state = .authenticating
|
||||
// Phase 1 will plug RoyalVNCKit.VNCConnection here and drive its
|
||||
// state machine from the transport byte stream.
|
||||
} catch {
|
||||
lastError = error
|
||||
state = .disconnected(reason: .networkError(String(describing: error)))
|
||||
public func pressKeyCombo(_ keys: [VNCKeyCode]) {
|
||||
guard !viewOnly, let connection else { return }
|
||||
for code in keys { connection.keyDown(code) }
|
||||
for code in keys.reversed() { connection.keyUp(code) }
|
||||
}
|
||||
|
||||
private func pressKey(_ code: VNCKeyCode) {
|
||||
guard !viewOnly else { return }
|
||||
connection?.keyDown(code)
|
||||
connection?.keyUp(code)
|
||||
}
|
||||
|
||||
// MARK: Clipboard
|
||||
|
||||
public func pushLocalClipboardNow(_ text: String) {
|
||||
ClipboardSink.set(text)
|
||||
}
|
||||
|
||||
// MARK: Internal handlers (invoked on MainActor by adapter)
|
||||
|
||||
func handleConnectionState(status: VNCConnection.Status, errorMessage: String?) {
|
||||
switch status {
|
||||
case .connecting:
|
||||
state = .connecting
|
||||
case .connected:
|
||||
isReconnecting = false
|
||||
reconnectAttempt = 0
|
||||
if let size = framebufferSize {
|
||||
state = .connected(framebufferSize: size)
|
||||
} else {
|
||||
state = .authenticating
|
||||
}
|
||||
case .disconnecting:
|
||||
break
|
||||
case .disconnected:
|
||||
let reason: DisconnectReason
|
||||
if let errorMessage {
|
||||
if errorMessage.lowercased().contains("authentication") {
|
||||
reason = .authenticationFailed
|
||||
} else {
|
||||
reason = .networkError(errorMessage)
|
||||
}
|
||||
lastErrorMessage = errorMessage
|
||||
} else {
|
||||
reason = explicitlyStopped ? .userRequested : .remoteClosed
|
||||
}
|
||||
state = .disconnected(reason: reason)
|
||||
connection?.delegate = nil
|
||||
connection = nil
|
||||
delegateAdapter = nil
|
||||
if !explicitlyStopped, reconnectPolicy.shouldReconnect(for: reason) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleFramebufferCreated(size: FramebufferSize, screens: [RemoteScreen]) {
|
||||
framebufferSize = size
|
||||
self.screens = screens
|
||||
state = .connected(framebufferSize: size)
|
||||
applyQuality()
|
||||
}
|
||||
|
||||
func handleFramebufferResized(size: FramebufferSize, screens: [RemoteScreen]) {
|
||||
framebufferSize = size
|
||||
self.screens = screens
|
||||
state = .connected(framebufferSize: size)
|
||||
}
|
||||
|
||||
func handleFramebufferUpdated(region: CGRect) {
|
||||
if let existing = pendingDirtyRect {
|
||||
pendingDirtyRect = existing.union(region)
|
||||
} else {
|
||||
pendingDirtyRect = region
|
||||
}
|
||||
guard !redrawScheduled else { return }
|
||||
redrawScheduled = true
|
||||
Task { @MainActor in
|
||||
self.redrawScheduled = false
|
||||
self.refreshImage()
|
||||
}
|
||||
}
|
||||
|
||||
func handleCursorUpdate(image: CGImage?, hotspot: CGPoint) {
|
||||
cursorImage = image
|
||||
cursorHotspot = hotspot
|
||||
}
|
||||
|
||||
func refreshImage() {
|
||||
guard let connection, let fb = connection.framebuffer else { return }
|
||||
currentImage = fb.cgImage
|
||||
lastUpdatedRegion = pendingDirtyRect
|
||||
pendingDirtyRect = nil
|
||||
imageRevision &+= 1
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private func beginConnect() {
|
||||
if connection != nil { return }
|
||||
state = .connecting
|
||||
lastErrorMessage = nil
|
||||
let settings = VNCConnection.Settings(
|
||||
isDebugLoggingEnabled: false,
|
||||
hostname: host,
|
||||
port: UInt16(clamping: port),
|
||||
isShared: true,
|
||||
isScalingEnabled: false,
|
||||
useDisplayLink: false,
|
||||
inputMode: .none,
|
||||
isClipboardRedirectionEnabled: clipboardSyncEnabled,
|
||||
colorDepth: .depth24Bit,
|
||||
frameEncodings: preferredEncodings
|
||||
)
|
||||
let connection = VNCConnection(settings: settings)
|
||||
let adapter = DelegateAdapter(
|
||||
controller: self,
|
||||
passwordProvider: passwordProvider,
|
||||
keychainTag: keychainTag
|
||||
)
|
||||
connection.delegate = adapter
|
||||
self.connection = connection
|
||||
self.delegateAdapter = adapter
|
||||
connection.connect()
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
let attempt = reconnectAttempt + 1
|
||||
reconnectAttempt = attempt
|
||||
guard let delay = reconnectPolicy.delay(for: attempt) else {
|
||||
isReconnecting = false
|
||||
return
|
||||
}
|
||||
isReconnecting = true
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(Int(delay * 1000)))
|
||||
guard let self, !self.explicitlyStopped else { return }
|
||||
self.beginConnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func startObservingPath() {
|
||||
pathObserver?.cancel()
|
||||
let stream = pathProvider.pathChanges
|
||||
pathObserver = Task { @MainActor [weak self] in
|
||||
for await change in stream {
|
||||
guard let self else { return }
|
||||
if change.isExpensive != self.lastPathExpensive {
|
||||
self.lastPathExpensive = change.isExpensive
|
||||
self.applyQuality()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var lastPathExpensive: Bool = false
|
||||
|
||||
private func applyQuality() {
|
||||
// Phase 2 hook: with extended encodings we'd push CompressionLevel/JPEGQuality
|
||||
// pseudo-encodings here. RoyalVNCKit currently picks fixed levels internally;
|
||||
// the connection re-orders frame encodings on next handshake. For now this is
|
||||
// a no-op stub; the field is wired so QualityPreset round-trips through state.
|
||||
_ = quality
|
||||
}
|
||||
|
||||
private func framebufferPoint(for normalized: CGPoint) -> (x: UInt16, y: UInt16)? {
|
||||
guard let size = framebufferSize else { return nil }
|
||||
let nx = max(0, min(1, normalized.x))
|
||||
let ny = max(0, min(1, normalized.y))
|
||||
let x = UInt16(clamping: Int((nx * CGFloat(size.width - 1)).rounded()))
|
||||
let y = UInt16(clamping: Int((ny * CGFloat(size.height - 1)).rounded()))
|
||||
return (x, y)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegate Adapter
|
||||
|
||||
private final class DelegateAdapter: NSObject, VNCConnectionDelegate, @unchecked Sendable {
|
||||
weak var controller: SessionController?
|
||||
let passwordProvider: any PasswordProviding
|
||||
let keychainTag: String
|
||||
|
||||
init(controller: SessionController,
|
||||
passwordProvider: any PasswordProviding,
|
||||
keychainTag: String) {
|
||||
self.controller = controller
|
||||
self.passwordProvider = passwordProvider
|
||||
self.keychainTag = keychainTag
|
||||
}
|
||||
|
||||
func connection(_ connection: VNCConnection,
|
||||
stateDidChange newState: VNCConnection.ConnectionState) {
|
||||
let status = newState.status
|
||||
let errorMessage: String? = newState.error.flatMap { error in
|
||||
if let localized = error as? LocalizedError, let desc = localized.errorDescription {
|
||||
return desc
|
||||
}
|
||||
return String(describing: error)
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.controller?.handleConnectionState(status: status, errorMessage: errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func connection(_ connection: VNCConnection,
|
||||
credentialFor authenticationType: VNCAuthenticationType,
|
||||
completion: @escaping (VNCCredential?) -> Void) {
|
||||
let pwd = passwordProvider.password(for: keychainTag) ?? ""
|
||||
let credential: VNCCredential
|
||||
switch authenticationType {
|
||||
case .vnc:
|
||||
credential = VNCPasswordCredential(password: pwd)
|
||||
case .appleRemoteDesktop, .ultraVNCMSLogonII:
|
||||
credential = VNCUsernamePasswordCredential(username: "", password: pwd)
|
||||
@unknown default:
|
||||
credential = VNCPasswordCredential(password: pwd)
|
||||
}
|
||||
completion(credential)
|
||||
}
|
||||
|
||||
func connection(_ connection: VNCConnection,
|
||||
didCreateFramebuffer framebuffer: VNCFramebuffer) {
|
||||
let size = FramebufferSize(width: Int(framebuffer.size.width),
|
||||
height: Int(framebuffer.size.height))
|
||||
let screens = framebuffer.screens.map {
|
||||
RemoteScreen(id: $0.id, frame: $0.cgFrame)
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.controller?.handleFramebufferCreated(size: size, screens: screens)
|
||||
self.controller?.refreshImage()
|
||||
}
|
||||
}
|
||||
|
||||
func connection(_ connection: VNCConnection,
|
||||
didResizeFramebuffer framebuffer: VNCFramebuffer) {
|
||||
let size = FramebufferSize(width: Int(framebuffer.size.width),
|
||||
height: Int(framebuffer.size.height))
|
||||
let screens = framebuffer.screens.map {
|
||||
RemoteScreen(id: $0.id, frame: $0.cgFrame)
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.controller?.handleFramebufferResized(size: size, screens: screens)
|
||||
self.controller?.refreshImage()
|
||||
}
|
||||
}
|
||||
|
||||
func connection(_ connection: VNCConnection,
|
||||
didUpdateFramebuffer framebuffer: VNCFramebuffer,
|
||||
x: UInt16, y: UInt16,
|
||||
width: UInt16, height: UInt16) {
|
||||
let region = CGRect(x: Int(x), y: Int(y), width: Int(width), height: Int(height))
|
||||
Task { @MainActor in
|
||||
self.controller?.handleFramebufferUpdated(region: region)
|
||||
}
|
||||
}
|
||||
|
||||
func connection(_ connection: VNCConnection,
|
||||
didUpdateCursor cursor: VNCCursor) {
|
||||
let imageBox = SendableValueBox(cursor.cgImage)
|
||||
let hotspot = cursor.cgHotspot
|
||||
Task { @MainActor in
|
||||
self.controller?.handleCursorUpdate(image: imageBox.value, hotspot: hotspot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VNCKeyCode {
|
||||
static func functionKey(_ index: Int) -> VNCKeyCode? {
|
||||
switch index {
|
||||
case 1: .f1
|
||||
case 2: .f2
|
||||
case 3: .f3
|
||||
case 4: .f4
|
||||
case 5: .f5
|
||||
case 6: .f6
|
||||
case 7: .f7
|
||||
case 8: .f8
|
||||
case 9: .f9
|
||||
case 10: .f10
|
||||
case 11: .f11
|
||||
case 12: .f12
|
||||
case 13: .f13
|
||||
case 14: .f14
|
||||
case 15: .f15
|
||||
case 16: .f16
|
||||
case 17: .f17
|
||||
case 18: .f18
|
||||
case 19: .f19
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PointerButton {
|
||||
var vnc: VNCMouseButton {
|
||||
switch self {
|
||||
case .left: .left
|
||||
case .middle: .middle
|
||||
case .right: .right
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ScrollDirection {
|
||||
var vnc: VNCMouseWheel {
|
||||
switch self {
|
||||
case .up: .up
|
||||
case .down: .down
|
||||
case .left: .left
|
||||
case .right: .right
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ public final class SavedConnection {
|
||||
public var preferredEncodings: [String]
|
||||
public var keychainTag: String
|
||||
public var qualityRaw: String
|
||||
public var inputModeRaw: String
|
||||
public var viewOnly: Bool
|
||||
public var curtainMode: Bool
|
||||
public var clipboardSyncEnabled: Bool
|
||||
public var notes: String
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
@@ -21,11 +24,14 @@ public final class SavedConnection {
|
||||
host: String,
|
||||
port: Int = 5900,
|
||||
colorTag: ColorTag = .blue,
|
||||
preferredEncodings: [String] = ["tight", "zrle", "hextile", "raw"],
|
||||
preferredEncodings: [String] = ["7", "16", "5", "6"],
|
||||
keychainTag: String = UUID().uuidString,
|
||||
quality: QualityPreset = .adaptive,
|
||||
inputMode: InputModePreference = .touch,
|
||||
viewOnly: Bool = false,
|
||||
curtainMode: Bool = false
|
||||
curtainMode: Bool = false,
|
||||
clipboardSyncEnabled: Bool = true,
|
||||
notes: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.displayName = displayName
|
||||
@@ -36,8 +42,11 @@ public final class SavedConnection {
|
||||
self.preferredEncodings = preferredEncodings
|
||||
self.keychainTag = keychainTag
|
||||
self.qualityRaw = quality.rawValue
|
||||
self.inputModeRaw = inputMode.rawValue
|
||||
self.viewOnly = viewOnly
|
||||
self.curtainMode = curtainMode
|
||||
self.clipboardSyncEnabled = clipboardSyncEnabled
|
||||
self.notes = notes
|
||||
}
|
||||
|
||||
public var colorTag: ColorTag {
|
||||
@@ -49,6 +58,11 @@ public final class SavedConnection {
|
||||
get { QualityPreset(rawValue: qualityRaw) ?? .adaptive }
|
||||
set { qualityRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
public var inputMode: InputModePreference {
|
||||
get { InputModePreference(rawValue: inputModeRaw) ?? .touch }
|
||||
set { inputModeRaw = newValue.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
public enum ColorTag: String, CaseIterable, Sendable {
|
||||
@@ -58,3 +72,8 @@ public enum ColorTag: String, CaseIterable, Sendable {
|
||||
public enum QualityPreset: String, CaseIterable, Sendable {
|
||||
case adaptive, high, low
|
||||
}
|
||||
|
||||
public enum InputModePreference: String, CaseIterable, Sendable {
|
||||
case touch
|
||||
case trackpad
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
114
Packages/VNCUI/Sources/VNCUI/Session/KeyMapping.swift
Normal file
114
Packages/VNCUI/Sources/VNCUI/Session/KeyMapping.swift
Normal 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
|
||||
64
Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift
Normal file
64
Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
66
Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift
Normal file
66
Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
Screens/Resources/PrivacyInfo.xcprivacy
Normal file
39
Screens/Resources/PrivacyInfo.xcprivacy
Normal 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>
|
||||
Reference in New Issue
Block a user