mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
Harden the startup migration path: fail fast and show an error dialog if schema migration cannot complete. Repair the gate retroactively for databases that were already migrated before the gate system existed. Raise the gate after every import path (full import, incremental import, stream import) and after opening any database that already carries a current-segment schema. Centralise gate-raising inside DatabaseManager so call sites cannot accidentally skip it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
433 lines
14 KiB
TypeScript
433 lines
14 KiB
TypeScript
import { app, shell, BrowserWindow, protocol, nativeTheme, dialog } from 'electron'
|
||
import { join } from 'path'
|
||
import { optimizer, is, platform } from '@electron-toolkit/utils'
|
||
import { checkUpdate } from './update'
|
||
import mainIpcMain, { cleanup } from './ipcMain'
|
||
import { startInternalServer, stopInternalServer, registerInternalApiIpc } from './internal-api'
|
||
import { getPathProvider } from './path-context'
|
||
import { initAnalytics, trackDailyActive } from './analytics'
|
||
import { initProxy } from './network/proxy'
|
||
import {
|
||
needsLegacyMigration,
|
||
migrateFromLegacyDir,
|
||
ensureAppDirs,
|
||
cleanupPendingDeleteDir,
|
||
applyPendingDataDirMigration,
|
||
needsUnifiedDirMigration,
|
||
migrateToUnifiedDirs,
|
||
verifyDataPath,
|
||
getSystemDataDir,
|
||
getAiDataDir,
|
||
} from './paths'
|
||
import { migrateAllDatabases, checkMigrationNeeded } from './database/core'
|
||
import {
|
||
assertDesktopStartupMigrationSucceeded,
|
||
repairDesktopStartupCompatibilityGate,
|
||
} from './database/startup-migration'
|
||
import { initLocale } from './i18n'
|
||
import { MigrationRunner, ALL_MIGRATIONS } from '@openchatlab/config'
|
||
import { assertDesktopDataDirCompatible, getDesktopAppVersion } from './runtime-compat'
|
||
import type { RuntimeIdentity } from '@openchatlab/node-runtime/src/data-dir-compat'
|
||
import {
|
||
applyCurrentTitleBarOverlay,
|
||
getTitleBarOverlayOptions,
|
||
resetCurrentTitleBarOverlayColor,
|
||
} from './window-titlebar'
|
||
|
||
type AppWithQuitFlag = typeof app & { isQuiting?: boolean }
|
||
// 统一通过扩展类型访问退出标记,避免使用 @ts-ignore。
|
||
const appWithQuitFlag = app as AppWithQuitFlag
|
||
|
||
class MainProcess {
|
||
mainWindow: BrowserWindow | null
|
||
isTestMode: boolean
|
||
constructor() {
|
||
// 主窗口
|
||
this.mainWindow = null
|
||
|
||
// E2E 测试模式检查:跳过遗留数据迁移和其他测试无关的初始化
|
||
this.isTestMode = process.env.TEST_MODE === 'true'
|
||
|
||
// E2E 测试隔离:为并行测试实例设置独立的用户数据目录
|
||
// 这防止了并发进程的状态泄漏、死锁和数据库冲突
|
||
const e2eUserDataDir = process.env.CHATLAB_E2E_USER_DATA_DIR
|
||
if (this.isTestMode && e2eUserDataDir) {
|
||
app.setPath('userData', e2eUserDataDir)
|
||
} else if (!this.isTestMode && e2eUserDataDir) {
|
||
console.warn('[Main] Ignored CHATLAB_E2E_USER_DATA_DIR because TEST_MODE is not enabled')
|
||
}
|
||
|
||
// 设置应用程序名称
|
||
if (process.platform === 'win32') app.setAppUserModelId(app.getName())
|
||
// 初始化
|
||
this.checkApp().then(async (lockObtained) => {
|
||
if (lockObtained) {
|
||
await this.init()
|
||
}
|
||
})
|
||
}
|
||
|
||
// 单例锁
|
||
async checkApp() {
|
||
// E2E 测试模式:绕过单实例锁以支持并行实例
|
||
const isTestMode = process.env.TEST_MODE === 'true'
|
||
if (isTestMode) {
|
||
return true
|
||
}
|
||
|
||
if (!app.requestSingleInstanceLock()) {
|
||
app.quit()
|
||
// 未获得锁
|
||
return false
|
||
}
|
||
// 聚焦到当前程序
|
||
else {
|
||
app.on('second-instance', () => {
|
||
if (this.mainWindow) {
|
||
this.mainWindow.show()
|
||
if (this.mainWindow.isMinimized()) this.mainWindow.restore()
|
||
this.mainWindow.focus()
|
||
}
|
||
})
|
||
// 获得锁
|
||
return true
|
||
}
|
||
}
|
||
|
||
// 初始化程序
|
||
async init() {
|
||
initAnalytics()
|
||
|
||
// E2E 测试模式:跳过遗留数据迁移
|
||
// 遗留迁移会删除 Documents/ChatLab,在本地测试时可能破坏用户数据
|
||
if (!this.isTestMode) {
|
||
// 应用上次设置页登记的数据目录切换;必须早于任何数据库连接初始化
|
||
this.applyPendingDataDirMigrationIfNeeded()
|
||
|
||
// 清理上次切换目录后的旧数据目录
|
||
cleanupPendingDeleteDir()
|
||
|
||
// 执行数据目录迁移(从 Documents/ChatLab 迁移到 userData)
|
||
this.migrateDataIfNeeded()
|
||
|
||
// 执行统一目录结构迁移(Electron 旧布局 → 双根目录)
|
||
this.migrateToUnifiedDirsIfNeeded()
|
||
}
|
||
|
||
// 验证数据路径是否正确(安全网:防止 config.toml 指向空目录)
|
||
verifyDataPath()
|
||
|
||
// 确保应用目录存在
|
||
ensureAppDirs()
|
||
|
||
let runtime: RuntimeIdentity
|
||
try {
|
||
runtime = assertDesktopDataDirCompatible(getPathProvider(), getDesktopAppVersion(app.getVersion()))
|
||
} catch (error) {
|
||
console.error('[Main] Data directory compatibility check failed:', error)
|
||
dialog.showErrorBox(
|
||
'ChatLab Data Directory Incompatible',
|
||
`ChatLab cannot open this data directory with the current desktop version.\n\n${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
)
|
||
app.quit()
|
||
return
|
||
}
|
||
|
||
// 执行配置数据迁移(Migration Runner,Electron 和 CLI 共享)
|
||
await new MigrationRunner(ALL_MIGRATIONS, {
|
||
dataDir: getSystemDataDir(),
|
||
aiDataDir: getAiDataDir(),
|
||
logger: {
|
||
info: (_cat: string, msg: string) => console.log(`[Migration] ${msg}`),
|
||
warn: (_cat: string, msg: string) => console.warn(`[Migration] ${msg}`),
|
||
error: (_cat: string, msg: string, ...args: unknown[]) => console.error(`[Migration] ${msg}`, ...args),
|
||
},
|
||
}).run()
|
||
|
||
// 初始化主进程国际化(在 ensureAppDirs 之后,确保 settings 目录存在)
|
||
await initLocale()
|
||
|
||
// 执行数据库 schema 迁移(确保所有数据库在 Worker 查询前已是最新 schema)
|
||
try {
|
||
this.migrateDatabasesIfNeeded(runtime)
|
||
} catch (error) {
|
||
console.error('[Main] Database schema migration failed:', error)
|
||
dialog.showErrorBox(
|
||
'ChatLab Database Migration Failed',
|
||
`ChatLab cannot start because database migration did not complete safely.\n\n${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
)
|
||
app.quit()
|
||
return
|
||
}
|
||
|
||
initProxy() // 初始化代理配置
|
||
|
||
// 暂不注册自定义协议,避免触发系统 URL 协议关联提示
|
||
|
||
// 应用程序准备好之前注册
|
||
protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: { secure: true, standard: true } }])
|
||
|
||
// 主应用程序事件
|
||
this.mainAppEvents()
|
||
}
|
||
|
||
// 应用设置页登记的数据目录切换(重启期执行)
|
||
applyPendingDataDirMigrationIfNeeded() {
|
||
const result = applyPendingDataDirMigration()
|
||
if (result.skipped) {
|
||
console.log('[Main] No pending data directory migration')
|
||
return
|
||
}
|
||
if (result.success) {
|
||
console.log('[Main] Pending data directory migration completed')
|
||
} else {
|
||
console.error('[Main] Pending data directory migration failed:', result.error)
|
||
}
|
||
}
|
||
|
||
// 从旧目录迁移数据(Documents/ChatLab → userData/data)
|
||
migrateDataIfNeeded() {
|
||
if (needsLegacyMigration()) {
|
||
console.log('[Main] Legacy data migration needed, starting migration...')
|
||
const result = migrateFromLegacyDir()
|
||
if (result.success) {
|
||
console.log(`[Main] Migration completed. Migrated: ${result.migratedDirs.join(', ')}`)
|
||
} else {
|
||
console.error('[Main] Migration failed:', result.error)
|
||
}
|
||
} else {
|
||
console.log('[Main] No legacy data migration needed')
|
||
}
|
||
}
|
||
|
||
// 从 Electron 旧目录结构迁移到新的双根目录结构
|
||
migrateToUnifiedDirsIfNeeded() {
|
||
if (needsUnifiedDirMigration()) {
|
||
console.log('[Main] Unified directory migration needed, starting...')
|
||
const result = migrateToUnifiedDirs()
|
||
if (result.success) {
|
||
console.log('[Main] Unified directory migration completed')
|
||
} else {
|
||
console.error('[Main] Unified directory migration failed:', result.error)
|
||
}
|
||
} else {
|
||
console.log('[Main] No unified directory migration needed')
|
||
}
|
||
}
|
||
|
||
// 执行启动期数据库 schema 迁移;失败时必须中断,避免 schema 已升级但兼容门禁未落盘。
|
||
migrateDatabasesIfNeeded(runtime: RuntimeIdentity) {
|
||
const { count } = checkMigrationNeeded()
|
||
if (count > 0) {
|
||
assertDesktopStartupMigrationSucceeded(migrateAllDatabases(runtime))
|
||
}
|
||
|
||
repairDesktopStartupCompatibilityGate(runtime, { pathProvider: getPathProvider() })
|
||
}
|
||
|
||
// 创建主窗口
|
||
async createWindow() {
|
||
// 平台差异化窗口配置
|
||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
||
width: 1180,
|
||
height: 752,
|
||
minWidth: 1180,
|
||
minHeight: 752,
|
||
show: false,
|
||
autoHideMenuBar: true,
|
||
webPreferences: {
|
||
preload: join(__dirname, '../preload/index.js'),
|
||
sandbox: false,
|
||
devTools: true,
|
||
},
|
||
}
|
||
|
||
// macOS: 使用 hiddenInset 保留红绿灯按钮
|
||
// Windows: 使用 titleBarOverlay,在自定义标题栏区域右侧显示原生窗口按钮
|
||
// Linux: 使用自定义标题栏和自定义按钮
|
||
if (platform.isMacOS) {
|
||
windowOptions.titleBarStyle = 'hiddenInset'
|
||
} else if (platform.isWindows) {
|
||
// 保留系统框架,只隐藏标题栏内容,把内容区域顶到最上方
|
||
windowOptions.titleBarStyle = 'hidden'
|
||
// 获取当前主题状态
|
||
const isDark = nativeTheme.shouldUseDarkColors
|
||
windowOptions.titleBarOverlay = getTitleBarOverlayOptions(isDark)
|
||
windowOptions.backgroundColor = isDark ? '#111827' : '#f9fafb'
|
||
} else {
|
||
// Linux 继续使用无边框 + 自定义按钮
|
||
windowOptions.frame = false
|
||
}
|
||
|
||
this.mainWindow = new BrowserWindow(windowOptions)
|
||
|
||
this.mainWindow.once('ready-to-show', () => {
|
||
this.mainWindow?.show()
|
||
|
||
// Windows 上根据当前主题设置 titleBarOverlay 颜色
|
||
if (platform.isWindows) {
|
||
applyCurrentTitleBarOverlay(this.mainWindow, nativeTheme.shouldUseDarkColors)
|
||
|
||
// 监听主题变化,动态更新颜色
|
||
nativeTheme.on('updated', () => {
|
||
if (this.mainWindow && platform.isWindows) {
|
||
resetCurrentTitleBarOverlayColor()
|
||
applyCurrentTitleBarOverlay(this.mainWindow, nativeTheme.shouldUseDarkColors)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
// 主窗口事件
|
||
this.mainWindowEvents()
|
||
|
||
this.mainWindow.webContents.setWindowOpenHandler((details) => {
|
||
shell.openExternal(details.url)
|
||
return { action: 'deny' }
|
||
})
|
||
|
||
// HMR for renderer base on electron-vite cli.
|
||
// Load the remote URL for development or the local html file for production.
|
||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||
this.mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||
} else {
|
||
this.mainWindow.loadFile(join(__dirname, '../../out/renderer/index.html'))
|
||
}
|
||
}
|
||
|
||
// 主应用程序事件
|
||
mainAppEvents() {
|
||
app.whenReady().then(async () => {
|
||
console.log('[Main] App is ready')
|
||
// 设置Windows应用程序用户模型id
|
||
if (process.platform === 'win32') app.setAppUserModelId(app.getName())
|
||
|
||
// 记录日活(用于统计操作系统版本、客户端版本,便于更好的适配客户端)
|
||
trackDailyActive()
|
||
|
||
// 启动 Internal API Server(硬依赖:失败则退出应用)
|
||
try {
|
||
await startInternalServer(getPathProvider())
|
||
registerInternalApiIpc()
|
||
console.log('[Main] Internal API Server ready')
|
||
} catch (err) {
|
||
console.error('[Main] Internal API Server failed to start:', err)
|
||
dialog.showErrorBox(
|
||
'ChatLab Internal Server Error',
|
||
`Internal API Server failed to start. The application cannot continue.\n\n${err instanceof Error ? err.message : String(err)}`
|
||
)
|
||
app.quit()
|
||
return
|
||
}
|
||
|
||
// 创建主窗口
|
||
console.log('[Main] Creating window...')
|
||
await this.createWindow()
|
||
console.log('[Main] Window created')
|
||
|
||
// 检查更新逻辑
|
||
checkUpdate(this.mainWindow)
|
||
|
||
// 引入主进程ipcMain
|
||
if (this.mainWindow) {
|
||
console.log('[Main] Registering IPC handlers...')
|
||
mainIpcMain(this.mainWindow)
|
||
console.log('[Main] IPC handlers registered')
|
||
}
|
||
|
||
// 开发环境下 F12 打开控制台
|
||
app.on('browser-window-created', (_, window) => {
|
||
optimizer.watchWindowShortcuts(window)
|
||
})
|
||
|
||
app.on('activate', () => {
|
||
// 在 macOS 上,当单击 Dock 图标且没有其他窗口时,通常会重新创建窗口
|
||
if (BrowserWindow.getAllWindows().length === 0) {
|
||
this.createWindow()
|
||
return
|
||
}
|
||
|
||
if (platform.isMacOS) {
|
||
this.mainWindow?.show()
|
||
}
|
||
})
|
||
|
||
// 监听渲染进程崩溃
|
||
app.on('render-process-gone', (_event, w, d) => {
|
||
if (d.reason == 'crashed') {
|
||
w.reload()
|
||
}
|
||
// fs.appendFile(`./error-log-${+new Date()}.txt`, `${new Date()}渲染进程被杀死${d.reason}\n`)
|
||
})
|
||
|
||
// 自定义协议
|
||
app.on('open-url', (_, url) => {
|
||
console.log('Received custom protocol URL:', url)
|
||
})
|
||
|
||
// 当所有窗口都关闭时退出应用,macOS 除外
|
||
app.on('window-all-closed', () => {
|
||
if (!platform.isMacOS) {
|
||
app.quit()
|
||
}
|
||
})
|
||
|
||
// 只有显式调用quit才退出系统,区分MAC系统程序坞退出和点击X隐藏
|
||
app.on('before-quit', () => {
|
||
appWithQuitFlag.isQuiting = true
|
||
})
|
||
|
||
// 退出前清理资源
|
||
app.on('will-quit', () => {
|
||
stopInternalServer().catch(() => {})
|
||
cleanup()
|
||
})
|
||
})
|
||
}
|
||
|
||
// 主窗口事件
|
||
mainWindowEvents() {
|
||
if (!this.mainWindow) {
|
||
return
|
||
}
|
||
this.mainWindow.webContents.on('did-finish-load', () => {
|
||
setTimeout(() => {
|
||
if (this.mainWindow) {
|
||
this.mainWindow.webContents.send('app-started')
|
||
}
|
||
}, 500)
|
||
})
|
||
|
||
this.mainWindow.on('maximize', () => {
|
||
this.mainWindow?.webContents.send('windowState', true)
|
||
})
|
||
|
||
this.mainWindow.on('unmaximize', () => {
|
||
this.mainWindow?.webContents.send('windowState', false)
|
||
})
|
||
|
||
// 窗口关闭
|
||
this.mainWindow.on('close', (event) => {
|
||
if (platform.isMacOS) {
|
||
// macOS: 只有明确退出时才真正关闭,否则只隐藏窗口(符合 macOS 用户习惯)
|
||
if (!appWithQuitFlag.isQuiting) {
|
||
event.preventDefault()
|
||
this.mainWindow?.hide()
|
||
}
|
||
}
|
||
// Windows/Linux: 不阻止关闭,正常触发 window-all-closed → app.quit() → cleanup()
|
||
})
|
||
}
|
||
}
|
||
|
||
// 捕获未捕获的异常
|
||
process.on('uncaughtException', (error) => {
|
||
console.error('Uncaught Exception:', error)
|
||
})
|
||
|
||
new MainProcess()
|