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,81 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _measure=require("./core/measure.js");var _store=require("./core/store.js");var _NavigationCollector=require("./collectors/NavigationCollector.js");var _ScrollCollector=require("./collectors/ScrollCollector.js");var _KeyboardCollector=require("./collectors/KeyboardCollector.js");var _RenderCollector=require("./collectors/RenderCollector.js");var _CacheCollector=require("./collectors/CacheCollector.js");var _HookCollector=require("./collectors/HookCollector.js");var _InteractionCollector=require("./collectors/InteractionCollector.js");var _InsightsCollector=require("./collectors/InsightsCollector.js");var _ThreadCollector=require("./collectors/ThreadCollector.js");var _ApiCollector=require("./collectors/ApiCollector.js");var _NetworkTimingCollector=require("./collectors/NetworkTimingCollector.js");/**
2
+ * perf/index.js — WebPerfCollector
3
+ *
4
+ * Thin orchestrator. Responsibilities:
5
+ * 1. Create all collectors with shared dependencies injected
6
+ * 2. Route native events to the correct collector
7
+ * 3. Wire cross-collector notifications (LoAF → Scroll, Keyboard → Scroll,
8
+ * Render → Interaction session, all events → Insights)
9
+ * 4. Expose WebPerfCollector.init(bridge) — called from WebBridge.init()
10
+ * 5. Drain native batch buffer via __catalystPerfBatch + requestIdleCallback
11
+ * 6. Keep a normalized queryable store for console APIs and future DevTools UI
12
+ *
13
+ * No instrumentation logic lives here. All span logic is in collectors/.
14
+ *
15
+ * Native event routing (live path — __catalystPerfEvent):
16
+ * keyboard-show / keyboard-hide → KeyboardCollector
17
+ * cold-start → NavigationCollector (live — emitted after page finishes, JS is ready)
18
+ * page-load-start / page-load-end / page-load-error → NavigationCollector.onBatchEvent
19
+ * (buffered — emitted during WebView lifecycle before JS is ready, flushed post-load)
20
+ * fps-drop-episode / long-task / memory-snapshot → RenderCollector
21
+ * hw-accel-change → ThreadCollector
22
+ * scroll-start / scroll-end → ScrollCollector
23
+ *
24
+ * Native batch routing (post-load idle drain — __catalystPerfBatch):
25
+ * boot-activity-created / boot-webview-constructed /
26
+ * boot-load-url / boot-page-started / boot-page-finished → NavigationCollector.onBatchEvent
27
+ * cache-hit-* / cache-miss-* / network-fetch-complete → CacheCollector.onBatchEvent
28
+ * cache-summary → CacheCollector.onBatchEvent
29
+ * api-call → ApiCollector.onBatchEvent
30
+ *
31
+ * Web-side instrumentation:
32
+ * fetch / XMLHttpRequest → NetworkTimingCollector
33
+ *
34
+ * Cross-collector wiring:
35
+ * RenderCollector.addLoafListener → ScrollCollector.onLoaf
36
+ * RenderCollector.addLoafListener → HookCollector.onLoaf
37
+ * KeyboardCollector (scroll during keyboard) → ScrollCollector.onScrollDuringKeyboard
38
+ * NavigationCollector.isLoading → RenderCollector session context
39
+ * InteractionCollector interaction range → RenderCollector spans
40
+ * InteractionCollector.activeInteractionId → NetworkTimingCollector fetch/XHR spans
41
+ * RenderCollector → InteractionCollector (onFpsDrop)
42
+ * CacheCollector.cache-summary → InsightsCollector.notifyCacheSummary
43
+ * All collectors → InsightsCollector (reactive warnings + session summary)
44
+ * All high-level collector outputs → PerfStore for waterfalls/tables/export
45
+ */class WebPerfCollector{constructor(){// Align native timestamps (SystemClock.elapsedRealtime) to performance.now() space.
46
+ // Read lazily on each call — __NATIVE_TIME_OFFSET is injected via evaluateJavascript
47
+ // in onPageStarted which may not have executed yet when this constructor runs.
48
+ const nativeToWeb=nativeMs=>nativeMs-(window.__NATIVE_TIME_OFFSET??0);this._nativeToWeb=nativeToWeb;// ── Shared core ────────────────────────────────────────────────────────
49
+ const measure=new _measure.Measure();this._store=new _store.PerfStore(nativeToWeb);// ── Collectors ─────────────────────────────────────────────────────────
50
+ this._nav=new _NavigationCollector.NavigationCollector(measure,nativeToWeb);this._scroll=new _ScrollCollector.ScrollCollector(measure,nativeToWeb);this._keyboard=new _KeyboardCollector.KeyboardCollector(measure,nativeToWeb);this._render=new _RenderCollector.RenderCollector(measure,()=>this._sessionContext(),nativeToWeb);this._cache=new _CacheCollector.CacheCollector(measure,nativeToWeb);this._hook=new _HookCollector.HookCollector(measure);this._interaction=new _InteractionCollector.InteractionCollector(measure);this._insights=new _InsightsCollector.InsightsCollector(measure);this._thread=new _ThreadCollector.ThreadCollector(measure,nativeToWeb);this._api=new _ApiCollector.ApiCollector(measure,nativeToWeb);this._networkTiming=new _NetworkTimingCollector.NetworkTimingCollector(measure);// ── Cross-collector wiring ─────────────────────────────────────────────
51
+ // LoAF → ScrollCollector + HookCollector
52
+ this._render.addLoafListener((start,end)=>this._scroll.onLoaf(start,end));this._render.addLoafListener((start,end)=>this._hook.onLoaf(start,end));// LoAF during navigation → Insights
53
+ this._render.addLoafListener((start,end)=>{if(this._nav.isLoading){this._insights.notifyLoafDuringNav({durationMs:end-start,startTime:start,endTime:end});}});// Interaction session → RenderCollector
54
+ this._render.setInteractionSource(()=>this._interaction);this._networkTiming.setInteractionSource(()=>this._interaction);this._api.setInteractionSource(()=>this._interaction);// Normalized store feed for waterfalls, tables, export, and future DevTools UI.
55
+ this._nav.setWaterfallSource(data=>this._store.recordNavigation(data));this._interaction.setWaterfallSource(data=>this._store.recordInteraction(data));this._networkTiming.setWaterfallSource(data=>this._store.recordNetwork(data));this._api.setWaterfallSource(data=>this._store.recordNativeApi(data));this._render.setWaterfallSource(data=>this._store.recordRender(data));this._hook.setWaterfallSource(data=>this._store.recordHook(data));this._scroll.setWaterfallSource(data=>this._store.recordScroll(data));this._keyboard.setWaterfallSource(data=>this._store.recordKeyboard(data));// Interaction → Insights (slow tap-to-paint)
56
+ this._interaction.setInsightsSource(data=>this._insights.notifySlowInteraction(data));// Render → Insights (layout shift + memory)
57
+ this._render.setInsightsSource(data=>{if(data.currentMb!=null)this._insights.notifyHighMemory(data);else this._insights.notifyLayoutShift(data);});// Thread → Insights (hw-accel disabled during dropped frame)
58
+ this._thread.setInsightsSource(data=>this._insights.notifyHwAccelDuringFrame(data));// Cache summary → Insights (low hit rate)
59
+ this._cache.setInsightsSource(data=>this._insights.notifyCacheSummary(data));// Scroll jank → Insights
60
+ this._scroll.setInsightsSource(data=>this._insights.notifyScrollJank(data));// Insights → normalized store
61
+ this._insights.setStoreSource(data=>{this._store.recordInsight(data);});}static init(bridge){if(window.__catalystPerfCollector)return;const collector=new WebPerfCollector();window.__catalystPerfCollector=collector;collector._setup(bridge);console.log("[CatalystPerf] active — nativeOffset:",window.__NATIVE_TIME_OFFSET??0,"ms");}_setup(bridge){this._store.expose();// Native → web event receiver (called directly by Kotlin via evaluateJavascript)
62
+ window.__catalystPerfEvent=jsonOrString=>{try{const event=typeof jsonOrString==="string"?JSON.parse(jsonOrString):jsonOrString;this._routeNativeEvent(event);}catch(e){console.warn("[CatalystPerf] Bad native event payload:",jsonOrString,e);}};// Native batch flush receiver — called by Kotlin after onPageFinished + 250ms delay.
63
+ // Drains via requestIdleCallback so web render is not blocked.
64
+ window.__catalystPerfBatch=(batchId,jsonArrayString)=>{if(jsonArrayString==null){jsonArrayString=batchId;batchId=null;}try{console.log("[CatalystPerf] __catalystPerfBatch received, length=",typeof jsonArrayString==="string"?jsonArrayString.length:"?");const events=typeof jsonArrayString==="string"?JSON.parse(jsonArrayString):jsonArrayString;if(!Array.isArray(events)||events.length===0){console.warn("[CatalystPerf] __catalystPerfBatch — empty or invalid array");return false;}if(batchId){this._acceptedBatchIds??=new Set();if(this._acceptedBatchIds.has(batchId))return true;this._acceptedBatchIds.add(batchId);if(this._acceptedBatchIds.size>100){this._acceptedBatchIds.delete(this._acceptedBatchIds.values().next().value);}}const typeSummary=events.reduce((acc,e)=>{acc[e.type]=(acc[e.type]||0)+1;return acc;},{});console.log("[CatalystPerf] __catalystPerfBatch events:",events.length,JSON.stringify(typeSummary));this._drainBatch(events);return true;}catch(e){console.warn("[CatalystPerf] Bad batch payload:",e);return false;}};// WebBridge fallback registration (for frameworks that route via bridge callbacks)
65
+ if(bridge){bridge.register("CATALYST_PERF_EVENT",data=>{window.__catalystPerfEvent(data);});}// Initialise all collectors
66
+ this._nav.init();this._scroll.init();this._keyboard.init();this._render.init();this._cache.init();this._hook.init();this._interaction.init();this._insights.init();this._thread.init();this._api.init();this._networkTiming.init();}// ─── Native batch drain ───────────────────────────────────────────────────
67
+ // Processes buffered native events (boot timing, cache, api-call) via
68
+ // requestIdleCallback so the web thread stays free for rendering.
69
+ _drainBatch(events){let index=0;const CHUNK=20;// process up to 20 events per idle slice
70
+ const processChunk=deadline=>{let processed=0;while(index<events.length&&processed<CHUNK){if(deadline&&deadline.timeRemaining()<1)break;this._routeBatchEvent(events[index++]);processed++;}if(index<events.length){if(typeof requestIdleCallback==="function"){requestIdleCallback(processChunk,{timeout:2000});}else{// Fallback: setTimeout for environments without rIC
71
+ setTimeout(()=>processChunk(null),0);}}else{console.log(`[CatalystPerf] batch drained — ${events.length} events`);}};if(typeof requestIdleCallback==="function"){requestIdleCallback(processChunk,{timeout:2000});}else{setTimeout(()=>processChunk(null),0);}}_routeBatchEvent(event){const{type}=event;this._store.recordNativeEvent(event,"native-batch");if(type==="page-load-start"||type==="page-load-error"){this._nav.onNativeEvent(event);}else if(type==="page-load-end"){this._nav.onNativeEvent(event);if(event.durationMs!=null){this._insights.notifySlowLoad({url:event.url,durationMs:event.durationMs,...this._nativeRange(event)});}}else if(type?.startsWith("boot-")){this._nav.onBatchEvent(event);}else if(type?.startsWith("cache-")||type==="network-fetch-complete"){console.log("[CatalystPerf] routing cache event:",type,event.url?.slice(-40));this._cache.onBatchEvent(event);}else if(type==="cache-summary"){console.log("[CatalystPerf] routing cache-summary:",JSON.stringify(event));this._cache.onBatchEvent(event);}else if(type==="api-call"){this._api.onBatchEvent(event);}else if(type==="cold-start"){this._nav.onNativeEvent(event);this._insights.notifyColdStart({durationMs:event.durationMs,...this._nativeRange(event)});}else{// Unknown batch event — silently ignore (may be from future native additions)
72
+ }}// ─── Native event router ──────────────────────────────────────────────────
73
+ _routeNativeEvent(event){this._store.recordNativeEvent(event,"native-live");switch(event.type){case"keyboard-show":this._keyboard.onKeyboardShow(event);break;case"keyboard-hide":this._keyboard.onKeyboardHide(event);break;case"cache-hit-memory":case"cache-hit-disk":case"cache-miss-fetch":case"network-fetch-complete":// Legacy live path — only reached if buffer flush is disabled.
74
+ // In normal operation, cache events go via __catalystPerfBatch.
75
+ this._cache.onNativeEvent(event);break;case"page-load-start":case"page-load-error":this._nav.onNativeEvent(event);break;case"page-load-end":{this._nav.onNativeEvent(event);if(event.durationMs!=null){this._insights.notifySlowLoad({url:event.url,durationMs:event.durationMs,...this._nativeRange(event)});}break;}case"cold-start":this._nav.onNativeEvent(event);this._insights.notifyColdStart({durationMs:event.durationMs,...this._nativeRange(event)});break;case"fps-drop-episode":{const source=this._render.onNativeEvent(event);// Option B: feed fps-drop into scroll session if one is open
76
+ if(this._scroll._session.isOpen){this._scroll.onFpsDrop(event);}this._insights.notifyFpsDrop({minFps:event.minFps,avgFps:event.avgFps,durationMs:event.durationMs,duringInteraction:source?.interactionId!=null,deltaFps:event.deltaFps??null,baselineFps:event.baselineFps??null,interactionId:source?.interactionId??null,startTime:source?.startTime,endTime:source?.endTime});break;}case"long-task":this._render.onNativeEvent(event);break;case"memory-snapshot":this._render.onNativeEvent(event);// Insights notified from RenderCollector via setInsightsSource (uses nativeMb)
77
+ break;case"scroll-start":this._scroll.onScrollStart(event);break;case"scroll-end":this._scroll.onScrollEnd(event);break;case"hw-accel-change":this._thread.onNativeEvent(event);break;case"navigation-back":// Emit via NavigationCollector as a point-in-time batch-style event
78
+ this._nav.onBatchEvent(event);break;default:console.warn("[CatalystPerf] Unknown native event type:",event.type);}}_nativeRange(event){const nativeEnd=event.nativeTime??event.nativeEndMs??event.endNativeTime;if(!Number.isFinite(nativeEnd))return{};const endTime=this._nativeToWeb(nativeEnd);const durationMs=Number.isFinite(event.durationMs)?event.durationMs:1;return{startTime:Math.max(0,endTime-durationMs),endTime};}// ─── Session context helper ───────────────────────────────────────────────
79
+ // Used by RenderCollector to tag LoAF and layout-shift entries with the
80
+ // active session so you can see in DevTools which phase a bad frame belongs to.
81
+ _sessionContext(){if(this._nav.isLoading)return"navigation";if(this._scroll._session.isOpen)return"scroll";if(this._keyboard._session.isOpen)return"keyboard";return"none";}}var _default=exports.default=WebPerfCollector;
@@ -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;
@@ -5,7 +5,7 @@ module.exports=function createConfigPhase(ctx){const{WEBVIEW_CONFIG,iosConfig,PR
5
5
  import Foundation
6
6
 
7
7
  public enum ConfigConstants {
8
- public static let url = "${url}"`;const addedKeys=new Set();if(WEBVIEW_CONFIG&&typeof WEBVIEW_CONFIG==="object"){for(const[key,value]of Object.entries(WEBVIEW_CONFIG)){if(key==="ios"||key==="android")continue;if(key==="notifications")progress.log(`Processing notifications config: ${JSON.stringify(value)}`,"info");configContent+="\n"+generateSwiftProperty(key,value);addedKeys.add(key);}}if(iosConfig&&typeof iosConfig==="object"){configContent+="\n \n // iOS-specific configuration";for(const[key,value]of Object.entries(iosConfig)){if(addedKeys.has(key))continue;configContent+="\n"+generateSwiftProperty(key,value);}}if(WEBVIEW_CONFIG.accessControl){const accessControl=WEBVIEW_CONFIG.accessControl;configContent+=`\n public static let accessControlEnabled = ${accessControl.enabled||false}`;if(accessControl.allowedUrls&&Array.isArray(accessControl.allowedUrls)){const allowedUrls=accessControl.allowedUrls.map(u=>`"${u}"`).join(", ");configContent+=`\n public static let allowedUrls: [String] = [${allowedUrls}]`;}else if(accessControl.allowedUrls&&typeof accessControl.allowedUrls==="string"){const allowedUrls=accessControl.allowedUrls.split(",").map(u=>u.trim()).filter(u=>u.length>0).map(u=>`"${u}"`).join(", ");configContent+=`\n public static let allowedUrls: [String] = [${allowedUrls}]`;}}else{configContent+=`\n public static let accessControlEnabled = false\n public static let allowedUrls: [String] = []`;}const splashConfig=WEBVIEW_CONFIG.splashScreen;if(splashConfig){configContent+=`\n\n // Splash Screen Configuration\n public static let splashScreenEnabled = true`;configContent+=splashConfig.duration?`\n public static let splashScreenDuration: TimeInterval? = ${splashConfig.duration/1000.0}`:`\n public static let splashScreenDuration: TimeInterval? = nil`;configContent+=splashConfig.backgroundColor?`\n public static let splashScreenBackgroundColor = "${splashConfig.backgroundColor}"`:`\n public static let splashScreenBackgroundColor = "#ffffff"`;configContent+=`\n public static let splashScreenImageWidth: CGFloat = ${splashConfig.imageWidth||120}`;configContent+=`\n public static let splashScreenImageHeight: CGFloat = ${splashConfig.imageHeight||120}`;configContent+=`\n public static let splashScreenCornerRadius: CGFloat = ${splashConfig.cornerRadius||20}`;}else{configContent+=`\n\n // Splash Screen Configuration\n public static let splashScreenEnabled = false\n public static let splashScreenDuration: TimeInterval? = nil\n public static let splashScreenBackgroundColor = "#ffffff"\n public static let splashScreenImageWidth: CGFloat = 120\n public static let splashScreenImageHeight: CGFloat = 120\n public static let splashScreenCornerRadius: CGFloat = 20`;}if(!addedKeys.has("notifications")){progress.log("Notifications not found in config, adding default (false)","info");configContent+="\n public enum Notifications {\n public static let enabled = false\n }";addedKeys.add("notifications");}else{progress.log("Notifications config was processed from WEBVIEW_CONFIG","info");}if(!addedKeys.has("googleSignIn")){progress.log("Google Sign-In not found in config, adding defaults (disabled)","info");configContent+='\n public enum GoogleSignIn {\n public static let enabled = false\n public static let clientId = ""\n public static let iosClientId = ""\n }';addedKeys.add("googleSignIn");}if(!addedKeys.has("edgeToEdge")){progress.log("EdgeToEdge not found in config, adding default (false)","info");configContent+="\n public enum EdgeToEdge {\n public static let enabled = false\n }";addedKeys.add("edgeToEdge");}else{progress.log("EdgeToEdge config was processed from WEBVIEW_CONFIG","info");}if(!addedKeys.has("initial_url")){configContent+='\n public static let initial_url = ""';addedKeys.add("initial_url");}// Ensure appInfo always exists (default to empty string if not configured)
8
+ public static let url = "${url}"`;const addedKeys=new Set();if(WEBVIEW_CONFIG&&typeof WEBVIEW_CONFIG==="object"){for(const[key,value]of Object.entries(WEBVIEW_CONFIG)){if(key==="ios"||key==="android")continue;if(key==="notifications")progress.log(`Processing notifications config: ${JSON.stringify(value)}`,"info");configContent+="\n"+generateSwiftProperty(key,value);addedKeys.add(key);}}if(iosConfig&&typeof iosConfig==="object"){configContent+="\n \n // iOS-specific configuration";for(const[key,value]of Object.entries(iosConfig)){if(addedKeys.has(key))continue;configContent+="\n"+generateSwiftProperty(key,value);}}if(WEBVIEW_CONFIG.accessControl){const accessControl=WEBVIEW_CONFIG.accessControl;configContent+=`\n public static let accessControlEnabled = ${accessControl.enabled||false}`;if(accessControl.allowedUrls&&Array.isArray(accessControl.allowedUrls)){const allowedUrls=accessControl.allowedUrls.map(u=>`"${u}"`).join(", ");configContent+=`\n public static let allowedUrls: [String] = [${allowedUrls}]`;}else if(accessControl.allowedUrls&&typeof accessControl.allowedUrls==="string"){const allowedUrls=accessControl.allowedUrls.split(",").map(u=>u.trim()).filter(u=>u.length>0).map(u=>`"${u}"`).join(", ");configContent+=`\n public static let allowedUrls: [String] = [${allowedUrls}]`;}}else{configContent+=`\n public static let accessControlEnabled = false\n public static let allowedUrls: [String] = []`;}const splashConfig=WEBVIEW_CONFIG.splashScreen;if(splashConfig){configContent+=`\n\n // Splash Screen Configuration\n public static let splashScreenEnabled = true`;configContent+=splashConfig.duration?`\n public static let splashScreenDuration: TimeInterval? = ${splashConfig.duration/1000.0}`:`\n public static let splashScreenDuration: TimeInterval? = nil`;configContent+=splashConfig.backgroundColor?`\n public static let splashScreenBackgroundColor = "${splashConfig.backgroundColor}"`:`\n public static let splashScreenBackgroundColor = "#ffffff"`;configContent+=`\n public static let splashScreenImageWidth: CGFloat = ${splashConfig.imageWidth||120}`;configContent+=`\n public static let splashScreenImageHeight: CGFloat = ${splashConfig.imageHeight||120}`;configContent+=`\n public static let splashScreenCornerRadius: CGFloat = ${splashConfig.cornerRadius||20}`;}else{configContent+=`\n\n // Splash Screen Configuration\n public static let splashScreenEnabled = false\n public static let splashScreenDuration: TimeInterval? = nil\n public static let splashScreenBackgroundColor = "#ffffff"\n public static let splashScreenImageWidth: CGFloat = 120\n public static let splashScreenImageHeight: CGFloat = 120\n public static let splashScreenCornerRadius: CGFloat = 20`;}if(!addedKeys.has("notifications")){progress.log("Notifications not found in config, adding default (false)","info");configContent+="\n public enum Notifications {\n public static let enabled = false\n }";addedKeys.add("notifications");}else{progress.log("Notifications config was processed from WEBVIEW_CONFIG","info");}if(!addedKeys.has("googleSignIn")){progress.log("Google Sign-In not found in config, adding defaults (disabled)","info");configContent+='\n public enum GoogleSignIn {\n public static let enabled = false\n public static let clientId = ""\n public static let iosClientId = ""\n }';addedKeys.add("googleSignIn");}if(!addedKeys.has("profiler")){configContent+="\n public enum Profiler {\n public static let enabled = false\n }";addedKeys.add("profiler");}if(!addedKeys.has("edgeToEdge")){progress.log("EdgeToEdge not found in config, adding default (false)","info");configContent+="\n public enum EdgeToEdge {\n public static let enabled = false\n }";addedKeys.add("edgeToEdge");}else{progress.log("EdgeToEdge config was processed from WEBVIEW_CONFIG","info");}if(!addedKeys.has("initial_url")){configContent+='\n public static let initial_url = ""';addedKeys.add("initial_url");}// Ensure appInfo always exists (default to empty string if not configured)
9
9
  if(!addedKeys.has("appInfo")){configContent+='\n public static let appInfo = ""';addedKeys.add("appInfo");}configContent+=`\n}`;fs.writeFileSync(appConfigPath,configContent,"utf8");progress.log("Configuration constants generated successfully (SPM Package)","success");progress.complete("config");}catch(error){progress.fail("config",error.message);throw error;}}async function generateXCConfig(){try{const xconfigPath=path.join(PROJECT_DIR,"Configurations","Shared.xcconfig");// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
10
10
  const configDir=path.dirname(xconfigPath);if(!fs.existsSync(configDir))fs.mkdirSync(configDir,{recursive:true});const bundleId=iosConfig.bundleIdentifier||iosConfig.appBundleId||"com.catalyst.framework.app";const appName=iosConfig.appName||"Catalyst App";const teamId=iosConfig.developmentTeam||"";const swiftVersion=iosConfig.deployment?.swiftVersion||"5.9";const deploymentTarget=iosConfig.deployment?.target||"17.0";const marketingVersion=iosConfig.version?`${iosConfig.version.major||1}.${iosConfig.version.minor||0}.${iosConfig.version.patch||0}`:"1.0.0";const buildNumber=iosConfig.version?.buildNumber||1;const provisioningProfile=iosConfig.provisioningProfile||"";const xconfigContent=`// This file is auto-generated by buildAppIos.js. Do not edit.
11
11
  //
@@ -54,6 +54,21 @@ public enum CatalystConstants {
54
54
 
55
55
  // MARK: - Bridge Limits / Validation
56
56
  public enum Bridge {
57
+ public static let isDebugBuild: Bool = {
58
+ #if DEBUG
59
+ return true
60
+ #else
61
+ return false
62
+ #endif
63
+ }()
64
+
65
+ public static func isTraceExportEnabled(
66
+ debugBuild: Bool = isDebugBuild,
67
+ profilerEnabled: Bool = ConfigConstants.Profiler.enabled
68
+ ) -> Bool {
69
+ debugBuild && profilerEnabled
70
+ }
71
+
57
72
  // Safety limit for inbound JS message size (128 KB)
58
73
  public static let maxMessageSize: Int = 128 * 1024
59
74
  // Command execution timeout window
@@ -86,6 +101,10 @@ public enum CatalystConstants {
86
101
  "cancelTransition"
87
102
  ]
88
103
 
104
+ if isTraceExportEnabled() {
105
+ commands.insert("exportCatalystTrace")
106
+ }
107
+
89
108
  // Check if notifications are enabled via config
90
109
  // When enabled, CatalystNotifications module will be available at app layer
91
110
  if ConfigConstants.Notifications.enabled {
@@ -22,6 +22,10 @@ public enum ConfigConstants {
22
22
  public static let iosClientId = ""
23
23
  }
24
24
 
25
+ public enum Profiler {
26
+ public static let enabled = false
27
+ }
28
+
25
29
  public static let appInfo = "test-build"
26
30
  public static let cachePattern: [String] = []
27
31
  public static let LOCAL_IP = "localhost" // swiftlint:disable:this identifier_name
@@ -9,6 +9,10 @@
9
9
  import Foundation
10
10
  import os
11
11
 
12
+ enum AppBoot {
13
+ static let nativeStartMs = Int64(ProcessInfo.processInfo.systemUptime * 1000)
14
+ }
15
+
12
16
  #if DEBUG
13
17
  // Global app launch timestamp for all timing measurements
14
18
  let APP_LAUNCH_TIME = CFAbsoluteTimeGetCurrent()
@@ -23,4 +27,4 @@ public func logWithTimestamp(_ message: String) {
23
27
  #else
24
28
  // No-op in release builds
25
29
  public func logWithTimestamp(_ message: String) {}
26
- #endif
30
+ #endif
@@ -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))
@@ -191,12 +201,25 @@ public final class CacheManager {
191
201
 
192
202
  private func revalidateResource(request: URLRequest) async {
193
203
  logger.info("🔄 Starting revalidation for: \(request.url?.absoluteString ?? "")")
204
+ let fetchStartMs = CatalystPerf.nativeTimeMs()
194
205
 
195
206
  do {
196
- let (data, response) = try await session.data(for: request)
207
+ let configuration = URLSessionConfiguration.ephemeral
208
+ configuration.protocolClasses = []
209
+ let networkSession = URLSession(configuration: configuration)
210
+ let (data, response) = try await networkSession.data(for: request)
197
211
 
198
212
  if let httpResponse = response as? HTTPURLResponse,
199
- isCacheableResponse(httpResponse) {
213
+ isCacheableResponse(httpResponse, for: request) {
214
+ CatalystPerf.add([
215
+ "type": "network-fetch-complete",
216
+ "url": request.url?.absoluteString ?? "",
217
+ "statusCode": httpResponse.statusCode,
218
+ "resourceType": "document",
219
+ "nativeTime": fetchStartMs,
220
+ "nativeStartMs": fetchStartMs,
221
+ "durationMs": CatalystPerf.nativeTimeMs() - fetchStartMs,
222
+ ])
200
223
  storeCachedResponse(httpResponse, data: data, for: request)
201
224
  logger.info("Resource revalidated: \(request.url?.absoluteString ?? "")")
202
225
  }
@@ -263,6 +286,28 @@ public final class CacheManager {
263
286
  guard let url = response.url else { return false }
264
287
  return (200...299 ~= response.statusCode) && shouldCacheURL(url)
265
288
  }
289
+
290
+ func isCacheableResponse(_ response: HTTPURLResponse, for request: URLRequest) -> Bool {
291
+ guard let url = response.url, 200...299 ~= response.statusCode else { return false }
292
+
293
+ let matchesLegacyPattern = shouldCacheURL(url)
294
+ let offlineRouteSubresource = OfflineCacheService.shared.shouldCacheOfflineRouteSubresource(request)
295
+ guard matchesLegacyPattern || offlineRouteSubresource else { return false }
296
+
297
+ if offlineRouteSubresource && !matchesLegacyPattern && isDocumentOrDataResponse(response) {
298
+ return false
299
+ }
300
+
301
+ return true
302
+ }
303
+
304
+ private func isDocumentOrDataResponse(_ response: HTTPURLResponse) -> Bool {
305
+ let mimeType = response.mimeType?.lowercased() ?? ""
306
+ return mimeType == "text/html" ||
307
+ mimeType == "application/json" ||
308
+ mimeType.hasSuffix("+json") ||
309
+ mimeType == "text/event-stream"
310
+ }
266
311
 
267
312
  func getCacheStatistics() -> (memoryUsed: Int, diskUsed: Int) {
268
313
  let memoryUsed = session.configuration.urlCache?.currentMemoryUsage ?? 0
@@ -0,0 +1,89 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ final class DisplayLinkPerfMonitor {
5
+ private weak var webView: WKWebView?
6
+ private var displayLink: CADisplayLink?
7
+ private var dropStartMs: Int64?
8
+ private var minFps: Double = 60
9
+ private var fpsSum: Double = 0
10
+ private var sampleCount = 0
11
+ private var lastTimestamp: CFTimeInterval?
12
+ private var lastStableFps: Double = 60
13
+ private var dropBaselineFps: Double = 60
14
+
15
+ private let dropThresholdFps = 55.0
16
+ private let minDropDurationMs: Int64 = 250
17
+
18
+ init(webView: WKWebView) {
19
+ self.webView = webView
20
+ }
21
+
22
+ func start() {
23
+ guard displayLink == nil else { return }
24
+ let link = CADisplayLink(target: self, selector: #selector(tick(_:)))
25
+ link.add(to: .main, forMode: .common)
26
+ displayLink = link
27
+ }
28
+
29
+ func stop() {
30
+ displayLink?.invalidate()
31
+ displayLink = nil
32
+ dropStartMs = nil
33
+ sampleCount = 0
34
+ fpsSum = 0
35
+ minFps = 60
36
+ lastTimestamp = nil
37
+ lastStableFps = 60
38
+ dropBaselineFps = 60
39
+ }
40
+
41
+ @objc private func tick(_ link: CADisplayLink) {
42
+ defer { lastTimestamp = link.timestamp }
43
+ guard let lastTimestamp else { return }
44
+ let frameDuration = link.timestamp - lastTimestamp
45
+ guard frameDuration > 0 else { return }
46
+ let fps = min(60.0, 1.0 / frameDuration)
47
+ let now = CatalystPerf.nativeTimeMs()
48
+
49
+ if fps < dropThresholdFps {
50
+ if dropStartMs == nil {
51
+ dropStartMs = now
52
+ minFps = fps
53
+ fpsSum = 0
54
+ sampleCount = 0
55
+ dropBaselineFps = lastStableFps
56
+ }
57
+ minFps = min(minFps, fps)
58
+ fpsSum += fps
59
+ sampleCount += 1
60
+ return
61
+ }
62
+
63
+ guard let startMs = dropStartMs else {
64
+ lastStableFps = fps
65
+ return
66
+ }
67
+ let durationMs = now - startMs
68
+ if durationMs >= minDropDurationMs, sampleCount > 0 {
69
+ CatalystPerf.emit([
70
+ "type": "fps-drop-episode",
71
+ "nativeTime": startMs,
72
+ "startNativeTime": startMs,
73
+ "endNativeTime": now,
74
+ "durationMs": durationMs,
75
+ "minFps": minFps,
76
+ "avgFps": fpsSum / Double(sampleCount),
77
+ "deltaFps": dropBaselineFps - minFps,
78
+ "baselineFps": dropBaselineFps,
79
+ "thread": Thread.current.name ?? "main",
80
+ ], to: webView)
81
+ }
82
+
83
+ lastStableFps = fps
84
+ dropStartMs = nil
85
+ sampleCount = 0
86
+ fpsSum = 0
87
+ minFps = 60
88
+ }
89
+ }