mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-17 01:13:19 +08:00
1360 lines
52 KiB
Swift
1360 lines
52 KiB
Swift
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 VideoAdaptationPolicy: String, CaseIterable, Identifiable {
|
||
case frameRatePriority
|
||
case qualityPriority
|
||
case balanced
|
||
|
||
private static let defaultsKey = "crossdesk.mobile.video-adaptation-policy"
|
||
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .frameRatePriority: return "帧率优先"
|
||
case .qualityPriority: return "画质优先"
|
||
case .balanced: return "平衡"
|
||
}
|
||
}
|
||
|
||
var detail: String {
|
||
switch self {
|
||
case .frameRatePriority:
|
||
return "优先保持画面流畅,带宽或编码压力较大时会更积极地降低分辨率。"
|
||
case .qualityPriority:
|
||
return "始终保持较高分辨率,带宽不足时通过降低帧率来保证画面清晰度。"
|
||
case .balanced:
|
||
return "兼顾清晰度和流畅度,只在持续压力下逐步降低分辨率。"
|
||
}
|
||
}
|
||
|
||
var bridgeValue: CrossDeskVideoAdaptationPolicy {
|
||
switch self {
|
||
case .frameRatePriority: return .frameRatePriority
|
||
case .qualityPriority: return .qualityPriority
|
||
case .balanced: return .balanced
|
||
}
|
||
}
|
||
|
||
static var saved: VideoAdaptationPolicy {
|
||
guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
|
||
let policy = VideoAdaptationPolicy(rawValue: rawValue) else {
|
||
return .qualityPriority
|
||
}
|
||
return policy
|
||
}
|
||
|
||
func save() {
|
||
UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey)
|
||
}
|
||
}
|
||
|
||
private struct RemoteVideoFrame {
|
||
let pixelBuffer: CVPixelBuffer
|
||
let encodedSize: CGSize
|
||
let id: UInt64
|
||
let captureUptime: TimeInterval
|
||
}
|
||
|
||
private enum RecentConnectionStore {
|
||
private static let defaultsKey = "crossdesk.mobile.recent-connections.v1"
|
||
private static let thumbnails = ThumbnailFileStore(directory:
|
||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||
.appendingPathComponent("RecentConnectionThumbnails", isDirectory: true))
|
||
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 {
|
||
thumbnails.url(for: fileName)
|
||
}
|
||
|
||
private static func thumbnailFileName(for remoteID: String) -> String {
|
||
let safeID = remoteID.filter { $0.isLetter || $0.isNumber || $0 == "-" }
|
||
return "\(safeID.isEmpty ? "remote" : safeID).jpg"
|
||
}
|
||
|
||
static func captureThumbnail(from pixelBuffer: CVPixelBuffer,
|
||
remoteID: String,
|
||
completion: @escaping (String?) -> Void) {
|
||
let name = thumbnailFileName(for: remoteID)
|
||
thumbnails.save(fileName: name, makeData: {
|
||
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 before materializing a bitmap to avoid a full-size frame copy.
|
||
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 }
|
||
return UIImage(cgImage: thumbnailImage).jpegData(compressionQuality: 0.78)
|
||
}) { name in
|
||
DispatchQueue.main.async { completion(name) }
|
||
}
|
||
}
|
||
|
||
static func removeThumbnail(for connection: RecentConnection) {
|
||
// The first capture may still be queued, before its filename has been
|
||
// attached to the recent connection. Delete that pending file too.
|
||
let pendingFileName = thumbnailFileName(for: connection.remoteID)
|
||
thumbnails.remove(fileName: pendingFileName)
|
||
if let fileName = connection.thumbnailFileName, fileName != pendingFileName {
|
||
thumbnails.remove(fileName: fileName)
|
||
}
|
||
}
|
||
|
||
static func removeAllThumbnails(completion: @escaping (Bool) -> Void) {
|
||
thumbnails.removeAll { success in
|
||
DispatchQueue.main.async { completion(success) }
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
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<Int16>.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 {
|
||
static let officialSignalHost = "api.crossdesk.cn"
|
||
static let officialSignalPort = "9099"
|
||
|
||
private let privacyPreferences = PrivacyPreferences()
|
||
@Published private(set) var hasNetworkConsent = PrivacyPreferences().hasNetworkConsent
|
||
@Published private var privacyNotice = PrivacyNoticeState(
|
||
hasNetworkConsent: PrivacyPreferences().hasNetworkConsent
|
||
)
|
||
@Published private(set) var savesConnectionThumbnails = PrivacyPreferences().savesThumbnails
|
||
@Published private(set) var thumbnailCleanupInProgress = false
|
||
@Published private(set) var thumbnailCleanupError: String?
|
||
private var thumbnailGeneration = UUID()
|
||
|
||
@Published var signalHost = RemoteSessionModel.officialSignalHost
|
||
@Published var signalPort = RemoteSessionModel.officialSignalPort
|
||
@Published var mouseControlMode = MouseControlMode.saved {
|
||
didSet { mouseControlMode.save() }
|
||
}
|
||
@Published var videoAdaptationPolicy = VideoAdaptationPolicy.saved {
|
||
didSet { videoAdaptationPolicy.save() }
|
||
}
|
||
@Published private(set) var videoSettings = RemoteVideoSettingsState()
|
||
@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 identityStorageWarning: String?
|
||
@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 remoteCursorPositionRevision: UInt64 = 0
|
||
@Published private(set) var remoteCursorVisualOffset = CGPoint.zero
|
||
@Published private(set) var hasRemoteCursorState = false
|
||
private var networkStatistics = RemoteNetworkStatistics()
|
||
private var videoFrameID: UInt64 = 0
|
||
@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
|
||
@Published private(set) var connectionFailureMessage: String?
|
||
@Published private(set) var remoteUpdateMessage: String?
|
||
@Published private(set) var remotePlatform: RemoteHostPlatform = .unknown
|
||
|
||
let bridge = CrossDeskRTCBridge()
|
||
let announcements = AnnouncementInbox()
|
||
let appUpdates = AppUpdateChecker()
|
||
private var appIsActive = false
|
||
private let audioPlayer = RemoteAudioPlayer()
|
||
private var activeRemoteID = ""
|
||
private var remoteAppVersion = ""
|
||
private var remoteHostInfoReceived = false
|
||
private var remoteVersionCheckAttempted = false
|
||
private var remoteVersionCheckGeneration = UUID()
|
||
private var remoteVersionCheckTask: URLSessionDataTask?
|
||
private static let remoteUpdateNotice = "被控端版本过低,请升级到最新版本。"
|
||
private var pendingRememberPassword = false
|
||
private var connectionRecorded = false
|
||
private var shouldCaptureThumbnail = false
|
||
private var activeDisplayName = ""
|
||
private var remoteCursorSequence: UInt32?
|
||
private var videoWasBackgrounded = false
|
||
private var videoRecoveryTask: Task<Void, Never>?
|
||
private var videoSettingsTimeoutTask: Task<Void, Never>?
|
||
private var pendingPresenceRemoteID: String?
|
||
private var presenceProbeGeneration: UInt64 = 0
|
||
private var signalConnected = false
|
||
private var presenceIsForeground = false
|
||
private var awaitingPresenceSnapshot = true
|
||
private var presenceUpdatedAt: [String: ContinuousClock.Instant] = [:]
|
||
private var lastPresenceRefreshAt: ContinuousClock.Instant?
|
||
private var presenceTimer: Timer?
|
||
private let presenceClock = ContinuousClock()
|
||
private static let presenceRefreshInterval: Duration = .seconds(30)
|
||
private static let presenceMaxAge: Duration = .seconds(60)
|
||
|
||
private struct BridgeConfiguration: Equatable {
|
||
let host: String
|
||
let signalPort: Int
|
||
}
|
||
private var bridgeConfiguration: BridgeConfiguration?
|
||
|
||
var pixelBuffer: CVPixelBuffer? { videoFrame?.pixelBuffer }
|
||
var currentVideoFrameID: UInt64 { videoFrame?.id ?? 0 }
|
||
var frameCaptureUptime: TimeInterval { videoFrame?.captureUptime ?? 0 }
|
||
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
|
||
announcements.send = { [weak bridge] data, requestID in
|
||
bridge?.sendAnnouncementRequest(data, requestID: requestID)
|
||
}
|
||
if !savesConnectionThumbnails { clearConnectionThumbnails() }
|
||
if hasNetworkConsent { configureBridge() }
|
||
}
|
||
|
||
deinit {
|
||
presenceTimer?.invalidate()
|
||
remoteVersionCheckTask?.cancel()
|
||
}
|
||
|
||
var privacyNoticeVisible: Bool { privacyNotice.isVisible }
|
||
|
||
func applicationDidBecomeActive() {
|
||
appIsActive = true
|
||
appUpdates.setEnabled(hasNetworkConsent)
|
||
privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent)
|
||
refreshRecentConnectionPresenceAfterForeground()
|
||
refreshAnnouncements()
|
||
resumeVideoAfterForeground()
|
||
}
|
||
|
||
func applicationDidEnterBackground() {
|
||
appIsActive = false
|
||
appUpdates.setEnabled(false)
|
||
privacyNotice.didEnterBackground()
|
||
suspendVideoForBackground()
|
||
suspendPresenceMonitoring()
|
||
}
|
||
|
||
func showPrivacyNotice() {
|
||
privacyNotice.show()
|
||
}
|
||
|
||
func dismissPrivacyNotice() {
|
||
privacyNotice.dismiss()
|
||
}
|
||
|
||
func acceptNetworkConsent() {
|
||
privacyPreferences.setNetworkConsent(true)
|
||
hasNetworkConsent = true
|
||
appUpdates.setEnabled(appIsActive)
|
||
privacyNotice.dismiss()
|
||
configureBridge()
|
||
}
|
||
|
||
func declineNetworkConsent() {
|
||
revokeNetworkConsent()
|
||
privacyNotice.dismiss()
|
||
}
|
||
|
||
func revokeNetworkConsent() {
|
||
privacyPreferences.setNetworkConsent(false)
|
||
hasNetworkConsent = false
|
||
appUpdates.setEnabled(false)
|
||
signalConnected = false
|
||
stopPresenceMonitoring()
|
||
invalidatePresence()
|
||
resetConnection()
|
||
// disconnect() only closes the remote session. Consent withdrawal must
|
||
// also destroy the persistent signaling peer and its reconnect loop.
|
||
bridge.stopNetworking()
|
||
announcements.reset()
|
||
bridgeConfiguration = nil
|
||
localIdentity = ""
|
||
signalStatus = "等待隐私授权"
|
||
savesConnectionThumbnails = false
|
||
clearConnectionThumbnails()
|
||
}
|
||
|
||
func setSavesConnectionThumbnails(_ allowed: Bool) {
|
||
privacyPreferences.setSavesThumbnails(allowed)
|
||
savesConnectionThumbnails = privacyPreferences.savesThumbnails
|
||
if !savesConnectionThumbnails { clearConnectionThumbnails() }
|
||
// Enabling applies on the next connection, never to a frame captured
|
||
// before the user opted in.
|
||
}
|
||
|
||
func clearConnectionThumbnails() {
|
||
thumbnailGeneration = UUID()
|
||
shouldCaptureThumbnail = false
|
||
for index in recentConnections.indices {
|
||
recentConnections[index].thumbnailFileName = nil
|
||
}
|
||
RecentConnectionStore.save(recentConnections)
|
||
thumbnailCleanupInProgress = true
|
||
thumbnailCleanupError = nil
|
||
let generation = thumbnailGeneration
|
||
RecentConnectionStore.removeAllThumbnails { [weak self] success in
|
||
guard let self, self.thumbnailGeneration == generation else { return }
|
||
self.thumbnailCleanupInProgress = false
|
||
self.thumbnailCleanupError = success
|
||
? nil
|
||
: "部分预览图未能清除,请重试。"
|
||
}
|
||
}
|
||
|
||
var usesOfficialServer: Bool {
|
||
signalHost == Self.officialSignalHost && signalPort == Self.officialSignalPort
|
||
}
|
||
|
||
func restoreOfficialServerConfiguration() {
|
||
signalHost = Self.officialSignalHost
|
||
signalPort = Self.officialSignalPort
|
||
}
|
||
|
||
@discardableResult
|
||
func configureBridge() -> Bool {
|
||
guard hasNetworkConsent else {
|
||
signalStatus = "等待隐私授权"
|
||
return false
|
||
}
|
||
let host = signalHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard let signal = Int(signalPort),
|
||
(1...65535).contains(signal),
|
||
!host.isEmpty else {
|
||
signalStatus = "服务器配置无效"
|
||
return false
|
||
}
|
||
let configuration = BridgeConfiguration(host: host, signalPort: signal)
|
||
if bridgeConfiguration != configuration {
|
||
bridgeConfiguration = configuration
|
||
signalConnected = false
|
||
localIdentity = ""
|
||
announcements.reset()
|
||
signalStatus = "正在连接信令服务"
|
||
stopPresenceMonitoring()
|
||
invalidatePresence()
|
||
cancelPendingPresenceConnection()
|
||
} else if signalConnected {
|
||
signalStatus = "已连接服务器"
|
||
}
|
||
bridge.setVideoAdaptationPolicy(videoAdaptationPolicy.bridgeValue)
|
||
bridge.configure(withSignalHost: host,
|
||
signalPort: signal)
|
||
return true
|
||
}
|
||
|
||
func connect(password: String, rememberPassword: Bool) {
|
||
self.password = password
|
||
pendingRememberPassword = rememberPassword
|
||
connect()
|
||
}
|
||
|
||
func connect() {
|
||
guard hasNetworkConsent else {
|
||
showPrivacyNotice()
|
||
return
|
||
}
|
||
connectionFailureMessage = nil
|
||
let identifier = remoteID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !identifier.isEmpty else {
|
||
connectionStatus = "请输入远程设备 ID"
|
||
return
|
||
}
|
||
remoteID = identifier
|
||
guard configureBridge() else {
|
||
connectionStatus = signalStatus
|
||
return
|
||
}
|
||
if isDeviceOnline(identifier) {
|
||
beginRemoteConnection(identifier)
|
||
} else {
|
||
beginPresenceProbe(identifier)
|
||
}
|
||
}
|
||
|
||
private func beginRemoteConnection(_ identifier: String) {
|
||
networkStatistics = RemoteNetworkStatistics()
|
||
resetRemoteHostInfo()
|
||
cancelVideoRecovery()
|
||
resetVideoSettings()
|
||
cancelPresenceProbe()
|
||
activeRemoteID = identifier
|
||
activeDisplayName = ""
|
||
displaySizes = []
|
||
connectionRecorded = false
|
||
shouldCaptureThumbnail = savesConnectionThumbnails
|
||
isConnecting = true
|
||
isConnected = false
|
||
sessionVisible = false
|
||
resetRemoteCursorState()
|
||
connectionStatus = "正在准备连接…"
|
||
bridge.connect(toRemoteID: identifier, password: password)
|
||
}
|
||
|
||
private func beginPresenceProbe(_ identifier: String) {
|
||
guard hasNetworkConsent, signalConnected, presenceIsForeground else {
|
||
showDeviceOffline()
|
||
return
|
||
}
|
||
|
||
presenceProbeGeneration &+= 1
|
||
let generation = presenceProbeGeneration
|
||
pendingPresenceRemoteID = identifier
|
||
isConnecting = true
|
||
isConnected = false
|
||
sessionVisible = false
|
||
connectionStatus = "正在确认设备状态…"
|
||
requestPresenceProbe(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 cancelPendingPresenceConnection() {
|
||
guard pendingPresenceRemoteID != nil else { return }
|
||
cancelPresenceProbe()
|
||
isConnecting = false
|
||
connectionStatus = "未连接"
|
||
}
|
||
|
||
private func requestPresenceProbe(_ identifier: String) {
|
||
guard hasNetworkConsent, signalConnected, presenceIsForeground else { return }
|
||
// Older servers ignore subscribe=false and replace the watched list.
|
||
var identifiers = recentConnections.map(\.remoteID)
|
||
if !identifiers.contains(identifier) { identifiers.append(identifier) }
|
||
bridge.requestPresence(remoteIDs: identifiers, subscribe: false)
|
||
}
|
||
|
||
private func showDeviceOffline() {
|
||
isConnecting = false
|
||
isConnected = false
|
||
sessionVisible = false
|
||
connectionStatus = "设备离线"
|
||
deviceOfflineAlertVisible = true
|
||
}
|
||
|
||
func dismissDeviceOfflineAlert() {
|
||
deviceOfflineAlertVisible = false
|
||
}
|
||
|
||
func dismissConnectionFailure() {
|
||
guard connectionFailureMessage != nil else { return }
|
||
connectionFailureMessage = nil
|
||
}
|
||
|
||
private func finishConnection(withError message: String) {
|
||
// Cleanup may emit Closed. Preserve the terminal reason independently
|
||
// of the progress view and invalidate those callbacks in the bridge.
|
||
resetConnection()
|
||
connectionStatus = message
|
||
connectionFailureMessage = message
|
||
}
|
||
|
||
func disconnect() {
|
||
resetConnection()
|
||
}
|
||
|
||
private func resetConnection() {
|
||
resetRemoteHostInfo()
|
||
resetVideoSettings()
|
||
connectionFailureMessage = nil
|
||
cancelVideoRecovery()
|
||
let wasCheckingPresence = pendingPresenceRemoteID != nil
|
||
cancelPresenceProbe()
|
||
if wasCheckingPresence {
|
||
refreshRecentConnectionPresence()
|
||
}
|
||
bridge.disconnect()
|
||
isConnecting = false
|
||
isConnected = false
|
||
sessionVisible = false
|
||
videoFrame = nil
|
||
resetRemoteCursorState()
|
||
frameCount = 0
|
||
networkStatistics = RemoteNetworkStatistics()
|
||
displays = ["显示器 1"]
|
||
displaySizes = []
|
||
selectedDisplay = 0
|
||
connectionStatus = "未连接"
|
||
audioPlayer.setEnabled(false)
|
||
AppOrientation.update(to: .portrait)
|
||
appUpdates.checkNow()
|
||
}
|
||
|
||
func retry() {
|
||
resetRemoteHostInfo()
|
||
cancelVideoRecovery()
|
||
bridge.disconnect()
|
||
connect()
|
||
}
|
||
|
||
func dismissRemoteUpdate() {
|
||
remoteUpdateMessage = nil
|
||
}
|
||
|
||
private func resetRemoteHostInfo() {
|
||
remoteVersionCheckTask?.cancel()
|
||
remoteVersionCheckTask = nil
|
||
remoteVersionCheckGeneration = UUID()
|
||
remoteVersionCheckAttempted = false
|
||
remoteAppVersion = ""
|
||
remotePlatform = .unknown
|
||
remoteHostInfoReceived = false
|
||
remoteUpdateMessage = nil
|
||
}
|
||
|
||
private func checkRemoteVersionIfNeeded() {
|
||
guard hasNetworkConsent, isConnected, remoteHostInfoReceived,
|
||
!remoteVersionCheckAttempted else { return }
|
||
remoteVersionCheckAttempted = true
|
||
if remoteAppVersion.isEmpty {
|
||
remoteUpdateMessage = Self.remoteUpdateNotice
|
||
return
|
||
}
|
||
let generation = remoteVersionCheckGeneration
|
||
let appVersion = remoteAppVersion
|
||
// Only public release metadata is fetched; no peer ID or version is sent.
|
||
let url = URL(string: "https://version.crossdesk.cn/version.json")!
|
||
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData,
|
||
timeoutInterval: 15)
|
||
remoteVersionCheckTask = URLSession.shared.dataTask(with: request) {
|
||
[weak self] data, response, error in
|
||
let latest: String?
|
||
if error == nil, let data,
|
||
(response as? HTTPURLResponse)?.statusCode == 200 {
|
||
latest = CrossDeskRTCBridge.availableUpdate(appVersion: appVersion,
|
||
releaseJSON: data)
|
||
} else {
|
||
latest = nil
|
||
}
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self,
|
||
self.remoteVersionCheckGeneration == generation,
|
||
self.isConnected else { return }
|
||
self.remoteVersionCheckTask = nil
|
||
guard latest != nil else { return }
|
||
self.remoteUpdateMessage = Self.remoteUpdateNotice
|
||
}
|
||
}
|
||
remoteVersionCheckTask?.resume()
|
||
}
|
||
|
||
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 savesConnectionThumbnails,
|
||
let fileName = connection.thumbnailFileName else {
|
||
return nil
|
||
}
|
||
return UIImage(contentsOfFile: RecentConnectionStore.thumbnailURL(fileName: fileName).path)
|
||
}
|
||
|
||
func isRecentConnectionOnline(_ connection: RecentConnection) -> Bool {
|
||
isDeviceOnline(connection.remoteID)
|
||
}
|
||
|
||
private func isDeviceOnline(_ identifier: String) -> Bool {
|
||
guard hasNetworkConsent, signalConnected, presenceIsForeground, !awaitingPresenceSnapshot,
|
||
recentConnectionPresence[identifier] == true,
|
||
let updatedAt = presenceUpdatedAt[identifier] else { return false }
|
||
return updatedAt.duration(to: presenceClock.now) < Self.presenceMaxAge
|
||
}
|
||
|
||
private func refreshRecentConnectionPresence() {
|
||
guard hasNetworkConsent, signalConnected, presenceIsForeground else { return }
|
||
lastPresenceRefreshAt = presenceClock.now
|
||
bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID),
|
||
subscribe: true)
|
||
}
|
||
|
||
private func invalidatePresence() {
|
||
bridge.invalidatePresence()
|
||
recentConnectionPresence = [:]
|
||
presenceUpdatedAt = [:]
|
||
awaitingPresenceSnapshot = true
|
||
lastPresenceRefreshAt = nil
|
||
}
|
||
|
||
private func startPresenceMonitoring() {
|
||
guard hasNetworkConsent, signalConnected, presenceIsForeground, presenceTimer == nil else { return }
|
||
let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
|
||
self?.maintainPresence()
|
||
}
|
||
RunLoop.main.add(timer, forMode: .common)
|
||
presenceTimer = timer
|
||
}
|
||
|
||
private func stopPresenceMonitoring() {
|
||
presenceTimer?.invalidate()
|
||
presenceTimer = nil
|
||
}
|
||
|
||
private func maintainPresence() {
|
||
guard hasNetworkConsent, signalConnected, presenceIsForeground else { return }
|
||
let now = presenceClock.now
|
||
let expired = presenceUpdatedAt.compactMap { identifier, updatedAt in
|
||
updatedAt.duration(to: now) >= Self.presenceMaxAge ? identifier : nil
|
||
}
|
||
if !expired.isEmpty {
|
||
var updated = recentConnectionPresence
|
||
for identifier in expired {
|
||
updated.removeValue(forKey: identifier)
|
||
presenceUpdatedAt.removeValue(forKey: identifier)
|
||
}
|
||
recentConnectionPresence = updated
|
||
}
|
||
if lastPresenceRefreshAt.map({ $0.duration(to: now) >= Self.presenceRefreshInterval }) ?? true {
|
||
refreshRecentConnectionPresence()
|
||
}
|
||
}
|
||
|
||
func suspendPresenceMonitoring() {
|
||
presenceIsForeground = false
|
||
stopPresenceMonitoring()
|
||
invalidatePresence()
|
||
cancelPendingPresenceConnection()
|
||
}
|
||
|
||
func suspendVideoForBackground() {
|
||
videoWasBackgrounded = true
|
||
networkStatistics.resetVideo()
|
||
cancelVideoRecovery()
|
||
}
|
||
|
||
func resumeVideoAfterForeground() {
|
||
guard videoWasBackgrounded else { return }
|
||
videoWasBackgrounded = false
|
||
guard isConnected, sessionVisible else { return }
|
||
|
||
cancelVideoRecovery()
|
||
NSLog("CrossDesk resuming foreground video, requesting key frame")
|
||
bridge.requestKeyFrame()
|
||
// The first request can race transport resumption. Retry briefly until
|
||
// a decoded frame arrives, and never carry retries into another session.
|
||
videoRecoveryTask = Task { @MainActor [weak self] in
|
||
for delay in [500_000_000, 1_000_000_000, 2_000_000_000] as [UInt64] {
|
||
do {
|
||
try await Task.sleep(nanoseconds: delay)
|
||
} catch {
|
||
return
|
||
}
|
||
guard !Task.isCancelled, let self,
|
||
!self.videoWasBackgrounded,
|
||
self.isConnected, self.sessionVisible else { return }
|
||
self.bridge.requestKeyFrame()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func cancelVideoRecovery() {
|
||
videoRecoveryTask?.cancel()
|
||
videoRecoveryTask = nil
|
||
}
|
||
|
||
func refreshRecentConnectionPresenceAfterForeground() {
|
||
presenceIsForeground = true
|
||
invalidatePresence()
|
||
startPresenceMonitoring()
|
||
refreshRecentConnectionPresence()
|
||
if let pendingPresenceRemoteID {
|
||
requestPresenceProbe(pendingPresenceRemoteID)
|
||
}
|
||
}
|
||
|
||
func removeRecentConnection(_ connection: RecentConnection) {
|
||
recentConnections.removeAll { $0.remoteID == connection.remoteID }
|
||
recentConnectionPresence.removeValue(forKey: connection.remoteID)
|
||
presenceUpdatedAt.removeValue(forKey: connection.remoteID)
|
||
RecentConnectionStore.save(recentConnections)
|
||
RecentConnectionStore.removeThumbnail(for: connection)
|
||
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,
|
||
platform: remoteHostInfoReceived ? remotePlatform : (previous?.platform ?? .unknown)
|
||
)
|
||
recentConnections.removeAll { $0.remoteID == activeRemoteID }
|
||
recentConnections.insert(connection, at: 0)
|
||
RecentConnectionStore.save(recentConnections)
|
||
refreshRecentConnectionPresence()
|
||
}
|
||
|
||
private func updateActiveConnectionHostInfo(_ name: String) {
|
||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !activeRemoteID.isEmpty else { return }
|
||
if !trimmed.isEmpty { activeDisplayName = trimmed }
|
||
guard let index = recentConnections.firstIndex(where: {
|
||
$0.remoteID == activeRemoteID
|
||
}) else { return }
|
||
recentConnections[index].updateHostInfo(name: trimmed, platform: remotePlatform)
|
||
RecentConnectionStore.save(recentConnections)
|
||
}
|
||
|
||
private func captureRecentThumbnailIfNeeded(_ pixelBuffer: CVPixelBuffer) {
|
||
guard hasNetworkConsent, savesConnectionThumbnails,
|
||
shouldCaptureThumbnail, !activeRemoteID.isEmpty else { return }
|
||
shouldCaptureThumbnail = false
|
||
let generation = thumbnailGeneration
|
||
let identifier = activeRemoteID
|
||
RecentConnectionStore.captureThumbnail(from: pixelBuffer,
|
||
remoteID: identifier) { [weak self] fileName in
|
||
guard let self, let fileName, self.hasNetworkConsent,
|
||
self.savesConnectionThumbnails, self.thumbnailGeneration == generation,
|
||
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
|
||
networkStatistics.resetVideo()
|
||
videoFrame = nil
|
||
resetRemoteCursorState()
|
||
frameCount = 0
|
||
bridge.switch(toDisplay: index)
|
||
}
|
||
|
||
var canChangeVideoSettings: Bool {
|
||
isConnected && videoSettings.supported
|
||
}
|
||
|
||
var videoSettingsFeedback: String {
|
||
if !canChangeVideoSettings {
|
||
return "连接建立后可调整,需被控端支持。"
|
||
}
|
||
if videoSettings.failed { return "调整失败,请重试。" }
|
||
return ""
|
||
}
|
||
|
||
func updateVideoSettings(quality: Int? = nil, frameRate: Int? = nil,
|
||
preference: Int? = nil) {
|
||
guard hasNetworkConsent, canChangeVideoSettings else { return }
|
||
var settings = videoSettings.selection
|
||
if let quality { settings.quality = quality }
|
||
if let frameRate { settings.frameRate = frameRate }
|
||
if let preference { settings.preference = preference }
|
||
guard let requestID = videoSettings.begin(settings) else { return }
|
||
videoSettingsTimeoutTask?.cancel()
|
||
bridge.sendVideoSettings(quality: settings.quality,
|
||
frameRate: settings.frameRate,
|
||
preference: settings.preference,
|
||
requestID: requestID)
|
||
videoSettingsTimeoutTask = Task { @MainActor [weak self] in
|
||
do {
|
||
try await Task.sleep(nanoseconds: 5_000_000_000)
|
||
} catch { return }
|
||
self?.videoSettings.expire(requestID: requestID)
|
||
}
|
||
}
|
||
|
||
private func resetVideoSettings() {
|
||
videoSettingsTimeoutTask?.cancel()
|
||
videoSettingsTimeoutTask = nil
|
||
videoSettings = RemoteVideoSettingsState(
|
||
preference: videoAdaptationPolicy.bridgeValue.rawValue)
|
||
}
|
||
|
||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||
didReceiveVideoSettingsQuality quality: Int,
|
||
frameRate: Int, preference: Int,
|
||
requestID: UInt32, accepted: Bool) {
|
||
guard hasNetworkConsent, isConnected else { return }
|
||
videoSettings.receive(RemoteVideoSettings(quality: quality,
|
||
frameRate: frameRate,
|
||
preference: preference),
|
||
requestID: requestID, accepted: accepted)
|
||
if !videoSettings.pending {
|
||
videoSettingsTimeoutTask?.cancel()
|
||
videoSettingsTimeoutTask = nil
|
||
}
|
||
}
|
||
|
||
private func resetRemoteCursorState() {
|
||
remoteCursorVisible = true
|
||
remoteCursorShape = 0
|
||
remoteCursorPosition = nil
|
||
remoteCursorPositionRevision &+= 1
|
||
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, didChangeIdentityStorageError hasError: Bool) {
|
||
identityStorageWarning = hasError
|
||
? "本机登录凭据暂时无法安全存取。请解锁设备后,在设置中点击“应用”重试。"
|
||
: nil
|
||
}
|
||
|
||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||
didChange state: CrossDeskSignalState) {
|
||
guard hasNetworkConsent else { return }
|
||
signalConnected = state.rawValue == 1
|
||
announcements.setConnected(signalConnected)
|
||
invalidatePresence()
|
||
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 证书校验失败"
|
||
case 7: signalStatus = "无法访问本机登录凭据,请解锁后重试"
|
||
default: signalStatus = "未知信令状态"
|
||
}
|
||
if signalConnected {
|
||
startPresenceMonitoring()
|
||
refreshRecentConnectionPresence()
|
||
if let pendingPresenceRemoteID {
|
||
requestPresenceProbe(pendingPresenceRemoteID)
|
||
}
|
||
} else {
|
||
stopPresenceMonitoring()
|
||
cancelPendingPresenceConnection()
|
||
}
|
||
}
|
||
|
||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||
didChange state: CrossDeskConnectionState,
|
||
remoteID: String) {
|
||
guard isConnecting || isConnected || sessionVisible,
|
||
remoteID.isEmpty || remoteID == activeRemoteID else {
|
||
return
|
||
}
|
||
isConnected = state.rawValue == 1
|
||
if !isConnected {
|
||
cancelVideoRecovery()
|
||
}
|
||
isConnecting = [0, 2].contains(state.rawValue)
|
||
switch state.rawValue {
|
||
case 0: connectionStatus = "正在建立远程连接"
|
||
case 1:
|
||
connectionStatus = "已连接"
|
||
sessionVisible = true
|
||
if videoSettings.requestID == 0 { updateVideoSettings() }
|
||
checkRemoteVersionIfNeeded()
|
||
recordSuccessfulConnectionIfNeeded()
|
||
audioPlayer.setEnabled(audioEnabled)
|
||
bridge.setAudioEnabled(audioEnabled)
|
||
AppOrientation.update(to: .allButUpsideDown)
|
||
case 2: connectionStatus = "正在收集 ICE 候选"
|
||
case 3: finishConnection(withError: "网络连接已中断,请重新连接。")
|
||
case 4:
|
||
finishConnection(withError: "P2P/TURN 建链失败,请检查网络后重试。")
|
||
case 5:
|
||
finishConnection(withError: "远程连接已结束。")
|
||
case 6:
|
||
finishConnection(withError: "访问密码错误,请重新输入。")
|
||
case 7:
|
||
if !remoteID.isEmpty {
|
||
recentConnectionPresence[remoteID] = false
|
||
presenceUpdatedAt.removeValue(forKey: remoteID)
|
||
}
|
||
finishConnection(withError: "远程设备 ID 不存在,请检查设备 ID。")
|
||
case 8:
|
||
if !remoteID.isEmpty {
|
||
recentConnectionPresence[remoteID] = false
|
||
presenceUpdatedAt.removeValue(forKey: remoteID)
|
||
}
|
||
finishConnection(withError: "远程设备当前不可用,请稍后重试。")
|
||
default: connectionStatus = "未知连接状态"
|
||
}
|
||
}
|
||
|
||
func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) {
|
||
guard hasNetworkConsent else { return }
|
||
localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity
|
||
if let configuration = bridgeConfiguration {
|
||
announcements.configure(host: configuration.host, port: configuration.signalPort,
|
||
deviceID: localIdentity)
|
||
}
|
||
refreshRecentConnectionPresence()
|
||
if let pendingPresenceRemoteID {
|
||
requestPresenceProbe(pendingPresenceRemoteID)
|
||
}
|
||
}
|
||
|
||
var announcementsConnected: Bool { hasNetworkConsent && signalConnected }
|
||
|
||
func refreshAnnouncements() {
|
||
guard announcementsConnected else { return }
|
||
announcements.refresh()
|
||
}
|
||
|
||
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveAnnouncementMessage message: Data) {
|
||
guard announcementsConnected else { return }
|
||
let object = (try? JSONSerialization.jsonObject(with: message)) as? [String: Any]
|
||
if object?["type"] as? String == "announcements_changed" {
|
||
announcements.refresh()
|
||
} else {
|
||
announcements.receive(message)
|
||
}
|
||
}
|
||
|
||
func rtcBridge(_ bridge: CrossDeskRTCBridge, didFailAnnouncementRequest requestID: String) {
|
||
guard hasNetworkConsent else { return }
|
||
announcements.failed(requestID: requestID)
|
||
}
|
||
|
||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||
didReceivePresence presence: [String: NSNumber],
|
||
snapshot: Bool) {
|
||
guard hasNetworkConsent, signalConnected, presenceIsForeground,
|
||
snapshot || !awaitingPresenceSnapshot else { return }
|
||
var updated = snapshot ? [:] : recentConnectionPresence
|
||
if snapshot {
|
||
presenceUpdatedAt = [:]
|
||
awaitingPresenceSnapshot = false
|
||
}
|
||
let now = presenceClock.now
|
||
let watched = Set(recentConnections.map(\.remoteID))
|
||
for (remoteID, online) in presence
|
||
where watched.contains(remoteID) || pendingPresenceRemoteID == remoteID {
|
||
updated[remoteID] = online.boolValue
|
||
presenceUpdatedAt[remoteID] = now
|
||
}
|
||
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,
|
||
captureUptime: TimeInterval) {
|
||
guard hasNetworkConsent, isConnected, !videoWasBackgrounded else { return }
|
||
cancelVideoRecovery()
|
||
// 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.
|
||
videoFrameID &+= 1
|
||
videoFrame = RemoteVideoFrame(
|
||
pixelBuffer: pixelBuffer,
|
||
encodedSize: CGSize(width: width, height: height),
|
||
id: videoFrameID,
|
||
captureUptime: captureUptime
|
||
)
|
||
frameCount &+= 1
|
||
captureRecentThumbnailIfNeeded(pixelBuffer)
|
||
}
|
||
|
||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||
didReceiveHostName hostName: String,
|
||
appVersion: String,
|
||
platform: String,
|
||
displayNames remoteDisplayNames: [String],
|
||
displaySizes remoteDisplaySizes: [NSValue],
|
||
supportsVideoSettings: Bool) {
|
||
let previouslySupported = videoSettings.supported
|
||
videoSettings.setSupported(supportsVideoSettings)
|
||
if !supportsVideoSettings {
|
||
videoSettingsTimeoutTask?.cancel()
|
||
videoSettingsTimeoutTask = nil
|
||
} else if !previouslySupported {
|
||
updateVideoSettings()
|
||
}
|
||
remoteHostInfoReceived = true
|
||
remoteAppVersion = appVersion
|
||
remotePlatform = RemoteHostPlatform(rawValue: platform) ?? .unknown
|
||
checkRemoteVersionIfNeeded()
|
||
updateActiveConnectionHostInfo(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
|
||
}
|
||
remoteCursorPositionRevision &+= 1
|
||
}
|
||
hasRemoteCursorState = true
|
||
}
|
||
|
||
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveAudioPCM pcmData: Data) {
|
||
guard hasNetworkConsent, isConnected else { return }
|
||
audioPlayer.enqueue(pcmData)
|
||
}
|
||
|
||
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveClipboardText text: String) {
|
||
guard hasNetworkConsent, isConnected else { return }
|
||
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,
|
||
didUpdateNetworkStats stats: CrossDeskNetworkStats) {
|
||
guard hasNetworkConsent, isConnected else { return }
|
||
func traffic(_ value: CrossDeskTrafficStats) -> RemoteTrafficStatistics {
|
||
RemoteTrafficStatistics(inboundBitrate: value.inboundBitrate,
|
||
outboundBitrate: value.outboundBitrate,
|
||
lossRate: value.lossRate)
|
||
}
|
||
let mode: RemoteNetworkReport.Mode
|
||
switch stats.traversalMode {
|
||
case .direct: mode = .direct
|
||
case .relay: mode = .relay
|
||
default: mode = .unknown
|
||
}
|
||
networkStatistics.receive(RemoteNetworkReport(
|
||
video: traffic(stats.video), audio: traffic(stats.audio),
|
||
data: traffic(stats.data), total: traffic(stats.total), mode: mode,
|
||
srtpActive: stats.srtpActive.boolValue, rttMilliseconds: stats.rttMilliseconds
|
||
), at: ProcessInfo.processInfo.systemUptime)
|
||
}
|
||
|
||
// Called after the exact frame enters AVSampleBufferDisplayLayer. Keep this
|
||
// accumulator unpublished: submitting pixels occurs during a SwiftUI update.
|
||
func recordVideoFrameSubmission(id: UInt64, captureUptime: TimeInterval) {
|
||
guard isConnected, !videoWasBackgrounded, id == videoFrame?.id else { return }
|
||
networkStatistics.recordSubmittedFrame(id: id, captureUptime: captureUptime,
|
||
at: ProcessInfo.processInfo.systemUptime)
|
||
}
|
||
|
||
var networkSnapshot: RemoteNetworkSnapshot {
|
||
guard isConnected else { return RemoteNetworkSnapshot() }
|
||
return networkStatistics.displaySnapshot(at: ProcessInfo.processInfo.systemUptime)
|
||
}
|
||
|
||
var videoStatus: String {
|
||
if frameCount > 0 {
|
||
return "视频 \(Int(frameSize.width))×\(Int(frameSize.height)) · \(frameCount) 帧"
|
||
}
|
||
return isConnected ? "正在取回远程画面…" : "等待连接"
|
||
}
|
||
}
|