Files
WeFlow/electron/services/imageDecryptService.ts
2026-09-28 21:16:43 +08:00

3024 lines
114 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join, resolve as resolvePath } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs'
import { writeFile, rename, rm, readdir, appendFile, readFile } from 'fs/promises'
import { homedir, tmpdir } from 'os'
import crypto from 'crypto'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { decryptDatViaNativeAsync, 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
allowCachePromotion?: boolean
allowFilesystemScan?: boolean
suppressEvents?: boolean
}
type DecryptImagePayload = CachedImagePayload & {
force?: boolean
}
type ImageCacheOperationContext = {
generation: number
accountScope: string
accountDir: string | null
}
type CorruptedCacheValidationTask = {
key: string
filePath: string
generation: number
accountScope: string
}
type PendingImageTask<T extends DecryptResult> = {
promise: Promise<T>
emitsEvents: boolean
context: ImageCacheOperationContext
}
export class ImageDecryptService {
private configService = new ConfigService()
private resolvedCache = new Map<string, string>()
private pending = new Map<string, PendingImageTask<DecryptResult>>()
private resolvePending = new Map<string, PendingImageTask<DecryptResult & { hasUpdate?: boolean }>>()
private updateFlags = new Map<string, boolean>()
private nativeLogged = false
private runtimeConfig: { dbPath?: string; myAccountId?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
private datNameScanMissAt = new Map<string, number>()
private readonly datNameScanMissTtlMs = 1200
private readonly maxDatNameScanMissEntries = 2400
private readonly accountDirCache = new Map<string, string>()
private cacheGeneration = 0
private readonly ensuredDirs = new Set<string>()
private readonly maxResolvedCacheEntries = 12000
private pendingLogLines: string[] = []
private logFlushTimer: ReturnType<typeof setTimeout> | null = null
private corruptedCacheCheckPending = new Set<string>()
private corruptedCacheCheckQueue: CorruptedCacheValidationTask[] = []
private activeCorruptedCacheChecks = 0
private corruptedCacheCheckLastAt = new Map<string, number>()
private corruptedCachePathUntil = new Map<string, number>()
private readonly maxPendingLogLines = 1000
private readonly logFlushDelayMs = 200
private readonly corruptedCacheCheckCooldownMs = 5 * 60 * 1000
private readonly corruptedCacheQuarantineMs = 5 * 60 * 1000
private readonly maxConcurrentCorruptedCacheChecks = 1
private readonly maxCorruptedCacheChecks = 6000
private cacheMutationQueue: Promise<void> = Promise.resolve()
private cacheClearDepth = 0
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)
}
setRuntimeConfig(config: { dbPath?: string; myAccountId?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
this.runtimeConfig = config
}
private normalizeAccountDirIdentity(accountDir: string): string {
let normalized = resolvePath(String(accountDir || '').trim()).replace(/[\\/]+$/, '')
if (process.platform === 'win32') normalized = normalized.toLowerCase()
return normalized
}
private getAccountScopeForDir(accountDir: string): string {
const identity = this.normalizeAccountDirIdentity(accountDir)
return `account-${crypto.createHash('sha256').update(identity, 'utf8').digest('hex')}`
}
private getCurrentAccountScopeSnapshot(): { accountDir: string | null; accountScope: string } {
const accountDir = this.resolveCurrentAccountDir()
if (accountDir) {
return { accountDir, accountScope: this.getAccountScopeForDir(accountDir) }
}
// 未解析出目录时也不能退化成所有账号共享的 key;配置组合只用于隔离失败态,
// 真正读写图片缓存仍要求 accountDir 存在。
const fallbackIdentity = [
'unresolved-account-dir',
this.getConfiguredDbPath(),
this.getConfiguredMyAccountId()
].join('\0')
return {
accountDir: null,
accountScope: `account-${crypto.createHash('sha256').update(fallbackIdentity, 'utf8').digest('hex')}`
}
}
private createOperationContext(): ImageCacheOperationContext {
const { accountDir, accountScope } = this.getCurrentAccountScopeSnapshot()
return {
generation: this.cacheGeneration,
accountScope,
accountDir
}
}
private isOperationCurrent(context: ImageCacheOperationContext): boolean {
if (this.cacheClearDepth > 0) return false
if (context.generation !== this.cacheGeneration) return false
return context.accountScope === this.getCurrentAccountScopeSnapshot().accountScope
}
private getMemoryScope(context: ImageCacheOperationContext): string {
return `${context.accountScope}|generation-${context.generation}`
}
private getStaleOperationResult<T extends DecryptResult>(): T {
return {
success: false,
error: '图片缓存已清理或账号已切换,请重试',
failureKind: 'not_found'
} as T
}
private runCacheMutation<T>(operation: () => Promise<T>): Promise<T> {
const result = this.cacheMutationQueue.then(operation, operation)
this.cacheMutationQueue = result.then(() => undefined, () => undefined)
return result
}
private async finalizeOperationResult<T extends DecryptResult>(
context: ImageCacheOperationContext,
result: T
): Promise<T> {
if (this.isOperationCurrent(context)) return result
return this.getStaleOperationResult<T>()
}
private getConfiguredDbPath(): string {
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
}
private getConfiguredMyAccountId(): string {
return String(this.runtimeConfig?.myAccountId || this.configService.getMyAccountIdCleaned() || '').trim()
}
private getConfiguredImageKeys(): { xorKey: unknown; aesKey: string } {
const runtimeImageXorKey = this.runtimeConfig?.imageXorKey
const hasRuntimeXorKey = runtimeImageXorKey !== undefined && runtimeImageXorKey !== null && String(runtimeImageXorKey).trim() !== ''
const runtimeAesKey = String(this.runtimeConfig?.imageAesKey || '').trim()
if (hasRuntimeXorKey || runtimeAesKey) {
const fallback = this.configService.getImageKeysForCurrentAccountId()
return {
xorKey: hasRuntimeXorKey ? runtimeImageXorKey : fallback.xorKey,
aesKey: runtimeAesKey || fallback.aesKey
}
}
return this.configService.getImageKeysForCurrentAccountId()
}
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 {
if (!line) return
this.pendingLogLines.push(line)
while (this.pendingLogLines.length > this.maxPendingLogLines) {
this.pendingLogLines.shift()
}
if (this.logFlushTimer !== null) return
this.logFlushTimer = setTimeout(() => {
this.logFlushTimer = null
void this.flushPendingLogs()
}, this.logFlushDelayMs)
}
private async flushPendingLogs(): Promise<void> {
if (this.pendingLogLines.length === 0) return
const lines = this.pendingLogLines.splice(0, this.pendingLogLines.length).join('')
try {
const logDir = join(this.getUserDataPath(), 'logs')
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true })
}
await appendFile(join(logDir, 'wcdb.log'), lines, { encoding: 'utf8' })
} catch (err) {
console.error('写入日志失败:', err)
} finally {
if (this.pendingLogLines.length > 0 && this.logFlushTimer === null) {
this.logFlushTimer = setTimeout(() => {
this.logFlushTimer = null
void this.flushPendingLogs()
}, this.logFlushDelayMs)
}
}
}
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
const context = this.createOperationContext()
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
if (!cacheKey) {
return { success: false, error: '缺少图片标识', failureKind: 'not_found' }
}
const pendingKey = this.getResolvePendingKey(payload, cacheKey, context)
const pending = this.resolvePending.get(pendingKey)
if (pending) return this.resolvePendingResult(pending, payload, cacheKey)
const task = this.resolveCachedImageInternal({ ...payload, preferFilePath: true }, cacheKeys, cacheKey, context)
const pendingTask: PendingImageTask<DecryptResult & { hasUpdate?: boolean }> = {
promise: task,
emitsEvents: this.shouldEmitImageEvents(payload),
context
}
this.resolvePending.set(pendingKey, pendingTask)
try {
return await this.resolvePendingResult(pendingTask, payload, cacheKey)
} finally {
if (this.resolvePending.get(pendingKey) === pendingTask) {
this.resolvePending.delete(pendingKey)
}
}
}
private async resolveCachedImageInternal(
payload: CachedImagePayload,
cacheKeys: string[],
cacheKey: string,
context: ImageCacheOperationContext
): Promise<DecryptResult & { hasUpdate?: boolean }> {
if (!this.isOperationCurrent(context)) return this.getStaleOperationResult()
for (const key of cacheKeys) {
const cached = this.getResolvedMediaCache(payload, key, context)
if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) {
const upgraded = !this.isHdPath(cached) && payload.allowCachePromotion !== false
? await this.tryPromoteThumbnailCache(payload, key, cached, context)
: null
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
const finalPath = upgraded || cached
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
const isNonHd = !this.isHdPath(finalPath)
const hasUpdate = isNonHd ? this.getUpdateFlag(payload, key, context) : false
if (isNonHd) {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, key, finalPath, context)
}
} else {
this.clearUpdateFlag(payload, key, context)
}
this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath))
return this.finalizeOperationResult(context, { success: true, localPath, hasUpdate })
}
if (cached && !this.isUsableImageCacheFile(cached)) {
this.deleteResolvedMediaCache(payload, key, context)
}
}
const accountDir = context.accountDir
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,
allowFilesystemScan: payload.allowFilesystemScan !== false
},
context
)
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
if (datPath) {
const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false, context)
if (existing) {
const upgraded = !this.isHdPath(existing) && payload.allowCachePromotion !== false
? await this.tryPromoteThumbnailCache(payload, cacheKey, existing, context)
: null
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
const finalPath = upgraded || existing
this.cacheResolvedPaths(payload, cacheKey, finalPath, context)
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
const isNonHd = !this.isHdPath(finalPath)
const hasUpdate = isNonHd ? this.getUpdateFlag(payload, cacheKey, context) : false
if (isNonHd) {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, cacheKey, finalPath, context)
}
} else {
this.clearUpdateFlag(payload, cacheKey, context)
}
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath))
return this.finalizeOperationResult(context, { success: true, localPath, hasUpdate })
}
}
}
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
return this.finalizeOperationResult(context, { success: false, error: '未找到缓存图片', failureKind: 'not_found' })
}
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
const context = this.createOperationContext()
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.getResolvedMediaCache(payload, key, context)
if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached) && this.isHdPath(cached)) {
this.cacheResolvedPaths(payload, cacheKey, cached, context)
this.clearUpdateFlags(payload, cacheKey, context)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return this.finalizeOperationResult(context, { success: true, localPath })
}
if (cached && !this.isUsableImageCacheFile(cached)) {
this.deleteResolvedMediaCache(payload, key, context)
}
}
}
if (!payload.force) {
const cached = this.getResolvedMediaCache(payload, cacheKey, context)
if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) {
const upgraded = !this.isHdPath(cached)
? await this.tryPromoteThumbnailCache(payload, cacheKey, cached, context)
: null
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
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 this.finalizeOperationResult(context, { success: true, localPath })
}
if (cached && !this.isUsableImageCacheFile(cached)) {
this.deleteResolvedMediaCache(payload, cacheKey, context)
}
}
const pendingKey = this.getDecryptPendingKey(payload, cacheKey, context)
const pending = this.pending.get(pendingKey)
if (pending) {
this.emitDecryptProgress(payload, cacheKey, 'queued', 8, 'running')
return this.resolvePendingResult(pending, payload, cacheKey)
}
const task = this.decryptImageInternal({ ...payload, preferFilePath: true }, cacheKey, context)
const pendingTask: PendingImageTask<DecryptResult> = {
promise: task,
emitsEvents: this.shouldEmitImageEvents(payload),
context
}
this.pending.set(pendingKey, pendingTask)
try {
return await this.resolvePendingResult(pendingTask, payload, cacheKey)
} finally {
if (this.pending.get(pendingKey) === pendingTask) {
this.pending.delete(pendingKey)
}
}
}
async preloadImageHardlinkMd5s(
md5List: string[],
options?: { chunkSize?: number; yieldMs?: number; filesystemFallback?: boolean }
): Promise<void> {
const normalizedList = Array.from(
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
)
if (normalizedList.length === 0) return
const context = this.createOperationContext()
const accountDir = context.accountDir
if (!accountDir) return
try {
const chunkSize = Math.max(1, Math.min(256, Math.floor(Number(options?.chunkSize || normalizedList.length))))
const yieldMs = Math.max(0, Math.min(100, Math.floor(Number(options?.yieldMs || 0))))
const allowFilesystemFallback = options?.filesystemFallback !== false
for (let offset = 0; offset < normalizedList.length; offset += chunkSize) {
if (!this.isOperationCurrent(context)) return
const chunk = normalizedList.slice(offset, offset + chunkSize).filter((md5) => this.looksLikeMd5(md5))
if (chunk.length === 0) continue
const resolvedByBatch = new Set<string>()
try {
const result = await wcdbService.resolveImageHardlinkBatch(chunk.map((md5) => ({ md5, accountDir })))
if (!this.isOperationCurrent(context)) return
const rows = Array.isArray(result?.rows) ? result.rows : []
for (const row of rows) {
const md5 = String(row?.md5 || '').trim().toLowerCase()
if (!md5 || !row?.success || !row.data) continue
const fileName = String(row.data.file_name || '').trim()
const fullPath = String(row.data.full_path || '').trim()
if (!fileName || !fullPath) continue
const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName)
if (!selectedPath || !existsSync(selectedPath)) continue
this.cacheDatPath(accountDir, md5, selectedPath, context)
const selectedFileName = basename(selectedPath).toLowerCase()
if (selectedFileName) this.cacheDatPath(accountDir, selectedFileName, selectedPath, context)
resolvedByBatch.add(md5)
}
} catch {
// fall through to optional filesystem fallback
}
if (!allowFilesystemFallback) {
if (yieldMs > 0 && offset + chunkSize < normalizedList.length) {
await new Promise((resolve) => setTimeout(resolve, yieldMs))
}
continue
}
for (const md5 of chunk) {
if (resolvedByBatch.has(md5)) continue
const selectedPath = this.selectBestDatPathByBase(accountDir, md5, undefined, undefined, true)
if (!selectedPath) continue
this.cacheDatPath(accountDir, md5, selectedPath, context)
const fileName = basename(selectedPath).toLowerCase()
if (fileName) this.cacheDatPath(accountDir, fileName, selectedPath, context)
}
if (yieldMs > 0 && offset + chunkSize < normalizedList.length) {
await new Promise((resolve) => setTimeout(resolve, yieldMs))
}
}
} catch {
// ignore preload failures
}
}
emitCacheResolvedPath(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; suppressEvents?: boolean },
cacheKey: string,
localPath: string
): void {
const normalizedCacheKey = String(cacheKey || '').trim()
const resolvedPath = String(localPath || '').trim()
if (!normalizedCacheKey || !resolvedPath) return
if (/^(data:image\/|blob:|https?:\/\/)/i.test(resolvedPath)) {
this.emitCacheResolved(payload, normalizedCacheKey, resolvedPath)
return
}
const filePath = /^file:\/\//i.test(resolvedPath)
? (this.fileUrlToPathSafely(resolvedPath) || resolvedPath)
: resolvedPath
this.emitCacheResolved(payload, normalizedCacheKey, this.resolveEmitPath(filePath, true))
}
private async decryptImageInternal(
payload: DecryptImagePayload,
cacheKey: string,
context: ImageCacheOperationContext
): Promise<DecryptResult> {
if (!this.isOperationCurrent(context)) return this.getStaleOperationResult()
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
try {
const accountId = this.getConfiguredMyAccountId()
const dbPath = this.getConfiguredDbPath()
if (!accountId || !dbPath) {
this.logError('配置缺失', undefined, { accountId: !!accountId, dbPath: !!dbPath })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
return { success: false, error: '未配置账号或数据库路径', failureKind: 'not_found' }
}
const accountDir = context.accountDir
if (!accountDir) {
this.logError('未找到账号目录', undefined, { dbPath, accountId })
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,
allowFilesystemScan: payload.allowFilesystemScan !== false
},
context
)
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
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,
allowFilesystemScan: payload.allowFilesystemScan !== false
},
context
)
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
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,
allowFilesystemScan: payload.allowFilesystemScan !== false
},
context
)
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
}
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')) {
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
this.cacheResolvedPaths(payload, cacheKey, datPath, context)
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 this.finalizeOperationResult(context, { success: true, localPath, isThumb })
}
const preferHdCache = Boolean(payload.force && !fallbackToThumbnail)
const existingFast = this.findCachedOutputByDatPath(datPath, payload.sessionId, preferHdCache, context)
if (existingFast) {
this.logInfo('找到已解密文件(按DAT快速命中)', { existing: existingFast, isHd: this.isHdPath(existingFast) })
const isHd = this.isHdPath(existingFast)
if (!(payload.force && !isHd)) {
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
this.cacheResolvedPaths(payload, cacheKey, existingFast, context)
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 this.finalizeOperationResult(context, { success: true, localPath, isThumb })
}
}
// 优先使用当前 accountId 对应的密钥,找不到则回退到全局配置
const imageKeys = this.getConfiguredImageKeys()
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文件', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
const nativeResult = await this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
if (!nativeResult) {
const notConfigured = !nativeAddonLocation()
const message = notConfigured
? '未配置媒体解密实现,请在设置中指定第三方插件路径'
: '媒体解密失败,请检查密钥配置'
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', message)
return { success: false, error: message, failureKind: 'not_found' }
}
let decrypted: Buffer = nativeResult.data
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running')
// 统一走原有 wxgf/ffmpeg 流程,确保行为与历史版本一致
const wxgfResult = await this.unwrapWxgf(decrypted, context)
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
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
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId, context)
this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running')
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
// Write to an operation-owned temporary file first. Committing the shared
// final path is serialized with clearCache, so an old operation can neither
// resurrect a file after clear nor delete a newer operation's replacement.
const temporaryOutputPath = `${outputPath}.${process.pid}.${crypto.randomUUID()}.tmp`
let committed = false
try {
await writeFile(temporaryOutputPath, decrypted)
await this.runCacheMutation(async () => {
if (!this.isOperationCurrent(context)) return
await rename(temporaryOutputPath, outputPath)
committed = true
})
if (!committed) {
return this.finalizeOperationResult(context, this.getStaleOperationResult())
}
} finally {
if (!committed) {
await rm(temporaryOutputPath, { force: true }).catch(() => {})
}
}
if (!this.isOperationCurrent(context)) return this.finalizeOperationResult(context, this.getStaleOperationResult())
this.logInfo('解密成功', { outputPath, size: decrypted.length })
const isThumb = this.isThumbnailPath(datPath)
const isHdCache = this.isHdPath(outputPath)
this.removeDuplicateCacheCandidates(datPath, payload.sessionId, outputPath, context)
this.cacheResolvedPaths(payload, cacheKey, outputPath, context)
if (isHdCache) {
this.clearUpdateFlags(payload, cacheKey, context)
} else {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, cacheKey, outputPath, context)
}
}
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 this.finalizeOperationResult(context, { 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, accountId: string): string | null {
return this.configService.getAccountDir(dbPath, accountId)
}
private resolveCurrentAccountDir(): string | null {
const dbPath = this.getConfiguredDbPath()
const accountId = this.getConfiguredMyAccountId()
if (!dbPath || !accountId) return null
return this.resolveAccountDir(dbPath, accountId)
}
/**
* 获取解密后的缓存目录(用于查找 hardlink.db)
*/
private getDecryptedCacheDir(accountId: string): string | null {
const cachePath = this.configService.get('cachePath')
if (!cachePath) return null
const cleanedAccountId = this.cleanAccountDirName(accountId)
const cacheAccountDir = join(cachePath, cleanedAccountId)
// 检查缓存目录下是否有 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('accountId_')) {
const match = trimmed.match(/^(accountId_[^_]+)/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 | undefined,
imageDatName: string | undefined,
sessionId: string | undefined,
createTime: number | undefined,
options: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean; allowDatNameScanFallback?: boolean; allowFilesystemScan?: boolean } | undefined,
context: ImageCacheOperationContext
): Promise<string | null> {
if (!this.isOperationCurrent(context)) return null
if (this.getAccountScopeForDir(accountDir) !== context.accountScope) return null
const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false
const hardlinkOnly = options?.hardlinkOnly ?? false
const allowDatNameScanFallback = options?.allowDatNameScanFallback ?? true
const allowFilesystemScan = options?.allowFilesystemScan !== false
this.logInfo('[ImageDecrypt] resolveDatPath', {
imageMd5,
imageDatName,
createTime,
allowThumbnail,
skipResolvedCache,
hardlinkOnly,
allowDatNameScanFallback,
allowFilesystemScan
})
const lookupBases = this.collectLookupBasesForScan(imageMd5, imageDatName, allowDatNameScanFallback)
if (lookupBases.length === 0) {
this.logInfo('[ImageDecrypt] resolveDatPath miss (no lookup base)', { imageMd5, imageDatName })
return null
}
const missKey = this.getResolveDatPathMissKey(
this.getMemoryScope(context),
lookupBases,
imageMd5,
imageDatName,
sessionId,
createTime,
allowThumbnail,
allowDatNameScanFallback,
skipResolvedCache,
allowFilesystemScan
)
const lastMiss = this.getDatNameScanMissAt(missKey)
if (lastMiss && (Date.now() - lastMiss) < this.datNameScanMissTtlMs) {
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 = `${this.getMemoryScope(context)}|dat-source|${cacheKey}`
const cached = this.getResolvedCache(scopedKey)
if (!cached || !existsSync(cached)) continue
if (!allowThumbnail && !this.isHdDatPath(cached)) continue
return cached
}
}
if (hardlinkOnly) {
for (const baseMd5 of lookupBases) {
if (!this.looksLikeMd5(baseMd5)) continue
const hardlinkPath = await this.resolveHardlinkPath(accountDir, baseMd5, sessionId)
if (!this.isOperationCurrent(context)) return null
if (!hardlinkPath) continue
if (!allowThumbnail && !this.isHdDatPath(hardlinkPath)) continue
this.cacheDatPath(accountDir, baseMd5, hardlinkPath, context)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hardlinkPath, context)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath, context)
const normalizedFileName = basename(hardlinkPath).toLowerCase()
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, hardlinkPath, context)
this.datNameScanMissAt.delete(missKey)
return hardlinkPath
}
}
if (!allowFilesystemScan) {
if (this.isOperationCurrent(context)) this.setDatNameScanMissAt(missKey, Date.now())
return null
}
for (const baseMd5 of lookupBases) {
if (!this.isOperationCurrent(context)) return null
const selectedPath = this.selectBestDatPathByBase(accountDir, baseMd5, sessionId, createTime, allowThumbnail)
if (!selectedPath) continue
this.cacheDatPath(accountDir, baseMd5, selectedPath, context)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, selectedPath, context)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, selectedPath, context)
const normalizedFileName = basename(selectedPath).toLowerCase()
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, selectedPath, context)
this.logInfo('[ImageDecrypt] dat scan selected', {
baseMd5,
selectedPath,
allowThumbnail
})
this.datNameScanMissAt.delete(missKey)
return selectedPath
}
if (this.isOperationCurrent(context)) this.setDatNameScanMissAt(missKey, Date.now())
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,
context: ImageCacheOperationContext
): Promise<boolean> {
if (!this.isOperationCurrent(context)) return false
if (!cachedPath || !existsSync(cachedPath)) return false
if (this.isHdPath(cachedPath)) return false
const accountDir = context.accountDir
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; suppressEvents?: boolean },
cacheKey: string,
cachedPath: string,
context: ImageCacheOperationContext
): Promise<string | null> {
if (!this.isOperationCurrent(context)) return null
if (!cachedPath || !existsSync(cachedPath)) return null
if (!this.isImageFile(cachedPath)) return null
if (this.isHdPath(cachedPath)) return null
const accountDir = context.accountDir
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 },
context
)
if (!this.isOperationCurrent(context)) return null
if (!hdDatPath) return null
const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true, context)
if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && this.isHdPath(existingHd)) {
this.cacheResolvedPaths(payload, cacheKey, existingHd, context)
this.clearUpdateFlags(payload, cacheKey, context)
this.removeThumbnailCacheFile(cachedPath, existingHd, context)
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,
suppressEvents: payload.suppressEvents === true
})
if (!this.isOperationCurrent(context)) return null
if (!upgraded.success) return null
const cachedResult = this.getResolvedMediaCache(payload, cacheKey, context)
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(payload, cacheKey, upgradedPath, context)
this.clearUpdateFlags(payload, cacheKey, context)
this.removeThumbnailCacheFile(cachedPath, upgradedPath, context)
this.logInfo('[ImageDecrypt] thumbnail cache upgraded', {
cacheKey,
oldPath: cachedPath,
newPath: upgradedPath,
mode: 're-decrypt'
})
return upgradedPath
}
private removeThumbnailCacheFile(oldPath: string, keepPath: string | undefined, context: ImageCacheOperationContext): void {
if (!oldPath) return
if (keepPath && oldPath === keepPath) return
if (this.isHdPath(oldPath)) return
void this.runCacheMutation(async () => {
if (!this.isOperationCurrent(context)) return
if (!existsSync(oldPath)) return
await 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,
context: ImageCacheOperationContext
): void {
if (!this.isOperationCurrent(context)) return
if (!this.shouldCheckImageUpdate(payload)) return
if (this.getUpdateFlag(payload, cacheKey, context)) return
void this.checkHasUpdate(payload, cacheKey, cachedPath, context).then(async (hasUpdate) => {
if (!this.isOperationCurrent(context)) return
if (!hasUpdate) return
this.setUpdateFlag(payload, cacheKey, true, context)
const upgradedPath = await this.tryAutoRefreshBetterCache(payload, cacheKey, cachedPath, context)
if (!this.isOperationCurrent(context)) return
if (upgradedPath) {
this.clearUpdateFlag(payload, cacheKey, context)
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,
context: ImageCacheOperationContext
): Promise<string | null> {
if (!this.isOperationCurrent(context)) return 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 (!this.isOperationCurrent(context)) return null
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(payload, cacheKey, refreshedPath, context)
this.removeThumbnailCacheFile(cachedPath, refreshedPath, context)
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.getDatNameScanMissAt(missKey)
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.setDatNameScanMissAt(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 getResolveDatPathMissKey(
accountDir: string,
lookupBases: string[],
imageMd5?: string,
imageDatName?: string,
sessionId?: string,
createTime?: number,
allowThumbnail = true,
allowDatNameScanFallback = true,
skipResolvedCache = false,
allowFilesystemScan = true
): string {
const bases = Array.from(new Set(lookupBases.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)))
.sort()
.join(',')
const md5 = String(imageMd5 || '').trim().toLowerCase()
const datName = String(imageDatName || '').trim().toLowerCase()
const safeSessionId = String(sessionId || '').trim().toLowerCase()
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
return [
accountDir,
'resolve-dat-miss',
safeSessionId,
monthKey,
allowThumbnail ? 'all' : 'hd',
allowDatNameScanFallback ? 'fallback1' : 'fallback0',
skipResolvedCache ? 'skip-cache1' : 'skip-cache0',
allowFilesystemScan ? 'fs-scan1' : 'fs-scan0',
md5,
datName,
bases
].join('|')
}
private getDatNameScanMissAt(key: string): number {
const missedAt = this.datNameScanMissAt.get(key) || 0
if (!missedAt) return 0
if ((Date.now() - missedAt) >= this.datNameScanMissTtlMs) {
this.datNameScanMissAt.delete(key)
return 0
}
this.datNameScanMissAt.delete(key)
this.datNameScanMissAt.set(key, missedAt)
return missedAt
}
private setDatNameScanMissAt(key: string, missedAt: number): void {
if (!key || !missedAt) return
if (this.datNameScanMissAt.has(key)) {
this.datNameScanMissAt.delete(key)
}
this.datNameScanMissAt.set(key, missedAt)
const expiresBefore = Date.now() - this.datNameScanMissTtlMs
for (const [missKey, value] of this.datNameScanMissAt.entries()) {
if (value >= expiresBefore) continue
this.datNameScanMissAt.delete(missKey)
}
while (this.datNameScanMissAt.size > this.maxDatNameScanMissEntries) {
const oldestKey = this.datNameScanMissAt.keys().next().value
if (!oldestKey) break
this.datNameScanMissAt.delete(oldestKey)
}
}
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 accountId = this.configService.get('myAccountId')
if (!dbPath || !decryptKey || !accountId) return false
const accountDir = this.configService.getAccountDir(dbPath, accountId)
if (!accountDir) return false
return await wcdbService.open(accountDir, decryptKey)
}
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 | undefined,
context: ImageCacheOperationContext
): 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(context), contactDir, timeDir)
this.ensureDir(outputDir)
return join(outputDir, `${normalizedBase}${suffix}${ext}`)
}
private buildCacheOutputCandidatesFromDat(
datPath: string,
sessionId: string | undefined,
preferHd: boolean,
context: ImageCacheOperationContext
): string[] {
if (!this.isOperationCurrent(context)) return []
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(context)
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,
context: ImageCacheOperationContext
): void {
const candidateSets = [
...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, false, context),
...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, true, context)
]
const candidates = Array.from(new Set(candidateSets))
void this.runCacheMutation(async () => {
if (!this.isOperationCurrent(context)) return
for (const candidate of candidates) {
if (!candidate || candidate === keepPath) continue
if (!existsSync(candidate)) continue
if (!this.isImageFile(candidate)) continue
await rm(candidate, { force: true }).catch(() => { })
}
})
}
private findCachedOutputByDatPath(
datPath: string,
sessionId: string | undefined,
preferHd: boolean,
context: ImageCacheOperationContext
): string | null {
if (!this.isOperationCurrent(context)) return null
const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd, context)
for (const candidate of candidates) {
if (!existsSync(candidate)) continue
if (this.isUsableImageCacheFile(candidate)) return candidate
}
return null
}
private cacheResolvedPaths(
payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string },
cacheKey: string,
outputPath: string,
context: ImageCacheOperationContext
): void {
if (!this.isOperationCurrent(context)) return
const normalizedCacheKey = String(cacheKey || '').trim().toLowerCase()
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
const imageDatName = String(payload.imageDatName || '').trim().toLowerCase()
if (imageMd5) {
this.setResolvedCache(this.getMediaResolvedCacheKey(payload, imageMd5, context), outputPath)
}
if (normalizedCacheKey) {
this.setResolvedCache(this.getMediaResolvedCacheKey(payload, normalizedCacheKey, context), outputPath)
}
if (imageDatName) {
this.setResolvedCache(this.getMediaResolvedCacheKey(payload, imageDatName, context), outputPath)
}
}
private getResolvedMediaCache(
payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string },
key: string,
context: ImageCacheOperationContext
): string | undefined {
if (!this.isOperationCurrent(context)) return undefined
return this.getResolvedCache(this.getMediaResolvedCacheKey(payload, key, context))
}
private deleteResolvedMediaCache(
payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string },
key: string,
context: ImageCacheOperationContext
): void {
if (!this.isOperationCurrent(context)) return
this.resolvedCache.delete(this.getMediaResolvedCacheKey(payload, key, context))
}
private deleteResolvedCachesByPath(filePath: string): void {
if (!filePath) return
for (const [key, value] of Array.from(this.resolvedCache.entries())) {
if (value !== filePath) continue
this.resolvedCache.delete(key)
this.updateFlags.delete(key)
}
}
private getResolvedCache(key: string): string | undefined {
const cached = this.resolvedCache.get(key)
if (cached === undefined) return undefined
this.resolvedCache.delete(key)
this.resolvedCache.set(key, cached)
return cached
}
private setResolvedCache(key: string, value: string): void {
if (!key || !value) return
if (this.resolvedCache.has(key)) {
this.resolvedCache.delete(key)
}
this.resolvedCache.set(key, value)
while (this.resolvedCache.size > this.maxResolvedCacheEntries) {
const oldestKey = this.resolvedCache.keys().next().value
if (!oldestKey) break
this.resolvedCache.delete(oldestKey)
this.updateFlags.delete(oldestKey)
}
}
private getMediaResolvedCacheKey(
payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string },
key: string,
context: ImageCacheOperationContext
): string {
const normalizedKey = String(key || '').trim().toLowerCase()
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
const scope = this.getMemoryScope(context)
if (imageMd5 && normalizedKey === imageMd5) return `${scope}|md5:${imageMd5}`
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
const createTime = Number(payload.createTime || 0) || 0
return `${scope}|dat:${sessionId}|${createTime}|${normalizedKey}`
}
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 getResolvePendingKey(payload: CachedImagePayload, cacheKey: string, context: ImageCacheOperationContext): string {
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
const createTime = Number(payload.createTime || 0) || 0
return [
'resolve',
this.getMemoryScope(context),
cacheKey.toLowerCase(),
sessionId,
String(createTime),
payload.hardlinkOnly === true ? 'hl1' : 'hl0',
payload.disableUpdateCheck === true ? 'du1' : 'du0',
payload.allowCacheIndex === false ? 'ci0' : 'ci1',
payload.allowCachePromotion === false ? 'cp0' : 'cp1'
].join('|')
}
private getDecryptPendingKey(payload: DecryptImagePayload, cacheKey: string, context: ImageCacheOperationContext): string {
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
const createTime = Number(payload.createTime || 0) || 0
return [
'decrypt',
this.getMemoryScope(context),
cacheKey.toLowerCase(),
sessionId,
String(createTime),
payload.force === true ? 'f1' : 'f0',
payload.hardlinkOnly === true ? 'hl1' : 'hl0',
payload.disableUpdateCheck === true ? 'du1' : 'du0',
payload.allowCacheIndex === false ? 'ci0' : 'ci1',
payload.allowCachePromotion === false ? 'cp0' : 'cp1'
].join('|')
}
private async resolvePendingResult<T extends DecryptResult>(
pending: PendingImageTask<T>,
payload: CachedImagePayload,
cacheKey: string
): Promise<T> {
const result = await pending.promise
const finalized = await this.finalizeOperationResult(pending.context, result)
if (!this.isOperationCurrent(pending.context)) return finalized
if (!pending.emitsEvents && this.shouldEmitImageEvents(payload)) {
pending.emitsEvents = true
this.emitCacheResolvedFromResult(payload, cacheKey, finalized)
}
return this.formatPendingResultForPayload(finalized, payload)
}
private formatPendingResultForPayload<T extends DecryptResult>(result: T, payload: CachedImagePayload): T {
const localPath = String(result.localPath || '').trim()
if (!result.success || !localPath) return result
let formattedPath = localPath
if (payload.preferFilePath === true) {
if (/^file:\/\//i.test(localPath)) {
formattedPath = this.fileUrlToPathSafely(localPath) || localPath
}
} else if (!/^(data:image\/|blob:|https?:\/\/)/i.test(localPath)) {
const filePath = /^file:\/\//i.test(localPath)
? (this.fileUrlToPathSafely(localPath) || localPath)
: localPath
formattedPath = this.resolveLocalPathForPayload(filePath, false)
}
if (formattedPath === localPath) return result
return { ...result, localPath: formattedPath }
}
private fileUrlToPathSafely(value: string): string | null {
try {
const url = new URL(value)
url.search = ''
url.hash = ''
return fileURLToPath(url)
} catch {
return null
}
}
private emitCacheResolvedFromResult(payload: CachedImagePayload, cacheKey: string, result: DecryptResult): void {
if (!result.success || !result.localPath) return
const localPath = String(result.localPath || '').trim()
if (!localPath) return
const emitPath = payload.preferFilePath === true && !/^(data:image\/|blob:|https?:\/\/|file:\/\/)/i.test(localPath)
? this.filePathToUrl(localPath)
: localPath
this.emitCacheResolved(payload, cacheKey, emitPath)
}
private cacheDatPath(
accountDir: string,
datName: string,
datPath: string,
context: ImageCacheOperationContext
): void {
if (!this.isOperationCurrent(context)) return
if (this.getAccountScopeForDir(accountDir) !== context.accountScope) return
const key = `${this.getMemoryScope(context)}|dat-source|${String(datName || '').toLowerCase()}`
this.setResolvedCache(key, datPath)
const normalized = this.normalizeDatBase(datName)
if (normalized && normalized !== datName.toLowerCase()) {
this.setResolvedCache(`${this.getMemoryScope(context)}|dat-source|${normalized}`, datPath)
}
}
private getUpdateFlag(
payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string },
cacheKey: string,
context: ImageCacheOperationContext
): boolean {
if (!this.isOperationCurrent(context)) return false
return this.updateFlags.get(this.getMediaResolvedCacheKey(payload, cacheKey, context)) ?? false
}
private setUpdateFlag(
payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string },
cacheKey: string,
value: boolean,
context: ImageCacheOperationContext
): void {
if (!this.isOperationCurrent(context)) return
const key = this.getMediaResolvedCacheKey(payload, cacheKey, context)
if (value) this.updateFlags.set(key, true)
else this.updateFlags.delete(key)
}
private clearUpdateFlag(
payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string },
cacheKey: string,
context: ImageCacheOperationContext
): void {
if (!this.isOperationCurrent(context)) return
this.updateFlags.delete(this.getMediaResolvedCacheKey(payload, cacheKey, context))
}
private clearUpdateFlags(
payload: { sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string },
cacheKey: string,
context: ImageCacheOperationContext
): void {
if (!this.isOperationCurrent(context)) return
const keys = new Set<string>()
const normalizedCacheKey = String(cacheKey || '').trim().toLowerCase()
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
const imageDatName = String(payload.imageDatName || '').trim().toLowerCase()
if (normalizedCacheKey) keys.add(this.getMediaResolvedCacheKey(payload, normalizedCacheKey, context))
if (imageMd5) keys.add(this.getMediaResolvedCacheKey(payload, imageMd5, context))
if (imageDatName) keys.add(this.getMediaResolvedCacheKey(payload, imageDatName, context))
for (const key of keys) {
this.updateFlags.delete(key)
}
}
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; createTime?: number; suppressEvents?: boolean }, cacheKey: string): void {
if (!this.shouldEmitImageEvents(payload)) return
const message = {
cacheKey,
sessionId: payload.sessionId,
createTime: payload.createTime,
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; createTime?: number; suppressEvents?: boolean }, cacheKey: string, localPath: string): void {
if (!this.shouldEmitImageEvents(payload)) return
const message = {
cacheKey,
sessionId: payload.sessionId,
createTime: payload.createTime,
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(context: ImageCacheOperationContext): string {
const configured = this.configService.get('cachePath')
const imagesRoot = configured
? join(configured, 'Images')
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
const root = join(imagesRoot, context.accountScope)
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 async tryDecryptDatWithNative(
datPath: string,
xorKey: number,
aesKey?: string
): Promise<{ data: Buffer; ext: string; isWxgf: boolean } | null> {
// worker 线程内执行原生解密,避免滚动预载时批量同步调用阻塞主线程
const result = await decryptDatViaNativeAsync(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'
})
}
}
if (result) return result
const fallback = await this.tryDecryptDatWithJs(datPath, xorKey, aesKey)
if (fallback) {
this.logInfo('JS DAT 解密 fallback 已启用', { datPath, ext: fallback.ext })
}
return fallback
}
private async tryDecryptDatWithJs(
datPath: string,
xorKey: number,
aesKey?: string
): Promise<{ data: Buffer; ext: string; isWxgf: boolean } | null> {
try {
const encrypted = await readFile(datPath)
const directExt = this.detectImageExtension(encrypted)
if (directExt) return { data: encrypted, ext: directExt, isWxgf: false }
const candidates: Buffer[] = []
const aesKeyText = String(aesKey || '').trim()
const datVersion = this.getDatVersion(encrypted)
if (datVersion === 2 && aesKeyText.length >= 16) {
try {
candidates.push(this.decryptDatV4WithJs(encrypted, xorKey, Buffer.from(aesKeyText, 'ascii').subarray(0, 16)))
} catch { }
}
if (datVersion !== 2) {
candidates.push(this.decryptDatV3WithJs(encrypted, xorKey))
}
for (const candidate of candidates) {
const ext = this.detectImageExtension(candidate)
if (ext) return { data: candidate, ext, isWxgf: false }
}
} catch (error) {
this.logError('JS DAT 解密 fallback 失败', error, { datPath })
}
return null
}
private decryptDatV3WithJs(data: Buffer, xorKey: number): Buffer {
const output = Buffer.allocUnsafe(data.length)
for (let i = 0; i < data.length; i += 1) {
output[i] = data[i] ^ xorKey
}
return output
}
private decryptDatV4WithJs(data: Buffer, xorKey: number, aesKey: Buffer): Buffer {
if (data.length < 0x0f) {
throw new Error('dat file too small')
}
const header = data.subarray(0, 0x0f)
const payload = data.subarray(0x0f)
const aesSize = this.readInt32LeSafe(header, 6)
const xorSize = this.readInt32LeSafe(header, 10)
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > payload.length) throw new Error('invalid aes size')
const aesData = payload.subarray(0, alignedAesSize)
let plainAes: Buffer = Buffer.alloc(0)
if (aesData.length > 0) {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
decipher.setAutoPadding(false)
plainAes = this.strictRemovePkcs7Padding(Buffer.concat([decipher.update(aesData), decipher.final()]))
}
const remaining = payload.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) throw new Error('invalid xor size')
let rawData: Buffer = Buffer.alloc(0)
let decodedXor: Buffer = Buffer.alloc(0)
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) throw new Error('invalid raw size')
rawData = remaining.subarray(0, rawLength)
const xorData = remaining.subarray(rawLength)
decodedXor = Buffer.allocUnsafe(xorData.length)
for (let i = 0; i < xorData.length; i += 1) {
decodedXor[i] = xorData[i] ^ xorKey
}
} else {
rawData = remaining
}
return Buffer.concat([plainAes, rawData, decodedXor])
}
private getDatVersion(data: Buffer): number {
if (data.length < 6) return 0
const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
const sigV2 = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
if (data.subarray(0, 6).equals(sigV1)) return 1
if (data.subarray(0, 6).equals(sigV2)) return 2
return 0
}
private readInt32LeSafe(buffer: Buffer, offset: number): number {
if (offset < 0 || offset + 4 > buffer.length) throw new Error('invalid int32 offset')
return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)
}
private strictRemovePkcs7Padding(data: Buffer): Buffer {
if (data.length === 0) throw new Error('empty decrypted data')
const pad = data[data.length - 1]
if (pad <= 0 || pad > 16 || pad > data.length) throw new Error('invalid pkcs7 padding')
for (let i = data.length - pad; i < data.length; i += 1) {
if (data[i] !== pad) throw new Error('invalid pkcs7 padding')
}
return data.subarray(0, data.length - pad)
}
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'
}
private isUsableImageCacheFile(filePath: string): boolean {
if (!this.isImageFile(filePath)) return false
if (!existsSync(filePath)) return false
const quarantinedUntil = this.corruptedCachePathUntil.get(filePath) || 0
if (quarantinedUntil > Date.now()) return false
this.scheduleCorruptedCacheValidation(filePath)
return true
}
private scheduleCorruptedCacheValidation(filePath: string): void {
const ext = extname(filePath).toLowerCase()
if (ext !== '.jpg' && ext !== '.jpeg') return
const context = this.createOperationContext()
if (!this.isOperationCurrent(context)) return
const taskKey = `${context.generation}|${context.accountScope}|${filePath}`
const now = Date.now()
const lastCheckedAt = this.corruptedCacheCheckLastAt.get(filePath) || 0
if (lastCheckedAt && now - lastCheckedAt < this.corruptedCacheCheckCooldownMs) return
if (this.corruptedCacheCheckPending.has(taskKey)) return
this.corruptedCacheCheckLastAt.set(filePath, now)
this.pruneCorruptedCacheCheckMaps(now)
this.corruptedCacheCheckPending.add(taskKey)
this.corruptedCacheCheckQueue.push({
key: taskKey,
filePath,
generation: context.generation,
accountScope: context.accountScope
})
this.processCorruptedCacheValidationQueue()
}
private processCorruptedCacheValidationQueue(): void {
while (
this.activeCorruptedCacheChecks < this.maxConcurrentCorruptedCacheChecks &&
this.corruptedCacheCheckQueue.length > 0
) {
const task = this.corruptedCacheCheckQueue.shift()
if (!task) continue
this.activeCorruptedCacheChecks += 1
void this.validateCorruptedCacheFile(task).finally(() => {
this.activeCorruptedCacheChecks = Math.max(0, this.activeCorruptedCacheChecks - 1)
this.corruptedCacheCheckPending.delete(task.key)
this.processCorruptedCacheValidationQueue()
})
}
}
private isCorruptedValidationTaskCurrent(task: CorruptedCacheValidationTask): boolean {
return (
this.cacheClearDepth === 0 &&
task.generation === this.cacheGeneration &&
task.accountScope === this.getCurrentAccountScopeSnapshot().accountScope
)
}
private async validateCorruptedCacheFile(task: CorruptedCacheValidationTask): Promise<void> {
try {
if (!this.isCorruptedValidationTaskCurrent(task)) return
const data = await readFile(task.filePath)
if (!this.isCorruptedValidationTaskCurrent(task)) return
if (!this.isLikelyCorruptedJpegBuffer(data)) return
const validatedDigest = crypto.createHash('sha256').update(data).digest('hex')
await this.runCacheMutation(async () => {
if (!this.isCorruptedValidationTaskCurrent(task)) return
// The path can be replaced by a fresh decode while validation is in
// flight. Re-read under the mutation lock and delete only the exact
// bytes that were diagnosed as corrupt.
const current = await readFile(task.filePath).catch(() => null)
if (!current) return
const currentDigest = crypto.createHash('sha256').update(current).digest('hex')
if (currentDigest !== validatedDigest) return
this.logInfo('[ImageDecrypt] 后台移除疑似损坏缓存文件', { filePath: task.filePath })
this.corruptedCachePathUntil.set(task.filePath, Date.now() + this.corruptedCacheQuarantineMs)
this.deleteResolvedCachesByPath(task.filePath)
await rm(task.filePath, { force: true }).catch(() => { })
})
} catch {
// Cache validation is best-effort and must not affect browsing.
}
}
private pruneCorruptedCacheCheckMaps(now = Date.now()): void {
const checkedExpiresBefore = now - this.corruptedCacheCheckCooldownMs
for (const [filePath, checkedAt] of this.corruptedCacheCheckLastAt.entries()) {
if (checkedAt >= checkedExpiresBefore) continue
this.corruptedCacheCheckLastAt.delete(filePath)
}
for (const [filePath, until] of this.corruptedCachePathUntil.entries()) {
if (until >= now) continue
this.corruptedCachePathUntil.delete(filePath)
}
while (this.corruptedCacheCheckLastAt.size > this.maxCorruptedCacheChecks) {
const oldest = this.corruptedCacheCheckLastAt.keys().next().value
if (!oldest) break
this.corruptedCacheCheckLastAt.delete(oldest)
}
while (this.corruptedCachePathUntil.size > this.maxCorruptedCacheChecks) {
const oldest = this.corruptedCachePathUntil.keys().next().value
if (!oldest) break
this.corruptedCachePathUntil.delete(oldest)
}
while (this.corruptedCacheCheckQueue.length > this.maxCorruptedCacheChecks) {
const dropped = this.corruptedCacheCheckQueue.shift()
if (dropped) this.corruptedCacheCheckPending.delete(dropped.key)
}
}
private isLikelyCorruptedJpegBuffer(data: Buffer): boolean {
if (data.length < 4096) return false
let zeroCount = 0
for (let i = 0; i < data.length; i += 1) {
if (data[i] === 0x00) zeroCount += 1
}
const zeroRatio = zeroCount / data.length
if (zeroRatio >= 0.985) return true
// ffmpeg commonly stores the COM marker at byte offset 24; subarray(0, 24)
// is end-exclusive and missed that exact layout. Scan a bounded header
// window instead of assuming one encoder-specific offset.
const headerEnd = Math.min(data.length, 128)
const hasLavcTag = headerEnd >= 4 && data.subarray(0, headerEnd).includes(Buffer.from('Lavc'))
if (!hasLavcTag) return false
// JPEG 扫描段若几乎全是 0,通常表示解码失败但被编码器强行输出。
let sosPos = -1
for (let i = 2; i < data.length - 1; i += 1) {
if (data[i] === 0xff && data[i + 1] === 0xda) {
sosPos = i
break
}
}
if (sosPos < 0 || sosPos + 4 >= data.length) return zeroRatio >= 0.95
const sosLength = (data[sosPos + 2] << 8) | data[sosPos + 3]
const scanStart = sosPos + 2 + sosLength
if (scanStart >= data.length - 2) return zeroRatio >= 0.95
let eoiPos = -1
for (let i = data.length - 2; i >= scanStart; i -= 1) {
if (data[i] === 0xff && data[i + 1] === 0xd9) {
eoiPos = i
break
}
}
if (eoiPos < 0 || eoiPos <= scanStart) return zeroRatio >= 0.95
const scanData = data.subarray(scanStart, eoiPos)
if (scanData.length < 1024) return zeroRatio >= 0.95
let scanZeroCount = 0
for (let i = 0; i < scanData.length; i += 1) {
if (scanData[i] === 0x00) scanZeroCount += 1
}
const scanZeroRatio = scanZeroCount / scanData.length
return scanZeroRatio >= 0.985
}
/**
* 解包 wxgf 格式
* wxgf 是一种图片容器格式,内部使用 HEVC 编码
*/
private async unwrapWxgf(
buffer: Buffer,
context: ImageCacheOperationContext
): Promise<{ data: Buffer; isWxgf: boolean }> {
if (!this.isOperationCurrent(context)) return { data: Buffer.alloc(0), isWxgf: true }
// 检查是否是 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 }
}
}
const hevcCandidates = this.buildWxgfHevcCandidates(buffer)
this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', {
candidateCount: hevcCandidates.length,
candidates: hevcCandidates.map((item) => `${item.name}:${item.data.length}`)
})
for (const candidate of hevcCandidates) {
if (!this.isOperationCurrent(context)) return { data: Buffer.alloc(0), isWxgf: true }
try {
const jpgData = await this.convertHevcToJpg(candidate.data, context)
if (!this.isOperationCurrent(context)) return { data: Buffer.alloc(0), isWxgf: true }
if (!jpgData || jpgData.length === 0) continue
return { data: jpgData, isWxgf: false }
} catch (e) {
this.logError('unwrapWxgf: 候选流转换失败', e, { candidate: candidate.name })
}
}
const fallback = hevcCandidates[0]?.data || buffer.subarray(4)
return { data: fallback, isWxgf: true }
}
private buildWxgfHevcCandidates(buffer: Buffer): Array<{ name: string; data: Buffer }> {
const units = this.extractHevcNaluUnits(buffer)
const candidates: Array<{ name: string; data: Buffer }> = []
const addCandidate = (name: string, data: Buffer | null | undefined): void => {
if (!data || data.length < 100) return
if (candidates.some((item) => item.data.equals(data))) return
candidates.push({ name, data })
}
// 1) 优先尝试按 VPS(32) 分组后的候选流
const vpsStarts: number[] = []
for (let i = 0; i < units.length; i += 1) {
const unit = units[i]
if (!unit || unit.length < 2) continue
const type = (unit[0] >> 1) & 0x3f
if (type === 32) vpsStarts.push(i)
}
const groups: Array<{ index: number; data: Buffer; size: number }> = []
for (let i = 0; i < vpsStarts.length; i += 1) {
const start = vpsStarts[i]
const end = i + 1 < vpsStarts.length ? vpsStarts[i + 1] : units.length
const groupUnits = units.slice(start, end)
if (groupUnits.length === 0) continue
let hasVcl = false
for (const unit of groupUnits) {
if (!unit || unit.length < 2) continue
const type = (unit[0] >> 1) & 0x3f
if (type === 19 || type === 20 || type === 1) {
hasVcl = true
break
}
}
if (!hasVcl) continue
const merged = this.mergeHevcNaluUnits(groupUnits)
groups.push({ index: i, data: merged, size: merged.length })
}
groups.sort((a, b) => b.size - a.size)
for (const group of groups) {
addCandidate(`group_${group.index}`, group.data)
}
// 2) 全量扫描提取流
addCandidate('scan_all_nalus', this.mergeHevcNaluUnits(units))
// 3) 兜底:直接跳过 wxgf 头喂 ffmpeg
addCandidate('raw_skip4', buffer.subarray(4))
return candidates
}
private mergeHevcNaluUnits(units: Buffer[]): Buffer {
if (!Array.isArray(units) || units.length === 0) return Buffer.alloc(0)
const merged: Buffer[] = []
for (const unit of units) {
if (!unit || unit.length < 2) continue
merged.push(Buffer.from([0x00, 0x00, 0x00, 0x01]))
merged.push(unit)
}
return Buffer.concat(merged)
}
private extractHevcNaluUnits(buffer: Buffer): Buffer[] {
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 []
const units: Buffer[] = []
let keptUnits = 0
let droppedUnits = 0
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
const payload = buffer.subarray(payloadStart, end)
if (payload.length < 2) {
droppedUnits += 1
continue
}
if ((payload[0] & 0x80) !== 0) {
droppedUnits += 1
continue
}
units.push(payload)
keptUnits += 1
}
return units
}
/**
* 从 wxgf 数据中提取 HEVC NALU 裸流
*/
private extractHevcNalu(buffer: Buffer): Buffer | null {
const units = this.extractHevcNaluUnits(buffer)
if (units.length === 0) return null
const merged = this.mergeHevcNaluUnits(units)
return merged.length > 0 ? merged : null
}
/**
* 获取 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,
context: ImageCacheOperationContext
): Promise<Buffer | null> {
if (!this.isOperationCurrent(context)) return 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 {
if (!this.isOperationCurrent(context)) return null
await writeFile(tmpInput, hevcData)
if (!this.isOperationCurrent(context)) return null
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
const attempts: { label: string; inputArgs: string[]; outputArgs?: string[] }[] = [
{ label: 'hevc raw frame0', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
{ label: 'hevc raw frame1', inputArgs: ['-f', 'hevc', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] },
{ label: 'hevc raw frame5', inputArgs: ['-f', 'hevc', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] },
{ label: 'h265 raw frame0', inputArgs: ['-f', 'h265', '-i', tmpInput] },
{ label: 'h265 raw frame1', inputArgs: ['-f', 'h265', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] },
{ label: 'h265 raw frame5', inputArgs: ['-f', 'h265', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] },
{ label: 'auto detect frame0', inputArgs: ['-i', tmpInput] },
{ label: 'auto detect frame1', inputArgs: ['-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] },
{ label: 'auto detect frame5', inputArgs: ['-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] },
]
for (const attempt of attempts) {
if (!this.isOperationCurrent(context)) return null
// 清理上一轮的输出
await rm(tmpOutput, { force: true }).catch(() => {})
const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label, attempt.outputArgs)
if (!this.isOperationCurrent(context)) return null
if (!result) continue
if (this.isLikelyCorruptedJpegBuffer(result)) continue
return result
}
return null
} catch (e) {
this.logError('ffmpeg 转换异常', e)
return null
} finally {
await rm(tmpInput, { force: true }).catch(() => {})
await rm(tmpOutput, { force: true }).catch(() => {})
}
}
private runFfmpegConvert(
ffmpeg: string,
inputArgs: string[],
tmpOutput: string,
label: string,
outputArgs?: string[]
): Promise<Buffer | null> {
return new Promise((resolve) => {
const { spawn } = require('child_process')
const errChunks: Buffer[] = []
const args = [
'-hide_banner', '-loglevel', 'error',
'-y',
...inputArgs,
...(outputArgs || []),
'-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', async (code: number) => {
clearTimeout(timer)
if (code === 0 && existsSync(tmpOutput)) {
try {
const jpgBuf = await readFile(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(options?: { currentAccountOnly?: boolean }): Promise<{ success: boolean; error?: string }> {
const configured = this.configService.get('cachePath')
const imagesRoot = configured
? join(configured, 'Images')
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
const currentScope = this.getCurrentAccountScopeSnapshot().accountScope
const root = options?.currentAccountOnly === true
? join(imagesRoot, currentScope)
: imagesRoot
// Set the barrier before queueing behind older commits. New operations
// must not capture the incremented generation and create files while the
// asynchronous directory traversal is still deleting cache contents.
this.cacheClearDepth += 1
try {
await this.runCacheMutation(async () => {
// Invalidate operations while holding the same lock used for committing
// final cache paths. This closes the check/rename/clear race completely.
this.cacheGeneration += 1
this.resolvedCache.clear()
this.pending.clear()
this.resolvePending.clear()
this.updateFlags.clear()
this.datNameScanMissAt.clear()
this.accountDirCache.clear()
this.ensuredDirs.clear()
this.corruptedCacheCheckPending.clear()
this.corruptedCacheCheckQueue = []
// Active validators keep their slot until their generation-aware finally
// runs; resetting the counter here would allow excess concurrent tasks.
this.corruptedCacheCheckLastAt.clear()
this.corruptedCachePathUntil.clear()
if (!existsSync(root)) return
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) }
} finally {
this.cacheClearDepth = Math.max(0, this.cacheClearDepth - 1)
}
}
}
export const imageDecryptService = new ImageDecryptService()