Files
WeFlow/electron/main.ts
cc 2e41a03c96 feat: 所有数据解析完全后台进行以解决页面未响应的问题;优化了头像渲染逻辑以提升渲染速度
fix: 修复了虚拟机上无法索引到wxkey的问题;修复图片密钥扫描的问题;修复年度报告错误;修复了年度报告和数据分析中的发送者错误问题;修复了部分页面偶发的未渲染名称问题;修复了头像偶发渲染失败的问题;修复了部分图片无法解密的问题
2026-01-14 22:43:42 +08:00

720 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
import { Worker } from 'worker_threads'
import { join } from 'path'
import { autoUpdater } from 'electron-updater'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import { ConfigService } from './services/config'
import { dbPathService } from './services/dbPathService'
import { wcdbService } from './services/wcdbService'
import { chatService } from './services/chatService'
import { imageDecryptService } from './services/imageDecryptService'
import { imagePreloadService } from './services/imagePreloadService'
import { analyticsService } from './services/analyticsService'
import { groupAnalyticsService } from './services/groupAnalyticsService'
import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions } from './services/exportService'
import { KeyService } from './services/keyService'
// 配置自动更新
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
const AUTO_UPDATE_ENABLED =
process.env.AUTO_UPDATE_ENABLED === 'true' ||
process.env.AUTO_UPDATE_ENABLED === '1' ||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
// 单例服务
let configService: ConfigService | null = null
// 协议窗口实例
let agreementWindow: BrowserWindow | null = null
let onboardingWindow: BrowserWindow | null = null
const keyService = new KeyService()
let mainWindowReady = false
let shouldShowMain = true
function createWindow(options: { autoShow?: boolean } = {}) {
// 获取图标路径 - 打包后在 resources 目录
const { autoShow = true } = options
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
const win = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1000,
minHeight: 700,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: '#1a1a1a',
height: 40
},
show: false
})
// 窗口准备好后显示
win.once('ready-to-show', () => {
mainWindowReady = true
if (autoShow || shouldShowMain) {
win.show()
}
})
// 开发环境加载 vite 服务器
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(process.env.VITE_DEV_SERVER_URL)
// 开发环境下按 F12 或 Ctrl+Shift+I 打开开发者工具
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
event.preventDefault()
}
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'))
}
return win
}
/**
* 创建用户协议窗口
*/
function createAgreementWindow() {
// 如果已存在,聚焦
if (agreementWindow && !agreementWindow.isDestroyed()) {
agreementWindow.focus()
return agreementWindow
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
const isDark = nativeTheme.shouldUseDarkColors
agreementWindow = new BrowserWindow({
width: 700,
height: 600,
minWidth: 500,
minHeight: 400,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: isDark ? '#FFFFFF' : '#333333',
height: 32
},
show: false,
backgroundColor: isDark ? '#1A1A1A' : '#FFFFFF'
})
agreementWindow.once('ready-to-show', () => {
agreementWindow?.show()
})
if (process.env.VITE_DEV_SERVER_URL) {
agreementWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/agreement-window`)
} else {
agreementWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/agreement-window' })
}
agreementWindow.on('closed', () => {
agreementWindow = null
})
return agreementWindow
}
/**
* 创建首次引导窗口
*/
function createOnboardingWindow() {
if (onboardingWindow && !onboardingWindow.isDestroyed()) {
onboardingWindow.focus()
return onboardingWindow
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
onboardingWindow = new BrowserWindow({
width: 1100,
height: 720,
minWidth: 900,
minHeight: 600,
frame: false,
transparent: true,
backgroundColor: '#00000000',
hasShadow: false,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
show: false
})
onboardingWindow.once('ready-to-show', () => {
onboardingWindow?.show()
})
if (process.env.VITE_DEV_SERVER_URL) {
onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/onboarding-window`)
} else {
onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/onboarding-window' })
}
onboardingWindow.on('closed', () => {
onboardingWindow = null
})
return onboardingWindow
}
function showMainWindow() {
shouldShowMain = true
if (mainWindowReady) {
mainWindow?.show()
}
}
// 注册 IPC 处理器
function registerIpcHandlers() {
// 配置相关
ipcMain.handle('config:get', async (_, key: string) => {
return configService?.get(key as any)
})
ipcMain.handle('config:set', async (_, key: string, value: any) => {
return configService?.set(key as any, value)
})
ipcMain.handle('config:clear', async () => {
configService?.clear()
return true
})
// 文件对话框
ipcMain.handle('dialog:openFile', async (_, options) => {
const { dialog } = await import('electron')
return dialog.showOpenDialog(options)
})
ipcMain.handle('dialog:openDirectory', async (_, options) => {
const { dialog } = await import('electron')
return dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
...options
})
})
ipcMain.handle('dialog:saveFile', async (_, options) => {
const { dialog } = await import('electron')
return dialog.showSaveDialog(options)
})
ipcMain.handle('shell:openPath', async (_, path: string) => {
const { shell } = await import('electron')
return shell.openPath(path)
})
ipcMain.handle('shell:openExternal', async (_, url: string) => {
const { shell } = await import('electron')
return shell.openExternal(url)
})
ipcMain.handle('app:getDownloadsPath', async () => {
return app.getPath('downloads')
})
ipcMain.handle('app:getVersion', async () => {
return app.getVersion()
})
ipcMain.handle('log:getPath', async () => {
return join(app.getPath('userData'), 'logs', 'wcdb.log')
})
ipcMain.handle('log:read', async () => {
try {
const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log')
const content = await readFile(logPath, 'utf8')
return { success: true, content }
} catch (e) {
return { success: false, error: String(e) }
}
})
ipcMain.handle('app:checkForUpdates', async () => {
if (!AUTO_UPDATE_ENABLED) {
return { hasUpdate: false }
}
try {
const result = await autoUpdater.checkForUpdates()
if (result && result.updateInfo) {
const currentVersion = app.getVersion()
const latestVersion = result.updateInfo.version
if (latestVersion !== currentVersion) {
return {
hasUpdate: true,
version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes as string || ''
}
}
}
return { hasUpdate: false }
} catch (error) {
console.error('检查更新失败:', error)
return { hasUpdate: false }
}
})
ipcMain.handle('app:downloadAndInstall', async (event) => {
if (!AUTO_UPDATE_ENABLED) {
throw new Error('自动更新已暂时禁用')
}
const win = BrowserWindow.fromWebContents(event.sender)
// 监听下载进度
autoUpdater.on('download-progress', (progress) => {
win?.webContents.send('app:downloadProgress', progress.percent)
})
// 下载完成后自动安装
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall(false, true)
})
try {
await autoUpdater.downloadUpdate()
} catch (error) {
console.error('下载更新失败:', error)
throw error
}
})
// 窗口控制
ipcMain.on('window:minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
})
ipcMain.on('window:maximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win?.isMaximized()) {
win.unmaximize()
} else {
win?.maximize()
}
})
ipcMain.on('window:close', (event) => {
BrowserWindow.fromWebContents(event.sender)?.close()
})
// 更新窗口控件主题色
ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) {
try {
win.setTitleBarOverlay({
color: '#00000000',
symbolColor: options.symbolColor,
height: 40
})
} catch (error) {
console.warn('TitleBarOverlay not enabled for this window:', error)
}
}
})
// 数据库路径相关
ipcMain.handle('dbpath:autoDetect', async () => {
return dbPathService.autoDetect()
})
ipcMain.handle('dbpath:scanWxids', async (_, rootPath: string) => {
return dbPathService.scanWxids(rootPath)
})
ipcMain.handle('dbpath:getDefault', async () => {
return dbPathService.getDefaultPath()
})
// WCDB 数据库相关
ipcMain.handle('wcdb:testConnection', async (_, dbPath: string, hexKey: string, wxid: string) => {
return wcdbService.testConnection(dbPath, hexKey, wxid)
})
ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => {
return wcdbService.open(dbPath, hexKey, wxid)
})
ipcMain.handle('wcdb:close', async () => {
wcdbService.close()
return true
})
// 聊天相关
ipcMain.handle('chat:connect', async () => {
return chatService.connect()
})
ipcMain.handle('chat:getSessions', async () => {
return chatService.getSessions()
})
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => {
return chatService.enrichSessionsContactInfo(usernames)
})
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
return chatService.getMessages(sessionId, offset, limit)
})
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
return chatService.getLatestMessages(sessionId, limit)
})
ipcMain.handle('chat:getContact', async (_, username: string) => {
return chatService.getContact(username)
})
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
return chatService.getContactAvatar(username)
})
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
return chatService.getCachedSessionMessages(sessionId)
})
ipcMain.handle('chat:getMyAvatarUrl', async () => {
return chatService.getMyAvatarUrl()
})
ipcMain.handle('chat:downloadEmoji', async (_, cdnUrl: string, md5?: string) => {
return chatService.downloadEmoji(cdnUrl, md5)
})
ipcMain.handle('chat:close', async () => {
chatService.close()
return true
})
ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => {
return chatService.getSessionDetail(sessionId)
})
ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => {
return chatService.getImageData(sessionId, msgId)
})
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string) => {
return chatService.getVoiceData(sessionId, msgId)
})
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
return chatService.getMessageById(sessionId, localId)
})
// 私聊克隆
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
return imageDecryptService.decryptImage(payload)
})
ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => {
return imageDecryptService.resolveCachedImage(payload)
})
ipcMain.handle('image:preload', async (_, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => {
imagePreloadService.enqueue(payloads || [])
return true
})
// 导出相关
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => {
return exportService.exportSessions(sessionIds, outputDir, options)
})
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
})
// 数据分析相关
ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
return analyticsService.getOverallStatistics(force)
})
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => {
return analyticsService.getContactRankings(limit)
})
ipcMain.handle('analytics:getTimeDistribution', async () => {
return analyticsService.getTimeDistribution()
})
// 群聊分析相关
ipcMain.handle('groupAnalytics:getGroupChats', async () => {
return groupAnalyticsService.getGroupChats()
})
ipcMain.handle('groupAnalytics:getGroupMembers', async (_, chatroomId: string) => {
return groupAnalyticsService.getGroupMembers(chatroomId)
})
ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => {
return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime)
})
ipcMain.handle('groupAnalytics:getGroupActiveHours', async (_, chatroomId: string, startTime?: number, endTime?: number) => {
return groupAnalyticsService.getGroupActiveHours(chatroomId, startTime, endTime)
})
ipcMain.handle('groupAnalytics:getGroupMediaStats', async (_, chatroomId: string, startTime?: number, endTime?: number) => {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
})
// 打开协议窗口
ipcMain.handle('window:openAgreementWindow', async () => {
createAgreementWindow()
return true
})
// 完成引导,关闭引导窗口并显示主窗口
ipcMain.handle('window:completeOnboarding', async () => {
try {
configService?.set('onboardingDone', true)
} catch (e) {
console.error('保存引导完成状态失败:', e)
}
if (onboardingWindow && !onboardingWindow.isDestroyed()) {
onboardingWindow.close()
}
showMainWindow()
return true
})
// 重新打开首次引导窗口,并隐藏主窗口
ipcMain.handle('window:openOnboardingWindow', async () => {
shouldShowMain = false
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.hide()
}
createOnboardingWindow()
return true
})
// 年度报告相关
ipcMain.handle('annualReport:getAvailableYears', async () => {
const cfg = configService || new ConfigService()
configService = cfg
return annualReportService.getAvailableYears({
dbPath: cfg.get('dbPath'),
decryptKey: cfg.get('decryptKey'),
wxid: cfg.get('myWxid')
})
})
ipcMain.handle('annualReport:generateReport', async (_, year: number) => {
const cfg = configService || new ConfigService()
configService = cfg
const dbPath = cfg.get('dbPath')
const decryptKey = cfg.get('decryptKey')
const wxid = cfg.get('myWxid')
const logEnabled = cfg.get('logEnabled')
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const userDataPath = app.getPath('userData')
const workerPath = join(__dirname, 'annualReportWorker.js')
return await new Promise((resolve) => {
const worker = new Worker(workerPath, {
workerData: { year, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled }
})
const cleanup = () => {
worker.removeAllListeners()
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'annualReport:progress') {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('annualReport:progress', msg.data)
}
}
return
}
if (msg && (msg.type === 'annualReport:result' || msg.type === 'done')) {
cleanup()
void worker.terminate()
resolve(msg.data ?? msg.result)
return
}
if (msg && (msg.type === 'annualReport:error' || msg.type === 'error')) {
cleanup()
void worker.terminate()
resolve({ success: false, error: msg.error || '年度报告生成失败' })
}
})
worker.on('error', (err) => {
cleanup()
resolve({ success: false, error: String(err) })
})
worker.on('exit', (code) => {
if (code !== 0) {
cleanup()
resolve({ success: false, error: `年度报告线程异常退出: ${code}` })
}
})
})
})
ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => {
try {
const { baseDir, folderName, images } = payload
if (!baseDir || !folderName || !Array.isArray(images) || images.length === 0) {
return { success: false, error: '导出参数无效' }
}
let targetDir = join(baseDir, folderName)
if (existsSync(targetDir)) {
let idx = 2
while (existsSync(`${targetDir}_${idx}`)) idx++
targetDir = `${targetDir}_${idx}`
}
await mkdir(targetDir, { recursive: true })
for (const img of images) {
const dataUrl = img.dataUrl || ''
const commaIndex = dataUrl.indexOf(',')
if (commaIndex <= 0) continue
const base64 = dataUrl.slice(commaIndex + 1)
const buffer = Buffer.from(base64, 'base64')
const filePath = join(targetDir, img.name)
await writeFile(filePath, buffer)
}
return { success: true, dir: targetDir }
} catch (e) {
return { success: false, error: String(e) }
}
})
// 密钥获取
ipcMain.handle('key:autoGetDbKey', async (event) => {
return keyService.autoGetDbKey(60_000, (message, level) => {
event.sender.send('key:dbKeyStatus', { message, level })
})
})
ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string) => {
return keyService.autoGetImageKey(manualDir, (message) => {
event.sender.send('key:imageKeyStatus', { message })
})
})
}
// 主窗口引用
let mainWindow: BrowserWindow | null = null
// 启动时自动检测更新
function checkForUpdatesOnStartup() {
if (!AUTO_UPDATE_ENABLED) return
// 开发环境不检测更新
if (process.env.VITE_DEV_SERVER_URL) return
// 延迟3秒检测等待窗口完全加载
setTimeout(async () => {
try {
const result = await autoUpdater.checkForUpdates()
if (result && result.updateInfo) {
const currentVersion = app.getVersion()
const latestVersion = result.updateInfo.version
if (latestVersion !== currentVersion && mainWindow) {
// 通知渲染进程有新版本
mainWindow.webContents.send('app:updateAvailable', {
version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes || ''
})
}
}
} catch (error) {
console.error('启动时检查更新失败:', error)
}
}, 3000)
}
app.whenReady().then(() => {
configService = new ConfigService()
const candidateResources = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const fallbackResources = join(process.cwd(), 'resources')
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
const userDataPath = app.getPath('userData')
wcdbService.setPaths(resourcesPath, userDataPath)
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
registerIpcHandlers()
const onboardingDone = configService.get('onboardingDone')
shouldShowMain = onboardingDone === true
mainWindow = createWindow({ autoShow: shouldShowMain })
if (!onboardingDone) {
createOnboardingWindow()
}
// 启动时检测更新
checkForUpdatesOnStartup()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})