mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
Merge branch 'develop'
This commit is contained in:
+4
-1
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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_files(V4)。
|
||||||
|
if (cache.imageKeyRoot) {
|
||||||
|
cache.imageKeyRoot = redirectLegacyWeChatFilesToXwechat(cache.imageKeyRoot)
|
||||||
|
}
|
||||||
|
if (cache.dbRoot) {
|
||||||
|
cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot)
|
||||||
|
}
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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_files,V3 时代的 "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())
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.0,V3 及以下无法解析
|
||||||
|
</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>
|
||||||
|
|||||||
+12
-7
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user