mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
refactor(image-decrypt): 设置面板 UI 重构 + 测试步骤联动
页面拆分两个目录语义: - 上方「图片解密状态」的「图片资源目录」始终等于 chat 实时识别的默认目录(chat.getCurrentAccountRoot()), 不再被用户输入覆盖。 - 下方「图片密钥管理」移除「图片资源目录」输入框,只保留 XOR / AES 两个 Key。 该字段之前会被保存但实际不影响图片解密(数据库查询走默认根目录),保留只会让用户困惑。 UI 改动: - ImageKeyConfiguration:移除 resourceRoot 输入框,edit() 签名收窄为 xorKey | aesKey。 - ImageTestSection:测试结果由 3 列横排改为步骤条样式(pending / ok / fail / skipped), 任意一步 ✗ 后续步骤自动标记「跳过」并灰掉,前端语义上变成「要么都成功要么都失败」。 - AutoDetectImageKeySection:加一行小字提示「仅支持 WeChat 4.0,V3 及以下无法解析」。 后端改动: - testImageDecryption:解密失败时把 fileFound/decrypted/readable 统一置 false, 拆出「解密成功但不可读」中间态,让 UI 步骤联动准确反映。 - inspectImageDecryptionStatus:accountRoot 兜底改为 getCurrentAccountRoot() || config.resourceRoot, 让状态面板始终显示当前识别到的默认目录。 - sanitizeImageError:把 'no_image_message' / '300' / 'unsupported' / 'dat version' 关键字翻译成具体提示。 - useImageDecryptionController.autoDetect:错误信息原文透传,不再走 sanitizeImageError(后者会 把扫描阶段的「未找到 V2 模板文件」/「60 秒未找到 AES 密钥」等归类成「无法解析媒体文件」,掩盖真因)。 - image-key-config-service.save():不再写回 imageKeyRoot,下方输入框只用于校验,不再落盘。
This commit is contained in:
@@ -17,7 +17,9 @@ import { isWechatRunning } from './wechat-process-status'
|
||||
export async function inspectImageDecryptionStatus(
|
||||
config: ImageKeyConfigResult
|
||||
): Promise<ImageDecryptionStatus> {
|
||||
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot
|
||||
// 状态面板的"图片资源目录"始终等于当前识别到的微信账号根目录;
|
||||
// 仅在微信未连接时回退到上次配置中的 resourceRoot,避免空白。
|
||||
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot || ''
|
||||
const imageDirectoryFound = hasImageDirectory(accountRoot)
|
||||
const stickerCacheFound =
|
||||
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
||||
@@ -65,7 +67,10 @@ export function testImageDecryption(
|
||||
.reverse()
|
||||
.find((message) => message.contentData?.type === 'image')
|
||||
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
|
||||
return failure('NO_IMAGE_MESSAGE', '所选聊天最近没有可测试的图片消息')
|
||||
return failure(
|
||||
'NO_IMAGE_MESSAGE',
|
||||
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
|
||||
)
|
||||
}
|
||||
|
||||
const service = new ImageDecryptService(
|
||||
@@ -74,26 +79,51 @@ export function testImageDecryption(
|
||||
chat.getChatDb()?.getWcdb4Client()
|
||||
)
|
||||
const image = imageMessage.contentData
|
||||
let filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: false })
|
||||
// 测试时优先使用用户在下方"图片资源目录"输入框填写的目录;
|
||||
// 找不到再退回默认 accountDir。
|
||||
const testAccountDir = normalized.resourceRoot || undefined
|
||||
let filePath = service.findImageFile(image.md5, image.datName, {
|
||||
allowThumbnail: false,
|
||||
accountDir: testAccountDir
|
||||
})
|
||||
if (!filePath)
|
||||
filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: true })
|
||||
filePath = service.findImageFile(image.md5, image.datName, {
|
||||
allowThumbnail: true,
|
||||
accountDir: testAccountDir
|
||||
})
|
||||
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
|
||||
|
||||
const data = service.decryptImageToBase64(filePath)
|
||||
if (!data) {
|
||||
// 三步联动:解密失败 → fileFound/decrypted/readable 都为 false。
|
||||
return {
|
||||
...failure('DECRYPT_FAILED', '无法解析媒体文件'),
|
||||
fileFound: true
|
||||
success: false,
|
||||
code: 'DECRYPT_FAILED',
|
||||
error: '无法解析媒体文件',
|
||||
fileFound: false,
|
||||
decrypted: false,
|
||||
readable: false
|
||||
}
|
||||
}
|
||||
const readable = data.startsWith('data:image/')
|
||||
if (!readable) {
|
||||
// 三步联动:解密成功但字节流不可读 → 前一步打勾(确实找到了 dat),
|
||||
// 但 decrypted/readable 全为 false,让 UI 表达"找到但解析失败"。
|
||||
return {
|
||||
success: false,
|
||||
code: 'DECRYPT_FAILED',
|
||||
error: '图片解密结果不可读取',
|
||||
fileFound: true,
|
||||
decrypted: false,
|
||||
readable: false,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: readable,
|
||||
code: readable ? undefined : 'DECRYPT_FAILED',
|
||||
error: readable ? undefined : '图片解密结果不可读取',
|
||||
success: true,
|
||||
fileFound: true,
|
||||
decrypted: true,
|
||||
readable,
|
||||
readable: true,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -92,9 +92,10 @@ export class ImageKeyConfigService {
|
||||
if (!result.success || !result.entry) {
|
||||
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
|
||||
}
|
||||
// 注意:normalized.resourceRoot 不再写回 imageKeyRoot。
|
||||
// 下方的"图片资源目录"输入框仅用于本次手动测试,不再污染状态面板上方显示。
|
||||
saveSettings({
|
||||
...settings,
|
||||
imageKeyRoot: normalized.resourceRoot,
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: false
|
||||
@@ -106,7 +107,9 @@ export class ImageKeyConfigService {
|
||||
encryptionAvailable: true,
|
||||
source: 'secure-storage',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: normalized.resourceRoot,
|
||||
// 返回的 resourceRoot 始终是识别到的默认目录,与状态面板一致;
|
||||
// 不返回 normalized.resourceRoot,避免把用户输入的测试目录当成状态写回。
|
||||
resourceRoot: context.resourceRoot,
|
||||
xorKey: result.entry.xorKey,
|
||||
aesKey: result.entry.aesKey,
|
||||
updatedAt: result.entry.updatedAt
|
||||
|
||||
@@ -5791,6 +5791,59 @@ body {
|
||||
width: 100%;
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
.image-step-list {
|
||||
list-style: none;
|
||||
margin: 14px 0 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5ecea;
|
||||
background: #f7faf9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.image-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.image-step-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.image-step-ok .image-step-icon {
|
||||
background: #2e765d;
|
||||
color: #fff;
|
||||
}
|
||||
.image-step-fail .image-step-icon {
|
||||
background: #a84444;
|
||||
color: #fff;
|
||||
}
|
||||
.image-step-fail span:last-child {
|
||||
color: #a84444;
|
||||
}
|
||||
.image-step-pending .image-step-icon {
|
||||
background: #d6dde0;
|
||||
color: #6a7378;
|
||||
}
|
||||
.image-step-pending span:last-child {
|
||||
color: #98a1a4;
|
||||
}
|
||||
.image-step-skipped .image-step-icon {
|
||||
background: transparent;
|
||||
color: #b8c0c4;
|
||||
}
|
||||
.image-step-skipped span:last-child {
|
||||
color: #b8c0c4;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.image-inline-error {
|
||||
margin: 14px 0 0;
|
||||
color: #a84444;
|
||||
@@ -5829,6 +5882,12 @@ body {
|
||||
border-radius: 7px;
|
||||
background: #f1f4f3;
|
||||
}
|
||||
.image-auto-scope-hint {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
color: #7d8c8a;
|
||||
font-size: 11px;
|
||||
}
|
||||
.image-auto-unavailable {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
|
||||
@@ -45,6 +45,10 @@ export function AutoDetectImageKeySection({
|
||||
{state.status.platform === 'darwin'
|
||||
? '扫描本机微信缓存并通过图片模板验证候选密钥。'
|
||||
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
|
||||
<br />
|
||||
<small className="image-auto-scope-hint">
|
||||
仅支持 WeChat 4.0,V3 及以下无法解析
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -7,19 +7,10 @@ export function ImageKeyConfiguration({
|
||||
}: {
|
||||
state: ImageDecryptionState
|
||||
disabled: boolean
|
||||
onEdit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void
|
||||
onEdit: (field: 'xorKey' | 'aesKey', value: string) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className="settings-card image-key-editor">
|
||||
<label>
|
||||
<span>图片资源目录</span>
|
||||
<input
|
||||
value={state.resourceRoot}
|
||||
disabled={disabled}
|
||||
title={state.resourceRoot}
|
||||
onChange={(event) => onEdit('resourceRoot', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="image-key-grid">
|
||||
<label>
|
||||
<span>XOR Key</span>
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
import type { ImageDecryptionState } from './types'
|
||||
|
||||
type StepState = 'pending' | 'ok' | 'fail' | 'skipped'
|
||||
|
||||
function stepClass(step: StepState): string {
|
||||
switch (step) {
|
||||
case 'ok':
|
||||
return 'image-step image-step-ok'
|
||||
case 'fail':
|
||||
return 'image-step image-step-fail'
|
||||
case 'skipped':
|
||||
return 'image-step image-step-skipped'
|
||||
default:
|
||||
return 'image-step image-step-pending'
|
||||
}
|
||||
}
|
||||
|
||||
function stepIcon(step: StepState): string {
|
||||
switch (step) {
|
||||
case 'ok':
|
||||
return '✓'
|
||||
case 'fail':
|
||||
return '×'
|
||||
case 'skipped':
|
||||
return '·'
|
||||
default:
|
||||
return '○'
|
||||
}
|
||||
}
|
||||
|
||||
function pickSteps(result: {
|
||||
fileFound: boolean
|
||||
decrypted: boolean
|
||||
readable: boolean
|
||||
success: boolean
|
||||
}): { find: StepState; decrypt: StepState; read: StepState } {
|
||||
// 三步严格联动:找到失败 → 解密/读取 skipped;解密失败 → 读取 skipped;
|
||||
// 解密成功但不可读 → 读取 fail。
|
||||
if (!result.fileFound) {
|
||||
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
|
||||
}
|
||||
if (!result.success && !result.decrypted && !result.readable) {
|
||||
// 后端把 fileFound=false 的情况也用 success:false 表达;
|
||||
// 此时第一步直接 fail,后两步 skip。
|
||||
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
|
||||
}
|
||||
if (!result.decrypted) {
|
||||
return { find: 'ok', decrypt: 'fail', read: 'skipped' }
|
||||
}
|
||||
if (!result.readable) {
|
||||
return { find: 'ok', decrypt: 'ok', read: 'fail' }
|
||||
}
|
||||
return { find: 'ok', decrypt: 'ok', read: 'ok' }
|
||||
}
|
||||
|
||||
export function ImageTestSection({
|
||||
state,
|
||||
disabled,
|
||||
@@ -16,6 +69,14 @@ export function ImageTestSection({
|
||||
onSave: () => void
|
||||
}): React.ReactElement {
|
||||
const result = state.testResult
|
||||
const steps = result
|
||||
? pickSteps({
|
||||
fileFound: result.fileFound,
|
||||
decrypted: result.decrypted,
|
||||
readable: result.readable,
|
||||
success: result.success
|
||||
})
|
||||
: null
|
||||
return (
|
||||
<section className="settings-card image-test-section">
|
||||
<div>
|
||||
@@ -48,16 +109,27 @@ export function ImageTestSection({
|
||||
确认保存
|
||||
</button>
|
||||
</div>
|
||||
{result ? (
|
||||
<div className={`image-test-result ${result.success ? 'success' : 'error'}`}>
|
||||
<span>{result.fileFound ? '✓' : '×'} 找到图片文件</span>
|
||||
<span>{result.decrypted ? '✓' : '×'} 解密成功</span>
|
||||
<span>{result.readable ? '✓' : '×'} 图片可以读取</span>
|
||||
{!result.success ? <p>{result.error}</p> : null}
|
||||
</div>
|
||||
{steps ? (
|
||||
<ol className="image-step-list">
|
||||
<li className={stepClass(steps.find)}>
|
||||
<span className="image-step-icon">{stepIcon(steps.find)}</span>
|
||||
<span>找到图片文件</span>
|
||||
</li>
|
||||
<li className={stepClass(steps.decrypt)}>
|
||||
<span className="image-step-icon">{stepIcon(steps.decrypt)}</span>
|
||||
<span>解密成功</span>
|
||||
</li>
|
||||
<li className={stepClass(steps.read)}>
|
||||
<span className="image-step-icon">{stepIcon(steps.read)}</span>
|
||||
<span>图片可以读取</span>
|
||||
</li>
|
||||
</ol>
|
||||
) : state.error ? (
|
||||
<p className="image-inline-error">{state.error}</p>
|
||||
) : null}
|
||||
{result && !result.success && result.error ? (
|
||||
<p className="image-inline-error">{result.error}</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ export function imageDecryptionReducer(
|
||||
config: action.config,
|
||||
status: action.status,
|
||||
contacts: action.contacts,
|
||||
resourceRoot: action.config.resourceRoot,
|
||||
// 下方"图片资源目录"跟随状态面板的默认目录同步;
|
||||
// 一旦用户手动编辑过(dirty),就不再被刷新覆盖。
|
||||
resourceRoot: state.dirty && state.resourceRoot
|
||||
? state.resourceRoot
|
||||
: action.status.resourceRoot || action.config.resourceRoot,
|
||||
xorKey: action.config.xorKey || '0x40',
|
||||
aesKey: action.config.aesKey || '',
|
||||
error: action.config.success ? undefined : action.config.error,
|
||||
@@ -126,7 +130,7 @@ export function imageDecryptionReducer(
|
||||
config: action.config,
|
||||
status: action.status,
|
||||
contacts: state.contacts,
|
||||
resourceRoot: action.config.resourceRoot
|
||||
resourceRoot: action.status.resourceRoot || action.config.resourceRoot
|
||||
}
|
||||
default:
|
||||
return state
|
||||
|
||||
@@ -53,7 +53,7 @@ export type ImageDecryptionAction =
|
||||
contacts: Contact[]
|
||||
}
|
||||
| { type: 'LOAD_ERROR'; error: string }
|
||||
| { type: 'EDIT'; field: 'resourceRoot' | 'xorKey' | 'aesKey'; value: string }
|
||||
| { type: 'EDIT'; field: 'xorKey' | 'aesKey'; value: string }
|
||||
| { type: 'SELECT_CHAT'; userMd5: string }
|
||||
| { type: 'TEST_START' }
|
||||
| { type: 'TEST_DONE'; result: ImageDecryptionTestResult }
|
||||
@@ -80,7 +80,7 @@ export interface ImageDecryptionController {
|
||||
pageStatus: 'configured' | 'unconfigured' | 'partial'
|
||||
busy: boolean
|
||||
canSave: boolean
|
||||
edit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void
|
||||
edit: (field: 'xorKey' | 'aesKey', value: string) => void
|
||||
selectChat: (userMd5: string) => void
|
||||
test: () => Promise<void>
|
||||
save: () => Promise<void>
|
||||
|
||||
+12
-7
@@ -33,7 +33,7 @@ export function useImageDecryptionController({
|
||||
})
|
||||
}, [refresh])
|
||||
|
||||
const edit = useCallback((field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string): void => {
|
||||
const edit = useCallback((field: 'xorKey' | 'aesKey', value: string): void => {
|
||||
dispatch({ type: 'EDIT', field, value })
|
||||
}, [])
|
||||
|
||||
@@ -84,12 +84,17 @@ export function useImageDecryptionController({
|
||||
dispatch({ type: 'AUTO_START' })
|
||||
const result = await window.api.autoGetImageKey({ save: false })
|
||||
if (!result.success || !result.aesKey || !result.verified) {
|
||||
dispatch({
|
||||
type: 'AUTO_ERROR',
|
||||
error: result.success
|
||||
? '获取到候选密钥,但未通过图片验证'
|
||||
: sanitizeImageError(result.error)
|
||||
})
|
||||
// 自动获取链路:原文透传后端错误信息,不要走 sanitizeImageError。
|
||||
// sanitizeImageError 是给"测试图片解析"设计的字典,会把
|
||||
// "未找到 V2 模板文件 / 微信进程未运行 / 60 秒未扫描到密钥" 等
|
||||
// 完全合法的扫描阶段错误强制翻成"无法解析媒体文件"。
|
||||
const rawError = (result.error || '').toString().trim()
|
||||
const errorMessage = !result.success
|
||||
? rawError || '自动获取图片密钥失败'
|
||||
: !result.aesKey
|
||||
? '自动获取未返回 AES 密钥'
|
||||
: '获取到候选密钥,但未通过图片验证'
|
||||
dispatch({ type: 'AUTO_ERROR', error: errorMessage })
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
|
||||
@@ -11,11 +11,17 @@ export function formatImageConfigTime(value?: number): string {
|
||||
|
||||
export function sanitizeImageError(error?: string): string {
|
||||
const value = String(error || '').toLowerCase()
|
||||
if (value.includes('no_image_message') || value.includes('300')) {
|
||||
return '当前会话最近 300 条消息内没有图片,请换一个含图片的聊天再测试'
|
||||
}
|
||||
if (value.includes('unsupported') || value.includes('dat version')) {
|
||||
return '仅支持 WeChat 4.0 图片协议,V3 及以下无法解析'
|
||||
}
|
||||
if (value.includes('key') || value.includes('密钥')) return '图片密钥未配置或与当前账号不匹配'
|
||||
if (value.includes('不存在') || value.includes('not found')) return '图片文件不存在'
|
||||
if (value.includes('账号')) return '当前账号不匹配'
|
||||
if (value.includes('目录')) return '图片资源目录不可用'
|
||||
return error ? '无法解析媒体文件' : '图片解析测试未通过'
|
||||
return error ? '无法解析媒体文件(仅支持 WeChat 4.0)' : '图片解析测试未通过'
|
||||
}
|
||||
|
||||
export function normalizeAutoXorKey(value?: number, formatted?: string): string {
|
||||
|
||||
Reference in New Issue
Block a user