Files
VibeCoding/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift
TongTongStudio da0a3bf59d chore(ios): 添加LoginView并修复TokenStore类型与线程调度问题
- 在Xcode项目中添加LoginView.swift文件引用
- 修复TokenStore中Keychain查询字典类型为CFString
- 将DispatchQueue.main.async替换为MainActor.run,避免弱引用丢失
- 修复绑定设备信息时的线程安全与变量命名
2026-08-03 20:51:16 +08:00

718 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = "981964879"
@Published var statusText: String = "未连接"
/// Android setupPanel/controlPanel
@Published var isControlling: Bool = false
@Published var isConnecting: Bool = false
@Published var statsText: String = ""
///
@Published var statsExpanded: Bool = false
@Published var streamMode: StreamMode = .webrtc
@Published var selectedResolution: ResolutionOption = ResolutionOption.all[0]
/// supported_fps
@Published var fpsOptions: [Int] = [15, 24, 30, 60]
/// 0
@Published var currentFps: Int = 0
/// /
@Published var videoAspect: CGFloat = 9.0 / 16.0
@Published var showAuthSheet: Bool = false
@Published var alertMessage: String?
///
@Published var isRecording: Bool = false
///
@Published var lastRecordingPath: String?
/// ID REGISTER_SUCCESS fromDeviceId
@Published var myDeviceId: String = ""
// MARK: -
@Published var isLoggedIn: Bool = false
@Published var username: String = ""
@Published var loginError: String?
@Published var isLoggingIn: Bool = false
/// accessTokenBearer refreshToken Keychain
private var accessToken: String?
// MARK: - UIKit ViewModel SwiftUI Representable
let remoteVideoView = RTCMTLVideoView()
let selfCodecView = SampleBufferDisplayView()
// MARK: -
private var signalingClient: SignalingClient?
private var webRTCClient: WebRTCClient?
private let selfCodecDecoder = SelfCodecDecoder()
/// MP4
private let videoRecorder = VideoRecorder()
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
/// 0
private var lastReportedWidth: Int32 = 0
private var lastReportedHeight: Int32 = 0
override init() {
super.init()
remoteVideoView.delegate = self
restoreLoginState()
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)
}
// AVCC
selfCodecDecoder.onDecodedSampleBuffer = { [weak self] sample, isKey in
self?.videoRecorder.appendDecodedSampleBuffer(sample, isKey: isKey)
}
// /
videoRecorder.onStateChange = { [weak self] state in
self?.isRecording = (state == .recording)
}
videoRecorder.onSaved = { [weak self] path in
self?.lastRecordingPath = path
self?.alertMessage = "录制已保存:\n\(path)"
}
videoRecorder.onSavedEmpty = { [weak self] in
self?.alertMessage = "未录到任何画面"
}
videoRecorder.onError = { [weak self] msg in
self?.alertMessage = "录制失败: \(msg)"
}
}
// MARK: - /
/// ""Bearer token
func requestConnect() {
let target = targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !serverUrl.trimmingCharacters(in: .whitespaces).isEmpty else {
alertMessage = "请输入信令服务器地址"
return
}
guard !target.isEmpty else {
alertMessage = "请输入目标设备 ID"
return
}
guard isLoggedIn else {
alertMessage = "请先在右上角登录账号"
return
}
showAuthSheet = true
}
/// accessToken
func connect(authType: AuthType, authValue: String) {
showAuthSheet = false
pendingAuthType = authType
pendingAuthValue = authValue
isConnecting = true
statusText = "正在连接信令服务器..."
let signaling = SignalingClient(
serverUrl: serverUrl.trimmingCharacters(in: .whitespaces),
token: accessToken)
signaling.delegate = self
signalingClient = signaling
signaling.connect()
}
func disconnect() {
stopStatsTimer()
videoRecorder.cancel()
isRecording = false
selfCodecDecoder.setEnabled(false)
webRTCClient?.close()
webRTCClient = nil
signalingClient?.disconnect()
signalingClient = nil
isConnecting = false
isControlling = false
statusText = "未连接"
statsText = ""
streamMode = .webrtc
selectedResolution = ResolutionOption.all[0]
fpsOptions = [15, 24, 30, 60]
currentFps = 0
lastReportedWidth = 0
lastReportedHeight = 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)
}
/// 沿0
func selectFps(_ fps: Int) {
guard fps > 0, fps != currentFps else { return }
webRTCClient?.requestResolutionChange(width: lastReportedWidth,
height: lastReportedHeight,
fps: Int32(fps))
}
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: -
/// WebRTC
///
func startRecording() {
guard isControlling else {
alertMessage = "请先连接被控设备"
return
}
guard !isRecording else { return }
videoRecorder.start()
}
/// MP4 Documents/WebRTCRecordings
func stopRecording() {
guard isRecording else { return }
videoRecorder.stop()
}
// MARK: -
fileprivate func startWebRTC() {
guard let signaling = signalingClient else { return }
guard !myDeviceId.isEmpty else {
failConnection("未获取到本机设备ID连接中止")
return
}
statusText = "正在建立 WebRTC 连接..."
let client = WebRTCClient(signaling: signaling, myDeviceId: myDeviceId)
client.delegate = self
client.selfCodecDecoder = selfCodecDecoder
client.extraRenderer = videoRecorder
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: - /
/// Keychain accessToken / refreshToken
private func restoreLoginState() {
let at = TokenStore.loadAccessToken()
let rt = TokenStore.loadRefreshToken()
if at != nil, rt != nil {
accessToken = at
username = TokenStore.loadUsername() ?? ""
isLoggedIn = true
}
}
/// /api/auth/login
func login(username: String, password: String) async {
isLoggingIn = true
loginError = nil
do {
let data = try await ApiClient.login(username: username, password: password)
guard let at = data["accessToken"] as? String,
let rt = data["refreshToken"] as? String else {
throw ApiError(message: "登录返回缺失")
}
TokenStore.saveAccessToken(at)
TokenStore.saveRefreshToken(rt)
TokenStore.saveUsername(username)
accessToken = at
self.username = username
isLoggedIn = true
} catch {
loginError = (error as? ApiError)?.message ?? error.localizedDescription
}
isLoggingIn = false
}
/// 退
func logout() {
TokenStore.clear()
accessToken = nil
username = ""
isLoggedIn = false
if isConnecting || isControlling {
disconnect()
}
}
/// accessToken refreshToken
private func ensureAccessToken() async throws -> String {
if let existing = accessToken {
do {
_ = try await ApiClient.verify(accessToken: existing)
return existing
} catch {
//
}
}
guard let rt = TokenStore.loadRefreshToken() else {
throw ApiError(message: "无 refreshToken请重新登录")
}
let data = try await ApiClient.refresh(refreshToken: rt)
guard let newAt = data["accessToken"] as? String else {
throw ApiError(message: "刷新失败")
}
TokenStore.saveAccessToken(newAt)
if let newRt = data["refreshToken"] as? String {
TokenStore.saveRefreshToken(newRt)
}
accessToken = newAt
return newAt
}
///
private func refreshAndReconnect() async {
do {
let newAt = try await ensureAccessToken()
accessToken = newAt
await MainActor.run { [weak self] in
guard let self else { return }
self.signalingClient?.disconnect()
self.signalingClient = nil
// REGISTER_SUCCESS startWebRTC
let signaling = SignalingClient(
serverUrl: self.serverUrl.trimmingCharacters(in: .whitespaces),
token: newAt)
signaling.delegate = self
self.signalingClient = signaling
signaling.connect()
}
} catch {
await MainActor.run { [weak self] in
guard let self else { return }
self.logout()
self.alertMessage = "令牌刷新失败,请重新登录"
}
}
}
///
private func loadBindings() {
guard let at = accessToken else { return }
Task {
do {
let data = try await ApiClient.bindings(accessToken: at)
guard let list = data["bindings"] as? [[String: Any]], !list.isEmpty else { return }
let uids = list.compactMap { entry -> String? in
if let u = entry["deviceUid"] as? String, !u.isEmpty { return u }
return entry["deviceId"] as? String
}.filter { !$0.isEmpty }
if !uids.isEmpty {
let message = "已绑定设备: " + uids.joined(separator: ", ")
Task { @MainActor [weak self] in
self?.alertMessage = message
}
}
} catch { /* */ }
}
}
/// TURN ICE WebRTCClient
private func loadTurnCredentials() async {
guard let at = accessToken else { return }
do {
let data = try await ApiClient.turnCredentials(accessToken: at)
guard let servers = data["iceServers"] as? [[String: Any]], !servers.isEmpty else { return }
var mapped: [[String: Any]] = []
for s in servers {
var entry: [String: Any] = [:]
if let urls = s["urls"] as? String { entry["urls"] = urls }
if let user = s["username"] as? String { entry["username"] = user }
if let cred = s["credential"] as? String { entry["credential"] = cred }
mapped.append(entry)
}
if !mapped.isEmpty {
WebRTCClient.iceServersOverride = mapped
}
} catch { /* TURN */ }
}
// 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)
Task { @MainActor in
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 = "信令已连接,等待注册..."
}
func signalingDidDisconnect() {
guard isConnecting || isControlling else { return }
failConnection("信令服务器连接断开")
}
func signaling(didFail error: String) {
guard isConnecting || isControlling else { return }
failConnection("信令连接失败: \(error)")
}
/// 4001
func signalingTokenExpired() {
Task { await refreshAndReconnect() }
}
/// 线 4003
func signalingForceLogout() {
DispatchQueue.main.async {
self.logout()
self.alertMessage = "账号已在其他位置登录,已强制下线"
}
}
func signaling(didReceive message: SignalMessage) {
// deviceIdCONTROLLER WebRTC Offer
if (message.type ?? "").uppercased() == "REGISTER_SUCCESS",
let from = message.fromDeviceId, !from.isEmpty {
myDeviceId = from
statusText = "已注册 (\(from)),正在发起会话..."
startWebRTC()
loadBindings()
Task { await loadTurnCredentials() }
return
}
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 }
lastReportedWidth = Int32(width)
lastReportedHeight = Int32(height)
if streamMode == .webrtc {
videoAspect = CGFloat(width) / CGFloat(height)
}
}
func webRTCClient(didReportFps fps: Int, supportedFps: [Int]) {
if fps > 0 { currentFps = fps }
if !supportedFps.isEmpty { fpsOptions = supportedFps }
}
}
// 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
}
}
}
}