Phases 1-4: full VNC client implementation

- SessionController wraps RoyalVNCKit.VNCConnection via nonisolated delegate
  adapter that bridges callbacks to @MainActor; Keychain-resolved passwords;
  reconnect with jittered exponential backoff; NWPathMonitor adaptive-quality
  hook; framebuffer rendered to CALayer.contents from didUpdateFramebuffer.
- Touch + trackpad input modes with floating soft cursor overlay; hardware
  keyboard via pressesBegan/Ended → X11 keysyms; UIPointerInteraction with
  hidden cursor for indirect pointers; pinch-to-zoom; Apple Pencil as direct
  touch; two-finger pan / indirect scroll wheel events.
- Bidirectional clipboard sync (per-connection opt-in); multi-monitor screen
  picker with input remapping; screenshot capture → share sheet; on-disconnect
  reconnect/close prompt; view-only and curtain-mode persisted.
- iPad multi-window via WindowGroup(for: UUID.self) + context-menu open;
  CloudKit-backed ModelContainer with local fallback; PrivacyInfo.xcprivacy.

10 VNCCore tests + 4 VNCUI tests pass; iPhone and iPad simulator builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-16 20:07:54 -05:00
parent 102c3484e9
commit 1c01b3573f
28 changed files with 2359 additions and 158 deletions

View File

@@ -2,17 +2,38 @@ import SwiftUI
import SwiftData
import VNCCore
public struct AddConnectionPrefill: Equatable, Sendable {
public let displayName: String
public let host: String
public let port: Int
public init(displayName: String = "", host: String = "", port: Int = 5900) {
self.displayName = displayName
self.host = host
self.port = port
}
}
public struct AddConnectionView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let prefill: AddConnectionPrefill?
@State private var displayName = ""
@State private var host = ""
@State private var port = "5900"
@State private var password = ""
@State private var colorTag: ColorTag = .blue
@State private var quality: QualityPreset = .adaptive
@State private var inputMode: InputModePreference = .touch
@State private var clipboardSync = true
@State private var viewOnly = false
@State private var notes = ""
public init() {}
public init(prefill: AddConnectionPrefill? = nil) {
self.prefill = prefill
}
public var body: some View {
NavigationStack {
@@ -31,14 +52,39 @@ public struct AddConnectionView: View {
}
Section("Authentication") {
SecureField("VNC password", text: $password)
Text("Stored in iOS Keychain (this device only).")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Defaults") {
Picker("Default input", selection: $inputMode) {
ForEach(InputModePreference.allCases, id: \.self) { mode in
Text(mode == .touch ? "Touch" : "Trackpad").tag(mode)
}
}
Picker("Quality", selection: $quality) {
Text("Adaptive").tag(QualityPreset.adaptive)
Text("High").tag(QualityPreset.high)
Text("Low (slow links)").tag(QualityPreset.low)
}
Toggle("Sync clipboard", isOn: $clipboardSync)
Toggle("View only", isOn: $viewOnly)
}
Section("Appearance") {
Picker("Color tag", selection: $colorTag) {
ForEach(ColorTag.allCases, id: \.self) { tag in
Text(tag.rawValue.capitalized).tag(tag)
HStack {
Circle().fill(tag.color).frame(width: 14, height: 14)
Text(tag.rawValue.capitalized)
}
.tag(tag)
}
}
}
Section("Notes") {
TextField("Optional", text: $notes, axis: .vertical)
.lineLimit(2...6)
}
}
.navigationTitle("New Connection")
#if os(iOS)
@@ -53,16 +99,30 @@ public struct AddConnectionView: View {
.disabled(displayName.isEmpty || host.isEmpty)
}
}
.onAppear { applyPrefillIfNeeded() }
}
}
private func applyPrefillIfNeeded() {
guard let prefill, displayName.isEmpty, host.isEmpty else { return }
displayName = prefill.displayName
host = prefill.host
port = String(prefill.port)
}
private func save() {
let portInt = Int(port) ?? 5900
let connection = SavedConnection(
displayName: displayName,
host: host,
port: portInt,
colorTag: colorTag
colorTag: colorTag,
quality: quality,
inputMode: inputMode,
viewOnly: viewOnly,
curtainMode: false,
clipboardSyncEnabled: clipboardSync,
notes: notes
)
context.insert(connection)
if !password.isEmpty {

View File

@@ -8,13 +8,18 @@ struct ConnectionCard: View {
HStack(spacing: 12) {
Circle()
.fill(connection.colorTag.color)
.frame(width: 12, height: 12)
.frame(width: 14, height: 14)
VStack(alignment: .leading, spacing: 2) {
Text(connection.displayName)
.font(.headline)
Text("\(connection.host):\(connection.port)")
.font(.caption)
.foregroundStyle(.secondary)
if connection.viewOnly {
Text("View only")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
Spacer()
if let last = connection.lastConnectedAt {
@@ -27,7 +32,7 @@ struct ConnectionCard: View {
}
}
private extension ColorTag {
extension ColorTag {
var color: Color {
switch self {
case .red: .red

View File

@@ -4,59 +4,71 @@ import VNCCore
public struct ConnectionListView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.openWindow) private var openWindow
@Query(sort: \SavedConnection.displayName) private var connections: [SavedConnection]
@State private var discovery = DiscoveryService()
@State private var showingAdd = false
@State private var selectedConnection: SavedConnection?
@State private var showingSettings = false
@State private var addPrefill: AddConnectionPrefill?
@State private var path: [SessionRoute] = []
@State private var resolvingHostID: String?
public init() {}
public var body: some View {
NavigationStack {
NavigationStack(path: $path) {
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
}
}
}
discoveredSection
}
savedSection
}
#if os(iOS)
.listStyle(.insetGrouped)
#endif
.navigationTitle("Screens")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
addPrefill = nil
showingAdd = true
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("Add connection")
}
#if os(iOS)
ToolbarItem(placement: .topBarLeading) {
Button {
showingSettings = true
} label: {
Image(systemName: "gear")
}
.accessibilityLabel("Settings")
}
#else
ToolbarItem {
Button {
showingSettings = true
} label: {
Image(systemName: "gear")
}
}
#endif
}
.sheet(isPresented: $showingAdd) {
AddConnectionView()
AddConnectionView(prefill: addPrefill)
}
.navigationDestination(item: $selectedConnection) { connection in
SessionView(connection: connection)
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.navigationDestination(for: SessionRoute.self) { route in
if let connection = connection(with: route.connectionID) {
SessionView(connection: connection)
} else {
ContentUnavailableView("Connection unavailable",
systemImage: "exclamationmark.triangle")
}
}
.task {
discovery.start()
@@ -66,4 +78,107 @@ public struct ConnectionListView: View {
}
}
}
private var discoveredSection: some View {
Section {
ForEach(discovery.hosts) { host in
Button {
Task { await prepareDiscoveredHost(host) }
} label: {
HStack {
Image(systemName: "bonjour")
.foregroundStyle(.secondary)
VStack(alignment: .leading) {
Text(host.displayName)
Text(host.serviceType)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
if resolvingHostID == host.id {
ProgressView()
}
}
}
.disabled(resolvingHostID != nil)
}
} header: {
Text("Discovered on this network")
}
}
private var savedSection: some View {
Section {
if connections.isEmpty {
ContentUnavailableView(
"No saved connections",
systemImage: "display",
description: Text("Tap + to add a computer to control.")
)
} else {
ForEach(connections) { connection in
NavigationLink(value: SessionRoute(connectionID: connection.id)) {
ConnectionCard(connection: connection)
}
.contextMenu {
#if os(iOS)
Button {
openWindow(value: connection.id)
} label: {
Label("Open in New Window", systemImage: "rectangle.stack.badge.plus")
}
#endif
Button(role: .destructive) {
delete(connection)
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions {
Button(role: .destructive) {
delete(connection)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
} header: {
Text("Saved")
}
}
private func prepareDiscoveredHost(_ host: DiscoveredHost) async {
resolvingHostID = host.id
defer { resolvingHostID = nil }
do {
let resolved = try await discovery.resolve(host)
addPrefill = AddConnectionPrefill(
displayName: host.displayName,
host: resolved.host,
port: resolved.port
)
} catch {
addPrefill = AddConnectionPrefill(
displayName: host.displayName,
host: "",
port: 5900
)
}
showingAdd = true
}
private func connection(with id: UUID) -> SavedConnection? {
connections.first(where: { $0.id == id })
}
private func delete(_ connection: SavedConnection) {
try? KeychainService().deletePassword(account: connection.keychainTag)
modelContext.delete(connection)
try? modelContext.save()
}
}
struct SessionRoute: Hashable {
let connectionID: UUID
}

View File

@@ -2,35 +2,470 @@
import UIKit
import VNCCore
final class FramebufferUIView: UIView {
weak var coordinator: FramebufferView.Coordinator?
private let contentLayer = CALayer()
@MainActor
final class FramebufferUIView: UIView,
UIGestureRecognizerDelegate,
UIPointerInteractionDelegate {
weak var controller: SessionController?
var inputMode: InputMode = .touch
var selectedScreen: RemoteScreen? {
didSet { setNeedsLayout() }
}
var zoomScale: CGFloat = 1.0 {
didSet { setNeedsLayout() }
}
var contentTranslation: CGPoint = .zero {
didSet { setNeedsLayout() }
}
var trackpadCursorNormalized: CGPoint = CGPoint(x: 0.5, y: 0.5)
var onTrackpadCursorChanged: ((CGPoint) -> Void)?
private let imageLayer = CALayer()
private let inputMapper = InputMapper()
// Touch-mode pan with implicit left-button drag
private var touchPanActiveButtonDown = false
private var touchPanLastNormalized: CGPoint?
// Trackpad-mode pan moves the soft cursor
private var trackpadPanStartCursor: CGPoint?
private var trackpadPanStartPoint: CGPoint?
// Two-finger scroll accumulator
private var scrollAccumulator: CGPoint = .zero
private static let scrollStepPoints: CGFloat = 28
// Pinch zoom anchor
private var pinchStartScale: CGFloat = 1.0
private var pinchStartTranslation: CGPoint = .zero
// Indirect pointer (trackpad/mouse via UIPointerInteraction)
private var indirectPointerNormalized: CGPoint?
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = true
backgroundColor = .black
contentLayer.magnificationFilter = .nearest
contentLayer.minificationFilter = .linear
layer.addSublayer(contentLayer)
isMultipleTouchEnabled = true
clipsToBounds = true
imageLayer.magnificationFilter = .nearest
imageLayer.minificationFilter = .linear
imageLayer.contentsGravity = .resize
layer.addSublayer(imageLayer)
configureGestureRecognizers()
configurePointerInteraction()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
contentLayer.frame = bounds
override var canBecomeFirstResponder: Bool { true }
override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil { _ = becomeFirstResponder() }
}
func apply(state: SessionState) {
switch state {
case .connected(let size):
contentLayer.backgroundColor = UIColor.darkGray.cgColor
_ = size
// MARK: Layout / image
override func layoutSubviews() {
super.layoutSubviews()
applyLayerFrame()
}
func apply(image: CGImage?, framebufferSize: CGSize) {
imageLayer.contents = image
applyLayerFrame()
}
private func applyLayerFrame() {
let bounds = self.bounds
let fbSize = framebufferContentSize()
guard fbSize.width > 0, fbSize.height > 0,
bounds.width > 0, bounds.height > 0,
let displayed = inputMapper.displayedRect(for: fbSize, in: bounds.size) else {
imageLayer.frame = bounds
imageLayer.contentsRect = CGRect(x: 0, y: 0, width: 1, height: 1)
return
}
let scaled = displayed.applying(
CGAffineTransform(translationX: -bounds.midX, y: -bounds.midY)
.concatenating(CGAffineTransform(scaleX: zoomScale, y: zoomScale))
.concatenating(CGAffineTransform(translationX: bounds.midX + contentTranslation.x,
y: bounds.midY + contentTranslation.y))
)
imageLayer.frame = scaled
if let screen = selectedScreen {
let totalWidth = framebufferAbsoluteSize().width
let totalHeight = framebufferAbsoluteSize().height
if totalWidth > 0, totalHeight > 0 {
imageLayer.contentsRect = CGRect(
x: screen.frame.origin.x / totalWidth,
y: screen.frame.origin.y / totalHeight,
width: screen.frame.width / totalWidth,
height: screen.frame.height / totalHeight
)
} else {
imageLayer.contentsRect = CGRect(x: 0, y: 0, width: 1, height: 1)
}
} else {
imageLayer.contentsRect = CGRect(x: 0, y: 0, width: 1, height: 1)
}
}
private func framebufferAbsoluteSize() -> CGSize {
guard let size = controller?.framebufferSize else { return .zero }
return CGSize(width: CGFloat(size.width), height: CGFloat(size.height))
}
private func framebufferContentSize() -> CGSize {
if let screen = selectedScreen {
return CGSize(width: screen.frame.width, height: screen.frame.height)
}
return framebufferAbsoluteSize()
}
// MARK: Gesture recognition
private func configureGestureRecognizers() {
let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap(_:)))
singleTap.numberOfTapsRequired = 1
singleTap.numberOfTouchesRequired = 1
addGestureRecognizer(singleTap)
let twoFingerTap = UITapGestureRecognizer(target: self, action: #selector(handleTwoFingerTap(_:)))
twoFingerTap.numberOfTapsRequired = 1
twoFingerTap.numberOfTouchesRequired = 2
addGestureRecognizer(twoFingerTap)
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
longPress.minimumPressDuration = 0.55
longPress.delegate = self
addGestureRecognizer(longPress)
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.minimumNumberOfTouches = 1
pan.maximumNumberOfTouches = 1
pan.delegate = self
pan.allowedTouchTypes = [
NSNumber(value: UITouch.TouchType.direct.rawValue),
NSNumber(value: UITouch.TouchType.pencil.rawValue)
]
addGestureRecognizer(pan)
let twoFingerPan = UIPanGestureRecognizer(target: self, action: #selector(handleTwoFingerPan(_:)))
twoFingerPan.minimumNumberOfTouches = 2
twoFingerPan.maximumNumberOfTouches = 2
twoFingerPan.delegate = self
addGestureRecognizer(twoFingerPan)
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))
pinch.delegate = self
addGestureRecognizer(pinch)
let indirectScroll = UIPanGestureRecognizer(target: self, action: #selector(handleIndirectScroll(_:)))
indirectScroll.allowedScrollTypesMask = .all
indirectScroll.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)]
indirectScroll.delegate = self
addGestureRecognizer(indirectScroll)
let hover = UIHoverGestureRecognizer(target: self, action: #selector(handleHover(_:)))
addGestureRecognizer(hover)
}
// MARK: UIGestureRecognizerDelegate
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPinchGestureRecognizer || other is UIPinchGestureRecognizer {
return true
}
return false
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRequireFailureOf other: UIGestureRecognizer) -> Bool {
if let tap = gestureRecognizer as? UITapGestureRecognizer,
tap.numberOfTouchesRequired == 1,
let otherTap = other as? UITapGestureRecognizer,
otherTap.numberOfTouchesRequired == 2 {
return true
}
return false
}
// MARK: Pointer Interaction
private func configurePointerInteraction() {
let interaction = UIPointerInteraction(delegate: self)
addInteraction(interaction)
}
func pointerInteraction(_ interaction: UIPointerInteraction,
styleFor region: UIPointerRegion) -> UIPointerStyle? {
UIPointerStyle.hidden()
}
func pointerInteraction(_ interaction: UIPointerInteraction,
regionFor request: UIPointerRegionRequest,
defaultRegion: UIPointerRegion) -> UIPointerRegion? {
let viewPoint = request.location
if let normalized = inputMapper.normalize(viewPoint: viewPoint,
in: bounds.size,
framebufferSize: framebufferContentSize()) {
indirectPointerNormalized = normalized
controller?.pointerMove(toNormalized: mapToFullFramebuffer(normalized: normalized))
}
return defaultRegion
}
// MARK: Gesture Handlers
@objc private func handleSingleTap(_ recognizer: UITapGestureRecognizer) {
let location = recognizer.location(in: self)
switch inputMode {
case .touch:
if let normalized = normalizedFor(location) {
controller?.pointerClick(.left,
atNormalized: mapToFullFramebuffer(normalized: normalized))
}
case .trackpad:
controller?.pointerClick(.left,
atNormalized: mapToFullFramebuffer(normalized: trackpadCursorNormalized))
}
if !isFirstResponder { _ = becomeFirstResponder() }
}
@objc private func handleTwoFingerTap(_ recognizer: UITapGestureRecognizer) {
let location = recognizer.location(in: self)
switch inputMode {
case .touch:
if let normalized = normalizedFor(location) {
controller?.pointerClick(.right,
atNormalized: mapToFullFramebuffer(normalized: normalized))
}
case .trackpad:
controller?.pointerClick(.right,
atNormalized: mapToFullFramebuffer(normalized: trackpadCursorNormalized))
}
}
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
guard recognizer.state == .began else { return }
let location = recognizer.location(in: self)
switch inputMode {
case .touch:
if let normalized = normalizedFor(location) {
controller?.pointerClick(.right,
atNormalized: mapToFullFramebuffer(normalized: normalized))
}
case .trackpad:
controller?.pointerClick(.right,
atNormalized: mapToFullFramebuffer(normalized: trackpadCursorNormalized))
}
}
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
switch inputMode {
case .touch:
handleTouchPan(recognizer)
case .trackpad:
handleTrackpadPan(recognizer)
}
}
private func handleTouchPan(_ recognizer: UIPanGestureRecognizer) {
let location = recognizer.location(in: self)
guard let normalized = normalizedFor(location) else { return }
let mapped = mapToFullFramebuffer(normalized: normalized)
switch recognizer.state {
case .began:
touchPanLastNormalized = mapped
touchPanActiveButtonDown = true
controller?.pointerDown(.left, atNormalized: mapped)
case .changed:
touchPanLastNormalized = mapped
controller?.pointerMove(toNormalized: mapped)
case .ended, .cancelled, .failed:
if touchPanActiveButtonDown {
touchPanActiveButtonDown = false
let endNormalized = touchPanLastNormalized ?? mapped
controller?.pointerUp(.left, atNormalized: endNormalized)
}
touchPanLastNormalized = nil
default:
contentLayer.backgroundColor = UIColor.black.cgColor
break
}
}
private func handleTrackpadPan(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self)
switch recognizer.state {
case .began:
trackpadPanStartCursor = trackpadCursorNormalized
trackpadPanStartPoint = .zero
case .changed:
guard let start = trackpadPanStartCursor else { return }
let fbSize = framebufferContentSize()
guard let displayed = inputMapper.displayedRect(for: fbSize, in: bounds.size),
displayed.width > 0, displayed.height > 0 else { return }
let dx = translation.x / displayed.width
let dy = translation.y / displayed.height
let newCursor = CGPoint(
x: max(0, min(1, start.x + dx)),
y: max(0, min(1, start.y + dy))
)
trackpadCursorNormalized = newCursor
onTrackpadCursorChanged?(newCursor)
controller?.pointerMove(toNormalized: mapToFullFramebuffer(normalized: newCursor))
case .ended, .cancelled, .failed:
trackpadPanStartCursor = nil
trackpadPanStartPoint = nil
default: break
}
}
@objc private func handleTwoFingerPan(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self)
recognizer.setTranslation(.zero, in: self)
scrollAccumulator.x += translation.x
scrollAccumulator.y += translation.y
emitScrollEvents()
if recognizer.state == .ended || recognizer.state == .cancelled {
scrollAccumulator = .zero
}
}
@objc private func handleIndirectScroll(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self)
recognizer.setTranslation(.zero, in: self)
scrollAccumulator.x += translation.x
scrollAccumulator.y += translation.y
emitScrollEvents()
if recognizer.state == .ended || recognizer.state == .cancelled {
scrollAccumulator = .zero
}
}
private func emitScrollEvents() {
guard let controller else { return }
let cursor: CGPoint = inputMode == .trackpad
? trackpadCursorNormalized
: (indirectPointerNormalized ?? CGPoint(x: 0.5, y: 0.5))
let mapped = mapToFullFramebuffer(normalized: cursor)
let stepsY = Int((scrollAccumulator.y / Self.scrollStepPoints).rounded(.towardZero))
if stepsY != 0 {
let dir: ScrollDirection = stepsY > 0 ? .down : .up
controller.pointerScroll(dir, steps: UInt32(abs(stepsY)), atNormalized: mapped)
scrollAccumulator.y -= CGFloat(stepsY) * Self.scrollStepPoints
}
let stepsX = Int((scrollAccumulator.x / Self.scrollStepPoints).rounded(.towardZero))
if stepsX != 0 {
let dir: ScrollDirection = stepsX > 0 ? .right : .left
controller.pointerScroll(dir, steps: UInt32(abs(stepsX)), atNormalized: mapped)
scrollAccumulator.x -= CGFloat(stepsX) * Self.scrollStepPoints
}
}
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
switch recognizer.state {
case .began:
pinchStartScale = zoomScale
pinchStartTranslation = contentTranslation
case .changed:
let newScale = max(0.5, min(8.0, pinchStartScale * recognizer.scale))
zoomScale = newScale
case .ended, .cancelled, .failed:
// snap back if zoomed too much
if zoomScale < 1.01 {
zoomScale = 1.0
contentTranslation = .zero
}
default: break
}
}
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
let location = recognizer.location(in: self)
guard let normalized = inputMapper.normalize(viewPoint: location,
in: bounds.size,
framebufferSize: framebufferContentSize()) else { return }
indirectPointerNormalized = normalized
if recognizer.state == .changed || recognizer.state == .began {
controller?.pointerMove(toNormalized: mapToFullFramebuffer(normalized: normalized))
}
}
private func normalizedFor(_ point: CGPoint) -> CGPoint? {
inputMapper.normalize(viewPoint: point,
in: bounds.size,
framebufferSize: framebufferContentSize())
}
private func mapToFullFramebuffer(normalized: CGPoint) -> CGPoint {
guard let screen = selectedScreen,
let totalSize = controller?.framebufferSize,
totalSize.width > 0, totalSize.height > 0 else {
return normalized
}
let totalWidth = CGFloat(totalSize.width)
let totalHeight = CGFloat(totalSize.height)
let x = (screen.frame.origin.x + normalized.x * screen.frame.width) / totalWidth
let y = (screen.frame.origin.y + normalized.y * screen.frame.height) / totalHeight
return CGPoint(x: max(0, min(1, x)), y: max(0, min(1, y)))
}
// MARK: Hardware keyboard
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
var handled = false
for press in presses {
guard let key = press.key else { continue }
handleKey(key, isDown: true)
handled = true
}
if !handled { super.pressesBegan(presses, with: event) }
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
var handled = false
for press in presses {
guard let key = press.key else { continue }
handleKey(key, isDown: false)
handled = true
}
if !handled { super.pressesEnded(presses, with: event) }
}
override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
guard let key = press.key else { continue }
handleKey(key, isDown: false)
}
super.pressesCancelled(presses, with: event)
}
private func handleKey(_ key: UIKey, isDown: Bool) {
guard let controller else { return }
let modifiers = KeyMapping.modifierKeysyms(for: key.modifierFlags)
if isDown {
for sym in modifiers { controller.keyDown(keysym: sym) }
}
if let sym = KeyMapping.keysym(for: key) {
if isDown {
controller.keyDown(keysym: sym)
} else {
controller.keyUp(keysym: sym)
}
}
if !isDown {
for sym in modifiers.reversed() { controller.keyUp(keysym: sym) }
}
}
}

View File

@@ -1,36 +1,57 @@
#if canImport(UIKit)
import SwiftUI
import VNCCore
import CoreGraphics
struct FramebufferView: UIViewRepresentable {
let controller: SessionController
let inputMode: InputMode
let selectedScreen: RemoteScreen?
@Binding var trackpadCursor: CGPoint
func makeUIView(context: Context) -> FramebufferUIView {
let view = FramebufferUIView()
view.coordinator = context.coordinator
view.controller = controller
view.inputMode = inputMode
view.selectedScreen = selectedScreen
view.trackpadCursorNormalized = trackpadCursor
view.onTrackpadCursorChanged = { [binding = $trackpadCursor] new in
binding.wrappedValue = new
}
return view
}
func updateUIView(_ uiView: FramebufferUIView, context: Context) {
uiView.apply(state: controller.state)
uiView.controller = controller
uiView.inputMode = inputMode
uiView.selectedScreen = selectedScreen
if uiView.trackpadCursorNormalized != trackpadCursor {
uiView.trackpadCursorNormalized = trackpadCursor
}
uiView.apply(image: controller.currentImage,
framebufferSize: framebufferSize)
// Touch the revision so SwiftUI re-runs us when frames arrive
_ = controller.imageRevision
}
func makeCoordinator() -> Coordinator {
Coordinator(inputMapper: InputMapper())
}
@MainActor
final class Coordinator {
let inputMapper: InputMapper
init(inputMapper: InputMapper) { self.inputMapper = inputMapper }
private var framebufferSize: CGSize {
if let size = controller.framebufferSize {
return CGSize(width: CGFloat(size.width), height: CGFloat(size.height))
}
return .zero
}
}
#else
import SwiftUI
import VNCCore
import CoreGraphics
struct FramebufferView: View {
let controller: SessionController
let inputMode: InputMode
let selectedScreen: RemoteScreen?
@Binding var trackpadCursor: CGPoint
var body: some View { Color.black }
}
#endif

View File

@@ -1,30 +1,73 @@
import CoreGraphics
import Foundation
public enum InputMode: Sendable {
public enum InputMode: String, Sendable, CaseIterable, Hashable {
case touch
case trackpad
}
public struct PointerEvent: Sendable, Equatable {
public let location: CGPoint
public let buttonMask: UInt8
}
public struct DisplayedRect: Equatable, Sendable {
public let rect: CGRect
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)
public init(rect: CGRect) {
self.rect = rect
}
}
public struct InputMapper: Sendable {
public init() {}
/// Return the rect where a framebuffer of `framebufferSize` is drawn inside
/// `viewSize` using aspect-fit (`.resizeAspect`) gravity.
public func displayedRect(for framebufferSize: CGSize,
in viewSize: CGSize) -> CGRect? {
guard framebufferSize.width > 0, framebufferSize.height > 0,
viewSize.width > 0, viewSize.height > 0 else {
return nil
}
let viewAspect = viewSize.width / viewSize.height
let fbAspect = framebufferSize.width / framebufferSize.height
if viewAspect > fbAspect {
let displayedWidth = viewSize.height * fbAspect
let xOffset = (viewSize.width - displayedWidth) / 2
return CGRect(x: xOffset, y: 0,
width: displayedWidth,
height: viewSize.height)
} else {
let displayedHeight = viewSize.width / fbAspect
let yOffset = (viewSize.height - displayedHeight) / 2
return CGRect(x: 0, y: yOffset,
width: viewSize.width,
height: displayedHeight)
}
}
/// Convert a point in view coordinates to normalized framebuffer coordinates [0,1].
/// Returns nil if either size is empty.
public func normalize(viewPoint: CGPoint,
in viewSize: CGSize,
framebufferSize: CGSize) -> CGPoint? {
guard let displayed = displayedRect(for: framebufferSize, in: viewSize) else {
return nil
}
let nx = (viewPoint.x - displayed.origin.x) / displayed.width
let ny = (viewPoint.y - displayed.origin.y) / displayed.height
return CGPoint(x: clamp(nx), y: clamp(ny))
}
/// Convert a normalized framebuffer point [0,1] to view coordinates.
public func viewPoint(forNormalized normalized: CGPoint,
in viewSize: CGSize,
framebufferSize: CGSize) -> CGPoint? {
guard let displayed = displayedRect(for: framebufferSize, in: viewSize) else {
return nil
}
let x = displayed.origin.x + normalized.x * displayed.width
let y = displayed.origin.y + normalized.y * displayed.height
return CGPoint(x: x, y: y)
}
private func clamp(_ value: CGFloat) -> CGFloat {
max(0, min(1, value))
}
}

View File

@@ -0,0 +1,114 @@
#if canImport(UIKit)
import UIKit
enum KeyMapping {
/// X11 keysym values for special keys.
enum X11 {
static let backspace: UInt32 = 0xFF08
static let tab: UInt32 = 0xFF09
static let `return`: UInt32 = 0xFF0D
static let escape: UInt32 = 0xFF1B
static let delete: UInt32 = 0xFFFF
static let home: UInt32 = 0xFF50
static let leftArrow: UInt32 = 0xFF51
static let upArrow: UInt32 = 0xFF52
static let rightArrow: UInt32 = 0xFF53
static let downArrow: UInt32 = 0xFF54
static let pageUp: UInt32 = 0xFF55
static let pageDown: UInt32 = 0xFF56
static let end: UInt32 = 0xFF57
static let insert: UInt32 = 0xFF63
static let f1: UInt32 = 0xFFBE
static let f2: UInt32 = 0xFFBF
static let f3: UInt32 = 0xFFC0
static let f4: UInt32 = 0xFFC1
static let f5: UInt32 = 0xFFC2
static let f6: UInt32 = 0xFFC3
static let f7: UInt32 = 0xFFC4
static let f8: UInt32 = 0xFFC5
static let f9: UInt32 = 0xFFC6
static let f10: UInt32 = 0xFFC7
static let f11: UInt32 = 0xFFC8
static let f12: UInt32 = 0xFFC9
static let leftShift: UInt32 = 0xFFE1
static let rightShift: UInt32 = 0xFFE2
static let leftControl: UInt32 = 0xFFE3
static let rightControl: UInt32 = 0xFFE4
static let capsLock: UInt32 = 0xFFE5
static let leftAlt: UInt32 = 0xFFE9
static let rightAlt: UInt32 = 0xFFEA
static let leftMeta: UInt32 = 0xFFE7
static let rightMeta: UInt32 = 0xFFE8
static let leftCommand: UInt32 = 0xFFEB
static let rightCommand: UInt32 = 0xFFEC
static let space: UInt32 = 0x0020
}
/// Convert a UIKey into one or more VNC keysyms (UInt32 X11 values).
/// Returns nil if no mapping is found.
static func keysym(for key: UIKey) -> UInt32? {
let usage = key.keyCode
if let mapped = mapHIDUsage(usage) {
return mapped
}
let chars = key.charactersIgnoringModifiers
guard let scalar = chars.unicodeScalars.first else { return nil }
return scalar.value
}
static func modifierKeysyms(for flags: UIKeyModifierFlags) -> [UInt32] {
var out: [UInt32] = []
if flags.contains(.shift) { out.append(X11.leftShift) }
if flags.contains(.control) { out.append(X11.leftControl) }
if flags.contains(.alternate) { out.append(X11.leftAlt) }
if flags.contains(.command) { out.append(X11.leftCommand) }
return out
}
private static func mapHIDUsage(_ usage: UIKeyboardHIDUsage) -> UInt32? {
switch usage {
case .keyboardReturnOrEnter, .keypadEnter: return X11.return
case .keyboardEscape: return X11.escape
case .keyboardDeleteOrBackspace: return X11.backspace
case .keyboardTab: return X11.tab
case .keyboardSpacebar: return X11.space
case .keyboardLeftArrow: return X11.leftArrow
case .keyboardRightArrow: return X11.rightArrow
case .keyboardUpArrow: return X11.upArrow
case .keyboardDownArrow: return X11.downArrow
case .keyboardHome: return X11.home
case .keyboardEnd: return X11.end
case .keyboardPageUp: return X11.pageUp
case .keyboardPageDown: return X11.pageDown
case .keyboardInsert: return X11.insert
case .keyboardDeleteForward: return X11.delete
case .keyboardCapsLock: return X11.capsLock
case .keyboardLeftShift: return X11.leftShift
case .keyboardRightShift: return X11.rightShift
case .keyboardLeftControl: return X11.leftControl
case .keyboardRightControl: return X11.rightControl
case .keyboardLeftAlt: return X11.leftAlt
case .keyboardRightAlt: return X11.rightAlt
case .keyboardLeftGUI: return X11.leftCommand
case .keyboardRightGUI: return X11.rightCommand
case .keyboardF1: return X11.f1
case .keyboardF2: return X11.f2
case .keyboardF3: return X11.f3
case .keyboardF4: return X11.f4
case .keyboardF5: return X11.f5
case .keyboardF6: return X11.f6
case .keyboardF7: return X11.f7
case .keyboardF8: return X11.f8
case .keyboardF9: return X11.f9
case .keyboardF10: return X11.f10
case .keyboardF11: return X11.f11
case .keyboardF12: return X11.f12
default: return nil
}
}
}
#endif

View File

@@ -0,0 +1,64 @@
import SwiftUI
import VNCCore
struct SessionToolbar: View {
let controller: SessionController
@Binding var inputMode: InputMode
@Binding var showKeyboardBar: Bool
@Binding var selectedScreenID: UInt32?
var onScreenshot: () -> Void
var onDisconnect: () -> Void
var body: some View {
HStack(spacing: 12) {
Picker("Input Mode", selection: $inputMode) {
Text("Touch").tag(InputMode.touch)
Text("Trackpad").tag(InputMode.trackpad)
}
.pickerStyle(.segmented)
.fixedSize()
Button {
showKeyboardBar.toggle()
} label: {
Image(systemName: "keyboard")
}
.accessibilityLabel("Toggle keyboard bar")
if controller.screens.count > 1 {
Menu {
Button("All screens") { selectedScreenID = nil }
ForEach(controller.screens) { screen in
Button {
selectedScreenID = screen.id
} label: {
Text("Screen \(screen.id) (\(Int(screen.frame.width))×\(Int(screen.frame.height)))")
}
}
} label: {
Image(systemName: "rectangle.on.rectangle")
}
.accessibilityLabel("Choose monitor")
}
Button {
onScreenshot()
} label: {
Image(systemName: "camera")
}
.accessibilityLabel("Screenshot")
Spacer()
Button(role: .destructive) {
onDisconnect()
} label: {
Label("Disconnect", systemImage: "xmark.circle.fill")
.labelStyle(.iconOnly)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}

View File

@@ -1,9 +1,26 @@
import SwiftUI
import SwiftData
import VNCCore
import CoreGraphics
#if canImport(UIKit)
import UIKit
#endif
public struct SessionView: View {
let connection: SavedConnection
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
@AppStorage("defaultInputMode") private var defaultInputModeRaw = "touch"
@State private var controller: SessionController?
@State private var inputMode: InputMode = .touch
@State private var showKeyboardBar = false
@State private var showFunctionRow = false
@State private var trackpadCursor: CGPoint = CGPoint(x: 0.5, y: 0.5)
@State private var selectedScreenID: UInt32?
@State private var screenshotItem: ScreenshotShareItem?
public init(connection: SavedConnection) {
self.connection = connection
@@ -12,57 +29,212 @@ public struct SessionView: View {
public var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if let controller {
FramebufferView(controller: controller)
statusOverlay(for: controller.state)
FramebufferView(
controller: controller,
inputMode: inputMode,
selectedScreen: selectedScreen(for: controller),
trackpadCursor: $trackpadCursor
)
.ignoresSafeArea()
if inputMode == .trackpad,
let size = controller.framebufferSize {
TrackpadCursorOverlay(
normalizedPosition: trackpadCursor,
framebufferSize: CGSize(width: CGFloat(size.width),
height: CGFloat(size.height)),
isVisible: true
)
}
statusOverlay(for: controller.state, controller: controller)
overlayChrome(controller: controller)
} else {
ProgressView("Preparing session…")
.tint(.white)
.foregroundStyle(.white)
}
}
.navigationTitle(connection.displayName)
.navigationTitle(controller?.desktopName ?? connection.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if os(iOS)
.toolbar(.hidden, for: .tabBar)
#endif
.task(id: connection.id) {
await startSession()
}
.onChange(of: scenePhase) { _, phase in
if phase == .background {
controller?.stop()
}
}
.onDisappear {
controller?.stop()
persistLastConnected()
}
.sheet(item: $screenshotItem) { item in
ShareSheet(items: [item.image])
}
}
@ViewBuilder
private func statusOverlay(for state: SessionState) -> some View {
private func overlayChrome(controller: SessionController) -> some View {
VStack {
SessionToolbar(
controller: controller,
inputMode: $inputMode,
showKeyboardBar: $showKeyboardBar,
selectedScreenID: $selectedScreenID,
onScreenshot: { takeScreenshot(controller: controller) },
onDisconnect: { stopAndDismiss() }
)
.padding(.horizontal, 12)
.padding(.top, 6)
Spacer()
if showKeyboardBar {
SoftKeyboardBar(controller: controller, isExpanded: $showFunctionRow)
.padding(.horizontal, 12)
.padding(.bottom, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
@ViewBuilder
private func statusOverlay(for state: SessionState,
controller: SessionController) -> some View {
switch state {
case .connecting:
VStack {
messageOverlay {
ProgressView("Connecting…").tint(.white).foregroundStyle(.white)
}
case .authenticating:
VStack {
messageOverlay {
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:
disconnectedOverlay(reason: reason, controller: controller)
case .idle, .connected:
EmptyView()
}
}
@MainActor
@ViewBuilder
private func disconnectedOverlay(reason: DisconnectReason,
controller: SessionController) -> some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.largeTitle)
.foregroundStyle(.yellow)
Text("Disconnected")
.font(.headline)
.foregroundStyle(.white)
Text(humanReadable(reason: reason, lastError: controller.lastErrorMessage))
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
HStack {
Button {
controller.reconnectNow()
} label: {
Label("Reconnect", systemImage: "arrow.clockwise")
}
.buttonStyle(.borderedProminent)
Button("Close") {
stopAndDismiss()
}
.buttonStyle(.bordered)
}
if controller.isReconnecting {
ProgressView("Reconnecting attempt \(controller.reconnectAttempt)")
.tint(.white)
.foregroundStyle(.white)
}
}
.padding(24)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
@ViewBuilder
private func messageOverlay<V: View>(@ViewBuilder _ content: () -> V) -> some View {
content()
.padding(20)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
}
private func selectedScreen(for controller: SessionController) -> RemoteScreen? {
guard let id = selectedScreenID else { return nil }
return controller.screens.first { $0.id == id }
}
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()
if controller == nil {
inputMode = InputMode(rawValue: defaultInputModeRaw) ?? .touch
let provider = DefaultPasswordProvider()
let controller = SessionController(connection: connection,
passwordProvider: provider)
self.controller = controller
controller.start()
}
}
private func stopAndDismiss() {
controller?.stop()
persistLastConnected()
dismiss()
}
private func persistLastConnected() {
connection.lastConnectedAt = .now
try? modelContext.save()
}
private func humanReadable(reason: DisconnectReason, lastError: String?) -> String {
switch reason {
case .userRequested: return "You ended this session."
case .authenticationFailed: return "Authentication failed. Check the password and try again."
case .networkError(let detail): return lastError ?? detail
case .protocolError(let detail): return lastError ?? detail
case .remoteClosed: return "The remote computer closed the session."
}
}
private func takeScreenshot(controller: SessionController) {
#if canImport(UIKit)
guard let cgImage = controller.currentImage else { return }
let image = UIImage(cgImage: cgImage)
screenshotItem = ScreenshotShareItem(image: image)
#endif
}
}
private struct ScreenshotShareItem: Identifiable {
let id = UUID()
#if canImport(UIKit)
let image: UIImage
#else
let image: Any
#endif
}
#if canImport(UIKit)
private struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#else
private struct ShareSheet: View {
let items: [Any]
var body: some View { EmptyView() }
}
#endif

View File

@@ -0,0 +1,66 @@
import SwiftUI
import VNCCore
struct SoftKeyboardBar: View {
let controller: SessionController
@Binding var isExpanded: Bool
var body: some View {
VStack(spacing: 8) {
HStack(spacing: 12) {
Button {
withAnimation { isExpanded.toggle() }
} label: {
Label("Function keys",
systemImage: isExpanded ? "chevron.down" : "chevron.up")
.labelStyle(.iconOnly)
}
Button("Esc") { controller.sendEscape() }
Button("Tab") { controller.sendTab() }
Button("") { controller.sendReturn() }
Button(action: { controller.sendBackspace() }) {
Image(systemName: "delete.left")
}
Spacer()
arrowButtons
}
.buttonStyle(.bordered)
.controlSize(.small)
if isExpanded {
HStack {
ForEach(1...12, id: \.self) { idx in
Button("F\(idx)") {
controller.sendFunctionKey(idx)
}
.buttonStyle(.bordered)
.controlSize(.mini)
}
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var arrowButtons: some View {
HStack(spacing: 4) {
Button(action: { controller.sendArrow(.left) }) {
Image(systemName: "arrow.left")
}
VStack(spacing: 2) {
Button(action: { controller.sendArrow(.up) }) {
Image(systemName: "arrow.up")
}
Button(action: { controller.sendArrow(.down) }) {
Image(systemName: "arrow.down")
}
}
Button(action: { controller.sendArrow(.right) }) {
Image(systemName: "arrow.right")
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}

View File

@@ -0,0 +1,29 @@
import SwiftUI
import VNCCore
struct TrackpadCursorOverlay: View {
let normalizedPosition: CGPoint
let framebufferSize: CGSize
let isVisible: Bool
var body: some View {
GeometryReader { proxy in
let mapper = InputMapper()
if isVisible,
let displayed = mapper.displayedRect(for: framebufferSize, in: proxy.size),
displayed.width > 0 && displayed.height > 0 {
let x = displayed.origin.x + normalizedPosition.x * displayed.width
let y = displayed.origin.y + normalizedPosition.y * displayed.height
Image(systemName: "cursorarrow")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(.white, .black)
.shadow(radius: 1)
.position(x: x, y: y)
.allowsHitTesting(false)
.accessibilityHidden(true)
.transition(.opacity)
}
}
.ignoresSafeArea()
}
}

View File

@@ -2,8 +2,12 @@ import SwiftUI
import VNCCore
public struct SettingsView: View {
@AppStorage("clipboardSyncEnabled") private var clipboardSync = true
@Environment(\.dismiss) private var dismiss
@AppStorage("clipboardSyncEnabled") private var clipboardSyncDefault = true
@AppStorage("defaultInputMode") private var defaultInputModeRaw = "touch"
@AppStorage("autoReconnectEnabled") private var autoReconnect = true
@AppStorage("reduceMotionInSession") private var reduceMotion = false
public init() {}
@@ -16,14 +20,41 @@ public struct SettingsView: View {
Text("Trackpad").tag("trackpad")
}
}
Section("Connection") {
Toggle("Auto-reconnect on drop", isOn: $autoReconnect)
}
Section("Privacy") {
Toggle("Sync clipboard with remote", isOn: $clipboardSync)
Toggle("Sync clipboard with remote (default)", isOn: $clipboardSyncDefault)
Text("Each connection can override this; secrets never sync to iCloud.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Display") {
Toggle("Reduce motion in session", isOn: $reduceMotion)
}
Section("About") {
LabeledContent("Version", value: "0.1 (Phase 0)")
LabeledContent("Version", value: Self.shortVersion)
LabeledContent("Build", value: Self.buildNumber)
Link("Privacy policy", destination: URL(string: "https://example.com/privacy")!)
}
}
.navigationTitle("Settings")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
private static var shortVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0"
}
private static var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
}
}

View File

@@ -3,13 +3,53 @@ import Testing
import CoreGraphics
@Suite struct InputMapperTests {
@Test @MainActor func tapInMiddleMapsToFramebufferCenter() {
@Test func centerOfViewMapsToCenterOfFramebuffer() {
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)
let normalized = mapper.normalize(viewPoint: CGPoint(x: 96, y: 54),
in: view,
framebufferSize: fb)
#expect(normalized != nil)
#expect(abs((normalized?.x ?? 0) - 0.5) < 0.001)
#expect(abs((normalized?.y ?? 0) - 0.5) < 0.001)
}
@Test func aspectFitLetterboxesTallerView() {
let mapper = InputMapper()
let fb = CGSize(width: 1600, height: 900) // 16:9
let view = CGSize(width: 800, height: 800) // 1:1
let displayed = mapper.displayedRect(for: fb, in: view)
#expect(displayed != nil)
#expect(abs((displayed?.width ?? 0) - 800) < 0.001)
#expect(abs((displayed?.height ?? 0) - 450) < 0.001)
#expect(abs((displayed?.origin.y ?? 0) - 175) < 0.001)
}
@Test func aspectFitPillarboxesWiderView() {
let mapper = InputMapper()
let fb = CGSize(width: 800, height: 800)
let view = CGSize(width: 1600, height: 900)
let displayed = mapper.displayedRect(for: fb, in: view)
#expect(displayed != nil)
#expect(abs((displayed?.height ?? 0) - 900) < 0.001)
#expect(abs((displayed?.width ?? 0) - 900) < 0.001)
#expect(abs((displayed?.origin.x ?? 0) - 350) < 0.001)
}
@Test func roundTripNormalizationIsStable() {
let mapper = InputMapper()
let fb = CGSize(width: 1920, height: 1080)
let view = CGSize(width: 800, height: 600)
let target = CGPoint(x: 0.25, y: 0.75)
let viewPoint = mapper.viewPoint(forNormalized: target,
in: view,
framebufferSize: fb)
let normalized = mapper.normalize(viewPoint: viewPoint ?? .zero,
in: view,
framebufferSize: fb)
#expect(normalized != nil)
#expect(abs((normalized?.x ?? 0) - target.x) < 0.001)
#expect(abs((normalized?.y ?? 0) - target.y) < 0.001)
}
}