Phase 0: scaffold
Two SPM packages (VNCCore, VNCUI) + thin iOS app target wired via xcodegen. Builds for iPhone 17 simulator, unit tests pass. - VNCCore: SessionState, SessionController stub, Transport protocol with DirectTransport (NWConnection), DiscoveryService (Bonjour on _rfb._tcp and _workstation._tcp), SavedConnection @Model, ConnectionStore, KeychainService, ClipboardBridge - VNCUI: ConnectionListView, AddConnectionView, SessionView, FramebufferView/FramebufferUIView (UIKit CALayer), InputMapper, SettingsView; UIKit bits guarded with #if canImport(UIKit) so swift test runs on macOS - App: @main VNCApp, AppStateController state machine, RootView - RoyalVNCKit dependency pinned to main (transitive CryptoSwift constraint blocks tagged releases) - xcodegen Project.yml + README + .gitignore Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
public final class ClipboardBridge {
|
||||
public var isEnabled: Bool = true
|
||||
|
||||
public init() {}
|
||||
|
||||
public func readLocal() -> String? {
|
||||
#if canImport(UIKit)
|
||||
return UIPasteboard.general.string
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
public func writeLocal(_ string: String) {
|
||||
guard isEnabled else { return }
|
||||
#if canImport(UIKit)
|
||||
UIPasteboard.general.string = string
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
public struct DiscoveredHost: Identifiable, Hashable, Sendable {
|
||||
public let id: String // stable identifier (name + type)
|
||||
public let displayName: String
|
||||
public let host: String?
|
||||
public let port: Int?
|
||||
public let serviceType: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class DiscoveryService {
|
||||
public private(set) var hosts: [DiscoveredHost] = []
|
||||
public private(set) var isBrowsing = false
|
||||
|
||||
private var browsers: [NWBrowser] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public func start() {
|
||||
guard !isBrowsing else { return }
|
||||
isBrowsing = true
|
||||
hosts = []
|
||||
|
||||
for type in ["_rfb._tcp", "_workstation._tcp"] {
|
||||
let descriptor = NWBrowser.Descriptor.bonjour(type: type, domain: nil)
|
||||
let browser = NWBrowser(for: descriptor, using: .tcp)
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
Task { @MainActor in
|
||||
self?.merge(results: results, serviceType: type)
|
||||
}
|
||||
}
|
||||
browser.start(queue: .main)
|
||||
browsers.append(browser)
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
browsers.forEach { $0.cancel() }
|
||||
browsers.removeAll()
|
||||
isBrowsing = false
|
||||
}
|
||||
|
||||
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 }
|
||||
return DiscoveredHost(
|
||||
id: "\(type)\(name)",
|
||||
displayName: name,
|
||||
host: nil, // resolved at connect time
|
||||
port: nil,
|
||||
serviceType: serviceType
|
||||
)
|
||||
}
|
||||
var merged = hosts.filter { $0.serviceType != serviceType }
|
||||
merged.append(contentsOf: newHosts)
|
||||
hosts = merged.sorted { $0.displayName < $1.displayName }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public protocol KeychainServicing: Sendable {
|
||||
func storePassword(_ password: String, account: String) throws
|
||||
func loadPassword(account: String) throws -> String?
|
||||
func deletePassword(account: String) throws
|
||||
}
|
||||
|
||||
public struct KeychainService: KeychainServicing {
|
||||
private let service: String
|
||||
|
||||
public init(service: String = "com.screens.vnc") {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
public func storePassword(_ password: String, account: String) throws {
|
||||
guard let data = password.data(using: .utf8) else { throw KeychainError.encoding }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var attributes = query
|
||||
attributes[kSecValueData as String] = data
|
||||
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
let status = SecItemAdd(attributes as CFDictionary, nil)
|
||||
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
|
||||
}
|
||||
|
||||
public func loadPassword(account: String) throws -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw KeychainError.unhandled(status)
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
public func deletePassword(account: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
throw KeychainError.unhandled(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum KeychainError: Error, Sendable {
|
||||
case encoding
|
||||
case unhandled(OSStatus)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class SessionController {
|
||||
public private(set) var state: SessionState = .idle
|
||||
public private(set) var lastError: Error?
|
||||
|
||||
private let transport: any Transport
|
||||
private var runTask: Task<Void, Never>?
|
||||
|
||||
public init(transport: any Transport) {
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
public func start() {
|
||||
guard case .idle = state else { return }
|
||||
state = .connecting
|
||||
runTask = Task { [weak self] in
|
||||
await self?.run()
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() async {
|
||||
runTask?.cancel()
|
||||
await transport.disconnect()
|
||||
state = .disconnected(reason: .userRequested)
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Packages/VNCCore/Sources/VNCCore/Session/SessionState.swift
Normal file
27
Packages/VNCCore/Sources/VNCCore/Session/SessionState.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
public enum SessionState: Equatable, Sendable {
|
||||
case idle
|
||||
case connecting
|
||||
case authenticating
|
||||
case connected(framebufferSize: FramebufferSize)
|
||||
case disconnected(reason: DisconnectReason)
|
||||
}
|
||||
|
||||
public struct FramebufferSize: Equatable, Sendable, Hashable {
|
||||
public let width: Int
|
||||
public let height: Int
|
||||
public init(width: Int, height: Int) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
}
|
||||
|
||||
public enum DisconnectReason: Equatable, Sendable {
|
||||
case userRequested
|
||||
case authenticationFailed
|
||||
case networkError(String)
|
||||
case protocolError(String)
|
||||
case remoteClosed
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
public final class ConnectionStore {
|
||||
private let context: ModelContext
|
||||
|
||||
public init(context: ModelContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
public func all() throws -> [SavedConnection] {
|
||||
let descriptor = FetchDescriptor<SavedConnection>(
|
||||
sortBy: [SortDescriptor(\.displayName, order: .forward)]
|
||||
)
|
||||
return try context.fetch(descriptor)
|
||||
}
|
||||
|
||||
public func insert(_ connection: SavedConnection) {
|
||||
context.insert(connection)
|
||||
}
|
||||
|
||||
public func delete(_ connection: SavedConnection) {
|
||||
context.delete(connection)
|
||||
}
|
||||
|
||||
public func save() throws {
|
||||
try context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
public final class SavedConnection {
|
||||
@Attribute(.unique) public var id: UUID
|
||||
public var displayName: String
|
||||
public var host: String
|
||||
public var port: Int
|
||||
public var colorTagRaw: String
|
||||
public var lastConnectedAt: Date?
|
||||
public var preferredEncodings: [String]
|
||||
public var keychainTag: String
|
||||
public var qualityRaw: String
|
||||
public var viewOnly: Bool
|
||||
public var curtainMode: Bool
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
displayName: String,
|
||||
host: String,
|
||||
port: Int = 5900,
|
||||
colorTag: ColorTag = .blue,
|
||||
preferredEncodings: [String] = ["tight", "zrle", "hextile", "raw"],
|
||||
keychainTag: String = UUID().uuidString,
|
||||
quality: QualityPreset = .adaptive,
|
||||
viewOnly: Bool = false,
|
||||
curtainMode: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.displayName = displayName
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.colorTagRaw = colorTag.rawValue
|
||||
self.lastConnectedAt = nil
|
||||
self.preferredEncodings = preferredEncodings
|
||||
self.keychainTag = keychainTag
|
||||
self.qualityRaw = quality.rawValue
|
||||
self.viewOnly = viewOnly
|
||||
self.curtainMode = curtainMode
|
||||
}
|
||||
|
||||
public var colorTag: ColorTag {
|
||||
get { ColorTag(rawValue: colorTagRaw) ?? .blue }
|
||||
set { colorTagRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
public var quality: QualityPreset {
|
||||
get { QualityPreset(rawValue: qualityRaw) ?? .adaptive }
|
||||
set { qualityRaw = newValue.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
public enum ColorTag: String, CaseIterable, Sendable {
|
||||
case red, orange, yellow, green, blue, purple, pink, gray
|
||||
}
|
||||
|
||||
public enum QualityPreset: String, CaseIterable, Sendable {
|
||||
case adaptive, high, low
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
public actor DirectTransport: Transport {
|
||||
private let endpoint: TransportEndpoint
|
||||
private var connection: NWConnection?
|
||||
private var receiveContinuation: AsyncThrowingStream<Data, Error>.Continuation?
|
||||
|
||||
public init(endpoint: TransportEndpoint) {
|
||||
self.endpoint = endpoint
|
||||
}
|
||||
|
||||
public func connect() async throws {
|
||||
let host = NWEndpoint.Host(endpoint.host)
|
||||
let port = NWEndpoint.Port(integerLiteral: UInt16(endpoint.port))
|
||||
let connection = NWConnection(host: host, port: port, using: .tcp)
|
||||
self.connection = connection
|
||||
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
continuation.resume()
|
||||
case .failed(let error):
|
||||
continuation.resume(throwing: error)
|
||||
case .cancelled:
|
||||
continuation.resume(throwing: CancellationError())
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
startReceiveLoop()
|
||||
}
|
||||
|
||||
public func send(_ data: Data) async throws {
|
||||
guard let connection else { throw TransportError.notConnected }
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: data, completion: .contentProcessed { error in
|
||||
if let error { continuation.resume(throwing: error) }
|
||||
else { continuation.resume() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated func receive() -> AsyncThrowingStream<Data, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
Task { await self.installContinuation(continuation) }
|
||||
}
|
||||
}
|
||||
|
||||
public func disconnect() async {
|
||||
connection?.cancel()
|
||||
connection = nil
|
||||
receiveContinuation?.finish()
|
||||
receiveContinuation = nil
|
||||
}
|
||||
|
||||
private func installContinuation(_ continuation: AsyncThrowingStream<Data, Error>.Continuation) {
|
||||
self.receiveContinuation = continuation
|
||||
}
|
||||
|
||||
private func startReceiveLoop() {
|
||||
guard let connection else { return }
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
await self.forward(data: data, isComplete: isComplete, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func forward(data: Data?, isComplete: Bool, error: NWError?) {
|
||||
if let error {
|
||||
receiveContinuation?.finish(throwing: error)
|
||||
return
|
||||
}
|
||||
if let data, !data.isEmpty {
|
||||
receiveContinuation?.yield(data)
|
||||
}
|
||||
if isComplete {
|
||||
receiveContinuation?.finish()
|
||||
return
|
||||
}
|
||||
startReceiveLoop()
|
||||
}
|
||||
}
|
||||
|
||||
public enum TransportError: Error, Sendable {
|
||||
case notConnected
|
||||
}
|
||||
18
Packages/VNCCore/Sources/VNCCore/Transport/Transport.swift
Normal file
18
Packages/VNCCore/Sources/VNCCore/Transport/Transport.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
public protocol Transport: Sendable {
|
||||
func connect() async throws
|
||||
func send(_ data: Data) async throws
|
||||
func receive() -> AsyncThrowingStream<Data, Error>
|
||||
func disconnect() async
|
||||
}
|
||||
|
||||
public struct TransportEndpoint: Sendable, Hashable {
|
||||
public let host: String
|
||||
public let port: Int
|
||||
|
||||
public init(host: String, port: Int) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user