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)
|
||||
}
|
||||
|
||||
/// 在线被控端列表(仅当前在线、本账号可连接的设备)。
|
||||
/// 参考 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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user