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:
Claude
2026-04-16 19:29:47 -05:00
commit 2cff17fa0d
28 changed files with 1161 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "VNCCore",
platforms: [.iOS(.v18), .macOS(.v14)],
products: [
.library(name: "VNCCore", targets: ["VNCCore"])
],
dependencies: [
.package(url: "https://github.com/royalapplications/royalvnc.git", branch: "main")
],
targets: [
.target(
name: "VNCCore",
dependencies: [
.product(name: "RoyalVNCKit", package: "royalvnc")
]
),
.testTarget(
name: "VNCCoreTests",
dependencies: ["VNCCore"]
)
],
swiftLanguageModes: [.v6]
)

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,19 @@
import Testing
@testable import VNCCore
import Foundation
@Suite struct SessionStateTests {
@Test func idleEqualsIdle() {
#expect(SessionState.idle == .idle)
}
@Test func connectedWithDifferentSizesDiffer() {
let a = SessionState.connected(framebufferSize: FramebufferSize(width: 1920, height: 1080))
let b = SessionState.connected(framebufferSize: FramebufferSize(width: 1280, height: 800))
#expect(a != b)
}
@Test func disconnectReasonsDiffer() {
#expect(DisconnectReason.userRequested != .authenticationFailed)
}
}

View File

@@ -0,0 +1,24 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "VNCUI",
platforms: [.iOS(.v18), .macOS(.v14)],
products: [
.library(name: "VNCUI", targets: ["VNCUI"])
],
dependencies: [
.package(path: "../VNCCore")
],
targets: [
.target(
name: "VNCUI",
dependencies: ["VNCCore"]
),
.testTarget(
name: "VNCUITests",
dependencies: ["VNCUI"]
)
],
swiftLanguageModes: [.v6]
)

View File

@@ -0,0 +1,74 @@
import SwiftUI
import SwiftData
import VNCCore
public struct AddConnectionView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State private var displayName = ""
@State private var host = ""
@State private var port = "5900"
@State private var password = ""
@State private var colorTag: ColorTag = .blue
public init() {}
public var body: some View {
NavigationStack {
Form {
Section("Connection") {
TextField("Display name", text: $displayName)
TextField("Host or IP", text: $host)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
TextField("Port", text: $port)
#if os(iOS)
.keyboardType(.numberPad)
#endif
}
Section("Authentication") {
SecureField("VNC password", text: $password)
}
Section("Appearance") {
Picker("Color tag", selection: $colorTag) {
ForEach(ColorTag.allCases, id: \.self) { tag in
Text(tag.rawValue.capitalized).tag(tag)
}
}
}
}
.navigationTitle("New Connection")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.disabled(displayName.isEmpty || host.isEmpty)
}
}
}
}
private func save() {
let portInt = Int(port) ?? 5900
let connection = SavedConnection(
displayName: displayName,
host: host,
port: portInt,
colorTag: colorTag
)
context.insert(connection)
if !password.isEmpty {
try? KeychainService().storePassword(password, account: connection.keychainTag)
}
try? context.save()
dismiss()
}
}

View File

@@ -0,0 +1,43 @@
import SwiftUI
import VNCCore
struct ConnectionCard: View {
let connection: SavedConnection
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(connection.colorTag.color)
.frame(width: 12, height: 12)
VStack(alignment: .leading, spacing: 2) {
Text(connection.displayName)
.font(.headline)
Text("\(connection.host):\(connection.port)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let last = connection.lastConnectedAt {
Text(last, style: .relative)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.contentShape(Rectangle())
}
}
private extension ColorTag {
var color: Color {
switch self {
case .red: .red
case .orange: .orange
case .yellow: .yellow
case .green: .green
case .blue: .blue
case .purple: .purple
case .pink: .pink
case .gray: .gray
}
}
}

View File

@@ -0,0 +1,69 @@
import SwiftUI
import SwiftData
import VNCCore
public struct ConnectionListView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \SavedConnection.displayName) private var connections: [SavedConnection]
@State private var discovery = DiscoveryService()
@State private var showingAdd = false
@State private var selectedConnection: SavedConnection?
public init() {}
public var body: some View {
NavigationStack {
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
}
}
}
}
}
.navigationTitle("Screens")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingAdd = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAdd) {
AddConnectionView()
}
.navigationDestination(item: $selectedConnection) { connection in
SessionView(connection: connection)
}
.task {
discovery.start()
}
.onDisappear {
discovery.stop()
}
}
}
}

View File

@@ -0,0 +1,37 @@
#if canImport(UIKit)
import UIKit
import VNCCore
final class FramebufferUIView: UIView {
weak var coordinator: FramebufferView.Coordinator?
private let contentLayer = CALayer()
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = true
backgroundColor = .black
contentLayer.magnificationFilter = .nearest
contentLayer.minificationFilter = .linear
layer.addSublayer(contentLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
contentLayer.frame = bounds
}
func apply(state: SessionState) {
switch state {
case .connected(let size):
contentLayer.backgroundColor = UIColor.darkGray.cgColor
_ = size
default:
contentLayer.backgroundColor = UIColor.black.cgColor
}
}
}
#endif

View File

@@ -0,0 +1,36 @@
#if canImport(UIKit)
import SwiftUI
import VNCCore
struct FramebufferView: UIViewRepresentable {
let controller: SessionController
func makeUIView(context: Context) -> FramebufferUIView {
let view = FramebufferUIView()
view.coordinator = context.coordinator
return view
}
func updateUIView(_ uiView: FramebufferUIView, context: Context) {
uiView.apply(state: controller.state)
}
func makeCoordinator() -> Coordinator {
Coordinator(inputMapper: InputMapper())
}
@MainActor
final class Coordinator {
let inputMapper: InputMapper
init(inputMapper: InputMapper) { self.inputMapper = inputMapper }
}
}
#else
import SwiftUI
import VNCCore
struct FramebufferView: View {
let controller: SessionController
var body: some View { Color.black }
}
#endif

View File

@@ -0,0 +1,30 @@
import CoreGraphics
import Foundation
public enum InputMode: Sendable {
case touch
case trackpad
}
public struct PointerEvent: Sendable, Equatable {
public let location: CGPoint
public let buttonMask: UInt8
}
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)
}
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
import VNCCore
public struct SessionView: View {
let connection: SavedConnection
@State private var controller: SessionController?
public init(connection: SavedConnection) {
self.connection = connection
}
public var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if let controller {
FramebufferView(controller: controller)
statusOverlay(for: controller.state)
} else {
ProgressView("Preparing session…")
.tint(.white)
.foregroundStyle(.white)
}
}
.navigationTitle(connection.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.task(id: connection.id) {
await startSession()
}
}
@ViewBuilder
private func statusOverlay(for state: SessionState) -> some View {
switch state {
case .connecting:
VStack {
ProgressView("Connecting…").tint(.white).foregroundStyle(.white)
}
case .authenticating:
VStack {
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:
EmptyView()
}
}
@MainActor
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()
}
}

View File

@@ -0,0 +1,29 @@
import SwiftUI
import VNCCore
public struct SettingsView: View {
@AppStorage("clipboardSyncEnabled") private var clipboardSync = true
@AppStorage("defaultInputMode") private var defaultInputModeRaw = "touch"
public init() {}
public var body: some View {
NavigationStack {
Form {
Section("Input") {
Picker("Default input mode", selection: $defaultInputModeRaw) {
Text("Touch").tag("touch")
Text("Trackpad").tag("trackpad")
}
}
Section("Privacy") {
Toggle("Sync clipboard with remote", isOn: $clipboardSync)
}
Section("About") {
LabeledContent("Version", value: "0.1 (Phase 0)")
}
}
.navigationTitle("Settings")
}
}
}

View File

@@ -0,0 +1,15 @@
import Testing
@testable import VNCUI
import CoreGraphics
@Suite struct InputMapperTests {
@Test @MainActor func tapInMiddleMapsToFramebufferCenter() {
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)
}
}