TS 实现简易 LRU 缓存:新增缓存类 + 核心方法 + 容量满自动淘汰 LRU 项

This commit is contained in:
ILoveBingLu
2026-01-28 02:27:30 +08:00
parent ee9589bba0
commit eea7ee569c
18 changed files with 1132 additions and 338 deletions
+46 -25
View File
@@ -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) {
+8 -1
View File
@@ -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)
}
},
+333 -4
View File
@@ -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<string, number> = 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('<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()
+26 -15
View File
@@ -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 {
+20 -226
View File
@@ -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<void>(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()
+215
View 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
View 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) })
}