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.
Files changed (46) hide show
  1. package/.env.example +33 -0
  2. package/README.md +226 -0
  3. package/adapters/android/build.gradle +28 -0
  4. package/adapters/android/src/main/kotlin/dev/perfanalyzer/ChoreographerAdapter.kt +43 -0
  5. package/adapters/android/src/main/kotlin/dev/perfanalyzer/LogcatReader.kt +64 -0
  6. package/adapters/android/src/main/kotlin/dev/perfanalyzer/MemoryObserver.kt +46 -0
  7. package/adapters/android/src/main/kotlin/dev/perfanalyzer/OkHttpInterceptor.kt +53 -0
  8. package/adapters/android/src/main/kotlin/dev/perfanalyzer/PerfAnalyzer.kt +100 -0
  9. package/adapters/flutter/lib/perf_analyzer.dart +73 -0
  10. package/adapters/flutter/lib/src/frame_observer.dart +45 -0
  11. package/adapters/flutter/lib/src/network_observer.dart +72 -0
  12. package/adapters/flutter/lib/src/rebuild_observer.dart +57 -0
  13. package/adapters/flutter/lib/src/universal_event.dart +50 -0
  14. package/adapters/flutter/lib/src/vm_service_adapter.dart +81 -0
  15. package/adapters/flutter/pubspec.lock +184 -0
  16. package/adapters/flutter/pubspec.yaml +13 -0
  17. package/config/model.manifest.example.json +108 -0
  18. package/config/model.manifest.json +108 -0
  19. package/core/schema/universal_event.dart +63 -0
  20. package/dashboard/dist/assets/index-D5TCSsvB.js +107 -0
  21. package/dashboard/dist/index.html +118 -0
  22. package/package.json +62 -0
  23. package/scripts/encrypt-model.js +86 -0
  24. package/scripts/fill-manifest.js +67 -0
  25. package/scripts/load-env.js +20 -0
  26. package/scripts/model_crypto.js +14 -0
  27. package/scripts/setup-model.js +269 -0
  28. package/server/adb_bridge.js +201 -0
  29. package/server/adb_score.js +77 -0
  30. package/server/ai_proxy.js +373 -0
  31. package/server/analysis_engine.js +222 -0
  32. package/server/android_bridge.js +124 -0
  33. package/server/android_live.js +167 -0
  34. package/server/data/device_gpu_db.json +465 -0
  35. package/server/device_db.js +94 -0
  36. package/server/device_review.js +146 -0
  37. package/server/finding_generator.js +56 -0
  38. package/server/flutter_bridge.js +414 -0
  39. package/server/index.js +619 -0
  40. package/server/ios_bridge.js +181 -0
  41. package/server/model_config.js +54 -0
  42. package/server/report_template.js +169 -0
  43. package/server/session_manager.js +370 -0
  44. package/server/widget_gpu_classifier.js +77 -0
  45. package/server/widget_triage.js +78 -0
  46. 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();