Auditoria
O cliente HTTP
Kit/Sources/EscaliburNetwork/HTTPClient.swift
O único ponto do app que fala com a internet.
| 1 | import EscaliburChains |
| 2 | import Foundation |
| 3 | |
| 4 | /// O unico ponto do app que fala com a internet. |
| 5 | /// |
| 6 | /// Configuracao de docs/seguranca.md §5.1: sessao efemera sem cache, sem cookies e |
| 7 | /// sem credenciais guardadas (nada de `Cache.db` revelando quais tokens o dono olha), |
| 8 | /// sem seguir redirecionamento, resposta limitada a 4 MB, timeouts curtos, e um |
| 9 | /// User-Agent constante que nao entrega versao do app nem do iOS. |
| 10 | public final class HTTPClient: NSObject, @unchecked Sendable { |
| 11 | public enum Failure: Error, Equatable, Sendable { |
| 12 | case offline |
| 13 | case timeout |
| 14 | case status(Int) |
| 15 | case tooLarge |
| 16 | case redirectRefused |
| 17 | case invalidResponse |
| 18 | /// Host fora do hosts.lock, ou esquema diferente de https. |
| 19 | case hostNotAllowed |
| 20 | case decoding(String) |
| 21 | } |
| 22 | |
| 23 | public static let shared = HTTPClient() |
| 24 | |
| 25 | static let maxResponseBytes = 4 * 1024 * 1024 |
| 26 | static let userAgent = "EscaliburWallet" |
| 27 | |
| 28 | /// So para os testes: um `URLProtocol` no lugar da rede. |
| 29 | private let protocolClasses: [AnyClass]? |
| 30 | /// Os unicos hosts com que o cliente fala: gerados do hosts.lock |
| 31 | /// (`AllowedHosts.swift`, conferido pelo verificar.sh). Um endereco montado em tempo |
| 32 | /// de execucao, ou vindo de uma resposta, nao sai do aparelho. |
| 33 | private let allowedHosts: Set<String> |
| 34 | |
| 35 | public override convenience init() { self.init(protocolClasses: nil, allowedHosts: AllowedHosts.all) } |
| 36 | |
| 37 | init(protocolClasses: [AnyClass]?, allowedHosts: Set<String> = AllowedHosts.all) { |
| 38 | self.protocolClasses = protocolClasses |
| 39 | self.allowedHosts = allowedHosts |
| 40 | super.init() |
| 41 | } |
| 42 | |
| 43 | /// Criada uma vez so, sob a trava: `lazy var` nao e seguro entre threads, e duas |
| 44 | /// sessoes repetiriam `taskIdentifier`, trocando a leitura de uma pela de outra. |
| 45 | private var session: URLSession { |
| 46 | lock.withLock { |
| 47 | if let existing = sessionStorage { return existing } |
| 48 | let made = makeSession() |
| 49 | sessionStorage = made |
| 50 | return made |
| 51 | } |
| 52 | } |
| 53 | private var sessionStorage: URLSession? |
| 54 | |
| 55 | private func makeSession() -> URLSession { |
| 56 | let configuration = URLSessionConfiguration.ephemeral |
| 57 | if let protocolClasses { configuration.protocolClasses = protocolClasses } |
| 58 | configuration.urlCache = nil |
| 59 | configuration.httpCookieStorage = nil |
| 60 | configuration.urlCredentialStorage = nil |
| 61 | configuration.httpShouldSetCookies = false |
| 62 | configuration.requestCachePolicy = .reloadIgnoringLocalCacheData |
| 63 | configuration.waitsForConnectivity = false |
| 64 | configuration.timeoutIntervalForRequest = 10 |
| 65 | configuration.timeoutIntervalForResource = 30 |
| 66 | configuration.tlsMinimumSupportedProtocolVersion = .TLSv12 |
| 67 | configuration.httpAdditionalHeaders = ["User-Agent": Self.userAgent, "Accept-Language": "en"] |
| 68 | return URLSession(configuration: configuration, delegate: self, delegateQueue: nil) |
| 69 | } |
| 70 | |
| 71 | // MARK: Requisicoes |
| 72 | |
| 73 | public func get(_ url: URL, headers: [String: String] = [:], timeout: TimeInterval = 10) async throws -> Data { |
| 74 | var request = URLRequest(url: url, timeoutInterval: timeout) |
| 75 | request.httpMethod = "GET" |
| 76 | headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } |
| 77 | return try await perform(request) |
| 78 | } |
| 79 | |
| 80 | public func post(_ url: URL, json body: Data, headers: [String: String] = [:], timeout: TimeInterval = 10) async throws -> Data { |
| 81 | var request = URLRequest(url: url, timeoutInterval: timeout) |
| 82 | request.httpMethod = "POST" |
| 83 | request.httpBody = body |
| 84 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 85 | headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } |
| 86 | return try await perform(request) |
| 87 | } |
| 88 | |
| 89 | /// PUT e DELETE com corpo JSON, pelas mesmas regras de `get`/`post`. A CoW so aceita |
| 90 | /// cancelamento em `DELETE /api/v1/orders` e registro de appData em `PUT`. |
| 91 | public func send(_ method: String, _ url: URL, json body: Data?, headers: [String: String] = [:], timeout: TimeInterval = 10) async throws -> Data { |
| 92 | guard ["PUT", "DELETE"].contains(method) else { throw Failure.invalidResponse } |
| 93 | var request = URLRequest(url: url, timeoutInterval: timeout) |
| 94 | request.httpMethod = method |
| 95 | if let body { |
| 96 | request.httpBody = body |
| 97 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 98 | } |
| 99 | headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } |
| 100 | return try await perform(request) |
| 101 | } |
| 102 | |
| 103 | public func getJSON<T: Decodable>(_ type: T.Type, from url: URL, headers: [String: String] = [:]) async throws -> T { |
| 104 | let data = try await get(url, headers: headers) |
| 105 | do { |
| 106 | return try JSONDecoder().decode(T.self, from: data) |
| 107 | } catch { |
| 108 | throw Failure.decoding(String(describing: type)) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /// Hosts que servem muita coisa alem do que o app pede: so este caminho (auditoria 6). O |
| 113 | /// GitHub serve qualquer repositorio por `raw.githubusercontent.com`, e o app so le as |
| 114 | /// logos da Trust Wallet. O site da Escalibur (escalibur-wallet.github.io) e da org da |
| 115 | /// Escalibur inteiro e nao precisa de caminho. |
| 116 | static let pathPrefixes: [String: String] = [ |
| 117 | "raw.githubusercontent.com": "/trustwallet/assets/", |
| 118 | ] |
| 119 | |
| 120 | /// Porta alem da 443, por host. Os servidores publicos da Ripple atendem JSON-RPC na |
| 121 | /// 51234, a porta da documentacao deles: a trava da auditoria 6 sem esta excecao deixou |
| 122 | /// a XRP Ledger com um servidor so, e a leitura por dois parou (build 9, 19/10/2026). |
| 123 | static let extraPorts: [String: Int] = [ |
| 124 | "s1.ripple.com": 51234, |
| 125 | "s2.ripple.com": 51234, |
| 126 | ] |
| 127 | |
| 128 | /// A regra de todo endereco: https, host da lista, a porta 443 (ou a do host, acima) e, |
| 129 | /// quando o host tem, so o caminho dele. |
| 130 | static func permits(_ url: URL, allowedHosts: Set<String>) -> Bool { |
| 131 | guard url.scheme == "https", let host = url.host, allowedHosts.contains(host) else { return false } |
| 132 | guard url.port == nil || url.port == 443 || url.port == extraPorts[host] else { return false } |
| 133 | return pathPrefixes[host].map { url.path.hasPrefix($0) } ?? true |
| 134 | } |
| 135 | |
| 136 | /// O corpo e lido em fluxo e cortado ao passar do limite. Resposta sem |
| 137 | /// Content-Length (chunked) nao chega inteira na memoria antes de ser medida. |
| 138 | /// |
| 139 | /// Uma tarefa de dados com o delegado da sessao, e nao `bytes(for:)`: em algumas |
| 140 | /// versoes do macOS (a do runner do CI) o fluxo de `bytes(for:)` parava de entregar |
| 141 | /// o corpo, e o delegado classico mede cada pedaco igual em todas. |
| 142 | private func perform(_ request: URLRequest, limit: Int = HTTPClient.maxResponseBytes) async throws -> Data { |
| 143 | guard let url = request.url, Self.permits(url, allowedHosts: allowedHosts) else { throw Failure.hostNotAllowed } |
| 144 | guard !isQuiet else { throw Failure.offline } |
| 145 | let task = session.dataTask(with: request) |
| 146 | do { |
| 147 | return try await withTaskCancellationHandler { |
| 148 | try await withCheckedThrowingContinuation { continuation in |
| 149 | // A conferencia do silencio e o registro sob a mesma trava: uma tela de |
| 150 | // segredo que abre agora cancela esta tarefa, ou ela nem comeca. |
| 151 | let paused = lock.withLock { () -> Bool in |
| 152 | guard quietHolds == 0 else { return true } |
| 153 | transfers[ObjectIdentifier(task)] = Transfer(continuation, task: task, limit: limit) |
| 154 | return false |
| 155 | } |
| 156 | if paused { |
| 157 | continuation.resume(throwing: Failure.offline) |
| 158 | return |
| 159 | } |
| 160 | task.resume() |
| 161 | } |
| 162 | } onCancel: { |
| 163 | task.cancel() |
| 164 | } |
| 165 | } catch { |
| 166 | throw error |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | /// Uma leitura em andamento: o corpo que ja chegou, a recusa (se houve) e quem espera. |
| 171 | private final class Transfer { |
| 172 | var data = Data() |
| 173 | var failure: Failure? |
| 174 | let continuation: CheckedContinuation<Data, Error> |
| 175 | let task: URLSessionTask |
| 176 | let limit: Int |
| 177 | |
| 178 | init(_ continuation: CheckedContinuation<Data, Error>, task: URLSessionTask, limit: Int) { |
| 179 | self.continuation = continuation |
| 180 | self.task = task |
| 181 | self.limit = limit |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | // MARK: Silencio |
| 186 | |
| 187 | /// Quantas telas de segredo estao abertas agora (`beginQuiet`). Com alguma aberta, |
| 188 | /// nenhuma requisicao sai do aparelho, e as que estavam no ar sao canceladas: a tela |
| 189 | /// de digitar as palavras nao divide o momento com conexao nenhuma (pedido do dono, |
| 190 | /// 16/10/2026). As palavras nunca vao para a rede de qualquer jeito; isto garante que |
| 191 | /// nem saldo, nem preco, nem logo saem enquanto elas estao na tela. |
| 192 | private var quietHolds = 0 |
| 193 | |
| 194 | public var isQuiet: Bool { lock.withLock { quietHolds > 0 } } |
| 195 | |
| 196 | /// Abre o silencio e cancela o que estava no ar. Cada `beginQuiet` pede um `endQuiet`. |
| 197 | public func beginQuiet() { |
| 198 | let live = lock.withLock { () -> [URLSessionTask] in |
| 199 | quietHolds += 1 |
| 200 | return transfers.values.map(\.task) |
| 201 | } |
| 202 | live.forEach { $0.cancel() } |
| 203 | } |
| 204 | |
| 205 | public func endQuiet() { |
| 206 | lock.withLock { quietHolds = max(0, quietHolds - 1) } |
| 207 | } |
| 208 | |
| 209 | private let lock = NSLock() |
| 210 | /// Pela identidade da tarefa, viva ate `didCompleteWithError` tirar a entrada daqui. |
| 211 | private var transfers: [ObjectIdentifier: Transfer] = [:] |
| 212 | |
| 213 | private func transfer(_ task: URLSessionTask) -> Transfer? { |
| 214 | lock.withLock { transfers[ObjectIdentifier(task)] } |
| 215 | } |
| 216 | |
| 217 | static func failure(for error: Error) -> Failure { |
| 218 | switch (error as? URLError)?.code { |
| 219 | case .notConnectedToInternet, .networkConnectionLost, .dataNotAllowed: return .offline |
| 220 | case .timedOut: return .timeout |
| 221 | default: return .invalidResponse |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | extension HTTPClient: URLSessionDataDelegate { |
| 227 | /// Redirecionamento nunca e seguido: um provedor comprometido nao manda o app |
| 228 | /// buscar dado em outro host. |
| 229 | public func urlSession( |
| 230 | _ session: URLSession, task: URLSessionTask, |
| 231 | willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, |
| 232 | completionHandler: @escaping @Sendable (URLRequest?) -> Void |
| 233 | ) { |
| 234 | completionHandler(nil) |
| 235 | } |
| 236 | |
| 237 | /// Status e tamanho anunciado sao conferidos antes de qualquer byte do corpo. |
| 238 | public func urlSession( |
| 239 | _ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, |
| 240 | completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void |
| 241 | ) { |
| 242 | guard let transfer = transfer(dataTask) else { return completionHandler(.cancel) } |
| 243 | let refusal: Failure? |
| 244 | if let http = response as? HTTPURLResponse { |
| 245 | if (300..<400).contains(http.statusCode) { refusal = .redirectRefused } |
| 246 | else if !(200..<300).contains(http.statusCode) { refusal = .status(http.statusCode) } |
| 247 | else if http.expectedContentLength > Int64(transfer.limit) { refusal = .tooLarge } |
| 248 | else { refusal = nil } |
| 249 | } else { |
| 250 | refusal = .invalidResponse |
| 251 | } |
| 252 | lock.withLock { |
| 253 | transfer.failure = refusal |
| 254 | if refusal == nil, response.expectedContentLength > 0 { |
| 255 | transfer.data.reserveCapacity(Int(response.expectedContentLength)) |
| 256 | } |
| 257 | } |
| 258 | completionHandler(refusal == nil ? .allow : .cancel) |
| 259 | } |
| 260 | |
| 261 | public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { |
| 262 | guard let transfer = transfer(dataTask) else { return } |
| 263 | let over = lock.withLock { () -> Bool in |
| 264 | guard transfer.failure == nil else { return false } |
| 265 | transfer.data.append(data) |
| 266 | guard transfer.data.count > transfer.limit else { return false } |
| 267 | transfer.failure = .tooLarge |
| 268 | transfer.data = Data() |
| 269 | return true |
| 270 | } |
| 271 | if over { dataTask.cancel() } |
| 272 | } |
| 273 | |
| 274 | public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { |
| 275 | guard let transfer = lock.withLock({ transfers.removeValue(forKey: ObjectIdentifier(task)) }) else { return } |
| 276 | #if DEBUG |
| 277 | NetworkDiagnostics.shared.record(host: task.originalRequest?.url?.host ?? "?", failure: transfer.failure, error: error) |
| 278 | #endif |
| 279 | if let failure = transfer.failure { |
| 280 | transfer.continuation.resume(throwing: failure) |
| 281 | } else if let error { |
| 282 | transfer.continuation.resume(throwing: Self.failure(for: error)) |
| 283 | } else { |
| 284 | transfer.continuation.resume(returning: transfer.data) |
| 285 | } |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | /// Um conjunto de provedores equivalentes para o mesmo servico, com contingencia. |
| 290 | /// |
| 291 | /// Circuit breaker simples: tres falhas seguidas tiram o provedor por 60 segundos. |
| 292 | /// A ordem de preferencia e fixa no codigo; nenhuma resposta de rede acrescenta |
| 293 | /// provedor. |
| 294 | public actor ProviderPool { |
| 295 | public struct Provider: Sendable, Hashable { |
| 296 | public let name: String |
| 297 | public let baseURL: URL |
| 298 | |
| 299 | public init(name: String, baseURL: URL) { |
| 300 | self.name = name |
| 301 | self.baseURL = baseURL |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | public let providers: [Provider] |
| 306 | private var failures: [String: Int] = [:] |
| 307 | private var benchedUntil: [String: Date] = [:] |
| 308 | |
| 309 | public init(_ providers: [Provider]) { |
| 310 | self.providers = providers |
| 311 | } |
| 312 | |
| 313 | /// Os provedores disponiveis agora, na ordem de preferencia. |
| 314 | public func available(now: Date = .now) -> [Provider] { |
| 315 | let live = providers.filter { (benchedUntil[$0.name] ?? .distantPast) <= now } |
| 316 | return live.isEmpty ? providers : live |
| 317 | } |
| 318 | |
| 319 | public func reportSuccess(_ provider: Provider) { |
| 320 | failures[provider.name] = 0 |
| 321 | } |
| 322 | |
| 323 | public func reportFailure(_ provider: Provider, now: Date = .now) { |
| 324 | let count = (failures[provider.name] ?? 0) + 1 |
| 325 | failures[provider.name] = count |
| 326 | if count >= 3 { |
| 327 | benchedUntil[provider.name] = now.addingTimeInterval(60) |
| 328 | failures[provider.name] = 0 |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | /// A falha com o motivo. Recusa por limite vai direto para o banco, por mais tempo: |
| 333 | /// insistir num provedor que ja disse "chega" so prolonga o bloqueio do IP (a |
| 334 | /// Blockchair passa do 429 ao 430, "IP temporary blacklisted"). O resto segue a regra |
| 335 | /// das tres falhas seguidas. |
| 336 | public func reportFailure(_ provider: Provider, error: Error, now: Date = .now) { |
| 337 | guard let pause = Self.benchTime(for: error) else { return reportFailure(provider, now: now) } |
| 338 | let until = now.addingTimeInterval(pause) |
| 339 | benchedUntil[provider.name] = max(benchedUntil[provider.name] ?? until, until) |
| 340 | failures[provider.name] = 0 |
| 341 | } |
| 342 | |
| 343 | /// Quanto tempo cada recusa deixa o provedor de fora. 429 e o limite por IP; 430 |
| 344 | /// (Blockchair) e 403 (Cloudflare) sao bloqueio do IP, mais demorado; um provedor |
| 345 | /// fora do ar custa o timeout inteiro a cada pergunta. |
| 346 | public static func benchTime(for error: Error) -> TimeInterval? { |
| 347 | switch error as? HTTPClient.Failure { |
| 348 | case .status(429): return 5 * 60 |
| 349 | case .status(430), .status(403): return 15 * 60 |
| 350 | case .timeout: return 2 * 60 |
| 351 | default: return nil |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | /// So para os testes e para a tela: ate quando o provedor esta no banco. |
| 356 | public func benched(_ provider: Provider) -> Date? { benchedUntil[provider.name] } |
| 357 | |
| 358 | /// Tenta cada provedor em ordem ate um responder. |
| 359 | public func first<T: Sendable>(_ operation: @Sendable (Provider) async throws -> T) async throws -> T { |
| 360 | var lastError: Error = HTTPClient.Failure.offline |
| 361 | for provider in available() { |
| 362 | do { |
| 363 | let value = try await operation(provider) |
| 364 | reportSuccess(provider) |
| 365 | return value |
| 366 | } catch { |
| 367 | reportFailure(provider) |
| 368 | lastError = error |
| 369 | } |
| 370 | } |
| 371 | throw lastError |
| 372 | } |
| 373 | |
| 374 | /// Pergunta a provedores distintos e exige que dois concordem. Nonce, sequence e |
| 375 | /// saldo que vao para uma transacao passam por aqui (docs/seguranca.md §5.5). |
| 376 | /// |
| 377 | /// Nunca aceita uma resposta so: com menos de dois provedores respondendo, falha. |
| 378 | /// Os que estao no banco do circuit breaker entram no fim da fila, porque um |
| 379 | /// consenso que so tem um provedor vivo nao e consenso. |
| 380 | public func agreeing<T: Sendable & Equatable>(_ operation: @Sendable (Provider) async throws -> T) async throws -> T { |
| 381 | let live = available() |
| 382 | let candidates = live + providers.filter { !live.contains($0) } |
| 383 | var answers: [T] = [] |
| 384 | for provider in candidates { |
| 385 | do { |
| 386 | let value = try await operation(provider) |
| 387 | reportSuccess(provider) |
| 388 | if answers.contains(value) { return value } |
| 389 | answers.append(value) |
| 390 | } catch { |
| 391 | reportFailure(provider) |
| 392 | } |
| 393 | } |
| 394 | throw ConsensusFailure(answers: answers.count) |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | public struct ConsensusFailure: Error, Sendable { |
| 399 | public let answers: Int |
| 400 | } |
| 401 | |
| 402 | /// JSON-RPC 2.0 sobre HTTPS. |
| 403 | public struct JSONRPC: Sendable { |
| 404 | public struct RPCError: Error, Sendable, Decodable { |
| 405 | public let code: Int |
| 406 | public let message: String |
| 407 | } |
| 408 | |
| 409 | private struct Envelope<T: Decodable>: Decodable { |
| 410 | let result: T? |
| 411 | let error: RPCError? |
| 412 | } |
| 413 | |
| 414 | public static func call<T: Decodable>(_ url: URL, method: String, params: [JSONValue], as type: T.Type, client: HTTPClient = .shared) async throws -> T { |
| 415 | let body: [String: JSONValue] = [ |
| 416 | "jsonrpc": .string("2.0"), "id": .number(1), "method": .string(method), "params": .array(params), |
| 417 | ] |
| 418 | let data = try await client.post(url, json: try JSONEncoder().encode(body)) |
| 419 | let envelope: Envelope<T> |
| 420 | do { envelope = try JSONDecoder().decode(Envelope<T>.self, from: data) } catch { throw HTTPClient.Failure.decoding(method) } |
| 421 | if let error = envelope.error { throw error } |
| 422 | guard let result = envelope.result else { throw HTTPClient.Failure.invalidResponse } |
| 423 | return result |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | /// Um valor JSON qualquer, para montar parametros sem `[String: Any]`. |
| 428 | public enum JSONValue: Codable, Sendable, Equatable { |
| 429 | case string(String) |
| 430 | case number(Double) |
| 431 | case bool(Bool) |
| 432 | case array([JSONValue]) |
| 433 | case object([String: JSONValue]) |
| 434 | case null |
| 435 | |
| 436 | public func encode(to encoder: Encoder) throws { |
| 437 | var container = encoder.singleValueContainer() |
| 438 | switch self { |
| 439 | case .string(let value): try container.encode(value) |
| 440 | case .number(let value): |
| 441 | if value == value.rounded(), abs(value) < 1e15 { try container.encode(Int64(value)) } else { try container.encode(value) } |
| 442 | case .bool(let value): try container.encode(value) |
| 443 | case .array(let value): try container.encode(value) |
| 444 | case .object(let value): try container.encode(value) |
| 445 | case .null: try container.encodeNil() |
| 446 | } |
| 447 | } |
| 448 | |
| 449 | public init(from decoder: Decoder) throws { |
| 450 | let container = try decoder.singleValueContainer() |
| 451 | if container.decodeNil() { self = .null } |
| 452 | else if let value = try? container.decode(Bool.self) { self = .bool(value) } |
| 453 | else if let value = try? container.decode(Double.self) { self = .number(value) } |
| 454 | else if let value = try? container.decode(String.self) { self = .string(value) } |
| 455 | else if let value = try? container.decode([JSONValue].self) { self = .array(value) } |
| 456 | else { self = .object(try container.decode([String: JSONValue].self)) } |
| 457 | } |
| 458 | |
| 459 | public var stringValue: String? { if case .string(let v) = self { return v }; return nil } |
| 460 | public var doubleValue: Double? { if case .number(let v) = self { return v }; return nil } |
| 461 | public var boolValue: Bool? { if case .bool(let v) = self { return v }; return nil } |
| 462 | public subscript(key: String) -> JSONValue? { if case .object(let o) = self { return o[key] }; return nil } |
| 463 | public subscript(index: Int) -> JSONValue? { |
| 464 | if case .array(let a) = self, a.indices.contains(index) { return a[index] } |
| 465 | return nil |
| 466 | } |
| 467 | public var arrayValue: [JSONValue]? { if case .array(let a) = self { return a }; return nil } |
| 468 | } |
| 469 | |
| 470 | #if DEBUG |
| 471 | /// So no build de teste: as ultimas falhas de rede (host e motivo), para a tela dizer |
| 472 | /// por que uma leitura falhou no iPhone de quem testa. Fica so na memoria, sem endereco |
| 473 | /// nem corpo de resposta, e o build de distribuicao nem compila isto. |
| 474 | public final class NetworkDiagnostics: @unchecked Sendable { |
| 475 | public static let shared = NetworkDiagnostics() |
| 476 | |
| 477 | private let lock = NSLock() |
| 478 | private var entries: [(host: String, reason: String, at: Date)] = [] |
| 479 | |
| 480 | /// A recusa do app (status, tamanho, redirecionamento) ou o codigo do URLError. Pedido |
| 481 | /// cancelado (a tela saiu, a leitura foi trocada) nao e falha de rede e nao entra. |
| 482 | func record(host: String, failure: HTTPClient.Failure?, error: Error?) { |
| 483 | let reason: String |
| 484 | if let failure { |
| 485 | reason = String(describing: failure) |
| 486 | } else if let error = error as? URLError { |
| 487 | guard error.code != .cancelled else { return } |
| 488 | reason = "URLError \(error.code.rawValue)" |
| 489 | } else if let error { |
| 490 | reason = String(describing: type(of: error)) |
| 491 | } else { |
| 492 | return |
| 493 | } |
| 494 | lock.withLock { |
| 495 | entries.append((host, reason, .now)) |
| 496 | if entries.count > 80 { entries.removeFirst(entries.count - 80) } |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | /// "host: motivo (n)" de cada falha dos ultimos `seconds` segundos. |
| 501 | public func recent(seconds: TimeInterval = 180) -> [String] { |
| 502 | let since = Date.now.addingTimeInterval(-seconds) |
| 503 | let recent = lock.withLock { entries.filter { $0.at >= since } } |
| 504 | var counts: [String: Int] = [:] |
| 505 | var order: [String] = [] |
| 506 | for entry in recent { |
| 507 | let key = "\(entry.host): \(entry.reason)" |
| 508 | if counts[key] == nil { order.append(key) } |
| 509 | counts[key, default: 0] += 1 |
| 510 | } |
| 511 | return order.map { "\($0) (\(counts[$0] ?? 0))" } |
| 512 | } |
| 513 | } |
| 514 | #endif |