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.
- package/dist/native/androidProject/app/build.gradle.kts +26 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +381 -78
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +29 -8
- 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 +7 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/OfflineCacheService.kt +374 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +162 -26
- 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/CustomWebviewTest.kt +7 -2
- 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/buildAndroid/assets.js +2 -2
- 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 +49 -4
- package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineCacheService.swift +683 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineURLSchemeHandler.swift +94 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/ResourceURLProtocol.swift +19 -6
- package/dist/native/iosnativeWebView/Sources/Core/Utils/SafeAreaUtils.swift +1 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +1 -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 +141 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +157 -3
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
- package/dist/native/renameAndroidProject.js +2 -2
- package/dist/offline/catalyst-sw.js +1 -0
- package/dist/offline/registerServiceWorker.js +1 -0
- package/dist/router/ClientRouter.js +1 -1
- package/dist/scripts/build.js +1 -1
- package/dist/scripts/devBuild.js +1 -1
- package/dist/scripts/generateOfflineManifest.js +1 -0
- package/dist/server/expressServer.js +2 -1
- package/dist/server/renderer/document/Body.js +1 -1
- package/dist/server/renderer/handler.js +3 -3
- package/dist/webpack/base.babel.js +1 -1
- package/mcp_v2/knowledge-base.json +13 -0
- 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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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)
|