From d4be8b22b9d637e6e7d70011fc9e37697a0acf64 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Fri, 28 Mar 2025 02:13:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B4=A9=E6=BA=83=E5=A4=84=E7=90=86):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=B4=A9=E6=BA=83=E5=A4=84=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E4=BB=A5=E6=8D=95=E8=8E=B7=E5=B9=B6=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加崩溃处理模块 `crash-handler`,用于捕获未处理的异常和未捕获的 Promise 拒绝,生成崩溃报告,并在崩溃时显示错误对话框。该模块还支持保存崩溃日志、清理旧报告以及在应用恢复时显示恢复对话框。此功能提高了应用的稳定性和可维护性,帮助开发者更好地诊断和解决崩溃问题。 --- packages/logger/crash-handler.ts | 486 ++++++++++++++++++++++++++++++ src/common/event/PlatForm/index.ts | 4 + src/common/event/PlatForm/main/command.ts | 6 + src/main/App.ts | 2 + src/renderer/src/components/NavBar.vue | 6 + tsconfig.node.json | 1 + tsconfig.web.json | 1 + 7 files changed, 506 insertions(+) create mode 100644 packages/logger/crash-handler.ts diff --git a/packages/logger/crash-handler.ts b/packages/logger/crash-handler.ts new file mode 100644 index 0000000..c8ce008 --- /dev/null +++ b/packages/logger/crash-handler.ts @@ -0,0 +1,486 @@ +import { app, dialog } from "electron" +import fs from "fs" +import path from "path" +import os from "os" +import logger from "./main" +import errorHandler, { ErrorDetail } from "./main-error" + +/** + * 崩溃报告接口 + */ +export interface CrashReport { + timestamp: string + error: ErrorDetail + systemInfo: { + platform: string + release: string + arch: string + totalMemory: number + freeMemory: number + uptime: number + } + appInfo: { + version: string + name: string + path: string + argv: string[] + } +} + +/** + * 崩溃处理配置 + */ +export interface CrashHandlerOptions { + crashReportDir?: string + maxReports?: number + showDialog?: boolean +} + +/** + * 默认崩溃处理配置 + */ +const DEFAULT_OPTIONS: CrashHandlerOptions = { + maxReports: 10, + showDialog: true, +} + +/** + * 崩溃处理类 + */ +export class CrashHandler { + private static instance: CrashHandler + private options: CrashHandlerOptions + private crashReportDir: string + private initialized: boolean = false + private startTime: number = Date.now() + private normalShutdown: boolean = false + + /** + * 获取单例实例 + */ + public static getInstance(): CrashHandler { + if (!CrashHandler.instance) { + CrashHandler.instance = new CrashHandler() + } + return CrashHandler.instance + } + + /** + * 构造函数 + */ + private constructor() { + this.options = { ...DEFAULT_OPTIONS } + this.crashReportDir = path.join(app.getPath("userData"), "crash-reports") + } + + /** + * 初始化崩溃处理器 + */ + public init(options?: CrashHandlerOptions): void { + if (this.initialized) { + return + } + + this.options = { ...this.options, ...options } + + if (options?.crashReportDir) { + this.crashReportDir = options.crashReportDir + } + + // 确保崩溃报告目录存在 + if (!fs.existsSync(this.crashReportDir)) { + fs.mkdirSync(this.crashReportDir, { recursive: true }) + } + + // 检查上次是否崩溃 + this.checkPreviousCrash() + + // 记录应用启动时间 + this.startTime = Date.now() + this.saveStartupMarker() + + // 设置全局未捕获异常处理器 + this.setupGlobalHandlers() + + // 设置应用退出处理 + app.on("before-quit", () => { + this.normalShutdown = true + this.clearStartupMarker() + }) + + this.initialized = true + logger.info("crash-handler", "Crash handler initialized") + } + + /** + * 设置全局未捕获异常处理器 + */ + private setupGlobalHandlers(): void { + // 增强现有的错误处理器 + const originalCaptureError = errorHandler.captureError.bind(errorHandler) + errorHandler.captureError = (error: any, componentInfo?: string, additionalInfo?: Record) => { + // 调用原始方法记录错误 + originalCaptureError(error, componentInfo, additionalInfo) + + // 对于严重错误,生成崩溃报告 + if (error instanceof Error && error.stack) { + this.generateCrashReport(error, componentInfo, additionalInfo) + } + } + + // 捕获未处理的Promise异常 + process.on("unhandledRejection", (reason, promise) => { + logger.error("crash-handler", `Unhandled Promise Rejection: ${reason}`) + this.generateCrashReport(reason, "unhandledRejection", { promise: String(promise) }) + }) + + // 捕获未捕获的异常 + process.on("uncaughtException", error => { + logger.error("crash-handler", `Uncaught Exception: ${error.message}`) + this.generateCrashReport(error, "uncaughtException") + + // 显示错误对话框 + if (this.options.showDialog) { + this.showCrashDialog(error) + } + }) + } + + /** + * 生成崩溃报告 + */ + private generateCrashReport(error: any, componentInfo?: string, additionalInfo?: Record): void { + try { + // 格式化错误信息 + const errorDetail = this.formatError(error, componentInfo, additionalInfo) + + // 创建崩溃报告 + const crashReport: CrashReport = { + timestamp: new Date().toISOString(), + error: errorDetail, + systemInfo: { + platform: os.platform(), + release: os.release(), + arch: os.arch(), + totalMemory: os.totalmem(), + freeMemory: os.freemem(), + uptime: os.uptime(), + }, + appInfo: { + version: app.getVersion(), + name: app.getName(), + path: app.getAppPath(), + argv: process.argv, + }, + } + + // 保存崩溃报告 + this.saveCrashReport(crashReport) + } catch (e) { + logger.error("crash-handler", `Failed to generate crash report: ${e}`) + } + } + + /** + * 格式化错误信息 + */ + private formatError(error: any, componentInfo?: string, additionalInfo?: Record): ErrorDetail { + // 基本错误信息 + const errorDetail: ErrorDetail = { + message: "", + timestamp: new Date().toISOString(), + type: "Unknown", + } + + // 处理不同类型的错误 + if (error instanceof Error) { + errorDetail.message = error.message + errorDetail.type = error.name || error.constructor.name + errorDetail.stack = error.stack + } else if (typeof error === "string") { + errorDetail.message = error + errorDetail.type = "String" + } else if (error === null) { + errorDetail.message = "Null error received" + errorDetail.type = "Null" + } else if (error === undefined) { + errorDetail.message = "Undefined error received" + errorDetail.type = "Undefined" + } else if (typeof error === "object") { + try { + errorDetail.message = error.message || JSON.stringify(error) + errorDetail.type = "Object" + errorDetail.additionalInfo = { ...error } + } catch (e) { + errorDetail.message = "Unserializable error object" + errorDetail.type = "Unserializable" + } + } else { + try { + errorDetail.message = String(error) + errorDetail.type = typeof error + } catch (e) { + errorDetail.message = "Error converting to string" + errorDetail.type = "Unknown" + } + } + + // 添加组件信息 + if (componentInfo) { + errorDetail.componentInfo = componentInfo + } + + // 添加额外信息 + if (additionalInfo) { + errorDetail.additionalInfo = { + ...errorDetail.additionalInfo, + ...additionalInfo, + } + } + + return errorDetail + } + + /** + * 保存崩溃报告 + */ + private saveCrashReport(report: CrashReport): void { + try { + // 生成唯一的崩溃报告文件名 + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const filename = `crash-${timestamp}.json` + const filepath = path.join(this.crashReportDir, filename) + + // 写入崩溃报告 + fs.writeFileSync(filepath, JSON.stringify(report, null, 2)) + logger.info("crash-handler", `Crash report saved: ${filepath}`) + + // 清理旧的崩溃报告 + this.cleanupOldReports() + } catch (e) { + logger.error("crash-handler", `Failed to save crash report: ${e}`) + } + } + + /** + * 清理旧的崩溃报告 + */ + private cleanupOldReports(): void { + try { + // 获取所有崩溃报告文件 + const files = fs + .readdirSync(this.crashReportDir) + .filter(file => file.startsWith("crash-") && file.endsWith(".json")) + .map(file => ({ + name: file, + path: path.join(this.crashReportDir, file), + time: fs.statSync(path.join(this.crashReportDir, file)).mtime.getTime(), + })) + .sort((a, b) => b.time - a.time) // 按时间降序排序 + + // 删除超出最大数量的旧报告 + if (files.length > this.options.maxReports!) { + const filesToDelete = files.slice(this.options.maxReports!) + filesToDelete.forEach(file => { + fs.unlinkSync(file.path) + logger.debug("crash-handler", `Deleted old crash report: ${file.name}`) + }) + } + } catch (e) { + logger.error("crash-handler", `Failed to cleanup old reports: ${e}`) + } + } + + /** + * 保存启动标记 + */ + private saveStartupMarker(): void { + try { + const markerPath = path.join(this.crashReportDir, "startup-marker.json") + const marker = { + startTime: this.startTime, + pid: process.pid, + } + fs.writeFileSync(markerPath, JSON.stringify(marker)) + } catch (e) { + logger.error("crash-handler", `Failed to save startup marker: ${e}`) + } + } + + /** + * 清除启动标记 + */ + private clearStartupMarker(): void { + try { + const markerPath = path.join(this.crashReportDir, "startup-marker.json") + if (fs.existsSync(markerPath)) { + fs.unlinkSync(markerPath) + } + } catch (e) { + logger.error("crash-handler", `Failed to clear startup marker: ${e}`) + } + } + + /** + * 检查上次是否崩溃 + */ + private checkPreviousCrash(): boolean { + try { + const markerPath = path.join(this.crashReportDir, "startup-marker.json") + // 如果存在启动标记,说明上次可能崩溃了 + if (fs.existsSync(markerPath)) { + const markerData = JSON.parse(fs.readFileSync(markerPath, "utf8")) + const lastStartTime = markerData.startTime + const lastPid = markerData.pid + + logger.warn( + "crash-handler", + `Found previous startup marker. App may have crashed. Last PID: ${lastPid}, Last start time: ${new Date(lastStartTime).toISOString()}`, + ) + + // 查找最近的崩溃报告 + const recentCrash = this.getRecentCrashReport() + + // 显示崩溃恢复对话框 + if (recentCrash && this.options.showDialog) { + app.whenReady().then(() => { + this.showRecoveryDialog(recentCrash) + }) + } + + // 清除旧的启动标记 + fs.unlinkSync(markerPath) + return true + } + } catch (e) { + logger.error("crash-handler", `Failed to check previous crash: ${e}`) + } + + return false + } + + /** + * 获取最近的崩溃报告 + */ + private getRecentCrashReport(): CrashReport | null { + try { + // 获取所有崩溃报告文件 + const files = fs + .readdirSync(this.crashReportDir) + .filter(file => file.startsWith("crash-") && file.endsWith(".json")) + .map(file => ({ + name: file, + path: path.join(this.crashReportDir, file), + time: fs.statSync(path.join(this.crashReportDir, file)).mtime.getTime(), + })) + .sort((a, b) => b.time - a.time) // 按时间降序排序 + + // 读取最近的崩溃报告 + if (files.length > 0) { + const recentFile = files[0] + const reportData = fs.readFileSync(recentFile.path, "utf8") + return JSON.parse(reportData) as CrashReport + } + } catch (e) { + logger.error("crash-handler", `Failed to get recent crash report: ${e}`) + } + + return null + } + + /** + * 显示崩溃对话框 + */ + private showCrashDialog(error: Error): void { + try { + const options = { + type: "error" as const, + title: "应用崩溃", + message: "应用遇到了一个严重错误,即将关闭", + detail: `错误信息: ${error.message}\n\n堆栈信息: ${error.stack}\n\n崩溃报告已保存,应用将在您点击确定后关闭。`, + buttons: ["确定"], + defaultId: 0, + } + + dialog.showMessageBoxSync(options) + + // 强制退出应用 + setTimeout(() => { + app.exit(1) + }, 1000) + } catch (e) { + logger.error("crash-handler", `Failed to show crash dialog: ${e}`) + app.exit(1) + } + } + + /** + * 显示恢复对话框 + */ + private showRecoveryDialog(crashReport: CrashReport): void { + try { + const crashTime = new Date(crashReport.timestamp).toLocaleString() + const errorMessage = crashReport.error.message + const errorType = crashReport.error.type + + const options = { + type: "warning" as const, + title: "应用恢复", + message: "应用上次异常退出", + detail: `应用在 ${crashTime} 因 ${errorType} 错误崩溃: ${errorMessage}\n\n崩溃报告已保存,您可以继续使用应用或联系开发者报告此问题。`, + buttons: ["继续", "查看详情"], + defaultId: 0, + } + + const response = dialog.showMessageBoxSync(options) + + // 如果用户选择查看详情 + if (response === 1) { + // 显示详细的崩溃报告 + this.showDetailedCrashInfo(crashReport) + } + } catch (e) { + logger.error("crash-handler", `Failed to show recovery dialog: ${e}`) + } + } + + /** + * 显示详细的崩溃信息 + */ + private showDetailedCrashInfo(crashReport: CrashReport): void { + try { + const options = { + type: "info" as const, + title: "崩溃详情", + message: "应用崩溃详细信息", + detail: JSON.stringify(crashReport, null, 2), + buttons: ["关闭"], + defaultId: 0, + } + + dialog.showMessageBoxSync(options) + } catch (e) { + logger.error("crash-handler", `Failed to show detailed crash info: ${e}`) + } + } + + /** + * 设置崩溃处理选项 + */ + public setOptions(options: Partial): void { + this.options = { ...this.options, ...options } + } + + /** + * 获取当前选项 + */ + public getOptions(): CrashHandlerOptions { + return { ...this.options } + } +} + +// 创建默认实例 +const crashHandler = CrashHandler.getInstance() + +export default crashHandler +export { crashHandler } diff --git a/src/common/event/PlatForm/index.ts b/src/common/event/PlatForm/index.ts index 4393b90..17aa522 100644 --- a/src/common/event/PlatForm/index.ts +++ b/src/common/event/PlatForm/index.ts @@ -14,6 +14,10 @@ class PlatForm extends _Base { return this.api.call("BasicService.showAbout") } + async crash() { + return this.api.call("PlatFormCommand.crash") + } + async isFullScreen() { return this.api.call("PlatFormCommand.isFullscreen") } diff --git a/src/common/event/PlatForm/main/command.ts b/src/common/event/PlatForm/main/command.ts index 1e0bfe5..f60cd40 100644 --- a/src/common/event/PlatForm/main/command.ts +++ b/src/common/event/PlatForm/main/command.ts @@ -1,5 +1,6 @@ import { app, dialog, nativeTheme, TitleBarOverlayOptions } from "electron" import { inject } from "inversify" +import errorHandler from "logger/main-error" import Tabs from "main/modules/tabs" import WindowManager from "main/modules/window-manager" @@ -39,6 +40,11 @@ export default class PlatFormCommand { } } + crash() { + errorHandler.captureError(new Error("手动触发的崩溃")) + process.crash() + } + isFullscreen() { const focusedWindow = this._WindowManager.getFocusWindow() if (focusedWindow) { diff --git a/src/main/App.ts b/src/main/App.ts index 6c2bcd9..7e368c2 100644 --- a/src/main/App.ts +++ b/src/main/App.ts @@ -11,6 +11,7 @@ import IOC from "./_ioc" import DB from "./modules/db" import Zephyr from "./modules/zephyr" import Updater from "./modules/updater" +import { crashHandler } from "logger/crash-handler" protocol.registerSchemesAsPrivileged([ // { @@ -61,6 +62,7 @@ class App extends BaseClass { } async init() { + crashHandler.init() this._Updater.init() this._DB.init() this._Command.init() diff --git a/src/renderer/src/components/NavBar.vue b/src/renderer/src/components/NavBar.vue index 0775f7f..3d02242 100644 --- a/src/renderer/src/components/NavBar.vue +++ b/src/renderer/src/components/NavBar.vue @@ -86,6 +86,12 @@ PlatForm.reload() }, }, + { + label: "崩溃", + async click() { + PlatForm.crash() + }, + }, ]) const obj = e.target.getBoundingClientRect() menu.show({ x: ~~obj.x, y: ~~(obj.y + obj.height) }) diff --git a/tsconfig.node.json b/tsconfig.node.json index 47bc714..b15a15f 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -9,6 +9,7 @@ "packages/setting/main.ts", "packages/logger/main.ts", "packages/logger/main-error.ts", + "packages/logger/crash-handler.ts", "packages/logger/preload.ts", "packages/logger/preload-error.ts", "packages/logger/common.ts", diff --git a/tsconfig.web.json b/tsconfig.web.json index e1df0be..4aa9783 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -20,6 +20,7 @@ "packages/setting/main.ts", "packages/logger/main.ts", "packages/logger/main-error.ts", + "packages/logger/crash-handler.ts", "src/common/**/main/**/*", "src/common/**/*.main.ts", "src/common/**/main.ts"