diff --git a/electron/preload.ts b/electron/preload.ts index 4cf585b..91bb728 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -194,12 +194,11 @@ contextBridge.exposeInMainWorld('electronAPI', { } }, - // 视频 - video: { - getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), - parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) - }, - + // 视频 + video: { + getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), + parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) + }, // 数据分析 analytics: { getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index be86d54..845a1bd 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -4478,27 +4478,77 @@ class ChatService { } private resolveAccountDir(dbPath: string, wxid: string): string | null { - const normalized = dbPath.replace(/[\\\\/]+$/, '') + const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase() + const normalized = dbPath.replace(/[\\/]+$/, '') - // 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件) - // 则向上回溯到账号目录 - if (basename(normalized).toLowerCase() === 'db_storage') { - return dirname(normalized) - } - const dir = dirname(normalized) - if (basename(dir).toLowerCase() === 'db_storage') { - return dirname(dir) + const candidates: { path: string; mtime: number }[] = [] + + // 检查直接路径 + const direct = join(normalized, cleanedWxid) + if (existsSync(direct) && this.isAccountDir(direct)) { + candidates.push({ path: direct, mtime: this.getDirMtime(direct) }) } - // 否则,dbPath 应该是数据库根目录(如 xwechat_files) - // 账号目录应该是 {dbPath}/{wxid} - const accountDirWithWxid = join(normalized, wxid) - if (existsSync(accountDirWithWxid)) { - return accountDirWithWxid + // 检查 dbPath 本身是否就是账号目录 + if (this.isAccountDir(normalized)) { + candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) }) } - // 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录) - return normalized + // 扫描 dbPath 下的所有子目录寻找匹配的 wxid + try { + if (existsSync(normalized) && statSync(normalized).isDirectory()) { + const entries = readdirSync(normalized) + for (const entry of entries) { + const entryPath = join(normalized, entry) + try { + if (!statSync(entryPath).isDirectory()) continue + } catch { continue } + + const lowerEntry = entry.toLowerCase() + if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) { + if (this.isAccountDir(entryPath)) { + if (!candidates.some(c => c.path === entryPath)) { + candidates.push({ path: entryPath, mtime: this.getDirMtime(entryPath) }) + } + } + } + } + } + } catch { } + + if (candidates.length === 0) return null + + // 按修改时间降序排序,取最新的 + candidates.sort((a, b) => b.mtime - a.mtime) + return candidates[0].path + } + + private isAccountDir(dirPath: string): boolean { + return ( + existsSync(join(dirPath, 'db_storage')) || + existsSync(join(dirPath, 'FileStorage', 'Image')) || + existsSync(join(dirPath, 'FileStorage', 'Image2')) || + existsSync(join(dirPath, 'msg', 'attach')) + ) + } + + private getDirMtime(dirPath: string): number { + try { + const stat = statSync(dirPath) + let mtime = stat.mtimeMs + const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image'] + for (const sub of subDirs) { + const fullPath = join(dirPath, sub) + if (existsSync(fullPath)) { + try { + mtime = Math.max(mtime, statSync(fullPath).mtimeMs) + } catch { } + } + } + return mtime + } catch { + return 0 + } } private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise { diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index ee15b02..122c33a 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -77,7 +77,8 @@ export class DbPathService { return ( existsSync(join(entryPath, 'db_storage')) || existsSync(join(entryPath, 'FileStorage', 'Image')) || - existsSync(join(entryPath, 'FileStorage', 'Image2')) + existsSync(join(entryPath, 'FileStorage', 'Image2')) || + existsSync(join(entryPath, 'msg', 'attach')) ) } @@ -94,22 +95,21 @@ export class DbPathService { const accountStat = statSync(entryPath) let latest = accountStat.mtimeMs - const dbPath = join(entryPath, 'db_storage') - if (existsSync(dbPath)) { - const dbStat = statSync(dbPath) - latest = Math.max(latest, dbStat.mtimeMs) - } + const checkSubDirs = [ + 'db_storage', + join('FileStorage', 'Image'), + join('FileStorage', 'Image2'), + join('msg', 'attach') + ] - const imagePath = join(entryPath, 'FileStorage', 'Image') - if (existsSync(imagePath)) { - const imageStat = statSync(imagePath) - latest = Math.max(latest, imageStat.mtimeMs) - } - - const image2Path = join(entryPath, 'FileStorage', 'Image2') - if (existsSync(image2Path)) { - const image2Stat = statSync(image2Path) - latest = Math.max(latest, image2Stat.mtimeMs) + for (const sub of checkSubDirs) { + const fullPath = join(entryPath, sub) + if (existsSync(fullPath)) { + try { + const s = statSync(fullPath) + latest = Math.max(latest, s.mtimeMs) + } catch { } + } } return latest diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 7a8c043..62c683a 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow } from 'electron' import { basename, dirname, extname, join } from 'path' import { pathToFileURL } from 'url' import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs' @@ -11,16 +11,7 @@ import { wcdbService } from './wcdbService' // 获取 ffmpeg-static 的路径 function getStaticFfmpegPath(): string | null { try { - // 优先处理打包后的路径 - if (app.isPackaged) { - const resourcesPath = process.resourcesPath - const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') - if (existsSync(packedPath)) { - return packedPath - } - } - - // 方法1: 直接 require ffmpeg-static(开发环境) + // 方法1: 直接 require ffmpeg-static // eslint-disable-next-line @typescript-eslint/no-var-requires const ffmpegStatic = require('ffmpeg-static') @@ -28,12 +19,21 @@ function getStaticFfmpegPath(): string | null { return ffmpegStatic } - // 方法2: 手动构建路径(开发环境备用) + // 方法2: 手动构建路径(开发环境) const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') if (existsSync(devPath)) { return devPath } + // 方法3: 打包后的路径 + if (app.isPackaged) { + const resourcesPath = process.resourcesPath + const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') + if (existsSync(packedPath)) { + return packedPath + } + } + return null } catch { return null @@ -45,7 +45,6 @@ type DecryptResult = { localPath?: string error?: string isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) - liveVideoPath?: string // 实况照片的视频路径 } type HardlinkState = { @@ -62,7 +61,6 @@ export class ImageDecryptService { private cacheIndexed = false private cacheIndexing: Promise | null = null private updateFlags = new Map() - private noLiveSet = new Set() // 已确认无 live 视频的图片路径 private logInfo(message: string, meta?: Record): void { if (!this.configService.get('logEnabled')) return @@ -118,9 +116,8 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached) this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached)) - return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate, liveVideoPath } + return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(key) @@ -139,9 +136,8 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing) this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing)) - return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate, liveVideoPath } + return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } } } this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) @@ -155,25 +151,13 @@ export class ImageDecryptService { return { success: false, error: '缺少图片标识' } } - if (payload.force) { - const hdCached = this.findCachedOutput(cacheKey, true, payload.sessionId) - if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached) && !this.isThumbnailPath(hdCached)) { - const dataUrl = this.fileToDataUrl(hdCached) - const localPath = dataUrl || this.filePathToUrl(hdCached) - const liveVideoPath = this.checkLiveVideoCache(hdCached) - this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, isThumb: false, liveVideoPath } - } - } - if (!payload.force) { const cached = this.resolvedCache.get(cacheKey) if (cached && existsSync(cached) && this.isImageFile(cached)) { const dataUrl = this.fileToDataUrl(cached) const localPath = dataUrl || this.filePathToUrl(cached) - const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached) this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, liveVideoPath } + return { success: true, localPath } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(cacheKey) @@ -251,9 +235,8 @@ export class ImageDecryptService { const dataUrl = this.fileToDataUrl(existing) const localPath = dataUrl || this.filePathToUrl(existing) const isThumb = this.isThumbnailPath(existing) - const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing) this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, isThumb, liveVideoPath } + return { success: true, localPath, isThumb } } } @@ -297,14 +280,14 @@ export class ImageDecryptService { await writeFile(outputPath, decrypted) this.logInfo('解密成功', { outputPath, size: decrypted.length }) - // 对于 hevc 格式,返回错误提示 if (finalExt === '.hevc') { return { success: false, - error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示', + error: '此图片为微信新格式 (wxgf),需要安装 ffmpeg 才能显示', isThumb: this.isThumbnailPath(datPath) } } + const isThumb = this.isThumbnailPath(datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) if (!isThumb) { @@ -313,15 +296,7 @@ export class ImageDecryptService { const dataUrl = this.bufferToDataUrl(decrypted, finalExt) const localPath = dataUrl || this.filePathToUrl(outputPath) this.emitCacheResolved(payload, cacheKey, localPath) - - // 检测实况照片(Motion Photo) - let liveVideoPath: string | undefined - if (!isThumb && (finalExt === '.jpg' || finalExt === '.jpeg')) { - const videoPath = await this.extractMotionPhotoVideo(outputPath, decrypted) - if (videoPath) liveVideoPath = this.filePathToUrl(videoPath) - } - - return { success: true, localPath, isThumb, liveVideoPath } + return { success: true, localPath, isThumb } } catch (e) { this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: String(e) } @@ -357,37 +332,23 @@ export class ImageDecryptService { * 获取解密后的缓存目录(用于查找 hardlink.db) */ private getDecryptedCacheDir(wxid: string): string | null { + const cachePath = this.configService.get('cachePath') + if (!cachePath) return null + const cleanedWxid = this.cleanAccountDirName(wxid) - const configured = this.configService.get('cachePath') - const documentsPath = app.getPath('documents') - const baseCandidates = Array.from(new Set([ - configured || '', - join(documentsPath, 'WeFlow'), - join(documentsPath, 'WeFlowData'), - this.configService.getCacheBasePath() - ].filter(Boolean))) + const cacheAccountDir = join(cachePath, cleanedWxid) - for (const base of baseCandidates) { - const accountCandidates = Array.from(new Set([ - join(base, wxid), - join(base, cleanedWxid), - join(base, 'databases', wxid), - join(base, 'databases', cleanedWxid) - ])) - for (const accountDir of accountCandidates) { - if (existsSync(join(accountDir, 'hardlink.db'))) { - return accountDir - } - const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink') - if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) { - return hardlinkSubdir - } - } - if (existsSync(join(base, 'hardlink.db'))) { - return base - } + // 检查缓存目录下是否有 hardlink.db + if (existsSync(join(cacheAccountDir, 'hardlink.db'))) { + return cacheAccountDir + } + if (existsSync(join(cachePath, 'hardlink.db'))) { + return cachePath + } + const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink') + if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) { + return cacheHardlinkDir } - return null } @@ -396,8 +357,7 @@ export class ImageDecryptService { existsSync(join(dirPath, 'hardlink.db')) || existsSync(join(dirPath, 'db_storage')) || existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) || - existsSync(join(dirPath, 'msg', 'attach')) + existsSync(join(dirPath, 'FileStorage', 'Image2')) ) } @@ -421,7 +381,7 @@ export class ImageDecryptService { const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const cleaned = suffixMatch ? suffixMatch[1] : trimmed - + return cleaned } @@ -435,14 +395,35 @@ export class ImageDecryptService { const allowThumbnail = options?.allowThumbnail ?? true const skipResolvedCache = options?.skipResolvedCache ?? false this.logInfo('[ImageDecrypt] resolveDatPath', { - accountDir, imageMd5, imageDatName, - sessionId, allowThumbnail, skipResolvedCache }) + if (!skipResolvedCache) { + if (imageMd5) { + const cached = this.resolvedCache.get(imageMd5) + if (cached && existsSync(cached)) return cached + } + if (imageDatName) { + const cached = this.resolvedCache.get(imageDatName) + if (cached && existsSync(cached)) return cached + } + } + + // 1. 通过 MD5 快速定位 (MsgAttach 目录) + if (imageMd5) { + const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail) + if (res) return res + } + + // 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位 + if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { + const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail) + if (res) return res + } + // 优先通过 hardlink.db 查询 if (imageMd5) { this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) @@ -463,12 +444,6 @@ export class ImageDecryptService { if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } - const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false) - if (hdInDir) { - this.cacheDatPath(accountDir, imageMd5, hdInDir) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir) - return hdInDir - } // 没找到高清图,返回 null(不进行全局搜索) return null } @@ -486,16 +461,9 @@ export class ImageDecryptService { // 找到缩略图但要求高清图,尝试同目录查找高清图变体 const hdPath = this.findHdVariantInSameDir(fallbackPath) if (hdPath) { - this.cacheDatPath(accountDir, imageMd5, hdPath) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } - const hdInDir = await this.searchDatFileInDir(dirname(fallbackPath), imageDatName || imageMd5 || '', false) - if (hdInDir) { - this.cacheDatPath(accountDir, imageMd5, hdInDir) - this.cacheDatPath(accountDir, imageDatName, hdInDir) - return hdInDir - } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) @@ -518,17 +486,15 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } - const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false) - if (hdInDir) { - this.cacheDatPath(accountDir, imageDatName, hdInDir) - return hdInDir - } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } - // force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图 + // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) + if (!allowThumbnail) { + return null + } if (!imageDatName) return null if (!skipResolvedCache) { @@ -538,8 +504,6 @@ export class ImageDecryptService { // 缓存的是缩略图,尝试找高清图 const hdPath = this.findHdVariantInSameDir(cached) if (hdPath) return hdPath - const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false) - if (hdInDir) return hdInDir } } @@ -640,9 +604,7 @@ export class ImageDecryptService { }).catch(() => { }) } - private looksLikeMd5(value: string): boolean { - return /^[a-fA-F0-9]{16,32}$/.test(value) - } + private resolveHardlinkDbPath(accountDir: string): string | null { const wxid = this.configService.get('myWxid') @@ -858,7 +820,7 @@ export class ImageDecryptService { * 包含:1. 微信旧版结构 filename.substr(0, 2)/... * 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename */ - private async fastProbabilisticSearch(root: string, datName: string): Promise { + private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise { const { promises: fs } = require('fs') const { join } = require('path') @@ -894,7 +856,7 @@ export class ImageDecryptService { } catch { } } - // --- 策略 B: 新版 Session 哈希路径猜测 --- + // --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 --- try { const entries = await fs.readdir(root, { withFileTypes: true }) const sessionDirs = entries @@ -947,7 +909,7 @@ export class ImageDecryptService { /** * 在同一目录下查找高清图变体 - * 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat + * 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat */ private findHdVariantInSameDir(thumbPath: string): string | null { try { @@ -1029,55 +991,6 @@ export class ImageDecryptService { }) } - private matchesDatName(fileName: string, datName: string): boolean { - const lower = fileName.toLowerCase() - const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower - const normalizedBase = this.normalizeDatBase(base) - const normalizedTarget = this.normalizeDatBase(datName.toLowerCase()) - if (normalizedBase === normalizedTarget) return true - const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`, 'i') - if (pattern.test(lower)) return true - return lower.endsWith('.dat') && lower.includes(datName) - } - - private scoreDatName(fileName: string): number { - if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1 - if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1 - return 2 - } - - private isThumbnailDat(fileName: string): boolean { - return fileName.includes('.t.dat') || fileName.includes('_t.dat') - } - - private hasXVariant(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) - } - - private isThumbnailPath(filePath: string): boolean { - const lower = basename(filePath).toLowerCase() - if (this.isThumbnailDat(lower)) return true - const ext = extname(lower) - const base = ext ? lower.slice(0, -ext.length) : lower - // 支持新命名 _thumb 和旧命名 _t - return base.endsWith('_t') || base.endsWith('_thumb') - } - - private isHdPath(filePath: string): boolean { - const lower = basename(filePath).toLowerCase() - const ext = extname(lower) - const base = ext ? lower.slice(0, -ext.length) : lower - return base.endsWith('_hd') || base.endsWith('_h') - } - - private hasImageVariantSuffix(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) - } - - private isLikelyImageDatBase(baseLower: string): boolean { - return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower) - } - private normalizeDatBase(name: string): string { let base = name.toLowerCase() if (base.endsWith('.dat') || base.endsWith('.jpg')) { @@ -1089,27 +1002,16 @@ export class ImageDecryptService { return base } - private sanitizeDirName(name: string): string { - const trimmed = name.trim() - if (!trimmed) return 'unknown' - return trimmed.replace(/[<>:"/\\|?*]/g, '_') + private hasImageVariantSuffix(baseLower: string): boolean { + return /[._][a-z]$/.test(baseLower) } - private resolveTimeDir(datPath: string): string { - const parts = datPath.split(/[\\/]+/) - for (const part of parts) { - if (/^\d{4}-\d{2}$/.test(part)) return part - } - try { - const stat = statSync(datPath) - const year = stat.mtime.getFullYear() - const month = String(stat.mtime.getMonth() + 1).padStart(2, '0') - return `${year}-${month}` - } catch { - return 'unknown-time' - } + private isLikelyImageDatBase(baseLower: string): boolean { + return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower) } + + private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null { const allRoots = this.getAllCacheRoots() const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) @@ -1344,14 +1246,14 @@ export class ImageDecryptService { private async ensureCacheIndexed(): Promise { if (this.cacheIndexed) return if (this.cacheIndexing) return this.cacheIndexing - this.cacheIndexing = new Promise((resolve) => { + this.cacheIndexing = (async () => { // 扫描所有可能的缓存根目录 const allRoots = this.getAllCacheRoots() this.logInfo('开始索引缓存', { roots: allRoots.length }) for (const root of allRoots) { try { - this.indexCacheDir(root, 3, 0) // 增加深度到3,支持 sessionId/YYYY-MM 结构 + this.indexCacheDir(root, 3, 0) // 增加深度到 3,支持 sessionId/YYYY-MM 结构 } catch (e) { this.logError('索引目录失败', e, { root }) } @@ -1360,8 +1262,7 @@ export class ImageDecryptService { this.logInfo('缓存索引完成', { entries: this.resolvedCache.size }) this.cacheIndexed = true this.cacheIndexing = null - resolve() - }) + })() return this.cacheIndexing } @@ -1564,14 +1465,14 @@ export class ImageDecryptService { private bytesToInt32(bytes: Buffer): number { if (bytes.length !== 4) { - throw new Error('需要4个字节') + throw new Error('需要 4 个字节') } return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24) } asciiKey16(keyString: string): Buffer { if (keyString.length < 16) { - throw new Error('AES密钥至少需要16个字符') + throw new Error('AES密钥至少需要 16 个字符') } return Buffer.from(keyString, 'ascii').subarray(0, 16) } @@ -1738,76 +1639,6 @@ export class ImageDecryptService { return mostCommonKey } - /** - * 检查图片对应的 live 视频缓存,返回 file:// URL 或 undefined - * 已确认无 live 的路径会被记录,下次直接跳过 - */ - private checkLiveVideoCache(imagePath: string): string | undefined { - if (this.noLiveSet.has(imagePath)) return undefined - const livePath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4') - if (existsSync(livePath)) return this.filePathToUrl(livePath) - this.noLiveSet.add(imagePath) - return undefined - } - - /** - * 检测并分离 Motion Photo(实况照片) - * Google Motion Photo = JPEG + MP4 拼接在一起 - * 返回视频文件路径,如果不是实况照片则返回 null - */ - private async extractMotionPhotoVideo(imagePath: string, imageBuffer: Buffer): Promise { - // 只处理 JPEG 文件 - if (imageBuffer.length < 8) return null - if (imageBuffer[0] !== 0xff || imageBuffer[1] !== 0xd8) return null - - // 从末尾向前搜索 MP4 ftyp 原子签名 - // ftyp 原子结构: [4字节大小][ftyp(66 74 79 70)][品牌...] - // 实际起始位置在 ftyp 前4字节(大小字段) - const ftypSig = [0x66, 0x74, 0x79, 0x70] // 'ftyp' - let videoOffset: number | null = null - - const searchEnd = Math.max(0, imageBuffer.length - 8) - for (let i = searchEnd; i > 0; i--) { - if (imageBuffer[i] === ftypSig[0] && - imageBuffer[i + 1] === ftypSig[1] && - imageBuffer[i + 2] === ftypSig[2] && - imageBuffer[i + 3] === ftypSig[3]) { - // ftyp 前4字节是 box size,实际 MP4 从这里开始 - videoOffset = i - 4 - break - } - } - - // 备用:从 XMP 元数据中读取偏移量 - if (videoOffset === null || videoOffset <= 0) { - try { - const text = imageBuffer.toString('latin1') - const match = text.match(/MediaDataOffset="(\d+)"/i) || text.match(/MicroVideoOffset="(\d+)"/i) - if (match) { - const offset = parseInt(match[1], 10) - if (offset > 0 && offset < imageBuffer.length) { - videoOffset = imageBuffer.length - offset - } - } - } catch { } - } - - if (videoOffset === null || videoOffset <= 100) return null - - // 验证视频部分确实以有效 MP4 数据开头 - const videoStart = imageBuffer[videoOffset + 4] === 0x66 && - imageBuffer[videoOffset + 5] === 0x74 && - imageBuffer[videoOffset + 6] === 0x79 && - imageBuffer[videoOffset + 7] === 0x70 - if (!videoStart) return null - - // 写出视频文件 - const videoPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4') - const videoBuffer = imageBuffer.slice(videoOffset) - await writeFile(videoPath, videoBuffer) - return videoPath - } - /** * 解包 wxgf 格式 * wxgf 是微信的图片格式,内部使用 HEVC 编码 @@ -1851,7 +1682,7 @@ export class ImageDecryptService { } /** - * 从 wxgf 数据中提取 HEVC NALU 裸流 + * 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦 */ private extractHevcNalu(buffer: Buffer): Buffer | null { const nalUnits: Buffer[] = [] @@ -1961,6 +1792,44 @@ export class ImageDecryptService { }) } + private looksLikeMd5(s: string): boolean { + return /^[a-f0-9]{32}$/i.test(s) + } + + private isThumbnailDat(name: string): boolean { + const lower = name.toLowerCase() + return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat') + } + + private hasXVariant(base: string): boolean { + const lower = base.toLowerCase() + return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t') + } + + private isHdPath(p: string): boolean { + return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h') + } + + private isThumbnailPath(p: string): boolean { + const lower = p.toLowerCase() + return lower.includes('_thumb') || lower.includes('_t') || lower.includes('.t.') + } + + private sanitizeDirName(s: string): string { + return s.replace(/[<>:"/\\|?*]/g, '_').trim() || 'unknown' + } + + private resolveTimeDir(filePath: string): string { + try { + const stats = statSync(filePath) + const d = new Date(stats.mtime) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + } catch { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + } + } + // 保留原有的解密到文件方法(用于兼容) async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise { const version = this.getDatVersion(inputPath) @@ -1973,7 +1842,7 @@ export class ImageDecryptService { decrypted = this.decryptDatV4(inputPath, xorKey, key) } else { if (!aesKey || aesKey.length !== 16) { - throw new Error('V4版本需要16字节AES密钥') + throw new Error('V4版本需要 16 字节 AES 密钥') } decrypted = this.decryptDatV4(inputPath, xorKey, aesKey) } diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 34e7bd0..fb21b2f 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -744,7 +744,8 @@ export class KeyService { return ( existsSync(join(dirPath, 'db_storage')) || existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) + existsSync(join(dirPath, 'FileStorage', 'Image2')) || + existsSync(join(dirPath, 'msg', 'attach')) ) } @@ -761,8 +762,8 @@ export class KeyService { private listAccountDirs(rootDir: string): string[] { try { const entries = readdirSync(rootDir) - const high: string[] = [] - const low: string[] = [] + const candidates: { path: string; mtime: number; isAccount: boolean }[] = [] + for (const entry of entries) { const fullPath = join(rootDir, entry) try { @@ -775,18 +776,48 @@ export class KeyService { continue } - if (this.isAccountDir(fullPath)) { - high.push(fullPath) - } else { - low.push(fullPath) - } + const isAccount = this.isAccountDir(fullPath) + candidates.push({ + path: fullPath, + mtime: this.getDirMtime(fullPath), + isAccount + }) } - return high.length ? high.sort() : low.sort() + + // 优先选择有效账号目录,然后按修改时间从新到旧排序 + return candidates + .sort((a, b) => { + if (a.isAccount !== b.isAccount) return a.isAccount ? -1 : 1 + return b.mtime - a.mtime + }) + .map(c => c.path) } catch { return [] } } + private getDirMtime(dirPath: string): number { + try { + const stat = statSync(dirPath) + let mtime = stat.mtimeMs + + // 检查几个关键子目录的修改时间,以更准确地反映活动状态 + const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image'] + for (const sub of subDirs) { + const fullPath = join(dirPath, sub) + if (existsSync(fullPath)) { + try { + mtime = Math.max(mtime, statSync(fullPath).mtimeMs) + } catch { } + } + } + + return mtime + } catch { + return 0 + } + } + private normalizeExistingDir(inputPath: string): string | null { const trimmed = inputPath.replace(/[\\\\/]+$/, '') if (!existsSync(trimmed)) return null diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 6b44c2e..ddbc0c5 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -1,4 +1,4 @@ -import { join } from 'path' +import { join } from 'path' import { existsSync, readdirSync, statSync, readFileSync } from 'fs' import { ConfigService } from './config' import Database from 'better-sqlite3' @@ -36,7 +36,7 @@ class VideoService { * 获取缓存目录(解密后的数据库存放位置) */ private getCachePath(): string { - return this.configService.getCacheBasePath() + return this.configService.get('cachePath') || '' } /** @@ -69,6 +69,9 @@ class VideoService { const wxid = this.getMyWxid() const cleanedWxid = this.cleanWxid(wxid) + console.log('[VideoService] queryVideoFileName called with MD5:', md5) + console.log('[VideoService] cachePath:', cachePath, 'dbPath:', dbPath, 'wxid:', wxid, 'cleanedWxid:', cleanedWxid) + if (!wxid) return undefined // 方法1:优先在 cachePath 下查找解密后的 hardlink.db @@ -83,6 +86,7 @@ class VideoService { for (const p of cacheDbPaths) { if (existsSync(p)) { + console.log('[VideoService] Found decrypted hardlink.db at:', p) try { const db = new Database(p, { readonly: true }) const row = db.prepare(` @@ -94,10 +98,11 @@ class VideoService { if (row?.file_name) { const realMd5 = row.file_name.replace(/\.[^.]+$/, '') + console.log('[VideoService] Found video filename via cache:', realMd5) return realMd5 } } catch (e) { - // 忽略错误 + console.log('[VideoService] Failed to query cached hardlink.db:', e) } } } @@ -105,46 +110,41 @@ class VideoService { // 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db if (dbPath) { - // 检查 dbPath 是否已经包含 wxid - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxidLower = cleanedWxid.toLowerCase() - const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) - - const encryptedDbPaths: string[] = [] - if (dbPathContainsWxid) { - // dbPath 已包含 wxid,不需要再拼接 - encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')) - } else { - // dbPath 不包含 wxid,需要拼接 - encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db')) - encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')) - } + const encryptedDbPaths = [ + join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), + join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') + ] for (const p of encryptedDbPaths) { if (existsSync(p)) { + console.log('[VideoService] Found encrypted hardlink.db at:', p) try { const escapedMd5 = md5.replace(/'/g, "''") // 用 md5 字段查询,获取 file_name const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` + console.log('[VideoService] Query SQL:', sql) const result = await wcdbService.execQuery('media', p, sql) + console.log('[VideoService] Query result:', result) if (result.success && result.rows && result.rows.length > 0) { const row = result.rows[0] if (row?.file_name) { // 提取不带扩展名的文件名作为实际视频 MD5 const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '') + console.log('[VideoService] Found video filename:', realMd5) return realMd5 } } } catch (e) { - // 忽略错误 + console.log('[VideoService] Failed to query encrypted hardlink.db via wcdbService:', e) } } } } + + console.log('[VideoService] No matching video found in hardlink.db') return undefined } @@ -167,37 +167,34 @@ class VideoService { * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg */ async getVideoInfo(videoMd5: string): Promise { + console.log('[VideoService] getVideoInfo called with MD5:', videoMd5) + const dbPath = this.getDbPath() const wxid = this.getMyWxid() + console.log('[VideoService] Config - dbPath:', dbPath, 'wxid:', wxid) + if (!dbPath || !wxid || !videoMd5) { + console.log('[VideoService] Missing required params') return { exists: false } } // 先尝试从数据库查询真正的视频文件名 const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 + console.log('[VideoService] Real video MD5:', realVideoMd5) - // 检查 dbPath 是否已经包含 wxid,避免重复拼接 - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxid = this.cleanWxid(wxid) - - let videoBaseDir: string - if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) { - // dbPath 已经包含 wxid,直接使用 - videoBaseDir = join(dbPath, 'msg', 'video') - } else { - // dbPath 不包含 wxid,需要拼接 - videoBaseDir = join(dbPath, wxid, 'msg', 'video') - } + const videoBaseDir = join(dbPath, wxid, 'msg', 'video') + console.log('[VideoService] Video base dir:', videoBaseDir) if (!existsSync(videoBaseDir)) { + console.log('[VideoService] Video base dir does not exist') return { exists: false } } // 遍历年月目录查找视频文件 try { const allDirs = readdirSync(videoBaseDir) + console.log('[VideoService] Found year-month dirs:', allDirs) // 支持多种目录格式: YYYY-MM, YYYYMM, 或其他 const yearMonthDirs = allDirs @@ -214,8 +211,11 @@ class VideoService { const coverPath = join(dirPath, `${realVideoMd5}.jpg`) const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) + console.log('[VideoService] Checking:', videoPath) + // 检查视频文件是否存在 if (existsSync(videoPath)) { + console.log('[VideoService] Video file found!') return { videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取 coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), @@ -224,8 +224,10 @@ class VideoService { } } } + + console.log('[VideoService] Video file not found in any directory') } catch (e) { - // 忽略错误 + console.error('[VideoService] Error searching for video:', e) } return { exists: false } @@ -235,8 +237,10 @@ class VideoService { * 根据消息内容解析视频MD5 */ parseVideoMd5(content: string): string | undefined { + console.log('[VideoService] parseVideoMd5 called, content length:', content?.length) // 打印前500字符看看 XML 结构 + console.log('[VideoService] XML preview:', content?.substring(0, 500)) if (!content) return undefined @@ -248,6 +252,7 @@ class VideoService { while ((match = md5Regex.exec(content)) !== null) { allMd5s.push(`${match[0]}`) } + console.log('[VideoService] All MD5 attributes found:', allMd5s) // 提取 md5(用于查询 hardlink.db) // 注意:不是 rawmd5,rawmd5 是另一个值 @@ -256,18 +261,23 @@ class VideoService { // 尝试从videomsg标签中提取md5 const videoMsgMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) if (videoMsgMatch) { + console.log('[VideoService] Found MD5 via videomsg:', videoMsgMatch[1]) return videoMsgMatch[1].toLowerCase() } const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) if (attrMatch) { + console.log('[VideoService] Found MD5 via attribute:', attrMatch[1]) return attrMatch[1].toLowerCase() } const md5Match = /([a-fA-F0-9]+)<\/md5>/i.exec(content) if (md5Match) { + console.log('[VideoService] Found MD5 via tag:', md5Match[1]) return md5Match[1].toLowerCase() } + + console.log('[VideoService] No MD5 found in content') } catch (e) { console.error('[VideoService] 解析视频MD5失败:', e) } diff --git a/package-lock.json b/package-lock.json index 92d10ba..4ac3dc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2909,6 +2910,7 @@ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3055,6 +3057,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3994,6 +3997,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5103,6 +5107,7 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5290,6 +5295,7 @@ "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "2.3.0", "zrender": "5.6.1" @@ -5376,7 +5382,6 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5390,7 +5395,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5406,7 +5410,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5420,7 +5423,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -9150,6 +9152,7 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9159,6 +9162,7 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9593,6 +9597,7 @@ "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -10434,6 +10439,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10881,6 +10887,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10970,7 +10977,8 @@ "resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -10996,6 +11004,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b5ec3df..ee92354 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -3540,13 +3540,12 @@ function MessageBubble({ const requestVideoInfo = useCallback(async () => { if (!videoMd5 || videoLoadingRef.current) return - videoLoadingRef.current = true - setVideoLoading(true) - try { - const result = await window.electronAPI.video.getVideoInfo(videoMd5) - if (result && result.success && result.exists) { - setVideoInfo({ - exists: result.exists, + videoLoadingRef.current = true + setVideoLoading(true) + try { + const result = await window.electronAPI.video.getVideoInfo(videoMd5) + if (result && result.success && result.exists) { + setVideoInfo({ exists: result.exists, videoUrl: result.videoUrl, coverUrl: result.coverUrl, thumbUrl: result.thumbUrl