feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
@@ -24,6 +24,8 @@
|
||||
FB000000000000000000000F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000F /* Assets.xcassets */; };
|
||||
FB0000000000000000000010 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = FE0000000000000000000002 /* WebRTC */; };
|
||||
FB0000000000000000000012 /* VideoRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000012 /* VideoRecorder.swift */; };
|
||||
FB0000000000000000000013 /* SetupPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000013 /* SetupPanelView.swift */; };
|
||||
FB0000000000000000000014 /* ControlPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000014 /* ControlPanelView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -45,6 +47,8 @@
|
||||
FA0000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
FA0000000000000000000011 /* web_rtc_controller_ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = web_rtc_controller_ios.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FA0000000000000000000012 /* VideoRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRecorder.swift; sourceTree = "<group>"; };
|
||||
FA0000000000000000000013 /* SetupPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupPanelView.swift; sourceTree = "<group>"; };
|
||||
FA0000000000000000000014 /* ControlPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanelView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -132,6 +136,8 @@
|
||||
children = (
|
||||
FA000000000000000000000B /* ContentView.swift */,
|
||||
FA000000000000000000000C /* AuthSheetView.swift */,
|
||||
FA0000000000000000000013 /* SetupPanelView.swift */,
|
||||
FA0000000000000000000014 /* ControlPanelView.swift */,
|
||||
FA0000000000000000000008 /* RemoteTouchView.swift */,
|
||||
FA0000000000000000000009 /* RemoteVideoView.swift */,
|
||||
FA000000000000000000000A /* SelfCodecDisplayView.swift */,
|
||||
@@ -239,6 +245,8 @@
|
||||
FB0000000000000000000006 /* WebRTCClient.swift in Sources */,
|
||||
FB0000000000000000000007 /* SelfCodecDecoder.swift in Sources */,
|
||||
FB0000000000000000000012 /* VideoRecorder.swift in Sources */,
|
||||
FB0000000000000000000013 /* SetupPanelView.swift in Sources */,
|
||||
FB0000000000000000000014 /* ControlPanelView.swift in Sources */,
|
||||
FB0000000000000000000008 /* RemoteTouchView.swift in Sources */,
|
||||
FB0000000000000000000009 /* RemoteVideoView.swift in Sources */,
|
||||
FB000000000000000000000A /* SelfCodecDisplayView.swift in Sources */,
|
||||
|
||||
@@ -5,17 +5,22 @@ protocol SignalingClientDelegate: AnyObject {
|
||||
func signalingDidDisconnect()
|
||||
func signaling(didFail error: String)
|
||||
func signaling(didReceive message: SignalMessage)
|
||||
/// 令牌失效(关闭码 4001):需刷新令牌后重连。
|
||||
func signalingTokenExpired()
|
||||
/// 强制下线(关闭码 4003):需停止重连并跳登录。
|
||||
func signalingForceLogout()
|
||||
}
|
||||
|
||||
/// 信令 WebSocket 客户端(URLSessionWebSocketTask 实现)。
|
||||
/// 连接成功后自动发送 REGISTER(deviceType = CONTROLLER),
|
||||
/// 所有回调均已切换到主线程。
|
||||
///
|
||||
/// 鉴权方式:通过 URLRequest 在握手请求头携带 `Authorization: Bearer <accessToken>`。
|
||||
/// 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS。
|
||||
final class SignalingClient: NSObject {
|
||||
|
||||
weak var delegate: SignalingClientDelegate?
|
||||
|
||||
private let serverUrl: String
|
||||
private let deviceId: String
|
||||
private let token: String?
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionWebSocketTask?
|
||||
private var manuallyClosed = false
|
||||
@@ -23,10 +28,12 @@ final class SignalingClient: NSObject {
|
||||
private var isOpened = false
|
||||
/// 是否已上报过断开/失败,避免重复回调
|
||||
private var didNotifyClosure = false
|
||||
/// 心跳定时器(每 25s 发送 PING)
|
||||
private var heartbeatTimer: Timer?
|
||||
|
||||
init(serverUrl: String, deviceId: String) {
|
||||
init(serverUrl: String, token: String?) {
|
||||
self.serverUrl = serverUrl
|
||||
self.deviceId = deviceId
|
||||
self.token = token
|
||||
super.init()
|
||||
}
|
||||
|
||||
@@ -38,10 +45,17 @@ final class SignalingClient: NSObject {
|
||||
manuallyClosed = false
|
||||
isOpened = false
|
||||
didNotifyClosure = false
|
||||
|
||||
// 握手携带 Bearer token(URLSessionWebSocketTask 不直接支持自定义头,需用 URLRequest)。
|
||||
var request = URLRequest(url: url)
|
||||
if let token, !token.isEmpty {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 15
|
||||
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
task = session?.webSocketTask(with: url)
|
||||
task = session?.webSocketTask(with: request)
|
||||
// 接收循环在 didOpenWithProtocol(握手完成)后再启动,
|
||||
// 避免在 socket 未真正连接时调用 receive 触发 Code 57。
|
||||
task?.resume()
|
||||
@@ -50,6 +64,7 @@ final class SignalingClient: NSObject {
|
||||
func disconnect() {
|
||||
manuallyClosed = true
|
||||
isOpened = false
|
||||
stopHeartbeat()
|
||||
task?.cancel(with: .normalClosure, reason: "Disconnecting".data(using: .utf8))
|
||||
task = nil
|
||||
session?.invalidateAndCancel()
|
||||
@@ -71,12 +86,19 @@ final class SignalingClient: NSObject {
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func registerDevice() {
|
||||
var msg = SignalMessage()
|
||||
msg.type = "REGISTER"
|
||||
msg.fromDeviceId = deviceId
|
||||
msg.deviceType = "CONTROLLER"
|
||||
send(msg)
|
||||
private func startHeartbeat() {
|
||||
stopHeartbeat()
|
||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { [weak self] _ in
|
||||
guard let self, let task = self.task else { return }
|
||||
task.send(.string("{\"type\":\"PING\"}")) { error in
|
||||
if let error { NSLog("[Signaling] ping error: \(error.localizedDescription)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopHeartbeat() {
|
||||
heartbeatTimer?.invalidate()
|
||||
heartbeatTimer = nil
|
||||
}
|
||||
|
||||
private func receiveLoop() {
|
||||
@@ -99,24 +121,20 @@ final class SignalingClient: NSObject {
|
||||
}
|
||||
|
||||
/// 处理接收失败:区分"正常断开"与"连接失败"。
|
||||
/// Socket 已断开(Code 57 等)视为断开而非致命错误。
|
||||
private func handleReceiveFailure(_ error: Error) {
|
||||
guard !manuallyClosed, !didNotifyClosure else { return }
|
||||
didNotifyClosure = true
|
||||
|
||||
let nsError = error as NSError
|
||||
// NSPOSIXErrorDomain Code 57: Socket is not connected(连接已断开)
|
||||
let isDisconnect = (nsError.domain == NSPOSIXErrorDomain && nsError.code == 57)
|
||||
|| (nsError.domain == NSURLErrorDomain
|
||||
&& (nsError.code == NSURLErrorNetworkConnectionLost
|
||||
|| nsError.code == NSURLErrorCancelled))
|
||||
|| nsError.code == NSURLErrorCancelled))
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if self.isOpened || isDisconnect {
|
||||
// 连接已建立过后再断开,按"断开"处理
|
||||
self.delegate?.signalingDidDisconnect()
|
||||
} else {
|
||||
// 从未成功建立连接,按"连接失败"处理
|
||||
self.delegate?.signaling(didFail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -137,9 +155,10 @@ extension SignalingClient: URLSessionWebSocketDelegate {
|
||||
webSocketTask: URLSessionWebSocketTask,
|
||||
didOpenWithProtocol protocol: String?) {
|
||||
isOpened = true
|
||||
// 握手完成后再启动接收循环,避免 socket 未连接时 receive 报错
|
||||
// 握手完成后再启动接收循环,避免 socket 未连接时 receive 报错。
|
||||
// 不再发送 REGISTER,服务端根据 Bearer 令牌自动注册。
|
||||
receiveLoop()
|
||||
registerDevice()
|
||||
startHeartbeat()
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.signalingDidConnect()
|
||||
}
|
||||
@@ -150,8 +169,19 @@ extension SignalingClient: URLSessionWebSocketDelegate {
|
||||
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
||||
reason: Data?) {
|
||||
isOpened = false
|
||||
stopHeartbeat()
|
||||
guard !manuallyClosed, !didNotifyClosure else { return }
|
||||
didNotifyClosure = true
|
||||
|
||||
let code = Int(closeCode.rawValue)
|
||||
if code == 4001 {
|
||||
DispatchQueue.main.async { self.delegate?.signalingTokenExpired() }
|
||||
return
|
||||
}
|
||||
if code == 4003 {
|
||||
DispatchQueue.main.async { self.delegate?.signalingForceLogout() }
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.signalingDidDisconnect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
|
||||
/// 主控端 HTTP 客户端:对接安全信令服务器的账号体系与自助接口。
|
||||
///
|
||||
/// - login(username,password) → accessToken + refreshToken(一次性,ses_ 前缀);
|
||||
/// - refresh(refreshToken) → 新 accessToken(服务端可能轮换 refreshToken);
|
||||
/// - verify() → 校验 accessToken 是否仍有效;
|
||||
/// - bindings() → 本机可连接的被控端列表(仅已绑定设备);
|
||||
/// - turnCredentials() → TURN 短期凭证(服务端开启时返回 iceServers)。
|
||||
struct ApiClient {
|
||||
|
||||
/// 服务端 HTTP 基址(与信令同源)。部署时通过 Build Setting / Info.plist 注入。
|
||||
static var baseURL: String {
|
||||
// 优先读取 Info.plist 中的 APIBase,缺省回退到信号服务器同源 HTTPS。
|
||||
if let v = Bundle.main.object(forInfoDictionaryKey: "APIBase") as? String, !v.isEmpty {
|
||||
return v
|
||||
}
|
||||
return "https://www.ttstd.com"
|
||||
}
|
||||
|
||||
/// 登录:成功返回 accessToken / refreshToken / expireAt。
|
||||
static func login(username: String, password: String) async throws -> [String: Any] {
|
||||
let body: [String: Any] = ["username": username, "password": password]
|
||||
return try await post("/api/auth/login", body: body)
|
||||
}
|
||||
|
||||
/// 刷新令牌:body 需带 refreshToken;成功返回新的 accessToken(可能轮换 refreshToken)。
|
||||
static func refresh(refreshToken: String) async throws -> [String: Any] {
|
||||
let body: [String: Any] = ["refreshToken": refreshToken]
|
||||
return try await post("/api/auth/refresh", body: body)
|
||||
}
|
||||
|
||||
/// 校验当前 accessToken 是否有效。
|
||||
static func verify(accessToken: String) async throws -> [String: Any] {
|
||||
return try await get("/api/client/verify", accessToken: accessToken)
|
||||
}
|
||||
|
||||
/// 拉取本机可连接的被控端绑定列表(仅已绑定设备)。
|
||||
static func bindings(accessToken: String) async throws -> [String: Any] {
|
||||
return try await get("/api/client/bindings", accessToken: accessToken)
|
||||
}
|
||||
|
||||
/// 拉取 TURN 短期凭证(服务端未开启时抛错,调用方忽略)。
|
||||
static func turnCredentials(accessToken: String) async throws -> [String: Any] {
|
||||
return try await get("/api/client/turn-credentials", accessToken: accessToken)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func post(_ path: String, body: [String: Any]) async throws -> [String: Any] {
|
||||
var req = URLRequest(url: URL(string: baseURL + path)!)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
return try await perform(req)
|
||||
}
|
||||
|
||||
private static func get(_ path: String, accessToken: String) async throws -> [String: Any] {
|
||||
var req = URLRequest(url: URL(string: baseURL + path)!)
|
||||
req.httpMethod = "GET"
|
||||
req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
return try await perform(req)
|
||||
}
|
||||
|
||||
private static func perform(_ request: URLRequest) async throws -> [String: Any] {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw ApiError(message: "无效响应")
|
||||
}
|
||||
if http.statusCode == 401 {
|
||||
throw ApiError(code: "UNAUTHORIZED", message: "令牌失效", httpCode: 401)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String
|
||||
?? "HTTP \(http.statusCode)"
|
||||
throw ApiError(message: msg)
|
||||
}
|
||||
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
struct ApiError: Error {
|
||||
let code: String
|
||||
let message: String
|
||||
let httpCode: Int
|
||||
|
||||
init(code: String = "ERROR", message: String, httpCode: Int = -1) {
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.httpCode = httpCode
|
||||
}
|
||||
|
||||
var localizedDescription: String { message }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// 主控端令牌安全存储(Keychain)。
|
||||
///
|
||||
/// refreshToken 为一次性(ses_ 前缀),安全要求高,存 Keychain;
|
||||
/// accessToken 仅内存持有(掉线即失),这里不持久化。
|
||||
enum TokenStore {
|
||||
|
||||
private static let service = "com.ttstd.webrtccontroller"
|
||||
private static let accessKey = "access_token"
|
||||
private static let refreshKey = "refresh_token"
|
||||
private static let userKey = "username"
|
||||
|
||||
// MARK: - Access Token(内存 + Keychain 双重保留,便于冷启动恢复)
|
||||
|
||||
static func saveAccessToken(_ token: String) {
|
||||
save(key: accessKey, value: token)
|
||||
}
|
||||
|
||||
static func loadAccessToken() -> String? {
|
||||
load(key: accessKey)
|
||||
}
|
||||
|
||||
// MARK: - Refresh Token(一次性,必须持久化于 Keychain)
|
||||
|
||||
static func saveRefreshToken(_ token: String) {
|
||||
save(key: refreshKey, value: token)
|
||||
}
|
||||
|
||||
static func loadRefreshToken() -> String? {
|
||||
load(key: refreshKey)
|
||||
}
|
||||
|
||||
static func saveUsername(_ name: String) {
|
||||
save(key: userKey, value: name)
|
||||
}
|
||||
|
||||
static func loadUsername() -> String? {
|
||||
load(key: userKey)
|
||||
}
|
||||
|
||||
static func clear() {
|
||||
for k in [accessKey, refreshKey, userKey] {
|
||||
let query: [String: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: k
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func save(key: String, value: String) {
|
||||
// 先删后存,避免重复条目。
|
||||
let query: [String: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key,
|
||||
kSecValueData: Data(value.utf8)
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
private static func load(key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,18 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
/// 最近一次录制保存的文件路径(沙盒内)
|
||||
@Published var lastRecordingPath: String?
|
||||
|
||||
let myDeviceId: String = DeviceUtils.deviceId()
|
||||
/// 本机设备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
|
||||
|
||||
/// 当前 accessToken(Bearer 握手用),内存持有;refreshToken 存 Keychain。
|
||||
private var accessToken: String?
|
||||
|
||||
// MARK: - UIKit 渲染视图(由 ViewModel 持有,SwiftUI 通过 Representable 嵌入)
|
||||
|
||||
@@ -102,6 +113,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
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 }
|
||||
@@ -129,7 +141,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
|
||||
// MARK: - 连接 / 断开
|
||||
|
||||
/// 用户点击"连接":弹出鉴权选择
|
||||
/// 用户点击"连接":先确保已登录(Bearer token),再弹出鉴权选择
|
||||
func requestConnect() {
|
||||
let target = targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !serverUrl.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||
@@ -140,10 +152,14 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
alertMessage = "请输入目标设备 ID"
|
||||
return
|
||||
}
|
||||
guard isLoggedIn else {
|
||||
alertMessage = "请先在右上角登录账号"
|
||||
return
|
||||
}
|
||||
showAuthSheet = true
|
||||
}
|
||||
|
||||
/// 鉴权弹窗确认后开始连接
|
||||
/// 鉴权弹窗确认后开始连接(已确保 accessToken 有效)。
|
||||
func connect(authType: AuthType, authValue: String) {
|
||||
showAuthSheet = false
|
||||
pendingAuthType = authType
|
||||
@@ -153,7 +169,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
|
||||
let signaling = SignalingClient(
|
||||
serverUrl: serverUrl.trimmingCharacters(in: .whitespaces),
|
||||
deviceId: myDeviceId)
|
||||
token: accessToken)
|
||||
signaling.delegate = self
|
||||
signalingClient = signaling
|
||||
signaling.connect()
|
||||
@@ -277,6 +293,10 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
|
||||
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
|
||||
@@ -295,6 +315,141 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
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
|
||||
DispatchQueue.main.async {
|
||||
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 {
|
||||
DispatchQueue.main.async {
|
||||
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 { $ -> String? in
|
||||
if let u = $["deviceUid"] as? String, !u.isEmpty { return u }
|
||||
return $["deviceId"] as? String
|
||||
}.filter { !$0.isEmpty }
|
||||
if !uids.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.alertMessage = "已绑定设备: " + uids.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
} 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() {
|
||||
@@ -431,8 +586,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
extension ControllerViewModel: SignalingClientDelegate {
|
||||
|
||||
func signalingDidConnect() {
|
||||
statusText = "信令已连接,正在发起会话..."
|
||||
startWebRTC()
|
||||
statusText = "信令已连接,等待注册..."
|
||||
}
|
||||
|
||||
func signalingDidDisconnect() {
|
||||
@@ -445,7 +599,30 @@ extension ControllerViewModel: SignalingClientDelegate {
|
||||
failConnection("信令连接失败: \(error)")
|
||||
}
|
||||
|
||||
/// 令牌失效(关闭码 4001):刷新令牌后重连。
|
||||
func signalingTokenExpired() {
|
||||
Task { await refreshAndReconnect() }
|
||||
}
|
||||
|
||||
/// 强制下线(关闭码 4003):清空登录态,跳回登录。
|
||||
func signalingForceLogout() {
|
||||
DispatchQueue.main.async {
|
||||
self.logout()
|
||||
self.alertMessage = "账号已在其他位置登录,已强制下线"
|
||||
}
|
||||
}
|
||||
|
||||
func signaling(didReceive message: SignalMessage) {
|
||||
// 服务端下发本机 deviceId(CONTROLLER),随后发起 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 {
|
||||
|
||||
@@ -6,7 +6,9 @@ struct ContentView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if viewModel.isControlling {
|
||||
if !viewModel.isLoggedIn {
|
||||
LoginView(viewModel: viewModel)
|
||||
} else if viewModel.isControlling {
|
||||
ControlPanelView(viewModel: viewModel)
|
||||
} else {
|
||||
SetupPanelView(viewModel: viewModel)
|
||||
@@ -26,311 +28,3 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 设置面板
|
||||
|
||||
private struct SetupPanelView: View {
|
||||
@ObservedObject var viewModel: ControllerViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("本机信息") {
|
||||
HStack {
|
||||
Text("本机设备 ID")
|
||||
Spacer()
|
||||
Text(viewModel.myDeviceId)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
Section("连接设置") {
|
||||
TextField("信令服务器地址 (wss://...)", text: $viewModel.serverUrl)
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
TextField("目标设备 ID", text: $viewModel.targetDeviceId)
|
||||
.autocapitalization(.allCharacters)
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
viewModel.requestConnect()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
if viewModel.isConnecting {
|
||||
ProgressView()
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
Text(viewModel.isConnecting ? "连接中..." : "连接")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isConnecting)
|
||||
|
||||
if viewModel.isConnecting {
|
||||
Button(role: .destructive) {
|
||||
viewModel.disconnect()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("取消")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(viewModel.statusText)
|
||||
}
|
||||
}
|
||||
.navigationTitle("WebRTC 控制端")
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 控制面板
|
||||
|
||||
private struct ControlPanelView: View {
|
||||
@ObservedObject var viewModel: ControllerViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
statsPanel
|
||||
topBar
|
||||
videoArea
|
||||
navButtons
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
}
|
||||
|
||||
/// 统计信息面板:置于最上方,点击可展开/收起详情
|
||||
private var statsPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.statsExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
.font(.system(size: 11))
|
||||
Text(statsSummary)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer()
|
||||
Image(systemName: viewModel.statsExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(Color(white: 0.8))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if viewModel.statsExpanded {
|
||||
Text(viewModel.statsText.isEmpty ? "暂无统计数据" : viewModel.statsText)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.75))
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(white: 0.08))
|
||||
}
|
||||
|
||||
/// 概要行:取统计信息首行内容,收起时显示
|
||||
private var statsSummary: String {
|
||||
if let first = viewModel.statsText.split(separator: "\n").first {
|
||||
return String(first)
|
||||
}
|
||||
return "统计信息"
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
// 第一行:状态 + 串流模式切换 + 分辨率 + 帧率
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
Text(viewModel.statusText)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.green)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
|
||||
// 串流模式切换(WebRTC / 自编码)
|
||||
Toggle(isOn: Binding(
|
||||
get: { viewModel.streamMode == .selfCodec },
|
||||
set: { viewModel.toggleStreamMode($0) })) {
|
||||
Text("自编码")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.fixedSize()
|
||||
|
||||
// 分辨率选择
|
||||
Menu {
|
||||
ForEach(ResolutionOption.all) { option in
|
||||
Button {
|
||||
viewModel.selectResolution(option)
|
||||
} label: {
|
||||
if option == viewModel.selectedResolution {
|
||||
Label(option.title, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(option.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical")
|
||||
.font(.footnote)
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
// 帧率选择(档位来自被控端 REPORT_RESOLUTION 上报)
|
||||
Menu {
|
||||
ForEach(viewModel.fpsOptions, id: \.self) { fps in
|
||||
Button {
|
||||
viewModel.selectFps(fps)
|
||||
} label: {
|
||||
if fps == viewModel.currentFps {
|
||||
Label("\(fps)fps", systemImage: "checkmark")
|
||||
} else {
|
||||
Text("\(fps)fps")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.currentFps > 0 ? "\(viewModel.currentFps)fps" : "帧率",
|
||||
systemImage: "speedometer")
|
||||
.font(.footnote)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// 第二行:录制 + 断开(当前行下方)
|
||||
HStack(spacing: 12) {
|
||||
// 录制远端视频(MP4)
|
||||
Button {
|
||||
if viewModel.isRecording {
|
||||
viewModel.stopRecording()
|
||||
} else {
|
||||
viewModel.startRecording()
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.isRecording ? "停止" : "录制",
|
||||
systemImage: viewModel.isRecording ? "stop.circle.fill" : "circle.circle")
|
||||
.font(Font.footnote.weight(.semibold))
|
||||
.fixedSize()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(viewModel.isRecording ? .red : .orange)
|
||||
.controlSize(.small)
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.disconnect()
|
||||
} label: {
|
||||
Text("断开")
|
||||
.font(Font.footnote.weight(.semibold))
|
||||
.fixedSize()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.controlSize(.small)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
.background(Color(white: 0.1))
|
||||
}
|
||||
|
||||
/// 视频区域:按远端画面宽高比自适应,触控层与画面精确对齐
|
||||
private var videoArea: some View {
|
||||
GeometryReader { _ in
|
||||
ZStack {
|
||||
Color.black
|
||||
ZStack {
|
||||
// WebRTC 视频渲染(UIKit RTCMTLVideoView)
|
||||
RemoteVideoView(videoView: viewModel.remoteVideoView)
|
||||
.opacity(viewModel.streamMode == .webrtc ? 1 : 0)
|
||||
// 自编码 H.264 硬解渲染(UIKit AVSampleBufferDisplayLayer)
|
||||
SelfCodecDisplayViewRepresentable(view: viewModel.selfCodecView)
|
||||
.opacity(viewModel.streamMode == .selfCodec ? 1 : 0)
|
||||
// UIKit 触控捕获层
|
||||
TouchOverlay(
|
||||
onMotionEvent: { action, x, y in
|
||||
viewModel.sendMotionEvent(action: action, x: x, y: y)
|
||||
},
|
||||
onSwipe: { x1, y1, x2, y2, duration in
|
||||
viewModel.sendSwipe(x1: x1, y1: y1, x2: x2, y2: y2, duration: duration)
|
||||
})
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
// 录制中指示(左上角红点)
|
||||
if viewModel.isRecording {
|
||||
VStack {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color.red)
|
||||
.frame(width: 10, height: 10)
|
||||
Text("REC")
|
||||
.font(Font.caption2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.black.opacity(0.5))
|
||||
.cornerRadius(6)
|
||||
Spacer()
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var navButtons: some View {
|
||||
HStack(spacing: 24) {
|
||||
navButton(title: "返回", systemImage: "arrow.uturn.backward") {
|
||||
viewModel.sendKey(ControllerViewModel.AndroidKey.back)
|
||||
}
|
||||
navButton(title: "主页", systemImage: "circle") {
|
||||
viewModel.sendKey(ControllerViewModel.AndroidKey.home)
|
||||
}
|
||||
navButton(title: "多任务", systemImage: "square.on.square") {
|
||||
viewModel.sendKey(ControllerViewModel.AndroidKey.appSwitch)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(white: 0.1))
|
||||
}
|
||||
|
||||
private func navButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 18))
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 控制面板:连接成功后显示,承载远端画面、统计信息与控制操作。
|
||||
struct ControlPanelView: View {
|
||||
@ObservedObject var viewModel: ControllerViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
statsPanel
|
||||
topBar
|
||||
videoArea
|
||||
navButtons
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
}
|
||||
|
||||
/// 统计信息面板:置于最上方,点击可展开/收起详情
|
||||
private var statsPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.statsExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
.font(.system(size: 11))
|
||||
Text(statsSummary)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer()
|
||||
Image(systemName: viewModel.statsExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(Color(white: 0.8))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if viewModel.statsExpanded {
|
||||
Text(viewModel.statsText.isEmpty ? "暂无统计数据" : viewModel.statsText)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.75))
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(white: 0.08))
|
||||
}
|
||||
|
||||
/// 概要行:取统计信息首行内容,收起时显示
|
||||
private var statsSummary: String {
|
||||
if let first = viewModel.statsText.split(separator: "\n").first {
|
||||
return String(first)
|
||||
}
|
||||
return "统计信息"
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
// 第一行:状态 + 串流模式切换 + 分辨率 + 帧率
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
Text(viewModel.statusText)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.green)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
|
||||
// 串流模式切换(WebRTC / 自编码)
|
||||
Toggle(isOn: Binding(
|
||||
get: { viewModel.streamMode == .selfCodec },
|
||||
set: { viewModel.toggleStreamMode($0) })) {
|
||||
Text("自编码")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.fixedSize()
|
||||
|
||||
// 分辨率选择
|
||||
Menu {
|
||||
ForEach(ResolutionOption.all) { option in
|
||||
Button {
|
||||
viewModel.selectResolution(option)
|
||||
} label: {
|
||||
if option == viewModel.selectedResolution {
|
||||
Label(option.title, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(option.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical")
|
||||
.font(.footnote)
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
// 帧率选择(档位来自被控端 REPORT_RESOLUTION 上报)
|
||||
Menu {
|
||||
ForEach(viewModel.fpsOptions, id: \.self) { fps in
|
||||
Button {
|
||||
viewModel.selectFps(fps)
|
||||
} label: {
|
||||
if fps == viewModel.currentFps {
|
||||
Label("\(fps)fps", systemImage: "checkmark")
|
||||
} else {
|
||||
Text("\(fps)fps")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.currentFps > 0 ? "\(viewModel.currentFps)fps" : "帧率",
|
||||
systemImage: "speedometer")
|
||||
.font(.footnote)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// 第二行:录制 + 断开(当前行下方)
|
||||
HStack(spacing: 12) {
|
||||
// 录制远端视频(MP4)
|
||||
Button {
|
||||
if viewModel.isRecording {
|
||||
viewModel.stopRecording()
|
||||
} else {
|
||||
viewModel.startRecording()
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.isRecording ? "停止" : "录制",
|
||||
systemImage: viewModel.isRecording ? "stop.circle.fill" : "circle.circle")
|
||||
.font(Font.footnote.weight(.semibold))
|
||||
.fixedSize()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(viewModel.isRecording ? .red : .orange)
|
||||
.controlSize(.small)
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.disconnect()
|
||||
} label: {
|
||||
Text("断开")
|
||||
.font(Font.footnote.weight(.semibold))
|
||||
.fixedSize()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.controlSize(.small)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
.background(Color(white: 0.1))
|
||||
}
|
||||
|
||||
/// 视频区域:按远端画面宽高比自适应,触控层与画面精确对齐
|
||||
private var videoArea: some View {
|
||||
GeometryReader { _ in
|
||||
ZStack {
|
||||
Color.black
|
||||
ZStack {
|
||||
// WebRTC 视频渲染(UIKit RTCMTLVideoView)
|
||||
RemoteVideoView(videoView: viewModel.remoteVideoView)
|
||||
.opacity(viewModel.streamMode == .webrtc ? 1 : 0)
|
||||
// 自编码 H.264 硬解渲染(UIKit AVSampleBufferDisplayLayer)
|
||||
SelfCodecDisplayViewRepresentable(view: viewModel.selfCodecView)
|
||||
.opacity(viewModel.streamMode == .selfCodec ? 1 : 0)
|
||||
// UIKit 触控捕获层
|
||||
TouchOverlay(
|
||||
onMotionEvent: { action, x, y in
|
||||
viewModel.sendMotionEvent(action: action, x: x, y: y)
|
||||
},
|
||||
onSwipe: { x1, y1, x2, y2, duration in
|
||||
viewModel.sendSwipe(x1: x1, y1: y1, x2: x2, y2: y2, duration: duration)
|
||||
})
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
// 录制中指示(左上角红点)
|
||||
if viewModel.isRecording {
|
||||
VStack {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color.red)
|
||||
.frame(width: 10, height: 10)
|
||||
Text("REC")
|
||||
.font(Font.caption2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.black.opacity(0.5))
|
||||
.cornerRadius(6)
|
||||
Spacer()
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var navButtons: some View {
|
||||
HStack(spacing: 24) {
|
||||
navButton(title: "返回", systemImage: "arrow.uturn.backward") {
|
||||
viewModel.sendKey(ControllerViewModel.AndroidKey.back)
|
||||
}
|
||||
navButton(title: "主页", systemImage: "circle") {
|
||||
viewModel.sendKey(ControllerViewModel.AndroidKey.home)
|
||||
}
|
||||
navButton(title: "多任务", systemImage: "square.on.square") {
|
||||
viewModel.sendKey(ControllerViewModel.AndroidKey.appSwitch)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(white: 0.1))
|
||||
}
|
||||
|
||||
private func navButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 18))
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 登录界面:输入用户名/密码,调用 ViewModel.login 保存令牌后进入主界面。
|
||||
struct LoginView: View {
|
||||
@ObservedObject var viewModel: ControllerViewModel
|
||||
|
||||
@State private var username: String = ""
|
||||
@State private var password: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("账号登录") {
|
||||
TextField("用户名", text: $username)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
SecureField("密码", text: $password)
|
||||
}
|
||||
|
||||
if let err = viewModel.loginError, !err.isEmpty {
|
||||
Section {
|
||||
Text(err).foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.login(username: username, password: password)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
if viewModel.isLoggingIn {
|
||||
ProgressView().padding(.trailing, 8)
|
||||
}
|
||||
Text(viewModel.isLoggingIn ? "登录中..." : "登录")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isLoggingIn || username.isEmpty || password.isEmpty)
|
||||
} footer: {
|
||||
Text("登录后才能连接信令服务器并发起远程控制。账号由服务端统一创建与管控。")
|
||||
}
|
||||
}
|
||||
.navigationTitle("WebRTC 控制端")
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 设置面板:未连接时显示,用于查看本机设备 ID、配置信令服务器与连接目标。
|
||||
struct SetupPanelView: View {
|
||||
@ObservedObject var viewModel: ControllerViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("本机信息") {
|
||||
HStack {
|
||||
Text("本机设备 ID")
|
||||
Spacer()
|
||||
Text(viewModel.myDeviceId)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
Section("连接设置") {
|
||||
TextField("信令服务器地址 (wss://...)", text: $viewModel.serverUrl)
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
TextField("目标设备 ID", text: $viewModel.targetDeviceId)
|
||||
.autocapitalization(.allCharacters)
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
viewModel.requestConnect()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
if viewModel.isConnecting {
|
||||
ProgressView()
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
Text(viewModel.isConnecting ? "连接中..." : "连接")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isConnecting)
|
||||
|
||||
if viewModel.isConnecting {
|
||||
Button(role: .destructive) {
|
||||
viewModel.disconnect()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("取消")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(viewModel.statusText)
|
||||
}
|
||||
}
|
||||
.navigationTitle("WebRTC 控制端")
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ final class WebRTCClient: NSObject {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 由服务端下发的 TURN 短期凭证覆盖默认 ICE 配置(为空时用内置 STUN/TURN)。
|
||||
static var iceServersOverride: [[String: Any]]?
|
||||
|
||||
var isDataChannelOpen: Bool {
|
||||
controlChannel?.readyState == .open
|
||||
}
|
||||
@@ -71,13 +74,23 @@ final class WebRTCClient: NSObject {
|
||||
self.remoteRenderer = renderer
|
||||
|
||||
let config = RTCConfiguration()
|
||||
config.iceServers = [
|
||||
RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"]),
|
||||
RTCIceServer(urlStrings: ["stun:www.ttstd.com:3478"]),
|
||||
RTCIceServer(urlStrings: ["turn:www.ttstd.com:3478"],
|
||||
username: "ttstd",
|
||||
credential: "ttstd123")
|
||||
]
|
||||
if let override = WebRTCClient.iceServersOverride {
|
||||
// 服务端下发的 TURN 短期凭证(RFC 7635)。
|
||||
config.iceServers = override.compactMap { entry in
|
||||
guard let urls = entry["urls"] as? String else { return nil }
|
||||
return RTCIceServer(urlStrings: [urls],
|
||||
username: entry["username"] as? String,
|
||||
credential: entry["credential"] as? String)
|
||||
}
|
||||
} else {
|
||||
config.iceServers = [
|
||||
RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"]),
|
||||
RTCIceServer(urlStrings: ["stun:www.ttstd.com:3478"]),
|
||||
RTCIceServer(urlStrings: ["turn:www.ttstd.com:3478"],
|
||||
username: "ttstd",
|
||||
credential: "ttstd123")
|
||||
]
|
||||
}
|
||||
config.sdpSemantics = .unifiedPlan
|
||||
config.continualGatheringPolicy = .gatherContinually
|
||||
config.iceCandidatePoolSize = 10
|
||||
|
||||
Reference in New Issue
Block a user