@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.
- package/android/src/main/java/com/sentori/SentoriForegroundActivity.kt +145 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +13 -0
- package/android/src/main/java/com/sentori/SentoriReplayCapture.kt +261 -68
- package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +72 -36
- package/ios/SentoriModule.swift +15 -0
- package/ios/SentoriReplayCapture.swift +135 -10
- package/ios/SentoriScreenshotCapture.swift +69 -3
- package/lib/base64.d.ts +25 -0
- package/lib/base64.d.ts.map +1 -0
- package/lib/base64.js +30 -0
- package/lib/base64.js.map +1 -0
- package/lib/capture.d.ts +20 -1
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +45 -21
- package/lib/capture.js.map +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.bak +64 -0
- package/lib/index.js.map +1 -1
- package/lib/native.d.ts +68 -0
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +115 -0
- package/lib/native.js.map +1 -1
- package/lib/replay.d.ts +28 -4
- package/lib/replay.d.ts.map +1 -1
- package/lib/replay.js +242 -65
- package/lib/replay.js.map +1 -1
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +16 -0
- package/lib/transport.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/base64.test.ts +55 -0
- package/src/__tests__/capture-replay.test.ts +150 -0
- package/src/__tests__/replay-encoding.test.ts +237 -0
- package/src/base64.ts +29 -0
- package/src/capture.ts +56 -22
- package/src/index.ts +3 -0
- package/src/native.ts +177 -0
- package/src/replay.ts +294 -70
- 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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
@Volatile private var
|
|
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
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* `
|
|
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
|
-
|
|
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 =
|
|
106
|
-
|
|
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 =
|
|
124
|
-
|
|
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
|
-
|
|
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 (
|
|
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)
|
|
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
|
package/ios/SentoriModule.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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? {
|
package/lib/base64.d.ts
ADDED
|
@@ -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
|
package/lib/capture.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../src/capture.ts"],"names":[],"mappings":"
|
|
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"}
|