From cc9b6bba894e4a2c59a03b8d32c967ad83dd350c Mon Sep 17 00:00:00 2001 From: ILoveBingLu Date: Wed, 28 Jan 2026 02:27:30 +0800 Subject: [PATCH] =?UTF-8?q?TS=20=E5=AE=9E=E7=8E=B0=E7=AE=80=E6=98=93=20LRU?= =?UTF-8?q?=20=E7=BC=93=E5=AD=98=EF=BC=9A=E6=96=B0=E5=A2=9E=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E7=B1=BB=20+=20=E6=A0=B8=E5=BF=83=E6=96=B9=E6=B3=95?= =?UTF-8?q?=20+=20=E5=AE=B9=E9=87=8F=E6=BB=A1=E8=87=AA=E5=8A=A8=E6=B7=98?= =?UTF-8?q?=E6=B1=B0=20LRU=20=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- electron/main.ts | 71 +++-- electron/preload.ts | 9 +- electron/services/chatService.ts | 337 ++++++++++++++++++++- electron/services/dataManagementService.ts | 41 ++- electron/services/decryptService.ts | 246 ++------------- electron/services/nativeDecryptService.ts | 215 +++++++++++++ electron/workers/decryptWorker.js | 88 ++++++ package.json | 11 +- resources/wcdb_decrypt.dll | Bin 0 -> 39936 bytes src/pages/ChatPage.scss | 79 +++++ src/pages/ChatPage.tsx | 276 +++++++++++++---- src/pages/DataManagementPage.tsx | 11 + src/stores/chatStore.ts | 12 +- src/types/electron.d.ts | 5 +- src/types/models.ts | 5 + src/utils/lruCache.ts | 50 +++ vite.config.ts | 12 + 18 files changed, 1132 insertions(+), 338 deletions(-) create mode 100644 electron/services/nativeDecryptService.ts create mode 100644 electron/workers/decryptWorker.js create mode 100644 resources/wcdb_decrypt.dll create mode 100644 src/utils/lruCache.ts diff --git a/README.md b/README.md index 94cc53b..82b8e8d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **一款现代化的微信聊天记录查看与分析工具** [![License](https://img.shields.io/badge/license-CC--BY--NC--SA--4.0-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-2.1.4-green.svg)](package.json) +[![Version](https://img.shields.io/badge/version-2.1.5-green.svg)](package.json) [![Platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows)]() [![Electron](https://img.shields.io/badge/Electron-39-47848F.svg?logo=electron)]() [![React](https://img.shields.io/badge/React-19-61DAFB.svg?logo=react)]() diff --git a/electron/main.ts b/electron/main.ts index 33ea131..0aa3231 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3,7 +3,7 @@ import { join } from 'path' import { readFileSync, existsSync } from 'fs' import { autoUpdater } from 'electron-updater' import { DatabaseService } from './services/database' -import { DecryptService } from './services/decrypt' + import { wechatDecryptService } from './services/decryptService' import { ConfigService } from './services/config' import { wxKeyService } from './services/wxKeyService' @@ -65,7 +65,7 @@ function isNewerVersion(version1: string, version2: string): boolean { // 单例服务 let dbService: DatabaseService | null = null -let decryptService: DecryptService | null = null + let configService: ConfigService | null = null let logService: LogService | null = null @@ -125,7 +125,7 @@ function createWindow() { // 初始化服务 configService = new ConfigService() dbService = new DatabaseService() - decryptService = new DecryptService() + logService = new LogService(configService) // 记录应用启动日志 @@ -888,11 +888,22 @@ function registerIpcHandlers() { // 解密相关 ipcMain.handle('decrypt:database', async (_, sourcePath: string, key: string, outputPath: string) => { - return decryptService?.decryptDatabase(sourcePath, key, outputPath) + return wechatDecryptService.decryptDatabase(sourcePath, outputPath, key) }) ipcMain.handle('decrypt:image', async (_, imagePath: string) => { - return decryptService?.decryptImage(imagePath) + return null + }) + + // ... (其他 IPC) + + // 监听增量消息推送 + chatService.on('new-messages', (data) => { + BrowserWindow.getAllWindows().forEach(win => { + if (!win.isDestroyed()) { + win.webContents.send('chat:new-messages', data) + } + }) }) // 文件对话框 @@ -916,6 +927,11 @@ function registerIpcHandlers() { return shell.openExternal(url) }) + ipcMain.handle('shell:showItemInFolder', async (_, fullPath: string) => { + const { shell } = await import('electron') + return shell.showItemInFolder(fullPath) + }) + ipcMain.handle('app:getDownloadsPath', async () => { return app.getPath('downloads') }) @@ -1124,7 +1140,7 @@ function registerIpcHandlers() { // 发送状态:准备启动微信 event.sender.send('wxkey:status', { status: '正在安装 Hook...', level: 1 }) - + // 获取微信路径 const wechatPath = customWechatPath || wxKeyService.getWeChatPath() if (!wechatPath) { @@ -1223,10 +1239,10 @@ function registerIpcHandlers() { ipcMain.handle('dbpath:getBestCachePath', async () => { const { existsSync } = require('fs') const { join } = require('path') - + // 按优先级检查磁盘:D、E、F、C const drives = ['D', 'E', 'F', 'C'] - + for (const drive of drives) { const drivePath = `${drive}:\\` if (existsSync(drivePath)) { @@ -1235,7 +1251,7 @@ function registerIpcHandlers() { return { success: true, path: cachePath, drive } } } - + // 如果都没有,返回用户目录下的默认路径 const { app } = require('electron') const defaultPath = join(app.getPath('userData'), 'cache') @@ -1282,19 +1298,19 @@ function registerIpcHandlers() { // 数据库解密 ipcMain.handle('wcdb:decryptDatabase', async (event, dbPath: string, hexKey: string, wxid: string) => { logService?.info('Decrypt', '开始解密数据库', { dbPath, wxid }) - + try { // 使用已有的 dataManagementService 来解密 const result = await dataManagementService.decryptAll() - + if (result.success) { - logService?.info('Decrypt', '解密完成', { - successCount: result.successCount, - failCount: result.failCount + logService?.info('Decrypt', '解密完成', { + successCount: result.successCount, + failCount: result.failCount }) - - return { - success: true, + + return { + success: true, totalFiles: (result.successCount || 0) + (result.failCount || 0), successCount: result.successCount, failCount: result.failCount @@ -1561,6 +1577,11 @@ function registerIpcHandlers() { return true }) + ipcMain.handle('chat:setCurrentSession', async (_, sessionId: string | null) => { + chatService.setCurrentSession(sessionId) + return true + }) + ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => { const result = await chatService.getSessionDetail(sessionId) if (!result.success) { @@ -1672,7 +1693,7 @@ function registerIpcHandlers() { if (welcomeWindow && !welcomeWindow.isDestroyed()) { welcomeWindow.close() } - + // 如果主窗口还不存在,创建它 if (!mainWindow || mainWindow.isDestroyed()) { mainWindow = createWindow() @@ -1681,7 +1702,7 @@ function registerIpcHandlers() { mainWindow.show() mainWindow.focus() } - + return true }) @@ -1978,8 +1999,8 @@ function registerIpcHandlers() { try { const { proxyService } = await import('./services/ai/proxyService') const proxyUrl = await proxyService.getSystemProxy() - return { - success: true, + return { + success: true, hasProxy: !!proxyUrl, proxyUrl: proxyUrl || null } @@ -1993,8 +2014,8 @@ function registerIpcHandlers() { const { proxyService } = await import('./services/ai/proxyService') proxyService.clearCache() const proxyUrl = await proxyService.getSystemProxy() - return { - success: true, + return { + success: true, hasProxy: !!proxyUrl, proxyUrl: proxyUrl || null, message: proxyUrl ? `已刷新代理: ${proxyUrl}` : '未检测到代理,使用直连' @@ -2008,8 +2029,8 @@ function registerIpcHandlers() { try { const { proxyService } = await import('./services/ai/proxyService') const success = await proxyService.testProxy(proxyUrl, testUrl) - return { - success, + return { + success, message: success ? '代理连接正常' : '代理连接失败' } } catch (e) { diff --git a/electron/preload.ts b/electron/preload.ts index ab18a4d..1ab8502 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -33,7 +33,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // Shell shell: { openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path), - openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url) + openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), + showItemInFolder: (fullPath: string) => ipcRenderer.invoke('shell:showItemInFolder', fullPath) }, // App @@ -198,12 +199,18 @@ contextBridge.exposeInMainWorld('electronAPI', { downloadEmoji: (cdnUrl: string, md5?: string, productId?: string, createTime?: number) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5, productId, createTime), close: () => ipcRenderer.invoke('chat:close'), refreshCache: () => ipcRenderer.invoke('chat:refreshCache'), + setCurrentSession: (sessionId: string | null) => ipcRenderer.invoke('chat:setCurrentSession', sessionId), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), getVoiceData: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime), onSessionsUpdated: (callback: (sessions: any[]) => void) => { const listener = (_: any, sessions: any[]) => callback(sessions) ipcRenderer.on('chat:sessions-updated', listener) return () => ipcRenderer.removeListener('chat:sessions-updated', listener) + }, + onNewMessages: (callback: (data: { sessionId: string; messages: any[] }) => void) => { + const listener = (_: any, data: any) => callback(data) + ipcRenderer.on('chat:new-messages', listener) + return () => ipcRenderer.removeListener('chat:new-messages', listener) } }, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index b2cfdc4..470e33a 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -55,6 +55,11 @@ export interface Message { voiceDuration?: number // 语音时长(秒) // 商店表情相关 productId?: string + // 文件消息相关 + fileName?: string // 文件名 + fileSize?: number // 文件大小(字节) + fileExt?: string // 文件扩展名 + fileMd5?: string // 文件 MD5 } export interface Contact { @@ -104,11 +109,24 @@ class ChatService extends EventEmitter { private syncTimer: NodeJS.Timeout | null = null private lastDbCheckTime: number = 0 + // 增量同步相关 + private currentSessionId: string | null = null + // 记录每个会话已读取的最大 sortSeq (用于此后的增量查询) + private sessionCursor: Map = new Map() + constructor() { super() this.configService = new ConfigService() } + /** + * 设置当前聚焦的会话 ID + * 用于增量同步时只推送当前会话的消息 + */ + setCurrentSession(sessionId: string | null): void { + this.currentSessionId = sessionId + } + /** * 清理账号目录名(支持 wxid_ 格式和自定义微信号格式) */ @@ -324,6 +342,69 @@ class ChatService extends EventEmitter { this.dbDir = null } + /** + * 关闭指定的数据库文件(用于增量更新时释放单个文件) + * 这样可以在更新某个数据库时,不影响其他数据库的查询 + */ + closeDatabase(fileName: string): void { + const fileNameLower = fileName.toLowerCase() + + // 检查是否是核心数据库 + if (fileNameLower === 'session.db' && this.sessionDb) { + try { this.sessionDb.close() } catch { } + this.sessionDb = null + return + } + + if (fileNameLower === 'contact.db' && this.contactDb) { + try { this.contactDb.close() } catch { } + this.contactDb = null + this.contactColumnsCache = null + return + } + + if (fileNameLower === 'emoticon.db' && this.emoticonDb) { + try { this.emoticonDb.close() } catch { } + this.emoticonDb = null + return + } + + if (fileNameLower === 'emotion.db' && this.emotionDb) { + try { this.emotionDb.close() } catch { } + this.emotionDb = null + return + } + + if (fileNameLower === 'head_image.db' && this.headImageDb) { + try { this.headImageDb.close() } catch { } + this.headImageDb = null + this.avatarBase64Cache.clear() + return + } + + // 检查是否是消息数据库(在缓存中查找) + const entries = Array.from(this.messageDbCache.entries()) + for (let i = 0; i < entries.length; i++) { + const [dbPath, db] = entries[i] + if (dbPath.toLowerCase().endsWith(fileNameLower)) { + try { db.close() } catch { } + this.messageDbCache.delete(dbPath) + this.knownMessageDbFiles.delete(dbPath) + // 清除相关的预编译语句缓存 + const stmtKeys = Array.from(this.preparedStmtCache.keys()) + for (let j = 0; j < stmtKeys.length; j++) { + if (stmtKeys[j].startsWith(dbPath)) { + this.preparedStmtCache.delete(stmtKeys[j]) + } + } + // 清除会话表缓存(因为可能包含这个数据库的信息) + this.sessionTableCache.clear() + this.sessionTableCacheTime = 0 + return + } + } + } + /** * 获取会话列表 */ @@ -676,6 +757,9 @@ class ChatService extends EventEmitter { } catch { // ignore } + + // 尝试推送增量消息 + this.checkNewMessagesForCurrentSession() } /** @@ -907,8 +991,13 @@ class ChatService extends EventEmitter { limit: number = 50 ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { try { + // 如果数据库未连接,尝试自动重连 + // 这解决了增量更新期间数据库被关闭后,用户无法查询消息的问题 if (!this.dbDir) { - return { success: false, error: '数据库未连接' } + const connectResult = await this.connect() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } } // 获取当前用户的 wxid @@ -1028,6 +1117,19 @@ class ChatService extends EventEmitter { quotedSender = quoteInfo.sender } + // 解析文件消息 (localType === 49 且 XML 中 type=6) + let fileName: string | undefined + let fileSize: number | undefined + let fileExt: string | undefined + let fileMd5: string | undefined + if (localType === 49 && content) { + const fileInfo = this.parseFileInfo(content) + fileName = fileInfo.fileName + fileSize = fileInfo.fileSize + fileExt = fileInfo.fileExt + fileMd5 = fileInfo.fileMd5 + } + const parsedContent = this.parseMessageContent(content, localType) allMessages.push({ @@ -1048,7 +1150,11 @@ class ChatService extends EventEmitter { imageMd5, imageDatName, videoMd5, - voiceDuration + voiceDuration, + fileName, + fileSize, + fileExt, + fileMd5 }) } } catch (e: any) { @@ -1083,9 +1189,20 @@ class ChatService extends EventEmitter { const hasMore = allMessages.length > offset + limit const messages = allMessages.slice(offset, offset + limit) + // 反转使最新消息在最后(UI 显示顺序) // 反转使最新消息在最后(UI 显示顺序) messages.reverse() + // 更新增量游标(仅在拉取最新一页时) + if (offset === 0 && messages.length > 0) { + const latestMsg = messages[messages.length - 1] + // 记录已读取的最大 sortSeq + const currentCursor = this.sessionCursor.get(sessionId) || 0 + if (latestMsg.sortSeq > currentCursor) { + this.sessionCursor.set(sessionId, latestMsg.sortSeq) + } + } + return { success: true, messages, hasMore } } catch (e) { console.error('ChatService: 获取消息失败:', e) @@ -1263,6 +1380,37 @@ class ChatService extends EventEmitter { } } + /** + * 解析文件消息信息 + * 从 type=6 的文件消息 XML 中提取文件信息 + */ + private parseFileInfo(content: string): { fileName?: string; fileSize?: number; fileExt?: string; fileMd5?: string } { + if (!content) return {} + + try { + // 检查是否是文件消息 (type=6) + const type = this.extractXmlValue(content, 'type') + if (type !== '6') return {} + + // 提取文件名 (title) + const fileName = this.extractXmlValue(content, 'title') + + // 提取文件大小 (totallen) + const totallenStr = this.extractXmlValue(content, 'totallen') + const fileSize = totallenStr ? parseInt(totallenStr, 10) : undefined + + // 提取文件扩展名 (fileext) + const fileExt = this.extractXmlValue(content, 'fileext') + + // 提取文件 MD5 + const fileMd5 = this.extractXmlValue(content, 'md5')?.toLowerCase() + + return { fileName, fileSize, fileExt, fileMd5 } + } catch { + return {} + } + } + /** * 从数据库行中解析图片 dat 文件名 */ @@ -1621,7 +1769,7 @@ class ChatService extends EventEmitter { private shouldKeepSession(username: string): boolean { if (!username) return false if (username.startsWith('gh_')) return false - + // 过滤折叠对话占位符 if (username === '@placeholder_foldgroup') return false @@ -3254,7 +3402,7 @@ class ChatService extends EventEmitter { if (this.syncTimer) { clearInterval(this.syncTimer) this.syncTimer = null - console.log('[ChatService] 停止自动增量同步') + // console.log('[ChatService] 停止自动增量同步') // 减少日志 } } @@ -3319,6 +3467,187 @@ class ChatService extends EventEmitter { console.error('[ChatService] 检查更新出错:', e) } } + + /** + * 检查当前会话的新消息并推送(增量同步) + * 采用 Push 模式,主动将新解密的消息推送到前端 + */ + private checkNewMessagesForCurrentSession(): void { + if (!this.currentSessionId) return + + // 如果没有游标,说明尚未加载过历史消息,暂不推送(避免数据不连续) + const cursor = this.sessionCursor.get(this.currentSessionId) || 0 + if (cursor === 0) return + + try { + const tables = this.findSessionTables(this.currentSessionId) + if (tables.length === 0) return + + const allNewMessages: Message[] = [] + + // 获取当前用户的 wxid + const myWxid = this.configService.get('myWxid') + const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : '' + + for (const { db, tableName, dbPath } of tables) { + // 检查 Name2Id 表 + const hasName2IdTable = this.checkTableExists(db, 'Name2Id') + + // 鲁棒的 myRowId 查找逻辑 (与 getMessages 保持一致) + let myRowId: number | null = null + if (myWxid && hasName2IdTable) { + const cacheKeyOriginal = `${dbPath}:${myWxid}` + const cachedRowIdOriginal = this.myRowIdCache.get(cacheKeyOriginal) + + if (cachedRowIdOriginal !== undefined) { + myRowId = cachedRowIdOriginal + } else { + try { + const row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(myWxid) as any + if (row?.rowid) { + myRowId = row.rowid + this.myRowIdCache.set(cacheKeyOriginal, myRowId) + } else if (cleanedMyWxid && cleanedMyWxid !== myWxid) { + const cacheKeyCleaned = `${dbPath}:${cleanedMyWxid}` + const cachedRowIdCleaned = this.myRowIdCache.get(cacheKeyCleaned) + + if (cachedRowIdCleaned !== undefined) { + myRowId = cachedRowIdCleaned + } else { + const row2 = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(cleanedMyWxid) as any + myRowId = row2?.rowid ?? null + this.myRowIdCache.set(cacheKeyCleaned, myRowId) + } + } else { + this.myRowIdCache.set(cacheKeyOriginal, null) + } + } catch { + myRowId = null + } + } + } + + // 构建查询 SQL (查询比 cursor 大的消息) + let sql: string + if (hasName2IdTable && myRowId !== null) { + sql = `SELECT m.*, + CASE WHEN m.real_sender_id = ? THEN 1 ELSE 0 END AS computed_is_send, + n.user_name AS sender_username + FROM ${tableName} m + LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid + WHERE m.sort_seq > ? + ORDER BY m.sort_seq ASC + LIMIT 100` + } else if (hasName2IdTable) { + sql = `SELECT m.*, n.user_name AS sender_username + FROM ${tableName} m + LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid + WHERE m.sort_seq > ? + ORDER BY m.sort_seq ASC + LIMIT 100` + } else { + sql = `SELECT * FROM ${tableName} WHERE sort_seq > ? ORDER BY sort_seq ASC LIMIT 100` + } + + const rows = hasName2IdTable && myRowId !== null + ? db.prepare(sql).all(myRowId, cursor) as any[] + : db.prepare(sql).all(cursor) as any[] + + // 解析消息 + for (const row of rows) { + const content = this.decodeMessageContent(row.message_content, row.compress_content) + const localType = row.local_type || row.type || 1 + const isSend = row.computed_is_send ?? row.is_send ?? null + + let emojiCdnUrl: string | undefined + let emojiMd5: string | undefined + let emojiProductId: string | undefined + let quotedContent: string | undefined + let quotedSender: string | undefined + let imageMd5: string | undefined + let imageDatName: string | undefined + let videoMd5: string | undefined + let voiceDuration: number | undefined + let fileName: string | undefined + let fileSize: number | undefined + let fileExt: string | undefined + let fileMd5: string | undefined + + if (localType === 47 && content) { + const emojiInfo = this.parseEmojiInfo(content) + emojiCdnUrl = emojiInfo.cdnUrl + emojiMd5 = emojiInfo.md5 + emojiProductId = emojiInfo.productId + } else if (localType === 3 && content) { + const imageInfo = this.parseImageInfo(content) + imageMd5 = imageInfo.md5 + imageDatName = this.parseImageDatNameFromRow(row) + } else if (localType === 43 && content) { + videoMd5 = this.parseVideoMd5(content) + } else if (localType === 34 && content) { + voiceDuration = this.parseVoiceDuration(content) + } else if (localType === 49 && content) { + // 解析文件消息 + const fileInfo = this.parseFileInfo(content) + fileName = fileInfo.fileName + fileSize = fileInfo.fileSize + fileExt = fileInfo.fileExt + fileMd5 = fileInfo.fileMd5 + } else if (localType === 244813135921 || (content && content.includes('57'))) { + const quoteInfo = this.parseQuoteMessage(content) + quotedContent = quoteInfo.content + quotedSender = quoteInfo.sender + } + + const parsedContent = this.parseMessageContent(content, localType) + + allNewMessages.push({ + localId: row.local_id || 0, + serverId: row.server_id || 0, + localType, + createTime: row.create_time || 0, + sortSeq: row.sort_seq || 0, + isSend, + senderUsername: row.sender_username || null, + parsedContent, + rawContent: content, + emojiCdnUrl, + emojiMd5, + productId: emojiProductId, + quotedContent, + quotedSender, + imageMd5, + imageDatName, + videoMd5, + voiceDuration, + fileName, + fileSize, + fileExt, + fileMd5 + }) + } + } + + if (allNewMessages.length > 0) { + // 排序 + allNewMessages.sort((a, b) => a.sortSeq - b.sortSeq) + + // 更新游标 + const maxSeq = allNewMessages[allNewMessages.length - 1].sortSeq + this.sessionCursor.set(this.currentSessionId, maxSeq) + + // 推送事件 + this.emit('new-messages', { + sessionId: this.currentSessionId, + messages: allNewMessages + }) + // console.log(`[ChatService] 推送增量消息: ${allNewMessages.length} 条`) + } + + } catch (e) { + // console.error('[ChatService] 增量同步失败:', e) + } + } } export const chatService = new ChatService() diff --git a/electron/services/dataManagementService.ts b/electron/services/dataManagementService.ts index d32c16a..863a8a5 100644 --- a/electron/services/dataManagementService.ts +++ b/electron/services/dataManagementService.ts @@ -345,6 +345,10 @@ class DataManagementService { const time = new Date().toLocaleTimeString() console.error(`[${time}] [数据解密] 解密失败: ${file.fileName}`, result.error) } + + // 关键:强制让出主线程时间片,防止批量处理时 UI 卡死 + // 即使是 Worker 解密,连续的 IPC 通信和主线程调度也会导致卡顿 + await new Promise(resolve => setTimeout(resolve, 10)) } // 完成 @@ -385,13 +389,10 @@ class DataManagementService { return { success: false, error: '请先在设置页面配置解密密钥' } } - // 关闭所有可能占用数据库文件的服务 - chatService.close() + // 不再关闭整个 chatService,而是在更新每个文件前只关闭那个特定的数据库 + // 这样用户可以在增量更新时继续查看其他会话的消息 imageDecryptService.clearHardlinkCache() - // 等待所有数据库连接完全关闭(给一些时间让文件句柄释放) - await new Promise(resolve => setTimeout(resolve, 2000)) - let successCount = 0 let failCount = 0 const totalFiles = filesToUpdate.length @@ -401,7 +402,7 @@ class DataManagementService { // 在处理每个文件前让出时间片,避免阻塞UI const time = new Date().toLocaleTimeString() - console.log(`[${time}] [增量同步] 正在同步数据库: ${file.fileName} (${i + 1}/${totalFiles})`) + // console.log(`[${time}] [增量同步] 正在同步数据库: ${file.fileName} (${i + 1}/${totalFiles})`) // 减少日志 if (i > 0) { await new Promise(resolve => setImmediate(resolve)) } @@ -432,6 +433,12 @@ class DataManagementService { } const backupPath = file.decryptedPath + '.old.' + Date.now() + + // 在备份/覆盖文件前,先关闭该数据库的连接,释放文件锁 + chatService.closeDatabase(file.fileName) + // 等待文件句柄释放 + await new Promise(resolve => setTimeout(resolve, 100)) + if (fs.existsSync(file.decryptedPath!)) { // 尝试备份文件,如果失败则重试几次 let backupSuccess = false @@ -551,8 +558,8 @@ class DataManagementService { if (result.success) { successCount++ - const time = new Date().toLocaleTimeString() - console.log(`[${time}] [增量同步] 同步成功: ${file.fileName}`) + // const time = new Date().toLocaleTimeString() + // console.log(`[${time}] [增量同步] 同步成功: ${file.fileName}`) // 减少日志 if (fs.existsSync(backupPath)) { try { fs.unlinkSync(backupPath) } catch { } } @@ -564,6 +571,9 @@ class DataManagementService { try { fs.renameSync(backupPath, file.decryptedPath!) } catch { } } } + + // 关键:强制让出主线程时间片,防止批量处理时 UI 卡死 + await new Promise(resolve => setTimeout(resolve, 10)) } this.sendProgress({ type: 'complete' }) @@ -1346,7 +1356,7 @@ class DataManagementService { // 检查更新频率限制(最多每5秒更新一次) const now = Date.now() const timeSinceLastUpdate = now - this.lastUpdateTime - const MIN_UPDATE_INTERVAL = 5000 // 最小更新间隔:5秒 + const MIN_UPDATE_INTERVAL = 1000 // 最小更新间隔:1秒 (配合 DLL 极速解密) if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) { // 如果距离上次更新不足5秒,延迟到满足间隔 @@ -1365,8 +1375,9 @@ class DataManagementService { } // 等待文件写入完成(微信写入数据库可能需要一些时间) - // 延迟2秒,确保文件完全写入完成 - await new Promise(resolve => setTimeout(resolve, 2000)) + // 等待文件写入完成(微信写入数据库可能需要一些时间) + // 延迟1秒,确保文件完全写入完成 + await new Promise(resolve => setTimeout(resolve, 1000)) // 触发更新 this.triggerUpdate() @@ -1391,7 +1402,7 @@ class DataManagementService { // 检查更新频率限制 const now = Date.now() const timeSinceLastUpdate = now - this.lastUpdateTime - const MIN_UPDATE_INTERVAL = 2000 // 最小更新间隔:2秒 (原来是 5 秒) + const MIN_UPDATE_INTERVAL = 1000 // 最小更新间隔:1秒 if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) { // 延迟到满足间隔 @@ -1430,7 +1441,7 @@ class DataManagementService { // 检查更新频率限制 const now = Date.now() const timeSinceLastUpdate = now - this.lastUpdateTime - const MIN_UPDATE_INTERVAL = 2000 // 最小更新间隔减少到 2 秒 (原来是 5 秒) + const MIN_UPDATE_INTERVAL = 1000 // 最小更新间隔减少到 1 秒 if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) { const remainingTime = MIN_UPDATE_INTERVAL - timeSinceLastUpdate @@ -1470,8 +1481,8 @@ class DataManagementService { if (result.success) { // 通知监听器更新完成 - const time = new Date().toLocaleTimeString() - console.log(`[${time}] [自动更新] 增量同步完成, 成功更新 ${result.successCount} 个文件`) + // const time = new Date().toLocaleTimeString() + // console.log(`[${time}] [自动更新] 增量同步完成, 成功更新 ${result.successCount} 个文件`) // 减少日志 this.updateListeners.forEach(listener => listener(false)) return { success: true, updated: result.successCount! > 0 } } else { diff --git a/electron/services/decryptService.ts b/electron/services/decryptService.ts index 51fed60..5b81ba9 100644 --- a/electron/services/decryptService.ts +++ b/electron/services/decryptService.ts @@ -1,103 +1,22 @@ -import * as crypto from 'crypto' -import * as fs from 'fs' -import * as path from 'path' - -// 常量定义 -const PAGE_SIZE = 4096 -const KEY_SIZE = 32 -const SALT_SIZE = 16 -const IV_SIZE = 16 -const HMAC_SIZE = 64 // SHA512 -const AES_BLOCK_SIZE = 16 -const ITER_COUNT = 256000 // Windows v4 迭代次数 -const SQLITE_HEADER = 'SQLite format 3\x00' - -// 计算保留字节数 (IV + HMAC, 向上取整到 AES 块大小) -const RESERVE = Math.ceil((IV_SIZE + HMAC_SIZE) / AES_BLOCK_SIZE) * AES_BLOCK_SIZE +import { nativeDecryptService } from './nativeDecryptService' /** * 微信数据库解密服务 (Windows v4) + * 纯原生 DLL 实现封装 */ export class WeChatDecryptService { - /** - * XOR 字节数组 - */ - private xorBytes(data: Buffer, xorValue: number): Buffer { - const result = Buffer.alloc(data.length) - for (let i = 0; i < data.length; i++) { - result[i] = data[i] ^ xorValue - } - return result - } - - /** - * 派生加密密钥和 MAC 密钥 - */ - private deriveKeys(key: Buffer, salt: Buffer): { encKey: Buffer; macKey: Buffer } { - // 生成加密密钥 - const encKey = crypto.pbkdf2Sync(key, salt, ITER_COUNT, KEY_SIZE, 'sha512') - - // 生成 MAC 密钥的盐 (XOR 0x3a) - const macSalt = this.xorBytes(salt, 0x3a) - - // 生成 MAC 密钥 - const macKey = crypto.pbkdf2Sync(encKey, macSalt, 2, KEY_SIZE, 'sha512') - - return { encKey, macKey } - } - /** * 验证密钥是否正确 + * 目前未实现单独的验证逻辑,依赖解密过程中的验证 */ validateKey(dbPath: string, hexKey: string): boolean { - try { - const key = Buffer.from(hexKey, 'hex') - if (key.length !== KEY_SIZE) { - console.error('密钥长度错误:', key.length) - return false - } - - // 读取第一页 - const fd = fs.openSync(dbPath, 'r') - const page1 = Buffer.alloc(PAGE_SIZE) - fs.readSync(fd, page1, 0, PAGE_SIZE, 0) - fs.closeSync(fd) - - // 检查是否已解密 - if (page1.slice(0, 15).toString() === SQLITE_HEADER.slice(0, 15)) { - console.log('数据库已经是解密状态') - return true - } - - // 获取盐值 - const salt = page1.slice(0, SALT_SIZE) - - // 派生密钥 - const { macKey } = this.deriveKeys(key, salt) - - // 计算 HMAC - const dataEnd = PAGE_SIZE - RESERVE + IV_SIZE - const hmac = crypto.createHmac('sha512', macKey) - hmac.update(page1.slice(SALT_SIZE, dataEnd)) - - // 添加页码 (little-endian, 从1开始) - const pageNoBytes = Buffer.alloc(4) - pageNoBytes.writeUInt32LE(1, 0) - hmac.update(pageNoBytes) - - const calculatedMAC = hmac.digest() - const storedMAC = page1.slice(dataEnd, dataEnd + HMAC_SIZE) - - return calculatedMAC.equals(storedMAC) - } catch (e) { - console.error('验证密钥失败:', e) - return false - } + return true } /** * 解密数据库 + * 使用原生 DLL 解密(高性能、异步不卡顿) */ async decryptDatabase( inputPath: string, @@ -105,155 +24,30 @@ export class WeChatDecryptService { hexKey: string, onProgress?: (current: number, total: number) => void ): Promise<{ success: boolean; error?: string }> { + + // 检查服务是否可用 + if (!nativeDecryptService.isAvailable()) { + return { success: false, error: '原生解密服务不可用:DLL 加载失败或 Worker 未启动' } + } + try { - const key = Buffer.from(hexKey, 'hex') - if (key.length !== KEY_SIZE) { - return { success: false, error: '密钥长度错误' } - } + // console.log(`[Decrypt] 开始解密: ${inputPath} -> ${outputPath}`) // 减少日志 - // 获取文件信息 - const stats = fs.statSync(inputPath) - const fileSize = stats.size - const totalPages = Math.ceil(fileSize / PAGE_SIZE) + // 使用异步 DLL 解密 + const result = await nativeDecryptService.decryptDatabaseAsync(inputPath, outputPath, hexKey, onProgress) - // 读取第一页获取盐值 - const fd = fs.openSync(inputPath, 'r') - const page1 = Buffer.alloc(PAGE_SIZE) - fs.readSync(fd, page1, 0, PAGE_SIZE, 0) - - // 检查是否已解密 - if (page1.slice(0, 15).toString() === SQLITE_HEADER.slice(0, 15)) { - fs.closeSync(fd) - // 已解密,直接复制 - fs.copyFileSync(inputPath, outputPath) + if (result.success) { + // console.log('[Decrypt] 解密成功') // 减少日志 return { success: true } + } else { + console.warn(`[Decrypt] 解密失败: ${result.error}`) + return { success: false, error: result.error } } - - const salt = page1.slice(0, SALT_SIZE) - - // 验证密钥 - if (!this.validateKey(inputPath, hexKey)) { - fs.closeSync(fd) - return { success: false, error: '密钥验证失败' } - } - - // 派生密钥 - const { encKey, macKey } = this.deriveKeys(key, salt) - - // 确保输出目录存在 - const outputDir = path.dirname(outputPath) - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }) - } - - // 创建输出文件 - const outFd = fs.openSync(outputPath, 'w') - - // 写入 SQLite 头 - fs.writeSync(outFd, Buffer.from(SQLITE_HEADER)) - - // 处理每一页 - const pageBuf = Buffer.alloc(PAGE_SIZE) - - for (let pageNum = 0; pageNum < totalPages; pageNum++) { - // 读取一页 - const bytesRead = fs.readSync(fd, pageBuf, 0, PAGE_SIZE, pageNum * PAGE_SIZE) - - if (bytesRead === 0) break - - // 检查是否全为零 - let allZeros = true - for (let i = 0; i < bytesRead; i++) { - if (pageBuf[i] !== 0) { - allZeros = false - break - } - } - - if (allZeros) { - // 写入零页面(第一页需要减去盐值大小) - if (pageNum === 0) { - fs.writeSync(outFd, pageBuf.slice(SALT_SIZE, bytesRead)) - } else { - fs.writeSync(outFd, pageBuf.slice(0, bytesRead)) - } - continue - } - - // 解密页面 - const decrypted = this.decryptPage(pageBuf, encKey, macKey, pageNum) - - // 直接写入解密后的页面数据 - fs.writeSync(outFd, decrypted) - - // 进度回调 与 让出时间片 - // 减少到每50页让出一次,提高响应性(对于大文件更重要) - if (pageNum % 50 === 0) { - if (onProgress) { - onProgress(pageNum + 1, totalPages) - } - // 让出事件循环,防止界面卡死 - await new Promise(resolve => setImmediate(resolve)) - } - } - - fs.closeSync(fd) - fs.closeSync(outFd) - - if (onProgress) { - onProgress(totalPages, totalPages) - } - - return { success: true } } catch (e) { - console.error('解密数据库失败:', e) + console.error('[Decrypt] 调用异常:', e) return { success: false, error: String(e) } } } - - /** - * 解密单个页面 - */ - private decryptPage(pageBuf: Buffer, encKey: Buffer, macKey: Buffer, pageNum: number): Buffer { - const offset = pageNum === 0 ? SALT_SIZE : 0 - - // 验证 HMAC - const hmac = crypto.createHmac('sha512', macKey) - hmac.update(pageBuf.slice(offset, PAGE_SIZE - RESERVE + IV_SIZE)) - - const pageNoBytes = Buffer.alloc(4) - pageNoBytes.writeUInt32LE(pageNum + 1, 0) - hmac.update(pageNoBytes) - - const calculatedMAC = hmac.digest() - const hashMacStart = PAGE_SIZE - RESERVE + IV_SIZE - const storedMAC = pageBuf.slice(hashMacStart, hashMacStart + HMAC_SIZE) - - if (!calculatedMAC.equals(storedMAC)) { - console.warn(`页面 ${pageNum} HMAC 验证失败`) - } - - // 获取 IV - const iv = pageBuf.slice(PAGE_SIZE - RESERVE, PAGE_SIZE - RESERVE + IV_SIZE) - - // 解密数据 - const encrypted = pageBuf.slice(offset, PAGE_SIZE - RESERVE) - const decipher = crypto.createDecipheriv('aes-256-cbc', encKey, iv) - decipher.setAutoPadding(false) - - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final() - ]) - - // 组合解密后的页面:解密数据 + 保留区域 - const result = Buffer.concat([ - decrypted, - pageBuf.slice(PAGE_SIZE - RESERVE) - ]) - - return result - } } export const wechatDecryptService = new WeChatDecryptService() diff --git a/electron/services/nativeDecryptService.ts b/electron/services/nativeDecryptService.ts new file mode 100644 index 0000000..3237527 --- /dev/null +++ b/electron/services/nativeDecryptService.ts @@ -0,0 +1,215 @@ +/** + * 原生 DLL 解密服务 (Worker 多线程版) + * + * 使用独立的 Worker 线程加载 DLL 并执行解密, + * 彻底避免主线程阻塞,并支持实时进度回报。 + */ + +import * as path from 'path' +import * as fs from 'fs' +import { app } from 'electron' +import { Worker } from 'worker_threads' + +// 简单的 ID 生成器 +const generateId = () => Math.random().toString(36).substring(2, 15) + +interface DecryptTask { + resolve: (value: { success: boolean; error?: string }) => void + onProgress?: (current: number, total: number) => void +} + +/** + * 原生解密服务 + */ +export class NativeDecryptService { + private worker: Worker | null = null + private dllPath: string | null = null + private initialized: boolean = false + private initError: string | null = null + private tasks: Map = new Map() + + constructor() { + this.init() + } + + /** + * 初始化服务和 Worker + */ + private init(): void { + if (this.initialized) return + + try { + // 1. 查找 DLL 路径 + this.dllPath = this.findDllPath() + if (!this.dllPath) { + this.initError = '未找到 wcdb_decrypt.dll' + console.warn('[NativeDecrypt] ' + this.initError) + return + } + + // 2. 查找 Worker 脚本路径 + const workerScript = this.findWorkerPath() + if (!workerScript) { + this.initError = '未找到 decryptWorker.js' + console.warn('[NativeDecrypt] ' + this.initError) + return + } + + console.log('[NativeDecrypt] 启动 Worker:', workerScript) + console.log('[NativeDecrypt] DLL 路径:', this.dllPath) + + // 3. 启动 Worker 线程 + this.worker = new Worker(workerScript, { + workerData: { dllPath: this.dllPath } + }) + + // 4. 监听 Worker 消息 + this.worker.on('message', (msg) => this.handleWorkerMessage(msg)) + this.worker.on('error', (err: Error) => { + console.error('[NativeDecrypt] Worker 错误:', err) + this.initError = `Worker error: ${err.message}` + }) + this.worker.on('exit', (code) => { + if (code !== 0) { + console.error(`[NativeDecrypt] Worker 异常退出,代码: ${code}`) + this.worker = null + this.initialized = false + } + }) + + this.initialized = true + + } catch (e) { + this.initError = `初始化失败: ${e}` + console.error('[NativeDecrypt]', this.initError) + } + } + + /** + * 处理 Worker 发来的消息 + */ + private handleWorkerMessage(msg: any): void { + if (msg.type === 'ready') { + console.log('[NativeDecrypt] Worker 已就绪') + return + } + + const task = this.tasks.get(msg.id) + if (!task) return + + switch (msg.type) { + case 'success': + task.resolve({ success: true }) + this.tasks.delete(msg.id) + break + + case 'error': + task.resolve({ success: false, error: msg.error }) + this.tasks.delete(msg.id) + break + + case 'progress': + if (task.onProgress) { + task.onProgress(msg.current, msg.total) + } + break + } + } + + /** + * 查找 DLL 路径 + */ + private findDllPath(): string | null { + const candidates: string[] = [] + if (app.isPackaged) { + candidates.push( + path.join(process.resourcesPath, 'wcdb_decrypt.dll'), + path.join(process.resourcesPath, 'resources', 'wcdb_decrypt.dll'), + path.join(process.resourcesPath, 'app.asar.unpacked', 'resources', 'wcdb_decrypt.dll') + ) + } else { + candidates.push( + path.join(app.getAppPath(), 'resources', 'wcdb_decrypt.dll'), + path.join(app.getAppPath(), 'native-dlls', 'wcdb_decrypt', 'build', 'bin', 'Release', 'wcdb_decrypt.dll') + ) + } + return candidates.find(p => fs.existsSync(p)) || null + } + + /** + * 查找 Worker 脚本路径 + */ + private findWorkerPath(): string | null { + const candidates: string[] = [] + + if (app.isPackaged) { + // 生产模式:Worker 被编译到 dist-electron/workers 目录 + candidates.push( + path.join(process.resourcesPath, 'app.asar.unpacked', 'dist-electron', 'workers', 'decryptWorker.js'), + path.join(process.resourcesPath, 'dist-electron', 'workers', 'decryptWorker.js'), + path.join(__dirname, 'workers', 'decryptWorker.js'), + path.join(__dirname, '..', 'workers', 'decryptWorker.js') + ) + } else { + // 开发模式:Worker 在源码目录 + candidates.push( + path.join(app.getAppPath(), 'electron', 'workers', 'decryptWorker.js'), + path.join(__dirname, '..', 'workers', 'decryptWorker.js') + ) + } + + const found = candidates.find(p => fs.existsSync(p)) + if (found) { + console.log('[NativeDecrypt] 找到 Worker:', found) + } else { + console.error('[NativeDecrypt] 未找到 Worker,尝试的路径:', candidates) + } + return found || null + } + + /** + * 检查服务是否可用 + */ + isAvailable(): boolean { + return this.initialized && this.worker !== null + } + + /** + * 异步解密数据库(通过 Worker) + */ + async decryptDatabaseAsync( + inputPath: string, + outputPath: string, + hexKey: string, + onProgress?: (current: number, total: number) => void + ): Promise<{ success: boolean; error?: string }> { + if (!this.worker) { + // 如果 Worker 挂了,尝试重启 + if (!this.initialized && !this.initError) { + this.init() + } + if (!this.worker) { + return { success: false, error: this.initError || 'Worker 未启动' } + } + } + + return new Promise((resolve) => { + const id = generateId() + + // 注册任务 + this.tasks.set(id, { resolve, onProgress }) + + // 发送消息 + this.worker!.postMessage({ + type: 'decrypt', + id, + inputPath, + outputPath, + hexKey + }) + }) + } +} + +// 导出单例 +export const nativeDecryptService = new NativeDecryptService() diff --git a/electron/workers/decryptWorker.js b/electron/workers/decryptWorker.js new file mode 100644 index 0000000..10e01c5 --- /dev/null +++ b/electron/workers/decryptWorker.js @@ -0,0 +1,88 @@ +const { parentPort, workerData } = require('worker_threads') +const path = require('path') +const fs = require('fs') +const koffi = require('koffi') + +// 从 workerData 获取 DLL 路径 +const { dllPath } = workerData + +if (!dllPath || !fs.existsSync(dllPath)) { + parentPort?.postMessage({ type: 'error', error: 'DLL path not found: ' + dllPath }) + process.exit(1) +} + +try { + // 加载 DLL + const lib = koffi.load(dllPath) + + // 定义回调类型 + const ProgressCallback = koffi.proto('void ProgressCallback(int current, int total)') + + // 绑定函数 (这里使用同步版本,因为 Worker 本身就是独立的线程) + const Wcdb_DecryptDatabaseWithProgress = lib.func('int Wcdb_DecryptDatabaseWithProgress(const char* inputPath, const char* outputPath, const char* hexKey, ProgressCallback* callback)') + const Wcdb_GetLastErrorMsg = lib.func('int Wcdb_GetLastErrorMsg(char* buffer, int size)') + + // 监听主线程消息 + parentPort?.on('message', (message) => { + if (message.type === 'decrypt') { + const { id, inputPath, outputPath, hexKey } = message + + try { + // 确保输出目录存在 + const outputDir = path.dirname(outputPath) + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }) + } + + // 定义进度回调 (带节流,每 100ms 更新一次) + let lastUpdate = 0 + + const onProgress = koffi.register((current, total) => { + const now = Date.now() + if (now - lastUpdate > 100 || current === total || current === 1) { + lastUpdate = now + parentPort?.postMessage({ + type: 'progress', + id, + current, + total + }) + } + }, koffi.pointer(ProgressCallback)) + + // 执行解密 + const result = Wcdb_DecryptDatabaseWithProgress(inputPath, outputPath, hexKey, onProgress) + + // 注销回调以释放资源 + koffi.unregister(onProgress) + + if (result === 0) { + parentPort?.postMessage({ type: 'success', id }) + } else { + // 获取错误信息 + const buffer = Buffer.alloc(512) + Wcdb_GetLastErrorMsg(buffer, 512) + const errorMsg = buffer.toString('utf8').replace(/\0+$/, '') + + parentPort?.postMessage({ + type: 'error', + id, + error: errorMsg || `ErrorCode: ${result}` + }) + } + } catch (err) { + parentPort?.postMessage({ + type: 'error', + id, + error: String(err) + }) + } + } + }) + + // 通知主线程 Worker 已就绪 + parentPort?.postMessage({ type: 'ready' }) + +} catch (err) { + parentPort?.postMessage({ type: 'error', error: String(err) }) +} diff --git a/package.json b/package.json index 0f9cf85..6570487 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ciphertalk", - "version": "2.1.4", + "version": "2.1.5", "description": "密语 - 微信聊天记录查看工具", "author": "ILoveBingLu", "license": "CC-BY-NC-SA-4.0", @@ -20,6 +20,8 @@ "dependencies": { "@types/dompurify": "^3.0.5", "@types/marked": "^5.0.2", + "@types/react-virtualized-auto-sizer": "^1.0.4", + "@types/react-window": "^1.8.8", "better-sqlite3": "^12.5.0", "dom-to-image-more": "^3.7.2", "dompurify": "^3.3.1", @@ -40,6 +42,8 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "react-router-dom": "^7.1.1", + "react-virtualized-auto-sizer": "^2.0.2", + "react-window": "^2.2.5", "sherpa-onnx-node": "^1.12.23", "silk-wasm": "^3.7.1", "wechat-emojis": "^1.0.2", @@ -134,7 +138,10 @@ "asarUnpack": [ "node_modules/ffmpeg-static/**/*", "node_modules/silk-wasm/**/*", - "node_modules/sherpa-onnx-node/**/*" + "node_modules/sherpa-onnx-node/**/*", + "node_modules/koffi/**/*", + "dist-electron/workers/**/*", + "resources/**/*" ] } } diff --git a/resources/wcdb_decrypt.dll b/resources/wcdb_decrypt.dll new file mode 100644 index 0000000000000000000000000000000000000000..41f5259de8f3b50007bccf20bd08b2a2221a899c GIT binary patch literal 39936 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 YLps2*&8`Pa9&CFM!Edhrhc)oO0Z%=J=l}o! literal 0 HcmV?d00001 diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index c3f54f6..323dea5 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1644,6 +1644,85 @@ } } +// 文件消息卡片 +.file-message { + width: 240px; + background: var(--card-bg); + border-radius: 8px; + padding: 12px; + display: flex; + gap: 12px; + align-items: center; + border: 1px solid var(--border-color); + cursor: pointer; + transition: all 0.2s ease; + user-select: none; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + &:active { + transform: translateY(0); + } + + .file-icon { + flex-shrink: 0; + width: 44px; + height: 44px; + background: linear-gradient(135deg, #4a90d9 0%, #357abd 100%); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: white; + + svg { + width: 24px; + height: 24px; + } + } + + .file-info { + flex: 1; + min-width: 0; + overflow: hidden; + + .file-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; + } + + .file-meta { + font-size: 12px; + color: var(--text-tertiary); + } + } +} + +// 发送的文件消息样式 +.message-bubble.sent .file-message { + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.1); + + .file-name { + color: #333; + } + + .file-meta { + color: #999; + } +} + // 转账消息卡片 .transfer-message { width: 240px; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 984580c..292782f 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,14 +1,23 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, memo } from 'react' import { createPortal } from 'react-dom' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive } from 'lucide-react' import { useChatStore } from '../stores/chatStore' import { useUpdateStatusStore } from '../stores/updateStatusStore' import ChatBackground from '../components/ChatBackground' import MessageContent from '../components/MessageContent' import { getImageXorKey, getImageAesKey, getQuoteStyle } from '../services/config' +import { LRUCache } from '../utils/lruCache' import type { ChatSession, Message } from '../types/models' +import { List, RowComponentProps } from 'react-window' import './ChatPage.scss' +interface SessionRowData { + sessions: ChatSession[] + currentSessionId: string | null + onSelect: (s: ChatSession) => void + formatTime: (t: number) => string +} + interface ChatPageProps { @@ -155,6 +164,48 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu ) } +// 会话列表行组件(使用 memo 优化性能) +const SessionRow = (props: RowComponentProps) => { + const { index, style, sessions, currentSessionId, onSelect, formatTime } = props + const session = sessions[index] + + return ( +
onSelect(session)} + > + +
+
+ {session.displayName || session.username} + {formatTime(session.lastTimestamp || session.sortTimestamp)} +
+
+ + {(() => { + const summary = session.summary || '暂无消息' + const firstLine = summary.split('\n')[0] + const hasMoreLines = summary.includes('\n') + return ( + <> + + {hasMoreLines && ...} + + ) + })()} + + {session.unreadCount > 0 && ( + + {session.unreadCount > 99 ? '99+' : session.unreadCount} + + )} +
+
+
+ ) +} + function ChatPage(_props: ChatPageProps) { const [quoteStyle, setQuoteStyle] = useState<'default' | 'wechat'>('default') @@ -322,14 +373,14 @@ function ChatPage(_props: ChatPageProps) { // 合并:保留顺序,只更新变化的字段 const merged = result.sessions!.map(newSession => { const oldSession = oldSessionsMap.get(newSession.username) - + // 如果是新会话,直接返回 if (!oldSession) { return newSession } // 检查是否有实质性变化 - const hasChanges = + const hasChanges = oldSession.summary !== newSession.summary || oldSession.lastTimestamp !== newSession.lastTimestamp || oldSession.unreadCount !== newSession.unreadCount || @@ -439,6 +490,40 @@ function ChatPage(_props: ChatPageProps) { } } + // 监听增量消息推送 + useEffect(() => { + // 告知后端当前会话 + window.electronAPI.chat.setCurrentSession(currentSessionId) + + const cleanup = window.electronAPI.chat.onNewMessages((data: { sessionId: string; messages: Message[] }) => { + if (data.sessionId === currentSessionId && data.messages && data.messages.length > 0) { + setMessages((prev: Message[]) => { + // 使用 sortSeq 去重 + const newMsgs = data.messages.filter((nm: Message) => + !prev.some((pm: Message) => pm.sortSeq === nm.sortSeq) + ) + if (newMsgs.length === 0) return prev + + return [...prev, ...newMsgs] + }) + + // 平滑滚动到底部 + requestAnimationFrame(() => scrollToBottom(true)) + } + }) + + return () => { + cleanup() + } + }, [currentSessionId]) + + // 组件卸载时取消当前会话 + useEffect(() => { + return () => { + window.electronAPI.chat.setCurrentSession(null) + } + }, []) + // 选择会话 const handleSelectSession = (session: ChatSession) => { if (session.username === currentSessionId) { @@ -559,11 +644,11 @@ function ChatPage(_props: ChatPageProps) { useEffect(() => { if (!isConnected) return - // 监听会话列表更新 + // 监听会话列表更新 const removeSessionsListener = window.electronAPI.chat.onSessionsUpdated?.(async (newSessions) => { // 更新增量更新时间戳 lastIncrementalUpdateTime = Date.now() - + // 智能合并更新会话列表,避免闪烁 setSessions((prevSessions: ChatSession[]) => { // 如果之前没有会话,直接设置 @@ -579,14 +664,14 @@ function ChatPage(_props: ChatPageProps) { // 合并:保留顺序,只更新变化的字段 const merged = newSessions.map(newSession => { const oldSession = oldSessionsMap.get(newSession.username) - + // 如果是新会话,直接返回 if (!oldSession) { return newSession } // 检查是否有实质性变化 - const hasChanges = + const hasChanges = oldSession.summary !== newSession.summary || oldSession.lastTimestamp !== newSession.lastTimestamp || oldSession.unreadCount !== newSession.unreadCount || @@ -817,43 +902,22 @@ function ChatPage(_props: ChatPageProps) { ))} ) : filteredSessions.length > 0 ? ( -
- {filteredSessions.map(session => ( -
handleSelectSession(session)} - > - -
-
- {session.displayName || session.username} - {formatSessionTime(session.lastTimestamp || session.sortTimestamp)} -
-
- - {(() => { - const summary = session.summary || '暂无消息' - const firstLine = summary.split('\n')[0] - const hasMoreLines = summary.includes('\n') - return ( - <> - - {hasMoreLines && ...} - - ) - })()} - - {session.unreadCount > 0 && ( - - {session.unreadCount > 99 ? '99+' : session.unreadCount} - - )} -
-
-
- ))} +
+ {/* @ts-ignore - 类型定义不匹配但不影响运行 */} +
+ ) : (
@@ -1357,10 +1421,10 @@ function ChatPage(_props: ChatPageProps) { ) } -// 前端表情包缓存 -const emojiDataUrlCache = new Map() -// 前端图片缓存 -const imageDataUrlCache = new Map() +// 前端表情包缓存 (LRU 限制) +const emojiDataUrlCache = new LRUCache(200) +// 前端图片缓存 (LRU 限制) +const imageDataUrlCache = new LRUCache(50) // 图片解密队列管理 const imageDecryptQueue: Array<() => Promise> = [] @@ -1387,7 +1451,7 @@ function enqueueDecrypt(fn: () => Promise) { } // 视频信息缓存(带时间戳) -const videoInfoCache = new Map { if (isGroupChat && !isSent && message.senderUsername) { setIsLoadingSender(true) @@ -1855,7 +1919,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h setSenderName(result.displayName) } setIsLoadingSender(false) - }).catch(() => { + }).catch(() => { setIsLoadingSender(false) }) } @@ -2436,26 +2500,122 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h ) } + // 文件消息 (type=6):渲染为文件卡片 + if (appMsgType === '6') { + // 优先使用从接口获取的文件信息,否则从 XML 解析 + const fileName = message.fileName || title || '文件' + const fileSize = message.fileSize + const fileExt = message.fileExt || fileName.split('.').pop()?.toLowerCase() || '' + const fileMd5 = message.fileMd5 + + // 格式化文件大小 + const formatFileSize = (bytes: number | undefined): string => { + if (!bytes) return '' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB` + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB` + } + + // 根据扩展名选择图标 + const getFileIcon = (ext: string) => { + const archiveExts = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'] + if (archiveExts.includes(ext)) { + return + } + return + } + + // 点击文件消息,定位到文件所在文件夹并选中文件 + const handleFileClick = async () => { + try { + // 获取用户设置的微信原始存储目录(不是解密缓存目录) + const wechatDir = await window.electronAPI.config.get('dbPath') as string + if (!wechatDir) { + console.error('未设置微信存储目录') + return + } + + // 获取当前用户信息 + const userInfo = await window.electronAPI.chat.getMyUserInfo() + if (!userInfo.success || !userInfo.userInfo) { + console.error('无法获取用户信息') + return + } + + const wxid = userInfo.userInfo.wxid + + // 文件存储在 {微信存储目录}\{账号文件夹}\msg\file\{年-月}\ 目录下 + // 根据消息创建时间计算日期目录 + const msgDate = new Date(message.createTime * 1000) + const year = msgDate.getFullYear() + const month = String(msgDate.getMonth() + 1).padStart(2, '0') + const dateFolder = `${year}-${month}` + + // 构建完整文件路径(包括文件名) + const filePath = `${wechatDir}\\${wxid}\\msg\\file\\${dateFolder}\\${fileName}` + + // 使用 showItemInFolder 在文件管理器中定位并选中文件 + try { + await window.electronAPI.shell.showItemInFolder(filePath) + } catch (err) { + // 如果文件不存在或路径错误,尝试只打开文件夹 + console.warn('无法定位到具体文件,尝试打开文件夹:', err) + const fileDir = `${wechatDir}\\${wxid}\\msg\\file\\${dateFolder}` + const result = await window.electronAPI.shell.openPath(fileDir) + + // 如果还是失败,打开上级目录 + if (result) { + console.warn('无法打开月份文件夹,尝试打开上级目录') + const parentDir = `${wechatDir}\\${wxid}\\msg\\file` + await window.electronAPI.shell.openPath(parentDir) + } + } + } catch (error) { + console.error('打开文件夹失败:', error) + } + } + + return ( +
+
+ {getFileIcon(fileExt)} +
+
+
{fileName}
+
+ {fileSize ? formatFileSize(fileSize) : ''} +
+
+
+ ) + } + // 转账消息 (type=2000):渲染为转账卡片 if (appMsgType === '2000') { try { const content = message.rawContent || message.parsedContent || '' const parser = new DOMParser() const doc = parser.parseFromString(content, 'text/xml') - + const feedesc = doc.querySelector('feedesc')?.textContent || '' const payMemo = doc.querySelector('pay_memo')?.textContent || '' const paysubtype = doc.querySelector('paysubtype')?.textContent || '1' - + // paysubtype: 1=待收款, 3=已收款 const isReceived = paysubtype === '3' - + return (
- - + +
diff --git a/src/pages/DataManagementPage.tsx b/src/pages/DataManagementPage.tsx index 9d8da43..d64dd6c 100644 --- a/src/pages/DataManagementPage.tsx +++ b/src/pages/DataManagementPage.tsx @@ -111,6 +111,17 @@ function DataManagementPage() { } const handleDecryptAll = async () => { + // 先检查是否配置了解密密钥 + const decryptKey = await window.electronAPI.config.get('decryptKey') + if (!decryptKey) { + showMessage('请先在设置页面配置解密密钥', false) + // 3秒后自动跳转到设置页面 + setTimeout(() => { + window.location.hash = '#/settings' + }, 3000) + return + } + // 检查聊天窗口是否打开 const isChatOpen = await window.electronAPI.window.isChatWindowOpen() if (isChatOpen) { diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index fda8b32..52f12f8 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -36,7 +36,7 @@ export interface ChatState { setFilteredSessions: (sessions: ChatSession[]) => void setCurrentSession: (sessionId: string | null) => void setLoadingSessions: (loading: boolean) => void - setMessages: (messages: Message[]) => void + setMessages: (messages: Message[] | ((prev: Message[]) => Message[])) => void appendMessages: (messages: Message[], prepend?: boolean) => void setLoadingMessages: (loading: boolean) => void setLoadingMore: (loading: boolean) => void @@ -82,24 +82,26 @@ export const useChatStore = create((set, get) => ({ setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), - setMessages: (messages) => set({ messages }), + setMessages: (messages) => set((state) => ({ + messages: typeof messages === 'function' ? messages(state.messages) : messages + })), appendMessages: (newMessages, prepend = false) => set((state) => { // 使用与后端一致的多维 Key (serverId + localId + createTime + sortSeq) 进行去重 const existingKeys = new Set( state.messages.map(m => `${m.serverId}-${m.localId}-${m.createTime}-${m.sortSeq}`) ) - + // 过滤掉已存在的消息 const uniqueNewMessages = newMessages.filter( msg => !existingKeys.has(`${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`) ) - + // 如果没有新消息,直接返回原状态 if (uniqueNewMessages.length === 0) { return state } - + return { messages: prepend ? [...uniqueNewMessages, ...state.messages] diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index ebeec38..b480009 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -46,6 +46,7 @@ export interface ElectronAPI { shell: { openPath: (path: string) => Promise openExternal: (url: string) => Promise + showItemInFolder: (fullPath: string) => Promise } app: { getDownloadsPath: () => Promise @@ -202,6 +203,8 @@ export interface ElectronAPI { downloadEmoji: (cdnUrl: string, md5?: string, productId?: string, createTime?: number) => Promise<{ success: boolean; localPath?: string; error?: string }> close: () => Promise refreshCache: () => Promise + setCurrentSession: (sessionId: string | null) => Promise + onNewMessages: (callback: (data: { sessionId: string; messages: Message[] }) => void) => () => void getSessionDetail: (sessionId: string) => Promise<{ success: boolean detail?: { @@ -387,7 +390,7 @@ export interface ElectronAPI { successCount?: number error?: string }> - onProgress: (callback: (data: { + onProgress: (callback: (data: { current?: number total?: number currentSession?: string diff --git a/src/types/models.ts b/src/types/models.ts index 6dbd2da..7be2b9e 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -55,6 +55,11 @@ export interface Message { videoMd5?: string rawContent?: string productId?: string + // 文件消息相关 + fileName?: string // 文件名 + fileSize?: number // 文件大小(字节) + fileExt?: string // 文件扩展名 + fileMd5?: string // 文件 MD5 } // 分析数据 diff --git a/src/utils/lruCache.ts b/src/utils/lruCache.ts new file mode 100644 index 0000000..3a473c6 --- /dev/null +++ b/src/utils/lruCache.ts @@ -0,0 +1,50 @@ +/** + * 简单的 LRU (Least Recently Used) 缓存实现 + * 用于限制内存中缓存对象的数量,防止内存泄漏 + */ +export class LRUCache { + private capacity: number; + private cache: Map; + + constructor(capacity: number) { + this.capacity = capacity; + this.cache = new Map(); + } + + get(key: K): V | undefined { + if (!this.cache.has(key)) return undefined; + + // 刷新项目:先删除再添加,使其成为最新的(排在 Map 末尾) + const value = this.cache.get(key)!; + this.cache.delete(key); + this.cache.set(key, value); + return value; + } + + set(key: K, value: V): void { + if (this.cache.has(key)) { + // 如果已存在,删除旧的以便重新添加到末尾 + this.cache.delete(key); + } else if (this.cache.size >= this.capacity) { + // 如果达到容量上限,删除第一个项目(最久未使用的) + // Map.keys().next().value 获取的是插入顺序最早的那个 + const firstKey = this.cache.keys().next().value; + if (firstKey !== undefined) { + this.cache.delete(firstKey); + } + } + this.cache.set(key, value); + } + + has(key: K): boolean { + return this.cache.has(key); + } + + clear(): void { + this.cache.clear(); + } + + get size(): number { + return this.cache.size; + } +} diff --git a/vite.config.ts b/vite.config.ts index fb9cd58..046f1ed 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -35,6 +35,18 @@ export default defineConfig({ } } }, + { + // 数据库解密 Worker 线程 + entry: 'electron/workers/decryptWorker.js', + vite: { + build: { + outDir: 'dist-electron/workers', + rollupOptions: { + external: ['koffi'] + } + } + } + }, { // 语音转写 Worker 线程 entry: 'electron/transcribeWorker.ts',