Browse Source

feat(崩溃处理): 添加崩溃处理模块以捕获并报告应用崩溃

添加崩溃处理模块 `crash-handler`,用于捕获未处理的异常和未捕获的 Promise 拒绝,生成崩溃报告,并在崩溃时显示错误对话框。该模块还支持保存崩溃日志、清理旧报告以及在应用恢复时显示恢复对话框。此功能提高了应用的稳定性和可维护性,帮助开发者更好地诊断和解决崩溃问题。
feat/icon
npmrun 2 weeks ago
parent
commit
d4be8b22b9
  1. 486
      packages/logger/crash-handler.ts
  2. 4
      src/common/event/PlatForm/index.ts
  3. 6
      src/common/event/PlatForm/main/command.ts
  4. 2
      src/main/App.ts
  5. 6
      src/renderer/src/components/NavBar.vue
  6. 1
      tsconfig.node.json
  7. 1
      tsconfig.web.json

486
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<string, any>) => {
// 调用原始方法记录错误
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<string, any>): 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<string, any>): 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<CrashHandlerOptions>): void {
this.options = { ...this.options, ...options }
}
/**
*
*/
public getOptions(): CrashHandlerOptions {
return { ...this.options }
}
}
// 创建默认实例
const crashHandler = CrashHandler.getInstance()
export default crashHandler
export { crashHandler }

4
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")
}

6
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) {

2
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()

6
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) })

1
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",

1
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"

Loading…
Cancel
Save