From ff9155235615ab3e199ecc56bd88098ce3f9745a Mon Sep 17 00:00:00 2001 From: coso Date: Mon, 2 Mar 2026 17:52:19 +0800 Subject: [PATCH] feat: enhance crash diagnostics with invoke error buffer, frontend crash buffer, and app version - Add appVersion.ts: resolves app version from VITE_APP_VERSION env or package.json at build time - Inject VITE_APP_VERSION into vite.config.ts define block for consistent version across environments - Add frontend crash buffer to crashReporting.ts: persists last 80 crashes in localStorage - Add invoke error buffer to safeInvoke.ts: captures last 120 Tauri IPC errors with secret sanitization - Enhance CrashDiagnosticPayload with locale, timezone, page_url, runtime, invoke_error_buffer, frontend_crash_buffer, persisted_log_tail, workspace_repair_history, diagnostic_collection_notes - Add export types: DesktopPlatform, ClipboardPermissionGuide, CrashDiagnosticExportResult - Export getInvokeErrorBuffer and InvokeErrorBufferEntry from dev-bridge index - Update tauri-mock with new ServerStatus fields (capability_routing, response_cache, request_dedup, idempotency) - Add tests for appVersion, crashReporting, crashDiagnostic, workspaceHealthTelemetry Co-Authored-By: Claude Sonnet 4.6 --- src/lib/appVersion.test.ts | 14 + src/lib/appVersion.ts | 37 ++ src/lib/crashDiagnostic.test.ts | 194 +++++++++ src/lib/crashDiagnostic.ts | 482 ++++++++++++++++++++++- src/lib/crashReporting.test.ts | 26 +- src/lib/crashReporting.ts | 110 +++++- src/lib/dev-bridge/index.ts | 9 +- src/lib/dev-bridge/safeInvoke.ts | 171 +++++++- src/lib/tauri-mock/core.ts | 108 +++++ src/lib/workspaceHealthTelemetry.test.ts | 45 +++ vite.config.ts | 9 + 11 files changed, 1191 insertions(+), 14 deletions(-) create mode 100644 src/lib/appVersion.test.ts create mode 100644 src/lib/appVersion.ts create mode 100644 src/lib/crashDiagnostic.test.ts create mode 100644 src/lib/workspaceHealthTelemetry.test.ts diff --git a/src/lib/appVersion.test.ts b/src/lib/appVersion.test.ts new file mode 100644 index 000000000..66be2a662 --- /dev/null +++ b/src/lib/appVersion.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { resolveAppVersion } from "./appVersion"; + +describe("appVersion", () => { + it("优先返回首个有效版本号", () => { + expect(resolveAppVersion("0.76.1", "0.76.0")).toBe("0.76.1"); + expect(resolveAppVersion("unknown", "0.76.0")).toBe("0.76.0"); + expect(resolveAppVersion("", " ", "0.76.0")).toBe("0.76.0"); + }); + + it("无候选值时回退 package.json 版本", () => { + expect(resolveAppVersion(undefined, null, "unknown")).toBe("0.76.0"); + }); +}); diff --git a/src/lib/appVersion.ts b/src/lib/appVersion.ts new file mode 100644 index 000000000..68abed08d --- /dev/null +++ b/src/lib/appVersion.ts @@ -0,0 +1,37 @@ +import packageJson from "../../package.json"; + +function normalizeVersion(value?: string | null): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.toLowerCase() === "unknown") return null; + return trimmed; +} + +export function resolveAppVersion( + ...candidates: Array +): string { + for (const candidate of candidates) { + const normalized = normalizeVersion(candidate); + if (normalized) { + return normalized; + } + } + + const packageVersion = normalizeVersion( + typeof packageJson.version === "string" ? packageJson.version : null, + ); + + if (packageVersion) { + return packageVersion; + } + + return "unknown"; +} + +export function getRuntimeAppVersion(explicit?: string): string { + return resolveAppVersion( + explicit, + import.meta.env.VITE_APP_VERSION as string | undefined, + ); +} diff --git a/src/lib/crashDiagnostic.test.ts b/src/lib/crashDiagnostic.test.ts new file mode 100644 index 000000000..a0433e9f8 --- /dev/null +++ b/src/lib/crashDiagnostic.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildCrashDiagnosticPayload, + buildCrashDiagnosticFileName, + buildCrashDiagnosticClipboardText, + copyCrashDiagnosticJsonToClipboard, + copyCrashDiagnosticToClipboard, + detectDesktopPlatform, + getClipboardPermissionGuide, + sanitizeDiagnosticSceneTag, +} from "./crashDiagnostic"; +import { + clearWorkspaceRepairHistory, + recordWorkspaceRepair, +} from "./workspaceHealthTelemetry"; + +const payload = { + generated_at: "2026-03-02T00:00:00.000Z", + app_version: "0.76.0", + platform: "MacIntel", + user_agent: "ProxyCast-Test", + locale: "zh-CN", + timezone: "Asia/Shanghai", + page_url: "tauri://localhost/settings", + runtime: "tauri" as const, + crash_reporting: { + enabled: true, + dsn: null, + environment: "production", + sample_rate: 1, + send_pii: false, + }, + frontend_crash_logs: [], +}; + +describe("copyCrashDiagnosticToClipboard", () => { + afterEach(() => { + vi.restoreAllMocks(); + clearWorkspaceRepairHistory(); + }); + + it("应支持复制纯 JSON", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + await copyCrashDiagnosticJsonToClipboard(payload); + + expect(writeText).toHaveBeenCalledWith(JSON.stringify(payload, null, 2)); + }); + + it("优先使用 navigator.clipboard 写入", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + await copyCrashDiagnosticToClipboard(payload); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith( + buildCrashDiagnosticClipboardText(payload), + ); + }); + + it("clipboard API 失败时回退 execCommand", async () => { + const writeText = vi + .fn() + .mockRejectedValue( + new Error( + "The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.", + ), + ); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + const execCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + await copyCrashDiagnosticToClipboard(payload); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(execCommand).toHaveBeenCalledWith("copy"); + }); + + it("全部复制方案失败时返回中文可操作错误", async () => { + const writeText = vi + .fn() + .mockRejectedValue(new Error("NotAllowedError: denied permission")); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + const execCommand = vi.fn().mockReturnValue(false); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + await expect(copyCrashDiagnosticToClipboard(payload)).rejects.toThrow( + "剪贴板权限被系统拒绝,请允许权限后重试,或使用“导出诊断 JSON”", + ); + }); +}); + +describe("clipboard permission guide", () => { + it("应正确识别平台", () => { + expect(detectDesktopPlatform("MacIntel", "")).toBe("macos"); + expect(detectDesktopPlatform("Win32", "")).toBe("windows"); + expect(detectDesktopPlatform("Linux x86_64", "")).toBe("linux"); + }); + + it("应返回分系统引导文案", () => { + const macGuide = getClipboardPermissionGuide("MacIntel", ""); + expect(macGuide.title).toContain("macOS"); + expect(macGuide.steps.length).toBeGreaterThan(0); + expect(macGuide.settingsUrl).toContain("x-apple.systempreferences"); + + const windowsGuide = getClipboardPermissionGuide("Win32", ""); + expect(windowsGuide.title).toContain("Windows"); + expect(windowsGuide.settingsUrl).toBe("ms-settings:clipboard"); + }); +}); + +describe("diagnostic clipboard text", () => { + it("应包含提示词和 JSON 诊断数据", () => { + const text = buildCrashDiagnosticClipboardText(payload); + expect(text).toContain("ProxyCast 故障诊断请求"); + expect(text).toContain("自动摘要"); + expect(text).toContain("你的任务"); + expect(text).toContain("诊断数据(JSON)"); + expect(text).toContain('"platform": "MacIntel"'); + }); +}); + +describe("diagnostic export file name", () => { + it("应支持场景标签并生成稳定文件名", () => { + const fileName = buildCrashDiagnosticFileName(payload, { + sceneTag: "Workspace Path Missing", + timestamp: new Date("2026-03-02T08:09:10.000Z").getTime(), + }); + + expect(fileName).toContain("proxycast-crash-workspace-path-missing"); + expect(fileName).toContain("v-0-76-0"); + expect(fileName).toContain("20260302-"); + expect(fileName.endsWith(".json")).toBe(true); + }); + + it("应对场景标签做安全清洗", () => { + expect(sanitizeDiagnosticSceneTag(" Crash@Recovery/中文 ")).toBe( + "crash-recovery", + ); + expect(sanitizeDiagnosticSceneTag("___")).toBeUndefined(); + }); +}); + +describe("buildCrashDiagnosticPayload", () => { + afterEach(() => { + clearWorkspaceRepairHistory(); + }); + + it("应注入 workspace 自动修复记录", () => { + recordWorkspaceRepair({ + workspaceId: "ws-001", + rootPath: "/tmp/ws-001", + source: "workspace_refresh", + }); + + const diagnostic = buildCrashDiagnosticPayload({ + crashConfig: payload.crash_reporting, + logs: payload.frontend_crash_logs, + appVersion: payload.app_version, + platform: payload.platform, + userAgent: payload.user_agent, + }); + + expect(diagnostic.workspace_repair_history?.length).toBe(1); + expect(diagnostic.workspace_repair_history?.[0].workspace_id).toBe( + "ws-001", + ); + expect(diagnostic.workspace_repair_history?.[0].source).toBe( + "workspace_refresh", + ); + }); +}); diff --git a/src/lib/crashDiagnostic.ts b/src/lib/crashDiagnostic.ts index d61a0ea0b..36a40a16e 100644 --- a/src/lib/crashDiagnostic.ts +++ b/src/lib/crashDiagnostic.ts @@ -2,14 +2,60 @@ import type { CrashReportingConfig, LogEntry, } from "@/hooks/useTauri"; +import { + getInvokeErrorBuffer, + safeInvoke, + type InvokeErrorBufferEntry, +} from "@/lib/dev-bridge"; +import { getRuntimeAppVersion } from "@/lib/appVersion"; +import { + getFrontendCrashBuffer, + type FrontendCrashBufferEntry, +} from "@/lib/crashReporting"; +import { + getWorkspaceRepairHistory, + type WorkspaceRepairRecord, +} from "@/lib/workspaceHealthTelemetry"; export interface CrashDiagnosticPayload { generated_at: string; app_version: string; platform: string; user_agent: string; + locale: string; + timezone: string; + page_url: string; + runtime: "tauri" | "browser"; crash_reporting: CrashReportingConfig; frontend_crash_logs: LogEntry[]; + frontend_crash_buffer?: FrontendCrashBufferEntry[]; + invoke_error_buffer?: InvokeErrorBufferEntry[]; + persisted_log_tail?: LogEntry[]; + workspace_repair_history?: WorkspaceRepairRecord[]; + diagnostic_collection_notes?: string[]; +} + +export type DesktopPlatform = "macos" | "windows" | "linux" | "unknown"; + +export interface ClipboardPermissionGuide { + platform: DesktopPlatform; + title: string; + steps: string[]; + settingsUrl?: string; +} + +export interface CrashDiagnosticExportResult { + fileName: string; + locationHint: string; +} + +export interface CrashDiagnosticExportOptions { + sceneTag?: string; + timestamp?: number; +} + +export interface OpenDownloadDirectoryResult { + openedPath: string; } export const DEFAULT_CRASH_REPORTING_CONFIG: CrashReportingConfig = { @@ -58,10 +104,15 @@ export function pickFrontendCrashLogs( interface BuildCrashDiagnosticPayloadParams { crashConfig: CrashReportingConfig; logs: LogEntry[]; + persistedLogTail?: LogEntry[]; + collectionNotes?: string[]; appVersion?: string; platform: string; userAgent: string; maxCrashLogs?: number; + maxInvokeErrors?: number; + maxPersistedLogs?: number; + maxWorkspaceRepairs?: number; } export function buildCrashDiagnosticPayload( @@ -70,43 +121,466 @@ export function buildCrashDiagnosticPayload( const { crashConfig, logs, + persistedLogTail = [], + collectionNotes = [], appVersion, platform, userAgent, maxCrashLogs = 30, + maxInvokeErrors = 40, + maxPersistedLogs = 200, + maxWorkspaceRepairs = 50, } = params; return { generated_at: new Date().toISOString(), - app_version: appVersion || "unknown", + app_version: getRuntimeAppVersion(appVersion), platform, user_agent: userAgent, + locale: + (typeof navigator !== "undefined" && navigator.language) || "unknown", + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown", + page_url: + (typeof window !== "undefined" && window.location?.href) || "unknown", + runtime: isTauriRuntime() ? "tauri" : "browser", crash_reporting: { ...normalizeCrashReportingConfig(crashConfig), dsn: maskCrashReportingDsn(crashConfig.dsn), }, frontend_crash_logs: pickFrontendCrashLogs(logs, maxCrashLogs), + frontend_crash_buffer: getFrontendCrashBuffer(maxCrashLogs), + invoke_error_buffer: getInvokeErrorBuffer(maxInvokeErrors), + persisted_log_tail: persistedLogTail.slice(-maxPersistedLogs), + workspace_repair_history: getWorkspaceRepairHistory(maxWorkspaceRepairs), + diagnostic_collection_notes: collectionNotes.filter((item) => + typeof item === "string" && item.trim().length > 0 + ), }; } export async function copyCrashDiagnosticToClipboard( payload: CrashDiagnosticPayload, ): Promise { - await navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); + const text = buildCrashDiagnosticClipboardText(payload); + await copyTextToClipboard(text); +} + +export async function copyCrashDiagnosticJsonToClipboard( + payload: CrashDiagnosticPayload, +): Promise { + const text = JSON.stringify(payload, null, 2); + await copyTextToClipboard(text); +} + +async function copyTextToClipboard(text: string): Promise { + let lastError: unknown; + + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch (error) { + lastError = error; + if (copyTextWithExecCommand(text)) { + return; + } + } + } + + if (copyTextWithExecCommand(text)) { + return; + } + + if (await copyTextViaTauriClipboard(text)) { + return; + } + + if (lastError) { + throw new Error(formatClipboardCopyError(lastError)); + } + + throw new Error("复制诊断信息失败,请重试或使用“导出诊断 JSON”"); +} + +export function buildCrashDiagnosticClipboardText( + payload: CrashDiagnosticPayload, +): string { + const json = JSON.stringify(payload, null, 2); + const summary = buildDiagnosticSummary(payload); + return `# ProxyCast 故障诊断请求(可直接给 AI) + +请你扮演资深全栈工程师,基于下方诊断数据定位问题并给出可落地修复方案。 + +## 自动摘要 +${summary} + +## 你的任务 +1. 先判断最可能的根因(按概率排序,至少 3 条) +2. 给出最小改动修复方案(前端/后端分别说明) +3. 提供可直接执行的验证步骤(手工 + 自动化) +4. 标注高风险改动点与回滚方案 +5. 如果信息不足,请列出最小补充信息清单 + +## 输出格式要求 +- 根因分析 +- 修复方案(含关键代码位置) +- 验证步骤 +- 风险与回滚 +- 仍需补充的信息 + +## 诊断数据(JSON) +\`\`\`json +${json} +\`\`\` + +## 复现补充(请按需补充) +- 操作路径: +- 期望结果: +- 实际结果: +- 首次出现版本: +- 发生频率: +`; +} + +function buildDiagnosticSummary(payload: CrashDiagnosticPayload): string { + const crashLogCount = payload.frontend_crash_logs.length; + const localCrashCount = payload.frontend_crash_buffer?.length ?? 0; + const invokeErrorCount = payload.invoke_error_buffer?.length ?? 0; + const persistedLogCount = payload.persisted_log_tail?.length ?? 0; + const workspaceRepairCount = payload.workspace_repair_history?.length ?? 0; + const dsnConfigured = payload.crash_reporting.dsn ? "是" : "否"; + return [ + `- 版本:${payload.app_version}`, + `- 平台:${payload.platform}(${payload.runtime})`, + `- 语言/时区:${payload.locale} / ${payload.timezone}`, + `- 页面:${payload.page_url}`, + `- 崩溃日志条数:${crashLogCount}`, + `- 本地崩溃缓存条数:${localCrashCount}`, + `- 命令调用失败缓存条数:${invokeErrorCount}`, + `- 持久化日志尾部行数:${persistedLogCount}`, + `- Workspace 自动修复记录条数:${workspaceRepairCount}`, + `- 崩溃上报已启用:${payload.crash_reporting.enabled ? "是" : "否"}(DSN 已配置:${dsnConfigured})`, + ].join("\n"); +} + +function copyTextWithExecCommand(text: string): boolean { + if (typeof document === "undefined" || typeof document.execCommand !== "function") { + return false; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "0"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, text.length); + + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + document.body.removeChild(textarea); + } +} + +function formatClipboardCopyError(error: unknown): string { + if (isClipboardPermissionDeniedError(error)) { + return "剪贴板权限被系统拒绝,请允许权限后重试,或使用“导出诊断 JSON”"; + } + + const rawMessage = error instanceof Error ? error.message : String(error); + const normalizedMessage = rawMessage.toLowerCase(); + + if (normalizedMessage.includes("document is not focused")) { + return "当前窗口未激活,先点击应用窗口后重试,或使用“导出诊断 JSON”"; + } + + return "复制诊断信息失败,请重试或使用“导出诊断 JSON”"; +} + +export function isClipboardPermissionDeniedError(error: unknown): boolean { + const rawMessage = collectErrorMessages(error); + const normalizedMessage = rawMessage.toLowerCase(); + return ( + normalizedMessage.includes("not allowed by the user agent") || + normalizedMessage.includes("denied permission") || + normalizedMessage.includes("notallowederror") || + normalizedMessage.includes("权限被系统拒绝") || + normalizedMessage.includes("权限拒绝") + ); +} + +function collectErrorMessages(error: unknown): string { + const rootMessage = error instanceof Error ? error.message : String(error); + if ( + error instanceof Error && + "cause" in error && + error.cause instanceof Error + ) { + return `${rootMessage} ${error.cause.message}`; + } + return rootMessage; +} + +export function detectDesktopPlatform( + platform = typeof navigator !== "undefined" ? navigator.platform : "", + userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "", +): DesktopPlatform { + const platformText = `${platform} ${userAgent}`.toLowerCase(); + if (platformText.includes("mac")) return "macos"; + if (platformText.includes("win")) return "windows"; + if (platformText.includes("linux") || platformText.includes("x11")) { + return "linux"; + } + return "unknown"; +} + +export function getClipboardPermissionGuide( + platform = typeof navigator !== "undefined" ? navigator.platform : "", + userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "", +): ClipboardPermissionGuide { + const detectedPlatform = detectDesktopPlatform(platform, userAgent); + + if (detectedPlatform === "macos") { + return { + platform: detectedPlatform, + title: "macOS 剪贴板权限指引", + steps: [ + "先点击 ProxyCast 窗口任意区域,再重试复制。", + "打开“系统设置 → 隐私与安全性 → 辅助功能”,确认 ProxyCast 已启用。", + "若仍失败,请使用“导出诊断 JSON”并发送给开发者。", + ], + settingsUrl: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + }; + } + + if (detectedPlatform === "windows") { + return { + platform: detectedPlatform, + title: "Windows 剪贴板权限指引", + steps: [ + "先点击 ProxyCast 窗口任意区域,再重试复制。", + "打开“设置 → 隐私和安全性 → 剪贴板”,确认系统剪贴板功能可用。", + "若企业策略限制剪贴板访问,请改用“导出诊断 JSON”。", + ], + settingsUrl: "ms-settings:clipboard", + }; + } + + if (detectedPlatform === "linux") { + return { + platform: detectedPlatform, + title: "Linux 剪贴板权限指引", + steps: [ + "先点击 ProxyCast 窗口任意区域,再重试复制。", + "请在桌面环境隐私设置中确认应用未被限制访问剪贴板。", + "Wayland 环境若仍失败,建议使用“导出诊断 JSON”。", + ], + }; + } + + return { + platform: detectedPlatform, + title: "剪贴板权限指引", + steps: [ + "先点击 ProxyCast 窗口任意区域,再重试复制。", + "请在系统隐私设置中确认未禁止应用访问剪贴板。", + "若仍失败,请使用“导出诊断 JSON”。", + ], + }; } export function exportCrashDiagnosticToJson( payload: CrashDiagnosticPayload, -): void { + options: CrashDiagnosticExportOptions = {}, +): CrashDiagnosticExportResult { + const fileName = buildCrashDiagnosticFileName(payload, options); const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = `proxycast-crash-diagnostic-${Date.now()}.json`; + link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); + + return { + fileName, + locationHint: getDefaultDownloadDirectoryHint(), + }; +} + +export function buildCrashDiagnosticFileName( + payload: CrashDiagnosticPayload, + options: CrashDiagnosticExportOptions = {}, +): string { + const sceneTag = + sanitizeDiagnosticSceneTag(options.sceneTag) || + inferDiagnosticSceneTag(payload) || + "manual-export"; + const appVersionTag = + sanitizeDiagnosticSceneTag(`v-${payload.app_version || "unknown"}`) || + "v-unknown"; + const timestamp = formatDiagnosticTimestamp(options.timestamp ?? Date.now()); + return `proxycast-crash-${sceneTag}-${appVersionTag}-${timestamp}.json`; +} + +export async function openCrashDiagnosticDownloadDirectory(): Promise { + if (!isTauriRuntime()) { + throw new Error("当前环境不支持自动打开下载目录,请手动前往系统下载目录"); + } + + const platform = detectDesktopPlatform(); + const homeDir = await safeInvoke("get_home_dir").catch(() => ""); + const candidates = buildDownloadDirectoryCandidates(platform, homeDir); + + for (const path of candidates) { + try { + await safeInvoke("reveal_in_finder", { path }); + return { openedPath: path }; + } catch { + // continue + } + } + + throw new Error(`无法自动打开下载目录,请手动前往 ${getDefaultDownloadDirectoryHint()}`); +} + +async function copyTextViaTauriClipboard(text: string): Promise { + if (!isTauriRuntime()) { + return false; + } + + try { + const result = await safeInvoke("copy_machine_id_to_clipboard", { + machineId: text, + }); + + if (typeof result === "boolean") { + return result; + } + if ( + typeof result === "object" && + result !== null && + "success" in result + ) { + return Boolean((result as { success?: boolean }).success); + } + return Boolean(result); + } catch { + return false; + } +} + +function isTauriRuntime(): boolean { + return Boolean( + typeof window !== "undefined" && + ((window as any).__TAURI__?.core?.invoke || (window as any).__TAURI__?.invoke), + ); +} + +function getDefaultDownloadDirectoryHint(): string { + const platform = detectDesktopPlatform(); + if (platform === "windows") { + return "%USERPROFILE%/Downloads"; + } + if (platform === "macos") { + return "~/Downloads"; + } + if (platform === "linux") { + return "~/Downloads"; + } + return "系统默认下载目录"; +} + +export function sanitizeDiagnosticSceneTag( + input?: string | null, +): string | undefined { + if (!input) return undefined; + const normalized = input + .trim() + .toLowerCase() + .replace(/[\s_/]+/g, "-") + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 40); + return normalized || undefined; +} + +function inferDiagnosticSceneTag(payload: CrashDiagnosticPayload): string | undefined { + const notes = payload.diagnostic_collection_notes ?? []; + const joined = notes.join(" ").toLowerCase(); + if (joined.includes("workspace") && joined.includes("不存在")) { + return "workspace-path-missing"; + } + if (joined.includes("boundary_error")) { + return "crash-recovery"; + } + if (joined.includes("clipboard")) { + return "clipboard-permission"; + } + return undefined; +} + +function formatDiagnosticTimestamp(timestamp: number): string { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + return `${year}${month}${day}-${hours}${minutes}${seconds}`; +} + +function buildDownloadDirectoryCandidates( + platform: DesktopPlatform, + homeDirRaw: string, +): string[] { + const homeDir = homeDirRaw.trim(); + const delimiter = platform === "windows" ? "\\" : "/"; + const candidates: string[] = []; + + const pushCandidate = (value: string) => { + const normalized = value.trim(); + if (!normalized) return; + if (!candidates.includes(normalized)) { + candidates.push(normalized); + } + }; + + if (homeDir) { + const hasTrailingDelimiter = + homeDir.endsWith("/") || homeDir.endsWith("\\"); + const base = hasTrailingDelimiter + ? homeDir.slice(0, -1) + : homeDir; + pushCandidate(`${base}${delimiter}Downloads`); + if (platform === "windows") { + pushCandidate(`${base}\\downloads`); + } + pushCandidate(base); + } + + if (platform === "windows") { + pushCandidate("C:\\Users\\Public\\Downloads"); + } + if (platform === "linux") { + pushCandidate("/home"); + } + if (platform === "macos") { + pushCandidate("/Users"); + } + + return candidates; } diff --git a/src/lib/crashReporting.test.ts b/src/lib/crashReporting.test.ts index e34665b9f..70b78cfe3 100644 --- a/src/lib/crashReporting.test.ts +++ b/src/lib/crashReporting.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from "vitest"; -import { sanitizeCrashValue } from "./crashReporting"; +import { afterEach, describe, expect, it } from "vitest"; +import { + clearFrontendCrashBuffer, + getFrontendCrashBuffer, + reportFrontendError, + sanitizeCrashValue, +} from "./crashReporting"; + +afterEach(() => { + clearFrontendCrashBuffer(); +}); describe("crashReporting sanitizeCrashValue", () => { it("应脱敏常见密钥与 token", () => { @@ -32,4 +41,17 @@ describe("crashReporting sanitizeCrashValue", () => { expect(Array.isArray(sanitized.list)).toBe(true); expect(String((sanitized.list as unknown[])[2])).toContain("Bearer ***"); }); + + it("应将前端崩溃写入本地缓冲区", async () => { + clearFrontendCrashBuffer(); + await reportFrontendError(new Error("frontend buffer test"), { + source: "unit-test", + workflow_step: "test", + }); + + const buffer = getFrontendCrashBuffer(10); + expect(buffer.length).toBeGreaterThan(0); + expect(buffer[buffer.length - 1].message).toContain("frontend buffer test"); + expect(buffer[buffer.length - 1].source).toBe("unit-test"); + }); }); diff --git a/src/lib/crashReporting.ts b/src/lib/crashReporting.ts index de66127ed..259bf10f0 100644 --- a/src/lib/crashReporting.ts +++ b/src/lib/crashReporting.ts @@ -6,6 +6,7 @@ import { } from "@/hooks/useTauri"; import { safeInvoke } from "@/lib/dev-bridge"; import configEventManager from "@/lib/configEventManager"; +import { getRuntimeAppVersion } from "@/lib/appVersion"; export type CrashContext = Record; @@ -27,6 +28,17 @@ interface FrontendCrashReportPayload { context?: Record; } +export interface FrontendCrashBufferEntry { + timestamp: string; + message: string; + name?: string; + stack_preview?: string; + workflow_step?: string; + creation_mode?: string; + source?: string; + page_url?: string; +} + const SECRET_PATTERNS: Array<[RegExp, string]> = [ [/\bBearer\s+[A-Za-z0-9._-]+\b/gi, "Bearer ***"], [/\bapi[_-]?key\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, "api_key=***"], @@ -36,6 +48,9 @@ const SECRET_PATTERNS: Array<[RegExp, string]> = [ [/\bsk-[A-Za-z0-9]{12,}\b/g, "sk-***"], ]; +const FRONTEND_CRASH_BUFFER_KEY = "proxycast_frontend_crash_buffer_v1"; +const FRONTEND_CRASH_BUFFER_LIMIT = 80; + const DEFAULT_CRASH_REPORTING_CONFIG: ResolvedCrashReportingConfig = { enabled: true, dsn: null, @@ -146,9 +161,7 @@ function getRuntimeTags(context: CrashContext): Record { const tags: Record = { platform, - app_version: - (import.meta.env.VITE_APP_VERSION as string | undefined)?.trim() || - "unknown", + app_version: getRuntimeAppVersion(), }; const workflowStep = context.workflow_step; @@ -229,7 +242,79 @@ function buildCrashReportPayload( }; } +function readFrontendCrashBufferFromStorage(): FrontendCrashBufferEntry[] { + if (typeof window === "undefined") { + return []; + } + try { + const raw = window.localStorage.getItem(FRONTEND_CRASH_BUFFER_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .filter( + (item): item is FrontendCrashBufferEntry => + item && + typeof item === "object" && + typeof item.timestamp === "string" && + typeof item.message === "string", + ) + .slice(-FRONTEND_CRASH_BUFFER_LIMIT); + } catch { + return []; + } +} + +function writeFrontendCrashBufferToStorage( + items: FrontendCrashBufferEntry[], +): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem( + FRONTEND_CRASH_BUFFER_KEY, + JSON.stringify(items.slice(-FRONTEND_CRASH_BUFFER_LIMIT)), + ); + } catch { + // ignore + } +} + +function persistFrontendCrashToLocalBuffer(report: FrontendCrashReportPayload): void { + const stackPreview = report.stack + ? sanitizeText(report.stack).split("\n").slice(0, 3).join(" | ") + : undefined; + const entry: FrontendCrashBufferEntry = { + timestamp: new Date().toISOString(), + message: sanitizeText(report.message), + name: report.name ? sanitizeText(report.name) : undefined, + stack_preview: stackPreview, + workflow_step: report.workflow_step + ? sanitizeText(report.workflow_step) + : undefined, + creation_mode: report.creation_mode + ? sanitizeText(report.creation_mode) + : undefined, + source: + typeof report.context?.source === "string" + ? sanitizeText(String(report.context.source)) + : undefined, + page_url: + typeof window !== "undefined" ? sanitizeText(window.location.href) : undefined, + }; + + const current = readFrontendCrashBufferFromStorage(); + current.push(entry); + writeFrontendCrashBufferToStorage(current); +} + function persistFrontendCrash(report: FrontendCrashReportPayload): void { + persistFrontendCrashToLocalBuffer(report); void safeInvoke("report_frontend_crash", { report }).catch((error) => { console.warn("[CrashReporting] 写入后端崩溃日志失败:", error); }); @@ -398,3 +483,22 @@ export async function reportFrontendError( Sentry.captureException(normalizedError); }); } + +export function getFrontendCrashBuffer(limit = 30): FrontendCrashBufferEntry[] { + const safeLimit = Number.isFinite(limit) + ? Math.min(200, Math.max(1, Math.floor(limit))) + : 30; + const entries = readFrontendCrashBufferFromStorage(); + return entries.slice(-safeLimit); +} + +export function clearFrontendCrashBuffer(): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.removeItem(FRONTEND_CRASH_BUFFER_KEY); + } catch { + // ignore + } +} diff --git a/src/lib/dev-bridge/index.ts b/src/lib/dev-bridge/index.ts index 7e06d8e68..d8b05702d 100644 --- a/src/lib/dev-bridge/index.ts +++ b/src/lib/dev-bridge/index.ts @@ -18,4 +18,11 @@ export type { BridgeStatus, } from "./http-client"; -export { safeInvoke, safeListen, safeEmit } from "./safeInvoke"; +export { + safeInvoke, + safeListen, + safeEmit, + getInvokeErrorBuffer, + clearInvokeErrorBuffer, +} from "./safeInvoke"; +export type { InvokeErrorBufferEntry } from "./safeInvoke"; diff --git a/src/lib/dev-bridge/safeInvoke.ts b/src/lib/dev-bridge/safeInvoke.ts index 22b33108e..f8162b9fc 100644 --- a/src/lib/dev-bridge/safeInvoke.ts +++ b/src/lib/dev-bridge/safeInvoke.ts @@ -13,6 +13,153 @@ import { listen as baseListen, emit as baseEmit } from "@tauri-apps/api/event"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { invokeViaHttp, isDevBridgeAvailable } from "./http-client"; +export interface InvokeErrorBufferEntry { + timestamp: string; + command: string; + transport: "tauri-ipc" | "tauri-legacy" | "http-bridge" | "fallback-invoke"; + error: string; + args_preview?: Record; +} + +const INVOKE_ERROR_BUFFER_KEY = "proxycast_invoke_error_buffer_v1"; +const INVOKE_ERROR_BUFFER_LIMIT = 120; +const INVOKE_ERROR_TEXT_LIMIT = 800; + +const SECRET_PATTERNS: Array<[RegExp, string]> = [ + [/\bBearer\s+[A-Za-z0-9._-]+\b/gi, "Bearer ***"], + [/\bapi[_-]?key\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, "api_key=***"], + [/\baccess[_-]?token\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, "access_token=***"], + [/\brefresh[_-]?token\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, "refresh_token=***"], + [/\btoken\s*[:=]\s*["']?[A-Za-z0-9._-]{10,}["']?/gi, "token=***"], + [/\bsk-[A-Za-z0-9]{12,}\b/g, "sk-***"], +]; + +function sanitizeText(input: string): string { + let sanitized = input; + for (const [pattern, replacement] of SECRET_PATTERNS) { + sanitized = sanitized.replace(pattern, replacement); + } + return sanitized; +} + +function sanitizeValue(value: unknown, depth = 0): unknown { + if (depth > 4) { + return "[depth_limited]"; + } + if (value === null || value === undefined) { + return value; + } + if (typeof value === "string") { + return sanitizeText(value); + } + if (typeof value === "number" || typeof value === "boolean") { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => sanitizeValue(item, depth + 1)); + } + if (typeof value === "object") { + const output: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + output[key] = sanitizeValue(nestedValue, depth + 1); + } + return output; + } + return sanitizeText(String(value)); +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + const core = error.message || error.name || "Unknown error"; + return sanitizeText(core).slice(0, INVOKE_ERROR_TEXT_LIMIT); + } + return sanitizeText(String(error || "Unknown error")).slice( + 0, + INVOKE_ERROR_TEXT_LIMIT, + ); +} + +function readInvokeErrorBuffer(): InvokeErrorBufferEntry[] { + if (typeof window === "undefined") { + return []; + } + try { + const raw = window.localStorage.getItem(INVOKE_ERROR_BUFFER_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .filter( + (item): item is InvokeErrorBufferEntry => + item && + typeof item === "object" && + typeof item.timestamp === "string" && + typeof item.command === "string" && + typeof item.transport === "string" && + typeof item.error === "string", + ) + .slice(-INVOKE_ERROR_BUFFER_LIMIT); + } catch { + return []; + } +} + +function writeInvokeErrorBuffer(items: InvokeErrorBufferEntry[]): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem( + INVOKE_ERROR_BUFFER_KEY, + JSON.stringify(items.slice(-INVOKE_ERROR_BUFFER_LIMIT)), + ); + } catch { + // ignore + } +} + +function recordInvokeError( + command: string, + args: Record | undefined, + error: unknown, + transport: InvokeErrorBufferEntry["transport"], +): void { + const current = readInvokeErrorBuffer(); + const entry: InvokeErrorBufferEntry = { + timestamp: new Date().toISOString(), + command: sanitizeText(command), + transport, + error: toErrorMessage(error), + args_preview: args + ? (sanitizeValue(args) as Record) + : undefined, + }; + current.push(entry); + writeInvokeErrorBuffer(current); +} + +export function getInvokeErrorBuffer(limit = 50): InvokeErrorBufferEntry[] { + const safeLimit = Number.isFinite(limit) + ? Math.min(200, Math.max(1, Math.floor(limit))) + : 50; + return readInvokeErrorBuffer().slice(-safeLimit); +} + +export function clearInvokeErrorBuffer(): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.removeItem(INVOKE_ERROR_BUFFER_KEY); + } catch { + // ignore + } +} + /** * 安全的 Tauri invoke 封装 * 支持三种模式:Tauri IPC → HTTP Bridge → Mock @@ -26,12 +173,22 @@ export async function safeInvoke( typeof window !== "undefined" && (window as any).__TAURI__?.core?.invoke ) { - return (window as any).__TAURI__.core.invoke(cmd, args); + try { + return await (window as any).__TAURI__.core.invoke(cmd, args); + } catch (error) { + recordInvokeError(cmd, args, error, "tauri-ipc"); + throw error; + } } // Legacy check for older Tauri versions if (typeof window !== "undefined" && (window as any).__TAURI__?.invoke) { - return (window as any).__TAURI__.invoke(cmd, args); + try { + return await (window as any).__TAURI__.invoke(cmd, args); + } catch (error) { + recordInvokeError(cmd, args, error, "tauri-legacy"); + throw error; + } } // 2. Dev 模式下尝试 HTTP 桥接(浏览器环境,Tauri 后端在运行) @@ -39,13 +196,19 @@ export async function safeInvoke( try { const result = await invokeViaHttp(cmd, args); return result as T; - } catch { + } catch (error) { + recordInvokeError(cmd, args, error, "http-bridge"); // 继续尝试 mock } } // 3. Fallback 到 mock(Vite alias 会替换 @tauri-apps 导入) - return baseInvoke(cmd, args); + try { + return await baseInvoke(cmd, args); + } catch (error) { + recordInvokeError(cmd, args, error, "fallback-invoke"); + throw error; + } } /** diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index d04ecac82..8504478b9 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -13,6 +13,13 @@ const defaultMocks: Record = { host: "127.0.0.1", port: 8787, api_key: "", + response_cache: { + enabled: true, + ttl_secs: 600, + max_entries: 200, + max_body_bytes: 1048576, + cacheable_status_codes: [200], + }, tls: { enable: false, cert_path: null, @@ -126,6 +133,48 @@ const defaultMocks: Record = { port: 8787, requests: 0, uptime_secs: 0, + error_rate_1m: 0, + p95_latency_ms_1m: null, + open_circuit_count: 0, + active_requests: 0, + capability_routing: { + filter_eval_total: 0, + filter_excluded_total: 0, + filter_excluded_tools_total: 0, + filter_excluded_vision_total: 0, + filter_excluded_context_total: 0, + provider_fallback_total: 0, + model_fallback_total: 0, + all_candidates_excluded_total: 0, + }, + response_cache: { + size: 0, + hits: 0, + misses: 0, + evictions: 0, + }, + request_dedup: { + inflight_size: 0, + completed_size: 0, + check_new_total: 0, + check_in_progress_total: 0, + check_completed_total: 0, + wait_success_total: 0, + wait_timeout_total: 0, + wait_no_result_total: 0, + complete_total: 0, + remove_total: 0, + }, + idempotency: { + entries_size: 0, + in_progress_size: 0, + completed_size: 0, + check_new_total: 0, + check_in_progress_total: 0, + check_completed_total: 0, + complete_total: 0, + remove_total: 0, + }, }), check_server_status: () => ({ running: false, @@ -133,6 +182,48 @@ const defaultMocks: Record = { port: 8787, requests: 0, uptime_secs: 0, + error_rate_1m: 0, + p95_latency_ms_1m: null, + open_circuit_count: 0, + active_requests: 0, + capability_routing: { + filter_eval_total: 0, + filter_excluded_total: 0, + filter_excluded_tools_total: 0, + filter_excluded_vision_total: 0, + filter_excluded_context_total: 0, + provider_fallback_total: 0, + model_fallback_total: 0, + all_candidates_excluded_total: 0, + }, + response_cache: { + size: 0, + hits: 0, + misses: 0, + evictions: 0, + }, + request_dedup: { + inflight_size: 0, + completed_size: 0, + check_new_total: 0, + check_in_progress_total: 0, + check_completed_total: 0, + wait_success_total: 0, + wait_timeout_total: 0, + wait_no_result_total: 0, + complete_total: 0, + remove_total: 0, + }, + idempotency: { + entries_size: 0, + in_progress_size: 0, + completed_size: 0, + check_new_total: 0, + check_in_progress_total: 0, + check_completed_total: 0, + complete_total: 0, + remove_total: 0, + }, }), start_server: () => "Server started (mock)", stop_server: () => "Server stopped (mock)", @@ -807,6 +898,23 @@ const defaultMocks: Record = { generate_content_creator_tasks: () => 0, execution_run_list: () => [], execution_run_get: () => null, + + // Workspace 相关 + workspace_list: () => [], + workspace_get_default: () => null, + workspace_set_default: () => ({}), + workspace_get_by_path: () => null, + workspace_ensure_default_ready: () => null, + workspace_ensure_ready: (args: any) => ({ + workspaceId: args?.id ?? "mock-workspace", + rootPath: "~/mock-workspace", + existed: true, + created: false, + repaired: false, + relocated: false, + previousRootPath: null, + warning: null, + }), }; /** diff --git a/src/lib/workspaceHealthTelemetry.test.ts b/src/lib/workspaceHealthTelemetry.test.ts new file mode 100644 index 000000000..bb6a80009 --- /dev/null +++ b/src/lib/workspaceHealthTelemetry.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + clearWorkspaceRepairHistory, + getWorkspaceRepairHistory, + recordWorkspaceRepair, +} from "./workspaceHealthTelemetry"; + +describe("workspaceHealthTelemetry", () => { + afterEach(() => { + clearWorkspaceRepairHistory(); + }); + + it("应记录并读取修复历史(新记录优先)", () => { + recordWorkspaceRepair({ + workspaceId: "ws-1", + rootPath: "/tmp/ws-1", + source: "app_startup", + }); + recordWorkspaceRepair({ + workspaceId: "ws-2", + rootPath: "/tmp/ws-2", + source: "workspace_set_default", + }); + + const history = getWorkspaceRepairHistory(); + expect(history.length).toBe(2); + expect(history[0].workspace_id).toBe("ws-2"); + expect(history[1].workspace_id).toBe("ws-1"); + }); + + it("应限制最大记录条数为 50", () => { + for (let i = 1; i <= 55; i += 1) { + recordWorkspaceRepair({ + workspaceId: `ws-${i}`, + rootPath: `/tmp/ws-${i}`, + source: "workspace_refresh", + }); + } + + const history = getWorkspaceRepairHistory(); + expect(history.length).toBe(50); + expect(history[0].workspace_id).toBe("ws-55"); + expect(history[49].workspace_id).toBe("ws-6"); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index a54d0df07..ae563f052 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,9 +4,15 @@ import react from "@vitejs/plugin-react"; import svgr from "vite-plugin-svgr"; import path from "path"; import { fileURLToPath } from "url"; +import { readFileSync } from "fs"; // ES 模块中获取 __dirname 的方式 const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageJson = JSON.parse( + readFileSync(path.resolve(__dirname, "./package.json"), "utf-8"), +) as { version?: string }; +const appVersion = + process.env.VITE_APP_VERSION?.trim() || packageJson.version || "unknown"; // 获取 Tauri mock 目录路径 const tauriMockDir = path.resolve(__dirname, "./src/lib/tauri-mock"); @@ -29,6 +35,9 @@ export default defineConfig(({ mode }) => { }; return { + define: { + "import.meta.env.VITE_APP_VERSION": JSON.stringify(appVersion), + }, plugins: [ react({ jsxRuntime: mode === "development" ? "automatic" : "automatic",