mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-04-27 05:52:36 +08:00
TS 实现简易 LRU 缓存:新增缓存类 + 核心方法 + 容量满自动淘汰 LRU 项
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
**一款现代化的微信聊天记录查看与分析工具**
|
**一款现代化的微信聊天记录查看与分析工具**
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](package.json)
|
[](package.json)
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { join } from 'path'
|
|||||||
import { readFileSync, existsSync } from 'fs'
|
import { readFileSync, existsSync } from 'fs'
|
||||||
import { autoUpdater } from 'electron-updater'
|
import { autoUpdater } from 'electron-updater'
|
||||||
import { DatabaseService } from './services/database'
|
import { DatabaseService } from './services/database'
|
||||||
import { DecryptService } from './services/decrypt'
|
|
||||||
import { wechatDecryptService } from './services/decryptService'
|
import { wechatDecryptService } from './services/decryptService'
|
||||||
import { ConfigService } from './services/config'
|
import { ConfigService } from './services/config'
|
||||||
import { wxKeyService } from './services/wxKeyService'
|
import { wxKeyService } from './services/wxKeyService'
|
||||||
@@ -65,7 +65,7 @@ function isNewerVersion(version1: string, version2: string): boolean {
|
|||||||
|
|
||||||
// 单例服务
|
// 单例服务
|
||||||
let dbService: DatabaseService | null = null
|
let dbService: DatabaseService | null = null
|
||||||
let decryptService: DecryptService | null = null
|
|
||||||
let configService: ConfigService | null = null
|
let configService: ConfigService | null = null
|
||||||
let logService: LogService | null = null
|
let logService: LogService | null = null
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ function createWindow() {
|
|||||||
// 初始化服务
|
// 初始化服务
|
||||||
configService = new ConfigService()
|
configService = new ConfigService()
|
||||||
dbService = new DatabaseService()
|
dbService = new DatabaseService()
|
||||||
decryptService = new DecryptService()
|
|
||||||
logService = new LogService(configService)
|
logService = new LogService(configService)
|
||||||
|
|
||||||
// 记录应用启动日志
|
// 记录应用启动日志
|
||||||
@@ -888,11 +888,22 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
// 解密相关
|
// 解密相关
|
||||||
ipcMain.handle('decrypt:database', async (_, sourcePath: string, key: string, outputPath: string) => {
|
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) => {
|
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)
|
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 () => {
|
ipcMain.handle('app:getDownloadsPath', async () => {
|
||||||
return app.getPath('downloads')
|
return app.getPath('downloads')
|
||||||
})
|
})
|
||||||
@@ -1124,7 +1140,7 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
// 发送状态:准备启动微信
|
// 发送状态:准备启动微信
|
||||||
event.sender.send('wxkey:status', { status: '正在安装 Hook...', level: 1 })
|
event.sender.send('wxkey:status', { status: '正在安装 Hook...', level: 1 })
|
||||||
|
|
||||||
// 获取微信路径
|
// 获取微信路径
|
||||||
const wechatPath = customWechatPath || wxKeyService.getWeChatPath()
|
const wechatPath = customWechatPath || wxKeyService.getWeChatPath()
|
||||||
if (!wechatPath) {
|
if (!wechatPath) {
|
||||||
@@ -1223,10 +1239,10 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('dbpath:getBestCachePath', async () => {
|
ipcMain.handle('dbpath:getBestCachePath', async () => {
|
||||||
const { existsSync } = require('fs')
|
const { existsSync } = require('fs')
|
||||||
const { join } = require('path')
|
const { join } = require('path')
|
||||||
|
|
||||||
// 按优先级检查磁盘:D、E、F、C
|
// 按优先级检查磁盘:D、E、F、C
|
||||||
const drives = ['D', 'E', 'F', 'C']
|
const drives = ['D', 'E', 'F', 'C']
|
||||||
|
|
||||||
for (const drive of drives) {
|
for (const drive of drives) {
|
||||||
const drivePath = `${drive}:\\`
|
const drivePath = `${drive}:\\`
|
||||||
if (existsSync(drivePath)) {
|
if (existsSync(drivePath)) {
|
||||||
@@ -1235,7 +1251,7 @@ function registerIpcHandlers() {
|
|||||||
return { success: true, path: cachePath, drive }
|
return { success: true, path: cachePath, drive }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果都没有,返回用户目录下的默认路径
|
// 如果都没有,返回用户目录下的默认路径
|
||||||
const { app } = require('electron')
|
const { app } = require('electron')
|
||||||
const defaultPath = join(app.getPath('userData'), 'cache')
|
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) => {
|
ipcMain.handle('wcdb:decryptDatabase', async (event, dbPath: string, hexKey: string, wxid: string) => {
|
||||||
logService?.info('Decrypt', '开始解密数据库', { dbPath, wxid })
|
logService?.info('Decrypt', '开始解密数据库', { dbPath, wxid })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用已有的 dataManagementService 来解密
|
// 使用已有的 dataManagementService 来解密
|
||||||
const result = await dataManagementService.decryptAll()
|
const result = await dataManagementService.decryptAll()
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logService?.info('Decrypt', '解密完成', {
|
logService?.info('Decrypt', '解密完成', {
|
||||||
successCount: result.successCount,
|
successCount: result.successCount,
|
||||||
failCount: result.failCount
|
failCount: result.failCount
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
totalFiles: (result.successCount || 0) + (result.failCount || 0),
|
totalFiles: (result.successCount || 0) + (result.failCount || 0),
|
||||||
successCount: result.successCount,
|
successCount: result.successCount,
|
||||||
failCount: result.failCount
|
failCount: result.failCount
|
||||||
@@ -1561,6 +1577,11 @@ function registerIpcHandlers() {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('chat:setCurrentSession', async (_, sessionId: string | null) => {
|
||||||
|
chatService.setCurrentSession(sessionId)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => {
|
ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => {
|
||||||
const result = await chatService.getSessionDetail(sessionId)
|
const result = await chatService.getSessionDetail(sessionId)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -1672,7 +1693,7 @@ function registerIpcHandlers() {
|
|||||||
if (welcomeWindow && !welcomeWindow.isDestroyed()) {
|
if (welcomeWindow && !welcomeWindow.isDestroyed()) {
|
||||||
welcomeWindow.close()
|
welcomeWindow.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果主窗口还不存在,创建它
|
// 如果主窗口还不存在,创建它
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
mainWindow = createWindow()
|
mainWindow = createWindow()
|
||||||
@@ -1681,7 +1702,7 @@ function registerIpcHandlers() {
|
|||||||
mainWindow.show()
|
mainWindow.show()
|
||||||
mainWindow.focus()
|
mainWindow.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1978,8 +1999,8 @@ function registerIpcHandlers() {
|
|||||||
try {
|
try {
|
||||||
const { proxyService } = await import('./services/ai/proxyService')
|
const { proxyService } = await import('./services/ai/proxyService')
|
||||||
const proxyUrl = await proxyService.getSystemProxy()
|
const proxyUrl = await proxyService.getSystemProxy()
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
hasProxy: !!proxyUrl,
|
hasProxy: !!proxyUrl,
|
||||||
proxyUrl: proxyUrl || null
|
proxyUrl: proxyUrl || null
|
||||||
}
|
}
|
||||||
@@ -1993,8 +2014,8 @@ function registerIpcHandlers() {
|
|||||||
const { proxyService } = await import('./services/ai/proxyService')
|
const { proxyService } = await import('./services/ai/proxyService')
|
||||||
proxyService.clearCache()
|
proxyService.clearCache()
|
||||||
const proxyUrl = await proxyService.getSystemProxy()
|
const proxyUrl = await proxyService.getSystemProxy()
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
hasProxy: !!proxyUrl,
|
hasProxy: !!proxyUrl,
|
||||||
proxyUrl: proxyUrl || null,
|
proxyUrl: proxyUrl || null,
|
||||||
message: proxyUrl ? `已刷新代理: ${proxyUrl}` : '未检测到代理,使用直连'
|
message: proxyUrl ? `已刷新代理: ${proxyUrl}` : '未检测到代理,使用直连'
|
||||||
@@ -2008,8 +2029,8 @@ function registerIpcHandlers() {
|
|||||||
try {
|
try {
|
||||||
const { proxyService } = await import('./services/ai/proxyService')
|
const { proxyService } = await import('./services/ai/proxyService')
|
||||||
const success = await proxyService.testProxy(proxyUrl, testUrl)
|
const success = await proxyService.testProxy(proxyUrl, testUrl)
|
||||||
return {
|
return {
|
||||||
success,
|
success,
|
||||||
message: success ? '代理连接正常' : '代理连接失败'
|
message: success ? '代理连接正常' : '代理连接失败'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// Shell
|
// Shell
|
||||||
shell: {
|
shell: {
|
||||||
openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path),
|
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
|
// 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),
|
downloadEmoji: (cdnUrl: string, md5?: string, productId?: string, createTime?: number) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5, productId, createTime),
|
||||||
close: () => ipcRenderer.invoke('chat:close'),
|
close: () => ipcRenderer.invoke('chat:close'),
|
||||||
refreshCache: () => ipcRenderer.invoke('chat:refreshCache'),
|
refreshCache: () => ipcRenderer.invoke('chat:refreshCache'),
|
||||||
|
setCurrentSession: (sessionId: string | null) => ipcRenderer.invoke('chat:setCurrentSession', sessionId),
|
||||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime),
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime),
|
||||||
onSessionsUpdated: (callback: (sessions: any[]) => void) => {
|
onSessionsUpdated: (callback: (sessions: any[]) => void) => {
|
||||||
const listener = (_: any, sessions: any[]) => callback(sessions)
|
const listener = (_: any, sessions: any[]) => callback(sessions)
|
||||||
ipcRenderer.on('chat:sessions-updated', listener)
|
ipcRenderer.on('chat:sessions-updated', listener)
|
||||||
return () => ipcRenderer.removeListener('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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ export interface Message {
|
|||||||
voiceDuration?: number // 语音时长(秒)
|
voiceDuration?: number // 语音时长(秒)
|
||||||
// 商店表情相关
|
// 商店表情相关
|
||||||
productId?: string
|
productId?: string
|
||||||
|
// 文件消息相关
|
||||||
|
fileName?: string // 文件名
|
||||||
|
fileSize?: number // 文件大小(字节)
|
||||||
|
fileExt?: string // 文件扩展名
|
||||||
|
fileMd5?: string // 文件 MD5
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Contact {
|
export interface Contact {
|
||||||
@@ -104,11 +109,24 @@ class ChatService extends EventEmitter {
|
|||||||
private syncTimer: NodeJS.Timeout | null = null
|
private syncTimer: NodeJS.Timeout | null = null
|
||||||
private lastDbCheckTime: number = 0
|
private lastDbCheckTime: number = 0
|
||||||
|
|
||||||
|
// 增量同步相关
|
||||||
|
private currentSessionId: string | null = null
|
||||||
|
// 记录每个会话已读取的最大 sortSeq (用于此后的增量查询)
|
||||||
|
private sessionCursor: Map<string, number> = new Map()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前聚焦的会话 ID
|
||||||
|
* 用于增量同步时只推送当前会话的消息
|
||||||
|
*/
|
||||||
|
setCurrentSession(sessionId: string | null): void {
|
||||||
|
this.currentSessionId = sessionId
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理账号目录名(支持 wxid_ 格式和自定义微信号格式)
|
* 清理账号目录名(支持 wxid_ 格式和自定义微信号格式)
|
||||||
*/
|
*/
|
||||||
@@ -324,6 +342,69 @@ class ChatService extends EventEmitter {
|
|||||||
this.dbDir = null
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 尝试推送增量消息
|
||||||
|
this.checkNewMessagesForCurrentSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -907,8 +991,13 @@ class ChatService extends EventEmitter {
|
|||||||
limit: number = 50
|
limit: number = 50
|
||||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
|
// 如果数据库未连接,尝试自动重连
|
||||||
|
// 这解决了增量更新期间数据库被关闭后,用户无法查询消息的问题
|
||||||
if (!this.dbDir) {
|
if (!this.dbDir) {
|
||||||
return { success: false, error: '数据库未连接' }
|
const connectResult = await this.connect()
|
||||||
|
if (!connectResult.success) {
|
||||||
|
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前用户的 wxid
|
// 获取当前用户的 wxid
|
||||||
@@ -1028,6 +1117,19 @@ class ChatService extends EventEmitter {
|
|||||||
quotedSender = quoteInfo.sender
|
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)
|
const parsedContent = this.parseMessageContent(content, localType)
|
||||||
|
|
||||||
allMessages.push({
|
allMessages.push({
|
||||||
@@ -1048,7 +1150,11 @@ class ChatService extends EventEmitter {
|
|||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
videoMd5,
|
videoMd5,
|
||||||
voiceDuration
|
voiceDuration,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
fileExt,
|
||||||
|
fileMd5
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -1083,9 +1189,20 @@ class ChatService extends EventEmitter {
|
|||||||
const hasMore = allMessages.length > offset + limit
|
const hasMore = allMessages.length > offset + limit
|
||||||
const messages = allMessages.slice(offset, offset + limit)
|
const messages = allMessages.slice(offset, offset + limit)
|
||||||
|
|
||||||
|
// 反转使最新消息在最后(UI 显示顺序)
|
||||||
// 反转使最新消息在最后(UI 显示顺序)
|
// 反转使最新消息在最后(UI 显示顺序)
|
||||||
messages.reverse()
|
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 }
|
return { success: true, messages, hasMore }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: 获取消息失败:', 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 文件名
|
* 从数据库行中解析图片 dat 文件名
|
||||||
*/
|
*/
|
||||||
@@ -1621,7 +1769,7 @@ class ChatService extends EventEmitter {
|
|||||||
private shouldKeepSession(username: string): boolean {
|
private shouldKeepSession(username: string): boolean {
|
||||||
if (!username) return false
|
if (!username) return false
|
||||||
if (username.startsWith('gh_')) return false
|
if (username.startsWith('gh_')) return false
|
||||||
|
|
||||||
// 过滤折叠对话占位符
|
// 过滤折叠对话占位符
|
||||||
if (username === '@placeholder_foldgroup') return false
|
if (username === '@placeholder_foldgroup') return false
|
||||||
|
|
||||||
@@ -3254,7 +3402,7 @@ class ChatService extends EventEmitter {
|
|||||||
if (this.syncTimer) {
|
if (this.syncTimer) {
|
||||||
clearInterval(this.syncTimer)
|
clearInterval(this.syncTimer)
|
||||||
this.syncTimer = null
|
this.syncTimer = null
|
||||||
console.log('[ChatService] 停止自动增量同步')
|
// console.log('[ChatService] 停止自动增量同步') // 减少日志
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3319,6 +3467,187 @@ class ChatService extends EventEmitter {
|
|||||||
console.error('[ChatService] 检查更新出错:', e)
|
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('<type>57</type>'))) {
|
||||||
|
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()
|
export const chatService = new ChatService()
|
||||||
|
|||||||
@@ -345,6 +345,10 @@ class DataManagementService {
|
|||||||
const time = new Date().toLocaleTimeString()
|
const time = new Date().toLocaleTimeString()
|
||||||
console.error(`[${time}] [数据解密] 解密失败: ${file.fileName}`, result.error)
|
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: '请先在设置页面配置解密密钥' }
|
return { success: false, error: '请先在设置页面配置解密密钥' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭所有可能占用数据库文件的服务
|
// 不再关闭整个 chatService,而是在更新每个文件前只关闭那个特定的数据库
|
||||||
chatService.close()
|
// 这样用户可以在增量更新时继续查看其他会话的消息
|
||||||
imageDecryptService.clearHardlinkCache()
|
imageDecryptService.clearHardlinkCache()
|
||||||
|
|
||||||
// 等待所有数据库连接完全关闭(给一些时间让文件句柄释放)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
||||||
|
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
let failCount = 0
|
let failCount = 0
|
||||||
const totalFiles = filesToUpdate.length
|
const totalFiles = filesToUpdate.length
|
||||||
@@ -401,7 +402,7 @@ class DataManagementService {
|
|||||||
|
|
||||||
// 在处理每个文件前让出时间片,避免阻塞UI
|
// 在处理每个文件前让出时间片,避免阻塞UI
|
||||||
const time = new Date().toLocaleTimeString()
|
const time = new Date().toLocaleTimeString()
|
||||||
console.log(`[${time}] [增量同步] 正在同步数据库: ${file.fileName} (${i + 1}/${totalFiles})`)
|
// console.log(`[${time}] [增量同步] 正在同步数据库: ${file.fileName} (${i + 1}/${totalFiles})`) // 减少日志
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
}
|
}
|
||||||
@@ -432,6 +433,12 @@ class DataManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const backupPath = file.decryptedPath + '.old.' + Date.now()
|
const backupPath = file.decryptedPath + '.old.' + Date.now()
|
||||||
|
|
||||||
|
// 在备份/覆盖文件前,先关闭该数据库的连接,释放文件锁
|
||||||
|
chatService.closeDatabase(file.fileName)
|
||||||
|
// 等待文件句柄释放
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
|
||||||
if (fs.existsSync(file.decryptedPath!)) {
|
if (fs.existsSync(file.decryptedPath!)) {
|
||||||
// 尝试备份文件,如果失败则重试几次
|
// 尝试备份文件,如果失败则重试几次
|
||||||
let backupSuccess = false
|
let backupSuccess = false
|
||||||
@@ -551,8 +558,8 @@ class DataManagementService {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
successCount++
|
successCount++
|
||||||
const time = new Date().toLocaleTimeString()
|
// const time = new Date().toLocaleTimeString()
|
||||||
console.log(`[${time}] [增量同步] 同步成功: ${file.fileName}`)
|
// console.log(`[${time}] [增量同步] 同步成功: ${file.fileName}`) // 减少日志
|
||||||
if (fs.existsSync(backupPath)) {
|
if (fs.existsSync(backupPath)) {
|
||||||
try { fs.unlinkSync(backupPath) } catch { }
|
try { fs.unlinkSync(backupPath) } catch { }
|
||||||
}
|
}
|
||||||
@@ -564,6 +571,9 @@ class DataManagementService {
|
|||||||
try { fs.renameSync(backupPath, file.decryptedPath!) } catch { }
|
try { fs.renameSync(backupPath, file.decryptedPath!) } catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关键:强制让出主线程时间片,防止批量处理时 UI 卡死
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendProgress({ type: 'complete' })
|
this.sendProgress({ type: 'complete' })
|
||||||
@@ -1346,7 +1356,7 @@ class DataManagementService {
|
|||||||
// 检查更新频率限制(最多每5秒更新一次)
|
// 检查更新频率限制(最多每5秒更新一次)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const timeSinceLastUpdate = now - this.lastUpdateTime
|
const timeSinceLastUpdate = now - this.lastUpdateTime
|
||||||
const MIN_UPDATE_INTERVAL = 5000 // 最小更新间隔:5秒
|
const MIN_UPDATE_INTERVAL = 1000 // 最小更新间隔:1秒 (配合 DLL 极速解密)
|
||||||
|
|
||||||
if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) {
|
if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) {
|
||||||
// 如果距离上次更新不足5秒,延迟到满足间隔
|
// 如果距离上次更新不足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()
|
this.triggerUpdate()
|
||||||
@@ -1391,7 +1402,7 @@ class DataManagementService {
|
|||||||
// 检查更新频率限制
|
// 检查更新频率限制
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const timeSinceLastUpdate = now - this.lastUpdateTime
|
const timeSinceLastUpdate = now - this.lastUpdateTime
|
||||||
const MIN_UPDATE_INTERVAL = 2000 // 最小更新间隔:2秒 (原来是 5 秒)
|
const MIN_UPDATE_INTERVAL = 1000 // 最小更新间隔:1秒
|
||||||
|
|
||||||
if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) {
|
if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) {
|
||||||
// 延迟到满足间隔
|
// 延迟到满足间隔
|
||||||
@@ -1430,7 +1441,7 @@ class DataManagementService {
|
|||||||
// 检查更新频率限制
|
// 检查更新频率限制
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const timeSinceLastUpdate = now - this.lastUpdateTime
|
const timeSinceLastUpdate = now - this.lastUpdateTime
|
||||||
const MIN_UPDATE_INTERVAL = 2000 // 最小更新间隔减少到 2 秒 (原来是 5 秒)
|
const MIN_UPDATE_INTERVAL = 1000 // 最小更新间隔减少到 1 秒
|
||||||
|
|
||||||
if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) {
|
if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) {
|
||||||
const remainingTime = MIN_UPDATE_INTERVAL - timeSinceLastUpdate
|
const remainingTime = MIN_UPDATE_INTERVAL - timeSinceLastUpdate
|
||||||
@@ -1470,8 +1481,8 @@ class DataManagementService {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 通知监听器更新完成
|
// 通知监听器更新完成
|
||||||
const time = new Date().toLocaleTimeString()
|
// const time = new Date().toLocaleTimeString()
|
||||||
console.log(`[${time}] [自动更新] 增量同步完成, 成功更新 ${result.successCount} 个文件`)
|
// console.log(`[${time}] [自动更新] 增量同步完成, 成功更新 ${result.successCount} 个文件`) // 减少日志
|
||||||
this.updateListeners.forEach(listener => listener(false))
|
this.updateListeners.forEach(listener => listener(false))
|
||||||
return { success: true, updated: result.successCount! > 0 }
|
return { success: true, updated: result.successCount! > 0 }
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,103 +1,22 @@
|
|||||||
import * as crypto from 'crypto'
|
import { nativeDecryptService } from './nativeDecryptService'
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信数据库解密服务 (Windows v4)
|
* 微信数据库解密服务 (Windows v4)
|
||||||
|
* 纯原生 DLL 实现封装
|
||||||
*/
|
*/
|
||||||
export class WeChatDecryptService {
|
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 {
|
validateKey(dbPath: string, hexKey: string): boolean {
|
||||||
try {
|
return true
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解密数据库
|
* 解密数据库
|
||||||
|
* 使用原生 DLL 解密(高性能、异步不卡顿)
|
||||||
*/
|
*/
|
||||||
async decryptDatabase(
|
async decryptDatabase(
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
@@ -105,155 +24,30 @@ export class WeChatDecryptService {
|
|||||||
hexKey: string,
|
hexKey: string,
|
||||||
onProgress?: (current: number, total: number) => void
|
onProgress?: (current: number, total: number) => void
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
|
||||||
|
// 检查服务是否可用
|
||||||
|
if (!nativeDecryptService.isAvailable()) {
|
||||||
|
return { success: false, error: '原生解密服务不可用:DLL 加载失败或 Worker 未启动' }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const key = Buffer.from(hexKey, 'hex')
|
// console.log(`[Decrypt] 开始解密: ${inputPath} -> ${outputPath}`) // 减少日志
|
||||||
if (key.length !== KEY_SIZE) {
|
|
||||||
return { success: false, error: '密钥长度错误' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取文件信息
|
// 使用异步 DLL 解密
|
||||||
const stats = fs.statSync(inputPath)
|
const result = await nativeDecryptService.decryptDatabaseAsync(inputPath, outputPath, hexKey, onProgress)
|
||||||
const fileSize = stats.size
|
|
||||||
const totalPages = Math.ceil(fileSize / PAGE_SIZE)
|
|
||||||
|
|
||||||
// 读取第一页获取盐值
|
if (result.success) {
|
||||||
const fd = fs.openSync(inputPath, 'r')
|
// console.log('[Decrypt] 解密成功') // 减少日志
|
||||||
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)
|
|
||||||
return { success: true }
|
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<void>(resolve => setImmediate(resolve))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.closeSync(fd)
|
|
||||||
fs.closeSync(outFd)
|
|
||||||
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress(totalPages, totalPages)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('解密数据库失败:', e)
|
console.error('[Decrypt] 调用异常:', e)
|
||||||
return { success: false, error: String(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()
|
export const wechatDecryptService = new WeChatDecryptService()
|
||||||
|
|||||||
215
electron/services/nativeDecryptService.ts
Normal file
215
electron/services/nativeDecryptService.ts
Normal file
@@ -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<string, DecryptTask> = 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()
|
||||||
88
electron/workers/decryptWorker.js
Normal file
88
electron/workers/decryptWorker.js
Normal file
@@ -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) })
|
||||||
|
}
|
||||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ciphertalk",
|
"name": "ciphertalk",
|
||||||
"version": "2.1.4",
|
"version": "2.1.5",
|
||||||
"description": "密语 - 微信聊天记录查看工具",
|
"description": "密语 - 微信聊天记录查看工具",
|
||||||
"author": "ILoveBingLu",
|
"author": "ILoveBingLu",
|
||||||
"license": "CC-BY-NC-SA-4.0",
|
"license": "CC-BY-NC-SA-4.0",
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/marked": "^5.0.2",
|
"@types/marked": "^5.0.2",
|
||||||
|
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"dom-to-image-more": "^3.7.2",
|
"dom-to-image-more": "^3.7.2",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
@@ -40,6 +42,8 @@
|
|||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
"react-virtualized-auto-sizer": "^2.0.2",
|
||||||
|
"react-window": "^2.2.5",
|
||||||
"sherpa-onnx-node": "^1.12.23",
|
"sherpa-onnx-node": "^1.12.23",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
@@ -134,7 +138,10 @@
|
|||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/ffmpeg-static/**/*",
|
"node_modules/ffmpeg-static/**/*",
|
||||||
"node_modules/silk-wasm/**/*",
|
"node_modules/silk-wasm/**/*",
|
||||||
"node_modules/sherpa-onnx-node/**/*"
|
"node_modules/sherpa-onnx-node/**/*",
|
||||||
|
"node_modules/koffi/**/*",
|
||||||
|
"dist-electron/workers/**/*",
|
||||||
|
"resources/**/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
resources/wcdb_decrypt.dll
Normal file
BIN
resources/wcdb_decrypt.dll
Normal file
Binary file not shown.
@@ -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 {
|
.transfer-message {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
|
|||||||
@@ -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 { 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 { useChatStore } from '../stores/chatStore'
|
||||||
import { useUpdateStatusStore } from '../stores/updateStatusStore'
|
import { useUpdateStatusStore } from '../stores/updateStatusStore'
|
||||||
import ChatBackground from '../components/ChatBackground'
|
import ChatBackground from '../components/ChatBackground'
|
||||||
import MessageContent from '../components/MessageContent'
|
import MessageContent from '../components/MessageContent'
|
||||||
import { getImageXorKey, getImageAesKey, getQuoteStyle } from '../services/config'
|
import { getImageXorKey, getImageAesKey, getQuoteStyle } from '../services/config'
|
||||||
|
import { LRUCache } from '../utils/lruCache'
|
||||||
import type { ChatSession, Message } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
|
import { List, RowComponentProps } from 'react-window'
|
||||||
import './ChatPage.scss'
|
import './ChatPage.scss'
|
||||||
|
|
||||||
|
interface SessionRowData {
|
||||||
|
sessions: ChatSession[]
|
||||||
|
currentSessionId: string | null
|
||||||
|
onSelect: (s: ChatSession) => void
|
||||||
|
formatTime: (t: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
@@ -155,6 +164,48 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 会话列表行组件(使用 memo 优化性能)
|
||||||
|
const SessionRow = (props: RowComponentProps<SessionRowData>) => {
|
||||||
|
const { index, style, sessions, currentSessionId, onSelect, formatTime } = props
|
||||||
|
const session = sessions[index]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
className={`session-item ${currentSessionId === session.username ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelect(session)}
|
||||||
|
>
|
||||||
|
<SessionAvatar session={session} size={48} />
|
||||||
|
<div className="session-info">
|
||||||
|
<div className="session-top">
|
||||||
|
<span className="session-name">{session.displayName || session.username}</span>
|
||||||
|
<span className="session-time">{formatTime(session.lastTimestamp || session.sortTimestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="session-bottom">
|
||||||
|
<span className="session-summary">
|
||||||
|
{(() => {
|
||||||
|
const summary = session.summary || '暂无消息'
|
||||||
|
const firstLine = summary.split('\n')[0]
|
||||||
|
const hasMoreLines = summary.includes('\n')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MessageContent content={firstLine} disableLinks={true} />
|
||||||
|
{hasMoreLines && <span>...</span>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
{session.unreadCount > 0 && (
|
||||||
|
<span className="unread-badge">
|
||||||
|
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ChatPage(_props: ChatPageProps) {
|
function ChatPage(_props: ChatPageProps) {
|
||||||
const [quoteStyle, setQuoteStyle] = useState<'default' | 'wechat'>('default')
|
const [quoteStyle, setQuoteStyle] = useState<'default' | 'wechat'>('default')
|
||||||
|
|
||||||
@@ -322,14 +373,14 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
// 合并:保留顺序,只更新变化的字段
|
// 合并:保留顺序,只更新变化的字段
|
||||||
const merged = result.sessions!.map(newSession => {
|
const merged = result.sessions!.map(newSession => {
|
||||||
const oldSession = oldSessionsMap.get(newSession.username)
|
const oldSession = oldSessionsMap.get(newSession.username)
|
||||||
|
|
||||||
// 如果是新会话,直接返回
|
// 如果是新会话,直接返回
|
||||||
if (!oldSession) {
|
if (!oldSession) {
|
||||||
return newSession
|
return newSession
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有实质性变化
|
// 检查是否有实质性变化
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
oldSession.summary !== newSession.summary ||
|
oldSession.summary !== newSession.summary ||
|
||||||
oldSession.lastTimestamp !== newSession.lastTimestamp ||
|
oldSession.lastTimestamp !== newSession.lastTimestamp ||
|
||||||
oldSession.unreadCount !== newSession.unreadCount ||
|
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) => {
|
const handleSelectSession = (session: ChatSession) => {
|
||||||
if (session.username === currentSessionId) {
|
if (session.username === currentSessionId) {
|
||||||
@@ -559,11 +644,11 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConnected) return
|
if (!isConnected) return
|
||||||
|
|
||||||
// 监听会话列表更新
|
// 监听会话列表更新
|
||||||
const removeSessionsListener = window.electronAPI.chat.onSessionsUpdated?.(async (newSessions) => {
|
const removeSessionsListener = window.electronAPI.chat.onSessionsUpdated?.(async (newSessions) => {
|
||||||
// 更新增量更新时间戳
|
// 更新增量更新时间戳
|
||||||
lastIncrementalUpdateTime = Date.now()
|
lastIncrementalUpdateTime = Date.now()
|
||||||
|
|
||||||
// 智能合并更新会话列表,避免闪烁
|
// 智能合并更新会话列表,避免闪烁
|
||||||
setSessions((prevSessions: ChatSession[]) => {
|
setSessions((prevSessions: ChatSession[]) => {
|
||||||
// 如果之前没有会话,直接设置
|
// 如果之前没有会话,直接设置
|
||||||
@@ -579,14 +664,14 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
// 合并:保留顺序,只更新变化的字段
|
// 合并:保留顺序,只更新变化的字段
|
||||||
const merged = newSessions.map(newSession => {
|
const merged = newSessions.map(newSession => {
|
||||||
const oldSession = oldSessionsMap.get(newSession.username)
|
const oldSession = oldSessionsMap.get(newSession.username)
|
||||||
|
|
||||||
// 如果是新会话,直接返回
|
// 如果是新会话,直接返回
|
||||||
if (!oldSession) {
|
if (!oldSession) {
|
||||||
return newSession
|
return newSession
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有实质性变化
|
// 检查是否有实质性变化
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
oldSession.summary !== newSession.summary ||
|
oldSession.summary !== newSession.summary ||
|
||||||
oldSession.lastTimestamp !== newSession.lastTimestamp ||
|
oldSession.lastTimestamp !== newSession.lastTimestamp ||
|
||||||
oldSession.unreadCount !== newSession.unreadCount ||
|
oldSession.unreadCount !== newSession.unreadCount ||
|
||||||
@@ -817,43 +902,22 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filteredSessions.length > 0 ? (
|
) : filteredSessions.length > 0 ? (
|
||||||
<div className="session-list">
|
<div className="session-list" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||||
{filteredSessions.map(session => (
|
{/* @ts-ignore - 类型定义不匹配但不影响运行 */}
|
||||||
<div
|
<List
|
||||||
key={session.username}
|
style={{ height: '100%', width: '100%' }}
|
||||||
className={`session-item ${currentSessionId === session.username ? 'active' : ''}`}
|
rowCount={filteredSessions.length}
|
||||||
onClick={() => handleSelectSession(session)}
|
rowHeight={72}
|
||||||
>
|
rowProps={{
|
||||||
<SessionAvatar session={session} size={48} />
|
sessions: filteredSessions,
|
||||||
<div className="session-info">
|
currentSessionId,
|
||||||
<div className="session-top">
|
onSelect: handleSelectSession,
|
||||||
<span className="session-name">{session.displayName || session.username}</span>
|
formatTime: formatSessionTime
|
||||||
<span className="session-time">{formatSessionTime(session.lastTimestamp || session.sortTimestamp)}</span>
|
}}
|
||||||
</div>
|
rowComponent={SessionRow}
|
||||||
<div className="session-bottom">
|
/>
|
||||||
<span className="session-summary">
|
|
||||||
{(() => {
|
|
||||||
const summary = session.summary || '暂无消息'
|
|
||||||
const firstLine = summary.split('\n')[0]
|
|
||||||
const hasMoreLines = summary.includes('\n')
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MessageContent content={firstLine} disableLinks={true} />
|
|
||||||
{hasMoreLines && <span>...</span>}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
{session.unreadCount > 0 && (
|
|
||||||
<span className="unread-badge">
|
|
||||||
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-sessions">
|
<div className="empty-sessions">
|
||||||
<MessageSquare />
|
<MessageSquare />
|
||||||
@@ -1357,10 +1421,10 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前端表情包缓存
|
// 前端表情包缓存 (LRU 限制)
|
||||||
const emojiDataUrlCache = new Map<string, string>()
|
const emojiDataUrlCache = new LRUCache<string, string>(200)
|
||||||
// 前端图片缓存
|
// 前端图片缓存 (LRU 限制)
|
||||||
const imageDataUrlCache = new Map<string, string>()
|
const imageDataUrlCache = new LRUCache<string, string>(50)
|
||||||
|
|
||||||
// 图片解密队列管理
|
// 图片解密队列管理
|
||||||
const imageDecryptQueue: Array<() => Promise<void>> = []
|
const imageDecryptQueue: Array<() => Promise<void>> = []
|
||||||
@@ -1387,7 +1451,7 @@ function enqueueDecrypt(fn: () => Promise<void>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 视频信息缓存(带时间戳)
|
// 视频信息缓存(带时间戳)
|
||||||
const videoInfoCache = new Map<string, {
|
const videoInfoCache = new Map<string, {
|
||||||
videoUrl?: string
|
videoUrl?: string
|
||||||
coverUrl?: string
|
coverUrl?: string
|
||||||
thumbUrl?: string
|
thumbUrl?: string
|
||||||
@@ -1652,12 +1716,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
// 智能缓存失效:如果视频不存在,且缓存时间早于最后一次增量更新,则重新获取
|
// 智能缓存失效:如果视频不存在,且缓存时间早于最后一次增量更新,则重新获取
|
||||||
const shouldRefetch = !cached.exists && cached.cachedAt < lastIncrementalUpdateTime
|
const shouldRefetch = !cached.exists && cached.cachedAt < lastIncrementalUpdateTime
|
||||||
|
|
||||||
if (!shouldRefetch) {
|
if (!shouldRefetch) {
|
||||||
setVideoInfo(cached)
|
setVideoInfo(cached)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要重新获取,清除旧缓存
|
// 需要重新获取,清除旧缓存
|
||||||
videoInfoCache.delete(message.videoMd5)
|
videoInfoCache.delete(message.videoMd5)
|
||||||
}
|
}
|
||||||
@@ -1845,7 +1909,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
|||||||
|
|
||||||
// 群聊中获取发送者信息
|
// 群聊中获取发送者信息
|
||||||
const [isLoadingSender, setIsLoadingSender] = useState(false)
|
const [isLoadingSender, setIsLoadingSender] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isGroupChat && !isSent && message.senderUsername) {
|
if (isGroupChat && !isSent && message.senderUsername) {
|
||||||
setIsLoadingSender(true)
|
setIsLoadingSender(true)
|
||||||
@@ -1855,7 +1919,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
|||||||
setSenderName(result.displayName)
|
setSenderName(result.displayName)
|
||||||
}
|
}
|
||||||
setIsLoadingSender(false)
|
setIsLoadingSender(false)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setIsLoadingSender(false)
|
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 <FileArchive size={28} />
|
||||||
|
}
|
||||||
|
return <FileText size={28} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击文件消息,定位到文件所在文件夹并选中文件
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="file-message"
|
||||||
|
onClick={handleFileClick}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
title="点击定位到文件所在文件夹"
|
||||||
|
>
|
||||||
|
<div className="file-icon">
|
||||||
|
{getFileIcon(fileExt)}
|
||||||
|
</div>
|
||||||
|
<div className="file-info">
|
||||||
|
<div className="file-name" title={fileName}>{fileName}</div>
|
||||||
|
<div className="file-meta">
|
||||||
|
{fileSize ? formatFileSize(fileSize) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 转账消息 (type=2000):渲染为转账卡片
|
// 转账消息 (type=2000):渲染为转账卡片
|
||||||
if (appMsgType === '2000') {
|
if (appMsgType === '2000') {
|
||||||
try {
|
try {
|
||||||
const content = message.rawContent || message.parsedContent || ''
|
const content = message.rawContent || message.parsedContent || ''
|
||||||
const parser = new DOMParser()
|
const parser = new DOMParser()
|
||||||
const doc = parser.parseFromString(content, 'text/xml')
|
const doc = parser.parseFromString(content, 'text/xml')
|
||||||
|
|
||||||
const feedesc = doc.querySelector('feedesc')?.textContent || ''
|
const feedesc = doc.querySelector('feedesc')?.textContent || ''
|
||||||
const payMemo = doc.querySelector('pay_memo')?.textContent || ''
|
const payMemo = doc.querySelector('pay_memo')?.textContent || ''
|
||||||
const paysubtype = doc.querySelector('paysubtype')?.textContent || '1'
|
const paysubtype = doc.querySelector('paysubtype')?.textContent || '1'
|
||||||
|
|
||||||
// paysubtype: 1=待收款, 3=已收款
|
// paysubtype: 1=待收款, 3=已收款
|
||||||
const isReceived = paysubtype === '3'
|
const isReceived = paysubtype === '3'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`transfer-message ${isReceived ? 'received' : ''}`}>
|
<div className={`transfer-message ${isReceived ? 'received' : ''}`}>
|
||||||
<div className="transfer-icon">
|
<div className="transfer-icon">
|
||||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||||
<circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2"/>
|
<circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" />
|
||||||
<path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="transfer-info">
|
<div className="transfer-info">
|
||||||
|
|||||||
@@ -111,6 +111,17 @@ function DataManagementPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDecryptAll = async () => {
|
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()
|
const isChatOpen = await window.electronAPI.window.isChatWindowOpen()
|
||||||
if (isChatOpen) {
|
if (isChatOpen) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export interface ChatState {
|
|||||||
setFilteredSessions: (sessions: ChatSession[]) => void
|
setFilteredSessions: (sessions: ChatSession[]) => void
|
||||||
setCurrentSession: (sessionId: string | null) => void
|
setCurrentSession: (sessionId: string | null) => void
|
||||||
setLoadingSessions: (loading: boolean) => void
|
setLoadingSessions: (loading: boolean) => void
|
||||||
setMessages: (messages: Message[]) => void
|
setMessages: (messages: Message[] | ((prev: Message[]) => Message[])) => void
|
||||||
appendMessages: (messages: Message[], prepend?: boolean) => void
|
appendMessages: (messages: Message[], prepend?: boolean) => void
|
||||||
setLoadingMessages: (loading: boolean) => void
|
setLoadingMessages: (loading: boolean) => void
|
||||||
setLoadingMore: (loading: boolean) => void
|
setLoadingMore: (loading: boolean) => void
|
||||||
@@ -82,24 +82,26 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
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) => {
|
appendMessages: (newMessages, prepend = false) => set((state) => {
|
||||||
// 使用与后端一致的多维 Key (serverId + localId + createTime + sortSeq) 进行去重
|
// 使用与后端一致的多维 Key (serverId + localId + createTime + sortSeq) 进行去重
|
||||||
const existingKeys = new Set(
|
const existingKeys = new Set(
|
||||||
state.messages.map(m => `${m.serverId}-${m.localId}-${m.createTime}-${m.sortSeq}`)
|
state.messages.map(m => `${m.serverId}-${m.localId}-${m.createTime}-${m.sortSeq}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 过滤掉已存在的消息
|
// 过滤掉已存在的消息
|
||||||
const uniqueNewMessages = newMessages.filter(
|
const uniqueNewMessages = newMessages.filter(
|
||||||
msg => !existingKeys.has(`${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`)
|
msg => !existingKeys.has(`${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果没有新消息,直接返回原状态
|
// 如果没有新消息,直接返回原状态
|
||||||
if (uniqueNewMessages.length === 0) {
|
if (uniqueNewMessages.length === 0) {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: prepend
|
messages: prepend
|
||||||
? [...uniqueNewMessages, ...state.messages]
|
? [...uniqueNewMessages, ...state.messages]
|
||||||
|
|||||||
5
src/types/electron.d.ts
vendored
5
src/types/electron.d.ts
vendored
@@ -46,6 +46,7 @@ export interface ElectronAPI {
|
|||||||
shell: {
|
shell: {
|
||||||
openPath: (path: string) => Promise<string>
|
openPath: (path: string) => Promise<string>
|
||||||
openExternal: (url: string) => Promise<void>
|
openExternal: (url: string) => Promise<void>
|
||||||
|
showItemInFolder: (fullPath: string) => Promise<void>
|
||||||
}
|
}
|
||||||
app: {
|
app: {
|
||||||
getDownloadsPath: () => Promise<string>
|
getDownloadsPath: () => Promise<string>
|
||||||
@@ -202,6 +203,8 @@ export interface ElectronAPI {
|
|||||||
downloadEmoji: (cdnUrl: string, md5?: string, productId?: string, createTime?: number) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
downloadEmoji: (cdnUrl: string, md5?: string, productId?: string, createTime?: number) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||||
close: () => Promise<boolean>
|
close: () => Promise<boolean>
|
||||||
refreshCache: () => Promise<boolean>
|
refreshCache: () => Promise<boolean>
|
||||||
|
setCurrentSession: (sessionId: string | null) => Promise<boolean>
|
||||||
|
onNewMessages: (callback: (data: { sessionId: string; messages: Message[] }) => void) => () => void
|
||||||
getSessionDetail: (sessionId: string) => Promise<{
|
getSessionDetail: (sessionId: string) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
detail?: {
|
detail?: {
|
||||||
@@ -387,7 +390,7 @@ export interface ElectronAPI {
|
|||||||
successCount?: number
|
successCount?: number
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
onProgress: (callback: (data: {
|
onProgress: (callback: (data: {
|
||||||
current?: number
|
current?: number
|
||||||
total?: number
|
total?: number
|
||||||
currentSession?: string
|
currentSession?: string
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ export interface Message {
|
|||||||
videoMd5?: string
|
videoMd5?: string
|
||||||
rawContent?: string
|
rawContent?: string
|
||||||
productId?: string
|
productId?: string
|
||||||
|
// 文件消息相关
|
||||||
|
fileName?: string // 文件名
|
||||||
|
fileSize?: number // 文件大小(字节)
|
||||||
|
fileExt?: string // 文件扩展名
|
||||||
|
fileMd5?: string // 文件 MD5
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分析数据
|
// 分析数据
|
||||||
|
|||||||
50
src/utils/lruCache.ts
Normal file
50
src/utils/lruCache.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 简单的 LRU (Least Recently Used) 缓存实现
|
||||||
|
* 用于限制内存中缓存对象的数量,防止内存泄漏
|
||||||
|
*/
|
||||||
|
export class LRUCache<K, V> {
|
||||||
|
private capacity: number;
|
||||||
|
private cache: Map<K, V>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,18 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 数据库解密 Worker 线程
|
||||||
|
entry: 'electron/workers/decryptWorker.js',
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron/workers',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['koffi']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// 语音转写 Worker 线程
|
// 语音转写 Worker 线程
|
||||||
entry: 'electron/transcribeWorker.ts',
|
entry: 'electron/transcribeWorker.ts',
|
||||||
|
|||||||
Reference in New Issue
Block a user