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:
74
Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift
Normal file
74
Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
43
Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift
Normal file
43
Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
69
Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift
Normal file
69
Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift
Normal file
37
Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift
Normal 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
|
||||
36
Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift
Normal file
36
Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift
Normal 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
|
||||
30
Packages/VNCUI/Sources/VNCUI/Session/InputMapper.swift
Normal file
30
Packages/VNCUI/Sources/VNCUI/Session/InputMapper.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
68
Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift
Normal file
68
Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
29
Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift
Normal file
29
Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user