catalyst-core 0.2.0-beta.1 → 0.2.0-beta.3

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 (67) hide show
  1. package/dist/native/androidProject/app/build.gradle.kts +26 -8
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +381 -78
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +29 -8
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MetricsMonitor.kt +143 -17
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +7 -1
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/OfflineCacheService.kt +374 -0
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +162 -26
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
  10. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
  11. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
  12. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
  13. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/CustomWebviewTest.kt +7 -2
  14. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
  15. package/dist/native/bridge/WebBridge.js +2 -2
  16. package/dist/native/bridge/constants/NativeInterfaces.js +2 -1
  17. package/dist/native/bridge/perf/collectors/ApiCollector.js +22 -0
  18. package/dist/native/bridge/perf/collectors/CacheCollector.js +40 -0
  19. package/dist/native/bridge/perf/collectors/HookCollector.js +43 -0
  20. package/dist/native/bridge/perf/collectors/InsightsCollector.js +47 -0
  21. package/dist/native/bridge/perf/collectors/InteractionCollector.js +38 -0
  22. package/dist/native/bridge/perf/collectors/KeyboardCollector.js +25 -0
  23. package/dist/native/bridge/perf/collectors/NavigationCollector.js +53 -0
  24. package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +7 -0
  25. package/dist/native/bridge/perf/collectors/RenderCollector.js +40 -0
  26. package/dist/native/bridge/perf/collectors/ScrollCollector.js +25 -0
  27. package/dist/native/bridge/perf/collectors/ThreadCollector.js +27 -0
  28. package/dist/native/bridge/perf/core/constants.js +43 -0
  29. package/dist/native/bridge/perf/core/measure.js +40 -0
  30. package/dist/native/bridge/perf/core/session.js +34 -0
  31. package/dist/native/bridge/perf/core/store.js +1 -0
  32. package/dist/native/bridge/perf/core/summary.js +1 -0
  33. package/dist/native/bridge/perf/core/traceExport.js +1 -0
  34. package/dist/native/bridge/perf/core/waterfalls.js +1 -0
  35. package/dist/native/bridge/perf/index.js +81 -0
  36. package/dist/native/buildAndroid/assets.js +2 -2
  37. package/dist/native/buildIos/config.js +1 -1
  38. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
  39. package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
  40. package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
  41. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +49 -4
  42. package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
  43. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineCacheService.swift +683 -0
  44. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineURLSchemeHandler.swift +94 -0
  45. package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
  46. package/dist/native/iosnativeWebView/Sources/Core/Utils/ResourceURLProtocol.swift +19 -6
  47. package/dist/native/iosnativeWebView/Sources/Core/Utils/SafeAreaUtils.swift +1 -0
  48. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +1 -0
  49. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
  50. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
  51. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
  52. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +141 -1
  53. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +157 -3
  54. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
  55. package/dist/native/renameAndroidProject.js +2 -2
  56. package/dist/offline/catalyst-sw.js +1 -0
  57. package/dist/offline/registerServiceWorker.js +1 -0
  58. package/dist/router/ClientRouter.js +1 -1
  59. package/dist/scripts/build.js +1 -1
  60. package/dist/scripts/devBuild.js +1 -1
  61. package/dist/scripts/generateOfflineManifest.js +1 -0
  62. package/dist/server/expressServer.js +2 -1
  63. package/dist/server/renderer/document/Body.js +1 -1
  64. package/dist/server/renderer/handler.js +3 -3
  65. package/dist/webpack/base.babel.js +1 -1
  66. package/mcp_v2/knowledge-base.json +13 -0
  67. package/package.json +1 -1
@@ -0,0 +1,289 @@
1
+ package io.yourname.androidproject.utils
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.os.SystemClock
6
+ import android.webkit.WebView
7
+ import io.yourname.androidproject.BuildConfig
8
+ import org.json.JSONArray
9
+ import org.json.JSONObject
10
+ import java.util.concurrent.ConcurrentLinkedQueue
11
+ import java.util.concurrent.atomic.AtomicBoolean
12
+
13
+ /**
14
+ * PerfEventBuffer — native ring buffer for Catalyst profiler events.
15
+ *
16
+ * Collects all events from onCreate (boot timing, cache, API calls) and flushes
17
+ * them as a single JSON array to the web layer after onPageFinished.
18
+ *
19
+ * Design:
20
+ * - add() is safe from any thread (ConcurrentLinkedQueue)
21
+ * - scheduleFlush() called from onPageFinished — posts to main thread after 250ms
22
+ * so the JS environment is ready to receive events
23
+ * - flush() serialises all events to JSON array, calls window.__catalystPerfBatch()
24
+ * - CacheSummarizer: running tally of hit/miss/fetch + top-5 min-heap for InsightsCollector
25
+ * - Max ring buffer size: 500 events (oldest dropped if exceeded)
26
+ *
27
+ * API call timing:
28
+ * - bridgeCallReceived(callId, method) → stamps t0, stores in pendingCalls map
29
+ * - bridgeCallDispatched(callId) → stamps t1, computes durationMs, adds api-call event to buffer
30
+ */
31
+ object PerfEventBuffer {
32
+
33
+ private const val TAG = "PerfEventBuffer"
34
+ private const val MAX_BUFFER_SIZE = 500
35
+ private const val FLUSH_DELAY_MS = 250L
36
+ private const val FLUSH_RETRY_DELAY_MS = 250L
37
+ private const val MAX_FLUSH_RETRIES = 5
38
+ private const val PERIODIC_FLUSH_INTERVAL_MS = 4000L
39
+
40
+ private val buffer = ConcurrentLinkedQueue<JSONObject>()
41
+ private val mainHandler = Handler(Looper.getMainLooper())
42
+ private val flushed = AtomicBoolean(false)
43
+ @Volatile private var profilerEnabled = false
44
+ private val deliveryState = PerfDeliveryState<JSONArray>()
45
+
46
+ // Periodic flush state
47
+ @Volatile private var periodicWebView: WebView? = null
48
+ private var lastFlushedSize = 0
49
+ private val periodicRunnable: Runnable = object : Runnable {
50
+ override fun run() {
51
+ if (!isEnabled()) return
52
+ val wv = periodicWebView ?: return
53
+ val pendingBatch = deliveryState.current()
54
+ if (pendingBatch != null) {
55
+ deliver(wv, pendingBatch)
56
+ } else if (buffer.size > lastFlushedSize) {
57
+ val currentSize = buffer.size
58
+ android.util.Log.d(TAG, "[PerfBuffer] periodicFlush() — $currentSize events since last flush")
59
+ beginFlush(wv, includeCacheSummary = false)
60
+ }
61
+ mainHandler.postDelayed(this, PERIODIC_FLUSH_INTERVAL_MS)
62
+ }
63
+ }
64
+
65
+ // Pending API call timing: callId → t0 nativeMs + method
66
+ private val pendingCalls = java.util.concurrent.ConcurrentHashMap<String, Pair<Long, String>>()
67
+
68
+ // ─── CacheSummarizer ─────────────────────────────────────────────────────
69
+
70
+ private var cacheHits = 0
71
+ private var cacheMisses = 0
72
+ private var cacheFetches = 0
73
+ private var cacheTotalMs = 0L
74
+ // top-5 slowest: list of Pair(durationMs, filename)
75
+ private val cacheTopSlow = java.util.PriorityQueue<Pair<Long, String>>(
76
+ 6, compareBy { it.first }
77
+ )
78
+
79
+ private fun updateCacheSummary(type: String, durationMs: Long, url: String) {
80
+ val filename = try {
81
+ val path = java.net.URI(url).path
82
+ path.split("/").filter { it.isNotEmpty() }.lastOrNull()?.take(40) ?: url.take(40)
83
+ } catch (e: Exception) { url.take(40) }
84
+
85
+ when (type) {
86
+ "cache-hit-memory", "cache-hit-disk" -> cacheHits++
87
+ "cache-miss-fetch" -> cacheMisses++
88
+ "network-fetch-complete" -> {
89
+ cacheFetches++
90
+ cacheTotalMs += durationMs
91
+ cacheTopSlow.add(Pair(durationMs, filename))
92
+ if (cacheTopSlow.size > 5) cacheTopSlow.poll() // remove smallest
93
+ }
94
+ }
95
+ }
96
+
97
+ private fun buildCacheSummaryEvent(): JSONObject? {
98
+ val total = cacheHits + cacheMisses
99
+ if (total == 0) return null
100
+ val hitRate = if (total > 0) (cacheHits * 100.0 / total) else 0.0
101
+ val avgFetchMs = if (cacheFetches > 0) cacheTotalMs / cacheFetches else 0L
102
+ val topSlow = JSONArray()
103
+ cacheTopSlow.sortedByDescending { it.first }.forEach { (ms, name) ->
104
+ topSlow.put(JSONObject().apply {
105
+ put("filename", name)
106
+ put("durationMs", ms)
107
+ })
108
+ }
109
+ return JSONObject().apply {
110
+ put("type", "cache-summary")
111
+ put("hits", cacheHits)
112
+ put("misses", cacheMisses)
113
+ put("fetches", cacheFetches)
114
+ put("total", total)
115
+ put("hitRatePct", Math.round(hitRate * 10) / 10.0)
116
+ put("avgFetchMs", avgFetchMs)
117
+ put("topSlowest", topSlow)
118
+ put("nativeTime", SystemClock.elapsedRealtime())
119
+ }
120
+ }
121
+
122
+ // ─── Public API ──────────────────────────────────────────────────────────
123
+
124
+ fun configure(enabled: Boolean) {
125
+ profilerEnabled = BuildConfig.DEBUG && enabled
126
+ if (!profilerEnabled) reset()
127
+ }
128
+
129
+ fun isEnabled(): Boolean = profilerEnabled
130
+
131
+ /** Add a perf event to the buffer. Safe from any thread. */
132
+ fun add(event: JSONObject) {
133
+ if (!isEnabled()) return
134
+ if (buffer.size >= MAX_BUFFER_SIZE) {
135
+ buffer.poll() // drop oldest
136
+ }
137
+ // Update cache summary tally
138
+ val type = event.optString("type")
139
+ if (type.startsWith("cache-") || type == "network-fetch-complete") {
140
+ updateCacheSummary(type, event.optLong("durationMs", 0), event.optString("url"))
141
+ }
142
+ buffer.add(event)
143
+ android.util.Log.d(TAG, "[PerfBuffer] add() type=$type bufferSize=${buffer.size}")
144
+ }
145
+
146
+ /**
147
+ * Record start of an @JavascriptInterface bridge call.
148
+ * Call this immediately when the method is invoked on the JavascriptInterface.
149
+ */
150
+ fun bridgeCallReceived(callId: String, method: String) {
151
+ if (!isEnabled()) return
152
+ pendingCalls[callId] = Pair(SystemClock.elapsedRealtime(), method)
153
+ }
154
+
155
+ /**
156
+ * Record the end of immediate native bridge-handler work and add an api-call event.
157
+ */
158
+ fun bridgeCallDispatched(callId: String) {
159
+ if (!isEnabled()) return
160
+ val pending = pendingCalls.remove(callId) ?: return
161
+ val (t0, method) = pending
162
+ val t1 = SystemClock.elapsedRealtime()
163
+ val durationMs = t1 - t0
164
+ add(JSONObject().apply {
165
+ put("type", "api-call")
166
+ put("callId", callId)
167
+ put("method", method)
168
+ put("nativeStartMs", t0)
169
+ put("nativeEndMs", t1)
170
+ put("durationMs", durationMs)
171
+ put("thread", Thread.currentThread().name)
172
+ })
173
+ }
174
+
175
+ /**
176
+ * Schedule a flush after onPageFinished (initial/full page load).
177
+ * Also starts the periodic flush loop for subsequent SPA navigations.
178
+ */
179
+ fun scheduleFlush(webView: WebView) {
180
+ if (!isEnabled()) return
181
+ if (flushed.getAndSet(true)) {
182
+ android.util.Log.d(TAG, "[PerfBuffer] scheduleFlush() skipped — already flushed")
183
+ return
184
+ }
185
+ android.util.Log.d(TAG, "[PerfBuffer] scheduleFlush() scheduled in ${FLUSH_DELAY_MS}ms, bufferSize=${buffer.size}")
186
+ mainHandler.postDelayed({
187
+ beginFlush(webView, includeCacheSummary = true)
188
+ // Start periodic flush loop for SPA navigations after initial load
189
+ startPeriodicFlush(webView)
190
+ }, FLUSH_DELAY_MS)
191
+ }
192
+
193
+ /**
194
+ * Flush any pending events immediately (e.g. before a full URL navigation).
195
+ * Does NOT reset the buffer — only drains buffered events up to this point.
196
+ */
197
+ fun flushNow(webView: WebView) {
198
+ if (!isEnabled()) return
199
+ mainHandler.post { beginFlush(webView, includeCacheSummary = false) }
200
+ }
201
+
202
+ /**
203
+ * Start periodic flush loop — fires every 4s and flushes only if new events exist.
204
+ * Call after onPageFinished so the JS receiver is registered.
205
+ */
206
+ fun startPeriodicFlush(webView: WebView) {
207
+ if (!isEnabled()) return
208
+ mainHandler.removeCallbacks(periodicRunnable)
209
+ periodicWebView = webView
210
+ lastFlushedSize = 0
211
+ mainHandler.postDelayed(periodicRunnable, PERIODIC_FLUSH_INTERVAL_MS)
212
+ android.util.Log.d(TAG, "[PerfBuffer] startPeriodicFlush() — interval=${PERIODIC_FLUSH_INTERVAL_MS}ms")
213
+ }
214
+
215
+ /** Stop periodic flush (call on destroy / loadUrl reset). */
216
+ fun stopPeriodicFlush() {
217
+ mainHandler.removeCallbacks(periodicRunnable)
218
+ periodicWebView = null
219
+ }
220
+
221
+ /**
222
+ * Reset the buffer for the next page load.
223
+ * Call this from loadUrl so boot timing events for the new page are captured.
224
+ */
225
+ fun reset() {
226
+ stopPeriodicFlush()
227
+ buffer.clear()
228
+ deliveryState.reset()
229
+ pendingCalls.clear()
230
+ flushed.set(false)
231
+ lastFlushedSize = 0
232
+ cacheHits = 0
233
+ cacheMisses = 0
234
+ cacheFetches = 0
235
+ cacheTotalMs = 0L
236
+ cacheTopSlow.clear()
237
+ }
238
+
239
+ // ─── Flush ───────────────────────────────────────────────────────────────
240
+
241
+ /** Moves the current queue into an immutable in-flight batch until JS acknowledges it. */
242
+ private fun beginFlush(webView: WebView, includeCacheSummary: Boolean) {
243
+ if (!isEnabled()) return
244
+ if (Looper.myLooper() != Looper.getMainLooper()) {
245
+ mainHandler.post { beginFlush(webView, includeCacheSummary) }
246
+ return
247
+ }
248
+ if (deliveryState.hasInFlight()) return
249
+ val events = JSONArray()
250
+ while (buffer.isNotEmpty()) {
251
+ buffer.poll()?.let { events.put(it) }
252
+ }
253
+ if (includeCacheSummary) buildCacheSummaryEvent()?.let { events.put(it) }
254
+ if (events.length() == 0) return
255
+
256
+ val batch = deliveryState.begin(events) ?: return
257
+ deliver(webView, batch)
258
+ }
259
+
260
+ private fun deliver(webView: WebView, batch: PerfDeliveryState.Batch<JSONArray>) {
261
+ if (!isEnabled() || !deliveryState.isCurrent(batch)) return
262
+ try {
263
+ val json = batch.payload.toString().replace("\\", "\\\\").replace("'", "\\'")
264
+ val batchId = JSONObject.quote(batch.id)
265
+ webView.evaluateJavascript(
266
+ "window.__catalystPerfBatch ? window.__catalystPerfBatch($batchId, '$json') : false",
267
+ ) { result ->
268
+ if (result == "true") acknowledge(batch) else retry(webView, batch)
269
+ }
270
+ } catch (_: Exception) {
271
+ retry(webView, batch)
272
+ }
273
+ }
274
+
275
+ private fun acknowledge(batch: PerfDeliveryState.Batch<JSONArray>) {
276
+ if (deliveryState.acknowledge(batch)) {
277
+ lastFlushedSize = 0
278
+ }
279
+ }
280
+
281
+ private fun retry(webView: WebView, batch: PerfDeliveryState.Batch<JSONArray>) {
282
+ val retry = deliveryState.retry(batch, MAX_FLUSH_RETRIES)
283
+ if (retry == null) {
284
+ android.util.Log.w(TAG, "[PerfBuffer] batch ${batch.id} was not acknowledged; retaining until reset")
285
+ return
286
+ }
287
+ mainHandler.postDelayed({ deliver(webView, retry) }, FLUSH_RETRY_DELAY_MS)
288
+ }
289
+ }
@@ -0,0 +1,13 @@
1
+ package io.yourname.androidproject.utils
2
+
3
+ internal class ProfilerNavigationState {
4
+ private var isInitialNavigation = true
5
+
6
+ data class Navigation(val url: String, val shouldReset: Boolean)
7
+
8
+ fun begin(url: String): Navigation {
9
+ val shouldReset = !isInitialNavigation
10
+ isInitialNavigation = false
11
+ return Navigation(url, shouldReset)
12
+ }
13
+ }
@@ -85,7 +85,7 @@ class CustomWebviewTest {
85
85
  setProperty("accessControl.allowedUrls", "https://example.com,https://api.example.com")
86
86
  }
87
87
 
88
- val enabled = properties.getProperty("accessControl.enabled", "true").equals("true", ignoreCase = true)
88
+ val enabled = properties.getProperty("accessControl.enabled", "false").equals("true", ignoreCase = true)
89
89
  assertTrue("Access control should be enabled", enabled)
90
90
 
91
91
  val allowedUrls = properties.getProperty("accessControl.allowedUrls", "")
@@ -101,8 +101,13 @@ class CustomWebviewTest {
101
101
  val disabledProps = Properties().apply {
102
102
  setProperty("accessControl.enabled", "false")
103
103
  }
104
- val disabled = disabledProps.getProperty("accessControl.enabled", "true").equals("true", ignoreCase = true)
104
+ val disabled = disabledProps.getProperty("accessControl.enabled", "false").equals("true", ignoreCase = true)
105
105
  assertFalse("Access control should be disabled", disabled)
106
+
107
+ // Test missing config defaults to disabled
108
+ val missingProps = Properties()
109
+ val missing = missingProps.getProperty("accessControl.enabled", "false").equals("true", ignoreCase = true)
110
+ assertFalse("Missing access control config should default to disabled", missing)
106
111
  }
107
112
 
108
113
  @Test
@@ -0,0 +1,51 @@
1
+ package io.yourname.androidproject
2
+
3
+ import io.yourname.androidproject.utils.PerfDeliveryState
4
+ import io.yourname.androidproject.utils.ProfilerNavigationState
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertFalse
7
+ import org.junit.Assert.assertNotNull
8
+ import org.junit.Assert.assertNull
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+
12
+ class ProfilerLifecycleTest {
13
+ @Test
14
+ fun `offline startup and retry navigation record separate page lifecycles`() {
15
+ val state = ProfilerNavigationState()
16
+ val offlineStart = state.begin("file:///android_asset/offline/offline.html")
17
+ val retry = state.begin("https://example.com")
18
+
19
+ assertFalse(offlineStart.shouldReset)
20
+ assertEquals("file:///android_asset/offline/offline.html", offlineStart.url)
21
+ assertTrue(retry.shouldReset)
22
+ assertEquals("https://example.com", retry.url)
23
+ }
24
+
25
+ @Test
26
+ fun `batch remains in flight until acknowledged and retries retain its id`() {
27
+ val state = PerfDeliveryState<List<String>>()
28
+ val batch = state.begin(listOf("boot-activity-created"))
29
+
30
+ assertNotNull(batch)
31
+ assertNull(state.begin(listOf("boot-page-started")))
32
+
33
+ val retry = state.retry(batch!!, maxRetries = 2)
34
+ assertNotNull(retry)
35
+ assertEquals(batch.id, retry!!.id)
36
+ assertEquals(1, retry.retryCount)
37
+ assertTrue(state.acknowledge(retry))
38
+ assertNotNull(state.begin(listOf("boot-page-started")))
39
+ }
40
+
41
+ @Test
42
+ fun `reset invalidates stale delivery callbacks`() {
43
+ val state = PerfDeliveryState<List<String>>()
44
+ val batch = state.begin(listOf("boot-load-url"))!!
45
+
46
+ state.reset()
47
+
48
+ assertFalse(state.acknowledge(batch))
49
+ assertNull(state.retry(batch, maxRetries = 2))
50
+ }
51
+ }
@@ -1,8 +1,8 @@
1
- "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _NativeInterfaces=require("./constants/NativeInterfaces.js");var _NativeBridge=_interopRequireDefault(require("./utils/NativeBridge.js"));var _CameraUtils=_interopRequireDefault(require("./utils/CameraUtils.js"));var _PluginBridge=_interopRequireDefault(require("../plugin-bridge/PluginBridge.js"));function _interopRequireDefault(e){return e&&e.__esModule?e:{default:e};}const DEVICE_INFO_PLUGIN={pluginId:"io.catalyst.device_info",command:"getDeviceInfo",successEvent:"onSuccess",errorEvent:"onError"};const DEVICE_INFO_REQUEST_TIMEOUT_MS=5000;const DEVICE_INFO_PLUGIN_FALLBACK_CODES=new Set(["PLUGIN_NOT_FOUND","COMMAND_NOT_SUPPORTED","PLUGIN_NOT_REGISTERED"]);const DEVICE_INFO_PLUGIN_FALLBACK_MESSAGES=["pluginbridge is not available in this environment","pluginbridge is not available","device info plugin request timed out"];const normalizeDeviceInfoPayload=payload=>{if(typeof payload!=="string"){return payload;}try{return JSON.parse(payload);}catch{return{message:payload};}};const createDeviceInfoError=(payload,fallbackMessage)=>{const parsed=normalizeDeviceInfoPayload(payload);const message=parsed?.message||parsed?.error||fallbackMessage;const error=new Error(message);if(parsed?.code){error.code=parsed.code;}if(parsed?.pluginId){error.pluginId=parsed.pluginId;}if(parsed?.command){error.command=parsed.command;}return error;};class WebBridge{constructor(){this.handlers=new Map();this.initialized=false;this._logInitialization();}/**
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _NativeInterfaces=require("./constants/NativeInterfaces.js");var _NativeBridge=_interopRequireDefault(require("./utils/NativeBridge.js"));var _CameraUtils=_interopRequireDefault(require("./utils/CameraUtils.js"));var _PluginBridge=_interopRequireDefault(require("../plugin-bridge/PluginBridge.js"));function _interopRequireDefault(e){return e&&e.__esModule?e:{default:e};}function _interopRequireWildcard(e,t){if("function"==typeof WeakMap)var r=new WeakMap(),n=new WeakMap();return(_interopRequireWildcard=function(e,t){if(!t&&e&&e.__esModule)return e;var o,i,f={__proto__:null,default:e};if(null===e||"object"!=typeof e&&"function"!=typeof e)return f;if(o=t?n:r){if(o.has(e))return o.get(e);o.set(e,f);}for(const t in e)"default"!==t&&{}.hasOwnProperty.call(e,t)&&((i=(o=Object.defineProperty)&&Object.getOwnPropertyDescriptor(e,t))&&(i.get||i.set)?o(f,t,i):f[t]=e[t]);return f;})(e,t);}const DEVICE_INFO_PLUGIN={pluginId:"io.catalyst.device_info",command:"getDeviceInfo",successEvent:"onSuccess",errorEvent:"onError"};const DEVICE_INFO_REQUEST_TIMEOUT_MS=5000;const DEVICE_INFO_PLUGIN_FALLBACK_CODES=new Set(["PLUGIN_NOT_FOUND","COMMAND_NOT_SUPPORTED","PLUGIN_NOT_REGISTERED"]);const DEVICE_INFO_PLUGIN_FALLBACK_MESSAGES=["pluginbridge is not available in this environment","pluginbridge is not available","device info plugin request timed out"];const normalizeDeviceInfoPayload=payload=>{if(typeof payload!=="string"){return payload;}try{return JSON.parse(payload);}catch{return{message:payload};}};const createDeviceInfoError=(payload,fallbackMessage)=>{const parsed=normalizeDeviceInfoPayload(payload);const message=parsed?.message||parsed?.error||fallbackMessage;const error=new Error(message);if(parsed?.code){error.code=parsed.code;}if(parsed?.pluginId){error.pluginId=parsed.pluginId;}if(parsed?.command){error.command=parsed.command;}return error;};class WebBridge{constructor(){this.handlers=new Map();this.initialized=false;this._logInitialization();}/**
2
2
  * Log initialization info
3
3
  */_logInitialization(){console.log("🌉 WebBridge initialized");console.log("🌉 Available callback interfaces:",_NativeInterfaces.ALL_CALLBACKS.length);if(process.env.NODE_ENV==="development"){(0,_NativeInterfaces.debugInterfaces)();}}/**
4
4
  * Static method to initialize WebBridge on window object
5
- */static init=()=>{if(typeof window==="undefined"){console.error("🌉 WebBridge cannot be initialized outside the browser!");return null;}if(window.WebBridge){console.warn("🌉 WebBridge already initialized!");return window.WebBridge;}const bridge=new WebBridge();window.WebBridge=bridge;bridge.initialized=true;const{platform}=_NativeBridge.default.getEnvironmentInfo();console.log("🌉 WebBridge created and attached to window");return{bridge,platform,getDeviceInfo:bridge.getDeviceInfo};};/**
5
+ */static init=()=>{if(typeof window==="undefined"){console.error("🌉 WebBridge cannot be initialized outside the browser!");return null;}if(window.WebBridge){console.warn("🌉 WebBridge already initialized!");return window.WebBridge;}const bridge=new WebBridge();window.WebBridge=bridge;bridge.initialized=true;const{platform}=_NativeBridge.default.getEnvironmentInfo();if(window.__CATALYST_PROFILER_ENABLED===true){Promise.resolve().then(()=>_interopRequireWildcard(require("./perf/index.js"))).then(({default:WebPerfCollector})=>{WebPerfCollector.init(bridge);});}console.log("🌉 WebBridge created and attached to window");return{bridge,platform,getDeviceInfo:bridge.getDeviceInfo};};/**
6
6
  * Main callback handler called by native platforms
7
7
  * @param {string} interfaceName - The callback interface name
8
8
  * @param {any} data - Data from native platform
@@ -25,7 +25,8 @@ ON_GOOGLE_SIGN_IN_SUCCESS:"ON_GOOGLE_SIGN_IN_SUCCESS",ON_GOOGLE_SIGN_IN_ERROR:"O
25
25
  NETWORK_STATUS_CHANGED:"NETWORK_STATUS_CHANGED",// Safe area callbacks
26
26
  ON_SAFE_AREA_INSETS_UPDATED:"ON_SAFE_AREA_INSETS_UPDATED",// Transition callbacks
27
27
  ON_TRANSITION_COMMITTED:"ON_TRANSITION_COMMITTED",ON_TRANSITION_CANCELLED:"ON_TRANSITION_CANCELLED",ON_TRANSITION_TIMEOUT:"ON_TRANSITION_TIMEOUT",// Security callbacks
28
- ON_SCREEN_SECURE_SET:"ON_SCREEN_SECURE_SET",ON_SCREEN_SECURE_STATUS:"ON_SCREEN_SECURE_STATUS",ON_SCREEN_SECURE_ERROR:"ON_SCREEN_SECURE_ERROR",ON_WEB_DATA_CLEARED:"ON_WEB_DATA_CLEARED",ON_WEB_DATA_CLEAR_ERROR:"ON_WEB_DATA_CLEAR_ERROR"};// Interface Categories for easier management
28
+ ON_SCREEN_SECURE_SET:"ON_SCREEN_SECURE_SET",ON_SCREEN_SECURE_STATUS:"ON_SCREEN_SECURE_STATUS",ON_SCREEN_SECURE_ERROR:"ON_SCREEN_SECURE_ERROR",ON_WEB_DATA_CLEARED:"ON_WEB_DATA_CLEARED",ON_WEB_DATA_CLEAR_ERROR:"ON_WEB_DATA_CLEAR_ERROR",// Perf telemetry callbacks (native → web)
29
+ CATALYST_PERF_EVENT:"CATALYST_PERF_EVENT"};// Interface Categories for easier management
29
30
  const INTERFACE_CATEGORIES=exports.INTERFACE_CATEGORIES={CAMERA:{commands:[NATIVE_COMMANDS.OPEN_CAMERA,NATIVE_COMMANDS.REQUEST_CAMERA_PERMISSION],callbacks:[NATIVE_CALLBACKS.CAMERA_PERMISSION_STATUS,NATIVE_CALLBACKS.ON_CAMERA_CAPTURE,NATIVE_CALLBACKS.ON_CAMERA_ERROR]},FILE:{commands:[NATIVE_COMMANDS.PICK_FILE,NATIVE_COMMANDS.OPEN_FILE_WITH_INTENT],callbacks:[NATIVE_CALLBACKS.ON_FILE_PICKED,NATIVE_CALLBACKS.ON_FILE_PICK_ERROR,NATIVE_CALLBACKS.ON_FILE_PICK_CANCELLED,NATIVE_CALLBACKS.ON_FILE_PICK_STATE_UPDATE]},INTENT:{commands:[NATIVE_COMMANDS.OPEN_FILE_WITH_INTENT],callbacks:[NATIVE_CALLBACKS.ON_INTENT_SUCCESS,NATIVE_CALLBACKS.ON_INTENT_ERROR,NATIVE_CALLBACKS.ON_INTENT_CANCELLED]},HAPTIC:{commands:[NATIVE_COMMANDS.REQUEST_HAPTIC_FEEDBACK],callbacks:[NATIVE_CALLBACKS.HAPTIC_FEEDBACK]},NOTIFICATION:{commands:[NATIVE_COMMANDS.SCHEDULE_LOCAL_NOTIFICATION,NATIVE_COMMANDS.CANCEL_LOCAL_NOTIFICATION,NATIVE_COMMANDS.REQUEST_NOTIFICATION_PERMISSION,NATIVE_COMMANDS.REGISTER_FOR_PUSH_NOTIFICATIONS,NATIVE_COMMANDS.UPDATE_BADGE_COUNT,NATIVE_COMMANDS.SUBSCRIBE_TO_TOPIC,NATIVE_COMMANDS.UNSUBSCRIBE_FROM_TOPIC,NATIVE_COMMANDS.GET_SUBSCRIBED_TOPICS],callbacks:[NATIVE_CALLBACKS.NOTIFICATION_PERMISSION_STATUS,NATIVE_CALLBACKS.LOCAL_NOTIFICATION_SCHEDULED,NATIVE_CALLBACKS.LOCAL_NOTIFICATION_CANCELLED,NATIVE_CALLBACKS.PUSH_NOTIFICATION_TOKEN,NATIVE_CALLBACKS.NOTIFICATION_RECEIVED,NATIVE_CALLBACKS.NOTIFICATION_ACTION_PERFORMED,NATIVE_CALLBACKS.NOTIFICATION_TAPPED,NATIVE_CALLBACKS.TOPIC_SUBSCRIPTION_RESULT,NATIVE_CALLBACKS.SUBSCRIBED_TOPICS_RESULT]},DEVICE_INFO:{commands:[NATIVE_COMMANDS.GET_DEVICE_INFO],callbacks:[NATIVE_CALLBACKS.ON_DEVICE_INFO_SUCCESS,NATIVE_CALLBACKS.ON_DEVICE_INFO_ERROR]},AUTH:{commands:[NATIVE_COMMANDS.GOOGLE_SIGN_IN],callbacks:[NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_SUCCESS,NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_ERROR,NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_CANCELLED]},NETWORK:{commands:[NATIVE_COMMANDS.GET_NETWORK_STATUS],callbacks:[NATIVE_CALLBACKS.NETWORK_STATUS_CHANGED]},SECURITY:{commands:[NATIVE_COMMANDS.SET_SCREEN_SECURE,NATIVE_COMMANDS.GET_SCREEN_SECURE,NATIVE_COMMANDS.CLEAR_WEB_DATA],callbacks:[NATIVE_CALLBACKS.ON_SCREEN_SECURE_SET,NATIVE_CALLBACKS.ON_SCREEN_SECURE_ERROR,NATIVE_CALLBACKS.ON_WEB_DATA_CLEARED,NATIVE_CALLBACKS.ON_WEB_DATA_CLEAR_ERROR]},TRANSITION:{commands:[NATIVE_COMMANDS.START_TRANSITION,NATIVE_COMMANDS.COMMIT_TRANSITION,NATIVE_COMMANDS.CANCEL_TRANSITION],callbacks:[NATIVE_CALLBACKS.ON_TRANSITION_COMMITTED,NATIVE_CALLBACKS.ON_TRANSITION_CANCELLED,NATIVE_CALLBACKS.ON_TRANSITION_TIMEOUT]}};// All available interfaces (for validation)
30
31
  const ALL_COMMANDS=exports.ALL_COMMANDS=Object.values(NATIVE_COMMANDS);const ALL_CALLBACKS=exports.ALL_CALLBACKS=Object.values(NATIVE_CALLBACKS);const ALL_INTERFACES=exports.ALL_INTERFACES=[...ALL_COMMANDS,...ALL_CALLBACKS];// Permission status constants
31
32
  const PERMISSION_STATUS=exports.PERMISSION_STATUS={GRANTED:"GRANTED",DENIED:"DENIED",NOT_DETERMINED:"NOT_DETERMINED",RESTRICTED:"RESTRICTED"};// Haptic feedback types (matching Android implementation)
@@ -0,0 +1,22 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.ApiCollector=void 0;var _constants=require("../core/constants.js");/**
2
+ * ApiCollector.js
3
+ *
4
+ * Instruments native API call timing — how long each @JavascriptInterface
5
+ * method takes from native receipt until its immediate native handler work is
6
+ * dispatched. It does not measure JS-to-native transport, JS callback delivery,
7
+ * or UI completion.
8
+ *
9
+ * Native events received (via __catalystPerfBatch drain):
10
+ * api-call { callId, method, nativeStartMs, nativeEndMs, durationMs, thread }
11
+ *
12
+ * Spans emitted (one per call, on Catalyst > Native API track):
13
+ * catalyst:api/<method> — nativeStart → nativeEnd
14
+ * color: primary-dark (<50ms), secondary (<200ms), error (>200ms)
15
+ * properties: method, durationMs, callId, thread, simulatorValid: false
16
+ *
17
+ * Reading the track:
18
+ * Blue = fast native call (<50ms) — expected for sync APIs (getDeviceInfo, haptic)
19
+ * Grey = medium native call (<200ms) — native work worth inspecting
20
+ * Red = slow native call (>200ms) — native work worth inspecting
21
+ */class ApiCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;}init(){// No web-side observers — all events come from native buffer drain
22
+ }/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}setInteractionSource(fn){this._getInteraction=fn;}onBatchEvent(event){if(event.type!=="api-call")return;const{method,callId,nativeStartMs,nativeEndMs,durationMs,thread}=event;const startTime=this._nativeToWeb(nativeStartMs);const endTime=this._nativeToWeb(nativeEndMs);const interactionId=this._getInteraction?.()?.interactionIdForRange(startTime,endTime)??null;const color=durationMs>200?_constants.COLOR.ERROR:durationMs>50?_constants.COLOR.SECONDARY:_constants.COLOR.PRIMARY_DARK;const startMark=`${_constants.PREFIX.NATIVE_API}:${method}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});this._notifyWaterfall?.({method,durationMs,callId,thread:thread??null,startTime,endTime,sessionId:interactionId,parentOperation:`Native execution: ${method}`});this._measure.emit(`${interactionId?`[${interactionId}] `:""}Native API: ${method} - ${durationMs}ms`,startMark,endTime,{interactionId,parentOperation:`Native execution: ${method}`,method,durationMs,callId,thread:thread??null,simulatorValid:false},_constants.TRACK.NATIVE_API,color);}}exports.ApiCollector=ApiCollector;
@@ -0,0 +1,40 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.CacheCollector=void 0;var _constants=require("../core/constants.js");/**
2
+ * CacheCollector.js
3
+ *
4
+ * Instruments: bugs #1, #2, #10
5
+ * #1 page slowness OTC pages → cache miss → slow fetch → resource blocks render
6
+ * #2 back-forward reloads screen → back navigation triggers a burst of cache misses
7
+ * #10 footer flash on Drug/OTC → cache miss for footer chunk → layout shift after load
8
+ *
9
+ * Native events received in two modes:
10
+ *
11
+ * 1. Live (legacy path — only used if buffer disabled):
12
+ * cache-hit-memory / cache-hit-disk / cache-miss-fetch / network-fetch-complete
13
+ * → Each emits immediately to Catalyst > Cache track via onNativeEvent()
14
+ *
15
+ * 2. Batched (primary path via __catalystPerfBatch drain in index.js):
16
+ * Same event types arrive as part of the native buffer flush after onPageFinished.
17
+ * JS drains via requestIdleCallback → onBatchEvent() → emits spans on Catalyst > Cache > Detail
18
+ *
19
+ * cache-summary { hits, misses, fetches, total, hitRatePct, avgFetchMs, topSlowest[] }
20
+ * → One summary span emitted on main Catalyst > Cache track for InsightsCollector
21
+ *
22
+ * Spans emitted:
23
+ * Catalyst > Cache > Detail individual spans (one per file) during idle drain
24
+ * Catalyst > Cache summary span (hit rate, avg fetch, top slowest)
25
+ *
26
+ * Reading the track:
27
+ * Blue = cache HIT memory — fastest path
28
+ * Blue-light = cache HIT disk — good, slightly slower
29
+ * Red = cache MISS — matched pattern but not cached
30
+ * Yellow/Orange = network fetch — orange if slow >300ms
31
+ */const TRACK_CACHE_DETAIL="Cache > Detail";class CacheCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;this._notifyInsights=null;// set by index.js after construction
32
+ }/** Called by index.js to wire up insights notifications for cache-summary. */setInsightsSource(fn){this._notifyInsights=fn;}init(){// No web-side observers — all events come from native
33
+ }// ── Live path (individual events routed from index.js) ──────────────────
34
+ onNativeEvent(event){const{type}=event;if(!type?.startsWith("cache-")&&type!=="network-fetch-complete")return;this._emitCacheSpan(event,_constants.TRACK.CACHE);}// ── Batch path (native buffer flush) ────────────────────────────────────
35
+ onBatchEvent(event){const{type}=event;if(type==="cache-summary"){this._emitSummarySpan(event);return;}if(!type?.startsWith("cache-")&&type!=="network-fetch-complete")return;// Individual cache spans go on the Detail sub-track
36
+ this._emitCacheSpan(event,TRACK_CACHE_DETAIL);}// ── Internal ─────────────────────────────────────────────────────────────
37
+ _emitCacheSpan(event,track){const{type,url,durationMs,nativeStartMs,resourceType,thread,statusCode}=event;const resType=resourceType??"other";const filename=this._filename(url);const endTime=nativeStartMs!=null?this._nativeToWeb(nativeStartMs)+(durationMs??0):performance.now();const startTime=endTime-(durationMs??0);let measureName,color,detail;const interceptThread=event.interceptThread??null;const emitThread=event.emitThread??null;if(type==="cache-hit-memory"){measureName=`${_constants.PREFIX.CACHE}/hit|memory|${resType}|${filename}`;color=_constants.COLOR.PRIMARY;detail={url,source:"memory",resourceType:resType,durationMs,thread:thread??null,interceptThread,emitThread,simulatorValid:false};}else if(type==="cache-hit-disk"){measureName=`${_constants.PREFIX.CACHE}/hit|disk|${resType}|${filename}`;color=_constants.COLOR.PRIMARY_LIGHT;detail={url,source:"disk",resourceType:resType,durationMs,thread:thread??null,interceptThread,emitThread,simulatorValid:false};}else if(type==="cache-miss-fetch"){measureName=`${_constants.PREFIX.CACHE}/miss|${resType}|${filename}`;color=_constants.COLOR.ERROR;detail={url,resourceType:resType,durationMs,thread:thread??null,interceptThread,emitThread,simulatorValid:false};}else if(type==="network-fetch-complete"){const slow=(durationMs??0)>_constants.THRESHOLD.FETCH_SLOW_MS;measureName=`${_constants.PREFIX.CACHE}/fetch|${resType}|${filename}`;color=slow?_constants.COLOR.TERTIARY_DARK:_constants.COLOR.TERTIARY;detail={url,resourceType:resType,durationMs,statusCode:statusCode??null,thread:thread??null,emitThread,slow,simulatorValid:false};}else{return;}const startMark=`${measureName}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});this._measure.emit(measureName,startMark,endTime,detail,track,color);}_emitSummarySpan(event){const{hits,misses,fetches,total,hitRatePct,avgFetchMs,topSlowest,nativeTime}=event;if(total===0)return;// Place summary span at current time (post-load, idle phase)
38
+ const startTime=this._nativeToWeb(nativeTime??0)||performance.now();const endTime=startTime+1;// point span
39
+ const color=hitRatePct>=80?_constants.COLOR.PRIMARY_DARK:hitRatePct>=50?_constants.COLOR.SECONDARY:_constants.COLOR.ERROR;const topNames=Array.isArray(topSlowest)?topSlowest.map(e=>`${e.filename}(${e.durationMs}ms)`).join(", "):"";const measureName=`${_constants.PREFIX.CACHE}/summary`;const startMark=`${measureName}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});this._measure.emit(measureName,startMark,endTime,{hits,misses,fetches,total,hitRatePct,avgFetchMs,topSlowest:topNames,simulatorValid:false},_constants.TRACK.CACHE,color);// Notify InsightsCollector so it can flag low hit rates
40
+ this._notifyInsights?.({hitRatePct,total,avgFetchMs,startTime,endTime});}_filename(url){try{const path=new URL(url).pathname;const name=path.split("/").filter(Boolean).pop()??path;return name.slice(0,40);}catch{return String(url).slice(0,40);}}}exports.CacheCollector=CacheCollector;
@@ -0,0 +1,43 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.HookCollector=void 0;var _constants=require("../core/constants.js");/**
2
+ * HookCollector.js — Option A (symptom detection only)
3
+ *
4
+ * Instruments: bugs #13, #15, #16
5
+ * #13 cart computation loop jank → LoAF bursts sustained over time, not tied to a single interaction
6
+ * detected as: LoAF count > threshold within a rolling window
7
+ * #15 quantity selector slow first → interaction → first LoAF burst → long gap → paint
8
+ * detected as: LoAF fires within LAZY_LOAF_WINDOW_MS after tap
9
+ * #16 prescription screen touch lag → same pattern as #15
10
+ *
11
+ * Option A strategy (no React hooks needed):
12
+ * - Watch for LoAF bursts within 2s of a pointerdown (taps that might trigger lazy load)
13
+ * - If LoAF count > 0 in that window → emit hook/lazy span showing the blocked period
14
+ * - Sustained LoAF (3+ frames in 5s with no user interaction) → emit hook/mount span
15
+ * flagging a background computation loop
16
+ *
17
+ * Spans emitted:
18
+ * catalyst:hook/lazy|<label> — LoAF burst after interaction (Track: Hook)
19
+ * detail: loafCount, totalBlockingMs, triggerTarget
20
+ * catalyst:hook/mount|loop — sustained background LoAF (Track: Hook)
21
+ * detail: loafCount, windowMs, likelyCause='computation-loop'
22
+ *
23
+ * TODO (Option B — future):
24
+ * Expose window.catalyst.markComponentMount(name) / markComponentReady(name)
25
+ * for precise hook-lifecycle spans without symptom inference.
26
+ */const LOOP_LOAF_COUNT=3;// ≥3 LoAFs in the loop window = computation loop
27
+ const LOOP_WINDOW_MS=5000;// rolling window to count sustained LoAFs
28
+ class HookCollector{constructor(measure){this._measure=measure;// Lazy-load detection state
29
+ this._pendingLazy=null;// { startMark, startTime, target, loafCount, totalBlocking, timer }
30
+ // Computation loop detection state
31
+ this._loopLoafs=[];// [{ start, end }] in rolling LOOP_WINDOW_MS window
32
+ }init(){// Watch for taps that might trigger lazy component loads
33
+ document.addEventListener("pointerdown",this._onPointerDown.bind(this),{passive:true});}/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}// Called by RenderCollector via addLoafListener() on every LoAF
34
+ onLoaf(start,end){// ── Lazy-load window ────────────────────────────────────────────────
35
+ if(this._pendingLazy){const p=this._pendingLazy;p.loafCount++;p.totalBlocking+=end-start;// Reset the close timer — extend window on each LoAF
36
+ clearTimeout(p.timer);p.timer=setTimeout(()=>this._closeLazySpan(),_constants.THRESHOLD.LAZY_LOAF_WINDOW_MS);}// ── Computation loop detection ───────────────────────────────────────
37
+ const now=performance.now();this._loopLoafs.push({start,end});// Evict entries outside the rolling window
38
+ this._loopLoafs=this._loopLoafs.filter(e=>now-e.start<LOOP_WINDOW_MS);if(this._loopLoafs.length>=LOOP_LOAF_COUNT){this._emitLoopSpan();this._loopLoafs=[];// reset after emitting to avoid duplicate spans
39
+ }}// ─── Lazy-load span ───────────────────────────────────────────────────────
40
+ _onPointerDown(e){// If a lazy span is already open, let it close naturally
41
+ if(this._pendingLazy)return;const target=e.target?.tagName?.toLowerCase()??"unknown";const startTime=performance.now();const startMark=`${_constants.PREFIX.HOOK_LAZY}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});const timer=setTimeout(()=>this._closeLazySpan(),_constants.THRESHOLD.LAZY_LOAF_WINDOW_MS);this._pendingLazy={startMark,startTime,target,loafCount:0,totalBlocking:0,timer};}_closeLazySpan(){const p=this._pendingLazy;if(!p)return;this._pendingLazy=null;clearTimeout(p.timer);// Only emit if LoAF actually fired — otherwise it was a clean interaction
42
+ if(p.loafCount===0)return;const endTime=performance.now();this._measure.emit(`${_constants.PREFIX.HOOK_LAZY}|${p.target}`,p.startMark,endTime,{loafCount:p.loafCount,totalBlockingMs:Math.round(p.totalBlocking),triggerTarget:p.target,likelyCause:"lazy-load",simulatorValid:false},_constants.TRACK.HOOK);this._notifyWaterfall?.({hookType:"lazy-load",label:`Hook lazy-load ${p.target}`,startTime:p.startTime,endTime,durationMs:Math.round(endTime-p.startTime),loafCount:p.loafCount,totalBlockingMs:Math.round(p.totalBlocking),likelyCause:"lazy-load"});}// ─── Computation loop span ────────────────────────────────────────────────
43
+ _emitLoopSpan(){const first=this._loopLoafs[0];const last=this._loopLoafs[this._loopLoafs.length-1];const markName=`${_constants.PREFIX.HOOK_MOUNT}:loop:${Math.round(first.start)}`;performance.mark(markName,{startTime:first.start});const totalBlocking=this._loopLoafs.reduce((sum,e)=>sum+(e.end-e.start),0);this._measure.emit(`${_constants.PREFIX.HOOK_MOUNT}|loop`,markName,last.end,{loafCount:this._loopLoafs.length,windowMs:Math.round(last.end-first.start),totalBlockingMs:Math.round(totalBlocking),likelyCause:"computation-loop",simulatorValid:false},_constants.TRACK.HOOK);this._notifyWaterfall?.({hookType:"computation-loop",label:"Hook computation loop",startTime:first.start,endTime:last.end,durationMs:Math.round(last.end-first.start),loafCount:this._loopLoafs.length,totalBlockingMs:Math.round(totalBlocking),likelyCause:"computation-loop"});}}exports.HookCollector=HookCollector;
@@ -0,0 +1,47 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.InsightsCollector=void 0;var _constants=require("../core/constants.js");/**
2
+ * InsightsCollector.js
3
+ *
4
+ * Watches accumulated perf data and emits actionable warnings to the
5
+ * Catalyst > Insights track in Chrome DevTools.
6
+ *
7
+ * Each insight is a point-in-time span with:
8
+ * - severity: 'critical' | 'warning' | 'info'
9
+ * - rule: short machine-readable id (e.g. 'slow-page-load')
10
+ * - message: one-sentence human diagnosis
11
+ * - fix: immediate actionable recommendation
12
+ * - evidence: the numbers that triggered the rule
13
+ *
14
+ * Insights are emitted in two modes:
15
+ * 1. Reactive — emitted immediately when a threshold breach is detected
16
+ * (e.g. an fps-drop episode during interaction)
17
+ * 2. Summary — emitted once after SESSION_SUMMARY_DELAY_MS of inactivity
18
+ * to give a rolled-up view of what happened in the last session
19
+ *
20
+ * How to read this in DevTools:
21
+ * Performance tab → Catalyst > Insights track
22
+ * Click any span → Tooltip shows: severity, rule, message, fix, evidence
23
+ * Red spans = critical (user-visible jank/failure)
24
+ * Yellow (tertiary) = warning
25
+ * Blue (secondary) = info / optimization opportunity
26
+ */const SESSION_SUMMARY_DELAY_MS=3000;function round(value){return Number.isFinite(value)?Math.round(value):value;}class InsightsCollector{constructor(measure){this._measure=measure;this._summaryTimer=null;this._insightSeq=0;// Counters reset per session
27
+ this._fpsDrops=[];// [{ minFps, durationMs }]
28
+ this._slowLoads=[];// [{ url, durationMs }]
29
+ this._loafsDuringNav=0;this._slowInteractions=[];// [{ target, responseMs }]
30
+ this._highMemory=[];// [{ currentMb }]
31
+ this._layoutShifts=[];// [{ value }]
32
+ this._coldStartMs=null;// always attached to other insights once known
33
+ }init(){// No web observers — fed via notify*() methods from index.js
34
+ }setStoreSource(fn){this._notifyStore=fn;}// ─── Feed methods — called by index.js after routing native events ─────────
35
+ notifyFpsDrop({minFps,avgFps,durationMs,duringInteraction,deltaFps,baselineFps,startTime,endTime,interactionId}){const source={startTime,endTime,related:{interactionId}};this._fpsDrops.push({minFps,durationMs});if(duringInteraction){this._emitReactive({severity:"critical",rule:"fps-drop-during-tap",message:`FPS dropped to ${minFps.toFixed(1)} for ${Math.round(durationMs)}ms right after a tap`,fix:"Heavy computation is blocking the render thread after interaction — compare spans with the same interaction ID",evidence:{minFps,avgFps,durationMs,deltaFps,baselineFps},...source});}else if(minFps<30){this._emitReactive({severity:"warning",rule:"severe-fps-drop",message:`Severe FPS drop: ${minFps.toFixed(1)}fps min for ${Math.round(durationMs)}ms`,fix:"Look for LoAF spans overlapping this window on the Render track — a long script or layout is stalling frames",evidence:{minFps,avgFps,durationMs,deltaFps,baselineFps},...source});}else if(deltaFps!=null&&deltaFps>=10){// Sudden spike: FPS fell sharply even if absolute value is not critical
36
+ this._emitReactive({severity:"warning",rule:"fps-spike-drop",message:`Sudden FPS spike: dropped ${deltaFps.toFixed(1)}fps (${baselineFps?.toFixed(1)} → ${minFps.toFixed(1)}) in one frame window`,fix:"A sudden spike drop — often a one-off heavy layout or image decode. Check Render track for a LoAF aligned with this episode.",evidence:{minFps,avgFps,durationMs,deltaFps,baselineFps},...source});}this._scheduleSummary();}notifySlowLoad({url,durationMs,startTime,endTime}){this._slowLoads.push({url,durationMs});if(durationMs>4000){this._emitReactive({severity:"critical",rule:"page-load-too-slow",message:`Page load took ${Math.round(durationMs)}ms (${url.split("/").pop()||url})`,fix:"Check Cache track for cache-miss-fetch bursts; consider preloading route chunks or warming the WebView cache",evidence:{url,durationMs},startTime,endTime});}this._scheduleSummary();}notifyColdStart({durationMs,startTime,endTime}){this._coldStartMs=durationMs;// attach to all future insights automatically
37
+ const severity=durationMs>3000?"critical":durationMs>1800?"warning":"info";const label=durationMs>3000?"slow":durationMs>1800?"acceptable":"fast";this._emitReactive({severity,rule:"cold-start-time",message:`Cold start: ${Math.round(durationMs)}ms (${label}) — app launched to first page finish`,fix:durationMs>3000?"Cold start >3s — check if WebView.loadUrl() is called before layout inflation completes; defer non-critical MainActivity init; consider prewarming WebView in Application.onCreate()":durationMs>1800?"Cold start >1.8s — consider moving WebView construction earlier (Application.onCreate vs MainActivity.onCreate); check if large local assets block onPageFinished":"Cold start within target (≤1.8s). No action needed.",evidence:{durationMs,targetMs:1800,acceptableMs:3000},startTime,endTime});}notifyLoafDuringNav({durationMs,startTime,endTime}){this._loafsDuringNav++;if(this._loafsDuringNav===1){this._emitReactive({severity:"warning",rule:"loaf-blocking-nav",message:`Long animation frame (${Math.round(durationMs)}ms) fired during navigation — may cause blank screen`,fix:"Defer non-critical JS execution after page-load-end; split large bundles with dynamic import()",evidence:{durationMs,loafsDuringNav:this._loafsDuringNav},startTime,endTime});}}notifySlowInteraction({target,responseMs,startTime,endTime,interactionId}){this._slowInteractions.push({target,responseMs});if(responseMs>300){this._emitReactive({severity:"critical",rule:"interaction-unresponsive",message:`Tap on '${target}' took ${responseMs}ms to paint (threshold ${_constants.THRESHOLD.INTERACTION_SLOW_MS}ms)`,fix:"A LoAF is delaying the paint — inspect work overlapping the interaction",evidence:{target,responseMs,thresholdMs:_constants.THRESHOLD.INTERACTION_SLOW_MS},startTime,endTime,related:{interactionId}});}this._scheduleSummary();}notifyHighMemory({currentMb,startTime,endTime,memoryId}){const source={startTime,endTime,related:{memoryId}};// currentMb is webviewMb (V8 + Blink + JNI PSS) — the WebView engine memory.
38
+ // A healthy WebView app idles at 80-120MB. Growth beyond that signals
39
+ // a real leak (un-released image bitmaps, accumulating JS closures, JNI objects).
40
+ // Thresholds are intentionally higher than total-PSS thresholds used previously
41
+ // because we now track the native slice, not the whole process.
42
+ this._highMemory.push({currentMb});if(currentMb>120){this._emitReactive({severity:"critical",rule:"memory-critical",message:`WebView memory at ${Math.round(currentMb)}MB — above 120MB critical threshold (V8/Blink/JNI leak likely)`,fix:"Check for un-released image bitmaps, accumulating JS closures, or JNI objects not returned to GC. Compare WebView memory growth across snapshots on the Memory track.",evidence:{webviewMb:currentMb},...source});}else if(currentMb>80){this._emitReactive({severity:"warning",rule:"memory-high",message:`WebView memory at ${Math.round(currentMb)}MB — above 80MB warning threshold (healthy idle is <80MB)`,fix:"Monitor WebView memory on the Memory track — sustained growth = leak. Check image cache eviction and dispose WebView listeners on route unmount.",evidence:{webviewMb:currentMb},...source});}}notifyHwAccelDuringFrame({durationMs,thread,trigger,startTime,endTime}){this._emitReactive({severity:"critical",rule:"hw-accel-during-frame",message:`Software rendering was active for ${Math.round(durationMs)}ms during a dropped frame (hw-accel disabled by ${trigger??"cache-serve"})`,fix:"Cache serving on shouldInterceptRequest disables hardware acceleration — move cache I/O off the WebView thread or batch cache warming on app start to reduce mid-session hw-accel toggles",evidence:{durationMs,thread:thread??"unknown",trigger:trigger??"unknown"},startTime,endTime});}notifyLayoutShift({value,sessionContext,startTime,endTime}){this._layoutShifts.push({value});if(value>0.25){this._emitReactive({severity:"warning",rule:"large-layout-shift",message:`Large layout shift (CLS ${value.toFixed(3)}) during '${sessionContext}' phase`,fix:"Reserve space for async-loaded content (images, ads, banners) using explicit height; avoid injecting DOM above visible content",evidence:{value,sessionContext},startTime,endTime});}}notifyScrollJank({minFps,frameDropCount,startTime,endTime}){const severity=minFps<30?"critical":"warning";const rule=minFps<30?"scroll-jank-critical":"scroll-jank-warning";this._emitReactive({severity,rule,message:`Scroll jank: ${Math.round(frameDropCount)} frame drop(s), min ${minFps.toFixed(1)}fps during scroll`,fix:minFps<30?"Severe scroll jank — check for layout thrash triggered during scroll. Look for LoAF spans on Render track overlapping the Input track scroll session.":"Scroll dropped below 55fps — common causes: sticky header recalc, cart badge updates, or image decode on scroll. Check Render track LoAF spans inside the scroll session.",evidence:{minFps,frameDropCount},startTime,endTime});this._scheduleSummary();}notifyCacheSummary({hitRatePct,total,avgFetchMs,startTime,endTime}){if(total<5)return;// not enough data
43
+ if(hitRatePct<50){this._emitReactive({severity:"warning",rule:"low-cache-hit-rate",message:`Cache hit rate ${hitRatePct}% (${total} resources) — majority served via network`,fix:"Add missed URLs to the allowedUrls cache list; check cache max-age and disk eviction policy",evidence:{hitRatePct,total,avgFetchMs},startTime,endTime});}}// ─── Reactive insight emission ─────────────────────────────────────────────
44
+ _emitReactive({severity,rule,message,fix,evidence={},startTime,endTime,related={}}){const start=Number.isFinite(startTime)?startTime:performance.now();const end=Number.isFinite(endTime)?Math.max(start+1,endTime):start+2;const markName=`${_constants.PREFIX.INSIGHT}:${rule}:${Math.round(start)}:${++this._insightSeq}`;const color=severity==="critical"?"error":severity==="warning"?"tertiary":"secondary";const title=this._titleFor(rule,evidence,message);const category=this._categoryFor(rule);const detail={severity,rule,title,category,message,fix,...related,...(this._coldStartMs!=null?{"coldStart.ms":String(Math.round(this._coldStartMs))}:{}),...Object.fromEntries(Object.entries(evidence).filter(([,value])=>value!=null).map(([key,value])=>[`evidence.${key}`,String(value)]))};performance.mark(markName,{startTime:start});this._notifyStore?.({severity,rule,title,category,message,fix,evidence,...related,startTime:start,endTime:end,durationMs:end-start});this._measure.emit(`${related.interactionId?`[${related.interactionId}] `:""}Insight: ${title}`,markName,end,detail,_constants.TRACK.INSIGHTS,color);}_titleFor(rule,evidence,message){switch(rule){case"fps-drop-during-tap":return`FPS drop after tap: ${round(evidence.minFps)}fps`;case"severe-fps-drop":return`FPS drop: ${round(evidence.minFps)}fps for ${round(evidence.durationMs)}ms`;case"fps-spike-drop":return`FPS spike drop: -${round(evidence.deltaFps)}fps`;case"page-load-too-slow":return`Page load slow: ${round(evidence.durationMs)}ms`;case"cold-start-time":return`Cold start: ${round(evidence.durationMs)}ms`;case"loaf-blocking-nav":return`Render blocked during navigation: ${round(evidence.durationMs)}ms`;case"interaction-unresponsive":return`Slow tap: ${evidence.target??"unknown"} ${round(evidence.responseMs)}ms`;case"memory-critical":case"memory-high":return`Memory high: ${round(evidence.webviewMb)}MB`;case"hw-accel-during-frame":return`Software render window: ${round(evidence.durationMs)}ms`;case"large-layout-shift":return`Large layout shift: ${Number.isFinite(evidence.value)?evidence.value.toFixed(3):"unknown"}`;case"scroll-jank-critical":case"scroll-jank-warning":return`Scroll jank: ${round(evidence.minFps)}fps`;case"low-cache-hit-rate":return`Cache hit rate low: ${evidence.hitRatePct}%`;case"session-summary":return"Session issues";default:return message??rule;}}_categoryFor(rule){if(rule.includes("fps")||rule.includes("loaf")||rule.includes("layout"))return"render";if(rule.includes("page-load")||rule.includes("cold-start"))return"navigation";if(rule.includes("interaction")||rule.includes("tap"))return"interaction";if(rule.includes("memory"))return"memory";if(rule.includes("scroll"))return"scroll";if(rule.includes("cache"))return"cache";if(rule.includes("session"))return"session";return"general";}// ─── Session summary ───────────────────────────────────────────────────────
45
+ // Emitted after SESSION_SUMMARY_DELAY_MS of quiet — a rolled-up health check.
46
+ _scheduleSummary(){if(this._summaryTimer)clearTimeout(this._summaryTimer);this._summaryTimer=setTimeout(()=>this._emitSummary(),SESSION_SUMMARY_DELAY_MS);}_emitSummary(){this._summaryTimer=null;const problems=[];if(this._fpsDrops.length>0)problems.push(`${this._fpsDrops.length} fps-drop episode(s), min ${Math.min(...this._fpsDrops.map(f=>f.minFps)).toFixed(0)}fps`);if(this._slowLoads.length>0)problems.push(`${this._slowLoads.length} slow page load(s)`);if(this._slowInteractions.length>0)problems.push(`${this._slowInteractions.length} slow tap(s)`);if(this._highMemory.length>0)problems.push(`peak ${Math.max(...this._highMemory.map(m=>m.currentMb)).toFixed(0)}MB memory`);const severity=this._fpsDrops.some(f=>f.minFps<20)?"critical":problems.length>2?"warning":"info";const message=problems.length===0?"Session clean — no threshold breaches detected":`Session issues: ${problems.join(" · ")}`;const fix=problems.length===0?"No action needed":"Expand each red/yellow span above for per-issue fixes";this._emitReactive({severity:severity==="info"?"info":severity,rule:"session-summary",message,fix,evidence:{fpsDropEpisodes:this._fpsDrops.length,slowPageLoads:this._slowLoads.length,slowInteractions:this._slowInteractions.length,highMemoryReadings:this._highMemory.length,layoutShifts:this._layoutShifts.length}});// Reset counters for next session
47
+ this._fpsDrops=[];this._slowLoads=[];this._loafsDuringNav=0;this._slowInteractions=[];this._highMemory=[];this._layoutShifts=[];}}exports.InsightsCollector=InsightsCollector;