From 90bf1aed90cdfac294a71bf7c7c97206526ea849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <969409112@qq.com> Date: Sat, 1 Aug 2026 13:56:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=20wxgf=20=E5=8E=9F?= =?UTF-8?q?=E5=9B=BE=E8=A7=A3=E5=AF=86=E5=B9=B6=E4=BF=AE=E5=A4=8D=E7=BC=A9?= =?UTF-8?q?=E7=95=A5=E5=9B=BE=E7=BC=93=E5=AD=98=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 wxgf/HEVC 图片转换支持 - 增加 FFmpeg 跨平台安装、目录填写与能力检测 - 避免缩略图占用原图缓存,下载原图后可即时刷新 - 移除聊天图片的缩略图角标 (cherry picked from commit 4cee159a65496bd30dd690b568c47a120f3fff30) --- src/main/image-decrypt-service.ts | 168 +++++++++++++++- src/main/index.ts | 76 +++++++- .../image-decryption-status-service.ts | 9 +- src/main/services/settings-store.ts | 2 + src/preload/index.d.ts | 9 + src/preload/index.ts | 7 + src/renderer/src/components/ImageBubble.tsx | 7 +- src/renderer/src/components/image-loader.ts | 10 + .../ImageDecoderRequirementNotice.tsx | 182 ++++++++++++++++++ .../settings/pages/ImageDecryptionPage.tsx | 6 + src/renderer/src/styles/settings.scss | 164 ++++++++++++++++ src/shared/image-decryption.ts | 17 ++ 12 files changed, 639 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/features/settings/image-decryption/ImageDecoderRequirementNotice.tsx diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index a13204a..60671e2 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -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 { + 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 { + 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(() => { diff --git a/src/main/index.ts b/src/main/index.ts index 96a07c0..8b99f78 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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, { diff --git a/src/main/services/image-decryption-status-service.ts b/src/main/services/image-decryption-status-service.ts index a3ab925..6d09f06 100644 --- a/src/main/services/image-decryption-status-service.ts +++ b/src/main/services/image-decryption-status-service.ts @@ -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 ? '已找到' : '未找到'), diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index bacfc41..818f349 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -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( diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index f43ec9e..f016935 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 getImageDecryptionStatus: () => Promise + selectImageDecoder: () => Promise + getImageDecoderStatus: () => Promise + openImageDecoderDownload: () => Promise<{ success: boolean; error?: string }> saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise 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 diff --git a/src/preload/index.ts b/src/preload/index.ts index ec8f25e..77c927d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => + ipcRenderer.invoke('image:selectDecoder'), + getImageDecoderStatus: (): Promise => + 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'), diff --git a/src/renderer/src/components/ImageBubble.tsx b/src/renderer/src/components/ImageBubble.tsx index c637281..9bd2420 100644 --- a/src/renderer/src/components/ImageBubble.tsx +++ b/src/renderer/src/components/ImageBubble.tsx @@ -25,7 +25,6 @@ export function ImageBubble({ const [loading, setLoading] = useState(false) const [upgrading, setUpgrading] = useState(false) const [error, setError] = useState(null) - const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail)) const [usingFallback, setUsingFallback] = useState(false) const containerRef = useRef(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) && ( -
{upgrading ? '正在查找原图' : '缩略图'}
- )} + {upgrading &&
正在查找原图
}
+ + +
+ +
+ {platform === 'win32' ? ( + <> + Windows 安装与定位 +

+ 下载后右键压缩包选择“全部解压”,再填写解压后的文件夹。如果已经安装但没有自动找到,可在 + PowerShell 输入 (Get-Command ffmpeg).Source,或在命令提示符(CMD)输入{' '} + where ffmpeg,然后选择返回路径所在的 bin 文件夹。 +

+ + ) : platform === 'darwin' ? ( + <> + macOS 安装与定位 +

+ 打开“终端”并输入 brew install ffmpeg。如果提示没有 + brew,请先通过上方按钮打开 Homebrew 官网完成安装。安装后输入{' '} + which ffmpeg,再选择返回路径所在的文件夹, 通常是{' '} + /opt/homebrew/bin/usr/local/bin。 +

+ + ) : ( + <> + Linux 安装与定位 +

+ 通过系统包管理器安装 FFmpeg,执行 which ffmpeg{' '} + 查看位置,再选择返回路径所在的文件夹。 +

+ + )} +
+ + {error && ( +

+ {error} +

+ )} + + + ) +} diff --git a/src/renderer/src/features/settings/pages/ImageDecryptionPage.tsx b/src/renderer/src/features/settings/pages/ImageDecryptionPage.tsx index 2e7e3f9..f141c3c 100644 --- a/src/renderer/src/features/settings/pages/ImageDecryptionPage.tsx +++ b/src/renderer/src/features/settings/pages/ImageDecryptionPage.tsx @@ -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 { + 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; diff --git a/src/shared/image-decryption.ts b/src/shared/image-decryption.ts index e72a734..eb746b7 100644 --- a/src/shared/image-decryption.ts +++ b/src/shared/image-decryption.ts @@ -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