catalyst-core 0.2.0-beta.1 → 0.2.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/native/androidProject/app/build.gradle.kts +26 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +381 -78
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +29 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MetricsMonitor.kt +143 -17
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +7 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/OfflineCacheService.kt +374 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +162 -26
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/CustomWebviewTest.kt +7 -2
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
- package/dist/native/bridge/WebBridge.js +2 -2
- package/dist/native/bridge/constants/NativeInterfaces.js +2 -1
- package/dist/native/bridge/perf/collectors/ApiCollector.js +22 -0
- package/dist/native/bridge/perf/collectors/CacheCollector.js +40 -0
- package/dist/native/bridge/perf/collectors/HookCollector.js +43 -0
- package/dist/native/bridge/perf/collectors/InsightsCollector.js +47 -0
- package/dist/native/bridge/perf/collectors/InteractionCollector.js +38 -0
- package/dist/native/bridge/perf/collectors/KeyboardCollector.js +25 -0
- package/dist/native/bridge/perf/collectors/NavigationCollector.js +53 -0
- package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +7 -0
- package/dist/native/bridge/perf/collectors/RenderCollector.js +40 -0
- package/dist/native/bridge/perf/collectors/ScrollCollector.js +25 -0
- package/dist/native/bridge/perf/collectors/ThreadCollector.js +27 -0
- package/dist/native/bridge/perf/core/constants.js +43 -0
- package/dist/native/bridge/perf/core/measure.js +40 -0
- package/dist/native/bridge/perf/core/session.js +34 -0
- package/dist/native/bridge/perf/core/store.js +1 -0
- package/dist/native/bridge/perf/core/summary.js +1 -0
- package/dist/native/bridge/perf/core/traceExport.js +1 -0
- package/dist/native/bridge/perf/core/waterfalls.js +1 -0
- package/dist/native/bridge/perf/index.js +81 -0
- package/dist/native/buildAndroid/assets.js +2 -2
- package/dist/native/buildIos/config.js +1 -1
- package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
- package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
- package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +49 -4
- package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineCacheService.swift +683 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineURLSchemeHandler.swift +94 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/ResourceURLProtocol.swift +19 -6
- package/dist/native/iosnativeWebView/Sources/Core/Utils/SafeAreaUtils.swift +1 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +1 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
- package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +141 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +157 -3
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
- package/dist/native/renameAndroidProject.js +2 -2
- package/dist/offline/catalyst-sw.js +1 -0
- package/dist/offline/registerServiceWorker.js +1 -0
- package/dist/router/ClientRouter.js +1 -1
- package/dist/scripts/build.js +1 -1
- package/dist/scripts/devBuild.js +1 -1
- package/dist/scripts/generateOfflineManifest.js +1 -0
- package/dist/server/expressServer.js +2 -1
- package/dist/server/renderer/document/Body.js +1 -1
- package/dist/server/renderer/handler.js +3 -3
- package/dist/webpack/base.babel.js +1 -1
- package/mcp_v2/knowledge-base.json +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,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"];
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
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
|
+
}
|