letfixit 0.1.0
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/.env.example +33 -0
- package/README.md +226 -0
- package/adapters/android/build.gradle +28 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/ChoreographerAdapter.kt +43 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/LogcatReader.kt +64 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/MemoryObserver.kt +46 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/OkHttpInterceptor.kt +53 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/PerfAnalyzer.kt +100 -0
- package/adapters/flutter/lib/perf_analyzer.dart +73 -0
- package/adapters/flutter/lib/src/frame_observer.dart +45 -0
- package/adapters/flutter/lib/src/network_observer.dart +72 -0
- package/adapters/flutter/lib/src/rebuild_observer.dart +57 -0
- package/adapters/flutter/lib/src/universal_event.dart +50 -0
- package/adapters/flutter/lib/src/vm_service_adapter.dart +81 -0
- package/adapters/flutter/pubspec.lock +184 -0
- package/adapters/flutter/pubspec.yaml +13 -0
- package/config/model.manifest.example.json +108 -0
- package/config/model.manifest.json +108 -0
- package/core/schema/universal_event.dart +63 -0
- package/dashboard/dist/assets/index-D5TCSsvB.js +107 -0
- package/dashboard/dist/index.html +118 -0
- package/package.json +62 -0
- package/scripts/encrypt-model.js +86 -0
- package/scripts/fill-manifest.js +67 -0
- package/scripts/load-env.js +20 -0
- package/scripts/model_crypto.js +14 -0
- package/scripts/setup-model.js +269 -0
- package/server/adb_bridge.js +201 -0
- package/server/adb_score.js +77 -0
- package/server/ai_proxy.js +373 -0
- package/server/analysis_engine.js +222 -0
- package/server/android_bridge.js +124 -0
- package/server/android_live.js +167 -0
- package/server/data/device_gpu_db.json +465 -0
- package/server/device_db.js +94 -0
- package/server/device_review.js +146 -0
- package/server/finding_generator.js +56 -0
- package/server/flutter_bridge.js +414 -0
- package/server/index.js +619 -0
- package/server/ios_bridge.js +181 -0
- package/server/model_config.js +54 -0
- package/server/report_template.js +169 -0
- package/server/session_manager.js +370 -0
- package/server/widget_gpu_classifier.js +77 -0
- package/server/widget_triage.js +78 -0
- package/setup.sh +160 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ── Connected Device Performance Test ────────────────────────────────────────
|
|
2
|
+
// Platform-agnostic runner for any mobile plugged into the laptop — Android
|
|
3
|
+
// (adb) or iOS (xctrace/simctl/devicectl). Same flow for both: pick a device →
|
|
4
|
+
// enter bundle id + module name → run N iterations where the app is launched,
|
|
5
|
+
// the user performs the flow, metrics are captured, and the app is restarted →
|
|
6
|
+
// score in code → AI suggestions (only if issues) → HTML report.
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const adb = require('./adb_bridge');
|
|
10
|
+
const ios = require('./ios_bridge');
|
|
11
|
+
const { aggregate, score, detectIssues } = require('./adb_score');
|
|
12
|
+
const { getDeviceSuggestions } = require('./ai_proxy');
|
|
13
|
+
const { buildReport } = require('./report_template');
|
|
14
|
+
|
|
15
|
+
const REPORTS_DIR = path.join(__dirname, 'reports');
|
|
16
|
+
const DEFAULT_ITERATIONS = 8;
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'module';
|
|
19
|
+
|
|
20
|
+
// Uniform capture interface over the two low-level bridges.
|
|
21
|
+
function adapterFor(platform) {
|
|
22
|
+
if (platform === 'android') {
|
|
23
|
+
return {
|
|
24
|
+
deviceInfo: (dev) => adb.deviceInfo(dev.id),
|
|
25
|
+
isInstalled: (dev, pkg) => adb.isInstalled(dev.id, pkg),
|
|
26
|
+
async begin(dev, pkg) { await adb.launch(dev.id, pkg); await sleep(1500); await adb.resetGfx(dev.id, pkg); return {}; },
|
|
27
|
+
capture: (dev, pkg) => adb.collect(dev.id, pkg),
|
|
28
|
+
stop: (dev, pkg) => adb.forceStop(dev.id, pkg),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return { // ios
|
|
32
|
+
deviceInfo: (dev) => ios.deviceInfo(dev),
|
|
33
|
+
isInstalled: (dev, pkg) => ios.isInstalled(dev, pkg),
|
|
34
|
+
begin: (dev, pkg) => ios.beginCapture(dev, pkg), // launches app + starts trace
|
|
35
|
+
capture: (dev, pkg, ctx) => ios.endCapture(dev, pkg, ctx),
|
|
36
|
+
stop: (dev, pkg) => ios.terminate(dev, pkg),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function collectAllDevices() {
|
|
41
|
+
const list = [];
|
|
42
|
+
if (await adb.isAvailable()) {
|
|
43
|
+
for (const d of await adb.listDevices()) list.push({ platform: 'android', id: d.serial, model: d.model, kind: 'device' });
|
|
44
|
+
}
|
|
45
|
+
if (await ios.isAvailable()) {
|
|
46
|
+
for (const d of await ios.listDevices()) list.push(d); // already {platform:'ios', id, model, kind, osVersion}
|
|
47
|
+
}
|
|
48
|
+
return list;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param ask async (question) => answer
|
|
53
|
+
* @param opts { port }
|
|
54
|
+
*/
|
|
55
|
+
async function run(ask, opts = {}) {
|
|
56
|
+
const port = opts.port || process.env.DASHBOARD_PORT || 8081;
|
|
57
|
+
|
|
58
|
+
// ── Device discovery across both platforms ──────────────────────────────────
|
|
59
|
+
const devices = await collectAllDevices();
|
|
60
|
+
if (devices.length === 0) {
|
|
61
|
+
console.log('\n ⚠️ No connected devices found.');
|
|
62
|
+
console.log(' Android: enable USB debugging + authorize the adb prompt.');
|
|
63
|
+
console.log(' iOS: pair the device in Xcode, or boot a Simulator. Needs Xcode command-line tools.\n');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let device = devices[0];
|
|
68
|
+
if (devices.length > 1) {
|
|
69
|
+
console.log('\n Connected devices:');
|
|
70
|
+
devices.forEach((d, i) => console.log(` ${i + 1}) [${d.platform === 'ios' ? 'iOS' : 'Android'}] ${d.model}${d.kind === 'simulator' ? ' (Simulator)' : ''} — ${d.id}`));
|
|
71
|
+
const pick = parseInt(await ask(` → Select device [1-${devices.length}]: `), 10);
|
|
72
|
+
device = devices[pick - 1] || devices[0];
|
|
73
|
+
}
|
|
74
|
+
const bridge = adapterFor(device.platform);
|
|
75
|
+
const info = await bridge.deviceInfo(device);
|
|
76
|
+
console.log(` 📱 Using [${info.os}] ${info.brand} ${info.model}${device.kind === 'simulator' ? ' (Simulator)' : ''} · ${info.os} ${info.osVersion}`);
|
|
77
|
+
|
|
78
|
+
// ── Bundle id + module name ──────────────────────────────────────────────────
|
|
79
|
+
const idHint = device.platform === 'ios' ? 'com.example.MyApp' : 'com.example.app';
|
|
80
|
+
const bundleId = (await ask(` 📦 App bundle id (e.g. ${idHint}): `)).trim();
|
|
81
|
+
if (!bundleId) { console.log(' ⚠️ No bundle id — cancelled.'); return; }
|
|
82
|
+
if (!(await bridge.isInstalled(device, bundleId))) {
|
|
83
|
+
const cont = (await ask(` ⚠️ "${bundleId}" not detected on the device. Continue anyway? [y/N]: `)).trim().toLowerCase();
|
|
84
|
+
if (cont !== 'y') return;
|
|
85
|
+
}
|
|
86
|
+
const moduleName = (await ask(' 📝 Module / flow name (e.g. Login Flow): ')).trim() || 'App Flow';
|
|
87
|
+
const iterInput = parseInt(await ask(` 🔁 Iterations [${DEFAULT_ITERATIONS}]: `), 10);
|
|
88
|
+
const iterations = Number.isFinite(iterInput) && iterInput > 0 ? iterInput : DEFAULT_ITERATIONS;
|
|
89
|
+
|
|
90
|
+
console.log(`\n ▶ Reviewing "${moduleName}" on ${bundleId} — ${iterations} iterations.`);
|
|
91
|
+
console.log(' Each run: the app launches, you perform the flow, then press Enter to capture.');
|
|
92
|
+
if (device.platform === 'ios') console.log(' (iOS captures an Instruments trace per run — allow a couple of seconds after Enter.)');
|
|
93
|
+
console.log('');
|
|
94
|
+
|
|
95
|
+
// ── Iteration loop ───────────────────────────────────────────────────────────
|
|
96
|
+
const samples = [];
|
|
97
|
+
for (let i = 1; i <= iterations; i++) {
|
|
98
|
+
console.log(` ── Iteration ${i}/${iterations} ─────────────────────────────`);
|
|
99
|
+
let ctx = {};
|
|
100
|
+
try { ctx = await bridge.begin(device, bundleId); }
|
|
101
|
+
catch (e) { console.log(` ⚠️ launch/prepare failed: ${e.message}`); }
|
|
102
|
+
|
|
103
|
+
await ask(` ▶ Perform the "${moduleName}" flow now, then press Enter to capture… `);
|
|
104
|
+
|
|
105
|
+
let metrics = {};
|
|
106
|
+
try { metrics = await bridge.capture(device, bundleId, ctx); }
|
|
107
|
+
catch (e) { console.log(` ⚠️ capture failed: ${e.message}`); }
|
|
108
|
+
try { await bridge.stop(device, bundleId); } catch (_) {}
|
|
109
|
+
|
|
110
|
+
samples.push(metrics);
|
|
111
|
+
console.log(` ✓ captured — jank ${fmt(metrics.jankPct, '%')} · p90 ${fmt(metrics.p90, 'ms')} · PSS ${fmt(metrics.pssMb, 'MB')} · CPU ${fmt(metrics.cpuPct, '%')}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Analyze (code) → suggestions (smart LLM) ─────────────────────────────────
|
|
115
|
+
console.log('\n 🧮 Aggregating metrics & scoring…');
|
|
116
|
+
const metrics = aggregate(samples);
|
|
117
|
+
const scored = score(metrics);
|
|
118
|
+
const issues = detectIssues(metrics);
|
|
119
|
+
console.log(` 📊 Score: ${scored.overall}/100 (${scored.grade}) · ${issues.length} issue(s) detected`);
|
|
120
|
+
|
|
121
|
+
console.log(issues.length ? ' 🤖 Asking local LLM for fixes…' : ' ✓ No issues — skipping LLM.');
|
|
122
|
+
const ai = await getDeviceSuggestions({ app: bundleId, module: moduleName, deviceInfo: info, metrics, issues });
|
|
123
|
+
|
|
124
|
+
// ── Report ───────────────────────────────────────────────────────────────────
|
|
125
|
+
const html = buildReport({
|
|
126
|
+
app: bundleId, module: moduleName, deviceInfo: info,
|
|
127
|
+
iterations: samples, metrics, score: scored, issues,
|
|
128
|
+
suggestions: ai.suggestions, generatedAt: new Date().toString(),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
fs.mkdirSync(REPORTS_DIR, { recursive: true });
|
|
132
|
+
const fileName = `${slug(moduleName)}-${Date.now()}.html`;
|
|
133
|
+
const filePath = path.join(REPORTS_DIR, fileName);
|
|
134
|
+
fs.writeFileSync(filePath, html, 'utf8');
|
|
135
|
+
|
|
136
|
+
console.log(`\n ✅ Report saved: ${filePath}`);
|
|
137
|
+
console.log(` Served at: http://localhost:${port}/reports/${fileName}`);
|
|
138
|
+
console.log(' Opening in your browser…\n');
|
|
139
|
+
adb.openFile(filePath); // openFile is OS-generic (open / xdg-open / start)
|
|
140
|
+
|
|
141
|
+
return { filePath, fileName, url: `http://localhost:${port}/reports/${fileName}`, score: scored };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fmt = (v, unit) => (v == null ? '—' : `${v}${unit}`);
|
|
145
|
+
|
|
146
|
+
module.exports = { run, REPORTS_DIR };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const { classifyWidget } = require('./widget_gpu_classifier');
|
|
2
|
+
|
|
3
|
+
function generatePrompt(finding) {
|
|
4
|
+
const exceededBy = finding.metric.exceededBy != null ? finding.metric.exceededBy : 'N/A';
|
|
5
|
+
const widgetName = finding.widget || 'N/A';
|
|
6
|
+
|
|
7
|
+
// Enrich with GPU cost context for widget-related findings
|
|
8
|
+
const gpuInfo = widgetName !== 'N/A' ? classifyWidget(widgetName) : null;
|
|
9
|
+
const gpuBlock = gpuInfo && gpuInfo.cost !== 'unknown'
|
|
10
|
+
? `Widget GPU cost: ${gpuInfo.cost}\nRisky on GPU tiers: ${gpuInfo.risky.join(', ') || 'none (safe on all tiers)'}\nGPU note: ${gpuInfo.note}`
|
|
11
|
+
: '';
|
|
12
|
+
|
|
13
|
+
// Device context — populated when analysis engine receives device metadata
|
|
14
|
+
const deviceBlock = finding.deviceContext
|
|
15
|
+
? `Device: ${finding.deviceContext.model || 'unknown'}\nGPU tier: ${finding.deviceContext.gpuTier || 'unknown'}\nArchitecture: ${finding.deviceContext.arch || 'unknown'}\nAndroid API: ${finding.deviceContext.api || 'N/A'}`
|
|
16
|
+
: '';
|
|
17
|
+
|
|
18
|
+
const contextLines = [gpuBlock, deviceBlock].filter(Boolean).join('\n');
|
|
19
|
+
|
|
20
|
+
return `You are a Flutter performance expert. Analyze this finding and respond strictly in the format below.
|
|
21
|
+
|
|
22
|
+
Widget: ${widgetName}
|
|
23
|
+
Issue: ${finding.title}
|
|
24
|
+
Metric: ${finding.metric.value} ${finding.metric.unit}
|
|
25
|
+
Budget: ${finding.metric.budget != null ? finding.metric.budget + ' ' + finding.metric.unit : 'N/A'}
|
|
26
|
+
Exceeded by: ${exceededBy}
|
|
27
|
+
Hypothesis: ${finding.hypothesis}
|
|
28
|
+
${contextLines ? '\n' + contextLines : ''}
|
|
29
|
+
|
|
30
|
+
Respond with EXACTLY these fields, nothing else:
|
|
31
|
+
SEVERITY: critical|high|medium|low
|
|
32
|
+
CAUSE: one sentence — why this widget causes the performance issue
|
|
33
|
+
FIX: one sentence — the specific code change to fix it
|
|
34
|
+
CODE: Flutter/Dart snippet (max 5 lines) that implements the fix
|
|
35
|
+
GAIN: expected improvement e.g. +15 FPS or -30ms build time`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseAIResponse(text) {
|
|
39
|
+
if (!text) return null;
|
|
40
|
+
const get = (key) => {
|
|
41
|
+
const match = text.match(new RegExp(`${key}:\\s*(.+?)(?=\\n[A-Z]+:|$)`, 's'));
|
|
42
|
+
return match ? match[1].trim() : null;
|
|
43
|
+
};
|
|
44
|
+
const result = {
|
|
45
|
+
severity: get('SEVERITY'),
|
|
46
|
+
cause: get('CAUSE'),
|
|
47
|
+
fix: get('FIX'),
|
|
48
|
+
code: get('CODE'),
|
|
49
|
+
gain: get('GAIN'),
|
|
50
|
+
};
|
|
51
|
+
// At least one field must be present for a valid parse
|
|
52
|
+
if (!result.cause && !result.fix && !result.gain) return null;
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { generatePrompt, parseAIResponse };
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
require('dotenv').config();
|
|
2
|
+
const WebSocket = require('ws');
|
|
3
|
+
const { EventEmitter } = require('events');
|
|
4
|
+
|
|
5
|
+
const RECONNECT_DELAY = 3000;
|
|
6
|
+
|
|
7
|
+
// Read lazily at connect() time so a readline prompt in index.js can set
|
|
8
|
+
// process.env.FLUTTER_VM_URL before the first connection attempt.
|
|
9
|
+
function getVMUrl() {
|
|
10
|
+
return process.env.FLUTTER_VM_URL || 'ws://127.0.0.1:8181/ws';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class FlutterBridge extends EventEmitter {
|
|
14
|
+
constructor() {
|
|
15
|
+
super();
|
|
16
|
+
this._ws = null;
|
|
17
|
+
this._reconnecting = false;
|
|
18
|
+
this._pendingRequests = new Map();
|
|
19
|
+
this._reqId = 1;
|
|
20
|
+
this._connected = false;
|
|
21
|
+
this._buildStack = [];
|
|
22
|
+
this._isolateId = null;
|
|
23
|
+
this._memoryTimer = null;
|
|
24
|
+
this._recentErrors = [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
connect() {
|
|
28
|
+
if (this._ws) return;
|
|
29
|
+
this._reconnecting = false;
|
|
30
|
+
|
|
31
|
+
const url = getVMUrl();
|
|
32
|
+
const ws = new WebSocket(url);
|
|
33
|
+
this._ws = ws;
|
|
34
|
+
|
|
35
|
+
ws.on('open', () => {
|
|
36
|
+
this._connected = true;
|
|
37
|
+
console.log(` ✅ Flutter VM Service connected (${url})`);
|
|
38
|
+
this._emitStatus(true);
|
|
39
|
+
this._subscribeToStreams();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
ws.on('message', (raw) => {
|
|
43
|
+
try {
|
|
44
|
+
const msg = JSON.parse(raw);
|
|
45
|
+
if (msg.id && this._pendingRequests.has(msg.id)) {
|
|
46
|
+
this._pendingRequests.get(msg.id)(msg.result);
|
|
47
|
+
this._pendingRequests.delete(msg.id);
|
|
48
|
+
} else if (msg.method === 'streamNotify') {
|
|
49
|
+
this._handleStreamEvent(msg.params);
|
|
50
|
+
}
|
|
51
|
+
} catch (_) {}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
ws.on('close', () => {
|
|
55
|
+
if (this._connected) {
|
|
56
|
+
this._connected = false;
|
|
57
|
+
console.log(' ⚠️ Flutter VM Service disconnected — retrying...');
|
|
58
|
+
this._emitStatus(false);
|
|
59
|
+
}
|
|
60
|
+
if (this._memoryTimer) { clearInterval(this._memoryTimer); this._memoryTimer = null; }
|
|
61
|
+
this._isolateId = null;
|
|
62
|
+
this._ws = null;
|
|
63
|
+
if (!this._reconnecting) {
|
|
64
|
+
this._reconnecting = true;
|
|
65
|
+
setTimeout(() => this.connect(), RECONNECT_DELAY);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
ws.on('error', (err) => {
|
|
70
|
+
// Suppress noisy ECONNREFUSED spam while Flutter isn't running yet
|
|
71
|
+
if (err.code !== 'ECONNREFUSED') {
|
|
72
|
+
console.log(` ⚠️ Flutter VM bridge error: ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
ws.terminate();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get isConnected() { return this._connected; }
|
|
79
|
+
get vmUrl() { return getVMUrl(); }
|
|
80
|
+
|
|
81
|
+
_emitStatus(connected) {
|
|
82
|
+
this.emit('status', { bridge: 'flutter', connected, url: getVMUrl() });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_send(method, params = {}) {
|
|
86
|
+
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return Promise.resolve(null);
|
|
87
|
+
const id = this._reqId++;
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
this._pendingRequests.set(id, resolve);
|
|
90
|
+
this._ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
|
|
91
|
+
setTimeout(() => {
|
|
92
|
+
if (this._pendingRequests.has(id)) {
|
|
93
|
+
this._pendingRequests.delete(id);
|
|
94
|
+
resolve(null);
|
|
95
|
+
}
|
|
96
|
+
}, 5000);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async _subscribeToStreams() {
|
|
101
|
+
await this._send('streamListen', { streamId: 'Extension' });
|
|
102
|
+
await this._send('streamListen', { streamId: 'Timeline' });
|
|
103
|
+
await this._send('streamListen', { streamId: 'Isolate' });
|
|
104
|
+
// Error sources: structured framework errors arrive on Extension as
|
|
105
|
+
// 'Flutter.Error'; dev.log / logger output arrives on Logging; uncaught
|
|
106
|
+
// zone errors and stack dumps arrive on Stderr.
|
|
107
|
+
await this._send('streamListen', { streamId: 'Logging' });
|
|
108
|
+
await this._send('streamListen', { streamId: 'Stderr' });
|
|
109
|
+
// Enable Flutter timeline events so build/raster data flows
|
|
110
|
+
await this._send('setVMTimelineFlags', { recordedStreams: ['GC', 'Dart', 'Embedder'] });
|
|
111
|
+
// Auto-detect the running device's OS/arch (best-effort). Precise GPU/RAM
|
|
112
|
+
// comes from the user picking a target device in the dashboard.
|
|
113
|
+
this._detectDevice();
|
|
114
|
+
this._startMemoryPolling();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async _detectDevice() {
|
|
118
|
+
const vm = await this._send('getVM', {});
|
|
119
|
+
if (!vm) return;
|
|
120
|
+
this._isolateId = vm.isolates?.[0]?.id || null;
|
|
121
|
+
this.emit('device', {
|
|
122
|
+
os: vm.operatingSystem || null, // 'android' | 'ios' | 'macos' ...
|
|
123
|
+
arch: vm.hostCPU || null, // e.g. 'arm64'
|
|
124
|
+
bits: vm.architectureBits || null,
|
|
125
|
+
version: vm.version || null,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Fetch the live widget tree via the Flutter widget inspector service
|
|
131
|
+
* extension. Returns the raw DiagnosticsNode root (normalization happens in
|
|
132
|
+
* index.js), or null if the inspector isn't available.
|
|
133
|
+
*/
|
|
134
|
+
async getWidgetTree() {
|
|
135
|
+
if (!this._connected) return null;
|
|
136
|
+
let isolateId = this._isolateId;
|
|
137
|
+
if (!isolateId) {
|
|
138
|
+
const vm = await this._send('getVM', {});
|
|
139
|
+
isolateId = vm?.isolates?.[0]?.id || null;
|
|
140
|
+
this._isolateId = isolateId;
|
|
141
|
+
}
|
|
142
|
+
if (!isolateId) return null;
|
|
143
|
+
|
|
144
|
+
const groupName = 'perf-analyzer';
|
|
145
|
+
// Newer Flutter exposes ...WithPreviews; older exposes the plain variant.
|
|
146
|
+
const methods = [
|
|
147
|
+
'ext.flutter.inspector.getRootWidgetSummaryTreeWithPreviews',
|
|
148
|
+
'ext.flutter.inspector.getRootWidgetSummaryTree',
|
|
149
|
+
'ext.flutter.inspector.getRootWidget',
|
|
150
|
+
];
|
|
151
|
+
for (const method of methods) {
|
|
152
|
+
const res = await this._send(method, { isolateId, groupName, objectGroup: groupName });
|
|
153
|
+
// Service-extension payloads may be double-wrapped: { result: <node> }.
|
|
154
|
+
const node = res?.result ?? res;
|
|
155
|
+
if (node && (node.description || node.children || node.widgetRuntimeType)) {
|
|
156
|
+
return node;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_handleStreamEvent(params) {
|
|
163
|
+
const { streamId, event } = params;
|
|
164
|
+
|
|
165
|
+
// Flutter.Frame — fires on every rendered frame with timing breakdown
|
|
166
|
+
if (streamId === 'Extension' && event.extensionKind === 'Flutter.Frame') {
|
|
167
|
+
this._handleFlutterFrame(event);
|
|
168
|
+
} else if (streamId === 'Extension' && event.extensionKind === 'Flutter.Error') {
|
|
169
|
+
this._handleFlutterError(event);
|
|
170
|
+
} else if (streamId === 'Timeline' && event.timelineEvents) {
|
|
171
|
+
this._handleTimelineEvents(event.timelineEvents);
|
|
172
|
+
} else if (streamId === 'Logging' && event.logRecord) {
|
|
173
|
+
this._handleLogRecord(event.logRecord);
|
|
174
|
+
} else if (streamId === 'Stderr' && event.bytes) {
|
|
175
|
+
this._handleStderr(event.bytes);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Normalize a widget/type name to the SAME short form the widget tree uses
|
|
180
|
+
// (index.js normalizeTreeNode), so a widget named in an error/finding lines up
|
|
181
|
+
// with its node in the tree. Strips "-[GlobalKey#..]", generics, args, etc.
|
|
182
|
+
_shortWidgetName(raw) {
|
|
183
|
+
if (!raw) return null;
|
|
184
|
+
const s = String(raw).trim().split(/[\s(<[#]/)[0];
|
|
185
|
+
return s || null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Strip ANSI colors, box-drawing decoration, and leading frame markers so the
|
|
189
|
+
// message that reaches the feed is the actual human-readable error text.
|
|
190
|
+
_cleanErrorMessage(raw) {
|
|
191
|
+
// NB: leave "#12 " frame markers intact so _isMetaLine can still detect and
|
|
192
|
+
// drop standalone stack frames — stripping them here would let a bare frame
|
|
193
|
+
// masquerade as a real message.
|
|
194
|
+
return String(raw || '')
|
|
195
|
+
.replace(/\x1B\[[0-9;]*m/g, '') // ANSI color codes
|
|
196
|
+
.replace(/[═╡╞╪║╔╗╚╝╬╣╠─│┌┐└┘├┤┬┴┼]+/g, ' ') // box-drawing chars
|
|
197
|
+
.replace(/\s+/g, ' ')
|
|
198
|
+
.trim();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Meta/decoration lines that are never the actual error message.
|
|
202
|
+
_isMetaLine(l) {
|
|
203
|
+
return !l || l.length < 3 ||
|
|
204
|
+
/^#\d+\s/.test(l) ||
|
|
205
|
+
/EXCEPTION CAUGHT|error-causing widget|When the exception was thrown|stack trace|^Another exception|^The following .* was thrown|^═|^╞|^╡/i.test(l);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Pull the meaningful message + offending widget out of a Flutter error dump.
|
|
209
|
+
_parseErrorDump(text) {
|
|
210
|
+
const lines = text.split('\n').map((l) => this._cleanErrorMessage(l)).filter(Boolean);
|
|
211
|
+
let message = '', widget = null;
|
|
212
|
+
|
|
213
|
+
for (let i = 0; i < lines.length; i++) {
|
|
214
|
+
const l = lines[i];
|
|
215
|
+
// "... was thrown building Foo:" → Foo is the widget
|
|
216
|
+
const built = l.match(/thrown building ([A-Za-z_$][\w$]*)/);
|
|
217
|
+
if (built) widget = this._shortWidgetName(built[1]);
|
|
218
|
+
// Message: the content right after "was thrown[...]:" or "Unhandled exception:"
|
|
219
|
+
if (!message && /was thrown/i.test(l)) {
|
|
220
|
+
const after = l.split(/:\s*/).slice(1).join(': ').trim();
|
|
221
|
+
message = (after && !/^building/i.test(after)) ? after
|
|
222
|
+
: (lines[i + 1] && !this._isMetaLine(lines[i + 1]) ? lines[i + 1] : '');
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!message && /^Unhandled exception/i.test(l)) {
|
|
226
|
+
message = lines[i + 1] && !this._isMetaLine(lines[i + 1]) ? lines[i + 1] : '';
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// "The relevant error-causing widget was: Row Row:file://…"
|
|
230
|
+
const rel = l.match(/error-causing widget was:?\s*(.*)/i);
|
|
231
|
+
if (rel) {
|
|
232
|
+
const cand = (rel[1] || lines[i + 1] || '').trim();
|
|
233
|
+
if (cand) widget = this._shortWidgetName(cand);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (!message) message = lines.find((l) => !this._isMetaLine(l)) || '';
|
|
237
|
+
return { message, widget };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Emit a normalized error event. Message is cleaned; widget is short-form so it
|
|
241
|
+
// matches the widget tree. Deduped against a short rolling window so a single
|
|
242
|
+
// crash dumped across multiple stream lines doesn't spam the feed.
|
|
243
|
+
_emitError(message, { widget = null, severity = 'high', stack = null } = {}) {
|
|
244
|
+
const msg = this._cleanErrorMessage(message);
|
|
245
|
+
if (!msg || msg.length < 3) return;
|
|
246
|
+
const shortWidget = this._shortWidgetName(widget);
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
this._recentErrors = (this._recentErrors || []).filter((e) => now - e.ts < 2000);
|
|
249
|
+
if (this._recentErrors.some((e) => e.msg === msg)) return;
|
|
250
|
+
this._recentErrors.push({ msg, ts: now });
|
|
251
|
+
|
|
252
|
+
this.emit('event', {
|
|
253
|
+
platform: 'flutter',
|
|
254
|
+
event: 'error',
|
|
255
|
+
timestamp: now,
|
|
256
|
+
severity,
|
|
257
|
+
widget: shortWidget,
|
|
258
|
+
title: msg.length > 120 ? msg.slice(0, 117) + '…' : msg,
|
|
259
|
+
metric: { message: msg, unit: 'error', stack: stack || null },
|
|
260
|
+
context: {},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Structured Flutter framework error (FlutterErrorDetails). extensionData.data
|
|
265
|
+
// is a DiagnosticsNode tree; pull the most useful human-readable summary.
|
|
266
|
+
_handleFlutterError(event) {
|
|
267
|
+
const d = event.extensionData?.data ?? event.extensionData ?? {};
|
|
268
|
+
const summary =
|
|
269
|
+
d.errorSummary?.description ||
|
|
270
|
+
(Array.isArray(d.properties) && d.properties.find((p) => p.description)?.description) ||
|
|
271
|
+
d.description ||
|
|
272
|
+
d.renderedErrorText ||
|
|
273
|
+
'Flutter framework error';
|
|
274
|
+
const widget = d.errorSummary?.widgetRuntimeType || d.context?.description || null;
|
|
275
|
+
this._emitError(summary, { widget, severity: 'critical' });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// dev.log() / package:logging records. Only surface warnings and above so
|
|
279
|
+
// routine info/debug logs don't flood the error feed.
|
|
280
|
+
_handleLogRecord(rec) {
|
|
281
|
+
const level = Number(rec.level) || 0; // package:logging: WARNING=900, SEVERE=1000
|
|
282
|
+
if (level < 900) return;
|
|
283
|
+
const msg = this._decodeInstance(rec.message) || this._decodeInstance(rec.error);
|
|
284
|
+
if (!msg) return;
|
|
285
|
+
this._emitError(msg, {
|
|
286
|
+
widget: this._decodeInstance(rec.loggerName) || null,
|
|
287
|
+
severity: level >= 1000 ? 'critical' : 'medium',
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Uncaught errors / stack dumps written to stderr (base64 in the event).
|
|
292
|
+
_handleStderr(b64) {
|
|
293
|
+
let text = '';
|
|
294
|
+
try { text = Buffer.from(b64, 'base64').toString('utf8'); } catch { return; }
|
|
295
|
+
text = text.trim();
|
|
296
|
+
if (!text) return;
|
|
297
|
+
const { message, widget } = this._parseErrorDump(text);
|
|
298
|
+
if (!message) return; // pure stack/decoration with no headline — skip
|
|
299
|
+
this._emitError(message, { widget, severity: 'critical', stack: text.slice(0, 2000) });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// VM Service string values may arrive as {type:'@Instance', valueAsString}.
|
|
303
|
+
_decodeInstance(v) {
|
|
304
|
+
if (v == null) return '';
|
|
305
|
+
if (typeof v === 'string') return v;
|
|
306
|
+
return v.valueAsString || v.value || '';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Flutter.Frame extensionData fields (all microseconds):
|
|
310
|
+
// elapsed — total frame duration
|
|
311
|
+
// build — Dart/widget build phase
|
|
312
|
+
// raster — GPU raster phase
|
|
313
|
+
// vsyncOverhead — time from vsync to build start
|
|
314
|
+
_handleFlutterFrame(event) {
|
|
315
|
+
const d = event.extensionData?.data ?? event.extensionData ?? {};
|
|
316
|
+
const buildUs = Number(d.build) || 0;
|
|
317
|
+
const rasterUs = Number(d.raster) || 0;
|
|
318
|
+
const elapsedUs = Number(d.elapsed) || (buildUs + rasterUs);
|
|
319
|
+
const totalMs = elapsedUs / 1000;
|
|
320
|
+
|
|
321
|
+
// Instantaneous FPS: a frame that took `totalMs` sustains 1000/totalMs fps,
|
|
322
|
+
// capped at the 60Hz display refresh. Sub-16.6ms frames all read as 60.
|
|
323
|
+
const fps = totalMs > 0 ? Math.min(60, Math.round(1000 / totalMs)) : 60;
|
|
324
|
+
|
|
325
|
+
this.emit('event', {
|
|
326
|
+
platform: 'flutter',
|
|
327
|
+
event: 'frame',
|
|
328
|
+
timestamp: Date.now(),
|
|
329
|
+
severity: totalMs > 33 ? 'critical' : totalMs > 16.6 ? 'high' : 'low',
|
|
330
|
+
widget: null,
|
|
331
|
+
metric: {
|
|
332
|
+
value: parseFloat(totalMs.toFixed(2)),
|
|
333
|
+
unit: 'ms',
|
|
334
|
+
fps,
|
|
335
|
+
budget: 16.6,
|
|
336
|
+
exceededBy: totalMs > 16.6 ? parseFloat((totalMs - 16.6).toFixed(2)) : 0,
|
|
337
|
+
build: parseFloat((buildUs / 1000).toFixed(2)),
|
|
338
|
+
raster: parseFloat((rasterUs / 1000).toFixed(2)),
|
|
339
|
+
frameNumber: d.number ?? null,
|
|
340
|
+
},
|
|
341
|
+
context: {},
|
|
342
|
+
raw: d,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Poll the isolate's heap usage every 2s and emit a memory event. This is the
|
|
347
|
+
// only source of live memory data — Flutter doesn't push it as a stream.
|
|
348
|
+
_startMemoryPolling() {
|
|
349
|
+
if (this._memoryTimer) return;
|
|
350
|
+
this._memoryTimer = setInterval(async () => {
|
|
351
|
+
if (!this._connected) return;
|
|
352
|
+
let isolateId = this._isolateId;
|
|
353
|
+
if (!isolateId) {
|
|
354
|
+
const vm = await this._send('getVM', {});
|
|
355
|
+
isolateId = vm?.isolates?.[0]?.id || null;
|
|
356
|
+
this._isolateId = isolateId;
|
|
357
|
+
}
|
|
358
|
+
if (!isolateId) return;
|
|
359
|
+
const usage = await this._send('getMemoryUsage', { isolateId });
|
|
360
|
+
if (!usage) return;
|
|
361
|
+
const bytes = (Number(usage.heapUsage) || 0) + (Number(usage.externalUsage) || 0);
|
|
362
|
+
if (bytes <= 0) return;
|
|
363
|
+
this.emit('event', {
|
|
364
|
+
platform: 'flutter',
|
|
365
|
+
event: 'memory',
|
|
366
|
+
timestamp: Date.now(),
|
|
367
|
+
severity: 'low',
|
|
368
|
+
widget: null,
|
|
369
|
+
metric: {
|
|
370
|
+
value: bytes,
|
|
371
|
+
unit: 'bytes',
|
|
372
|
+
heapUsage: Number(usage.heapUsage) || 0,
|
|
373
|
+
heapCapacity: Number(usage.heapCapacity) || 0,
|
|
374
|
+
externalUsage: Number(usage.externalUsage) || 0,
|
|
375
|
+
},
|
|
376
|
+
context: {},
|
|
377
|
+
});
|
|
378
|
+
}, 2000);
|
|
379
|
+
this._memoryTimer.unref?.();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// BUILD timeline events are nested (a widget's build contains its children's
|
|
383
|
+
// builds), arriving as begin ('B') / end ('E') pairs with microsecond `ts`.
|
|
384
|
+
// We pair them with a stack to recover each widget's own build duration.
|
|
385
|
+
_handleTimelineEvents(events) {
|
|
386
|
+
for (const e of events) {
|
|
387
|
+
const name = e.name ?? '';
|
|
388
|
+
const phase = e.ph ?? '';
|
|
389
|
+
if (name !== 'BUILD') continue;
|
|
390
|
+
|
|
391
|
+
if (phase === 'B') {
|
|
392
|
+
// Short-form name so rebuilds line up with the widget tree + findings.
|
|
393
|
+
const widgetName = this._shortWidgetName(e.args?.type ?? e.args?.name ?? '');
|
|
394
|
+
this._buildStack.push({ widget: widgetName, ts: Number(e.ts) || 0 });
|
|
395
|
+
} else if (phase === 'E') {
|
|
396
|
+
const open = this._buildStack.pop();
|
|
397
|
+
if (!open || !open.widget) continue;
|
|
398
|
+
const durMs = ((Number(e.ts) || 0) - open.ts) / 1000;
|
|
399
|
+
this.emit('event', {
|
|
400
|
+
platform: 'flutter',
|
|
401
|
+
event: 'rebuild',
|
|
402
|
+
timestamp: Date.now(),
|
|
403
|
+
severity: durMs > 8 ? 'high' : durMs > 4 ? 'medium' : 'low',
|
|
404
|
+
widget: open.widget,
|
|
405
|
+
metric: { value: 1, unit: 'rebuild', budget: 10, buildMs: parseFloat(durMs.toFixed(3)) },
|
|
406
|
+
context: {},
|
|
407
|
+
raw: e,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
module.exports = new FlutterBridge();
|