@goliapkg/sentori-react-native 6.2.0 → 6.3.0

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 (56) hide show
  1. package/SentoriReactNative.podspec +11 -4
  2. package/android/build.gradle +21 -1
  3. package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
  4. package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
  5. package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
  6. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
  7. package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
  8. package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
  9. package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -0
  10. package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
  11. package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
  12. package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
  13. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
  14. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
  15. package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
  16. package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
  17. package/android/src/main/java-core/com/sentori/SentoriPush.kt +588 -0
  18. package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
  19. package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
  20. package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -0
  21. package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
  22. package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
  23. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +476 -0
  24. package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
  25. package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
  26. package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
  27. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +149 -0
  28. package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
  29. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -0
  30. package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
  31. package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
  32. package/ios/core/SentoriAttachment.swift +129 -0
  33. package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -0
  34. package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
  35. package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
  36. package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
  37. package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -0
  38. package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
  39. package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
  40. package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
  41. package/lib/push.d.ts.map +1 -1
  42. package/lib/push.js +51 -1
  43. package/lib/push.js.map +1 -1
  44. package/lib/scope.d.ts +6 -0
  45. package/lib/scope.d.ts.map +1 -1
  46. package/lib/scope.js +45 -1
  47. package/lib/scope.js.map +1 -1
  48. package/lib/transport.js +1 -1
  49. package/package.json +2 -2
  50. package/src/__tests__/push.test.ts +99 -1
  51. package/src/push.ts +49 -1
  52. package/src/scope.ts +50 -1
  53. package/src/transport.ts +1 -1
  54. package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
  55. package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
  56. package/ios/Tests/SentoriThreadSamplerTests.swift +0 -96
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/test/java/com/sentori/SentoriScreenshotCaptureTest.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  // Phase 42 sub-F.10 — Robolectric coverage for SentoriScreenshotCapture.
2
5
  //
3
6
  // Run via Gradle on the Android host:
@@ -0,0 +1,185 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/test/java/com/sentori/SentoriVerbsTest.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import org.junit.After
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertNotNull
9
+ import org.junit.Assert.assertNull
10
+ import org.junit.Assert.assertTrue
11
+ import org.junit.Before
12
+ import org.junit.Test
13
+ import org.junit.runner.RunWith
14
+ import org.robolectric.RobolectricTestRunner
15
+
16
+ /**
17
+ * The five verbs, and the contract an integrator is entitled to:
18
+ * synchronous, an id back every time, never a throw, and a no-op
19
+ * before [Sentori.start] rather than a crash.
20
+ *
21
+ * Deliberately the same assertions as `SentoriVerbsTests.swift`. The
22
+ * dashboard cannot tell which platform sent an event and should not
23
+ * have to, so the two suites disagreeing is itself the bug.
24
+ */
25
+ @RunWith(RobolectricTestRunner::class)
26
+ class SentoriVerbsTest {
27
+
28
+ @Before
29
+ fun setUp() {
30
+ SentoriTransport.resetForTests()
31
+ SentoriConfig.resetForTests()
32
+ SentoriScope.clear()
33
+ SentoriSignalRing.clear()
34
+ SentoriDevice.resetForTests()
35
+ }
36
+
37
+ @After
38
+ fun tearDown() {
39
+ SentoriTransport.resetForTests()
40
+ SentoriConfig.resetForTests()
41
+ }
42
+
43
+ private fun started() {
44
+ // Port 9 is discard: nothing listens, so a send fails without
45
+ // waiting on DNS.
46
+ Sentori.start(
47
+ SentoriConfig(
48
+ token = "st_test",
49
+ ingestUrl = "http://127.0.0.1:9",
50
+ release = "app@1.0.0",
51
+ environment = "test",
52
+ )
53
+ )
54
+ }
55
+
56
+ private fun queued() = SentoriTransport.peekQueue()
57
+
58
+ @Test
59
+ fun everyVerbBeforeStartIsASilentNoOpThatStillReturnsAnId() {
60
+ val ids =
61
+ listOf(
62
+ Sentori.error(IllegalStateException("boom")),
63
+ Sentori.warn("checkout.slow"),
64
+ Sentori.trace("cart.opened"),
65
+ Sentori.assert("total.positive", false),
66
+ Sentori.probe("SEN-482"),
67
+ )
68
+ ids.forEach { assertEquals("every verb returns an event id", 36, it.length) }
69
+ assertTrue("and nothing is queued before init", queued().isEmpty())
70
+ }
71
+
72
+ @Test
73
+ fun errorCarriesTypeMessageStackAndTheSignalRing() {
74
+ started()
75
+ Sentori.pushSignal("nav", mapOf("to" to "/checkout"))
76
+ Sentori.error(IllegalStateException("boom"), mapOf("cartId" to "c_1"))
77
+
78
+ val e = queued().last()
79
+ assertEquals("error", e["kind"])
80
+ assertEquals("android", e["platform"])
81
+ assertEquals("app@1.0.0", e["release"])
82
+
83
+ @Suppress("UNCHECKED_CAST") val payload = e["payload"] as Map<String, Any?>
84
+ @Suppress("UNCHECKED_CAST") val err = payload["error"] as Map<String, Any?>
85
+ assertEquals("java.lang.IllegalStateException", err["type"])
86
+ assertEquals("boom", err["message"])
87
+ assertTrue("a stack with no frames symbolicates nothing", (err["stack"] as List<*>).isNotEmpty())
88
+
89
+ @Suppress("UNCHECKED_CAST") val data = payload["data"] as Map<String, Any?>
90
+ assertEquals("c_1", data["cartId"])
91
+
92
+ val signals = payload["signals"] as List<*>
93
+ assertEquals(1, signals.size)
94
+ }
95
+
96
+ @Test
97
+ fun aPassingAssertIsNeverAnEvent() {
98
+ started()
99
+ Sentori.assert("total.positive", true)
100
+ Sentori.assert("total.positive", true)
101
+ assertTrue("passes aggregate; only failures are events", queued().isEmpty())
102
+ assertEquals(2, SentoriTransport.peekAssertStats().first()["passDelta"])
103
+
104
+ Sentori.assert("total.positive", false)
105
+ assertEquals(1, queued().size)
106
+ assertEquals("assert", queued().first()["kind"])
107
+ }
108
+
109
+ @Test
110
+ fun assertNeverStopsTheProgram() {
111
+ started()
112
+ Sentori.assert("impossible", false)
113
+ Sentori.assert("also impossible", false)
114
+ assertEquals("reached here, twice", 2, queued().size)
115
+ }
116
+
117
+ @Test
118
+ fun quietTraceReachesTheRingAndNotTheQueue() {
119
+ started()
120
+ Sentori.trace("tick", quiet = true)
121
+ assertTrue("a quiet trace must stay affordable", queued().isEmpty())
122
+ assertEquals("but it is still context", 1, SentoriSignalRing.snapshot().size)
123
+
124
+ Sentori.trace("cart.opened")
125
+ assertEquals(1, queued().size)
126
+ assertEquals("cart.opened", queued().first()["name"])
127
+ }
128
+
129
+ @Test
130
+ fun userKeyRidesEventsOnlyAfterUserIsCalled() {
131
+ started()
132
+ Sentori.warn("before")
133
+ assertNull("no identity, no key — not an empty one", queued().last()["userKey"])
134
+
135
+ Sentori.user("usr_123", null)
136
+ Sentori.warn("after")
137
+ assertEquals(SentoriIdentity.hash("id", "usr_123"), queued().last()["userKey"])
138
+ }
139
+
140
+ @Test
141
+ fun contextMergesAndRidesEveryEvent() {
142
+ started()
143
+ Sentori.context(mapOf("tenant" to "acme"))
144
+ Sentori.context(mapOf("plan" to "pro"))
145
+ Sentori.probe("SEN-1")
146
+
147
+ @Suppress("UNCHECKED_CAST")
148
+ val payload = queued().last()["payload"] as Map<String, Any?>
149
+ @Suppress("UNCHECKED_CAST") val ctx = payload["context"] as Map<String, Any?>
150
+ assertEquals("acme", ctx["tenant"])
151
+ assertEquals("pro", ctx["plan"])
152
+ }
153
+
154
+ @Test
155
+ fun eventIdsAreUuidV7AndSortByTime() {
156
+ val first = Sentori.newEventId()
157
+ Thread.sleep(10)
158
+ val second = Sentori.newEventId()
159
+ assertEquals(36, first.length)
160
+ assertEquals("version nibble", '7', first[14])
161
+ assertTrue("v7 ids sort by creation time as strings", first < second)
162
+ }
163
+
164
+ @Test
165
+ fun theQueueIsBoundedAndDropsOldestFirst() {
166
+ started()
167
+ // Not flushed: nothing drains to a dead port fast enough to
168
+ // race this, so it measures the cap rather than a timer.
169
+ repeat(2000) { Sentori.trace("t$it") }
170
+ assertTrue("an unbounded queue is a leak with a nicer name", queued().size <= 500)
171
+ assertEquals("t1999", queued().last()["name"])
172
+ }
173
+
174
+ @Test
175
+ fun garbageInDataDoesNotReachTheCaller() {
176
+ started()
177
+ val id =
178
+ Sentori.warn(
179
+ "bad",
180
+ mapOf("nan" to Double.NaN, "obj" to Any(), "nested" to mapOf("k" to Double.NaN)),
181
+ )
182
+ assertEquals(36, id.length)
183
+ assertNotNull(queued().last())
184
+ }
185
+ }
@@ -0,0 +1,129 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/ios/Sources/Sentori/SentoriAttachment.swift
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ import Foundation
5
+
6
+ /// Blobs that belong to an event: the screenshot taken as the app
7
+ /// died, the wireframe replay ring behind it.
8
+ ///
9
+ /// They upload *after* the event, never before. The server keys an
10
+ /// attachment on an event id it must already know, so an upload that
11
+ /// races the batch 404s — and it always wins that race, because the
12
+ /// batch waits for a flush and the upload does not.
13
+ ///
14
+ /// The multipart body is built by hand rather than with a form API.
15
+ /// React Native learned that the expensive way: its `FormData` file
16
+ /// part wants a `uri`, the `data:` form throws a bare network error on
17
+ /// iOS, and every JS attachment died silently for a release. Doing it
18
+ /// literally is both shorter and checkable.
19
+ enum SentoriAttachment {
20
+
21
+ /// The kinds the server's CHECK constraint accepts, from
22
+ /// `handlers/sdk/events_attachments.rs`. Anything else is a 400,
23
+ /// so it is dropped here with the round trip saved.
24
+ ///
25
+ /// This list is worth keeping honest: the first version of it had
26
+ /// three entries, and the crash handler's view tree — written on
27
+ /// both platforms, kind `viewTree` — was not one of them. It
28
+ /// would have been dropped on the way out by the very code meant
29
+ /// to deliver it, silently, with the event arriving intact and
30
+ /// the evidence missing.
31
+ static let known: Set<String> = [
32
+ "logTail", "replay", "screens", "screenshot",
33
+ "sessionTrail", "stateSnapshot", "viewTree",
34
+ ]
35
+
36
+ /// `android` | `ios` | `js`, likewise CHECK-constrained.
37
+ static let knownSources: Set<String> = ["android", "ios", "js"]
38
+
39
+ /// Test seam: capture instead of sending.
40
+ ///
41
+ /// What is worth asserting here is *what* would be uploaded and
42
+ /// *when* — the kind, the event it is keyed on, the body, and
43
+ /// that none of it moves before the batch lands. None of that is
44
+ /// about TCP. The wire itself is covered by `ios-live-ingest`,
45
+ /// which posts to a real server and reads the blob back.
46
+ static var recorderForTests: ((String, String, String) -> Void)?
47
+
48
+ /// Upload one blob against an event the server already has.
49
+ ///
50
+ /// Fire and forget: nothing here reaches the caller, and a
51
+ /// failure costs the attachment rather than the crash report it
52
+ /// belongs to.
53
+ static func upload(
54
+ eventId: String,
55
+ kind: String,
56
+ base64: String,
57
+ mediaType: String,
58
+ source: String = "ios",
59
+ completion: ((Bool) -> Void)? = nil
60
+ ) {
61
+ guard let config = SentoriConfig.current, known.contains(kind),
62
+ knownSources.contains(source),
63
+ let encodedId = eventId.addingPercentEncoding(
64
+ withAllowedCharacters: .urlPathAllowed),
65
+ let url = URL(
66
+ string: "\(config.ingestUrl)/v1/events/\(encodedId)/attachments/\(kind)")
67
+ else {
68
+ completion?(false)
69
+ return
70
+ }
71
+
72
+ let boundary = "----sentori-\(eventId)"
73
+ if let record = recorderForTests {
74
+ record(
75
+ eventId, kind,
76
+ multipartBody(
77
+ boundary: boundary, kind: kind, mediaType: mediaType, base64: base64,
78
+ source: source))
79
+ completion?(true)
80
+ return
81
+ }
82
+ var body = ""
83
+ body += "--\(boundary)\r\n"
84
+ body += "Content-Disposition: form-data; name=\"file\"; filename=\"\(kind).bin\"\r\n"
85
+ body += "Content-Type: \(mediaType)\r\n"
86
+ // The server decodes when told to. Without this header it
87
+ // stores the base64 text as the image, which renders as
88
+ // nothing and looks like a capture problem.
89
+ body += "Content-Transfer-Encoding: base64\r\n"
90
+ body += "\r\n\(base64)\r\n"
91
+ body += "--\(boundary)\r\n"
92
+ body += "Content-Disposition: form-data; name=\"source\"\r\n"
93
+ body += "\r\n\(source)\r\n"
94
+ body += "--\(boundary)--\r\n"
95
+
96
+ var request = URLRequest(url: url)
97
+ request.httpMethod = "POST"
98
+ request.setValue(
99
+ "multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
100
+ request.setValue("Bearer \(config.token)", forHTTPHeaderField: "Authorization")
101
+ request.setValue("swift/\(SentoriVersion.current)", forHTTPHeaderField: "Sentori-Sdk")
102
+ request.httpBody = body.data(using: .utf8)
103
+ request.timeoutInterval = 30
104
+
105
+ URLSession.shared.dataTask(with: request) { _, response, _ in
106
+ let code = (response as? HTTPURLResponse)?.statusCode ?? 0
107
+ completion?((200..<300).contains(code))
108
+ }.resume()
109
+ }
110
+
111
+ /// The multipart body, exposed so a test can read what would go
112
+ /// on the wire. Building it correctly is the whole job here, and
113
+ /// the failure mode is a 2xx that stored the wrong bytes.
114
+ static func multipartBody(
115
+ boundary: String, kind: String, mediaType: String, base64: String, source: String
116
+ ) -> String {
117
+ var body = ""
118
+ body += "--\(boundary)\r\n"
119
+ body += "Content-Disposition: form-data; name=\"file\"; filename=\"\(kind).bin\"\r\n"
120
+ body += "Content-Type: \(mediaType)\r\n"
121
+ body += "Content-Transfer-Encoding: base64\r\n"
122
+ body += "\r\n\(base64)\r\n"
123
+ body += "--\(boundary)\r\n"
124
+ body += "Content-Disposition: form-data; name=\"source\"\r\n"
125
+ body += "\r\n\(source)\r\n"
126
+ body += "--\(boundary)--\r\n"
127
+ return body
128
+ }
129
+ }
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/ios/Sources/Sentori/SentoriCrashHandler.swift
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  import Foundation
2
5
  import MachO
3
6
 
@@ -64,6 +67,34 @@ import MachO
64
67
  return UserDefaults.standard.dictionary(forKey: configKey) ?? [:]
65
68
  }
66
69
 
70
+ /// Test-only seam onto `write`, which the real path reaches from
71
+ /// an `@convention(c)` handler an XCTest cannot raise safely.
72
+ ///
73
+ /// The test that calls this was written against this name and has
74
+ /// never compiled: the sources were loose files in an Expo module
75
+ /// with no module to import, so nothing ever built the test target
76
+ /// and nothing reported that the member was missing. Internal
77
+ /// rather than public — `@testable import` reaches it, an app
78
+ /// does not.
79
+ internal static func persistForTesting(exception: NSException) {
80
+ write(exception: exception)
81
+ }
82
+
83
+ /// Test-only seam that writes a crash file verbatim.
84
+ ///
85
+ /// `persistForTesting(exception:)` goes through `write`, which
86
+ /// composes the file from a live exception and a live screen —
87
+ /// neither of which an XCTest can arrange. Delivery tests need to
88
+ /// state the file they are about to deliver, particularly the
89
+ /// pre-death attachments, which only exist when there was a
90
+ /// window to capture.
91
+ internal static func persistRawForTesting(_ event: [String: Any]) {
92
+ guard let dir = pendingDir(),
93
+ let data = try? JSONSerialization.data(withJSONObject: event)
94
+ else { return }
95
+ try? data.write(to: dir.appendingPathComponent("\(UUID().uuidString.lowercased()).json"))
96
+ }
97
+
67
98
  private static func write(exception: NSException) {
68
99
  let cfg = config()
69
100
  let release = (cfg["release"] as? String) ?? "unknown"
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/ios/Sources/Sentori/SentoriHangWatchdog.swift
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  import Foundation
2
5
 
3
6
  /// iOS hang detector — mirrors the Android ANR watchdog (Phase 22 sub-D).
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/ios/Sources/Sentori/SentoriMobileVitals.swift
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  import Foundation
2
5
  import UIKit
3
6
  import QuartzCore
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/ios/Sources/Sentori/SentoriNativeExceptionBridge.swift
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  import Foundation
2
5
 
3
6
  /// v0.9.5 #8 — partial-fix for the "TurboModule swallows NSException
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/ios/Sources/Sentori/SentoriPushNotifications.swift
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  // v2.9 — iOS push notification bridge.
2
5
  //
3
6
  // Owns:
@@ -40,9 +43,40 @@ import ObjectiveC.runtime
40
43
 
41
44
  // MARK: Permission
42
45
 
46
+ /// `UNUserNotificationCenter.current()` raises
47
+ /// `NSInternalInconsistencyException` — "bundleProxyForCurrentProcess
48
+ /// is nil" — in any process without an app bundle: a unit-test
49
+ /// target that links this SDK, a command-line tool, some extension
50
+ /// hosts. An Objective-C exception cannot be caught in Swift, so
51
+ /// it terminates the host outright.
52
+ ///
53
+ /// That is the failure-isolation rule broken in the loudest
54
+ /// possible way, and it is the SDK crashing the app rather than
55
+ /// the app crashing. Found when this package's own test target
56
+ /// called it and the runner died mid-suite.
57
+ ///
58
+ /// The precondition is whether this process *is* an app bundle,
59
+ /// not whether it has an identifier. `bundleIdentifier` is
60
+ /// non-nil in an xctest host and the call raised anyway: the
61
+ /// message names the real cause — `mainBundle.bundleURL` pointing
62
+ /// at Xcode's `Agents/` directory, which LaunchServices has no
63
+ /// bundle proxy for.
64
+ ///
65
+ /// An app is `…/Foo.app`; a notification service extension is
66
+ /// `…/Bar.appex` and legitimately uses this API. Anything else —
67
+ /// a test runner, a command-line tool — gets "unavailable" and a
68
+ /// `no-transport` result rather than a terminated process.
69
+ private var notificationCentreIsUsable: Bool {
70
+ ["app", "appex"].contains(Bundle.main.bundleURL.pathExtension)
71
+ }
72
+
43
73
  /// Returns the current permission status as a JS-friendly string.
44
74
  /// Does NOT prompt.
45
75
  @objc public func currentPermission(completion: @escaping (String) -> Void) {
76
+ guard notificationCentreIsUsable else {
77
+ completion("unavailable")
78
+ return
79
+ }
46
80
  UNUserNotificationCenter.current().getNotificationSettings { settings in
47
81
  completion(authString(settings.authorizationStatus))
48
82
  }
@@ -51,6 +85,10 @@ import ObjectiveC.runtime
51
85
  /// Requests authorization. Triggers the OS prompt the first time;
52
86
  /// subsequent calls return the cached decision.
53
87
  @objc public func requestPermission(completion: @escaping (String) -> Void) {
88
+ guard notificationCentreIsUsable else {
89
+ completion("unavailable")
90
+ return
91
+ }
54
92
  let center = UNUserNotificationCenter.current()
55
93
  center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
56
94
  if let error = error {
@@ -71,6 +109,10 @@ import ObjectiveC.runtime
71
109
  /// method, which routes the token into our buffer.
72
110
  @objc public func registerForRemoteNotifications() {
73
111
  ensureSwizzleInstalled()
112
+ // Same guard as the permission calls: installing the
113
+ // delegate touches the notification centre, which raises in a
114
+ // process that is not an app bundle.
115
+ guard notificationCentreIsUsable else { return }
74
116
  UNUserNotificationCenter.current().delegate = self
75
117
  DispatchQueue.main.async {
76
118
  UIApplication.shared.registerForRemoteNotifications()
@@ -96,9 +138,17 @@ import ObjectiveC.runtime
96
138
  @objc public func handleRegisteredToken(_ deviceToken: Data) {
97
139
  let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
98
140
  bufferLock.lock()
141
+ let changed = tokenHex != nil && tokenHex != hex
99
142
  tokenHex = hex
100
143
  registrationError = nil
101
144
  bufferLock.unlock()
145
+
146
+ // A token that replaced a different one is a rotation, and the
147
+ // server has to hear about it now. Buffering it in a field was
148
+ // the whole of what happened here before, so a rotation stayed
149
+ // invisible until the host next called `register` — and the
150
+ // device received nothing in between.
151
+ if changed { Sentori.push.handleRotatedToken(hex) }
102
152
  }
103
153
 
104
154
  /// Host-callable counterpart for the failure path.
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/ios/Sources/Sentori/SentoriReplayCapture.swift
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  import Foundation
2
5
  import UIKit
3
6
 
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/ios/Sources/Sentori/SentoriScreenshotCapture.swift
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  import Foundation
2
5
  import UIKit
3
6
 
@@ -309,6 +312,17 @@ import UIKit
309
312
  return found
310
313
  }
311
314
 
315
+ /// Test-only seam onto the tree walk. The test that covers the
316
+ /// depth cap reached for it through
317
+ /// `value(forKey: "viewTreeForTesting")` — a name nothing defines,
318
+ /// and KVC cannot reach a Swift static method under any name. It
319
+ /// compiled because KVC is resolved at runtime, and it never ran
320
+ /// because the sources were loose files in an Expo module with no
321
+ /// test target to build.
322
+ internal static func viewTreeForTesting(root: UIView) -> [String: Any] {
323
+ return walkTree(root: root)
324
+ }
325
+
312
326
  private static func walkTree(root: UIView) -> [String: Any] {
313
327
  var nodes: [String: Any] = [:]
314
328
  var counter = 0
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/ios/Sources/Sentori/SentoriThreadSampler.swift
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  import Foundation
2
5
 
3
6
  /// Captures the main thread's program-counter chain for hang reporting.
package/lib/push.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"AAqCA,OAAO,EAEL,aAAa,EAEb,qBAAqB,EAEtB,MAAM,aAAa,CAAA;AAqBpB,MAAM,MAAM,mBAAmB,GAAG;IAChC;;;;;;mEAM+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAClC;kDAC8C;IAC9C,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,IAAI,CAAA;IACtD,sDAAsD;IACtD,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IAC/B;iEAC6D;IAC7D,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B;;yBAEqB;IACrB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAA;IAC9B;;;kBAGc;IACd,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,mBAAmB,GAC3B,iBAAiB,GACjB,cAAc,GACd,mBAAmB,GACnB,iBAAiB,GACjB,eAAe,CAAA;AAEnB;;;;mCAImC;AACnC,MAAM,MAAM,kBAAkB,GAC1B;IACE,EAAE,EAAE,IAAI,CAAA;IACR;;oDAEgD;IAChD,GAAG,EAAE,MAAM,CAAA;CACZ,GACD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAA;CAAE,CAAA;AAE/D,MAAM,MAAM,uBAAuB,GAAG;IACpC,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAClC,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,QAAQ,CAAC,IAAI,GAAE,mBAAwB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAqC1F;AAyBD;;;GAGG;AACH,wBAAsB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAiBhD;AAED;4DAC4D;AAC5D,wBAAgB,YAAY,IAAI,IAAI,GAAG,MAAM,CAE5C;AAED,sDAAsD;AACtD,OAAO,EAAE,aAAa,IAAI,SAAS,EAAE,qBAAqB,IAAI,iBAAiB,EAAE,CAAA;AAsNjF;oEACoE;AACpE,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAEhE;AA+ED;;+DAE+D;AAC/D,wBAAgB,eAAe,IAAI,IAAI,CAMtC;AAID;qCACqC;AACrC,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,KAAK,GAAG,SAAS,GAAG,SAAS,GAAG,IAAI,GAAG,IAAI,CAEnF"}
1
+ {"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"AAqCA,OAAO,EAEL,aAAa,EAEb,qBAAqB,EAEtB,MAAM,aAAa,CAAA;AA6BpB,MAAM,MAAM,mBAAmB,GAAG;IAChC;;;;;;mEAM+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAClC;kDAC8C;IAC9C,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,IAAI,CAAA;IACtD,sDAAsD;IACtD,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IAC/B;iEAC6D;IAC7D,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B;;yBAEqB;IACrB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAA;IAC9B;;;kBAGc;IACd,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,mBAAmB,GAC3B,iBAAiB,GACjB,cAAc,GACd,mBAAmB,GACnB,iBAAiB,GACjB,eAAe,CAAA;AAEnB;;;;mCAImC;AACnC,MAAM,MAAM,kBAAkB,GAC1B;IACE,EAAE,EAAE,IAAI,CAAA;IACR;;oDAEgD;IAChD,GAAG,EAAE,MAAM,CAAA;CACZ,GACD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAA;CAAE,CAAA;AAE/D,MAAM,MAAM,uBAAuB,GAAG;IACpC,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAClC,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,QAAQ,CAAC,IAAI,GAAE,mBAAwB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAyC1F;AAgDD;;;GAGG;AACH,wBAAsB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAoBhD;AAED;4DAC4D;AAC5D,wBAAgB,YAAY,IAAI,IAAI,GAAG,MAAM,CAE5C;AAED,sDAAsD;AACtD,OAAO,EAAE,aAAa,IAAI,SAAS,EAAE,qBAAqB,IAAI,iBAAiB,EAAE,CAAA;AA4NjF;oEACoE;AACpE,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAEhE;AA+ED;;+DAE+D;AAC/D,wBAAgB,eAAe,IAAI,IAAI,CAUtC;AAID;qCACqC;AACrC,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,KAAK,GAAG,SAAS,GAAG,SAAS,GAAG,IAAI,GAAG,IAAI,CAEnF"}
package/lib/push.js CHANGED
@@ -21,10 +21,18 @@
21
21
  // buffered events while the app is foreground. Pauses on
22
22
  // background, resumes on active, per the perf iron rule.
23
23
  import { logger, pushSignal } from '@goliapkg/sentori-core';
24
- import { currentUserKey } from './scope.js';
24
+ import { currentUserKey, currentUserTraits, onIdentityChange } from './scope.js';
25
25
  import { pushDrainState, pushGetStatus, pushRegister as nativePushRegister, pushRequestPermission, pushUnregister as nativePushUnregister, } from './native.js';
26
26
  const STORAGE_KEY = 'sentori.push.ipt';
27
27
  let _cachedIpt = null;
28
+ // The last token the vendor handed us, kept so a sign-in can re-send
29
+ // the registration without asking the OS for a token again.
30
+ let _lastNativeToken = null;
31
+ let _lastOptions = {};
32
+ // What the server was last told. A re-registration that would send
33
+ // the same thing is not sent: `user()` is a verb an app may call on
34
+ // every screen, and one HTTP request per call is not free to a host.
35
+ let _lastSentIdentity = null;
28
36
  let _drainInterval = null;
29
37
  let _appStateSubscription = null;
30
38
  let _backgrounded = false;
@@ -77,9 +85,13 @@ export async function register(opts = {}) {
77
85
  const token = await waitForToken(opts.tokenTimeoutMs ?? 8000);
78
86
  const ipt = await registerWithServer(cfg, token, opts);
79
87
  _cachedIpt = ipt;
88
+ _lastNativeToken = token;
89
+ _lastOptions = opts;
80
90
  void persistIpt(ipt);
81
91
  opts.onToken?.(ipt);
82
92
  bindBufferDrain(opts.onMessage, opts.onTap);
93
+ // From here on, signing in or out updates the row by itself.
94
+ onIdentityChange(reRegisterAfterIdentityChange);
83
95
  return { ok: true, ipt };
84
96
  }
85
97
  catch (e) {
@@ -95,6 +107,30 @@ function fail(opts, reason, message) {
95
107
  opts.onError?.(new Error(message));
96
108
  return { ok: false, message, reason };
97
109
  }
110
+ /**
111
+ * Send the registration again because the person changed.
112
+ *
113
+ * Only for a device that has already registered — the vendor token is
114
+ * the evidence, the same rule the native SDKs use for a rotated
115
+ * token. Nothing here throws and nothing here is awaited by the host:
116
+ * `user()` is synchronous and stays that way.
117
+ */
118
+ function reRegisterAfterIdentityChange() {
119
+ const token = _lastNativeToken;
120
+ const cfg = tryGetRuntimeConfig();
121
+ if (token == null || cfg == null)
122
+ return;
123
+ const identity = JSON.stringify([currentUserKey() ?? null, currentUserTraits() ?? null]);
124
+ if (identity === _lastSentIdentity)
125
+ return;
126
+ _lastSentIdentity = identity;
127
+ void registerWithServer(cfg, token, _lastOptions).catch((e) => {
128
+ // A failed update leaves the row pointing at the previous person,
129
+ // so the next change must be allowed to try again.
130
+ _lastSentIdentity = null;
131
+ logger.warn('push', 'updating the device after a sign-in failed', e);
132
+ });
133
+ }
98
134
  /** Internal carrier so the helpers below can name which failure they
99
135
  * are without every one of them returning a result object. */
100
136
  class PushRegisterError extends Error {
@@ -124,6 +160,9 @@ export async function unregister() {
124
160
  }
125
161
  }
126
162
  nativePushUnregister();
163
+ onIdentityChange(undefined);
164
+ _lastNativeToken = null;
165
+ _lastSentIdentity = null;
127
166
  _cachedIpt = null;
128
167
  void clearPersistedIpt();
129
168
  teardownBufferDrain();
@@ -196,6 +235,13 @@ async function registerWithServer(cfg, nativeToken, opts) {
196
235
  // not — 2026-08-11.)
197
236
  if (opts.metadata != null)
198
237
  body.metadata = opts.metadata;
238
+ // Attributes of the person rather than of the device, kept apart so
239
+ // a build channel called "pro" cannot answer a send aimed at the pro
240
+ // plan. Absent leaves whatever the row already had; `{}` clears it,
241
+ // which is what signing out sends.
242
+ const traits = currentUserTraits();
243
+ if (traits != null)
244
+ body.traits = traits;
199
245
  const res = await fetch(joinUrl(cfg.ingestUrl, '/v1/push/tokens'), {
200
246
  method: 'POST',
201
247
  headers: {
@@ -412,6 +458,10 @@ export function __resetForTests() {
412
458
  _onMessage = undefined;
413
459
  _onTap = undefined;
414
460
  _ackQueue = [];
461
+ _lastNativeToken = null;
462
+ _lastOptions = {};
463
+ _lastSentIdentity = null;
464
+ onIdentityChange(undefined);
415
465
  teardownBufferDrain();
416
466
  }
417
467
  let _platformOverride = null;