feat: 初始化 iOS 远程控制端项目

This commit is contained in:
2026-07-29 10:34:02 +08:00
parent 11173eb04c
commit 1e37aa64cc
20 changed files with 2610 additions and 0 deletions

View File

@@ -0,0 +1,462 @@
import Foundation
import Combine
import UIKit
import WebRTC
///
enum AuthType: String, CaseIterable, Identifiable {
case none = "NONE"
case code = "CODE"
case password = "PASSWORD"
var id: String { rawValue }
var title: String {
switch self {
case .none: return "免密连接"
case .code: return "动态验证码"
case .password: return "固定密码"
}
}
}
///
enum StreamMode: Int {
case webrtc = 0
case selfCodec = 1
}
/// Android 0
struct ResolutionOption: Identifiable, Equatable {
let id: Int
let title: String
let width: Int32
let height: Int32
static let all: [ResolutionOption] = [
ResolutionOption(id: 0, title: "原始画质", width: 0, height: 0),
ResolutionOption(id: 1, title: "1080P", width: 1920, height: 1080),
ResolutionOption(id: 2, title: "720P", width: 1280, height: 720),
ResolutionOption(id: 3, title: "480P", width: 854, height: 480)
]
}
/// ViewModelWebRTC UI
/// 线@Published SwiftUI
final class ControllerViewModel: NSObject, ObservableObject {
// MARK: - UI
@Published var serverUrl: String = "wss://www.ttstd.com/signal"
@Published var targetDeviceId: String = ""
@Published var statusText: String = "未连接"
/// Android setupPanel/controlPanel
@Published var isControlling: Bool = false
@Published var isConnecting: Bool = false
@Published var statsText: String = ""
@Published var streamMode: StreamMode = .webrtc
@Published var selectedResolution: ResolutionOption = ResolutionOption.all[0]
/// /
@Published var videoAspect: CGFloat = 9.0 / 16.0
@Published var showAuthSheet: Bool = false
@Published var alertMessage: String?
let myDeviceId: String = DeviceUtils.deviceId()
// MARK: - UIKit ViewModel SwiftUI Representable
let remoteVideoView = RTCMTLVideoView()
let selfCodecView = SampleBufferDisplayView()
// MARK: -
private var signalingClient: SignalingClient?
private var webRTCClient: WebRTCClient?
private let selfCodecDecoder = SelfCodecDecoder()
private var statsTimer: Timer?
private var connectionStartTime: Date?
private var lastBytesReceived: Double = 0
private var lastStatsTime: Date?
private var pendingAuthType: AuthType = .none
private var pendingAuthValue: String = ""
///
private var suppressStreamModeSend = false
override init() {
super.init()
remoteVideoView.delegate = self
selfCodecDecoder.displayLayer = selfCodecView.sampleBufferLayer
selfCodecDecoder.onResolutionUpdate = { [weak self] w, h in
guard let self, self.streamMode == .selfCodec, w > 0, h > 0 else { return }
self.videoAspect = CGFloat(w) / CGFloat(h)
}
}
// MARK: - /
/// ""
func requestConnect() {
let target = targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !serverUrl.trimmingCharacters(in: .whitespaces).isEmpty else {
alertMessage = "请输入信令服务器地址"
return
}
guard !target.isEmpty else {
alertMessage = "请输入目标设备 ID"
return
}
showAuthSheet = true
}
///
func connect(authType: AuthType, authValue: String) {
showAuthSheet = false
pendingAuthType = authType
pendingAuthValue = authValue
isConnecting = true
statusText = "正在连接信令服务器..."
let signaling = SignalingClient(
serverUrl: serverUrl.trimmingCharacters(in: .whitespaces),
deviceId: myDeviceId)
signaling.delegate = self
signalingClient = signaling
signaling.connect()
}
func disconnect() {
stopStatsTimer()
selfCodecDecoder.setEnabled(false)
webRTCClient?.close()
webRTCClient = nil
signalingClient?.disconnect()
signalingClient = nil
isConnecting = false
isControlling = false
statusText = "未连接"
statsText = ""
streamMode = .webrtc
selectedResolution = ResolutionOption.all[0]
videoAspect = 9.0 / 16.0
}
// MARK: -
func sendMotionEvent(action: Int32, x: Double, y: Double) {
var msg = ControlMessage()
msg.action = .motionEvent
msg.motionAction = action
msg.x = x
msg.y = y
webRTCClient?.sendControlCommand(msg)
}
func sendSwipe(x1: Double, y1: Double, x2: Double, y2: Double, duration: Int64) {
var msg = ControlMessage()
msg.action = .swipe
msg.x1 = x1
msg.y1 = y1
msg.x2 = x2
msg.y2 = y2
msg.duration = duration
webRTCClient?.sendControlCommand(msg)
}
/// Android KeyEvent keyCode+
func sendKey(_ keyCode: Int32) {
var down = ControlMessage()
down.action = .key
down.keyCode = keyCode
down.keyAction = 0
webRTCClient?.sendControlCommand(down)
var up = ControlMessage()
up.action = .key
up.keyCode = keyCode
up.keyAction = 1
webRTCClient?.sendControlCommand(up)
}
/// Android
enum AndroidKey {
static let back: Int32 = 4
static let home: Int32 = 3
static let appSwitch: Int32 = 187
}
// MARK: - /
func selectResolution(_ option: ResolutionOption) {
selectedResolution = option
webRTCClient?.requestResolutionChange(width: option.width, height: option.height, fps: 0)
}
func toggleStreamMode(_ selfCodecOn: Bool) {
let newMode: StreamMode = selfCodecOn ? .selfCodec : .webrtc
guard newMode != streamMode else { return }
streamMode = newMode
applyStreamModeLocally(newMode)
if !suppressStreamModeSend {
webRTCClient?.sendStreamMode(Int32(newMode.rawValue))
}
}
private func applyStreamModeLocally(_ mode: StreamMode) {
selfCodecDecoder.setEnabled(mode == .selfCodec)
}
// MARK: -
fileprivate func startWebRTC() {
guard let signaling = signalingClient else { return }
statusText = "正在建立 WebRTC 连接..."
let client = WebRTCClient(signaling: signaling, myDeviceId: myDeviceId)
client.delegate = self
client.selfCodecDecoder = selfCodecDecoder
webRTCClient = client
client.createOffer(
targetDeviceId: targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines),
renderer: remoteVideoView,
authType: pendingAuthType.rawValue,
authValue: pendingAuthType == .none ? nil : pendingAuthValue)
}
fileprivate func failConnection(_ reason: String) {
alertMessage = reason
disconnect()
}
// MARK: -
private func startStatsTimer() {
connectionStartTime = Date()
lastBytesReceived = 0
lastStatsTime = nil
statsTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.collectStats()
}
}
private func stopStatsTimer() {
statsTimer?.invalidate()
statsTimer = nil
}
private func collectStats() {
webRTCClient?.stats { [weak self] report in
guard let self else { return }
let text = self.buildStatsText(report)
DispatchQueue.main.async {
self.statsText = text
}
}
}
private func buildStatsText(_ report: RTCStatisticsReport) -> String {
var width = 0, height = 0
var fps: Double = 0
var bytesReceived: Double = 0
var framesDropped: Double = 0
var codecId: String?
var codecName = ""
var decoderImpl = ""
var rttMs: Double = -1
var localCandidateId: String?
var remoteCandidateId: String?
for (_, stat) in report.statistics {
switch stat.type {
case "inbound-rtp":
guard (stat.values["kind"] as? String) == "video" else { continue }
width = (stat.values["frameWidth"] as? NSNumber)?.intValue ?? width
height = (stat.values["frameHeight"] as? NSNumber)?.intValue ?? height
fps = (stat.values["framesPerSecond"] as? NSNumber)?.doubleValue ?? fps
bytesReceived = (stat.values["bytesReceived"] as? NSNumber)?.doubleValue ?? bytesReceived
framesDropped = (stat.values["framesDropped"] as? NSNumber)?.doubleValue ?? framesDropped
codecId = stat.values["codecId"] as? String
decoderImpl = (stat.values["decoderImplementation"] as? String) ?? decoderImpl
case "candidate-pair":
let nominated = (stat.values["nominated"] as? NSNumber)?.boolValue ?? false
let state = stat.values["state"] as? String
if nominated && state == "succeeded" {
rttMs = ((stat.values["currentRoundTripTime"] as? NSNumber)?.doubleValue ?? 0) * 1000
localCandidateId = stat.values["localCandidateId"] as? String
remoteCandidateId = stat.values["remoteCandidateId"] as? String
}
default:
break
}
}
if let codecId, let codecStat = report.statistics[codecId] {
codecName = (codecStat.values["mimeType"] as? String)?
.replacingOccurrences(of: "video/", with: "") ?? ""
}
// Android /
var bitrateText = "--"
let now = Date()
if let last = lastStatsTime, bytesReceived >= lastBytesReceived {
let interval = now.timeIntervalSince(last)
if interval > 0 {
let bps = (bytesReceived - lastBytesReceived) * 8 / interval
bitrateText = Self.formatBitrate(bps)
}
}
lastBytesReceived = bytesReceived
lastStatsTime = now
//
var connType = "--"
if let lid = localCandidateId, let rid = remoteCandidateId,
let local = report.statistics[lid], let remote = report.statistics[rid] {
let localType = (local.values["candidateType"] as? String) ?? ""
let remoteType = (remote.values["candidateType"] as? String) ?? ""
let proto = ((local.values["protocol"] as? String) ?? "").uppercased()
if localType == "relay" || remoteType == "relay" {
connType = "TURN 中继(\(proto))"
} else if localType == "srflx" || remoteType == "srflx" {
connType = "P2P 公网直连(\(proto))"
} else if localType == "host" && remoteType == "host" {
connType = "P2P 局域网直连(\(proto))"
} else {
connType = "\(localType)/\(remoteType)(\(proto))"
}
}
var duration = ""
if let start = connectionStartTime {
let secs = Int(Date().timeIntervalSince(start))
duration = String(format: "%02d:%02d", secs / 60, secs % 60)
}
let modeText = streamMode == .selfCodec ? "自编码" : "WebRTC"
var lines: [String] = []
lines.append("模式: \(modeText) 时长: \(duration)")
if streamMode == .selfCodec {
let w = selfCodecDecoder.videoWidth
let h = selfCodecDecoder.videoHeight
lines.append("分辨率: \(w > 0 ? "\(w)x\(h)" : "--") 解码: VideoToolbox(H264)")
} else {
lines.append("分辨率: \(width > 0 ? "\(width)x\(height)" : "--") 帧率: \(Int(fps))fps 丢帧: \(Int(framesDropped))")
lines.append("编码: \(codecName.isEmpty ? "--" : codecName) 解码器: \(decoderImpl.isEmpty ? "--" : decoderImpl)")
}
lines.append("码率: \(bitrateText) 延迟: \(rttMs >= 0 ? "\(Int(rttMs))ms" : "--")")
lines.append("链路: \(connType)")
return lines.joined(separator: "\n")
}
private static func formatBitrate(_ bps: Double) -> String {
if bps >= 1_000_000 {
return String(format: "%.1f Mbps", bps / 1_000_000)
}
if bps >= 1_000 {
return String(format: "%.0f Kbps", bps / 1_000)
}
return String(format: "%.0f bps", bps)
}
}
// MARK: - SignalingClientDelegate
extension ControllerViewModel: SignalingClientDelegate {
func signalingDidConnect() {
statusText = "信令已连接,正在发起会话..."
startWebRTC()
}
func signalingDidDisconnect() {
guard isConnecting || isControlling else { return }
failConnection("信令服务器连接断开")
}
func signaling(didFail error: String) {
guard isConnecting || isControlling else { return }
failConnection("信令连接失败: \(error)")
}
func signaling(didReceive message: SignalMessage) {
switch (message.type ?? "").uppercased() {
case "ANSWER":
if let sdp = message.payloadJSON()?["sdp"] as? String {
statusText = "收到应答,正在协商..."
webRTCClient?.handleAnswer(sdp: sdp)
}
case "ICE_CANDIDATE":
if let json = message.payloadJSON(),
let candidate = json["candidate"] as? String {
let sdpMid = json["sdpMid"] as? String ?? ""
let index = Int32((json["sdpMLineIndex"] as? NSNumber)?.intValue ?? 0)
webRTCClient?.addIceCandidate(sdpMid: sdpMid, sdpMLineIndex: index, candidate: candidate)
}
case "TARGET_OFFLINE":
failConnection("目标设备不在线")
case "CONNECTION_REJECTED":
let reason = message.payloadJSON()?["reason"] as? String
failConnection(reason ?? "对方拒绝了连接请求")
case "REQUEST_ERROR":
let reason = message.payloadJSON()?["reason"] as? String
failConnection(reason ?? "请求错误")
case "REQUEST_TIMEOUT":
failConnection("连接请求超时,对方未响应")
default:
break
}
}
}
// MARK: - WebRTCClientDelegate
extension ControllerViewModel: WebRTCClientDelegate {
func webRTCClientDidConnect() {
isConnecting = false
isControlling = true
statusText = "已连接: \(targetDeviceId)"
applyStreamModeLocally(streamMode)
startStatsTimer()
}
func webRTCClientDidDisconnect() {
guard isControlling else { return }
alertMessage = "连接已断开"
disconnect()
}
func webRTCClient(didFail error: String) {
failConnection(error)
}
func webRTCClient(didReportStreamMode mode: Int) {
let reported = StreamMode(rawValue: mode) ?? .webrtc
guard reported != streamMode else { return }
// SET_STREAM_MODE
suppressStreamModeSend = true
streamMode = reported
applyStreamModeLocally(reported)
suppressStreamModeSend = false
}
func webRTCClient(didReportResolution width: Int, height: Int) {
guard width > 0, height > 0 else { return }
if streamMode == .webrtc {
videoAspect = CGFloat(width) / CGFloat(height)
}
}
}
// MARK: - RTCVideoViewDelegateWebRTC
extension ControllerViewModel: RTCVideoViewDelegate {
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
DispatchQueue.main.async {
guard size.width > 0, size.height > 0 else { return }
if self.streamMode == .webrtc {
self.videoAspect = size.width / size.height
}
}
}
}