From 2cff17fa0da52ff8fbc401c63f35ae81cd7d5b7c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 19:29:47 -0500 Subject: [PATCH] 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) --- .gitignore | 21 +++++ Packages/VNCCore/Package.swift | 26 ++++++ .../VNCCore/Clipboard/ClipboardBridge.swift | 26 ++++++ .../VNCCore/Discovery/DiscoveryService.swift | 62 +++++++++++++ .../VNCCore/Security/KeychainService.swift | 65 +++++++++++++ .../VNCCore/Session/SessionController.swift | 42 +++++++++ .../VNCCore/Session/SessionState.swift | 27 ++++++ .../VNCCore/Storage/ConnectionStore.swift | 30 ++++++ .../VNCCore/Storage/SavedConnection.swift | 60 ++++++++++++ .../VNCCore/Transport/DirectTransport.swift | 92 +++++++++++++++++++ .../Sources/VNCCore/Transport/Transport.swift | 18 ++++ .../VNCCoreTests/SessionStateTests.swift | 19 ++++ Packages/VNCUI/Package.swift | 24 +++++ .../VNCUI/Edit/AddConnectionView.swift | 74 +++++++++++++++ .../Sources/VNCUI/List/ConnectionCard.swift | 43 +++++++++ .../VNCUI/List/ConnectionListView.swift | 69 ++++++++++++++ .../VNCUI/Session/FramebufferUIView.swift | 37 ++++++++ .../VNCUI/Session/FramebufferView.swift | 36 ++++++++ .../Sources/VNCUI/Session/InputMapper.swift | 30 ++++++ .../Sources/VNCUI/Session/SessionView.swift | 68 ++++++++++++++ .../Sources/VNCUI/Settings/SettingsView.swift | 29 ++++++ .../Tests/VNCUITests/InputMapperTests.swift | 15 +++ Project.yml | 39 ++++++++ README.md | 51 ++++++++++ Screens/App/AppStateController.swift | 41 +++++++++ Screens/App/RootView.swift | 45 +++++++++ Screens/App/VNCApp.swift | 17 ++++ Screens/Resources/Info.plist | 55 +++++++++++ 28 files changed, 1161 insertions(+) create mode 100644 .gitignore create mode 100644 Packages/VNCCore/Package.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Clipboard/ClipboardBridge.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Discovery/DiscoveryService.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Security/KeychainService.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Session/SessionController.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Session/SessionState.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Storage/ConnectionStore.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Storage/SavedConnection.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Transport/DirectTransport.swift create mode 100644 Packages/VNCCore/Sources/VNCCore/Transport/Transport.swift create mode 100644 Packages/VNCCore/Tests/VNCCoreTests/SessionStateTests.swift create mode 100644 Packages/VNCUI/Package.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/Session/InputMapper.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift create mode 100644 Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift create mode 100644 Packages/VNCUI/Tests/VNCUITests/InputMapperTests.swift create mode 100644 Project.yml create mode 100644 README.md create mode 100644 Screens/App/AppStateController.swift create mode 100644 Screens/App/RootView.swift create mode 100644 Screens/App/VNCApp.swift create mode 100644 Screens/Resources/Info.plist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c9ec60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Xcode +*.xcodeproj/xcuserdata/ +*.xcodeproj/project.xcworkspace/xcuserdata/ +*.xcworkspace/xcuserdata/ +DerivedData/ +*.xcuserstate + +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +# macOS +.DS_Store + +# Claude Code local settings +.claude/settings.local.json + +# xcodegen output +# (keep the .xcodeproj out of git; regenerate from Project.yml) +Screens.xcodeproj/ diff --git a/Packages/VNCCore/Package.swift b/Packages/VNCCore/Package.swift new file mode 100644 index 0000000..8e41847 --- /dev/null +++ b/Packages/VNCCore/Package.swift @@ -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] +) diff --git a/Packages/VNCCore/Sources/VNCCore/Clipboard/ClipboardBridge.swift b/Packages/VNCCore/Sources/VNCCore/Clipboard/ClipboardBridge.swift new file mode 100644 index 0000000..a353e09 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Clipboard/ClipboardBridge.swift @@ -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 + } +} diff --git a/Packages/VNCCore/Sources/VNCCore/Discovery/DiscoveryService.swift b/Packages/VNCCore/Sources/VNCCore/Discovery/DiscoveryService.swift new file mode 100644 index 0000000..2da9ae2 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Discovery/DiscoveryService.swift @@ -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, 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 } + } +} diff --git a/Packages/VNCCore/Sources/VNCCore/Security/KeychainService.swift b/Packages/VNCCore/Sources/VNCCore/Security/KeychainService.swift new file mode 100644 index 0000000..6b56c41 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Security/KeychainService.swift @@ -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) +} diff --git a/Packages/VNCCore/Sources/VNCCore/Session/SessionController.swift b/Packages/VNCCore/Sources/VNCCore/Session/SessionController.swift new file mode 100644 index 0000000..c5f32d7 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Session/SessionController.swift @@ -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? + + 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))) + } + } +} diff --git a/Packages/VNCCore/Sources/VNCCore/Session/SessionState.swift b/Packages/VNCCore/Sources/VNCCore/Session/SessionState.swift new file mode 100644 index 0000000..06bae7b --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Session/SessionState.swift @@ -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 +} diff --git a/Packages/VNCCore/Sources/VNCCore/Storage/ConnectionStore.swift b/Packages/VNCCore/Sources/VNCCore/Storage/ConnectionStore.swift new file mode 100644 index 0000000..6754655 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Storage/ConnectionStore.swift @@ -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( + 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() + } +} diff --git a/Packages/VNCCore/Sources/VNCCore/Storage/SavedConnection.swift b/Packages/VNCCore/Sources/VNCCore/Storage/SavedConnection.swift new file mode 100644 index 0000000..ea71611 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Storage/SavedConnection.swift @@ -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 +} diff --git a/Packages/VNCCore/Sources/VNCCore/Transport/DirectTransport.swift b/Packages/VNCCore/Sources/VNCCore/Transport/DirectTransport.swift new file mode 100644 index 0000000..c8122b1 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Transport/DirectTransport.swift @@ -0,0 +1,92 @@ +import Foundation +import Network + +public actor DirectTransport: Transport { + private let endpoint: TransportEndpoint + private var connection: NWConnection? + private var receiveContinuation: AsyncThrowingStream.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) 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) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume() } + }) + } + } + + public nonisolated func receive() -> AsyncThrowingStream { + 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.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 +} diff --git a/Packages/VNCCore/Sources/VNCCore/Transport/Transport.swift b/Packages/VNCCore/Sources/VNCCore/Transport/Transport.swift new file mode 100644 index 0000000..e7879b4 --- /dev/null +++ b/Packages/VNCCore/Sources/VNCCore/Transport/Transport.swift @@ -0,0 +1,18 @@ +import Foundation + +public protocol Transport: Sendable { + func connect() async throws + func send(_ data: Data) async throws + func receive() -> AsyncThrowingStream + 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 + } +} diff --git a/Packages/VNCCore/Tests/VNCCoreTests/SessionStateTests.swift b/Packages/VNCCore/Tests/VNCCoreTests/SessionStateTests.swift new file mode 100644 index 0000000..39f5044 --- /dev/null +++ b/Packages/VNCCore/Tests/VNCCoreTests/SessionStateTests.swift @@ -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) + } +} diff --git a/Packages/VNCUI/Package.swift b/Packages/VNCUI/Package.swift new file mode 100644 index 0000000..614e95a --- /dev/null +++ b/Packages/VNCUI/Package.swift @@ -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] +) diff --git a/Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift b/Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift new file mode 100644 index 0000000..4d80e88 --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift @@ -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() + } +} diff --git a/Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift b/Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift new file mode 100644 index 0000000..6b0e636 --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift @@ -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 + } + } +} diff --git a/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift b/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift new file mode 100644 index 0000000..6903f95 --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift @@ -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() + } + } + } +} diff --git a/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift new file mode 100644 index 0000000..e1d4da1 --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift @@ -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 diff --git a/Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift new file mode 100644 index 0000000..5d02324 --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift @@ -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 diff --git a/Packages/VNCUI/Sources/VNCUI/Session/InputMapper.swift b/Packages/VNCUI/Sources/VNCUI/Session/InputMapper.swift new file mode 100644 index 0000000..de5449a --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/Session/InputMapper.swift @@ -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) + } +} diff --git a/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift b/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift new file mode 100644 index 0000000..5a0121b --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift @@ -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() + } +} diff --git a/Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift b/Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift new file mode 100644 index 0000000..5757a11 --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift @@ -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") + } + } +} diff --git a/Packages/VNCUI/Tests/VNCUITests/InputMapperTests.swift b/Packages/VNCUI/Tests/VNCUITests/InputMapperTests.swift new file mode 100644 index 0000000..28eabfa --- /dev/null +++ b/Packages/VNCUI/Tests/VNCUITests/InputMapperTests.swift @@ -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) + } +} diff --git a/Project.yml b/Project.yml new file mode 100644 index 0000000..2105694 --- /dev/null +++ b/Project.yml @@ -0,0 +1,39 @@ +name: Screens +options: + bundleIdPrefix: com.example.screens + deploymentTarget: + iOS: "18.0" + developmentLanguage: en + xcodeVersion: "16.0" +settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + ENABLE_USER_SCRIPT_SANDBOXING: YES + CODE_SIGN_STYLE: Automatic + +packages: + VNCCore: + path: Packages/VNCCore + VNCUI: + path: Packages/VNCUI + +targets: + Screens: + type: application + platform: iOS + deploymentTarget: "18.0" + sources: + - path: Screens + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.example.screens + PRODUCT_NAME: Screens + TARGETED_DEVICE_FAMILY: "1,2" + GENERATE_INFOPLIST_FILE: NO + INFOPLIST_FILE: Screens/Resources/Info.plist + dependencies: + - package: VNCCore + product: VNCCore + - package: VNCUI + product: VNCUI diff --git a/README.md b/README.md new file mode 100644 index 0000000..41709ba --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Screens (working title) + +A VNC / RFB remote-desktop client for iPhone and iPad, modeled on [Screens by Edovia](https://www.edovia.com/en/screens/). See `/Users/treyt/.claude/plans/i-want-to-make-sprightly-pike.md` for the full plan. + +> The name "Screens" is owned by Edovia. Pick a different App Store name before any public release. + +## Layout + +``` +Screens/ +├── Screens/ # iOS app target (thin shell: @main, RootView, AppStateController) +├── Packages/ +│ ├── VNCCore/ # Protocol + transport + storage. Tested with `swift test`. +│ └── VNCUI/ # SwiftUI views + UIKit framebuffer. Tested in-simulator. +├── Project.yml # xcodegen project definition — generates the .xcodeproj +├── README.md +└── .gitignore +``` + +## Setup + +1. Install [xcodegen](https://github.com/yonaskolb/XcodeGen): + ```sh + brew install xcodegen + ``` +2. Generate the Xcode project from the repo root: + ```sh + xcodegen generate + ``` +3. Open `Screens.xcodeproj` in Xcode 16+. First open resolves the RoyalVNCKit SPM dependency. +4. Select an iOS 18 simulator (or a signed device) and run. + +## Testing + +```sh +# Fast unit tests (no simulator) +cd Packages/VNCCore && swift test +cd Packages/VNCUI && swift test + +# Full app + UI tests (simulator) +xcodebuild -project Screens.xcodeproj -scheme Screens -destination 'platform=iOS Simulator,name=iPhone 16' test +``` + +## Phase 0 status + +Scaffold only. Tapping a saved connection starts a `SessionController` but it only opens the TCP socket — no RFB handshake yet. Phase 1 wires RoyalVNCKit into `SessionController` and implements framebuffer rendering. + +## Dependencies + +- [RoyalVNCKit](https://github.com/royalapplications/royalvnc) (MIT) — RFB protocol, encodings, auth. +- Apple first-party only: `Network`, `SwiftData`, `Security`, `UIKit`, `SwiftUI`, `Observation`. diff --git a/Screens/App/AppStateController.swift b/Screens/App/AppStateController.swift new file mode 100644 index 0000000..41175f2 --- /dev/null +++ b/Screens/App/AppStateController.swift @@ -0,0 +1,41 @@ +import Foundation +import Observation +import VNCCore + +enum AppState: Equatable, Sendable { + case launching + case list + case error(AppError) +} + +enum AppError: Equatable, Sendable { + case storageUnavailable(String) +} + +@Observable +@MainActor +final class AppStateController { + private(set) var state: AppState = .launching + + func initialize() async { + // Placeholder for Phase 1: warm storage, load recent connections, etc. + try? await Task.sleep(for: .milliseconds(150)) + state = .list + } + + func transition(to newState: AppState) { + guard isValidTransition(from: state, to: newState) else { + assertionFailure("Invalid transition: \(state) → \(newState)") + return + } + state = newState + } + + private func isValidTransition(from: AppState, to: AppState) -> Bool { + switch (from, to) { + case (.launching, .list), (.launching, .error): true + case (.list, .error), (.error, .list): true + default: false + } + } +} diff --git a/Screens/App/RootView.swift b/Screens/App/RootView.swift new file mode 100644 index 0000000..5d80f04 --- /dev/null +++ b/Screens/App/RootView.swift @@ -0,0 +1,45 @@ +import SwiftUI +import VNCUI + +struct RootView: View { + @Environment(AppStateController.self) private var appState + + var body: some View { + ZStack { + switch appState.state { + case .launching: + LaunchView() + .transition(.opacity) + case .list: + ConnectionListView() + .transition(.opacity) + case .error(let error): + ErrorView(error: error) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: appState.state) + } +} + +private struct LaunchView: View { + var body: some View { + VStack(spacing: 16) { + Image(systemName: "display") + .font(.system(size: 48, weight: .semibold)) + .foregroundStyle(.tint) + ProgressView() + } + } +} + +private struct ErrorView: View { + let error: AppError + var body: some View { + ContentUnavailableView( + "Something went wrong", + systemImage: "exclamationmark.triangle", + description: Text(String(describing: error)) + ) + } +} diff --git a/Screens/App/VNCApp.swift b/Screens/App/VNCApp.swift new file mode 100644 index 0000000..4f2188a --- /dev/null +++ b/Screens/App/VNCApp.swift @@ -0,0 +1,17 @@ +import SwiftUI +import SwiftData +import VNCCore + +@main +struct VNCApp: App { + @State private var appState = AppStateController() + + var body: some Scene { + WindowGroup { + RootView() + .environment(appState) + .task { await appState.initialize() } + } + .modelContainer(for: SavedConnection.self) + } +} diff --git a/Screens/Resources/Info.plist b/Screens/Resources/Info.plist new file mode 100644 index 0000000..c9b9c63 --- /dev/null +++ b/Screens/Resources/Info.plist @@ -0,0 +1,55 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Screens + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSLocalNetworkUsageDescription + Discover computers on your network that you can control remotely. + NSBonjourServices + + _rfb._tcp + _workstation._tcp + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + +