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:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -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/
|
||||||
26
Packages/VNCCore/Package.swift
Normal file
26
Packages/VNCCore/Package.swift
Normal 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]
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Packages/VNCCore/Tests/VNCCoreTests/SessionStateTests.swift
Normal file
19
Packages/VNCCore/Tests/VNCCoreTests/SessionStateTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Packages/VNCUI/Package.swift
Normal file
24
Packages/VNCUI/Package.swift
Normal 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]
|
||||||
|
)
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Packages/VNCUI/Tests/VNCUITests/InputMapperTests.swift
Normal file
15
Packages/VNCUI/Tests/VNCUITests/InputMapperTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
Project.yml
Normal file
39
Project.yml
Normal file
@@ -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
|
||||||
51
README.md
Normal file
51
README.md
Normal file
@@ -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`.
|
||||||
41
Screens/App/AppStateController.swift
Normal file
41
Screens/App/AppStateController.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
Screens/App/RootView.swift
Normal file
45
Screens/App/RootView.swift
Normal file
@@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Screens/App/VNCApp.swift
Normal file
17
Screens/App/VNCApp.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Screens/Resources/Info.plist
Normal file
55
Screens/Resources/Info.plist
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Screens</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>Discover computers on your network that you can control remotely.</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_rfb._tcp</string>
|
||||||
|
<string>_workstation._tcp</string>
|
||||||
|
</array>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Reference in New Issue
Block a user