diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceModels.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceModels.swift new file mode 100644 index 0000000..48d625b --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceModels.swift @@ -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 +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift new file mode 100644 index 0000000..522b55c --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift @@ -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 + } + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift new file mode 100644 index 0000000..badfc97 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift @@ -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 + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift new file mode 100644 index 0000000..32f21d4 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift @@ -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 = ["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)" + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift index 5da2802..3ed4894 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift @@ -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) diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift index cc2c1a6..b5b965e 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift @@ -26,4 +26,16 @@ enum DefaultContainer { ) ) } + + @MainActor + static func makeDeviceModel(instance: Instance) -> DeviceViewModel { + DeviceViewModel( + instance: instance, + service: DeviceService( + client: SimAdminClient(), + sessions: InstanceSessionStore(), + credentials: KeychainCredentialStore() + ) + ) + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift b/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift index 521578f..41919ed 100644 --- a/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift +++ b/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift @@ -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)"] diff --git a/apps/ios/Tests/MultiSimAdminTests/DeviceServiceTests.swift b/apps/ios/Tests/MultiSimAdminTests/DeviceServiceTests.swift new file mode 100644 index 0000000..fe25c97 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/DeviceServiceTests.swift @@ -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)) + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/UpstreamRouteTests.swift b/apps/ios/Tests/MultiSimAdminTests/UpstreamRouteTests.swift index 059636e..b7661c5 100644 --- a/apps/ios/Tests/MultiSimAdminTests/UpstreamRouteTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/UpstreamRouteTests.swift @@ -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, diff --git a/scripts/build-ios-ipa.sh b/scripts/build-ios-ipa.sh index 0e10d4e..bccdaa5 100755 --- a/scripts/build-ios-ipa.sh +++ b/scripts/build-ios-ipa.sh @@ -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"