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,683 @@
1
+ import Foundation
2
+ import WebKit
3
+ import os
4
+ #if canImport(CryptoKit)
5
+ import CryptoKit
6
+ #endif
7
+
8
+ private let offlineLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "OfflineCache")
9
+
10
+ final class OfflineCacheService {
11
+ static let shared = OfflineCacheService()
12
+ static let offlineHTTPScheme = "catalyst-offline-http"
13
+ static let offlineHTTPSScheme = "catalyst-offline-https"
14
+ private static let snapshotMaxAge: TimeInterval = 24 * 60 * 60
15
+ private static let assetExtensions = Set([
16
+ ".js",
17
+ ".mjs",
18
+ ".css",
19
+ ".png",
20
+ ".jpg",
21
+ ".jpeg",
22
+ ".gif",
23
+ ".svg",
24
+ ".webp",
25
+ ".ico",
26
+ ".woff",
27
+ ".woff2",
28
+ ".ttf",
29
+ ".eot"
30
+ ])
31
+
32
+ private struct OfflineRoute {
33
+ let pattern: String
34
+ let regex: String
35
+ }
36
+
37
+ private struct OfflineManifest {
38
+ let buildId: String
39
+ let routes: [OfflineRoute]
40
+ }
41
+
42
+ private let queue = DispatchQueue(label: "com.app.offlinecache", attributes: .concurrent)
43
+ private let cacheDirectory: URL
44
+ private let routeDirectory: URL
45
+ private let manifestURL: URL
46
+ private var manifest: OfflineManifest?
47
+ private var ongoingSnapshots: Set<String> = []
48
+ private var activeOfflineRouteOrigin: String?
49
+
50
+ private init() {
51
+ let baseDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
52
+ cacheDirectory = baseDirectory.appendingPathComponent("CatalystOffline", isDirectory: true)
53
+ routeDirectory = cacheDirectory.appendingPathComponent("routes", isDirectory: true)
54
+ manifestURL = cacheDirectory.appendingPathComponent("manifest.json")
55
+
56
+ try? FileManager.default.createDirectory(at: routeDirectory, withIntermediateDirectories: true)
57
+ manifest = loadCachedManifest()
58
+ if let manifest {
59
+ offlineLogger.info("Loaded cached offline manifest buildId=\(manifest.buildId), routes=\(manifest.routes.count)")
60
+ } else {
61
+ offlineLogger.info("No cached offline manifest found")
62
+ }
63
+ }
64
+
65
+ func storeRouteSnapshot(for request: URLRequest, webView: WKWebView?) {
66
+ guard let url = request.url, isHTTP(url) else { return }
67
+ let normalizedURL = normalizeSnapshotURL(url)
68
+ let snapshotKey = normalizedURL.absoluteString
69
+
70
+ guard markSnapshotStarted(snapshotKey) else {
71
+ offlineLogger.info("Snapshot fetch already in progress: \(snapshotKey)")
72
+ return
73
+ }
74
+ offlineLogger.info("Snapshot store requested: \(snapshotKey)")
75
+
76
+ Task {
77
+ defer { self.markSnapshotFinished(snapshotKey) }
78
+
79
+ let refreshedManifest = await refreshManifestAsync(for: url, request: request, webView: webView)
80
+ guard self.isOfflineRoute(url) else {
81
+ offlineLogger.info("Snapshot skipped; route is not offline eligible: \(url.absoluteString), manifestRoutes=\(refreshedManifest?.routes.count ?? 0)")
82
+ return
83
+ }
84
+ let fileURL = snapshotFileURL(for: normalizedURL)
85
+ guard !isFreshSnapshot(fileURL) else {
86
+ offlineLogger.info("Snapshot already fresh: \(normalizedURL.absoluteString)")
87
+ if let data = try? Data(contentsOf: fileURL) {
88
+ await self.cacheSnapshotAssets(from: data, baseURL: normalizedURL, originalRequest: request, webView: webView)
89
+ }
90
+ return
91
+ }
92
+
93
+ do {
94
+ var snapshotRequest = URLRequest(url: normalizedURL)
95
+ snapshotRequest.httpMethod = "GET"
96
+ snapshotRequest.timeoutInterval = 15
97
+ snapshotRequest.setValue("1", forHTTPHeaderField: "X-Catalyst-Offline-Snapshot-Fetch")
98
+ for (key, value) in request.allHTTPHeaderFields ?? [:] {
99
+ if shouldForwardHeader(key) {
100
+ snapshotRequest.setValue(value, forHTTPHeaderField: key)
101
+ }
102
+ }
103
+
104
+ if let cookieHeader = await cookieHeader(for: normalizedURL, webView: webView), !cookieHeader.isEmpty {
105
+ snapshotRequest.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
106
+ }
107
+
108
+ let (data, response) = try await URLSession.shared.data(for: snapshotRequest)
109
+ guard let httpResponse = response as? HTTPURLResponse else {
110
+ offlineLogger.info("Snapshot fetch returned non-HTTP response: \(normalizedURL.absoluteString)")
111
+ return
112
+ }
113
+ let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
114
+ offlineLogger.info("Snapshot fetch response: url=\(normalizedURL.absoluteString), status=\(httpResponse.statusCode), contentType=\(contentType), bytes=\(data.count)")
115
+
116
+ guard httpResponse.statusCode == 200,
117
+ contentType.localizedCaseInsensitiveContains("text/html"),
118
+ !data.isEmpty else {
119
+ offlineLogger.info("Snapshot not stored; response is not cacheable HTML: \(normalizedURL.absoluteString)")
120
+ return
121
+ }
122
+
123
+ try? FileManager.default.createDirectory(
124
+ at: fileURL.deletingLastPathComponent(),
125
+ withIntermediateDirectories: true
126
+ )
127
+ try data.write(to: fileURL)
128
+ offlineLogger.info("Stored route snapshot: \(normalizedURL.absoluteString), path=\(fileURL.path)")
129
+ await self.cacheSnapshotAssets(from: data, baseURL: normalizedURL, originalRequest: request, webView: webView)
130
+ } catch {
131
+ offlineLogger.info("Unable to store route snapshot \(normalizedURL.absoluteString): \(error.localizedDescription)")
132
+ }
133
+ }
134
+ }
135
+
136
+ func loadSnapshot(in webView: WKWebView, for url: URL) -> Bool {
137
+ let normalizedURL = normalizeSnapshotURL(url)
138
+ guard isOfflineRoute(url) else {
139
+ let hasManifest = queue.sync { self.manifest != nil }
140
+ offlineLogger.info("Snapshot miss; route is not offline eligible or manifest is missing: \(url.absoluteString), hasManifest=\(hasManifest)")
141
+ return false
142
+ }
143
+
144
+ let fileURL = snapshotFileURL(for: normalizedURL)
145
+ guard let data = try? Data(contentsOf: fileURL) else {
146
+ offlineLogger.info("Snapshot miss; file not found: \(normalizedURL.absoluteString), path=\(fileURL.path)")
147
+ return false
148
+ }
149
+
150
+ guard let offlineURL = offlineURL(for: normalizedURL) else {
151
+ offlineLogger.info("Snapshot miss; unable to create offline URL: \(normalizedURL.absoluteString)")
152
+ return false
153
+ }
154
+
155
+ webView.load(URLRequest(url: offlineURL))
156
+ setActiveOfflineRouteOrigin(origin(for: url))
157
+ offlineLogger.info("Loaded route snapshot through offline scheme: original=\(normalizedURL.absoluteString), offline=\(offlineURL.absoluteString), bytes=\(data.count)")
158
+ return true
159
+ }
160
+
161
+ func snapshotData(for url: URL) -> Data? {
162
+ let normalizedURL = normalizeSnapshotURL(url)
163
+ guard isOfflineRoute(normalizedURL) else { return nil }
164
+ return try? Data(contentsOf: snapshotFileURL(for: normalizedURL))
165
+ }
166
+
167
+ func offlineURL(for originalURL: URL) -> URL? {
168
+ guard isHTTP(originalURL),
169
+ var components = URLComponents(url: originalURL, resolvingAgainstBaseURL: false),
170
+ let scheme = originalURL.scheme?.lowercased() else {
171
+ return nil
172
+ }
173
+ components.scheme = scheme == "https" ? Self.offlineHTTPSScheme : Self.offlineHTTPScheme
174
+ return components.url
175
+ }
176
+
177
+ func originalURL(forOfflineURL offlineURL: URL) -> URL? {
178
+ guard var components = URLComponents(url: offlineURL, resolvingAgainstBaseURL: false),
179
+ let scheme = offlineURL.scheme?.lowercased() else {
180
+ return nil
181
+ }
182
+
183
+ if scheme == Self.offlineHTTPSScheme {
184
+ components.scheme = "https"
185
+ } else if scheme == Self.offlineHTTPScheme {
186
+ components.scheme = "http"
187
+ } else {
188
+ return nil
189
+ }
190
+
191
+ return components.url
192
+ }
193
+
194
+ func isOfflineRoute(_ url: URL) -> Bool {
195
+ guard isHTTP(url) else { return false }
196
+ let path = url.path.isEmpty ? "/" : url.path
197
+ let currentManifest = queue.sync { manifest }
198
+ guard let currentManifest else { return false }
199
+
200
+ return currentManifest.routes.contains { route in
201
+ guard let regex = try? NSRegularExpression(pattern: route.regex, options: [.caseInsensitive]) else {
202
+ return false
203
+ }
204
+ let range = NSRange(path.startIndex..., in: path)
205
+ return regex.firstMatch(in: path, options: [], range: range) != nil
206
+ }
207
+ }
208
+
209
+ func shouldCacheAssetURL(_ url: URL) -> Bool {
210
+ guard isHTTP(url) else { return false }
211
+ let path = url.path.lowercased()
212
+ return Self.assetExtensions.contains { path.hasSuffix($0) }
213
+ }
214
+
215
+ func prepareActiveOfflineRoute(for request: URLRequest, webView: WKWebView?) async -> Bool {
216
+ guard let url = request.url, isHTTP(url) else {
217
+ clearActiveOfflineRoute()
218
+ return false
219
+ }
220
+
221
+ if queue.sync(execute: { manifest == nil }) {
222
+ _ = await refreshManifestAsync(for: url, request: request, webView: webView)
223
+ }
224
+
225
+ let isActive = isOfflineRoute(url)
226
+ setActiveOfflineRouteOrigin(isActive ? origin(for: url) : nil)
227
+ return isActive
228
+ }
229
+
230
+ func clearActiveOfflineRoute() {
231
+ setActiveOfflineRouteOrigin(nil)
232
+ }
233
+
234
+ func shouldCacheOfflineRouteSubresource(_ request: URLRequest) -> Bool {
235
+ guard let url = request.url,
236
+ isHTTP(url),
237
+ (request.httpMethod?.uppercased() ?? "GET") == "GET",
238
+ !isInternalOfflineRuntimeURL(url),
239
+ queue.sync(execute: { activeOfflineRouteOrigin }) == origin(for: url) else {
240
+ return false
241
+ }
242
+
243
+ let destination = request.value(forHTTPHeaderField: "Sec-Fetch-Dest")?.lowercased()
244
+ if destination == "document" || destination == "empty" {
245
+ return false
246
+ }
247
+ if let destination,
248
+ ["script", "style", "image", "font", "audio", "video", "track", "manifest"].contains(destination) {
249
+ return true
250
+ }
251
+
252
+ let accept = request.value(forHTTPHeaderField: "Accept")?.lowercased() ?? ""
253
+ if accept.contains("text/html") ||
254
+ accept.contains("application/json") ||
255
+ accept.contains("text/event-stream") {
256
+ return false
257
+ }
258
+ if accept.contains("text/css") ||
259
+ accept.contains("javascript") ||
260
+ accept.contains("image/") ||
261
+ accept.contains("font/") ||
262
+ accept.contains("application/font") ||
263
+ accept.contains("application/wasm") {
264
+ return true
265
+ }
266
+
267
+ return shouldCacheAssetURL(url)
268
+ }
269
+
270
+ func clearAll() {
271
+ queue.async(flags: .barrier) {
272
+ self.manifest = nil
273
+ self.ongoingSnapshots.removeAll()
274
+ self.activeOfflineRouteOrigin = nil
275
+ try? FileManager.default.removeItem(at: self.cacheDirectory)
276
+ try? FileManager.default.createDirectory(at: self.routeDirectory, withIntermediateDirectories: true)
277
+ offlineLogger.info("Offline route snapshots cleared")
278
+ }
279
+ }
280
+
281
+ private func refreshManifestAsync(for url: URL, request: URLRequest?, webView: WKWebView?) async -> OfflineManifest? {
282
+ do {
283
+ let manifestRemoteURL = manifestURLFor(url)
284
+ var manifestRequest = URLRequest(url: manifestRemoteURL)
285
+ manifestRequest.httpMethod = "GET"
286
+ manifestRequest.timeoutInterval = 10
287
+
288
+ for (key, value) in request?.allHTTPHeaderFields ?? [:] {
289
+ if shouldForwardHeader(key) {
290
+ manifestRequest.setValue(value, forHTTPHeaderField: key)
291
+ }
292
+ }
293
+
294
+ if let cookieHeader = await cookieHeader(for: manifestRemoteURL, webView: webView), !cookieHeader.isEmpty {
295
+ manifestRequest.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
296
+ }
297
+
298
+ let (data, response) = try await URLSession.shared.data(for: manifestRequest)
299
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
300
+ return queue.sync { manifest }
301
+ }
302
+
303
+ let parsed = try parseManifest(data)
304
+ try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
305
+ try data.write(to: manifestURL)
306
+ queue.sync(flags: .barrier) {
307
+ self.manifest = parsed
308
+ }
309
+ offlineLogger.info("Refreshed offline manifest: url=\(manifestRemoteURL.absoluteString), buildId=\(parsed.buildId), routes=\(parsed.routes.count)")
310
+ return parsed
311
+ } catch {
312
+ offlineLogger.info("Unable to refresh offline manifest: \(error.localizedDescription)")
313
+ return queue.sync { manifest }
314
+ }
315
+ }
316
+
317
+ private func loadCachedManifest() -> OfflineManifest? {
318
+ guard let data = try? Data(contentsOf: manifestURL) else { return nil }
319
+ return try? parseManifest(data)
320
+ }
321
+
322
+ private func parseManifest(_ data: Data) throws -> OfflineManifest {
323
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
324
+ let buildId = json["buildId"] as? String ?? "unknown"
325
+
326
+ let routes = (json["routes"] as? [[String: Any]] ?? []).compactMap { route -> OfflineRoute? in
327
+ guard let pattern = route["pattern"] as? String,
328
+ let regex = route["regex"] as? String,
329
+ !pattern.isEmpty,
330
+ !regex.isEmpty else { return nil }
331
+ return OfflineRoute(pattern: pattern, regex: regex)
332
+ }
333
+
334
+ return OfflineManifest(buildId: buildId, routes: routes)
335
+ }
336
+
337
+ private func manifestURLFor(_ url: URL) -> URL {
338
+ var components = URLComponents()
339
+ components.scheme = url.scheme
340
+ components.host = url.host
341
+ components.port = url.port
342
+ components.path = "/catalyst-offline-manifest.json"
343
+ return components.url!
344
+ }
345
+
346
+ private func snapshotFileURL(for url: URL) -> URL {
347
+ let currentManifest = queue.sync { manifest }
348
+ let namespace = "\(origin(for: url)):\(currentManifest?.buildId ?? "unknown")"
349
+ return routeDirectory
350
+ .appendingPathComponent(sha256(namespace), isDirectory: true)
351
+ .appendingPathComponent("\(sha256(url.absoluteString)).html")
352
+ }
353
+
354
+ private func normalizeSnapshotURL(_ url: URL) -> URL {
355
+ guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
356
+ components.fragment = nil
357
+ return components.url ?? url
358
+ }
359
+
360
+ private func origin(for url: URL) -> String {
361
+ var components = URLComponents()
362
+ components.scheme = url.scheme
363
+ components.host = url.host
364
+ components.port = url.port
365
+ return components.string ?? url.host ?? "unknown"
366
+ }
367
+
368
+ private func isHTTP(_ url: URL) -> Bool {
369
+ let scheme = url.scheme?.lowercased()
370
+ return scheme == "http" || scheme == "https"
371
+ }
372
+
373
+ private func isInternalOfflineRuntimeURL(_ url: URL) -> Bool {
374
+ return url.path == "/catalyst-offline-manifest.json" ||
375
+ url.path == "/catalyst-sw.js" ||
376
+ url.path == "/offline.html"
377
+ }
378
+
379
+ private func setActiveOfflineRouteOrigin(_ origin: String?) {
380
+ queue.sync(flags: .barrier) {
381
+ self.activeOfflineRouteOrigin = origin
382
+ }
383
+ }
384
+
385
+ private func cacheSnapshotAssets(from data: Data, baseURL: URL, originalRequest: URLRequest, webView: WKWebView?) async {
386
+ guard let html = String(data: data, encoding: .utf8) else { return }
387
+
388
+ var assetURLs = extractAssetURLs(from: html, baseURL: baseURL)
389
+ guard !assetURLs.isEmpty else {
390
+ offlineLogger.info("Snapshot asset warmup skipped; no cacheable assets found: \(baseURL.absoluteString)")
391
+ return
392
+ }
393
+
394
+ offlineLogger.info("Snapshot asset warmup started: page=\(baseURL.absoluteString), assets=\(assetURLs.count)")
395
+
396
+ var cachedCount = 0
397
+ var index = 0
398
+ var seen = Set(assetURLs.map(\.absoluteString))
399
+ while index < assetURLs.count {
400
+ let assetURL = assetURLs[index]
401
+ index += 1
402
+ do {
403
+ var assetRequest = URLRequest(url: assetURL)
404
+ assetRequest.httpMethod = "GET"
405
+ assetRequest.timeoutInterval = 15
406
+ assetRequest.setValue("1", forHTTPHeaderField: "X-Catalyst-Offline-Snapshot-Fetch")
407
+ for (key, value) in originalRequest.allHTTPHeaderFields ?? [:] {
408
+ if shouldForwardHeader(key) {
409
+ assetRequest.setValue(value, forHTTPHeaderField: key)
410
+ }
411
+ }
412
+
413
+ if let cookieHeader = await cookieHeader(for: assetURL, webView: webView), !cookieHeader.isEmpty {
414
+ assetRequest.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
415
+ }
416
+
417
+ let configuration = URLSessionConfiguration.ephemeral
418
+ configuration.protocolClasses = []
419
+ let session = URLSession(configuration: configuration)
420
+ let (assetData, response) = try await session.data(for: assetRequest)
421
+ guard let httpResponse = response as? HTTPURLResponse,
422
+ 200...299 ~= httpResponse.statusCode,
423
+ !assetData.isEmpty,
424
+ !isDocumentOrDataResponse(httpResponse) else {
425
+ offlineLogger.info("Snapshot asset not cached: \(assetURL.absoluteString)")
426
+ continue
427
+ }
428
+
429
+ CacheManager.shared.storeCachedResponse(httpResponse, data: assetData, for: assetRequest)
430
+ if httpResponse.mimeType?.localizedCaseInsensitiveContains("text/css") == true,
431
+ let css = String(data: assetData, encoding: .utf8) {
432
+ for nestedURL in extractCSSAssetURLs(from: css, stylesheetURL: assetURL, pageBaseURL: baseURL) {
433
+ if seen.insert(nestedURL.absoluteString).inserted {
434
+ assetURLs.append(nestedURL)
435
+ }
436
+ }
437
+ }
438
+ cachedCount += 1
439
+ } catch {
440
+ offlineLogger.info("Unable to warm snapshot asset \(assetURL.absoluteString): \(error.localizedDescription)")
441
+ }
442
+ }
443
+
444
+ offlineLogger.info("Snapshot asset warmup finished: page=\(baseURL.absoluteString), cached=\(cachedCount), total=\(assetURLs.count)")
445
+ }
446
+
447
+ private func extractAssetURLs(from html: String, baseURL: URL) -> [URL] {
448
+ let tagPattern = #"<\s*(script|link|img|source|video|audio|track|iframe)\b[^>]*>"#
449
+ guard let tagRegex = try? NSRegularExpression(pattern: tagPattern, options: [.caseInsensitive]) else {
450
+ return []
451
+ }
452
+
453
+ var urls: [URL] = []
454
+ var seen = Set<String>()
455
+ let range = NSRange(html.startIndex..., in: html)
456
+
457
+ for match in tagRegex.matches(in: html, options: [], range: range) {
458
+ guard let tagRange = Range(match.range(at: 0), in: html),
459
+ let nameRange = Range(match.range(at: 1), in: html) else { continue }
460
+ let tag = String(html[tagRange])
461
+ let tagName = String(html[nameRange]).lowercased()
462
+
463
+ for rawValue in assetValues(in: tag, tagName: tagName) {
464
+ let decodedValue = htmlDecodeAttribute(rawValue)
465
+ guard let assetURL = URL(string: decodedValue, relativeTo: baseURL)?.absoluteURL,
466
+ isCacheableSnapshotAsset(assetURL),
467
+ seen.insert(assetURL.absoluteString).inserted else {
468
+ continue
469
+ }
470
+ urls.append(assetURL)
471
+ }
472
+ }
473
+
474
+ return urls
475
+ }
476
+
477
+ private func extractCSSAssetURLs(from css: String, stylesheetURL: URL, pageBaseURL: URL) -> [URL] {
478
+ let pattern = #"url\(\s*(['"]?)([^)'"]+)\1\s*\)"#
479
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
480
+ return []
481
+ }
482
+
483
+ var urls: [URL] = []
484
+ let range = NSRange(css.startIndex..., in: css)
485
+ for match in regex.matches(in: css, options: [], range: range) {
486
+ guard let valueRange = Range(match.range(at: 2), in: css) else { continue }
487
+ let rawValue = String(css[valueRange])
488
+ if rawValue.hasPrefix("data:") || rawValue.hasPrefix("#") { continue }
489
+ guard let assetURL = URL(string: rawValue, relativeTo: stylesheetURL)?.absoluteURL,
490
+ isCacheableSnapshotAsset(assetURL) else {
491
+ continue
492
+ }
493
+ urls.append(assetURL)
494
+ }
495
+ return urls
496
+ }
497
+
498
+ private func assetValues(in tag: String, tagName: String) -> [String] {
499
+ var attributes: [String: String] = [:]
500
+ let attributePattern = #"([a-zA-Z][a-zA-Z0-9:-]*)\s*=\s*["']([^"']+)["']"#
501
+ if let regex = try? NSRegularExpression(pattern: attributePattern, options: [.caseInsensitive]) {
502
+ let range = NSRange(tag.startIndex..., in: tag)
503
+ for match in regex.matches(in: tag, options: [], range: range) {
504
+ guard let nameRange = Range(match.range(at: 1), in: tag),
505
+ let valueRange = Range(match.range(at: 2), in: tag) else { continue }
506
+ attributes[String(tag[nameRange]).lowercased()] = String(tag[valueRange])
507
+ }
508
+ }
509
+
510
+ switch tagName {
511
+ case "script", "img", "source", "video", "audio", "track", "iframe":
512
+ return attributes["src"].map { [$0] } ?? []
513
+ case "link":
514
+ guard let href = attributes["href"] else { return [] }
515
+ let rel = attributes["rel"]?.lowercased() ?? ""
516
+ let asValue = attributes["as"]?.lowercased() ?? ""
517
+ let isAssetLink = rel.contains("stylesheet") ||
518
+ rel.contains("preload") ||
519
+ rel.contains("modulepreload") ||
520
+ rel.contains("icon") ||
521
+ ["script", "style", "font", "image"].contains(asValue)
522
+ return isAssetLink ? [href] : []
523
+ default:
524
+ return []
525
+ }
526
+ }
527
+
528
+ private func isCacheableSnapshotAsset(_ url: URL) -> Bool {
529
+ guard isHTTP(url),
530
+ !isInternalOfflineRuntimeURL(url) else {
531
+ return false
532
+ }
533
+
534
+ return true
535
+ }
536
+
537
+ private func isDocumentOrDataResponse(_ response: HTTPURLResponse) -> Bool {
538
+ let mimeType = response.mimeType?.lowercased() ?? ""
539
+ return mimeType == "text/html" ||
540
+ mimeType == "application/json" ||
541
+ mimeType.hasSuffix("+json") ||
542
+ mimeType == "text/event-stream"
543
+ }
544
+
545
+ func rewriteSameOriginReferencesForOffline(_ data: Data, originalURL: URL) -> Data? {
546
+ guard var text = String(data: data, encoding: .utf8) else {
547
+ return nil
548
+ }
549
+
550
+ for rawValue in absoluteHTTPReferences(in: text) {
551
+ let decodedValue = htmlDecodeAttribute(rawValue)
552
+ guard let assetURL = URL(string: decodedValue),
553
+ shouldRewriteOfflineReference(assetURL),
554
+ let offlineURL = offlineURL(for: assetURL) else {
555
+ continue
556
+ }
557
+
558
+ let replacement = rawValue.contains("&amp;")
559
+ ? htmlEncodeAttribute(offlineURL.absoluteString)
560
+ : offlineURL.absoluteString
561
+ text = text.replacingOccurrences(of: rawValue, with: replacement)
562
+
563
+ let escapedRawValue = escapeForwardSlashes(rawValue)
564
+ let escapedReplacement = escapeForwardSlashes(replacement)
565
+ text = text.replacingOccurrences(of: escapedRawValue, with: escapedReplacement)
566
+ }
567
+ return text.data(using: .utf8)
568
+ }
569
+
570
+ private func absoluteHTTPReferences(in text: String) -> [String] {
571
+ let pattern = #"https?://[^"'\\\s<>)]+"#
572
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
573
+ return []
574
+ }
575
+
576
+ var values: [String] = []
577
+ var seen = Set<String>()
578
+ let range = NSRange(text.startIndex..., in: text)
579
+ for match in regex.matches(in: text, options: [], range: range) {
580
+ guard let valueRange = Range(match.range(at: 0), in: text) else { continue }
581
+ var value = String(text[valueRange])
582
+ while let last = value.last, [".", ",", ";", ":"].contains(last) {
583
+ value.removeLast()
584
+ }
585
+ if seen.insert(value).inserted {
586
+ values.append(value)
587
+ }
588
+ }
589
+ return values
590
+ }
591
+
592
+ private func shouldRewriteOfflineReference(_ url: URL) -> Bool {
593
+ guard isCacheableSnapshotAsset(url) else { return false }
594
+ let host = url.host?.lowercased() ?? ""
595
+ return shouldCacheAssetURL(url) ||
596
+ host == "fonts.googleapis.com" ||
597
+ host == "fonts.gstatic.com"
598
+ }
599
+
600
+ private func htmlDecodeAttribute(_ value: String) -> String {
601
+ value
602
+ .replacingOccurrences(of: "&amp;", with: "&")
603
+ .replacingOccurrences(of: "&#38;", with: "&")
604
+ }
605
+
606
+ private func htmlEncodeAttribute(_ value: String) -> String {
607
+ value.replacingOccurrences(of: "&", with: "&amp;")
608
+ }
609
+
610
+ func mimeType(for url: URL) -> String {
611
+ let path = url.path.lowercased()
612
+ if path.hasSuffix(".js") || path.hasSuffix(".mjs") { return "application/javascript" }
613
+ if path.hasSuffix(".css") { return "text/css" }
614
+ if path.hasSuffix(".png") { return "image/png" }
615
+ if path.hasSuffix(".jpg") || path.hasSuffix(".jpeg") { return "image/jpeg" }
616
+ if path.hasSuffix(".gif") { return "image/gif" }
617
+ if path.hasSuffix(".svg") { return "image/svg+xml" }
618
+ if path.hasSuffix(".webp") { return "image/webp" }
619
+ if path.hasSuffix(".ico") { return "image/x-icon" }
620
+ if path.hasSuffix(".woff") { return "font/woff" }
621
+ if path.hasSuffix(".woff2") { return "font/woff2" }
622
+ if path.hasSuffix(".ttf") { return "font/ttf" }
623
+ if path.hasSuffix(".eot") { return "application/vnd.ms-fontobject" }
624
+ return "application/octet-stream"
625
+ }
626
+
627
+ private func escapeForwardSlashes(_ value: String) -> String {
628
+ value.replacingOccurrences(of: "/", with: "\\/")
629
+ }
630
+
631
+ private func shouldForwardHeader(_ header: String) -> Bool {
632
+ return header.caseInsensitiveCompare("Cookie") != .orderedSame &&
633
+ header.caseInsensitiveCompare("Host") != .orderedSame &&
634
+ header.caseInsensitiveCompare("Cache-Control") != .orderedSame &&
635
+ header.caseInsensitiveCompare("Pragma") != .orderedSame
636
+ }
637
+
638
+ private func isFreshSnapshot(_ url: URL) -> Bool {
639
+ guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
640
+ let modifiedAt = attributes[.modificationDate] as? Date else {
641
+ return false
642
+ }
643
+ return Date().timeIntervalSince(modifiedAt) < Self.snapshotMaxAge
644
+ }
645
+
646
+ private func markSnapshotStarted(_ key: String) -> Bool {
647
+ var inserted = false
648
+ queue.sync(flags: .barrier) {
649
+ inserted = ongoingSnapshots.insert(key).inserted
650
+ }
651
+ return inserted
652
+ }
653
+
654
+ private func markSnapshotFinished(_ key: String) {
655
+ queue.async(flags: .barrier) {
656
+ self.ongoingSnapshots.remove(key)
657
+ }
658
+ }
659
+
660
+ private func cookieHeader(for url: URL, webView: WKWebView?) async -> String? {
661
+ guard let webView else { return nil }
662
+
663
+ let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies()
664
+ let matchingCookies = cookies.filter { cookie in
665
+ guard let host = url.host else { return false }
666
+ let domain = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain
667
+ return host == domain || host.hasSuffix(".\(domain)")
668
+ }
669
+ return HTTPCookie.requestHeaderFields(with: matchingCookies)["Cookie"]
670
+ }
671
+
672
+ private func sha256(_ value: String) -> String {
673
+ let data = Data(value.utf8)
674
+ #if canImport(CryptoKit)
675
+ let digest = SHA256.hash(data: data)
676
+ return digest.map { String(format: "%02x", $0) }.joined()
677
+ #else
678
+ return String(value.hashValue)
679
+ #endif
680
+ }
681
+ }
682
+
683
+ extension OfflineCacheService: @unchecked Sendable {}