Files
WeFlow/electron/services/imageDecryptService.ts

1998 lines
71 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { homedir, tmpdir } from 'os'
import crypto from 'crypto'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt'
// 获取 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
failureKind?: 'not_found' | 'decrypt_failed'
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
}
type DecryptProgressStage = 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
type CachedImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}
type DecryptImagePayload = CachedImagePayload & {
force?: boolean
}
export class ImageDecryptService {
private configService = new ConfigService()
private resolvedCache = new Map<string, string>()
private pending = new Map<string, Promise<DecryptResult>>()
private updateFlags = new Map<string, boolean>()
private nativeLogged = false
private datNameScanMissAt = new Map<string, number>()
private readonly datNameScanMissTtlMs = 1200
private readonly accountDirCache = new Map<string, string>()
private cacheRootPath: string | null = null
private readonly ensuredDirs = new Set<string>()
private shouldEmitImageEvents(payload?: { suppressEvents?: boolean }): boolean {
if (payload?.suppressEvents === true) return false
// 导出 worker 场景不需要向渲染层广播逐条图片事件,避免事件风暴拖慢主界面。
if (process.env.WEFLOW_WORKER === '1') return false
return true
}
private shouldCheckImageUpdate(payload?: { disableUpdateCheck?: boolean; suppressEvents?: boolean }): boolean {
if (payload?.disableUpdateCheck === true) return false
return this.shouldEmitImageEvents(payload)
}
private logInfo(message: string, meta?: Record<string, unknown>): 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<string, unknown>): 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(this.getUserDataPath(), 'logs')
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true })
}
appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' })
} catch (err) {
console.error('写入日志失败:', err)
}
}
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
if (!cacheKey) {
return { success: false, error: '缺少图片标识', failureKind: 'not_found' }
}
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const upgraded = !this.isHdPath(cached)
? await this.tryPromoteThumbnailCache(payload, key, cached)
: null
const finalPath = upgraded || cached
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
const isNonHd = !this.isHdPath(finalPath)
const hasUpdate = isNonHd ? (this.updateFlags.get(key) ?? false) : false
if (isNonHd) {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, key, finalPath)
}
} else {
this.updateFlags.delete(key)
}
this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath))
return { success: true, localPath, hasUpdate }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(key)
}
}
const accountDir = this.resolveCurrentAccountDir()
if (accountDir) {
const datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
payload.createTime,
{
allowThumbnail: true,
skipResolvedCache: false,
hardlinkOnly: true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
}
)
if (datPath) {
const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false)
if (existing) {
const upgraded = !this.isHdPath(existing)
? await this.tryPromoteThumbnailCache(payload, cacheKey, existing)
: null
const finalPath = upgraded || existing
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, finalPath)
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
const isNonHd = !this.isHdPath(finalPath)
const hasUpdate = isNonHd ? (this.updateFlags.get(cacheKey) ?? false) : false
if (isNonHd) {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, cacheKey, finalPath)
}
} else {
this.updateFlags.delete(cacheKey)
}
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath))
return { success: true, localPath, hasUpdate }
}
}
}
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到缓存图片', failureKind: 'not_found' }
}
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
if (!cacheKey) {
return { success: false, error: '缺少图片标识', failureKind: 'not_found' }
}
this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running')
if (payload.force) {
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached) && this.isHdPath(cached)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(key)
}
}
}
if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const upgraded = !this.isHdPath(cached)
? await this.tryPromoteThumbnailCache(payload, cacheKey, cached)
: null
const finalPath = upgraded || cached
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(cacheKey)
}
}
const pending = this.pending.get(cacheKey)
if (pending) {
this.emitDecryptProgress(payload, cacheKey, 'queued', 8, 'running')
return pending
}
const task = this.decryptImageInternal(payload, cacheKey)
this.pending.set(cacheKey, task)
try {
return await task
} finally {
this.pending.delete(cacheKey)
}
}
async preloadImageHardlinkMd5s(md5List: string[]): Promise<void> {
const normalizedList = Array.from(
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
)
if (normalizedList.length === 0) return
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) return
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) return
try {
for (const md5 of normalizedList) {
if (!this.looksLikeMd5(md5)) continue
const selectedPath = this.selectBestDatPathByBase(accountDir, md5, undefined, undefined, true)
if (!selectedPath) continue
this.cacheDatPath(accountDir, md5, selectedPath)
const fileName = basename(selectedPath).toLowerCase()
if (fileName) this.cacheDatPath(accountDir, fileName, selectedPath)
}
} catch {
// ignore preload failures
}
}
private async decryptImageInternal(
payload: DecryptImagePayload,
cacheKey: string
): Promise<DecryptResult> {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
try {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) {
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
return { success: false, error: '未配置账号或数据库路径', failureKind: 'not_found' }
}
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) {
this.logError('未找到账号目录', undefined, { dbPath, wxid })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失')
return { success: false, error: '未找到账号目录', failureKind: 'not_found' }
}
let datPath: string | null = null
let usedHdAttempt = false
let fallbackToThumbnail = false
// force=true 时先尝试高清;若高清缺失则回退到缩略图,避免直接失败。
if (payload.force) {
usedHdAttempt = true
datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
payload.createTime,
{
allowThumbnail: false,
skipResolvedCache: false,
hardlinkOnly: payload.hardlinkOnly === true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
}
)
if (!datPath) {
datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
payload.createTime,
{
allowThumbnail: true,
skipResolvedCache: false,
hardlinkOnly: payload.hardlinkOnly === true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
}
)
fallbackToThumbnail = Boolean(datPath)
if (fallbackToThumbnail) {
this.logInfo('高清缺失,回退解密缩略图', {
md5: payload.imageMd5,
datName: payload.imageDatName
})
}
}
} else {
datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
payload.createTime,
{
allowThumbnail: true,
skipResolvedCache: false,
hardlinkOnly: payload.hardlinkOnly === true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
}
)
}
if (!datPath) {
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '未找到DAT文件')
if (usedHdAttempt) {
return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试', failureKind: 'not_found' }
}
return { success: false, error: '未找到图片文件', failureKind: 'not_found' }
}
this.logInfo('找到DAT文件', { datPath })
this.emitDecryptProgress(payload, cacheKey, 'locating', 34, 'running')
if (!extname(datPath).toLowerCase().includes('dat')) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
const isThumb = this.isThumbnailPath(datPath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath, isThumb }
}
const preferHdCache = Boolean(payload.force && !fallbackToThumbnail)
const existingFast = this.findCachedOutputByDatPath(datPath, payload.sessionId, preferHdCache)
if (existingFast) {
this.logInfo('找到已解密文件(按DAT快速命中)', { existing: existingFast, isHd: this.isHdPath(existingFast) })
const isHd = this.isHdPath(existingFast)
if (!(payload.force && !isHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingFast)
const localPath = this.resolveLocalPathForPayload(existingFast, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existingFast)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingFast, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
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)) {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥')
return { success: false, error: '未配置图片解密密钥', failureKind: 'not_found' }
}
const aesKeyRaw = imageKeys.aesKey
const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : ''
const aesKeyForNative = aesKeyText || undefined
this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
if (!nativeResult) {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'Rust原生解密不可用')
return { success: false, error: 'Rust原生解密不可用或解密失败请检查 native 模块与密钥配置', failureKind: 'not_found' }
}
let decrypted: Buffer = nativeResult.data
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running')
// 统一走原有 wxgf/ffmpeg 流程,确保行为与历史版本一致
const wxgfResult = await this.unwrapWxgf(decrypted)
decrypted = wxgfResult.data
const detectedExt = this.detectImageExtension(decrypted)
// 如果解密产物无法识别为图片,归类为“解密失败”。
if (!detectedExt) {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '解密后不是有效图片')
return {
success: false,
error: '解密后不是有效图片',
failureKind: 'decrypt_failed',
isThumb: this.isThumbnailPath(datPath)
}
}
const finalExt = detectedExt
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running')
await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length })
const isThumb = this.isThumbnailPath(datPath)
const isHdCache = this.isHdPath(outputPath)
this.removeDuplicateCacheCandidates(datPath, payload.sessionId, outputPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (isHdCache) {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
} else {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, cacheKey, outputPath)
}
}
const localPath = payload.preferFilePath
? outputPath
: (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath))
const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, emitPath)
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath, isThumb }
} catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', String(e))
return { success: false, error: String(e), failureKind: 'not_found' }
}
}
private resolveAccountDir(dbPath: string, wxid: string): string | null {
const cleanedWxid = this.cleanAccountDirName(wxid)
const normalized = dbPath.replace(/[\\/]+$/, '')
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
const cached = this.accountDirCache.get(cacheKey)
if (cached && existsSync(cached)) return cached
if (cached && !existsSync(cached)) {
this.accountDirCache.delete(cacheKey)
}
const direct = join(normalized, cleanedWxid)
if (existsSync(direct)) {
this.accountDirCache.set(cacheKey, direct)
return direct
}
if (this.isAccountDir(normalized)) {
this.accountDirCache.set(cacheKey, 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)) {
this.accountDirCache.set(cacheKey, entryPath)
return entryPath
}
}
}
} catch { }
return null
}
private resolveCurrentAccountDir(): string | null {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) return null
return this.resolveAccountDir(dbPath, wxid)
}
/**
* 获取解密后的缓存目录(用于查找 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,
createTime?: number,
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean; allowDatNameScanFallback?: boolean }
): Promise<string | null> {
const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false
const hardlinkOnly = options?.hardlinkOnly ?? false
const allowDatNameScanFallback = options?.allowDatNameScanFallback ?? true
this.logInfo('[ImageDecrypt] resolveDatPath', {
imageMd5,
imageDatName,
createTime,
allowThumbnail,
skipResolvedCache,
hardlinkOnly,
allowDatNameScanFallback
})
const lookupBases = this.collectLookupBasesForScan(imageMd5, imageDatName, allowDatNameScanFallback)
if (lookupBases.length === 0) {
this.logInfo('[ImageDecrypt] resolveDatPath miss (no lookup base)', { imageMd5, imageDatName })
return null
}
if (!skipResolvedCache) {
const cacheCandidates = Array.from(new Set([
...lookupBases,
String(imageMd5 || '').trim().toLowerCase(),
String(imageDatName || '').trim().toLowerCase()
].filter(Boolean)))
for (const cacheKey of cacheCandidates) {
const scopedKey = `${accountDir}|${cacheKey}`
const cached = this.resolvedCache.get(scopedKey)
if (!cached || !existsSync(cached)) continue
if (!allowThumbnail && !this.isHdDatPath(cached)) continue
return cached
}
}
for (const baseMd5 of lookupBases) {
const selectedPath = this.selectBestDatPathByBase(accountDir, baseMd5, sessionId, createTime, allowThumbnail)
if (!selectedPath) continue
this.cacheDatPath(accountDir, baseMd5, selectedPath)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, selectedPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, selectedPath)
const normalizedFileName = basename(selectedPath).toLowerCase()
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, selectedPath)
this.logInfo('[ImageDecrypt] dat scan selected', {
baseMd5,
selectedPath,
allowThumbnail
})
return selectedPath
}
this.logInfo('[ImageDecrypt] resolveDatPath miss (dat scan)', {
imageMd5,
imageDatName,
lookupBases,
allowThumbnail
})
return null
}
private async checkHasUpdate(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number },
_cacheKey: string,
cachedPath: string
): Promise<boolean> {
if (!cachedPath || !existsSync(cachedPath)) return false
if (this.isHdPath(cachedPath)) 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 lookupBases = this.collectLookupBasesForScan(payload.imageMd5, payload.imageDatName, true)
if (lookupBases.length === 0) return false
let currentTier = this.getCachedPathTier(cachedPath)
let bestDatPath: string | null = null
let bestDatTier = -1
for (const baseMd5 of lookupBases) {
const candidate = this.selectBestDatPathByBase(accountDir, baseMd5, payload.sessionId, payload.createTime, true)
if (!candidate) continue
const candidateTier = this.getDatTier(candidate, baseMd5)
if (candidateTier <= 0) continue
if (!bestDatPath) {
bestDatPath = candidate
bestDatTier = candidateTier
continue
}
if (candidateTier > bestDatTier) {
bestDatPath = candidate
bestDatTier = candidateTier
continue
}
if (candidateTier === bestDatTier) {
const candidateSize = this.fileSizeSafe(candidate)
const bestSize = this.fileSizeSafe(bestDatPath)
if (candidateSize > bestSize) {
bestDatPath = candidate
bestDatTier = candidateTier
}
}
}
if (!bestDatPath || bestDatTier <= 0) return false
if (currentTier < 0) currentTier = 1
return bestDatTier > currentTier
}
private async tryPromoteThumbnailCache(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean },
cacheKey: string,
cachedPath: string
): Promise<string | null> {
if (!cachedPath || !existsSync(cachedPath)) return null
if (!this.isImageFile(cachedPath)) return null
if (this.isHdPath(cachedPath)) return null
const accountDir = this.resolveCurrentAccountDir()
if (!accountDir) return null
const hdDatPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
payload.createTime,
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false }
)
if (!hdDatPath) return null
const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true)
if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && this.isHdPath(existingHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
this.removeThumbnailCacheFile(cachedPath, existingHd)
this.logInfo('[ImageDecrypt] thumbnail cache upgraded', {
cacheKey,
oldPath: cachedPath,
newPath: existingHd,
mode: 'existing'
})
return existingHd
}
const upgraded = await this.decryptImage({
sessionId: payload.sessionId,
imageMd5: payload.imageMd5,
imageDatName: payload.imageDatName,
createTime: payload.createTime,
preferFilePath: true,
force: true,
hardlinkOnly: true,
disableUpdateCheck: true
})
if (!upgraded.success) return null
const cachedResult = this.resolvedCache.get(cacheKey)
const upgradedPath = (cachedResult && existsSync(cachedResult))
? cachedResult
: String(upgraded.localPath || '').trim()
if (!upgradedPath || !existsSync(upgradedPath)) return null
if (!this.isImageFile(upgradedPath) || !this.isHdPath(upgradedPath)) return null
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, upgradedPath)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
this.removeThumbnailCacheFile(cachedPath, upgradedPath)
this.logInfo('[ImageDecrypt] thumbnail cache upgraded', {
cacheKey,
oldPath: cachedPath,
newPath: upgradedPath,
mode: 're-decrypt'
})
return upgradedPath
}
private removeThumbnailCacheFile(oldPath: string, keepPath?: string): void {
if (!oldPath) return
if (keepPath && oldPath === keepPath) return
if (!existsSync(oldPath)) return
if (this.isHdPath(oldPath)) return
void rm(oldPath, { force: true }).catch(() => { })
}
private triggerUpdateCheck(
payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
disableUpdateCheck?: boolean
suppressEvents?: boolean
},
cacheKey: string,
cachedPath: string
): void {
if (!this.shouldCheckImageUpdate(payload)) return
if (this.updateFlags.get(cacheKey)) return
void this.checkHasUpdate(payload, cacheKey, cachedPath).then(async (hasUpdate) => {
if (!hasUpdate) return
this.updateFlags.set(cacheKey, true)
const upgradedPath = await this.tryAutoRefreshBetterCache(payload, cacheKey, cachedPath)
if (upgradedPath) {
this.updateFlags.delete(cacheKey)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(upgradedPath, payload.preferFilePath))
return
}
this.emitImageUpdate(payload, cacheKey)
}).catch(() => { })
}
private async tryAutoRefreshBetterCache(
payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
disableUpdateCheck?: boolean
suppressEvents?: boolean
},
cacheKey: string,
cachedPath: string
): Promise<string | null> {
if (!cachedPath || !existsSync(cachedPath)) return null
if (this.isHdPath(cachedPath)) return null
const refreshed = await this.decryptImage({
sessionId: payload.sessionId,
imageMd5: payload.imageMd5,
imageDatName: payload.imageDatName,
createTime: payload.createTime,
preferFilePath: true,
force: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: true
})
if (!refreshed.success || !refreshed.localPath) return null
const refreshedPath = String(refreshed.localPath || '').trim()
if (!refreshedPath || !existsSync(refreshedPath)) return null
if (!this.isImageFile(refreshedPath)) return null
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, refreshedPath)
this.removeThumbnailCacheFile(cachedPath, refreshedPath)
return refreshedPath
}
private collectHardlinkLookupMd5s(imageMd5?: string, imageDatName?: string): string[] {
const keys: string[] = []
const pushMd5 = (value?: string) => {
const normalized = String(value || '').trim().toLowerCase()
if (!normalized) return
if (!this.looksLikeMd5(normalized)) return
if (!keys.includes(normalized)) keys.push(normalized)
}
pushMd5(imageMd5)
const datNameRaw = String(imageDatName || '').trim().toLowerCase()
if (!datNameRaw) return keys
pushMd5(datNameRaw)
const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw
pushMd5(datNameNoExt)
pushMd5(this.normalizeDatBase(datNameNoExt))
return keys
}
private collectLookupBasesForScan(imageMd5?: string, imageDatName?: string, allowDatNameScanFallback = true): string[] {
const bases = this.collectHardlinkLookupMd5s(imageMd5, imageDatName)
if (!allowDatNameScanFallback) return bases
const fallbackRaw = String(imageDatName || imageMd5 || '').trim().toLowerCase()
if (!fallbackRaw) return bases
const fallbackNoExt = fallbackRaw.endsWith('.dat') ? fallbackRaw.slice(0, -4) : fallbackRaw
const fallbackBase = this.normalizeDatBase(fallbackNoExt)
if (this.looksLikeMd5(fallbackBase) && !bases.includes(fallbackBase)) {
bases.push(fallbackBase)
}
return bases
}
private collectAllDatCandidatesForBase(
accountDir: string,
baseMd5: string,
sessionId?: string,
createTime?: number
): string[] {
const sessionMonth = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime)
return Array.from(new Set(sessionMonth.filter((item) => {
const path = String(item || '').trim()
return path && existsSync(path) && path.toLowerCase().endsWith('.dat')
})))
}
private isImgScopedDatPath(filePath: string): boolean {
const lower = String(filePath || '').toLowerCase()
return /[\\/](img|image|msgimg)[\\/]/.test(lower)
}
private fileSizeSafe(filePath: string): number {
try {
return statSync(filePath).size || 0
} catch {
return 0
}
}
private fileMtimeSafe(filePath: string): number {
try {
return statSync(filePath).mtimeMs || 0
} catch {
return 0
}
}
private pickLargestDatPath(paths: string[]): string | null {
const list = Array.from(new Set(paths.filter(Boolean)))
if (list.length === 0) return null
list.sort((a, b) => {
const sizeDiff = this.fileSizeSafe(b) - this.fileSizeSafe(a)
if (sizeDiff !== 0) return sizeDiff
const mtimeDiff = this.fileMtimeSafe(b) - this.fileMtimeSafe(a)
if (mtimeDiff !== 0) return mtimeDiff
return a.localeCompare(b)
})
return list[0] || null
}
private selectBestDatPathByBase(
accountDir: string,
baseMd5: string,
sessionId?: string,
createTime?: number,
allowThumbnail = true
): string | null {
const candidates = this.collectAllDatCandidatesForBase(accountDir, baseMd5, sessionId, createTime)
if (candidates.length === 0) return null
const imgCandidates = candidates.filter((item) => this.isImgScopedDatPath(item))
const imgHdCandidates = imgCandidates.filter((item) => this.isHdDatPath(item))
const hdInImg = this.pickLargestDatPath(imgHdCandidates)
if (hdInImg) return hdInImg
if (!allowThumbnail) {
// 高清优先仅认 img/image/msgimg 路径中的 H 变体;
// 若该范围没有,则交由 allowThumbnail=true 的回退分支按 base.dat/_t 继续挑选。
return null
}
// 无 H 时,优先尝试原始无后缀 DAT{md5}.dat
const baseDatInImg = this.pickLargestDatPath(
imgCandidates.filter((item) => this.isBaseDatPath(item, baseMd5))
)
if (baseDatInImg) return baseDatInImg
const baseDatAny = this.pickLargestDatPath(
candidates.filter((item) => this.isBaseDatPath(item, baseMd5))
)
if (baseDatAny) return baseDatAny
const thumbDatInImg = this.pickLargestDatPath(
imgCandidates.filter((item) => this.isTVariantDat(item))
)
if (thumbDatInImg) return thumbDatInImg
const thumbDatAny = this.pickLargestDatPath(
candidates.filter((item) => this.isTVariantDat(item))
)
if (thumbDatAny) return thumbDatAny
return null
}
private resolveDatPathFromParsedDatName(
accountDir: string,
imageDatName?: string,
sessionId?: string,
createTime?: number,
allowThumbnail = true
): string | null {
const datNameRaw = String(imageDatName || '').trim().toLowerCase()
if (!datNameRaw) return null
const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw
const baseMd5 = this.normalizeDatBase(datNameNoExt)
if (!this.looksLikeMd5(baseMd5)) return null
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
const missKey = `${accountDir}|scan|${String(sessionId || '').trim()}|${monthKey}|${baseMd5}|${allowThumbnail ? 'all' : 'hd'}`
const lastMiss = this.datNameScanMissAt.get(missKey) || 0
if (lastMiss && (Date.now() - lastMiss) < this.datNameScanMissTtlMs) {
return null
}
const sessionMonthCandidates = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime)
if (sessionMonthCandidates.length > 0) {
const orderedSessionMonth = this.sortDatCandidatePaths(sessionMonthCandidates, baseMd5)
for (const candidatePath of orderedSessionMonth) {
if (!allowThumbnail && !this.isHdDatPath(candidatePath)) continue
this.datNameScanMissAt.delete(missKey)
this.logInfo('[ImageDecrypt] datName fallback selected (session-month)', {
accountDir,
sessionId,
imageDatName: datNameRaw,
createTime,
monthKey,
baseMd5,
allowThumbnail,
selectedPath: candidatePath
})
return candidatePath
}
}
// 新策略:只扫描会话月目录,不做 account-wide 根目录回退。
this.datNameScanMissAt.set(missKey, Date.now())
this.logInfo('[ImageDecrypt] datName fallback precise scan miss', {
accountDir,
sessionId,
imageDatName: datNameRaw,
createTime,
monthKey,
baseMd5,
allowThumbnail
})
return null
}
private resolveYearMonthFromCreateTime(createTime?: number): string {
const raw = Number(createTime)
if (!Number.isFinite(raw) || raw <= 0) return ''
const ts = raw > 1e12 ? raw : raw * 1000
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
private collectDatCandidatesFromSessionMonth(
accountDir: string,
baseMd5: string,
sessionId?: string,
createTime?: number
): string[] {
const normalizedSessionId = String(sessionId || '').trim()
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
if (!normalizedSessionId || !monthKey) return []
const sessionDir = this.resolveSessionDirForStorage(normalizedSessionId)
if (!sessionDir) return []
const candidates = new Set<string>()
const budget = { remaining: 240 }
const targetDirs: Array<{ dir: string; depth: number }> = [
// 1) accountDir/msg/attach/{sessionMd5}/{yyyy-MM}/Img
{ dir: join(accountDir, 'msg', 'attach', sessionDir, monthKey, 'Img'), depth: 1 }
]
for (const target of targetDirs) {
if (budget.remaining <= 0) break
this.scanDatCandidatesUnderRoot(target.dir, baseMd5, target.depth, candidates, budget)
}
return Array.from(candidates)
}
private resolveSessionDirForStorage(sessionId: string): string {
const normalized = String(sessionId || '').trim().toLowerCase()
if (!normalized) return ''
if (this.looksLikeMd5(normalized)) return normalized
const cleaned = this.cleanAccountDirName(normalized).toLowerCase()
if (this.looksLikeMd5(cleaned)) return cleaned
return crypto.createHash('md5').update(cleaned || normalized).digest('hex').toLowerCase()
}
private scanDatCandidatesUnderRoot(
rootDir: string,
baseMd5: string,
maxDepth: number,
out: Set<string>,
budget: { remaining: number }
): void {
if (!rootDir || maxDepth < 0 || budget.remaining <= 0) return
if (!existsSync(rootDir) || !this.isDirectory(rootDir)) return
const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }]
while (stack.length > 0 && budget.remaining > 0) {
const current = stack.pop()
if (!current) break
budget.remaining -= 1
let entries: Array<{ name: string; isFile: () => boolean; isDirectory: () => boolean }>
try {
entries = readdirSync(current.dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (!entry.isFile()) continue
const name = String(entry.name || '')
if (!this.isHardlinkCandidateName(name, baseMd5)) continue
const fullPath = join(current.dir, name)
if (existsSync(fullPath)) out.add(fullPath)
}
if (current.depth >= maxDepth) continue
for (const entry of entries) {
if (!entry.isDirectory()) continue
const name = String(entry.name || '')
if (!name || name === '.' || name === '..') continue
if (name.startsWith('.')) continue
stack.push({ dir: join(current.dir, name), depth: current.depth + 1 })
}
}
}
private sortDatCandidatePaths(paths: string[], baseMd5: string): string[] {
const list = Array.from(new Set(paths.filter(Boolean)))
list.sort((a, b) => {
const nameA = basename(a).toLowerCase()
const nameB = basename(b).toLowerCase()
const priorityA = this.getHardlinkCandidatePriority(nameA, baseMd5)
const priorityB = this.getHardlinkCandidatePriority(nameB, baseMd5)
if (priorityA !== priorityB) return priorityA - priorityB
let sizeA = 0
let sizeB = 0
try {
sizeA = statSync(a).size
} catch { }
try {
sizeB = statSync(b).size
} catch { }
if (sizeA !== sizeB) return sizeB - sizeA
let mtimeA = 0
let mtimeB = 0
try {
mtimeA = statSync(a).mtimeMs
} catch { }
try {
mtimeB = statSync(b).mtimeMs
} catch { }
if (mtimeA !== mtimeB) return mtimeB - mtimeA
return nameA.localeCompare(nameB)
})
return list
}
private isHardlinkCandidateName(fileName: string, baseMd5: string): boolean {
const lower = String(fileName || '').trim().toLowerCase()
if (!lower.endsWith('.dat')) return false
const base = lower.slice(0, -4)
if (base === baseMd5) return true
if (base.startsWith(`${baseMd5}_`) || base.startsWith(`${baseMd5}.`)) return true
if (base.length === baseMd5.length + 1 && base.startsWith(baseMd5)) return true
return this.normalizeDatBase(base) === baseMd5
}
private getHardlinkCandidatePriority(fileName: string, _baseMd5: string): number {
const lower = String(fileName || '').trim().toLowerCase()
if (!lower.endsWith('.dat')) return 999
const base = lower.slice(0, -4)
if (
base.endsWith('_h') ||
base.endsWith('.h') ||
base.endsWith('_hd') ||
base.endsWith('.hd')
) {
return 0
}
if (base.endsWith('_b') || base.endsWith('.b')) return 1
if (this.isThumbnailDat(lower)) return 3
return 2
}
private normalizeHardlinkDatPathByFileName(fullPath: string, fileName: string): string {
const normalizedPath = String(fullPath || '').trim()
const normalizedFileName = String(fileName || '').trim().toLowerCase()
if (!normalizedPath || !normalizedFileName) return normalizedPath
if (!normalizedFileName.endsWith('.dat')) return normalizedPath
const normalizedBase = this.normalizeDatBase(normalizedFileName.slice(0, -4))
if (!this.looksLikeMd5(normalizedBase)) return ''
// 最新策略:只要 hardlink 有记录,始终直接使用其记录路径(包括无后缀 DAT
return normalizedPath
}
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
try {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
if (!this.looksLikeMd5(normalizedMd5)) return null
const ready = await this.ensureWcdbReady()
if (!ready) {
this.logInfo('[ImageDecrypt] hardlink db not ready')
return null
}
const resolveResult = await wcdbService.resolveImageHardlink(normalizedMd5, accountDir)
if (!resolveResult.success || !resolveResult.data) return null
const fileName = String(resolveResult.data.file_name || '').trim()
const fullPath = String(resolveResult.data.full_path || '').trim()
if (!fileName || !fullPath) return null
const lowerFileName = String(fileName).toLowerCase()
if (lowerFileName.endsWith('.dat')) {
const normalizedBase = this.normalizeDatBase(lowerFileName.slice(0, -4))
if (!this.looksLikeMd5(normalizedBase)) {
this.logInfo('[ImageDecrypt] hardlink fileName rejected', { fileName })
return null
}
}
const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName)
if (existsSync(selectedPath)) {
this.logInfo('[ImageDecrypt] hardlink path hit', { md5: normalizedMd5, fileName, fullPath, selectedPath })
return selectedPath
}
this.logInfo('[ImageDecrypt] hardlink path miss', { md5: normalizedMd5, fileName, fullPath, selectedPath })
return null
} catch {
// ignore
}
return null
}
private async ensureWcdbReady(): Promise<boolean> {
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 stripDatVariantSuffix(base: string): string {
const lower = base.toLowerCase()
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_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 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 getCacheVariantSuffixFromDat(datPath: string): string {
if (this.isHdDatPath(datPath)) return '_hd'
const name = basename(datPath)
const lower = name.toLowerCase()
const stem = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
const base = this.normalizeDatBase(stem)
const rawSuffix = stem.slice(base.length)
if (!rawSuffix) return ''
const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '')
if (!safe) return ''
if (safe.startsWith('_') || safe.startsWith('.')) return safe
return `_${safe}`
}
private getCacheVariantSuffixFromCachedPath(cachePath: string): string {
const raw = String(cachePath || '').split('?')[0]
const name = basename(raw)
const ext = extname(name).toLowerCase()
const stem = (ext ? name.slice(0, -ext.length) : name).toLowerCase()
const base = this.normalizeDatBase(stem)
const rawSuffix = stem.slice(base.length)
if (!rawSuffix) return ''
const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '')
if (!safe) return ''
if (safe.startsWith('_') || safe.startsWith('.')) return safe
return `_${safe}`
}
private buildCacheSuffixSearchOrder(primarySuffix: string, preferHd: boolean): string[] {
const fallbackSuffixes = [
'_hd',
'_thumb',
'_t',
'.t',
'_b',
'.b',
'_w',
'.w',
'_c',
'.c',
''
]
const ordered = preferHd
? ['_hd', primarySuffix, ...fallbackSuffixes]
: [primarySuffix, '_hd', ...fallbackSuffixes]
return Array.from(new Set(ordered.map((item) => String(item || '').trim()).filter((item) => item.length >= 0)))
}
private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string {
const name = basename(datPath)
const lower = name.toLowerCase()
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
const normalizedBase = this.normalizeDatBase(base)
const suffix = this.getCacheVariantSuffixFromDat(datPath)
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
const timeDir = this.resolveTimeDir(datPath)
const outputDir = join(this.getCacheRoot(), contactDir, timeDir)
this.ensureDir(outputDir)
return join(outputDir, `${normalizedBase}${suffix}${ext}`)
}
private buildCacheOutputCandidatesFromDat(datPath: string, sessionId?: string, preferHd = false): string[] {
const name = basename(datPath)
const lower = name.toLowerCase()
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
const normalizedBase = this.normalizeDatBase(base)
const primarySuffix = this.getCacheVariantSuffixFromDat(datPath)
const suffixes = this.buildCacheSuffixSearchOrder(primarySuffix, preferHd)
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
const root = this.getCacheRoot()
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
const timeDir = this.resolveTimeDir(datPath)
const currentDir = join(root, contactDir, timeDir)
const legacyDir = join(root, normalizedBase)
const candidates: string[] = []
for (const suffix of suffixes) {
for (const ext of extensions) {
candidates.push(join(currentDir, `${normalizedBase}${suffix}${ext}`))
}
}
// 兼容旧目录结构
for (const suffix of suffixes) {
for (const ext of extensions) {
candidates.push(join(legacyDir, `${normalizedBase}${suffix}${ext}`))
}
}
// 兼容最旧平铺结构
for (const ext of extensions) {
candidates.push(join(root, `${normalizedBase}${ext}`))
candidates.push(join(root, `${normalizedBase}_t${ext}`))
candidates.push(join(root, `${normalizedBase}_hd${ext}`))
}
return candidates
}
private removeDuplicateCacheCandidates(datPath: string, sessionId: string | undefined, keepPath: string): void {
const candidateSets = [
...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, false),
...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, true)
]
const candidates = Array.from(new Set(candidateSets))
for (const candidate of candidates) {
if (!candidate || candidate === keepPath) continue
if (!existsSync(candidate)) continue
if (!this.isImageFile(candidate)) continue
void rm(candidate, { force: true }).catch(() => { })
}
}
private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null {
const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd)
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
return null
}
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 getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> {
try {
const getter = (BrowserWindow as unknown as { getAllWindows?: () => any[] } | undefined)?.getAllWindows
if (typeof getter !== 'function') return []
const windows = getter()
if (!Array.isArray(windows)) return []
return windows.filter((win) => (
win &&
typeof win.isDestroyed === 'function' &&
win.webContents &&
typeof win.webContents.send === 'function'
))
} catch {
return []
}
}
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string): void {
if (!this.shouldEmitImageEvents(payload)) return
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName }
for (const win of this.getActiveWindowsSafely()) {
if (!win.isDestroyed()) {
win.webContents.send('image:updateAvailable', message)
}
}
}
private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string, localPath: string): void {
if (!this.shouldEmitImageEvents(payload)) return
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath }
for (const win of this.getActiveWindowsSafely()) {
if (!win.isDestroyed()) {
win.webContents.send('image:cacheResolved', message)
}
}
}
private emitDecryptProgress(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean },
cacheKey: string,
stage: DecryptProgressStage,
progress: number,
status: 'running' | 'done' | 'error',
message?: string
): void {
if (!this.shouldEmitImageEvents(payload)) return
const safeProgress = Math.max(0, Math.min(100, Math.floor(progress)))
const event = {
cacheKey,
imageMd5: payload.imageMd5,
imageDatName: payload.imageDatName,
stage,
progress: safeProgress,
status,
message: message || ''
}
for (const win of this.getActiveWindowsSafely()) {
if (!win.isDestroyed()) {
win.webContents.send('image:decryptProgress', event)
}
}
}
private getCacheRoot(): string {
let root = this.cacheRootPath
if (!root) {
const configured = this.configService.get('cachePath')
root = configured
? join(configured, 'Images')
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
this.cacheRootPath = root
}
this.ensureDir(root)
return root
}
private ensureDir(dirPath: string): void {
if (!dirPath) return
if (this.ensuredDirs.has(dirPath) && existsSync(dirPath)) return
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true })
}
this.ensuredDirs.add(dirPath)
}
private tryDecryptDatWithNative(
datPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean } | null {
const result = decryptDatViaNative(datPath, xorKey, aesKey)
if (!this.nativeLogged) {
this.nativeLogged = true
if (result) {
this.logInfo('Rust 原生解密已启用', {
addonPath: nativeAddonLocation(),
source: 'native'
})
} else {
this.logInfo('Rust 原生解密不可用', {
addonPath: nativeAddonLocation(),
source: 'native_unavailable'
})
}
}
return result
}
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 resolveLocalPathForPayload(filePath: string, preferFilePath?: boolean): string {
if (preferFilePath) return filePath
return this.resolveEmitPath(filePath, false)
}
private resolveEmitPath(filePath: string, preferFilePath?: boolean): string {
if (preferFilePath) return this.filePathToUrl(filePath)
return this.fileToDataUrl(filePath) || this.filePathToUrl(filePath)
}
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'
}
/**
* 解包 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 starts: number[] = []
let i = 4
while (i < buffer.length - 3) {
const hasPrefix4 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01
const hasPrefix3 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
buffer[i + 2] === 0x01
if (hasPrefix4 || hasPrefix3) {
starts.push(i)
i += hasPrefix4 ? 4 : 3
continue
}
i += 1
}
if (starts.length === 0) return null
const nalUnits: Buffer[] = []
for (let index = 0; index < starts.length; index += 1) {
const start = starts[index]
const end = index + 1 < starts.length ? starts[index + 1] : buffer.length
const hasPrefix4 = buffer[start] === 0x00 && buffer[start + 1] === 0x00 &&
buffer[start + 2] === 0x00 && buffer[start + 3] === 0x01
const prefixLength = hasPrefix4 ? 4 : 3
const payloadStart = start + prefixLength
if (payloadStart >= end) continue
nalUnits.push(Buffer.from([0x00, 0x00, 0x00, 0x01]))
nalUnits.push(buffer.subarray(payloadStart, end))
}
if (nalUnits.length === 0) 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<Buffer | null> {
const ffmpeg = this.getFfmpegPath()
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
const tmpDir = join(this.getTempPath(), 'weflow_hevc')
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
const uniqueId = `${process.pid}_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`
const tmpInput = join(tmpDir, `hevc_${uniqueId}.hevc`)
const tmpOutput = join(tmpDir, `hevc_${uniqueId}.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: 'h265 raw', inputArgs: ['-f', 'h265', '-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<Buffer | null> {
return new Promise((resolve) => {
const { spawn } = require('child_process')
const errChunks: Buffer[] = []
const args = [
'-hide_banner', '-loglevel', 'error',
'-y',
...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 isHdDatPath(datPath: string): boolean {
const name = basename(String(datPath || '')).toLowerCase()
if (!name.endsWith('.dat')) return false
const stem = name.slice(0, -4)
return (
stem.endsWith('_h') ||
stem.endsWith('.h') ||
stem.endsWith('_hd') ||
stem.endsWith('.hd')
)
}
private isTVariantDat(datPath: string): boolean {
const name = basename(String(datPath || '')).toLowerCase()
return this.isThumbnailDat(name)
}
private isBaseDatPath(datPath: string, baseMd5: string): boolean {
const normalizedBase = String(baseMd5 || '').trim().toLowerCase()
if (!normalizedBase) return false
const name = basename(String(datPath || '')).toLowerCase()
return name === `${normalizedBase}.dat`
}
private getDatTier(datPath: string, baseMd5: string): number {
if (this.isHdDatPath(datPath)) return 3
if (this.isBaseDatPath(datPath, baseMd5)) return 2
if (this.isTVariantDat(datPath)) return 1
return 0
}
private getCachedPathTier(cachePath: string): number {
if (this.isHdPath(cachePath)) return 3
const suffix = this.getCacheVariantSuffixFromCachedPath(cachePath)
if (!suffix) return 2
const normalized = suffix.toLowerCase()
if (normalized === '_t' || normalized === '.t' || normalized === '_thumb' || normalized === '.thumb') {
return 1
}
return 1
}
private isHdPath(p: string): boolean {
const raw = String(p || '').split('?')[0]
const name = basename(raw).toLowerCase()
const ext = extname(name).toLowerCase()
const stem = ext ? name.slice(0, -ext.length) : name
return stem.endsWith('_hd')
}
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')}`
}
}
private getElectronPath(name: 'userData' | 'documents' | 'temp'): string | null {
try {
const getter = (app as unknown as { getPath?: (n: string) => string } | undefined)?.getPath
if (typeof getter !== 'function') return null
const value = getter(name)
return typeof value === 'string' && value.trim() ? value : null
} catch {
return null
}
}
private getUserDataPath(): string {
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
if (workerUserDataPath) return workerUserDataPath
return this.getElectronPath('userData') || process.cwd()
}
private getDocumentsPath(): string {
return this.getElectronPath('documents') || join(homedir(), 'Documents')
}
private getTempPath(): string {
return this.getElectronPath('temp') || tmpdir()
}
async clearCache(): Promise<{ success: boolean; error?: string }> {
this.resolvedCache.clear()
this.pending.clear()
this.updateFlags.clear()
this.accountDirCache.clear()
this.ensuredDirs.clear()
this.cacheRootPath = null
const configured = this.configService.get('cachePath')
const root = configured
? join(configured, 'Images')
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
try {
if (!existsSync(root)) {
return { success: true }
}
const monthPattern = /^\d{4}-\d{2}$/
const clearFilesInDir = async (dirPath: string): Promise<void> => {
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<void> => {
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()