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(() => {
|
||||
|
||||
+72
-4
@@ -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 ? '已找到' : '未找到'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Vendored
+9
@@ -14,6 +14,8 @@ import type {
|
||||
DatabaseKeyValidationResult
|
||||
} from '../shared/database-key'
|
||||
import type {
|
||||
ImageDecoderSelectionResult,
|
||||
ImageDecoderStatus,
|
||||
ImageDecryptionStatus,
|
||||
ImageDecryptionTestResult,
|
||||
ImageKeyConfigResult,
|
||||
@@ -260,6 +262,7 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
@@ -273,6 +276,9 @@ declare global {
|
||||
}>
|
||||
getImageKeyConfig: () => Promise<ImageKeyConfigResult>
|
||||
getImageDecryptionStatus: () => Promise<ImageDecryptionStatus>
|
||||
selectImageDecoder: () => Promise<ImageDecoderSelectionResult>
|
||||
getImageDecoderStatus: () => Promise<ImageDecoderStatus>
|
||||
openImageDecoderDownload: () => Promise<{ success: boolean; error?: string }>
|
||||
saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise<ImageKeyConfigResult>
|
||||
testImageDecryption: (
|
||||
request: TestImageDecryptionRequest
|
||||
@@ -291,6 +297,7 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
@@ -310,6 +317,7 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
@@ -327,6 +335,7 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { AppLogEntry } from '../shared/app-log'
|
||||
import type { AppUpdateState } from '../shared/app-update'
|
||||
import type { CacheSummary } from '../shared/cache'
|
||||
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
@@ -105,6 +106,12 @@ const api = {
|
||||
ipcRenderer.invoke('key:autoGetImageKey', options),
|
||||
getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'),
|
||||
getImageDecryptionStatus: () => ipcRenderer.invoke('image:getStatus'),
|
||||
selectImageDecoder: (): Promise<ImageDecoderSelectionResult> =>
|
||||
ipcRenderer.invoke('image:selectDecoder'),
|
||||
getImageDecoderStatus: (): Promise<ImageDecoderStatus> =>
|
||||
ipcRenderer.invoke('image:getDecoderStatus'),
|
||||
openImageDecoderDownload: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('image:openDecoderDownload'),
|
||||
saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request),
|
||||
testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request),
|
||||
clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'),
|
||||
|
||||
@@ -25,7 +25,6 @@ export function ImageBubble({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||
const [usingFallback, setUsingFallback] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mountedRef = useRef(true)
|
||||
@@ -75,7 +74,6 @@ export function ImageBubble({
|
||||
if (!mountedRef.current) return
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(result.isThumbnail)
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return
|
||||
@@ -145,7 +143,6 @@ export function ImageBubble({
|
||||
if (result.data.startsWith('data:image/') || result.data.startsWith('wxe-media://')) {
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(result.isThumbnail)
|
||||
setError(null)
|
||||
onImageClick?.(result.data)
|
||||
return
|
||||
@@ -193,9 +190,7 @@ export function ImageBubble({
|
||||
alt="图片"
|
||||
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
|
||||
/>
|
||||
{(upgrading || isThumbnail) && (
|
||||
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
||||
)}
|
||||
{upgrading && <div className="image-quality-badge">正在查找原图</div>}
|
||||
<div className="image-actions">
|
||||
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
|
||||
复制
|
||||
|
||||
@@ -59,6 +59,12 @@ function getCachedImage(
|
||||
for (const key of keys) {
|
||||
const cached = imageCache.get(key)
|
||||
if (!cached) continue
|
||||
if (options.force && cached.isThumbnail) {
|
||||
imageCache.delete(key)
|
||||
imageCacheBytes -= imageCacheSizes.get(key) || 0
|
||||
imageCacheSizes.delete(key)
|
||||
continue
|
||||
}
|
||||
imageCache.delete(key)
|
||||
imageCache.set(key, cached)
|
||||
return cached
|
||||
@@ -81,6 +87,10 @@ function cacheImage(
|
||||
options: ImageLoadOptions,
|
||||
image: LoadedImage
|
||||
): void {
|
||||
// A forced original request may temporarily fall back to a thumbnail. Do not
|
||||
// let that fallback prevent a later retry after WeChat downloads the original.
|
||||
if (options.force && image.isThumbnail) return
|
||||
|
||||
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||
const size = image.data.length * 2
|
||||
for (const key of keys) {
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ImageDecoderStatus } from '../../../../../shared/image-decryption'
|
||||
|
||||
interface ImageDecoderRequirementNoticeProps {
|
||||
status?: ImageDecoderStatus
|
||||
onNotice: (message: string) => void
|
||||
}
|
||||
|
||||
export function ImageDecoderRequirementNotice({
|
||||
status,
|
||||
onNotice
|
||||
}: ImageDecoderRequirementNoticeProps): React.ReactElement {
|
||||
const platform = window.electron.process.platform
|
||||
const [currentStatus, setCurrentStatus] = useState(status)
|
||||
const [selecting, setSelecting] = useState(false)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
useEffect(() => setCurrentStatus(status), [status])
|
||||
|
||||
const selectDirectory = async (): Promise<void> => {
|
||||
setSelecting(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const result = await window.api.selectImageDecoder()
|
||||
if (result.canceled) return
|
||||
if (!result.success || !result.status) {
|
||||
setError(result.error || '没有在所选文件夹中找到 FFmpeg,请重新选择。')
|
||||
return
|
||||
}
|
||||
setCurrentStatus(result.status)
|
||||
onNotice(
|
||||
result.status.available
|
||||
? 'FFmpeg 安装目录已保存,原图支持可以使用'
|
||||
: 'FFmpeg 安装目录已保存,但原图支持未通过检测'
|
||||
)
|
||||
} catch {
|
||||
setError('无法打开目录选择窗口,请稍后重试。')
|
||||
} finally {
|
||||
setSelecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkOriginalSupport = async (): Promise<void> => {
|
||||
setChecking(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const nextStatus = await window.api.getImageDecoderStatus()
|
||||
setCurrentStatus(nextStatus)
|
||||
onNotice(nextStatus.available ? '原图支持检测通过' : '原图支持检测未通过')
|
||||
} catch {
|
||||
setError('原图支持检测失败,请稍后重试。')
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDownload = async (): Promise<void> => {
|
||||
setError(undefined)
|
||||
try {
|
||||
const result = await window.api.openImageDecoderDownload()
|
||||
if (!result.success) setError(result.error || '无法打开 FFmpeg 下载页面')
|
||||
} catch {
|
||||
setError('无法打开 FFmpeg 下载页面,请检查系统默认浏览器设置。')
|
||||
}
|
||||
}
|
||||
|
||||
const installed = currentStatus?.installed === true
|
||||
const supported = currentStatus?.available === true
|
||||
const downloadLabel = platform === 'darwin' ? '打开 Homebrew 官网' : '下载 FFmpeg'
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`image-decoder-requirement ${supported ? 'ready' : ''}`}
|
||||
aria-label="FFmpeg 原图支持"
|
||||
>
|
||||
<span className="image-decoder-requirement-icon" aria-hidden>
|
||||
{supported ? '✓' : '!'}
|
||||
</span>
|
||||
<div className="image-decoder-requirement-body">
|
||||
<div className="image-decoder-requirement-heading">
|
||||
<strong>FFmpeg 原图支持</strong>
|
||||
<span>
|
||||
{supported ? '原图支持可用' : installed ? 'FFmpeg 已安装' : '需要安装 FFmpeg'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>如需打开少量 wxgf/HEVC 特殊原图,必须安装 FFmpeg。未安装不会影响聊天记录和普通图片。</p>
|
||||
|
||||
<div className="image-decoder-checks">
|
||||
<div>
|
||||
<span className={`image-decoder-check-index ${installed ? 'complete' : ''}`}>1</span>
|
||||
<div>
|
||||
<strong>FFmpeg 安装目录</strong>
|
||||
<small title={currentStatus?.directory}>
|
||||
{currentStatus?.directory || '尚未检测到 FFmpeg 安装目录'}
|
||||
</small>
|
||||
</div>
|
||||
<b className={installed ? 'success' : ''}>{installed ? '已检测' : '未检测'}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`image-decoder-check-index ${supported ? 'complete' : ''}`}>2</span>
|
||||
<div>
|
||||
<strong>原图支持检测</strong>
|
||||
<small>
|
||||
{supported
|
||||
? '已支持 wxgf/HEVC 特殊原图转换'
|
||||
: installed
|
||||
? '尚未检测到 HEVC 原图转换能力'
|
||||
: '请先安装并检测 FFmpeg 目录'}
|
||||
</small>
|
||||
</div>
|
||||
<b className={supported ? 'success' : ''}>{supported ? '已通过' : '未通过'}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="image-decoder-requirement-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-header-action"
|
||||
onClick={() => void openDownload()}
|
||||
>
|
||||
{downloadLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-primary-button"
|
||||
disabled={selecting}
|
||||
onClick={() => void selectDirectory()}
|
||||
>
|
||||
{selecting ? '正在保存…' : '填写 FFmpeg 安装目录'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-header-action"
|
||||
disabled={!installed || checking}
|
||||
onClick={() => void checkOriginalSupport()}
|
||||
>
|
||||
{checking ? '正在检测…' : '检测原图支持'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="image-decoder-platform-help">
|
||||
{platform === 'win32' ? (
|
||||
<>
|
||||
<strong>Windows 安装与定位</strong>
|
||||
<p>
|
||||
下载后右键压缩包选择“全部解压”,再填写解压后的文件夹。如果已经安装但没有自动找到,可在
|
||||
PowerShell 输入 <code>(Get-Command ffmpeg).Source</code>,或在命令提示符(CMD)输入{' '}
|
||||
<code>where ffmpeg</code>,然后选择返回路径所在的 bin 文件夹。
|
||||
</p>
|
||||
</>
|
||||
) : platform === 'darwin' ? (
|
||||
<>
|
||||
<strong>macOS 安装与定位</strong>
|
||||
<p>
|
||||
打开“终端”并输入 <code>brew install ffmpeg</code>。如果提示没有
|
||||
brew,请先通过上方按钮打开 Homebrew 官网完成安装。安装后输入{' '}
|
||||
<code>which ffmpeg</code>,再选择返回路径所在的文件夹, 通常是{' '}
|
||||
<code>/opt/homebrew/bin</code> 或 <code>/usr/local/bin</code>。
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Linux 安装与定位</strong>
|
||||
<p>
|
||||
通过系统包管理器安装 FFmpeg,执行 <code>which ffmpeg</code>{' '}
|
||||
查看位置,再选择返回路径所在的文件夹。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="image-decoder-requirement-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SettingsSelfInfo } from '../model/types'
|
||||
import { AutoDetectImageKeySection } from '../image-decryption/AutoDetectImageKeySection'
|
||||
import { DangerZone } from '../image-decryption/DangerZone'
|
||||
import { ImageDecoderRequirementNotice } from '../image-decryption/ImageDecoderRequirementNotice'
|
||||
import { ImageDecryptStatus } from '../image-decryption/ImageDecryptStatus'
|
||||
import { ImageKeyConfiguration } from '../image-decryption/ImageKeyConfiguration'
|
||||
import { ImageTestSection } from '../image-decryption/ImageTestSection'
|
||||
@@ -55,6 +56,11 @@ export function ImageDecryptionPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ImageDecoderRequirementNotice
|
||||
status={controller.state.status?.decoder}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
|
||||
<h2 className="settings-section-heading">图片解密状态</h2>
|
||||
<ImageDecryptStatus
|
||||
state={controller.state}
|
||||
|
||||
@@ -956,6 +956,170 @@
|
||||
.image-decryption-content {
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
.image-decoder-requirement {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
margin-top: 14px;
|
||||
padding: 18px;
|
||||
border: 1px solid #ead2a8;
|
||||
border-radius: 8px;
|
||||
background: #fff9ee;
|
||||
color: #4b4439;
|
||||
}
|
||||
.image-decoder-requirement.ready {
|
||||
border-color: #bfddd2;
|
||||
background: #f2f8f5;
|
||||
}
|
||||
.image-decoder-requirement-icon {
|
||||
display: grid;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: 0 0 22px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #b87524;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.image-decoder-requirement.ready .image-decoder-requirement-icon {
|
||||
background: #247a63;
|
||||
}
|
||||
.image-decoder-requirement-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.image-decoder-requirement-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.image-decoder-requirement-heading strong {
|
||||
color: #61451f;
|
||||
font-size: 14px;
|
||||
}
|
||||
.image-decoder-requirement.ready .image-decoder-requirement-heading strong {
|
||||
color: #294b41;
|
||||
}
|
||||
.image-decoder-requirement-heading span {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
background: rgba(184, 117, 36, 0.1);
|
||||
color: #8b5d23;
|
||||
font-size: 11px;
|
||||
}
|
||||
.image-decoder-requirement.ready .image-decoder-requirement-heading span {
|
||||
background: #e1f0ea;
|
||||
color: #247a63;
|
||||
}
|
||||
.image-decoder-requirement p {
|
||||
margin: 7px 0 12px;
|
||||
color: #6d665c;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.image-decoder-requirement.ready p {
|
||||
color: #5d6d66;
|
||||
}
|
||||
.image-decoder-checks {
|
||||
margin: 2px 0 14px;
|
||||
border-top: 1px solid rgba(139, 103, 47, 0.16);
|
||||
}
|
||||
.image-decoder-checks > div {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid rgba(139, 103, 47, 0.16);
|
||||
}
|
||||
.image-decoder-check-index {
|
||||
display: grid;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(184, 117, 36, 0.12);
|
||||
color: #8b5d23;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.image-decoder-check-index.complete {
|
||||
background: #e1f0ea;
|
||||
color: #247a63;
|
||||
}
|
||||
.image-decoder-checks strong,
|
||||
.image-decoder-checks small {
|
||||
display: block;
|
||||
}
|
||||
.image-decoder-checks strong {
|
||||
color: #4f4a42;
|
||||
font-size: 12px;
|
||||
}
|
||||
.image-decoder-checks small {
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
color: #81796d;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.image-decoder-checks b {
|
||||
color: #9a7750;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.image-decoder-checks b.success {
|
||||
color: #247a63;
|
||||
}
|
||||
.image-decoder-requirement-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.image-decoder-requirement-actions button {
|
||||
min-height: 34px;
|
||||
}
|
||||
.image-decoder-requirement small {
|
||||
display: block;
|
||||
color: #81796d;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.image-decoder-platform-help {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(139, 103, 47, 0.16);
|
||||
}
|
||||
.image-decoder-platform-help strong {
|
||||
color: #5b5145;
|
||||
font-size: 12px;
|
||||
}
|
||||
.image-decoder-platform-help p {
|
||||
margin: 5px 0 0;
|
||||
color: #71695f;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.image-decoder-platform-help code {
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
background: rgba(117, 82, 31, 0.1);
|
||||
color: #68491f;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.image-decoder-requirement .image-decoder-requirement-error {
|
||||
margin: 10px 0 0;
|
||||
color: #b34848;
|
||||
font-size: 12px;
|
||||
}
|
||||
.image-decrypt-badge.unconfigured {
|
||||
background: #f1f3f2;
|
||||
color: #66706b;
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
export type ImageKeySource = 'secure-storage' | 'legacy-settings' | 'environment' | 'none'
|
||||
export type ImageResourceState = 'available' | 'unavailable' | 'unknown'
|
||||
export type ImageDecoderSource = 'selected' | 'environment' | 'bundled' | 'system' | 'none'
|
||||
|
||||
export interface ImageDecoderStatus {
|
||||
installed: boolean
|
||||
available: boolean
|
||||
source: ImageDecoderSource
|
||||
selected: boolean
|
||||
directory?: string
|
||||
}
|
||||
|
||||
export interface ImageDecoderSelectionResult {
|
||||
success: boolean
|
||||
canceled: boolean
|
||||
status?: ImageDecoderStatus
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ImageKeyConfigResult {
|
||||
success: boolean
|
||||
@@ -33,6 +49,7 @@ export interface ImageDecryptionStatus {
|
||||
wechatRunning: boolean
|
||||
accountIdentified: boolean
|
||||
cacheState: 'normal' | 'unavailable'
|
||||
decoder: ImageDecoderStatus
|
||||
resources: {
|
||||
imageIndex: ImageResourceCheck
|
||||
imageDirectory: ImageResourceCheck
|
||||
|
||||
Reference in New Issue
Block a user