Files
crossdesk/apps/ios/CrossDeskMobile/RemoteSessionView.swift
T

1372 lines
52 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import UIKit
import UniformTypeIdentifiers
private enum RemoteKeyboardPalette {
static let background = Color(red: 0.82, green: 0.84, blue: 0.87)
static let characterKey = Color.white
static let specialKey = Color(red: 0.68, green: 0.71, blue: 0.75)
static let accentKey = Color(red: 0.04, green: 0.48, blue: 1.00)
static let keyText = Color.black.opacity(0.88)
}
private enum RemoteKeyboardMetrics {
static let rowHeight: CGFloat = 31
static let keySpacing: CGFloat = 3
static let rowSpacing: CGFloat = 3
static let cornerRadius: CGFloat = 5
static let panelPadding: CGFloat = 5
static let dragHandleHeight: CGFloat = 10
}
private enum RemoteKeyboardMode: String {
case system
case computer
}
private struct RemoteViewportState: Equatable {
var scale: CGFloat = 1
var offset = CGSize.zero
}
struct RemoteSessionView: View {
@ObservedObject var session: RemoteSessionModel
@State private var keyboardInputVisible = false
@State private var keyboardMode: RemoteKeyboardMode = .system
@State private var viewport = RemoteViewportState()
@State private var showingFileImporter = false
@State private var disconnectConfirmationVisible = false
@State private var cursorPosition: CGPoint?
@State private var statusMenuVisible = false
@State private var statusOrbCenter: CGPoint?
@State private var keyboardPanelCenter: CGPoint?
@GestureState private var statusOrbDragTranslation = CGSize.zero
@GestureState private var keyboardDragTranslation = CGSize.zero
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
GeometryReader { proxy in
ZStack(alignment: .topLeading) {
if let videoRect = RemoteVideoGeometry.aspectFitRect(
containerSize: proxy.size,
videoSize: session.displayGeometrySize
) {
ZStack(alignment: .topLeading) {
NativeVideoView(pixelBuffer: session.pixelBuffer)
if let cursorPosition,
session.pixelBuffer != nil {
RemoteCursorOverlay(
normalizedPosition: cursorPosition,
normalizedVisualOffset:
session.remoteCursorVisualOffset,
contentSize: videoRect.size,
viewportScale: viewport.scale,
shape: visibleCursorShape
)
.allowsHitTesting(false)
}
}
.frame(width: videoRect.width,
height: videoRect.height)
.position(x: videoRect.midX, y: videoRect.midY)
.scaleEffect(viewport.scale, anchor: .center)
.offset(viewport.offset)
}
RemoteTouchInputView(
videoSize: session.displayGeometrySize,
controlMode: session.mouseControlMode,
remoteCursorPosition: session.remoteCursorPosition,
remoteCursorPositionRevision: session.remoteCursorPositionRevision,
viewportScale: viewport.scale,
viewportOffset: viewport.offset,
viewportChanged: { scale, offset in
// Scale and offset describe one affine transform.
// Publish them atomically so all layers observe the
// same viewport.
viewport = RemoteViewportState(scale: scale,
offset: offset)
},
move: { x, y in
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
session.bridge.sendPointer(x: x, y: y, action: .move)
},
leftDown: { x, y in
session.bridge.sendPointer(x: x, y: y, action: .leftDown)
},
leftUp: { x, y in
session.bridge.sendPointer(x: x, y: y, action: .leftUp)
},
rightClick: { x, y in
session.bridge.sendPointer(x: x, y: y, action: .rightDown)
session.bridge.sendPointer(x: x, y: y, action: .rightUp)
},
scroll: { x, y, dx, dy in
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
session.bridge.sendScrollX(x, y: y,
deltaX: dx, deltaY: dy)
}
)
.frame(width: proxy.size.width, height: proxy.size.height)
.allowsHitTesting(session.pixelBuffer != nil)
}
.frame(width: proxy.size.width, height: proxy.size.height)
}
.ignoresSafeArea()
.transaction { transaction in
transaction.animation = nil
transaction.disablesAnimations = true
}
if session.pixelBuffer == nil {
VStack(spacing: 12) {
ProgressView()
.tint(.white)
Text(session.videoStatus)
.font(.callout.monospacedDigit())
.foregroundStyle(.white)
}
.padding(20)
.background(.black.opacity(0.55), in: RoundedRectangle(cornerRadius: 14))
.allowsHitTesting(true)
}
if keyboardInputVisible {
draggableKeyboard
.zIndex(20)
}
floatingStatusControls
.zIndex(30)
if disconnectConfirmationVisible {
disconnectConfirmationOverlay
.zIndex(50)
}
}
.alert("升级提示", isPresented: Binding(
get: { session.remoteUpdateMessage != nil },
set: { if !$0 { session.dismissRemoteUpdate() } }
)) {
Button("知道了", role: .cancel) { session.dismissRemoteUpdate() }
} message: {
Text(session.remoteUpdateMessage ?? "")
}
.onDisappear {
keyboardInputVisible = false
viewport = RemoteViewportState()
cursorPosition = nil
statusMenuVisible = false
statusOrbCenter = nil
keyboardPanelCenter = nil
}
.onChange(of: session.selectedDisplay) { _ in
cursorPosition = nil
viewport = RemoteViewportState()
}
.onChange(of: session.mouseControlMode) { _ in
cursorPosition = session.remoteCursorPosition
}
.onChange(of: session.remoteCursorPositionRevision) { _ in
guard let position = session.remoteCursorPosition else { return }
cursorPosition = position
}
.fileImporter(isPresented: $showingFileImporter,
allowedContentTypes: [.item],
allowsMultipleSelection: false) { result in
if case let .success(urls) = result, let url = urls.first {
session.sendFile(url)
}
}
}
private var disconnectConfirmationOverlay: some View {
GeometryReader { proxy in
ZStack {
Color.black.opacity(0.28)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeOut(duration: 0.16)) {
disconnectConfirmationVisible = false
}
}
VStack(spacing: 0) {
VStack(spacing: 4) {
Text("断开远程连接?")
.font(.headline)
.foregroundStyle(Color.black.opacity(0.9))
Text("断开后将返回首页")
.font(.subheadline)
.foregroundStyle(Color.black.opacity(0.55))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
Divider()
Button {
disconnectConfirmationVisible = false
session.disconnect()
} label: {
Text("断开连接")
.font(.body.weight(.semibold))
.foregroundStyle(Color.red)
.frame(maxWidth: .infinity)
.frame(height: 45)
}
Divider()
Button {
withAnimation(.easeOut(duration: 0.16)) {
disconnectConfirmationVisible = false
}
} label: {
Text("取消")
.font(.body.weight(.semibold))
.foregroundStyle(Color.blue)
.frame(maxWidth: .infinity)
.frame(height: 45)
}
}
.background(
Color.white,
in: RoundedRectangle(cornerRadius: 16,
style: .continuous)
)
.clipShape(RoundedRectangle(cornerRadius: 16,
style: .continuous))
.shadow(color: .black.opacity(0.2), radius: 18, y: 7)
.frame(width: min(250, max(220, proxy.size.width - 32)))
.position(x: proxy.size.width / 2,
y: proxy.size.height / 2)
.transition(.scale(scale: 0.96).combined(with: .opacity))
}
.frame(width: proxy.size.width, height: proxy.size.height)
}
.ignoresSafeArea()
}
private var floatingStatusControls: some View {
// Keep the orb and expanded menu inside the safe area so their initial
// positions and drag bounds clear the Dynamic Island in either orientation.
GeometryReader { proxy in
let containerSize = proxy.size
let restingOrbCenter = boundedOrbCenter(in: containerSize)
let orbCenter = constrainedOrbCenter(
CGPoint(x: restingOrbCenter.x + statusOrbDragTranslation.width,
y: restingOrbCenter.y + statusOrbDragTranslation.height),
in: containerSize
)
let panelSize = CGSize(
width: min(340, max(280, containerSize.width - 160)),
height: min(250, max(220, containerSize.height - 48))
)
let panelCenter = floatingPanelCenter(
orbCenter: orbCenter,
panelSize: panelSize,
containerSize: containerSize
)
ZStack {
if statusMenuVisible {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeOut(duration: 0.14)) {
statusMenuVisible = false
}
}
FloatingSessionMenu(
session: session,
showKeyboard: {
statusMenuVisible = false
keyboardInputVisible.toggle()
},
chooseFile: {
statusMenuVisible = false
showingFileImporter = true
},
close: {
withAnimation(.easeOut(duration: 0.14)) {
statusMenuVisible = false
}
},
disconnect: {
statusMenuVisible = false
keyboardInputVisible = false
withAnimation(.easeOut(duration: 0.18)) {
disconnectConfirmationVisible = true
}
}
)
.frame(width: panelSize.width, height: panelSize.height)
.position(panelCenter)
.transition(.scale(scale: 0.94, anchor: .topTrailing)
.combined(with: .opacity))
}
CrossDeskStatusOrb(isExpanded: statusMenuVisible)
.frame(width: 54, height: 54)
.contentShape(Circle())
.position(orbCenter)
.onTapGesture {
withAnimation(.spring(response: 0.24,
dampingFraction: 0.84)) {
statusMenuVisible.toggle()
}
}
.gesture(
DragGesture(minimumDistance: 1)
.updating($statusOrbDragTranslation) { value, state, _ in
state = value.translation
}
.onChanged { _ in
if statusMenuVisible {
statusMenuVisible = false
}
}
.onEnded { value in
statusOrbCenter = constrainedOrbCenter(
CGPoint(x: restingOrbCenter.x + value.translation.width,
y: restingOrbCenter.y + value.translation.height),
in: containerSize
)
}
)
}
}
}
private var draggableKeyboard: some View {
GeometryReader { proxy in
let containerSize = proxy.size
let panelSize = keyboardPanelSize(in: containerSize)
let restingCenter = constrainedKeyboardCenter(
keyboardPanelCenter ?? defaultKeyboardCenter(
in: containerSize,
panelSize: panelSize
),
in: containerSize,
panelSize: panelSize
)
let visibleCenter = constrainedKeyboardCenter(
CGPoint(
x: restingCenter.x + keyboardDragTranslation.width,
y: restingCenter.y + keyboardDragTranslation.height
),
in: containerSize,
panelSize: panelSize
)
remoteKeyboardPanel(containerSize: containerSize,
panelSize: panelSize)
.frame(width: panelSize.width, height: panelSize.height)
.position(restingCenter)
.offset(x: visibleCenter.x - restingCenter.x,
y: visibleCenter.y - restingCenter.y)
.transaction { transaction in
transaction.animation = nil
}
}
}
private func remoteKeyboardPanel(containerSize: CGSize,
panelSize: CGSize) -> some View {
VStack(spacing: RemoteKeyboardMetrics.rowSpacing) {
Capsule()
.fill(Color.black.opacity(0.28))
.frame(width: 38, height: 4)
.frame(maxWidth: .infinity)
.frame(height: RemoteKeyboardMetrics.dragHandleHeight)
.contentShape(Rectangle())
.gesture(keyboardDragGesture(containerSize: containerSize,
panelSize: panelSize))
.accessibilityLabel("拖动键盘")
Group {
if keyboardMode == .system {
RemoteSystemKeyboard(
session: session,
switchKeyboard: { keyboardMode = .computer },
dismiss: { keyboardInputVisible = false }
)
} else {
RemoteComputerKeyboard(
session: session,
switchKeyboard: { keyboardMode = .system },
dismiss: { keyboardInputVisible = false }
)
}
}
}
.padding(RemoteKeyboardMetrics.panelPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(RemoteKeyboardPalette.background,
in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.shadow(color: .black.opacity(0.2), radius: 10, y: 4)
}
private func keyboardPanelSize(in containerSize: CGSize) -> CGSize {
let rowCount: CGFloat = keyboardMode == .system ? 4 : 6
let keyboardHeight = rowCount * RemoteKeyboardMetrics.rowHeight +
(rowCount - 1) * RemoteKeyboardMetrics.rowSpacing
let height = keyboardHeight +
RemoteKeyboardMetrics.dragHandleHeight +
RemoteKeyboardMetrics.rowSpacing +
RemoteKeyboardMetrics.panelPadding * 2
return CGSize(width: min(780, max(0, containerSize.width - 12)),
height: height)
}
private func defaultKeyboardCenter(in containerSize: CGSize,
panelSize: CGSize) -> CGPoint {
CGPoint(x: containerSize.width / 2,
y: containerSize.height - panelSize.height / 2 - 6)
}
private func constrainedKeyboardCenter(_ point: CGPoint,
in containerSize: CGSize,
panelSize: CGSize) -> CGPoint {
let margin: CGFloat = 6
let minimumX = panelSize.width / 2 + margin
let maximumX = max(minimumX,
containerSize.width - panelSize.width / 2 - margin)
let minimumY = panelSize.height / 2 + margin
let maximumY = max(minimumY,
containerSize.height - panelSize.height / 2 - margin)
return CGPoint(x: min(max(point.x, minimumX), maximumX),
y: min(max(point.y, minimumY), maximumY))
}
private func keyboardDragGesture(containerSize: CGSize,
panelSize: CGSize) -> some Gesture {
DragGesture(minimumDistance: 1, coordinateSpace: .global)
.updating($keyboardDragTranslation) { value, state, _ in
state = value.translation
}
.onEnded { value in
let restingCenter = constrainedKeyboardCenter(
keyboardPanelCenter ?? defaultKeyboardCenter(
in: containerSize,
panelSize: panelSize
),
in: containerSize,
panelSize: panelSize
)
keyboardPanelCenter = constrainedKeyboardCenter(
CGPoint(x: restingCenter.x + value.translation.width,
y: restingCenter.y + value.translation.height),
in: containerSize,
panelSize: panelSize
)
}
}
private func boundedOrbCenter(in size: CGSize) -> CGPoint {
let initial = statusOrbCenter ?? CGPoint(x: size.width - 39, y: 44)
return constrainedOrbCenter(initial, in: size)
}
private func constrainedOrbCenter(_ point: CGPoint, in size: CGSize) -> CGPoint {
let horizontalMargin: CGFloat = 39
let verticalMargin: CGFloat = 39
return CGPoint(
x: min(max(point.x, horizontalMargin),
max(horizontalMargin, size.width - horizontalMargin)),
y: min(max(point.y, verticalMargin),
max(verticalMargin, size.height - verticalMargin))
)
}
private func floatingPanelCenter(orbCenter: CGPoint,
panelSize: CGSize,
containerSize: CGSize) -> CGPoint {
let gap: CGFloat = 12
let orbRadius: CGFloat = 27
let horizontalPadding: CGFloat = 8
let verticalPadding: CGFloat = 8
let preferredX = orbCenter.x > containerSize.width / 2
? orbCenter.x - orbRadius - gap - panelSize.width / 2
: orbCenter.x + orbRadius + gap + panelSize.width / 2
return CGPoint(
x: min(max(preferredX, panelSize.width / 2 + horizontalPadding),
containerSize.width - panelSize.width / 2 - horizontalPadding),
y: min(max(orbCenter.y, panelSize.height / 2 + verticalPadding),
containerSize.height - panelSize.height / 2 - verticalPadding)
)
}
private var visibleCursorShape: Int {
guard session.hasRemoteCursorState,
session.remoteCursorVisible,
session.remoteCursorShape != 1 else {
// The desktop hides its native cursor from the captured frame and
// can therefore report a hidden cursor while remote input is
// active. Keep the iOS-side touch cursor visible in that state.
return 0
}
return session.remoteCursorShape
}
}
private enum RemoteSystemKeyboardPage {
case letters
case numbers
case symbols
}
private enum RemoteSystemKeyAction {
case text(String)
case shift
case backspace
case returnKey
case page(RemoteSystemKeyboardPage)
case switchKeyboard
case dismiss
}
private enum RemoteSystemKeyStyle {
case character
case special
case accent
}
private struct RemoteKeyboardKeyCap: View {
let title: String?
let symbol: String?
let style: RemoteSystemKeyStyle
let active: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Group {
if let symbol {
Image(systemName: symbol)
.font(.system(size: 14, weight: .semibold))
} else {
Text(title ?? "")
.font(.system(size: labelFontSize,
weight: .medium,
design: .rounded))
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.62)
}
}
.foregroundStyle(usesAccentColor
? Color.white : RemoteKeyboardPalette.keyText)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(backgroundColor,
in: RoundedRectangle(
cornerRadius: RemoteKeyboardMetrics.cornerRadius,
style: .continuous
))
.shadow(color: .black.opacity(0.14), radius: 0.4, y: 0.8)
}
.buttonStyle(RemoteKeyboardPressStyle())
}
private var labelFontSize: CGFloat {
guard let title else { return 14 }
if title.contains("\n") { return 9 }
switch title.count {
case 0...1: return 14
case 2...3: return 11.5
default: return 10
}
}
private var usesAccentColor: Bool {
if active { return true }
if case .accent = style { return true }
return false
}
private var backgroundColor: Color {
if usesAccentColor { return RemoteKeyboardPalette.accentKey }
switch style {
case .character: return RemoteKeyboardPalette.characterKey
case .special: return RemoteKeyboardPalette.specialKey
case .accent: return RemoteKeyboardPalette.accentKey
}
}
}
private struct RemoteKeyboardPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.brightness(configuration.isPressed ? -0.08 : 0)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
}
}
private struct RemoteSystemKey {
let title: String?
let symbol: String?
let width: CGFloat
let style: RemoteSystemKeyStyle
let action: RemoteSystemKeyAction
init(_ title: String,
width: CGFloat = 1,
style: RemoteSystemKeyStyle = .character,
action: RemoteSystemKeyAction) {
self.title = title
self.symbol = nil
self.width = width
self.style = style
self.action = action
}
init(symbol: String,
width: CGFloat = 1,
style: RemoteSystemKeyStyle = .special,
action: RemoteSystemKeyAction) {
self.title = nil
self.symbol = symbol
self.width = width
self.style = style
self.action = action
}
var isShift: Bool {
if case .shift = action { return true }
return false
}
var isLetter: Bool {
guard case let .text(value) = action else { return false }
return value.rangeOfCharacter(from: .letters) != nil
}
}
private struct RemoteSystemKeyboard: View {
@ObservedObject var session: RemoteSessionModel
let switchKeyboard: () -> Void
let dismiss: () -> Void
@State private var page: RemoteSystemKeyboardPage = .letters
@State private var shiftEnabled = false
var body: some View {
VStack(spacing: RemoteKeyboardMetrics.rowSpacing) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
RemoteSystemKeyboardRow(
keys: row,
shiftEnabled: shiftEnabled,
action: handleKey
)
}
}
}
private var rows: [[RemoteSystemKey]] {
switch page {
case .letters:
return letterRows
case .numbers:
return numberRows
case .symbols:
return symbolRows
}
}
private var letterRows: [[RemoteSystemKey]] {
[
textKeys("qwertyuiop"),
textKeys("asdfghjkl"),
[RemoteSystemKey(symbol: "shift",
width: 1.55,
action: .shift)] +
textKeys("zxcvbnm") +
[RemoteSystemKey(symbol: "delete.left",
width: 1.55,
action: .backspace)],
bottomRow(pageTitle: "123", targetPage: .numbers)
]
}
private var numberRows: [[RemoteSystemKey]] {
[
valueKeys(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]),
valueKeys(["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""]),
[RemoteSystemKey("#+=", width: 1.55, style: .special,
action: .page(.symbols))] +
valueKeys([".", ",", "?", "!", "'"]) +
[RemoteSystemKey(symbol: "delete.left", width: 1.55,
action: .backspace)],
bottomRow(pageTitle: "ABC", targetPage: .letters)
]
}
private var symbolRows: [[RemoteSystemKey]] {
[
valueKeys(["[", "]", "{", "}", "#", "%", "^", "*", "+", "="]),
valueKeys(["_", "\\", "|", "~", "<", ">", "$", "&", "@", "`"]),
[RemoteSystemKey("123", width: 1.55, style: .special,
action: .page(.numbers))] +
valueKeys([".", ",", "?", "!", "'"]) +
[RemoteSystemKey(symbol: "delete.left", width: 1.55,
action: .backspace)],
bottomRow(pageTitle: "ABC", targetPage: .letters)
]
}
private func textKeys(_ letters: String) -> [RemoteSystemKey] {
letters.map { character in
let value = String(character)
return RemoteSystemKey(value, action: .text(value))
}
}
private func valueKeys(_ values: [String]) -> [RemoteSystemKey] {
values.map { RemoteSystemKey($0, action: .text($0)) }
}
private func bottomRow(pageTitle: String,
targetPage: RemoteSystemKeyboardPage)
-> [RemoteSystemKey] {
[
RemoteSystemKey(pageTitle, width: 1.45, style: .special,
action: .page(targetPage)),
RemoteSystemKey(symbol: "arrow.left.arrow.right", width: 1.2,
action: .switchKeyboard),
RemoteSystemKey("空格", width: 5, action: .text(" ")),
RemoteSystemKey("回车", width: 1.55, style: .accent,
action: .returnKey),
RemoteSystemKey(symbol: "keyboard.chevron.compact.down", width: 1.2,
action: .dismiss)
]
}
private func handleKey(_ key: RemoteSystemKey) {
switch key.action {
case let .text(value):
let isLetter = value.rangeOfCharacter(from: .letters) != nil
session.bridge.sendText(shiftEnabled && isLetter
? value.uppercased() : value)
case .shift:
withTransaction(Transaction(animation: nil)) {
shiftEnabled.toggle()
}
case .backspace:
session.sendKeyStroke(0x08)
case .returnKey:
session.sendKeyStroke(0x0D)
case let .page(newPage):
page = newPage
case .switchKeyboard:
switchKeyboard()
case .dismiss:
dismiss()
}
}
}
private struct RemoteSystemKeyboardRow: View {
let keys: [RemoteSystemKey]
let shiftEnabled: Bool
let action: (RemoteSystemKey) -> Void
var body: some View {
GeometryReader { proxy in
let spacing = RemoteKeyboardMetrics.keySpacing
let totalWidth = keys.reduce(CGFloat.zero) { $0 + $1.width }
let keySpace = max(0, proxy.size.width -
CGFloat(max(0, keys.count - 1)) * spacing)
HStack(spacing: spacing) {
ForEach(Array(keys.enumerated()), id: \.offset) { _, key in
let isActive = key.isShift && shiftEnabled
let visibleTitle = shiftEnabled && key.isLetter
? key.title?.uppercased() : key.title
let visibleSymbol = isActive && key.isShift
? "shift.fill" : key.symbol
RemoteKeyboardKeyCap(
title: visibleTitle,
symbol: visibleSymbol,
style: key.style,
active: isActive
) {
action(key)
}
.id("\(visibleTitle ?? visibleSymbol ?? "key")-\(isActive)")
.frame(width: keySpace * key.width / totalWidth)
.accessibilityLabel(visibleTitle ?? "键盘操作")
}
}
}
.frame(height: RemoteKeyboardMetrics.rowHeight)
}
}
private enum RemoteComputerKeyAction {
case stroke(UInt)
case modifier(UInt)
case switchKeyboard
case dismiss
}
private struct RemoteComputerKey {
let title: String?
let symbol: String?
let width: CGFloat
let style: RemoteSystemKeyStyle
let action: RemoteComputerKeyAction
init(_ title: String,
_ keyCode: UInt,
width: CGFloat = 1,
isModifier: Bool = false,
style: RemoteSystemKeyStyle? = nil) {
self.title = title
self.symbol = nil
self.width = width
self.style = style ?? (isModifier ? .special : .character)
self.action = isModifier ? .modifier(keyCode) : .stroke(keyCode)
}
init(_ title: String,
width: CGFloat = 1,
style: RemoteSystemKeyStyle = .special,
action: RemoteComputerKeyAction) {
self.title = title
self.symbol = nil
self.width = width
self.style = style
self.action = action
}
init(symbol: String,
_ keyCode: UInt,
width: CGFloat = 1,
isModifier: Bool = false,
style: RemoteSystemKeyStyle? = nil) {
self.title = nil
self.symbol = symbol
self.width = width
self.style = style ?? (isModifier ? .special : .character)
self.action = isModifier ? .modifier(keyCode) : .stroke(keyCode)
}
init(symbol: String,
width: CGFloat = 1,
style: RemoteSystemKeyStyle = .special,
action: RemoteComputerKeyAction) {
self.title = nil
self.symbol = symbol
self.width = width
self.style = style
self.action = action
}
var modifierCode: UInt? {
if case let .modifier(keyCode) = action { return keyCode }
return nil
}
}
private struct RemoteComputerKeyboard: View {
@ObservedObject var session: RemoteSessionModel
let switchKeyboard: () -> Void
let dismiss: () -> Void
@State private var activeModifiers: Set<UInt> = []
private static let rows: [[RemoteComputerKey]] = [
[
.init("Esc", 0x1B, width: 1.2),
.init("F1", 0x70), .init("F2", 0x71), .init("F3", 0x72),
.init("F4", 0x73), .init("F5", 0x74), .init("F6", 0x75),
.init("F7", 0x76), .init("F8", 0x77), .init("F9", 0x78),
.init("F10", 0x79), .init("F11", 0x7A), .init("F12", 0x7B),
.init("Del", 0x2E, width: 1.2)
],
[
.init("~\n`", 0xC0),
.init("!\n1", 0x31), .init("@\n2", 0x32),
.init("#\n3", 0x33), .init("$\n4", 0x34),
.init("%\n5", 0x35), .init("^\n6", 0x36),
.init("&\n7", 0x37), .init("*\n8", 0x38),
.init("(\n9", 0x39), .init(")\n0", 0x30),
.init("_\n-", 0xBD), .init("+\n=", 0xBB),
.init(symbol: "delete.left", 0x08, width: 1.65,
style: .special)
],
[
.init("Tab", 0x09, width: 1.45, style: .special),
.init("Q", 0x51), .init("W", 0x57), .init("E", 0x45),
.init("R", 0x52), .init("T", 0x54), .init("Y", 0x59),
.init("U", 0x55), .init("I", 0x49), .init("O", 0x4F),
.init("P", 0x50), .init("{\n[", 0xDB),
.init("}\n]", 0xDD), .init("|\n\\", 0xDC, width: 1.25)
],
[
.init("Caps", 0x14, width: 1.7, style: .special),
.init("A", 0x41), .init("S", 0x53), .init("D", 0x44),
.init("F", 0x46), .init("G", 0x47), .init("H", 0x48),
.init("J", 0x4A), .init("K", 0x4B), .init("L", 0x4C),
.init(":\n;", 0xBA), .init("\"\n'", 0xDE),
.init("回车", 0x0D, width: 1.9, style: .accent)
],
[
.init(symbol: "shift", 0x10, width: 2.15, isModifier: true),
.init("Z", 0x5A), .init("X", 0x58), .init("C", 0x43),
.init("V", 0x56), .init("B", 0x42), .init("N", 0x4E),
.init("M", 0x4D), .init("<\n,", 0xBC),
.init(">\n.", 0xBE), .init("?\n/", 0xBF),
.init(symbol: "shift", 0x10, width: 2.15, isModifier: true)
],
[
.init("Ctrl", 0x11, width: 1.35, isModifier: true),
.init("Win", 0x5B, width: 1.25, isModifier: true),
.init("Alt", 0x12, width: 1.25, isModifier: true),
.init("空格", 0x20, width: 3.8),
.init("Alt", 0x12, width: 1.25, isModifier: true),
.init("Ctrl", 0x11, width: 1.35, isModifier: true),
.init("←", 0x25, style: .special),
.init("↑", 0x26, style: .special),
.init("↓", 0x28, style: .special),
.init("→", 0x27, style: .special),
.init(symbol: "arrow.left.arrow.right", width: 1.35,
action: .switchKeyboard),
.init(symbol: "keyboard.chevron.compact.down", width: 1.35,
action: .dismiss)
]
]
var body: some View {
VStack(spacing: RemoteKeyboardMetrics.rowSpacing) {
ForEach(Array(Self.rows.enumerated()), id: \.offset) { _, row in
RemoteComputerKeyboardRow(
keys: row,
activeModifiers: activeModifiers,
action: handleKey
)
}
}
.onDisappear(perform: releaseModifiers)
}
private func handleKey(_ key: RemoteComputerKey) {
switch key.action {
case let .modifier(keyCode):
if activeModifiers.contains(keyCode) {
session.sendKeyState(keyCode, isDown: false)
activeModifiers.remove(keyCode)
} else {
session.sendKeyState(keyCode, isDown: true)
activeModifiers.insert(keyCode)
}
case let .stroke(keyCode):
session.sendKeyStroke(keyCode)
case .switchKeyboard:
releaseModifiers()
switchKeyboard()
case .dismiss:
releaseModifiers()
dismiss()
}
}
private func releaseModifiers() {
for keyCode in activeModifiers {
session.sendKeyState(keyCode, isDown: false)
}
activeModifiers.removeAll()
}
}
private struct RemoteComputerKeyboardRow: View {
let keys: [RemoteComputerKey]
let activeModifiers: Set<UInt>
let action: (RemoteComputerKey) -> Void
var body: some View {
GeometryReader { proxy in
let spacing = RemoteKeyboardMetrics.keySpacing
let totalWidth = keys.reduce(CGFloat.zero) { $0 + $1.width }
let keySpace = max(0, proxy.size.width -
CGFloat(max(0, keys.count - 1)) * spacing)
HStack(spacing: spacing) {
ForEach(Array(keys.enumerated()), id: \.offset) { _, key in
let isActive = key.modifierCode.map(activeModifiers.contains) ?? false
let visibleSymbol = isActive && key.symbol == "shift"
? "shift.fill" : key.symbol
RemoteKeyboardKeyCap(
title: key.title,
symbol: visibleSymbol,
style: key.style,
active: isActive
) {
action(key)
}
.frame(width: keySpace * key.width / totalWidth)
.accessibilityLabel((key.title ?? "键盘操作")
.replacingOccurrences(of: "\n", with: " "))
}
}
}
.frame(height: RemoteKeyboardMetrics.rowHeight)
}
}
private struct CrossDeskStatusOrb: View {
let isExpanded: Bool
private static let appIcon: UIImage? = {
let primaryIcon = (Bundle.main.infoDictionary?["CFBundleIcons"]
as? [String: Any])?["CFBundlePrimaryIcon"] as? [String: Any]
let iconName = primaryIcon?["CFBundleIconName"] as? String
let iconFiles = primaryIcon?["CFBundleIconFiles"] as? [String]
return iconName.flatMap(UIImage.init(named:))
?? iconFiles?.last.flatMap(UIImage.init(named:))
?? UIImage(named: "AppIcon")
}()
var body: some View {
ZStack(alignment: .bottomTrailing) {
Circle()
.fill(Color(.secondarySystemBackground).opacity(0.96))
if let icon = Self.appIcon {
Image(uiImage: icon)
.resizable()
.scaledToFill()
.clipShape(Circle())
.padding(4)
} else {
Image(systemName: "desktopcomputer")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.blue)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.overlay {
Circle()
.stroke(Color.white.opacity(isExpanded ? 0.95 : 0.65),
lineWidth: isExpanded ? 2 : 1)
}
.compositingGroup()
.accessibilityLabel(isExpanded ? "收起远程控制菜单" : "展开远程控制菜单")
}
}
private struct FloatingSessionMenu: View {
@ObservedObject var session: RemoteSessionModel
let showKeyboard: () -> Void
let chooseFile: () -> Void
let close: () -> Void
let disconnect: () -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: 7),
count: 3)
var body: some View {
VStack(spacing: 9) {
HStack(spacing: 8) {
Circle()
.fill(session.isConnected ? Color.green : Color.orange)
.frame(width: 9, height: 9)
VStack(alignment: .leading, spacing: 1) {
Text(session.connectionStatus)
.font(.subheadline.weight(.semibold))
Text(statusDetail)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
Spacer()
Button(action: close) {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
LazyVGrid(columns: columns, spacing: 7) {
FloatingControlButton(title: "键盘", symbol: "keyboard",
action: showKeyboard)
Menu {
ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in
Button {
session.selectDisplay(index)
} label: {
if index == session.selectedDisplay {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
FloatingControlLabel(title: "显示器", symbol: "display")
}
.buttonStyle(.plain)
FloatingControlButton(
title: session.audioEnabled ? "声音" : "静音",
symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash",
action: session.toggleAudio
)
FloatingControlButton(
title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标",
symbol: "computermouse",
action: {
session.mouseControlMode = session.mouseControlMode == .relative
? .absolute : .relative
}
)
FloatingControlButton(title: "发送文件",
symbol: "folder.badge.plus",
action: chooseFile)
FloatingControlButton(title: "Ctrl+Alt+Del",
symbol: "lock.trianglebadge.exclamationmark",
action: session.bridge.sendSecureAttentionSequence)
}
if !session.transferStatus.isEmpty || !session.clipboardStatus.isEmpty {
HStack(spacing: 8) {
Text(!session.transferStatus.isEmpty
? session.transferStatus : session.clipboardStatus)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 0)
if session.transferProgress > 0 && session.transferProgress < 1 {
ProgressView(value: session.transferProgress)
.frame(width: 60)
}
if let file = session.receivedFileURL {
ShareLink(item: file) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
Button(role: .destructive, action: disconnect) {
Text("断开连接")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
.padding(12)
.background(Color.white,
in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color(.separator).opacity(0.24), lineWidth: 1)
}
.shadow(color: .black.opacity(0.34), radius: 18, y: 7)
.environment(\.colorScheme, .light)
}
private var statusDetail: String {
var components = [session.usingTURN ? "TURN" : "P2P"]
let frameSize = session.frameSize
if frameSize.width > 0 && frameSize.height > 0 {
components.append("\(Int(frameSize.width))×\(Int(frameSize.height))")
}
components.append(session.formattedBitrate)
if session.lossRate > 0 {
components.append(String(format: "丢包 %.1f%%", session.lossRate * 100))
}
return components.joined(separator: " · ")
}
}
private struct FloatingControlButton: View {
let title: String
let symbol: String
let action: () -> Void
var body: some View {
Button(action: action) {
FloatingControlLabel(title: title, symbol: symbol)
}
.buttonStyle(.plain)
}
}
private struct FloatingControlLabel: View {
let title: String
let symbol: String
var body: some View {
VStack(spacing: 4) {
Image(systemName: symbol)
.font(.system(size: 16, weight: .semibold))
Text(title)
.font(.system(size: 9.5, weight: .medium))
.lineLimit(1)
.minimumScaleFactor(0.72)
}
.foregroundStyle(.primary)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(Color.primary.opacity(0.07),
in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
private struct RemoteCursorOverlay: View {
let normalizedPosition: CGPoint
let normalizedVisualOffset: CGPoint
let contentSize: CGSize
let viewportScale: CGFloat
let shape: Int
var body: some View {
let scale = max(viewportScale, 0.0001)
let x = min(max(normalizedPosition.x + normalizedVisualOffset.x, 0), 1) *
contentSize.width
let y = min(max(normalizedPosition.y + normalizedVisualOffset.y, 0), 1) *
contentSize.height
let offset = RemoteCursorGlyph.hotspotOffset(for: shape)
ZStack(alignment: .topLeading) {
RemoteCursorGlyph(shape: shape)
// The cursor lives inside the exact same transformed view as
// the decoded frame, so its anchor cannot drift from video
// because of an independently reconstructed transform. Undo
// the outer zoom for the glyph itself and pre-divide the
// hotspot offset so the cursor remains a constant screen size.
.scaleEffect(1 / scale, anchor: .center)
.position(x: x + offset.x / scale,
y: y + offset.y / scale)
}
.frame(width: contentSize.width, height: contentSize.height,
alignment: .topLeading)
}
}
private struct RemoteCursorGlyph: View {
let shape: Int
var body: some View {
Group {
if shape == 0 {
ZStack {
RemoteCursorArrowShape()
.stroke(Color.black, lineWidth: 1.75)
RemoteCursorArrowShape()
.fill(Color.white)
}
.frame(width: 12, height: 15)
} else if shape == 7 {
ZStack {
RemoteIBeamShape()
.stroke(Color.black, lineWidth: 2.5)
RemoteIBeamShape()
.stroke(Color.white, lineWidth: 1)
}
.frame(width: 12, height: 15)
} else {
Image(systemName: availableSymbolName)
.font(.system(size: 10.5, weight: .black))
.symbolRenderingMode(.monochrome)
.foregroundStyle(.white)
.shadow(color: .black, radius: 0.75)
}
}
.frame(width: 15, height: 15)
.accessibilityHidden(true)
}
static func hotspotOffset(for shape: Int) -> CGPoint {
switch shape {
case 0:
// The arrow path's visual tip is near (2.5, 0.6) inside its
// 15-point container, rather than at the container origin.
return CGPoint(x: 5, y: 7)
case 3:
return CGPoint(x: 4.5, y: 4.5)
default:
return .zero
}
}
private var availableSymbolName: String {
let desired: String
switch shape {
case 2: desired = "questionmark.circle.fill"
case 3: desired = "hand.point.up.left.fill"
case 4: desired = "arrow.triangle.2.circlepath"
case 5: desired = "hourglass"
case 6: desired = "scope"
case 8: desired = "arrow.turn.up.right"
case 9: desired = "plus.square.on.square"
case 10: desired = "arrow.up.and.down.and.arrow.left.and.right"
case 11, 12: desired = "nosign"
case 13, 14: desired = "hand.raised.fill"
case 15, 25: desired = "arrow.left.and.right"
case 16, 26: desired = "arrow.up.and.down"
case 17: desired = "arrow.up"
case 18: desired = "arrow.right"
case 19: desired = "arrow.down"
case 20: desired = "arrow.left"
case 21: desired = "arrow.up.right"
case 22: desired = "arrow.up.left"
case 23: desired = "arrow.down.right"
case 24: desired = "arrow.down.left"
case 27: desired = "arrow.up.right.and.arrow.down.left"
case 28: desired = "arrow.up.left.and.arrow.down.right"
default: desired = "arrow.up.left"
}
return UIImage(systemName: desired) == nil ? "arrow.up.left" : desired
}
}
private struct RemoteCursorArrowShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX + rect.width * 0.08,
y: rect.minY + rect.height * 0.04))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.08,
y: rect.minY + rect.height * 0.78))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.34,
y: rect.minY + rect.height * 0.60))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.55,
y: rect.minY + rect.height * 0.94))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.73,
y: rect.minY + rect.height * 0.84))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.52,
y: rect.minY + rect.height * 0.51))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.88,
y: rect.minY + rect.height * 0.49))
path.closeSubpath()
return path
}
}
private struct RemoteIBeamShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let centerX = rect.midX
let top = rect.minY + 2
let bottom = rect.maxY - 2
path.move(to: CGPoint(x: centerX, y: top))
path.addLine(to: CGPoint(x: centerX, y: bottom))
path.move(to: CGPoint(x: rect.minX + 5, y: top))
path.addLine(to: CGPoint(x: rect.maxX - 5, y: top))
path.move(to: CGPoint(x: rect.minX + 5, y: bottom))
path.addLine(to: CGPoint(x: rect.maxX - 5, y: bottom))
return path
}
}