From 1bd0318d35ef2fdc42d30aa5ce17987d6b3c72ea Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Mon, 3 Aug 2026 21:42:31 +0800 Subject: [PATCH] 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 --- .../Utils/ApiClient.swift | 20 ++++ .../ViewModel/ControllerViewModel.swift | 109 ++++++++++++++++-- .../Views/SetupPanelView.swift | 57 +++++++++ 3 files changed, 179 insertions(+), 7 deletions(-) diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Utils/ApiClient.swift b/webrtc_controller_ios/web_rtc_controller_ios/Utils/ApiClient.swift index 9cbb086..4ebf236 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/Utils/ApiClient.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/Utils/ApiClient.swift @@ -45,12 +45,32 @@ struct ApiClient { 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 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)!) req.httpMethod = "POST" 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) return try await perform(req) } diff --git a/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift b/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift index f5474e8..fb756ac 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift @@ -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 状态。 /// 所有回调均已在主线程触发,@Published 属性直接驱动 SwiftUI。 final class ControllerViewModel: NSObject, ObservableObject { @@ -67,6 +75,17 @@ final class ControllerViewModel: NSObject, ObservableObject { @Published var showAuthSheet: Bool = false @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 /// 最近一次录制保存的文件路径(沙盒内) @@ -156,10 +175,12 @@ final class ControllerViewModel: NSObject, ObservableObject { alertMessage = "请先在右上角登录账号" return } + loadOnlineDevices() showAuthSheet = true } - /// 鉴权弹窗确认后开始连接(已确保 accessToken 有效)。 + /// 鉴权弹窗确认后开始连接。先确保 accessToken 有效(参考 Flutter 的 connectSignaling 先 verify/refresh), + /// 避免首连时直接带着过期令牌被服务端以 4001 关闭。 func connect(authType: AuthType, authValue: String) { showAuthSheet = false pendingAuthType = authType @@ -167,12 +188,25 @@ final class ControllerViewModel: NSObject, ObservableObject { isConnecting = true statusText = "正在连接信令服务器..." - let signaling = SignalingClient( - serverUrl: serverUrl.trimmingCharacters(in: .whitespaces), - token: accessToken) - signaling.delegate = self - signalingClient = signaling - signaling.connect() + Task { + do { + let token = try await ensureAccessToken() + accessToken = token + await MainActor.run { [weak self] in + guard let self else { return } + let signaling = SignalingClient( + serverUrl: self.serverUrl.trimmingCharacters(in: .whitespaces), + token: token) + signaling.delegate = self + self.signalingClient = signaling + signaling.connect() + } + } catch { + await MainActor.run { [weak self] in + self?.failConnection((error as? ApiError)?.message ?? "令牌失效,请重新登录") + } + } + } } func disconnect() { @@ -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 读取)。 private func loadTurnCredentials() async { guard let at = accessToken else { return } diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Views/SetupPanelView.swift b/webrtc_controller_ios/web_rtc_controller_ios/Views/SetupPanelView.swift index 7bd123f..d83a7b7 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/Views/SetupPanelView.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/Views/SetupPanelView.swift @@ -26,6 +26,63 @@ struct SetupPanelView: View { TextField("目标设备 ID", text: $viewModel.targetDeviceId) .autocapitalization(.allCharacters) .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 {