mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-08-31 13:32:10 +08:00
[feat] add native iOS client support
This commit is contained in:
@@ -0,0 +1,875 @@
|
||||
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<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 {
|
||||
@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 ? "正在取回远程画面…" : "等待连接"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user