feat(ios): add device details and reliable ipa build

This commit is contained in:
chick
2026-09-09 00:46:28 +08:00
parent 7778693807
commit 84ec3678c0
10 changed files with 402 additions and 6 deletions
@@ -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,
+6 -3
View File
@@ -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"