@goliapkg/sentori-react-native 0.9.11 → 1.0.0-rc.10

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 (41) hide show
  1. package/android/src/main/java/com/sentori/SentoriForegroundActivity.kt +145 -0
  2. package/android/src/main/java/com/sentori/SentoriModule.kt +13 -0
  3. package/android/src/main/java/com/sentori/SentoriReplayCapture.kt +261 -68
  4. package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +72 -36
  5. package/ios/SentoriModule.swift +15 -0
  6. package/ios/SentoriReplayCapture.swift +135 -10
  7. package/ios/SentoriScreenshotCapture.swift +69 -3
  8. package/lib/base64.d.ts +25 -0
  9. package/lib/base64.d.ts.map +1 -0
  10. package/lib/base64.js +30 -0
  11. package/lib/base64.js.map +1 -0
  12. package/lib/capture.d.ts +20 -1
  13. package/lib/capture.d.ts.map +1 -1
  14. package/lib/capture.js +45 -21
  15. package/lib/capture.js.map +1 -1
  16. package/lib/index.d.ts +2 -1
  17. package/lib/index.d.ts.map +1 -1
  18. package/lib/index.js +2 -1
  19. package/lib/index.js.bak +64 -0
  20. package/lib/index.js.map +1 -1
  21. package/lib/native.d.ts +68 -0
  22. package/lib/native.d.ts.map +1 -1
  23. package/lib/native.js +115 -0
  24. package/lib/native.js.map +1 -1
  25. package/lib/replay.d.ts +28 -4
  26. package/lib/replay.d.ts.map +1 -1
  27. package/lib/replay.js +242 -65
  28. package/lib/replay.js.map +1 -1
  29. package/lib/transport.d.ts.map +1 -1
  30. package/lib/transport.js +16 -0
  31. package/lib/transport.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/__tests__/base64.test.ts +55 -0
  34. package/src/__tests__/capture-replay.test.ts +150 -0
  35. package/src/__tests__/replay-encoding.test.ts +237 -0
  36. package/src/base64.ts +29 -0
  37. package/src/capture.ts +56 -22
  38. package/src/index.ts +3 -0
  39. package/src/native.ts +177 -0
  40. package/src/replay.ts +294 -70
  41. package/src/transport.ts +31 -0
@@ -1,13 +1,11 @@
1
1
  package com.sentori
2
2
 
3
- import android.app.Activity
4
3
  import android.app.Application
5
4
  import android.graphics.Bitmap
6
5
  import android.graphics.Canvas
7
6
  import android.graphics.Color
8
7
  import android.graphics.Paint
9
8
  import android.os.Build
10
- import android.os.Bundle
11
9
  import android.os.Handler
12
10
  import android.os.HandlerThread
13
11
  import android.util.Base64
@@ -16,7 +14,6 @@ import android.view.View
16
14
  import android.view.ViewGroup
17
15
  import android.view.Window
18
16
  import java.io.ByteArrayOutputStream
19
- import java.lang.ref.WeakReference
20
17
  import java.util.concurrent.CountDownLatch
21
18
  import java.util.concurrent.TimeUnit
22
19
  import org.json.JSONArray
@@ -60,36 +57,44 @@ object SentoriScreenshotCapture {
60
57
  private const val MAX_NODES = 1500
61
58
  private const val PIXEL_COPY_TIMEOUT_MS = 200L
62
59
 
63
- /// Latest activity we've seen via `ActivityLifecycleCallbacks`
64
- /// used to find the window to screenshot when neither the JS
65
- /// side (which knows of `Activity.this` via React Native) nor
66
- /// the crash handler hands one to us.
67
- @Volatile private var lastActivity: WeakReference<Activity>? = null
60
+ // v1.0.0-rc.2 diagnostic readout so the JS side can ask
61
+ // "why did screenshot return null" without parsing logcat. Mirrors
62
+ // SentoriReplayCapture.probe() and the iOS Swift probe.
63
+ @Volatile private var lastDiagPath: String = "none(not-yet-called)"
64
+ @Volatile private var lastDiagW: Int = 0
65
+ @Volatile private var lastDiagH: Int = 0
68
66
 
69
67
  /**
70
- * Attach an `ActivityLifecycleCallbacks` so subsequent
71
- * `captureScreen()` calls know which Activity (and therefore
72
- * Window) to target. Idempotent; call from
73
- * `SentoriCrashHandler.register(context)`.
68
+ * Snapshot of the most recent capture attempt — what code path
69
+ * resolved an Activity, what the decor view's dimensions were,
70
+ * and what call source the foreground tracker last saw the
71
+ * Activity from. Used by `probeNativeScreenshot()` on the JS
72
+ * side so Insight can ship raw diagnostic state back without
73
+ * needing logcat access.
74
+ */
75
+ @JvmStatic
76
+ fun probe(): Map<String, Any> {
77
+ val tracked = SentoriForegroundActivity.current()
78
+ return mapOf(
79
+ "lastPath" to lastDiagPath,
80
+ "lastWidth" to lastDiagW,
81
+ "lastHeight" to lastDiagH,
82
+ "trackedSource" to SentoriForegroundActivity.lastPath,
83
+ "trackedActivity" to (tracked?.javaClass?.name ?: "null"),
84
+ "decorViewFound" to (tracked?.window?.decorView != null),
85
+ )
86
+ }
87
+
88
+ /**
89
+ * Idempotent. Wires the screenshot helper into the shared
90
+ * foreground-activity tracker; kept as a public entrypoint for
91
+ * backwards compat with existing call sites (the crash handler
92
+ * still calls this), but the actual lifecycle subscription lives
93
+ * in [SentoriForegroundActivity].
74
94
  */
75
95
  @JvmStatic
76
96
  fun register(application: Application) {
77
- application.registerActivityLifecycleCallbacks(object :
78
- Application.ActivityLifecycleCallbacks {
79
- override fun onActivityCreated(a: Activity, b: Bundle?) {
80
- lastActivity = WeakReference(a)
81
- }
82
- override fun onActivityStarted(a: Activity) {
83
- lastActivity = WeakReference(a)
84
- }
85
- override fun onActivityResumed(a: Activity) {
86
- lastActivity = WeakReference(a)
87
- }
88
- override fun onActivityPaused(a: Activity) {}
89
- override fun onActivityStopped(a: Activity) {}
90
- override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
91
- override fun onActivityDestroyed(a: Activity) {}
92
- })
97
+ SentoriForegroundActivity.install(application)
93
98
  }
94
99
 
95
100
  /**
@@ -102,8 +107,16 @@ object SentoriScreenshotCapture {
102
107
  */
103
108
  @JvmStatic
104
109
  fun captureKeyWindow(): Map<String, Any>? {
105
- val activity = lastActivity?.get() ?: return null
106
- val window = activity.window ?: return null
110
+ val activity = SentoriForegroundActivity.current()
111
+ if (activity == null) {
112
+ lastDiagPath = "activity.null"
113
+ return null
114
+ }
115
+ val window = activity.window
116
+ if (window == null) {
117
+ lastDiagPath = "window.null"
118
+ return null
119
+ }
107
120
  val out = mutableMapOf<String, Any>()
108
121
  captureScreen(window, emptySet())?.let { (base64, mediaType) ->
109
122
  out["screenshot"] = mapOf("base64" to base64, "mediaType" to mediaType)
@@ -120,8 +133,16 @@ object SentoriScreenshotCapture {
120
133
  /// masked subview's frame on the captured bitmap.
121
134
  @JvmStatic
122
135
  fun captureScreenshotWithMask(maskedIds: List<String>): Map<String, String>? {
123
- val activity = lastActivity?.get() ?: return null
124
- val window = activity.window ?: return null
136
+ val activity = SentoriForegroundActivity.current()
137
+ if (activity == null) {
138
+ lastDiagPath = "activity.null"
139
+ return null
140
+ }
141
+ val window = activity.window
142
+ if (window == null) {
143
+ lastDiagPath = "window.null"
144
+ return null
145
+ }
125
146
  val (base64, mediaType) = captureScreen(window, maskedIds.toHashSet()) ?: return null
126
147
  return mapOf("base64" to base64, "mediaType" to mediaType)
127
148
  }
@@ -135,12 +156,22 @@ object SentoriScreenshotCapture {
135
156
  // requires the activity not to be torn down. Skip for
136
157
  // now; v0.6.1 SDK can add the fallback if real-world
137
158
  // data shows we have users below API 24.
159
+ lastDiagPath = "api.unsupported"
160
+ return null
161
+ }
162
+ val decor = window.decorView
163
+ if (decor == null) {
164
+ lastDiagPath = "decorView.null"
138
165
  return null
139
166
  }
140
- val decor = window.decorView ?: return null
141
167
  val w = decor.width
142
168
  val h = decor.height
143
- if (w <= 0 || h <= 0) return null
169
+ lastDiagW = w
170
+ lastDiagH = h
171
+ if (w <= 0 || h <= 0) {
172
+ lastDiagPath = "decorView.zero-size"
173
+ return null
174
+ }
144
175
 
145
176
  // Long-edge scale.
146
177
  val longEdge = maxOf(w, h).toFloat()
@@ -175,12 +206,17 @@ object SentoriScreenshotCapture {
175
206
  )
176
207
  }
177
208
  latch.await(PIXEL_COPY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
178
- } catch (_: Throwable) {
209
+ } catch (t: Throwable) {
210
+ lastDiagPath = "pixelCopy.threw:${t.javaClass.simpleName}"
179
211
  return null
180
212
  } finally {
181
213
  handlerThread.quitSafely()
182
214
  }
183
- if (!success) return null
215
+ if (!success) {
216
+ lastDiagPath = "pixelCopy.notSuccess"
217
+ return null
218
+ }
219
+ lastDiagPath = "ok"
184
220
 
185
221
  // v0.7.3 — paint black rectangles over masked subviews on the
186
222
  // already-captured bitmap. We get window-relative coordinates
@@ -30,6 +30,21 @@ public class SentoriModule: Module {
30
30
  return SentoriReplayCapture.captureWireframe(maskedIds: maskedIds)
31
31
  }
32
32
 
33
+ // v0.9.12 — diagnostic readout for replay. Returns the last
34
+ // keyWindow resolution path + scene/window counts so a single
35
+ // JS-side button can answer "why is my ring empty?" without
36
+ // re-rolling the pod. See SentoriReplayCapture.swift.
37
+ Function("probeWireframe") { () -> [String: Any] in
38
+ return SentoriReplayCapture.probe()
39
+ }
40
+
41
+ // v1.0.0-rc.2 — diagnostic readout for screenshot. Same shape
42
+ // as Android side; lets Insight ship raw state back when the
43
+ // captureScreenshot path returns null.
44
+ Function("probeScreenshot") { () -> [String: Any] in
45
+ return SentoriScreenshotCapture.probe()
46
+ }
47
+
33
48
  // v0.9.4 #1 — Mobile Vitals exposure.
34
49
  Function("markJsBridgeReady") {
35
50
  SentoriMobileVitals.markJsBridgeReady()
@@ -29,16 +29,69 @@ import UIKit
29
29
  return result
30
30
  }
31
31
 
32
+ /// Diagnostic readouts exposed to JS via `probeWireframe()`.
33
+ ///
34
+ /// v0.9.12: lastPath / lastNodes / scene-window counts
35
+ /// v1.0.0-rc.3: + lastDepthMax / lastSizeBytes / totalTicks /
36
+ /// totalEmptyResultTicks — answers "the ring isn't
37
+ /// empty but the dashboard renders nothing, which
38
+ /// layer dropped the data?" without a re-roll.
39
+ @objc public static var lastDiagPath: String = "none(not-yet-called)"
40
+ @objc public static var lastDiagNodes: Int = 0
41
+ @objc public static var lastDiagSceneCount: Int = 0
42
+ @objc public static var lastDiagWindowCount: Int = 0
43
+ @objc public static var lastDiagDepthMax: Int = 0
44
+ @objc public static var lastDiagSizeBytes: Int = 0
45
+ @objc public static var totalTicks: Int = 0
46
+ @objc public static var totalEmptyResultTicks: Int = 0
47
+ private static var loggedFirstResult = false
48
+
32
49
  private static func captureSync(maskedIds: Set<String>) -> String? {
33
- guard let window = keyWindow() else { return nil }
50
+ totalTicks += 1
51
+ let (winOpt, path) = resolveKeyWindow()
52
+ lastDiagPath = path
53
+ lastDiagSceneCount = currentSceneCount()
54
+ lastDiagWindowCount = currentWindowCount()
55
+ guard let window = winOpt else {
56
+ totalEmptyResultTicks += 1
57
+ if !loggedFirstResult {
58
+ NSLog(
59
+ "[sentori] wireframe: returning nil — keyWindow path=%@ scenes=%d windows=%d",
60
+ path,
61
+ lastDiagSceneCount,
62
+ lastDiagWindowCount
63
+ )
64
+ loggedFirstResult = true
65
+ }
66
+ return nil
67
+ }
34
68
  var nodes: [[String: Any]] = []
69
+ var depthMax = 0
35
70
  walk(
36
71
  view: window,
72
+ depth: 0,
73
+ depthMax: &depthMax,
37
74
  parentMasked: false,
38
75
  maskedIds: maskedIds,
39
76
  window: window,
40
77
  nodes: &nodes
41
78
  )
79
+ lastDiagNodes = nodes.count
80
+ lastDiagDepthMax = depthMax
81
+ if nodes.isEmpty {
82
+ totalEmptyResultTicks += 1
83
+ }
84
+ if !loggedFirstResult {
85
+ NSLog(
86
+ "[sentori] wireframe: first capture ok — keyWindow path=%@ bounds=%.0fx%.0f nodes=%d depthMax=%d",
87
+ path,
88
+ window.bounds.width,
89
+ window.bounds.height,
90
+ nodes.count,
91
+ depthMax
92
+ )
93
+ loggedFirstResult = true
94
+ }
42
95
  let payload: [String: Any] = [
43
96
  "ts": Int(Date().timeIntervalSince1970 * 1000),
44
97
  "width": Double(window.bounds.width),
@@ -46,40 +99,110 @@ import UIKit
46
99
  "nodes": nodes,
47
100
  ]
48
101
  if let data = try? JSONSerialization.data(withJSONObject: payload, options: []) {
49
- return String(data: data, encoding: .utf8)
102
+ let s = String(data: data, encoding: .utf8)
103
+ lastDiagSizeBytes = s?.utf8.count ?? 0
104
+ return s
50
105
  }
51
106
  return nil
52
107
  }
53
108
 
54
- private static func keyWindow() -> UIWindow? {
109
+ /// Four-tier window resolution. The previous single-pass loop
110
+ /// returned nil whenever the first connected scene was a
111
+ /// `.background` or `.unattached` SwiftUI/preview scene that had
112
+ /// no windows yet — common on iOS 26 cold-start where the JS
113
+ /// thread spins up the replay tick before scene activation
114
+ /// settles (the v0.9.6 default 1 Hz fires within ~200 ms).
115
+ private static func resolveKeyWindow() -> (UIWindow?, String) {
55
116
  if #available(iOS 13.0, *) {
56
- for scene in UIApplication.shared.connectedScenes {
57
- guard let ws = scene as? UIWindowScene else { continue }
58
- if let key = ws.windows.first(where: { $0.isKeyWindow }) {
59
- return key
117
+ let scenes = Array(UIApplication.shared.connectedScenes)
118
+ // Pass 1: foregroundActive scene with a key window.
119
+ for scene in scenes where scene.activationState == .foregroundActive {
120
+ if let ws = scene as? UIWindowScene,
121
+ let key = ws.windows.first(where: { $0.isKeyWindow }) {
122
+ return (key, "scene.fg.key")
60
123
  }
61
- if let first = ws.windows.first {
62
- return first
124
+ }
125
+ // Pass 2: foregroundActive scene's first window (no key set yet).
126
+ for scene in scenes where scene.activationState == .foregroundActive {
127
+ if let ws = scene as? UIWindowScene, let win = ws.windows.first {
128
+ return (win, "scene.fg.first")
63
129
  }
64
130
  }
131
+ // Pass 3: foregroundInactive (mid-transition) scene with any window.
132
+ for scene in scenes where scene.activationState == .foregroundInactive {
133
+ if let ws = scene as? UIWindowScene, let win = ws.windows.first {
134
+ return (win, "scene.fgi.first")
135
+ }
136
+ }
137
+ // Pass 4: any scene at all with windows.
138
+ for scene in scenes {
139
+ if let ws = scene as? UIWindowScene, let win = ws.windows.first {
140
+ return (win, "scene.any.first")
141
+ }
142
+ }
143
+ // Fallthrough → legacy windows list.
144
+ }
145
+ if let leg = UIApplication.shared.windows.first {
146
+ return (leg, "legacy.first")
65
147
  }
66
- return UIApplication.shared.windows.first
148
+ return (nil, "none")
149
+ }
150
+
151
+ private static func currentSceneCount() -> Int {
152
+ if #available(iOS 13.0, *) {
153
+ return UIApplication.shared.connectedScenes.count
154
+ }
155
+ return 0
156
+ }
157
+
158
+ private static func currentWindowCount() -> Int {
159
+ if #available(iOS 13.0, *) {
160
+ return UIApplication.shared.connectedScenes.reduce(0) { acc, scene in
161
+ acc + ((scene as? UIWindowScene)?.windows.count ?? 0)
162
+ }
163
+ }
164
+ return UIApplication.shared.windows.count
165
+ }
166
+
167
+ /// JS-side probe. Returns a dict the example/dashboard can render
168
+ /// to ask "why is the ring empty?" without parsing Metro logs.
169
+ /// `lastNodes == 0 && lastPath != "none"` means the window walk
170
+ /// happened but the tree was empty (unusual — backgrounded?).
171
+ /// `lastPath == "none(...)"` means no UIWindow was reachable at
172
+ /// the moment of the last tick.
173
+ @objc public static func probe() -> [String: Any] {
174
+ return [
175
+ "lastPath": lastDiagPath,
176
+ "lastNodes": lastDiagNodes,
177
+ "sceneCount": lastDiagSceneCount,
178
+ "windowCount": lastDiagWindowCount,
179
+ "lastDepthMax": lastDiagDepthMax,
180
+ "lastSizeBytes": lastDiagSizeBytes,
181
+ "totalTicks": totalTicks,
182
+ "totalEmptyResultTicks": totalEmptyResultTicks,
183
+ ]
67
184
  }
68
185
 
69
186
  /// Cap on nodes per snapshot — extremely deep / wide trees can
70
187
  /// have thousands of subviews (UICollectionView recyclers).
71
188
  private static let MAX_NODES = 800
189
+ private static let MAX_DEPTH = 60
72
190
 
73
191
  private static func walk(
74
192
  view: UIView,
193
+ depth: Int,
194
+ depthMax: inout Int,
75
195
  parentMasked: Bool,
76
196
  maskedIds: Set<String>,
77
197
  window: UIWindow,
78
198
  nodes: inout [[String: Any]]
79
199
  ) {
80
200
  if nodes.count >= MAX_NODES { return }
201
+ if depth >= MAX_DEPTH { return }
81
202
  if view.isHidden || view.alpha < 0.01 { return }
82
203
 
204
+ if depth > depthMax { depthMax = depth }
205
+
83
206
  let isThisMasked = view.accessibilityIdentifier
84
207
  .map { maskedIds.contains($0) } ?? false
85
208
  let masked = parentMasked || isThisMasked
@@ -125,6 +248,8 @@ import UIKit
125
248
  for sub in view.subviews {
126
249
  walk(
127
250
  view: sub,
251
+ depth: depth + 1,
252
+ depthMax: &depthMax,
128
253
  parentMasked: masked,
129
254
  maskedIds: maskedIds,
130
255
  window: window,
@@ -82,10 +82,15 @@ import UIKit
82
82
  }
83
83
 
84
84
  private static func captureWithMaskSync(maskedIds: [String]) -> [String: String]? {
85
- guard let window = keyWindow() else { return nil }
85
+ guard let window = keyWindowDiag().window else {
86
+ lastDiagPath = "window.null"
87
+ return nil
88
+ }
86
89
  guard let jpeg = renderJpegBase64(window: window, maskedIds: Set(maskedIds)) else {
90
+ lastDiagPath = "render.failed"
87
91
  return nil
88
92
  }
93
+ lastDiagPath = "ok"
89
94
  return [
90
95
  "base64": jpeg,
91
96
  "mediaType": "image/jpeg",
@@ -94,8 +99,28 @@ import UIKit
94
99
 
95
100
  // MARK: - Internals
96
101
 
102
+ // v1.0.0-rc.2 — diagnostic readout mirror of the replay-capture
103
+ // probe. The JS side calls `probeScreenshot()` to ship raw state
104
+ // back when screenshot returns null.
105
+ private static var lastDiagPath: String = "none(not-yet-called)"
106
+
107
+ @objc public static func probe() -> [String: Any] {
108
+ let (win, path) = keyWindowDiag()
109
+ return [
110
+ "lastPath": lastDiagPath,
111
+ "resolvedPath": path,
112
+ "windowFound": win != nil,
113
+ "rootViewControllerFound": win?.rootViewController != nil,
114
+ "boundsW": win.map { Double($0.bounds.width) } ?? 0.0,
115
+ "boundsH": win.map { Double($0.bounds.height) } ?? 0.0,
116
+ ]
117
+ }
118
+
97
119
  private static func captureSync() -> [String: Any]? {
98
- guard let window = keyWindow() else { return nil }
120
+ guard let window = keyWindowDiag().window else {
121
+ lastDiagPath = "window.null"
122
+ return nil
123
+ }
99
124
  var out: [String: Any] = [:]
100
125
  if let jpeg = renderJpegBase64(window: window) {
101
126
  out["screenshot"] = [
@@ -104,7 +129,48 @@ import UIKit
104
129
  ]
105
130
  }
106
131
  out["viewTree"] = walkTree(root: window)
107
- return out.isEmpty ? nil : out
132
+ if out.isEmpty {
133
+ lastDiagPath = "empty"
134
+ return nil
135
+ }
136
+ lastDiagPath = "ok"
137
+ return out
138
+ }
139
+
140
+ /// keyWindow with the same 4-tier resolution as the replay capture,
141
+ /// plus the diagnostic path tag for the probe. The original
142
+ /// single-pass `keyWindow()` is kept for source-compat callers but
143
+ /// new paths funnel through this so screenshot + replay agree on
144
+ /// which window they are pointing at.
145
+ private static func keyWindowDiag() -> (window: UIWindow?, path: String) {
146
+ if #available(iOS 13.0, *) {
147
+ let scenes = Array(UIApplication.shared.connectedScenes)
148
+ for scene in scenes where scene.activationState == .foregroundActive {
149
+ if let ws = scene as? UIWindowScene,
150
+ let key = ws.windows.first(where: { $0.isKeyWindow }) {
151
+ return (key, "scene.fg.key")
152
+ }
153
+ }
154
+ for scene in scenes where scene.activationState == .foregroundActive {
155
+ if let ws = scene as? UIWindowScene, let win = ws.windows.first {
156
+ return (win, "scene.fg.first")
157
+ }
158
+ }
159
+ for scene in scenes where scene.activationState == .foregroundInactive {
160
+ if let ws = scene as? UIWindowScene, let win = ws.windows.first {
161
+ return (win, "scene.fgi.first")
162
+ }
163
+ }
164
+ for scene in scenes {
165
+ if let ws = scene as? UIWindowScene, let win = ws.windows.first {
166
+ return (win, "scene.any.first")
167
+ }
168
+ }
169
+ }
170
+ if let leg = UIApplication.shared.windows.first {
171
+ return (leg, "legacy.first")
172
+ }
173
+ return (nil, "none")
108
174
  }
109
175
 
110
176
  private static func keyWindow() -> UIWindow? {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * UTF-8-safe base64 encoder used by every JSON attachment path
3
+ * (sessionTrail, stateSnapshot, replay).
4
+ *
5
+ * Why this needs its own helper:
6
+ * • Hermes' `globalThis.btoa` (and the WHATWG spec) is **Latin-1
7
+ * only** — it throws `InvalidCharacterError` on any code point
8
+ * > 0xFF. A wireframe NDJSON that includes a TextView with
9
+ * Japanese / Chinese / em-dash text triggers it; the JS-side
10
+ * `try / catch` then swallows the throw and the replay
11
+ * attachment silently never lands.
12
+ * • Insight 2026-05-18 rc.3 verify hit exactly this on Android —
13
+ * the walker fix in rc.3 surfaced deep TextView text, which
14
+ * then collided with the unsafe `btoa(ndjson)` path that had
15
+ * worked accidentally on rc.2's shallow (text-free) snapshots.
16
+ *
17
+ * The pattern `btoa(unescape(encodeURIComponent(s)))` rewrites the
18
+ * UTF-8 byte sequence into a Latin-1-equivalent string that btoa
19
+ * can chew. `unescape` is deprecated for HTML but its byte-level
20
+ * behaviour is stable across every JS engine we ship to.
21
+ *
22
+ * Node / bun test fallback uses `Buffer` directly.
23
+ */
24
+ export declare function base64Utf8(s: string): string;
25
+ //# sourceMappingURL=base64.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base64.d.ts","sourceRoot":"","sources":["../src/base64.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAK5C"}
package/lib/base64.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * UTF-8-safe base64 encoder used by every JSON attachment path
3
+ * (sessionTrail, stateSnapshot, replay).
4
+ *
5
+ * Why this needs its own helper:
6
+ * • Hermes' `globalThis.btoa` (and the WHATWG spec) is **Latin-1
7
+ * only** — it throws `InvalidCharacterError` on any code point
8
+ * > 0xFF. A wireframe NDJSON that includes a TextView with
9
+ * Japanese / Chinese / em-dash text triggers it; the JS-side
10
+ * `try / catch` then swallows the throw and the replay
11
+ * attachment silently never lands.
12
+ * • Insight 2026-05-18 rc.3 verify hit exactly this on Android —
13
+ * the walker fix in rc.3 surfaced deep TextView text, which
14
+ * then collided with the unsafe `btoa(ndjson)` path that had
15
+ * worked accidentally on rc.2's shallow (text-free) snapshots.
16
+ *
17
+ * The pattern `btoa(unescape(encodeURIComponent(s)))` rewrites the
18
+ * UTF-8 byte sequence into a Latin-1-equivalent string that btoa
19
+ * can chew. `unescape` is deprecated for HTML but its byte-level
20
+ * behaviour is stable across every JS engine we ship to.
21
+ *
22
+ * Node / bun test fallback uses `Buffer` directly.
23
+ */
24
+ export function base64Utf8(s) {
25
+ if (typeof globalThis.btoa === 'function') {
26
+ return globalThis.btoa(unescape(encodeURIComponent(s)));
27
+ }
28
+ return Buffer.from(s, 'utf8').toString('base64');
29
+ }
30
+ //# sourceMappingURL=base64.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base64.js","sourceRoot":"","sources":["../src/base64.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,UAAU,CAAC,CAAS;IAClC,IAAI,OAAO,UAAU,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;QAC1C,OAAO,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1D,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AACnD,CAAC"}
package/lib/capture.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Tags, User } from './types';
1
+ import type { Event, Tags, User } from './types';
2
2
  export { captureStep, __resetTrailForTests } from './trail';
3
3
  export declare const __resetScreenshotBudgetForTests: () => void;
4
4
  /**
@@ -49,5 +49,24 @@ export declare const sendUserFeedback: (input: {
49
49
  issueId: null | string;
50
50
  }>;
51
51
  export declare const captureError: (error: Error, extras?: CaptureExtras) => void;
52
+ /** v0.9.6 #2 — upload the wireframe replay ring as a `replay`
53
+ * attachment. Plain NDJSON (one snapshot per line) — server may
54
+ * gzip on storage; the network upload is base64.
55
+ *
56
+ * rc.4: route through `base64Utf8` so non-Latin-1 text inside any
57
+ * walked TextView (Japanese / Chinese / em-dash etc.) doesn't blow
58
+ * up the Hermes-spec `btoa`. The pre-rc.4 inline `btoa(ndjson)` path
59
+ * threw `InvalidCharacterError` on those code points, the
60
+ * surrounding catch swallowed it silently, and the replay
61
+ * attachment never landed. Insight 2026-05-18 verify caught it
62
+ * after rc.3's walker fix surfaced deep TextView content. Dev
63
+ * logs replace the silent catch so the next failure shape is
64
+ * visible. */
65
+ declare function captureAndAttachReplay(event: Event, ndjson: string): Promise<void>;
52
66
  export declare const captureException: (error: Error, extras?: CaptureExtras) => void;
67
+ /** rc.4 — test hook. The real replay attach path is internal so we
68
+ * don't bloat the public surface, but the encoding bug Insight hit
69
+ * on 2026-05-18 needs a behaviour-level test that exercises the
70
+ * same code path captureException runs in production. */
71
+ export declare const __captureAndAttachReplayForTests: typeof captureAndAttachReplay;
53
72
  //# sourceMappingURL=capture.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../src/capture.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAoD,IAAI,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5F,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAgB5D,eAAO,MAAM,+BAA+B,QAAO,IAElD,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,IAAI,GAAG,IAAI,KAAG,IAE3C,CAAC;AAEF,eAAO,MAAM,OAAO,QAAO,IAAI,GAAG,IAAa,CAAC;AAEhD;sEACsE;AACtE,eAAO,MAAM,gBAAgB,QAAO,MAAM,GAAG,SAAmC,CAAC;AAEjF,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;qDAGiD;IACjD,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,GAAU,OAAO;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf,KAAG,OAAO,CAAC,IAAI,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,IAAI,GAAG,MAAM,CAAA;CAAE,CAKxD,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,OAAO,KAAK,EAAE,SAAS,aAAa,KAAG,IAqGnE,CAAC;AAyFF,eAAO,MAAM,gBAAgB,UA9LO,KAAK,WAAW,aAAa,KAAG,IA8LxB,CAAC"}
1
+ {"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../src/capture.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAA+B,KAAK,EAAgB,IAAI,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5F,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAgB5D,eAAO,MAAM,+BAA+B,QAAO,IAElD,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,IAAI,GAAG,IAAI,KAAG,IAE3C,CAAC;AAEF,eAAO,MAAM,OAAO,QAAO,IAAI,GAAG,IAAa,CAAC;AAEhD;sEACsE;AACtE,eAAO,MAAM,gBAAgB,QAAO,MAAM,GAAG,SAAmC,CAAC;AAEjF,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;qDAGiD;IACjD,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,GAAU,OAAO;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf,KAAG,OAAO,CAAC,IAAI,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,IAAI,GAAG,MAAM,CAAA;CAAE,CAKxD,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,OAAO,KAAK,EAAE,SAAS,aAAa,KAAG,IAiHnE,CAAC;AAEF;;;;;;;;;;;;eAYe;AACf,iBAAe,sBAAsB,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAiCjF;AAwDD,eAAO,MAAM,gBAAgB,UAzNO,KAAK,WAAW,aAAa,KAAG,IAyNxB,CAAC;AAE7C;;;0DAG0D;AAC1D,eAAO,MAAM,gCAAgC,+BAAyB,CAAC"}