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 <noreply@anthropic.com>
This commit is contained in:
coso
2026-03-02 17:52:19 +08:00
co-authored by Claude Sonnet 4.6
parent 68e16d4a04
commit ff91552356
11 changed files with 1191 additions and 14 deletions
+14
View File
@@ -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");
});
});
+37
View File
@@ -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 | null | undefined>
): 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,
);
}
+194
View File
@@ -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",
);
});
});
+478 -4
View File
@@ -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<void> {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
const text = buildCrashDiagnosticClipboardText(payload);
await copyTextToClipboard(text);
}
export async function copyCrashDiagnosticJsonToClipboard(
payload: CrashDiagnosticPayload,
): Promise<void> {
const text = JSON.stringify(payload, null, 2);
await copyTextToClipboard(text);
}
async function copyTextToClipboard(text: string): Promise<void> {
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<OpenDownloadDirectoryResult> {
if (!isTauriRuntime()) {
throw new Error("当前环境不支持自动打开下载目录,请手动前往系统下载目录");
}
const platform = detectDesktopPlatform();
const homeDir = await safeInvoke<string>("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<boolean> {
if (!isTauriRuntime()) {
return false;
}
try {
const result = await safeInvoke<unknown>("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;
}
+24 -2
View File
@@ -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");
});
});
+107 -3
View File
@@ -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<string, unknown>;
@@ -27,6 +28,17 @@ interface FrontendCrashReportPayload {
context?: Record<string, unknown>;
}
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<string, string> {
const tags: Record<string, string> = {
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
}
}
+8 -1
View File
@@ -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";
+167 -4
View File
@@ -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<string, unknown>;
}
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<string, unknown> = {};
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<string, unknown> | 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<string, unknown>)
: 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<T = any>(
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<T = any>(
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;
}
}
/**
+108
View File
@@ -13,6 +13,13 @@ const defaultMocks: Record<string, any> = {
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<string, any> = {
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<string, any> = {
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<string, any> = {
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,
}),
};
/**
+45
View File
@@ -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");
});
});
+9
View File
@@ -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",