mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
页面拆分两个目录语义: - 上方「图片解密状态」的「图片资源目录」始终等于 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,下方输入框只用于校验,不再落盘。
211 lines
7.0 KiB
TypeScript
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 }
|
|
}
|