mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(logging): capture frontend errors to disk with structured redaction
Frontend half of the logging overhaul. Capture: - Add a global error boundary plus window error/unhandledrejection handlers that persist renderer errors via tauri-plugin-log (new @tauri-apps/plugin-log dependency + log:default capability), so a white-screen crash leaves a trace. Redaction (two layers over every logged value): - Structured serializer: redact by sensitive property name (arrays and nested values included) and by opaque value shape. - Text layer, the universal final pass: redact URL credentials/query, auth schemes, known secret shapes, and named-key array/object values. The container rule closes prefix+JSON, double-encoded, and POJO-error bypasses regardless of which entrypoint the structured data came from. - Render Errors as redacted message + native stack across V8 and WebKit (macOS/Linux) without depending on a single stack format. - Bound all inputs and drop oversized JSON to keep the UI responsive. i18n: - zh / en / ja / zh-TW for the new log settings and error text.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import i18n from "@/i18n";
|
||||
import { reportFrontendError } from "@/lib/frontendLogger";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface FrontendErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class FrontendErrorBoundary extends React.Component<
|
||||
React.PropsWithChildren,
|
||||
FrontendErrorBoundaryState
|
||||
> {
|
||||
state: FrontendErrorBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): FrontendErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
reportFrontendError(
|
||||
"react.error_boundary",
|
||||
error,
|
||||
info.componentStack ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-background p-6 text-foreground">
|
||||
<section
|
||||
role="alert"
|
||||
className="w-full max-w-md space-y-5 rounded-lg border border-border bg-card p-6 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<TriangleAlert className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-base font-semibold">
|
||||
{i18n.t("errors.frontendCrashTitle", {
|
||||
defaultValue: "界面遇到了问题",
|
||||
})}
|
||||
</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{i18n.t("errors.frontendCrashMessage", {
|
||||
defaultValue:
|
||||
"已尝试将错误信息写入应用诊断日志。请重新加载界面;如果问题持续,请在提交 Issue 时附上日志。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
{i18n.t("errors.reloadInterface", {
|
||||
defaultValue: "重新加载界面",
|
||||
})}
|
||||
</Button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -397,10 +397,10 @@
|
||||
"cacheInjectionDescription": "Automatically inject standard 5-minute cache breakpoints at key positions to reduce duplicate token billing"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "Log Management",
|
||||
"description": "Control log output level",
|
||||
"enabled": "Enable Logging",
|
||||
"enabledDescription": "Master switch, all logging will be disabled when turned off",
|
||||
"title": "Application Diagnostic Logs",
|
||||
"description": "Control the output level for application diagnostic logs",
|
||||
"enabled": "Enable Diagnostic Logs",
|
||||
"enabledDescription": "Controls cc-switch.log; request usage records and crash.log are unaffected",
|
||||
"level": "Log Level",
|
||||
"levelDescription": "Set the minimum log level to output",
|
||||
"levels": {
|
||||
@@ -1701,7 +1701,10 @@
|
||||
"errors": {
|
||||
"usage_query_failed": "Usage query failed",
|
||||
"configLoadFailedTitle": "Configuration Load Failed",
|
||||
"configLoadFailedMessage": "Unable to read configuration file:\n{{path}}\n\nError details:\n{{detail}}\n\nPlease check if the JSON is valid, or restore from a backup file (e.g., config.json.bak) in the same directory.\n\nThe app will exit so you can fix this."
|
||||
"configLoadFailedMessage": "Unable to read configuration file:\n{{path}}\n\nError details:\n{{detail}}\n\nPlease check if the JSON is valid, or restore from a backup file (e.g., config.json.bak) in the same directory.\n\nThe app will exit so you can fix this.",
|
||||
"frontendCrashTitle": "Something went wrong in the interface",
|
||||
"frontendCrashMessage": "An attempt was made to write the error to the application diagnostic log. Reload the interface, and attach the log when opening an issue if the problem continues.",
|
||||
"reloadInterface": "Reload interface"
|
||||
},
|
||||
"presetSelector": {
|
||||
"title": "Select Configuration Type",
|
||||
@@ -2533,8 +2536,8 @@
|
||||
"description": "Maximum wait time for a single request (0 = unlimited, or 10 ~ 600 seconds)"
|
||||
},
|
||||
"enableLogging": {
|
||||
"label": "Enable Logging",
|
||||
"description": "Log all routing requests for troubleshooting"
|
||||
"label": "Record Request Usage",
|
||||
"description": "Write routing request usage and status to the local statistics database"
|
||||
},
|
||||
"streamingFirstByteTimeout": {
|
||||
"label": "Streaming First Byte Timeout (sec)",
|
||||
|
||||
@@ -397,10 +397,10 @@
|
||||
"cacheInjectionDescription": "リクエストの重要な位置に標準の 5 分間 Cache ブレークポイントを自動注入し、重複トークンの課金を削減"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "ログ管理",
|
||||
"description": "ログ出力レベルを制御",
|
||||
"enabled": "ログを有効化",
|
||||
"enabledDescription": "マスタースイッチ、オフにするとすべてのログが無効になります",
|
||||
"title": "アプリ診断ログ",
|
||||
"description": "アプリ診断ログの出力レベルを制御",
|
||||
"enabled": "診断ログを有効化",
|
||||
"enabledDescription": "cc-switch.log を制御します。リクエスト使用量記録と crash.log には影響しません",
|
||||
"level": "ログレベル",
|
||||
"levelDescription": "出力する最小ログレベルを設定",
|
||||
"levels": {
|
||||
@@ -1701,7 +1701,10 @@
|
||||
"errors": {
|
||||
"usage_query_failed": "利用状況の取得に失敗しました",
|
||||
"configLoadFailedTitle": "設定の読み込みに失敗しました",
|
||||
"configLoadFailedMessage": "設定ファイルを読み込めません:\n{{path}}\n\nエラー詳細:\n{{detail}}\n\nJSON が正しいか確認するか、同じディレクトリのバックアップファイル(config.json.bak など)から復元してください。\n\nアプリを終了して修正してください。"
|
||||
"configLoadFailedMessage": "設定ファイルを読み込めません:\n{{path}}\n\nエラー詳細:\n{{detail}}\n\nJSON が正しいか確認するか、同じディレクトリのバックアップファイル(config.json.bak など)から復元してください。\n\nアプリを終了して修正してください。",
|
||||
"frontendCrashTitle": "画面で問題が発生しました",
|
||||
"frontendCrashMessage": "エラー情報をアプリ診断ログに保存しようとしました。画面を再読み込みし、問題が続く場合は Issue にログを添付してください。",
|
||||
"reloadInterface": "画面を再読み込み"
|
||||
},
|
||||
"presetSelector": {
|
||||
"title": "設定タイプを選択",
|
||||
@@ -2533,8 +2536,8 @@
|
||||
"description": "単一リクエストの最大待機時間(0 = 無制限、または 10 ~ 600 秒)"
|
||||
},
|
||||
"enableLogging": {
|
||||
"label": "ログ記録を有効化",
|
||||
"description": "トラブルシューティングのためにすべてのルーティングリクエストを記録"
|
||||
"label": "リクエスト使用量を記録",
|
||||
"description": "ルーティングリクエストの使用量と状態をローカル統計データベースに保存"
|
||||
},
|
||||
"streamingFirstByteTimeout": {
|
||||
"label": "ストリーミング初回バイトタイムアウト(秒)",
|
||||
|
||||
@@ -397,10 +397,10 @@
|
||||
"cacheInjectionDescription": "自動在請求關鍵位置注入標準 5 分鐘 Cache 斷點,減少重複 token 計費"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "日誌管理",
|
||||
"description": "控制日誌輸出層級",
|
||||
"enabled": "啟用日誌",
|
||||
"enabledDescription": "總開關,關閉後所有日誌將被停用",
|
||||
"title": "應用程式診斷日誌",
|
||||
"description": "控制應用程式診斷日誌的輸出層級",
|
||||
"enabled": "啟用應用程式診斷日誌",
|
||||
"enabledDescription": "控制 cc-switch.log;不影響請求用量記錄和 crash.log",
|
||||
"level": "日誌層級",
|
||||
"levelDescription": "設定輸出的最低日誌層級",
|
||||
"levels": {
|
||||
@@ -1673,7 +1673,10 @@
|
||||
"errors": {
|
||||
"usage_query_failed": "用量查詢失敗",
|
||||
"configLoadFailedTitle": "設定載入失敗",
|
||||
"configLoadFailedMessage": "無法讀取設定檔:\n{{path}}\n\n錯誤詳情:\n{{detail}}\n\n請手動檢查 JSON 是否有效,或從同目錄的備份檔案(如 config.json.bak)還原。\n\n應用程式將退出以便您進行修復。"
|
||||
"configLoadFailedMessage": "無法讀取設定檔:\n{{path}}\n\n錯誤詳情:\n{{detail}}\n\n請手動檢查 JSON 是否有效,或從同目錄的備份檔案(如 config.json.bak)還原。\n\n應用程式將退出以便您進行修復。",
|
||||
"frontendCrashTitle": "介面遇到了問題",
|
||||
"frontendCrashMessage": "已嘗試將錯誤資訊寫入應用程式診斷日誌。請重新載入介面;如果問題持續,請在提交 Issue 時附上日誌。",
|
||||
"reloadInterface": "重新載入介面"
|
||||
},
|
||||
"presetSelector": {
|
||||
"title": "選擇設定類型",
|
||||
@@ -2505,8 +2508,8 @@
|
||||
"description": "單個請求的最大等待時間(0 表示不限制,或設定 10 ~ 600 秒)"
|
||||
},
|
||||
"enableLogging": {
|
||||
"label": "啟用日誌紀錄",
|
||||
"description": "紀錄所有路由請求,便於排查問題"
|
||||
"label": "記錄請求用量",
|
||||
"description": "將路由請求的用量與狀態寫入本機統計資料庫"
|
||||
},
|
||||
"streamingFirstByteTimeout": {
|
||||
"label": "串流首位元組逾時(秒)",
|
||||
|
||||
@@ -397,10 +397,10 @@
|
||||
"cacheInjectionDescription": "自动在请求关键位置注入标准 5 分钟 Cache 断点,减少重复 token 计费"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "日志管理",
|
||||
"description": "控制日志输出级别",
|
||||
"enabled": "启用日志",
|
||||
"enabledDescription": "总开关,关闭后所有日志将被禁用",
|
||||
"title": "应用诊断日志",
|
||||
"description": "控制应用诊断日志的输出级别",
|
||||
"enabled": "启用应用诊断日志",
|
||||
"enabledDescription": "控制 cc-switch.log;不影响请求用量记录和 crash.log",
|
||||
"level": "日志级别",
|
||||
"levelDescription": "设置输出的最低日志级别",
|
||||
"levels": {
|
||||
@@ -1701,7 +1701,10 @@
|
||||
"errors": {
|
||||
"usage_query_failed": "用量查询失败",
|
||||
"configLoadFailedTitle": "配置加载失败",
|
||||
"configLoadFailedMessage": "无法读取配置文件:\n{{path}}\n\n错误详情:\n{{detail}}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。"
|
||||
"configLoadFailedMessage": "无法读取配置文件:\n{{path}}\n\n错误详情:\n{{detail}}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。",
|
||||
"frontendCrashTitle": "界面遇到了问题",
|
||||
"frontendCrashMessage": "已尝试将错误信息写入应用诊断日志。请重新加载界面;如果问题持续,请在提交 Issue 时附上日志。",
|
||||
"reloadInterface": "重新加载界面"
|
||||
},
|
||||
"presetSelector": {
|
||||
"title": "选择配置类型",
|
||||
@@ -2533,8 +2536,8 @@
|
||||
"description": "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)"
|
||||
},
|
||||
"enableLogging": {
|
||||
"label": "启用日志记录",
|
||||
"description": "记录所有路由请求,便于排查问题"
|
||||
"label": "记录请求用量",
|
||||
"description": "将路由请求的用量与状态写入本地统计数据库"
|
||||
},
|
||||
"streamingFirstByteTimeout": {
|
||||
"label": "流式首字超时(秒)",
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { error as writeErrorLog } from "@tauri-apps/plugin-log";
|
||||
|
||||
const MAX_LOG_MESSAGE_LENGTH = 12_000;
|
||||
const MAX_RAW_LOG_INPUT_LENGTH = 16_000;
|
||||
const MAX_SERIALIZED_STRING_LENGTH = 2_000;
|
||||
const MAX_SERIALIZED_ENTRIES = 32;
|
||||
const MAX_SERIALIZED_TOTAL_VALUES = 64;
|
||||
const MAX_SERIALIZATION_DEPTH = 4;
|
||||
const QUERY_VALUE_PATTERN = /([?&][A-Za-z0-9_.~-]+)=([^&#\s"'<>]*)/g;
|
||||
const URL_CREDENTIAL_PATTERN = /(https?:\/\/)[^/@\s]+@/gi;
|
||||
const QUOTED_NAMED_SECRET_PATTERN =
|
||||
/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|authorization|auth|password|passwd|pwd|secret|cookie)\s*["']?\s*[:=]\s*)(["'])(.*?)\2/gi;
|
||||
const NAMED_SECRET_PATTERN =
|
||||
/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|authorization|auth|password|passwd|pwd|secret|cookie)\s*["']?\s*[:=]\s*["']?)([^\s"',}]+)/gi;
|
||||
// 敏感键的值是数组/对象(`"tokens":[...]`、`"auth":{...}`)时,标量正则够不着里面的元素。
|
||||
// 文本层是所有入口(Error/string/对象/嵌套/前缀+JSON)最终汇聚的唯一出口,故在这里
|
||||
// 兜底:命中敏感键名后把紧跟的 `[..]`/`{..}` 整体替换。`\b` 防止匹配到 monkey 之类的后缀,
|
||||
// `(?:\\?["'])?` 同时兼容裸引号与转义引号(双重编码 JSON 里的 `\"tokens\"`)。
|
||||
const NAMED_SECRET_CONTAINER_PATTERN =
|
||||
/((?:\\?["'])?\b(?:api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|session[_-]?id|authorization|credential|password|passwd|bearer|cookie|secret|token|auth|pwd|key)s?(?:\\?["'])?\s*[:=]\s*)(\[[^\]]*\]|\{[^{}]*\})/gi;
|
||||
const SENSITIVE_HEADER_LINE_PATTERN =
|
||||
/(^|[\r\n])([ \t]*(?:(?:proxy-)?authorization|cookie|set-cookie|x-api-key|api-key)\s*[:=]\s*)[^\r\n]+/gim;
|
||||
const AUTH_SCHEME_PATTERN =
|
||||
/\b(Bearer|Basic|Token|ApiKey|Digest|Negotiate|AWS4-HMAC-SHA256)\s+[^\s"',}\]]+/gi;
|
||||
const SECRET_VALUE_IN_TEXT_PATTERN =
|
||||
/(^|[^A-Za-z0-9]|\\[nrt])(?:sk-[A-Za-z0-9._~+\/-]{6,}|AIza[A-Za-z0-9_-]{8,}|github_pat_[A-Za-z0-9_]{6,}|gh[pousr]_[A-Za-z0-9_]{6,}|xox[baprs]-[A-Za-z0-9-]{6,}|ya29\.[A-Za-z0-9._-]{6,}|(?:AKIA|ASIA)[A-Z0-9]{12,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/gim;
|
||||
const SECRET_VALUE_WITHIN_STRING_PATTERN =
|
||||
/(?:sk-[A-Za-z0-9._~+\/-]{6,}|AIza[A-Za-z0-9_-]{8,}|github_pat_[A-Za-z0-9_]{6,}|gh[pousr]_[A-Za-z0-9_]{6,}|xox[baprs]-[A-Za-z0-9-]{6,}|ya29\.[A-Za-z0-9._-]{6,}|(?:AKIA|ASIA)[A-Z0-9]{12,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i;
|
||||
function truncateForProcessing(input: string, limit: number): string {
|
||||
if (input.length <= limit) {
|
||||
return input;
|
||||
}
|
||||
return `${input.slice(0, limit)}\n[input truncated]`;
|
||||
}
|
||||
|
||||
export function redactFrontendLogText(input: string): string {
|
||||
return input
|
||||
.replace(QUERY_VALUE_PATTERN, "$1=[REDACTED]")
|
||||
.replace(URL_CREDENTIAL_PATTERN, "$1[REDACTED]@")
|
||||
.replace(SENSITIVE_HEADER_LINE_PATTERN, "$1$2[REDACTED]")
|
||||
.replace(AUTH_SCHEME_PATTERN, "$1 [REDACTED]")
|
||||
.replace(SECRET_VALUE_IN_TEXT_PATTERN, "$1[REDACTED]")
|
||||
.replace(NAMED_SECRET_CONTAINER_PATTERN, "$1[REDACTED]")
|
||||
.replace(QUOTED_NAMED_SECRET_PATTERN, "$1$2[REDACTED]$2")
|
||||
.replace(NAMED_SECRET_PATTERN, "$1[REDACTED]");
|
||||
}
|
||||
|
||||
function looksLikeSecretValue(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (SECRET_VALUE_WITHIN_STRING_PATTERN.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unknown opaque credentials: keep this only in the structured serializer,
|
||||
// where a false positive costs diagnostics but cannot alter application data.
|
||||
return (
|
||||
trimmed.length >= 32 &&
|
||||
/^[A-Za-z0-9._~+/=-]+$/.test(trimmed) &&
|
||||
/[A-Za-z]/.test(trimmed) &&
|
||||
/\d/.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
// 结构化对象里,命中这些属性名即认定其整个值(标量/数组/对象)为敏感并整体隐藏。
|
||||
// 存单数形式,查表前去掉尾部 s,让复数(tokens/apiKeys/credentials)自动覆盖,
|
||||
// 不必逐个枚举——正则文本层只能匹配 `"name":"value"` 标量,抓不到数组/嵌套。
|
||||
const SENSITIVE_KEY_NAMES = new Set([
|
||||
"key",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"secretkey",
|
||||
"privatekey",
|
||||
"clientsecret",
|
||||
"token",
|
||||
"authtoken",
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"idtoken",
|
||||
"sessiontoken",
|
||||
"sessionid",
|
||||
"authorization",
|
||||
"auth",
|
||||
"bearer",
|
||||
"password",
|
||||
"passwd",
|
||||
"pwd",
|
||||
"secret",
|
||||
"credential",
|
||||
"cookie",
|
||||
]);
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const normalized = key
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "")
|
||||
.replace(/s$/, "");
|
||||
return SENSITIVE_KEY_NAMES.has(normalized);
|
||||
}
|
||||
|
||||
function normalizeForSerialization(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
ancestors: WeakSet<object>,
|
||||
budget: { remaining: number },
|
||||
): unknown {
|
||||
if (budget.remaining <= 0) {
|
||||
return "[Serialization budget exhausted]";
|
||||
}
|
||||
budget.remaining -= 1;
|
||||
|
||||
if (typeof value === "string") {
|
||||
// 值一级脱敏:对“看起来像密钥”的不透明串整体隐藏。命名字段(apiKey/token/...)
|
||||
// 由上层 isSensitiveKey 按属性名整体隐藏;文本里的裸密钥再由 redactFrontendLogText 兜底。
|
||||
if (looksLikeSecretValue(value)) {
|
||||
return "[REDACTED]";
|
||||
}
|
||||
return truncateForProcessing(value, MAX_SERIALIZED_STRING_LENGTH);
|
||||
}
|
||||
if (
|
||||
value == null ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return `${value}n`;
|
||||
}
|
||||
if (typeof value === "symbol") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
return `[Function ${value.name || "anonymous"}]`;
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return String(value);
|
||||
}
|
||||
if (ancestors.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
if (depth >= MAX_SERIALIZATION_DEPTH) {
|
||||
return "[Object: max depth reached]";
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
const items: unknown[] = [];
|
||||
for (const item of value.slice(0, MAX_SERIALIZED_ENTRIES)) {
|
||||
if (budget.remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
items.push(
|
||||
normalizeForSerialization(item, depth + 1, ancestors, budget),
|
||||
);
|
||||
}
|
||||
if (value.length > items.length) {
|
||||
items.push(`[${value.length - items.length} more items]`);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const output: Record<string, unknown> = {};
|
||||
const keys = Object.keys(value);
|
||||
for (const key of keys.slice(0, MAX_SERIALIZED_ENTRIES)) {
|
||||
if (budget.remaining <= 0) {
|
||||
output["[truncated]"] = "Serialization budget exhausted";
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor?.get) {
|
||||
output[key] = "[Getter omitted]";
|
||||
continue;
|
||||
}
|
||||
if (isSensitiveKey(key)) {
|
||||
// 敏感属性名 → 整个值(含数组/对象)一律隐藏,不递归、不猜形状。
|
||||
output[key] = "[REDACTED]";
|
||||
continue;
|
||||
}
|
||||
output[key] = normalizeForSerialization(
|
||||
descriptor?.value,
|
||||
depth + 1,
|
||||
ancestors,
|
||||
budget,
|
||||
);
|
||||
} catch {
|
||||
output[key] = "[Property access failed]";
|
||||
}
|
||||
}
|
||||
if (keys.length > MAX_SERIALIZED_ENTRIES) {
|
||||
output["[truncated]"] =
|
||||
`${keys.length - MAX_SERIALIZED_ENTRIES} more properties`;
|
||||
}
|
||||
return output;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeStructured(value: unknown): string | null {
|
||||
try {
|
||||
const serialized = JSON.stringify(
|
||||
normalizeForSerialization(value, 0, new WeakSet(), {
|
||||
remaining: MAX_SERIALIZED_TOTAL_VALUES,
|
||||
}),
|
||||
);
|
||||
return serialized ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 结构化数据可能以“字符串形态的 JSON”混进来(Promise.reject(JSON.stringify(...))、
|
||||
// throw new Error(JSON.stringify(...)))。不还原结构就只剩文本正则,够不着数组/嵌套字段。
|
||||
// 命中 JSON 结构则 parse 后走属性级脱敏;非 JSON 返回 null 交调用方按普通文本处理。
|
||||
function redactStructuredString(text: string): string | null {
|
||||
const trimmed = text.trim();
|
||||
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) {
|
||||
return null;
|
||||
}
|
||||
if (text.length > MAX_RAW_LOG_INPUT_LENGTH) {
|
||||
// 合法的超大 JSON 一旦截断必成非法 JSON,会退回够不着数组字段的文本正则并泄漏;
|
||||
// 也不冒险 parse 多 MiB 输入阻塞 UI。检出 JSON-like 且超限即整体丢弃。
|
||||
return "[oversized structured error omitted]";
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// 标量 JSON(如 "42")无字段可脱,交给文本层。
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return serializeStructured(parsed) ?? "[Unserializable structured error]";
|
||||
}
|
||||
|
||||
// 把 Error 渲染成“脱敏 message + 原生调用栈”,与浏览器引擎的 stack 格式无关:
|
||||
// - V8/Chromium(Windows WebView2):stack 首行内嵌 message → 全局字面量替换成脱敏版;
|
||||
// - WebKit/JSC(macOS/Linux WKWebView)、SpiderMonkey:stack 是纯栈帧、不含 message → 补脱敏头。
|
||||
// 不识别 ` at ` / `@` 等引擎特有格式(枚举不完,正是它把 WebKit 栈整段丢了),只按
|
||||
// “message 是否出现在 stack 里”分流,从而各平台都保留原生调用栈,且绝不残留未脱敏 message。
|
||||
function renderRedactedError(error: Error, structuredMessage: string): string {
|
||||
const head = `${error.name}: ${structuredMessage}`;
|
||||
const stack = error.stack;
|
||||
if (!stack) {
|
||||
return head;
|
||||
}
|
||||
if (error.message && stack.includes(error.message)) {
|
||||
// V8:message 内嵌在 stack —— 全局字面量替换(split/join 换掉所有出现),栈帧原样保留。
|
||||
return stack.split(error.message).join(structuredMessage);
|
||||
}
|
||||
// WebKit/Firefox:stack 不含 message —— 纯栈帧前补一个脱敏 message 头。
|
||||
return `${head}\n${stack}`;
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
// throw new Error(JSON.stringify(payload)) 很常见:凭据会藏进 message,而 V8 的
|
||||
// stack 首行就是原始 message。先对 message 按 JSON 结构化脱敏,命中则渲染成
|
||||
// “脱敏 message + 原生栈”,避免 stack 把未脱敏的 message 直接吐出去。
|
||||
const structuredMessage = redactStructuredString(error.message);
|
||||
if (structuredMessage !== null) {
|
||||
return truncateForProcessing(
|
||||
renderRedactedError(error, structuredMessage),
|
||||
MAX_RAW_LOG_INPUT_LENGTH,
|
||||
);
|
||||
}
|
||||
return truncateForProcessing(
|
||||
error.stack || `${error.name}: ${error.message}`,
|
||||
MAX_RAW_LOG_INPUT_LENGTH,
|
||||
);
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
const structured = redactStructuredString(error);
|
||||
if (structured !== null) {
|
||||
return structured;
|
||||
}
|
||||
return truncateForProcessing(error, MAX_RAW_LOG_INPUT_LENGTH);
|
||||
}
|
||||
if (error == null) {
|
||||
return String(error);
|
||||
}
|
||||
const structured = serializeStructured(error);
|
||||
if (structured === null) {
|
||||
return "[Unserializable thrown value]";
|
||||
}
|
||||
return truncateForProcessing(structured, MAX_RAW_LOG_INPUT_LENGTH);
|
||||
}
|
||||
|
||||
export function reportFrontendError(
|
||||
context: string,
|
||||
error: unknown,
|
||||
details?: string,
|
||||
): void {
|
||||
// 先限制每段原始输入,再执行全局正则,避免异常携带多 MiB 文本时阻塞 UI。
|
||||
const raw = truncateForProcessing(
|
||||
[
|
||||
`[frontend] ${truncateForProcessing(context, MAX_RAW_LOG_INPUT_LENGTH)}`,
|
||||
describeError(error),
|
||||
details
|
||||
? truncateForProcessing(details, MAX_RAW_LOG_INPUT_LENGTH).trim()
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
MAX_RAW_LOG_INPUT_LENGTH,
|
||||
);
|
||||
const redacted = redactFrontendLogText(raw);
|
||||
const message =
|
||||
redacted.length > MAX_LOG_MESSAGE_LENGTH
|
||||
? `${redacted.slice(0, MAX_LOG_MESSAGE_LENGTH)}\n[truncated]`
|
||||
: redacted;
|
||||
|
||||
// Web 开发/测试环境没有 Tauri invoke,日志上报失败不应再触发
|
||||
// console.error 或未处理 Promise,否则会形成错误循环。
|
||||
void writeErrorLog(message, { file: "frontend" }).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function installGlobalErrorHandlers(
|
||||
target: Window = window,
|
||||
): () => void {
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
const location = event.filename
|
||||
? `${event.filename}:${event.lineno}:${event.colno}`
|
||||
: undefined;
|
||||
reportFrontendError("window.error", event.error ?? event.message, location);
|
||||
};
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
reportFrontendError("unhandledrejection", event.reason);
|
||||
};
|
||||
|
||||
target.addEventListener("error", handleError);
|
||||
target.addEventListener("unhandledrejection", handleUnhandledRejection);
|
||||
|
||||
return () => {
|
||||
target.removeEventListener("error", handleError);
|
||||
target.removeEventListener("unhandledrejection", handleUnhandledRejection);
|
||||
};
|
||||
}
|
||||
+25
-14
@@ -14,6 +14,13 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { exit } from "@tauri-apps/plugin-process";
|
||||
import { FrontendErrorBoundary } from "./components/FrontendErrorBoundary";
|
||||
import {
|
||||
installGlobalErrorHandlers,
|
||||
reportFrontendError,
|
||||
} from "./lib/frontendLogger";
|
||||
|
||||
installGlobalErrorHandlers();
|
||||
|
||||
// 根据平台添加 body class,便于平台特定样式
|
||||
try {
|
||||
@@ -70,7 +77,7 @@ try {
|
||||
});
|
||||
} catch (e) {
|
||||
// 忽略事件订阅异常(例如在非 Tauri 环境下)
|
||||
console.error("订阅 configLoadError 事件失败", e);
|
||||
reportFrontendError("config_load_error_listener", e);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
@@ -83,10 +90,12 @@ async function bootstrap() {
|
||||
// 数据库版本过新:渲染应用内「升级应用」恢复界面,不进入正常 App
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
|
||||
<DatabaseUpgrade payload={initError} />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
<FrontendErrorBoundary>
|
||||
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
|
||||
<DatabaseUpgrade payload={initError} />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</FrontendErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
return;
|
||||
@@ -98,19 +107,21 @@ async function bootstrap() {
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略拉取错误,继续渲染
|
||||
console.error("拉取初始化错误失败", e);
|
||||
reportFrontendError("get_init_error", e);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
|
||||
<UpdateProvider>
|
||||
<App />
|
||||
<Toaster />
|
||||
</UpdateProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
<FrontendErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
|
||||
<UpdateProvider>
|
||||
<App />
|
||||
<Toaster />
|
||||
</UpdateProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</FrontendErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user