Files
WechatExplorer/src/main/image-decrypt-service.ts
T
Nanin 291c82f0e2 修复bug,第一次导出缩略图,后面有了高清图后再次导出应该覆盖
按原图、中图和缩略图对本地图片资源分级,始终优先选择高清变体。

增量导出仅复用满足清晰度要求的图片,重新探测旧缩略图并在高清图出现后替换;仅缩略图降级时显示提示。
2026-08-08 20:53:40 +08:00

1951 lines
63 KiB
TypeScript

import { basename, dirname, extname, join, resolve } from 'path'
import { existsSync, readFileSync, statSync, readdirSync, promises as fsPromises } from 'fs'
import crypto from 'crypto'
import os from 'os'
import { app } from 'electron'
import { execFile } from 'child_process'
import { Worker } from 'worker_threads'
import ffmpegStaticPath from 'ffmpeg-static'
import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption'
import { imageFileQuality, imageQualityRank } from '../shared/image-quality'
import { loadSettings } from './services/settings-store'
import { Wcdb4Client } from './wcdb4-client'
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
const imageDecryptLog = (...args: unknown[]): void => {
if (imageDecryptDebugEnabled) console.log(...args)
}
export type DecodedImage = {
data: string
filePath: string
isThumbnail: boolean
cacheFilePath?: string
mimeType?: string
}
export type ImageDecodeDiagnosticCode =
| 'NOT_RUN'
| 'FILE_NOT_FOUND'
| 'DIRECT_IMAGE'
| 'UNSUPPORTED_DAT_VERSION'
| 'MISSING_AES_KEY'
| 'AES_DECRYPT_FAILED'
| 'INVALID_DAT_FILE'
| 'WXGF_REQUIRES_DECODER'
| 'UNKNOWN_IMAGE_FORMAT'
| 'SUCCESS'
export interface ImageDecodeDiagnostic {
code: ImageDecodeDiagnosticCode
detail: string
datVersion?: number
fileSize?: number
imageFormat?: string
wxgf?: boolean
}
type ImageFindOptions = {
allowThumbnail?: boolean
accountDir?: string
preferThumbnail?: boolean
sessionId?: string
sessionMd5?: string
createTime?: number
}
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
const MAX_PERSISTENT_IMAGE_CACHE_BYTES = 512 * 1024 * 1024
const MAX_PERSISTENT_IMAGE_CACHE_FILES = 512
const PERSISTENT_IMAGE_CACHE_VERSION = 2
interface PersistentImageMeta {
version: number
sourcePath: string
sourceSize: number
sourceMtimeMs: number
fileName: string
cacheSize: number
mimeType: string
isThumbnail: boolean
}
type FfmpegCandidate = { executable: string; source: ImageDecoderSource }
function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCandidate[] {
const selected = String(selectedPath || '').trim()
const environment = String(process.env['FFMPEG_BIN'] || '').trim()
const executable = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const staticExecutable = String(ffmpegStaticPath || '')
.replace('app.asar', 'app.asar.unpacked')
.trim()
const candidates: FfmpegCandidate[] = [
...(selected ? [{ executable: selected, source: 'selected' as const }] : []),
...(environment ? [{ executable: environment, source: 'environment' as const }] : []),
...(staticExecutable ? [{ executable: staticExecutable, source: 'bundled' as const }] : []),
{
executable: join(process.resourcesPath, 'resources', 'ffmpeg', executable),
source: 'bundled'
},
{
executable: join(process.resourcesPath, 'ffmpeg', executable),
source: 'bundled'
},
{
executable: join(process.cwd(), 'resources', 'ffmpeg', executable),
source: 'bundled'
},
...(process.platform === 'darwin'
? [
{ executable: `/opt/homebrew/bin/${executable}`, source: 'system' as const },
{ executable: `/usr/local/bin/${executable}`, source: 'system' as const }
]
: []),
{ executable: process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg', source: 'system' }
]
return candidates.filter(
(candidate, index) =>
candidates.findIndex(
(other) => other.executable.toLowerCase() === candidate.executable.toLowerCase()
) === index
)
}
function resolveFfmpegExecutable(): string {
for (const candidate of getFfmpegCandidates()) {
const pathLike = candidate.executable.includes('/') || candidate.executable.includes('\\')
if (pathLike) {
if (existsSync(candidate.executable)) return candidate.executable
continue
}
return candidate.executable
}
return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
}
function runImageDecoderCommand(
executable: string,
args: string[]
): Promise<{ success: boolean; output: string }> {
return new Promise((resolveValidation) => {
execFile(
executable,
args,
{ timeout: 7_000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 },
(error, stdout, stderr) => {
resolveValidation({ success: !error, output: `${stdout}\n${stderr}` })
}
)
})
}
export async function inspectImageDecoderExecutable(
executable: string
): Promise<{ installed: boolean; supportsHevc: boolean }> {
const version = await runImageDecoderCommand(executable, ['-hide_banner', '-version'])
if (!version.success || !/ffmpeg version/i.test(version.output)) {
return { installed: false, supportsHevc: false }
}
const decoders = await runImageDecoderCommand(executable, ['-hide_banner', '-decoders'])
return {
installed: true,
supportsHevc: decoders.success && /^\s*[A-Z.]{6}\s+hevc\s/im.test(decoders.output)
}
}
async function resolveImageDecoderPath(executable: string): Promise<string | undefined> {
if (existsSync(executable)) return resolve(executable)
const locator = process.platform === 'win32' ? 'where.exe' : 'which'
const located = await runImageDecoderCommand(locator, [executable])
if (!located.success) return undefined
return located.output
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line && existsSync(line))
}
export async function inspectImageDecoderStatus(
selectedPath = loadSettings().ffmpegPath
): Promise<ImageDecoderStatus> {
for (const candidate of getFfmpegCandidates(selectedPath)) {
const inspection = await inspectImageDecoderExecutable(candidate.executable)
if (inspection.installed) {
const resolvedPath = await resolveImageDecoderPath(candidate.executable)
return {
installed: true,
available: inspection.supportsHevc,
source: candidate.source,
selected: candidate.source === 'selected',
directory: resolvedPath ? dirname(resolvedPath) : undefined
}
}
}
return { installed: false, available: false, source: 'none', selected: false }
}
const IMAGE_DECRYPT_WORKER_SOURCE = String.raw`
const crypto = require('node:crypto')
const childProcess = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { parentPort, workerData } = require('node:worker_threads')
function normalizeDatBase(value) {
const lower = String(value || '').trim().toLowerCase()
if (!lower) return ''
const file = lower.split('/').pop().split('\\').pop()
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
return withoutDat.replace(
/(_thumb|\.thumb|_hd|\.hd|_h_m|_h|\.h|_t_m|_t|\.t|_m|_b|_w|_c)$/i,
''
)
}
function isThumbnailName(fileName) {
const lower = fileName.toLowerCase()
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
}
function imageQualityRank(fileName) {
const lower = fileName.toLowerCase()
if (/(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)) return 1
if (/(?:_hd|\.hd|_h_m|_h|\.h)\.dat$/i.test(lower)) return 3
return 2
}
function buildPreferredDatNames(baseName) {
const base = normalizeDatBase(baseName)
if (!base) return []
return [
base + '.dat',
base + '_hd.dat',
base + '_h_M.dat',
base + '_h.dat',
base + '_M.dat',
base + '_b.dat',
base + '_w.dat',
base + '_c.dat',
base + '_t_M.dat',
base + '_t.dat',
base + '.thumb.dat',
base + '_thumb.dat'
]
}
function detectImageExtension(buffer) {
if (buffer.length < 4) return null
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return '.png'
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) return '.gif'
if (buffer[0] === 0x42 && buffer[1] === 0x4d) return '.bmp'
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) return '.webp'
return null
}
function getMimeType(extension) {
if (extension === '.png') return 'image/png'
if (extension === '.gif') return 'image/gif'
if (extension === '.bmp') return 'image/bmp'
if (extension === '.webp') return 'image/webp'
return 'image/jpeg'
}
function strictRemovePadding(buffer) {
if (buffer.length === 0) return buffer
const paddingLength = buffer[buffer.length - 1]
if (paddingLength <= 0 || paddingLength > 16 || paddingLength > buffer.length) {
throw new Error('invalid PKCS#7 padding')
}
for (let index = buffer.length - paddingLength; index < buffer.length; index += 1) {
if (buffer[index] !== paddingLength) throw new Error('invalid PKCS#7 padding')
}
return buffer.subarray(0, buffer.length - paddingLength)
}
function unwrapWxgf(buffer, ffmpegPath) {
if (
buffer.length < 20 ||
buffer[0] !== 0x77 ||
buffer[1] !== 0x78 ||
buffer[2] !== 0x67 ||
buffer[3] !== 0x66
) {
return buffer
}
for (let index = 4; index < Math.min(buffer.length - 12, 4096); index += 1) {
if (buffer[index] === 0xff && buffer[index + 1] === 0xd8 && buffer[index + 2] === 0xff) {
return buffer.subarray(index)
}
if (
buffer[index] === 0x89 &&
buffer[index + 1] === 0x50 &&
buffer[index + 2] === 0x4e &&
buffer[index + 3] === 0x47
) {
return buffer.subarray(index)
}
}
function findHevcPartitions(data) {
if (data.length < 15) return []
const headerLength = data[4]
if (headerLength < 5 || headerLength >= data.length) return []
for (const pattern of [Buffer.from([0, 0, 0, 1]), Buffer.from([0, 0, 1])]) {
const partitions = []
let searchOffset = headerLength
while (searchOffset < data.length) {
const relativeIndex = data.subarray(searchOffset).indexOf(pattern)
if (relativeIndex < 0) break
const offset = searchOffset + relativeIndex
if (offset < 4) {
searchOffset = offset + 1
continue
}
const size = data.readUInt32BE(offset - 4)
if (size === 0 || offset + size > data.length) {
searchOffset = offset + 1
continue
}
partitions.push({ offset, size })
searchOffset = offset + size
}
if (partitions.length > 0) return partitions
}
return []
}
const partitions = findHevcPartitions(buffer)
let hevcData = null
if (partitions.length > 0) {
const largest = partitions.reduce((best, current) =>
current.size > best.size ? current : best
)
hevcData = buffer.subarray(largest.offset, largest.offset + largest.size)
} else {
for (let index = 4; index < Math.min(buffer.length - 4, 4096); index += 1) {
if (
buffer[index] === 0x00 &&
buffer[index + 1] === 0x00 &&
buffer[index + 2] === 0x00 &&
buffer[index + 3] === 0x01
) {
hevcData = buffer.subarray(index)
break
}
}
}
if (!hevcData || !ffmpegPath) return buffer
const nonce = process.pid + '-' + Date.now() + '-' + crypto.randomBytes(4).toString('hex')
const tempBase = path.join(os.tmpdir(), 'wxe-wxgf-' + nonce)
const inputPath = tempBase + '.hevc'
const outputPath = tempBase + '.png'
try {
fs.writeFileSync(inputPath, hevcData)
childProcess.execFileSync(
ffmpegPath,
[
'-hide_banner',
'-loglevel',
'error',
'-y',
'-f',
'hevc',
'-i',
inputPath,
'-frames:v',
'1',
outputPath
],
{ timeout: 20_000, windowsHide: true, stdio: 'ignore' }
)
const converted = fs.readFileSync(outputPath)
return detectImageExtension(converted) ? converted : buffer
} catch {
return buffer
} finally {
try { fs.rmSync(inputPath, { force: true }) } catch {}
try { fs.rmSync(outputPath, { force: true }) } catch {}
}
}
function decryptCandidate(filePath, aesKey, xorKey, ffmpegPath) {
const bytes = fs.readFileSync(filePath)
const directExtension = detectImageExtension(bytes)
if (directExtension) {
return {
data: 'data:' + getMimeType(directExtension) + ';base64,' + bytes.toString('base64'),
filePath
}
}
if (!path.extname(filePath).toLowerCase().includes('dat')) {
const extension = path.extname(filePath).toLowerCase()
return { data: 'data:' + getMimeType(extension) + ';base64,' + bytes.toString('base64'), filePath }
}
if (
bytes.length < 15 ||
bytes[0] !== 0x07 ||
bytes[1] !== 0x08 ||
bytes[2] !== 0x56 ||
bytes[3] !== 0x32 ||
bytes[4] !== 0x08 ||
bytes[5] !== 0x07 ||
!aesKey
) {
return null
}
const payload = bytes.subarray(15)
const aesSize = bytes.readInt32LE(6)
const xorSize = bytes.readInt32LE(10)
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > payload.length) return null
const aesData = payload.subarray(0, alignedAesSize)
let unpadded = Buffer.alloc(0)
if (aesData.length > 0) {
const key = Buffer.from(aesKey, 'ascii').subarray(0, 16)
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
decipher.setAutoPadding(false)
unpadded = strictRemovePadding(Buffer.concat([decipher.update(aesData), decipher.final()]))
}
const remaining = payload.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) return null
const rawLength = remaining.length - xorSize
const rawData = remaining.subarray(0, rawLength)
const xorData = remaining.subarray(rawLength)
const xorPlain = Buffer.allocUnsafe(xorData.length)
for (let index = 0; index < xorData.length; index += 1) {
xorPlain[index] = xorData[index] ^ xorKey
}
const image = unwrapWxgf(Buffer.concat([unpadded, rawData, xorPlain]), ffmpegPath)
const extension = detectImageExtension(image)
if (!extension) return null
return {
data: 'data:' + getMimeType(extension) + ';base64,' + image.toString('base64'),
filePath
}
}
function collectCandidates(datPath, allowThumbnail) {
const candidates = [datPath]
if (!path.extname(datPath).toLowerCase().includes('dat')) return candidates
const directory = path.dirname(datPath)
const base = normalizeDatBase(path.basename(datPath))
const siblings = buildPreferredDatNames(base)
.filter((name) => allowThumbnail || !isThumbnailName(name))
.map((name) => path.join(directory, name))
.filter((candidate) => fs.existsSync(candidate))
.sort((left, right) => {
const qualityOrder = imageQualityRank(path.basename(right)) - imageQualityRank(path.basename(left))
return qualityOrder || fs.statSync(right).size - fs.statSync(left).size
})
return Array.from(new Set(candidates.concat(siblings)))
}
let result = null
for (const candidate of collectCandidates(workerData.datPath, workerData.allowThumbnail)) {
try {
result = decryptCandidate(
candidate,
workerData.aesKey,
workerData.xorKey,
workerData.ffmpegPath
)
if (result) break
} catch {
// Try the next local quality variant.
}
}
parentPort.postMessage(result)
`
export class ImageDecryptService {
private xorKey: number = 0
private aesKey: string = ''
private wcdb4Client: Wcdb4Client | null = null
private accountDirResolved = false
private cachedAccountDir: string | null = null
private imagePathCache = new Map<string, string>()
private decodedImageCache = new Map<string, DecodedImage>()
private decodedImageCacheBytes = 0
private persistentCachePrunePromise: Promise<void> | null = null
private persistentCachePrunePending = false
private lastDecodeDiagnostic: ImageDecodeDiagnostic = {
code: 'NOT_RUN',
detail: '尚未执行图片解析'
}
constructor(
xorKey: string,
aesKey: string,
wcdb4Client?: Wcdb4Client | null,
configuredAccountDir?: string
) {
// 解析 XOR Key (支持 0x40 或 64 格式)
const xorHex = xorKey.trim().toLowerCase()
if (xorHex.startsWith('0x')) {
this.xorKey = parseInt(xorHex, 16)
} else {
this.xorKey = parseInt(xorHex, 10)
}
// AES Key 直接使用
this.aesKey = aesKey.trim()
this.wcdb4Client = wcdb4Client || null
const accountDir = this.wcdb4Client?.getAccountRoot() || configuredAccountDir
if (accountDir && existsSync(accountDir)) {
this.cachedAccountDir = accountDir
this.accountDirResolved = true
}
}
getLastDecodeDiagnostic(): ImageDecodeDiagnostic {
return { ...this.lastDecodeDiagnostic }
}
/**
* 获取账号目录
*/
private getAccountDir(): string | null {
if (this.accountDirResolved) return this.cachedAccountDir
this.accountDirResolved = true
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
this.cachedAccountDir = wcdbAccountRoot
return this.cachedAccountDir
}
const homeDir = os.homedir()
const accountRoot = join(
homeDir,
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
)
if (!existsSync(accountRoot)) {
imageDecryptLog('[ImageDecrypt] account root not found:', accountRoot)
return null
}
const accounts = readdirSync(accountRoot)
.filter((name) => {
const fullPath = join(accountRoot, name)
try {
return statSync(fullPath).isDirectory()
} catch {
return false
}
})
.map((name) => ({
name,
mtime: statSync(join(accountRoot, name)).mtimeMs
}))
.sort((a, b) => b.mtime - a.mtime)
if (accounts.length === 0) {
imageDecryptLog('[ImageDecrypt] no accounts found')
return null
}
// 返回最新的账号目录
this.cachedAccountDir = join(accountRoot, accounts[0].name)
return this.cachedAccountDir
}
/**
* 根据 md5 查找图片文件 (WechatExplorer 风格)
*/
findImageFile(md5?: string, imageDatName?: string, options?: ImageFindOptions): string | null {
const allowThumbnail = options?.allowThumbnail !== false
const normalizedMd5 = this.normalizeDatBase(md5 || '')
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
const sessionDirectory = this.getSessionDirectoryName(options?.sessionMd5 || options?.sessionId)
const pathCacheKey = [
normalizedMd5,
normalizedDatName,
allowThumbnail ? 'thumb' : 'original',
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
options?.accountDir || '',
sessionDirectory,
options?.createTime || 0
].join('|')
const cachedPath = this.imagePathCache.get(pathCacheKey)
if (cachedPath && existsSync(cachedPath)) return cachedPath
const rememberPath = (path: string | null): string | null => {
if (path) this.imagePathCache.set(pathCacheKey, path)
return path
}
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
const accountDir =
options?.accountDir && existsSync(options.accountDir)
? options.accountDir
: this.getAccountDir()
if (!accountDir) return null
imageDecryptLog('[ImageDecrypt] findImageFile:', {
md5: normalizedMd5,
imageDatName: normalizedDatName,
accountDir,
allowThumbnail,
sessionDirectory,
createTime: options?.createTime
})
const attachDir = join(accountDir, 'msg', 'attach')
// The attach directory stores DAT filenames, while the message MD5 often
// identifies the original image rather than the local file.
const searchKeys = this.uniq([normalizedDatName, normalizedMd5])
if (sessionDirectory && allowThumbnail && options?.preferThumbnail) {
const bubblePreview = this.findBubblePreview(
accountDir,
searchKeys,
sessionDirectory,
options?.createTime
)
if (bubblePreview) return rememberPath(bubblePreview)
}
// Message rows already identify their conversation. Prefer that small,
// deterministic directory before consulting the native hardlink database.
if (sessionDirectory && existsSync(attachDir)) {
for (const key of searchKeys) {
const scopedHit = this.fastProbabilisticSearch(
attachDir,
key,
allowThumbnail,
options?.preferThumbnail,
sessionDirectory,
options?.createTime
)
if (scopedHit) return rememberPath(scopedHit)
}
}
for (const key of this.uniq([normalizedMd5, normalizedDatName])) {
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
if (fullPath && existsSync(fullPath)) {
const selected = this.getPreferredDatVariantPath(
fullPath,
allowThumbnail,
options?.preferThumbnail
)
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
return rememberPath(selected)
}
}
}
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
if (!existsSync(attachDir)) {
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
return rememberPath(
this.findImageFileInLegacyDirs(
accountDir,
normalizedMd5 || normalizedDatName,
allowThumbnail,
options?.preferThumbnail
)
)
}
if (searchKeys.length === 0) return null
if (!sessionDirectory) {
for (const key of searchKeys) {
const directHit = this.fastProbabilisticSearch(
attachDir,
key,
allowThumbnail,
options?.preferThumbnail
)
if (directHit) return rememberPath(directHit)
}
}
const legacyHit = this.findImageFileInLegacyDirs(
accountDir,
searchKeys[0],
allowThumbnail,
options?.preferThumbnail
)
if (legacyHit) return rememberPath(legacyHit)
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
return null
}
async getCachedDecodedImage(
key: string,
options: { includeData?: boolean } = {}
): Promise<DecodedImage | null> {
const cached = this.decodedImageCache.get(key)
if (cached) {
this.decodedImageCache.delete(key)
this.decodedImageCache.set(key, cached)
return cached
}
const persistent = await this.getPersistentDecodedImage(key, options.includeData !== false)
if (!persistent) return null
if (persistent.data) this.putDecodedImageInMemory(key, persistent)
return persistent
}
async cacheDecodedImage(key: string, image: DecodedImage): Promise<void> {
this.putDecodedImageInMemory(key, image)
try {
const persisted = await this.writePersistentDecodedImage(key, image)
if (persisted) {
const current = this.decodedImageCache.get(key)
if (current) {
current.cacheFilePath = persisted.cacheFilePath
current.mimeType = persisted.mimeType
}
}
} catch (error) {
imageDecryptLog('[ImageDecrypt] persistent cache write failed:', error)
}
}
private putDecodedImageInMemory(key: string, image: DecodedImage): void {
const size = image.data.length * 2
const previous = this.decodedImageCache.get(key)
if (previous) {
this.decodedImageCacheBytes -= previous.data.length * 2
this.decodedImageCache.delete(key)
}
this.decodedImageCache.set(key, image)
this.decodedImageCacheBytes += size
while (
this.decodedImageCacheBytes > MAX_DECODED_IMAGE_CACHE_BYTES &&
this.decodedImageCache.size > 1
) {
const oldestKey = this.decodedImageCache.keys().next().value
if (!oldestKey) break
const oldest = this.decodedImageCache.get(oldestKey)
this.decodedImageCache.delete(oldestKey)
this.decodedImageCacheBytes -= oldest?.data.length ? oldest.data.length * 2 : 0
}
}
private getPersistentCacheDir(): string | null {
try {
return join(app.getPath('userData'), 'cache', 'images')
} catch (error) {
imageDecryptLog('[ImageDecrypt] persistent cache path unavailable:', error)
return null
}
}
private getPersistentCacheKey(key: string): string {
const accountDir = this.getAccountDir() || this.wcdb4Client?.getAccountRoot() || ''
const resolvedAccountDir = accountDir ? resolve(accountDir) : ''
const accountScope =
process.platform === 'win32'
? resolvedAccountDir.replace(/\\/g, '/').toLowerCase()
: resolvedAccountDir
const scope = JSON.stringify({
version: PERSISTENT_IMAGE_CACHE_VERSION,
accountDir: accountScope,
key
})
return crypto.createHash('sha256').update(scope).digest('hex')
}
private async getPersistentDecodedImage(
key: string,
includeData: boolean
): Promise<DecodedImage | null> {
const cacheDir = this.getPersistentCacheDir()
if (!cacheDir) return null
const cacheKey = this.getPersistentCacheKey(key)
const metadataPath = join(cacheDir, `${cacheKey}.json`)
let metadata: PersistentImageMeta | null = null
try {
metadata = JSON.parse(await fsPromises.readFile(metadataPath, 'utf8')) as PersistentImageMeta
if (!this.isValidPersistentImageMeta(metadata, cacheKey)) {
throw new Error('invalid persistent image metadata')
}
const sourceStat = await fsPromises.stat(metadata.sourcePath)
if (
!sourceStat.isFile() ||
sourceStat.size !== metadata.sourceSize ||
Math.trunc(sourceStat.mtimeMs) !== Math.trunc(metadata.sourceMtimeMs)
) {
throw new Error('persistent image source changed')
}
const cacheFilePath = join(cacheDir, metadata.fileName)
const cacheStat = await fsPromises.stat(cacheFilePath)
if (!cacheStat.isFile() || cacheStat.size <= 0 || cacheStat.size !== metadata.cacheSize) {
throw new Error('persistent image payload changed')
}
const payload = includeData
? await fsPromises.readFile(cacheFilePath)
: await this.readImageSignature(cacheFilePath)
const extension = this.detectImageExtension(payload)
if (!extension || this.getMimeType(extension) !== metadata.mimeType) {
throw new Error('persistent image payload is invalid')
}
const now = new Date()
void Promise.all([
fsPromises.utimes(cacheFilePath, now, now),
fsPromises.utimes(metadataPath, now, now)
]).catch(() => undefined)
return {
data: includeData ? `data:${metadata.mimeType};base64,${payload.toString('base64')}` : '',
filePath: metadata.sourcePath,
isThumbnail: metadata.isThumbnail,
cacheFilePath,
mimeType: metadata.mimeType
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
imageDecryptLog('[ImageDecrypt] persistent cache miss:', error)
}
await this.removePersistentCacheEntry(cacheDir, cacheKey, metadata?.fileName)
return null
}
}
private async writePersistentDecodedImage(
key: string,
image: DecodedImage
): Promise<{ cacheFilePath: string; mimeType: string } | null> {
const cacheDir = this.getPersistentCacheDir()
if (!cacheDir || !image.filePath) return null
const payload = await this.getDecodedImagePayload(image)
if (!payload) return null
const extension = this.detectImageExtension(payload)
if (!extension) return null
const sourceStat = await fsPromises.stat(image.filePath)
if (!sourceStat.isFile()) return null
const cacheKey = this.getPersistentCacheKey(key)
const fileName = `${cacheKey}${extension}`
const cacheFilePath = join(cacheDir, fileName)
const metadataPath = join(cacheDir, `${cacheKey}.json`)
const mimeType = this.getMimeType(extension)
const nonce = `${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`
const payloadTempPath = join(cacheDir, `${cacheKey}.${nonce}.tmp`)
const metadataTempPath = join(cacheDir, `${cacheKey}.${nonce}.json.tmp`)
const metadata: PersistentImageMeta = {
version: PERSISTENT_IMAGE_CACHE_VERSION,
sourcePath: image.filePath,
sourceSize: sourceStat.size,
sourceMtimeMs: sourceStat.mtimeMs,
fileName,
cacheSize: payload.length,
mimeType,
isThumbnail: image.isThumbnail
}
await fsPromises.mkdir(cacheDir, { recursive: true })
try {
await fsPromises.writeFile(payloadTempPath, payload)
await this.replaceFileAtomically(payloadTempPath, cacheFilePath)
await fsPromises.writeFile(metadataTempPath, JSON.stringify(metadata))
await this.replaceFileAtomically(metadataTempPath, metadataPath)
await this.removeOtherPersistentPayloads(cacheDir, cacheKey, fileName)
} finally {
await Promise.allSettled([
fsPromises.rm(payloadTempPath, { force: true }),
fsPromises.rm(metadataTempPath, { force: true })
])
}
this.schedulePersistentCachePrune()
return { cacheFilePath, mimeType }
}
private async getDecodedImagePayload(image: DecodedImage): Promise<Buffer | null> {
const separatorIndex = image.data.indexOf(',')
if (separatorIndex > 0) {
const header = image.data.slice(0, separatorIndex)
if (/^data:image\/[a-z0-9.+-]+;base64$/i.test(header)) {
const payload = Buffer.from(image.data.slice(separatorIndex + 1), 'base64')
return payload.length > 0 ? payload : null
}
}
if (image.cacheFilePath) {
try {
return await fsPromises.readFile(image.cacheFilePath)
} catch {
return null
}
}
return null
}
async findImageFileAsync(
md5?: string,
imageDatName?: string,
options?: ImageFindOptions
): Promise<string | null> {
const sessionDirectory = this.getSessionDirectoryName(options?.sessionMd5 || options?.sessionId)
if (!sessionDirectory) return this.findImageFile(md5, imageDatName, options)
const allowThumbnail = options?.allowThumbnail !== false
const normalizedMd5 = this.normalizeDatBase(md5 || '')
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
const pathCacheKey = [
normalizedMd5,
normalizedDatName,
allowThumbnail ? 'thumb' : 'original',
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
options?.accountDir || '',
sessionDirectory,
options?.createTime || 0
].join('|')
const cachedPath = this.imagePathCache.get(pathCacheKey)
if (cachedPath && existsSync(cachedPath)) return cachedPath
const rememberPath = (filePath: string | null): string | null => {
if (filePath) this.imagePathCache.set(pathCacheKey, filePath)
return filePath
}
const accountDir =
options?.accountDir && existsSync(options.accountDir)
? options.accountDir
: this.getAccountDir()
if (!accountDir) return null
const attachDir = join(accountDir, 'msg', 'attach')
const searchKeys = this.uniq([normalizedDatName, normalizedMd5])
if (allowThumbnail && options?.preferThumbnail) {
const bubblePreview = await this.findBubblePreviewAsync(
accountDir,
searchKeys,
sessionDirectory,
options?.createTime
)
if (bubblePreview) return rememberPath(bubblePreview)
}
if (existsSync(attachDir)) {
for (const key of searchKeys) {
const scopedHit = await this.findImageInSessionDirectoryAsync(
attachDir,
key,
allowThumbnail,
options?.preferThumbnail,
sessionDirectory,
options?.createTime
)
if (scopedHit) return rememberPath(scopedHit)
}
}
for (const key of this.uniq([normalizedMd5, normalizedDatName])) {
const hardlink = await this.wcdb4Client?.resolveImageHardlinkAsync(key)
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
if (!fullPath || !existsSync(fullPath)) continue
const selected = this.getPreferredDatVariantPath(
fullPath,
allowThumbnail,
options?.preferThumbnail
)
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
return rememberPath(selected)
}
}
return null
}
private async findImageInSessionDirectoryAsync(
attachDir: string,
datName: string,
allowThumbnail: boolean,
preferThumbnail: boolean | undefined,
sessionDirectory: string,
createTime?: number
): Promise<string | null> {
const normalized = this.normalizeDatBase(datName)
if (!normalized || !sessionDirectory) return null
const sessionRoot = join(attachDir, sessionDirectory)
let monthDirectories: string[]
try {
monthDirectories = (await fsPromises.readdir(sessionRoot, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name))
.map((entry) => entry.name)
.sort((left, right) => right.localeCompare(left))
} catch {
return null
}
monthDirectories = this.prioritizeImageMonth(monthDirectories, createTime)
const variants = this.buildPreferredDatNames(normalized)
for (const month of monthDirectories) {
const candidates = ['Img', 'Image', 'image'].flatMap((subDirectory) =>
variants.map((variant) => join(sessionRoot, month, subDirectory, variant))
)
const found = await this.getLargestExistingPathAsync(
candidates,
allowThumbnail,
preferThumbnail
)
if (found) return found
}
return null
}
private isValidPersistentImageMeta(metadata: PersistentImageMeta, cacheKey: string): boolean {
return (
metadata !== null &&
typeof metadata === 'object' &&
metadata.version === PERSISTENT_IMAGE_CACHE_VERSION &&
typeof metadata.sourcePath === 'string' &&
metadata.sourcePath.length > 0 &&
Number.isFinite(metadata.sourceSize) &&
metadata.sourceSize >= 0 &&
Number.isFinite(metadata.sourceMtimeMs) &&
typeof metadata.fileName === 'string' &&
basename(metadata.fileName) === metadata.fileName &&
metadata.fileName.startsWith(`${cacheKey}.`) &&
Number.isFinite(metadata.cacheSize) &&
metadata.cacheSize > 0 &&
typeof metadata.mimeType === 'string' &&
metadata.mimeType.startsWith('image/') &&
typeof metadata.isThumbnail === 'boolean'
)
}
private async readImageSignature(filePath: string): Promise<Buffer> {
const handle = await fsPromises.open(filePath, 'r')
try {
const signature = Buffer.alloc(16)
const { bytesRead } = await handle.read(signature, 0, signature.length, 0)
return signature.subarray(0, bytesRead)
} finally {
await handle.close()
}
}
private async replaceFileAtomically(tempPath: string, targetPath: string): Promise<void> {
try {
await fsPromises.rename(tempPath, targetPath)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== 'EEXIST' && code !== 'EPERM') throw error
await fsPromises.rm(targetPath, { force: true })
await fsPromises.rename(tempPath, targetPath)
}
}
private async removeOtherPersistentPayloads(
cacheDir: string,
cacheKey: string,
keepFileName: string
): Promise<void> {
const names = await fsPromises.readdir(cacheDir)
const obsolete = names.filter(
(name) =>
name !== keepFileName &&
name.startsWith(`${cacheKey}.`) &&
/^\.(?:jpe?g|png|gif|bmp|webp)$/i.test(name.slice(cacheKey.length))
)
await Promise.allSettled(
obsolete.map((name) => fsPromises.rm(join(cacheDir, name), { force: true }))
)
}
private async removePersistentCacheEntry(
cacheDir: string,
cacheKey: string,
fileName?: string
): Promise<void> {
const candidates = new Set<string>([`${cacheKey}.json`])
if (fileName && basename(fileName) === fileName && fileName.startsWith(`${cacheKey}.`)) {
candidates.add(fileName)
} else {
try {
const names = await fsPromises.readdir(cacheDir)
for (const name of names) {
if (
name.startsWith(`${cacheKey}.`) &&
/^\.(?:jpe?g|png|gif|bmp|webp)$/i.test(name.slice(cacheKey.length))
) {
candidates.add(name)
}
}
} catch {
return
}
}
await Promise.allSettled(
Array.from(candidates, (name) => fsPromises.rm(join(cacheDir, name), { force: true }))
)
}
private schedulePersistentCachePrune(): void {
if (this.persistentCachePrunePromise) {
this.persistentCachePrunePending = true
return
}
this.persistentCachePrunePromise = this.prunePersistentCache()
.catch((error) => imageDecryptLog('[ImageDecrypt] persistent cache prune failed:', error))
.finally(() => {
this.persistentCachePrunePromise = null
if (this.persistentCachePrunePending) {
this.persistentCachePrunePending = false
this.schedulePersistentCachePrune()
}
})
}
private async prunePersistentCache(): Promise<void> {
const cacheDir = this.getPersistentCacheDir()
if (!cacheDir) return
const entries = await fsPromises.readdir(cacheDir, { withFileTypes: true })
const payloadNames = entries
.filter(
(entry) => entry.isFile() && /^[a-f0-9]{64}\.(?:jpe?g|png|gif|bmp|webp)$/i.test(entry.name)
)
.map((entry) => entry.name)
const payloads = (
await Promise.all(
payloadNames.map(async (name) => {
try {
const stat = await fsPromises.stat(join(cacheDir, name))
return { name, size: stat.size, mtimeMs: stat.mtimeMs }
} catch {
return null
}
})
)
)
.filter((entry): entry is { name: string; size: number; mtimeMs: number } => entry !== null)
.sort((left, right) => left.mtimeMs - right.mtimeMs)
let totalBytes = payloads.reduce((total, entry) => total + entry.size, 0)
let totalFiles = payloads.length
for (const payload of payloads) {
if (
totalFiles <= MAX_PERSISTENT_IMAGE_CACHE_FILES &&
totalBytes <= MAX_PERSISTENT_IMAGE_CACHE_BYTES
) {
break
}
const cacheKey = payload.name.slice(0, 64)
await Promise.allSettled([
fsPromises.rm(join(cacheDir, payload.name), { force: true }),
fsPromises.rm(join(cacheDir, `${cacheKey}.json`), { force: true })
])
totalFiles -= 1
totalBytes -= payload.size
}
const remainingPayloadKeys = new Set(
payloads.slice(payloads.length - totalFiles).map((payload) => payload.name.slice(0, 64))
)
const staleMetadata = entries.filter(
(entry) =>
entry.isFile() &&
/^[a-f0-9]{64}\.json$/i.test(entry.name) &&
!remainingPayloadKeys.has(entry.name.slice(0, 64))
)
await Promise.allSettled(
staleMetadata.map((entry) => fsPromises.rm(join(cacheDir, entry.name), { force: true }))
)
}
private fastProbabilisticSearch(
attachDir: string,
datName: string,
allowThumbnail = true,
preferThumbnail = false,
sessionDirectory = '',
createTime?: number
): string | null {
const normalized = this.normalizeDatBase(datName)
if (!normalized) return null
const variants = this.buildPreferredDatNames(normalized)
if (/^[a-f0-9]{32}$/.test(normalized)) {
const dir1 = normalized.substring(0, 2)
const dir2 = normalized.substring(2, 4)
for (const variant of variants) {
const candidates = [
join(attachDir, dir1, dir2, variant),
join(attachDir, dir1, dir2, 'Img', variant),
join(attachDir, dir1, dir2, 'Image', variant),
join(attachDir, dir1, dir2, 'image', variant)
]
const found = this.getLargestExistingPath(candidates, allowThumbnail, preferThumbnail)
if (found) {
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
return found
}
}
}
try {
const sessionDirs = sessionDirectory
? existsSync(join(attachDir, sessionDirectory))
? [sessionDirectory]
: []
: readdirSync(attachDir).filter((name) => name.length === 32 && /^[a-f0-9]+$/i.test(name))
for (const sessDir of sessionDirs) {
const sessionRoot = join(attachDir, sessDir)
const months = sessionDirectory
? this.getImageMonthDirectories(sessionRoot, createTime)
: this.getRecentImageMonths(24)
for (const month of months) {
for (const sub of ['Img', 'Image', 'image']) {
const imgDir = join(attachDir, sessDir, month, sub)
if (!existsSync(imgDir)) continue
const found = this.getLargestExistingPath(
variants.map((variant) => join(imgDir, variant)),
allowThumbnail,
preferThumbnail
)
if (found) {
imageDecryptLog('[ImageDecrypt] found at:', found)
return found
}
}
}
}
} catch (e) {
imageDecryptLog('[ImageDecrypt]遍历目录失败:', e)
}
return null
}
private findImageFileInLegacyDirs(
accountDir: string,
datName: string,
allowThumbnail = true,
preferThumbnail = false
): string | null {
const normalized = this.normalizeDatBase(datName)
if (!normalized) return null
const roots = [
join(accountDir, 'FileStorage', 'Image'),
join(accountDir, 'FileStorage', 'Image2'),
join(accountDir, 'FileStorage', 'MsgImg')
].filter((root) => existsSync(root))
for (const root of roots) {
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail, preferThumbnail)
if (found) return found
}
return null
}
private recursiveFindDat(
dir: string,
datName: string,
depth: number,
allowThumbnail = true,
preferThumbnail = false
): string | null {
if (depth < 0) return null
try {
const variantNames = this.buildPreferredDatNames(datName).filter(
(name) => allowThumbnail || !this.isThumbnailName(name)
)
const variants = new Set(
preferThumbnail
? [
...variantNames.filter((name) => this.isThumbnailName(name)),
...variantNames.filter((name) => !this.isThumbnailName(name))
]
: variantNames
)
const entries = readdirSync(dir)
const matchingFiles: string[] = []
for (const entry of entries) {
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isFile() && variants.has(entry.toLowerCase())) {
matchingFiles.push(fullPath)
}
}
const preferredFile = this.getLargestExistingPath(
matchingFiles,
allowThumbnail,
preferThumbnail
)
if (preferredFile) {
imageDecryptLog('[ImageDecrypt] legacy path hit:', preferredFile)
return preferredFile
}
for (const entry of entries) {
const fullPath = join(dir, entry)
if (!statSync(fullPath).isDirectory()) continue
const found = this.recursiveFindDat(
fullPath,
datName,
depth - 1,
allowThumbnail,
preferThumbnail
)
if (found) return found
}
} catch {
return null
}
return null
}
/**
* 解密图片文件并返回 Buffer
*/
decryptImage(datPath: string): Buffer | null {
if (!existsSync(datPath)) {
imageDecryptLog('[ImageDecrypt] file not found:', datPath)
this.lastDecodeDiagnostic = {
code: 'FILE_NOT_FOUND',
detail: '候选图片文件不存在'
}
return null
}
try {
const source = readFileSync(datPath)
const directExtension = this.detectImageExtension(source)
if (directExtension) {
this.lastDecodeDiagnostic = {
code: 'DIRECT_IMAGE',
detail: 'DAT 文件内容是可直接读取的图片',
fileSize: source.length,
imageFormat: directExtension.replace(/^\./, '').toUpperCase()
}
return source
}
const version = this.getDatVersion(datPath)
const fileSize = statSync(datPath).size
imageDecryptLog(
'[ImageDecrypt] dat version:',
version,
'file:',
datPath,
'aesKey present:',
!!this.aesKey
)
let decrypted: Buffer
if (version === 2) {
// WeChat 4.0 标准 dat 头: 07 08 56 32 08 07
imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)')
if (!this.aesKey) {
imageDecryptLog('[ImageDecrypt] no AES key configured')
this.lastDecodeDiagnostic = {
code: 'MISSING_AES_KEY',
detail: '未配置 AES 图片密钥',
datVersion: version,
fileSize
}
return null
}
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
decrypted = this.decryptDatV4(datPath, key)
} else {
// 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version)
this.lastDecodeDiagnostic = {
code: 'UNSUPPORTED_DAT_VERSION',
detail: '不是受支持的 WeChat 4.0 DAT 图片格式',
datVersion: version,
fileSize
}
return null
}
this.lastDecodeDiagnostic = {
code: 'SUCCESS',
detail: 'DAT 数据已解密,正在识别图片格式',
datVersion: version,
fileSize
}
return decrypted
} catch (error) {
imageDecryptLog('[ImageDecrypt] decrypt error:', error)
const message = error instanceof Error ? error.message : String(error)
const aesFailure = /padding|bad decrypt|decrypt/i.test(message)
this.lastDecodeDiagnostic = {
code: aesFailure ? 'AES_DECRYPT_FAILED' : 'INVALID_DAT_FILE',
detail: aesFailure
? 'AES 解密校验失败,密钥可能与当前账号不匹配'
: 'DAT 文件结构异常或文件不完整',
datVersion: 2,
fileSize: existsSync(datPath) ? statSync(datPath).size : undefined
}
return null
}
}
/**
* 将解密后的图片转换为 base64
*/
decryptImageToBase64(datPath: string): string | null {
if (!extname(datPath).toLowerCase().includes('dat')) {
try {
const data = readFileSync(datPath)
const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase()
const mimeType = this.getMimeType(ext)
this.lastDecodeDiagnostic = {
code: 'DIRECT_IMAGE',
detail: '文件本身是可直接读取的图片',
fileSize: data.length,
imageFormat: ext.replace(/^\./, '').toUpperCase()
}
return `data:${mimeType};base64,${data.toString('base64')}`
} catch {
this.lastDecodeDiagnostic = {
code: 'FILE_NOT_FOUND',
detail: '候选图片文件无法读取'
}
return null
}
}
const decrypted = this.decryptImage(datPath)
if (!decrypted) return null
const directImage = this.lastDecodeDiagnostic.code === 'DIRECT_IMAGE'
const wxgf = this.isWxgfBuffer(decrypted)
const unwrapped = this.unwrapWxgf(decrypted)
const ext = this.detectImageExtension(unwrapped)
if (!ext) {
imageDecryptLog('[ImageDecrypt] unknown image format')
this.lastDecodeDiagnostic = {
...this.lastDecodeDiagnostic,
code: wxgf ? 'WXGF_REQUIRES_DECODER' : 'UNKNOWN_IMAGE_FORMAT',
detail: wxgf
? '已解密为 WXGF/HEVC 数据,但 FFmpeg 转换未成功'
: '数据已解密,但无法识别为常见图片格式',
wxgf
}
return null
}
const mimeType = this.getMimeType(ext)
this.lastDecodeDiagnostic = {
...this.lastDecodeDiagnostic,
code: directImage ? 'DIRECT_IMAGE' : 'SUCCESS',
detail: directImage ? 'DAT 文件内容是可直接读取的图片' : '图片解密并识别成功',
imageFormat: ext.replace(/^\./, '').toUpperCase(),
wxgf
}
return `data:${mimeType};base64,${unwrapped.toString('base64')}`
}
/**
* 首选 DAT 无法解密时,继续尝试同目录下属于同一图片的其他清晰度变体。
* 微信可能只保留 base/_h/_hd/_t 中的一部分,不能把首个文件失败等同于整张图失败。
*/
decryptImageToBase64WithFallback(
datPath: string,
allowThumbnail = true
): { data: string; filePath: string } | null {
const candidates = [datPath]
if (extname(datPath).toLowerCase().includes('dat')) {
const dir = dirname(datPath)
const base = this.normalizeDatBase(basename(datPath))
const siblings = this.buildPreferredDatNames(base)
.filter((name) => allowThumbnail || !this.isThumbnailName(name))
.map((name) => join(dir, name))
.filter((candidate) => existsSync(candidate))
.sort((left, right) => {
const leftThumb = this.isThumbnailName(basename(left)) ? 1 : 0
const rightThumb = this.isThumbnailName(basename(right)) ? 1 : 0
if (leftThumb !== rightThumb) return leftThumb - rightThumb
return statSync(right).size - statSync(left).size
})
candidates.push(...siblings)
}
for (const candidate of this.uniq(candidates)) {
const data = this.decryptImageToBase64(candidate)
if (data) return { data, filePath: candidate }
}
imageDecryptLog('[ImageDecrypt] all variants failed:', this.uniq(candidates))
return null
}
async decryptImageToBase64WithFallbackAsync(
datPath: string,
allowThumbnail = true
): Promise<{ data: string; filePath: string } | null> {
if (!existsSync(datPath)) return null
return new Promise((resolve) => {
let settled = false
const worker = new Worker(IMAGE_DECRYPT_WORKER_SOURCE, {
eval: true,
workerData: {
datPath,
allowThumbnail,
xorKey: this.xorKey,
aesKey: this.aesKey,
ffmpegPath: resolveFfmpegExecutable()
}
})
const timeout = setTimeout(() => {
imageDecryptLog('[ImageDecrypt] worker timed out:', datPath)
void worker.terminate()
finish(null)
}, 30_000)
const finish = (result: { data: string; filePath: string } | null): void => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(result)
}
worker.once('message', (value: unknown) => {
if (
value &&
typeof value === 'object' &&
typeof (value as { data?: unknown }).data === 'string' &&
typeof (value as { filePath?: unknown }).filePath === 'string'
) {
finish(value as { data: string; filePath: string })
return
}
finish(null)
})
worker.once('error', (error) => {
imageDecryptLog('[ImageDecrypt] worker failed:', error)
finish(null)
})
worker.once('exit', () => finish(null))
})
}
/**
* 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。
* 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。
*/
private getDatVersion(inputPath: string): number {
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, 0x32, 0x08, 0x07]))) {
return 2
}
return 0
}
/**
* V4 解密 - AES + XOR
*/
private decryptDatV4(inputPath: string, 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 字节边界
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > data.length) {
throw new Error('文件格式异常:AES 数据长度超过文件实际长度')
}
// 解密 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()])
unpadded = this.strictRemovePadding(decrypted)
}
// 解密 XOR 数据
const remaining = data.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) {
throw new Error('文件格式异常:XOR 数据长度不合法')
}
let rawData: Buffer
let xoredData: Buffer
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] ^ this.xorKey
}
} else {
rawData = remaining
xoredData = Buffer.alloc(0)
}
return Buffer.concat([unpadded, rawData, xoredData])
}
/**
* 检测图片扩展名
*/
private detectImageExtension(buffer: Buffer): string | null {
if (buffer.length < 4) return null
const SIGNATURES: Record<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])
}
for (const [ext, sig] of Object.entries(SIGNATURES)) {
if (this.compareBytes(buffer.subarray(0, sig.length), sig)) {
return ext
}
}
return null
}
private getMimeType(ext: string): string {
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp'
}
return mimeTypes[ext] || 'image/jpeg'
}
private normalizeDatBase(value: string): string {
const lower = String(value || '')
.trim()
.toLowerCase()
if (!lower) return ''
const file = lower.split('/').pop()?.split('\\').pop() || lower
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
return withoutDat
.replace(/(_thumb|\.thumb|_hd|\.hd|_h_m|_h|\.h|_t_m|_t|\.t|_m|_b|_w|_c)$/i, '')
.toLowerCase()
}
private getSessionDirectoryName(sessionId?: string): string {
const value = String(sessionId || '').trim()
if (!value) return ''
if (/^[a-f0-9]{32}$/i.test(value)) return value.toLowerCase()
return crypto.createHash('md5').update(value).digest('hex')
}
private getImageMonth(createTime?: number): string {
if (!createTime || !Number.isFinite(createTime)) return ''
const date = new Date(createTime * 1000)
if (Number.isNaN(date.getTime())) return ''
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}
private prioritizeImageMonth(months: string[], createTime?: number): string[] {
const preferred = this.getImageMonth(createTime)
if (!preferred || !months.includes(preferred)) return months
return [preferred, ...months.filter((month) => month !== preferred)]
}
private getImageMonthDirectories(root: string, createTime?: number): string[] {
try {
const months = readdirSync(root)
.filter((name) => /^\d{4}-\d{2}$/.test(name) && existsSync(join(root, name)))
.sort((left, right) => right.localeCompare(left))
return this.prioritizeImageMonth(months, createTime)
} catch {
return []
}
}
private getRecentImageMonths(count: number): string[] {
const now = new Date()
return Array.from({ length: count }, (_, index) => {
const date = new Date(now.getFullYear(), now.getMonth() - index, 1)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
})
}
private findBubblePreview(
accountDir: string,
imageKeys: string[],
sessionDirectory: string,
createTime?: number
): string | null {
const cacheRoot = join(accountDir, 'cache')
const months = this.getImageMonthDirectories(cacheRoot, createTime)
const previewNames = imageKeys.flatMap((key) => [
`${key}_b.dat`,
`${key}_w.dat`,
`${key}_c.dat`,
`${key}_t_M.dat`,
`${key}_t.dat`
])
for (const month of months) {
const bubbleDir = join(cacheRoot, month, 'Message', sessionDirectory, 'Bubble')
const found = this.getLargestExistingPath(
previewNames.map((name) => join(bubbleDir, name)),
true,
true
)
if (found) return found
}
return null
}
private async findBubblePreviewAsync(
accountDir: string,
imageKeys: string[],
sessionDirectory: string,
createTime?: number
): Promise<string | null> {
const cacheRoot = join(accountDir, 'cache')
let months: string[]
try {
months = (await fsPromises.readdir(cacheRoot, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name))
.map((entry) => entry.name)
.sort((left, right) => right.localeCompare(left))
} catch {
return null
}
months = this.prioritizeImageMonth(months, createTime)
const previewNames = imageKeys.flatMap((key) => [
`${key}_b.dat`,
`${key}_w.dat`,
`${key}_c.dat`,
`${key}_t_M.dat`,
`${key}_t.dat`
])
for (const month of months) {
const bubbleDir = join(cacheRoot, month, 'Message', sessionDirectory, 'Bubble')
const found = await this.getLargestExistingPathAsync(
previewNames.map((name) => join(bubbleDir, name)),
true,
true
)
if (found) return found
}
return null
}
private buildPreferredDatNames(baseName: string): string[] {
const base = this.normalizeDatBase(baseName)
if (!base) return []
return [
`${base}.dat`,
`${base}_hd.dat`,
`${base}_h_M.dat`,
`${base}_h.dat`,
`${base}_M.dat`,
`${base}_b.dat`,
`${base}_w.dat`,
`${base}_c.dat`,
`${base}_t_M.dat`,
`${base}_t.dat`,
`${base}.thumb.dat`,
`${base}_thumb.dat`
]
}
private getPreferredDatVariantPath(
inputPath: string,
allowThumbnail: boolean,
preferThumbnail = false
): string {
const actualDir = dirname(inputPath)
const base = this.normalizeDatBase(basename(inputPath))
const variants = this.buildPreferredDatNames(base)
const ordered = allowThumbnail
? variants
: variants.filter((name) => !this.isThumbnailName(name))
const largest = this.getLargestExistingPath(
ordered.map((variant) => join(actualDir, variant)),
allowThumbnail,
preferThumbnail
)
if (largest) return largest
return inputPath
}
private getLargestExistingPath(
paths: string[],
allowThumbnail: boolean,
preferThumbnail = false
): string | null {
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
candidates
.filter((candidate) => existsSync(candidate))
.map((candidate) => {
try {
return { candidate, size: statSync(candidate).size }
} catch {
return { candidate, size: 0 }
}
})
.sort((left, right) => right.size - left.size)
const thumbnail = toSized(
paths.filter((candidate) => imageFileQuality(candidate) === 'thumbnail')
)
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
const allowed = toSized(paths)
.filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail')
.sort(
(left, right) =>
imageQualityRank(imageFileQuality(right.candidate)) -
imageQualityRank(imageFileQuality(left.candidate)) || right.size - left.size
)
return allowed[0]?.candidate || null
}
private async getLargestExistingPathAsync(
paths: string[],
allowThumbnail: boolean,
preferThumbnail = false
): Promise<string | null> {
const sized = (
await Promise.all(
paths.map(async (candidate) => {
try {
const stat = await fsPromises.stat(candidate)
return stat.isFile() ? { candidate, size: stat.size } : null
} catch {
return null
}
})
)
)
.filter((entry): entry is { candidate: string; size: number } => entry !== null)
.sort((left, right) => right.size - left.size)
if (preferThumbnail) {
const thumbnail = sized.find((entry) => imageFileQuality(entry.candidate) === 'thumbnail')
if (thumbnail) return thumbnail.candidate
}
const allowed = sized
.filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail')
.sort(
(left, right) =>
imageQualityRank(imageFileQuality(right.candidate)) -
imageQualityRank(imageFileQuality(left.candidate)) || right.size - left.size
)
return allowed[0]?.candidate || null
}
private isThumbnailName(fileName: string): boolean {
return imageFileQuality(fileName) === 'thumbnail'
}
isThumbnailFile(filePath: string): boolean {
return this.isThumbnailName(basename(filePath))
}
private unwrapWxgf(buffer: Buffer): Buffer {
if (!this.isWxgfBuffer(buffer)) {
return buffer
}
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i += 1) {
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
return buffer.subarray(i)
}
if (
buffer[i] === 0x89 &&
buffer[i + 1] === 0x50 &&
buffer[i + 2] === 0x4e &&
buffer[i + 3] === 0x47
) {
return buffer.subarray(i)
}
}
return buffer
}
private isWxgfBuffer(buffer: Buffer): boolean {
return (
buffer.length >= 20 &&
buffer[0] === 0x77 &&
buffer[1] === 0x78 &&
buffer[2] === 0x67 &&
buffer[3] === 0x66
)
}
private uniq(values: string[]): string[] {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
}
private bytesToInt32(bytes: Buffer): number {
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
}
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
}
private strictRemovePadding(buffer: Buffer): Buffer {
if (buffer.length === 0) return buffer
const lastByte = buffer[buffer.length - 1]
if (lastByte <= 0 || lastByte > 16 || lastByte > buffer.length) {
throw new Error('invalid PKCS#7 padding')
}
const paddingLength = lastByte
for (let i = buffer.length - paddingLength; i < buffer.length; i += 1) {
if (buffer[i] !== lastByte) throw new Error('invalid PKCS#7 padding')
}
return buffer.subarray(0, buffer.length - paddingLength)
}
}