import AVFoundation import CoreImage import CoreVideo import Foundation import Security import UIKit enum MouseControlMode: String, CaseIterable, Identifiable { case relative case absolute private static let defaultsKey = "crossdesk.mobile.mouse-control-mode" var id: String { rawValue } var title: String { switch self { case .absolute: return "绝对位置" case .relative: return "相对位置" } } var detail: String { switch self { case .absolute: return "触摸位置直接对应远端屏幕位置,适合快速定位。" case .relative: return "像触控板一样滑动光标,点击时操作当前光标位置。" } } static var saved: MouseControlMode { guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey), let mode = MouseControlMode(rawValue: rawValue) else { return .relative } return mode } func save() { UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey) } } enum VideoCodecMode: String, CaseIterable, Identifiable { case hardware case software private static let defaultsKey = "crossdesk.mobile.video-codec-mode" var id: String { rawValue } var title: String { switch self { case .hardware: return "硬件" case .software: return "软件" } } var detail: String { switch self { case .hardware: return "使用 VideoToolbox,延迟和耗电更低,推荐日常使用。" case .software: return "使用 OpenH264 软件编解码,适合兼容性测试。" } } static var saved: VideoCodecMode { guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey), let mode = VideoCodecMode(rawValue: rawValue) else { return .hardware } return mode } func save() { UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey) } } private struct RemoteVideoFrame { let pixelBuffer: CVPixelBuffer let encodedSize: CGSize } struct RecentConnection: Codable, Identifiable, Equatable { let remoteID: String var displayName: String var lastConnectedAt: Date var remembersPassword: Bool var thumbnailFileName: String? var id: String { remoteID } } private enum RecentConnectionStore { private static let defaultsKey = "crossdesk.mobile.recent-connections.v1" private static let thumbnailQueue = DispatchQueue(label: "cn.crossdesk.mobile.thumbnails", qos: .utility) private static let context = CIContext(options: [.cacheIntermediates: false]) static func load() -> [RecentConnection] { guard let data = UserDefaults.standard.data(forKey: defaultsKey), let connections = try? JSONDecoder().decode([RecentConnection].self, from: data) else { return [] } return connections.sorted { $0.lastConnectedAt > $1.lastConnectedAt } } static func save(_ connections: [RecentConnection]) { guard let data = try? JSONEncoder().encode(connections) else { return } UserDefaults.standard.set(data, forKey: defaultsKey) } static func thumbnailURL(fileName: String) -> URL? { guard let directory = thumbnailDirectory() else { return nil } return directory.appendingPathComponent(fileName, isDirectory: false) } static func captureThumbnail(from pixelBuffer: CVPixelBuffer, remoteID: String, completion: @escaping (String?) -> Void) { thumbnailQueue.async { let fileName: String? = autoreleasepool { let image = CIImage(cvPixelBuffer: pixelBuffer) let targetSize = CGSize(width: 640, height: 360) guard image.extent.width > 0, image.extent.height > 0 else { return nil } // Scale in Core Image before materializing a CGImage. Creating // a full 4K bitmap and then drawing it into a thumbnail can add // tens of megabytes to the first-frame memory peak. let scale = max(targetSize.width / image.extent.width, targetSize.height / image.extent.height) let scaled = image.transformed(by: CGAffineTransform( scaleX: scale, y: scale )) let cropRect = CGRect( x: scaled.extent.midX - targetSize.width / 2, y: scaled.extent.midY - targetSize.height / 2, width: targetSize.width, height: targetSize.height ).integral guard let thumbnailImage = context.createCGImage( scaled.cropped(to: cropRect), from: cropRect ) else { return nil } let safeID = remoteID.filter { $0.isLetter || $0.isNumber || $0 == "-" } let name = "\(safeID.isEmpty ? "remote" : safeID).jpg" guard let url = thumbnailURL(fileName: name), let data = UIImage(cgImage: thumbnailImage) .jpegData(compressionQuality: 0.78) else { return nil } do { try data.write(to: url, options: .atomic) return name } catch { return nil } } DispatchQueue.main.async { completion(fileName) } } } static func removeThumbnail(fileName: String?) { guard let fileName, let url = thumbnailURL(fileName: fileName) else { return } try? FileManager.default.removeItem(at: url) } private static func thumbnailDirectory() -> URL? { guard let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } let directory = base.appendingPathComponent("RecentConnectionThumbnails", isDirectory: true) do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) return directory } catch { return nil } } } private enum ConnectionCredentialStore { private static let service = "cn.crossdesk.mobile.saved-password" static func password(for remoteID: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: remoteID, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } static func save(password: String, for remoteID: String) { removePassword(for: remoteID) let attributes: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: remoteID, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, kSecValueData as String: Data(password.utf8) ] SecItemAdd(attributes as CFDictionary, nil) } static func removePassword(for remoteID: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: remoteID ] SecItemDelete(query as CFDictionary) } } private final class RemoteAudioPlayer { private let engine = AVAudioEngine() private let player = AVAudioPlayerNode() private let queue = DispatchQueue(label: "cn.crossdesk.mobile.audio") private let format = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48_000, channels: 1, interleaved: false)! private var enabled = true private var queuedBuffers = 0 init() { engine.attach(player) engine.connect(player, to: engine.mainMixerNode, format: format) } func setEnabled(_ value: Bool) { queue.async { self.enabled = value if value { self.startIfNeeded() } else { self.player.stop() self.engine.stop() self.queuedBuffers = 0 } } } func enqueue(_ data: Data) { guard !data.isEmpty, data.count.isMultiple(of: MemoryLayout.size) else { return } queue.async { guard self.enabled, self.queuedBuffers < 80 else { return } self.startIfNeeded() guard self.engine.isRunning, let buffer = AVAudioPCMBuffer(pcmFormat: self.format, frameCapacity: AVAudioFrameCount(data.count / 2)), let samples = buffer.int16ChannelData?[0] else { return } buffer.frameLength = buffer.frameCapacity data.withUnsafeBytes { bytes in guard let source = bytes.baseAddress else { return } memcpy(samples, source, data.count) } self.queuedBuffers += 1 self.player.scheduleBuffer(buffer) { self.queue.async { self.queuedBuffers = max(0, self.queuedBuffers - 1) } } if !self.player.isPlaying { self.player.play() } } } private func startIfNeeded() { guard enabled, !engine.isRunning else { return } do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .moviePlayback, options: [.mixWithOthers]) try session.setActive(true) try engine.start() player.play() } catch { // The next PCM packet retries activation after route changes. } } } final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDelegate { @Published var signalHost = "api.crossdesk.cn" @Published var signalPort = "9099" @Published var turnPort = "3478" @Published var enableSRTP = false @Published var mouseControlMode = MouseControlMode.saved { didSet { mouseControlMode.save() } } @Published var videoCodecMode = VideoCodecMode.saved { didSet { videoCodecMode.save() } } @Published var remoteID = "" @Published var password = "" @Published private(set) var signalStatus = "正在连接信令服务" @Published private(set) var connectionStatus = "未连接" @Published private(set) var localIdentity = "" @Published private(set) var isConnecting = false @Published private(set) var isConnected = false @Published private(set) var sessionVisible = false @Published private var videoFrame: RemoteVideoFrame? @Published private(set) var remoteCursorVisible = true @Published private(set) var remoteCursorShape = 0 @Published private(set) var remoteCursorPosition: CGPoint? @Published private(set) var remoteCursorVisualOffset = CGPoint.zero @Published private(set) var hasRemoteCursorState = false @Published private(set) var bitrate: UInt = 0 @Published private(set) var lossRate: Float = 0 @Published private(set) var usingTURN = false @Published private(set) var displays: [String] = ["显示器 1"] @Published private(set) var displaySizes: [CGSize] = [] @Published var selectedDisplay = 0 @Published var audioEnabled = true @Published private(set) var clipboardStatus = "" @Published private(set) var transferStatus = "" @Published private(set) var transferProgress = 0.0 @Published private(set) var receivedFileURL: URL? @Published private(set) var frameCount: UInt64 = 0 @Published private(set) var recentConnections = RecentConnectionStore.load() @Published private(set) var recentConnectionPresence: [String: Bool] = [:] @Published private(set) var deviceOfflineAlertVisible = false let bridge = CrossDeskRTCBridge() private let audioPlayer = RemoteAudioPlayer() private var activeRemoteID = "" private var pendingRememberPassword = false private var connectionRecorded = false private var shouldCaptureThumbnail = false private var activeDisplayName = "" private var remoteCursorSequence: UInt32? private var pendingPresenceRemoteID: String? private var presenceProbeGeneration: UInt64 = 0 var pixelBuffer: CVPixelBuffer? { videoFrame?.pixelBuffer } var frameSize: CGSize { videoFrame?.encodedSize ?? .zero } var displayGeometrySize: CGSize { guard displaySizes.indices.contains(selectedDisplay) else { return frameSize } let size = displaySizes[selectedDisplay] return size.width > 0 && size.height > 0 ? size : frameSize } override init() { super.init() bridge.delegate = self configureBridge() } func configureBridge() { guard let signal = Int(signalPort), let turn = Int(turnPort), !signalHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { signalStatus = "服务器配置无效" return } bridge.setHardwareAccelerationEnabled(videoCodecMode == .hardware) bridge.configure(withSignalHost: signalHost, signalPort: signal, turnPort: turn, enableSRTP: enableSRTP) } func connect(password: String, rememberPassword: Bool) { self.password = password pendingRememberPassword = rememberPassword connect() } func connect() { let identifier = remoteID.trimmingCharacters(in: .whitespacesAndNewlines) guard !identifier.isEmpty else { connectionStatus = "请输入远程设备 ID" return } remoteID = identifier configureBridge() if recentConnectionPresence[identifier] == true { beginRemoteConnection(identifier) } else { beginPresenceProbe(identifier) } } private func beginRemoteConnection(_ identifier: String) { cancelPresenceProbe() activeRemoteID = identifier activeDisplayName = "" displaySizes = [] connectionRecorded = false shouldCaptureThumbnail = true isConnecting = true isConnected = false sessionVisible = false resetRemoteCursorState() connectionStatus = "正在准备连接…" bridge.connect(toRemoteID: identifier, password: password) } private func beginPresenceProbe(_ identifier: String) { guard signalStatus == "已连接服务器" else { showDeviceOffline() return } presenceProbeGeneration &+= 1 let generation = presenceProbeGeneration pendingPresenceRemoteID = identifier isConnecting = true isConnected = false sessionVisible = false connectionStatus = "正在确认设备状态…" bridge.requestPresence(remoteIDs: [identifier]) DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in guard let self, self.presenceProbeGeneration == generation, self.pendingPresenceRemoteID == identifier else { return } self.cancelPresenceProbe() self.refreshRecentConnectionPresence() self.showDeviceOffline() } } private func cancelPresenceProbe() { presenceProbeGeneration &+= 1 pendingPresenceRemoteID = nil } private func showDeviceOffline() { isConnecting = false isConnected = false sessionVisible = false connectionStatus = "设备离线" deviceOfflineAlertVisible = true } func dismissDeviceOfflineAlert() { deviceOfflineAlertVisible = false } func disconnect() { let wasCheckingPresence = pendingPresenceRemoteID != nil cancelPresenceProbe() if wasCheckingPresence { refreshRecentConnectionPresence() } bridge.disconnect() isConnecting = false isConnected = false sessionVisible = false videoFrame = nil resetRemoteCursorState() frameCount = 0 bitrate = 0 displays = ["显示器 1"] displaySizes = [] selectedDisplay = 0 connectionStatus = "未连接" audioPlayer.setEnabled(false) AppOrientation.update(to: .portrait) } func retry() { bridge.disconnect() connect() } func savedPassword(for remoteID: String) -> String { ConnectionCredentialStore.password(for: remoteID) ?? "" } func savedCredential(for remoteID: String) -> String? { ConnectionCredentialStore.password(for: remoteID) } func remembersPassword(for remoteID: String) -> Bool { recentConnections.first(where: { $0.remoteID == remoteID })? .remembersPassword == true } func thumbnailImage(for connection: RecentConnection) -> UIImage? { guard let fileName = connection.thumbnailFileName, let url = RecentConnectionStore.thumbnailURL(fileName: fileName) else { return nil } return UIImage(contentsOfFile: url.path) } func isRecentConnectionOnline(_ connection: RecentConnection) -> Bool { recentConnectionPresence[connection.remoteID] == true } private func refreshRecentConnectionPresence() { bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID)) } func refreshRecentConnectionPresenceAfterForeground() { // iOS can suspend the process while it is in the background, so presence // updates received during that time are missed. Treat cached values as // unknown (and therefore offline, like the desktop client) until the // signaling server returns a fresh snapshot. recentConnectionPresence = [:] if let pendingPresenceRemoteID { bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID]) } else { refreshRecentConnectionPresence() } } func removeRecentConnection(_ connection: RecentConnection) { recentConnections.removeAll { $0.remoteID == connection.remoteID } recentConnectionPresence.removeValue(forKey: connection.remoteID) RecentConnectionStore.save(recentConnections) RecentConnectionStore.removeThumbnail(fileName: connection.thumbnailFileName) ConnectionCredentialStore.removePassword(for: connection.remoteID) refreshRecentConnectionPresence() } private func recordSuccessfulConnectionIfNeeded() { guard !connectionRecorded, !activeRemoteID.isEmpty else { return } connectionRecorded = true if pendingRememberPassword { ConnectionCredentialStore.save(password: password, for: activeRemoteID) } else { ConnectionCredentialStore.removePassword(for: activeRemoteID) } let previous = recentConnections.first { $0.remoteID == activeRemoteID } let title = activeDisplayName.isEmpty ? (previous?.displayName ?? activeRemoteID) : activeDisplayName let connection = RecentConnection( remoteID: activeRemoteID, displayName: title, lastConnectedAt: Date(), remembersPassword: pendingRememberPassword, thumbnailFileName: previous?.thumbnailFileName ) recentConnections.removeAll { $0.remoteID == activeRemoteID } recentConnections.insert(connection, at: 0) recentConnectionPresence[activeRemoteID] = true RecentConnectionStore.save(recentConnections) refreshRecentConnectionPresence() } private func updateActiveConnectionName(_ name: String) { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !activeRemoteID.isEmpty else { return } activeDisplayName = trimmed guard let index = recentConnections.firstIndex(where: { $0.remoteID == activeRemoteID }) else { return } recentConnections[index].displayName = trimmed RecentConnectionStore.save(recentConnections) } private func captureRecentThumbnailIfNeeded(_ pixelBuffer: CVPixelBuffer) { guard shouldCaptureThumbnail, !activeRemoteID.isEmpty else { return } shouldCaptureThumbnail = false let identifier = activeRemoteID RecentConnectionStore.captureThumbnail(from: pixelBuffer, remoteID: identifier) { [weak self] fileName in guard let self, let fileName, let index = self.recentConnections.firstIndex(where: { $0.remoteID == identifier }) else { return } self.recentConnections[index].thumbnailFileName = fileName RecentConnectionStore.save(self.recentConnections) } } func selectDisplay(_ index: Int) { guard displays.indices.contains(index) else { return } selectedDisplay = index videoFrame = nil resetRemoteCursorState() frameCount = 0 bridge.switch(toDisplay: index) } private func resetRemoteCursorState() { remoteCursorVisible = true remoteCursorShape = 0 remoteCursorPosition = nil remoteCursorVisualOffset = .zero hasRemoteCursorState = false remoteCursorSequence = nil } func toggleAudio() { audioEnabled.toggle() audioPlayer.setEnabled(audioEnabled) bridge.setAudioEnabled(audioEnabled) } func sendClipboard() { guard let text = UIPasteboard.general.string, !text.isEmpty else { clipboardStatus = "剪贴板中没有文本" return } guard text.lengthOfBytes(using: .utf8) <= 128 * 1024 else { clipboardStatus = "剪贴板文本超过 128 KiB" return } bridge.sendClipboardText(text) clipboardStatus = "已发送本机剪贴板" } func sendFile(_ url: URL) { transferStatus = "正在发送 \(url.lastPathComponent)" transferProgress = 0 bridge.sendFile(at: url) } func sendKeyStroke(_ keyCode: UInt) { bridge.sendWindowsKeyCode(keyCode, isDown: true) bridge.sendWindowsKeyCode(keyCode, isDown: false) } func sendKeyState(_ keyCode: UInt, isDown: Bool) { bridge.sendWindowsKeyCode(keyCode, isDown: isDown) } func rtcBridge(_ bridge: CrossDeskRTCBridge, didChange state: CrossDeskSignalState) { switch state.rawValue { case 0: signalStatus = "正在连接信令服务" case 1: signalStatus = "已连接服务器" case 2: signalStatus = "信令连接失败" case 3: signalStatus = "信令连接已关闭" case 4: signalStatus = "信令服务重连中" case 5: signalStatus = "信令服务器已关闭连接" case 6: signalStatus = "TLS 证书校验失败" default: signalStatus = "未知信令状态" } if state.rawValue == 1 { if let pendingPresenceRemoteID { bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID]) } else { refreshRecentConnectionPresence() } } } func rtcBridge(_ bridge: CrossDeskRTCBridge, didChange state: CrossDeskConnectionState, remoteID: String) { isConnected = state.rawValue == 1 isConnecting = [0, 2].contains(state.rawValue) switch state.rawValue { case 0: connectionStatus = "正在建立远程连接" case 1: connectionStatus = "已连接" if !remoteID.isEmpty { recentConnectionPresence[remoteID] = true } sessionVisible = true recordSuccessfulConnectionIfNeeded() audioPlayer.setEnabled(audioEnabled) bridge.setAudioEnabled(audioEnabled) AppOrientation.update(to: .allButUpsideDown) case 2: connectionStatus = "正在收集 ICE 候选" case 3: connectionStatus = "网络连接已中断" case 4: connectionStatus = "P2P/TURN 建链失败" sessionVisible = false AppOrientation.update(to: .portrait) case 5: connectionStatus = "未连接" sessionVisible = false AppOrientation.update(to: .portrait) case 6: connectionStatus = "访问密码错误" sessionVisible = false AppOrientation.update(to: .portrait) case 7: connectionStatus = "远程设备 ID 不存在" if !remoteID.isEmpty { recentConnectionPresence[remoteID] = false } sessionVisible = false AppOrientation.update(to: .portrait) case 8: connectionStatus = "远程设备当前不可用" if !remoteID.isEmpty { recentConnectionPresence[remoteID] = false } sessionVisible = false AppOrientation.update(to: .portrait) default: connectionStatus = "未知连接状态" } } func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) { localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity if let pendingPresenceRemoteID { bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID]) } else { refreshRecentConnectionPresence() } } func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceivePresence presence: [String: NSNumber]) { var updated = recentConnectionPresence for (remoteID, online) in presence { updated[remoteID] = online.boolValue } recentConnectionPresence = updated guard let pendingRemoteID = pendingPresenceRemoteID, let online = presence[pendingRemoteID]?.boolValue else { return } cancelPresenceProbe() refreshRecentConnectionPresence() if online { beginRemoteConnection(pendingRemoteID) } else { showDeviceOffline() } } func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceive pixelBuffer: CVPixelBuffer, width: Int, height: Int) { // Buffer and encoded dimensions must be one observable value. Adaptive // resolution changes must never expose a new frame with the previous // frame's geometry to SwiftUI. videoFrame = RemoteVideoFrame( pixelBuffer: pixelBuffer, encodedSize: CGSize(width: width, height: height) ) frameCount &+= 1 captureRecentThumbnailIfNeeded(pixelBuffer) } func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveHostName hostName: String, displayNames remoteDisplayNames: [String], displaySizes remoteDisplaySizes: [NSValue]) { updateActiveConnectionName(hostName) let names = remoteDisplayNames.enumerated().map { index, displayName in let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines) return name.isEmpty ? "显示器 \(index + 1)" : name } if !names.isEmpty { displays = names displaySizes = remoteDisplaySizes.map(\.cgSizeValue) selectedDisplay = min(selectedDisplay, names.count - 1) bridge.switch(toDisplay: selectedDisplay) } } func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveCursorVisible visible: Bool, shape: Int, positionUpdate: Bool, positionValid: Bool, x: Float, y: Float, visualOffsetX: Float, visualOffsetY: Float, display displayIndex: Int, sequence: UInt32) { if let previous = remoteCursorSequence, sequence != 0, Int32(bitPattern: sequence &- previous) <= 0 { return } remoteCursorSequence = sequence remoteCursorVisible = visible remoteCursorShape = min(max(shape, 0), 28) remoteCursorVisualOffset = CGPoint(x: CGFloat(visualOffsetX), y: CGFloat(visualOffsetY)) if positionUpdate { if positionValid, displayIndex == selectedDisplay { remoteCursorPosition = CGPoint( x: CGFloat(min(max(x, 0), 1)), y: CGFloat(min(max(y, 0), 1)) ) } else { remoteCursorPosition = nil } } hasRemoteCursorState = true } func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveAudioPCM pcmData: Data) { audioPlayer.enqueue(pcmData) } func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveClipboardText text: String) { UIPasteboard.general.string = text clipboardStatus = "已接收远端剪贴板" } func rtcBridge(_ bridge: CrossDeskRTCBridge, didUpdateFileTransfer fileName: String, progress: Double, sending: Bool) { if progress < 0 { transferStatus = "\(fileName) 传输失败" transferProgress = 0 return } transferProgress = progress let percent = Int((progress * 100).rounded()) transferStatus = "\(sending ? "发送" : "接收") \(fileName) · \(percent)%" } func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveFileAt fileURL: URL) { receivedFileURL = fileURL transferStatus = "已接收 \(fileURL.lastPathComponent)" transferProgress = 1 } func rtcBridge(_ bridge: CrossDeskRTCBridge, didUpdateBitrate bitsPerSecond: UInt, lossRate: Float, usingTURN: Bool) { bitrate = bitsPerSecond self.lossRate = lossRate self.usingTURN = usingTURN } var formattedBitrate: String { if bitrate >= 1_000_000 { return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000) } if bitrate >= 1_000 { return String(format: "%.0f Kbps", Double(bitrate) / 1_000) } return "\(bitrate) bps" } var videoStatus: String { if frameCount > 0 { return "视频 \(Int(frameSize.width))×\(Int(frameSize.height)) · \(frameCount) 帧" } return isConnected ? "正在取回远程画面…" : "等待连接" } }