mirror of
https://fastgit.cc/github.com/hicccc77/WeFlow
synced 2026-04-20 21:01:15 +08:00
1998 lines
71 KiB
TypeScript
1998 lines
71 KiB
TypeScript
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()
|