diff --git a/package.json b/package.json index 31d6964a2..cd6061840 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2.8.0", "@tauri-apps/plugin-dialog": "^2.4.0", + "@tauri-apps/plugin-log": "2.8.0", "@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-store": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fe22cfc0..b9a556367 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2.4.0 version: 2.4.0 + '@tauri-apps/plugin-log': + specifier: 2.8.0 + version: 2.8.0 '@tauri-apps/plugin-process': specifier: ^2.0.0 version: 2.3.0 @@ -1562,6 +1565,9 @@ packages: '@tauri-apps/plugin-dialog@2.4.0': resolution: {integrity: sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w==} + '@tauri-apps/plugin-log@2.8.0': + resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==} + '@tauri-apps/plugin-process@2.3.0': resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==} @@ -4348,6 +4354,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.8.0 + '@tauri-apps/plugin-log@2.8.0': + dependencies: + '@tauri-apps/api': 2.8.0 + '@tauri-apps/plugin-process@2.3.0': dependencies: '@tauri-apps/api': 2.8.0 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8132efe91..94fe966bd 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,13 +2,12 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": [ - "main" - ], + "windows": ["main"], "permissions": [ "core:default", "opener:default", "updater:default", + "log:default", "core:window:allow-set-skip-taskbar", "core:window:allow-start-dragging", "core:window:allow-minimize", @@ -19,4 +18,4 @@ "process:allow-restart", "dialog:default" ] -} \ No newline at end of file +} diff --git a/src/components/FrontendErrorBoundary.tsx b/src/components/FrontendErrorBoundary.tsx new file mode 100644 index 000000000..9e4f15c3e --- /dev/null +++ b/src/components/FrontendErrorBoundary.tsx @@ -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 ( +
+
+
+ +
+

+ {i18n.t("errors.frontendCrashTitle", { + defaultValue: "界面遇到了问题", + })} +

+

+ {i18n.t("errors.frontendCrashMessage", { + defaultValue: + "已尝试将错误信息写入应用诊断日志。请重新加载界面;如果问题持续,请在提交 Issue 时附上日志。", + })} +

+
+
+ +
+
+ ); + } +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 714cbcacf..08f42b1ad 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2b61d2d2b..4fe3d8229 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": "ストリーミング初回バイトタイムアウト(秒)", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 5c6865868..f782f5c44 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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": "串流首位元組逾時(秒)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9bd52b7cb..7bf167a22 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "流式首字超时(秒)", diff --git a/src/lib/frontendLogger.ts b/src/lib/frontendLogger.ts new file mode 100644 index 000000000..c130ed79b --- /dev/null +++ b/src/lib/frontendLogger.ts @@ -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, + 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 = {}; + 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); + }; +} diff --git a/src/main.tsx b/src/main.tsx index 923f0ab41..fe4af84be 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - - - - + + + + + + , ); return; @@ -98,19 +107,21 @@ async function bootstrap() { } } catch (e) { // 忽略拉取错误,继续渲染 - console.error("拉取初始化错误失败", e); + reportFrontendError("get_init_error", e); } ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - - - - + + + + + + + + + + , ); } diff --git a/tests/components/FrontendErrorBoundary.test.tsx b/tests/components/FrontendErrorBoundary.test.tsx new file mode 100644 index 000000000..20fb57e11 --- /dev/null +++ b/tests/components/FrontendErrorBoundary.test.tsx @@ -0,0 +1,36 @@ +const reportFrontendError = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/frontendLogger", () => ({ + reportFrontendError, +})); + +import { render, screen } from "@testing-library/react"; +import { FrontendErrorBoundary } from "@/components/FrontendErrorBoundary"; + +function ThrowingChild(): React.ReactNode { + throw new Error("sensitive render failure"); +} + +describe("FrontendErrorBoundary", () => { + it("reports render failures and replaces the broken tree", () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + render( + + + , + ); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(reportFrontendError).toHaveBeenCalledWith( + "react.error_boundary", + expect.objectContaining({ message: "sensitive render failure" }), + expect.any(String), + ); + + consoleError.mockRestore(); + }); +}); diff --git a/tests/lib/frontendLogger.test.ts b/tests/lib/frontendLogger.test.ts new file mode 100644 index 000000000..660d34ddd --- /dev/null +++ b/tests/lib/frontendLogger.test.ts @@ -0,0 +1,327 @@ +const writeErrorLog = vi.hoisted(() => + vi.fn<(message: string, options: { file: string }) => Promise>(() => + Promise.resolve(), + ), +); + +vi.mock("@tauri-apps/plugin-log", () => ({ + error: writeErrorLog, +})); + +import { + installGlobalErrorHandlers, + redactFrontendLogText, + reportFrontendError, +} from "@/lib/frontendLogger"; + +describe("frontendLogger", () => { + beforeEach(() => { + writeErrorLog.mockClear(); + }); + + it("redacts URL parameters and named credentials", () => { + const redacted = redactFrontendLogText( + "https://example.test/path?apiKey=query-secret&name=alice\n" + + 'api_key: "config secret with spaces"\n' + + "Authorization: Bearer bearer-secret\n" + + "Authorization: Basic dXNlcjpwYXNz\n" + + "Authorization: Token token-secret\n" + + " Cookie: session=cookie-secret; preference=private\n" + + "ApiKey standalone-secret\n" + + "https://user:password@example.test/private", + ); + + expect(redacted).not.toContain("query-secret"); + expect(redacted).not.toContain("alice"); + expect(redacted).not.toContain("config secret with spaces"); + expect(redacted).not.toContain("secret with spaces"); + expect(redacted).not.toContain("bearer-secret"); + expect(redacted).not.toContain("dXNlcjpwYXNz"); + expect(redacted).not.toContain("token-secret"); + expect(redacted).not.toContain("standalone-secret"); + expect(redacted).not.toContain("cookie-secret"); + expect(redacted).not.toContain("preference=private"); + expect(redacted).not.toContain("user:password"); + expect(redacted).toContain("apiKey=[REDACTED]"); + expect(redacted).toContain('api_key: "[REDACTED]"'); + + const escapedMultiline = redactFrontendLogText( + '{"detail":"line1\\nsk-ant-api03-escaped-secret"}', + ); + expect(escapedMultiline).not.toContain("sk-ant-api03-escaped-secret"); + }); + + it("writes bounded, redacted errors through the Tauri log plugin", () => { + const secret = "sensitive-token"; + const oversizedDetails = `${"x".repeat(2_000_000)} token=${secret}`; + + reportFrontendError( + "window.error", + new Error(`failed at https://example.test/?key=${secret}`), + oversizedDetails, + ); + + expect(writeErrorLog).toHaveBeenCalledOnce(); + const [message, options] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain(secret); + expect(message).toContain("[truncated]"); + expect(message.length).toBeLessThanOrEqual(12_020); + expect(options).toEqual({ file: "frontend" }); + }); + + it("serializes object-shaped rejection reasons without exposing secrets", () => { + // 两层脱敏契约: + // - 属性名一级:命中敏感名(含复数/数组/嵌套)整个值一律隐藏; + // - 值一级:任意位置的“不透明密钥形状”按形状隐藏; + // - 文本一级:序列化后 `"name":"value"` 的裸密钥由正则兜底。 + reportFrontendError("unhandledrejection", { + code: 500, + message: "auth failed", // 良性文本保留(值含 "auth" 但非 name:value) + key: "short-secret", // 裸 key 标量(非不透明) → 属性名层 + token: "object-secret", // 标量命名 → 属性名层 + tokens: ["k-9f3a7c2b1e"], // 复数+数组(短值) → 属性名层(去尾 s + 整体隐藏) + auth: ["opaque-credential"], // 数组 → 属性名层 + credential: "AIzaRealCredential123", + nested: { detail: "ghp_abcdef123456" }, // 非敏感名,靠不透明形状 + values: ["eyJhbGciOiJIUzI1NiJ9.cGF5bG9hZA.c2lnbmF0dXJl"], // 数组内不透明形状 + multiline: "line1\nsk-ant-api03-multiline-secret", // 串内不透明形状 + session: { activeTab: "providers", scrollPos: 120 }, // 良性状态原样保留 + }); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).toContain('"code":500'); + expect(message).toContain('"message":"auth failed"'); + expect(message).not.toContain("short-secret"); + expect(message).not.toContain("object-secret"); + expect(message).not.toContain("k-9f3a7c2b1e"); + expect(message).not.toContain("opaque-credential"); + expect(message).not.toContain("AIzaRealCredential123"); + expect(message).not.toContain("ghp_abcdef123456"); + expect(message).not.toContain("eyJhbGciOiJIUzI1NiJ9"); + expect(message).not.toContain("sk-ant-api03-multiline-secret"); + expect(message).toContain('"key":"[REDACTED]"'); + expect(message).toContain('"token":"[REDACTED]"'); + expect(message).toContain('"tokens":"[REDACTED]"'); + expect(message).toContain('"auth":"[REDACTED]"'); + expect(message).toContain('"credential":"[REDACTED]"'); + expect(message).toContain( + '"session":{"activeTab":"providers","scrollPos":120}', + ); + }); + + it("applies property-level redaction to stringified-JSON rejection reasons", () => { + // 字符串形态的 JSON 不能只靠文本正则——数组/复数/裸标量字段会漏。 + reportFrontendError( + "unhandledrejection", + '{"tokens":["k-9f3a7c2b1e"],"auth":["opaque-credential"],"key":"short-secret","keepMe":"visible"}', + ); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain("k-9f3a7c2b1e"); + expect(message).not.toContain("opaque-credential"); + expect(message).not.toContain("short-secret"); + expect(message).toContain('"tokens":"[REDACTED]"'); + expect(message).toContain('"auth":"[REDACTED]"'); + expect(message).toContain('"key":"[REDACTED]"'); + expect(message).toContain('"keepMe":"visible"'); + }); + + it("keeps non-JSON error strings as readable text", () => { + reportFrontendError("window.error", "plain failure at step 3"); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).toContain("plain failure at step 3"); + }); + + it("applies property-level redaction to JSON wrapped in an Error message", () => { + // throw new Error(JSON.stringify(payload)) 会把凭据藏进 message, + // 而 error.stack 第一行原样吐出 message —— 必须先对 message 结构化脱敏。 + reportFrontendError( + "unhandledrejection", + new Error( + '{"tokens":["k-9f3a7c2b1e"],"key":"short-secret","keepMe":"visible"}', + ), + ); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain("k-9f3a7c2b1e"); + expect(message).not.toContain("short-secret"); + expect(message).toContain('"tokens":"[REDACTED]"'); + expect(message).toContain('"key":"[REDACTED]"'); + expect(message).toContain('"keepMe":"visible"'); + }); + + it("omits oversized JSON error strings instead of leaking truncated fields", () => { + // 合法但超长的 JSON 若先截断再 parse,必成非法 JSON 而退回文本层,数组字段泄漏。 + const padding = "x".repeat(20_000); + reportFrontendError( + "unhandledrejection", + `{"tokens":["k-9f3a7c2b1e"],"padding":"${padding}"}`, + ); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain("k-9f3a7c2b1e"); + expect(message).toContain("[oversized structured error omitted]"); + }); + + it("omits oversized JSON wrapped in an Error message", () => { + const padding = "x".repeat(20_000); + reportFrontendError( + "unhandledrejection", + new Error(`{"tokens":["k-9f3a7c2b1e"],"padding":"${padding}"}`), + ); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain("k-9f3a7c2b1e"); + expect(message).toContain("[oversized structured error omitted]"); + }); + + it("redacts array credentials in prefix+JSON rejection strings", () => { + // 前缀 + JSON:`redactStructuredString` 的 startsWith 门被前缀挡掉,退回文本层。 + // 文本层的容器正则必须在这个统一出口兜住 `"tokens":[...]`。 + reportFrontendError( + "unhandledrejection", + 'Load failed: {"tokens":["ak_live_7f3d9b21c8e4"]}', + ); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain("ak_live_7f3d9b21c8e4"); + expect(message).toContain("Load failed"); + }); + + it("redacts array credentials in prefix+JSON wrapped in an Error", () => { + reportFrontendError( + "unhandledrejection", + new Error( + "Provider provisioning failed: " + + JSON.stringify({ apiKeys: ["ak_live_7f3d9b21c8e4"] }), + ), + ); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain("ak_live_7f3d9b21c8e4"); + }); + + it("redacts credentials in double-encoded nested JSON (escaped quotes)", () => { + reportFrontendError( + "unhandledrejection", + new Error( + JSON.stringify({ + status: 400, + body: '{"keys":["abcd1234efgh5678ij"]}', + }), + ), + ); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain("abcd1234efgh5678ij"); + }); + + it("redacts array credentials in a POJO (non-Error) rejection", () => { + reportFrontendError("unhandledrejection", { + name: "HttpError", + message: '{"tokens":["opaqueTokenValue12345"]}', + code: 400, + }); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain("opaqueTokenValue12345"); + }); + + it("redacts container values only under sensitive keys", () => { + // 容器正则只对敏感键生效:普通数组/对象(items/config)与后缀含 key 的词(monkey)不误伤。 + const redacted = redactFrontendLogText( + '{"tokens":["k-secret-1"],"monkey":["visible-a"],"items":["visible-b"],"config":{"theme":"dark"}}', + ); + + expect(redacted).not.toContain("k-secret-1"); + expect(redacted).toContain("visible-a"); + expect(redacted).toContain("visible-b"); + expect(redacted).toContain('"theme":"dark"'); + }); + + it("preserves native WebKit-style stack frames for JSON-wrapped errors", () => { + // macOS/Linux 的 WKWebView 用 `fn@file:line:col` 格式,且 stack 不含 message。 + // 旧的 `/^\s+at\s/` 过滤会把整段栈丢掉;新实现须补脱敏头并保留原生栈。 + const err = new Error('{"tokens":["k-9f3a7c2b1e"]}'); + Object.defineProperty(err, "stack", { + value: + "handleClick@tauri://localhost/assets/index.js:42:15\n" + + "dispatch@tauri://localhost/assets/index.js:99:3", + }); + + reportFrontendError("unhandledrejection", err); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain("k-9f3a7c2b1e"); + expect(message).toContain('"tokens":"[REDACTED]"'); // 脱敏 message 头 + expect(message).toContain("handleClick@tauri://localhost"); // 原生栈帧保留 + expect(message).toContain("dispatch@tauri://localhost"); + }); + + it("replaces every occurrence of the raw message in a V8-style stack", () => { + // message 若在 stack 里出现多次(eval/匿名帧回显),字面量替换必须全部换掉,零残留。 + const err = new Error('{"tokens":["k-9f3a7c2b1e"]}'); + Object.defineProperty(err, "stack", { + value: + 'Error: {"tokens":["k-9f3a7c2b1e"]}\n' + + ' at eval (eval at , {"tokens":["k-9f3a7c2b1e"]}:1:1)\n' + + " at run (index.js:10:5)", + }); + + reportFrontendError("unhandledrejection", err); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain("k-9f3a7c2b1e"); + expect(message).toContain("at run (index.js:10:5)"); // 栈帧保留 + }); + + it("redacts standalone secret shapes in ordinary error text", () => { + reportFrontendError( + "window.error", + new Error("request failed with sk-ant-api03-real-secret"), + ); + + const [message] = writeErrorLog.mock.calls[0]; + expect(message).not.toContain("sk-ant-api03-real-secret"); + expect(message).toContain("[REDACTED]"); + }); + + it("handles circular rejection objects", () => { + const reason: Record = { message: "failed" }; + reason.self = reason; + + expect(() => + reportFrontendError("unhandledrejection", reason), + ).not.toThrow(); + expect(writeErrorLog.mock.calls[0][0]).toContain("[Circular]"); + }); + + it("captures global errors and unhandled rejections and can uninstall", () => { + const target = new EventTarget() as unknown as Window; + const uninstall = installGlobalErrorHandlers(target); + + const errorEvent = new Event("error") as ErrorEvent; + Object.defineProperties(errorEvent, { + error: { value: new Error("render failed") }, + filename: { value: "app.js" }, + lineno: { value: 10 }, + colno: { value: 4 }, + }); + target.dispatchEvent(errorEvent); + + const rejectionEvent = new Event( + "unhandledrejection", + ) as PromiseRejectionEvent; + Object.defineProperty(rejectionEvent, "reason", { + value: new Error("request failed"), + }); + target.dispatchEvent(rejectionEvent); + + expect(writeErrorLog).toHaveBeenCalledTimes(2); + + uninstall(); + target.dispatchEvent(errorEvent); + expect(writeErrorLog).toHaveBeenCalledTimes(2); + }); +});