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

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