From a0fc568879c67976911e7fd7965b618ad678046d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 15:09:37 +0000 Subject: [PATCH 1/2] feat(desktop): add perf log capture + export in Settings Agent-Logs-Url: https://github.com/Genuifx/claude-code-env-manager/sessions/2a324f2b-54ab-4403-96fe-8888fa95aa7f Co-authored-by: Genuifx <10156994+Genuifx@users.noreply.github.com> --- apps/desktop/src/lib/perf-log.ts | 394 ++++++++++++++++++++++++++++ apps/desktop/src/locales/en.json | 13 + apps/desktop/src/locales/zh.json | 13 + apps/desktop/src/main.tsx | 2 + apps/desktop/src/pages/Settings.tsx | 126 +++++++++ 5 files changed, 548 insertions(+) create mode 100644 apps/desktop/src/lib/perf-log.ts diff --git a/apps/desktop/src/lib/perf-log.ts b/apps/desktop/src/lib/perf-log.ts new file mode 100644 index 00000000..5d028f3c --- /dev/null +++ b/apps/desktop/src/lib/perf-log.ts @@ -0,0 +1,394 @@ +/** + * Lightweight in-memory performance logger. + * + * Captures coarse-grained signals useful to diagnose user-reported jank: + * - PerformanceObserver: longtask / paint / largest-contentful-paint / navigation + * - Window error & unhandledrejection + * - Tauri IPC durations (by patching window.__TAURI_INTERNALS__.invoke) + * - Manual marks via recordPerfMark() + * + * The buffer is bounded (BUFFER_CAPACITY) so it is safe to leave enabled. Data + * stays in-process — nothing is sent anywhere unless the user explicitly + * exports it from the Settings page. + */ + +export type PerfEventType = + | 'longtask' + | 'paint' + | 'lcp' + | 'navigation' + | 'ipc' + | 'ipc-error' + | 'frame-drop' + | 'mark' + | 'error'; + +export interface PerfEvent { + /** Monotonic timestamp relative to navigation start, in ms. */ + t: number; + /** Wall-clock timestamp (ISO 8601) for human reading after export. */ + iso: string; + type: PerfEventType; + name: string; + /** Duration in ms when applicable (longtask, ipc, navigation). */ + durationMs?: number; + meta?: Record; +} + +const BUFFER_CAPACITY = 500; +const LONG_FRAME_THRESHOLD_MS = 80; // 12.5fps — anything noticeably janky +const LONG_IPC_THRESHOLD_MS = 250; + +const buffer: PerfEvent[] = []; +let bufferCursor = 0; +let installed = false; +let ipcPatched = false; +let frameSamplerStop: (() => void) | null = null; +const observers: PerformanceObserver[] = []; + +function pushEvent(event: PerfEvent) { + if (buffer.length < BUFFER_CAPACITY) { + buffer.push(event); + } else { + buffer[bufferCursor] = event; + bufferCursor = (bufferCursor + 1) % BUFFER_CAPACITY; + } +} + +function nowMs(): number { + return typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); +} + +function record(event: Omit) { + pushEvent({ + t: Math.round(nowMs()), + iso: new Date().toISOString(), + ...event, + }); +} + +/** Get a snapshot of buffered events in chronological order. */ +export function getPerfEvents(): PerfEvent[] { + if (buffer.length < BUFFER_CAPACITY) { + return buffer.slice(); + } + return buffer.slice(bufferCursor).concat(buffer.slice(0, bufferCursor)); +} + +/** Clear all buffered events. */ +export function clearPerfLog() { + buffer.length = 0; + bufferCursor = 0; +} + +/** Manual instrumentation hook for callers (e.g. page transitions). */ +export function recordPerfMark(name: string, meta?: Record) { + record({ type: 'mark', name, meta }); +} + +/** Manual duration record (e.g. wrap a heavy computation). */ +export function recordPerfDuration( + name: string, + durationMs: number, + meta?: Record +) { + record({ type: 'mark', name, durationMs, meta }); +} + +interface PerfSummaryEntry { + count: number; + avgMs?: number; + p95Ms?: number; + maxMs?: number; +} + +export type PerfSummary = Partial>; + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); + return sorted[idx]; +} + +/** Aggregate summary suitable for showing in the Settings page. */ +export function getPerfSummary(): PerfSummary { + const events = getPerfEvents(); + const grouped = new Map(); + const counts = new Map(); + + for (const e of events) { + counts.set(e.type, (counts.get(e.type) ?? 0) + 1); + if (typeof e.durationMs === 'number' && Number.isFinite(e.durationMs)) { + const arr = grouped.get(e.type) ?? []; + arr.push(e.durationMs); + grouped.set(e.type, arr); + } + } + + const summary: PerfSummary = {}; + for (const [type, count] of counts) { + const durations = grouped.get(type); + if (durations && durations.length > 0) { + const sorted = [...durations].sort((a, b) => a - b); + const avg = durations.reduce((s, n) => s + n, 0) / durations.length; + summary[type] = { + count, + avgMs: Math.round(avg * 10) / 10, + p95Ms: Math.round(percentile(sorted, 95) * 10) / 10, + maxMs: Math.round(sorted[sorted.length - 1] * 10) / 10, + }; + } else { + summary[type] = { count }; + } + } + return summary; +} + +interface DiagnosticsEnvelope { + generatedAt: string; + appUserAgent: string; + language: string; + url: string; + performanceMode?: string; + hardwareConcurrency?: number; + deviceMemory?: number; + summary: PerfSummary; + events: PerfEvent[]; +} + +/** Build the JSON envelope that the export button writes to disk. */ +export function buildDiagnosticsEnvelope(): DiagnosticsEnvelope { + const nav = typeof navigator !== 'undefined' ? navigator : undefined; + const doc = typeof document !== 'undefined' ? document : undefined; + return { + generatedAt: new Date().toISOString(), + appUserAgent: nav?.userAgent ?? '', + language: nav?.language ?? '', + url: typeof location !== 'undefined' ? location.href : '', + performanceMode: doc?.documentElement.dataset.performanceMode, + hardwareConcurrency: nav?.hardwareConcurrency, + deviceMemory: (nav as { deviceMemory?: number } | undefined)?.deviceMemory, + summary: getPerfSummary(), + events: getPerfEvents(), + }; +} + +/** Serialize the diagnostics envelope as a pretty-printed JSON string. */ +export function exportPerfLogAsJson(): string { + return JSON.stringify(buildDiagnosticsEnvelope(), null, 2); +} + +function safeObserve(type: string, callback: (entries: PerformanceEntryList) => void) { + try { + const supported = + typeof PerformanceObserver !== 'undefined' && + Array.isArray((PerformanceObserver as unknown as { supportedEntryTypes?: string[] }).supportedEntryTypes) && + (PerformanceObserver as unknown as { supportedEntryTypes: string[] }).supportedEntryTypes.includes(type); + if (!supported) return; + const observer = new PerformanceObserver((list) => callback(list.getEntries())); + observer.observe({ type, buffered: true }); + observers.push(observer); + } catch { + // PerformanceObserver may throw on unsupported types; ignore. + } +} + +function installFrameSampler(targetWindow: Window): () => void { + if (typeof targetWindow.requestAnimationFrame !== 'function') { + return () => {}; + } + let last = nowMs(); + let rafId = 0; + let stopped = false; + const tick = () => { + if (stopped) return; + const current = nowMs(); + const delta = current - last; + last = current; + if (delta >= LONG_FRAME_THRESHOLD_MS) { + record({ + type: 'frame-drop', + name: 'rAF gap', + durationMs: Math.round(delta), + }); + } + rafId = targetWindow.requestAnimationFrame(tick); + }; + rafId = targetWindow.requestAnimationFrame(tick); + return () => { + stopped = true; + if (targetWindow.cancelAnimationFrame) { + targetWindow.cancelAnimationFrame(rafId); + } + }; +} + +interface TauriInternals { + invoke?: (...args: unknown[]) => Promise; +} + +function patchTauriInvoke(targetWindow: Window): boolean { + const internals = (targetWindow as unknown as { __TAURI_INTERNALS__?: TauriInternals }).__TAURI_INTERNALS__; + if (!internals || typeof internals.invoke !== 'function') { + return false; + } + if ((internals.invoke as { __ccemInstrumented?: boolean }).__ccemInstrumented) { + return true; + } + const original = internals.invoke.bind(internals); + const wrapped = async (...args: unknown[]) => { + const command = typeof args[0] === 'string' ? args[0] : 'unknown'; + const start = nowMs(); + try { + const result = await original(...args); + const duration = nowMs() - start; + if (duration >= LONG_IPC_THRESHOLD_MS) { + record({ + type: 'ipc', + name: command, + durationMs: Math.round(duration), + }); + } + return result; + } catch (err) { + const duration = nowMs() - start; + record({ + type: 'ipc-error', + name: command, + durationMs: Math.round(duration), + meta: { message: err instanceof Error ? err.message : String(err) }, + }); + throw err; + } + }; + (wrapped as { __ccemInstrumented?: boolean }).__ccemInstrumented = true; + internals.invoke = wrapped as TauriInternals['invoke']; + return true; +} + +interface InstallOptions { + /** Try to instrument Tauri IPC. Retries a few times if internals aren't ready yet. */ + patchTauri?: boolean; +} + +/** + * Install observers and IPC instrumentation. Safe to call multiple times. + */ +export function initPerfLog( + targetWindow: Window = window, + options: InstallOptions = {} +): void { + if (installed) return; + installed = true; + const { patchTauri = true } = options; + + safeObserve('longtask', (entries) => { + for (const entry of entries) { + record({ + type: 'longtask', + name: entry.name || 'longtask', + durationMs: Math.round(entry.duration), + }); + } + }); + + safeObserve('paint', (entries) => { + for (const entry of entries) { + record({ + type: 'paint', + name: entry.name, + durationMs: Math.round(entry.startTime), + }); + } + }); + + safeObserve('largest-contentful-paint', (entries) => { + for (const entry of entries) { + record({ + type: 'lcp', + name: 'largest-contentful-paint', + durationMs: Math.round(entry.startTime), + }); + } + }); + + safeObserve('navigation', (entries) => { + for (const entry of entries as PerformanceNavigationTiming[]) { + record({ + type: 'navigation', + name: entry.name || 'navigation', + durationMs: Math.round(entry.duration), + meta: { + domContentLoaded: Math.round(entry.domContentLoadedEventEnd), + loadEvent: Math.round(entry.loadEventEnd), + }, + }); + } + }); + + if (typeof targetWindow.addEventListener === 'function') { + targetWindow.addEventListener('error', (event) => { + record({ + type: 'error', + name: event.message || 'window.error', + meta: { + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }, + }); + }); + targetWindow.addEventListener('unhandledrejection', (event) => { + const reason = event.reason; + record({ + type: 'error', + name: 'unhandledrejection', + meta: { + message: reason instanceof Error ? reason.message : String(reason), + }, + }); + }); + } + + frameSamplerStop = installFrameSampler(targetWindow); + + if (patchTauri) { + ipcPatched = patchTauriInvoke(targetWindow); + if (!ipcPatched) { + // Tauri may inject __TAURI_INTERN__ slightly after page load. Retry briefly. + let attempts = 0; + const retry = () => { + if (ipcPatched || attempts >= 20) return; + attempts += 1; + ipcPatched = patchTauriInvoke(targetWindow); + if (!ipcPatched) { + targetWindow.setTimeout(retry, 100); + } + }; + targetWindow.setTimeout(retry, 50); + } + } + + record({ type: 'mark', name: 'perf-log:installed' }); +} + +/** For tests: tear everything down. */ +export function _resetPerfLogForTests() { + clearPerfLog(); + observers.forEach((o) => { + try { + o.disconnect(); + } catch { + /* noop */ + } + }); + observers.length = 0; + if (frameSamplerStop) { + frameSamplerStop(); + frameSamplerStop = null; + } + installed = false; + ipcPatched = false; +} diff --git a/apps/desktop/src/locales/en.json b/apps/desktop/src/locales/en.json index e9298000..dd1ea13b 100644 --- a/apps/desktop/src/locales/en.json +++ b/apps/desktop/src/locales/en.json @@ -610,6 +610,19 @@ "updateCheckFailed": "Update check failed: {error}", "updateInstallFailed": "Update install failed: {error}", "feedback": "Report Issue", + "diagnosticsTitle": "Performance diagnostics", + "diagnosticsDesc": "Locally captures recent long tasks, slow IPC calls, frame drops and errors. Export the JSON file and attach it to your feedback to help us reproduce jank.", + "diagnosticsLongtask": "Long task (>50ms blocking)", + "diagnosticsIpcSlow": "Slow IPC (>250ms)", + "diagnosticsIpcError": "IPC error", + "diagnosticsFrameDrop": "Frame drop", + "diagnosticsLcp": "Largest contentful paint", + "diagnosticsError": "Uncaught error", + "diagnosticsExport": "Export performance log", + "diagnosticsClear": "Clear", + "diagnosticsExported": "Performance log exported", + "diagnosticsExportFailed": "Export failed: {error}", + "diagnosticsCleared": "Performance log cleared", "saveSettings": "Save Settings", "settingsSaved": "Settings saved", "settingsSavedLocal": "Settings saved (local)", diff --git a/apps/desktop/src/locales/zh.json b/apps/desktop/src/locales/zh.json index 17b19151..f6c7981b 100644 --- a/apps/desktop/src/locales/zh.json +++ b/apps/desktop/src/locales/zh.json @@ -610,6 +610,19 @@ "updateCheckFailed": "检查更新失败:{error}", "updateInstallFailed": "安装更新失败:{error}", "feedback": "反馈问题", + "diagnosticsTitle": "性能诊断", + "diagnosticsDesc": "本地记录最近的 longtask、IPC 耗时、掉帧和错误。导出 JSON 文件可附在反馈中帮助定位卡顿原因。", + "diagnosticsLongtask": "Long Task(>50ms 阻塞)", + "diagnosticsIpcSlow": "慢 IPC(>250ms)", + "diagnosticsIpcError": "IPC 失败", + "diagnosticsFrameDrop": "明显掉帧", + "diagnosticsLcp": "LCP 最大绘制", + "diagnosticsError": "未捕获错误", + "diagnosticsExport": "导出性能日志", + "diagnosticsClear": "清空", + "diagnosticsExported": "性能日志已导出", + "diagnosticsExportFailed": "导出失败:{error}", + "diagnosticsCleared": "性能日志已清空", "saveSettings": "保存设置", "settingsSaved": "设置已保存", "settingsSavedLocal": "设置已保存 (本地)", diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index e8802574..d866d365 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -2,9 +2,11 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import { initPerformanceMode } from './lib/performance'; +import { initPerfLog } from './lib/perf-log'; import './index.css'; initPerformanceMode(); +initPerfLog(); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/apps/desktop/src/pages/Settings.tsx b/apps/desktop/src/pages/Settings.tsx index 33f7fdf4..72067ef7 100644 --- a/apps/desktop/src/pages/Settings.tsx +++ b/apps/desktop/src/pages/Settings.tsx @@ -12,6 +12,13 @@ import { SettingsSkeleton } from '@/components/ui/skeleton-states'; import { useTauriCommands } from '@/hooks/useTauriCommands'; import { setPerformancePreference as applyPerformancePreference, type PerformancePreference } from '@/lib/performance'; import { scheduleAfterFirstPaint } from '@/lib/idle'; +import { + exportPerfLogAsJson, + getPerfSummary, + clearPerfLog, + recordPerfMark, + type PerfSummary, +} from '@/lib/perf-log'; import type { AppUpdateMetadata } from '@/lib/tauri-ipc'; import { shallow } from 'zustand/shallow'; @@ -816,6 +823,8 @@ export function Settings() {

)}
+ +
+ +
+
+ ); +} + function ToggleSetting({ checked, onChange, title, description }: { checked: boolean; onChange: (v: boolean) => void; From 1ae457f1e0d6262ed391f53c56c5acb0c08efdc6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 15:11:41 +0000 Subject: [PATCH 2/2] chore(desktop): address review (typo, refresh interval, fallback) Agent-Logs-Url: https://github.com/Genuifx/claude-code-env-manager/sessions/2a324f2b-54ab-4403-96fe-8888fa95aa7f Co-authored-by: Genuifx <10156994+Genuifx@users.noreply.github.com> --- apps/desktop/src/lib/perf-log.ts | 2 +- apps/desktop/src/pages/Settings.tsx | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/lib/perf-log.ts b/apps/desktop/src/lib/perf-log.ts index 5d028f3c..55de6b4c 100644 --- a/apps/desktop/src/lib/perf-log.ts +++ b/apps/desktop/src/lib/perf-log.ts @@ -357,7 +357,7 @@ export function initPerfLog( if (patchTauri) { ipcPatched = patchTauriInvoke(targetWindow); if (!ipcPatched) { - // Tauri may inject __TAURI_INTERN__ slightly after page load. Retry briefly. + // Tauri may inject __TAURI_INTERNALS__ slightly after page load. Retry briefly. let attempts = 0; const retry = () => { if (ipcPatched || attempts >= 20) return; diff --git a/apps/desktop/src/pages/Settings.tsx b/apps/desktop/src/pages/Settings.tsx index 72067ef7..afa40cd1 100644 --- a/apps/desktop/src/pages/Settings.tsx +++ b/apps/desktop/src/pages/Settings.tsx @@ -935,7 +935,7 @@ function DiagnosticsPanel({ active }: { active: boolean }) { setSummary(getPerfSummary()); const id = window.setInterval(() => { setSummary(getPerfSummary()); - }, 2000); + }, 3000); return () => { window.clearInterval(id); }; @@ -957,12 +957,7 @@ function DiagnosticsPanel({ active }: { active: boolean }) { recordPerfMark('diagnostics:exported'); toast.success(t('settings.diagnosticsExported')); } catch (error) { - toast.error( - (t('settings.diagnosticsExportFailed') || 'Export failed: {error}').replace( - '{error}', - String(error) - ) - ); + toast.error(t('settings.diagnosticsExportFailed').replace('{error}', String(error))); } };