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,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")
}
}
}