feat(ios): add device details and reliable ipa build
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
public struct DeviceSection: Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let values: [String: String]
|
||||
}
|
||||
|
||||
public struct DeviceSnapshot: Equatable, Sendable {
|
||||
public let sections: [DeviceSection]
|
||||
}
|
||||
|
||||
public enum DeviceServiceError: Error, Equatable {
|
||||
case unauthorized
|
||||
case invalidResponse
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
|
||||
public struct DeviceService: Sendable {
|
||||
private let client: any SimAdminClientProtocol
|
||||
private let sessions: InstanceSessionStore
|
||||
private let credentials: (any CredentialStore)?
|
||||
|
||||
public init(
|
||||
client: any SimAdminClientProtocol,
|
||||
sessions: InstanceSessionStore,
|
||||
credentials: (any CredentialStore)? = nil
|
||||
) {
|
||||
self.client = client
|
||||
self.sessions = sessions
|
||||
self.credentials = credentials
|
||||
}
|
||||
|
||||
public func snapshot(_ instance: Instance) async throws -> DeviceSnapshot {
|
||||
let routes: [(id: String, title: String, route: UpstreamRoute)] = [
|
||||
("device", "设备", .device),
|
||||
("sim", "SIM", .sim),
|
||||
("network", "蜂窝网络", .network),
|
||||
("stats", "系统资源", .stats),
|
||||
("cpu", "CPU", .statsCpu),
|
||||
("connectivity", "连接状态", .connectivity)
|
||||
]
|
||||
|
||||
var sections: [DeviceSection] = []
|
||||
var successful = 0
|
||||
var unauthorized = false
|
||||
for item in routes {
|
||||
do {
|
||||
let response = try await request(item.route, instance)
|
||||
let values = try Self.flatten(Self.dataObject(response.data))
|
||||
sections.append(DeviceSection(id: item.id, title: item.title, values: values))
|
||||
successful += 1
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
unauthorized = true
|
||||
sections.append(DeviceSection(id: item.id, title: item.title, values: [:]))
|
||||
} catch {
|
||||
sections.append(DeviceSection(id: item.id, title: item.title, values: [:]))
|
||||
}
|
||||
}
|
||||
if successful == 0 {
|
||||
throw unauthorized ? DeviceServiceError.unauthorized : DeviceServiceError.invalidResponse
|
||||
}
|
||||
return DeviceSnapshot(sections: sections)
|
||||
}
|
||||
|
||||
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
||||
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
do {
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
cookie = try await login(instance)
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
}
|
||||
}
|
||||
|
||||
private func login(_ instance: Instance) async throws -> String {
|
||||
guard let credentials,
|
||||
let password = try credentials.get(instanceID: instance.id),
|
||||
!password.isEmpty
|
||||
else {
|
||||
throw DeviceServiceError.unauthorized
|
||||
}
|
||||
let sessionService = InstanceSessionService(store: sessions, client: client)
|
||||
let result = try await sessionService.login(instance, password: password)
|
||||
guard result.authenticated,
|
||||
let cookie = await sessionService.cookie(for: instance)
|
||||
else {
|
||||
throw DeviceServiceError.unauthorized
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
static func dataObject(_ data: Data) throws -> [String: Any] {
|
||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw DeviceServiceError.invalidResponse
|
||||
}
|
||||
if let payload = root["data"] as? [String: Any] { return payload }
|
||||
if let response = root["response"] as? [String: Any],
|
||||
let payload = response["data"] as? [String: Any] {
|
||||
return payload
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
static func flatten(_ source: [String: Any], maximum: Int = 40) -> [String: String] {
|
||||
var output: [String: String] = [:]
|
||||
flatten(source, prefix: "", into: &output, remaining: maximum)
|
||||
return output
|
||||
}
|
||||
|
||||
private static func flatten(
|
||||
_ source: [String: Any],
|
||||
prefix: String,
|
||||
into output: inout [String: String],
|
||||
remaining: Int
|
||||
) {
|
||||
for (key, value) in source.sorted(by: { $0.key < $1.key }) {
|
||||
guard output.count < remaining else { return }
|
||||
let fullKey = prefix.isEmpty ? key : "\(prefix)_\(key)"
|
||||
switch value {
|
||||
case let text as String:
|
||||
output[fullKey] = text
|
||||
case let flag as Bool:
|
||||
output[fullKey] = flag ? "是" : "否"
|
||||
case let number as Int:
|
||||
output[fullKey] = String(number)
|
||||
case let number as Double:
|
||||
output[fullKey] = String(format: "%.1f", number)
|
||||
case let array as [Any]:
|
||||
let text = array.compactMap { entry -> String? in
|
||||
if let record = entry as? [String: Any] {
|
||||
let preferred = ["name", "zone", "mount_point", "interface", "model_name"]
|
||||
.compactMap { record[$0] as? String }
|
||||
.first
|
||||
if let preferred { return preferred }
|
||||
}
|
||||
return entry as? String
|
||||
}.joined(separator: "、")
|
||||
output[fullKey] = text
|
||||
case let nested as [String: Any]:
|
||||
flatten(nested, prefix: fullKey, into: &output, remaining: remaining - output.count)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class DeviceViewModel: ObservableObject {
|
||||
@Published var snapshot: DeviceSnapshot?
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
let instance: Instance
|
||||
private let service: DeviceService
|
||||
|
||||
init(instance: Instance, service: DeviceService) {
|
||||
self.instance = instance
|
||||
self.service = service
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
snapshot = try await service.snapshot(instance)
|
||||
} catch DeviceServiceError.unauthorized {
|
||||
errorMessage = "需要实例密码登录后才能读取设备数据。"
|
||||
} catch {
|
||||
errorMessage = "无法加载设备数据。"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceDetailView: View {
|
||||
@StateObject private var model: DeviceViewModel
|
||||
@State private var revealsSensitive = false
|
||||
|
||||
init(instance: Instance, model: DeviceViewModel? = nil) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
header
|
||||
if let errorMessage = model.errorMessage {
|
||||
AICard {
|
||||
Text(errorMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.warning)
|
||||
}
|
||||
}
|
||||
if let snapshot = model.snapshot {
|
||||
ForEach(snapshot.sections) { section in
|
||||
sectionCard(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle(model.instance.name)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
revealsSensitive.toggle()
|
||||
} label: {
|
||||
Label(revealsSensitive ? "隐藏敏感值" : "显示敏感值",
|
||||
systemImage: revealsSensitive ? "eye.slash" : "eye")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("设备详情")
|
||||
.font(.headline)
|
||||
Text(model.instance.origin.absoluteString)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
if model.isLoading {
|
||||
Text("正在读取设备能力…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionCard(_ section: DeviceSection) -> some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(section.title)
|
||||
.font(.headline)
|
||||
if section.values.isEmpty {
|
||||
Text("此模块当前不可用或未返回数据。")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
} else {
|
||||
ForEach(section.values.sorted(by: { $0.key < $1.key }), id: \.key) { item in
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Text(fieldLabel(item.key))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(display(item.key, item.value))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let fieldLabels: [String: String] = [
|
||||
"model": "型号",
|
||||
"manufacturer": "制造商",
|
||||
"imei": "IMEI",
|
||||
"revision": "固件版本",
|
||||
"online": "在线",
|
||||
"powered": "基带电源",
|
||||
"present": "SIM 在位",
|
||||
"iccid": "ICCID",
|
||||
"imsi": "IMSI",
|
||||
"phone_numbers": "号码",
|
||||
"sms_center": "短信中心",
|
||||
"operator_name": "运营商",
|
||||
"registration_status": "注册状态",
|
||||
"technology_preference": "网络制式",
|
||||
"signal_strength": "信号强度",
|
||||
"core_count": "核心数",
|
||||
"model_name": "CPU 型号",
|
||||
"state": "连接状态",
|
||||
"latency_ms": "延迟(ms)"
|
||||
]
|
||||
|
||||
private let sensitiveKeys: Set<String> = ["imei", "iccid", "imsi", "phone_numbers", "sms_center"]
|
||||
|
||||
private func fieldLabel(_ key: String) -> String {
|
||||
fieldLabels[key] ?? key.replacingOccurrences(of: "_", with: " ")
|
||||
}
|
||||
|
||||
private func display(_ key: String, _ value: String) -> String {
|
||||
guard sensitiveKeys.contains(key), !revealsSensitive else { return value }
|
||||
guard let first = value.first, value.count > 6 else { return String(repeating: "•", count: min(value.count, 8)) }
|
||||
let tail = value.suffix(4)
|
||||
return "\(first) •••• \(tail)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user