@goliapkg/sentori-react-native 0.1.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 (86) hide show
  1. package/README.md +5 -0
  2. package/SentoriReactNative.podspec +21 -0
  3. package/android/build.gradle +38 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/sentori/SentoriCrashHandler.kt +213 -0
  6. package/android/src/main/java/com/sentori/SentoriModule.kt +39 -0
  7. package/android/src/test/java/com/sentori/SentoriCrashHandlerTest.kt +60 -0
  8. package/expo-module.config.json +9 -0
  9. package/ios/SentoriCrashHandler.swift +160 -0
  10. package/ios/SentoriModule.swift +43 -0
  11. package/ios/Tests/SentoriCrashHandlerTests.swift +59 -0
  12. package/lib/breadcrumbs.d.ts +11 -0
  13. package/lib/breadcrumbs.d.ts.map +1 -0
  14. package/lib/breadcrumbs.js +21 -0
  15. package/lib/breadcrumbs.js.map +1 -0
  16. package/lib/capture.d.ts +23 -0
  17. package/lib/capture.d.ts.map +1 -0
  18. package/lib/capture.js +91 -0
  19. package/lib/capture.js.map +1 -0
  20. package/lib/config.d.ts +12 -0
  21. package/lib/config.d.ts.map +1 -0
  22. package/lib/config.js +10 -0
  23. package/lib/config.js.map +1 -0
  24. package/lib/error-boundary.d.ts +17 -0
  25. package/lib/error-boundary.d.ts.map +1 -0
  26. package/lib/error-boundary.js +26 -0
  27. package/lib/error-boundary.js.map +1 -0
  28. package/lib/handlers/global.d.ts +2 -0
  29. package/lib/handlers/global.d.ts.map +1 -0
  30. package/lib/handlers/global.js +29 -0
  31. package/lib/handlers/global.js.map +1 -0
  32. package/lib/handlers/network.d.ts +2 -0
  33. package/lib/handlers/network.d.ts.map +1 -0
  34. package/lib/handlers/network.js +69 -0
  35. package/lib/handlers/network.js.map +1 -0
  36. package/lib/handlers/promise.d.ts +2 -0
  37. package/lib/handlers/promise.d.ts.map +1 -0
  38. package/lib/handlers/promise.js +27 -0
  39. package/lib/handlers/promise.js.map +1 -0
  40. package/lib/index.d.ts +18 -0
  41. package/lib/index.d.ts.map +1 -0
  42. package/lib/index.js +20 -0
  43. package/lib/index.js.map +1 -0
  44. package/lib/init.d.ts +18 -0
  45. package/lib/init.d.ts.map +1 -0
  46. package/lib/init.js +56 -0
  47. package/lib/init.js.map +1 -0
  48. package/lib/native.d.ts +23 -0
  49. package/lib/native.d.ts.map +1 -0
  50. package/lib/native.js +56 -0
  51. package/lib/native.js.map +1 -0
  52. package/lib/stack.d.ts +3 -0
  53. package/lib/stack.d.ts.map +1 -0
  54. package/lib/stack.js +69 -0
  55. package/lib/stack.js.map +1 -0
  56. package/lib/transport.d.ts +8 -0
  57. package/lib/transport.d.ts.map +1 -0
  58. package/lib/transport.js +143 -0
  59. package/lib/transport.js.map +1 -0
  60. package/lib/types.d.ts +62 -0
  61. package/lib/types.d.ts.map +1 -0
  62. package/lib/types.js +2 -0
  63. package/lib/types.js.map +1 -0
  64. package/lib/uuid.d.ts +11 -0
  65. package/lib/uuid.d.ts.map +1 -0
  66. package/lib/uuid.js +46 -0
  67. package/lib/uuid.js.map +1 -0
  68. package/package.json +66 -0
  69. package/src/__tests__/breadcrumbs.test.ts +44 -0
  70. package/src/__tests__/stack.test.ts +43 -0
  71. package/src/__tests__/transport.test.ts +112 -0
  72. package/src/__tests__/uuid.test.ts +41 -0
  73. package/src/breadcrumbs.ts +33 -0
  74. package/src/capture.ts +108 -0
  75. package/src/config.ts +21 -0
  76. package/src/error-boundary.tsx +38 -0
  77. package/src/handlers/global.ts +36 -0
  78. package/src/handlers/network.ts +70 -0
  79. package/src/handlers/promise.ts +38 -0
  80. package/src/index.ts +37 -0
  81. package/src/init.ts +80 -0
  82. package/src/native.ts +71 -0
  83. package/src/stack.ts +72 -0
  84. package/src/transport.ts +164 -0
  85. package/src/types.ts +63 -0
  86. package/src/uuid.ts +56 -0
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @sentori/react-native
2
+
3
+ React Native SDK — JS / iOS native / Android native error capture.
4
+
5
+ JS layer in **Phase 3**, native layers in **Phase 7**. Currently a placeholder.
@@ -0,0 +1,21 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'SentoriReactNative'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = 'TBD'
11
+ s.author = { 'Sentori' => 'support@sentori.golia.jp' }
12
+ s.homepage = 'https://sentori.golia.jp'
13
+ s.platforms = { ios: '13.4', tvos: '13.4' }
14
+ s.swift_version = '5.4'
15
+ s.source = { git: '' }
16
+ s.static_framework = true
17
+
18
+ s.dependency 'ExpoModulesCore'
19
+
20
+ s.source_files = 'ios/**/*.{h,m,mm,swift,hpp,cpp}'
21
+ end
@@ -0,0 +1,38 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'kotlin-android'
3
+
4
+ group = 'com.sentori'
5
+ version = rootProject.hasProperty('sentoriPackageVersion')
6
+ ? rootProject.sentoriPackageVersion
7
+ : '0.0.0'
8
+
9
+ android {
10
+ namespace 'com.sentori'
11
+ compileSdk rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : 35
12
+
13
+ defaultConfig {
14
+ minSdk rootProject.hasProperty('minSdkVersion') ? rootProject.minSdkVersion : 24
15
+ targetSdk rootProject.hasProperty('targetSdkVersion') ? rootProject.targetSdkVersion : 35
16
+ }
17
+
18
+ sourceSets {
19
+ main {
20
+ manifest.srcFile 'src/main/AndroidManifest.xml'
21
+ java.srcDirs = ['src/main/java']
22
+ }
23
+ }
24
+
25
+ kotlinOptions {
26
+ jvmTarget = '17'
27
+ }
28
+
29
+ compileOptions {
30
+ sourceCompatibility JavaVersion.VERSION_17
31
+ targetCompatibility JavaVersion.VERSION_17
32
+ }
33
+ }
34
+
35
+ dependencies {
36
+ implementation project(':expo-modules-core')
37
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:${rootProject.ext.kotlinVersion ?: '2.0.21'}"
38
+ }
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,213 @@
1
+ package com.sentori
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import org.json.JSONArray
6
+ import org.json.JSONObject
7
+ import java.io.File
8
+ import java.text.SimpleDateFormat
9
+ import java.util.Date
10
+ import java.util.Locale
11
+ import java.util.TimeZone
12
+ import java.util.UUID
13
+
14
+ /**
15
+ * Static crash handler — captures Java/Kotlin uncaught exceptions on
16
+ * Android and writes one event-shaped JSON file per crash to
17
+ * <filesDir>/sentori/pending/<uuid>.json. JS drains that directory on
18
+ * next launch via Sentori.drainPending().
19
+ *
20
+ * What this does NOT do (Phase 7 v0.1):
21
+ * - native crashes (NDK / SIGSEGV) — Phase 7 explicitly skips signal-
22
+ * based handlers per ROADMAP.
23
+ * - ANR detection — deferred to v0.2.
24
+ */
25
+ object SentoriCrashHandler {
26
+
27
+ private const val PREFS = "sentori"
28
+ private const val PENDING_DIR_NAME = "sentori/pending"
29
+
30
+ @Volatile private var appCtx: Context? = null
31
+ @Volatile private var previousHandler: Thread.UncaughtExceptionHandler? = null
32
+
33
+ @JvmStatic
34
+ fun register(context: Context) {
35
+ appCtx = context.applicationContext
36
+ previousHandler = Thread.getDefaultUncaughtExceptionHandler()
37
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
38
+ try {
39
+ write(throwable)
40
+ } catch (_: Throwable) {
41
+ // never throw inside the crash handler
42
+ }
43
+ previousHandler?.uncaughtException(thread, throwable)
44
+ }
45
+ }
46
+
47
+ @JvmStatic
48
+ fun setConfig(config: Map<String, Any?>) {
49
+ val ctx = appCtx ?: return
50
+ val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
51
+ val edit = prefs.edit()
52
+ edit.clear()
53
+ for ((k, v) in config) {
54
+ when (v) {
55
+ is String -> edit.putString(k, v)
56
+ is Int -> edit.putInt(k, v)
57
+ is Boolean -> edit.putBoolean(k, v)
58
+ else -> {}
59
+ }
60
+ }
61
+ edit.apply()
62
+ }
63
+
64
+ @JvmStatic
65
+ fun consumePending(): List<String> {
66
+ val dir = pendingDir() ?: return emptyList()
67
+ if (!dir.exists()) return emptyList()
68
+ val out = mutableListOf<String>()
69
+ val files = dir.listFiles { f -> f.extension == "json" } ?: emptyArray()
70
+ for (f in files) {
71
+ try {
72
+ out.add(f.readText())
73
+ } catch (_: Throwable) {
74
+ // skip unreadable file
75
+ }
76
+ f.delete()
77
+ }
78
+ return out
79
+ }
80
+
81
+ // ── internals ────────────────────────────────────────────────
82
+
83
+ private fun pendingDir(): File? {
84
+ val ctx = appCtx ?: return null
85
+ val dir = File(ctx.filesDir, PENDING_DIR_NAME)
86
+ if (!dir.exists()) dir.mkdirs()
87
+ return dir
88
+ }
89
+
90
+ private fun configMap(): Map<String, String> {
91
+ val ctx = appCtx ?: return emptyMap()
92
+ val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
93
+ val out = mutableMapOf<String, String>()
94
+ for ((k, v) in prefs.all) if (v is String) out[k] = v
95
+ return out
96
+ }
97
+
98
+ private fun write(throwable: Throwable) {
99
+ val cfg = configMap()
100
+ val release = cfg["release"] ?: "unknown"
101
+ val environment = cfg["environment"] ?: "prod"
102
+
103
+ val device = JSONObject().apply {
104
+ put("os", "android")
105
+ put("osVersion", Build.VERSION.RELEASE)
106
+ put("model", "${Build.MANUFACTURER} ${Build.MODEL}")
107
+ }
108
+
109
+ val app = JSONObject().apply {
110
+ put("version", appVersion())
111
+ put("build", appBuild())
112
+ }
113
+
114
+ val error = errorToJson(throwable)
115
+
116
+ val event = JSONObject().apply {
117
+ put("id", uuidLower())
118
+ put("timestamp", iso8601Now())
119
+ put("kind", "error")
120
+ put("platform", "android")
121
+ put("release", release)
122
+ put("environment", environment)
123
+ put("device", device)
124
+ put("app", app)
125
+ put("user", JSONObject.NULL)
126
+ put("tags", JSONObject())
127
+ put("breadcrumbs", JSONArray())
128
+ put("error", error)
129
+ put("fingerprint", JSONArray())
130
+ put("traceId", JSONObject.NULL)
131
+ put("spanId", JSONObject.NULL)
132
+ }
133
+
134
+ val dir = pendingDir() ?: return
135
+ val file = File(dir, "${uuidLower()}.json")
136
+ try {
137
+ file.writeText(event.toString())
138
+ } catch (_: Throwable) {
139
+ // best-effort
140
+ }
141
+ }
142
+
143
+ private fun errorToJson(throwable: Throwable): JSONObject {
144
+ return JSONObject().apply {
145
+ put("type", throwable.javaClass.name)
146
+ put("message", throwable.message ?: "")
147
+ put("stack", framesToJson(throwable))
148
+ val cause = throwable.cause
149
+ if (cause != null && cause !== throwable) {
150
+ put("cause", errorToJson(cause))
151
+ } else {
152
+ put("cause", JSONObject.NULL)
153
+ }
154
+ }
155
+ }
156
+
157
+ private fun framesToJson(throwable: Throwable): JSONArray {
158
+ val arr = JSONArray()
159
+ for (f in throwable.stackTrace) {
160
+ val frame = JSONObject().apply {
161
+ put("function", "${f.className}.${f.methodName}")
162
+ put("file", f.fileName ?: "<unknown>")
163
+ put("line", f.lineNumber.coerceAtLeast(0))
164
+ put("inApp", isInApp(f))
165
+ }
166
+ arr.put(frame)
167
+ }
168
+ return arr
169
+ }
170
+
171
+ private fun isInApp(f: StackTraceElement): Boolean {
172
+ val cls = f.className
173
+ if (cls.startsWith("android.")) return false
174
+ if (cls.startsWith("androidx.")) return false
175
+ if (cls.startsWith("java.")) return false
176
+ if (cls.startsWith("javax.")) return false
177
+ if (cls.startsWith("kotlin.")) return false
178
+ if (cls.startsWith("kotlinx.")) return false
179
+ if (cls.startsWith("com.facebook.react.")) return false
180
+ if (cls.startsWith("com.android.")) return false
181
+ if (cls.startsWith("dalvik.")) return false
182
+ if (cls.startsWith("sun.")) return false
183
+ return true
184
+ }
185
+
186
+ private fun iso8601Now(): String {
187
+ val f = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
188
+ f.timeZone = TimeZone.getTimeZone("UTC")
189
+ return f.format(Date())
190
+ }
191
+
192
+ private fun uuidLower(): String = UUID.randomUUID().toString().lowercase(Locale.US)
193
+
194
+ private fun appVersion(): String {
195
+ val ctx = appCtx ?: return "0.0.0"
196
+ return try {
197
+ val pi = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
198
+ pi.versionName ?: "0.0.0"
199
+ } catch (_: Throwable) {
200
+ "0.0.0"
201
+ }
202
+ }
203
+
204
+ private fun appBuild(): String {
205
+ val ctx = appCtx ?: return "0"
206
+ return try {
207
+ val pi = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
208
+ pi.longVersionCode.toString()
209
+ } catch (_: Throwable) {
210
+ "0"
211
+ }
212
+ }
213
+ }
@@ -0,0 +1,39 @@
1
+ package com.sentori
2
+
3
+ import expo.modules.kotlin.modules.Module
4
+ import expo.modules.kotlin.modules.ModuleDefinition
5
+
6
+ /**
7
+ * Expo Module exposing the Android crash handler to JS. Same JS contract
8
+ * as the iOS module:
9
+ * - setConfig({ token, release, environment })
10
+ * - drainPending() -> List<String> (JSON bodies)
11
+ */
12
+ class SentoriModule : Module() {
13
+ override fun definition() = ModuleDefinition {
14
+ Name("Sentori")
15
+
16
+ OnCreate {
17
+ val ctx = appContext.reactContext ?: return@OnCreate
18
+ SentoriCrashHandler.register(ctx)
19
+ }
20
+
21
+ Function("setConfig") { config: Map<String, Any?> ->
22
+ SentoriCrashHandler.setConfig(config)
23
+ }
24
+
25
+ AsyncFunction("drainPending") {
26
+ SentoriCrashHandler.consumePending()
27
+ }
28
+
29
+ // Dev-only helper — schedules an uncaught RuntimeException after
30
+ // a tick so the JS bridge has time to return; the crash is then
31
+ // captured by SentoriCrashHandler and written to
32
+ // <filesDir>/sentori/pending/.
33
+ Function("triggerTestNativeCrash") {
34
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
35
+ throw RuntimeException("Sentori test native crash")
36
+ }, 50)
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,60 @@
1
+ // Phase 16 sub-E: Robolectric coverage for SentoriCrashHandler (回填 Phase 7).
2
+ //
3
+ // Run via Gradle on the Android host:
4
+ // ./gradlew :sentori-react-native:testDebugUnitTest
5
+ //
6
+ // We can't easily catch a real uncaught Throwable in-process (the test
7
+ // runner would itself crash), so we drive the persistence helper
8
+ // directly and assert it writes a protocol-shaped JSON file.
9
+
10
+ package com.sentori
11
+
12
+ import androidx.test.core.app.ApplicationProvider
13
+ import org.junit.After
14
+ import org.junit.Before
15
+ import org.junit.Test
16
+ import org.junit.runner.RunWith
17
+ import org.robolectric.RobolectricTestRunner
18
+ import org.json.JSONObject
19
+ import java.io.File
20
+ import kotlin.test.assertEquals
21
+ import kotlin.test.assertNotNull
22
+ import kotlin.test.assertTrue
23
+
24
+ @RunWith(RobolectricTestRunner::class)
25
+ class SentoriCrashHandlerTest {
26
+ private lateinit var pendingDir: File
27
+
28
+ @Before
29
+ fun setUp() {
30
+ val ctx = ApplicationProvider.getApplicationContext<android.content.Context>()
31
+ pendingDir = File(ctx.filesDir, "sentori/pending")
32
+ pendingDir.deleteRecursively()
33
+ pendingDir.mkdirs()
34
+ SentoriCrashHandler.installForTesting(ctx)
35
+ }
36
+
37
+ @After
38
+ fun tearDown() {
39
+ pendingDir.deleteRecursively()
40
+ }
41
+
42
+ @Test
43
+ fun writePendingProducesValidEventJson() {
44
+ val ex = RuntimeException("boom from robolectric")
45
+ SentoriCrashHandler.persistForTesting(ex, "android-test-thread")
46
+
47
+ val files = pendingDir.listFiles { f -> f.name.endsWith(".json") }
48
+ assertNotNull(files, "no listing in $pendingDir")
49
+ assertEquals(1, files.size, "expected exactly one pending file")
50
+
51
+ val payload = JSONObject(files[0].readText())
52
+ assertEquals("error", payload.getString("kind"))
53
+ assertEquals("android", payload.getString("platform"))
54
+ assertNotNull(payload.getString("id"))
55
+ assertNotNull(payload.getString("timestamp"))
56
+ val error = payload.getJSONObject("error")
57
+ assertEquals("RuntimeException", error.getString("type"))
58
+ assertTrue(error.getString("message").contains("boom"))
59
+ }
60
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["SentoriModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["com.sentori.SentoriModule"]
8
+ }
9
+ }
@@ -0,0 +1,160 @@
1
+ import Foundation
2
+
3
+ /// Static crash handler — captures NSException and writes one JSON file
4
+ /// per crash to <Documents>/sentori/pending/<uuid>.json. JS drains that
5
+ /// directory on next launch via `Sentori.drainPending()`.
6
+ ///
7
+ /// What this does NOT do (Phase 7 v0.1):
8
+ /// - signal-based native crashes (SIGSEGV / SIGABRT etc.) — see ROADMAP
9
+ /// "explicitly out" list. Only Objective-C exceptions are caught here.
10
+ @objc public final class SentoriCrashHandler: NSObject {
11
+
12
+ private static let configKey = "com.sentori.config"
13
+ private static let pendingDirName = "sentori/pending"
14
+
15
+ /// Install the global uncaught-exception handler. C function pointer,
16
+ /// so we cannot capture local context (no chaining to a previously
17
+ /// installed handler in v0.1 — RedBox in dev still receives via
18
+ /// JS-side handlers; in release this just replaces the default).
19
+ @objc public static func register() {
20
+ NSSetUncaughtExceptionHandler(SentoriCrashHandler.exceptionHandler)
21
+ }
22
+
23
+ private static let exceptionHandler: @convention(c) (NSException) -> Void = { exception in
24
+ SentoriCrashHandler.write(exception: exception)
25
+ }
26
+
27
+ /// JS side calls this on `sentori.init(...)` so the crash handler
28
+ /// has release / environment when an exception fires later.
29
+ @objc public static func setConfig(_ config: [String: Any]) {
30
+ UserDefaults.standard.set(config, forKey: configKey)
31
+ }
32
+
33
+ /// Read all pending-crash files, return their contents (UTF-8 JSON
34
+ /// strings), and remove them from disk. Best-effort: any I/O error
35
+ /// drops that one file silently.
36
+ @objc public static func consumePending() -> [String] {
37
+ guard let dir = pendingDir() else { return [] }
38
+ let urls = (try? FileManager.default.contentsOfDirectory(
39
+ at: dir, includingPropertiesForKeys: nil)) ?? []
40
+ var out: [String] = []
41
+ for url in urls where url.pathExtension == "json" {
42
+ if let data = try? Data(contentsOf: url),
43
+ let str = String(data: data, encoding: .utf8) {
44
+ out.append(str)
45
+ }
46
+ try? FileManager.default.removeItem(at: url)
47
+ }
48
+ return out
49
+ }
50
+
51
+ // MARK: - Internals
52
+
53
+ private static func pendingDir() -> URL? {
54
+ guard let docs = FileManager.default.urls(
55
+ for: .documentDirectory, in: .userDomainMask).first else { return nil }
56
+ let dir = docs.appendingPathComponent(pendingDirName)
57
+ try? FileManager.default.createDirectory(
58
+ at: dir, withIntermediateDirectories: true)
59
+ return dir
60
+ }
61
+
62
+ private static func config() -> [String: Any] {
63
+ return UserDefaults.standard.dictionary(forKey: configKey) ?? [:]
64
+ }
65
+
66
+ private static func write(exception: NSException) {
67
+ let cfg = config()
68
+ let release = (cfg["release"] as? String) ?? "unknown"
69
+ let environment = (cfg["environment"] as? String) ?? "prod"
70
+
71
+ let event: [String: Any] = [
72
+ "id": UUID().uuidString.lowercased(),
73
+ "timestamp": iso8601(Date()),
74
+ "kind": "error",
75
+ "platform": "ios",
76
+ "release": release,
77
+ "environment": environment,
78
+ "device": [
79
+ "os": "ios",
80
+ "osVersion": osVersion(),
81
+ "model": deviceModel(),
82
+ ],
83
+ "app": appInfo(),
84
+ "user": NSNull(),
85
+ "tags": [String: String](),
86
+ "breadcrumbs": [Any](),
87
+ "error": [
88
+ "type": exception.name.rawValue,
89
+ "message": exception.reason ?? "",
90
+ "stack": frames(from: exception.callStackSymbols),
91
+ "cause": NSNull(),
92
+ ],
93
+ "fingerprint": [String](),
94
+ "traceId": NSNull(),
95
+ "spanId": NSNull(),
96
+ ]
97
+
98
+ guard let dir = pendingDir() else { return }
99
+ let url = dir.appendingPathComponent("\(UUID().uuidString.lowercased()).json")
100
+ if let data = try? JSONSerialization.data(withJSONObject: event, options: []) {
101
+ try? data.write(to: url)
102
+ }
103
+ }
104
+
105
+ private static func iso8601(_ date: Date) -> String {
106
+ let f = ISO8601DateFormatter()
107
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
108
+ return f.string(from: date)
109
+ }
110
+
111
+ private static func osVersion() -> String {
112
+ let v = ProcessInfo.processInfo.operatingSystemVersion
113
+ return "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
114
+ }
115
+
116
+ private static func deviceModel() -> String {
117
+ var s = utsname()
118
+ uname(&s)
119
+ return withUnsafePointer(to: &s.machine) { ptr in
120
+ ptr.withMemoryRebound(to: CChar.self, capacity: 1) {
121
+ String(validatingUTF8: $0) ?? "unknown"
122
+ }
123
+ }
124
+ }
125
+
126
+ private static func appInfo() -> [String: Any] {
127
+ let info = Bundle.main.infoDictionary ?? [:]
128
+ var d: [String: Any] = [
129
+ "version": (info["CFBundleShortVersionString"] as? String) ?? "0.0.0",
130
+ ]
131
+ if let build = info["CFBundleVersion"] as? String {
132
+ d["build"] = build
133
+ }
134
+ return d
135
+ }
136
+
137
+ /// Best-effort frame parse from `[NSException callStackSymbols]`.
138
+ /// Each line looks roughly like:
139
+ /// "1 AppName 0x0001a0b0 -[ClassName method:] + 100"
140
+ /// We don't have file/line info in raw symbol output; sourcemap-style
141
+ /// symbolication for native happens server-side (Phase 8+).
142
+ private static func frames(from symbols: [String]) -> [[String: Any]] {
143
+ return symbols.map { sym -> [String: Any] in
144
+ let parts = sym.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
145
+ let module = parts.count > 1 ? parts[1] : "<unknown>"
146
+ let function = parts.count > 3 ? parts.dropFirst(3).joined(separator: " ") : "<anonymous>"
147
+ let inApp = !module.contains("UIKit")
148
+ && !module.contains("Foundation")
149
+ && !module.contains("CoreFoundation")
150
+ && !module.contains("libsystem")
151
+ && !module.contains("libobjc")
152
+ return [
153
+ "function": function,
154
+ "file": module,
155
+ "line": 0,
156
+ "inApp": inApp,
157
+ ]
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,43 @@
1
+ import ExpoModulesCore
2
+
3
+ /// Expo Module exposing the iOS crash handler to JS.
4
+ ///
5
+ /// JS contract (mirrored in src/native.ts):
6
+ /// - setConfig({ token, release, environment }): stash for the crash
7
+ /// writer. Token is currently unused at native side; release and
8
+ /// environment are baked into the saved event JSON.
9
+ /// - drainPending() → string[]: read & delete all pending crash files
10
+ /// from <Documents>/sentori/pending and return their JSON bodies.
11
+ public class SentoriModule: Module {
12
+ public func definition() -> ModuleDefinition {
13
+ Name("Sentori")
14
+
15
+ OnCreate {
16
+ SentoriCrashHandler.register()
17
+ }
18
+
19
+ Function("setConfig") { (config: [String: Any]) in
20
+ SentoriCrashHandler.setConfig(config)
21
+ }
22
+
23
+ AsyncFunction("drainPending") { () -> [String] in
24
+ return SentoriCrashHandler.consumePending()
25
+ }
26
+
27
+ // Dev-only helper used by the example app to verify the
28
+ // crash-write / drain round-trip without writing native code in
29
+ // the host app. Schedules a real NSException after a tick so
30
+ // the JS bridge has time to return; the resulting crash hits
31
+ // SentoriCrashHandler and writes a JSON file under
32
+ // <Documents>/sentori/pending/.
33
+ Function("triggerTestNativeCrash") {
34
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
35
+ NSException(
36
+ name: NSExceptionName("SentoriTestException"),
37
+ reason: "Sentori test native crash",
38
+ userInfo: nil
39
+ ).raise()
40
+ }
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,59 @@
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
+ }
@@ -0,0 +1,11 @@
1
+ import type { Breadcrumb, BreadcrumbType } from './types';
2
+ export type AddBreadcrumbInput = {
3
+ type: BreadcrumbType;
4
+ data: Record<string, unknown>;
5
+ timestamp?: string;
6
+ };
7
+ export declare const addBreadcrumb: (input: AddBreadcrumbInput) => void;
8
+ export declare const getBreadcrumbs: () => Breadcrumb[];
9
+ export declare const clearBreadcrumbs: () => void;
10
+ export declare const __resetForTests: () => void;
11
+ //# sourceMappingURL=breadcrumbs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"breadcrumbs.d.ts","sourceRoot":"","sources":["../src/breadcrumbs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAM1D,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,cAAc,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,OAAO,kBAAkB,KAAG,IAUzD,CAAC;AAEF,eAAO,MAAM,cAAc,QAAO,UAAU,EAAkB,CAAC;AAE/D,eAAO,MAAM,gBAAgB,QAAO,IAEnC,CAAC;AAEF,eAAO,MAAM,eAAe,QAAO,IAElC,CAAC"}