catalyst-core 0.2.0-beta.1 → 0.2.0-beta.2
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 +25 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +239 -75
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +3 -2
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +1 -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 +136 -26
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/CustomWebviewTest.kt +7 -2
- package/dist/native/buildAndroid/assets.js +2 -2
- package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +39 -4
- 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/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/WebView.swift +15 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +82 -2
- 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,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("&")
|
|
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: "&", with: "&")
|
|
603
|
+
.replacingOccurrences(of: "&", with: "&")
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private func htmlEncodeAttribute(_ value: String) -> String {
|
|
607
|
+
value.replacingOccurrences(of: "&", with: "&")
|
|
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 {}
|