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.
- package/dist/native/androidProject/app/build.gradle.kts +1 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +143 -4
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +26 -6
- 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 +6 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +26 -0
- 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/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/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 +10 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -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 +126 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +75 -1
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
- 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
|
+
}
|