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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
114
Packages/VNCUI/Sources/VNCUI/Session/KeyMapping.swift
Normal file
114
Packages/VNCUI/Sources/VNCUI/Session/KeyMapping.swift
Normal 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
|
||||
64
Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift
Normal file
64
Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
66
Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift
Normal file
66
Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user