@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.
- package/SentoriReactNative.podspec +11 -4
- package/android/build.gradle +21 -1
- package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
- package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
- package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
- package/android/src/main/java-core/com/sentori/SentoriPush.kt +588 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
- package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +476 -0
- package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
- package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +149 -0
- package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
- package/ios/core/SentoriAttachment.swift +129 -0
- package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -0
- package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
- package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
- package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
- package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -0
- package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
- package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
- package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +51 -1
- package/lib/push.js.map +1 -1
- package/lib/scope.d.ts +6 -0
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +45 -1
- package/lib/scope.js.map +1 -1
- package/lib/transport.js +1 -1
- package/package.json +2 -2
- package/src/__tests__/push.test.ts +99 -1
- package/src/push.ts +49 -1
- package/src/scope.ts +50 -1
- package/src/transport.ts +1 -1
- package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
- package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
- 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/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/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;
|
|
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;
|