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
@@ -136,6 +136,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
136
136
  "X-Safe-Area-Right" to latestSafeAreaInsets.right.toString(),
137
137
  "X-Safe-Area-Bottom" to latestSafeAreaInsets.bottom.toString(),
138
138
  "X-Safe-Area-Left" to latestSafeAreaInsets.left.toString(),
139
+ "X-Catalyst-Native-WebView" to "1",
139
140
  // Prevent caching of SSR response so updated headers are always used
140
141
  "Cache-Control" to "no-cache, no-store, must-revalidate",
141
142
  "Pragma" to "no-cache"
@@ -273,6 +274,9 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
273
274
  properties.setProperty("buildType", if (BuildConfig.DEBUG) "debug" else "release")
274
275
  properties.setProperty("buildOptimisation", (!BuildConfig.DEBUG).toString())
275
276
  }
277
+ io.yourname.androidproject.utils.PerfEventBuffer.configure(
278
+ properties.getProperty("profiler.enabled", "false").toBoolean()
279
+ )
276
280
 
277
281
  configureEdgeToEdge()
278
282
 
@@ -285,16 +289,29 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
285
289
  })
286
290
  }
287
291
 
288
- // Initialize MetricsMonitor
292
+ // Initialize MetricsMonitor — recordAppStart() resets the cold-start clock for this
293
+ // Activity lifecycle. The singleton may have been constructed in a previous session
294
+ // (hot restart / Activity recreation), so appStartTime would be stale without this.
289
295
  metricsMonitor = MetricsMonitor.getInstance(this)
296
+ metricsMonitor.recordAppStart()
297
+
298
+ // Boot timing: record activity onCreate as the earliest boot event
299
+ if (BuildConfig.DEBUG) {
300
+ io.yourname.androidproject.utils.PerfEventBuffer.reset()
301
+ io.yourname.androidproject.utils.PerfEventBuffer.add(org.json.JSONObject().apply {
302
+ put("type", "boot-activity-created")
303
+ put("nativeTime", android.os.SystemClock.elapsedRealtime())
304
+ put("thread", Thread.currentThread().name)
305
+ })
306
+ }
290
307
 
291
308
  // Setup UI
292
309
  binding = ActivityMainBinding.inflate(layoutInflater)
293
310
  supportActionBar?.hide()
294
311
  setContentView(binding.root)
295
312
 
296
- // Initialize keyboard utility
297
- keyboardUtil = KeyboardUtil(this, binding.webviewContainer)
313
+ // Initialize keyboard utility (pass webView so keyboard events reach WebPerfCollector)
314
+ keyboardUtil = KeyboardUtil(this, binding.webviewContainer, binding.webview)
298
315
  keyboardUtil.initialize()
299
316
 
300
317
  // Enable hardware acceleration for the window
@@ -350,6 +367,12 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
350
367
  onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
351
368
  override fun handleOnBackPressed() {
352
369
  if (customWebView.canGoBack()) {
370
+ if (BuildConfig.DEBUG) {
371
+ BridgeUtils.emitPerfEvent(customWebView.getWebView(), org.json.JSONObject().apply {
372
+ put("type", "navigation-back")
373
+ put("nativeTime", android.os.SystemClock.elapsedRealtime())
374
+ })
375
+ }
353
376
  customWebView.goBack()
354
377
  } else {
355
378
  // Disable this callback and let the system handle back press
@@ -385,12 +408,9 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
385
408
  if (BuildConfig.DEBUG) {
386
409
  Log.w(TAG, "📴 Device offline on launch, showing offline page")
387
410
  }
388
- customWebView.showOfflinePage()
411
+ customWebView.showOfflineRouteOrOfflinePage(currentUrl)
389
412
  }
390
413
  }
391
-
392
- metricsMonitor.markAppStartComplete()
393
-
394
414
  } catch (e: Exception) {
395
415
  Log.e(TAG, "Failed to load initial URL: ${e.message}")
396
416
  // TODO: Add HTML file workflow - error.html not available yet
@@ -452,6 +472,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
452
472
  }
453
473
 
454
474
  override fun onDestroy() {
475
+ io.yourname.androidproject.utils.PerfEventBuffer.reset()
455
476
  // Log all performance metrics before destroying
456
477
  if (BuildConfig.DEBUG) {
457
478
  Log.d(TAG, "🏁 App shutting down - logging final metrics...")
@@ -533,7 +554,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
533
554
  loadUrlWithSafeArea(url)
534
555
  } else {
535
556
  Log.w(TAG, "📴 Offline during notification click, showing offline page")
536
- customWebView.showOfflinePage()
557
+ customWebView.showOfflineRouteOrOfflinePage(url)
537
558
  }
538
559
 
539
560
  } catch (e: Exception) {
@@ -5,6 +5,10 @@ import android.os.Debug
5
5
  import android.os.SystemClock
6
6
  import android.util.Log
7
7
  import android.view.Choreographer
8
+ import android.webkit.WebView
9
+ import io.yourname.androidproject.utils.BridgeUtils
10
+ import io.yourname.androidproject.utils.PerfEventBuffer
11
+ import org.json.JSONObject
8
12
  import java.util.concurrent.TimeUnit
9
13
  import java.util.concurrent.atomic.AtomicInteger
10
14
 
@@ -13,11 +17,29 @@ import java.util.concurrent.atomic.AtomicInteger
13
17
  * Tracks key performance metrics and logs them on app destroy.
14
18
  */
15
19
  class MetricsMonitor(private val context: Context) {
20
+
21
+ // WebView reference for emitting perf events — set via attachWebView()
22
+ @Volatile private var webView: WebView? = null
23
+
24
+ fun attachWebView(wv: WebView) {
25
+ webView = wv
26
+ }
27
+
28
+ fun detachWebView() {
29
+ // Flush any open fps-drop episode before losing the WebView reference
30
+ if (inFpsDropEpisode) emitFpsDropEpisode(endTime = SystemClock.elapsedRealtime())
31
+ webView = null
32
+ }
33
+
34
+ private fun emitPerf(payload: JSONObject) {
35
+ webView?.let { BridgeUtils.emitPerfEvent(it, payload) }
36
+ }
16
37
  private val TAG = "MetricsMonitor"
17
38
 
18
39
  // App Startup metrics
19
40
  private var appStartTime: Long = SystemClock.elapsedRealtime()
20
41
  private var coldStartDuration: Long = 0
42
+ private var coldStartCompleted = false
21
43
 
22
44
  // Page Load metrics
23
45
  private val pageLoadTimes = mutableMapOf<String, Long>()
@@ -34,6 +56,15 @@ class MetricsMonitor(private val context: Context) {
34
56
  private var avgFps = 0.0
35
57
  private var fpsReadings = 0
36
58
 
59
+ // fps-drop episode tracking
60
+ private var inFpsDropEpisode = false
61
+ private var episodeStartNativeTime: Long = 0
62
+ private var episodeMinFps = Double.MAX_VALUE
63
+ private var episodeFpsSum = 0.0
64
+ private var episodeSampleCount = 0
65
+ private val FPS_EPISODE_MAX_MS = 10_000L // force-close after 10s
66
+ private var lastStableFps = 60.0 // fps reading just before episode started — used to compute deltaFps
67
+
37
68
  // Memory metrics
38
69
  private var initialMemory: Long = getMemoryUsage()
39
70
  private var peakMemory: Long = initialMemory
@@ -74,8 +105,18 @@ class MetricsMonitor(private val context: Context) {
74
105
  }
75
106
 
76
107
  fun markAppStartComplete() {
108
+ if (coldStartCompleted) return
109
+ coldStartCompleted = true
77
110
  coldStartDuration = SystemClock.elapsedRealtime() - appStartTime
78
111
  Log.d(TAG, "App cold start completed in: $coldStartDuration ms")
112
+ // Buffer so it is delivered with the post-load batch flush when JS is ready.
113
+ // emitPerf (live path) would be a no-op here — JS may not have loaded yet.
114
+ PerfEventBuffer.add(JSONObject().apply {
115
+ put("type", "cold-start")
116
+ put("nativeTime", SystemClock.elapsedRealtime())
117
+ put("durationMs", coldStartDuration)
118
+ put("thread", Thread.currentThread().name)
119
+ })
79
120
  }
80
121
 
81
122
  fun trackPageLoadStart(url: String) {
@@ -144,8 +185,14 @@ class MetricsMonitor(private val context: Context) {
144
185
  uiBlockingEvents++
145
186
  }
146
187
 
147
- fun recordLongTask() {
188
+ fun recordLongTask(durationMs: Long = longTaskThreshold) {
148
189
  longTasksDetected++
190
+ emitPerf(JSONObject().apply {
191
+ put("type", "long-task")
192
+ put("nativeTime", SystemClock.elapsedRealtime())
193
+ put("durationMs", durationMs)
194
+ put("thread", Thread.currentThread().name)
195
+ })
149
196
  }
150
197
 
151
198
  private fun startFrameMonitoring() {
@@ -173,27 +220,106 @@ class MetricsMonitor(private val context: Context) {
173
220
  val timeSpan = now - lastFpsCheckTime
174
221
  lastFpsCheckTime = now
175
222
 
176
- if (timeSpan > 0) {
177
- currentFps = frames * 1000.0 / timeSpan
178
-
179
- // Update min/max/avg
180
- if (minFps == Double.MAX_VALUE) minFps = currentFps
181
- minFps = minFps.coerceAtMost(currentFps)
182
- maxFps = maxFps.coerceAtLeast(currentFps)
183
-
184
- val totalFps = avgFps * fpsReadings + currentFps
185
- fpsReadings++
186
- avgFps = totalFps / fpsReadings
223
+ if (timeSpan <= 0) return
224
+
225
+ currentFps = frames * 1000.0 / timeSpan
226
+
227
+ // Update session-level min/max/avg
228
+ if (minFps == Double.MAX_VALUE) minFps = currentFps
229
+ minFps = minFps.coerceAtMost(currentFps)
230
+ maxFps = maxFps.coerceAtLeast(currentFps)
231
+ val totalFps = avgFps * fpsReadings + currentFps
232
+ fpsReadings++
233
+ avgFps = totalFps / fpsReadings
234
+
235
+ if (currentFps < 55.0) {
236
+ if (!inFpsDropEpisode) {
237
+ // Start new episode — capture the last stable fps as baseline for deltaFps
238
+ inFpsDropEpisode = true
239
+ episodeStartNativeTime = now - timeSpan // start of the measured window
240
+ episodeMinFps = currentFps
241
+ episodeFpsSum = currentFps
242
+ episodeSampleCount = 1
243
+ // lastStableFps already holds the previous reading (set in the else branch below)
244
+ } else {
245
+ // Accumulate into current episode
246
+ episodeMinFps = episodeMinFps.coerceAtMost(currentFps)
247
+ episodeFpsSum += currentFps
248
+ episodeSampleCount++
249
+
250
+ // Force-close if episode has run for >= 10s
251
+ val episodeDuration = now - episodeStartNativeTime
252
+ if (episodeDuration >= FPS_EPISODE_MAX_MS) {
253
+ emitFpsDropEpisode(endTime = now)
254
+ // Immediately start a new episode for the continuing drop
255
+ inFpsDropEpisode = true
256
+ episodeStartNativeTime = now
257
+ episodeMinFps = currentFps
258
+ episodeFpsSum = currentFps
259
+ episodeSampleCount = 1
260
+ }
261
+ }
262
+ } else {
263
+ // FPS is good — update lastStableFps and close episode if one was open
264
+ lastStableFps = currentFps
265
+ if (inFpsDropEpisode) {
266
+ emitFpsDropEpisode(endTime = now)
267
+ }
187
268
  }
188
269
  }
189
270
 
271
+ private fun emitFpsDropEpisode(endTime: Long) {
272
+ if (!inFpsDropEpisode) return
273
+ val durationMs = endTime - episodeStartNativeTime
274
+ val avgEpisodeFps = if (episodeSampleCount > 0) episodeFpsSum / episodeSampleCount else episodeMinFps
275
+ // deltaFps = how far FPS fell from the last stable reading before the episode
276
+ val deltaFps = lastStableFps - episodeMinFps
277
+ emitPerf(JSONObject().apply {
278
+ put("type", "fps-drop-episode")
279
+ put("startNativeTime", episodeStartNativeTime)
280
+ put("endNativeTime", endTime)
281
+ put("durationMs", durationMs)
282
+ put("minFps", episodeMinFps)
283
+ put("avgFps", avgEpisodeFps)
284
+ put("deltaFps", deltaFps) // drop magnitude from last stable fps
285
+ put("baselineFps", lastStableFps)
286
+ put("thread", Thread.currentThread().name)
287
+ })
288
+ inFpsDropEpisode = false
289
+ episodeMinFps = Double.MAX_VALUE
290
+ episodeFpsSum = 0.0
291
+ episodeSampleCount = 0
292
+ }
293
+
190
294
  private fun monitorMemoryPeriodically() {
191
295
  Thread {
192
296
  while (!Thread.interrupted()) {
193
297
  try {
194
- currentMemory = getMemoryUsage()
195
- peakMemory = peakMemory.coerceAtLeast(currentMemory)
196
- Thread.sleep(5000) // Check every 5 seconds
298
+ val memInfo = Debug.MemoryInfo()
299
+ Debug.getMemoryInfo(memInfo)
300
+
301
+ // PSS breakdown — all values in KB, convert to MB
302
+ val jvmMb = memInfo.dalvikPss / 1024.0 // Kotlin/Java heap
303
+ val webviewMb = memInfo.nativePss / 1024.0 // V8 + Blink + JNI (WebView proxy)
304
+ val otherMb = memInfo.otherPss / 1024.0 // graphics buffers, code, stack
305
+ val totalMb = memInfo.totalPss / 1024.0 // what Android OOM killer sees
306
+
307
+ currentMemory = memInfo.totalPss.toLong()
308
+ peakMemory = peakMemory.coerceAtLeast(currentMemory)
309
+
310
+ val now = SystemClock.elapsedRealtime()
311
+ emitPerf(JSONObject().apply {
312
+ put("type", "memory-snapshot")
313
+ put("nativeTime", now)
314
+ put("jvmMb", jvmMb)
315
+ put("webviewMb", webviewMb)
316
+ put("otherMb", otherMb)
317
+ put("totalMb", totalMb)
318
+ put("peakMb", peakMemory / 1024.0)
319
+ put("thread", Thread.currentThread().name)
320
+ if (coldStartDuration > 0) put("coldStartMs", coldStartDuration)
321
+ })
322
+ Thread.sleep(5000)
197
323
  } catch (e: InterruptedException) {
198
324
  break
199
325
  }
@@ -324,7 +450,7 @@ class MetricsMonitor(private val context: Context) {
324
450
  }
325
451
 
326
452
  fun cleanup() {
327
- // Remove frame callback
453
+ if (inFpsDropEpisode) emitFpsDropEpisode(endTime = SystemClock.elapsedRealtime())
328
454
  Choreographer.getInstance().removeFrameCallback(frameCallback)
329
455
  }
330
456
 
@@ -338,4 +464,4 @@ class MetricsMonitor(private val context: Context) {
338
464
  }
339
465
  }
340
466
  }
341
- }
467
+ }
@@ -213,7 +213,7 @@ class NativeBridge(
213
213
  .filter { it.isNotEmpty() }
214
214
 
215
215
  accessControlEnabled = properties
216
- .getProperty("accessControl.enabled", "true")
216
+ .getProperty("accessControl.enabled", "false")
217
217
  .equals("true", ignoreCase = true)
218
218
 
219
219
  if (accessControlEnabled && allowedUrls.isNotEmpty()) {
@@ -332,8 +332,11 @@ class NativeBridge(
332
332
 
333
333
  @JavascriptInterface
334
334
  fun openCamera(options: String?) {
335
+ val callId = "openCamera:${android.os.SystemClock.elapsedRealtime()}"
336
+ BridgeUtils.bridgeCallReceived(callId, "openCamera")
335
337
  BridgeUtils.safeExecute(webView, BridgeUtils.WebEvents.ON_CAMERA_ERROR, "open camera") {
336
338
  mainActivity.runOnUiThread {
339
+ BridgeUtils.bridgeCallDispatched(callId)
337
340
  if (CameraUtils.hasCameraPermission(mainActivity)) {
338
341
  launchCamera()
339
342
  } else {
@@ -492,10 +495,13 @@ class NativeBridge(
492
495
 
493
496
  @JavascriptInterface
494
497
  fun getDeviceInfo(options: String?) {
498
+ val callId = "getDeviceInfo:${android.os.SystemClock.elapsedRealtime()}"
499
+ BridgeUtils.bridgeCallReceived(callId, "getDeviceInfo")
495
500
  BridgeUtils.safeExecute(webView, BridgeUtils.WebEvents.ON_DEVICE_INFO_ERROR, "get device info") {
496
501
  mainActivity.runOnUiThread {
497
502
  val deviceInfo = DeviceInfoUtils.getDeviceInfo(mainActivity, properties)
498
503
  BridgeUtils.logDebug(TAG, "Device info retrieved: $deviceInfo")
504
+ BridgeUtils.bridgeCallDispatched(callId)
499
505
  BridgeUtils.notifyWeb(webView, BridgeUtils.WebEvents.ON_DEVICE_INFO_SUCCESS, deviceInfo.toString())
500
506
  }
501
507
  }