@pushmesh/sdk 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -0
  3. package/android/build.gradle +82 -0
  4. package/android/src/main/AndroidManifest.xml +69 -0
  5. package/android/src/main/java/io/pushmesh/sdk/PushMeshClickActivity.kt +124 -0
  6. package/android/src/main/java/io/pushmesh/sdk/PushMeshFirebaseModule.kt +512 -0
  7. package/android/src/main/java/io/pushmesh/sdk/PushMeshFirebasePackage.kt +20 -0
  8. package/android/src/main/java/io/pushmesh/sdk/PushMeshMessageBuffer.kt +53 -0
  9. package/android/src/main/java/io/pushmesh/sdk/PushMeshMessageRouter.kt +477 -0
  10. package/android/src/main/java/io/pushmesh/sdk/PushMeshMessagingReceiver.kt +77 -0
  11. package/android/src/main/java/io/pushmesh/sdk/PushMeshMessagingService.kt +88 -0
  12. package/dist/cjs/boot.d.ts +7 -0
  13. package/dist/cjs/boot.js +1 -0
  14. package/dist/cjs/compat.d.ts +63 -0
  15. package/dist/cjs/compat.js +1 -0
  16. package/dist/cjs/doctor.d.ts +27 -0
  17. package/dist/cjs/doctor.js +2 -0
  18. package/dist/cjs/events.d.ts +21 -0
  19. package/dist/cjs/events.js +1 -0
  20. package/dist/cjs/http.d.ts +20 -0
  21. package/dist/cjs/http.js +1 -0
  22. package/dist/cjs/inapp/SkeletonShimmer.d.ts +1 -0
  23. package/dist/cjs/inapp/SkeletonShimmer.js +1 -0
  24. package/dist/cjs/inapp/auto.d.ts +1 -0
  25. package/dist/cjs/inapp/auto.js +1 -0
  26. package/dist/cjs/inapp/components.d.ts +14 -0
  27. package/dist/cjs/inapp/components.js +1 -0
  28. package/dist/cjs/inapp/engine.d.ts +1 -0
  29. package/dist/cjs/inapp/engine.js +1 -0
  30. package/dist/cjs/inapp/imageCache.d.ts +1 -0
  31. package/dist/cjs/inapp/imageCache.js +1 -0
  32. package/dist/cjs/inapp/index.d.ts +20 -0
  33. package/dist/cjs/inapp/index.js +1 -0
  34. package/dist/cjs/inapp/lifecycle.d.ts +1 -0
  35. package/dist/cjs/inapp/lifecycle.js +1 -0
  36. package/dist/cjs/inapp/normalize.d.ts +1 -0
  37. package/dist/cjs/inapp/normalize.js +1 -0
  38. package/dist/cjs/inapp/registro.d.ts +1 -0
  39. package/dist/cjs/inapp/registro.js +1 -0
  40. package/dist/cjs/inapp/sessao.d.ts +1 -0
  41. package/dist/cjs/inapp/sessao.js +1 -0
  42. package/dist/cjs/index.d.ts +118 -0
  43. package/dist/cjs/index.js +1 -0
  44. package/dist/cjs/perfil.d.ts +52 -0
  45. package/dist/cjs/perfil.js +1 -0
  46. package/dist/cjs/permissions.d.ts +54 -0
  47. package/dist/cjs/permissions.js +1 -0
  48. package/dist/cjs/players.d.ts +20 -0
  49. package/dist/cjs/players.js +1 -0
  50. package/dist/cjs/queue.d.ts +1 -0
  51. package/dist/cjs/queue.js +1 -0
  52. package/dist/cjs/receipts.d.ts +32 -0
  53. package/dist/cjs/receipts.js +1 -0
  54. package/dist/cjs/state.d.ts +21 -0
  55. package/dist/cjs/state.js +1 -0
  56. package/dist/cjs/storage.d.ts +1 -0
  57. package/dist/cjs/storage.js +1 -0
  58. package/dist/cjs/tags.d.ts +13 -0
  59. package/dist/cjs/tags.js +1 -0
  60. package/dist/cjs/triggers.d.ts +22 -0
  61. package/dist/cjs/triggers.js +1 -0
  62. package/dist/cjs/types.d.ts +258 -0
  63. package/dist/cjs/types.js +1 -0
  64. package/dist/cjs/zeroconfig.d.ts +45 -0
  65. package/dist/cjs/zeroconfig.js +1 -0
  66. package/ios/NotificationService/LEIA-ME.md +105 -0
  67. package/ios/NotificationService/PushMeshNotificationService.swift +384 -0
  68. package/ios/PushMeshApns.swift +746 -0
  69. package/ios/PushMeshApnsModule.m +76 -0
  70. package/ios/PushMeshSwizzleRegistro.swift +141 -0
  71. package/package.json +113 -0
  72. package/pushmesh-sdk.podspec +32 -0
  73. package/react-native.config.js +17 -0
@@ -0,0 +1,384 @@
1
+ import Foundation
2
+ import UserNotifications
3
+
4
+ /**
5
+ * Notification Service Extension do PUSHMESH — a peça que faz a IMAGEM
6
+ * aparecer no push do iOS.
7
+ *
8
+ * POR QUE ESTE ARQUIVO EXISTE (e por que ele não pode morar dentro do app):
9
+ * o servidor já manda tudo que é preciso — `mutable-content: 1` no `aps` e a
10
+ * URL da imagem no payload. Só que no iOS QUEM baixa a imagem é o aparelho,
11
+ * num processo SEPARADO do app, e esse processo só existe se o projeto tiver
12
+ * um alvo (target) do tipo Notification Service Extension. Sem esse alvo o
13
+ * iOS ignora o `mutable-content` em silêncio e a notificação chega sem
14
+ * imagem — exatamente o sintoma "no Android a imagem aparece, no iOS não".
15
+ * É exigência da Apple, não limitação do SDK: nenhum código dentro do app
16
+ * consegue substituir a extensão, porque em background/app morto o app nem
17
+ * roda. Passo a passo de instalação: LEIA-ME.md, nesta mesma pasta.
18
+ *
19
+ * O QUE ESTA EXTENSÃO FAZ (só isto — de propósito):
20
+ * 1. lê a URL da imagem no payload (chaves do servidor, ver `PushMeshAnexo`);
21
+ * 2. baixa com prazo curto e teto de tamanho;
22
+ * 3. grava num arquivo temporário com a extensão CERTA (.jpg/.png/.gif);
23
+ * 4. anexa e entrega.
24
+ *
25
+ * A LEI DESTE ARQUIVO: a notificação NUNCA some. Imagem é enfeite; a mensagem
26
+ * é a promessa. Falha de rede, 404, formato que a Apple não aceita, arquivo
27
+ * gigante, tempo esgotado — todo caminho de erro entrega o conteúdo ORIGINAL.
28
+ * O `contentHandler` é chamado EXATAMENTE UMA VEZ, sempre (chamar duas vezes
29
+ * é comportamento indefinido; não chamar faz o iOS entregar uma notificação
30
+ * degradada depois de ~30s).
31
+ *
32
+ * O QUE ESTA EXTENSÃO **NÃO** FAZ (não é esquecimento, é escopo):
33
+ * recibo de entrega em background/app morto. Isso é outra frente — exigiria
34
+ * a extensão conhecer appId/baseUrl e falar com o servidor. Enquanto não
35
+ * chega, o recibo do iOS segue contando o que o app vê (foreground/toque),
36
+ * como está documentado no README do SDK.
37
+ *
38
+ * DEPENDÊNCIAS: nenhuma. Só Foundation + UserNotifications, ambos do SDK do
39
+ * sistema. Sem CocoaPods, sem App Group, sem Keychain compartilhado — por
40
+ * isso a instalação é "criar o alvo e apontar este arquivo", e nada mais.
41
+ */
42
+
43
+ // MARK: - Partes puras (é o que o cenário executável dos testes exercita)
44
+
45
+ /// Leitura do payload e decisão do formato — SEM rede e SEM UNNotification*,
46
+ /// para poder ser exercitado por um binário de teste de verdade
47
+ /// (`__tests__/ios/cenario_nse.swift`).
48
+ enum PushMeshAnexo {
49
+
50
+ // As chaves são as do servidor (src/domain/payload.rs). Mudou lá, muda aqui
51
+ // — e o teste `ios-nse.test.ts` lê o Rust e falha se os nomes divergirem.
52
+
53
+ /// `ios_attachments` da API: objeto `{nome: url}`. Vai no payload APNs nos
54
+ /// DOIS caminhos de envio (APNs direto e via FCM).
55
+ static let CHAVE_ANEXOS = "pm_attachments"
56
+
57
+ /// `big_picture` da API — a imagem única, a que o painel de fato manda.
58
+ /// No APNs direto ela chega na RAIZ do payload; no caminho FCM ela sai em
59
+ /// `message.data` e o FCM copia os dados para as chaves custom do APNs.
60
+ static let CHAVE_IMAGEM = "pm_big_picture"
61
+
62
+ /// Espelho da mesma URL no bloco de exibição do Android (`pmx_image`).
63
+ /// Só é usado se as duas de cima faltarem: custa uma linha e salva o caso
64
+ /// de um payload antigo/parcial chegar sem `pm_big_picture`.
65
+ static let CHAVE_IMAGEM_ESPELHO = "pmx_image"
66
+
67
+ /// Teto do arquivo baixado. A Apple recusa anexo de imagem acima de 10 MB —
68
+ /// baixar mais que isso é gastar a janela para levar erro na cara.
69
+ static let TETO_BYTES = 10 * 1024 * 1024
70
+
71
+ /// Prazo de UMA tentativa de download. A Apple dá ~30s para a extensão
72
+ /// inteira; 10s deixa folga para uma segunda URL e para gravar o anexo.
73
+ static let TIMEOUT_SEGUNDOS: TimeInterval = 10
74
+
75
+ /// Prazo de TODAS as tentativas somadas. Passou disto, entrega sem imagem —
76
+ /// esperar o tapa do sistema entregaria uma notificação pior.
77
+ static let JANELA_TOTAL_SEGUNDOS: TimeInterval = 20
78
+
79
+ /// URLs candidatas, na ordem de preferência e sem repetição.
80
+ ///
81
+ /// A ordem dos anexos é por NOME ORDENADO, não a ordem do JSON: o payload
82
+ /// vira dicionário (NSDictionary), que não tem ordem — sem ordenar, duas
83
+ /// entregas do mesmo push podiam escolher imagens diferentes.
84
+ static func urlsCandidatas(_ userInfo: [AnyHashable: Any]) -> [String] {
85
+ var achadas: [String] = []
86
+
87
+ func juntar(_ valor: Any?) {
88
+ guard let texto = normalizar(valor) else { return }
89
+ if !achadas.contains(texto) { achadas.append(texto) }
90
+ }
91
+
92
+ if let anexos = userInfo[CHAVE_ANEXOS] as? [String: Any] {
93
+ for nome in anexos.keys.sorted() { juntar(anexos[nome]) }
94
+ }
95
+ juntar(userInfo[CHAVE_IMAGEM])
96
+ juntar(userInfo[CHAVE_IMAGEM_ESPELHO])
97
+ return achadas
98
+ }
99
+
100
+ /// String não vazia e absoluta http(s) — o resto não é URL de mídia.
101
+ static func normalizar(_ valor: Any?) -> String? {
102
+ guard let texto = (valor as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
103
+ !texto.isEmpty else { return nil }
104
+ let minuscula = texto.lowercased()
105
+ guard minuscula.hasPrefix("https://") || minuscula.hasPrefix("http://") else { return nil }
106
+ return texto
107
+ }
108
+
109
+ /// Extensão de arquivo + UTI do anexo, ou `nil` se o formato não serve.
110
+ ///
111
+ /// POR QUE A EXTENSÃO IMPORTA: o `UNNotificationAttachment` decide o tipo
112
+ /// pelo NOME do arquivo. Arquivo sem extensão (ou com extensão errada) é
113
+ /// recusado e a imagem some — e o download da CDN vem, quase sempre, com
114
+ /// nome/URL sem extensão nenhuma.
115
+ ///
116
+ /// A ordem da decisão é do mais confiável para o menos:
117
+ /// 1. os bytes do arquivo (verdade absoluta, não mente);
118
+ /// 2. o `Content-Type` da resposta (CDN mal configurada manda
119
+ /// `application/octet-stream`);
120
+ /// 3. a extensão na URL (some em URL assinada/`?token=`).
121
+ ///
122
+ /// Só jpg/png/gif: são os formatos de imagem que a Apple aceita em anexo.
123
+ /// WEBP/HEIC/AVIF são recusados — por isso a mídia do painel deve ser
124
+ /// jpg/png/gif.
125
+ static func formatoSuportado(
126
+ bytes: [UInt8],
127
+ contentType: String?,
128
+ url: String?
129
+ ) -> (extensao: String, uti: String)? {
130
+ if bytes.count >= 3, bytes[0] == 0xFF, bytes[1] == 0xD8, bytes[2] == 0xFF {
131
+ return ("jpg", "public.jpeg")
132
+ }
133
+ if bytes.count >= 4, bytes[0] == 0x89, bytes[1] == 0x50, bytes[2] == 0x4E, bytes[3] == 0x47 {
134
+ return ("png", "public.png")
135
+ }
136
+ if bytes.count >= 4, bytes[0] == 0x47, bytes[1] == 0x49, bytes[2] == 0x46, bytes[3] == 0x38 {
137
+ return ("gif", "com.compuserve.gif")
138
+ }
139
+
140
+ if let tipo = contentType?
141
+ .split(separator: ";").first?
142
+ .trimmingCharacters(in: .whitespaces)
143
+ .lowercased() {
144
+ switch tipo {
145
+ case "image/jpeg", "image/jpg": return ("jpg", "public.jpeg")
146
+ case "image/png": return ("png", "public.png")
147
+ case "image/gif": return ("gif", "com.compuserve.gif")
148
+ default: break
149
+ }
150
+ }
151
+
152
+ if let sufixo = extensaoDaUrl(url) {
153
+ switch sufixo {
154
+ case "jpg", "jpeg": return ("jpg", "public.jpeg")
155
+ case "png": return ("png", "public.png")
156
+ case "gif": return ("gif", "com.compuserve.gif")
157
+ default: break
158
+ }
159
+ }
160
+ return nil
161
+ }
162
+
163
+ /// Extensão no caminho da URL, já sem `?query` e sem `#fragmento`.
164
+ static func extensaoDaUrl(_ url: String?) -> String? {
165
+ guard let url else { return nil }
166
+ let caminho = url.split(separator: "?").first.map(String.init) ?? url
167
+ let semAncora = caminho.split(separator: "#").first.map(String.init) ?? caminho
168
+ guard let ultimo = semAncora.split(separator: "/").last else { return nil }
169
+ let partes = ultimo.split(separator: ".")
170
+ guard partes.count >= 2, let sufixo = partes.last else { return nil }
171
+ return sufixo.lowercased()
172
+ }
173
+ }
174
+
175
+ // MARK: - A extensão
176
+
177
+ /// Classe principal do alvo. O nome é o mesmo que o Xcode dá ao criar o alvo
178
+ /// (`NotificationService`), então o `NSExtensionPrincipalClass` do Info.plist
179
+ /// gerado — `$(PRODUCT_MODULE_NAME).NotificationService` — já aponta para cá
180
+ /// e NÃO precisa ser editado. Trocar o nome desta classe sem trocar o
181
+ /// Info.plist faz a extensão não rodar EM SILÊNCIO (a imagem simplesmente
182
+ /// não aparece, sem erro nenhum): não troque.
183
+ final class NotificationService: UNNotificationServiceExtension {
184
+
185
+ private var entregar: ((UNNotificationContent) -> Void)?
186
+ private var conteudo: UNMutableNotificationContent?
187
+ private var original: UNNotificationContent?
188
+ private var tarefa: URLSessionDownloadTask?
189
+ private var candidatas: [String] = []
190
+ private var limite = Date.distantPast
191
+
192
+ /// Guarda da entrega única. O `serviceExtensionTimeWillExpire` chega numa
193
+ /// thread DIFERENTE da resposta do download: sem trava, os dois podiam
194
+ /// chamar o `contentHandler` no mesmo instante.
195
+ private let trava = NSLock()
196
+ private var jaEntregue = false
197
+
198
+ override func didReceive(
199
+ _ request: UNNotificationRequest,
200
+ withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
201
+ ) {
202
+ entregar = contentHandler
203
+ original = request.content
204
+ conteudo = request.content.mutableCopy() as? UNMutableNotificationContent
205
+ limite = Date().addingTimeInterval(PushMeshAnexo.JANELA_TOTAL_SEGUNDOS)
206
+ candidatas = PushMeshAnexo.urlsCandidatas(request.content.userInfo)
207
+
208
+ // Sem imagem no payload (a maioria dos pushes): entrega JÁ. A extensão
209
+ // roda em todo push com mutable-content — segurar aqui atrasaria tudo.
210
+ if candidatas.isEmpty { entregarUmaVezSo(); return }
211
+
212
+ guard conteudo != nil else {
213
+ // Nunca visto em campo; se acontecer, a mensagem vai intacta.
214
+ NSLog("[PushMesh NSE] conteúdo não é mutável — entregando sem imagem")
215
+ entregarUmaVezSo()
216
+ return
217
+ }
218
+ tentarProxima()
219
+ }
220
+
221
+ /// Fim da janela da Apple (~30s). Entregar aqui é OBRIGATÓRIO: sem isto o
222
+ /// sistema mostra a notificação sem passar pelo nosso conteúdo.
223
+ override func serviceExtensionTimeWillExpire() {
224
+ NSLog(
225
+ "[PushMesh NSE] a janela da extensão expirou antes de a imagem baixar — "
226
+ + "entregando a notificação SEM imagem (a mensagem nunca é sacrificada). "
227
+ + "Como corrigir: imagem menor (< 1 MB) ou CDN mais rápida.")
228
+ tarefa?.cancel()
229
+ entregarUmaVezSo()
230
+ }
231
+
232
+ // MARK: - Passos
233
+
234
+ private func tentarProxima() {
235
+ guard !candidatas.isEmpty else { entregarUmaVezSo(); return }
236
+ guard Date() < limite else {
237
+ NSLog("[PushMesh NSE] tempo de download esgotado — entregando sem imagem")
238
+ entregarUmaVezSo()
239
+ return
240
+ }
241
+ let texto = candidatas.removeFirst()
242
+ guard let url = URL(string: texto) else {
243
+ NSLog(
244
+ "[PushMesh NSE] URL de imagem inválida: %@. "
245
+ + "Como corrigir: mande a mídia como URL absoluta https:// e sem espaços.",
246
+ texto)
247
+ tentarProxima()
248
+ return
249
+ }
250
+ baixar(url) { [weak self] deuCerto in
251
+ guard let self else { return }
252
+ if deuCerto { self.entregarUmaVezSo() } else { self.tentarProxima() }
253
+ }
254
+ }
255
+
256
+ private func baixar(_ url: URL, _ pronto: @escaping (Bool) -> Void) {
257
+ // Sessão efêmera: a extensão vive segundos, não faz sentido deixar cache
258
+ // e cookies no contêiner dela.
259
+ let config = URLSessionConfiguration.ephemeral
260
+ config.timeoutIntervalForRequest = PushMeshAnexo.TIMEOUT_SEGUNDOS
261
+ config.timeoutIntervalForResource = PushMeshAnexo.TIMEOUT_SEGUNDOS
262
+ config.waitsForConnectivity = false
263
+ let sessao = URLSession(configuration: config)
264
+
265
+ // downloadTask (e não dataTask): o corpo vai direto para disco — imagem
266
+ // grande não passa pela memória de um processo que o iOS mata sem dó.
267
+ let tarefa = sessao.downloadTask(with: url) { [weak self] temporario, resposta, erro in
268
+ defer { sessao.finishTasksAndInvalidate() }
269
+ guard let self else { pronto(false); return }
270
+ pronto(self.anexar(temporario: temporario, resposta: resposta, erro: erro, url: url))
271
+ }
272
+ self.tarefa = tarefa
273
+ tarefa.resume()
274
+ }
275
+
276
+ /// Valida o que veio e anexa. Devolve `false` para qualquer motivo de
277
+ /// desistência — e todo `false` é logado com causa + como corrigir, porque
278
+ /// o sintoma no aparelho é sempre o mesmo (imagem some) e sem log o
279
+ /// operador não tem como saber se a culpa é da URL, do formato ou do peso.
280
+ private func anexar(
281
+ temporario: URL?,
282
+ resposta: URLResponse?,
283
+ erro: Error?,
284
+ url: URL
285
+ ) -> Bool {
286
+ if let erro {
287
+ let dica =
288
+ url.scheme?.lowercased() == "http"
289
+ ? " A URL é http:// — o iOS bloqueia por App Transport Security. "
290
+ + "Como corrigir: publique a mídia em https://."
291
+ : " Como corrigir: confira se a URL abre no navegador e se a CDN responde rápido."
292
+ NSLog("[PushMesh NSE] download da imagem falhou: %@.%@", erro.localizedDescription, dica)
293
+ return false
294
+ }
295
+ guard let temporario else { return false }
296
+
297
+ if let http = resposta as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
298
+ NSLog(
299
+ "[PushMesh NSE] a CDN respondeu %ld para a imagem. "
300
+ + "Como corrigir: verifique se a URL é pública (sem login/token expirado).",
301
+ http.statusCode)
302
+ return false
303
+ }
304
+
305
+ let atributos = try? FileManager.default.attributesOfItem(atPath: temporario.path)
306
+ let bytes = (atributos?[.size] as? NSNumber)?.intValue ?? 0
307
+ guard bytes > 0 else {
308
+ NSLog("[PushMesh NSE] a imagem baixada veio vazia — entregando sem imagem")
309
+ return false
310
+ }
311
+ guard bytes <= PushMeshAnexo.TETO_BYTES else {
312
+ NSLog(
313
+ "[PushMesh NSE] imagem de %ld bytes acima do teto de %ld. "
314
+ + "Como corrigir: a Apple recusa anexo de imagem acima de 10 MB — "
315
+ + "publique uma versão comprimida.",
316
+ bytes, PushMeshAnexo.TETO_BYTES)
317
+ return false
318
+ }
319
+
320
+ let contentType = (resposta as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Type")
321
+ guard
322
+ let formato = PushMeshAnexo.formatoSuportado(
323
+ bytes: primeirosBytes(temporario, 12),
324
+ contentType: contentType,
325
+ url: url.absoluteString)
326
+ else {
327
+ NSLog(
328
+ "[PushMesh NSE] formato de imagem não reconhecido (Content-Type: %@). "
329
+ + "Como corrigir: o iOS só aceita jpg, png e gif em anexo de notificação — "
330
+ + "webp/heic/avif não entram.",
331
+ contentType ?? "ausente")
332
+ return false
333
+ }
334
+
335
+ // O arquivo do downloadTask é apagado quando este bloco retorna: mover
336
+ // para uma pasta nossa (com a extensão certa no nome) é obrigatório.
337
+ let pasta = FileManager.default.temporaryDirectory
338
+ .appendingPathComponent("pushmesh-\(UUID().uuidString)", isDirectory: true)
339
+ do {
340
+ try FileManager.default.createDirectory(at: pasta, withIntermediateDirectories: true)
341
+ let destino = pasta.appendingPathComponent("imagem.\(formato.extensao)")
342
+ try FileManager.default.moveItem(at: temporario, to: destino)
343
+ // identifier vazio = o sistema gera um; o typeHint evita que o iOS
344
+ // tenha de adivinhar o tipo de novo.
345
+ let anexo = try UNNotificationAttachment(
346
+ identifier: "",
347
+ url: destino,
348
+ options: [UNNotificationAttachmentOptionsTypeHintKey: formato.uti])
349
+ conteudo?.attachments = [anexo]
350
+ return true
351
+ } catch {
352
+ // O anexo consome o arquivo quando dá certo; quando dá errado, a
353
+ // sujeira é nossa para limpar.
354
+ try? FileManager.default.removeItem(at: pasta)
355
+ NSLog("[PushMesh NSE] não deu para anexar a imagem: %@", error.localizedDescription)
356
+ return false
357
+ }
358
+ }
359
+
360
+ /// Cabeça do arquivo para farejar o formato — lê só alguns bytes, nunca a
361
+ /// imagem inteira.
362
+ private func primeirosBytes(_ arquivo: URL, _ quantos: Int) -> [UInt8] {
363
+ guard let fh = try? FileHandle(forReadingFrom: arquivo) else { return [] }
364
+ defer { try? fh.close() }
365
+ let dados = (try? fh.read(upToCount: quantos)) ?? Data()
366
+ return [UInt8](dados)
367
+ }
368
+
369
+ /// Entrega o melhor conteúdo que existir — uma vez só, venha de onde vier.
370
+ private func entregarUmaVezSo() {
371
+ trava.lock()
372
+ if jaEntregue {
373
+ trava.unlock()
374
+ return
375
+ }
376
+ jaEntregue = true
377
+ let mao = entregar
378
+ let carga = conteudo ?? original
379
+ entregar = nil
380
+ trava.unlock()
381
+
382
+ if let mao, let carga { mao(carga) }
383
+ }
384
+ }