fix: 完善聊天解析与导出体验

- 修复引用消息名称和图片布局
- 明确单会话图片测试日志范围
- 支持导出文件附件
- 完善图片批测、会话刷新和安全退出
This commit is contained in:
Wxw-Gu
2026-08-04 12:03:07 +08:00
parent d76727875d
commit c70e49bf16
41 changed files with 2320 additions and 221 deletions
+18 -4
View File
@@ -55,7 +55,8 @@ export interface FormattedMessage {
voiceDataUrl?: string
voiceDuration?: number
exportMediaUrl?: string
exportMediaType?: 'image' | 'video' | 'sticker'
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
exportMediaName?: string
exportShowAvatar?: boolean
exportMediaError?: string
exportAvatarUrl?: string
@@ -109,10 +110,24 @@ function normalizeMsgType(value: string | number | undefined): number {
}
let dbRef: WechatDb | null = null
let shutdownRequested = false
export function setChatDb(db: WechatDb | null): void {
export function setChatDb(db: WechatDb | null): boolean {
if (shutdownRequested) {
db?.close()
return false
}
dbRef?.close()
dbRef = db
return true
}
export async function closeChatDbForQuit(): Promise<boolean> {
shutdownRequested = true
const current = dbRef
dbRef = null
if (!current) return true
return current.closeAsync()
}
export function getChatDb(): WechatDb | null {
@@ -639,8 +654,7 @@ export function reopenWithRoot(accountRoot: string): boolean {
if (!key) return false
try {
const next = new WechatDb(key, accountRoot)
setChatDb(next)
return true
return setChatDb(next)
} catch (error) {
console.error('[ChatService] reopen with root failed:', error)
return false
@@ -3,13 +3,18 @@ import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import type {
ImageDecoderStatus,
ImageDecryptionStatus,
ImageDecryptionTestResult,
ImageKeyConfigResult,
ImageResourceCheck,
TestImageDecryptionRequest
} from '../../shared/image-decryption'
import { ImageDecryptService, inspectImageDecoderStatus } from '../image-decrypt-service'
import {
ImageDecryptService,
inspectImageDecoderStatus,
type ImageDecodeDiagnostic
} from '../image-decrypt-service'
import * as chat from './chat-service'
import { validateImageKeyRequest } from './image-key-config-service'
import { isWechatRunning } from './wechat-process-status'
@@ -57,13 +62,35 @@ export async function inspectImageDecryptionStatus(
}
}
export function testImageDecryption(
export async function testImageDecryption(
request: TestImageDecryptionRequest
): ImageDecryptionTestResult {
): Promise<ImageDecryptionTestResult> {
const startedAt = Date.now()
let testedImage:
| { md5?: string; datName?: string; sessionId?: string; selection: string }
| undefined
let filePath: string | undefined
let decodeDiagnostic: ImageDecodeDiagnostic | undefined
let decoder: ImageDecoderStatus | undefined
const finish = (
result: Omit<ImageDecryptionTestResult, 'diagnosticLog'>
): ImageDecryptionTestResult => ({
...result,
diagnosticLog: buildImageTestDiagnosticLog({
request,
result,
startedAt,
testedImage,
filePath,
decodeDiagnostic,
decoder
})
})
const normalized = validateImageKeyRequest(request)
if (!normalized.success) return failure('NOT_CONFIGURED', normalized.error)
if (!normalized.success) return finish(failure('NOT_CONFIGURED', normalized.error))
if (!chat.isReady() || !request.userMd5) {
return failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录')
return finish(failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录'))
}
try {
@@ -72,9 +99,11 @@ export function testImageDecryption(
.reverse()
.find((message) => message.contentData?.type === 'image')
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
return failure(
'NO_IMAGE_MESSAGE',
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
return finish(
failure(
'NO_IMAGE_MESSAGE',
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
)
)
}
@@ -84,58 +113,324 @@ export function testImageDecryption(
chat.getChatDb()?.getWcdb4Client()
)
const image = imageMessage.contentData
// 测试时优先使用用户在下方"图片资源目录"输入框填写的目录;
// 找不到再退回默认 accountDir。
testedImage = {
md5: image.md5,
datName: image.datName,
sessionId: imageMessage.sessionId,
selection: '所选会话最近 300 条消息中的最后一张图片'
}
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,
accountDir: testAccountDir
})
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
filePath =
(await service.findImageFileAsync(image.md5, image.datName, {
allowThumbnail: false,
accountDir: testAccountDir,
sessionId: imageMessage.sessionId
})) || undefined
if (!filePath) {
filePath =
(await service.findImageFileAsync(image.md5, image.datName, {
allowThumbnail: true,
accountDir: testAccountDir,
sessionId: imageMessage.sessionId
})) || undefined
}
if (!filePath) return finish(failure('FILE_NOT_FOUND', '图片文件不存在'))
const data = service.decryptImageToBase64(filePath)
if (!data) {
// 三步联动:解密失败 → fileFound/decrypted/readable 都为 false。
return {
let decoded = await service.decryptImageToBase64WithFallbackAsync(filePath, true)
if (!decoded) {
// Worker 失败后在主进程做一次同步诊断:既能保留具体失败阶段,
// 也能在少数 Worker 启动异常时继续测试普通图片。
decoded = service.decryptImageToBase64WithFallback(filePath, true)
}
if (!decoded) {
decodeDiagnostic = service.getLastDecodeDiagnostic()
if (decodeDiagnostic.code === 'WXGF_REQUIRES_DECODER') {
decoder = await inspectImageDecoderStatus()
}
return finish({
success: false,
code: 'DECRYPT_FAILED',
error: '无法解析媒体文件',
fileFound: false,
decrypted: false,
readable: false
}
error: getDecodeFailureMessage(decodeDiagnostic, decoder),
fileFound: true,
decrypted: isDecryptedDiagnostic(decodeDiagnostic.code),
readable: false,
isThumbnail: service.isThumbnailFile(filePath)
})
}
const readable = data.startsWith('data:image/')
filePath = decoded.filePath
decodeDiagnostic = buildSuccessDiagnostic(decoded.data, decoded.filePath)
const readable = decoded.data.startsWith('data:image/')
if (!readable) {
// 三步联动:解密成功但字节流不可读 → 前一步打勾(确实找到了 dat),
// 但 decrypted/readable 全为 false,让 UI 表达"找到但解析失败"。
return {
return finish({
success: false,
code: 'DECRYPT_FAILED',
error: '图片解密结果不可读取',
fileFound: true,
decrypted: false,
decrypted: true,
readable: false,
isThumbnail: service.isThumbnailFile(filePath)
}
isThumbnail: service.isThumbnailFile(decoded.filePath)
})
}
return {
return finish({
success: true,
fileFound: true,
decrypted: true,
readable: true,
isThumbnail: service.isThumbnailFile(filePath)
}
isThumbnail: service.isThumbnailFile(decoded.filePath)
})
} catch {
return failure('UNKNOWN', '图片解析测试未通过')
return finish(failure('UNKNOWN', '图片解析测试未通过'))
}
}
function buildSuccessDiagnostic(data: string, filePath: string): ImageDecodeDiagnostic {
const format = /^data:image\/([^;]+);/i.exec(data)?.[1]?.toUpperCase()
const directImageFormat = inspectDirectImageFormat(filePath)
return {
code: directImageFormat ? 'DIRECT_IMAGE' : 'SUCCESS',
detail: directImageFormat ? 'DAT 文件内容是可直接读取的图片' : '图片解密并识别成功',
datVersion: directImageFormat ? undefined : inspectDatVersion(filePath),
fileSize: safeFileSize(filePath),
imageFormat: format || directImageFormat
}
}
function inspectDirectImageFormat(filePath: string): string | undefined {
try {
const signature = fs.readFileSync(filePath).subarray(0, 12)
if (signature[0] === 0xff && signature[1] === 0xd8 && signature[2] === 0xff) return 'JPEG'
if (
signature[0] === 0x89 &&
signature[1] === 0x50 &&
signature[2] === 0x4e &&
signature[3] === 0x47
)
return 'PNG'
if (
signature[0] === 0x47 &&
signature[1] === 0x49 &&
signature[2] === 0x46 &&
signature[3] === 0x38
)
return 'GIF'
if (signature[0] === 0x42 && signature[1] === 0x4d) return 'BMP'
if (signature.subarray(0, 4).toString('ascii') === 'RIFF') return 'WEBP'
return undefined
} catch {
return undefined
}
}
function inspectDatVersion(filePath: string): number | undefined {
if (!path.extname(filePath).toLowerCase().includes('dat')) return undefined
try {
const signature = fs.readFileSync(filePath).subarray(0, 6)
return signature.equals(Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])) ? 2 : 0
} catch {
return undefined
}
}
function safeFileSize(filePath: string): number | undefined {
try {
return fs.statSync(filePath).size
} catch {
return undefined
}
}
function isDecryptedDiagnostic(code: ImageDecodeDiagnostic['code']): boolean {
return code === 'WXGF_REQUIRES_DECODER' || code === 'UNKNOWN_IMAGE_FORMAT'
}
function getDecodeFailureMessage(
diagnostic: ImageDecodeDiagnostic,
decoder?: ImageDecoderStatus
): string {
switch (diagnostic.code) {
case 'UNSUPPORTED_DAT_VERSION':
return '仅支持 WeChat 4.0 图片协议,当前图片格式不受支持'
case 'MISSING_AES_KEY':
return '图片密钥未配置'
case 'AES_DECRYPT_FAILED':
return '图片密钥与当前账号不匹配,或图片文件已损坏'
case 'INVALID_DAT_FILE':
return '图片文件不完整或格式异常'
case 'WXGF_REQUIRES_DECODER':
return decoder?.available
? 'WXGF/HEVC 图片转换失败,请复制测试日志反馈'
: '该图片需要 FFmpeg 的 HEVC 解码能力'
case 'UNKNOWN_IMAGE_FORMAT':
return '图片已解密,但当前格式无法识别'
default:
return '无法解析媒体文件'
}
}
export function buildImageTestDiagnosticLog(input: {
request: TestImageDecryptionRequest
result: Omit<ImageDecryptionTestResult, 'diagnosticLog'>
startedAt: number
testedImage?: { md5?: string; datName?: string; sessionId?: string; selection: string }
filePath?: string
decodeDiagnostic?: ImageDecodeDiagnostic
decoder?: ImageDecoderStatus
}): string {
const root = String(input.request.resourceRoot || '').trim()
const rootExists = root ? fs.existsSync(root) : false
const rootIsDirectory = rootExists ? safeIsDirectory(root) : false
const resultCode = input.result.success ? 'SUCCESS' : input.result.code || 'UNKNOWN'
return [
'WechatExplorer 图片解析测试日志(已脱敏)',
`时间:${new Date().toISOString()}`,
`应用版本:${safeAppVersion()}`,
`运行环境:${process.platform} ${process.arch}`,
`测试结果:${input.result.success ? '成功' : '失败'}${resultCode}`,
`耗时:${Date.now() - input.startedAt} ms`,
'',
'[配置]',
`资源目录:${root ? `已填写(末级 ${redactIdentifier(path.basename(root))}` : '未填写'}`,
`目录存在:${yesNo(rootExists)}`,
`目录可读取:${yesNo(rootIsDirectory)}`,
`包含图片目录:${yesNo(rootIsDirectory && hasImageDirectory(root))}`,
`AES 密钥:${input.request.aesKey.trim().length === 16 ? '已配置(长度有效,内容未记录)' : '未配置或长度无效'}`,
`XOR Key${/^0x[0-9a-f]{2}$/i.test(input.request.xorKey.trim()) ? '格式有效(内容未记录)' : '格式无效'}`,
'',
'[测试样本]',
`选取方式:${input.testedImage?.selection || '未选取'}`,
`会话定位信息:${input.testedImage?.sessionId ? '有' : '无'}`,
`图片 MD5${redactIdentifier(input.testedImage?.md5)}`,
`DAT 文件名:${redactFileName(input.testedImage?.datName)}`,
'',
'[文件查找]',
'查找方式:异步会话目录 + Hardlink 索引',
`找到文件:${yesNo(input.result.fileFound)}`,
`文件来源:${input.filePath ? describeFileSource(input.filePath) : '无'}`,
`清晰度:${input.filePath ? (input.result.isThumbnail ? '缩略图' : '原图/高清变体') : '未知'}`,
`文件大小:${formatBytes(input.decodeDiagnostic?.fileSize ?? (input.filePath ? safeFileSize(input.filePath) : undefined))}`,
`DAT 协议:${formatDatProtocol(input.decodeDiagnostic, input.filePath)}`,
'',
'[解析结果]',
`文件找到:${yesNo(input.result.fileFound)}`,
`数据解密:${yesNo(input.result.decrypted)}`,
`图片可读:${yesNo(input.result.readable)}`,
`诊断代码:${input.decodeDiagnostic?.code || resultCode}`,
`诊断说明:${input.decodeDiagnostic?.detail || input.result.error || '无'}`,
`图片格式:${input.decodeDiagnostic?.imageFormat || '未识别'}`,
`WXGF/HEVC${input.decodeDiagnostic?.wxgf ? '是' : '否/未检测'}`,
`FFmpeg${formatDecoder(input.decoder)}`,
'',
`建议:${buildDiagnosticAdvice(resultCode, input.decodeDiagnostic, input.decoder)}`
].join('\n')
}
function safeAppVersion(): string {
try {
return app.getVersion()
} catch {
return '未知'
}
}
function safeIsDirectory(value: string): boolean {
try {
return fs.statSync(value).isDirectory()
} catch {
return false
}
}
function redactIdentifier(value?: string): string {
const normalized = String(value || '').trim()
if (!normalized) return '无'
if (normalized.length <= 8) return `${normalized.slice(0, 2)}***`
return `${normalized.slice(0, 4)}${normalized.slice(-4)}`
}
function redactFileName(value?: string): string {
const normalized = path.basename(String(value || '').trim())
if (!normalized) return '无'
const extension = path.extname(normalized)
const stem = extension ? normalized.slice(0, -extension.length) : normalized
return `${redactIdentifier(stem)}${extension.toLowerCase()}`
}
function describeFileSource(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/').toLowerCase()
if (normalized.includes('/msg/attach/')) return 'msg/attach'
if (normalized.includes('/cache/')) return 'cache'
if (normalized.includes('/filestorage/')) return 'FileStorage'
return '其他本地目录(完整路径未记录)'
}
function yesNo(value: boolean): string {
return value ? '是' : '否'
}
function formatBytes(value?: number): string {
if (!Number.isFinite(value)) return '未知'
if ((value as number) < 1024) return `${value} B`
return `${((value as number) / 1024).toFixed(1)} KiB`
}
function formatDatVersion(value?: number): string {
if (value === 2) return 'WeChat 4.0 V2'
if (value === 0) return '不受支持/旧版格式'
return '未检测'
}
function formatDatProtocol(diagnostic?: ImageDecodeDiagnostic, filePath?: string): string {
if (diagnostic?.code === 'DIRECT_IMAGE') return '明文图片(无需 DAT 解密)'
return formatDatVersion(
diagnostic?.datVersion ?? (filePath ? inspectDatVersion(filePath) : undefined)
)
}
function formatDecoder(decoder?: ImageDecoderStatus): string {
if (!decoder) return '未检测(当前失败阶段不需要)'
if (!decoder.installed) return '未安装'
return decoder.available
? `可用(${decoder.source},支持 HEVC`
: `已安装但不支持 HEVC${decoder.source}`
}
function buildDiagnosticAdvice(
resultCode: string,
diagnostic?: ImageDecodeDiagnostic,
decoder?: ImageDecoderStatus
): string {
if (resultCode === 'SUCCESS') return '图片解析正常,无需处理。'
if (resultCode === 'FILE_NOT_FOUND') {
return '确认图片资源目录属于当前微信账号,并在最近发送过图片的会话中重新测试。'
}
switch (diagnostic?.code) {
case 'UNSUPPORTED_DAT_VERSION':
return '换一张由微信 4.0 接收或发送的近期图片测试。'
case 'AES_DECRYPT_FAILED':
case 'MISSING_AES_KEY':
return '重新获取当前微信账号的图片密钥后再测试。'
case 'INVALID_DAT_FILE':
return '在微信中重新打开或下载该图片,再重新测试。'
case 'WXGF_REQUIRES_DECODER':
return decoder?.available
? 'FFmpeg 已可用但转换失败,请将本日志发给开发者。'
: '安装或重新选择支持 HEVC 的 FFmpeg 后再测试。'
case 'UNKNOWN_IMAGE_FORMAT':
return '请将本日志发给开发者,并换一张近期普通图片交叉测试。'
default:
return inputAdviceForCode(resultCode)
}
}
function inputAdviceForCode(resultCode: string): string {
if (resultCode === 'NO_CONVERSATION') return '先连接微信账号并选择一条聊天记录。'
if (resultCode === 'NO_IMAGE_MESSAGE') return '换一个最近 300 条消息内包含图片的会话。'
if (resultCode === 'NOT_CONFIGURED') return '检查资源目录、AES 密钥和 XOR Key 格式。'
return '请将本日志发给开发者进一步排查。'
}
function hasImageDirectory(accountRoot: string): boolean {
if (!accountRoot) return false
return [