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