fix: 支持 wxgf 原图解密并修复缩略图缓存刷新

- 增加 wxgf/HEVC 图片转换支持
- 增加 FFmpeg 跨平台安装、目录填写与能力检测
- 避免缩略图占用原图缓存,下载原图后可即时刷新
- 移除聊天图片的缩略图角标

(cherry picked from commit 4cee159a65496bd30dd690b568c47a120f3fff30)
This commit is contained in:
电摇小子
2026-08-03 10:03:55 +08:00
committed by Wxw-Gu
parent a3955d691d
commit 90bf1aed90
12 changed files with 639 additions and 18 deletions
+162 -6
View File
@@ -3,7 +3,10 @@ import { existsSync, readFileSync, statSync, readdirSync, promises as fsPromises
import crypto from 'crypto'
import os from 'os'
import { app } from 'electron'
import { execFile } from 'child_process'
import { Worker } from 'worker_threads'
import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption'
import { loadSettings } from './services/settings-store'
import { Wcdb4Client } from './wcdb4-client'
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
@@ -42,9 +45,111 @@ interface PersistentImageMeta {
isThumbnail: boolean
}
type FfmpegCandidate = { executable: string; source: ImageDecoderSource }
function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCandidate[] {
const selected = String(selectedPath || '').trim()
const environment = String(process.env['FFMPEG_BIN'] || '').trim()
const executable = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const candidates: FfmpegCandidate[] = [
...(selected ? [{ executable: selected, source: 'selected' as const }] : []),
...(environment ? [{ executable: environment, source: 'environment' as const }] : []),
{
executable: join(process.resourcesPath, 'ffmpeg', executable),
source: 'bundled'
},
{
executable: join(process.cwd(), 'resources', 'ffmpeg', executable),
source: 'bundled'
},
{ executable: process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg', source: 'system' }
]
return candidates.filter(
(candidate, index) =>
candidates.findIndex(
(other) => other.executable.toLowerCase() === candidate.executable.toLowerCase()
) === index
)
}
function resolveFfmpegExecutable(): string {
for (const candidate of getFfmpegCandidates()) {
if (candidate.source === 'environment' || candidate.source === 'system') {
return candidate.executable
}
if (existsSync(candidate.executable)) return candidate.executable
}
return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
}
function runImageDecoderCommand(
executable: string,
args: string[]
): Promise<{ success: boolean; output: string }> {
return new Promise((resolveValidation) => {
execFile(
executable,
args,
{ timeout: 7_000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 },
(error, stdout, stderr) => {
resolveValidation({ success: !error, output: `${stdout}\n${stderr}` })
}
)
})
}
export async function inspectImageDecoderExecutable(
executable: string
): Promise<{ installed: boolean; supportsHevc: boolean }> {
const version = await runImageDecoderCommand(executable, ['-hide_banner', '-version'])
if (!version.success || !/ffmpeg version/i.test(version.output)) {
return { installed: false, supportsHevc: false }
}
const decoders = await runImageDecoderCommand(executable, ['-hide_banner', '-decoders'])
return {
installed: true,
supportsHevc:
decoders.success && /^\s*[A-Z.]{6}\s+hevc\s/im.test(decoders.output)
}
}
async function resolveImageDecoderPath(executable: string): Promise<string | undefined> {
if (existsSync(executable)) return resolve(executable)
const locator = process.platform === 'win32' ? 'where.exe' : 'which'
const located = await runImageDecoderCommand(locator, [executable])
if (!located.success) return undefined
return located.output
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line && existsSync(line))
}
export async function inspectImageDecoderStatus(
selectedPath = loadSettings().ffmpegPath
): Promise<ImageDecoderStatus> {
for (const candidate of getFfmpegCandidates(selectedPath)) {
const inspection = await inspectImageDecoderExecutable(candidate.executable)
if (inspection.installed) {
const resolvedPath = await resolveImageDecoderPath(candidate.executable)
return {
installed: true,
available: inspection.supportsHevc,
source: candidate.source,
selected: candidate.source === 'selected',
directory: resolvedPath ? dirname(resolvedPath) : undefined
}
}
}
return { installed: false, available: false, source: 'none', selected: false }
}
const IMAGE_DECRYPT_WORKER_SOURCE = String.raw`
const crypto = require('node:crypto')
const childProcess = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { parentPort, workerData } = require('node:worker_threads')
@@ -105,7 +210,7 @@ function strictRemovePadding(buffer) {
return buffer.subarray(0, buffer.length - paddingLength)
}
function unwrapWxgf(buffer) {
function unwrapWxgf(buffer, ffmpegPath) {
if (
buffer.length < 20 ||
buffer[0] !== 0x77 ||
@@ -128,10 +233,55 @@ function unwrapWxgf(buffer) {
return buffer.subarray(index)
}
}
return buffer
let hevcOffset = -1
for (let index = 4; index < Math.min(buffer.length - 4, 4096); index += 1) {
if (
buffer[index] === 0x00 &&
buffer[index + 1] === 0x00 &&
buffer[index + 2] === 0x00 &&
buffer[index + 3] === 0x01
) {
hevcOffset = index
break
}
}
if (hevcOffset < 0 || !ffmpegPath) return buffer
const nonce = process.pid + '-' + Date.now() + '-' + crypto.randomBytes(4).toString('hex')
const tempBase = path.join(os.tmpdir(), 'wxe-wxgf-' + nonce)
const inputPath = tempBase + '.hevc'
const outputPath = tempBase + '.png'
try {
fs.writeFileSync(inputPath, buffer.subarray(hevcOffset))
childProcess.execFileSync(
ffmpegPath,
[
'-hide_banner',
'-loglevel',
'error',
'-y',
'-f',
'hevc',
'-i',
inputPath,
'-frames:v',
'1',
outputPath
],
{ timeout: 20_000, windowsHide: true, stdio: 'ignore' }
)
const converted = fs.readFileSync(outputPath)
return detectImageExtension(converted) ? converted : buffer
} catch {
return buffer
} finally {
try { fs.rmSync(inputPath, { force: true }) } catch {}
try { fs.rmSync(outputPath, { force: true }) } catch {}
}
}
function decryptCandidate(filePath, aesKey, xorKey) {
function decryptCandidate(filePath, aesKey, xorKey, ffmpegPath) {
const bytes = fs.readFileSync(filePath)
if (!path.extname(filePath).toLowerCase().includes('dat')) {
const extension = detectImageExtension(bytes) || path.extname(filePath).toLowerCase()
@@ -176,7 +326,7 @@ function decryptCandidate(filePath, aesKey, xorKey) {
xorPlain[index] = xorData[index] ^ xorKey
}
const image = unwrapWxgf(Buffer.concat([unpadded, rawData, xorPlain]))
const image = unwrapWxgf(Buffer.concat([unpadded, rawData, xorPlain]), ffmpegPath)
const extension = detectImageExtension(image)
if (!extension) return null
return {
@@ -204,7 +354,12 @@ function collectCandidates(datPath, allowThumbnail) {
let result = null
for (const candidate of collectCandidates(workerData.datPath, workerData.allowThumbnail)) {
try {
result = decryptCandidate(candidate, workerData.aesKey, workerData.xorKey)
result = decryptCandidate(
candidate,
workerData.aesKey,
workerData.xorKey,
workerData.ffmpegPath
)
if (result) break
} catch {
// Try the next local quality variant.
@@ -1163,7 +1318,8 @@ export class ImageDecryptService {
datPath,
allowThumbnail,
xorKey: this.xorKey,
aesKey: this.aesKey
aesKey: this.aesKey,
ffmpegPath: resolveFfmpegExecutable()
}
})
const timeout = setTimeout(() => {
+72 -4
View File
@@ -11,7 +11,7 @@ import {
dialog,
protocol
} from 'electron'
import { join } from 'path'
import { dirname, join } from 'path'
import { existsSync, promises as fsPromises } from 'fs'
import { extname } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
@@ -21,7 +21,12 @@ import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service'
import { parseMessageContent } from './message-parser'
import { ImageDecryptService, type DecodedImage } from './image-decrypt-service'
import {
ImageDecryptService,
inspectImageDecoderExecutable,
inspectImageDecoderStatus,
type DecodedImage
} from './image-decrypt-service'
import { exportGroupReport } from './group-report-service'
import {
deleteGeneratedReport,
@@ -659,6 +664,67 @@ app.whenReady().then(async () => {
return result
})
ipcMain.handle('image:selectDecoder', async (event) => {
const settings = loadSettings()
const owner = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(owner!, {
title: '选择 FFmpeg 解压或安装目录',
defaultPath: settings.ffmpegPath ? dirname(settings.ffmpegPath) : app.getPath('downloads'),
properties: ['openDirectory']
})
if (result.canceled) return { success: false, canceled: true }
const selectedDirectory = result.filePaths[0]
if (!selectedDirectory) return { success: false, canceled: true }
const executable = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const candidates = [
join(selectedDirectory, executable),
join(selectedDirectory, 'bin', executable)
]
let selectedPath = ''
for (const candidate of candidates) {
if (!existsSync(candidate)) continue
const inspection = await inspectImageDecoderExecutable(candidate)
if (inspection.installed) {
selectedPath = candidate
break
}
}
if (!selectedPath) {
return {
success: false,
canceled: false,
error: '所选目录中没有找到 FFmpeg,请选择解压后的文件夹或其中的 bin 文件夹。'
}
}
saveSettings({ ...settings, ffmpegPath: selectedPath })
return {
success: true,
canceled: false,
status: await inspectImageDecoderStatus(selectedPath)
}
})
ipcMain.handle('image:getDecoderStatus', () => inspectImageDecoderStatus())
ipcMain.handle('image:openDecoderDownload', async () => {
const url =
process.platform === 'win32'
? 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip'
: process.platform === 'darwin'
? 'https://brew.sh/'
: 'https://ffmpeg.org/download.html'
try {
await shell.openExternal(url)
return { success: true }
} catch {
return { success: false, error: '无法打开下载页面,请检查系统默认浏览器设置。' }
}
})
ipcMain.handle('db:getBootstrapCache', () => {
if (!chat.isReady()) return null
return getBootstrapCache(chat.getCurrentAccountRoot())
@@ -865,7 +931,7 @@ app.whenReady().then(async () => {
const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
includeData: !mediaService
})
if (cachedImage) {
if (cachedImage && (!force || !cachedImage.isThumbnail)) {
return buildImageResponse(cachedImage)
}
@@ -894,7 +960,9 @@ app.whenReady().then(async () => {
const queuedCacheHit = await coldService.getCachedDecodedImage(imageCacheKey, {
includeData: !queuedMediaService
})
if (queuedCacheHit) return buildImageResponse(queuedCacheHit)
if (queuedCacheHit && (!force || !queuedCacheHit.isThumbnail)) {
return buildImageResponse(queuedCacheHit)
}
let filePath = force
? await coldService.findImageFileAsync(imageMd5, imageDatName, {
@@ -9,7 +9,7 @@ import type {
ImageResourceCheck,
TestImageDecryptionRequest
} from '../../shared/image-decryption'
import { ImageDecryptService } from '../image-decrypt-service'
import { ImageDecryptService, inspectImageDecoderStatus } from '../image-decrypt-service'
import * as chat from './chat-service'
import { validateImageKeyRequest } from './image-key-config-service'
import { isWechatRunning } from './wechat-process-status'
@@ -25,6 +25,10 @@ export async function inspectImageDecryptionStatus(
fs.existsSync(path.join(accountRoot, 'cache')) ||
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
const dbConnected = chat.isReady()
const [wechatRunning, decoder] = await Promise.all([
isWechatRunning(),
inspectImageDecoderStatus()
])
return {
configured: config.configured,
@@ -36,9 +40,10 @@ export async function inspectImageDecryptionStatus(
updatedAt: config.updatedAt,
platform: process.platform,
autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin',
wechatRunning: await isWechatRunning(),
wechatRunning,
accountIdentified: Boolean(chat.getSelfAccountInfo()?.wxid),
cacheState: canUseCacheRoot() ? 'normal' : 'unavailable',
decoder,
resources: {
imageIndex: check(dbConnected, dbConnected ? '可用' : '数据库尚未连接'),
imageDirectory: check(imageDirectoryFound, imageDirectoryFound ? '已找到' : '未找到'),
+2
View File
@@ -28,6 +28,7 @@ export interface AppSettings {
imageXorKey: string
imageAesKey: string
imageKeyFallbackDisabled: boolean
ffmpegPath: string
recallProtectionEnabled: boolean
debugEnabled: boolean
autoLogin: boolean
@@ -108,6 +109,7 @@ const DEFAULT_SETTINGS: AppSettings = {
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: false,
ffmpegPath: '',
recallProtectionEnabled: false,
debugEnabled: false,
autoLogin: ['1', 'true', 'yes', 'on'].includes(