mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
co-authored by
Claude Sonnet 4.6
parent
68e16d4a04
commit
ff91552356
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user