diff --git a/apps/desktop/src/lib/perf-log.ts b/apps/desktop/src/lib/perf-log.ts
new file mode 100644
index 0000000..55de6b4
--- /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_INTERNALS__ 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 e929800..dd1ea13 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 17b1915..f6c7981 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 e880257..d866d36 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 33f7fdf..afa40cd 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() {
)}
+
+