Files
WechatExplorer/src/main/services/image-key-config-service.ts
T
电摇小子 1a9f27488b 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,下方输入框只用于校验,不再落盘。
2026-07-26 20:46:20 +08:00

211 lines
7.0 KiB
TypeScript

import path from 'path'
import type { ImageKeyConfigResult, SaveImageKeyRequest } from '../../shared/image-decryption'
import { ImageKeyStore } from '../image-key-store'
import * as chat from './chat-service'
import { loadSettings, saveSettings, type AppSettings } from './settings-store'
export class ImageKeyConfigService {
constructor(private readonly store = new ImageKeyStore()) {}
getConfig(): ImageKeyConfigResult {
const settings = loadSettings()
const context = this.getContext(settings)
const stored = this.store.get(context.accountId)
if (!stored.success) {
return {
success: false,
configured: false,
saved: false,
encryptionAvailable: stored.encryptionAvailable,
source: 'none',
accountId: context.accountId,
resourceRoot: context.resourceRoot,
error: stored.error
}
}
if (stored.entry) {
return {
success: true,
configured: true,
saved: true,
encryptionAvailable: stored.encryptionAvailable,
source: 'secure-storage',
accountId: context.accountId,
resourceRoot: context.resourceRoot,
xorKey: stored.entry.xorKey,
aesKey: stored.entry.aesKey,
updatedAt: stored.entry.updatedAt
}
}
if (settings.imageAesKey.trim()) {
const legacy = this.buildResult({
source: 'legacy-settings',
xorKey: settings.imageXorKey || '0x40',
aesKey: settings.imageAesKey,
context,
encryptionAvailable: stored.encryptionAvailable
})
if (!stored.encryptionAvailable) return legacy
const migrated = this.save({
resourceRoot: context.resourceRoot,
xorKey: legacy.xorKey || '0x40',
aesKey: legacy.aesKey || ''
})
return migrated.success ? this.getConfig() : legacy
}
const envAesKey = String(import.meta.env.VITE_IMAGE_AES_KEY || '').trim()
if (!settings.imageKeyFallbackDisabled && envAesKey) {
return this.buildResult({
source: 'environment',
xorKey: String(import.meta.env.VITE_IMAGE_XOR_KEY || '0x40'),
aesKey: envAesKey,
context,
encryptionAvailable: stored.encryptionAvailable
})
}
return {
success: true,
configured: false,
saved: false,
encryptionAvailable: stored.encryptionAvailable,
source: 'none',
accountId: context.accountId,
resourceRoot: context.resourceRoot
}
}
save(request: SaveImageKeyRequest): ImageKeyConfigResult {
const normalized = validateImageKeyRequest(request)
if (!normalized.success) return { ...this.getEmptyConfig(), error: normalized.error }
const settings = loadSettings()
const context = this.getContext(settings)
if (!chat.getSelfAccountInfo()?.wxid) {
return { ...this.getEmptyConfig(), error: '当前微信账号尚未识别' }
}
const result = this.store.save(context.accountId, {
xorKey: normalized.xorKey,
aesKey: normalized.aesKey
})
if (!result.success || !result.entry) {
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
}
// 注意:normalized.resourceRoot 不再写回 imageKeyRoot。
// 下方的"图片资源目录"输入框仅用于本次手动测试,不再污染状态面板上方显示。
saveSettings({
...settings,
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: false
})
return {
success: true,
configured: true,
saved: true,
encryptionAvailable: true,
source: 'secure-storage',
accountId: context.accountId,
// 返回的 resourceRoot 始终是识别到的默认目录,与状态面板一致;
// 不返回 normalized.resourceRoot,避免把用户输入的测试目录当成状态写回。
resourceRoot: context.resourceRoot,
xorKey: result.entry.xorKey,
aesKey: result.entry.aesKey,
updatedAt: result.entry.updatedAt
}
}
clear(): { success: boolean; error?: string } {
const settings = loadSettings()
const context = this.getContext(settings)
const cleared = this.store.clear(context.accountId)
if (!cleared.success) return cleared
saveSettings({
...settings,
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: true
})
return { success: true }
}
getLegacySettingsView(): AppSettings {
const settings = loadSettings()
const config = this.getConfig()
return {
...settings,
imageXorKey: config.xorKey || '',
imageAesKey: config.aesKey || ''
}
}
private getContext(settings: AppSettings): { accountId: string; resourceRoot: string } {
const self = chat.getSelfAccountInfo()
const accountRoot = self?.accountRoot || chat.getCurrentAccountRoot() || settings.dbRoot
return {
accountId: self?.wxid || path.basename(accountRoot || '') || 'unbound',
resourceRoot: settings.imageKeyRoot || accountRoot || settings.dbRoot
}
}
private buildResult(input: {
source: 'legacy-settings' | 'environment'
xorKey: string
aesKey: string
context: { accountId: string; resourceRoot: string }
encryptionAvailable: boolean
}): ImageKeyConfigResult {
return {
success: true,
configured: true,
saved: false,
encryptionAvailable: input.encryptionAvailable,
source: input.source,
accountId: input.context.accountId,
resourceRoot: input.context.resourceRoot,
xorKey: normalizeImageXorKey(input.xorKey),
aesKey: input.aesKey.trim()
}
}
private getEmptyConfig(): ImageKeyConfigResult {
const settings = loadSettings()
const context = this.getContext(settings)
const stored = this.store.get(context.accountId)
return {
success: false,
configured: false,
saved: false,
encryptionAvailable: stored.encryptionAvailable,
source: 'none',
accountId: context.accountId,
resourceRoot: context.resourceRoot
}
}
}
export function normalizeImageXorKey(value: unknown): string {
const raw = String(value ?? '').trim()
if (!raw) return '0x40'
const parsed = raw.toLowerCase().startsWith('0x')
? Number.parseInt(raw.slice(2), 16)
: Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 255) return raw
return `0x${parsed.toString(16).toUpperCase().padStart(2, '0')}`
}
export function validateImageKeyRequest(
request: SaveImageKeyRequest
):
| { success: true; resourceRoot: string; xorKey: string; aesKey: string }
| { success: false; error: string } {
const resourceRoot = request.resourceRoot.trim()
const xorKey = normalizeImageXorKey(request.xorKey)
const aesKey = request.aesKey.trim()
if (!resourceRoot) return { success: false, error: '图片资源目录不能为空' }
if (!/^0x[0-9A-F]{2}$/.test(xorKey)) return { success: false, error: 'XOR Key 格式不正确' }
if (aesKey.length !== 16) return { success: false, error: 'AES Key 必须为 16 个字符' }
return { success: true, resourceRoot, xorKey, aesKey }
}