diff --git a/electron/main.ts b/electron/main.ts index d636dd5..076c16d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1563,6 +1563,10 @@ function registerIpcHandlers() { return chatService.getMessageById(sessionId, localId) }) + ipcMain.handle('chat:searchMessages', async (_, keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => { + return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp) + }) + ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => { return chatService.execQuery(kind, path, sql) }) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e8da599..52f988e 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -6699,6 +6699,20 @@ class ChatService { } } + async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: Message[]; error?: string }> { + try { + const result = await wcdbService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp) + if (!result.success || !result.messages) { + return { success: false, error: result.error || '搜索失败' } + } + const messages = result.messages.map((row: any) => this.parseMessage(row)).filter(Boolean) as Message[] + return { success: true, messages } + } catch (e) { + console.error('ChatService: searchMessages 失败:', e) + return { success: false, error: String(e) } + } + } + private parseMessage(row: any): Message { const sourceInfo = this.getMessageSourceInfo(row) const rawContent = this.decodeMessageContent( diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index a78b7ed..9ad2c25 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -436,6 +436,10 @@ export class ImageDecryptService { if (imageMd5) { const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail) if (res) return res + if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) { + const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail) + if (datNameRes) return datNameRes + } } // 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位 @@ -889,7 +893,8 @@ export class ImageDecryptService { const now = new Date() const months: string[] = [] - for (let i = 0; i < 2; i++) { + // Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months". + for (let i = 0; i < 24; i++) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1) const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` months.push(mStr) diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index 494e1fb..f87e8d0 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -507,26 +507,39 @@ export class KeyServiceMac { const wxidCandidates = this.collectWxidCandidates(accountPath, wxid) if (wxidCandidates.length === 0) { - return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' } + return { success: false, error: '未找到可用的账号候选,请先选择正确的账号目录' } } + const accountPathCandidates = this.collectAccountPathCandidates(accountPath) + // 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错 - let verifyCiphertext: Buffer | null = null - if (accountPath && existsSync(accountPath)) { - const template = await this._findTemplateData(accountPath, 32) - verifyCiphertext = template.ciphertext - } - if (verifyCiphertext) { + if (accountPathCandidates.length > 0) { onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`) - for (const candidateWxid of wxidCandidates) { - for (const code of codes) { - const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) - if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue - onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) - return { success: true, xorKey, aesKey } + for (const candidateAccountPath of accountPathCandidates) { + if (!existsSync(candidateAccountPath)) continue + const template = await this._findTemplateData(candidateAccountPath, 32) + if (!template.ciphertext) continue + + const accountDirWxid = basename(candidateAccountPath) + const orderedWxids: string[] = [] + this.pushAccountIdCandidates(orderedWxids, accountDirWxid) + for (const candidate of wxidCandidates) { + this.pushAccountIdCandidates(orderedWxids, candidate) + } + + for (const candidateWxid of orderedWxids) { + for (const code of codes) { + const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) + if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue + onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) + return { success: true, xorKey, aesKey } + } } } - return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' } + return { + success: false, + error: '缓存 code 与当前账号 wxid 未匹配。若数据库密钥获取后微信刚刚崩溃并重启,可能当前选中的账号目录已经不是最新会话;请先重新扫描 wxid,或直接使用内存扫描。' + } } // 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code) @@ -561,16 +574,21 @@ export class KeyServiceMac { onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) - // 2. 找微信 PID - const pid = await this.findWeChatPid() - if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' } - - onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) - - // 3. 持续轮询内存扫描 + // 2. 持续轮询微信 PID 与内存扫描,兼容微信崩溃后重启 PID 变化 const deadline = Date.now() + 60_000 let scanCount = 0 + let lastPid: number | null = null while (Date.now() < deadline) { + const pid = await this.findWeChatPid() + if (!pid) { + onProgress?.('暂未检测到微信主进程,请确认微信已经重新打开...') + await new Promise(r => setTimeout(r, 2000)) + continue + } + if (lastPid !== pid) { + lastPid = pid + onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) + } scanCount++ onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) @@ -838,11 +856,8 @@ export class KeyServiceMac { } private async findWeChatPid(): Promise { - const { execSync } = await import('child_process') try { - const output = execSync('pgrep -x WeChat', { encoding: 'utf8' }) - const pid = parseInt(output.trim()) - return isNaN(pid) ? null : pid + return await this.getWeChatPid() } catch { return null } @@ -859,12 +874,70 @@ export class KeyServiceMac { this.machPortDeallocate = null } + private normalizeAccountId(value: string): string { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match?.[1] || trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed + } + + private isIgnoredAccountName(value: string): boolean { + const lowered = String(value || '').trim().toLowerCase() + if (!lowered) return true + return lowered === 'xwechat_files' || + lowered === 'all_users' || + lowered === 'backup' || + lowered === 'wmpf' || + lowered === 'app_data' + } + + private isReasonableAccountId(value: string): boolean { + const trimmed = String(value || '').trim() + if (!trimmed) return false + if (trimmed.includes('/') || trimmed.includes('\\')) return false + return !this.isIgnoredAccountName(trimmed) + } + + private isAccountDirPath(entryPath: string): boolean { + return existsSync(join(entryPath, 'db_storage')) || + existsSync(join(entryPath, 'msg')) || + existsSync(join(entryPath, 'FileStorage', 'Image')) || + existsSync(join(entryPath, 'FileStorage', 'Image2')) + } + + private resolveXwechatRootFromPath(accountPath?: string): string | null { + const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '') + if (!normalized) return null + const marker = '/xwechat_files' + const markerIdx = normalized.indexOf(marker) + if (markerIdx < 0) return null + return normalized.slice(0, markerIdx + marker.length) + } + + private pushAccountIdCandidates(candidates: string[], value?: string): void { + const pushUnique = (item: string) => { + const trimmed = String(item || '').trim() + if (!trimmed || candidates.includes(trimmed)) return + candidates.push(trimmed) + } + + const raw = String(value || '').trim() + if (!this.isReasonableAccountId(raw)) return + pushUnique(raw) + const normalized = this.normalizeAccountId(raw) + if (normalized && normalized !== raw && this.isReasonableAccountId(normalized)) { + pushUnique(normalized) + } + } + private cleanWxid(wxid: string): string { - const first = wxid.indexOf('_') - if (first === -1) return wxid - const second = wxid.indexOf('_', first + 1) - if (second === -1) return wxid - return wxid.substring(0, second) + return this.normalizeAccountId(wxid) } private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } { @@ -877,32 +950,59 @@ export class KeyServiceMac { private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] { const candidates: string[] = [] - const pushUnique = (value: string) => { - const v = String(value || '').trim() - if (!v || candidates.includes(v)) return - candidates.push(v) - } // 1) 显式传参优先 - if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam) + this.pushAccountIdCandidates(candidates, wxidParam) if (accountPath) { const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '') const dirName = basename(normalized) - // 2) 当前目录名为 wxid_* - if (dirName.startsWith('wxid_')) pushUnique(dirName) + // 2) 当前目录名本身就是账号目录 + this.pushAccountIdCandidates(candidates, dirName) - // 3) 从 xwechat_files 根目录枚举全部 wxid_* 目录 - const marker = '/xwechat_files' - const markerIdx = normalized.indexOf(marker) - if (markerIdx >= 0) { - const root = normalized.slice(0, markerIdx + marker.length) + // 3) 从 xwechat_files 根目录枚举全部账号目录 + const root = this.resolveXwechatRootFromPath(accountPath) + if (root) { if (existsSync(root)) { try { for (const entry of readdirSync(root, { withFileTypes: true })) { if (!entry.isDirectory()) continue - if (!entry.name.startsWith('wxid_')) continue - pushUnique(entry.name) + const entryPath = join(root, entry.name) + if (!this.isAccountDirPath(entryPath)) continue + this.pushAccountIdCandidates(candidates, entry.name) + } + } catch { + // ignore + } + } + } + } + + if (candidates.length === 0) candidates.push('unknown') + return candidates + } + + private collectAccountPathCandidates(accountPath?: string): string[] { + const candidates: string[] = [] + const pushUnique = (value?: string) => { + const v = String(value || '').trim() + if (!v || candidates.includes(v)) return + candidates.push(v) + } + + if (accountPath) pushUnique(accountPath) + + if (accountPath) { + const root = this.resolveXwechatRootFromPath(accountPath) + if (root) { + if (existsSync(root)) { + try { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const entryPath = join(root, entry.name) + if (!this.isAccountDirPath(entryPath)) continue + if (!this.isReasonableAccountId(entry.name)) continue + pushUnique(entryPath) } } catch { // ignore @@ -911,7 +1011,6 @@ export class KeyServiceMac { } } - pushUnique('unknown') return candidates } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 7e69caa..a9b99dd 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -106,6 +106,7 @@ export class WcdbCore { private wcdbGetEmoticonCdnUrl: any = null private wcdbGetDbStatus: any = null private wcdbGetVoiceData: any = null + private wcdbSearchMessages: any = null private wcdbGetSnsTimeline: any = null private wcdbGetSnsAnnualStats: any = null private wcdbInstallSnsBlockDeleteTrigger: any = null @@ -817,6 +818,13 @@ export class WcdbCore { this.wcdbGetVoiceData = null } + // wcdb_status wcdb_search_messages(wcdb_handle handle, const char* session_id, const char* keyword, int32_t limit, int32_t offset, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) + try { + this.wcdbSearchMessages = this.lib.func('int32 wcdb_search_messages(int64 handle, const char* sessionId, const char* keyword, int32 limit, int32 offset, int32 beginTimestamp, int32 endTimestamp, _Out_ void** outJson)') + } catch { + this.wcdbSearchMessages = null + } + // wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json) try { this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)') @@ -1488,10 +1496,19 @@ export class WcdbCore { } // 让出控制权,避免阻塞事件循环 + const handle = this.handle await new Promise(resolve => setImmediate(resolve)) + // await 后 handle 可能已被关闭,需重新检查 + if (handle === null || this.handle !== handle) { + if (Object.keys(resultMap).length > 0) { + return { success: true, map: resultMap, error: '连接已断开' } + } + return { success: false, error: '连接已断开' } + } + const outPtr = [null as any] - const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr) + const result = this.wcdbGetAvatarUrls(handle, JSON.stringify(toFetch), outPtr) // DLL 调用后再次让出控制权 await new Promise(resolve => setImmediate(resolve)) @@ -2270,6 +2287,36 @@ export class WcdbCore { }) } + async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbSearchMessages) return { success: false, error: '当前 DLL 版本不支持搜索消息' } + try { + const handle = this.handle + await new Promise(resolve => setImmediate(resolve)) + if (handle === null || this.handle !== handle) return { success: false, error: '连接已断开' } + const outPtr = [null as any] + const result = this.wcdbSearchMessages( + handle, + sessionId || '', + keyword, + limit || 50, + offset || 0, + beginTimestamp || 0, + endTimestamp || 0, + outPtr + ) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `搜索消息失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析搜索结果失败' } + const messages = JSON.parse(jsonStr) + return { success: true, messages } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 286ddae..b5fcb24 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -406,6 +406,10 @@ export class WcdbService { return this.callWorker('getMessageById', { sessionId, localId }) } + async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> { + return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp }) + } + /** * 获取语音数据 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 8a49cad..5d02904 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -140,6 +140,9 @@ if (parentPort) { case 'getMessageById': result = await core.getMessageById(payload.sessionId, payload.localId) break + case 'searchMessages': + result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp) + break case 'getVoiceData': result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId) if (!result.success) {