mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
fix: 支持 wxgf 原图解密并修复缩略图缓存刷新
- 增加 wxgf/HEVC 图片转换支持 - 增加 FFmpeg 跨平台安装、目录填写与能力检测 - 避免缩略图占用原图缓存,下载原图后可即时刷新 - 移除聊天图片的缩略图角标 (cherry picked from commit 4cee159a65496bd30dd690b568c47a120f3fff30)
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user