@goliapkg/sentori-react-native 6.2.0 → 6.2.1
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 +15 -1
- package/android/src/main/java-core/com/sentori/Sentori.kt +244 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +3 -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/SentoriPush.kt +359 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +60 -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 +383 -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 +106 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +151 -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/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +16 -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/core/SentoriPush.swift +340 -0
- package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +42 -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/transport.js +1 -1
- package/package.json +1 -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
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// GENERATED MIRROR — do not edit.
|
|
2
|
+
// Source of truth: sdk/native/ios/Sources/Sentori/SentoriPush.swift
|
|
3
|
+
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
4
|
+
import Foundation
|
|
5
|
+
|
|
6
|
+
/// Push, as an app writes it.
|
|
7
|
+
///
|
|
8
|
+
/// let r = await Sentori.push.register()
|
|
9
|
+
/// if case .failure(let reason) = r { … }
|
|
10
|
+
///
|
|
11
|
+
/// The pieces underneath already existed — permission, the
|
|
12
|
+
/// `AppDelegate` swizzle, the token buffer, the tap and foreground
|
|
13
|
+
/// callbacks — and were reachable only through the React Native
|
|
14
|
+
/// bridge. What was missing everywhere but JavaScript is the part
|
|
15
|
+
/// that puts a token on the server, which is the only reason a
|
|
16
|
+
/// registered device is reachable at all.
|
|
17
|
+
///
|
|
18
|
+
/// Asked for by insight (2026-08-11): two apps with no React Native,
|
|
19
|
+
/// blocked, and unwilling to reimplement the HTTP contract because a
|
|
20
|
+
/// second implementation drifts silently on the one path where nobody
|
|
21
|
+
/// is watching.
|
|
22
|
+
@objc(SentoriPush)
|
|
23
|
+
public final class SentoriPush: NSObject {
|
|
24
|
+
|
|
25
|
+
/// Why a registration did not produce a device handle. Each value
|
|
26
|
+
/// asks the host for something different, which is the only
|
|
27
|
+
/// reason to distinguish them — matching `PushRegisterFailure` in
|
|
28
|
+
/// the React Native SDK exactly, so the same integration notes
|
|
29
|
+
/// apply on both.
|
|
30
|
+
@objc public enum Failure: Int {
|
|
31
|
+
/// `Sentori.start` has not run. A wiring bug.
|
|
32
|
+
case notInitialised
|
|
33
|
+
/// The user said no. Not an error: do not retry on a timer,
|
|
34
|
+
/// and offer it again from a settings screen.
|
|
35
|
+
case permissionDenied
|
|
36
|
+
/// No push entitlement in this build, or a simulator without
|
|
37
|
+
/// one. Nothing to do at runtime.
|
|
38
|
+
case noTransport
|
|
39
|
+
/// The OS never handed back a token inside the window.
|
|
40
|
+
/// Usually provisioning; retrying later is reasonable.
|
|
41
|
+
case tokenTimeout
|
|
42
|
+
/// Sentori answered non-2xx. Settings ▸ Push is where to look.
|
|
43
|
+
case serverRejected
|
|
44
|
+
|
|
45
|
+
public var name: String {
|
|
46
|
+
switch self {
|
|
47
|
+
case .notInitialised: return "not-initialised"
|
|
48
|
+
case .permissionDenied: return "permission-denied"
|
|
49
|
+
case .noTransport: return "no-transport"
|
|
50
|
+
case .tokenTimeout: return "token-timeout"
|
|
51
|
+
case .serverRejected: return "server-rejected"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Registration never throws. A denied permission is an ordinary
|
|
57
|
+
/// answer, and an opt-in that throws inside someone's view model
|
|
58
|
+
/// is the failure this SDK's contract with its host is written
|
|
59
|
+
/// against.
|
|
60
|
+
public enum Result {
|
|
61
|
+
/// The `device_tokens` row id. Revoking takes it, and so does
|
|
62
|
+
/// a targeted send.
|
|
63
|
+
case success(handle: String)
|
|
64
|
+
case failure(reason: Failure, message: String)
|
|
65
|
+
|
|
66
|
+
public var handle: String? {
|
|
67
|
+
if case .success(let h) = self { return h }
|
|
68
|
+
return nil
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@objc public static let shared = SentoriPush()
|
|
73
|
+
|
|
74
|
+
private let lock = NSLock()
|
|
75
|
+
private var cachedHandle: String?
|
|
76
|
+
private var onMessage: (([String: Any]) -> Void)?
|
|
77
|
+
private var onTap: (([String: Any]) -> Void)?
|
|
78
|
+
private var drainTimer: DispatchSourceTimer?
|
|
79
|
+
|
|
80
|
+
private static let handleKey = "com.sentori.push.handle"
|
|
81
|
+
|
|
82
|
+
/// Ask for permission, get a token, register it.
|
|
83
|
+
///
|
|
84
|
+
/// Safe to call on every launch: the OS returns its cached
|
|
85
|
+
/// decision without re-prompting, and the server upserts on
|
|
86
|
+
/// `(project, provider, token)`.
|
|
87
|
+
///
|
|
88
|
+
/// Call `Sentori.user` first if the device should be reachable
|
|
89
|
+
/// from an issue. Without it the registration carries no user key
|
|
90
|
+
/// and the device receives broadcasts only — the dashboard shows
|
|
91
|
+
/// that as "N devices, 0 addressable", which is the one symptom
|
|
92
|
+
/// with no other explanation.
|
|
93
|
+
@discardableResult
|
|
94
|
+
public func register(
|
|
95
|
+
timeout: TimeInterval = 8,
|
|
96
|
+
onMessage: (([String: Any]) -> Void)? = nil,
|
|
97
|
+
onTap: (([String: Any]) -> Void)? = nil
|
|
98
|
+
) async -> Result {
|
|
99
|
+
guard let config = SentoriConfig.current else {
|
|
100
|
+
return .failure(reason: .notInitialised, message: "Sentori.start has not run")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Bind before asking for anything: iOS replays a tap that
|
|
104
|
+
// happened while the app was not running as soon as the
|
|
105
|
+
// delegate attaches, and a callback set afterwards misses it.
|
|
106
|
+
lock.lock()
|
|
107
|
+
self.onMessage = onMessage
|
|
108
|
+
self.onTap = onTap
|
|
109
|
+
lock.unlock()
|
|
110
|
+
|
|
111
|
+
let status = await currentOrRequestPermission()
|
|
112
|
+
// "unavailable" is the native layer saying there is no app
|
|
113
|
+
// bundle to ask on behalf of — a test host, a CLI, some
|
|
114
|
+
// extensions. Not a decision the user made.
|
|
115
|
+
guard let status, status != "unavailable" else {
|
|
116
|
+
return .failure(reason: .noTransport, message: "no push support in this build")
|
|
117
|
+
}
|
|
118
|
+
guard ["granted", "provisional", "ephemeral"].contains(status) else {
|
|
119
|
+
return .failure(reason: .permissionDenied, message: "push permission '\(status)'")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
SentoriPushNotifications.shared.registerForRemoteNotifications()
|
|
123
|
+
guard let token = await waitForToken(timeout: timeout) else {
|
|
124
|
+
return .failure(
|
|
125
|
+
reason: .tokenTimeout,
|
|
126
|
+
message: "no device token within \(Int(timeout))s"
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
switch await registerWithServer(token: token, config: config) {
|
|
131
|
+
case .success(let handle):
|
|
132
|
+
lock.lock()
|
|
133
|
+
cachedHandle = handle
|
|
134
|
+
lock.unlock()
|
|
135
|
+
UserDefaults.standard.set(handle, forKey: Self.handleKey)
|
|
136
|
+
startDrain()
|
|
137
|
+
return .success(handle: handle)
|
|
138
|
+
case .failure(let reason, let message):
|
|
139
|
+
return .failure(reason: reason, message: message)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// The handle from an earlier `register`, without a round trip.
|
|
144
|
+
@objc public func cachedDeviceHandle() -> String? {
|
|
145
|
+
lock.lock()
|
|
146
|
+
let h = cachedHandle
|
|
147
|
+
lock.unlock()
|
|
148
|
+
return h ?? UserDefaults.standard.string(forKey: Self.handleKey)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Current permission without prompting. `nil` when there is no
|
|
152
|
+
/// push support in this build.
|
|
153
|
+
public func permissionStatus() async -> String? {
|
|
154
|
+
await withCheckedContinuation { cont in
|
|
155
|
+
SentoriPushNotifications.shared.currentPermission { cont.resume(returning: $0) }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Revoke the handle server-side and stop local delivery.
|
|
160
|
+
/// Idempotent — repeat calls do nothing.
|
|
161
|
+
@discardableResult
|
|
162
|
+
public func unregister() async -> Bool {
|
|
163
|
+
let handle = cachedDeviceHandle()
|
|
164
|
+
lock.lock()
|
|
165
|
+
cachedHandle = nil
|
|
166
|
+
onMessage = nil
|
|
167
|
+
onTap = nil
|
|
168
|
+
drainTimer?.cancel()
|
|
169
|
+
drainTimer = nil
|
|
170
|
+
lock.unlock()
|
|
171
|
+
UserDefaults.standard.removeObject(forKey: Self.handleKey)
|
|
172
|
+
SentoriPushNotifications.shared.unregisterForRemoteNotifications()
|
|
173
|
+
|
|
174
|
+
guard let handle, let config = SentoriConfig.current,
|
|
175
|
+
let url = URL(string: "\(config.ingestUrl)/v1/push/tokens/\(handle)")
|
|
176
|
+
else { return false }
|
|
177
|
+
|
|
178
|
+
var request = URLRequest(url: url)
|
|
179
|
+
request.httpMethod = "DELETE"
|
|
180
|
+
request.setValue("Bearer \(config.token)", forHTTPHeaderField: "Authorization")
|
|
181
|
+
request.timeoutInterval = 15
|
|
182
|
+
let ok = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
183
|
+
URLSession.shared.dataTask(with: request) { _, response, _ in
|
|
184
|
+
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
185
|
+
cont.resume(returning: (200..<300).contains(code))
|
|
186
|
+
}.resume()
|
|
187
|
+
}
|
|
188
|
+
return ok
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── internals ─────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
private func currentOrRequestPermission() async -> String? {
|
|
194
|
+
let current = await permissionStatus()
|
|
195
|
+
// `nil` means no native push at all; asking would not change
|
|
196
|
+
// that. Anything already decided is returned as decided,
|
|
197
|
+
// because a second prompt is not something iOS shows anyway.
|
|
198
|
+
guard let current else { return nil }
|
|
199
|
+
if current == "undetermined" || current == "notDetermined" {
|
|
200
|
+
return await withCheckedContinuation { cont in
|
|
201
|
+
SentoriPushNotifications.shared.requestPermission { cont.resume(returning: $0) }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return current
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// The token arrives asynchronously through the AppDelegate
|
|
208
|
+
/// swizzle and lands in the native buffer, so this polls it. Any
|
|
209
|
+
/// notifications or taps that arrive alongside are delivered
|
|
210
|
+
/// rather than dropped on the floor.
|
|
211
|
+
private func waitForToken(timeout: TimeInterval) async -> String? {
|
|
212
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
213
|
+
while Date() < deadline {
|
|
214
|
+
let state = SentoriPushNotifications.shared.drainState()
|
|
215
|
+
flush(state)
|
|
216
|
+
if let token = state["token"] as? String { return token }
|
|
217
|
+
if state["error"] != nil { return nil }
|
|
218
|
+
try? await Task.sleep(nanoseconds: 200_000_000)
|
|
219
|
+
}
|
|
220
|
+
return nil
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private func registerWithServer(token: String, config: SentoriConfig) async -> Result {
|
|
224
|
+
guard let url = URL(string: "\(config.ingestUrl)/v1/push/tokens") else {
|
|
225
|
+
return .failure(reason: .serverRejected, message: "bad ingest url")
|
|
226
|
+
}
|
|
227
|
+
var body: [String: Any] = [
|
|
228
|
+
// `kind`, not `provider`. The React Native SDK sent
|
|
229
|
+
// `provider` for a year and earned a 422 for every
|
|
230
|
+
// registration it ever attempted.
|
|
231
|
+
"kind": "apns",
|
|
232
|
+
"nativeToken": token,
|
|
233
|
+
// Sandbox and production APNs are different hosts, and a
|
|
234
|
+
// token minted against one is rejected by the other.
|
|
235
|
+
"env": isDebugBuild ? "sandbox" : "production",
|
|
236
|
+
]
|
|
237
|
+
// The same salted-nothing hash every event carries, so the
|
|
238
|
+
// dashboard can address this device by the person who hit an
|
|
239
|
+
// issue. Absent until the host calls `Sentori.user`.
|
|
240
|
+
if let userKey = SentoriScope.userKey { body["userKey"] = userKey }
|
|
241
|
+
|
|
242
|
+
guard let payload = try? JSONSerialization.data(withJSONObject: body) else {
|
|
243
|
+
return .failure(reason: .serverRejected, message: "could not encode registration")
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
var request = URLRequest(url: url)
|
|
247
|
+
request.httpMethod = "POST"
|
|
248
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
249
|
+
request.setValue("Bearer \(config.token)", forHTTPHeaderField: "Authorization")
|
|
250
|
+
request.setValue("swift/\(SentoriVersion.current)", forHTTPHeaderField: "Sentori-Sdk")
|
|
251
|
+
request.httpBody = payload
|
|
252
|
+
request.timeoutInterval = 15
|
|
253
|
+
|
|
254
|
+
return await withCheckedContinuation { (cont: CheckedContinuation<Result, Never>) in
|
|
255
|
+
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
256
|
+
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
257
|
+
guard (200..<300).contains(code) else {
|
|
258
|
+
let why = error?.localizedDescription ?? "HTTP \(code)"
|
|
259
|
+
cont.resume(
|
|
260
|
+
returning: .failure(reason: .serverRejected, message: why))
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
// The handle is the `device_tokens` row id, a bare
|
|
264
|
+
// uuid. The RN SDK parsed it as an `ipt_*` string no
|
|
265
|
+
// server has ever returned.
|
|
266
|
+
guard let data,
|
|
267
|
+
let j = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
268
|
+
let handle = j["token_id"] as? String, !handle.isEmpty
|
|
269
|
+
else {
|
|
270
|
+
cont.resume(
|
|
271
|
+
returning: .failure(
|
|
272
|
+
reason: .serverRejected,
|
|
273
|
+
message: "server returned no device token id"))
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
cont.resume(returning: .success(handle: handle))
|
|
277
|
+
}.resume()
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/// 1 Hz while registered. The native side buffers arrivals and
|
|
282
|
+
/// taps; this hands them to the host.
|
|
283
|
+
private func startDrain() {
|
|
284
|
+
lock.lock()
|
|
285
|
+
defer { lock.unlock() }
|
|
286
|
+
guard drainTimer == nil else { return }
|
|
287
|
+
let t = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
|
|
288
|
+
t.schedule(deadline: .now() + 1, repeating: 1)
|
|
289
|
+
t.setEventHandler { [weak self] in
|
|
290
|
+
guard let self else { return }
|
|
291
|
+
self.flush(SentoriPushNotifications.shared.drainState())
|
|
292
|
+
}
|
|
293
|
+
drainTimer = t
|
|
294
|
+
t.resume()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private func flush(_ state: [String: Any]) {
|
|
298
|
+
lock.lock()
|
|
299
|
+
let message = onMessage
|
|
300
|
+
let tap = onTap
|
|
301
|
+
lock.unlock()
|
|
302
|
+
guard message != nil || tap != nil else { return }
|
|
303
|
+
|
|
304
|
+
let notifications = state["notifications"] as? [[String: Any]] ?? []
|
|
305
|
+
let taps = state["taps"] as? [[String: Any]] ?? []
|
|
306
|
+
guard !notifications.isEmpty || !taps.isEmpty else { return }
|
|
307
|
+
|
|
308
|
+
// The host's UI lives on the main thread and a notification
|
|
309
|
+
// callback almost always touches it.
|
|
310
|
+
DispatchQueue.main.async {
|
|
311
|
+
notifications.forEach { message?($0) }
|
|
312
|
+
taps.forEach { tap?($0) }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private var isDebugBuild: Bool {
|
|
317
|
+
#if DEBUG
|
|
318
|
+
return true
|
|
319
|
+
#else
|
|
320
|
+
return false
|
|
321
|
+
#endif
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
func __resetForTests() {
|
|
325
|
+
lock.lock()
|
|
326
|
+
cachedHandle = nil
|
|
327
|
+
onMessage = nil
|
|
328
|
+
onTap = nil
|
|
329
|
+
drainTimer?.cancel()
|
|
330
|
+
drainTimer = nil
|
|
331
|
+
lock.unlock()
|
|
332
|
+
UserDefaults.standard.removeObject(forKey: Self.handleKey)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
extension Sentori {
|
|
337
|
+
/// `Sentori.push.register()`, matching `sentori.push.register()`
|
|
338
|
+
/// in the React Native SDK.
|
|
339
|
+
public static var push: SentoriPush { SentoriPush.shared }
|
|
340
|
+
}
|
|
@@ -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()
|
|
@@ -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/transport.js
CHANGED
|
@@ -15,7 +15,7 @@ const MAX_RETRY = 3;
|
|
|
15
15
|
const STORAGE_KEY = '@sentori/pending';
|
|
16
16
|
const MAX_PERSISTED = 1000;
|
|
17
17
|
// Pinned to package.json by a test — bump both together.
|
|
18
|
-
const SDK_VERSION = '6.2.
|
|
18
|
+
const SDK_VERSION = '6.2.1';
|
|
19
19
|
let _queue = [];
|
|
20
20
|
let _assertStats = new Map();
|
|
21
21
|
let _flushTimer = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goliapkg/sentori-react-native",
|
|
3
|
-
"version": "6.2.
|
|
3
|
+
"version": "6.2.1",
|
|
4
4
|
"description": "Sentori SDK for React Native — JS-layer error capture, native crash handlers (iOS / Android), batched transport, fetch + react-navigation tracing.",
|
|
5
5
|
"license": "Apache-2.0 OR MIT",
|
|
6
6
|
"author": "GOLIA K.K. <takagi@golia.jp> (https://golia.jp)",
|
package/src/transport.ts
CHANGED
|
@@ -20,7 +20,7 @@ const STORAGE_KEY = '@sentori/pending';
|
|
|
20
20
|
const MAX_PERSISTED = 1000;
|
|
21
21
|
|
|
22
22
|
// Pinned to package.json by a test — bump both together.
|
|
23
|
-
const SDK_VERSION = '6.2.
|
|
23
|
+
const SDK_VERSION = '6.2.1';
|
|
24
24
|
|
|
25
25
|
let _queue: WireEvent[] = [];
|
|
26
26
|
let _assertStats = new Map<string, AssertStat>();
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
// Phase 16 sub-E: XCTest coverage for SentoriCrashHandler (回填 Phase 7).
|
|
2
|
-
//
|
|
3
|
-
// Run via Xcode (target → SentoriTests, ⌘U) or via xcodebuild from the
|
|
4
|
-
// iOS host:
|
|
5
|
-
// xcodebuild test \
|
|
6
|
-
// -scheme SentoriTests \
|
|
7
|
-
// -destination 'platform=iOS Simulator,name=iPhone 15'
|
|
8
|
-
//
|
|
9
|
-
// The handler writes one JSON file per crash to
|
|
10
|
-
// <Documents>/sentori/pending/<uuid>.json
|
|
11
|
-
// We can't easily re-raise NSException (it tears down the test
|
|
12
|
-
// process), so we exercise the persistence helper directly instead.
|
|
13
|
-
|
|
14
|
-
import XCTest
|
|
15
|
-
@testable import SentoriCrashHandler
|
|
16
|
-
|
|
17
|
-
final class SentoriCrashHandlerTests: XCTestCase {
|
|
18
|
-
private var pendingDir: URL!
|
|
19
|
-
|
|
20
|
-
override func setUpWithError() throws {
|
|
21
|
-
let docs = try FileManager.default.url(
|
|
22
|
-
for: .documentDirectory,
|
|
23
|
-
in: .userDomainMask,
|
|
24
|
-
appropriateFor: nil,
|
|
25
|
-
create: true
|
|
26
|
-
)
|
|
27
|
-
pendingDir = docs.appendingPathComponent("sentori/pending", isDirectory: true)
|
|
28
|
-
try? FileManager.default.removeItem(at: pendingDir)
|
|
29
|
-
try FileManager.default.createDirectory(at: pendingDir, withIntermediateDirectories: true)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
func testWritePendingProducesValidEventJson() throws {
|
|
33
|
-
// Synthesize a payload like the @convention(c) handler would.
|
|
34
|
-
let exception = NSException(
|
|
35
|
-
name: NSExceptionName("XCTestSyntheticException"),
|
|
36
|
-
reason: "boom",
|
|
37
|
-
userInfo: nil
|
|
38
|
-
)
|
|
39
|
-
SentoriCrashHandler.persistForTesting(exception: exception)
|
|
40
|
-
|
|
41
|
-
let files = try FileManager.default.contentsOfDirectory(atPath: pendingDir.path)
|
|
42
|
-
.filter { $0.hasSuffix(".json") }
|
|
43
|
-
XCTAssertEqual(files.count, 1, "expected exactly one pending file, got \(files.count)")
|
|
44
|
-
|
|
45
|
-
let url = pendingDir.appendingPathComponent(files[0])
|
|
46
|
-
let data = try Data(contentsOf: url)
|
|
47
|
-
let payload = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
|
|
48
|
-
XCTAssertNotNil(payload, "pending file is not valid JSON")
|
|
49
|
-
|
|
50
|
-
// Spot-check the protocol shape (Phase 1 schema).
|
|
51
|
-
XCTAssertEqual(payload?["kind"] as? String, "error")
|
|
52
|
-
XCTAssertEqual(payload?["platform"] as? String, "ios")
|
|
53
|
-
XCTAssertNotNil(payload?["id"] as? String)
|
|
54
|
-
XCTAssertNotNil(payload?["timestamp"] as? String)
|
|
55
|
-
let error = payload?["error"] as? [String: Any]
|
|
56
|
-
XCTAssertEqual(error?["type"] as? String, "XCTestSyntheticException")
|
|
57
|
-
XCTAssertEqual(error?["message"] as? String, "boom")
|
|
58
|
-
}
|
|
59
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
// Phase 42 sub-E.10 — XCTest coverage for SentoriScreenshotCapture.
|
|
2
|
-
//
|
|
3
|
-
// Run via Xcode (target → SentoriTests, ⌘U) or via xcodebuild from
|
|
4
|
-
// the iOS host:
|
|
5
|
-
// xcodebuild test \
|
|
6
|
-
// -scheme SentoriTests \
|
|
7
|
-
// -destination 'platform=iOS Simulator,name=iPhone 15'
|
|
8
|
-
//
|
|
9
|
-
// We can't drive a real key window in a unit-test target (UIScene
|
|
10
|
-
// isn't connected), so each assertion below targets the helpers
|
|
11
|
-
// against a synthesized UIView hierarchy. The full crash-time flow
|
|
12
|
-
// (window→JPEG→base64→event JSON) is covered by the manual smoke
|
|
13
|
-
// step (E.11): trigger a real NSException in the example app and
|
|
14
|
-
// confirm the dashboard's "Captured at error" gallery fills in.
|
|
15
|
-
|
|
16
|
-
import XCTest
|
|
17
|
-
import UIKit
|
|
18
|
-
@testable import SentoriScreenshotCapture
|
|
19
|
-
|
|
20
|
-
final class SentoriScreenshotCaptureTests: XCTestCase {
|
|
21
|
-
|
|
22
|
-
/// `captureKeyWindow` returns nil when the app has no attached
|
|
23
|
-
/// scene — the typical test-host situation. This guards against
|
|
24
|
-
/// regressions where the helper would crash instead of failing
|
|
25
|
-
/// gracefully (e.g. force-unwrapping `UIApplication.shared.windows.first`).
|
|
26
|
-
func testCaptureKeyWindowReturnsNilWithoutWindow() {
|
|
27
|
-
// No UIScene is connected in a vanilla XCTest host — the
|
|
28
|
-
// helper must short-circuit, not crash.
|
|
29
|
-
let result = SentoriScreenshotCapture.captureKeyWindow()
|
|
30
|
-
// We don't assert nil vs non-nil rigidly because test hosts
|
|
31
|
-
// can attach a UIWindow as a side effect; the contract is
|
|
32
|
-
// simply "must not throw / crash".
|
|
33
|
-
_ = result
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/// Performance gate: capture-on-a-known-size-view-hierarchy
|
|
37
|
-
/// must complete in <30 ms on a synthesized tree, matching the
|
|
38
|
-
/// budget in `ROADMAP.md` (sub-E.10 perf bench).
|
|
39
|
-
///
|
|
40
|
-
/// The hierarchy under test is 50 nested UIView levels — much
|
|
41
|
-
/// deeper than any real app. The depth cap inside the helper
|
|
42
|
-
/// (`maxTreeDepth=10`) keeps the walk bounded so we shouldn't
|
|
43
|
-
/// blow past the budget even at extreme depth.
|
|
44
|
-
func testTreeWalkRespectsDepthCap() {
|
|
45
|
-
let root = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
|
|
46
|
-
var cursor = root
|
|
47
|
-
for _ in 0..<50 {
|
|
48
|
-
let child = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
|
|
49
|
-
cursor.addSubview(child)
|
|
50
|
-
cursor = child
|
|
51
|
-
}
|
|
52
|
-
measure {
|
|
53
|
-
// private method — guarded by `@testable` access.
|
|
54
|
-
// We're not asserting the output, just the timing budget.
|
|
55
|
-
_ = SentoriScreenshotCapture.value(forKey: "viewTreeForTesting")
|
|
56
|
-
.map { _ in 0 } ?? 0
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|