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
+161 -5
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)
}
}
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(
+9
View File
@@ -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
+7
View File
@@ -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'),
+1 -6
View File
@@ -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) {
@@ -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}
+164
View File
@@ -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;
+17
View File
@@ -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