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.
Files changed (29) hide show
  1. package/dist/native/androidProject/app/build.gradle.kts +25 -8
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +239 -75
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +3 -2
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +1 -1
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/OfflineCacheService.kt +374 -0
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +136 -26
  7. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/CustomWebviewTest.kt +7 -2
  8. package/dist/native/buildAndroid/assets.js +2 -2
  9. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +39 -4
  10. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineCacheService.swift +683 -0
  11. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineURLSchemeHandler.swift +94 -0
  12. package/dist/native/iosnativeWebView/Sources/Core/Utils/ResourceURLProtocol.swift +19 -6
  13. package/dist/native/iosnativeWebView/Sources/Core/Utils/SafeAreaUtils.swift +1 -0
  14. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +1 -0
  15. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +15 -1
  16. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +82 -2
  17. package/dist/native/renameAndroidProject.js +2 -2
  18. package/dist/offline/catalyst-sw.js +1 -0
  19. package/dist/offline/registerServiceWorker.js +1 -0
  20. package/dist/router/ClientRouter.js +1 -1
  21. package/dist/scripts/build.js +1 -1
  22. package/dist/scripts/devBuild.js +1 -1
  23. package/dist/scripts/generateOfflineManifest.js +1 -0
  24. package/dist/server/expressServer.js +2 -1
  25. package/dist/server/renderer/document/Body.js +1 -1
  26. package/dist/server/renderer/handler.js +3 -3
  27. package/dist/webpack/base.babel.js +1 -1
  28. package/mcp_v2/knowledge-base.json +13 -0
  29. package/package.json +1 -1
@@ -85,7 +85,7 @@ class CustomWebviewTest {
85
85
  setProperty("accessControl.allowedUrls", "https://example.com,https://api.example.com")
86
86
  }
87
87
 
88
- val enabled = properties.getProperty("accessControl.enabled", "true").equals("true", ignoreCase = true)
88
+ val enabled = properties.getProperty("accessControl.enabled", "false").equals("true", ignoreCase = true)
89
89
  assertTrue("Access control should be enabled", enabled)
90
90
 
91
91
  val allowedUrls = properties.getProperty("accessControl.allowedUrls", "")
@@ -101,8 +101,13 @@ class CustomWebviewTest {
101
101
  val disabledProps = Properties().apply {
102
102
  setProperty("accessControl.enabled", "false")
103
103
  }
104
- val disabled = disabledProps.getProperty("accessControl.enabled", "true").equals("true", ignoreCase = true)
104
+ val disabled = disabledProps.getProperty("accessControl.enabled", "false").equals("true", ignoreCase = true)
105
105
  assertFalse("Access control should be disabled", disabled)
106
+
107
+ // Test missing config defaults to disabled
108
+ val missingProps = Properties()
109
+ val missing = missingProps.getProperty("accessControl.enabled", "false").equals("true", ignoreCase = true)
110
+ assertFalse("Missing access control config should default to disabled", missing)
106
111
  }
107
112
 
108
113
  @Test
@@ -7,7 +7,7 @@ const source=findIconForDensity(density.key);cleanDensityDir(targetDir);if(sourc
7
7
  fs.copyFileSync(source.path,destination);hasCustomIcons=true;}}if(!hasCustomIcons&&fallbackExists){const targetDir=path.join(destPath,"mipmap-xxxhdpi");// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - destPath is derived from pwd (internal); "mipmap-xxxhdpi" is a fixed literal.
8
8
  fs.mkdirSync(targetDir,{recursive:true});cleanDensityDir(targetDir);const destination=path.join(targetDir,"icon.png");// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - targetDir is derived from pwd (internal); "icon.png" is a fixed literal.
9
9
  fs.copyFileSync(fallbackIconPath,destination);usedFallback=true;}const setManifestIcons=(iconValue,roundIconValue)=>{let current;try{current=fs.readFileSync(manifestPath,"utf8");}catch(e){if(e.code==="ENOENT")return;throw e;}const updated=current.replace(/android:icon="[^"]*"/g,`android:icon="${iconValue}"`).replace(/android:roundIcon="[^"]*"/g,`android:roundIcon="${roundIconValue}"`);if(updated!==current)fs.writeFileSync(manifestPath,updated);};if(!hasCustomIcons&&!usedFallback){progress.log("No custom Android icons found; using default template icons.","info");setManifestIcons("@mipmap/ic_launcher","@mipmap/ic_launcher_round");return;}setManifestIcons("@mipmap/icon","@mipmap/icon");if(hasCustomIcons){progress.log("Applied Android launcher icons from public/android/appIcons.","success");}if(usedFallback){progress.log("Used bundled Catalyst fallback icon for launcher.","info");}}catch(error){progress.log(`Warning: Error copying app icon assets: ${error.message}`,"warning");}}async function configureAppName(androidConfig){try{const destPath=`${pwd}/androidProject/app/src/main/res`;const stringsFile=`${destPath}/values/strings.xml`;let stringsContent=fs.readFileSync(stringsFile,"utf8");if(androidConfig.appName){stringsContent=stringsContent.replace(/<string name="app_name">.*?<\/string>/,`<string name="app_name">${androidConfig.appName}</string>`);fs.writeFileSync(stringsFile,stringsContent);progress.log(`App display name configured: ${androidConfig.appName}`,"success");}else{stringsContent=stringsContent.replace(/<string name="app_name">.*?<\/string>/,`<string name="app_name">Catalyst Application</string>`);fs.writeFileSync(stringsFile,stringsContent);progress.log("App display name reverted to default: Catalyst Application","info");}}catch(error){progress.log(`Warning: Error configuring app name: ${error.message}`,"warning");}}// ── Notification helpers ──────────────────────────────────────────────────
10
- async function handleGoogleServicesJson(){try{const rootGoogleServicesPath=`${process.cwd()}/google-services.json`;const androidGoogleServicesPath=`${pwd}/androidProject/app/google-services.json`;if(fs.existsSync(rootGoogleServicesPath)){progress.log("Found google-services.json in root directory","info");const appDir=`${pwd}/androidProject/app`;if(!fs.existsSync(appDir)){fs.mkdirSync(appDir,{recursive:true});}fs.copyFileSync(rootGoogleServicesPath,androidGoogleServicesPath);progress.log("Copied google-services.json to androidProject/app/","success");return true;}else if(fs.existsSync(androidGoogleServicesPath)){progress.log("google-services.json already exists in androidProject/app/","info");return true;}else{progress.log("google-services.json not found - Firebase push notifications will not work","warning");progress.log("Place google-services.json in project root or src/native/androidProject/app/","info");return false;}}catch(error){progress.log(`Warning: Error handling google-services.json: ${error.message}`,"warning");return false;}}async function processNotifications(WEBVIEW_CONFIG){const hasNotificationConfig=!!WEBVIEW_CONFIG.notifications?.enabled;try{await cleanupNotificationPermissions();await cleanupNotificationResources();await cleanupNotificationMetadata();await cleanupNotificationAssets();if(!hasNotificationConfig){progress.log("Notifications disabled - cleaned up notification configurations","info");return;}const hasGoogleServices=await handleGoogleServicesJson();if(!hasGoogleServices){progress.log("Continuing without Firebase - only local notifications will work","warning");}await addNotificationPermissions();await generateNotificationResources(WEBVIEW_CONFIG.notifications);await addNotificationMetadata();await processNotificationAssets();progress.log("Notification configuration completed successfully!","success");}catch(error){progress.log(`Warning: Error processing notifications: ${error.message}`,"warning");}}async function addNotificationPermissions(){try{const manifestPath=`${pwd}/androidProject/app/src/main/AndroidManifest.xml`;let manifestContent=fs.readFileSync(manifestPath,"utf8");const permissionsXml=` <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
10
+ async function handleGoogleServicesJson(){try{const rootGoogleServicesPath=`${process.cwd()}/google-services.json`;const androidGoogleServicesPath=`${pwd}/androidProject/app/google-services.json`;if(fs.existsSync(rootGoogleServicesPath)){progress.log("Found google-services.json in root directory","info");const appDir=`${pwd}/androidProject/app`;if(!fs.existsSync(appDir)){fs.mkdirSync(appDir,{recursive:true});}fs.copyFileSync(rootGoogleServicesPath,androidGoogleServicesPath);progress.log("Copied google-services.json to androidProject/app/","success");return true;}else if(fs.existsSync(androidGoogleServicesPath)){progress.log("google-services.json already exists in androidProject/app/","info");return true;}else{progress.log("google-services.json not found - Firebase push notifications will not work","warning");progress.log("Place google-services.json in project root or src/native/androidProject/app/","info");return false;}}catch(error){progress.log(`Warning: Error handling google-services.json: ${error.message}`,"warning");return false;}}async function processNotifications(WEBVIEW_CONFIG){const hasNotificationConfig=!!WEBVIEW_CONFIG.notifications?.enabled;try{await writeGeneratedBuildProperties(hasNotificationConfig);await cleanupNotificationPermissions();await cleanupNotificationResources();await cleanupNotificationMetadata();await cleanupNotificationAssets();if(!hasNotificationConfig){progress.log("Notifications disabled - cleaned up notification configurations","info");return;}const hasGoogleServices=await handleGoogleServicesJson();if(!hasGoogleServices){progress.log("Continuing without Firebase - only local notifications will work","warning");}await addNotificationPermissions();await generateNotificationResources(WEBVIEW_CONFIG.notifications);await addNotificationMetadata();await processNotificationAssets();progress.log("Notification configuration completed successfully!","success");}catch(error){progress.log(`Warning: Error processing notifications: ${error.message}`,"warning");}}async function writeGeneratedBuildProperties(notificationsEnabled){try{const propertiesPath=`${pwd}/androidProject/app/catalyst-build.properties`;const propertiesContent=["# Generated by Catalyst. Used by Android Studio builds when -PconfigPath is not provided.",`notifications.enabled=${notificationsEnabled?"true":"false"}`,""].join("\n");fs.writeFileSync(propertiesPath,propertiesContent);progress.log("Updated generated Android build properties","success");}catch(error){throw new Error(`Failed to write generated Android build properties: ${error.message}`);}}async function addNotificationPermissions(){try{const manifestPath=`${pwd}/androidProject/app/src/main/AndroidManifest.xml`;let manifestContent=fs.readFileSync(manifestPath,"utf8");const permissionsXml=` <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
11
11
  <uses-permission android:name="android.permission.VIBRATE" />
12
12
  <uses-permission android:name="android.permission.WAKE_LOCK" />
13
13
  <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />`;manifestContent=manifestContent.replace(/(<uses-permission[^>]*>[\s\S]*?)(\s*<uses-feature)/,`$1\n${permissionsXml}$2`);fs.writeFileSync(manifestPath,manifestContent);progress.log("Added notification permissions to AndroidManifest.xml","success");}catch(error){throw new Error(`Failed to add notification permissions: ${error.message}`);}}async function generateNotificationResources(notificationConfig){try{const colorsPath=`${pwd}/androidProject/app/src/main/res/values/colors.xml`;let colorsContent=fs.readFileSync(colorsPath,"utf8");const notificationColorXml=` <color name="notification_color">${notificationConfig.color||"#007AFF"}</color>`;if(!colorsContent.includes('name="notification_color"')){colorsContent=colorsContent.replace(/(<\/resources>)/,`${notificationColorXml}\n$1`);fs.writeFileSync(colorsPath,colorsContent);progress.log("Added notification color to colors.xml","success");}}catch(error){throw new Error(`Failed to generate notification resources: ${error.message}`);}}async function addNotificationMetadata(){try{const manifestPath=`${pwd}/androidProject/app/src/main/AndroidManifest.xml`;let manifestContent=fs.readFileSync(manifestPath,"utf8");const metadataXml=`
@@ -49,4 +49,4 @@ async function handleGoogleServicesJson(){try{const rootGoogleServicesPath=`${pr
49
49
  <path
50
50
  android:fillColor="@android:color/white"
51
51
  android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
52
- </vector>`;fs.writeFileSync(`${destPath}/drawable/${iconName}.xml`,iconXml);}async function cleanupNotificationPermissions(){try{const manifestPath=`${pwd}/androidProject/app/src/main/AndroidManifest.xml`;let manifestContent=fs.readFileSync(manifestPath,"utf8");const notificationPermissions=["android.permission.POST_NOTIFICATIONS","android.permission.VIBRATE","android.permission.WAKE_LOCK","android.permission.RECEIVE_BOOT_COMPLETED"];manifestContent=manifestContent.split("\n").filter(line=>{return!notificationPermissions.some(permission=>line.includes(`<uses-permission android:name="${permission}"`));}).join("\n");fs.writeFileSync(manifestPath,manifestContent);}catch(error){progress.log(`Warning: Error cleaning notification permissions: ${error.message}`,"warning");}}async function cleanupNotificationResources(){try{const colorsPath=`${pwd}/androidProject/app/src/main/res/values/colors.xml`;let colorsContent=fs.readFileSync(colorsPath,"utf8");const existingColorLine=colorsContent.split("\n").find(line=>line.includes('name="notification_color"'));if(existingColorLine){colorsContent=colorsContent.replace(existingColorLine,"");fs.writeFileSync(colorsPath,colorsContent);}}catch(error){progress.log(`Warning: Error cleaning notification resources: ${error.message}`,"warning");}}async function cleanupNotificationMetadata(){try{const manifestPath=`${pwd}/androidProject/app/src/main/AndroidManifest.xml`;let manifestContent=fs.readFileSync(manifestPath,"utf8");const metadataNames=["default_notification_channel_id","default_notification_icon","default_notification_color","com.google.firebase.messaging.default_notification_channel_id","com.google.firebase.messaging.default_notification_icon","com.google.firebase.messaging.default_notification_sound"];manifestContent=manifestContent.split("\n").filter(line=>{return!metadataNames.some(metadataName=>line.includes(`android:name="${metadataName}"`));}).join("\n");const serviceRegex=/\s*<!--\s*Push Notification Service\s*-->\s*<service[^>]*android:name="[^"]*PushNotificationUtils"[\s\S]*?<\/service>/gi;manifestContent=manifestContent.replace(serviceRegex,"");manifestContent=manifestContent.replace(/\s*<!--\s*Default notification configuration\s*-->/gi,"");manifestContent=manifestContent.replace(/\s*<!--\s*Firebase default notification configuration\s*-->/gi,"");manifestContent=manifestContent.replace(/\s*<!--\s*Push Notification Service\s*-->/gi,"");fs.writeFileSync(manifestPath,manifestContent);}catch(error){progress.log(`Warning: Error cleaning notification metadata: ${error.message}`,"warning");}}async function cleanupNotificationAssets(){try{const destPath=`${pwd}/androidProject/app/src/main/res`;const imageFormats=["png","jpg","jpeg","gif","bmp","svg","webp","xml"];const audioFormats=["mp3","wav","ogg"];const notificationIcons=["ic_notification","ic_notification_large"];const notificationSounds=["notification_sound_default","notification_sound_urgent"];for(const icon of notificationIcons){for(const format of imageFormats){const iconPath=`${destPath}/drawable/${icon}.${format}`;if(fs.existsSync(iconPath)){fs.unlinkSync(iconPath);}}}const rawDir=`${destPath}/raw`;if(fs.existsSync(rawDir)){for(const sound of notificationSounds){for(const format of audioFormats){const soundPath=`${rawDir}/${sound}.${format}`;if(fs.existsSync(soundPath)){fs.unlinkSync(soundPath);}}}}}catch(error){progress.log(`Warning: Error cleaning notification assets: ${error.message}`,"warning");}}return{copyBuildAssets,copySplashscreenAssets,copyOfflinePage,copyIconAssets,configureAppName,processNotifications};}module.exports=createAssetsPhase;
52
+ </vector>`;fs.writeFileSync(`${destPath}/drawable/${iconName}.xml`,iconXml);}async function cleanupNotificationPermissions(){try{const manifestPath=`${pwd}/androidProject/app/src/main/AndroidManifest.xml`;let manifestContent=fs.readFileSync(manifestPath,"utf8");const notificationPermissions=["android.permission.POST_NOTIFICATIONS","android.permission.VIBRATE","android.permission.WAKE_LOCK","android.permission.RECEIVE_BOOT_COMPLETED"];manifestContent=manifestContent.split("\n").filter(line=>{return!notificationPermissions.some(permission=>line.includes(`<uses-permission android:name="${permission}"`));}).join("\n");fs.writeFileSync(manifestPath,manifestContent);}catch(error){progress.log(`Warning: Error cleaning notification permissions: ${error.message}`,"warning");}}async function cleanupNotificationResources(){try{const colorsPath=`${pwd}/androidProject/app/src/main/res/values/colors.xml`;let colorsContent=fs.readFileSync(colorsPath,"utf8");const existingColorLine=colorsContent.split("\n").find(line=>line.includes('name="notification_color"'));if(existingColorLine){colorsContent=colorsContent.replace(existingColorLine,"");fs.writeFileSync(colorsPath,colorsContent);}}catch(error){progress.log(`Warning: Error cleaning notification resources: ${error.message}`,"warning");}}async function cleanupNotificationMetadata(){try{const manifestPath=`${pwd}/androidProject/app/src/main/AndroidManifest.xml`;let manifestContent=fs.readFileSync(manifestPath,"utf8");const metadataNames=["default_notification_channel_id","default_notification_icon","default_notification_color","com.google.firebase.messaging.default_notification_channel_id","com.google.firebase.messaging.default_notification_icon","com.google.firebase.messaging.default_notification_sound"];for(const metadataName of metadataNames){const metadataRegex=new RegExp(`\\s*<meta-data\\s+[\\s\\S]*?android:name="${metadataName}"[\\s\\S]*?\\/>`,"gi");manifestContent=manifestContent.replace(metadataRegex,"");}const serviceRegex=/\s*<!--\s*Push Notification Service\s*-->\s*<service[^>]*android:name="[^"]*PushNotificationUtils"[\s\S]*?<\/service>/gi;manifestContent=manifestContent.replace(serviceRegex,"");manifestContent=manifestContent.replace(/\s*<!--\s*Default notification configuration\s*-->/gi,"");manifestContent=manifestContent.replace(/\s*<!--\s*Firebase default notification configuration\s*-->/gi,"");manifestContent=manifestContent.replace(/\s*<!--\s*Push Notification Service\s*-->/gi,"");fs.writeFileSync(manifestPath,manifestContent);}catch(error){progress.log(`Warning: Error cleaning notification metadata: ${error.message}`,"warning");}}async function cleanupNotificationAssets(){try{const destPath=`${pwd}/androidProject/app/src/main/res`;const imageFormats=["png","jpg","jpeg","gif","bmp","svg","webp","xml"];const audioFormats=["mp3","wav","ogg"];const notificationIcons=["ic_notification","ic_notification_large"];const notificationSounds=["notification_sound_default","notification_sound_urgent"];for(const icon of notificationIcons){for(const format of imageFormats){const iconPath=`${destPath}/drawable/${icon}.${format}`;if(fs.existsSync(iconPath)){fs.unlinkSync(iconPath);}}}const rawDir=`${destPath}/raw`;if(fs.existsSync(rawDir)){for(const sound of notificationSounds){for(const format of audioFormats){const soundPath=`${rawDir}/${sound}.${format}`;if(fs.existsSync(soundPath)){fs.unlinkSync(soundPath);}}}}}catch(error){progress.log(`Warning: Error cleaning notification assets: ${error.message}`,"warning");}}return{copyBuildAssets,copySplashscreenAssets,copyOfflinePage,copyIconAssets,configureAppName,processNotifications};}module.exports=createAssetsPhase;
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import CryptoKit
2
3
  import os
3
4
 
4
5
  private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "CacheManager")
@@ -118,8 +119,8 @@ public final class CacheManager {
118
119
  }
119
120
 
120
121
  private func getCacheKey(for url: URL) -> String {
121
- return url.absoluteString.replacingOccurrences(of: "/", with: "_")
122
- .replacingOccurrences(of: ":", with: "_")
122
+ let digest = SHA256.hash(data: Data(url.absoluteString.utf8))
123
+ return digest.map { String(format: "%02x", $0) }.joined()
123
124
  }
124
125
 
125
126
  private func getCacheFilePath(for url: URL) -> URL {
@@ -140,6 +141,11 @@ public final class CacheManager {
140
141
 
141
142
  return false
142
143
  }
144
+
145
+ func shouldCacheRequest(_ request: URLRequest) -> Bool {
146
+ guard let url = request.url else { return false }
147
+ return shouldCacheURL(url) || OfflineCacheService.shared.shouldCacheOfflineRouteSubresource(request)
148
+ }
143
149
 
144
150
  private func getCacheState(for timestamp: Date) -> CacheState {
145
151
  let age = Date().timeIntervalSince(timestamp)
@@ -181,6 +187,10 @@ public final class CacheManager {
181
187
  logger.info("🔴 Cache expired for: \(urlString)")
182
188
  continuation.resume(returning: (nil, .expired, nil))
183
189
  }
190
+ } else if let cachedResponse = manager.session.configuration.urlCache?.cachedResponse(for: request) {
191
+ let mimeType = (cachedResponse.response as? HTTPURLResponse)?.mimeType ?? "application/octet-stream"
192
+ logger.info("🟢 URLCache fallback hit for: \(urlString)")
193
+ continuation.resume(returning: (cachedResponse.data, .fresh, mimeType))
184
194
  } else {
185
195
  logger.info("❌ Cache miss for: \(urlString)")
186
196
  continuation.resume(returning: (nil, .expired, nil))
@@ -193,10 +203,13 @@ public final class CacheManager {
193
203
  logger.info("🔄 Starting revalidation for: \(request.url?.absoluteString ?? "")")
194
204
 
195
205
  do {
196
- let (data, response) = try await session.data(for: request)
206
+ let configuration = URLSessionConfiguration.ephemeral
207
+ configuration.protocolClasses = []
208
+ let networkSession = URLSession(configuration: configuration)
209
+ let (data, response) = try await networkSession.data(for: request)
197
210
 
198
211
  if let httpResponse = response as? HTTPURLResponse,
199
- isCacheableResponse(httpResponse) {
212
+ isCacheableResponse(httpResponse, for: request) {
200
213
  storeCachedResponse(httpResponse, data: data, for: request)
201
214
  logger.info("Resource revalidated: \(request.url?.absoluteString ?? "")")
202
215
  }
@@ -263,6 +276,28 @@ public final class CacheManager {
263
276
  guard let url = response.url else { return false }
264
277
  return (200...299 ~= response.statusCode) && shouldCacheURL(url)
265
278
  }
279
+
280
+ func isCacheableResponse(_ response: HTTPURLResponse, for request: URLRequest) -> Bool {
281
+ guard let url = response.url, 200...299 ~= response.statusCode else { return false }
282
+
283
+ let matchesLegacyPattern = shouldCacheURL(url)
284
+ let offlineRouteSubresource = OfflineCacheService.shared.shouldCacheOfflineRouteSubresource(request)
285
+ guard matchesLegacyPattern || offlineRouteSubresource else { return false }
286
+
287
+ if offlineRouteSubresource && !matchesLegacyPattern && isDocumentOrDataResponse(response) {
288
+ return false
289
+ }
290
+
291
+ return true
292
+ }
293
+
294
+ private func isDocumentOrDataResponse(_ response: HTTPURLResponse) -> Bool {
295
+ let mimeType = response.mimeType?.lowercased() ?? ""
296
+ return mimeType == "text/html" ||
297
+ mimeType == "application/json" ||
298
+ mimeType.hasSuffix("+json") ||
299
+ mimeType == "text/event-stream"
300
+ }
266
301
 
267
302
  func getCacheStatistics() -> (memoryUsed: Int, diskUsed: Int) {
268
303
  let memoryUsed = session.configuration.urlCache?.currentMemoryUsage ?? 0