catalyst-core 0.2.0-beta.2 → 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.
- package/dist/native/androidProject/app/build.gradle.kts +1 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +143 -4
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +26 -6
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MetricsMonitor.kt +143 -17
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +6 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +26 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
- package/dist/native/bridge/WebBridge.js +2 -2
- package/dist/native/bridge/constants/NativeInterfaces.js +2 -1
- package/dist/native/bridge/perf/collectors/ApiCollector.js +22 -0
- package/dist/native/bridge/perf/collectors/CacheCollector.js +40 -0
- package/dist/native/bridge/perf/collectors/HookCollector.js +43 -0
- package/dist/native/bridge/perf/collectors/InsightsCollector.js +47 -0
- package/dist/native/bridge/perf/collectors/InteractionCollector.js +38 -0
- package/dist/native/bridge/perf/collectors/KeyboardCollector.js +25 -0
- package/dist/native/bridge/perf/collectors/NavigationCollector.js +53 -0
- package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +7 -0
- package/dist/native/bridge/perf/collectors/RenderCollector.js +40 -0
- package/dist/native/bridge/perf/collectors/ScrollCollector.js +25 -0
- package/dist/native/bridge/perf/collectors/ThreadCollector.js +27 -0
- package/dist/native/bridge/perf/core/constants.js +43 -0
- package/dist/native/bridge/perf/core/measure.js +40 -0
- package/dist/native/bridge/perf/core/session.js +34 -0
- package/dist/native/bridge/perf/core/store.js +1 -0
- package/dist/native/bridge/perf/core/summary.js +1 -0
- package/dist/native/bridge/perf/core/traceExport.js +1 -0
- package/dist/native/bridge/perf/core/waterfalls.js +1 -0
- package/dist/native/bridge/perf/index.js +81 -0
- package/dist/native/buildIos/config.js +1 -1
- package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
- package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
- package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +10 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
- package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +126 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +75 -1
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
|
237
|
-
|
|
238
|
-
|
|
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: "
|
|
247
|
-
code: "
|
|
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:
|
|
254
|
-
|
|
255
|
-
|
|
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: "
|
|
263
|
-
code: "
|
|
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)
|