import SwiftUI import UIKit import UniformTypeIdentifiers private enum RemoteKeyboardPalette { static let background = adaptive(light: .systemGray4, dark: .secondarySystemBackground) static let characterKey = adaptive(light: .white, dark: UIColor(white: 0.36, alpha: 1)) static let specialKey = adaptive(light: .systemGray2, dark: UIColor(white: 0.22, alpha: 1)) static let pressedKey = adaptive(light: .systemGray3, dark: UIColor(white: 0.48, alpha: 1)) static let keyText = Color(uiColor: .label) static let keyShadow = Color.black.opacity(0.3) private static func adaptive(light: UIColor, dark: UIColor) -> Color { Color(uiColor: UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light }) } } private enum RemoteKeyboardMetrics { static let keySpacing: CGFloat = 5 static let rowSpacing: CGFloat = 6 static let cornerRadius: CGFloat = 7 static let panelPadding: CGFloat = 6 } 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 @Environment(\.scenePhase) private var scenePhase @State private var mouseInput = RemoteMouseInput() @State private var virtualMouseVisible = false @StateObject private var systemKeyboardContext = RemoteKeyboardInputContext() @StateObject private var keyboardGeometry = RemoteKeyboardGeometry() @State private var keyboardInputVisible = false @State private var keyboardMode: RemoteKeyboardMode = .system @State private var animateSystemKeyboardPresentation = true @State private var waitingForSystemKeyboard = false @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? @GestureState private var statusOrbDragTranslation = 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, frameID: session.currentVideoFrameID, captureUptime: session.frameCaptureUptime, onFrameSubmitted: session.recordVideoFrameSubmission, pictureInPicture: session.pictureInPicture ) 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( mouseInput: mouseInput, 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 mouseInput.position(x: x, y: y) cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y)) session.bridge.sendPointer(x: x, y: y, action: .move) }, leftDown: { x, y in mouseInput.position(x: x, y: y) mouseInput.setGestureLeft(true) }, leftUp: { x, y in mouseInput.position(x: x, y: y) mouseInput.setGestureLeft(false) }, rightClick: { x, y in systemKeyboardContext.remoteFocusWillChange() session.bridge.sendPointer(x: x, y: y, action: .rightDown) session.bridge.sendPointer(x: x, y: y, action: .rightUp) }, scroll: { x, y, dx, dy in mouseInput.position(x: x, y: y) 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 { ZStack { if keyboardMode == .system { RemoteSystemKeyboard(session: session, inputContext: systemKeyboardContext, geometry: keyboardGeometry, animatePresentation: animateSystemKeyboardPresentation, didPresent: { waitingForSystemKeyboard = false }, switchKeyboard: { keyboardMode = .computer }, dismiss: { keyboardInputVisible = false }) } if keyboardMode == .computer || waitingForSystemKeyboard { dockedKeyboard } } .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 ?? "") } .onAppear { mouseInput.send = { x, y, flag, wheel in cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y)) if flag == 1 || flag == 3 { systemKeyboardContext.remoteFocusWillChange() } switch flag { case 0: session.bridge.sendPointer(x: x, y: y, action: .move) case 1: session.bridge.sendPointer(x: x, y: y, action: .leftDown) case 2: session.bridge.sendPointer(x: x, y: y, action: .leftUp) case 3: session.bridge.sendPointer(x: x, y: y, action: .rightDown) case 4: session.bridge.sendPointer(x: x, y: y, action: .rightUp) case 7: session.bridge.sendScrollX(x, y: y, deltaX: 0, deltaY: wheel) default: break } } if let point = session.remoteCursorPosition { mouseInput.position(x: Float(point.x), y: Float(point.y)) } } .onDisappear { mouseInput.releaseAll() mouseInput.send = nil virtualMouseVisible = false keyboardInputVisible = false viewport = RemoteViewportState() cursorPosition = nil statusMenuVisible = false statusOrbCenter = nil } .onChange(of: session.selectedDisplay) { _ in systemKeyboardContext.remoteFocusWillChange() mouseInput.releaseAll() mouseInput.position(x: 0.5, y: 0.5) cursorPosition = nil viewport = RemoteViewportState() } .onChange(of: session.mouseControlMode) { _ in mouseInput.releaseAll() cursorPosition = session.remoteCursorPosition if let point = cursorPosition { mouseInput.position(x: Float(point.x), y: Float(point.y)) } } .onChange(of: scenePhase) { phase in if phase != .active { mouseInput.releaseAll() } if phase == .background { keyboardInputVisible = false } } .onChange(of: keyboardInputVisible) { visible in if !visible { animateSystemKeyboardPresentation = true waitingForSystemKeyboard = false } } .onChange(of: session.isConnected) { connected in if !connected { mouseInput.releaseAll() keyboardInputVisible = false } } .onChange(of: session.remoteCursorPositionRevision) { _ in guard !mouseInput.hasVirtualButton, let position = session.remoteCursorPosition else { return } mouseInput.position(x: Float(position.x), y: Float(position.y)) cursorPosition = position } .fileImporter(isPresented: $showingFileImporter, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in if case let .success(urls) = result, let url = urls.first { session.sendFile(url) } else if case .failure = result { session.fileImportFailed() } } } 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 menuColumns = containerSize.height < 400 && containerSize.width >= 400 ? 4 : 3 let panelWidth = min(max(1, containerSize.width - 16), menuColumns == 4 ? 380 : min(340, max(280, containerSize.width - 160))) ZStack(alignment: .topLeading) { if virtualMouseVisible && !statusMenuVisible && session.pixelBuffer != nil { VirtualMouseBar(input: mouseInput) .frame(width: 140, height: 48) .position(virtualMouseCenter(orbCenter: orbCenter, in: containerSize)) } if statusMenuVisible { Color.clear .contentShape(Rectangle()) .onTapGesture { withAnimation(.easeOut(duration: 0.14)) { statusMenuVisible = false } } } FloatingMenuLayout(orbCenter: orbCenter, panelWidth: panelWidth) { if statusMenuVisible { FloatingSessionMenu( session: session, columnCount: menuColumns, virtualMouseVisible: virtualMouseVisible, toggleMouse: { mouseInput.releaseAll() virtualMouseVisible.toggle() statusMenuVisible = false }, releaseMouse: mouseInput.releaseAll, showKeyboard: { statusMenuVisible = false keyboardInputVisible.toggle() }, chooseFile: { statusMenuVisible = false keyboardInputVisible = false showingFileImporter = true }, close: { withAnimation(.easeOut(duration: 0.14)) { statusMenuVisible = false } }, disconnect: { mouseInput.releaseAll() statusMenuVisible = false keyboardInputVisible = false withAnimation(.easeOut(duration: 0.18)) { disconnectConfirmationVisible = true } } ) .transition(.scale(scale: 0.94, anchor: .topTrailing) .combined(with: .opacity)) } } .frame(width: containerSize.width, height: containerSize.height) .allowsHitTesting(statusMenuVisible) CrossDeskStatusOrb(isExpanded: statusMenuVisible) .frame(width: 54, height: 54) .contentShape(Circle()) .position(orbCenter) .onTapGesture { mouseInput.releaseAll() withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) { statusMenuVisible.toggle() } } .gesture( DragGesture(minimumDistance: 1) .updating($statusOrbDragTranslation) { value, state, _ in state = value.translation } .onChanged { _ in mouseInput.releaseAll() if statusMenuVisible { statusMenuVisible = false } } .onEnded { value in statusOrbCenter = constrainedOrbCenter( CGPoint(x: restingOrbCenter.x + value.translation.width, y: restingOrbCenter.y + value.translation.height), in: containerSize ) } ) } .frame(width: containerSize.width, height: containerSize.height) .onChange(of: containerSize) { _ in mouseInput.releaseAll() } } } private func virtualMouseCenter(orbCenter: CGPoint, in size: CGSize) -> CGPoint { let halfWidth: CGFloat = 70 let halfHeight: CGFloat = 24 let reach: CGFloat = 27 + 6 + halfWidth var x = orbCenter.x - reach var y = orbCenter.y if x - halfWidth < 8 { x = orbCenter.x + reach if x + halfWidth > size.width - 8 { x = orbCenter.x y = orbCenter.y + 27 + 6 + halfHeight if y + halfHeight > size.height - 8 { y = orbCenter.y - 27 - 6 - halfHeight } } } x = min(max(x, halfWidth + 8), max(halfWidth + 8, size.width - halfWidth - 8)) if keyboardInputVisible && keyboardMode == .computer { let keyboardSize = keyboardPanelSize(in: size) let center = keyboardCenter(in: size, panelSize: keyboardSize) let keyboardRect = CGRect(x: center.x - keyboardSize.width / 2, y: center.y - keyboardSize.height / 2, width: keyboardSize.width, height: keyboardSize.height) if keyboardRect.intersects(CGRect(x: x - halfWidth, y: y - halfHeight, width: 140, height: 48)) { y = keyboardRect.minY - 6 - halfHeight if y - halfHeight < 8 { y = keyboardRect.maxY + 6 + halfHeight } } } return CGPoint(x: x, y: min(max(y, halfHeight + 8), max(halfHeight + 8, size.height - halfHeight - 8))) } private var dockedKeyboard: some View { GeometryReader { proxy in let containerSize = CGSize(width: proxy.size.width, height: proxy.size.height + proxy.safeAreaInsets.bottom) let windowSize = CGSize( width: proxy.size.width + proxy.safeAreaInsets.leading + proxy.safeAreaInsets.trailing, height: containerSize.height + proxy.safeAreaInsets.top) let panelSize = keyboardPanelSize(in: containerSize, bottomInset: proxy.safeAreaInsets.bottom, windowSize: windowSize) remoteKeyboardPanel(bottomInset: proxy.safeAreaInsets.bottom) .frame(width: panelSize.width, height: panelSize.height) .position(keyboardCenter(in: containerSize, panelSize: panelSize)) .transaction { transaction in transaction.animation = nil } } .ignoresSafeArea(.keyboard) } private func remoteKeyboardPanel(bottomInset: CGFloat) -> some View { RemoteComputerKeyboard( session: session, switchKeyboard: { animateSystemKeyboardPresentation = false waitingForSystemKeyboard = true keyboardMode = .system }, dismiss: { keyboardInputVisible = false } ) .padding(.bottom, bottomInset) .background(RemoteKeyboardPalette.background) } private func keyboardPanelSize(in containerSize: CGSize, bottomInset: CGFloat = 0, windowSize: CGSize? = nil) -> CGSize { CGSize(width: min(780, max(0, containerSize.width)), height: min(containerSize.height, keyboardGeometry.panelHeight(in: windowSize ?? containerSize, bottomInset: bottomInset))) } private func keyboardCenter(in containerSize: CGSize, panelSize: CGSize) -> CGPoint { CGPoint(x: containerSize.width / 2, y: containerSize.height - panelSize.height / 2) } 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 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 RemoteKeyboardKeyStyle { case character case special } private struct RemoteKeyboardKeyCap: View { @Environment(\.verticalSizeClass) private var verticalSizeClass let title: String? let symbol: String? let style: RemoteKeyboardKeyStyle let active: Bool let action: () -> Void var body: some View { Button(action: action) { Group { if let symbol { Image(systemName: symbol) .font(.system(size: 17, weight: .regular)) } else if let title, title.contains("\n") { let legends = title.components(separatedBy: "\n") if verticalSizeClass == .compact { HStack(spacing: 3) { Text(legends[1]).font(.system(size: 17)) Text(legends[0]).font(.system(size: 10)) .foregroundStyle(.secondary) } } else { VStack(spacing: 0) { Text(legends[0]) .font(.system(size: 10)) .foregroundStyle(.secondary) Text(legends[1]) .font(.system(size: 18)) } } } else { Text(title ?? "") .font(.system(size: labelFontSize, weight: .regular)) .multilineTextAlignment(.center) .lineLimit(1) .minimumScaleFactor(0.62) } } .foregroundStyle(active ? Color.black : RemoteKeyboardPalette.keyText) .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) } .buttonStyle(RemoteKeyboardPressStyle(backgroundColor: backgroundColor)) .accessibilityAddTraits(active ? .isSelected : []) } private var labelFontSize: CGFloat { guard let title else { return 14 } switch title.count { case 0...1: return 20 case 2...3: return 14 default: return 14 } } private var backgroundColor: Color { // Match the system keyboard's latched Shift appearance. if active { return .white } switch style { case .character: return RemoteKeyboardPalette.characterKey case .special: return RemoteKeyboardPalette.specialKey } } } private struct RemoteKeyboardPressStyle: ButtonStyle { let backgroundColor: Color func makeBody(configuration: Configuration) -> some View { configuration.label .background { RoundedRectangle(cornerRadius: RemoteKeyboardMetrics.cornerRadius, style: .continuous) .fill(configuration.isPressed ? RemoteKeyboardPalette.pressedKey : backgroundColor) .shadow(color: RemoteKeyboardPalette.keyShadow, radius: 0, y: 1) } } } private final class RemoteKeyboardFeedback: ObservableObject { private var generator: UIImpactFeedbackGenerator? func prepare() { if generator == nil { generator = UIImpactFeedbackGenerator(style: .light) } generator?.prepare() } func keyPressed() { let generator = generator ?? UIImpactFeedbackGenerator(style: .light) self.generator = generator generator.impactOccurred(intensity: 1.0) // Keep rapid typing responsive without repeatedly allocating generators. generator.prepare() } func stop() { generator = nil } } 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: RemoteKeyboardKeyStyle let action: RemoteComputerKeyAction init(_ title: String, _ keyCode: UInt, width: CGFloat = 1, isModifier: Bool = false, style: RemoteKeyboardKeyStyle? = 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: RemoteKeyboardKeyStyle = .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: RemoteKeyboardKeyStyle? = 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: RemoteKeyboardKeyStyle = .special, action: RemoteComputerKeyAction) { self.title = nil self.symbol = symbol self.width = width self.style = style self.action = action } var accessibilityLabel: String { if let title { return title.replacingOccurrences(of: "\n", with: " ") } switch symbol { case "shift": return "Shift" case "delete.left": return "退格" default: return "键盘操作" } } var modifierCode: UInt? { if case let .modifier(keyCode) = action { return keyCode } return nil } } private enum RemoteComputerKeyboardPage: String, CaseIterable { case input = "字母数字" case controls = "符号功能" var rows: [[RemoteComputerKey]] { let shift = RemoteComputerKey(symbol: "shift", 0x10, isModifier: true) let backspace = RemoteComputerKey(symbol: "delete.left", 0x08, style: .special) switch self { case .input: return [ [.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("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("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)], [shift, .init("Z", 0x5A), .init("X", 0x58), .init("C", 0x43), .init("V", 0x56), .init("B", 0x42), .init("N", 0x4E), .init("M", 0x4D), backspace] ] case .controls: return [ [.init("~\n`", 0xC0), .init("_\n-", 0xBD), .init("+\n=", 0xBB), .init("{\n[", 0xDB), .init("}\n]", 0xDD), .init("|\n\\", 0xDC), .init(":\n;", 0xBA), .init("\"\n'", 0xDE), .init("<\n,", 0xBC), .init(">\n.", 0xBE), .init("?\n/", 0xBF)], [.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("Esc", 0x1B, style: .special), .init("Tab", 0x09, style: .special), .init("Caps", 0x14), .init("Ins", 0x2D), .init("Del", 0x2E), .init("Home", 0x24), .init("End", 0x23)], [shift, .init("←", 0x25), .init("↑", 0x26), .init("↓", 0x28), .init("→", 0x27), .init("PgUp", 0x21), .init("PgDn", 0x22), backspace] ] } } func rows(compact: Bool) -> [[RemoteComputerKey]] { let rows = self.rows guard compact, self == .controls else { return rows } // Landscape has room for one full function-key row, leaving the same // five-row height budget as the input page (including common keys). return [rows[0], rows[1] + rows[2], rows[3], rows[4]] } } private struct RemoteComputerKeyboard: View { @ObservedObject var session: RemoteSessionModel @Environment(\.verticalSizeClass) private var verticalSizeClass @StateObject private var feedback = RemoteKeyboardFeedback() let switchKeyboard: () -> Void let dismiss: () -> Void @State private var activeModifiers: Set = [] @State private var page: RemoteComputerKeyboardPage = .input private static let commonRow: [RemoteComputerKey] = [ .init("Ctrl", 0x11, isModifier: true), .init("Win", 0x5B, isModifier: true), .init("Alt", 0x12, isModifier: true), .init("空格", 0x20, width: 2.6), .init("回车", 0x0D, width: 1.4, style: .special) ] var body: some View { VStack(spacing: 0) { HStack(spacing: 4) { Button("系统键盘") { feedback.keyPressed() releaseModifiers() switchKeyboard() } .font(.system(size: 15, weight: .medium)) .frame(width: 80, height: 44) .accessibilityLabel("切换到系统键盘") if verticalSizeClass == .compact { pageTabs } else { Spacer(minLength: 0) } RemoteKeyboardDismissButton(accessibilityLabel: "收起电脑键盘") { feedback.keyPressed() releaseModifiers() dismiss() } .frame(width: RemoteKeyboardDismissButton.sideLength, height: RemoteKeyboardDismissButton.sideLength) } .padding(.horizontal, 4) .frame(height: 44) if verticalSizeClass != .compact { pageTabs .padding(.horizontal, RemoteKeyboardMetrics.panelPadding) } GeometryReader { proxy in // Anchor both bottom rows to the input page's dimensions so // shared controls keep their size and position across pages. let inputRows = RemoteComputerKeyboardPage.input .rows(compact: verticalSizeClass == .compact) let rows = page.rows(compact: verticalSizeClass == .compact) let inputRowCount = CGFloat(inputRows.count) let sharedRowHeight = max(0, (proxy.size.height - inputRowCount * RemoteKeyboardMetrics.rowSpacing) / (inputRowCount + 1)) let inputEdgeRow = inputRows.last ?? [] let edgeKeyWidth = max(0, proxy.size.width - CGFloat(max(0, inputEdgeRow.count - 1)) * RemoteKeyboardMetrics.keySpacing) / max(1, inputEdgeRow.reduce(CGFloat.zero) { $0 + $1.width }) VStack(spacing: RemoteKeyboardMetrics.rowSpacing) { VStack(spacing: RemoteKeyboardMetrics.rowSpacing) { ForEach(Array(rows.dropLast().enumerated()), id: \.offset) { _, row in RemoteComputerKeyboardRow( keys: row, activeModifiers: activeModifiers, action: handleKey ) } } .frame(maxHeight: .infinity) if let row = rows.last { RemoteComputerKeyboardRow( keys: row, activeModifiers: activeModifiers, action: handleKey, fixedEdgeWidth: edgeKeyWidth ) .frame(height: sharedRowHeight) } RemoteComputerKeyboardRow( keys: Self.commonRow, activeModifiers: activeModifiers, action: handleKey ) .frame(height: sharedRowHeight) } .frame(width: proxy.size.width, height: proxy.size.height) } .padding(RemoteKeyboardMetrics.panelPadding) } .foregroundStyle(RemoteKeyboardPalette.keyText) .buttonStyle(.plain) .background(RemoteKeyboardPalette.background) .onAppear { feedback.prepare() } .onDisappear { releaseModifiers() feedback.stop() } } private var pageTabs: some View { HStack(spacing: 6) { ForEach(RemoteComputerKeyboardPage.allCases, id: \.self) { item in Button { guard page != item else { return } feedback.keyPressed() page = item } label: { Text(item.rawValue) .font(.system(size: 14, weight: page == item ? .semibold : .regular)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(page == item ? RemoteKeyboardPalette.specialKey : .clear, in: RoundedRectangle(cornerRadius: 6)) .contentShape(Rectangle()) } .accessibilityLabel("\(item.rawValue)键盘页") .accessibilityAddTraits(page == item ? .isSelected : []) } } .frame(height: 36) } private func handleKey(_ key: RemoteComputerKey) { feedback.keyPressed() 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 fixedEdgeWidth: CGFloat? = nil var body: some View { GeometryReader { proxy in let spacing = RemoteKeyboardMetrics.keySpacing let edgeWidth = keys.count > 2 ? fixedEdgeWidth : nil let flexibleKeys = edgeWidth == nil ? keys[...] : keys.dropFirst().dropLast() let totalWidth = flexibleKeys.reduce(CGFloat.zero) { $0 + $1.width } let keySpace = max(0, proxy.size.width - 2 * (edgeWidth ?? 0) - CGFloat(max(0, keys.count - 1)) * spacing) let unitWidth = keySpace / totalWidth HStack(spacing: spacing) { ForEach(Array(keys.enumerated()), id: \.offset) { index, key in let width = index == 0 || index == keys.count - 1 ? (edgeWidth ?? unitWidth * key.width) : unitWidth * key.width 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: width) .accessibilityLabel(key.accessibilityLabel) } } .frame(width: proxy.size.width, height: proxy.size.height) } .frame(maxHeight: .infinity) } } 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 ? "收起远程控制菜单" : "展开远程控制菜单") } } /// Measure the menu before positioning it; no fixed height or later-frame /// preference update is needed when changing pages, text size or orientation. private struct FloatingMenuLayout: Layout { let orbCenter: CGPoint let panelWidth: CGFloat func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { proposal.replacingUnspecifiedDimensions() } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { guard let menu = subviews.first else { return } let size = menu.sizeThatFits(ProposedViewSize( width: panelWidth, height: max(0, bounds.height - 16))) let preferredX = orbCenter.x > bounds.width / 2 ? orbCenter.x - 39 - size.width / 2 : orbCenter.x + 39 + size.width / 2 let center = CGPoint( x: bounds.minX + min(max(preferredX, size.width / 2 + 8), bounds.width - size.width / 2 - 8), y: bounds.minY + min(max(orbCenter.y, size.height / 2 + 8), bounds.height - size.height / 2 - 8)) menu.place(at: center, anchor: .center, proposal: ProposedViewSize(size)) } } private struct FloatingSessionMenu: View { @ObservedObject var session: RemoteSessionModel let virtualMouseVisible: Bool let toggleMouse: () -> Void let releaseMouse: () -> Void private enum Page { case controls, videoSettings, networkStats } @State private var page: Page = .controls @State private var displayedNetworkSnapshot: RemoteNetworkSnapshot @State private var displayedFrameSize: CGSize let showKeyboard: () -> Void let chooseFile: () -> Void let close: () -> Void let disconnect: () -> Void let columnCount: Int private var columns: [GridItem] { Array(repeating: GridItem(.flexible(), spacing: 7), count: columnCount) } init(session: RemoteSessionModel, columnCount: Int, virtualMouseVisible: Bool, toggleMouse: @escaping () -> Void, releaseMouse: @escaping () -> Void, showKeyboard: @escaping () -> Void, chooseFile: @escaping () -> Void, close: @escaping () -> Void, disconnect: @escaping () -> Void) { self.session = session self.columnCount = columnCount self.virtualMouseVisible = virtualMouseVisible self.toggleMouse = toggleMouse self.releaseMouse = releaseMouse self.showKeyboard = showKeyboard self.chooseFile = chooseFile self.close = close self.disconnect = disconnect // Render the first frame with real values instead of replacing empty // placeholders while the panel's insertion animation is running. _displayedNetworkSnapshot = State(initialValue: session.networkSnapshot) _displayedFrameSize = State(initialValue: session.frameSize) } var body: some View { // Prefer the full natural-height panel. Only use a scrolling body when // the available safe-area height cannot contain all of its content. ViewThatFits(in: .vertical) { panel(scrolls: false).fixedSize(horizontal: false, vertical: true) panel(scrolls: true) } .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) .task(id: session.selectedDisplay) { // This task survives ordinary video-frame redraws and is cancelled // when the menu closes. Rendering only reads the sampled values. while !Task.isCancelled { displayedNetworkSnapshot = session.networkSnapshot displayedFrameSize = session.frameSize do { try await Task.sleep(nanoseconds: 1_000_000_000) } catch { return } } } } private func panel(scrolls: Bool) -> some View { VStack(spacing: 9) { menuHeader if scrolls { ScrollView { menuContent } } else { menuContent } disconnectButton } } @ViewBuilder private var menuHeader: some View { HStack(spacing: 8) { if page != .controls { Button { page = .controls } label: { Image(systemName: "chevron.left") .frame(width: 28, height: 32) } .buttonStyle(.plain) .accessibilityLabel("返回控制栏") Text(page == .videoSettings ? "画面设置" : "网络状态") .font(.subheadline.weight(.semibold)) } else { 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)) .lineLimit(1) Text(statusDetail) .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer() Button(action: close) { Image(systemName: "xmark.circle.fill") .font(.title3) .foregroundStyle(.secondary) } .buttonStyle(.plain) .accessibilityLabel("关闭控制栏") } if page == .videoSettings, !session.videoSettingsFeedback.isEmpty { Text(session.videoSettingsFeedback) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } } @ViewBuilder private var menuContent: some View { if page == .videoSettings { FloatingVideoSettings(session: session) } else if page == .networkStats { FloatingNetworkStatistics(snapshot: displayedNetworkSnapshot, frameSize: displayedFrameSize) } else { VStack(spacing: 9) { LazyVGrid(columns: columns, spacing: 7) { FloatingControlButton(title: "键盘", symbol: "keyboard", action: showKeyboard) FloatingControlButton( title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标", symbol: "computermouse", action: { session.mouseControlMode = session.mouseControlMode == .relative ? .absolute : .relative } ) FloatingControlButton(title: virtualMouseVisible ? "收起鼠标" : "虚拟鼠标", symbol: "computermouse", action: toggleMouse) Menu { ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in Button { releaseMouse() session.selectDisplay(index) } label: { if index == session.selectedDisplay { Label(name, systemImage: "checkmark") } else { Text(name) } } } } label: { FloatingControlLabel(title: "显示器", symbol: "display") } .buttonStyle(.plain) FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") { page = .videoSettings } FloatingControlButton( title: session.audioEnabled ? "声音" : "静音", symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash", action: session.toggleAudio ) FloatingControlButton(title: "发送文件", symbol: "folder.badge.plus", action: chooseFile) FloatingControlButton(title: "网络状态", symbol: "chart.bar.xaxis") { page = .networkStats } FloatingControlButton(title: "Ctrl+Alt+Del", symbol: "lock.trianglebadge.exclamationmark", action: session.sendSecureAttention) FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard", action: session.sendClipboard) } if !session.controlStatus.isEmpty { Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary) } if !session.clipboardStatus.isEmpty { Text(session.clipboardStatus).font(.caption2).foregroundStyle(.secondary) } if !session.transferStatus.isEmpty { HStack(spacing: 8) { Text(session.transferStatus) .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") } } } } } } } private var disconnectButton: some View { Button(role: .destructive, action: disconnect) { Text("断开连接") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .frame(height: 34) } .buttonStyle(.borderedProminent) .tint(.red) } private var statusDetail: String { let snapshot = displayedNetworkSnapshot var components = [snapshot.connectionMode] let frameSize = displayedFrameSize if frameSize.width > 0 && frameSize.height > 0 { components.append("\(Int(frameSize.width))×\(Int(frameSize.height))") } components.append(RemoteNetworkSnapshot.bitrate(snapshot.report?.total.inboundBitrate)) if let loss = snapshot.report?.video.lossRate, loss > 0 { components.append("丢包 \(RemoteNetworkSnapshot.lossRate(loss))") } return components.joined(separator: " · ") } } private struct FloatingNetworkStatistics: View { let snapshot: RemoteNetworkSnapshot let frameSize: CGSize var body: some View { VStack(spacing: 10) { VStack(spacing: 0) { trafficRow("", inbound: "接收", outbound: "发送", loss: "丢包率") .foregroundStyle(.secondary) trafficRow("视频", statistics: snapshot.report?.video) trafficRow("音频", statistics: snapshot.report?.audio) trafficRow("数据", statistics: snapshot.report?.data) trafficRow("合计", statistics: snapshot.report?.total) .fontWeight(.semibold) .background(Color.blue.opacity(0.07), in: RoundedRectangle(cornerRadius: 5)) } .font(.caption2.monospacedDigit()) Divider() VStack(spacing: 7) { detail("帧率", value: "\(snapshot.framesPerSecond) FPS") detail("分辨率", value: resolution) detail("画面延时", value: RemoteNetworkSnapshot.latency(snapshot.videoLatencyMilliseconds)) detail("连接延时(RTT)", value: RemoteNetworkSnapshot.latency(snapshot.rttMilliseconds)) detail("连接方式", value: snapshot.connectionMode) HStack { Text("媒体加密") .foregroundStyle(.secondary) Spacer(minLength: 8) if let report = snapshot.report { Label(report.srtpActive ? "SRTP 已启用" : "未启用", systemImage: report.srtpActive ? "lock.shield" : "lock.open") .foregroundStyle(report.srtpActive ? Color.green : Color.secondary) } else { Text("—") } } .accessibilityElement(children: .combine) } .font(.caption.monospacedDigit()) } .padding(.vertical, 2) } private var resolution: String { guard frameSize.width > 0, frameSize.height > 0 else { return "—" } return "\(Int(frameSize.width)) × \(Int(frameSize.height))" } private func detail(_ title: String, value: String) -> some View { HStack { Text(title).foregroundStyle(.secondary) Spacer(minLength: 8) Text(value) } .accessibilityElement(children: .combine) } private func trafficRow(_ title: String, statistics: RemoteTrafficStatistics?) -> some View { trafficRow(title, inbound: RemoteNetworkSnapshot.bitrate(statistics?.inboundBitrate), outbound: RemoteNetworkSnapshot.bitrate(statistics?.outboundBitrate), loss: RemoteNetworkSnapshot.lossRate(statistics?.lossRate)) .accessibilityLabel("\(title),接收 \(RemoteNetworkSnapshot.bitrate(statistics?.inboundBitrate)),发送 \(RemoteNetworkSnapshot.bitrate(statistics?.outboundBitrate)),丢包率 \(RemoteNetworkSnapshot.lossRate(statistics?.lossRate))") } private func trafficRow(_ title: String, inbound: String, outbound: String, loss: String) -> some View { HStack(spacing: 4) { Text(title).frame(width: 28, alignment: .leading) Text(inbound).frame(maxWidth: .infinity, alignment: .trailing) Text(outbound).frame(maxWidth: .infinity, alignment: .trailing) Text(loss).frame(width: 46, alignment: .trailing) } .lineLimit(1) .minimumScaleFactor(0.75) .padding(.horizontal, 4) .padding(.vertical, 5) .accessibilityElement(children: .combine) } } private struct FloatingVideoSettings: View { @ObservedObject var session: RemoteSessionModel var body: some View { VStack(alignment: .leading, spacing: 12) { settingRow("画面质量", options: ["低", "中", "高"], values: [0, 1, 2], selection: Binding( get: { session.videoSettings.selection.quality }, set: { session.updateVideoSettings(quality: $0) } )) settingRow("画面采集帧率", options: ["30 fps", "60 fps"], values: [30, 60], selection: Binding( get: { session.videoSettings.selection.frameRate }, set: { session.updateVideoSettings(frameRate: $0) } )) settingRow("画面偏好", options: VideoAdaptationPolicy.allCases.map(\.title), values: VideoAdaptationPolicy.allCases.map { $0.bridgeValue.rawValue }, selection: Binding( get: { session.videoSettings.selection.preference }, set: { session.updateVideoSettings(preference: $0) } )) } .padding(.vertical, 2) } private func settingRow(_ title: String, options: [String], values: [Int], selection: Binding) -> some View { VStack(alignment: .leading, spacing: 5) { Text(title) .font(.caption) .foregroundStyle(.secondary) Picker(title, selection: selection) { ForEach(options.indices, id: \.self) { index in Text(options[index]).tag(values[index]) } } .pickerStyle(.segmented) .disabled(!session.canChangeVideoSettings) } } } 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 } }