Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift
T
chick 3f6090081f feat(ios): 中继通道 P4/P5——端点抽象、信封、传输、配对与分层错误
- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct,
  写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>)
- RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器,
  因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写)
- NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter;
  DirectTransport 是薄适配,直通路径行为不变
- RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃,
  四类以上失败各有独立 UpstreamError 分支与用户文案
- DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet
- 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成
- scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫
- 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、
  传输往返与各类拒绝路径
2026-09-16 18:57:41 +08:00

224 lines
9.2 KiB
Swift

import Foundation
public struct DeviceService: Sendable {
private let transport: any NodeTransport
public init(transport: any NodeTransport) {
self.transport = transport
}
/// Direct-mode construction, kept for the existing call sites and tests.
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil
) {
self.init(transport: DirectTransport(client: client, sessions: sessions, 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)
}
public func summary(_ instance: Instance) async throws -> DeviceSummary {
let routes: [(id: String, route: UpstreamRoute)] = [
("device", .device),
("sim", .sim),
("network", .network),
("stats", .stats),
("cpu", .statsCpu),
("connectivity", .connectivity)
]
var sections: [(String, [String: String])] = []
var successful = 0
for item in routes {
do {
let response = try await request(item.route, instance)
let values = try Self.flatten(Self.dataObject(response.data))
sections.append((item.id, values))
successful += 1
} catch {
continue
}
}
guard successful > 0 else {
throw DeviceServiceError.invalidResponse
}
return Self.summary(from: sections)
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
}
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 summary(from sections: [(String, [String: String])]) -> DeviceSummary {
func key(_ sectionID: String, _ names: [String]) -> String? {
guard let section = sections.first(where: { $0.0 == sectionID }) else { return nil }
return names.lazy.compactMap { section.1[$0] }.first { !$0.isEmpty }
}
func number(_ text: String?) -> Double? {
guard let text = text?.replacingOccurrences(of: "%", with: "") else { return nil }
return Double(text)
}
func bool(_ text: String?) -> Bool? {
switch text?.lowercased() {
case "是", "true", "yes", "1": return true
case "否", "false", "no", "0": return false
default: return nil
}
}
let linkState = DeviceLinkState(
hardwareOnline: bool(key("device", ["powered"])),
controlOnline: bool(key("device", ["online"])),
simPresent: sections.contains { $0.0 == "sim" },
cellularOnline: key("network", ["registration_status"])?
.lowercased()
.hasPrefix("registered")
)
return DeviceSummary(
deviceName: key("device", ["model", "name", "device"]),
phoneNumber: key("sim", ["phone_numbers", "phone_number"]),
operatorName: key("network", ["operator_name", "operator", "carrier"]),
networkTechnology: key(
"network",
["network_type", "technology_preference", "technology", "radio_mode"]
),
signalPercent: number(key("network", ["signal_percent", "signal_strength"]))
.flatMap { Int($0.rounded()) },
cpuPercent: number(key("cpu", ["cpu_percent", "cpu_load", "usage"])),
memoryPercent: number(key("stats", ["memory_percent", "usage"])),
cpuCount: number(key("cpu", ["core_count", "cores"])).flatMap { Int($0.rounded()) },
temperatureCelsius: number(key("stats", ["temperature", "max_temperature_c"])),
batteryPercent: number(key("stats", ["battery_percent", "battery_level"])),
ipAddress: key("network", ["ipv4", "ip_address", "ipv6"]),
latencyMilliseconds: number(key("connectivity", ["latency_ms", "latency"])).flatMap {
Int($0.rounded())
},
connectivity: key("connectivity", ["state", "connected", "latency_ms"]),
firmwareVersion: key("device", ["revision", "firmware_version"]),
totalStorageBytes: Self.byteCount(
key("stats", ["disk_total_bytes", "storage_total_bytes", "total_bytes"])
),
availableStorageBytes: Self.byteCount(
key("stats", ["disk_available_bytes", "storage_available_bytes", "available_bytes"])
),
downloadRateBytesPerSecond: Self.byteRate(
key("connectivity", ["download", "download_bytes_per_second", "rx_rate"])
),
uploadRateBytesPerSecond: Self.byteRate(
key("connectivity", ["upload", "upload_bytes_per_second", "tx_rate"])
),
messagesToday: number(
key("connectivity", ["messages_today", "messages", "sms_today"])
).flatMap { Int($0.rounded()) },
uptimeSeconds: number(
key("stats", ["uptime_seconds", "uptime", "system_uptime_seconds"])
) ?? number(key("stats", ["uptime_uptime_seconds"]))
?? number(key("stats", ["system_uptime_seconds"])),
linkState: linkState
)
}
private static func byteCount(_ text: String?) -> Int64? {
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
return nil
}
return Int64(value.rounded())
}
private static func byteRate(_ text: String?) -> Double? {
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
return nil
}
return value
}
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
}
}
}
}