Merge branch 'develop'

This commit is contained in:
Wxw-Gu
2026-07-27 09:42:25 +08:00
20 changed files with 340 additions and 81 deletions
+4 -1
View File
@@ -6,9 +6,12 @@ VITE_DB_KEY=
VITE_AUTO_LOGIN=false VITE_AUTO_LOGIN=false
# AI API Configuration (Optional, can be entered in UI) # AI API Configuration (Optional, can be entered in UI)
# 注意:发布版本不再自动读取以下环境变量。
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
# 然后在「设置 → AI 模型」里手动完成"添加供应商"流程。
VITE_DEEPSEEK_API_KEY= VITE_DEEPSEEK_API_KEY=
VITE_AI_BASE_URL=https://api.deepseek.com 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. # Message types to filter out (comma separated). Empty means show all message types.
VITE_FILTER_MSG_TYPES= VITE_FILTER_MSG_TYPES=
+1 -1
View File
@@ -9,7 +9,7 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。 在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
> 当前版本:`v2.1.4`。macOS 支持相对稳定;Windows 可能会遇到性能 卡顿问题, 仍在持续兼容不同微信版本与本地目录结构。 > macOS 支持相对稳定;Windows 已初步支持 但因聊天记录大/机械硬盘等问题 会有所卡顿,仍在持续兼容不同微信版本与本地目录结构。
## ✨ 功能特性 ## ✨ 功能特性
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "wechatexplorer", "name": "wechatexplorer",
"version": "2.1.4", "version": "2.1.5",
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手", "description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
"keywords": [ "keywords": [
"wechat", "wechat",
+11 -15
View File
@@ -10,8 +10,6 @@ const imageDecryptLog = (...args: unknown[]): void => {
} }
export class ImageDecryptService { export class ImageDecryptService {
private readonly defaultV1AesKey = 'cfcd208495d565ef'
private xorKey: number = 0 private xorKey: number = 0
private aesKey: string = '' private aesKey: string = ''
private wcdb4Client: Wcdb4Client | null = null private wcdb4Client: Wcdb4Client | null = null
@@ -80,9 +78,11 @@ export class ImageDecryptService {
findImageFile( findImageFile(
md5?: string, md5?: string,
imageDatName?: string, imageDatName?: string,
options?: { allowThumbnail?: boolean } options?: { allowThumbnail?: boolean; accountDir?: string }
): string | null { ): string | null {
const accountDir = this.getAccountDir() // 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
const accountDir =
options?.accountDir && existsSync(options.accountDir) ? options.accountDir : this.getAccountDir()
if (!accountDir) return null if (!accountDir) return null
const allowThumbnail = options?.allowThumbnail !== false const allowThumbnail = options?.allowThumbnail !== false
@@ -273,12 +273,9 @@ export class ImageDecryptService {
) )
let decrypted: Buffer let decrypted: Buffer
if (version === 1) { if (version === 2) {
imageDecryptLog('[ImageDecrypt] using V1 (default AES key)') // WeChat 4.0 标准 dat 头: 07 08 56 32 08 07
const key = Buffer.from(this.defaultV1AesKey, 'ascii') imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)')
decrypted = this.decryptDatV4(datPath, key)
} else if (version === 2) {
imageDecryptLog('[ImageDecrypt] using V2 (user AES key)')
if (!this.aesKey) { if (!this.aesKey) {
imageDecryptLog('[ImageDecrypt] no AES key configured') imageDecryptLog('[ImageDecrypt] no AES key configured')
return null return null
@@ -286,7 +283,8 @@ export class ImageDecryptService {
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16) const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
decrypted = this.decryptDatV4(datPath, key) decrypted = this.decryptDatV4(datPath, key)
} else { } else {
imageDecryptLog('[ImageDecrypt] unsupported dat version:', version) // 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version)
return null return null
} }
@@ -356,7 +354,8 @@ export class ImageDecryptService {
} }
/** /**
* 检测 DAT 文件版本 * 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。
* 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。
*/ */
private getDatVersion(inputPath: string): number { private getDatVersion(inputPath: string): number {
const bytes = readFileSync(inputPath) const bytes = readFileSync(inputPath)
@@ -365,9 +364,6 @@ export class ImageDecryptService {
} }
const signature = bytes.subarray(0, 6) 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]))) { if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
return 2 return 2
} }
+18 -2
View File
@@ -297,7 +297,12 @@ app.whenReady().then(async () => {
const nextWechatDb = await WechatDb.create(key, settings.dbRoot) const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot() const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
if (resolvedRoot && resolvedRoot !== settings.dbRoot) { if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
saveSettings({ ...settings, dbRoot: resolvedRoot }) // 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
saveSettings({
...settings,
dbRoot: resolvedRoot,
imageKeyRoot: resolvedRoot
})
} }
chat.setChatDb(nextWechatDb) chat.setChatDb(nextWechatDb)
const wcdb4Client = nextWechatDb.getWcdb4Client() const wcdb4Client = nextWechatDb.getWcdb4Client()
@@ -411,7 +416,13 @@ app.whenReady().then(async () => {
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => { ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
const settings = loadSettings() const settings = loadSettings()
const self = chat.getSelfAccountInfo() 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 wxid = self?.wxid
const onStatus = (message: string): void => { const onStatus = (message: string): void => {
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message }) if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
@@ -788,6 +799,11 @@ app.whenReady().then(async () => {
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => { ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
const ok = chat.reopenWithRoot(accountRoot) const ok = chat.reopenWithRoot(accountRoot)
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' } if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
// 同步 imageKeyRoot,避免自动获取扫描到旧目录
const settings = loadSettings()
if (accountRoot && accountRoot !== settings.imageKeyRoot) {
saveSettings({ ...settings, imageKeyRoot: accountRoot })
}
const info = chat.getSelfAccountInfo() const info = chat.getSelfAccountInfo()
return { success: true, info } return { success: true, info }
}) })
+53 -4
View File
@@ -889,7 +889,8 @@ export class KeyService {
const dirName = normalized.split(/[\\/]/).pop() ?? '' const dirName = normalized.split(/[\\/]/).pop() ?? ''
if (dirName.startsWith('wxid_')) pushUnique(dirName) 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) { if (marker) {
const root = normalized.slice(0, marker.index! + marker[0].length) const root = normalized.slice(0, marker.index! + marker[0].length)
try { try {
@@ -934,15 +935,49 @@ export class KeyService {
onProgress?.('正在查找模板文件...') onProgress?.('正在查找模板文件...')
let result = await this._findTemplateData(userDir, 32) let result = await this._findTemplateData(userDir, 32)
let { ciphertext, xorKey } = result let { ciphertext, xorKey } = result
const firstDiag = (this as { _imageTemplateDiag?: {
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
} })._imageTemplateDiag
// 如果找不到密钥,尝试扫描更多文件 // 如果找不到密钥,尝试扫描更多文件
if (ciphertext && xorKey === null) { if (ciphertext && xorKey === null) {
onProgress?.('未找到有效密钥,尝试扫描更多文件...') onProgress?.('未找到有效密钥,尝试扫描更多文件...')
result = await this._findTemplateData(userDir, 100) result = await this._findTemplateData(userDir, 100)
xorKey = result.xorKey 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 密钥,请确保在微信中查看了多张不同的图片' } if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
@@ -1005,6 +1040,8 @@ export class KeyService {
let ciphertext: Buffer | null = null let ciphertext: Buffer | null = null
const tailCounts: Record<string, number> = {} const tailCounts: Record<string, number> = {}
let v2Count = 0
let nonV2Count = 0
for (const f of files.slice(0, 32)) { for (const f of files.slice(0, 32)) {
try { try {
@@ -1013,8 +1050,11 @@ export class KeyService {
// 统计末尾两字节用于 XOR 密钥 // 统计末尾两字节用于 XOR 密钥
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
v2Count++
const key = `${data[data.length - 2]}_${data[data.length - 1]}` const key = `${data[data.length - 2]}_${data[data.length - 1]}`
tailCounts[key] = (tailCounts[key] ?? 0) + 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 } 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 } return { ciphertext, xorKey }
} }
+4 -10
View File
@@ -41,7 +41,6 @@ export class AIProviderService {
constructor(private readonly keyStore = new AIProviderKeyStore()) {} constructor(private readonly keyStore = new AIProviderKeyStore()) {}
list(): AIProviderListResult { list(): AIProviderListResult {
this.ensureEnvironmentMigration()
try { try {
const data = this.readMetadata() const data = this.readMetadata()
return { return {
@@ -339,15 +338,10 @@ export class AIProviderService {
} }
private ensureEnvironmentMigration(): void { private ensureEnvironmentMigration(): void {
const data = this.readMetadata() // 已禁用:内置环境变量 Key 自动迁移策略。
if (data.providers.length) return // 安全要求:发布给最终用户的版本不应携带任何内置 API Key,
const apiKey = String(import.meta.env.VITE_DEEPSEEK_API_KEY || '').trim() // 必须由用户自己在 UI 里手动配置(或者通过自己的 .env.local 注入)。
if (!apiKey) return // 保留此方法作为占位,方便后续重新评估。
this.migrateLegacy({
apiKey,
baseUrl: String(import.meta.env.VITE_AI_BASE_URL || ''),
model: String(import.meta.env.VITE_AI_MODEL || '')
})
} }
private toSummary( private toSummary(
@@ -17,7 +17,9 @@ import { isWechatRunning } from './wechat-process-status'
export async function inspectImageDecryptionStatus( export async function inspectImageDecryptionStatus(
config: ImageKeyConfigResult config: ImageKeyConfigResult
): Promise<ImageDecryptionStatus> { ): Promise<ImageDecryptionStatus> {
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot // 状态面板的"图片资源目录"始终等于当前识别到的微信账号根目录;
// 仅在微信未连接时回退到上次配置中的 resourceRoot,避免空白。
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot || ''
const imageDirectoryFound = hasImageDirectory(accountRoot) const imageDirectoryFound = hasImageDirectory(accountRoot)
const stickerCacheFound = const stickerCacheFound =
fs.existsSync(path.join(accountRoot, 'cache')) || fs.existsSync(path.join(accountRoot, 'cache')) ||
@@ -65,7 +67,10 @@ export function testImageDecryption(
.reverse() .reverse()
.find((message) => message.contentData?.type === 'image') .find((message) => message.contentData?.type === 'image')
if (!imageMessage || imageMessage.contentData?.type !== 'image') { if (!imageMessage || imageMessage.contentData?.type !== 'image') {
return failure('NO_IMAGE_MESSAGE', '所选聊天最近没有可测试的图片消息') return failure(
'NO_IMAGE_MESSAGE',
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
)
} }
const service = new ImageDecryptService( const service = new ImageDecryptService(
@@ -74,26 +79,51 @@ export function testImageDecryption(
chat.getChatDb()?.getWcdb4Client() chat.getChatDb()?.getWcdb4Client()
) )
const image = imageMessage.contentData 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) 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', '图片文件不存在') if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
const data = service.decryptImageToBase64(filePath) const data = service.decryptImageToBase64(filePath)
if (!data) { if (!data) {
// 三步联动:解密失败 → fileFound/decrypted/readable 都为 false。
return { return {
...failure('DECRYPT_FAILED', '无法解析媒体文件'), success: false,
fileFound: true code: 'DECRYPT_FAILED',
error: '无法解析媒体文件',
fileFound: false,
decrypted: false,
readable: false
} }
} }
const readable = data.startsWith('data:image/') 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 { return {
success: readable, success: true,
code: readable ? undefined : 'DECRYPT_FAILED',
error: readable ? undefined : '图片解密结果不可读取',
fileFound: true, fileFound: true,
decrypted: true, decrypted: true,
readable, readable: true,
isThumbnail: service.isThumbnailFile(filePath) isThumbnail: service.isThumbnailFile(filePath)
} }
} catch { } catch {
@@ -92,9 +92,10 @@ export class ImageKeyConfigService {
if (!result.success || !result.entry) { if (!result.success || !result.entry) {
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' } return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
} }
// 注意:normalized.resourceRoot 不再写回 imageKeyRoot。
// 下方的"图片资源目录"输入框仅用于本次手动测试,不再污染状态面板上方显示。
saveSettings({ saveSettings({
...settings, ...settings,
imageKeyRoot: normalized.resourceRoot,
imageXorKey: '', imageXorKey: '',
imageAesKey: '', imageAesKey: '',
imageKeyFallbackDisabled: false imageKeyFallbackDisabled: false
@@ -106,7 +107,9 @@ export class ImageKeyConfigService {
encryptionAvailable: true, encryptionAvailable: true,
source: 'secure-storage', source: 'secure-storage',
accountId: context.accountId, accountId: context.accountId,
resourceRoot: normalized.resourceRoot, // 返回的 resourceRoot 始终是识别到的默认目录,与状态面板一致;
// 不返回 normalized.resourceRoot,避免把用户输入的测试目录当成状态写回。
resourceRoot: context.resourceRoot,
xorKey: result.entry.xorKey, xorKey: result.entry.xorKey,
aesKey: result.entry.aesKey, aesKey: result.entry.aesKey,
updatedAt: result.entry.updatedAt updatedAt: result.entry.updatedAt
+30 -3
View File
@@ -4,6 +4,22 @@ import path from 'path'
import os from 'os' import os from 'os'
import { discoverWindowsDbRoots } from '../windows-db-root-discovery' 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 { export interface AppSettings {
dbRoot: string dbRoot: string
apiEnabled: boolean apiEnabled: boolean
@@ -25,16 +41,17 @@ function getDefaultDbRoot(): string {
function getDefaultDbRootCandidates(home: string): string[] { function getDefaultDbRootCandidates(home: string): string[] {
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
// macOS 仅支持 WeChat 4.0 路径(xwechat_files
return [ return [
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files') 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 = [ const candidates = [
...getWeflowDbPathCandidates(home), ...getWeflowDbPathCandidates(home),
path.join(home, 'Documents', 'WeChat Files'),
path.join(home, 'Documents', 'xwechat_files'), path.join(home, 'Documents', 'xwechat_files'),
path.join(home, 'WeChat Files'),
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_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)) { if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
cache.dbRoot = getDefaultDbRoot() cache.dbRoot = getDefaultDbRoot()
} }
if (!cache.imageKeyRoot) { // 同步:imageKeyRoot 必须跟随 dbRoot 更新,
// 否则自动获取会扫错目录(旧 bug:状态面板显示 D 盘,自动获取扫 C 盘)。
if (!cache.imageKeyRoot || !isUsableDbRoot(cache.imageKeyRoot)) {
cache.imageKeyRoot = cache.dbRoot cache.imageKeyRoot = cache.dbRoot
} }
// V4-only 兜底:如果 imageKeyRoot 指向旧的 "WeChat Files"V3 路径),
// 重定向到同一父目录下的 xwechat_filesV4)。
if (cache.imageKeyRoot) {
cache.imageKeyRoot = redirectLegacyWeChatFilesToXwechat(cache.imageKeyRoot)
}
if (cache.dbRoot) {
cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot)
}
return cache return cache
} }
} catch (error) { } catch (error) {
+1 -2
View File
@@ -311,11 +311,10 @@ export class Wcdb4Client {
private static getDefaultRootCandidates(): string[] { private static getDefaultRootCandidates(): string[] {
const home = os.homedir() const home = os.homedir()
if (process.platform === 'win32') { if (process.platform === 'win32') {
// 仅支持 WeChat 4.0:只认 xwechat_filesV3 时代的 "WeChat Files" 不再加入候选
const candidates = [ const candidates = [
...Wcdb4Client.getWeflowDbPathCandidates(home), ...Wcdb4Client.getWeflowDbPathCandidates(home),
path.join(home, 'Documents', 'WeChat Files'),
path.join(home, 'Documents', 'xwechat_files'), path.join(home, 'Documents', 'xwechat_files'),
path.join(home, 'WeChat Files'),
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files') path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
] ]
candidates.push(...discoverWindowsDbRoots()) candidates.push(...discoverWindowsDbRoots())
+2 -1
View File
@@ -1,7 +1,8 @@
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'path' 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([ const SKIPPED_DIRECTORY_NAMES = new Set([
'$recycle.bin', '$recycle.bin',
'system volume information', 'system volume information',
+59
View File
@@ -5791,6 +5791,59 @@ body {
width: 100%; width: 100%;
margin: 2px 0 0; 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 { .image-inline-error {
margin: 14px 0 0; margin: 14px 0 0;
color: #a84444; color: #a84444;
@@ -5829,6 +5882,12 @@ body {
border-radius: 7px; border-radius: 7px;
background: #f1f4f3; background: #f1f4f3;
} }
.image-auto-scope-hint {
display: inline-block;
margin-top: 4px;
color: #7d8c8a;
font-size: 11px;
}
.image-auto-unavailable { .image-auto-unavailable {
padding-top: 20px; padding-top: 20px;
padding-bottom: 20px; padding-bottom: 20px;
@@ -45,6 +45,10 @@ export function AutoDetectImageKeySection({
{state.status.platform === 'darwin' {state.status.platform === 'darwin'
? '扫描本机微信缓存并通过图片模板验证候选密钥。' ? '扫描本机微信缓存并通过图片模板验证候选密钥。'
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'} : '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
<br />
<small className="image-auto-scope-hint">
WeChat 4.0V3
</small>
</p> </p>
</div> </div>
<button <button
@@ -7,19 +7,10 @@ export function ImageKeyConfiguration({
}: { }: {
state: ImageDecryptionState state: ImageDecryptionState
disabled: boolean disabled: boolean
onEdit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void onEdit: (field: 'xorKey' | 'aesKey', value: string) => void
}): React.ReactElement { }): React.ReactElement {
return ( return (
<section className="settings-card image-key-editor"> <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"> <div className="image-key-grid">
<label> <label>
<span>XOR Key</span> <span>XOR Key</span>
@@ -1,5 +1,58 @@
import type { ImageDecryptionState } from './types' 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({ export function ImageTestSection({
state, state,
disabled, disabled,
@@ -16,6 +69,14 @@ export function ImageTestSection({
onSave: () => void onSave: () => void
}): React.ReactElement { }): React.ReactElement {
const result = state.testResult const result = state.testResult
const steps = result
? pickSteps({
fileFound: result.fileFound,
decrypted: result.decrypted,
readable: result.readable,
success: result.success
})
: null
return ( return (
<section className="settings-card image-test-section"> <section className="settings-card image-test-section">
<div> <div>
@@ -48,16 +109,27 @@ export function ImageTestSection({
</button> </button>
</div> </div>
{result ? ( {steps ? (
<div className={`image-test-result ${result.success ? 'success' : 'error'}`}> <ol className="image-step-list">
<span>{result.fileFound ? '✓' : '×'} </span> <li className={stepClass(steps.find)}>
<span>{result.decrypted ? '✓' : '×'} </span> <span className="image-step-icon">{stepIcon(steps.find)}</span>
<span>{result.readable ? '✓' : '×'} </span> <span></span>
{!result.success ? <p>{result.error}</p> : null} </li>
</div> <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 ? ( ) : state.error ? (
<p className="image-inline-error">{state.error}</p> <p className="image-inline-error">{state.error}</p>
) : null} ) : null}
{result && !result.success && result.error ? (
<p className="image-inline-error">{result.error}</p>
) : null}
</section> </section>
) )
} }
@@ -27,7 +27,11 @@ export function imageDecryptionReducer(
config: action.config, config: action.config,
status: action.status, status: action.status,
contacts: action.contacts, 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', xorKey: action.config.xorKey || '0x40',
aesKey: action.config.aesKey || '', aesKey: action.config.aesKey || '',
error: action.config.success ? undefined : action.config.error, error: action.config.success ? undefined : action.config.error,
@@ -126,7 +130,7 @@ export function imageDecryptionReducer(
config: action.config, config: action.config,
status: action.status, status: action.status,
contacts: state.contacts, contacts: state.contacts,
resourceRoot: action.config.resourceRoot resourceRoot: action.status.resourceRoot || action.config.resourceRoot
} }
default: default:
return state return state
@@ -53,7 +53,7 @@ export type ImageDecryptionAction =
contacts: Contact[] contacts: Contact[]
} }
| { type: 'LOAD_ERROR'; error: string } | { 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: 'SELECT_CHAT'; userMd5: string }
| { type: 'TEST_START' } | { type: 'TEST_START' }
| { type: 'TEST_DONE'; result: ImageDecryptionTestResult } | { type: 'TEST_DONE'; result: ImageDecryptionTestResult }
@@ -80,7 +80,7 @@ export interface ImageDecryptionController {
pageStatus: 'configured' | 'unconfigured' | 'partial' pageStatus: 'configured' | 'unconfigured' | 'partial'
busy: boolean busy: boolean
canSave: boolean canSave: boolean
edit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void edit: (field: 'xorKey' | 'aesKey', value: string) => void
selectChat: (userMd5: string) => void selectChat: (userMd5: string) => void
test: () => Promise<void> test: () => Promise<void>
save: () => Promise<void> save: () => Promise<void>
@@ -33,7 +33,7 @@ export function useImageDecryptionController({
}) })
}, [refresh]) }, [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 }) dispatch({ type: 'EDIT', field, value })
}, []) }, [])
@@ -84,12 +84,17 @@ export function useImageDecryptionController({
dispatch({ type: 'AUTO_START' }) dispatch({ type: 'AUTO_START' })
const result = await window.api.autoGetImageKey({ save: false }) const result = await window.api.autoGetImageKey({ save: false })
if (!result.success || !result.aesKey || !result.verified) { if (!result.success || !result.aesKey || !result.verified) {
dispatch({ // 自动获取链路:原文透传后端错误信息,不要走 sanitizeImageError。
type: 'AUTO_ERROR', // sanitizeImageError 是给"测试图片解析"设计的字典,会把
error: result.success // "未找到 V2 模板文件 / 微信进程未运行 / 60 秒未扫描到密钥" 等
? '获取到候选密钥,但未通过图片验证' // 完全合法的扫描阶段错误强制翻成"无法解析媒体文件"。
: sanitizeImageError(result.error) const rawError = (result.error || '').toString().trim()
}) const errorMessage = !result.success
? rawError || '自动获取图片密钥失败'
: !result.aesKey
? '自动获取未返回 AES 密钥'
: '获取到候选密钥,但未通过图片验证'
dispatch({ type: 'AUTO_ERROR', error: errorMessage })
return return
} }
dispatch({ dispatch({
@@ -11,11 +11,17 @@ export function formatImageConfigTime(value?: number): string {
export function sanitizeImageError(error?: string): string { export function sanitizeImageError(error?: string): string {
const value = String(error || '').toLowerCase() 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('key') || value.includes('密钥')) return '图片密钥未配置或与当前账号不匹配'
if (value.includes('不存在') || value.includes('not found')) return '图片文件不存在' if (value.includes('不存在') || value.includes('not found')) return '图片文件不存在'
if (value.includes('账号')) return '当前账号不匹配' if (value.includes('账号')) return '当前账号不匹配'
if (value.includes('目录')) return '图片资源目录不可用' if (value.includes('目录')) return '图片资源目录不可用'
return error ? '无法解析媒体文件' : '图片解析测试未通过' return error ? '无法解析媒体文件(仅支持 WeChat 4.0' : '图片解析测试未通过'
} }
export function normalizeAutoXorKey(value?: number, formatted?: string): string { export function normalizeAutoXorKey(value?: number, formatted?: string): string {