mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-08-31 13:32:10 +08:00
[feat] add native iOS client support
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum RemoteVideoGeometry {
|
||||
private static func pixelAligned(_ rect: CGRect) -> CGRect {
|
||||
let scale = max(UIScreen.main.scale, 1)
|
||||
let minX = (rect.minX * scale).rounded() / scale
|
||||
let minY = (rect.minY * scale).rounded() / scale
|
||||
let maxX = (rect.maxX * scale).rounded() / scale
|
||||
let maxY = (rect.maxY * scale).rounded() / scale
|
||||
return CGRect(x: minX, y: minY,
|
||||
width: max(0, maxX - minX),
|
||||
height: max(0, maxY - minY))
|
||||
}
|
||||
|
||||
static func aspectFitRect(containerSize: CGSize,
|
||||
videoSize: CGSize) -> CGRect? {
|
||||
guard containerSize.width > 0, containerSize.height > 0,
|
||||
videoSize.width > 0, videoSize.height > 0 else { return nil }
|
||||
let videoAspect = videoSize.width / videoSize.height
|
||||
let containerAspect = containerSize.width / containerSize.height
|
||||
if videoAspect > containerAspect {
|
||||
let height = containerSize.width / videoAspect
|
||||
return pixelAligned(
|
||||
CGRect(x: 0, y: (containerSize.height - height) / 2,
|
||||
width: containerSize.width, height: height)
|
||||
)
|
||||
}
|
||||
let width = containerSize.height * videoAspect
|
||||
return pixelAligned(
|
||||
CGRect(x: (containerSize.width - width) / 2, y: 0,
|
||||
width: width, height: containerSize.height)
|
||||
)
|
||||
}
|
||||
|
||||
static func transformedRect(containerSize: CGSize,
|
||||
videoSize: CGSize,
|
||||
scale: CGFloat,
|
||||
offset: CGSize) -> CGRect? {
|
||||
guard let baseRect = aspectFitRect(containerSize: containerSize,
|
||||
videoSize: videoSize) else {
|
||||
return nil
|
||||
}
|
||||
// SwiftUI scales the video around the center of its own pixel-aligned
|
||||
// frame. That center can differ fractionally from the container center
|
||||
// after edge alignment, and the difference is amplified at 10x.
|
||||
let center = CGPoint(x: baseRect.midX, y: baseRect.midY)
|
||||
return CGRect(
|
||||
x: center.x + (baseRect.minX - center.x) * scale + offset.width,
|
||||
y: center.y + (baseRect.minY - center.y) * scale + offset.height,
|
||||
width: baseRect.width * scale,
|
||||
height: baseRect.height * scale
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteTouchInputView: UIViewRepresentable {
|
||||
let videoSize: CGSize
|
||||
let controlMode: MouseControlMode
|
||||
let remoteCursorPosition: CGPoint?
|
||||
let viewportScale: CGFloat
|
||||
let viewportOffset: CGSize
|
||||
let viewportChanged: (CGFloat, CGSize) -> Void
|
||||
let move: (Float, Float) -> Void
|
||||
let leftDown: (Float, Float) -> Void
|
||||
let leftUp: (Float, Float) -> Void
|
||||
let rightClick: (Float, Float) -> Void
|
||||
let scroll: (Float, Float, Int, Int) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> RemoteTouchSurface {
|
||||
RemoteTouchSurface()
|
||||
}
|
||||
|
||||
func updateUIView(_ view: RemoteTouchSurface, context: Context) {
|
||||
view.videoSize = videoSize
|
||||
view.controlMode = controlMode
|
||||
view.setViewport(scale: viewportScale, offset: viewportOffset)
|
||||
view.onViewportChanged = viewportChanged
|
||||
view.onMove = move
|
||||
view.onLeftDown = leftDown
|
||||
view.onLeftUp = leftUp
|
||||
view.onRightClick = rightClick
|
||||
view.onScroll = scroll
|
||||
view.synchronizeRemoteCursor(remoteCursorPosition)
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
var videoSize = CGSize.zero {
|
||||
didSet { setNeedsLayout() }
|
||||
}
|
||||
var controlMode: MouseControlMode = .absolute {
|
||||
didSet {
|
||||
guard oldValue != controlMode else { return }
|
||||
relativeCursorPoint = lastRemoteCursorPoint ?? (0.5, 0.5)
|
||||
relativePanActive = false
|
||||
heldDragPoint = nil
|
||||
heldDragLastLocation = nil
|
||||
hoverLastLocation = nil
|
||||
}
|
||||
}
|
||||
var onMove: ((Float, Float) -> Void)?
|
||||
var onLeftDown: ((Float, Float) -> Void)?
|
||||
var onLeftUp: ((Float, Float) -> Void)?
|
||||
var onRightClick: ((Float, Float) -> Void)?
|
||||
var onScroll: ((Float, Float, Int, Int) -> Void)?
|
||||
var onViewportChanged: ((CGFloat, CGSize) -> Void)?
|
||||
|
||||
private var relativeCursorPoint: (Float, Float) = (0.5, 0.5)
|
||||
private var lastRemoteCursorPoint: (Float, Float)?
|
||||
private var relativePanActive = false
|
||||
private var heldDragPoint: (Float, Float)?
|
||||
private var heldDragLastLocation: CGPoint?
|
||||
private var hoverLastLocation: CGPoint?
|
||||
private var viewportScale: CGFloat = 1
|
||||
private var viewportOffset = CGSize.zero
|
||||
private var pinchActive = false
|
||||
private var viewportPanActive = false
|
||||
private let relativePointerSpeed: CGFloat = 1.35
|
||||
private let maximumViewportScale: CGFloat = 10
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isMultipleTouchEnabled = true
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
tap.numberOfTouchesRequired = 1
|
||||
addGestureRecognizer(tap)
|
||||
|
||||
let rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap(_:)))
|
||||
rightTap.numberOfTouchesRequired = 2
|
||||
tap.require(toFail: rightTap)
|
||||
addGestureRecognizer(rightTap)
|
||||
|
||||
let drag = UIPanGestureRecognizer(target: self, action: #selector(handleDrag(_:)))
|
||||
drag.minimumNumberOfTouches = 1
|
||||
drag.maximumNumberOfTouches = 1
|
||||
drag.delegate = self
|
||||
addGestureRecognizer(drag)
|
||||
|
||||
let heldDrag = UILongPressGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(handleHeldDrag(_:))
|
||||
)
|
||||
heldDrag.minimumPressDuration = 0.35
|
||||
heldDrag.allowableMovement = 14
|
||||
heldDrag.numberOfTouchesRequired = 1
|
||||
heldDrag.delegate = self
|
||||
addGestureRecognizer(heldDrag)
|
||||
|
||||
let pinch = UIPinchGestureRecognizer(target: self,
|
||||
action: #selector(handlePinch(_:)))
|
||||
pinch.delegate = self
|
||||
addGestureRecognizer(pinch)
|
||||
|
||||
let wheel = UIPanGestureRecognizer(target: self, action: #selector(handleWheel(_:)))
|
||||
wheel.minimumNumberOfTouches = 2
|
||||
wheel.maximumNumberOfTouches = 2
|
||||
wheel.delegate = self
|
||||
addGestureRecognizer(wheel)
|
||||
|
||||
let hover = UIHoverGestureRecognizer(target: self,
|
||||
action: #selector(handleHover(_:)))
|
||||
addGestureRecognizer(hover)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let constrained = constrainedViewportOffset(viewportOffset,
|
||||
scale: viewportScale)
|
||||
guard constrained != viewportOffset else { return }
|
||||
viewportOffset = constrained
|
||||
publishViewport()
|
||||
}
|
||||
|
||||
func setViewport(scale: CGFloat, offset: CGSize) {
|
||||
let nextScale = min(max(scale, 1), maximumViewportScale)
|
||||
viewportScale = nextScale
|
||||
viewportOffset = constrainedViewportOffset(offset, scale: nextScale)
|
||||
}
|
||||
|
||||
func synchronizeRemoteCursor(_ position: CGPoint?) {
|
||||
guard let position else {
|
||||
lastRemoteCursorPoint = nil
|
||||
return
|
||||
}
|
||||
let point = (
|
||||
Float(min(max(position.x, 0), 1)),
|
||||
Float(min(max(position.y, 0), 1))
|
||||
)
|
||||
lastRemoteCursorPoint = point
|
||||
applyRemoteCursorIfIdle()
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
let recognizers = [gestureRecognizer, otherGestureRecognizer]
|
||||
let hasPinch = recognizers.contains { $0 is UIPinchGestureRecognizer }
|
||||
let hasTwoFingerPan = recognizers.contains { recognizer in
|
||||
guard let pan = recognizer as? UIPanGestureRecognizer else {
|
||||
return false
|
||||
}
|
||||
return pan.minimumNumberOfTouches == 2
|
||||
}
|
||||
return hasPinch && hasTwoFingerPan
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
|
||||
let location = recognizer.location(in: self)
|
||||
guard recognizer.state == .ended,
|
||||
let point = pointerPoint(for: location) else { return }
|
||||
#if DEBUG
|
||||
NSLog("CrossDesk tap location=%@ normalized=(%.5f, %.5f) " +
|
||||
"render=%@ scale=%.4f offset=%@ mode=%@",
|
||||
String(describing: location), point.0, point.1,
|
||||
renderRect().map { String(describing: $0) } ?? "nil",
|
||||
viewportScale, String(describing: viewportOffset),
|
||||
controlMode == .relative ? "relative" : "absolute")
|
||||
#endif
|
||||
onMove?(point.0, point.1)
|
||||
onLeftDown?(point.0, point.1)
|
||||
onLeftUp?(point.0, point.1)
|
||||
}
|
||||
|
||||
@objc private func handleRightTap(_ recognizer: UITapGestureRecognizer) {
|
||||
guard recognizer.state == .ended,
|
||||
let point = pointerPoint(for: recognizer.location(in: self)) else { return }
|
||||
onMove?(point.0, point.1)
|
||||
onRightClick?(point.0, point.1)
|
||||
}
|
||||
|
||||
@objc private func handleDrag(_ recognizer: UIPanGestureRecognizer) {
|
||||
if controlMode == .relative {
|
||||
handleRelativeDrag(recognizer)
|
||||
return
|
||||
}
|
||||
|
||||
switch recognizer.state {
|
||||
case .began, .changed:
|
||||
guard let point = normalized(recognizer.location(in: self)) else { return }
|
||||
onMove?(point.0, point.1)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
|
||||
if controlMode == .relative {
|
||||
handleRelativeHeldDrag(recognizer)
|
||||
return
|
||||
}
|
||||
|
||||
let point = normalized(recognizer.location(in: self)) ?? heldDragPoint
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
guard let point else { return }
|
||||
heldDragPoint = point
|
||||
onMove?(point.0, point.1)
|
||||
onLeftDown?(point.0, point.1)
|
||||
case .changed:
|
||||
guard let point else { return }
|
||||
heldDragPoint = point
|
||||
onMove?(point.0, point.1)
|
||||
case .ended, .cancelled, .failed:
|
||||
if let point {
|
||||
onLeftUp?(point.0, point.1)
|
||||
}
|
||||
heldDragPoint = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
|
||||
let focus = recognizer.location(in: self)
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
pinchActive = renderRect()?.contains(focus) == true
|
||||
recognizer.scale = 1
|
||||
case .changed:
|
||||
guard pinchActive else { return }
|
||||
let oldScale = viewportScale
|
||||
let newScale = min(max(oldScale * recognizer.scale, 1),
|
||||
maximumViewportScale)
|
||||
recognizer.scale = 1
|
||||
guard abs(newScale - oldScale) > 0.0001 else { return }
|
||||
|
||||
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
let ratio = newScale / oldScale
|
||||
let proposedOffset = CGSize(
|
||||
width: focus.x - center.x -
|
||||
(focus.x - center.x - viewportOffset.width) * ratio,
|
||||
height: focus.y - center.y -
|
||||
(focus.y - center.y - viewportOffset.height) * ratio
|
||||
)
|
||||
viewportScale = newScale
|
||||
viewportOffset = constrainedViewportOffset(proposedOffset,
|
||||
scale: newScale)
|
||||
publishViewport()
|
||||
case .ended, .cancelled, .failed:
|
||||
pinchActive = false
|
||||
if viewportScale <= 1.001 {
|
||||
viewportScale = 1
|
||||
viewportOffset = .zero
|
||||
publishViewport()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleWheel(_ recognizer: UIPanGestureRecognizer) {
|
||||
if viewportScale > 1.001 || pinchActive {
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
viewportPanActive = renderRect()?.contains(
|
||||
recognizer.location(in: self)
|
||||
) == true
|
||||
recognizer.setTranslation(.zero, in: self)
|
||||
case .changed:
|
||||
if !viewportPanActive {
|
||||
viewportPanActive = renderRect()?.contains(
|
||||
recognizer.location(in: self)
|
||||
) == true
|
||||
}
|
||||
guard viewportPanActive else { return }
|
||||
let translation = recognizer.translation(in: self)
|
||||
recognizer.setTranslation(.zero, in: self)
|
||||
let proposedOffset = CGSize(
|
||||
width: viewportOffset.width + translation.x,
|
||||
height: viewportOffset.height + translation.y
|
||||
)
|
||||
viewportOffset = constrainedViewportOffset(proposedOffset,
|
||||
scale: viewportScale)
|
||||
publishViewport()
|
||||
default:
|
||||
viewportPanActive = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let point = pointerPoint(for: recognizer.location(in: self)) else { return }
|
||||
let translation = recognizer.translation(in: self)
|
||||
let deltaX = Int(-translation.x / 12)
|
||||
let deltaY = Int(-translation.y / 12)
|
||||
if deltaX != 0 || deltaY != 0 {
|
||||
onScroll?(point.0, point.1, deltaX, deltaY)
|
||||
recognizer.setTranslation(.zero, in: self)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
|
||||
let location = recognizer.location(in: self)
|
||||
if controlMode == .relative {
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
guard renderRect()?.contains(location) == true else { return }
|
||||
hoverLastLocation = location
|
||||
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
|
||||
case .changed:
|
||||
guard let previous = hoverLastLocation else { return }
|
||||
hoverLastLocation = location
|
||||
if let point = updateRelativeCursor(
|
||||
by: CGPoint(x: location.x - previous.x,
|
||||
y: location.y - previous.y)
|
||||
) {
|
||||
onMove?(point.0, point.1)
|
||||
}
|
||||
default:
|
||||
hoverLastLocation = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard recognizer.state == .began || recognizer.state == .changed,
|
||||
let point = normalized(location) else { return }
|
||||
onMove?(point.0, point.1)
|
||||
}
|
||||
|
||||
private func handleRelativeDrag(_ recognizer: UIPanGestureRecognizer) {
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
let location = recognizer.location(in: self)
|
||||
relativePanActive = renderRect()?.contains(location) == true
|
||||
recognizer.setTranslation(.zero, in: self)
|
||||
if relativePanActive {
|
||||
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
|
||||
}
|
||||
case .changed:
|
||||
guard relativePanActive else { return }
|
||||
let translation = recognizer.translation(in: self)
|
||||
recognizer.setTranslation(.zero, in: self)
|
||||
if let point = updateRelativeCursor(by: translation) {
|
||||
onMove?(point.0, point.1)
|
||||
}
|
||||
default:
|
||||
relativePanActive = false
|
||||
applyRemoteCursorIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRelativeHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
|
||||
let location = recognizer.location(in: self)
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
guard renderRect()?.contains(location) == true else { return }
|
||||
heldDragLastLocation = location
|
||||
heldDragPoint = relativeCursorPoint
|
||||
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
|
||||
onLeftDown?(relativeCursorPoint.0, relativeCursorPoint.1)
|
||||
case .changed:
|
||||
guard let previous = heldDragLastLocation else { return }
|
||||
heldDragLastLocation = location
|
||||
if let point = updateRelativeCursor(
|
||||
by: CGPoint(x: location.x - previous.x,
|
||||
y: location.y - previous.y)
|
||||
) {
|
||||
heldDragPoint = point
|
||||
onMove?(point.0, point.1)
|
||||
}
|
||||
case .ended, .cancelled, .failed:
|
||||
if let point = heldDragPoint {
|
||||
onLeftUp?(point.0, point.1)
|
||||
}
|
||||
heldDragPoint = nil
|
||||
heldDragLastLocation = nil
|
||||
applyRemoteCursorIfIdle()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func pointerPoint(for location: CGPoint) -> (Float, Float)? {
|
||||
if controlMode == .relative {
|
||||
guard renderRect()?.contains(location) == true else { return nil }
|
||||
return relativeCursorPoint
|
||||
}
|
||||
return normalized(location)
|
||||
}
|
||||
|
||||
private func updateRelativeCursor(by translation: CGPoint) -> (Float, Float)? {
|
||||
guard let renderRect = renderRect(),
|
||||
renderRect.width > 0, renderRect.height > 0 else { return nil }
|
||||
let nextX = CGFloat(relativeCursorPoint.0) +
|
||||
translation.x / renderRect.width * relativePointerSpeed
|
||||
let nextY = CGFloat(relativeCursorPoint.1) +
|
||||
translation.y / renderRect.height * relativePointerSpeed
|
||||
relativeCursorPoint = (
|
||||
Float(min(max(nextX, 0), 1)),
|
||||
Float(min(max(nextY, 0), 1))
|
||||
)
|
||||
return relativeCursorPoint
|
||||
}
|
||||
|
||||
private func applyRemoteCursorIfIdle() {
|
||||
guard controlMode == .relative,
|
||||
!relativePanActive,
|
||||
heldDragPoint == nil,
|
||||
let point = lastRemoteCursorPoint else { return }
|
||||
relativeCursorPoint = point
|
||||
}
|
||||
|
||||
private func normalized(_ point: CGPoint) -> (Float, Float)? {
|
||||
guard let renderRect = renderRect(), renderRect.contains(point) else { return nil }
|
||||
return (Float((point.x - renderRect.minX) / renderRect.width),
|
||||
Float((point.y - renderRect.minY) / renderRect.height))
|
||||
}
|
||||
|
||||
private func renderRect() -> CGRect? {
|
||||
RemoteVideoGeometry.transformedRect(
|
||||
containerSize: bounds.size,
|
||||
videoSize: videoSize,
|
||||
scale: viewportScale,
|
||||
offset: viewportOffset
|
||||
)
|
||||
}
|
||||
|
||||
private func baseRenderRect() -> CGRect? {
|
||||
RemoteVideoGeometry.aspectFitRect(containerSize: bounds.size,
|
||||
videoSize: videoSize)
|
||||
}
|
||||
|
||||
private func constrainedViewportOffset(_ offset: CGSize,
|
||||
scale: CGFloat) -> CGSize {
|
||||
guard let baseRect = baseRenderRect() else { return offset }
|
||||
let maximumX = max(0, baseRect.width * (scale - 1) / 2)
|
||||
let maximumY = max(0, baseRect.height * (scale - 1) / 2)
|
||||
return CGSize(
|
||||
width: min(max(offset.width, -maximumX), maximumX),
|
||||
height: min(max(offset.height, -maximumY), maximumY)
|
||||
)
|
||||
}
|
||||
|
||||
private func publishViewport() {
|
||||
onViewportChanged?(viewportScale, viewportOffset)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user