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
+4 -2
View File
@@ -1,6 +1,6 @@
import type { Message } from '../shared/types' import type { Message } from '../shared/types'
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.media-status{margin-top:8px;padding:6px 8px;border-left:3px solid #b27a18;background:#fff8e8;color:#79530f;font-size:12px;line-height:1.5}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}.lightbox-close{position:fixed;top:20px;right:20px;z-index:11;width:42px;height:42px;border:1px solid #ffffff66;border-radius:50%;background:#14231dcc;color:#fff;font-size:30px;line-height:1;cursor:pointer}` export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.media-status{margin-top:8px;padding:6px 8px;border-left:3px solid #b27a18;background:#fff8e8;color:#79530f;font-size:12px;line-height:1.5}.file-attachment{display:flex;align-items:center;gap:10px;min-width:220px;padding:11px 13px;border:1px solid var(--border);border-radius:10px;background:#f6faf8;color:var(--accent);font-weight:650;text-decoration:none;word-break:break-all}.file-attachment:before{content:'文件';display:grid;place-items:center;width:34px;height:34px;flex:0 0 auto;border-radius:8px;background:#dcebe4;color:var(--accent);font-size:11px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}.lightbox-close{position:fixed;top:20px;right:20px;z-index:11;width:42px;height:42px;border:1px solid #ffffff66;border-radius:50%;background:#14231dcc;color:#fff;font-size:30px;line-height:1;cursor:pointer}`
const safe = (value: unknown): string => const safe = (value: unknown): string =>
String(value ?? '').replace( String(value ?? '').replace(
/[&<>"']/g, /[&<>"']/g,
@@ -30,7 +30,9 @@ export function renderExportPage(name: string, messages: Message[]): string {
? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>` ? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>`
: m.exportMediaUrl && m.exportMediaType === 'sticker' : m.exportMediaUrl && m.exportMediaType === 'sticker'
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="表情包">` ? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="表情包">`
: '' : m.exportMediaUrl && m.exportMediaType === 'file'
? `<a class="file-attachment" href="${safe(m.exportMediaUrl)}" download>${safe(m.exportMediaName || (m.contentData?.type === 'share' ? m.contentData.title : '') || '下载文件')}</a>`
: ''
const avatarMarkup = const avatarMarkup =
m.exportShowAvatar === false m.exportShowAvatar === false
? '' ? ''
+20 -1
View File
@@ -17,6 +17,7 @@ import { ImageKeyConfigService } from './services/image-key-config-service'
import { VideoAssetService } from './video-asset-service' import { VideoAssetService } from './video-asset-service'
import { StickerService } from './sticker-service' import { StickerService } from './sticker-service'
import { getImageExportAttempts } from '../shared/export-media' import { getImageExportAttempts } from '../shared/export-media'
import { FileAssetService } from './file-asset-service'
const jobs = new Set<string>() const jobs = new Set<string>()
const safeFilePart = (value: string): string => const safeFilePart = (value: string): string =>
@@ -87,6 +88,7 @@ async function readAvatarAsset(
const kindOf = (message: Message): ExportMessageKind => { const kindOf = (message: Message): ExportMessageKind => {
const type = message.contentData?.type const type = message.contentData?.type
if (type === 'system' && message.contentData?.pat) return 'text' if (type === 'system' && message.contentData?.pat) return 'text'
if (type === 'share' && message.contentData.typeVal === '6') return 'file'
if ( if (
type === 'image' || type === 'image' ||
type === 'video' || type === 'video' ||
@@ -142,6 +144,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
for (const message of messages) { for (const message of messages) {
message.exportMediaUrl = undefined message.exportMediaUrl = undefined
message.exportMediaType = undefined message.exportMediaType = undefined
message.exportMediaName = undefined
message.exportMediaError = undefined message.exportMediaError = undefined
message.voiceDataUrl = undefined message.voiceDataUrl = undefined
message.exportShowAvatar = request.includeAvatars !== false message.exportShowAvatar = request.includeAvatars !== false
@@ -149,7 +152,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
if (mappedName) message.name = mappedName if (mappedName) message.name = mappedName
if ( if (
request.format !== 'html' && request.format !== 'html' &&
['image', 'video', 'voice', 'sticker'].includes(kindOf(message)) ['image', 'video', 'voice', 'sticker', 'file'].includes(kindOf(message))
) { ) {
message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件' message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件'
} }
@@ -198,6 +201,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
: null : null
const videoService = client ? new VideoAssetService(client) : null const videoService = client ? new VideoAssetService(client) : null
const stickerService = client ? new StickerService(client) : null const stickerService = client ? new StickerService(client) : null
const fileService = client ? new FileAssetService(client) : null
const exportedAvatars = new Map<string, string>() const exportedAvatars = new Map<string, string>()
const voiceService = const voiceService =
request.includeMedia && chat.getChatDb() request.includeMedia && chat.getChatDb()
@@ -362,6 +366,21 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
} else { } else {
keepMediaError(request, message, result.error || '表情资源缺失或下载失败') keepMediaError(request, message, result.error || '表情资源缺失或下载失败')
} }
} else if (message.contentData.type === 'share' && message.contentData.typeVal === '6') {
if (!fileService) {
keepMediaError(request, message, '数据库未连接,无法定位本地文件附件')
} else {
const resolved = fileService.resolve(message.contentData.title, message.createTime)
if (!resolved.success || !resolved.filePath || !resolved.fileName) {
keepMediaError(request, message, resolved.error || '本地文件附件缺失')
} else {
const name = `file_${index + 1}_${safeFilePart(resolved.fileName)}`
await fs.copyFile(resolved.filePath, join(outputDir, 'media', name))
message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'file'
message.exportMediaName = message.contentData.title || resolved.fileName
}
}
} }
send({ send({
jobId: request.jobId, jobId: request.jobId,
+122
View File
@@ -0,0 +1,122 @@
import fs from 'fs-extra'
import path from 'path'
import type { Wcdb4Client } from './wcdb4-client'
export type FileAssetResult = {
success: boolean
filePath?: string
fileName?: string
error?: string
}
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const monthName = (timestamp?: number): string => {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
if (Number.isNaN(date.getTime())) return ''
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}
export class FileAssetService {
private index:
| {
root: string
files: { filePath: string; fileName: string; month: string; mtimeMs: number }[]
}
| undefined
constructor(private readonly client: Pick<Wcdb4Client, 'getAccountRoot'>) {}
resolve(fileTitle: string, createTime?: number): FileAssetResult {
const normalizedTitle = path.basename(
String(fileTitle || '')
.trim()
.replace(/\\/g, '/')
)
if (!normalizedTitle) return { success: false, error: '文件名为空,无法定位本地附件' }
const configuredRoot = path.resolve(this.client.getAccountRoot(), 'msg', 'file')
if (!fs.existsSync(configuredRoot)) {
return { success: false, error: '本地文件附件目录不存在' }
}
const root = fs.realpathSync(configuredRoot)
const preferredMonth = monthName(createTime)
const extension = path.extname(normalizedTitle)
const stem = normalizedTitle.slice(0, normalizedTitle.length - extension.length)
const duplicatePattern = new RegExp(
`^${escapeRegExp(stem)}(?:\\(\\d+\\))?${escapeRegExp(extension)}$`,
'i'
)
const expectedTime = createTime ? createTime * 1000 : 0
const candidates = this.getIndex(root).filter(({ fileName }) => duplicatePattern.test(fileName))
candidates.sort((left, right) => {
const leftPreferred = left.month === preferredMonth ? 1 : 0
const rightPreferred = right.month === preferredMonth ? 1 : 0
if (leftPreferred !== rightPreferred) return rightPreferred - leftPreferred
const leftExact = left.fileName === normalizedTitle ? 1 : 0
const rightExact = right.fileName === normalizedTitle ? 1 : 0
if (leftExact !== rightExact) return rightExact - leftExact
if (expectedTime) {
const timeDifference =
Math.abs(left.mtimeMs - expectedTime) - Math.abs(right.mtimeMs - expectedTime)
if (timeDifference) return timeDifference
}
return left.fileName.localeCompare(right.fileName)
})
const selected = candidates[0]
if (!selected) return { success: false, error: `本地未找到文件附件:${normalizedTitle}` }
return { success: true, filePath: selected.filePath, fileName: selected.fileName }
}
private isSafeChild(root: string, candidate: string): boolean {
return candidate === root || candidate.startsWith(`${root}${path.sep}`)
}
private getIndex(
root: string
): { filePath: string; fileName: string; month: string; mtimeMs: number }[] {
if (this.index?.root === root) return this.index.files
const files: { filePath: string; fileName: string; month: string; mtimeMs: number }[] = []
for (const month of fs.readdirSync(root)) {
const monthPath = path.resolve(root, month)
if (!this.isSafeChild(root, monthPath) || !this.isDirectory(monthPath)) continue
for (const fileName of fs.readdirSync(monthPath)) {
const candidate = path.resolve(monthPath, fileName)
if (!this.isSafeChild(root, candidate) || !this.isFile(candidate)) continue
const filePath = fs.realpathSync(candidate)
if (!this.isSafeChild(root, filePath)) continue
files.push({ filePath, fileName, month, mtimeMs: this.mtimeMs(filePath) })
}
}
this.index = { root, files }
return files
}
private isDirectory(filePath: string): boolean {
try {
return fs.statSync(filePath).isDirectory()
} catch {
return false
}
}
private isFile(filePath: string): boolean {
try {
return fs.statSync(filePath).isFile()
} catch {
return false
}
}
private mtimeMs(filePath: string): number {
try {
return fs.statSync(filePath).mtimeMs
} catch {
return 0
}
}
}
+142 -16
View File
@@ -22,6 +22,27 @@ export type DecodedImage = {
mimeType?: string mimeType?: string
} }
export type ImageDecodeDiagnosticCode =
| 'NOT_RUN'
| 'FILE_NOT_FOUND'
| 'DIRECT_IMAGE'
| 'UNSUPPORTED_DAT_VERSION'
| 'MISSING_AES_KEY'
| 'AES_DECRYPT_FAILED'
| 'INVALID_DAT_FILE'
| 'WXGF_REQUIRES_DECODER'
| 'UNKNOWN_IMAGE_FORMAT'
| 'SUCCESS'
export interface ImageDecodeDiagnostic {
code: ImageDecodeDiagnosticCode
detail: string
datVersion?: number
fileSize?: number
imageFormat?: string
wxgf?: boolean
}
type ImageFindOptions = { type ImageFindOptions = {
allowThumbnail?: boolean allowThumbnail?: boolean
accountDir?: string accountDir?: string
@@ -158,12 +179,15 @@ function normalizeDatBase(value) {
if (!lower) return '' if (!lower) return ''
const file = lower.split('/').pop().split('\\').pop() const file = lower.split('/').pop().split('\\').pop()
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '') return withoutDat.replace(
/(_thumb|\.thumb|_hd|\.hd|_h_m|_h|\.h|_t_m|_t|\.t|_m|_b|_w|_c)$/i,
''
)
} }
function isThumbnailName(fileName) { function isThumbnailName(fileName) {
const lower = fileName.toLowerCase() const lower = fileName.toLowerCase()
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat') return /(?:_t(?:_m)?|_thumb|\.thumb)\.dat$/i.test(lower)
} }
function buildPreferredDatNames(baseName) { function buildPreferredDatNames(baseName) {
@@ -172,10 +196,13 @@ function buildPreferredDatNames(baseName) {
return [ return [
base + '.dat', base + '.dat',
base + '_hd.dat', base + '_hd.dat',
base + '_h_M.dat',
base + '_h.dat', base + '_h.dat',
base + '_M.dat',
base + '_b.dat', base + '_b.dat',
base + '_w.dat', base + '_w.dat',
base + '_c.dat', base + '_c.dat',
base + '_t_M.dat',
base + '_t.dat', base + '_t.dat',
base + '.thumb.dat', base + '.thumb.dat',
base + '_thumb.dat' base + '_thumb.dat'
@@ -285,8 +312,15 @@ function unwrapWxgf(buffer, ffmpegPath) {
function decryptCandidate(filePath, aesKey, xorKey, ffmpegPath) { function decryptCandidate(filePath, aesKey, xorKey, ffmpegPath) {
const bytes = fs.readFileSync(filePath) const bytes = fs.readFileSync(filePath)
const directExtension = detectImageExtension(bytes)
if (directExtension) {
return {
data: 'data:' + getMimeType(directExtension) + ';base64,' + bytes.toString('base64'),
filePath
}
}
if (!path.extname(filePath).toLowerCase().includes('dat')) { if (!path.extname(filePath).toLowerCase().includes('dat')) {
const extension = detectImageExtension(bytes) || path.extname(filePath).toLowerCase() const extension = path.extname(filePath).toLowerCase()
return { data: 'data:' + getMimeType(extension) + ';base64,' + bytes.toString('base64'), filePath } return { data: 'data:' + getMimeType(extension) + ';base64,' + bytes.toString('base64'), filePath }
} }
if ( if (
@@ -381,6 +415,10 @@ export class ImageDecryptService {
private decodedImageCacheBytes = 0 private decodedImageCacheBytes = 0
private persistentCachePrunePromise: Promise<void> | null = null private persistentCachePrunePromise: Promise<void> | null = null
private persistentCachePrunePending = false private persistentCachePrunePending = false
private lastDecodeDiagnostic: ImageDecodeDiagnostic = {
code: 'NOT_RUN',
detail: '尚未执行图片解析'
}
constructor( constructor(
xorKey: string, xorKey: string,
@@ -407,6 +445,10 @@ export class ImageDecryptService {
} }
} }
getLastDecodeDiagnostic(): ImageDecodeDiagnostic {
return { ...this.lastDecodeDiagnostic }
}
/** /**
* 获取账号目录 * 获取账号目录
*/ */
@@ -1211,11 +1253,27 @@ export class ImageDecryptService {
decryptImage(datPath: string): Buffer | null { decryptImage(datPath: string): Buffer | null {
if (!existsSync(datPath)) { if (!existsSync(datPath)) {
imageDecryptLog('[ImageDecrypt] file not found:', datPath) imageDecryptLog('[ImageDecrypt] file not found:', datPath)
this.lastDecodeDiagnostic = {
code: 'FILE_NOT_FOUND',
detail: '候选图片文件不存在'
}
return null return null
} }
try { try {
const source = readFileSync(datPath)
const directExtension = this.detectImageExtension(source)
if (directExtension) {
this.lastDecodeDiagnostic = {
code: 'DIRECT_IMAGE',
detail: 'DAT 文件内容是可直接读取的图片',
fileSize: source.length,
imageFormat: directExtension.replace(/^\./, '').toUpperCase()
}
return source
}
const version = this.getDatVersion(datPath) const version = this.getDatVersion(datPath)
const fileSize = statSync(datPath).size
imageDecryptLog( imageDecryptLog(
'[ImageDecrypt] dat version:', '[ImageDecrypt] dat version:',
version, version,
@@ -1231,6 +1289,12 @@ export class ImageDecryptService {
imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)') imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)')
if (!this.aesKey) { if (!this.aesKey) {
imageDecryptLog('[ImageDecrypt] no AES key configured') imageDecryptLog('[ImageDecrypt] no AES key configured')
this.lastDecodeDiagnostic = {
code: 'MISSING_AES_KEY',
detail: '未配置 AES 图片密钥',
datVersion: version,
fileSize
}
return null return null
} }
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16) const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
@@ -1238,12 +1302,34 @@ export class ImageDecryptService {
} else { } else {
// 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。 // 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version) imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version)
this.lastDecodeDiagnostic = {
code: 'UNSUPPORTED_DAT_VERSION',
detail: '不是受支持的 WeChat 4.0 DAT 图片格式',
datVersion: version,
fileSize
}
return null return null
} }
this.lastDecodeDiagnostic = {
code: 'SUCCESS',
detail: 'DAT 数据已解密,正在识别图片格式',
datVersion: version,
fileSize
}
return decrypted return decrypted
} catch (error) { } catch (error) {
imageDecryptLog('[ImageDecrypt] decrypt error:', error) imageDecryptLog('[ImageDecrypt] decrypt error:', error)
const message = error instanceof Error ? error.message : String(error)
const aesFailure = /padding|bad decrypt|decrypt/i.test(message)
this.lastDecodeDiagnostic = {
code: aesFailure ? 'AES_DECRYPT_FAILED' : 'INVALID_DAT_FILE',
detail: aesFailure
? 'AES 解密校验失败,密钥可能与当前账号不匹配'
: 'DAT 文件结构异常或文件不完整',
datVersion: 2,
fileSize: existsSync(datPath) ? statSync(datPath).size : undefined
}
return null return null
} }
} }
@@ -1253,23 +1339,54 @@ export class ImageDecryptService {
*/ */
decryptImageToBase64(datPath: string): string | null { decryptImageToBase64(datPath: string): string | null {
if (!extname(datPath).toLowerCase().includes('dat')) { if (!extname(datPath).toLowerCase().includes('dat')) {
const data = readFileSync(datPath) try {
const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase() const data = readFileSync(datPath)
const mimeType = this.getMimeType(ext) const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase()
return `data:${mimeType};base64,${data.toString('base64')}` const mimeType = this.getMimeType(ext)
this.lastDecodeDiagnostic = {
code: 'DIRECT_IMAGE',
detail: '文件本身是可直接读取的图片',
fileSize: data.length,
imageFormat: ext.replace(/^\./, '').toUpperCase()
}
return `data:${mimeType};base64,${data.toString('base64')}`
} catch {
this.lastDecodeDiagnostic = {
code: 'FILE_NOT_FOUND',
detail: '候选图片文件无法读取'
}
return null
}
} }
const decrypted = this.decryptImage(datPath) const decrypted = this.decryptImage(datPath)
if (!decrypted) return null if (!decrypted) return null
const directImage = this.lastDecodeDiagnostic.code === 'DIRECT_IMAGE'
const wxgf = this.isWxgfBuffer(decrypted)
const unwrapped = this.unwrapWxgf(decrypted) const unwrapped = this.unwrapWxgf(decrypted)
const ext = this.detectImageExtension(unwrapped) const ext = this.detectImageExtension(unwrapped)
if (!ext) { if (!ext) {
imageDecryptLog('[ImageDecrypt] unknown image format') imageDecryptLog('[ImageDecrypt] unknown image format')
this.lastDecodeDiagnostic = {
...this.lastDecodeDiagnostic,
code: wxgf ? 'WXGF_REQUIRES_DECODER' : 'UNKNOWN_IMAGE_FORMAT',
detail: wxgf
? '已解密为 WXGF/HEVC 数据,但 FFmpeg 转换未成功'
: '数据已解密,但无法识别为常见图片格式',
wxgf
}
return null return null
} }
const mimeType = this.getMimeType(ext) const mimeType = this.getMimeType(ext)
this.lastDecodeDiagnostic = {
...this.lastDecodeDiagnostic,
code: directImage ? 'DIRECT_IMAGE' : 'SUCCESS',
detail: directImage ? 'DAT 文件内容是可直接读取的图片' : '图片解密并识别成功',
imageFormat: ext.replace(/^\./, '').toUpperCase(),
wxgf
}
return `data:${mimeType};base64,${unwrapped.toString('base64')}` return `data:${mimeType};base64,${unwrapped.toString('base64')}`
} }
@@ -1474,7 +1591,9 @@ export class ImageDecryptService {
if (!lower) return '' if (!lower) return ''
const file = lower.split('/').pop()?.split('\\').pop() || lower const file = lower.split('/').pop()?.split('\\').pop() || lower
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '').toLowerCase() return withoutDat
.replace(/(_thumb|\.thumb|_hd|\.hd|_h_m|_h|\.h|_t_m|_t|\.t|_m|_b|_w|_c)$/i, '')
.toLowerCase()
} }
private getSessionDirectoryName(sessionId?: string): string { private getSessionDirectoryName(sessionId?: string): string {
@@ -1490,10 +1609,13 @@ export class ImageDecryptService {
return [ return [
`${base}.dat`, `${base}.dat`,
`${base}_hd.dat`, `${base}_hd.dat`,
`${base}_h_M.dat`,
`${base}_h.dat`, `${base}_h.dat`,
`${base}_M.dat`,
`${base}_b.dat`, `${base}_b.dat`,
`${base}_w.dat`, `${base}_w.dat`,
`${base}_c.dat`, `${base}_c.dat`,
`${base}_t_M.dat`,
`${base}_t.dat`, `${base}_t.dat`,
`${base}.thumb.dat`, `${base}.thumb.dat`,
`${base}_thumb.dat` `${base}_thumb.dat`
@@ -1584,7 +1706,7 @@ export class ImageDecryptService {
private isThumbnailName(fileName: string): boolean { private isThumbnailName(fileName: string): boolean {
const lower = fileName.toLowerCase() const lower = fileName.toLowerCase()
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat') return /(?:_t(?:_m)?|_thumb|\.thumb)\.dat$/i.test(lower)
} }
isThumbnailFile(filePath: string): boolean { isThumbnailFile(filePath: string): boolean {
@@ -1592,13 +1714,7 @@ export class ImageDecryptService {
} }
private unwrapWxgf(buffer: Buffer): Buffer { private unwrapWxgf(buffer: Buffer): Buffer {
if ( if (!this.isWxgfBuffer(buffer)) {
buffer.length < 20 ||
buffer[0] !== 0x77 ||
buffer[1] !== 0x78 ||
buffer[2] !== 0x67 ||
buffer[3] !== 0x66
) {
return buffer return buffer
} }
@@ -1619,6 +1735,16 @@ export class ImageDecryptService {
return buffer return buffer
} }
private isWxgfBuffer(buffer: Buffer): boolean {
return (
buffer.length >= 20 &&
buffer[0] === 0x77 &&
buffer[1] === 0x78 &&
buffer[2] === 0x67 &&
buffer[3] === 0x66
)
}
private uniq(values: string[]): string[] { private uniq(values: string[]): string[] {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
} }
+52 -10
View File
@@ -267,6 +267,7 @@ const isolatedUserData = process.env['WXE_USER_DATA']
if (isolatedUserData) app.setPath('userData', isolatedUserData) if (isolatedUserData) app.setPath('userData', isolatedUserData)
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null = let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
null null
let appShutdownRequested = false
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03' const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
const TRAY_MODE = const TRAY_MODE =
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1' process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
@@ -493,12 +494,18 @@ app.whenReady().then(async () => {
}) })
ipcMain.handle('db:init', async (_, key: string, accountRoot?: string) => { ipcMain.handle('db:init', async (_, key: string, accountRoot?: string) => {
if (appShutdownRequested) {
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
}
if (dbInitInFlight) return dbInitInFlight if (dbInitInFlight) return dbInitInFlight
dbInitInFlight = (async () => { dbInitInFlight = (async () => {
const startedAt = Date.now() const startedAt = Date.now()
try { try {
if (wcdbBootstrapPromise) await wcdbBootstrapPromise if (wcdbBootstrapPromise) await wcdbBootstrapPromise
if (appShutdownRequested) {
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
}
const trimmedKey = String(key || '').trim() const trimmedKey = String(key || '').trim()
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`) console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
const settings = loadSettings() const settings = loadSettings()
@@ -529,6 +536,10 @@ app.whenReady().then(async () => {
return { success: true, monitoring: true } return { success: true, monitoring: true }
} }
const nextWechatDb = await WechatDb.create(key, selectedRoot) const nextWechatDb = await WechatDb.create(key, selectedRoot)
if (appShutdownRequested) {
await nextWechatDb.closeAsync()
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
}
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot() const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
if (resolvedRoot) { if (resolvedRoot) {
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录 // 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
@@ -538,7 +549,9 @@ app.whenReady().then(async () => {
imageKeyRoot: resolvedRoot imageKeyRoot: resolvedRoot
}) })
} }
chat.setChatDb(nextWechatDb) if (!chat.setChatDb(nextWechatDb)) {
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
}
const wcdb4Client = nextWechatDb.getWcdb4Client() const wcdb4Client = nextWechatDb.getWcdb4Client()
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false }) const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled) configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
@@ -1342,15 +1355,44 @@ app.on('window-all-closed', () => {
} }
}) })
app.on('before-quit', async () => { let quitCleanupStarted = false
agentHubService.stop() let quitCleanupComplete = false
flushBootstrapCacheWritesSync()
chat.setChatDb(null) app.on('before-quit', (event) => {
await apiServer.stop().catch(() => undefined) if (quitCleanupComplete) return
if (tray) { appShutdownRequested = true
tray.destroy() event.preventDefault()
tray = null if (quitCleanupStarted) return
} quitCleanupStarted = true
console.log('[Shutdown] cleanup started')
void (async () => {
agentHubService.stop()
flushBootstrapCacheWritesSync()
const [, nativeCallsDrained] = await Promise.all([
apiServer.stop().catch(() => undefined),
chat.closeChatDbForQuit().catch(() => false)
])
if (!nativeCallsDrained) {
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
}
if (tray) {
tray.destroy()
tray = null
}
console.log('[Shutdown] cleanup completed')
})()
.catch((error) => {
console.warn('[Shutdown] cleanup failed:', error)
})
.finally(() => {
quitCleanupComplete = true
// The first quit request has already been cancelled so cleanup can finish safely.
// app.exit() avoids starting a second before-quit cycle that can leave Electron alive
// on macOS even after every application service has stopped.
console.log('[Shutdown] exiting application')
app.exit(0)
})
}) })
function showMainWindow(): void { function showMainWindow(): void {
+8 -4
View File
@@ -590,10 +590,10 @@ function parseQuoteMessage(content: string): {
if (referMsgStart === -1 || referMsgEnd === -1) return {} if (referMsgStart === -1 || referMsgEnd === -1) return {}
const referMsgXml = content.substring(referMsgStart, referMsgEnd + '</refermsg>'.length) const referMsgXml = content.substring(referMsgStart, referMsgEnd + '</refermsg>'.length)
const sender = const displayName = sanitizeQuotedContent(extractXmlValue(referMsgXml, 'displayname'))
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'displayname')) || const chatUser = sanitizeQuotedSenderId(extractXmlValue(referMsgXml, 'chatusr'))
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'fromusr')) || const fromUser = sanitizeQuotedSenderId(extractXmlValue(referMsgXml, 'fromusr'))
undefined const sender = displayName || chatUser || fromUser || undefined
const referContent = extractXmlValue(referMsgXml, 'content') const referContent = extractXmlValue(referMsgXml, 'content')
const referType = extractXmlValue(referMsgXml, 'type') const referType = extractXmlValue(referMsgXml, 'type')
@@ -653,6 +653,10 @@ function sanitizeQuotedContent(content: string): string {
return decoded return decoded
} }
function sanitizeQuotedSenderId(value: string): string {
return decodeXmlEntities(String(value || '')).trim()
}
function stripChatroomPrefix(content: string): string { function stripChatroomPrefix(content: string): string {
return String(content || '') return String(content || '')
.replace(/^[0-9a-z_-]+@chatroom:\s*/i, '') .replace(/^[0-9a-z_-]+@chatroom:\s*/i, '')
+18 -4
View File
@@ -55,7 +55,8 @@ export interface FormattedMessage {
voiceDataUrl?: string voiceDataUrl?: string
voiceDuration?: number voiceDuration?: number
exportMediaUrl?: string exportMediaUrl?: string
exportMediaType?: 'image' | 'video' | 'sticker' exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
exportMediaName?: string
exportShowAvatar?: boolean exportShowAvatar?: boolean
exportMediaError?: string exportMediaError?: string
exportAvatarUrl?: string exportAvatarUrl?: string
@@ -109,10 +110,24 @@ function normalizeMsgType(value: string | number | undefined): number {
} }
let dbRef: WechatDb | null = null 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?.close()
dbRef = db 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 { export function getChatDb(): WechatDb | null {
@@ -639,8 +654,7 @@ export function reopenWithRoot(accountRoot: string): boolean {
if (!key) return false if (!key) return false
try { try {
const next = new WechatDb(key, accountRoot) const next = new WechatDb(key, accountRoot)
setChatDb(next) return setChatDb(next)
return true
} catch (error) { } catch (error) {
console.error('[ChatService] reopen with root failed:', error) console.error('[ChatService] reopen with root failed:', error)
return false return false
@@ -3,13 +3,18 @@ import fs from 'fs-extra'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
import type { import type {
ImageDecoderStatus,
ImageDecryptionStatus, ImageDecryptionStatus,
ImageDecryptionTestResult, ImageDecryptionTestResult,
ImageKeyConfigResult, ImageKeyConfigResult,
ImageResourceCheck, ImageResourceCheck,
TestImageDecryptionRequest TestImageDecryptionRequest
} from '../../shared/image-decryption' } 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 * as chat from './chat-service'
import { validateImageKeyRequest } from './image-key-config-service' import { validateImageKeyRequest } from './image-key-config-service'
import { isWechatRunning } from './wechat-process-status' import { isWechatRunning } from './wechat-process-status'
@@ -57,13 +62,35 @@ export async function inspectImageDecryptionStatus(
} }
} }
export function testImageDecryption( export async function testImageDecryption(
request: TestImageDecryptionRequest 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) 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) { if (!chat.isReady() || !request.userMd5) {
return failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录') return finish(failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录'))
} }
try { try {
@@ -72,9 +99,11 @@ 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( return finish(
'NO_IMAGE_MESSAGE', failure(
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话' 'NO_IMAGE_MESSAGE',
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
)
) )
} }
@@ -84,58 +113,324 @@ export function testImageDecryption(
chat.getChatDb()?.getWcdb4Client() chat.getChatDb()?.getWcdb4Client()
) )
const image = imageMessage.contentData const image = imageMessage.contentData
// 测试时优先使用用户在下方"图片资源目录"输入框填写的目录; testedImage = {
// 找不到再退回默认 accountDir。 md5: image.md5,
datName: image.datName,
sessionId: imageMessage.sessionId,
selection: '所选会话最近 300 条消息中的最后一张图片'
}
const testAccountDir = normalized.resourceRoot || undefined const testAccountDir = normalized.resourceRoot || undefined
let filePath = service.findImageFile(image.md5, image.datName, { filePath =
allowThumbnail: false, (await service.findImageFileAsync(image.md5, image.datName, {
accountDir: testAccountDir allowThumbnail: false,
}) accountDir: testAccountDir,
if (!filePath) sessionId: imageMessage.sessionId
filePath = service.findImageFile(image.md5, image.datName, { })) || undefined
allowThumbnail: true, if (!filePath) {
accountDir: testAccountDir filePath =
}) (await service.findImageFileAsync(image.md5, image.datName, {
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在') allowThumbnail: true,
accountDir: testAccountDir,
sessionId: imageMessage.sessionId
})) || undefined
}
if (!filePath) return finish(failure('FILE_NOT_FOUND', '图片文件不存在'))
const data = service.decryptImageToBase64(filePath) let decoded = await service.decryptImageToBase64WithFallbackAsync(filePath, true)
if (!data) { if (!decoded) {
// 三步联动:解密失败 → fileFound/decrypted/readable 都为 false。 // Worker 失败后在主进程做一次同步诊断:既能保留具体失败阶段,
return { // 也能在少数 Worker 启动异常时继续测试普通图片。
decoded = service.decryptImageToBase64WithFallback(filePath, true)
}
if (!decoded) {
decodeDiagnostic = service.getLastDecodeDiagnostic()
if (decodeDiagnostic.code === 'WXGF_REQUIRES_DECODER') {
decoder = await inspectImageDecoderStatus()
}
return finish({
success: false, success: false,
code: 'DECRYPT_FAILED', code: 'DECRYPT_FAILED',
error: '无法解析媒体文件', error: getDecodeFailureMessage(decodeDiagnostic, decoder),
fileFound: false, fileFound: true,
decrypted: false, decrypted: isDecryptedDiagnostic(decodeDiagnostic.code),
readable: false 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) { if (!readable) {
// 三步联动:解密成功但字节流不可读 → 前一步打勾(确实找到了 dat), return finish({
// 但 decrypted/readable 全为 false,让 UI 表达"找到但解析失败"。
return {
success: false, success: false,
code: 'DECRYPT_FAILED', code: 'DECRYPT_FAILED',
error: '图片解密结果不可读取', error: '图片解密结果不可读取',
fileFound: true, fileFound: true,
decrypted: false, decrypted: true,
readable: false, readable: false,
isThumbnail: service.isThumbnailFile(filePath) isThumbnail: service.isThumbnailFile(decoded.filePath)
} })
} }
return { return finish({
success: true, success: true,
fileFound: true, fileFound: true,
decrypted: true, decrypted: true,
readable: true, readable: true,
isThumbnail: service.isThumbnailFile(filePath) isThumbnail: service.isThumbnailFile(decoded.filePath)
} })
} catch { } 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 { function hasImageDirectory(accountRoot: string): boolean {
if (!accountRoot) return false if (!accountRoot) return false
return [ return [
+95 -18
View File
@@ -258,6 +258,8 @@ export class Wcdb4Client {
private sessionStatusesInFlight: Promise<void> | null = null private sessionStatusesInFlight: Promise<void> | null = null
private sessionStatusesUpdatedAt = 0 private sessionStatusesUpdatedAt = 0
private sessionCacheGeneration = 0 private sessionCacheGeneration = 0
private closing = false
private nativeCallsInFlight = new Set<Promise<unknown>>()
private wcdbShutdown: (() => number) | null = null private wcdbShutdown: (() => number) | null = null
private wcdbOpenAccount: private wcdbOpenAccount:
@@ -535,17 +537,12 @@ export class Wcdb4Client {
} }
const handleOut: WcdbHandleOut = [0] const handleOut: WcdbHandleOut = [0]
const openResult = await new Promise<number>((resolve, reject) => { const openResult = await this.callAsyncCode(
this.wcdbOpenAccountAsync!.async( this.wcdbOpenAccountAsync,
this.sessionDbPath, this.sessionDbPath,
this.key, this.key,
handleOut, handleOut
(error: unknown, result: unknown) => { )
if (error) reject(error)
else resolve(Number(result))
}
)
})
if (openResult !== 0 || handleOut[0] <= 0) { if (openResult !== 0 || handleOut[0] <= 0) {
throw new Error( throw new Error(
`wcdb_open_account failed, code=${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}` `wcdb_open_account failed, code=${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}`
@@ -566,19 +563,49 @@ export class Wcdb4Client {
} }
close(): void { close(): void {
this.closing = true
this.stopMonitor() this.stopMonitor()
if (this.handle === null || !this.wcdbShutdown) return const canShutdownNative = this.nativeCallsInFlight.size === 0
if (!canShutdownNative) {
console.warn(
`[WCDB4] skip synchronous native shutdown; ${this.nativeCallsInFlight.size} async call(s) still running`
)
}
this.finishClose(canShutdownNative)
}
if (process.platform !== 'win32') { async closeAsync(timeoutMs = 8_000): Promise<boolean> {
this.closing = true
this.stopMonitorTransport()
const drained = await this.waitForNativeCalls(timeoutMs)
this.stopNativeMonitor()
if (!drained) {
console.warn(
`[WCDB4] native calls did not drain within ${timeoutMs}ms; skip wcdb_shutdown during app quit`
)
}
this.finishClose(drained)
return drained
}
private finishClose(shutdownNative: boolean): void {
if (
shutdownNative &&
this.handle !== null &&
this.wcdbShutdown &&
process.platform !== 'win32'
) {
try { try {
this.wcdbShutdown() this.wcdbShutdown()
} catch { } catch {
// Shutdown is best-effort on app close. // Shutdown is best-effort after all tracked native calls have completed.
} }
} }
this.handle = null this.handle = null
this.cachedSessions = null this.cachedSessions = null
this.cachedChatTables = null
this.sessionsInFlight = null
this.sessionDisplayNamesInFlight = null
this.sessionDisplayNamesHydrated = false this.sessionDisplayNamesHydrated = false
this.sessionStatusesInFlight = null this.sessionStatusesInFlight = null
this.sessionStatusesUpdatedAt = 0 this.sessionStatusesUpdatedAt = 0
@@ -588,8 +615,32 @@ export class Wcdb4Client {
this.groupNicknameCache.clear() this.groupNicknameCache.clear()
} }
private waitForNativeCalls(timeoutMs: number): Promise<boolean> {
const pending = Array.from(this.nativeCallsInFlight)
if (pending.length === 0) return Promise.resolve(true)
return new Promise<boolean>((resolve) => {
let settled = false
const finish = (drained: boolean): void => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(drained)
}
const timer = setTimeout(() => finish(false), timeoutMs)
void Promise.allSettled(pending).then(() => {
// Koffi promise resolution can resume higher-level async cleanup (for example,
// closing a message cursor) in the same event-loop turn. Give those continuations
// one full turn before shutting down the process-wide WCDB runtime.
setImmediate(() => finish(this.nativeCallsInFlight.size === 0))
})
})
}
async startMonitor(callback: (type: string, json: string) => void): Promise<boolean> { async startMonitor(callback: (type: string, json: string) => void): Promise<boolean> {
if (!this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) return false if (this.closing || !this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) {
return false
}
this.stopMonitor() this.stopMonitor()
this.monitorCallback = callback this.monitorCallback = callback
@@ -604,6 +655,10 @@ export class Wcdb4Client {
return false return false
} }
this.monitorStarted = true this.monitorStarted = true
if (this.closing) {
this.stopMonitor()
return false
}
const outName: WcdbVoidOut = [null] const outName: WcdbVoidOut = [null]
const nameResult = await this.callAsyncCode( const nameResult = await this.callAsyncCode(
@@ -637,6 +692,11 @@ export class Wcdb4Client {
} }
stopMonitor(): void { stopMonitor(): void {
this.stopMonitorTransport()
this.stopNativeMonitor()
}
private stopMonitorTransport(): void {
this.monitorCallback = null this.monitorCallback = null
if (this.monitorConnectTimer) { if (this.monitorConnectTimer) {
@@ -651,6 +711,9 @@ export class Wcdb4Client {
this.monitorPipeClient.destroy() this.monitorPipeClient.destroy()
this.monitorPipeClient = null this.monitorPipeClient = null
} }
}
private stopNativeMonitor(): void {
if (this.monitorStarted && this.wcdbStopMonitorPipe) { if (this.monitorStarted && this.wcdbStopMonitorPipe) {
try { try {
this.wcdbStopMonitorPipe() this.wcdbStopMonitorPipe()
@@ -2218,7 +2281,7 @@ export class Wcdb4Client {
private callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> { private callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> {
const handle = this.ensureHandle() const handle = this.ensureHandle()
const outJson: WcdbVoidOut = [null] const outJson: WcdbVoidOut = [null]
return new Promise<T>((resolve, reject) => { return this.createTrackedNativeCall<T>((resolve, reject) => {
fn.async(handle, ...args, outJson, (error: unknown, result: unknown) => { fn.async(handle, ...args, outJson, (error: unknown, result: unknown) => {
if (error) { if (error) {
reject(error) reject(error)
@@ -2242,7 +2305,7 @@ export class Wcdb4Client {
} }
private callAsyncCode(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<number> { private callAsyncCode(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<number> {
return new Promise<number>((resolve, reject) => { return this.createTrackedNativeCall<number>((resolve, reject) => {
fn.async(...args, (error: unknown, result: unknown) => { fn.async(...args, (error: unknown, result: unknown) => {
if (error) reject(error) if (error) reject(error)
else resolve(Number(result)) else resolve(Number(result))
@@ -2250,7 +2313,21 @@ export class Wcdb4Client {
}) })
} }
private createTrackedNativeCall<T>(
executor: (resolve: (value: T) => void, reject: (reason?: unknown) => void) => void
): Promise<T> {
if (this.closing) return Promise.reject(new Error('微信数据库正在关闭'))
const promise = new Promise<T>(executor)
this.nativeCallsInFlight.add(promise)
void promise.then(
() => this.nativeCallsInFlight.delete(promise),
() => this.nativeCallsInFlight.delete(promise)
)
return promise
}
private ensureHandle(): number { private ensureHandle(): number {
if (this.closing) throw new Error('微信数据库正在关闭')
if (!this.handle) throw new Error('微信 4.0 数据库未打开') if (!this.handle) throw new Error('微信 4.0 数据库未打开')
return this.handle return this.handle
} }
+4
View File
@@ -156,6 +156,10 @@ export class WechatDb {
this.wcdb4Client.close() this.wcdb4Client.close()
} }
public closeAsync(timeoutMs?: number): Promise<boolean> {
return this.wcdb4Client.closeAsync(timeoutMs)
}
public getUserMessages( public getUserMessages(
userMd5: string, userMd5: string,
startTime?: number, startTime?: number,
+29 -76
View File
@@ -30,6 +30,7 @@ import {
mergeMessagePages, mergeMessagePages,
sortMessagesChronologically sortMessagesChronologically
} from './utils/message-pages' } from './utils/message-pages'
import { enrichQuotedMessages } from './utils/quoted-messages'
const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380 const SIDEBAR_MAX_WIDTH = 380
@@ -70,67 +71,6 @@ const INITIAL_MESSAGE_COUNT = 20
const MESSAGE_PAGE_SIZE = 100 const MESSAGE_PAGE_SIZE = 100
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
const EXPORT_PREVIEW_LIMIT = 20 const EXPORT_PREVIEW_LIMIT = 20
const normalizeQuotedText = (value: string | undefined): string =>
String(value || '')
.replace(/\s+/g, ' ')
.trim()
const isInternalReferenceSender = (value: string | undefined): boolean => {
const sender = String(value || '').trim()
return (
!sender ||
sender.endsWith('@chatroom') ||
sender.startsWith('wxid_') ||
/^[a-z0-9_@.-]{12,}$/i.test(sender)
)
}
const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]): Message[] => {
const imageDatNameByMd5 = new Map<string, string>()
const messagesByContent = new Map<string, Message[]>()
for (const message of referenceMessages) {
if (
message.contentData?.type === 'image' &&
message.contentData.md5 &&
message.contentData.datName
) {
imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName)
}
const content = normalizeQuotedText(message.content)
if (!content) continue
const candidates = messagesByContent.get(content) || []
candidates.push(message)
messagesByContent.set(content, candidates)
}
return messages.map((message) => {
if (message.contentData?.type !== 'quote') return message
const quote = message.contentData
let quotedImageDatName = quote.quotedImageDatName
if (!quotedImageDatName && quote.quotedImageMd5) {
quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5)
}
let quotedSender = quote.quotedSender
if (isInternalReferenceSender(quotedSender)) {
const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || []
const source = candidates
.filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity))
.sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0]
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
}
if (quotedSender === quote.quotedSender && quotedImageDatName === quote.quotedImageDatName) {
return message
}
return {
...message,
contentData: { ...quote, quotedSender, quotedImageDatName }
}
})
}
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => { const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
if (left === right) return true if (left === right) return true
if (left.length !== right.length) return false if (left.length !== right.length) return false
@@ -140,6 +80,16 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
return true return true
} }
const filterContactList = (list: Contact[], keyword: string): Contact[] => {
const lower = keyword.trim().toLowerCase()
if (!lower) return list
return list.filter((contact) =>
[contact.m_nsNickName, contact.m_nsUsrName, contact.remark, contact.wechatNickname].some(
(value) => value?.toLowerCase().includes(lower)
)
)
}
type GroupSnapshot = { type GroupSnapshot = {
roomId: string roomId: string
memberCount: number memberCount: number
@@ -501,11 +451,12 @@ function App(): React.ReactElement {
const loadContacts = async (options?: { const loadContacts = async (options?: {
waitForAvatars?: boolean waitForAvatars?: boolean
onProgress?: (message: string, percent?: number) => void onProgress?: (message: string, percent?: number) => void
filterKeyword?: string
}): Promise<void> => { }): Promise<void> => {
options?.onProgress?.('正在加载联系人...', 35) options?.onProgress?.('正在加载联系人...', 35)
const list = await window.api.getContacts() const list = await window.api.getContacts()
setContacts(list) setContacts(list)
setFilteredContacts(list) setFilteredContacts(filterContactList(list, options?.filterKeyword || ''))
const runId = ++contactAvatarHydrationRunRef.current const runId = ++contactAvatarHydrationRunRef.current
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress) const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
if (options?.waitForAvatars) { if (options?.waitForAvatars) {
@@ -886,11 +837,12 @@ function App(): React.ReactElement {
const applyGroupMemberMeta = React.useCallback( const applyGroupMemberMeta = React.useCallback(
(contact: Contact | null, baseMessages: Message[]): Message[] => { (contact: Contact | null, baseMessages: Message[]): Message[] => {
if (!contact || contact.type !== 'group') return baseMessages if (!contact || contact.type !== 'group') return baseMessages
const enrichedMessages = enrichQuotedMessages(baseMessages, [
...messageHistoryRef.current,
...baseMessages
])
const memberMap = groupMemberMetaRef.current[contact.md5] const memberMap = groupMemberMetaRef.current[contact.md5]
const enrichedMessages = enrichQuotedMessages(
baseMessages,
[...messageHistoryRef.current, ...baseMessages],
(senderId) => memberMap?.get(senderId)?.nickname
)
if (!memberMap || memberMap.size === 0) return enrichedMessages if (!memberMap || memberMap.size === 0) return enrichedMessages
return enrichedMessages.map((message) => { return enrichedMessages.map((message) => {
@@ -1347,16 +1299,16 @@ function App(): React.ReactElement {
]) ])
const handleSearchContacts = (keyword: string): void => { const handleSearchContacts = (keyword: string): void => {
if (!keyword) { setFilteredContacts(filterContactList(contacts, keyword))
setFilteredContacts(contacts) }
} else {
const lower = keyword.toLowerCase() const handleRefreshContacts = async (filterKeyword: string): Promise<void> => {
const filtered = contacts.filter( try {
(c) => await loadContacts({ waitForAvatars: false, filterKeyword })
c.m_nsNickName.toLowerCase().includes(lower) || setReportNotice('会话列表已刷新')
c.m_nsUsrName.toLowerCase().includes(lower) } catch (error) {
) console.warn('[Contacts] refresh failed:', error)
setFilteredContacts(filtered) setReportNotice('会话列表刷新失败,请稍后重试')
} }
} }
@@ -1588,6 +1540,7 @@ function App(): React.ReactElement {
dbReady={isDatabaseConnected} dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting} dbConnecting={isDatabaseConnecting}
onOpenSettings={openSettings} onOpenSettings={openSettings}
onRefresh={handleRefreshContacts}
/> />
<div className="resizer" onMouseDown={startResizing} /> <div className="resizer" onMouseDown={startResizing} />
<ChatWindow <ChatWindow
@@ -359,12 +359,13 @@ function QuoteBubble({
const quotedText = data.quotedContent || data.content || '[引用消息]' const quotedText = data.quotedContent || data.content || '[引用消息]'
const replyText = data.content || data.title || '' const replyText = data.content || data.title || ''
const quotedSender = data.quotedSender || data.sender || '' const quotedSender = data.quotedSender || data.sender || ''
const hasQuotedImage = Boolean(data.quotedImageMd5 || data.quotedImageDatName)
return ( return (
<div className="quote-message"> <div className="quote-message">
<div className="quoted-message"> <div className={`quoted-message${hasQuotedImage ? ' quoted-message-image' : ''}`}>
{quotedSender && <span className="quoted-sender">{quotedSender}</span>} {quotedSender && <span className="quoted-sender">{quotedSender}</span>}
{data.quotedImageMd5 || data.quotedImageDatName ? ( {hasQuotedImage ? (
<ImageBubble <ImageBubble
imageMd5={data.quotedImageMd5} imageMd5={data.quotedImageMd5}
imageDatName={data.quotedImageDatName} imageDatName={data.quotedImageDatName}
@@ -23,6 +23,7 @@ export interface ConversationSidebarProps {
dbReady: boolean dbReady: boolean
dbConnecting?: boolean dbConnecting?: boolean
onOpenSettings: () => void onOpenSettings: () => void
onRefresh: (filterKeyword: string) => Promise<void>
} }
type SectionName = 'groups' | 'folded' | 'contacts' type SectionName = 'groups' | 'folded' | 'contacts'
@@ -39,9 +40,11 @@ export function ConversationSidebar({
selfInfo, selfInfo,
dbReady, dbReady,
dbConnecting = false, dbConnecting = false,
onOpenSettings onOpenSettings,
onRefresh
}: ConversationSidebarProps): React.ReactElement { }: ConversationSidebarProps): React.ReactElement {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [isRefreshing, setIsRefreshing] = useState(false)
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({ const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
groups: true, groups: true,
folded: false, folded: false,
@@ -119,12 +122,24 @@ export function ConversationSidebar({
onSearch(term) onSearch(term)
} }
const handleRefresh = async (): Promise<void> => {
if (isRefreshing) return
setIsRefreshing(true)
try {
await onRefresh(searchTerm)
} finally {
setIsRefreshing(false)
}
}
return ( return (
<aside className="conversation-sidebar" style={{ width }}> <aside className="conversation-sidebar" style={{ width }}>
<ConversationSidebarHeader <ConversationSidebarHeader
totalCount={contacts.length} totalCount={contacts.length}
searchValue={searchTerm} searchValue={searchTerm}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
refreshing={isRefreshing}
onRefresh={() => void handleRefresh()}
/> />
<div ref={listRef} className="conversation-list" aria-label="会话列表"> <div ref={listRef} className="conversation-list" aria-label="会话列表">
<div <div
@@ -5,18 +5,36 @@ interface ConversationSidebarHeaderProps {
totalCount: number totalCount: number
searchValue: string searchValue: string
onSearchChange: (value: string) => void onSearchChange: (value: string) => void
refreshing: boolean
onRefresh: () => void
} }
export function ConversationSidebarHeader({ export function ConversationSidebarHeader({
totalCount, totalCount,
searchValue, searchValue,
onSearchChange onSearchChange,
refreshing,
onRefresh
}: ConversationSidebarHeaderProps): React.ReactElement { }: ConversationSidebarHeaderProps): React.ReactElement {
return ( return (
<div className="conversation-sidebar-header"> <div className="conversation-sidebar-header">
<div className="conversation-sidebar-title-row"> <div className="conversation-sidebar-title-row">
<h2></h2> <h2></h2>
<span>{totalCount} </span> <div className="conversation-sidebar-meta">
<span>{totalCount} </span>
<button
type="button"
className={`conversation-refresh-button ${refreshing ? 'is-refreshing' : ''}`}
aria-label={refreshing ? '正在刷新会话列表' : '刷新会话列表'}
title={refreshing ? '正在刷新…' : '刷新会话列表'}
disabled={refreshing}
onClick={onRefresh}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M16.1 6.2A7 7 0 1 0 17 12h-2a5 5 0 1 1-.7-3.4L12 11h6V5l-1.9 1.2Z" />
</svg>
</button>
</div>
</div> </div>
<ConversationSearch value={searchValue} onChange={onSearchChange} /> <ConversationSearch value={searchValue} onChange={onSearchChange} />
</div> </div>
@@ -66,8 +66,10 @@ export function ExportWorkspace({
const taskCount = exportTasks.filter((task) => task.status === 'running').length const taskCount = exportTasks.filter((task) => task.status === 'running').length
const activeName = displayName(activeContact) const activeName = displayName(activeContact)
const preview = previewMessages.slice(-20) const preview = previewMessages.slice(-20)
const previewMediaCount = preview.filter((message) => const previewMediaCount = preview.filter(
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') (message) =>
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') ||
(message.contentData?.type === 'share' && message.contentData.typeVal === '6')
).length ).length
const previewBytes = preview.reduce( const previewBytes = preview.reduce(
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0), (total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
@@ -439,7 +441,7 @@ export function ExportWorkspace({
<section className="export-section"> <section className="export-section">
<h3></h3> <h3></h3>
<label className="export-media-master"> <label className="export-media-master">
<span></span> <span></span>
<input <input
type="checkbox" type="checkbox"
checked={includeMedia} checked={includeMedia}
@@ -486,6 +488,7 @@ export function ExportWorkspace({
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
<span></span>
</div> </div>
<p className="export-helper-text"></p> <p className="export-helper-text"></p>
<label className="export-media-master"> <label className="export-media-master">
@@ -7,6 +7,7 @@ export const messageKinds = [
['video', '视频'], ['video', '视频'],
['voice', '语音'], ['voice', '语音'],
['sticker', '表情包'], ['sticker', '表情包'],
['file', '文件'],
['share', '链接与分享'], ['share', '链接与分享'],
['location', '位置'], ['location', '位置'],
['system', '系统消息'] ['system', '系统消息']
@@ -1,6 +1,9 @@
import type { ImageDecryptionState } from './types' import { useEffect, useMemo, useState } from 'react'
import type { Contact } from '../../../../../shared/types'
import type { ImageBatchTestItemStatus, ImageBatchTestState, ImageDecryptionState } from './types'
type StepState = 'pending' | 'ok' | 'fail' | 'skipped' type StepState = 'pending' | 'ok' | 'fail' | 'skipped'
type ContactFilter = 'all' | 'group' | 'user'
function stepClass(step: StepState): string { function stepClass(step: StepState): string {
switch (step) { switch (step) {
@@ -28,22 +31,16 @@ function stepIcon(step: StepState): string {
} }
} }
function pickSteps(result: { function pickSteps(result: { fileFound: boolean; decrypted: boolean; readable: boolean }): {
fileFound: boolean find: StepState
decrypted: boolean decrypt: StepState
readable: boolean read: StepState
success: boolean } {
}): { find: StepState; decrypt: StepState; read: StepState } {
// 三步严格联动:找到失败 → 解密/读取 skipped;解密失败 → 读取 skipped // 三步严格联动:找到失败 → 解密/读取 skipped;解密失败 → 读取 skipped
// 解密成功但不可读 → 读取 fail。 // 解密成功但不可读 → 读取 fail。
if (!result.fileFound) { if (!result.fileFound) {
return { find: 'fail', decrypt: 'skipped', read: 'skipped' } 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) { if (!result.decrypted) {
return { find: 'ok', decrypt: 'fail', read: 'skipped' } return { find: 'ok', decrypt: 'fail', read: 'skipped' }
} }
@@ -53,50 +50,228 @@ function pickSteps(result: {
return { find: 'ok', decrypt: 'ok', read: 'ok' } return { find: 'ok', decrypt: 'ok', read: 'ok' }
} }
function contactName(contact: Contact): string {
return contact.remark || contact.m_nsNickName || contact.wechatNickname || contact.m_nsUsrName
}
function formatElapsed(elapsedMs: number): string {
if (elapsedMs < 1000) return `${elapsedMs} ms`
if (elapsedMs < 60_000) return `${(elapsedMs / 1000).toFixed(elapsedMs < 10_000 ? 1 : 0)}`
const minutes = Math.floor(elapsedMs / 60_000)
const seconds = Math.floor((elapsedMs % 60_000) / 1000)
return `${minutes}${seconds}`
}
function batchStatusText(status: ImageBatchTestItemStatus): string {
switch (status) {
case 'testing':
return '测试中'
case 'success':
return '成功'
case 'failed':
return '失败'
case 'no-image':
return '无图片'
case 'stopped':
return '未测试'
default:
return '等待中'
}
}
export function ImageTestSection({ export function ImageTestSection({
state, state,
batchTest,
disabled, disabled,
canSave, canSave,
onSelect, onSelect,
onTest, onTest,
onBatchTest,
onStopBatchTest,
onCopyLog,
onSave onSave
}: { }: {
state: ImageDecryptionState state: ImageDecryptionState
batchTest: ImageBatchTestState
disabled: boolean disabled: boolean
canSave: boolean canSave: boolean
onSelect: (value: string) => void onSelect: (value: string) => void
onTest: () => void onTest: () => void
onBatchTest: (contacts: Contact[]) => void
onStopBatchTest: () => void
onCopyLog: () => void
onSave: () => void onSave: () => void
}): React.ReactElement { }): React.ReactElement {
const [contactFilter, setContactFilter] = useState<ContactFilter>('all')
const [searchValue, setSearchValue] = useState('')
const [liveClock, setLiveClock] = useState({ startedAt: 0, elapsedMs: 0 })
const result = state.testResult const result = state.testResult
const steps = result const steps = result
? pickSteps({ ? pickSteps({
fileFound: result.fileFound, fileFound: result.fileFound,
decrypted: result.decrypted, decrypted: result.decrypted,
readable: result.readable, readable: result.readable
success: result.success
}) })
: null : null
const contactCounts = useMemo(
() => ({
all: state.contacts.length,
group: state.contacts.filter((contact) => contact.type === 'group').length,
user: state.contacts.filter((contact) => contact.type === 'user').length
}),
[state.contacts]
)
const filteredContacts = useMemo(() => {
const keyword = searchValue.trim().toLowerCase()
return state.contacts.filter((contact) => {
if (contactFilter !== 'all' && contact.type !== contactFilter) return false
if (!keyword) return true
return [
contactName(contact),
contact.m_nsNickName,
contact.m_nsUsrName,
contact.wechatNickname,
contact.remark
].some((value) => value?.toLowerCase().includes(keyword))
})
}, [contactFilter, searchValue, state.contacts])
const filteredGroups = filteredContacts.filter((contact) => contact.type === 'group')
const filteredUsers = filteredContacts.filter((contact) => contact.type === 'user')
const batchCounts = useMemo(() => {
const completed = batchTest.items.filter((item) =>
['success', 'failed', 'no-image'].includes(item.status)
).length
return {
completed,
success: batchTest.items.filter((item) => item.status === 'success').length,
failed: batchTest.items.filter((item) => item.status === 'failed').length,
noImage: batchTest.items.filter((item) => item.status === 'no-image').length,
stopped: batchTest.items.filter((item) => item.status === 'stopped').length
}
}, [batchTest.items])
const batchProgress = batchTest.items.length
? Math.round((batchCounts.completed / batchTest.items.length) * 100)
: 0
useEffect(() => {
if (!batchTest.running || !batchTest.startedAt) return
const timer = window.setInterval(() => {
setLiveClock({
startedAt: batchTest.startedAt!,
elapsedMs: Date.now() - batchTest.startedAt!
})
}, 250)
return () => window.clearInterval(timer)
}, [batchTest.running, batchTest.startedAt])
const displayedElapsed =
batchTest.running && liveClock.startedAt === batchTest.startedAt
? Math.max(batchTest.elapsedMs, liveClock.elapsedMs)
: batchTest.elapsedMs
const handleBatchTest = (): void => {
if (filteredContacts.length === 0 || batchTest.running) return
const confirmed = window.confirm(
`即将测试 ${filteredContacts.length} 个会话。每个会话最多检查最近 300 条消息中的一张图片,会话较多时可能耗时数分钟。是否继续?`
)
if (confirmed) onBatchTest(filteredContacts)
}
const batchButtonLabel =
contactFilter === 'all' && !searchValue.trim()
? `全部测试(${filteredContacts.length}`
: `测试筛选结果(${filteredContacts.length}`
return ( return (
<section className="settings-card image-test-section"> <section className="settings-card image-test-section">
<div> <div className="image-test-heading">
<strong></strong> <div>
<p></p> <strong></strong>
<p></p>
</div>
<div className="image-test-copy-area">
<span
className="image-test-log-scope"
title="此日志只包含当前所选单个会话的图片解析结果,不包含批量测试结果"
>
<span className="image-test-log-scope-icon" aria-hidden="true">
!
</span>
</span>
<button
className="database-key-secondary image-test-copy"
disabled={!result?.diagnosticLog}
onClick={onCopyLog}
>
</button>
</div>
</div> </div>
<label htmlFor="image-test-chat"></label>
<div className="image-test-filter-bar" aria-label="会话类型筛选">
{(
[
['all', '全部'],
['group', '群聊'],
['user', '联系人']
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={contactFilter === value ? 'active' : ''}
aria-pressed={contactFilter === value}
disabled={disabled}
onClick={() => setContactFilter(value)}
>
{label} {contactCounts[value]}
</button>
))}
</div>
<label htmlFor="image-test-search"></label>
<input
id="image-test-search"
className="image-test-search"
type="search"
value={searchValue}
disabled={disabled}
placeholder="搜索群聊、联系人或 wxid"
onChange={(event) => setSearchValue(event.target.value)}
/>
<label htmlFor="image-test-chat"></label>
<select <select
id="image-test-chat" id="image-test-chat"
value={state.selectedUserMd5} value={state.selectedUserMd5}
disabled={disabled} disabled={disabled}
onChange={(event) => onSelect(event.target.value)} onChange={(event) => onSelect(event.target.value)}
> >
<option value=""></option> <option value=""></option>
{state.contacts.map((contact) => ( {filteredGroups.length > 0 && (
<option key={contact.md5} value={contact.md5}> <optgroup label={`群聊(${filteredGroups.length}`}>
{contact.m_nsNickName || contact.m_nsUsrName} {filteredGroups.map((contact) => (
</option> <option key={contact.md5} value={contact.md5}>
))} {contactName(contact)}
</option>
))}
</optgroup>
)}
{filteredUsers.length > 0 && (
<optgroup label={`联系人(${filteredUsers.length}`}>
{filteredUsers.map((contact) => (
<option key={contact.md5} value={contact.md5}>
{contactName(contact)}
</option>
))}
</optgroup>
)}
</select> </select>
{filteredContacts.length === 0 && <p className="image-test-empty"></p>}
<div className="image-test-actions"> <div className="image-test-actions">
<button <button
className="database-key-primary" className="database-key-primary"
@@ -108,7 +283,28 @@ export function ImageTestSection({
<button className="database-key-secondary" disabled={!canSave} onClick={onSave}> <button className="database-key-secondary" disabled={!canSave} onClick={onSave}>
</button> </button>
{batchTest.running ? (
<button
className="database-key-secondary image-batch-stop"
disabled={batchTest.stopRequested}
onClick={onStopBatchTest}
>
{batchTest.stopRequested ? '正在停止…' : '停止测试'}
</button>
) : (
<button
className="database-key-secondary"
disabled={disabled || filteredContacts.length === 0}
onClick={handleBatchTest}
>
{batchButtonLabel}
</button>
)}
</div> </div>
<p className="image-batch-warning">
</p>
{steps ? ( {steps ? (
<ol className="image-step-list"> <ol className="image-step-list">
<li className={stepClass(steps.find)}> <li className={stepClass(steps.find)}>
@@ -130,6 +326,49 @@ export function ImageTestSection({
{result && !result.success && result.error ? ( {result && !result.success && result.error ? (
<p className="image-inline-error">{result.error}</p> <p className="image-inline-error">{result.error}</p>
) : null} ) : null}
{batchTest.items.length > 0 && (
<div className="image-batch-results" aria-live="polite">
<div className="image-batch-summary">
<div>
<strong>{batchTest.running ? '正在批量测试' : '批量测试结果'}</strong>
<span>
{batchCounts.completed}/{batchTest.items.length} · {' '}
{formatElapsed(displayedElapsed)}
</span>
</div>
<span>{batchProgress}%</span>
</div>
<div className="image-batch-progress" aria-label={`批量测试进度 ${batchProgress}%`}>
<span style={{ width: `${batchProgress}%` }} />
</div>
<div className="image-batch-counts">
<span className="success"> {batchCounts.success}</span>
<span className="failed"> {batchCounts.failed}</span>
<span className="no-image"> {batchCounts.noImage}</span>
{batchCounts.stopped > 0 && <span> {batchCounts.stopped}</span>}
</div>
<ul className="image-batch-list">
{batchTest.items.map((item) => (
<li key={item.contact.md5} className={`image-batch-item ${item.status}`}>
<div className="image-batch-item-main">
<span className="image-batch-contact-name" title={contactName(item.contact)}>
{contactName(item.contact)}
</span>
<span className="image-batch-contact-type">
{item.contact.type === 'group' ? '群聊' : '联系人'}
</span>
<span className="image-batch-status">{batchStatusText(item.status)}</span>
<span className="image-batch-time">
{typeof item.elapsedMs === 'number' ? formatElapsed(item.elapsedMs) : '—'}
</span>
</div>
{item.error && <p title={item.error}>{item.error}</p>}
</li>
))}
</ul>
</div>
)}
</section> </section>
) )
} }
@@ -45,6 +45,30 @@ export interface ImageDecryptionState {
dirty: boolean dirty: boolean
} }
export type ImageBatchTestItemStatus =
| 'pending'
| 'testing'
| 'success'
| 'failed'
| 'no-image'
| 'stopped'
export interface ImageBatchTestItem {
contact: Contact
status: ImageBatchTestItemStatus
elapsedMs?: number
result?: ImageDecryptionTestResult
error?: string
}
export interface ImageBatchTestState {
running: boolean
stopRequested: boolean
startedAt?: number
elapsedMs: number
items: ImageBatchTestItem[]
}
export type ImageDecryptionAction = export type ImageDecryptionAction =
| { | {
type: 'LOADED' type: 'LOADED'
@@ -77,12 +101,16 @@ export type ImageDecryptionAction =
export interface ImageDecryptionController { export interface ImageDecryptionController {
state: ImageDecryptionState state: ImageDecryptionState
batchTest: ImageBatchTestState
pageStatus: 'configured' | 'unconfigured' | 'partial' pageStatus: 'configured' | 'unconfigured' | 'partial'
busy: boolean busy: boolean
canSave: boolean canSave: boolean
edit: (field: '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>
testMany: (contacts: Contact[]) => Promise<void>
stopBatchTest: () => void
copyDiagnostics: () => Promise<void>
save: () => Promise<void> save: () => Promise<void>
autoDetect: () => Promise<void> autoDetect: () => Promise<void>
clear: () => Promise<void> clear: () => Promise<void>
@@ -1,9 +1,28 @@
import { useCallback, useEffect, useMemo, useReducer } from 'react' import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import type { Contact } from '../../../../../shared/types'
import type { SettingsSelfInfo } from '../model/types' import type { SettingsSelfInfo } from '../model/types'
import { imageDecryptionReducer, initialImageDecryptionState } from './imageDecryptionReducer' import { imageDecryptionReducer, initialImageDecryptionState } from './imageDecryptionReducer'
import type { ImageDecryptionController } from './types' import type { ImageBatchTestItem, ImageBatchTestState, ImageDecryptionController } from './types'
import { normalizeAutoXorKey, sanitizeImageError } from './utils' import { normalizeAutoXorKey, sanitizeImageError } from './utils'
const EMPTY_BATCH_TEST: ImageBatchTestState = {
running: false,
stopRequested: false,
elapsedMs: 0,
items: []
}
const BATCH_CONCURRENCY = 3
function uniqueContacts(contacts: Contact[]): Contact[] {
const seen = new Set<string>()
return contacts.filter((contact) => {
if (!contact.md5 || seen.has(contact.md5)) return false
seen.add(contact.md5)
return true
})
}
export function useImageDecryptionController({ export function useImageDecryptionController({
selfInfo, selfInfo,
onNotice onNotice
@@ -12,6 +31,9 @@ export function useImageDecryptionController({
onNotice: (message: string) => void onNotice: (message: string) => void
}): ImageDecryptionController { }): ImageDecryptionController {
const [state, dispatch] = useReducer(imageDecryptionReducer, initialImageDecryptionState) const [state, dispatch] = useReducer(imageDecryptionReducer, initialImageDecryptionState)
const [batchTest, setBatchTest] = useState<ImageBatchTestState>(EMPTY_BATCH_TEST)
const batchRunRef = useRef(0)
const batchStopRef = useRef(false)
const refresh = useCallback(async (): Promise<void> => { const refresh = useCallback(async (): Promise<void> => {
try { try {
@@ -55,6 +77,116 @@ export function useImageDecryptionController({
}) })
}, [state.aesKey, state.resourceRoot, state.selectedUserMd5, state.xorKey]) }, [state.aesKey, state.resourceRoot, state.selectedUserMd5, state.xorKey])
const testMany = useCallback(
async (contacts: Contact[]): Promise<void> => {
const targets = uniqueContacts(contacts)
if (targets.length === 0 || batchTest.running) return
const runId = ++batchRunRef.current
const startedAt = Date.now()
let nextIndex = 0
batchStopRef.current = false
setBatchTest({
running: true,
stopRequested: false,
startedAt,
elapsedMs: 0,
items: targets.map((contact) => ({ contact, status: 'pending' }))
})
const updateItem = (
contactMd5: string,
update: (item: ImageBatchTestItem) => ImageBatchTestItem
): void => {
if (batchRunRef.current !== runId) return
setBatchTest((current) => ({
...current,
elapsedMs: Date.now() - startedAt,
items: current.items.map((item) =>
item.contact.md5 === contactMd5 ? update(item) : item
)
}))
}
const worker = async (): Promise<void> => {
while (!batchStopRef.current) {
const targetIndex = nextIndex
nextIndex += 1
const contact = targets[targetIndex]
if (!contact) return
updateItem(contact.md5, (item) => ({ ...item, status: 'testing' }))
const itemStartedAt = Date.now()
try {
const rawResult = await window.api.testImageDecryption({
userMd5: contact.md5,
resourceRoot: state.resourceRoot,
xorKey: state.xorKey,
aesKey: state.aesKey
})
const result = rawResult.success
? rawResult
: { ...rawResult, error: sanitizeImageError(rawResult.error) }
const status = result.success
? 'success'
: result.code === 'NO_IMAGE_MESSAGE'
? 'no-image'
: 'failed'
updateItem(contact.md5, (item) => ({
...item,
status,
elapsedMs: Math.max(1, Date.now() - itemStartedAt),
result,
error: result.error
}))
} catch (error) {
updateItem(contact.md5, (item) => ({
...item,
status: 'failed',
elapsedMs: Math.max(1, Date.now() - itemStartedAt),
error: sanitizeImageError(error instanceof Error ? error.message : String(error))
}))
}
}
}
await Promise.all(
Array.from({ length: Math.min(BATCH_CONCURRENCY, targets.length) }, () => worker())
)
if (batchRunRef.current !== runId) return
const stopped = batchStopRef.current
setBatchTest((current) => ({
...current,
running: false,
stopRequested: false,
elapsedMs: Date.now() - startedAt,
items: stopped
? current.items.map((item) =>
item.status === 'pending' ? { ...item, status: 'stopped' } : item
)
: current.items
}))
onNotice(stopped ? '批量图片测试已停止' : `批量图片测试完成,共 ${targets.length} 个会话`)
},
[batchTest.running, onNotice, state.aesKey, state.resourceRoot, state.xorKey]
)
const stopBatchTest = useCallback((): void => {
if (!batchTest.running || batchStopRef.current) return
batchStopRef.current = true
setBatchTest((current) => ({ ...current, stopRequested: true }))
}, [batchTest.running])
const copyDiagnostics = useCallback(async (): Promise<void> => {
const diagnosticLog = state.testResult?.diagnosticLog
if (!diagnosticLog) {
onNotice('请先完成一次图片解析测试')
return
}
const result = await window.api.copyText(diagnosticLog)
onNotice(result.success ? '图片解析测试日志已复制' : result.error || '复制测试日志失败')
}, [onNotice, state.testResult?.diagnosticLog])
const save = useCallback(async (): Promise<void> => { const save = useCallback(async (): Promise<void> => {
if (!state.testResult?.success && state.autoPhase !== 'success') return if (!state.testResult?.success && state.autoPhase !== 'success') return
if (state.autoPhase === 'success') dispatch({ type: 'AUTO_SAVE_START' }) if (state.autoPhase === 'success') dispatch({ type: 'AUTO_SAVE_START' })
@@ -125,7 +257,7 @@ export function useImageDecryptionController({
onNotice('图片密钥已清除,微信原始数据未受影响') onNotice('图片密钥已清除,微信原始数据未受影响')
}, [onNotice]) }, [onNotice])
const busy = ['checking', 'testing', 'clearing'].includes(state.phase) const busy = ['checking', 'testing', 'clearing'].includes(state.phase) || batchTest.running
const canSave = Boolean( const canSave = Boolean(
((state.testResult?.success && state.dirty) || state.autoPhase === 'success') && ((state.testResult?.success && state.dirty) || state.autoPhase === 'success') &&
state.status?.encryptionAvailable state.status?.encryptionAvailable
@@ -139,12 +271,16 @@ export function useImageDecryptionController({
return { return {
state, state,
batchTest,
pageStatus, pageStatus,
busy, busy,
canSave, canSave,
edit, edit,
selectChat, selectChat,
test, test,
testMany,
stopBatchTest,
copyDiagnostics,
save, save,
autoDetect, autoDetect,
clear, clear,
@@ -17,6 +17,16 @@ export function sanitizeImageError(error?: string): string {
if (value.includes('unsupported') || value.includes('dat version')) { if (value.includes('unsupported') || value.includes('dat version')) {
return '仅支持 WeChat 4.0 图片协议,V3 及以下无法解析' return '仅支持 WeChat 4.0 图片协议,V3 及以下无法解析'
} }
if (
value.includes('wxgf') ||
value.includes('ffmpeg') ||
value.includes('hevc') ||
value.includes('不完整') ||
value.includes('格式异常') ||
value.includes('无法识别')
) {
return error || '无法解析媒体文件'
}
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 '当前账号不匹配'
@@ -91,10 +91,14 @@ export function ImageDecryptionPage({
<h2 className="settings-section-heading"></h2> <h2 className="settings-section-heading"></h2>
<ImageTestSection <ImageTestSection
state={controller.state} state={controller.state}
batchTest={controller.batchTest}
disabled={controller.busy} disabled={controller.busy}
canSave={controller.canSave} canSave={controller.canSave}
onSelect={controller.selectChat} onSelect={controller.selectChat}
onTest={() => void controller.test()} onTest={() => void controller.test()}
onBatchTest={(contacts) => void controller.testMany(contacts)}
onStopBatchTest={controller.stopBatchTest}
onCopyLog={() => void controller.copyDiagnostics()}
onSave={() => void controller.save()} onSave={() => void controller.save()}
/> />
+6
View File
@@ -821,6 +821,12 @@
line-height: 1.4; line-height: 1.4;
} }
.quoted-message-image {
flex-direction: column;
align-items: flex-start;
gap: 7px;
}
.wechat-message-row.other .quoted-message { .wechat-message-row.other .quoted-message {
background: #f0f0f0; background: #f0f0f0;
color: #666; color: #666;
+51 -1
View File
@@ -21,7 +21,7 @@
.conversation-sidebar-title-row { .conversation-sidebar-title-row {
display: flex; display: flex;
align-items: baseline; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
@@ -42,6 +42,56 @@
} }
} }
.conversation-sidebar-meta {
display: flex;
align-items: center;
gap: 6px;
}
.conversation-refresh-button {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--wxex-text-muted);
cursor: pointer;
-webkit-app-region: no-drag;
&:hover:not(:disabled) {
background: rgba(36, 122, 99, 0.1);
color: var(--wxex-brand);
}
&:focus-visible {
outline: 2px solid rgba(36, 122, 99, 0.35);
outline-offset: 1px;
}
&:disabled {
cursor: wait;
opacity: 0.72;
}
svg {
width: 16px;
height: 16px;
fill: currentColor;
}
&.is-refreshing svg {
animation: conversation-refresh-spin 0.8s linear infinite;
}
}
@keyframes conversation-refresh-spin {
to {
transform: rotate(360deg);
}
}
.conversation-search { .conversation-search {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
+244 -2
View File
@@ -1223,7 +1223,8 @@
font-weight: 600; font-weight: 600;
} }
.image-key-editor input, .image-key-editor input,
.image-test-section select { .image-test-section select,
.image-test-search {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
height: 40px; height: 40px;
@@ -1240,7 +1241,8 @@
monospace; monospace;
} }
.image-key-editor input:focus, .image-key-editor input:focus,
.image-test-section select:focus { .image-test-section select:focus,
.image-test-search:focus {
border-color: #247a63; border-color: #247a63;
box-shadow: 0 0 0 2px rgba(36, 122, 99, 0.09); box-shadow: 0 0 0 2px rgba(36, 122, 99, 0.09);
} }
@@ -1269,6 +1271,45 @@
font-size: 12px; font-size: 12px;
line-height: 1.6; line-height: 1.6;
} }
.image-test-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.image-test-copy-area {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.image-test-log-scope {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
}
.image-test-log-scope-icon {
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
flex: 0 0 auto;
border-radius: 50%;
background: color-mix(in srgb, var(--warning-color, #b7791f) 14%, transparent);
color: var(--warning-color, #9a6700);
font-size: 12px;
font-weight: 800;
}
.image-test-copy {
flex: 0 0 auto;
}
.image-test-section > label { .image-test-section > label {
display: block; display: block;
margin: 18px 0 8px; margin: 18px 0 8px;
@@ -1276,11 +1317,64 @@
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
.image-test-filter-bar {
display: inline-flex;
gap: 3px;
margin-top: 18px;
padding: 3px;
border: 1px solid #dbe4e0;
border-radius: 9px;
background: #f1f5f3;
}
.image-test-filter-bar button {
min-width: 72px;
height: 30px;
border: 0;
border-radius: 6px;
background: transparent;
color: #66706b;
padding: 0 10px;
font-size: 12px;
cursor: pointer;
}
.image-test-filter-bar button:hover:not(:disabled) {
color: #247a63;
}
.image-test-filter-bar button.active {
background: #fff;
color: #247a63;
box-shadow: 0 1px 4px rgba(35, 69, 57, 0.12);
font-weight: 700;
}
.image-test-filter-bar button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.image-test-search {
display: block;
font-family: inherit;
}
.image-test-empty {
margin: 8px 0 0;
color: #8b938f;
font-size: 12px;
}
.image-test-actions { .image-test-actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 9px; gap: 9px;
margin-top: 14px; margin-top: 14px;
} }
.image-batch-stop {
border-color: #e2b8b8;
color: #a84444;
}
.image-batch-warning {
margin: 10px 0 0;
color: #7a6b48;
font-size: 12px;
line-height: 1.6;
}
.image-test-result { .image-test-result {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1362,6 +1456,154 @@
color: #a84444; color: #a84444;
font-size: 12px; font-size: 12px;
} }
.image-batch-results {
margin-top: 16px;
padding: 13px;
border: 1px solid #dfe8e4;
border-radius: 10px;
background: #f7faf9;
}
.image-batch-summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
color: #35403b;
font-size: 12px;
}
.image-batch-summary > div {
display: grid;
gap: 4px;
}
.image-batch-summary strong {
font-size: 13px;
}
.image-batch-summary span {
color: #66706b;
}
.image-batch-progress {
height: 6px;
margin-top: 10px;
overflow: hidden;
border-radius: 999px;
background: #dde6e2;
}
.image-batch-progress span {
display: block;
height: 100%;
border-radius: inherit;
background: #2e876b;
transition: width 180ms ease;
}
.image-batch-counts {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 10px;
}
.image-batch-counts span {
border-radius: 999px;
background: #e9eeec;
padding: 3px 8px;
color: #66706b;
font-size: 11px;
}
.image-batch-counts .success {
background: #e5f2ed;
color: #2e765d;
}
.image-batch-counts .failed {
background: #f9eaea;
color: #a84444;
}
.image-batch-counts .no-image {
background: #f5f0e4;
color: #806c3d;
}
.image-batch-list {
display: grid;
max-height: 360px;
margin: 12px 0 0;
padding: 0;
overflow: auto;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
list-style: none;
}
.image-batch-item {
min-width: 0;
border: 1px solid #e2e9e6;
border-left: 3px solid #c7cfcc;
border-radius: 8px;
background: #fff;
padding: 9px 10px;
}
.image-batch-item.testing {
border-left-color: #3c78b5;
}
.image-batch-item.success {
border-left-color: #2e876b;
}
.image-batch-item.failed {
border-left-color: #c45656;
}
.image-batch-item.no-image {
border-left-color: #b5964c;
}
.image-batch-item-main {
display: grid;
min-width: 0;
align-items: center;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 7px;
font-size: 11px;
}
.image-batch-contact-name {
overflow: hidden;
color: #35403b;
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-batch-contact-type {
border-radius: 999px;
background: #edf2f0;
padding: 2px 6px;
color: #6b746f;
}
.image-batch-status {
color: #66706b;
font-weight: 600;
}
.image-batch-item.success .image-batch-status {
color: #2e765d;
}
.image-batch-item.failed .image-batch-status {
color: #a84444;
}
.image-batch-item.no-image .image-batch-status {
color: #806c3d;
}
.image-batch-time {
color: #8a928e;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.image-batch-item p {
margin: 7px 0 0;
overflow: hidden;
color: #8a5757;
font-size: 11px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 760px) {
.image-batch-list {
grid-template-columns: 1fr;
}
}
.image-auto-heading { .image-auto-heading {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
+79
View File
@@ -0,0 +1,79 @@
import type { Message } from '../../../shared/types'
const normalizeQuotedText = (value: string | undefined): string =>
String(value || '')
.replace(/\s+/g, ' ')
.trim()
export const isInternalReferenceSender = (value: string | undefined): boolean => {
const sender = String(value || '').trim()
return (
!sender ||
sender.endsWith('@chatroom') ||
sender.startsWith('wxid_') ||
/^[a-z0-9_@.-]{12,}$/i.test(sender)
)
}
export function enrichQuotedMessages(
messages: Message[],
referenceMessages: Message[],
resolveSenderName?: (senderId: string) => string | undefined
): Message[] {
const imageDatNameByMd5 = new Map<string, string>()
const messagesByContent = new Map<string, Message[]>()
const senderNames = new Map<string, string>()
for (const message of referenceMessages) {
if (
message.contentData?.type === 'image' &&
message.contentData.md5 &&
message.contentData.datName
) {
imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName)
}
const senderId = String(message.senderId || '').trim()
const senderName = String(message.name || '').trim()
if (senderId && senderName && !isInternalReferenceSender(senderName)) {
senderNames.set(senderId, senderName)
}
const content = normalizeQuotedText(message.content)
if (!content) continue
const candidates = messagesByContent.get(content) || []
candidates.push(message)
messagesByContent.set(content, candidates)
}
return messages.map((message) => {
if (message.contentData?.type !== 'quote') return message
const quote = message.contentData
let quotedImageDatName = quote.quotedImageDatName
if (!quotedImageDatName && quote.quotedImageMd5) {
quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5)
}
let quotedSender = quote.quotedSender
if (isInternalReferenceSender(quotedSender)) {
const senderId = String(quotedSender || '').trim()
const mappedName =
(senderId ? resolveSenderName?.(senderId) : undefined) || senderNames.get(senderId)
if (mappedName && !isInternalReferenceSender(mappedName)) {
quotedSender = mappedName
} else {
const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || []
const source = candidates
.filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity))
.sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0]
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
}
}
if (quotedSender === quote.quotedSender && quotedImageDatName === quote.quotedImageDatName) {
return message
}
return {
...message,
contentData: { ...quote, quotedSender, quotedImageDatName }
}
})
}
+1
View File
@@ -7,6 +7,7 @@ export type ExportMessageKind =
| 'video' | 'video'
| 'voice' | 'voice'
| 'sticker' | 'sticker'
| 'file'
| 'share' | 'share'
| 'location' | 'location'
| 'system' | 'system'
+1
View File
@@ -85,4 +85,5 @@ export interface ImageDecryptionTestResult {
decrypted: boolean decrypted: boolean
readable: boolean readable: boolean
isThumbnail?: boolean isThumbnail?: boolean
diagnosticLog?: string
} }
+2 -1
View File
@@ -31,7 +31,8 @@ export interface Message {
recalledBy?: string recalledBy?: string
recoveredFromRecallJournal?: boolean recoveredFromRecallJournal?: boolean
exportMediaUrl?: string exportMediaUrl?: string
exportMediaType?: 'image' | 'video' | 'sticker' exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
exportMediaName?: string
exportShowAvatar?: boolean exportShowAvatar?: boolean
exportMediaError?: string exportMediaError?: string
exportAvatarUrl?: string exportAvatarUrl?: string
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ConversationSidebarHeader } from '../../src/renderer/src/components/conversation/ConversationSidebarHeader'
describe('ConversationSidebarHeader', () => {
it('refreshes the conversation list from the archive header', async () => {
const onRefresh = vi.fn()
render(
<ConversationSidebarHeader
totalCount={1306}
searchValue=""
onSearchChange={vi.fn()}
refreshing={false}
onRefresh={onRefresh}
/>
)
expect(screen.getByText('1306 个会话')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: '刷新会话列表' }))
expect(onRefresh).toHaveBeenCalledOnce()
})
it('disables the refresh button while contacts are loading', () => {
render(
<ConversationSidebarHeader
totalCount={12}
searchValue="测试"
onSearchChange={vi.fn()}
refreshing
onRefresh={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: '正在刷新会话列表' })).toBeDisabled()
expect(screen.getByDisplayValue('测试')).toBeInTheDocument()
})
})
+21
View File
@@ -9,6 +9,7 @@ vi.mock('../../src/renderer/src/components/image-loader', () => ({
})) }))
import { ImageBubble } from '../../src/renderer/src/components/ImageBubble' import { ImageBubble } from '../../src/renderer/src/components/ImageBubble'
import { RichMessageBubble } from '../../src/renderer/src/components/RichMessageBubble'
const thumbnail = const thumbnail =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=' 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
@@ -50,4 +51,24 @@ describe('ImageBubble', () => {
await userEvent.click(screen.getByText('加载失败')) await userEvent.click(screen.getByText('加载失败'))
expect(await screen.findByAltText('图片')).toBeVisible() expect(await screen.findByAltText('图片')).toBeVisible()
}) })
it('places a quoted image on the line below the quoted sender', async () => {
requestImage.mockResolvedValueOnce({ data: thumbnail, isThumbnail: true })
const { container } = render(
<RichMessageBubble
contentData={{
type: 'quote',
content: '回复内容',
quotedContent: '[图片]',
quotedSender: '测试群成员',
quotedImageMd5: 'fixture-image'
}}
sessionId="fixture-session"
/>
)
expect(screen.getByText('测试群成员')).toBeInTheDocument()
expect(container.querySelector('.quoted-message')).toHaveClass('quoted-message-image')
expect(await screen.findByAltText('图片')).toBeVisible()
})
}) })
@@ -0,0 +1,183 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ImageTestSection } from '../../src/renderer/src/features/settings/image-decryption/ImageTestSection'
import { initialImageDecryptionState } from '../../src/renderer/src/features/settings/image-decryption/imageDecryptionReducer'
import type { ImageBatchTestState } from '../../src/renderer/src/features/settings/image-decryption/types'
const emptyBatchTest: ImageBatchTestState = {
running: false,
stopRequested: false,
elapsedMs: 0,
items: []
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('ImageTestSection', () => {
it('keeps the file step successful when decryption fails and copies diagnostics', async () => {
const onCopyLog = vi.fn()
render(
<ImageTestSection
state={{
...initialImageDecryptionState,
phase: 'test-failed',
selectedUserMd5: 'conversation-md5',
contacts: [
{
md5: 'conversation-md5',
m_nsUsrName: 'fixture-user',
m_nsNickName: '测试会话',
type: 'user'
}
],
testResult: {
success: false,
code: 'DECRYPT_FAILED',
error: '图片密钥与当前账号不匹配',
fileFound: true,
decrypted: false,
readable: false,
diagnosticLog: 'WechatExplorer 图片解析测试日志(已脱敏)'
}
}}
batchTest={emptyBatchTest}
disabled={false}
canSave={false}
onSelect={vi.fn()}
onTest={vi.fn()}
onBatchTest={vi.fn()}
onStopBatchTest={vi.fn()}
onCopyLog={onCopyLog}
onSave={vi.fn()}
/>
)
expect(screen.getByText('找到图片文件').closest('li')).toHaveClass('image-step-ok')
expect(screen.getByText('解密成功').closest('li')).toHaveClass('image-step-fail')
expect(screen.getByText('图片可以读取').closest('li')).toHaveClass('image-step-skipped')
expect(screen.getByText('仅当前会话的单次测试日志')).toHaveAttribute(
'title',
expect.stringContaining('不包含批量测试结果')
)
await userEvent.click(screen.getByRole('button', { name: '复制日志' }))
expect(onCopyLog).toHaveBeenCalledOnce()
})
it('disables log copying before a test result exists', () => {
render(
<ImageTestSection
state={initialImageDecryptionState}
batchTest={emptyBatchTest}
disabled={false}
canSave={false}
onSelect={vi.fn()}
onTest={vi.fn()}
onBatchTest={vi.fn()}
onStopBatchTest={vi.fn()}
onCopyLog={vi.fn()}
onSave={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: '复制日志' })).toBeDisabled()
})
it('filters groups and contacts before starting a batch test', async () => {
const onBatchTest = vi.fn()
vi.spyOn(window, 'confirm').mockReturnValue(true)
render(
<ImageTestSection
state={{
...initialImageDecryptionState,
contacts: [
{
md5: 'group-md5',
m_nsUsrName: 'group@chatroom',
m_nsNickName: '测试群聊',
type: 'group'
},
{
md5: 'user-md5',
m_nsUsrName: 'wxid_friend',
m_nsNickName: '测试联系人',
type: 'user'
}
]
}}
batchTest={emptyBatchTest}
disabled={false}
canSave={false}
onSelect={vi.fn()}
onTest={vi.fn()}
onBatchTest={onBatchTest}
onStopBatchTest={vi.fn()}
onCopyLog={vi.fn()}
onSave={vi.fn()}
/>
)
await userEvent.click(screen.getByRole('button', { name: '群聊 1' }))
expect(screen.getByRole('option', { name: '测试群聊' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: '测试联系人' })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: '测试筛选结果(1' }))
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('测试 1 个会话'))
expect(onBatchTest).toHaveBeenCalledWith([
expect.objectContaining({ md5: 'group-md5', type: 'group' })
])
})
it('shows per-conversation batch result and elapsed time', () => {
render(
<ImageTestSection
state={initialImageDecryptionState}
batchTest={{
running: false,
stopRequested: false,
elapsedMs: 1320,
items: [
{
contact: {
md5: 'success-md5',
m_nsUsrName: 'success@chatroom',
m_nsNickName: '成功群聊',
type: 'group'
},
status: 'success',
elapsedMs: 120
},
{
contact: {
md5: 'no-image-md5',
m_nsUsrName: 'wxid_no_image',
m_nsNickName: '没有图片的联系人',
type: 'user'
},
status: 'no-image',
elapsedMs: 88,
error: '最近 300 条消息中没有图片'
}
]
}}
disabled={false}
canSave={false}
onSelect={vi.fn()}
onTest={vi.fn()}
onBatchTest={vi.fn()}
onStopBatchTest={vi.fn()}
onCopyLog={vi.fn()}
onSave={vi.fn()}
/>
)
expect(screen.getByText('成功群聊')).toBeInTheDocument()
expect(screen.getByText('没有图片的联系人')).toBeInTheDocument()
expect(screen.getByText('成功 1')).toBeInTheDocument()
expect(screen.getByText('无图片 1')).toBeInTheDocument()
expect(screen.getByText(/2\/2 · 已耗时 1\.3 秒/)).toBeInTheDocument()
})
})
+2 -1
View File
@@ -327,7 +327,8 @@ handle('image:testConfig', () => ({
success: true, success: true,
fileFound: true, fileFound: true,
decrypted: true, decrypted: true,
readable: true readable: true,
diagnosticLog: 'WechatExplorer 图片解析测试日志(已脱敏)\n测试结果:成功(SUCCESS'
})) }))
handle('image:clearConfig', () => ({ success: true })) handle('image:clearConfig', () => ({ success: true }))
handle('image:getDecoderStatus', () => ({ handle('image:getDecoderStatus', () => ({
+17 -3
View File
@@ -1,11 +1,12 @@
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Message } from '../../src/shared/types' import type { Message } from '../../src/shared/types'
const state = vi.hoisted(() => ({ const state = vi.hoisted(() => ({
documents: '', documents: '',
accountRoot: '',
videoPath: '', videoPath: '',
messages: [] as Message[], messages: [] as Message[],
imageLookups: [] as { allowThumbnail?: boolean; preferThumbnail?: boolean }[] imageLookups: [] as { allowThumbnail?: boolean; preferThumbnail?: boolean }[]
@@ -18,7 +19,9 @@ vi.mock('electron', () => ({
})) }))
vi.mock('../../src/main/services/chat-service', () => ({ vi.mock('../../src/main/services/chat-service', () => ({
listMessages: () => structuredClone(state.messages), listMessages: () => structuredClone(state.messages),
getChatDb: () => ({ getWcdb4Client: () => ({}) }), getChatDb: () => ({
getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot })
}),
getContactAvatars: () => ({}) getContactAvatars: () => ({})
})) }))
vi.mock('../../src/main/services/image-key-config-service', () => ({ vi.mock('../../src/main/services/image-key-config-service', () => ({
@@ -92,12 +95,16 @@ const message = (overrides: Partial<Message>): Message => ({
describe('media export flow', () => { describe('media export flow', () => {
beforeEach(() => { beforeEach(() => {
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-')) state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
state.accountRoot = join(state.documents, 'fixture-account')
state.videoPath = join(state.documents, 'fixture.mp4') state.videoPath = join(state.documents, 'fixture.mp4')
writeFileSync( writeFileSync(
state.videoPath, state.videoPath,
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex') Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
) )
state.imageLookups = [] state.imageLookups = []
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
mkdirSync(fileMonth, { recursive: true })
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
state.messages = [ state.messages = [
message({ message({
id: 'voice-ok', id: 'voice-ok',
@@ -123,6 +130,11 @@ describe('media export flow', () => {
id: 'video', id: 'video',
type: '视频', type: '视频',
contentData: { type: 'video', md5: 'b'.repeat(32) } contentData: { type: 'video', md5: 'b'.repeat(32) }
}),
message({
id: 'file',
type: '文件',
contentData: { type: 'share', typeVal: '6', title: '测试附件.txt', url: '' }
}) })
] ]
}) })
@@ -143,7 +155,7 @@ describe('media export flow', () => {
name: '脱敏会话', name: '脱敏会话',
format: 'html', format: 'html',
outputName: 'fixture', outputName: 'fixture',
kinds: ['voice', 'image', 'video'], kinds: ['voice', 'image', 'video', 'file'],
includeMedia: true, includeMedia: true,
preferOriginal: true, preferOriginal: true,
fallbackThumbnail: true, fallbackThumbnail: true,
@@ -161,8 +173,10 @@ describe('media export flow', () => {
expect(readFileSync(join(outputDir, 'media/video_4.mp4')).subarray(4, 8).toString()).toBe( expect(readFileSync(join(outputDir, 'media/video_4.mp4')).subarray(4, 8).toString()).toBe(
'ftyp' 'ftyp'
) )
expect(readFileSync(join(outputDir, 'media/file_5_测试附件.txt'), 'utf8')).toBe('附件内容')
expect(html).toContain('src="voices/voice_1_1.wav"') expect(html).toContain('src="voices/voice_1_1.wav"')
expect(html).toContain('src="media/video_4.mp4"') expect(html).toContain('src="media/video_4.mp4"')
expect(html).toContain('href="media/file_5_测试附件.txt" download')
expect(html).toContain('语音文件缺失:本地未找到语音数据') expect(html).toContain('语音文件缺失:本地未找到语音数据')
expect(state.imageLookups[0]).toMatchObject({ expect(state.imageLookups[0]).toMatchObject({
allowThumbnail: false, allowThumbnail: false,
+32 -1
View File
@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WechatDb } from '../../src/main/wechat-db' import type { WechatDb } from '../../src/main/wechat-db'
import { listContactsAsync, setChatDb } from '../../src/main/services/chat-service' import {
closeChatDbForQuit,
isReady,
listContactsAsync,
setChatDb
} from '../../src/main/services/chat-service'
describe('chat service contacts', () => { describe('chat service contacts', () => {
afterEach(() => setChatDb(null)) afterEach(() => setChatDb(null))
@@ -36,4 +41,30 @@ describe('chat service contacts', () => {
}) })
expect(contacts[0]?.m_nsNickName).toBe('测试群聊') expect(contacts[0]?.m_nsNickName).toBe('测试群聊')
}) })
it('detaches the database immediately and awaits native cleanup on quit', async () => {
let finishClose: ((value: boolean) => void) | undefined
const closeAsync = vi.fn(
() =>
new Promise<boolean>((resolve) => {
finishClose = resolve
})
)
const fakeDb = {
close: vi.fn(),
closeAsync
} as unknown as WechatDb
setChatDb(fakeDb)
const closing = closeChatDbForQuit()
expect(isReady()).toBe(false)
expect(closeAsync).toHaveBeenCalledOnce()
finishClose?.(true)
await expect(closing).resolves.toBe(true)
const lateDb = { close: vi.fn() } as unknown as WechatDb
expect(setChatDb(lateDb)).toBe(false)
expect(lateDb.close).toHaveBeenCalledOnce()
})
}) })
+9
View File
@@ -38,6 +38,14 @@ describe('export media', () => {
id: 'missing', id: 'missing',
type: '语音', type: '语音',
exportMediaError: '语音文件缺失:本地未找到语音数据' exportMediaError: '语音文件缺失:本地未找到语音数据'
}),
baseMessage({
id: 'file',
type: '文件',
contentData: { type: 'share', typeVal: '6', title: '示例附件.zip', url: '' },
exportMediaType: 'file',
exportMediaName: '示例附件.zip',
exportMediaUrl: 'media/file_4_示例附件.zip'
}) })
]) ])
@@ -46,6 +54,7 @@ describe('export media', () => {
) )
expect(html).toContain('video class="media-image" controls src="media/video_2.mp4"') expect(html).toContain('video class="media-image" controls src="media/video_2.mp4"')
expect(html).toContain('语音文件缺失:本地未找到语音数据') expect(html).toContain('语音文件缺失:本地未找到语音数据')
expect(html).toContain('class="file-attachment" href="media/file_4_示例附件.zip" download')
expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/) expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/)
}) })
+30
View File
@@ -0,0 +1,30 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, describe, expect, it } from 'vitest'
import { FileAssetService } from '../../src/main/file-asset-service'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('file asset service', () => {
it('prefers the message month and supports duplicate download suffixes', () => {
const accountRoot = mkdtempSync(join(tmpdir(), 'wxe-file-asset-'))
roots.push(accountRoot)
const august = join(accountRoot, 'msg', 'file', '2026-08')
const july = join(accountRoot, 'msg', 'file', '2026-07')
mkdirSync(august, { recursive: true })
mkdirSync(july, { recursive: true })
writeFileSync(join(july, '产品说明.txt'), 'old')
writeFileSync(join(august, '产品说明(1).txt'), 'current')
const service = new FileAssetService({ getAccountRoot: () => accountRoot })
const result = service.resolve('产品说明.txt', new Date(2026, 7, 4).getTime() / 1000)
expect(result).toMatchObject({ success: true, fileName: '产品说明(1).txt' })
expect(result.filePath).toBe(realpathSync(join(august, '产品说明(1).txt')))
})
})
+80 -6
View File
@@ -16,6 +16,7 @@ import { ImageDecryptService } from '../../src/main/image-decrypt-service'
const aesKey = '0123456789abcdef' const aesKey = '0123456789abcdef'
const xorKey = 0x40 const xorKey = 0x40
const originalResourcesPath = process.resourcesPath
function writeV2Dat(file: string): Buffer { function writeV2Dat(file: string): Buffer {
const aesPlain = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) const aesPlain = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
@@ -35,22 +36,95 @@ function writeV2Dat(file: string): Buffer {
} }
describe('DAT image decryption', () => { describe('DAT image decryption', () => {
beforeAll(() => mkdirSync(root, { recursive: true })) beforeAll(() => {
afterAll(() => rmSync(root, { recursive: true, force: true })) mkdirSync(root, { recursive: true })
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: root })
})
afterAll(() => {
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: originalResourcesPath
})
rmSync(root, { recursive: true, force: true })
})
it('decrypts a synthetic V2 AES/raw/XOR fixture', () => { it('decrypts a synthetic V2 AES/raw/XOR fixture', () => {
const file = join(root, 'fixture.dat') const file = join(root, 'fixture.dat')
const expected = writeV2Dat(file) const expected = writeV2Dat(file)
expect(new ImageDecryptService('0x40', aesKey).decryptImage(file)).toEqual(expected) const service = new ImageDecryptService('0x40', aesKey)
expect(service.decryptImage(file)).toEqual(expected)
expect(service.decryptImageToBase64(file)).toMatch(/^data:image\/png;base64,/)
expect(service.getLastDecodeDiagnostic()).toMatchObject({
code: 'SUCCESS',
datVersion: 2,
imageFormat: 'PNG'
})
}) })
it('rejects the wrong AES key and unsupported legacy signatures accurately', () => { it('rejects the wrong AES key and unsupported legacy signatures accurately', () => {
const file = join(root, 'fixture.dat') const file = join(root, 'fixture.dat')
writeV2Dat(file) writeV2Dat(file)
expect(new ImageDecryptService('0x40', 'fedcba9876543210').decryptImage(file)).toBeNull() const wrongKeyService = new ImageDecryptService('0x40', 'fedcba9876543210')
expect(wrongKeyService.decryptImage(file)).toBeNull()
expect(wrongKeyService.getLastDecodeDiagnostic()).toMatchObject({
code: 'AES_DECRYPT_FAILED',
datVersion: 2
})
const legacy = join(root, 'legacy.dat') const legacy = join(root, 'legacy.dat')
writeFileSync(legacy, Buffer.from([0xff, 0xd8, 0xff, 0x00])) writeFileSync(legacy, Buffer.from([0x12, 0x34, 0x56, 0x78]))
expect(new ImageDecryptService('0x40', aesKey).decryptImage(legacy)).toBeNull() const legacyService = new ImageDecryptService('0x40', aesKey)
expect(legacyService.decryptImage(legacy)).toBeNull()
expect(legacyService.getLastDecodeDiagnostic()).toMatchObject({
code: 'UNSUPPORTED_DAT_VERSION',
datVersion: 0
})
})
it('finds modern _M variants and reads plain images stored with a DAT extension', async () => {
const accountRoot = join(root, 'modern-account')
const sessionId = '77705c31c50e8a4242a9d527fe9433de'
const imageDirectory = join(accountRoot, 'msg', 'attach', sessionId, '2025-10', 'Img')
mkdirSync(imageDirectory, { recursive: true })
const imageBase = '9718e38ad90f57f9e833d17ff2373abd'
const mediumFile = join(imageDirectory, `${imageBase}_M.dat`)
writeFileSync(mediumFile, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]))
const service = new ImageDecryptService('0x40', aesKey)
await expect(
service.findImageFileAsync(undefined, imageBase, {
allowThumbnail: false,
accountDir: accountRoot,
sessionId
})
).resolves.toBe(mediumFile)
expect(service.decryptImageToBase64(mediumFile)).toMatch(/^data:image\/png;base64,/)
expect(service.getLastDecodeDiagnostic()).toMatchObject({
code: 'DIRECT_IMAGE',
imageFormat: 'PNG'
})
await expect(service.decryptImageToBase64WithFallbackAsync(mediumFile, true)).resolves.toEqual(
expect.objectContaining({ filePath: mediumFile })
)
const thumbnailBase = '37a9000000000000000000000000ceaa'
const thumbnailFile = join(imageDirectory, `${thumbnailBase}_t_M.dat`)
writeFileSync(thumbnailFile, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
expect(service.isThumbnailFile(thumbnailFile)).toBe(true)
await expect(
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
allowThumbnail: false,
accountDir: accountRoot,
sessionId
})
).resolves.toBeNull()
await expect(
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
allowThumbnail: true,
accountDir: accountRoot,
sessionId
})
).resolves.toBe(thumbnailFile)
}) })
}) })
@@ -0,0 +1,117 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
const root = mkdtempSync(join(tmpdir(), 'wxe-image-diagnostic-'))
vi.mock('electron', () => ({
app: {
getPath: () => root,
getVersion: () => '2.1.7-test'
}
}))
vi.mock('../../src/main/image-decrypt-service', () => ({
ImageDecryptService: class {},
inspectImageDecoderStatus: vi.fn()
}))
vi.mock('../../src/main/services/chat-service', () => ({}))
vi.mock('../../src/main/services/image-key-config-service', () => ({
validateImageKeyRequest: vi.fn()
}))
vi.mock('../../src/main/services/wechat-process-status', () => ({
isWechatRunning: vi.fn()
}))
import { buildImageTestDiagnosticLog } from '../../src/main/services/image-decryption-status-service'
describe('image decryption diagnostic log', () => {
beforeAll(() => mkdirSync(join(root, 'private-account', 'msg', 'attach'), { recursive: true }))
afterAll(() => rmSync(root, { recursive: true, force: true }))
it('keeps useful failure details without exposing keys or absolute paths', () => {
const resourceRoot = join(root, 'private-account')
const aesKey = '0123456789abcdef'
const imageMd5 = '1234567890abcdef1234567890abcdef'
const log = buildImageTestDiagnosticLog({
request: {
userMd5: 'conversation-secret',
resourceRoot,
xorKey: '0x40',
aesKey
},
result: {
success: false,
code: 'DECRYPT_FAILED',
error: '图片密钥与当前账号不匹配,或图片文件已损坏',
fileFound: true,
decrypted: false,
readable: false,
isThumbnail: false
},
startedAt: Date.now() - 25,
testedImage: {
md5: imageMd5,
datName: `${imageMd5}_h.dat`,
sessionId: 'wxid_private_session',
selection: '自动测试样本'
},
filePath: join(
resourceRoot,
'msg',
'attach',
imageMd5,
'2026-08',
'Img',
`${imageMd5}_h.dat`
),
decodeDiagnostic: {
code: 'AES_DECRYPT_FAILED',
detail: 'AES 解密校验失败,密钥可能与当前账号不匹配',
datVersion: 2,
fileSize: 2048
}
})
expect(log).toContain('AES_DECRYPT_FAILED')
expect(log).toContain('WeChat 4.0 V2')
expect(log).toContain('内容未记录')
expect(log).not.toContain(aesKey)
expect(log).not.toContain(resourceRoot)
expect(log).not.toContain(imageMd5)
expect(log).not.toContain('conversation-secret')
expect(log).not.toContain('wxid_private_session')
})
it('describes plain images with a DAT extension without reporting an unsupported protocol', () => {
const resourceRoot = join(root, 'private-account')
const filePath = join(resourceRoot, 'msg', 'attach', 'plain-image_M.dat')
writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]))
const log = buildImageTestDiagnosticLog({
request: {
userMd5: 'conversation-secret',
resourceRoot,
xorKey: '0x40',
aesKey: '0123456789abcdef'
},
result: {
success: true,
fileFound: true,
decrypted: true,
readable: true,
isThumbnail: false
},
startedAt: Date.now() - 10,
filePath,
decodeDiagnostic: {
code: 'DIRECT_IMAGE',
detail: 'DAT 文件内容是可直接读取的图片',
fileSize: 8,
imageFormat: 'PNG'
}
})
expect(log).toContain('DAT 协议:明文图片(无需 DAT 解密)')
expect(log).not.toContain('不受支持/旧版格式')
})
})
+13
View File
@@ -28,6 +28,19 @@ describe('message parser', () => {
} }
}) })
it('uses the quoted group member id instead of the chatroom id', () => {
const parsed = parseMessageContent(
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',
49
)
expect(parsed).toMatchObject({
type: 'quote',
quotedSender: 'wxid_fixture_member',
quotedContent: '被引用内容'
})
})
it('uses an explicit unknown type for unsupported messages', () => { it('uses an explicit unknown type for unsupported messages', () => {
expect(parseMessageContent('opaque fixture payload', 999)).toEqual({ expect(parseMessageContent('opaque fixture payload', 999)).toEqual({
type: 'unknown', type: 'unknown',
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { Message } from '../../src/shared/types'
import { enrichQuotedMessages } from '../../src/renderer/src/utils/quoted-messages'
const message = (overrides: Partial<Message>): Message => ({
id: 'fixture',
from: 'user',
type: '普通文本',
datetime: '2026-08-04 12:00:00',
content: '',
isSender: false,
createTime: 1_785_816_000,
...overrides
})
describe('quoted message enrichment', () => {
it('maps an internal quoted sender id to the loaded group member name', () => {
const quoted = message({
id: 'quote',
contentData: {
type: 'quote',
content: '回复',
quotedContent: '[图片]',
quotedSender: 'wxid_fixture_member',
quotedImageMd5: 'a'.repeat(32)
}
})
const [result] = enrichQuotedMessages([quoted], [quoted], (senderId) =>
senderId === 'wxid_fixture_member' ? '测试群成员' : undefined
)
expect(result.contentData).toMatchObject({ quotedSender: '测试群成员' })
})
})
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest'
import { Wcdb4Client } from '../../src/main/wcdb4-client'
function setPrivate(target: object, key: string, value: unknown): void {
Reflect.set(target, key, value)
}
describe('Wcdb4Client shutdown', () => {
it('waits for tracked Koffi calls before shutting down the native runtime', async () => {
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
const shutdown = vi.fn(() => 0)
const inFlight = new Set<Promise<unknown>>()
let finishCall: (() => void) | undefined
const pending = new Promise<void>((resolve) => {
finishCall = resolve
})
inFlight.add(pending)
void pending.then(() => inFlight.delete(pending))
setPrivate(client, 'nativeCallsInFlight', inFlight)
setPrivate(client, 'handle', 1)
setPrivate(client, 'wcdbShutdown', shutdown)
setPrivate(client, 'monitorStarted', false)
setPrivate(client, 'displayNameCache', new Map())
setPrivate(client, 'avatarCache', new Map())
setPrivate(client, 'sessionStatusCache', new Map())
setPrivate(client, 'groupNicknameCache', new Map())
const closing = client.closeAsync(1_000)
expect(shutdown).not.toHaveBeenCalled()
finishCall?.()
await expect(closing).resolves.toBe(true)
if (process.platform === 'win32') {
expect(shutdown).not.toHaveBeenCalled()
} else {
expect(shutdown).toHaveBeenCalledOnce()
}
})
})