diff --git a/.env.example b/.env.example index 5436001..cd56eb5 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,12 @@ VITE_DB_KEY= VITE_AUTO_LOGIN=false # AI API Configuration (Optional, can be entered in UI) +# 注意:发布版本不再自动读取以下环境变量。 +# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填, +# 然后在「设置 → AI 模型」里手动完成"添加供应商"流程。 VITE_DEEPSEEK_API_KEY= VITE_AI_BASE_URL=https://api.deepseek.com -VITE_AI_MODEL=deepseek-v4-flash +VITE_AI_MODEL=deepseek-chat # Message types to filter out (comma separated). Empty means show all message types. VITE_FILTER_MSG_TYPES= diff --git a/README.md b/README.md index 5832147..85009e6 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。 在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。 -> 当前版本:`v2.1.4`。macOS 支持相对稳定;Windows 可能会遇到性能 卡顿问题, 仍在持续兼容不同微信版本与本地目录结构。 +> macOS 支持相对稳定;Windows 已初步支持 但因聊天记录大/机械硬盘等问题 会有所卡顿,仍在持续兼容不同微信版本与本地目录结构。 ## ✨ 功能特性 diff --git a/package.json b/package.json index ca4022e..a9fed0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wechatexplorer", - "version": "2.1.4", + "version": "2.1.5", "description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手", "keywords": [ "wechat", diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index 8ed1aac..b1cab03 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -10,8 +10,6 @@ const imageDecryptLog = (...args: unknown[]): void => { } export class ImageDecryptService { - private readonly defaultV1AesKey = 'cfcd208495d565ef' - private xorKey: number = 0 private aesKey: string = '' private wcdb4Client: Wcdb4Client | null = null @@ -80,9 +78,11 @@ export class ImageDecryptService { findImageFile( md5?: string, imageDatName?: string, - options?: { allowThumbnail?: boolean } + options?: { allowThumbnail?: boolean; accountDir?: string } ): string | null { - const accountDir = this.getAccountDir() + // 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为 + const accountDir = + options?.accountDir && existsSync(options.accountDir) ? options.accountDir : this.getAccountDir() if (!accountDir) return null const allowThumbnail = options?.allowThumbnail !== false @@ -273,12 +273,9 @@ export class ImageDecryptService { ) let decrypted: Buffer - if (version === 1) { - imageDecryptLog('[ImageDecrypt] using V1 (default AES key)') - const key = Buffer.from(this.defaultV1AesKey, 'ascii') - decrypted = this.decryptDatV4(datPath, key) - } else if (version === 2) { - imageDecryptLog('[ImageDecrypt] using V2 (user AES key)') + if (version === 2) { + // WeChat 4.0 标准 dat 头: 07 08 56 32 08 07 + imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)') if (!this.aesKey) { imageDecryptLog('[ImageDecrypt] no AES key configured') return null @@ -286,7 +283,8 @@ export class ImageDecryptService { const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16) decrypted = this.decryptDatV4(datPath, key) } else { - imageDecryptLog('[ImageDecrypt] unsupported dat version:', version) + // 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。 + imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version) return null } @@ -356,7 +354,8 @@ export class ImageDecryptService { } /** - * 检测 DAT 文件版本 + * 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。 + * 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。 */ private getDatVersion(inputPath: string): number { const bytes = readFileSync(inputPath) @@ -365,9 +364,6 @@ export class ImageDecryptService { } const signature = bytes.subarray(0, 6) - if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) { - return 1 - } if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) { return 2 } diff --git a/src/main/index.ts b/src/main/index.ts index 2e572f2..74a44d3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -297,7 +297,12 @@ app.whenReady().then(async () => { const nextWechatDb = await WechatDb.create(key, settings.dbRoot) const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot() if (resolvedRoot && resolvedRoot !== settings.dbRoot) { - saveSettings({ ...settings, dbRoot: resolvedRoot }) + // 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录 + saveSettings({ + ...settings, + dbRoot: resolvedRoot, + imageKeyRoot: resolvedRoot + }) } chat.setChatDb(nextWechatDb) const wcdb4Client = nextWechatDb.getWcdb4Client() @@ -411,7 +416,13 @@ app.whenReady().then(async () => { ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => { const settings = loadSettings() const self = chat.getSelfAccountInfo() - const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot + // 优先级:chat 真实识别到的根 → self.accountRoot → settings.imageKeyRoot → settings.dbRoot + // 必须先看 chat.getCurrentAccountRoot(),否则 settings 缓存漂移会导致扫错目录。 + const accountRoot = + chat.getCurrentAccountRoot() || + self?.accountRoot || + settings.imageKeyRoot || + settings.dbRoot const wxid = self?.wxid const onStatus = (message: string): void => { if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message }) @@ -788,6 +799,11 @@ app.whenReady().then(async () => { ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => { const ok = chat.reopenWithRoot(accountRoot) if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' } + // 同步 imageKeyRoot,避免自动获取扫描到旧目录 + const settings = loadSettings() + if (accountRoot && accountRoot !== settings.imageKeyRoot) { + saveSettings({ ...settings, imageKeyRoot: accountRoot }) + } const info = chat.getSelfAccountInfo() return { success: true, info } }) diff --git a/src/main/key-service-win.ts b/src/main/key-service-win.ts index e5bd6b4..33b917d 100644 --- a/src/main/key-service-win.ts +++ b/src/main/key-service-win.ts @@ -889,7 +889,8 @@ export class KeyService { const dirName = normalized.split(/[\\/]/).pop() ?? '' if (dirName.startsWith('wxid_')) pushUnique(dirName) - const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i) + // 仅支持 WeChat 4.0:路径识别只匹配 xwechat_files + const marker = normalized.match(/[\\/]xwechat_files/i) if (marker) { const root = normalized.slice(0, marker.index! + marker[0].length) try { @@ -934,15 +935,49 @@ export class KeyService { onProgress?.('正在查找模板文件...') let result = await this._findTemplateData(userDir, 32) let { ciphertext, xorKey } = result - + const firstDiag = (this as { _imageTemplateDiag?: { + userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number + } })._imageTemplateDiag + // 如果找不到密钥,尝试扫描更多文件 if (ciphertext && xorKey === null) { onProgress?.('未找到有效密钥,尝试扫描更多文件...') result = await this._findTemplateData(userDir, 100) xorKey = result.xorKey } - - if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } + + if (!ciphertext) { + // 用诊断信息给具体提示 + const diag = (this as { _imageTemplateDiag?: { + userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number + } })._imageTemplateDiag || firstDiag + if (!diag || diag.totalTFiles === 0) { + return { + success: false, + error: + '在账号目录下未找到任何 _t.dat 图片文件。\n' + + `扫描路径:${diag?.userDir || userDir || '(空)'}\n` + + '原因:微信没在本地生成缩略图。\n' + + '请让用户在微信里打开任意聊天的图片大图(等"原图"按钮可点击),然后再试。' + } + } + if (diag.v2Count === 0 && diag.nonV2Count > 0) { + return { + success: false, + error: + `找到 ${diag.totalTFiles} 个 _t.dat,但都不是 V2 头(可能图片尚未解密到本地,或微信版本不同)。\n` + + `扫描路径:${diag.userDir}\n` + + '请让用户在微信里打开 2-3 张不同的图片大图,等"原图"按钮可点击后再试。' + } + } + return { + success: false, + error: + `找到 ${diag.totalTFiles} 个 _t.dat,其中 ${diag.v2Count} 个是 V2 头,但没有长度 ≥ 0x1F 的有效模板。\n` + + `扫描路径:${diag.userDir}\n` + + '请在微信中查看更多图片后再试。' + } + } if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' } onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) @@ -1005,6 +1040,8 @@ export class KeyService { let ciphertext: Buffer | null = null const tailCounts: Record = {} + let v2Count = 0 + let nonV2Count = 0 for (const f of files.slice(0, 32)) { try { @@ -1013,8 +1050,11 @@ export class KeyService { // 统计末尾两字节用于 XOR 密钥 if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { + v2Count++ const key = `${data[data.length - 2]}_${data[data.length - 1]}` tailCounts[key] = (tailCounts[key] ?? 0) + 1 + } else { + nonV2Count++ } // 提取密文(取第一个有效的) @@ -1031,6 +1071,15 @@ export class KeyService { if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k } } + // 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么 + const diag = { + userDir, + totalTFiles: files.length, + v2Count, + nonV2Count + } + ;(this as { _imageTemplateDiag?: unknown })._imageTemplateDiag = diag + return { ciphertext, xorKey } } diff --git a/src/main/services/ai-provider-service.ts b/src/main/services/ai-provider-service.ts index 5accd30..3335dd9 100644 --- a/src/main/services/ai-provider-service.ts +++ b/src/main/services/ai-provider-service.ts @@ -41,7 +41,6 @@ export class AIProviderService { constructor(private readonly keyStore = new AIProviderKeyStore()) {} list(): AIProviderListResult { - this.ensureEnvironmentMigration() try { const data = this.readMetadata() return { @@ -339,15 +338,10 @@ export class AIProviderService { } private ensureEnvironmentMigration(): void { - const data = this.readMetadata() - if (data.providers.length) return - const apiKey = String(import.meta.env.VITE_DEEPSEEK_API_KEY || '').trim() - if (!apiKey) return - this.migrateLegacy({ - apiKey, - baseUrl: String(import.meta.env.VITE_AI_BASE_URL || ''), - model: String(import.meta.env.VITE_AI_MODEL || '') - }) + // 已禁用:内置环境变量 Key 自动迁移策略。 + // 安全要求:发布给最终用户的版本不应携带任何内置 API Key, + // 必须由用户自己在 UI 里手动配置(或者通过自己的 .env.local 注入)。 + // 保留此方法作为占位,方便后续重新评估。 } private toSummary( diff --git a/src/main/services/image-decryption-status-service.ts b/src/main/services/image-decryption-status-service.ts index 75f92bd..a3ab925 100644 --- a/src/main/services/image-decryption-status-service.ts +++ b/src/main/services/image-decryption-status-service.ts @@ -17,7 +17,9 @@ import { isWechatRunning } from './wechat-process-status' export async function inspectImageDecryptionStatus( config: ImageKeyConfigResult ): Promise { - 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 { diff --git a/src/main/services/image-key-config-service.ts b/src/main/services/image-key-config-service.ts index a7bc547..3c54b42 100644 --- a/src/main/services/image-key-config-service.ts +++ b/src/main/services/image-key-config-service.ts @@ -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 diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index 5d6a61e..fc135c5 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -4,6 +4,22 @@ import path from 'path' import os from 'os' import { discoverWindowsDbRoots } from '../windows-db-root-discovery' +/** + * 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到 + * "...\\Documents\\xwechat_files"(V4)。如果 xwechat_files 不存在则保留原值。 + * 仅支持 WeChat 4.0:自动纠正用户机器上残留的旧路径。 + */ +function redirectLegacyWeChatFilesToXwechat(candidate: string): string { + if (!candidate) return candidate + const normalized = candidate.replace(/[\\/]+$/, '') + const lowered = normalized.toLowerCase() + const legacyMarker = `${path.sep}wechat files` + if (!lowered.endsWith(legacyMarker)) return candidate + const redirected = `${normalized.slice(0, -legacyMarker.length)}${path.sep}xwechat_files` + if (fs.existsSync(redirected)) return redirected + return candidate +} + export interface AppSettings { dbRoot: string apiEnabled: boolean @@ -25,16 +41,17 @@ function getDefaultDbRoot(): string { function getDefaultDbRootCandidates(home: string): string[] { if (process.platform !== 'win32') { + // macOS 仅支持 WeChat 4.0 路径(xwechat_files) return [ path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files') ] } + // 仅支持 WeChat 4.0:剔除 V3 时代的 "WeChat Files" 目录, + // 只认 xwechat_files(含 Documents\ 和 AppData\Roaming\Tencent\ 两种合法位置)。 const candidates = [ ...getWeflowDbPathCandidates(home), - path.join(home, 'Documents', 'WeChat Files'), path.join(home, 'Documents', 'xwechat_files'), - path.join(home, 'WeChat Files'), path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files') ] @@ -123,9 +140,19 @@ export function loadSettings(): AppSettings { if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) { cache.dbRoot = getDefaultDbRoot() } - if (!cache.imageKeyRoot) { + // 同步:imageKeyRoot 必须跟随 dbRoot 更新, + // 否则自动获取会扫错目录(旧 bug:状态面板显示 D 盘,自动获取扫 C 盘)。 + if (!cache.imageKeyRoot || !isUsableDbRoot(cache.imageKeyRoot)) { cache.imageKeyRoot = cache.dbRoot } + // V4-only 兜底:如果 imageKeyRoot 指向旧的 "WeChat Files"(V3 路径), + // 重定向到同一父目录下的 xwechat_files(V4)。 + if (cache.imageKeyRoot) { + cache.imageKeyRoot = redirectLegacyWeChatFilesToXwechat(cache.imageKeyRoot) + } + if (cache.dbRoot) { + cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot) + } return cache } } catch (error) { diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index dcf065f..e10504d 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -311,11 +311,10 @@ export class Wcdb4Client { private static getDefaultRootCandidates(): string[] { const home = os.homedir() if (process.platform === 'win32') { + // 仅支持 WeChat 4.0:只认 xwechat_files,V3 时代的 "WeChat Files" 不再加入候选 const candidates = [ ...Wcdb4Client.getWeflowDbPathCandidates(home), - path.join(home, 'Documents', 'WeChat Files'), path.join(home, 'Documents', 'xwechat_files'), - path.join(home, 'WeChat Files'), path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files') ] candidates.push(...discoverWindowsDbRoots()) diff --git a/src/main/windows-db-root-discovery.ts b/src/main/windows-db-root-discovery.ts index aa74fb8..5da052e 100644 --- a/src/main/windows-db-root-discovery.ts +++ b/src/main/windows-db-root-discovery.ts @@ -1,7 +1,8 @@ import fs from 'fs-extra' import path from 'path' -const DB_ROOT_NAMES = new Set(['xwechat_files', 'wechat files']) +// 仅支持 WeChat 4.0:只扫描 xwechat_files;旧 V3 时代的 "WeChat Files" 不再纳入候选 +const DB_ROOT_NAMES = new Set(['xwechat_files']) const SKIPPED_DIRECTORY_NAMES = new Set([ '$recycle.bin', 'system volume information', diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 1d62bb4..3efe764 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; diff --git a/src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection.tsx b/src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection.tsx index c89b7bd..0cc50dd 100644 --- a/src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection.tsx +++ b/src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection.tsx @@ -45,6 +45,10 @@ export function AutoDetectImageKeySection({ {state.status.platform === 'darwin' ? '扫描本机微信缓存并通过图片模板验证候选密钥。' : '扫描微信进程内存并通过本地图片模板验证候选密钥。'} +
+ + 仅支持 WeChat 4.0,V3 及以下无法解析 +

- {result ? ( -
- {result.fileFound ? '✓' : '×'} 找到图片文件 - {result.decrypted ? '✓' : '×'} 解密成功 - {result.readable ? '✓' : '×'} 图片可以读取 - {!result.success ?

{result.error}

: null} -
+ {steps ? ( +
    +
  1. + {stepIcon(steps.find)} + 找到图片文件 +
  2. +
  3. + {stepIcon(steps.decrypt)} + 解密成功 +
  4. +
  5. + {stepIcon(steps.read)} + 图片可以读取 +
  6. +
) : state.error ? (

{state.error}

) : null} + {result && !result.success && result.error ? ( +

{result.error}

+ ) : null} ) } diff --git a/src/renderer/src/features/settings/image-decryption/imageDecryptionReducer.ts b/src/renderer/src/features/settings/image-decryption/imageDecryptionReducer.ts index 36c6dd9..7378b04 100644 --- a/src/renderer/src/features/settings/image-decryption/imageDecryptionReducer.ts +++ b/src/renderer/src/features/settings/image-decryption/imageDecryptionReducer.ts @@ -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 diff --git a/src/renderer/src/features/settings/image-decryption/types.ts b/src/renderer/src/features/settings/image-decryption/types.ts index bedb6f2..2121469 100644 --- a/src/renderer/src/features/settings/image-decryption/types.ts +++ b/src/renderer/src/features/settings/image-decryption/types.ts @@ -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 save: () => Promise diff --git a/src/renderer/src/features/settings/image-decryption/useImageDecryptionController.ts b/src/renderer/src/features/settings/image-decryption/useImageDecryptionController.ts index 6582bc4..033af85 100644 --- a/src/renderer/src/features/settings/image-decryption/useImageDecryptionController.ts +++ b/src/renderer/src/features/settings/image-decryption/useImageDecryptionController.ts @@ -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({ diff --git a/src/renderer/src/features/settings/image-decryption/utils.ts b/src/renderer/src/features/settings/image-decryption/utils.ts index 21e5785..2ec6645 100644 --- a/src/renderer/src/features/settings/image-decryption/utils.ts +++ b/src/renderer/src/features/settings/image-decryption/utils.ts @@ -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 {