feat(ios): add online device list and pairing code redemption
- Add API methods for fetching online devices and redeeming pairing codes - Add OnlineDevice model and corresponding ViewModel state management - Add UI sections for online device list and pairing code input in SetupPanel - Ensure access token is verified before connecting to signaling server
This commit is contained in:
@@ -45,12 +45,32 @@ struct ApiClient {
|
|||||||
return try await get("/api/client/turn-credentials", accessToken: accessToken)
|
return try await get("/api/client/turn-credentials", accessToken: accessToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 在线被控端列表(仅当前在线、本账号可连接的设备)。
|
||||||
|
/// 参考 Flutter `devices_online`(GET /api/client/devices/online)。
|
||||||
|
static func onlineDevices(accessToken: String) async throws -> [String: Any] {
|
||||||
|
return try await get("/api/client/devices/online", accessToken: accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配对码兑换:用被控端显示的配对码建立绑定。
|
||||||
|
/// 参考 Flutter `redeemPairingCode`(POST /api/client/pairing/redeem)。
|
||||||
|
static func redeemPairingCode(accessToken: String, code: String) async throws -> [String: Any] {
|
||||||
|
let body: [String: Any] = ["code": code]
|
||||||
|
return try await post("/api/client/pairing/redeem", body: body, accessToken: accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private static func post(_ path: String, body: [String: Any]) async throws -> [String: Any] {
|
private static func post(_ path: String, body: [String: Any]) async throws -> [String: Any] {
|
||||||
|
return try await post(path, body: body, accessToken: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(_ path: String, body: [String: Any], accessToken: String?) async throws -> [String: Any] {
|
||||||
var req = URLRequest(url: URL(string: baseURL + path)!)
|
var req = URLRequest(url: URL(string: baseURL + path)!)
|
||||||
req.httpMethod = "POST"
|
req.httpMethod = "POST"
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
if let accessToken, !accessToken.isEmpty {
|
||||||
|
req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
return try await perform(req)
|
return try await perform(req)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ struct ResolutionOption: Identifiable, Equatable {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 在线被控端(参考 Flutter BindingDevice.fromOnlineJson)
|
||||||
|
struct OnlineDevice: Identifiable, Equatable {
|
||||||
|
let deviceId: String
|
||||||
|
let nickname: String
|
||||||
|
|
||||||
|
var id: String { deviceId }
|
||||||
|
}
|
||||||
|
|
||||||
/// 主控端 ViewModel:串联信令、WebRTC、自编码解码与 UI 状态。
|
/// 主控端 ViewModel:串联信令、WebRTC、自编码解码与 UI 状态。
|
||||||
/// 所有回调均已在主线程触发,@Published 属性直接驱动 SwiftUI。
|
/// 所有回调均已在主线程触发,@Published 属性直接驱动 SwiftUI。
|
||||||
final class ControllerViewModel: NSObject, ObservableObject {
|
final class ControllerViewModel: NSObject, ObservableObject {
|
||||||
@@ -67,6 +75,17 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
|||||||
@Published var showAuthSheet: Bool = false
|
@Published var showAuthSheet: Bool = false
|
||||||
@Published var alertMessage: String?
|
@Published var alertMessage: String?
|
||||||
|
|
||||||
|
// MARK: - 设备发现与配对
|
||||||
|
|
||||||
|
/// 当前在线的被控端列表(GET /api/client/devices/online)。
|
||||||
|
@Published var onlineDevices: [OnlineDevice] = []
|
||||||
|
/// 是否正在加载在线设备列表。
|
||||||
|
@Published var isLoadingOnlineDevices: Bool = false
|
||||||
|
/// 配对码输入框内容。
|
||||||
|
@Published var pairingCode: String = ""
|
||||||
|
/// 是否正在兑换配对码。
|
||||||
|
@Published var isRedeemingPairing: Bool = false
|
||||||
|
|
||||||
/// 是否正在录制远端视频
|
/// 是否正在录制远端视频
|
||||||
@Published var isRecording: Bool = false
|
@Published var isRecording: Bool = false
|
||||||
/// 最近一次录制保存的文件路径(沙盒内)
|
/// 最近一次录制保存的文件路径(沙盒内)
|
||||||
@@ -156,10 +175,12 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
|||||||
alertMessage = "请先在右上角登录账号"
|
alertMessage = "请先在右上角登录账号"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
loadOnlineDevices()
|
||||||
showAuthSheet = true
|
showAuthSheet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 鉴权弹窗确认后开始连接(已确保 accessToken 有效)。
|
/// 鉴权弹窗确认后开始连接。先确保 accessToken 有效(参考 Flutter 的 connectSignaling 先 verify/refresh),
|
||||||
|
/// 避免首连时直接带着过期令牌被服务端以 4001 关闭。
|
||||||
func connect(authType: AuthType, authValue: String) {
|
func connect(authType: AuthType, authValue: String) {
|
||||||
showAuthSheet = false
|
showAuthSheet = false
|
||||||
pendingAuthType = authType
|
pendingAuthType = authType
|
||||||
@@ -167,13 +188,26 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
|||||||
isConnecting = true
|
isConnecting = true
|
||||||
statusText = "正在连接信令服务器..."
|
statusText = "正在连接信令服务器..."
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let token = try await ensureAccessToken()
|
||||||
|
accessToken = token
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
let signaling = SignalingClient(
|
let signaling = SignalingClient(
|
||||||
serverUrl: serverUrl.trimmingCharacters(in: .whitespaces),
|
serverUrl: self.serverUrl.trimmingCharacters(in: .whitespaces),
|
||||||
token: accessToken)
|
token: token)
|
||||||
signaling.delegate = self
|
signaling.delegate = self
|
||||||
signalingClient = signaling
|
self.signalingClient = signaling
|
||||||
signaling.connect()
|
signaling.connect()
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.failConnection((error as? ApiError)?.message ?? "令牌失效,请重新登录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
stopStatsTimer()
|
stopStatsTimer()
|
||||||
@@ -433,6 +467,67 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取当前在线的被控端列表(GET /api/client/devices/online,仅在线设备)。
|
||||||
|
/// 参考 Flutter RemoteController.loadOnlineDevices / BindingDevice.fromOnlineJson。
|
||||||
|
func loadOnlineDevices() {
|
||||||
|
guard let at = accessToken else { return }
|
||||||
|
isLoadingOnlineDevices = true
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let data = try await ApiClient.onlineDevices(accessToken: at)
|
||||||
|
let list = (data["devices"] as? [[String: Any]]) ?? []
|
||||||
|
let devices: [OnlineDevice] = list.compactMap { entry in
|
||||||
|
guard let id = entry["deviceId"] as? String, !id.isEmpty else { return nil }
|
||||||
|
let nickname = (entry["nickname"] as? String) ?? id
|
||||||
|
return OnlineDevice(deviceId: id, nickname: nickname)
|
||||||
|
}
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.onlineDevices = devices
|
||||||
|
self.isLoadingOnlineDevices = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.isLoadingOnlineDevices = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配对码兑换:用被控端显示的配对码建立绑定(POST /api/client/pairing/redeem)。
|
||||||
|
/// 参考 Flutter RemoteController.redeemPairingCode。
|
||||||
|
func redeemPairingCode() {
|
||||||
|
let code = pairingCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !code.isEmpty else {
|
||||||
|
alertMessage = "请输入配对码"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let at = accessToken else {
|
||||||
|
alertMessage = "请先登录账号"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isRedeemingPairing = true
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let data = try await ApiClient.redeemPairingCode(accessToken: at, code: code)
|
||||||
|
let deviceId = (data["deviceId"] as? String) ?? ""
|
||||||
|
let nickname = (data["nickname"] as? String) ?? deviceId
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.isRedeemingPairing = false
|
||||||
|
self.pairingCode = ""
|
||||||
|
self.alertMessage = "绑定成功:\(nickname)(\(deviceId))"
|
||||||
|
self.loadOnlineDevices()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.isRedeemingPairing = false
|
||||||
|
self?.alertMessage = "配对失败:\((error as? ApiError)?.message ?? error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 拉取 TURN 短期凭证,覆盖默认 ICE(由 WebRTCClient 读取)。
|
/// 拉取 TURN 短期凭证,覆盖默认 ICE(由 WebRTCClient 读取)。
|
||||||
private func loadTurnCredentials() async {
|
private func loadTurnCredentials() async {
|
||||||
guard let at = accessToken else { return }
|
guard let at = accessToken else { return }
|
||||||
|
|||||||
@@ -26,6 +26,63 @@ struct SetupPanelView: View {
|
|||||||
TextField("目标设备 ID", text: $viewModel.targetDeviceId)
|
TextField("目标设备 ID", text: $viewModel.targetDeviceId)
|
||||||
.autocapitalization(.allCharacters)
|
.autocapitalization(.allCharacters)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.loadOnlineDevices()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("刷新在线设备")
|
||||||
|
Spacer()
|
||||||
|
if viewModel.isLoadingOnlineDevices {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.onlineDevices.isEmpty {
|
||||||
|
Section("在线设备(点击填入目标 ID)") {
|
||||||
|
ForEach(viewModel.onlineDevices) { device in
|
||||||
|
Button {
|
||||||
|
viewModel.targetDeviceId = device.deviceId
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(device.nickname)
|
||||||
|
Text(device.deviceId)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if viewModel.targetDeviceId == device.deviceId {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("配对码兑换(建立新绑定)") {
|
||||||
|
HStack {
|
||||||
|
TextField("输入被控端显示的配对码", text: $viewModel.pairingCode)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
Button {
|
||||||
|
viewModel.redeemPairingCode()
|
||||||
|
} label: {
|
||||||
|
if viewModel.isRedeemingPairing {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text("兑换")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isRedeemingPairing || viewModel.pairingCode.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
Text("在被控端生成配对码后填入此处即可建立绑定,绑定成功后会出现在上方在线设备列表中。")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
|||||||
Reference in New Issue
Block a user