catalyst-core 0.2.0-beta.2 → 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 (47) hide show
  1. package/dist/native/androidProject/app/build.gradle.kts +1 -0
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +143 -4
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +26 -6
  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 +6 -0
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +26 -0
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
  10. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
  11. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
  12. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
  13. package/dist/native/bridge/WebBridge.js +2 -2
  14. package/dist/native/bridge/constants/NativeInterfaces.js +2 -1
  15. package/dist/native/bridge/perf/collectors/ApiCollector.js +22 -0
  16. package/dist/native/bridge/perf/collectors/CacheCollector.js +40 -0
  17. package/dist/native/bridge/perf/collectors/HookCollector.js +43 -0
  18. package/dist/native/bridge/perf/collectors/InsightsCollector.js +47 -0
  19. package/dist/native/bridge/perf/collectors/InteractionCollector.js +38 -0
  20. package/dist/native/bridge/perf/collectors/KeyboardCollector.js +25 -0
  21. package/dist/native/bridge/perf/collectors/NavigationCollector.js +53 -0
  22. package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +7 -0
  23. package/dist/native/bridge/perf/collectors/RenderCollector.js +40 -0
  24. package/dist/native/bridge/perf/collectors/ScrollCollector.js +25 -0
  25. package/dist/native/bridge/perf/collectors/ThreadCollector.js +27 -0
  26. package/dist/native/bridge/perf/core/constants.js +43 -0
  27. package/dist/native/bridge/perf/core/measure.js +40 -0
  28. package/dist/native/bridge/perf/core/session.js +34 -0
  29. package/dist/native/bridge/perf/core/store.js +1 -0
  30. package/dist/native/bridge/perf/core/summary.js +1 -0
  31. package/dist/native/bridge/perf/core/traceExport.js +1 -0
  32. package/dist/native/bridge/perf/core/waterfalls.js +1 -0
  33. package/dist/native/bridge/perf/index.js +81 -0
  34. package/dist/native/buildIos/config.js +1 -1
  35. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
  36. package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
  37. package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
  38. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +10 -0
  39. package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
  40. package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
  41. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
  42. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
  43. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
  44. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +126 -0
  45. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +75 -1
  46. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
  47. package/package.json +1 -1
@@ -0,0 +1,40 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.Measure=void 0;var _constants=require("./constants.js");/**
2
+ * measure.js
3
+ *
4
+ * Shared _measure() and _colorFor() logic used by every collector.
5
+ * Injected as a dependency — collectors never call performance.measure() directly.
6
+ *
7
+ * Chrome DevTools Extensibility API rules (Chrome 128+):
8
+ * - detail must be { devtools: { dataType:'track-entry', ... } }
9
+ * - custom data goes in properties[] as [key, value] pairs
10
+ * - start AND end must both be numeric ms timestamps — passing a mark name
11
+ * string as start causes silent fallback to Timings row (confirmed Chrome 145)
12
+ */const INTERNAL_PROPERTIES=new Set(["simulatorValid"]);const PROPERTY_LABELS={interactionId:"Related tap",parentOperation:"Parent operation",callId:"Call ID",requestId:"Request ID",memoryId:"Memory sample"};const PROPERTY_PRIORITY={interactionId:0,parentOperation:1,outcome:2,ok:3,status:4,durationMs:5,roundTripMs:5,responseMs:5};class Measure{constructor(){// Rolling window of recent LoAF entries — shared across all collectors
13
+ // so any collector can check loafOverlap without knowing about others.
14
+ this._recentLoafs=[];}/**
15
+ * Record a LoAF entry so overlap detection stays current.
16
+ * Called by RenderCollector whenever a long-animation-frame fires.
17
+ */recordLoaf(start,end){this._recentLoafs.push({start,end});if(this._recentLoafs.length>_constants.THRESHOLD.LOAF_WINDOW){this._recentLoafs.shift();}}overlapsLoaf(startMs,endMs){return this._recentLoafs.some(loaf=>startMs<loaf.end&&endMs>loaf.start);}/**
18
+ * Emit a performance.measure() with Chrome DevTools Extensibility API metadata.
19
+ *
20
+ * @param {string} name - measure name (catalyst:...)
21
+ * @param {string} startMark - name of the opening performance.mark()
22
+ * @param {number} endTime - end time in ms (performance.now()-space)
23
+ * @param {object} detail - arbitrary detail fields added as properties[]
24
+ * @param {string} track - Chrome DevTools track name (from TRACK constant)
25
+ * @param {string} [color] - override color; if omitted, derived from name+detail
26
+ * @param {string} [trackGroup] - Chrome DevTools track group
27
+ */emit(name,startMark,endTime,detail={},track,color=null,trackGroup=_constants.TRACK_GROUP){try{const startTime=performance.getEntriesByName(startMark,"mark")[0]?.startTime;if(startTime==null){console.warn("[CatalystPerf] measure skipped — mark not found:",startMark);return;}const resolvedColor=color??this._colorFor(name,detail,startTime,endTime);const properties=this._properties(detail);performance.measure(name,{start:startTime,end:endTime,detail:{devtools:{dataType:"track-entry",trackGroup,track,color:resolvedColor,tooltipText:name,properties}}});}catch(e){console.warn("[CatalystPerf] measure failed:",name,e.message);}}marker(name,startTime,detail={},color=_constants.COLOR.SECONDARY){try{performance.mark(name,{startTime,detail:{devtools:{dataType:"marker",color,tooltipText:name,properties:this._properties(detail)}}});}catch(e){console.warn("[CatalystPerf] marker failed:",name,e.message);}}_properties(detail){return Object.entries(detail).filter(([key,value])=>!INTERNAL_PROPERTIES.has(key)&&value!=null).sort(([left],[right])=>(PROPERTY_PRIORITY[left]??100)-(PROPERTY_PRIORITY[right]??100)).map(([key,value])=>[this._propertyName(key),this._propertyValue(value)]);}_propertyName(key){if(PROPERTY_LABELS[key])return PROPERTY_LABELS[key];return key.replace(/([a-z0-9])([A-Z])/g,"$1 $2").replace(/[._-]+/g," ").replace(/^./,char=>char.toUpperCase());}_propertyValue(value){if(typeof value==="object"){try{return JSON.stringify(value);}catch{return String(value);}}return String(value);}// ─── Color derivation ─────────────────────────────────────────────────────
28
+ _colorFor(name,detail,startTime,endTime){const duration=endTime-startTime;// LoAF — always error (it's a bad frame by definition)
29
+ if(name.startsWith(_constants.PREFIX.LOAF))return _constants.COLOR.ERROR;// LCP — error if slow
30
+ if(name.startsWith(_constants.PREFIX.LCP))return detail.renderTime>_constants.THRESHOLD.LCP_BAD_MS?_constants.COLOR.ERROR:_constants.COLOR.PRIMARY;if(name.startsWith(_constants.PREFIX.FCP))return _constants.COLOR.PRIMARY;// Navigation session
31
+ if(name.startsWith(_constants.PREFIX.SESSION_NAV))return duration>_constants.THRESHOLD.NAV_SESSION_BAD_MS?_constants.COLOR.ERROR:_constants.COLOR.PRIMARY;// Route transition
32
+ if(name.startsWith(_constants.PREFIX.ROUTE_TRANSITION))return _constants.COLOR.SECONDARY;// Scroll session — error if janky
33
+ if(name.startsWith(_constants.PREFIX.SESSION_SCROLL))return detail.loafCount>0?_constants.COLOR.ERROR:_constants.COLOR.PRIMARY;// Keyboard / viewport — informational
34
+ if(name.startsWith(_constants.PREFIX.SESSION_KEYBOARD))return _constants.COLOR.SECONDARY;if(name.startsWith(_constants.PREFIX.VIEWPORT_RESIZE))return _constants.COLOR.SECONDARY;// Layout shift
35
+ if(name.startsWith(_constants.PREFIX.LAYOUT_SHIFT))return detail.value>_constants.THRESHOLD.CLS_BAD?_constants.COLOR.ERROR:_constants.COLOR.TERTIARY;// Cache spans — color passed explicitly by CacheCollector, this is fallback only
36
+ if(name.startsWith(_constants.PREFIX.CACHE+"/")){if(name.includes("/hit|memory|"))return _constants.COLOR.PRIMARY;if(name.includes("/hit|disk|"))return _constants.COLOR.PRIMARY_LIGHT;if(name.includes("/miss|"))return _constants.COLOR.ERROR;return _constants.COLOR.TERTIARY;}// Browser network calls
37
+ if(name.startsWith(_constants.PREFIX.NETWORK_TIMING)){if(detail.ok===false||duration>_constants.THRESHOLD.FETCH_SLOW_MS)return _constants.COLOR.ERROR;return _constants.COLOR.SECONDARY;}// Hook spans — error if LoAF burst detected during mount
38
+ if(name.startsWith(_constants.PREFIX.HOOK_MOUNT)||name.startsWith(_constants.PREFIX.HOOK_LAZY))return detail.loafCount>0?_constants.COLOR.ERROR:_constants.COLOR.SECONDARY;// Interaction
39
+ if(name.startsWith(_constants.PREFIX.INTERACTION)||Number.isFinite(detail.responseMs))return(detail.responseMs??duration)>_constants.THRESHOLD.INTERACTION_SLOW_MS?_constants.COLOR.ERROR:_constants.COLOR.PRIMARY;// Long press — just informational
40
+ if(name.startsWith(_constants.PREFIX.LONG_PRESS))return _constants.COLOR.TERTIARY;return _constants.COLOR.SECONDARY;}}exports.Measure=Measure;
@@ -0,0 +1,34 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.Session=void 0;/**
2
+ * session.js
3
+ *
4
+ * Reusable open/close session pattern.
5
+ * A session is a named span that stays open until explicitly closed.
6
+ *
7
+ * Usage:
8
+ * const session = new Session('catalyst:session/scroll', measure, TRACK.SCROLL)
9
+ * session.open({ loafCount: 0 })
10
+ * session.close({ loafCount: 3, janky: true })
11
+ *
12
+ * The session owns its own start mark so callers don't track mark names.
13
+ */class Session{/**
14
+ * @param {string} name - base measure name (catalyst:session/...)
15
+ * @param {Measure} measure - shared Measure instance
16
+ * @param {string} track - Chrome DevTools track name
17
+ */constructor(name,measure,track){this._name=name;this._measure=measure;this._track=track;this._state=null;// { startMark, startTime, ...extra } | null
18
+ }get isOpen(){return this._state!==null;}get startTime(){return this._state?.startTime??null;}/**
19
+ * Open the session.
20
+ * @param {object} extra - any fields to carry in state (e.g. loafCount)
21
+ * @param {number} [atTime] - override start time (default: performance.now())
22
+ */open(extra={},atTime=null){if(this._state)return;// already open — ignore double-open
23
+ const startTime=atTime??performance.now();const startMark=`${this._name}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});this._state={startMark,startTime,...extra};}/**
24
+ * Close the session and emit the measure.
25
+ * @param {object} detail - fields added to properties[] in DevTools
26
+ * @param {number} [atTime] - override end time (default: performance.now())
27
+ * @param {string} [color] - override color
28
+ */close(detail={},atTime=null,color=null){if(!this._state)return;const{startMark,startTime}=this._state;const endTime=atTime??performance.now();this._state=null;this._measure.emit(this._name,startMark,endTime,{duration:Math.round(endTime-startTime),...detail},this._track,color);}/**
29
+ * Read a value stored in session state (e.g. loafCount).
30
+ */get(key){return this._state?.[key];}/**
31
+ * Update a value in session state without closing.
32
+ */set(key,value){if(this._state)this._state[key]=value;}/**
33
+ * Increment a numeric counter in session state.
34
+ */increment(key){if(this._state)this._state[key]=(this._state[key]??0)+1;}}exports.Session=Session;
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.PerfStore=void 0;var _waterfalls=require("./waterfalls.js");var _summary=require("./summary.js");var _traceExport=require("./traceExport.js");const MAX_RECORDS=5000;const CACHE_STATUS_BY_TYPE={"cache-hit-memory":"memory-hit","cache-hit-disk":"disk-hit","cache-miss-fetch":"miss","network-fetch-complete":"fetch"};const BOOT_LABEL_BY_TYPE={"boot-activity-created":"Activity created","boot-webview-constructed":"WebView constructed","boot-load-url":"loadUrl called","boot-page-started":"Page started","boot-page-finished":"Page finished"};function clone(value){return JSON.parse(JSON.stringify(value));}function round(value){return Number.isFinite(value)?Math.round(value):value;}function shortUrl(url){if(!url)return"";try{const parsed=new URL(url,window.location?.href);return parsed.pathname||parsed.host;}catch{return String(url).slice(0,80);}}function isFiniteNumber(value){return Number.isFinite(value);}class PerfStore{constructor(nativeToWeb){this._nativeToWeb=nativeToWeb??(time=>time);this._seq=0;this._pendingPageLoads=new Map();this._pendingHwAccel=null;this._pendingInteractions=new Map();this._data=this._createData();}expose(){if(typeof window==="undefined")return;window.__catalystPerfStore=this._data;const api={version:this._data.version,store:()=>this.snapshot(),events:()=>clone(this._data.events),requests:()=>clone(this._data.requests),sessions:()=>clone(this._data.sessions),metrics:()=>clone(this._data.metrics),insights:()=>clone(this._data.insights),waterfalls:()=>(0,_waterfalls.listWaterfalls)(),waterfall:(type="all")=>(0,_waterfalls.buildWaterfall)(this.snapshot(),type),summary:()=>(0,_summary.buildSummary)(this.snapshot()),trace:()=>JSON.stringify((0,_traceExport.buildChromeTrace)(this.snapshot())),downloadTrace:filename=>(0,_traceExport.downloadChromeTrace)(this.snapshot(),filename),export:()=>this.snapshot(),clear:()=>this.clear()};window.CatalystPerf=api;}snapshot(){return clone(this._data);}clear(){this._seq=0;this._data.createdAt=new Date().toISOString();this._data.events.length=0;this._data.requests.length=0;this._data.sessions.length=0;this._data.metrics.length=0;this._data.insights.length=0;this._pendingPageLoads.clear();this._pendingInteractions.clear();this._pendingHwAccel=null;}recordNativeEvent(event,source="native"){if(!event?.type)return;const time=this._nativeEventTime(event);if(event.type==="memory-snapshot"){this._recordNativeDerivedData(event,time);return;}this._add("events",{id:this._id("event"),type:event.type,kind:this._eventKind(event.type),label:this._nativeEventLabel(event),source,startTime:time,endTime:time+1,durationMs:event.durationMs??1,detail:{...event}});this._recordNativeDerivedData(event,time);}recordNavigation(data){if(!data)return;if(data.label==="LCP"||data.label==="FCP"){this._add("metrics",{id:this._id("metric"),kind:"paint",name:data.label,label:data.label,startTime:data.startTime??0,endTime:data.endTime??data.durationMs??0,durationMs:data.durationMs,value:data.durationMs,unit:"ms",detail:{...data}});return;}this._add("sessions",{id:this._id("session"),kind:"route",label:data.label??"Route transition",startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,detail:{...data}});}recordInteraction(data){if(!data)return;if(data.phase==="start"){this._pendingInteractions.set(data.sessionId,data);this._add("events",{id:this._id("event"),type:"interaction-start",kind:"interaction",label:data.target?`Tap ${data.target}`:"Tap",source:"web",startTime:data.startTime,endTime:data.startTime+1,durationMs:1,detail:{...data}});return;}if(data.phase==="end"){const start=this._pendingInteractions.get(data.sessionId);this._pendingInteractions.delete(data.sessionId);const startTime=start?.startTime??data.startTime;const endTime=data.endTime??startTime;this._add("sessions",{id:this._id("session"),kind:"interaction",sessionId:data.sessionId,label:data.target?`Tap ${data.target}`:"Interaction",startTime,endTime,durationMs:data.durationMs??round(endTime-startTime),target:data.target??start?.target??null,detail:{...start,...data}});}}recordNetwork(data){if(!data)return;this._add("requests",{id:this._id("request"),kind:"network",label:`${data.method??"GET"} ${shortUrl(data.url)}`,url:data.url,method:data.method,status:data.status,startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,sessionId:data.sessionId??null,interactionId:data.sessionId??null,detail:{...data}});}recordNativeApi(data){if(!data)return;this._add("requests",{id:this._id("request"),kind:"native-api",label:`Native execution: ${data.method??"API"}`,method:data.method,startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,sessionId:data.sessionId??null,interactionId:data.sessionId??null,detail:{...data}});}recordRender(data){if(!data||data.metric==="memory")return;this._add("metrics",{id:this._id("metric"),kind:data.metric,name:data.metric,label:this._renderLabel(data),startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,value:data.value??data.minFps??data.durationMs??null,unit:this._renderUnit(data.metric),context:data.sessionId??null,detail:{...data}});}recordHook(data){if(!data)return;this._add("sessions",{id:this._id("session"),kind:"hook",label:data.label??`Hook ${data.hookType??"work"}`,startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,detail:{...data}});}recordScroll(data){if(!data)return;this._add("sessions",{id:this._id("session"),kind:"scroll",label:data.janky?"Scroll jank":"Scroll",startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,detail:{...data}});}recordKeyboard(data){if(!data)return;if(data.metric){this.recordRender(data);return;}this._add("sessions",{id:this._id("session"),kind:"keyboard",label:"Keyboard",startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,detail:{...data}});}recordInsight(data){if(!data)return;this._add("insights",{id:this._id("insight"),kind:"insight",severity:data.severity,rule:data.rule,label:data.title??data.rule,title:data.title??data.rule,category:data.category??"general",message:data.message,fix:data.fix,startTime:data.startTime,endTime:data.endTime??(data.startTime??0)+1,durationMs:data.durationMs??1,interactionId:data.interactionId??null,callId:data.callId??null,requestId:data.requestId??null,memoryId:data.memoryId??null,detail:{...data}});}_recordNativeDerivedData(event,time){const{type}=event;if(type==="cold-start"){this._add("metrics",{id:this._id("metric"),kind:"startup",name:"cold-start",label:`Cold start ${round(event.durationMs)}ms`,startTime:Math.max(0,time-(event.durationMs??0)),endTime:time,durationMs:event.durationMs,value:event.durationMs,unit:"ms",detail:{...event}});return;}if(type?.startsWith("boot-"))return;if(type==="page-load-start"){this._pendingPageLoads.set(event.url,{startTime:time,event});return;}if(type==="page-load-end"||type==="page-load-error"){const pending=this._pendingPageLoads.get(event.url);this._pendingPageLoads.delete(event.url);const durationMs=event.durationMs??null;const startTime=pending?.startTime??(durationMs!=null?Math.max(0,time-durationMs):time);this._add("sessions",{id:this._id("session"),kind:"page-load",label:event.url?`Page ${shortUrl(event.url)}`:"Page load",startTime,endTime:time,durationMs:durationMs??round(time-startTime),url:event.url,detail:{...(pending?.event??{}),...event}});return;}if(CACHE_STATUS_BY_TYPE[type]){this._recordCacheRequest(event,time);return;}if(type==="cache-summary"){this._add("metrics",{id:this._id("metric"),kind:"cache-summary",name:"cache-summary",label:`Cache ${event.hitRatePct??0}% hit`,startTime:time,endTime:time+1,durationMs:1,value:event.hitRatePct,unit:"%",detail:{...event}});return;}if(type==="memory-snapshot"){this._add("metrics",{id:this._id("metric"),kind:"memory",name:"memory",label:`Memory ${round(event.totalMb)}MB total`,startTime:time,endTime:time+1,durationMs:1,value:event.totalMb,unit:"MB",detail:{...event}});this._updateMemorySummary(event,time);return;}if(type==="hw-accel-change"){this._recordHwAccel(event,time);}}_recordCacheRequest(event,time){const durationMs=event.durationMs??0;const startTime=isFiniteNumber(event.nativeStartMs)?this._nativeToWeb(event.nativeStartMs):Math.max(0,time-durationMs);const endTime=startTime+durationMs;const cacheStatus=CACHE_STATUS_BY_TYPE[event.type];const label=`${cacheStatus} ${shortUrl(event.url)}`;this._add("requests",{id:this._id("request"),kind:"cache",label,url:event.url,status:event.statusCode??null,startTime,endTime,durationMs,source:event.type.includes("memory")?"memory":event.type.includes("disk")?"disk":event.type==="network-fetch-complete"?"network":"cache",cacheStatus,resourceType:event.resourceType??"other",detail:{...event}});}_recordHwAccel(event,time){if(event.state==="off"){this._pendingHwAccel={startTime:time,event};return;}if(event.state!=="on"||!this._pendingHwAccel)return;const start=this._pendingHwAccel;this._pendingHwAccel=null;this._add("sessions",{id:this._id("session"),kind:"hw-accel",label:"Software render window",startTime:start.startTime,endTime:time,durationMs:round(time-start.startTime),detail:{...start.event,restored:event}});}_updateMemorySummary(event,time){let summary=this._data.metrics.find(metric=>metric.kind==="memory-summary");if(!summary){summary=this._add("metrics",{id:"metric-memory-summary",kind:"memory-summary",name:"memory-summary",label:"Memory summary",startTime:time,endTime:time,durationMs:1,value:event.totalMb,unit:"MB",detail:{sampleCount:0,firstTime:time,lastTime:time,latest:{},peak:{},min:{},max:{}}});}const detail=summary.detail;const latest={jvmMb:event.jvmMb,webviewMb:event.webviewMb,otherMb:event.otherMb,totalMb:event.totalMb,peakMb:event.peakMb,coldStartMs:event.coldStartMs??null,thread:event.thread??null};const fields=["jvmMb","webviewMb","otherMb","totalMb","peakMb"];detail.sampleCount+=1;detail.lastTime=time;detail.latest=latest;for(const field of fields){const value=latest[field];if(!Number.isFinite(value))continue;detail.min[field]=Number.isFinite(detail.min[field])?Math.min(detail.min[field],value):value;detail.max[field]=Number.isFinite(detail.max[field])?Math.max(detail.max[field],value):value;detail.peak[field]=detail.max[field];}summary.label=`Memory ${round(event.totalMb)}MB total`;summary.startTime=detail.firstTime;summary.endTime=time;summary.durationMs=Math.max(1,round(time-detail.firstTime));summary.value=event.totalMb;}_nativeEventTime(event){if(isFiniteNumber(event.nativeTime))return this._nativeToWeb(event.nativeTime);if(isFiniteNumber(event.nativeEndMs))return this._nativeToWeb(event.nativeEndMs);if(isFiniteNumber(event.endNativeTime))return this._nativeToWeb(event.endNativeTime);if(isFiniteNumber(event.nativeStartMs))return this._nativeToWeb(event.nativeStartMs);if(isFiniteNumber(event.startNativeTime))return this._nativeToWeb(event.startNativeTime);return performance.now();}_nativeEventLabel(event){if(BOOT_LABEL_BY_TYPE[event.type])return BOOT_LABEL_BY_TYPE[event.type];if(event.type==="page-load-start")return`Page started ${shortUrl(event.url)}`;if(event.type==="page-load-end")return`Page finished ${shortUrl(event.url)}`;if(event.type==="page-load-error")return`Page error ${shortUrl(event.url)}`;if(CACHE_STATUS_BY_TYPE[event.type])return`${CACHE_STATUS_BY_TYPE[event.type]} ${shortUrl(event.url)}`;return event.type;}_eventKind(type){if(type?.startsWith("boot-")||type==="cold-start")return"startup";if(type?.startsWith("page-load"))return"page-load";if(type?.startsWith("cache-")||type==="network-fetch-complete")return"cache";if(type?.startsWith("keyboard"))return"keyboard";if(type?.startsWith("scroll"))return"scroll";if(type==="fps-drop-episode"||type==="long-task")return"render";return"event";}_renderLabel(data){if(data.metric==="fps-drop")return`FPS drop min ${data.minFps?.toFixed?.(1)??"?"}`;if(data.metric==="long-task")return`Long task ${round(data.durationMs)}ms`;if(data.metric==="loaf")return`LoAF ${round(data.durationMs)}ms`;if(data.metric==="layout-shift")return`Layout shift ${data.value?.toFixed?.(3)??"?"}`;return data.metric;}_renderUnit(metric){if(metric==="fps-drop")return"fps";if(metric==="layout-shift")return"score";return"ms";}_add(bucket,record){this._data[bucket].push(record);if(this._data[bucket].length>MAX_RECORDS){this._data[bucket].shift();}return record;}_id(prefix){this._seq+=1;return`${prefix}-${this._seq}`;}_createData(){return{version:1,createdAt:new Date().toISOString(),events:[],requests:[],sessions:[],metrics:[],insights:[]};}}exports.PerfStore=PerfStore;
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.buildSummary=buildSummary;const SLOW_MS={interaction:300,request:500,nativeApi:150,render:50};function round(value){return Number.isFinite(value)?Math.round(value):value;}function durationOf(record){if(Number.isFinite(record?.durationMs))return record.durationMs;if(Number.isFinite(record?.startTime)&&Number.isFinite(record?.endTime)){return Math.max(0,record.endTime-record.startTime);}return 0;}function byDurationDesc(a,b){return durationOf(b)-durationOf(a);}function overlaps(record,start,end,padding=0){if(!Number.isFinite(start)||!Number.isFinite(end))return false;const recordStart=record.startTime??0;const recordEnd=record.endTime??recordStart;return recordStart<end+padding&&recordEnd>=start-padding;}function top(records,count=5){return[...records].sort(byDurationDesc).slice(0,count);}function countBy(records,key){return records.reduce((acc,record)=>{const value=record?.[key]??"unknown";acc[value]=(acc[value]??0)+1;return acc;},{});}function summarizeInteraction(interaction,data){const start=interaction.startTime;const end=interaction.endTime;const sessionId=interaction.sessionId??interaction.detail?.sessionId;const related=(record,padding=150)=>{const detail=record.detail??{};return detail.sessionId===sessionId||detail.interactionId===sessionId||record.sessionId===sessionId||record.interactionId===sessionId||overlaps(record,start,end,padding);};const requests=data.requests.filter(request=>related(request));const render=data.metrics.filter(metric=>["fps-drop","long-task","loaf","layout-shift"].includes(metric.kind)).filter(metric=>related(metric,300));const insights=data.insights.filter(insight=>related(insight,300));const nativeApiTime=requests.filter(request=>request.kind==="native-api").reduce((total,request)=>total+durationOf(request),0);const networkTime=requests.filter(request=>request.kind==="network").reduce((total,request)=>total+durationOf(request),0);const slowestRequest=top(requests,1)[0]??null;return{id:interaction.id,label:interaction.label,target:interaction.target??interaction.detail?.target??null,startTime:round(start),durationMs:round(durationOf(interaction)),requestCount:requests.length,renderIssueCount:render.length,insightCount:insights.length,nativeApiTimeMs:round(nativeApiTime),networkTimeMs:round(networkTime),slowestRequest:slowestRequest?{id:slowestRequest.id,kind:slowestRequest.kind,label:slowestRequest.label,durationMs:round(durationOf(slowestRequest))}:null,severity:durationOf(interaction)>=SLOW_MS.interaction||render.length>0||insights.length>0?"warning":"ok"};}function buildFindings(data,interactionSummaries){const findings=[];const slowInteractions=interactionSummaries.filter(interaction=>interaction.durationMs>=SLOW_MS.interaction);const slowRequests=data.requests.filter(request=>durationOf(request)>=SLOW_MS.request);const slowNativeApi=data.requests.filter(request=>request.kind==="native-api"&&durationOf(request)>=SLOW_MS.nativeApi);const renderIssues=data.metrics.filter(metric=>["fps-drop","long-task","loaf","layout-shift"].includes(metric.kind));const cacheMisses=data.requests.filter(request=>request.cacheStatus==="miss");if(slowInteractions.length>0){findings.push({severity:"warning",title:`${slowInteractions.length} slow interaction${slowInteractions.length===1?"":"s"}`,detail:`${slowInteractions[0].label} took ${slowInteractions[0].durationMs}ms`});}if(slowRequests.length>0){const slowest=top(slowRequests,1)[0];findings.push({severity:"warning",title:`${slowRequests.length} slow request${slowRequests.length===1?"":"s"}`,detail:`${slowest.label} took ${round(durationOf(slowest))}ms`});}if(slowNativeApi.length>0){const slowest=top(slowNativeApi,1)[0];findings.push({severity:"warning",title:`${slowNativeApi.length} slow native call${slowNativeApi.length===1?"":"s"}`,detail:`${slowest.label} took ${round(durationOf(slowest))}ms`});}if(renderIssues.length>0){findings.push({severity:"warning",title:`${renderIssues.length} render issue${renderIssues.length===1?"":"s"}`,detail:renderIssues[0].label});}if(cacheMisses.length>0){findings.push({severity:"info",title:`${cacheMisses.length} cache miss${cacheMisses.length===1?"":"es"}`,detail:cacheMisses[0].label});}return findings;}function buildSummary(data){const sessions=data.sessions??[];const requests=data.requests??[];const metrics=data.metrics??[];const events=data.events??[];const insights=data.insights??[];const interactions=sessions.filter(session=>session.kind==="interaction");const renderIssues=metrics.filter(metric=>["fps-drop","long-task","loaf","layout-shift"].includes(metric.kind));const memory=metrics.find(metric=>metric.kind==="memory-summary")??null;const interactionSummaries=top(interactions,10).map(interaction=>summarizeInteraction(interaction,data));return{generatedAt:new Date().toISOString(),counts:{events:events.length,sessions:sessions.length,requests:requests.length,metrics:metrics.length,insights:insights.length},byKind:{sessions:countBy(sessions,"kind"),requests:countBy(requests,"kind"),metrics:countBy(metrics,"kind")},slowest:{interactions:top(interactions).map(record=>({id:record.id,label:record.label,durationMs:round(durationOf(record))})),requests:top(requests).map(record=>({id:record.id,kind:record.kind,label:record.label,durationMs:round(durationOf(record))})),render:top(renderIssues).map(record=>({id:record.id,kind:record.kind,label:record.label,durationMs:round(durationOf(record))}))},interactions:interactionSummaries,memory:memory?{label:memory.label,latest:memory.detail?.latest??{},peak:memory.detail?.peak??{},sampleCount:memory.detail?.sampleCount??0}:null,findings:buildFindings(data,interactionSummaries)};}
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.buildChromeTrace=buildChromeTrace;exports.downloadChromeTrace=downloadChromeTrace;const THREADS={navigation:1,input:2,render:3,memory:4,network:5,nativeApi:6,insights:7,events:8};const THREAD_NAMES={[THREADS.navigation]:"Navigation",[THREADS.input]:"Input",[THREADS.render]:"Render",[THREADS.memory]:"Memory",[THREADS.network]:"Network / Cache",[THREADS.nativeApi]:"Native API execution",[THREADS.insights]:"Insights",[THREADS.events]:"Events"};function us(ms){return Math.max(0,Math.round((Number(ms)||0)*1000));}function durationMs(record){if(Number.isFinite(record.durationMs))return Math.max(0.001,record.durationMs);if(Number.isFinite(record.startTime)&&Number.isFinite(record.endTime)){return Math.max(0.001,record.endTime-record.startTime);}return 0.001;}function label(record,fallback){return record.label??record.title??record.name??record.type??record.kind??fallback;}function detail(record){const source=record.detail??record;const relatedTap=record.interactionId??record.sessionId??source.interactionId??source.sessionId;return relatedTap?{relatedTap,...source}:source;}function displayLabel(record,fallback){const base=label(record,fallback);const source=record.detail??record;const relatedTap=record.interactionId??record.sessionId??source.interactionId??source.sessionId;if(!relatedTap)return base;if(record.kind==="interaction")return`${relatedTap}: ${record.target??source.target??base}`;return base.startsWith(`[${relatedTap}]`)?base:`[${relatedTap}] ${base}`;}function sessionThread(kind){if(kind==="page-load"||kind==="route")return THREADS.navigation;if(kind==="scroll"||kind==="keyboard"||kind==="interaction")return THREADS.input;if(kind==="hw-accel"||kind==="hook")return THREADS.render;return THREADS.events;}function requestThread(kind){if(kind==="native-api")return THREADS.nativeApi;return THREADS.network;}function metricThread(kind){if(kind==="memory"||kind==="memory-summary")return THREADS.memory;if(kind==="startup"||kind==="paint")return THREADS.navigation;return THREADS.render;}function addMetadata(events,pid){events.push({ph:"M",pid,tid:0,name:"process_name",args:{name:"Catalyst"}});for(const[tid,name]of Object.entries(THREAD_NAMES)){events.push({ph:"M",pid,tid:Number(tid),name:"thread_name",args:{name}});}}function addDuration(events,record,tid,category,fallbackName){const startTime=record.startTime??record.time??0;events.push({name:displayLabel(record,fallbackName),cat:category,ph:"X",ts:us(startTime),dur:us(durationMs(record)),pid:1,tid,args:detail(record)});}function addInstant(events,record,tid,category,fallbackName){events.push({name:displayLabel(record,fallbackName),cat:category,ph:"i",s:"t",ts:us(record.startTime??record.time??0),pid:1,tid,args:detail(record)});}function addTapMarker(events,session){const source=session.detail??session;const tap=session.interactionId??session.sessionId??source.interactionId??source.sessionId;if(!tap)return;events.push({name:`${tap}: ${session.target??source.target??"interaction"}`,cat:"Catalyst Interaction",ph:"i",s:"g",ts:us(session.startTime??0),pid:1,tid:THREADS.input,args:{relatedTap:tap,target:session.target??source.target??"unknown"}});}function addCounter(events,record){const args={};const source=record.detail??record;for(const key of["jvmMb","webviewMb","nativeMb","otherMb","totalMb","peakMb"]){if(Number.isFinite(source[key]))args[key]=source[key];}if(Object.keys(args).length===0&&Number.isFinite(record.value)){args.value=record.value;}events.push({name:label(record,"Memory"),cat:"Catalyst Memory",ph:"C",ts:us(record.startTime??record.time??0),pid:1,tid:THREADS.memory,args});}function buildChromeTrace(data){const traceEvents=[];addMetadata(traceEvents,1);for(const session of data.sessions??[]){addDuration(traceEvents,session,sessionThread(session.kind),`Catalyst ${session.kind}`,"Session");if(session.kind==="interaction")addTapMarker(traceEvents,session);}for(const request of data.requests??[]){addDuration(traceEvents,request,requestThread(request.kind),`Catalyst ${request.kind}`,"Request");}const hasMemorySamples=(data.metrics??[]).some(metric=>metric.kind==="memory");for(const metric of data.metrics??[]){if(metric.kind==="memory-summary"&&hasMemorySamples)continue;if(metric.kind==="memory"||metric.kind==="memory-summary"){addCounter(traceEvents,metric);}else{addDuration(traceEvents,metric,metricThread(metric.kind),`Catalyst ${metric.kind}`,"Metric");}}for(const event of data.events??[]){addInstant(traceEvents,event,THREADS.events,`Catalyst ${event.kind??"event"}`,"Event");}for(const insight of data.insights??[]){addInstant(traceEvents,insight,THREADS.insights,"Catalyst Insights","Insight");}return{traceEvents:traceEvents.sort((a,b)=>(a.ts??0)-(b.ts??0)),metadata:{source:"CatalystPerf",createdAt:new Date().toISOString(),storeCreatedAt:data.createdAt??null,version:data.version??1}};}function downloadChromeTrace(data,filename){const trace=buildChromeTrace(data);const traceJson=JSON.stringify(trace);const resolvedFilename=filename??`catalyst-trace-${new Date().toISOString().replace(/[:.]/g,"-")}.json`;const eventCount=trace.traceEvents.length;const isIOSNative=typeof window!=="undefined"&&!!window.webkit?.messageHandlers?.NativeBridge;const isAndroidNative=typeof window!=="undefined"&&!!window.NativeBridge;if(typeof console!=="undefined"){console.info(`[CatalystPerf] Trace ready: ${resolvedFilename} (${eventCount} events)`);}if(isIOSNative){window.webkit.messageHandlers.NativeBridge.postMessage({command:"exportCatalystTrace",data:{filename:resolvedFilename,trace:traceJson}});if(typeof console!=="undefined"){console.info(`[CatalystPerf] Sent ${resolvedFilename} to the iOS share sheet.`);}return;}if(isAndroidNative){return;}if(typeof document==="undefined"||typeof Blob==="undefined"||typeof URL==="undefined"){return;}const link=document.createElement("a");if(!("download"in link)){if(typeof console!=="undefined"){console.warn("[CatalystPerf] Browser does not support blob downloads. Use CatalystPerf.trace() instead.");}return;}const blob=new Blob([traceJson],{type:"application/json"});const url=URL.createObjectURL(blob);link.href=url;link.download=resolvedFilename;document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);if(typeof console!=="undefined"){console.info(`[CatalystPerf] Download started. Check your browser Downloads for ${resolvedFilename}.`);}}
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.buildWaterfall=buildWaterfall;exports.listWaterfalls=listWaterfalls;const REQUEST_LANES=new Set(["network","cache","native-api"]);function durationOf(row){if(Number.isFinite(row.durationMs))return row.durationMs;if(Number.isFinite(row.startTime)&&Number.isFinite(row.endTime)){return Math.max(0,Math.round(row.endTime-row.startTime));}return null;}function toRow(record,lane,group=null){const startTime=record.startTime??record.time??0;const endTime=record.endTime??startTime;return{id:record.id,lane,group,kind:record.kind??record.type??lane,label:record.label??record.name??record.type??record.kind??lane,startTime,endTime,durationMs:durationOf({...record,startTime,endTime}),severity:record.severity??null,detail:record.detail??record};}function byStartTime(a,b){return(a.startTime??0)-(b.startTime??0);}function overlaps(row,start,end){if(!Number.isFinite(start)||!Number.isFinite(end))return false;const rowStart=row.startTime??0;const rowEnd=row.endTime??rowStart;return rowStart<end&&rowEnd>=start;}function isStandaloneEvent(event){return event.kind==="startup"||event.type==="navigation-back"||event.type==="page-load-error";}function isTimelineMetric(metric){return metric.kind!=="memory"&&metric.kind!=="memory-summary";}function rowsForWindow(data,start,end,group){return[...data.sessions.map(session=>toRow(session,session.kind,group)),...data.requests.map(request=>toRow(request,request.kind,group)),...data.metrics.filter(isTimelineMetric).map(metric=>toRow(metric,metric.kind,group)),...data.events.filter(isStandaloneEvent).map(event=>toRow(event,event.kind??"event",group)),...data.insights.map(insight=>toRow(insight,"insight",group))].filter(row=>overlaps(row,start,end)).sort(byStartTime);}function buildPageLoadWaterfall(data){const pageLoads=data.sessions.filter(session=>session.kind==="page-load");if(pageLoads.length===0){return[...data.events.filter(event=>event.kind==="startup"||event.type?.startsWith("boot-")).map(event=>toRow(event,"startup","page-load")),...data.metrics.filter(metric=>metric.kind==="paint"||metric.kind==="startup").map(metric=>toRow(metric,metric.kind,"page-load"))].sort(byStartTime);}return pageLoads.flatMap((pageLoad,index)=>{const group=pageLoad.label;const startupRows=index===0?data.events.filter(event=>event.kind==="startup"&&(event.startTime??0)<=pageLoad.endTime).map(event=>toRow(event,"startup",group)):[];return[...startupRows,toRow(pageLoad,"page-load",group),...rowsForWindow(data,pageLoad.startTime,pageLoad.endTime,group).filter(row=>row.id!==pageLoad.id)];}).sort(byStartTime);}function buildRouteWaterfall(data){const routes=data.sessions.filter(session=>session.kind==="route");return routes.flatMap(route=>[toRow(route,"route",route.label),...rowsForWindow(data,route.startTime,route.endTime+1000,route.label).filter(row=>row.id!==route.id)]).sort(byStartTime);}function buildRequestWaterfall(data){return data.requests.filter(request=>REQUEST_LANES.has(request.kind)).map(request=>toRow(request,request.kind,request.sessionId??request.navigationId??null)).sort(byStartTime);}function buildNativeApiWaterfall(data){return data.requests.filter(request=>request.kind==="native-api").map(request=>toRow(request,request.kind,request.sessionId??null)).sort(byStartTime);}function buildInteractionWaterfall(data){const interactions=data.sessions.filter(session=>session.kind==="interaction");return interactions.flatMap(interaction=>{const group=interaction.label;const matchingSession=row=>{const detail=row.detail??{};return detail.sessionId===interaction.sessionId||detail.interactionId===interaction.sessionId||overlaps(row,interaction.startTime,interaction.endTime);};return[toRow(interaction,"interaction",group),...[...data.requests.map(request=>toRow(request,request.kind,group)),...data.metrics.filter(isTimelineMetric).map(metric=>toRow(metric,metric.kind,group)),...data.insights.map(insight=>toRow(insight,"insight",group))].filter(row=>row.id!==interaction.id&&matchingSession(row))];}).sort(byStartTime);}function buildRenderJankWaterfall(data){const renderKinds=new Set(["fps-drop","long-task","loaf","layout-shift","hw-accel"]);return[...data.metrics.filter(metric=>renderKinds.has(metric.kind)).map(metric=>toRow(metric,"render",metric.context??null)),...data.sessions.filter(session=>session.kind==="hw-accel").map(session=>toRow(session,"render",session.detail?.trigger??null))].sort(byStartTime);}function buildScrollWaterfall(data){const scrolls=data.sessions.filter(session=>session.kind==="scroll");return scrolls.flatMap(scroll=>[toRow(scroll,"scroll",scroll.label),...rowsForWindow(data,scroll.startTime,scroll.endTime,scroll.label).filter(row=>row.id!==scroll.id&&["fps-drop","long-task","loaf","layout-shift","insight"].includes(row.kind))]).sort(byStartTime);}function buildKeyboardWaterfall(data){const keyboards=data.sessions.filter(session=>session.kind==="keyboard");return keyboards.flatMap(keyboard=>[toRow(keyboard,"keyboard",keyboard.label),...rowsForWindow(data,keyboard.startTime,keyboard.endTime,keyboard.label).filter(row=>row.id!==keyboard.id&&["viewport-resize","scroll","layout-shift","insight"].includes(row.kind))]).sort(byStartTime);}function buildCacheWaterfall(data){return[...data.requests.filter(request=>request.kind==="cache").map(request=>toRow(request,"cache",request.cacheStatus??null)),...data.metrics.filter(metric=>metric.kind==="cache-summary").map(metric=>toRow(metric,"cache-summary"))].sort(byStartTime);}function buildMemoryTimeline(data){return data.metrics.filter(metric=>metric.kind==="memory"||metric.kind==="memory-summary").map(metric=>toRow(metric,"memory")).sort(byStartTime);}function buildAllWaterfall(data){return[...data.sessions.map(session=>toRow(session,session.kind)),...data.requests.map(request=>toRow(request,request.kind)),...data.metrics.filter(isTimelineMetric).map(metric=>toRow(metric,metric.kind)),...data.events.filter(isStandaloneEvent).map(event=>toRow(event,event.kind??"event")),...data.insights.map(insight=>toRow(insight,"insight"))].sort(byStartTime);}function buildWaterfall(data,type="all"){switch(type){case"page-load":case"page":return buildPageLoadWaterfall(data);case"route":case"navigation":return buildRouteWaterfall(data);case"requests":case"request":return buildRequestWaterfall(data);case"native-bridge":case"bridge":case"api":case"native-api":return buildNativeApiWaterfall(data);case"interactions":case"interaction":return buildInteractionWaterfall(data);case"render":case"jank":case"render-jank":return buildRenderJankWaterfall(data);case"scroll":return buildScrollWaterfall(data);case"keyboard":case"input":return buildKeyboardWaterfall(data);case"cache":return buildCacheWaterfall(data);case"memory":return buildMemoryTimeline(data);case"all":default:return buildAllWaterfall(data);}}function listWaterfalls(){return["page-load","navigation","requests","native-api","interactions","render-jank","scroll","keyboard","cache","memory","all"];}
@@ -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;
@@ -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
@@ -201,6 +201,7 @@ public final class CacheManager {
201
201
 
202
202
  private func revalidateResource(request: URLRequest) async {
203
203
  logger.info("🔄 Starting revalidation for: \(request.url?.absoluteString ?? "")")
204
+ let fetchStartMs = CatalystPerf.nativeTimeMs()
204
205
 
205
206
  do {
206
207
  let configuration = URLSessionConfiguration.ephemeral
@@ -210,6 +211,15 @@ public final class CacheManager {
210
211
 
211
212
  if let httpResponse = response as? HTTPURLResponse,
212
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
+ ])
213
223
  storeCachedResponse(httpResponse, data: data, for: request)
214
224
  logger.info("Resource revalidated: \(request.url?.absoluteString ?? "")")
215
225
  }
@@ -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
+ }