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

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.
@@ -0,0 +1,145 @@
1
+ package com.sentori
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import android.os.Bundle
6
+ import java.lang.ref.WeakReference
7
+
8
+ /**
9
+ * v1.0.0-rc.2 — process-wide foreground-Activity tracker.
10
+ *
11
+ * Both [SentoriScreenshotCapture] and [SentoriReplayCapture] need a
12
+ * pointer to the currently-foregrounded Activity to drive their
13
+ * native captures. The previous implementation registered each
14
+ * helper's own `ActivityLifecycleCallbacks` inside
15
+ * [SentoriCrashHandler.register], which runs from the Expo module's
16
+ * `OnCreate` lifecycle — **after** the MainActivity has already been
17
+ * resumed in the typical Insight / Expo dev-launcher topology.
18
+ * `ActivityLifecycleCallbacks` does not back-fill the current state,
19
+ * it only forwards future events, so `lastActivity` would stay null
20
+ * for the entire session if the user never backgrounded the app.
21
+ *
22
+ * Fix mirrors the iOS keyWindow 4-layer fallback:
23
+ *
24
+ * 1. Lifecycle callbacks track resumed/started activities going
25
+ * forward (the same approach as before, kept).
26
+ * 2. **On first install** we also probe `ActivityThread` reflection
27
+ * to back-fill whatever Activity is currently foreground, so the
28
+ * already-resumed window is seen by the SDK from boot.
29
+ * 3. If reflection fails (e.g. on a future Android release that
30
+ * removes `ActivityThread` access), the lifecycle callbacks
31
+ * still catch the next foreground transition.
32
+ *
33
+ * `lastPath` carries the diagnostic provenance for `probeWireframe` /
34
+ * `probeScreenshot`, so the JS side (and Insight) can tell whether
35
+ * the SDK was working off a live lifecycle event or fell back to
36
+ * reflection.
37
+ */
38
+ object SentoriForegroundActivity {
39
+
40
+ @Volatile private var lastActivity: WeakReference<Activity>? = null
41
+ @Volatile var lastPath: String = "none(not-yet-resolved)"
42
+ private set
43
+
44
+ /** Idempotent. Call from [SentoriCrashHandler.register]; subsequent
45
+ * calls are no-ops because the same callbacks object would be
46
+ * registered twice otherwise. */
47
+ @Volatile private var registered = false
48
+
49
+ @Synchronized
50
+ fun install(application: Application) {
51
+ if (registered) {
52
+ // Even on second install attempt, try the reflection
53
+ // back-fill again — process state may have advanced.
54
+ backfillFromActivityThread()
55
+ return
56
+ }
57
+ registered = true
58
+ application.registerActivityLifecycleCallbacks(object :
59
+ Application.ActivityLifecycleCallbacks {
60
+ override fun onActivityCreated(a: Activity, b: Bundle?) {
61
+ set(a, "lifecycle.created")
62
+ }
63
+ override fun onActivityStarted(a: Activity) {
64
+ set(a, "lifecycle.started")
65
+ }
66
+ override fun onActivityResumed(a: Activity) {
67
+ set(a, "lifecycle.resumed")
68
+ }
69
+ override fun onActivityPaused(a: Activity) {}
70
+ override fun onActivityStopped(a: Activity) {}
71
+ override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
72
+ override fun onActivityDestroyed(a: Activity) {}
73
+ })
74
+ // Back-fill in case the Activity was already resumed before
75
+ // we got installed (the dev-launcher → MainActivity transition
76
+ // happens before the Expo module's OnCreate fires).
77
+ backfillFromActivityThread()
78
+ }
79
+
80
+ /** Public for tests + the helpers' own explicit `setActivity`
81
+ * paths (kept for backwards-compat). */
82
+ fun set(activity: Activity, source: String) {
83
+ lastActivity = WeakReference(activity)
84
+ lastPath = source
85
+ }
86
+
87
+ fun current(): Activity? {
88
+ val live = lastActivity?.get()
89
+ if (live != null) return live
90
+ // Last-ditch: try reflection again in case install() ran before
91
+ // any Activity existed. Cheap if it fails.
92
+ backfillFromActivityThread()
93
+ return lastActivity?.get()
94
+ }
95
+
96
+ /**
97
+ * Best-effort foreground-Activity lookup using non-SDK reflection.
98
+ * Walks `ActivityThread.sCurrentActivityThread.mActivities` (an
99
+ * `ArrayMap<IBinder, ActivityClientRecord>`) and finds the record
100
+ * whose `paused` field is false. This is what Stetho / LeakCanary
101
+ * / Firebase Performance do, with the same caveat that future
102
+ * Android versions may break it — failures are silent and the
103
+ * lifecycle-callbacks path still wins.
104
+ *
105
+ * Tagged via `lastPath = "reflection.activityThread"` so the JS
106
+ * probe can see when reflection had to step in.
107
+ */
108
+ @Suppress("PrivateApi", "UNCHECKED_CAST")
109
+ private fun backfillFromActivityThread() {
110
+ try {
111
+ val activityThreadClass = Class.forName("android.app.ActivityThread")
112
+ val currentActivityThread = activityThreadClass
113
+ .getDeclaredMethod("currentActivityThread")
114
+ .also { it.isAccessible = true }
115
+ .invoke(null) ?: return
116
+ val activitiesField = activityThreadClass
117
+ .getDeclaredField("mActivities")
118
+ .also { it.isAccessible = true }
119
+ val activities = activitiesField.get(currentActivityThread) ?: return
120
+
121
+ // ArrayMap<IBinder, ActivityClientRecord> — iterate via reflection
122
+ // so we don't have to depend on the (also private) ArrayMap shape.
123
+ val valuesIter = (activities as? Map<*, *>)?.values?.iterator() ?: return
124
+ while (valuesIter.hasNext()) {
125
+ val record = valuesIter.next() ?: continue
126
+ val recordClass = record.javaClass
127
+ val pausedField = try {
128
+ recordClass.getDeclaredField("paused").also { it.isAccessible = true }
129
+ } catch (_: NoSuchFieldException) { null }
130
+ val activityField = try {
131
+ recordClass.getDeclaredField("activity").also { it.isAccessible = true }
132
+ } catch (_: NoSuchFieldException) { null } ?: continue
133
+ val paused = pausedField?.getBoolean(record) ?: false
134
+ val candidate = activityField.get(record) as? Activity ?: continue
135
+ if (!paused && !candidate.isFinishing && !candidate.isDestroyed) {
136
+ set(candidate, "reflection.activityThread")
137
+ return
138
+ }
139
+ }
140
+ } catch (_: Throwable) {
141
+ // Reflection unavailable; the lifecycle-callbacks path
142
+ // will still catch the next foreground transition.
143
+ }
144
+ }
145
+ }
@@ -34,6 +34,19 @@ class SentoriModule : Module() {
34
34
  SentoriReplayCapture.captureWireframe(maskedIds)
35
35
  }
36
36
 
37
+ // v0.9.12 — diagnostic readout for replay. See iOS side.
38
+ Function("probeWireframe") {
39
+ SentoriReplayCapture.probe()
40
+ }
41
+
42
+ // v1.0.0-rc.2 — diagnostic readout for screenshot. Surfaces
43
+ // foreground-Activity provenance, decor-view state, and the
44
+ // last failure reason so the JS side can ship raw state back
45
+ // when screenshot returns null.
46
+ Function("probeScreenshot") {
47
+ SentoriScreenshotCapture.probe()
48
+ }
49
+
37
50
  // v0.9.4 #1 — Mobile Vitals exposure.
38
51
  Function("markJsBridgeReady") {
39
52
  SentoriMobileVitals.markJsBridgeReady()
@@ -9,7 +9,6 @@ import android.widget.ImageView
9
9
  import android.widget.TextView
10
10
  import org.json.JSONArray
11
11
  import org.json.JSONObject
12
- import java.lang.ref.WeakReference
13
12
 
14
13
  /**
15
14
  * v0.9.6 #2 — wireframe session replay (Android side).
@@ -27,34 +26,55 @@ object SentoriReplayCapture {
27
26
 
28
27
  private const val MAX_NODES = 800
29
28
 
30
- @Volatile private var lastActivity: WeakReference<Activity>? = null
29
+ // v0.9.12 diagnostic readout so the JS side can ask "why is the
30
+ // ring empty?" without parsing logcat. Mirrors the iOS side.
31
+ @Volatile private var lastDiagPath: String = "none(not-yet-called)"
32
+ @Volatile private var lastDiagNodes: Int = 0
31
33
 
34
+ @JvmStatic
35
+ fun probe(): Map<String, Any> {
36
+ val activity = SentoriForegroundActivity.current()
37
+ return mapOf(
38
+ "lastPath" to lastDiagPath,
39
+ "lastNodes" to lastDiagNodes,
40
+ "trackedSource" to SentoriForegroundActivity.lastPath,
41
+ "trackedActivity" to (activity?.javaClass?.name ?: "null"),
42
+ "decorViewFound" to (activity?.window?.decorView != null),
43
+ )
44
+ }
45
+
46
+ /** Backwards compat — pre-rc.2 callers that hand-fed an Activity
47
+ * through `setActivity` still work; we forward to the shared
48
+ * tracker so screenshot + replay both see it. */
32
49
  @JvmStatic
33
50
  fun setActivity(activity: Activity?) {
34
- lastActivity = activity?.let { WeakReference(it) }
51
+ if (activity != null) SentoriForegroundActivity.set(activity, "manual.setActivity")
35
52
  }
36
53
 
37
- /** Attach an ActivityLifecycleCallbacks so future
38
- * `captureWireframe()` calls know which Activity to walk. */
54
+ /** Idempotent. Wires the replay helper into the shared tracker;
55
+ * kept as a public entrypoint for backwards compat with existing
56
+ * call sites. */
39
57
  @JvmStatic
40
58
  fun register(application: android.app.Application) {
41
- application.registerActivityLifecycleCallbacks(object :
42
- android.app.Application.ActivityLifecycleCallbacks {
43
- override fun onActivityCreated(a: Activity, b: android.os.Bundle?) { setActivity(a) }
44
- override fun onActivityStarted(a: Activity) { setActivity(a) }
45
- override fun onActivityResumed(a: Activity) { setActivity(a) }
46
- override fun onActivityPaused(a: Activity) {}
47
- override fun onActivityStopped(a: Activity) {}
48
- override fun onActivitySaveInstanceState(a: Activity, b: android.os.Bundle) {}
49
- override fun onActivityDestroyed(a: Activity) {}
50
- })
59
+ SentoriForegroundActivity.install(application)
51
60
  }
52
61
 
53
62
  @JvmStatic
54
63
  fun captureWireframe(maskedIds: List<String>): String? {
55
- val activity = lastActivity?.get() ?: return null
56
- val root = activity.window?.decorView ?: return null
57
- if (root.width <= 0 || root.height <= 0) return null
64
+ val activity = SentoriForegroundActivity.current()
65
+ if (activity == null) {
66
+ lastDiagPath = "activity.null"
67
+ return null
68
+ }
69
+ val root = activity.window?.decorView
70
+ if (root == null) {
71
+ lastDiagPath = "decorView.null"
72
+ return null
73
+ }
74
+ if (root.width <= 0 || root.height <= 0) {
75
+ lastDiagPath = "root.zero-size"
76
+ return null
77
+ }
58
78
 
59
79
  val maskedSet = maskedIds.toHashSet()
60
80
  val nodes = JSONArray()
@@ -62,6 +82,9 @@ object SentoriReplayCapture {
62
82
  val rootLoc = IntArray(2).also { root.getLocationInWindow(it) }
63
83
  walk(root, false, maskedSet, rootLoc, rect, nodes)
64
84
 
85
+ lastDiagPath = "ok(${SentoriForegroundActivity.lastPath})"
86
+ lastDiagNodes = nodes.length()
87
+
65
88
  val payload = JSONObject().apply {
66
89
  put("ts", System.currentTimeMillis())
67
90
  put("width", root.width)
@@ -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,8 +29,33 @@ import UIKit
29
29
  return result
30
30
  }
31
31
 
32
+ /// Last path the keyWindow lookup took. Exposed to JS via
33
+ /// `probeWireframe()` so the failure-mode diagnostic in Metro can
34
+ /// tell scene-race from "no window at all" without re-rolling the
35
+ /// pod. Updated on every captureSync call.
36
+ @objc public static var lastDiagPath: String = "none(not-yet-called)"
37
+ @objc public static var lastDiagNodes: Int = 0
38
+ @objc public static var lastDiagSceneCount: Int = 0
39
+ @objc public static var lastDiagWindowCount: Int = 0
40
+ private static var loggedFirstResult = false
41
+
32
42
  private static func captureSync(maskedIds: Set<String>) -> String? {
33
- guard let window = keyWindow() else { return nil }
43
+ let (winOpt, path) = resolveKeyWindow()
44
+ lastDiagPath = path
45
+ lastDiagSceneCount = currentSceneCount()
46
+ lastDiagWindowCount = currentWindowCount()
47
+ guard let window = winOpt else {
48
+ if !loggedFirstResult {
49
+ NSLog(
50
+ "[sentori] wireframe: returning nil — keyWindow path=%@ scenes=%d windows=%d",
51
+ path,
52
+ lastDiagSceneCount,
53
+ lastDiagWindowCount
54
+ )
55
+ loggedFirstResult = true
56
+ }
57
+ return nil
58
+ }
34
59
  var nodes: [[String: Any]] = []
35
60
  walk(
36
61
  view: window,
@@ -39,6 +64,17 @@ import UIKit
39
64
  window: window,
40
65
  nodes: &nodes
41
66
  )
67
+ lastDiagNodes = nodes.count
68
+ if !loggedFirstResult {
69
+ NSLog(
70
+ "[sentori] wireframe: first capture ok — keyWindow path=%@ bounds=%.0fx%.0f nodes=%d",
71
+ path,
72
+ window.bounds.width,
73
+ window.bounds.height,
74
+ nodes.count
75
+ )
76
+ loggedFirstResult = true
77
+ }
42
78
  let payload: [String: Any] = [
43
79
  "ts": Int(Date().timeIntervalSince1970 * 1000),
44
80
  "width": Double(window.bounds.width),
@@ -51,19 +87,77 @@ import UIKit
51
87
  return nil
52
88
  }
53
89
 
54
- private static func keyWindow() -> UIWindow? {
90
+ /// Four-tier window resolution. The previous single-pass loop
91
+ /// returned nil whenever the first connected scene was a
92
+ /// `.background` or `.unattached` SwiftUI/preview scene that had
93
+ /// no windows yet — common on iOS 26 cold-start where the JS
94
+ /// thread spins up the replay tick before scene activation
95
+ /// settles (the v0.9.6 default 1 Hz fires within ~200 ms).
96
+ private static func resolveKeyWindow() -> (UIWindow?, String) {
55
97
  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
98
+ let scenes = Array(UIApplication.shared.connectedScenes)
99
+ // Pass 1: foregroundActive scene with a key window.
100
+ for scene in scenes where scene.activationState == .foregroundActive {
101
+ if let ws = scene as? UIWindowScene,
102
+ let key = ws.windows.first(where: { $0.isKeyWindow }) {
103
+ return (key, "scene.fg.key")
104
+ }
105
+ }
106
+ // Pass 2: foregroundActive scene's first window (no key set yet).
107
+ for scene in scenes where scene.activationState == .foregroundActive {
108
+ if let ws = scene as? UIWindowScene, let win = ws.windows.first {
109
+ return (win, "scene.fg.first")
110
+ }
111
+ }
112
+ // Pass 3: foregroundInactive (mid-transition) scene with any window.
113
+ for scene in scenes where scene.activationState == .foregroundInactive {
114
+ if let ws = scene as? UIWindowScene, let win = ws.windows.first {
115
+ return (win, "scene.fgi.first")
60
116
  }
61
- if let first = ws.windows.first {
62
- return first
117
+ }
118
+ // Pass 4: any scene at all with windows.
119
+ for scene in scenes {
120
+ if let ws = scene as? UIWindowScene, let win = ws.windows.first {
121
+ return (win, "scene.any.first")
63
122
  }
64
123
  }
124
+ // Fallthrough → legacy windows list.
125
+ }
126
+ if let leg = UIApplication.shared.windows.first {
127
+ return (leg, "legacy.first")
65
128
  }
66
- return UIApplication.shared.windows.first
129
+ return (nil, "none")
130
+ }
131
+
132
+ private static func currentSceneCount() -> Int {
133
+ if #available(iOS 13.0, *) {
134
+ return UIApplication.shared.connectedScenes.count
135
+ }
136
+ return 0
137
+ }
138
+
139
+ private static func currentWindowCount() -> Int {
140
+ if #available(iOS 13.0, *) {
141
+ return UIApplication.shared.connectedScenes.reduce(0) { acc, scene in
142
+ acc + ((scene as? UIWindowScene)?.windows.count ?? 0)
143
+ }
144
+ }
145
+ return UIApplication.shared.windows.count
146
+ }
147
+
148
+ /// JS-side probe. Returns a dict the example/dashboard can render
149
+ /// to ask "why is the ring empty?" without parsing Metro logs.
150
+ /// `lastNodes == 0 && lastPath != "none"` means the window walk
151
+ /// happened but the tree was empty (unusual — backgrounded?).
152
+ /// `lastPath == "none(...)"` means no UIWindow was reachable at
153
+ /// the moment of the last tick.
154
+ @objc public static func probe() -> [String: Any] {
155
+ return [
156
+ "lastPath": lastDiagPath,
157
+ "lastNodes": lastDiagNodes,
158
+ "sceneCount": lastDiagSceneCount,
159
+ "windowCount": lastDiagWindowCount,
160
+ ]
67
161
  }
68
162
 
69
163
  /// Cap on nodes per snapshot — extremely deep / wide trees can