feat(controlled): 实现设备激活与安全认证流程

- 添加API客户端、加密存储和provision/token激活逻辑
- WebSocket改用Bearer令牌认证,移除REGISTER请求
- 设备ID改为服务端下发,支持令牌刷新和强制下线处理
- 新增deviceSecret加密存储和accessToken自动刷新
- 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
2026-08-01 15:13:12 +08:00
parent 376a2c1217
commit 6eb2c7321a
124 changed files with 10535 additions and 862 deletions

View File

@@ -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 */,

View File

@@ -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
/// REGISTERdeviceType = 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 tokenURLSessionWebSocketTask 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()
}

View File

@@ -0,0 +1,94 @@
import Foundation
/// HTTP
///
/// - login(username,password) accessToken + refreshTokenses_
/// - 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 }
}

View File

@@ -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)
}
}

View File

@@ -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
/// accessTokenBearer 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) {
// 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 {

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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