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)"
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,16 @@ struct FleetView: View {
|
||||
}
|
||||
}
|
||||
ForEach(model.rows) { row in
|
||||
instanceCard(row)
|
||||
if model.selectionMode {
|
||||
instanceCard(row)
|
||||
} else {
|
||||
NavigationLink {
|
||||
InstanceDetailView(instance: row.instance)
|
||||
} label: {
|
||||
instanceCard(row)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
@@ -26,4 +26,16 @@ enum DefaultContainer {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeDeviceModel(instance: Instance) -> DeviceViewModel {
|
||||
DeviceViewModel(
|
||||
instance: instance,
|
||||
service: DeviceService(
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: KeychainCredentialStore()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
public enum UpstreamRoute: Equatable, Sendable {
|
||||
public enum UpstreamRoute: Hashable, Sendable {
|
||||
case health
|
||||
case device
|
||||
case sim
|
||||
case network
|
||||
case signalStrength
|
||||
case stats
|
||||
case statsCpu
|
||||
case connectivity
|
||||
case smsList(limit: Int, offset: Int, direction: String?)
|
||||
case smsSend(phoneNumber: String, content: String)
|
||||
@@ -15,7 +17,8 @@ public enum UpstreamRoute: Equatable, Sendable {
|
||||
|
||||
public var method: String {
|
||||
switch self {
|
||||
case .health, .device, .sim, .network, .signalStrength, .connectivity, .smsList:
|
||||
case .health, .device, .sim, .network, .signalStrength, .stats, .statsCpu,
|
||||
.connectivity, .smsList:
|
||||
return "GET"
|
||||
case .smsSend, .smsBatchDelete, .authLogin, .authLogout:
|
||||
return "POST"
|
||||
@@ -29,6 +32,8 @@ public enum UpstreamRoute: Equatable, Sendable {
|
||||
case .sim: return "/api/sim"
|
||||
case .network: return "/api/network"
|
||||
case .signalStrength: return "/api/network/signal-strength"
|
||||
case .stats: return "/api/stats"
|
||||
case .statsCpu: return "/api/stats/cpu"
|
||||
case .connectivity: return "/api/connectivity"
|
||||
case .smsList(let limit, let offset, let direction):
|
||||
var items = ["limit=\(limit)", "offset=\(offset)"]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class DeviceServiceTests: XCTestCase {
|
||||
func testLoadsAndFlattensWhitelistedReadSections() async throws {
|
||||
let store = InstanceSessionStore()
|
||||
let client = DeviceTestClient()
|
||||
let service = DeviceService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
|
||||
let snapshot = try await service.snapshot(instance)
|
||||
|
||||
XCTAssertEqual(client.routes, [.device, .sim, .network, .stats, .statsCpu, .connectivity])
|
||||
XCTAssertEqual(snapshot.sections[0].values["model"], "SIMBox 8")
|
||||
XCTAssertEqual(snapshot.sections[0].values["online"], "是")
|
||||
XCTAssertEqual(snapshot.sections[1].values["phone_numbers"], "13800138000")
|
||||
XCTAssertEqual(snapshot.sections[2].values["operator_name"], "中国移动")
|
||||
XCTAssertEqual(snapshot.sections[3].values["uptime_uptime_seconds"], "86400")
|
||||
XCTAssertEqual(snapshot.sections[4].values["core_count"], "4")
|
||||
XCTAssertEqual(snapshot.sections[5].values["state"], "connected")
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
name: "Device Node",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
configRevision: 1,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class DeviceTestClient: SimAdminClientProtocol, @unchecked Sendable {
|
||||
var routes: [UpstreamRoute] = []
|
||||
|
||||
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
routes.append(route)
|
||||
guard cookie == "simadmin_session=token" else {
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
let payload: [UpstreamRoute: String] = [
|
||||
.device: #"{"data":{"model":"SIMBox 8","online":true}}"#,
|
||||
.sim: #"{"data":{"phone_numbers":["13800138000"],"iccid":"123"}}"#,
|
||||
.network: #"{"data":{"operator_name":"中国移动","registration_status":"registered"}}"#,
|
||||
.stats: #"{"data":{"uptime":{"uptime_seconds":86400}}}"#,
|
||||
.statsCpu: #"{"data":{"core_count":4,"model_name":"ARM CPU"}}"#,
|
||||
.connectivity: #"{"data":{"state":"connected","latency_ms":21}}"#
|
||||
]
|
||||
guard let json = payload[route] else {
|
||||
throw UpstreamError.notSupported
|
||||
}
|
||||
return UpstreamResponse(status: 200, data: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ final class UpstreamRouteTests: XCTestCase {
|
||||
XCTAssertEqual(UpstreamRoute.sim.path, "/api/sim")
|
||||
XCTAssertEqual(UpstreamRoute.network.path, "/api/network")
|
||||
XCTAssertEqual(UpstreamRoute.signalStrength.path, "/api/network/signal-strength")
|
||||
XCTAssertEqual(UpstreamRoute.stats.path, "/api/stats")
|
||||
XCTAssertEqual(UpstreamRoute.statsCpu.path, "/api/stats/cpu")
|
||||
XCTAssertEqual(UpstreamRoute.smsList(limit: 100, offset: 0, direction: nil).path,
|
||||
"/api/sms/list?limit=100&offset=0")
|
||||
XCTAssertEqual(UpstreamRoute.smsList(limit: 20, offset: 2, direction: "outgoing").path,
|
||||
|
||||
@@ -5,11 +5,14 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IOS_ROOT="$ROOT/apps/ios"
|
||||
BUILD_ROOT="$ROOT/build/ipa"
|
||||
APP_NAME="MultiSimAdmin"
|
||||
SDK_PATH="$(xcrun --sdk iphoneos --show-sdk-path)"
|
||||
DERIVED_ROOT="${MULTI_SIMADMIN_IOS_DERIVED:-$IOS_ROOT/.build/ios-derived}"
|
||||
|
||||
cd "$IOS_ROOT"
|
||||
swift build --configuration release --product "$APP_NAME" \
|
||||
--triple arm64-apple-ios16.0 --sdk "$SDK_PATH"
|
||||
xcodebuild -scheme "$APP_NAME" \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-configuration Release \
|
||||
-derivedDataPath "$DERIVED_ROOT" \
|
||||
build
|
||||
|
||||
rm -rf "$BUILD_ROOT"
|
||||
mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app"
|
||||
|
||||
Reference in New Issue
Block a user