@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,746 @@
1
+ import CryptoKit
2
+ import Foundation
3
+ import Network
4
+ import React
5
+ import UIKit
6
+ import UserNotifications
7
+
8
+ /**
9
+ * Módulo nativo iOS do modo ZERO-CONFIG: APNs DIRETO — sem Firebase e sem
10
+ * GoogleService-Info.plist no app do cliente (a chave .p8 fica no painel do
11
+ * servidor PUSHMESH).
12
+ *
13
+ * - getToken(): devolve o token APNs cru (64 hex). Se ainda não capturado,
14
+ * chama registerForRemoteNotifications() e resolve quando o delegate chegar
15
+ * (timeout de 10s com erro que ensina).
16
+ * - O token é capturado via METHOD SWIZZLING dos callbacks de APNs do
17
+ * AppDelegate. Por quê: exigir código no AppDelegate do host quebraria a
18
+ * premissa zero-config. É a técnica padrão de mercado para SDKs
19
+ * "zero-código", e aqui é feita do jeito seguro: o IMP original (próprio ou
20
+ * herdado) é SEMPRE encadeado — outro SDK que também faça swizzling convive,
21
+ * porque cada camada chama a anterior. Quem lembra o original é o
22
+ * `PushMeshSwizzleRegistro`, POR CLASSE: leia as leis lá (troca de classe
23
+ * de delegate A→B→A e encadeamento entre SDKs já quebraram este arquivo).
24
+ * - Evento "PushMeshApnsToken" quando o token MUDA (o JS re-registra o
25
+ * player). Vai pelo RCTEventEmitter → chega ao JS via DeviceEventEmitter.
26
+ *
27
+ * A PERMISSÃO de notificação NÃO é pedida aqui — já é responsabilidade do
28
+ * notifee (src/permissions.ts). registerForRemoteNotifications() com a
29
+ * permissão negada simplesmente não entrega token (didFail/timeout).
30
+ *
31
+ * Módulo old-arch (RCTEventEmitter/NativeModule): funciona na New
32
+ * Architecture (Fabric) via interop layer do RN. TurboModule/codegen fica
33
+ * para uma fase posterior.
34
+ */
35
+ @objc(PushMeshApns)
36
+ final class PushMeshApns: RCTEventEmitter {
37
+
38
+ // MARK: - Estado
39
+
40
+ private static weak var shared: PushMeshApns?
41
+ private var apnsToken: String?
42
+ private var hasListeners = false
43
+ private var pendentes: [(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock)] = []
44
+
45
+ private static let timeoutSegundos: TimeInterval = 10
46
+
47
+ override init() {
48
+ super.init()
49
+ PushMeshApns.shared = self
50
+ // O swizzling acontece na 1ª instanciação do módulo (boot do SDK). Em apps
51
+ // RN o AppDelegate já existe nesse momento; requiresMainQueueSetup = true
52
+ // garante que isto roda na main queue.
53
+ PushMeshApns.swizzleAppDelegate()
54
+ }
55
+
56
+ override static func requiresMainQueueSetup() -> Bool { true }
57
+
58
+ override func supportedEvents() -> [String]! {
59
+ ["PushMeshApnsToken", "PushMeshApnsMessage", "PushMeshApnsClick"]
60
+ }
61
+
62
+ override func startObserving() {
63
+ hasListeners = true
64
+ // o JS assinou os eventos: garante o swizzling do delegate de
65
+ // notificações (o notifee/host pode tê-lo definido DEPOIS do nosso init)
66
+ DispatchQueue.main.async { PushMeshApns.swizzleNotificationDelegateSePreciso() }
67
+ }
68
+ override func stopObserving() { hasListeners = false }
69
+
70
+ // MARK: - API JS
71
+
72
+ /// Garante o registro no APNs (idempotente no sistema).
73
+ @objc func register() {
74
+ DispatchQueue.main.async {
75
+ PushMeshApns.swizzleNotificationDelegateSePreciso()
76
+ UIApplication.shared.registerForRemoteNotifications()
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Abre DIRETO a tela de Notificações do app nos Ajustes (iOS 16+).
82
+ *
83
+ * Por que existe: `Linking.openSettings()` abre a página GERAL do app — e
84
+ * quando o app nunca pediu permissão ele sequer TEM página lá, então o
85
+ * sistema abre a raiz dos Ajustes e a pessoa se perde. O
86
+ * `openNotificationSettingsURLString` é a API PÚBLICA da Apple
87
+ * para o atalho certo; `App-prefs:` seria privada e reprovaria na revisão.
88
+ *
89
+ * Resolve `false` quando não dá (iOS < 16 ou URL recusada) — aí o JS cai no
90
+ * caminho padrão. Nunca lança: o push jamais derruba o app do cliente.
91
+ */
92
+ @objc func abrirAjustesDeNotificacao(_ resolve: @escaping RCTPromiseResolveBlock,
93
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
94
+ DispatchQueue.main.async {
95
+ guard #available(iOS 16.0, *) else { resolve(false); return }
96
+ guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else {
97
+ resolve(false); return
98
+ }
99
+ // sem canOpenURL de propósito (lição da casa: ele devolve false para
100
+ // esquema não declarado no Info.plist do host) — o próprio `open` diz se
101
+ // deu certo, e o JS cai no caminho padrão quando não dá.
102
+ UIApplication.shared.open(url, options: [:]) { ok in resolve(ok) }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Token APNs em hex (64 chars). SEMPRE pede um token fresco ao sistema
108
+ * (registerForRemoteNotifications dispara didRegister rápido quando já há
109
+ * registro) — rotação nunca fica escondida atrás de cache. No timeout,
110
+ * resolve o token pendente persistido (UserDefaults) se houver; só rejeita
111
+ * quando não há NADA (ex.: simulador sem capability).
112
+ */
113
+ @objc func getToken(_ resolve: @escaping RCTPromiseResolveBlock,
114
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
115
+ DispatchQueue.main.async {
116
+ if let token = self.apnsToken {
117
+ resolve(token) // capturado NESTE processo = fresco
118
+ return
119
+ }
120
+ self.pendentes.append((resolve, reject))
121
+ PushMeshApns.swizzleNotificationDelegateSePreciso()
122
+ UIApplication.shared.registerForRemoteNotifications()
123
+ DispatchQueue.main.asyncAfter(deadline: .now() + PushMeshApns.timeoutSegundos) { [weak self] in
124
+ guard let self = self, !self.pendentes.isEmpty else { return }
125
+ if let pendente = UserDefaults.standard.string(forKey: PushMeshApns.chaveTokenPendente) {
126
+ let pend = self.pendentes
127
+ self.pendentes.removeAll()
128
+ pend.forEach { $0.resolve(pendente) }
129
+ return
130
+ }
131
+ self.rejeitarPendentes(
132
+ codigo: "pushmesh_apns_timeout",
133
+ mensagem: "[PushMesh] token APNs não chegou em 10s. Como corrigir: rode num device FÍSICO " +
134
+ "com a capability 'Push Notifications' habilitada no Xcode (e Background Modes → " +
135
+ "Remote notifications). Simuladores antigos não recebem token APNs; se o app host " +
136
+ "gerencia o APNs por conta própria, prefira o modo host (getToken no init).",
137
+ erro: nil
138
+ )
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Clique em notificação com o app MORTO (iOS): o didReceive chegou sem JS
145
+ * e o payload ficou guardado. Lê e LIMPA. null quando não houve clique.
146
+ */
147
+ @objc func getPendingClick(_ resolve: @escaping RCTPromiseResolveBlock,
148
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
149
+ let pendente = UserDefaults.standard.dictionary(forKey: PushMeshApns.chaveClickPendente)
150
+ UserDefaults.standard.removeObject(forKey: PushMeshApns.chaveClickPendente)
151
+ resolve(pendente ?? NSNull())
152
+ }
153
+
154
+ /**
155
+ * PERFIL DO APARELHO — nível GRATUITO de rastreamento (espelha o método
156
+ * homônimo do módulo Android; as chaves são as mesmas).
157
+ *
158
+ * Só o que NÃO pede permissão e NÃO altera a ficha de privacidade do app do
159
+ * cliente na App Store. IDFA e localização ficam de fora por decisão de
160
+ * produto: ambos alterariam a ficha de privacidade e exigiriam consentimento.
161
+ *
162
+ * `carrier` NÃO é reportado de propósito: a Apple deprecou `CTCarrier` na
163
+ * iOS 16 e ele devolve "--". Preferimos campo ausente a dado inventado.
164
+ *
165
+ * Nunca rejeita: perfil ausente não pode impedir o aparelho de registrar.
166
+ */
167
+ @objc func perfilDispositivo(_ resolve: @escaping RCTPromiseResolveBlock,
168
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
169
+ var perfil: [String: Any] = [
170
+ "device_os": UIDevice.current.systemVersion,
171
+ "fabricante": "Apple",
172
+ "modelo": PushMeshApns.modeloHardware(),
173
+ "rooted": PushMeshApns.indicioDeJailbreak(),
174
+ ]
175
+ if let rede = PushMeshApns.tipoDeRede() {
176
+ perfil["net_type"] = rede
177
+ }
178
+ if let bits = PushMeshApns.bitmaskNotificacoes() {
179
+ perfil["notif_permissoes"] = bits
180
+ }
181
+ if let inst = PushMeshApns.idDaInstalacao() {
182
+ perfil["instalacao_id"] = inst
183
+ }
184
+ resolve(perfil)
185
+ }
186
+
187
+ /**
188
+ * Identificador estável do APARELHO — em HASH, nunca cru.
189
+ *
190
+ * Fonte: `identifierForVendor` (IDFV). Não pede permissão, não é o IDFA (não
191
+ * exige ATT) e é escopado ao fornecedor: não correlaciona entre empresas.
192
+ * Sobrevive à reinstalação enquanto houver ao menos um app do mesmo
193
+ * fornecedor no aparelho — é o que permite derrubar o token fantasma na
194
+ * hora, sem esperar um envio falhar.
195
+ *
196
+ * SHA-256 truncado: comparar igualdade basta, e o valor cru fica no aparelho.
197
+ *
198
+ * KEYCHAIN FOI CONSIDERADO E DESCARTADO (decisão de produto — não é um
199
+ * esquecimento a ser "melhorado" depois: trocar a fonte aqui muda o id de
200
+ * TODO o parque de aparelhos de uma vez). Um id gravado no Keychain
201
+ * sobreviveria à desinstalação de TODOS os apps do fornecedor, e é
202
+ * exatamente aí que ele deixa de ser "identidade da instalação" e vira
203
+ * RASTREAMENTO do aparelho além do consentimento — o que a política da Apple
204
+ * veta sem ATT/permissão explícita. O IDFV, que zera quando o último app do
205
+ * fornecedor sai, é o limite que aceitamos: some o aparelho, some o id.
206
+ * Consequência ASSUMIDA e medida: no iOS a reinstalação não derruba o token
207
+ * fantasma como derruba no Android.
208
+ */
209
+ static func idDaInstalacao() -> String? {
210
+ guard let idfv = UIDevice.current.identifierForVendor?.uuidString else { return nil }
211
+ // POR QUE O SAL: não é anti-reversão — IDFV é UUID com ~122 bits
212
+ // aleatórios, força bruta não volta dele com ou sem sal. O sal fixo do SDK
213
+ // serve para DESCORRELACIONAR: outro SDK que hasheie o MESMO IDFV sem esse
214
+ // prefixo chega a outro dígito, então ninguém cruza o nosso id com o dele.
215
+ // Fixo (e não aleatório) de propósito: o id precisa ser reproduzível a
216
+ // cada boot, senão deixa de identificar a instalação.
217
+ let digest = SHA256.hash(data: Data("pushmesh:\(idfv)".utf8))
218
+ return digest.prefix(16).map { String(format: "%02x", $0) }.joined()
219
+ }
220
+
221
+ /**
222
+ * BITMASK granular da permissão (contrato da migration 0013 — mesmo do
223
+ * Android): 1=habilitadas · 2=alerta/banner · 4=som · 8=badge · 16=tela de
224
+ * bloqueio · 32=central de notificações. É o que diagnostica "tenho
225
+ * permissão mas não vejo nada" (alerta ok, banner desligado).
226
+ *
227
+ * getNotificationSettings é assíncrono; espera CURTA (500 ms) — sem
228
+ * resposta, o campo não vai (nunca atrasar o registro por diagnóstico).
229
+ */
230
+ private static func bitmaskNotificacoes() -> Int? {
231
+ var bits: Int?
232
+ let semaforo = DispatchSemaphore(value: 0)
233
+ UNUserNotificationCenter.current().getNotificationSettings { st in
234
+ var b = 0
235
+ switch st.authorizationStatus {
236
+ case .authorized, .provisional, .ephemeral: b |= 1
237
+ default: break
238
+ }
239
+ if b & 1 != 0 {
240
+ if st.alertSetting == .enabled { b |= 2 }
241
+ if st.soundSetting == .enabled { b |= 4 }
242
+ if st.badgeSetting == .enabled { b |= 8 }
243
+ if st.lockScreenSetting == .enabled { b |= 16 }
244
+ if st.notificationCenterSetting == .enabled { b |= 32 }
245
+ }
246
+ bits = b
247
+ semaforo.signal()
248
+ }
249
+ _ = semaforo.wait(timeout: .now() + 0.5)
250
+ return bits
251
+ }
252
+
253
+ /**
254
+ * Identificador de HARDWARE ("iPhone17,3") — não o nome comercial.
255
+ * `UIDevice.model` devolve só "iPhone"; o que distingue os modelos é o
256
+ * `utsname.machine`, e é dele que sai a tradução para nome comercial (feita
257
+ * no painel, não aqui: tabela de nomes envelhece e o aparelho não deve
258
+ * carregá-la).
259
+ */
260
+ private static func modeloHardware() -> String {
261
+ #if targetEnvironment(simulator)
262
+ // No simulador o utsname devolve a arquitetura do Mac ("arm64"), não o
263
+ // modelo emulado — o modelo real vem desta env (verificado em bancada).
264
+ return ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "Simulator"
265
+ #else
266
+ var info = utsname()
267
+ uname(&info)
268
+ return withUnsafeBytes(of: &info.machine) { buf in
269
+ guard let base = buf.baseAddress else { return UIDevice.current.model }
270
+ return String(cString: base.assumingMemoryBound(to: CChar.self))
271
+ }
272
+ #endif
273
+ }
274
+
275
+ /**
276
+ * Wi-Fi × celular no instante do registro. Snapshot com espera CURTA (200 ms):
277
+ * o `NWPathMonitor` é assíncrono e não vale atrasar o registro por um campo
278
+ * de diagnóstico — sem resposta, o campo simplesmente não vai.
279
+ */
280
+ private static func tipoDeRede() -> String? {
281
+ let monitor = NWPathMonitor()
282
+ let fila = DispatchQueue(label: "pushmesh.rede")
283
+ let semaforo = DispatchSemaphore(value: 0)
284
+ var resultado: String?
285
+ monitor.pathUpdateHandler = { path in
286
+ if path.status == .satisfied {
287
+ if path.usesInterfaceType(.wifi) {
288
+ resultado = "wifi"
289
+ } else if path.usesInterfaceType(.cellular) {
290
+ resultado = "celular"
291
+ } else {
292
+ resultado = "outro"
293
+ }
294
+ }
295
+ semaforo.signal()
296
+ }
297
+ monitor.start(queue: fila)
298
+ _ = semaforo.wait(timeout: .now() + 0.2)
299
+ monitor.cancel()
300
+ return resultado
301
+ }
302
+
303
+ /**
304
+ * Heurística de jailbreak — SINAL, não prova (jailbreak moderno se esconde).
305
+ * Insumo antifraude; `false` significa "não achamos indício", nunca
306
+ * "aparelho confiável".
307
+ */
308
+ private static func indicioDeJailbreak() -> Bool {
309
+ #if targetEnvironment(simulator)
310
+ return false // simulador tem sistema de arquivos aberto: daria falso positivo
311
+ #else
312
+ let caminhos = [
313
+ "/Applications/Cydia.app",
314
+ "/Applications/Sileo.app",
315
+ "/private/var/lib/apt/",
316
+ "/private/var/lib/cydia",
317
+ "/usr/sbin/sshd",
318
+ "/bin/bash",
319
+ "/etc/apt",
320
+ ]
321
+ let fm = FileManager.default
322
+ if caminhos.contains(where: { fm.fileExists(atPath: $0) }) { return true }
323
+ // escrever fora do sandbox só é possível com jailbreak
324
+ let fora = "/private/pushmesh_teste.txt"
325
+ if (try? "x".write(toFile: fora, atomically: true, encoding: .utf8)) != nil {
326
+ try? fm.removeItem(atPath: fora)
327
+ return true
328
+ }
329
+ return false
330
+ #endif
331
+ }
332
+
333
+ // MARK: - Callbacks vindos do swizzling
334
+
335
+ private func handleDidRegister(deviceToken: Data) {
336
+ let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
337
+ let mudou = hex != apnsToken
338
+ apnsToken = hex
339
+ // token pendente SEMPRE persistido: rotação sem JS vivo é reconciliada
340
+ // no próximo boot (fallback do getToken).
341
+ UserDefaults.standard.set(hex, forKey: PushMeshApns.chaveTokenPendente)
342
+ let pend = pendentes
343
+ pendentes.removeAll()
344
+ pend.forEach { $0.resolve(hex) }
345
+ if mudou && hasListeners {
346
+ sendEvent(withName: "PushMeshApnsToken", body: hex)
347
+ }
348
+ }
349
+
350
+ /// Foreground (willPresent) ou clique (didReceive) → evento JS; sem JS
351
+ /// vivo, o CLIQUE fica pendente (getPendingClick no boot) — o foreground
352
+ /// sem JS não existe (app fechado não tem willPresent).
353
+ private func emitirNotificacao(_ evento: String, userInfo: [AnyHashable: Any], pendenteSeSemListener: Bool) {
354
+ if hasListeners {
355
+ sendEvent(withName: evento, body: userInfo as NSDictionary)
356
+ } else if pendenteSeSemListener {
357
+ UserDefaults.standard.set(userInfo as NSDictionary, forKey: PushMeshApns.chaveClickPendente)
358
+ }
359
+ }
360
+
361
+ private func handleDidFail(error: Error) {
362
+ rejeitarPendentes(
363
+ codigo: "pushmesh_apns_falhou",
364
+ mensagem: "[PushMesh] didFailToRegisterRemoteNotifications: \(error.localizedDescription). " +
365
+ "Como corrigir: confira a capability 'Push Notifications' no Xcode e use device físico.",
366
+ erro: error
367
+ )
368
+ }
369
+
370
+ private func rejeitarPendentes(codigo: String, mensagem: String, erro: Error?) {
371
+ let pend = pendentes
372
+ pendentes.removeAll()
373
+ pend.forEach { $0.reject(codigo, mensagem, erro) }
374
+ }
375
+
376
+ // MARK: - Method swizzling do AppDelegate
377
+
378
+ private typealias DidRegisterIMP = @convention(c) (AnyObject, Selector, UIApplication, Data) -> Void
379
+ private typealias DidFailIMP = @convention(c) (AnyObject, Selector, UIApplication, NSError) -> Void
380
+ private typealias WillPresentIMP = @convention(c) (
381
+ AnyObject, Selector, UNUserNotificationCenter, UNNotification,
382
+ @convention(block) (UNNotificationPresentationOptions) -> Void
383
+ ) -> Void
384
+ private typealias DidReceiveIMP = @convention(c) (
385
+ AnyObject, Selector, UNUserNotificationCenter, UNNotificationResponse,
386
+ @convention(block) () -> Void
387
+ ) -> Void
388
+
389
+ /**
390
+ * Espelho do `isNossa` do Android: a mensagem é NOSSA quando carrega alguma
391
+ * chave `pm_*` (bloco de exibição) ou `pm_*` (recibo/url). SEPARAÇÃO TOTAL
392
+ * (regra do produto): notificação de outro SDK atravessa o embrulho
393
+ * sem que a gente emita evento, persista clique ou abra URL — só o IMP
394
+ * original é encadeado.
395
+ */
396
+ static func ehNossa(_ userInfo: [AnyHashable: Any]) -> Bool {
397
+ for chave in userInfo.keys {
398
+ if let k = chave as? String, k.hasPrefix("pmx_") || k.hasPrefix("pm_") { return true }
399
+ }
400
+ return false
401
+ }
402
+
403
+ static let chaveTokenPendente = "pmx_pending_token"
404
+ static let chaveClickPendente = "pmx_pending_click"
405
+
406
+ private static var ganchoSetDelegateFeito = false
407
+ /**
408
+ * Toques já processados: identifier → instante.
409
+ *
410
+ * POR QUE NÃO UM SLOT ÚNICO (era `ultimoToqueId` + `ultimoToqueEm`):
411
+ * guardando só o ÚLTIMO toque, duas notificações INTERCALADAS furavam o
412
+ * dedup. O usuário toca A e logo B; o encadeamento de delegates não é
413
+ * garantidamente síncrono (um SDK que despacha para o JS pode reentrar
414
+ * depois), então a reentrada de A chegava DEPOIS de B ter tomado o slot — A
415
+ * passava de novo e a `pm_url` abria DUAS vezes. O LRU do lado JS não cobre
416
+ * isso: ele dedupa RECIBO por `pm_msg_id`, enquanto a abertura da URL é 100%
417
+ * nativa (foi o sintoma real, visto em campo, que este dedup veio matar).
418
+ *
419
+ * É o espelho do LRU do JS, em miniatura: janela de 5 s, teto de 16 ids
420
+ * (rajada anormal não pode virar vazamento de memória) e poda a cada
421
+ * consulta. NSLock porque `willPresent`/`didReceive` não têm garantia
422
+ * DOCUMENTADA de fila única — barato e tira a dúvida.
423
+ */
424
+ private static var toquesVistos: [String: TimeInterval] = [:]
425
+ private static let travaToques = NSLock()
426
+ private static let janelaToqueSegundos: TimeInterval = 5
427
+ private static let tetoToquesVistos = 16
428
+
429
+ /// true na PRIMEIRA passagem de um toque; false nas repetições do
430
+ /// encadeamento de delegates (janela de 5 s por identifier).
431
+ static func primeiraVezDoToque(_ id: String) -> Bool {
432
+ travaToques.lock()
433
+ defer { travaToques.unlock() }
434
+ let agora = Date().timeIntervalSince1970
435
+ // poda o que saiu da janela: o dicionário só guarda toque "quente"
436
+ toquesVistos = toquesVistos.filter { agora - $0.value < janelaToqueSegundos }
437
+ if toquesVistos[id] != nil { return false }
438
+ if toquesVistos.count >= tetoToquesVistos,
439
+ let maisAntigo = toquesVistos.min(by: { $0.value < $1.value })?.key {
440
+ toquesVistos.removeValue(forKey: maisAntigo)
441
+ }
442
+ toquesVistos[id] = agora
443
+ return true
444
+ }
445
+ /**
446
+ * O IMP original NUNCA mora numa estática única aqui — mora no
447
+ * `PushMeshSwizzleRegistro`, indexado por (classe, seletor), e cada bloco
448
+ * resolve pela classe em que ELE foi instalado (capturada na instalação).
449
+ * Pela classe do ALVO era quase certo — e caía no terceiro loop: subclasse
450
+ * que SOBRESCREVE o método e chama `super` (super → nosso bloco na
451
+ * ancestral → resolvendo pelo alvo achava o original da PRÓPRIA subclasse →
452
+ * sobrescrita de novo → ciclo).
453
+ *
454
+ * A LEI (dois defeitos reais que a estática única causava):
455
+ * - A→B→A: delegate da classe A embrulhado, host troca para B (a estática
456
+ * passava a apontar para o original de B) e depois volta para A. Ao
457
+ * re-embrulhar A, o "original" devolvido pelo runtime é O NOSSO PRÓPRIO
458
+ * BLOCO → o bloco chamava a si mesmo → recursão infinita → o app do
459
+ * cliente morre de stack overflow no primeiro push;
460
+ * - encadeamento entre SDKs: quem substitui o delegate costuma guardar o
461
+ * anterior e encaminhar a ele. Com a estática única, o bloco da classe A
462
+ * encadeava o IMP de B (corpo de outra classe com `self` de A) e fechava o
463
+ * ciclo B→A→B. Nem o ramo de dedup salvava: ele também encadeia o
464
+ * original.
465
+ *
466
+ * Estes quatro atalhos tipados existem só para converter o IMP cru do
467
+ * registro na assinatura C certa de cada callback.
468
+ */
469
+ private static func originalDidRegister(instaladaEm classe: AnyClass) -> DidRegisterIMP? {
470
+ PushMeshSwizzleRegistro.original(paraClasse: classe, seletor: didRegisterSel)
471
+ .map { unsafeBitCast($0, to: DidRegisterIMP.self) }
472
+ }
473
+ private static func originalDidFail(instaladaEm classe: AnyClass) -> DidFailIMP? {
474
+ PushMeshSwizzleRegistro.original(paraClasse: classe, seletor: didFailSel)
475
+ .map { unsafeBitCast($0, to: DidFailIMP.self) }
476
+ }
477
+ private static func originalWillPresent(instaladaEm classe: AnyClass) -> WillPresentIMP? {
478
+ PushMeshSwizzleRegistro.original(paraClasse: classe, seletor: willPresentSel)
479
+ .map { unsafeBitCast($0, to: WillPresentIMP.self) }
480
+ }
481
+ private static func originalDidReceive(instaladaEm classe: AnyClass) -> DidReceiveIMP? {
482
+ PushMeshSwizzleRegistro.original(paraClasse: classe, seletor: didReceiveSel)
483
+ .map { unsafeBitCast($0, to: DidReceiveIMP.self) }
484
+ }
485
+
486
+ private static let didRegisterSel =
487
+ #selector(UIApplicationDelegate.application(_:didRegisterForRemoteNotificationsWithDeviceToken:))
488
+ // ATENÇÃO ao "For": o seletor real é
489
+ // `application:didFailToRegisterForRemoteNotificationsWithError:`. Sem o
490
+ // "For" o Swift não acha o membro no protocolo e a compilação do app do
491
+ // cliente quebra (não é aviso — é erro de build).
492
+ private static let didFailSel =
493
+ #selector(UIApplicationDelegate.application(_:didFailToRegisterForRemoteNotificationsWithError:))
494
+ private static let willPresentSel =
495
+ #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:))
496
+ private static let didReceiveSel =
497
+ #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:))
498
+
499
+ /**
500
+ * Swizzling do delegate do UNUserNotificationCenter (foreground + clique) —
501
+ * mesmo cuidado do swizzling de registro: o IMP original (do notifee, que
502
+ * costuma ser o delegate, ou do host) é SEMPRE encadeado; sem delegate/
503
+ * original, o willPresent exibe banner+som e o didReceive só completa.
504
+ * Idempotente por CLASSE do delegate: se o host trocar o delegate depois
505
+ * (ex.: notifee se instalando), re-swizzla a classe nova.
506
+ */
507
+ /**
508
+ * Ponto de entrada chamado do `+load` (PushMeshApnsModule.m) quando o app
509
+ * termina de abrir — ANTES de o sistema entregar o toque que abriu o app.
510
+ *
511
+ * Por que existe: o `UNUserNotificationCenter` só entrega
512
+ * `didReceive` a quem já é delegate quando o launch termina. Nosso swizzle
513
+ * acontecia na criação do módulo React (depois disso), então o TOQUE COM O
514
+ * APP FECHADO era perdido em silêncio — clique não contava e a `pm_url` não
515
+ * abria. Aqui garantimos presença desde o início.
516
+ */
517
+ /**
518
+ * Abre a URL do clique — esperando o app estar ATIVO.
519
+ *
520
+ * `UIApplication.open` durante o launch é descartado pelo sistema. No toque
521
+ * com o app fechado o estado ainda é `.inactive`, então observamos
522
+ * `didBecomeActive` UMA vez e abrimos aí. Se já estiver ativo (toque com o
523
+ * app em segundo plano), abre na hora.
524
+ */
525
+ @objc static func abrirUrlQuandoAtivo(_ endereco: String) {
526
+ let texto = endereco.trimmingCharacters(in: .whitespacesAndNewlines)
527
+ guard !texto.isEmpty, let url = URL(string: texto) else { return }
528
+
529
+ let abrir = {
530
+ DispatchQueue.main.async {
531
+ NSLog("[PushMesh] abrindo pm_url: %@", texto)
532
+ // SEM canOpenURL DE PROPÓSITO (lei do zero-config): desde o iOS 9 o
533
+ // `canOpenURL` devolve false para QUALQUER esquema custom que o app
534
+ // host não tenha declarado em LSApplicationQueriesSchemes — inclusive
535
+ // o esquema do próprio app. Como o SDK promete não pedir nada no
536
+ // Info.plist do cliente, o guard reprovava todo deep link
537
+ // (`meuapp://tela`, que é o `app_url` do contrato do servidor) e o
538
+ // toque "não fazia nada" — só no iOS, e em silêncio. O `open` NÃO
539
+ // exige declaração alguma e já reporta o fracasso no completion: é lá
540
+ // que o erro ENSINA, e é o mesmo desenho do trampolim do Android.
541
+ UIApplication.shared.open(url, options: [:]) { ok in
542
+ if ok {
543
+ NSLog("[PushMesh] pm_url aberta: %@", texto)
544
+ } else {
545
+ NSLog("[PushMesh] pm_url NÃO abriu: %@ — nenhum app instalado atende o esquema \"%@\". " +
546
+ "Como corrigir: use https:// (abre no navegador/Universal Link) ou confira o " +
547
+ "esquema do deep link — ele precisa estar em CFBundleURLTypes do app que deve abrir.",
548
+ texto, url.scheme ?? "<sem esquema>")
549
+ }
550
+ }
551
+ }
552
+ }
553
+
554
+ DispatchQueue.main.async {
555
+ if UIApplication.shared.applicationState == .active {
556
+ abrir()
557
+ return
558
+ }
559
+ var token: NSObjectProtocol?
560
+ token = NotificationCenter.default.addObserver(
561
+ forName: UIApplication.didBecomeActiveNotification,
562
+ object: nil,
563
+ queue: .main
564
+ ) { _ in
565
+ if let t = token { NotificationCenter.default.removeObserver(t) }
566
+ abrir()
567
+ }
568
+ }
569
+ }
570
+
571
+ @objc static func instalarSwizzleDelegate() {
572
+ DispatchQueue.main.async { swizzleNotificationDelegateSePreciso() }
573
+ }
574
+
575
+ /**
576
+ * Gancho no `setDelegate:` do UNUserNotificationCenter — chamado do `+load`,
577
+ * antes de qualquer delegate existir.
578
+ *
579
+ * POR QUE ISTO É NECESSÁRIO: `swizzleNotificationDelegateSePreciso()` desiste
580
+ * quando ainda não há delegate (`guard let … else { return }`), e no arranque
581
+ * frio é exatamente esse o estado. O notifee/host define o delegate DEPOIS —
582
+ * e o toque que abriu o app já tinha se perdido. Interceptando a ATRIBUIÇÃO,
583
+ * embrulhamos o delegate no instante em que ele nasce, seja quem for e
584
+ * quando for. Foi este o defeito real de "no iOS vai direto pro app".
585
+ */
586
+ @objc static func instalarGanchoSetDelegate() {
587
+ guard !ganchoSetDelegateFeito else { return }
588
+ ganchoSetDelegateFeito = true
589
+
590
+ let cls: AnyClass = UNUserNotificationCenter.self
591
+ let sel = NSSelectorFromString("setDelegate:")
592
+ guard let metodo = class_getInstanceMethod(cls, sel) else {
593
+ NSLog("[PushMesh] gancho setDelegate: método não encontrado")
594
+ return
595
+ }
596
+ typealias SetDelegateIMP = @convention(c) (AnyObject, Selector, AnyObject?) -> Void
597
+ let originalFn = unsafeBitCast(method_getImplementation(metodo), to: SetDelegateIMP.self)
598
+
599
+ let bloco: @convention(block) (AnyObject, AnyObject?) -> Void = { alvo, novoDelegate in
600
+ originalFn(alvo, sel, novoDelegate)
601
+ if novoDelegate != nil {
602
+ // o delegate acabou de nascer: embrulha AGORA
603
+ swizzleNotificationDelegateSePreciso()
604
+ }
605
+ }
606
+ method_setImplementation(metodo, imp_implementationWithBlock(bloco))
607
+
608
+ // Caso raro: alguém já definiu o delegate antes do nosso +load.
609
+ swizzleNotificationDelegateSePreciso()
610
+ }
611
+
612
+ static func swizzleNotificationDelegateSePreciso() {
613
+ guard let delegate = UNUserNotificationCenter.current().delegate else {
614
+ NSLog("[PushMesh] delegate de notificações ainda não existe — o gancho do setDelegate pega quando nascer")
615
+ return
616
+ }
617
+ let cls: AnyClass = type(of: delegate)
618
+ // Idempotente por CLASSE (não "pela última classe vista"): no A→B→A a
619
+ // classe A já está embrulhada e re-embrulhar capturaria o NOSSO bloco como
620
+ // original — recursão infinita. Quem sabe disso é o registro.
621
+ guard !PushMeshSwizzleRegistro.jaEmbrulhada(cls, willPresentSel)
622
+ || !PushMeshSwizzleRegistro.jaEmbrulhada(cls, didReceiveSel) else { return }
623
+ NSLog("[PushMesh] delegate de notificações embrulhado: %@", NSStringFromClass(cls))
624
+
625
+ let blocoWill: @convention(block) (
626
+ AnyObject, UNUserNotificationCenter, UNNotification,
627
+ @convention(block) (UNNotificationPresentationOptions) -> Void
628
+ ) -> Void = { alvo, center, notification, completion in
629
+ let infoWill = notification.request.content.userInfo
630
+ if PushMeshApns.ehNossa(infoWill) {
631
+ PushMeshApns.shared?.emitirNotificacao(
632
+ "PushMeshApnsMessage",
633
+ userInfo: infoWill,
634
+ pendenteSeSemListener: false
635
+ )
636
+ }
637
+ // o original é o da classe onde ESTE bloco foi instalado (capturada no
638
+ // embrulho) — nunca o do último delegate embrulhado, nem o da classe do
639
+ // alvo (subclasse que sobrescreve e chama super entraria em ciclo).
640
+ if let original = PushMeshApns.originalWillPresent(instaladaEm: cls) {
641
+ original(alvo, PushMeshApns.willPresentSel, center, notification, completion)
642
+ } else {
643
+ completion([.banner, .list, .sound])
644
+ }
645
+ }
646
+ PushMeshSwizzleRegistro.instalar(classe: cls, seletor: willPresentSel, tipos: "v@:@@@",
647
+ nossoImp: imp_implementationWithBlock(blocoWill))
648
+
649
+ let blocoClick: @convention(block) (
650
+ AnyObject, UNUserNotificationCenter, UNNotificationResponse,
651
+ @convention(block) () -> Void
652
+ ) -> Void = { alvo, center, response, completion in
653
+ // SEPARAÇÃO TOTAL: toque em notificação de OUTRO SDK não é assunto nosso
654
+ // — nada de evento, recibo, clique pendente ou URL. Só encadeia.
655
+ guard PushMeshApns.ehNossa(response.notification.request.content.userInfo) else {
656
+ if let original = PushMeshApns.originalDidReceive(instaladaEm: cls) {
657
+ original(alvo, PushMeshApns.didReceiveSel, center, response, completion)
658
+ } else {
659
+ completion()
660
+ }
661
+ return
662
+ }
663
+ // DEDUP do toque: os delegates embrulhados se ENCADEIAM (um SDK de push
664
+ // costuma chamar o delegate anterior, e cada classe passa pelo bloco) — sem isto o
665
+ // mesmo toque processava DUAS vezes: duas aberturas de URL e dois
666
+ // recibos (visto em teste de campo). O original é SEMPRE
667
+ // encadeado; só o NOSSO processamento é único por toque.
668
+ guard PushMeshApns.primeiraVezDoToque(response.notification.request.identifier) else {
669
+ if let original = PushMeshApns.originalDidReceive(instaladaEm: cls) {
670
+ original(alvo, PushMeshApns.didReceiveSel, center, response, completion)
671
+ } else {
672
+ completion()
673
+ }
674
+ return
675
+ }
676
+ // O toque pode chegar ANTES de o módulo React existir (arranque frio:
677
+ // o swizzle é instalado no +load, mas `shared` só nasce quando a ponte
678
+ // RN cria os módulos). Sem esta guarda o clique se perdia em silêncio —
679
+ // era este o caso do "iOS não aponta para o link".
680
+ let userInfo = response.notification.request.content.userInfo
681
+
682
+ // pm_url é aberta AQUI, no nativo — não no JS. Duas razões:
683
+ // 1. no arranque frio o JS pode nem existir ainda quando o toque chega;
684
+ // 2. `Linking.openURL` durante o launch é descartado pelo iOS em
685
+ // silêncio (o app abria e o link não).
686
+ // O nativo espera o app ficar ATIVO e abre com UIApplication.open, que é
687
+ // o mesmo caminho que o trampolim do Android usa.
688
+ NSLog("[PushMesh] TOQUE recebido no nativo (pm_url=%@)",
689
+ (userInfo["pm_url"] as? String) ?? "<sem url>")
690
+ if let url = userInfo["pm_url"] as? String {
691
+ PushMeshApns.abrirUrlQuandoAtivo(url)
692
+ }
693
+
694
+ if let instancia = PushMeshApns.shared {
695
+ instancia.emitirNotificacao(
696
+ "PushMeshApnsClick",
697
+ userInfo: userInfo,
698
+ pendenteSeSemListener: true
699
+ )
700
+ } else {
701
+ UserDefaults.standard.set(userInfo as NSDictionary, forKey: PushMeshApns.chaveClickPendente)
702
+ }
703
+ if let original = PushMeshApns.originalDidReceive(instaladaEm: cls) {
704
+ original(alvo, PushMeshApns.didReceiveSel, center, response, completion)
705
+ } else {
706
+ completion()
707
+ }
708
+ }
709
+ PushMeshSwizzleRegistro.instalar(classe: cls, seletor: didReceiveSel, tipos: "v@:@@@",
710
+ nossoImp: imp_implementationWithBlock(blocoClick))
711
+ }
712
+
713
+ static func swizzleAppDelegate() {
714
+ guard let delegate = UIApplication.shared.delegate else {
715
+ NSLog("[PushMesh] AppDelegate ausente na 1ª instanciação — swizzling APNs não instalado; " +
716
+ "getToken pode falhar neste boot.")
717
+ return
718
+ }
719
+ let cls: AnyClass = type(of: delegate)
720
+ // idempotência por CLASSE (era um `swizzleFeito` global): se o AppDelegate
721
+ // trocar de classe, a classe nova também é embrulhada — e a antiga
722
+ // continua com o encadeamento certo, porque o original mora por classe.
723
+ guard !PushMeshSwizzleRegistro.jaEmbrulhada(cls, didRegisterSel)
724
+ || !PushMeshSwizzleRegistro.jaEmbrulhada(cls, didFailSel) else { return }
725
+
726
+ // didRegisterForRemoteNotificationsWithDeviceToken
727
+ let blocoReg: @convention(block) (AnyObject, UIApplication, Data) -> Void = { alvo, app, deviceToken in
728
+ PushMeshApns.shared?.handleDidRegister(deviceToken: deviceToken)
729
+ if let original = PushMeshApns.originalDidRegister(instaladaEm: cls) {
730
+ original(alvo, PushMeshApns.didRegisterSel, app, deviceToken)
731
+ }
732
+ }
733
+ PushMeshSwizzleRegistro.instalar(classe: cls, seletor: didRegisterSel, tipos: "v@:@@",
734
+ nossoImp: imp_implementationWithBlock(blocoReg))
735
+
736
+ // didFailToRegisterRemoteNotificationsWithError
737
+ let blocoErro: @convention(block) (AnyObject, UIApplication, NSError) -> Void = { alvo, app, erro in
738
+ PushMeshApns.shared?.handleDidFail(error: erro)
739
+ if let original = PushMeshApns.originalDidFail(instaladaEm: cls) {
740
+ original(alvo, PushMeshApns.didFailSel, app, erro)
741
+ }
742
+ }
743
+ PushMeshSwizzleRegistro.instalar(classe: cls, seletor: didFailSel, tipos: "v@:@@",
744
+ nossoImp: imp_implementationWithBlock(blocoErro))
745
+ }
746
+ }