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' import { writeFile, rm, readdir } from 'fs/promises' import crypto from 'crypto' import { Worker } from 'worker_threads' import { ConfigService } from './config' import { wcdbService } from './wcdbService' // 获取 ffmpeg-static 的路径 function getStaticFfmpegPath(): string | null { try { // 方法1: 直接 require ffmpeg-static // eslint-disable-next-line @typescript-eslint/no-var-requires const ffmpegStatic = require('ffmpeg-static') if (typeof ffmpegStatic === 'string') { // 修复:如果路径包含 app.asar(打包后),自动替换为 app.asar.unpacked let fixedPath = ffmpegStatic if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) { fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked') } if (existsSync(fixedPath)) { return fixedPath } } // 方法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 } } type DecryptResult = { success: boolean localPath?: string error?: string isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) } type HardlinkState = { imageTable?: string dirTable?: string } export class ImageDecryptService { private configService = new ConfigService() private hardlinkCache = new Map() private resolvedCache = new Map() private pending = new Map>() private readonly defaultV1AesKey = 'cfcd208495d565ef' private cacheIndexed = false private cacheIndexing: Promise | null = null private updateFlags = new Map() private logInfo(message: string, meta?: Record): void { if (!this.configService.get('logEnabled')) return const timestamp = new Date().toISOString() const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n` // 只写入文件,不输出到控制台 this.writeLog(logLine) } private logError(message: string, error?: unknown, meta?: Record): void { if (!this.configService.get('logEnabled')) return const timestamp = new Date().toISOString() const errorStr = error ? ` Error: ${String(error)}` : '' const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n` // 同时输出到控制台 console.error(message, error, meta) // 写入日志文件 this.writeLog(logLine) } private writeLog(line: string): void { try { const logDir = join(app.getPath('userData'), 'logs') if (!existsSync(logDir)) { mkdirSync(logDir, { recursive: true }) } appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' }) } catch (err) { console.error('写入日志失败:', err) } } async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise { await this.ensureCacheIndexed() const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] if (!cacheKey) { return { success: false, error: '缺少图片标识' } } for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) if (cached && existsSync(cached) && this.isImageFile(cached)) { const dataUrl = this.fileToDataUrl(cached) const isThumb = this.isThumbnailPath(cached) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { this.triggerUpdateCheck(payload, key, cached) } else { this.updateFlags.delete(key) } this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached)) return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(key) } } for (const key of cacheKeys) { const existing = this.findCachedOutput(key, false, payload.sessionId) if (existing) { this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing) const dataUrl = this.fileToDataUrl(existing) const isThumb = this.isThumbnailPath(existing) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { this.triggerUpdateCheck(payload, key, existing) } else { this.updateFlags.delete(key) } this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing)) return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } } } this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: '未找到缓存图片' } } async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise { await this.ensureCacheIndexed() const cacheKey = payload.imageMd5 || payload.imageDatName if (!cacheKey) { return { success: false, error: '缺少图片标识' } } 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) this.emitCacheResolved(payload, cacheKey, localPath) return { success: true, localPath } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(cacheKey) } } const pending = this.pending.get(cacheKey) if (pending) return pending const task = this.decryptImageInternal(payload, cacheKey) this.pending.set(cacheKey, task) try { return await task } finally { this.pending.delete(cacheKey) } } private async decryptImageInternal( payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }, cacheKey: string ): Promise { this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force }) try { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') if (!wxid || !dbPath) { this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath }) return { success: false, error: '未配置账号或数据库路径' } } const accountDir = this.resolveAccountDir(dbPath, wxid) if (!accountDir) { this.logError('未找到账号目录', undefined, { dbPath, wxid }) return { success: false, error: '未找到账号目录' } } const datPath = await this.resolveDatPath( accountDir, payload.imageMd5, payload.imageDatName, payload.sessionId, { allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) } ) // 如果要求高清图但没找到,直接返回提示 if (!datPath && payload.force) { this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } } if (!datPath) { this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: '未找到图片文件' } } this.logInfo('找到DAT文件', { datPath }) if (!extname(datPath).toLowerCase().includes('dat')) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) const dataUrl = this.fileToDataUrl(datPath) const localPath = dataUrl || this.filePathToUrl(datPath) const isThumb = this.isThumbnailPath(datPath) this.emitCacheResolved(payload, cacheKey, localPath) return { success: true, localPath, isThumb } } // 查找已缓存的解密文件 const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) if (existing) { this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) }) const isHd = this.isHdPath(existing) // 如果要求高清但找到的是缩略图,继续解密高清图 if (!(payload.force && !isHd)) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) const dataUrl = this.fileToDataUrl(existing) const localPath = dataUrl || this.filePathToUrl(existing) const isThumb = this.isThumbnailPath(existing) this.emitCacheResolved(payload, cacheKey, localPath) return { success: true, localPath, isThumb } } } // 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置 const imageKeys = this.configService.getImageKeysForCurrentWxid() const xorKeyRaw = imageKeys.xorKey // 支持十六进制格式(如 0x53)和十进制格式 let xorKey: number if (typeof xorKeyRaw === 'number') { xorKey = xorKeyRaw } else { const trimmed = String(xorKeyRaw ?? '').trim() if (trimmed.toLowerCase().startsWith('0x')) { xorKey = parseInt(trimmed, 16) } else { xorKey = parseInt(trimmed, 10) } } if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) { return { success: false, error: '未配置图片解密密钥' } } const aesKeyRaw = imageKeys.aesKey const aesKey = this.resolveAesKey(aesKeyRaw) this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey }) let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) // 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据 const wxgfResult = await this.unwrapWxgf(decrypted) decrypted = wxgfResult.data let ext = this.detectImageExtension(decrypted) // 如果是 wxgf 格式且没检测到扩展名 if (wxgfResult.isWxgf && !ext) { ext = '.hevc' } const finalExt = ext || '.jpg' const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId) await writeFile(outputPath, decrypted) this.logInfo('解密成功', { outputPath, size: decrypted.length }) if (finalExt === '.hevc') { return { success: false, error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志', isThumb: this.isThumbnailPath(datPath) } } const isThumb = this.isThumbnailPath(datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) if (!isThumb) { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) } const dataUrl = this.bufferToDataUrl(decrypted, finalExt) const localPath = dataUrl || this.filePathToUrl(outputPath) this.emitCacheResolved(payload, cacheKey, localPath) return { success: true, localPath, isThumb } } catch (e) { this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: String(e) } } } private resolveAccountDir(dbPath: string, wxid: string): string | null { const cleanedWxid = this.cleanAccountDirName(wxid) const normalized = dbPath.replace(/[\\/]+$/, '') const direct = join(normalized, cleanedWxid) if (existsSync(direct)) return direct if (this.isAccountDir(normalized)) return normalized try { const entries = readdirSync(normalized) const lowerWxid = cleanedWxid.toLowerCase() for (const entry of entries) { const entryPath = join(normalized, entry) if (!this.isDirectory(entryPath)) continue const lowerEntry = entry.toLowerCase() if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) { if (this.isAccountDir(entryPath)) return entryPath } } } catch { } return null } /** * 获取解密后的缓存目录(用于查找 hardlink.db) */ private getDecryptedCacheDir(wxid: string): string | null { const cachePath = this.configService.get('cachePath') if (!cachePath) return null const cleanedWxid = this.cleanAccountDirName(wxid) const cacheAccountDir = join(cachePath, cleanedWxid) // 检查缓存目录下是否有 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 } private isAccountDir(dirPath: string): boolean { return ( existsSync(join(dirPath, 'hardlink.db')) || existsSync(join(dirPath, 'db_storage')) || existsSync(join(dirPath, 'FileStorage', 'Image')) || existsSync(join(dirPath, 'FileStorage', 'Image2')) ) } private isDirectory(path: string): boolean { try { return statSync(path).isDirectory() } catch { return false } } private cleanAccountDirName(dirName: string): string { const trimmed = dirName.trim() if (!trimmed) return trimmed if (trimmed.toLowerCase().startsWith('wxid_')) { const match = trimmed.match(/^(wxid_[^_]+)/i) if (match) return match[1] return trimmed } const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const cleaned = suffixMatch ? suffixMatch[1] : trimmed return cleaned } private async resolveDatPath( accountDir: string, imageMd5?: string, imageDatName?: string, sessionId?: string, options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean } ): Promise { const allowThumbnail = options?.allowThumbnail ?? true const skipResolvedCache = options?.skipResolvedCache ?? false this.logInfo('[ImageDecrypt] resolveDatPath', { imageMd5, imageDatName, allowThumbnail, skipResolvedCache }) if (!skipResolvedCache) { if (imageMd5) { const cached = this.resolvedCache.get(imageMd5) if (cached && existsSync(cached)) { const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) this.cacheDatPath(accountDir, imageMd5, preferred) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred) return preferred } } if (imageDatName) { const cached = this.resolvedCache.get(imageDatName) if (cached && existsSync(cached)) { const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) this.cacheDatPath(accountDir, imageDatName, preferred) if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred) return preferred } } } // 1. 通过 MD5 快速定位 (MsgAttach 目录) if (imageMd5) { const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail) if (res) return res } // 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位 if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail) if (res) return res } // 优先通过 hardlink.db 查询 if (imageMd5) { this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId) if (hardlinkPath) { const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail) const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath }) this.cacheDatPath(accountDir, imageMd5, preferredPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath) return preferredPath } // hardlink 找到的是缩略图,但要求高清图 // 尝试在同一目录下查找高清图变体(快速查找,不遍历) const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageMd5, hdPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } // 没找到高清图,返回 null(不进行全局搜索) return null } this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 }) if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) { this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId }) const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) if (fallbackPath) { const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail) const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath }) this.cacheDatPath(accountDir, imageDatName, preferredPath) if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath) return preferredPath } // 找到缩略图但要求高清图,尝试同目录查找高清图变体 const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageDatName, hdPath) if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath) return hdPath } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } } if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId }) const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) if (hardlinkPath) { const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail) const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath }) this.cacheDatPath(accountDir, imageDatName, preferredPath) return preferredPath } // hardlink 找到的是缩略图,但要求高清图 const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) if (!allowThumbnail) { return null } if (!imageDatName) return null if (!skipResolvedCache) { const cached = this.resolvedCache.get(imageDatName) if (cached && existsSync(cached)) { const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred // 缓存的是缩略图,尝试找高清图 const hdPath = this.findHdVariantInSameDir(preferred) if (hdPath) return hdPath } } const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail) if (datPath) { this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath }) this.resolvedCache.set(imageDatName, datPath) this.cacheDatPath(accountDir, imageDatName, datPath) return datPath } const normalized = this.normalizeDatBase(imageDatName) if (normalized !== imageDatName.toLowerCase()) { const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail) if (normalizedPath) { this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, normalized, path: normalizedPath }) this.resolvedCache.set(imageDatName, normalizedPath) this.cacheDatPath(accountDir, imageDatName, normalizedPath) return normalizedPath } } this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, normalized }) return null } private async resolveThumbnailDatPath( accountDir: string, imageMd5?: string, imageDatName?: string, sessionId?: string ): Promise { if (imageMd5) { const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId) if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath } if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath } if (!imageDatName) return null return this.searchDatFile(accountDir, imageDatName, true, true) } private async checkHasUpdate( payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, cachedPath: string ): Promise { if (!cachedPath || !existsSync(cachedPath)) return false const isThumbnail = this.isThumbnailPath(cachedPath) if (!isThumbnail) return false const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') if (!wxid || !dbPath) return false const accountDir = this.resolveAccountDir(dbPath, wxid) if (!accountDir) return false const quickDir = this.getCachedDatDir(accountDir, payload.imageDatName, payload.imageMd5) if (quickDir) { const baseName = payload.imageDatName || payload.imageMd5 || cacheKey const candidate = this.findNonThumbnailVariantInDir(quickDir, baseName) if (candidate) { return true } } const thumbPath = await this.resolveThumbnailDatPath( accountDir, payload.imageMd5, payload.imageDatName, payload.sessionId ) if (thumbPath) { const baseName = payload.imageDatName || payload.imageMd5 || cacheKey const candidate = this.findNonThumbnailVariantInDir(dirname(thumbPath), baseName) if (candidate) { return true } const searchHit = await this.searchDatFileInDir(dirname(thumbPath), baseName, false) if (searchHit && this.isNonThumbnailVariantDat(searchHit)) { return true } } return false } private triggerUpdateCheck( payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, cachedPath: string ): void { if (this.updateFlags.get(cacheKey)) return void this.checkHasUpdate(payload, cacheKey, cachedPath).then((hasUpdate) => { if (!hasUpdate) return this.updateFlags.set(cacheKey, true) this.emitImageUpdate(payload, cacheKey) }).catch(() => { }) } private resolveHardlinkDbPath(accountDir: string): string | null { const wxid = this.configService.get('myWxid') const cacheDir = wxid ? this.getDecryptedCacheDir(wxid) : null const candidates = [ join(accountDir, 'db_storage', 'hardlink', 'hardlink.db'), join(accountDir, 'hardlink.db'), cacheDir ? join(cacheDir, 'hardlink.db') : null ].filter(Boolean) as string[] this.logInfo('[ImageDecrypt] hardlink db probe', { accountDir, cacheDir, candidates }) for (const candidate of candidates) { if (existsSync(candidate)) return candidate } this.logInfo('[ImageDecrypt] hardlink db missing', { accountDir, cacheDir, candidates }) return null } private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise { try { const hardlinkPath = this.resolveHardlinkDbPath(accountDir) if (!hardlinkPath) { return null } const ready = await this.ensureWcdbReady() if (!ready) { this.logInfo('[ImageDecrypt] hardlink db not ready') return null } const state = await this.getHardlinkState(accountDir, hardlinkPath) if (!state.imageTable) { this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath }) return null } const escapedMd5 = this.escapeSqlString(md5) const rowResult = await wcdbService.execQuery( 'media', hardlinkPath, `SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1` ) const row = rowResult.success && rowResult.rows ? rowResult.rows[0] : null if (!row) { this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable }) return null } const dir1 = this.getRowValue(row, 'dir1') const dir2 = this.getRowValue(row, 'dir2') const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName') if (dir1 === undefined || dir2 === undefined || !fileName) { this.logInfo('[ImageDecrypt] hardlink row incomplete', { row }) return null } const lowerFileName = fileName.toLowerCase() if (lowerFileName.endsWith('.dat')) { const baseLower = lowerFileName.slice(0, -4) if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) { this.logInfo('[ImageDecrypt] hardlink fileName rejected', { fileName }) return null } } // dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名 let dir1Name: string | null = null let dir2Name: string | null = null if (state.dirTable) { try { // 通过 rowid 查询目录名 const dir1Result = await wcdbService.execQuery( 'media', hardlinkPath, `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1` ) if (dir1Result.success && dir1Result.rows && dir1Result.rows.length > 0) { const value = this.getRowValue(dir1Result.rows[0], 'username') if (value) dir1Name = String(value) } const dir2Result = await wcdbService.execQuery( 'media', hardlinkPath, `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir2)} LIMIT 1` ) if (dir2Result.success && dir2Result.rows && dir2Result.rows.length > 0) { const value = this.getRowValue(dir2Result.rows[0], 'username') if (value) dir2Name = String(value) } } catch { // ignore } } if (!dir1Name || !dir2Name) { this.logInfo('[ImageDecrypt] hardlink dir resolve miss', { dir1, dir2, dir1Name, dir2Name }) return null } // 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName} const possiblePaths = [ join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName), join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName), join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName), ] for (const fullPath of possiblePaths) { if (existsSync(fullPath)) { this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath }) return fullPath } } this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths }) return null } catch { // ignore } return null } private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise { const cached = this.hardlinkCache.get(hardlinkPath) if (cached) return cached const imageResult = await wcdbService.execQuery( 'media', hardlinkPath, "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1" ) const dirResult = await wcdbService.execQuery( 'media', hardlinkPath, "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1" ) const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0 ? this.getRowValue(imageResult.rows[0], 'name') : undefined const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0 ? this.getRowValue(dirResult.rows[0], 'name') : undefined const state: HardlinkState = { imageTable: imageTable ? String(imageTable) : undefined, dirTable: dirTable ? String(dirTable) : undefined } this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable }) this.hardlinkCache.set(hardlinkPath, state) return state } private async ensureWcdbReady(): Promise { if (wcdbService.isReady()) return true const dbPath = this.configService.get('dbPath') const decryptKey = this.configService.get('decryptKey') const wxid = this.configService.get('myWxid') if (!dbPath || !decryptKey || !wxid) return false const cleanedWxid = this.cleanAccountDirName(wxid) return await wcdbService.open(dbPath, decryptKey, cleanedWxid) } private getRowValue(row: any, column: string): any { if (!row) return undefined if (Object.prototype.hasOwnProperty.call(row, column)) return row[column] const target = column.toLowerCase() for (const key of Object.keys(row)) { if (key.toLowerCase() === target) return row[key] } return undefined } private escapeSqlString(value: string): string { return value.replace(/'/g, "''") } private async searchDatFile( accountDir: string, datName: string, allowThumbnail = true, thumbOnly = false ): Promise { const key = `${accountDir}|${datName}` const cached = this.resolvedCache.get(key) if (cached && existsSync(cached)) { const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred } const root = join(accountDir, 'msg', 'attach') if (!existsSync(root)) return null // 优化1:快速概率性查找 // 包含:1. 基于文件名的前缀猜测 (旧版) // 2. 基于日期的最近月份扫描 (新版无索引时) const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail) if (fastHit) { this.resolvedCache.set(key, fastHit) return fastHit } // 优化2:兜底扫描 (异步非阻塞) const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly) if (found) { this.resolvedCache.set(key, found) return found } return null } /** * 基于文件名的哈希特征猜测可能的路径 * 包含:1. 微信旧版结构 filename.substr(0, 2)/... * 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename */ private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): Promise { const { promises: fs } = require('fs') const { join } = require('path') try { // --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) --- const lowerName = datName.toLowerCase() const baseName = this.normalizeDatBase(lowerName) const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail) const candidates: string[] = [] if (/^[a-f0-9]{32}$/.test(baseName)) { const dir1 = baseName.substring(0, 2) const dir2 = baseName.substring(2, 4) for (const targetName of targetNames) { candidates.push( join(root, dir1, dir2, targetName), join(root, dir1, dir2, 'Img', targetName), join(root, dir1, dir2, 'mg', targetName), join(root, dir1, dir2, 'Image', targetName) ) } } for (const path of candidates) { try { await fs.access(path) return path } catch { } } // --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 --- try { const entries = await fs.readdir(root, { withFileTypes: true }) const sessionDirs = entries .filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name)) .map((e: any) => e.name) if (sessionDirs.length === 0) return null const now = new Date() const months: string[] = [] for (let i = 0; i < 2; 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) } const batchSize = 20 for (let i = 0; i < sessionDirs.length; i += batchSize) { const batch = sessionDirs.slice(i, i + batchSize) const tasks = batch.map(async (sessDir: string) => { for (const month of months) { const subDirs = ['Img', 'Image'] for (const sub of subDirs) { const dirPath = join(root, sessDir, month, sub) try { await fs.access(dirPath) } catch { continue } for (const name of targetNames) { const p = join(dirPath, name) try { await fs.access(p); return p } catch { } } } } return null }) const results = await Promise.all(tasks) const hit = results.find(r => r !== null) if (hit) return hit } } catch { } } catch { } return null } /** * 在同一目录下查找高清图变体 * 优先 `_h`,再回退其他非缩略图变体 */ private findHdVariantInSameDir(thumbPath: string): string | null { try { const dir = dirname(thumbPath) const fileName = basename(thumbPath) return this.findPreferredDatVariantInDir(dir, fileName, false) } catch { } return null } private async searchDatFileInDir( dirPath: string, datName: string, allowThumbnail = true ): Promise { if (!existsSync(dirPath)) return null return await this.walkForDatInWorker(dirPath, datName.toLowerCase(), 3, allowThumbnail, false) } private async walkForDatInWorker( root: string, datName: string, maxDepth = 4, allowThumbnail = true, thumbOnly = false ): Promise { const workerPath = join(__dirname, 'imageSearchWorker.js') return await new Promise((resolve) => { const worker = new Worker(workerPath, { workerData: { root, datName, maxDepth, allowThumbnail, thumbOnly } }) const cleanup = () => { worker.removeAllListeners() } worker.on('message', (msg: any) => { if (msg && msg.type === 'done') { cleanup() void worker.terminate() resolve(msg.path || null) return } if (msg && msg.type === 'error') { cleanup() void worker.terminate() resolve(null) } }) worker.on('error', () => { cleanup() void worker.terminate() resolve(null) }) }) } private stripDatVariantSuffix(base: string): string { const lower = base.toLowerCase() const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c'] for (const suffix of suffixes) { if (lower.endsWith(suffix)) { return lower.slice(0, -suffix.length) } } if (/[._][a-z]$/.test(lower)) { return lower.slice(0, -2) } return lower } private getDatVariantPriority(name: string): number { const lower = name.toLowerCase() const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 if (!this.hasXVariant(baseLower)) return 500 if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450 if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 if (this.isThumbnailDat(lower)) return 100 return 350 } private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] { if (!baseName) return [] const names = [ `${baseName}_h.dat`, `${baseName}.h.dat`, `${baseName}.dat`, `${baseName}_hd.dat`, `${baseName}.hd.dat`, `${baseName}_c.dat`, `${baseName}.c.dat` ] if (allowThumbnail) { names.push( `${baseName}_thumb.dat`, `${baseName}.thumb.dat`, `${baseName}_t.dat`, `${baseName}.t.dat` ) } return Array.from(new Set(names)) } private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null { let entries: string[] try { entries = readdirSync(dirPath) } catch { return null } const target = this.normalizeDatBase(baseName.toLowerCase()) let bestPath: string | null = null let bestScore = Number.NEGATIVE_INFINITY for (const entry of entries) { const lower = entry.toLowerCase() if (!lower.endsWith('.dat')) continue if (!allowThumbnail && this.isThumbnailDat(lower)) continue const baseLower = lower.slice(0, -4) if (this.normalizeDatBase(baseLower) !== target) continue const score = this.getDatVariantPriority(lower) if (score > bestScore) { bestScore = score bestPath = join(dirPath, entry) } } return bestPath } private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string { const lower = datPath.toLowerCase() if (!lower.endsWith('.dat')) return datPath const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail) return preferred || datPath } private normalizeDatBase(name: string): string { let base = name.toLowerCase() if (base.endsWith('.dat') || base.endsWith('.jpg')) { base = base.slice(0, -4) } for (;;) { const stripped = this.stripDatVariantSuffix(base) if (stripped === base) { return base } base = stripped } } private hasImageVariantSuffix(baseLower: string): boolean { return this.stripDatVariantSuffix(baseLower) !== baseLower } private isLikelyImageDatBase(baseLower: string): boolean { return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower)) } private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null { const allRoots = this.getAllCacheRoots() const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] // 遍历所有可能的缓存根路径 for (const root of allRoots) { // 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg if (sessionId) { const sessionDir = join(root, this.sanitizeDirName(sessionId)) if (existsSync(sessionDir)) { try { const dateDirs = readdirSync(sessionDir, { withFileTypes: true }) .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)) .map(d => d.name) .sort() .reverse() // 最新的日期优先 for (const dateDir of dateDirs) { const imageDir = join(sessionDir, dateDir) const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd) if (hit) return hit } } catch { } } } // 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId) try { const sessionDirs = readdirSync(root, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name) for (const session of sessionDirs) { const sessionDir = join(root, session) // 检查是否是日期目录结构 try { const subDirs = readdirSync(sessionDir, { withFileTypes: true }) .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)) .map(d => d.name) for (const dateDir of subDirs) { const imageDir = join(sessionDir, dateDir) const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd) if (hit) return hit } } catch { } } } catch { } // 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg const oldImageDir = join(root, normalizedKey) if (existsSync(oldImageDir)) { const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd) if (hit) return hit } // 策略4: 最旧的平铺结构 Images/{file}.jpg for (const ext of extensions) { const candidate = join(root, `${cacheKey}${ext}`) if (existsSync(candidate)) return candidate } for (const ext of extensions) { const candidate = join(root, `${cacheKey}_t${ext}`) if (existsSync(candidate)) return candidate } } return null } private findCachedOutputInDir( dirPath: string, normalizedKey: string, extensions: string[], preferHd: boolean ): string | null { // 先检查并删除旧的 .hevc 文件(ffmpeg 转换失败时遗留的) const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`) const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`) try { if (existsSync(hevcThumb)) { require('fs').unlinkSync(hevcThumb) } if (existsSync(hevcHd)) { require('fs').unlinkSync(hevcHd) } } catch { } for (const ext of extensions) { if (preferHd) { const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) if (existsSync(hdPath)) return hdPath } const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`) if (existsSync(thumbPath)) return thumbPath // 允许返回 _hd 格式(因为它有 _hd 变体后缀) if (!preferHd) { const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) if (existsSync(hdPath)) return hdPath } } return null } private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string { const name = basename(datPath) const lower = name.toLowerCase() const base = lower.endsWith('.dat') ? name.slice(0, -4) : name // 提取基础名称(去掉 _t, _h 等后缀) const normalizedBase = this.normalizeDatBase(base) // 判断是缩略图还是高清图 const isThumb = this.isThumbnailDat(lower) const suffix = isThumb ? '_thumb' : '_hd' const contactDir = this.sanitizeDirName(sessionId || 'unknown') const timeDir = this.resolveTimeDir(datPath) const outputDir = join(this.getCacheRoot(), contactDir, timeDir) if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }) } return join(outputDir, `${normalizedBase}${suffix}${ext}`) } private cacheResolvedPaths(cacheKey: string, imageMd5: string | undefined, imageDatName: string | undefined, outputPath: string): void { this.resolvedCache.set(cacheKey, outputPath) if (imageMd5 && imageMd5 !== cacheKey) { this.resolvedCache.set(imageMd5, outputPath) } if (imageDatName && imageDatName !== cacheKey && imageDatName !== imageMd5) { this.resolvedCache.set(imageDatName, outputPath) } } private getCacheKeys(payload: { imageMd5?: string; imageDatName?: string }): string[] { const keys: string[] = [] const addKey = (value?: string) => { if (!value) return const lower = value.toLowerCase() if (!keys.includes(value)) keys.push(value) if (!keys.includes(lower)) keys.push(lower) const normalized = this.normalizeDatBase(lower) if (normalized && !keys.includes(normalized)) keys.push(normalized) } addKey(payload.imageMd5) if (payload.imageDatName && payload.imageDatName !== payload.imageMd5) { addKey(payload.imageDatName) } return keys } private cacheDatPath(accountDir: string, datName: string, datPath: string): void { const key = `${accountDir}|${datName}` this.resolvedCache.set(key, datPath) const normalized = this.normalizeDatBase(datName) if (normalized && normalized !== datName.toLowerCase()) { this.resolvedCache.set(`${accountDir}|${normalized}`, datPath) } } private clearUpdateFlags(cacheKey: string, imageMd5?: string, imageDatName?: string): void { this.updateFlags.delete(cacheKey) if (imageMd5) this.updateFlags.delete(imageMd5) if (imageDatName) this.updateFlags.delete(imageDatName) } private getCachedDatDir(accountDir: string, imageDatName?: string, imageMd5?: string): string | null { const keys = [ imageDatName ? `${accountDir}|${imageDatName}` : null, imageDatName ? `${accountDir}|${this.normalizeDatBase(imageDatName)}` : null, imageMd5 ? `${accountDir}|${imageMd5}` : null ].filter(Boolean) as string[] for (const key of keys) { const cached = this.resolvedCache.get(key) if (cached && existsSync(cached)) return dirname(cached) } return null } private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null { return this.findPreferredDatVariantInDir(dirPath, baseName, false) } private isNonThumbnailVariantDat(datPath: string): boolean { const lower = basename(datPath).toLowerCase() if (!lower.endsWith('.dat')) return false if (this.isThumbnailDat(lower)) return false const baseLower = lower.slice(0, -4) return this.isLikelyImageDatBase(baseLower) } private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void { const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName } for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) { win.webContents.send('image:updateAvailable', message) } } } private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, localPath: string): void { const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath } for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) { win.webContents.send('image:cacheResolved', message) } } } private async ensureCacheIndexed(): Promise { if (this.cacheIndexed) return if (this.cacheIndexing) return this.cacheIndexing 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 结构 } catch (e) { this.logError('索引目录失败', e, { root }) } } this.logInfo('缓存索引完成', { entries: this.resolvedCache.size }) this.cacheIndexed = true this.cacheIndexing = null })() return this.cacheIndexing } /** * 获取所有可能的缓存根路径(用于查找已缓存的图片) * 包含当前路径、配置路径、旧版本路径 */ private getAllCacheRoots(): string[] { const roots: string[] = [] const configured = this.configService.get('cachePath') const documentsPath = app.getPath('documents') // 主要路径(当前使用的) const mainRoot = this.getCacheRoot() roots.push(mainRoot) // 如果配置了自定义路径,也检查其下的 Images if (configured) { roots.push(join(configured, 'Images')) roots.push(join(configured, 'images')) } // 默认路径 roots.push(join(documentsPath, 'WeFlow', 'Images')) roots.push(join(documentsPath, 'WeFlow', 'images')) // 兼容旧路径(如果有的话) roots.push(join(documentsPath, 'WeFlowData', 'Images')) // 去重并过滤存在的路径 const uniqueRoots = Array.from(new Set(roots)) const existingRoots = uniqueRoots.filter(r => existsSync(r)) return existingRoots } private indexCacheDir(root: string, maxDepth: number, depth: number): void { let entries: string[] try { entries = readdirSync(root) } catch { return } const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] for (const entry of entries) { const fullPath = join(root, entry) let stat: ReturnType try { stat = statSync(fullPath) } catch { continue } if (stat.isDirectory()) { if (depth < maxDepth) { this.indexCacheDir(fullPath, maxDepth, depth + 1) } continue } if (!stat.isFile()) continue const lower = entry.toLowerCase() const ext = extensions.find((item) => lower.endsWith(item)) if (!ext) continue const base = entry.slice(0, -ext.length) this.addCacheIndex(base, fullPath) const normalized = this.normalizeDatBase(base) if (normalized && normalized !== base.toLowerCase()) { this.addCacheIndex(normalized, fullPath) } } } private addCacheIndex(key: string, path: string): void { const normalizedKey = key.toLowerCase() const existing = this.resolvedCache.get(normalizedKey) if (existing) { const existingIsThumb = this.isThumbnailPath(existing) const candidateIsThumb = this.isThumbnailPath(path) if (!existingIsThumb && candidateIsThumb) return } this.resolvedCache.set(normalizedKey, path) } private getCacheRoot(): string { const configured = this.configService.get('cachePath') const root = configured ? join(configured, 'Images') : join(app.getPath('documents'), 'WeFlow', 'Images') if (!existsSync(root)) { mkdirSync(root, { recursive: true }) } return root } private resolveAesKey(aesKeyRaw: string): Buffer | null { const trimmed = aesKeyRaw?.trim() ?? '' if (!trimmed) return null return this.asciiKey16(trimmed) } private async decryptDatAuto(datPath: string, xorKey: number, aesKey: Buffer | null): Promise { const version = this.getDatVersion(datPath) if (version === 0) { return this.decryptDatV3(datPath, xorKey) } if (version === 1) { const key = this.asciiKey16(this.defaultV1AesKey) return this.decryptDatV4(datPath, xorKey, key) } // version === 2 if (!aesKey || aesKey.length !== 16) { throw new Error('请到设置配置图片解密密钥') } return this.decryptDatV4(datPath, xorKey, aesKey) } private getDatVersion(inputPath: string): number { if (!existsSync(inputPath)) { throw new Error('文件不存在') } const bytes = readFileSync(inputPath) if (bytes.length < 6) { return 0 } const signature = bytes.subarray(0, 6) if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) { return 1 } if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) { return 2 } return 0 } private decryptDatV3(inputPath: string, xorKey: number): Buffer { const data = readFileSync(inputPath) const out = Buffer.alloc(data.length) for (let i = 0; i < data.length; i += 1) { out[i] = data[i] ^ xorKey } return out } private decryptDatV4(inputPath: string, xorKey: number, aesKey: Buffer): Buffer { const bytes = readFileSync(inputPath) if (bytes.length < 0x0f) { throw new Error('文件太小,无法解析') } const header = bytes.subarray(0, 0x0f) const data = bytes.subarray(0x0f) const aesSize = this.bytesToInt32(header.subarray(6, 10)) const xorSize = this.bytesToInt32(header.subarray(10, 14)) // AES 数据需要对齐到 16 字节(PKCS7 填充) // 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充 const remainder = ((aesSize % 16) + 16) % 16 const alignedAesSize = aesSize + (16 - remainder) if (alignedAesSize > data.length) { throw new Error('文件格式异常:AES 数据长度超过文件实际长度') } const aesData = data.subarray(0, alignedAesSize) let unpadded: Buffer = Buffer.alloc(0) if (aesData.length > 0) { const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null) decipher.setAutoPadding(false) const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()]) // 使用 PKCS7 填充移除 unpadded = this.strictRemovePadding(decrypted) } const remaining = data.subarray(alignedAesSize) if (xorSize < 0 || xorSize > remaining.length) { throw new Error('文件格式异常:XOR 数据长度不合法') } let rawData = Buffer.alloc(0) let xoredData = Buffer.alloc(0) if (xorSize > 0) { const rawLength = remaining.length - xorSize if (rawLength < 0) { throw new Error('文件格式异常:原始数据长度小于XOR长度') } rawData = remaining.subarray(0, rawLength) const xorData = remaining.subarray(rawLength) xoredData = Buffer.alloc(xorData.length) for (let i = 0; i < xorData.length; i += 1) { xoredData[i] = xorData[i] ^ xorKey } } else { rawData = remaining xoredData = Buffer.alloc(0) } return Buffer.concat([unpadded, rawData, xoredData]) } private bytesToInt32(bytes: Buffer): number { if (bytes.length !== 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 个字符') } return Buffer.from(keyString, 'ascii').subarray(0, 16) } private strictRemovePadding(data: Buffer): Buffer { if (!data.length) { throw new Error('解密结果为空,填充非法') } const paddingLength = data[data.length - 1] if (paddingLength === 0 || paddingLength > 16 || paddingLength > data.length) { throw new Error('PKCS7 填充长度非法') } for (let i = data.length - paddingLength; i < data.length; i += 1) { if (data[i] !== paddingLength) { throw new Error('PKCS7 填充内容非法') } } return data.subarray(0, data.length - paddingLength) } private detectImageExtension(buffer: Buffer): string | null { if (buffer.length < 12) return null if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) return '.gif' if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return '.png' if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg' if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { return '.webp' } return null } private bufferToDataUrl(buffer: Buffer, ext: string): string | null { const mimeType = this.mimeFromExtension(ext) if (!mimeType) return null return `data:${mimeType};base64,${buffer.toString('base64')}` } private fileToDataUrl(filePath: string): string | null { try { const ext = extname(filePath).toLowerCase() const mimeType = this.mimeFromExtension(ext) if (!mimeType) return null const data = readFileSync(filePath) return `data:${mimeType};base64,${data.toString('base64')}` } catch { return null } } private mimeFromExtension(ext: string): string | null { switch (ext.toLowerCase()) { case '.gif': return 'image/gif' case '.png': return 'image/png' case '.jpg': case '.jpeg': return 'image/jpeg' case '.webp': return 'image/webp' default: return null } } private filePathToUrl(filePath: string): string { const url = pathToFileURL(filePath).toString() try { const mtime = statSync(filePath).mtimeMs return `${url}?v=${Math.floor(mtime)}` } catch { return url } } private isImageFile(filePath: string): boolean { const ext = extname(filePath).toLowerCase() return ext === '.gif' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.webp' } private compareBytes(a: Buffer, b: Buffer): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return false } return true } // 保留原有的批量检测 XOR 密钥方法(用于兼容) async batchDetectXorKey(dirPath: string, maxFiles: number = 100): Promise { const keyCount: Map = new Map() let filesChecked = 0 const V1_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]) const V2_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) const IMAGE_SIGNATURES: { [key: string]: Buffer } = { jpg: Buffer.from([0xFF, 0xD8, 0xFF]), png: Buffer.from([0x89, 0x50, 0x4E, 0x47]), gif: Buffer.from([0x47, 0x49, 0x46, 0x38]), bmp: Buffer.from([0x42, 0x4D]), webp: Buffer.from([0x52, 0x49, 0x46, 0x46]) } const detectXorKeyFromV3 = (header: Buffer): number | null => { for (const [, signature] of Object.entries(IMAGE_SIGNATURES)) { const xorKey = header[0] ^ signature[0] let valid = true for (let i = 0; i < signature.length && i < header.length; i++) { if ((header[i] ^ xorKey) !== signature[i]) { valid = false break } } if (valid) return xorKey } return null } const scanDir = (dir: string) => { if (filesChecked >= maxFiles) return try { const entries = readdirSync(dir, { withFileTypes: true }) for (const entry of entries) { if (filesChecked >= maxFiles) return const fullPath = join(dir, entry.name) if (entry.isDirectory()) { scanDir(fullPath) } else if (entry.name.endsWith('.dat')) { try { const header = Buffer.alloc(16) const fd = require('fs').openSync(fullPath, 'r') require('fs').readSync(fd, header, 0, 16, 0) require('fs').closeSync(fd) if (header.subarray(0, 6).equals(V1_SIGNATURE) || header.subarray(0, 6).equals(V2_SIGNATURE)) { continue } const key = detectXorKeyFromV3(header) if (key !== null) { keyCount.set(key, (keyCount.get(key) || 0) + 1) filesChecked++ } } catch { } } } } catch { } } scanDir(dirPath) if (keyCount.size === 0) return null let maxCount = 0 let mostCommonKey: number | null = null keyCount.forEach((count, key) => { if (count > maxCount) { maxCount = count mostCommonKey = key } }) return mostCommonKey } /** * 解包 wxgf 格式 * wxgf 是微信的图片格式,内部使用 HEVC 编码 */ private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> { // 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf") if (buffer.length < 20 || buffer[0] !== 0x77 || buffer[1] !== 0x78 || buffer[2] !== 0x67 || buffer[3] !== 0x66) { return { data: buffer, isWxgf: false } } // 先尝试搜索内嵌的传统图片签名 for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) { if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) { return { data: buffer.subarray(i), isWxgf: false } } if (buffer[i] === 0x89 && buffer[i + 1] === 0x50 && buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47) { return { data: buffer.subarray(i), isWxgf: false } } } // 提取 HEVC NALU 裸流 const hevcData = this.extractHevcNalu(buffer) // 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据 const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4) this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', { naluExtracted: !!(hevcData && hevcData.length >= 100), feedSize: feedData.length }) // 尝试用 ffmpeg 转换 try { const jpgData = await this.convertHevcToJpg(feedData) if (jpgData && jpgData.length > 0) { return { data: jpgData, isWxgf: false } } } catch (e) { this.logError('unwrapWxgf: ffmpeg 转换失败', e) } return { data: feedData, isWxgf: true } } /** * 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦 */ private extractHevcNalu(buffer: Buffer): Buffer | null { const nalUnits: Buffer[] = [] let i = 4 while (i < buffer.length - 4) { if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 && buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) { let nalStart = i let nalEnd = buffer.length for (let j = i + 4; j < buffer.length - 3; j++) { if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) { if (buffer[j + 2] === 0x01 || (buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) { nalEnd = j break } } } const nalUnit = buffer.subarray(nalStart, nalEnd) if (nalUnit.length > 3) { nalUnits.push(nalUnit) } i = nalEnd } else { i++ } } if (nalUnits.length === 0) { for (let j = 4; j < buffer.length - 4; j++) { if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 && buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) { return buffer.subarray(j) } } return null } return Buffer.concat(nalUnits) } /** * 获取 ffmpeg 可执行文件路径 */ private getFfmpegPath(): string { const staticPath = getStaticFfmpegPath() this.logInfo('ffmpeg 路径检测', { staticPath, exists: staticPath ? existsSync(staticPath) : false }) if (staticPath) { return staticPath } // 回退到系统 ffmpeg return 'ffmpeg' } /** * 使用 ffmpeg 将 HEVC 裸流转换为 JPG */ private async convertHevcToJpg(hevcData: Buffer): Promise { const ffmpeg = this.getFfmpegPath() this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length }) const tmpDir = join(app.getPath('temp'), 'weflow_hevc') if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }) const ts = Date.now() const tmpInput = join(tmpDir, `hevc_${ts}.hevc`) const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`) try { await writeFile(tmpInput, hevcData) // 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测 const attempts: { label: string; inputArgs: string[] }[] = [ { label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] }, { label: 'auto detect', inputArgs: ['-i', tmpInput] }, ] for (const attempt of attempts) { // 清理上一轮的输出 try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {} const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label) if (result) return result } return null } catch (e) { this.logError('ffmpeg 转换异常', e) return null } finally { try { if (existsSync(tmpInput)) require('fs').unlinkSync(tmpInput) } catch {} try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {} } } private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise { return new Promise((resolve) => { const { spawn } = require('child_process') const errChunks: Buffer[] = [] const args = [ '-hide_banner', '-loglevel', 'error', ...inputArgs, '-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput ] this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') }) const proc = spawn(ffmpeg, args, { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true }) proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk)) const timer = setTimeout(() => { proc.kill('SIGKILL') this.logError(`ffmpeg [${label}] 超时(15s)`) resolve(null) }, 15000) proc.on('close', (code: number) => { clearTimeout(timer) if (code === 0 && existsSync(tmpOutput)) { try { const jpgBuf = readFileSync(tmpOutput) if (jpgBuf.length > 0) { this.logInfo(`ffmpeg [${label}] 成功`, { outputSize: jpgBuf.length }) resolve(jpgBuf) return } } catch (e) { this.logError(`ffmpeg [${label}] 读取输出失败`, e) } } const errMsg = Buffer.concat(errChunks).toString().trim() this.logInfo(`ffmpeg [${label}] 失败`, { code, error: errMsg }) resolve(null) }) proc.on('error', (err: Error) => { clearTimeout(timer) this.logError(`ffmpeg [${label}] 进程错误`, err) resolve(null) }) }) } 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 this.stripDatVariantSuffix(lower) !== lower } 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) let decrypted: Buffer if (version === 0) { decrypted = this.decryptDatV3(inputPath, xorKey) } else if (version === 1) { const key = this.asciiKey16(this.defaultV1AesKey) decrypted = this.decryptDatV4(inputPath, xorKey, key) } else { if (!aesKey || aesKey.length !== 16) { throw new Error('V4版本需要 16 字节 AES 密钥') } decrypted = this.decryptDatV4(inputPath, xorKey, aesKey) } const outputDir = dirname(outputPath) if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }) } await writeFile(outputPath, decrypted) } async clearCache(): Promise<{ success: boolean; error?: string }> { this.resolvedCache.clear() this.hardlinkCache.clear() this.pending.clear() this.updateFlags.clear() this.cacheIndexed = false this.cacheIndexing = null const configured = this.configService.get('cachePath') const root = configured ? join(configured, 'Images') : join(app.getPath('documents'), 'WeFlow', 'Images') try { if (!existsSync(root)) { return { success: true } } const monthPattern = /^\d{4}-\d{2}$/ const clearFilesInDir = async (dirPath: string): Promise => { let entries: Array<{ name: string; isDirectory: () => boolean }> try { entries = await readdir(dirPath, { withFileTypes: true }) } catch { return } for (const entry of entries) { const fullPath = join(dirPath, entry.name) if (entry.isDirectory()) { await clearFilesInDir(fullPath) continue } try { await rm(fullPath, { force: true }) } catch { } } } const traverse = async (dirPath: string): Promise => { let entries: Array<{ name: string; isDirectory: () => boolean }> try { entries = await readdir(dirPath, { withFileTypes: true }) } catch { return } for (const entry of entries) { const fullPath = join(dirPath, entry.name) if (entry.isDirectory()) { if (monthPattern.test(entry.name)) { await clearFilesInDir(fullPath) } else { await traverse(fullPath) } continue } try { await rm(fullPath, { force: true }) } catch { } } } await traverse(root) return { success: true } } catch (e) { return { success: false, error: String(e) } } } } export const imageDecryptService = new ImageDecryptService()