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, 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) } } .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.remoteCursorPosition) { position in guard let position 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 { 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 ) } ) } } .ignoresSafeArea() } 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 = [] 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 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", 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 } }