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,94 @@
1
+ import Foundation
2
+ import WebKit
3
+ import os
4
+
5
+ private let offlineSchemeLogger = Logger(
6
+ subsystem: Bundle.main.bundleIdentifier ?? "com.app",
7
+ category: "CatalystOfflineScheme"
8
+ )
9
+
10
+ final class OfflineURLSchemeHandler: NSObject, WKURLSchemeHandler {
11
+ static let shared = OfflineURLSchemeHandler()
12
+
13
+ private override init() {
14
+ super.init()
15
+ }
16
+
17
+ func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
18
+ guard let offlineURL = urlSchemeTask.request.url,
19
+ let originalURL = OfflineCacheService.shared.originalURL(forOfflineURL: offlineURL) else {
20
+ offlineSchemeLogger.info("SCHEME miss reason=invalid-url url=\(urlSchemeTask.request.url?.absoluteString ?? "nil")")
21
+ urlSchemeTask.didFailWithError(URLError(.badURL))
22
+ return
23
+ }
24
+
25
+ Task {
26
+ if let snapshot = OfflineCacheService.shared.snapshotData(for: originalURL) {
27
+ let data = OfflineCacheService.shared.rewriteSameOriginReferencesForOffline(
28
+ snapshot,
29
+ originalURL: originalURL
30
+ ) ?? snapshot
31
+ send(
32
+ data: data,
33
+ mimeType: "text/html",
34
+ encoding: "utf-8",
35
+ url: offlineURL,
36
+ task: urlSchemeTask
37
+ )
38
+ offlineSchemeLogger.info("SCHEME document-hit bytes=\(data.count) original=\(originalURL.absoluteString) offline=\(offlineURL.absoluteString)")
39
+ return
40
+ }
41
+
42
+ var originalRequest = URLRequest(url: originalURL)
43
+ originalRequest.httpMethod = "GET"
44
+ let (cachedData, cacheState, mimeType) = await CacheManager.shared.getCachedResource(for: originalRequest)
45
+ guard let cachedData, cacheState != .expired else {
46
+ offlineSchemeLogger.info("SCHEME asset-miss original=\(originalURL.absoluteString) offline=\(offlineURL.absoluteString)")
47
+ urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist))
48
+ return
49
+ }
50
+
51
+ let data = OfflineCacheService.shared.rewriteSameOriginReferencesForOffline(
52
+ cachedData,
53
+ originalURL: originalURL
54
+ ) ?? cachedData
55
+ send(
56
+ data: data,
57
+ mimeType: mimeType ?? OfflineCacheService.shared.mimeType(for: originalURL),
58
+ encoding: "utf-8",
59
+ url: offlineURL,
60
+ task: urlSchemeTask
61
+ )
62
+ offlineSchemeLogger.info("SCHEME asset-hit state=\(String(describing: cacheState)) bytes=\(data.count) mime=\(mimeType ?? "unknown") original=\(originalURL.absoluteString)")
63
+ }
64
+ }
65
+
66
+ func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
67
+ offlineSchemeLogger.debug("SCHEME stop url=\(urlSchemeTask.request.url?.absoluteString ?? "nil")")
68
+ }
69
+
70
+ private func send(
71
+ data: Data,
72
+ mimeType: String,
73
+ encoding: String,
74
+ url: URL,
75
+ task: WKURLSchemeTask
76
+ ) {
77
+ let headers = [
78
+ "Content-Type": "\(mimeType); charset=\(encoding)",
79
+ "Access-Control-Allow-Origin": "*",
80
+ "Cache-Control": "no-store"
81
+ ]
82
+ let response = HTTPURLResponse(
83
+ url: url,
84
+ statusCode: 200,
85
+ httpVersion: "HTTP/1.1",
86
+ headerFields: headers
87
+ )!
88
+ task.didReceive(response)
89
+ task.didReceive(data)
90
+ task.didFinish()
91
+ }
92
+ }
93
+
94
+ extension OfflineURLSchemeHandler: @unchecked Sendable {}
@@ -0,0 +1,373 @@
1
+ import Foundation
2
+ import WebKit
3
+ import MachO
4
+
5
+ enum CatalystPerf {
6
+ private static let maxBufferSize = 500
7
+ private static let flushDelayMs: UInt64 = 250
8
+ private static let flushRetryDelayMs: UInt64 = 250
9
+ private static let maxFlushRetries = 5
10
+ private static let periodicFlushIntervalMs: UInt64 = 4_000
11
+ private static let lock = NSLock()
12
+
13
+ private static var buffer: [[String: Any]] = []
14
+ private static var pendingCalls: [String: (startMs: Int64, method: String)] = [:]
15
+ private static var flushed = false
16
+ private static var flushGeneration = 0
17
+ private static var batchSequence = 0
18
+ private static var inFlight: PendingBatch?
19
+ private static weak var periodicWebView: WKWebView?
20
+ private static var periodicTask: Task<Void, Never>?
21
+
22
+ private static var cacheHits = 0
23
+ private static var cacheMisses = 0
24
+ private static var cacheFetches = 0
25
+ private static var cacheTotalMs: Int64 = 0
26
+ private static var cacheTopSlow: [(durationMs: Int64, filename: String)] = []
27
+
28
+ private struct PendingBatch {
29
+ let id: String
30
+ let events: [[String: Any]]
31
+ let generation: Int
32
+ let retryCount: Int
33
+ }
34
+
35
+ static func nativeTimeMs() -> Int64 {
36
+ Int64(ProcessInfo.processInfo.systemUptime * 1000)
37
+ }
38
+
39
+ static func add(_ event: [String: Any]) {
40
+ #if DEBUG
41
+ guard ConfigConstants.Profiler.enabled else { return }
42
+ var payload = event
43
+ if payload["nativeTime"] == nil {
44
+ payload["nativeTime"] = nativeTimeMs()
45
+ }
46
+
47
+ lock.lock()
48
+ if buffer.count >= maxBufferSize {
49
+ buffer.removeFirst()
50
+ }
51
+ updateCacheSummaryLocked(payload)
52
+ buffer.append(payload)
53
+ lock.unlock()
54
+ #endif
55
+ }
56
+
57
+ static func emit(_ event: [String: Any], to webView: WKWebView?) {
58
+ #if DEBUG
59
+ guard ConfigConstants.Profiler.enabled else { return }
60
+ guard let webView else {
61
+ add(event)
62
+ return
63
+ }
64
+
65
+ var payload = event
66
+ if payload["nativeTime"] == nil {
67
+ payload["nativeTime"] = nativeTimeMs()
68
+ }
69
+
70
+ DispatchQueue.main.async {
71
+ guard let json = jsonString(payload) else { return }
72
+ let script = "window.__catalystPerfEvent && window.__catalystPerfEvent(\(javaScriptStringLiteral(json)));"
73
+ webView.evaluateJavaScript(script)
74
+ }
75
+ #endif
76
+ }
77
+
78
+ static func bridgeCallReceived(callId: String, method: String) {
79
+ #if DEBUG
80
+ guard ConfigConstants.Profiler.enabled else { return }
81
+ lock.lock()
82
+ pendingCalls[callId] = (nativeTimeMs(), method)
83
+ lock.unlock()
84
+ #endif
85
+ }
86
+
87
+ static func bridgeCallDispatched(callId: String) {
88
+ #if DEBUG
89
+ guard ConfigConstants.Profiler.enabled else { return }
90
+ lock.lock()
91
+ guard let pending = pendingCalls.removeValue(forKey: callId) else {
92
+ lock.unlock()
93
+ return
94
+ }
95
+ lock.unlock()
96
+
97
+ let endMs = nativeTimeMs()
98
+ add([
99
+ "type": "api-call",
100
+ "callId": callId,
101
+ "method": pending.method,
102
+ "nativeStartMs": pending.startMs,
103
+ "nativeEndMs": endMs,
104
+ "durationMs": endMs - pending.startMs,
105
+ "thread": Thread.current.name ?? "unknown",
106
+ ])
107
+ #endif
108
+ }
109
+
110
+ static func scheduleFlush(_ webView: WKWebView?) {
111
+ #if DEBUG
112
+ guard ConfigConstants.Profiler.enabled else { return }
113
+ guard let webView else { return }
114
+
115
+ lock.lock()
116
+ if flushed {
117
+ lock.unlock()
118
+ return
119
+ }
120
+ flushed = true
121
+ lock.unlock()
122
+
123
+ Task { @MainActor in
124
+ try? await Task.sleep(nanoseconds: flushDelayMs * 1_000_000)
125
+ beginFlush(webView, includeCacheSummary: true)
126
+ startPeriodicFlush(webView)
127
+ }
128
+ #endif
129
+ }
130
+
131
+ static func flushNow(_ webView: WKWebView?) {
132
+ #if DEBUG
133
+ guard ConfigConstants.Profiler.enabled else { return }
134
+ guard let webView else { return }
135
+ Task { @MainActor in
136
+ beginFlush(webView, includeCacheSummary: false)
137
+ }
138
+ #endif
139
+ }
140
+
141
+ static func reset() {
142
+ #if DEBUG
143
+ stopPeriodicFlush()
144
+ lock.lock()
145
+ buffer.removeAll()
146
+ flushGeneration += 1
147
+ inFlight = nil
148
+ pendingCalls.removeAll()
149
+ flushed = false
150
+ cacheHits = 0
151
+ cacheMisses = 0
152
+ cacheFetches = 0
153
+ cacheTotalMs = 0
154
+ cacheTopSlow.removeAll()
155
+ lock.unlock()
156
+ #endif
157
+ }
158
+
159
+ static func memorySnapshot(to webView: WKWebView?, label: String? = nil) {
160
+ #if DEBUG
161
+ guard ConfigConstants.Profiler.enabled else { return }
162
+ let currentMb = currentResidentMemoryMb()
163
+ let event: [String: Any] = [
164
+ "type": "memory-snapshot",
165
+ "nativeTime": nativeTimeMs(),
166
+ "jvmMb": 0,
167
+ "webviewMb": currentMb,
168
+ "nativeMb": currentMb,
169
+ "totalMb": currentMb,
170
+ "otherMb": 0,
171
+ "peakMb": currentMb,
172
+ "thread": Thread.current.name ?? "unknown",
173
+ "label": label ?? "ios-process",
174
+ ]
175
+
176
+ if let webView {
177
+ emit(event, to: webView)
178
+ } else {
179
+ add(event)
180
+ }
181
+ #endif
182
+ }
183
+
184
+ static func injectNativeTimeOffset(into webView: WKWebView?) {
185
+ #if DEBUG
186
+ guard ConfigConstants.Profiler.enabled else { return }
187
+ guard let webView else { return }
188
+ let nativeNow = nativeTimeMs()
189
+ DispatchQueue.main.async {
190
+ webView.evaluateJavaScript("window.__NATIVE_TIME_OFFSET = \(nativeNow) - performance.now();")
191
+ }
192
+ #endif
193
+ }
194
+
195
+ private static func startPeriodicFlush(_ webView: WKWebView) {
196
+ periodicTask?.cancel()
197
+ periodicWebView = webView
198
+ periodicTask = Task { @MainActor in
199
+ while !Task.isCancelled {
200
+ try? await Task.sleep(nanoseconds: periodicFlushIntervalMs * 1_000_000)
201
+ guard let webView = periodicWebView else { return }
202
+ lock.lock()
203
+ let pendingBatch = inFlight
204
+ lock.unlock()
205
+ if let pendingBatch {
206
+ deliver(pendingBatch, to: webView)
207
+ } else {
208
+ beginFlush(webView, includeCacheSummary: false)
209
+ }
210
+ memorySnapshot(to: webView, label: "periodic")
211
+ }
212
+ }
213
+ }
214
+
215
+ private static func stopPeriodicFlush() {
216
+ periodicTask?.cancel()
217
+ periodicTask = nil
218
+ periodicWebView = nil
219
+ }
220
+
221
+ @MainActor
222
+ private static func beginFlush(_ webView: WKWebView, includeCacheSummary: Bool) {
223
+ lock.lock()
224
+ guard inFlight == nil else {
225
+ lock.unlock()
226
+ return
227
+ }
228
+ var events = buffer
229
+ buffer.removeAll()
230
+ if includeCacheSummary, let summary = buildCacheSummaryEventLocked() {
231
+ events.append(summary)
232
+ }
233
+ guard !events.isEmpty else {
234
+ lock.unlock()
235
+ return
236
+ }
237
+ batchSequence += 1
238
+ let batch = PendingBatch(
239
+ id: "catalyst-\(batchSequence)",
240
+ events: events,
241
+ generation: flushGeneration,
242
+ retryCount: 0
243
+ )
244
+ inFlight = batch
245
+ lock.unlock()
246
+
247
+ deliver(batch, to: webView)
248
+ }
249
+
250
+ @MainActor
251
+ private static func deliver(_ batch: PendingBatch, to webView: WKWebView) {
252
+ lock.lock()
253
+ let isCurrent = inFlight?.id == batch.id && batch.generation == flushGeneration
254
+ lock.unlock()
255
+ guard isCurrent, let json = jsonString(batch.events) else { return }
256
+
257
+ let script = "window.__catalystPerfBatch ? window.__catalystPerfBatch(\(javaScriptStringLiteral(batch.id)), \(javaScriptStringLiteral(json))) : false"
258
+ webView.evaluateJavaScript(script) { result, _ in
259
+ if (result as? Bool) == true {
260
+ acknowledge(batch)
261
+ } else {
262
+ retry(batch, to: webView)
263
+ }
264
+ }
265
+ }
266
+
267
+ private static func acknowledge(_ batch: PendingBatch) {
268
+ lock.lock()
269
+ if inFlight?.id == batch.id && batch.generation == flushGeneration {
270
+ inFlight = nil
271
+ }
272
+ lock.unlock()
273
+ }
274
+
275
+ private static func retry(_ batch: PendingBatch, to webView: WKWebView) {
276
+ lock.lock()
277
+ guard inFlight?.id == batch.id && batch.generation == flushGeneration else {
278
+ lock.unlock()
279
+ return
280
+ }
281
+ guard batch.retryCount < maxFlushRetries else {
282
+ lock.unlock()
283
+ return
284
+ }
285
+ let retryBatch = PendingBatch(
286
+ id: batch.id,
287
+ events: batch.events,
288
+ generation: batch.generation,
289
+ retryCount: batch.retryCount + 1
290
+ )
291
+ inFlight = retryBatch
292
+ lock.unlock()
293
+
294
+ Task { @MainActor in
295
+ try? await Task.sleep(nanoseconds: flushRetryDelayMs * 1_000_000)
296
+ deliver(retryBatch, to: webView)
297
+ }
298
+ }
299
+
300
+ private static func updateCacheSummaryLocked(_ event: [String: Any]) {
301
+ guard let type = event["type"] as? String else { return }
302
+ let durationMs = event["durationMs"] as? Int64 ?? Int64(event["durationMs"] as? Int ?? 0)
303
+ let url = event["url"] as? String ?? ""
304
+
305
+ switch type {
306
+ case "cache-hit-memory", "cache-hit-disk":
307
+ cacheHits += 1
308
+ case "cache-miss-fetch":
309
+ cacheMisses += 1
310
+ case "network-fetch-complete":
311
+ cacheFetches += 1
312
+ cacheTotalMs += durationMs
313
+ cacheTopSlow.append((durationMs, filename(from: url)))
314
+ cacheTopSlow = Array(cacheTopSlow.sorted { $0.durationMs > $1.durationMs }.prefix(5))
315
+ default:
316
+ break
317
+ }
318
+ }
319
+
320
+ private static func buildCacheSummaryEventLocked() -> [String: Any]? {
321
+ let total = cacheHits + cacheMisses
322
+ guard total > 0 else { return nil }
323
+ let hitRate = Double(cacheHits) * 100.0 / Double(total)
324
+ let avgFetchMs = cacheFetches > 0 ? cacheTotalMs / Int64(cacheFetches) : 0
325
+ return [
326
+ "type": "cache-summary",
327
+ "hits": cacheHits,
328
+ "misses": cacheMisses,
329
+ "fetches": cacheFetches,
330
+ "total": total,
331
+ "hitRatePct": (hitRate * 10).rounded() / 10,
332
+ "avgFetchMs": avgFetchMs,
333
+ "topSlowest": cacheTopSlow.map { ["filename": $0.filename, "durationMs": $0.durationMs] },
334
+ "nativeTime": nativeTimeMs(),
335
+ ]
336
+ }
337
+
338
+ private static func currentResidentMemoryMb() -> Int {
339
+ var info = mach_task_basic_info()
340
+ var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
341
+ let result = withUnsafeMutablePointer(to: &info) {
342
+ $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
343
+ task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
344
+ }
345
+ }
346
+ guard result == KERN_SUCCESS else { return 0 }
347
+ return Int(info.resident_size / 1024 / 1024)
348
+ }
349
+
350
+ private static func filename(from url: String) -> String {
351
+ URL(string: url)?.lastPathComponent.prefix(40).description ?? String(url.prefix(40))
352
+ }
353
+
354
+ private static func jsonString(_ object: Any) -> String? {
355
+ guard JSONSerialization.isValidJSONObject(object),
356
+ let data = try? JSONSerialization.data(withJSONObject: object),
357
+ let json = String(data: data, encoding: .utf8) else {
358
+ return nil
359
+ }
360
+ return json
361
+ }
362
+
363
+ private static func javaScriptStringLiteral(_ value: String) -> String {
364
+ let escaped = value
365
+ .replacingOccurrences(of: "\\", with: "\\\\")
366
+ .replacingOccurrences(of: "'", with: "\\'")
367
+ .replacingOccurrences(of: "\n", with: "\\n")
368
+ .replacingOccurrences(of: "\r", with: "\\r")
369
+ .replacingOccurrences(of: "\u{2028}", with: "\\u2028")
370
+ .replacingOccurrences(of: "\u{2029}", with: "\\u2029")
371
+ return "'\(escaped)'"
372
+ }
373
+ }
@@ -7,16 +7,20 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app"
7
7
  final class ResourceURLProtocol: URLProtocol {
8
8
  private var dataTask: URLSessionDataTask?
9
9
  private static let handledKey = "ResourceURLProtocolHandled"
10
+ private static var isRegistered = false
10
11
 
11
12
  // MARK: - Protocol Registration
12
13
  static func register() {
13
- logger.warning("⚠️ ResourceURLProtocol registration is DISABLED to preserve POST bodies")
14
- return
15
-
14
+ guard !isRegistered else { return }
15
+ URLProtocol.registerClass(self)
16
+ isRegistered = true
17
+ logger.info("✅ ResourceURLProtocol registered for GET cache interception")
16
18
  }
17
19
 
18
20
  static func unregister() {
21
+ guard isRegistered else { return }
19
22
  URLProtocol.unregisterClass(self)
23
+ isRegistered = false
20
24
  }
21
25
 
22
26
  // MARK: - URLProtocol
@@ -45,7 +49,15 @@ final class ResourceURLProtocol: URLProtocol {
45
49
  return false
46
50
  }
47
51
 
48
- let shouldCache = CacheManager.shared.shouldCacheURL(url)
52
+ if request.value(forHTTPHeaderField: "X-Catalyst-Offline-Snapshot-Fetch") == "1" ||
53
+ url.path == "/catalyst-offline-manifest.json" ||
54
+ url.path == "/catalyst-sw.js" ||
55
+ url.path == "/offline.html" {
56
+ logger.info("🔍 ResourceURLProtocol.canInit: Skipping internal Catalyst offline request")
57
+ return false
58
+ }
59
+
60
+ let shouldCache = CacheManager.shared.shouldCacheRequest(request)
49
61
  logger.info("🔍 ResourceURLProtocol.canInit: GET request, shouldCache=\(shouldCache)")
50
62
  return shouldCache
51
63
  }
@@ -115,15 +127,16 @@ final class ResourceURLProtocol: URLProtocol {
115
127
  // If not in cache or expired, load from network using URLSession
116
128
  logger.info("🌐 Fetching from network: \(url.absoluteString)")
117
129
 
118
- let config = URLSessionConfiguration.default
130
+ let config = URLSessionConfiguration.ephemeral
119
131
  config.requestCachePolicy = .reloadIgnoringLocalCacheData
132
+ config.protocolClasses = []
120
133
  let session = URLSession(configuration: config)
121
134
 
122
135
  let (data, response) = try await session.data(for: mutableRequest as URLRequest)
123
136
 
124
137
  if let httpResponse = response as? HTTPURLResponse {
125
138
  // Cache the response if it's valid
126
- if CacheManager.shared.isCacheableResponse(httpResponse) {
139
+ if CacheManager.shared.isCacheableResponse(httpResponse, for: request) {
127
140
  CacheManager.shared.storeCachedResponse(httpResponse, data: data, for: request)
128
141
  }
129
142
 
@@ -49,6 +49,7 @@ public struct SafeAreaInsets: Codable, Equatable, CustomStringConvertible {
49
49
  "X-Safe-Area-Right": String(Int(right)),
50
50
  "X-Safe-Area-Bottom": String(Int(bottom)),
51
51
  "X-Safe-Area-Left": String(Int(left)),
52
+ "X-Catalyst-Native-WebView": "1",
52
53
  // Prevent caching of SSR response so updated headers are always used
53
54
  "Cache-Control": "no-cache, no-store, must-revalidate",
54
55
  "Pragma": "no-cache"
@@ -508,6 +508,7 @@ class BridgeCommandHandler {
508
508
  store.removeData(ofTypes: dataTypes, modifiedSince: .distantPast) { [weak self] in
509
509
  guard let self else { return }
510
510
  CacheManager.shared.clearCache()
511
+ OfflineCacheService.shared.clearAll()
511
512
  commandLogger.debug("clearWebData complete")
512
513
  DispatchQueue.main.async {
513
514
  self.delegate?.sendJSONCallback(eventName: "ON_WEB_DATA_CLEARED", data: [
@@ -48,6 +48,40 @@ class BridgeFileHandler: NSObject {
48
48
 
49
49
  // MARK: - File Intent Operations
50
50
 
51
+ func exportCatalystTrace(params: Any?) {
52
+ guard let payload = params as? [String: Any],
53
+ let filename = payload["filename"] as? String,
54
+ let trace = payload["trace"] as? String,
55
+ !filename.isEmpty,
56
+ !trace.isEmpty else {
57
+ delegate?.sendErrorCallback(eventName: "ON_INTENT_ERROR", error: "Invalid Catalyst trace export payload", code: "INVALID_PARAMETERS")
58
+ return
59
+ }
60
+
61
+ let safeFilename = filename.replacingOccurrences(of: "/", with: "-")
62
+ let exportsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
63
+ .appendingPathComponent("CatalystTraces", isDirectory: true)
64
+ let fileURL = exportsURL.appendingPathComponent(safeFilename)
65
+
66
+ do {
67
+ try FileManager.default.createDirectory(at: exportsURL, withIntermediateDirectories: true)
68
+ try trace.write(to: fileURL, atomically: true, encoding: .utf8)
69
+ DispatchQueue.main.async { [weak self] in
70
+ self?.presentSharingSheet(for: fileURL)
71
+ }
72
+ delegate?.sendJSONCallback(eventName: "ON_INTENT_SUCCESS", data: [
73
+ "message": "Catalyst trace exported",
74
+ "filePath": fileURL.path,
75
+ "filename": safeFilename,
76
+ "timestamp": ISO8601DateFormatter().string(from: Date()),
77
+ "platform": "ios"
78
+ ])
79
+ } catch {
80
+ fileLogger.error("Failed to export Catalyst trace: \(error.localizedDescription)")
81
+ delegate?.sendErrorCallback(eventName: "ON_INTENT_ERROR", error: "Failed to export Catalyst trace", code: "TRACE_EXPORT_ERROR")
82
+ }
83
+ }
84
+
51
85
  // Open file with external app using intent (iOS equivalent of Android intent)
52
86
  func openFileWithIntent(params: Any?) {
53
87
  fileLogger.debug("openFileWithIntent called with params: \(String(describing: params))")
@@ -337,4 +371,4 @@ extension BridgeFileHandler: UIDocumentInteractionControllerDelegate {
337
371
  ])
338
372
  }
339
373
  }
340
- }
374
+ }
@@ -208,6 +208,15 @@ class BridgeMessageValidator {
208
208
  "type": "object",
209
209
  "properties": [:],
210
210
  "additionalProperties": false
211
+ ],
212
+ "exportCatalystTrace": [
213
+ "type": "object",
214
+ "properties": [
215
+ "filename": ["type": "string"],
216
+ "trace": ["type": "string"]
217
+ ],
218
+ "required": ["filename", "trace"],
219
+ "additionalProperties": false
211
220
  ]
212
221
  ]
213
222
 
@@ -233,34 +242,38 @@ class BridgeMessageValidator {
233
242
  )
234
243
  }
235
244
 
236
- // Step 2: Validate message size
237
- if let messageData = try? JSONSerialization.data(withJSONObject: message.body, options: []),
238
- messageData.count > CatalystConstants.Bridge.maxMessageSize {
239
- logger.error("Message size exceeds limit: \(messageData.count) > \(CatalystConstants.Bridge.maxMessageSize)")
245
+ // Step 2: Validate message body structure
246
+ guard let body = message.body as? [String: Any] else {
247
+ logger.error("Invalid message format - body is not a dictionary")
240
248
  return BridgeValidationResult(
241
249
  isValid: false,
242
250
  command: nil,
243
251
  params: nil,
244
252
  body: nil,
245
253
  error: BridgeValidationError(
246
- message: "Message too large",
247
- code: "MESSAGE_TOO_LARGE",
254
+ message: "Invalid message format",
255
+ code: "INVALID_FORMAT",
248
256
  eventName: "BRIDGE_ERROR"
249
257
  )
250
258
  )
251
259
  }
252
260
 
253
- // Step 3: Validate message body structure
254
- guard let body = message.body as? [String: Any] else {
255
- logger.error("Invalid message format - body is not a dictionary")
261
+ // Step 3: Trace export gets a larger limit only when that debug profiler command is enabled.
262
+ let rawCommand = body["command"] as? String
263
+ let maxMessageSize = rawCommand == "exportCatalystTrace" && CatalystConstants.Bridge.isTraceExportEnabled()
264
+ ? 25 * 1024 * 1024
265
+ : CatalystConstants.Bridge.maxMessageSize
266
+ if let messageData = try? JSONSerialization.data(withJSONObject: message.body, options: []),
267
+ messageData.count > maxMessageSize {
268
+ logger.error("Message size exceeds limit: \(messageData.count) > \(maxMessageSize)")
256
269
  return BridgeValidationResult(
257
270
  isValid: false,
258
271
  command: nil,
259
272
  params: nil,
260
273
  body: nil,
261
274
  error: BridgeValidationError(
262
- message: "Invalid message format",
263
- code: "INVALID_FORMAT",
275
+ message: "Message too large",
276
+ code: "MESSAGE_TOO_LARGE",
264
277
  eventName: "BRIDGE_ERROR"
265
278
  )
266
279
  )
@@ -267,7 +267,10 @@ extension NativeBridge: WKScriptMessageHandler {
267
267
  logger.debug("Received validated command: \(command)")
268
268
 
269
269
  // Execute commands with proper error handling
270
+ let callId = "\(command):\(UUID().uuidString)"
271
+ CatalystPerf.bridgeCallReceived(callId: callId, method: command)
270
272
  executeCommand(command, params: params)
273
+ CatalystPerf.bridgeCallDispatched(callId: callId)
271
274
  }
272
275
 
273
276
  // MARK: - Command Execution
@@ -295,6 +298,12 @@ extension NativeBridge: WKScriptMessageHandler {
295
298
  commandHandler.pickFile(options: optionsString)
296
299
  case "openFileWithIntent":
297
300
  fileHandler.openFileWithIntent(params: params)
301
+ case "exportCatalystTrace":
302
+ guard CatalystConstants.Bridge.isTraceExportEnabled() else {
303
+ sendErrorCallback(eventName: "BRIDGE_ERROR", error: "Unsupported command: exportCatalystTrace", code: "UNSUPPORTED_COMMAND")
304
+ return
305
+ }
306
+ fileHandler.exportCatalystTrace(params: params)
298
307
  case "requestHapticFeedback":
299
308
  let feedbackType = delegateHandler.extractFeedbackType(from: params)
300
309
  commandHandler.requestHapticFeedback(feedbackType: feedbackType)