mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
fix: 完善聊天解析与导出体验
- 修复引用消息名称和图片布局 - 明确单会话图片测试日志范围 - 支持导出文件附件 - 完善图片批测、会话刷新和安全退出
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
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 =>
|
||||
String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
@@ -30,7 +30,9 @@ export function renderExportPage(name: string, messages: Message[]): string {
|
||||
? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>`
|
||||
: m.exportMediaUrl && m.exportMediaType === 'sticker'
|
||||
? `<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 =
|
||||
m.exportShowAvatar === false
|
||||
? ''
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||
import { VideoAssetService } from './video-asset-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
import { getImageExportAttempts } from '../shared/export-media'
|
||||
import { FileAssetService } from './file-asset-service'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
@@ -87,6 +88,7 @@ async function readAvatarAsset(
|
||||
const kindOf = (message: Message): ExportMessageKind => {
|
||||
const type = message.contentData?.type
|
||||
if (type === 'system' && message.contentData?.pat) return 'text'
|
||||
if (type === 'share' && message.contentData.typeVal === '6') return 'file'
|
||||
if (
|
||||
type === 'image' ||
|
||||
type === 'video' ||
|
||||
@@ -142,6 +144,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
for (const message of messages) {
|
||||
message.exportMediaUrl = undefined
|
||||
message.exportMediaType = undefined
|
||||
message.exportMediaName = undefined
|
||||
message.exportMediaError = undefined
|
||||
message.voiceDataUrl = undefined
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
@@ -149,7 +152,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
if (mappedName) message.name = mappedName
|
||||
if (
|
||||
request.format !== 'html' &&
|
||||
['image', 'video', 'voice', 'sticker'].includes(kindOf(message))
|
||||
['image', 'video', 'voice', 'sticker', 'file'].includes(kindOf(message))
|
||||
) {
|
||||
message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件'
|
||||
}
|
||||
@@ -198,6 +201,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
: null
|
||||
const videoService = client ? new VideoAssetService(client) : null
|
||||
const stickerService = client ? new StickerService(client) : null
|
||||
const fileService = client ? new FileAssetService(client) : null
|
||||
const exportedAvatars = new Map<string, string>()
|
||||
const voiceService =
|
||||
request.includeMedia && chat.getChatDb()
|
||||
@@ -362,6 +366,21 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
} else {
|
||||
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({
|
||||
jobId: request.jobId,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,27 @@ export type DecodedImage = {
|
||||
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 = {
|
||||
allowThumbnail?: boolean
|
||||
accountDir?: string
|
||||
@@ -158,12 +179,15 @@ function normalizeDatBase(value) {
|
||||
if (!lower) return ''
|
||||
const file = lower.split('/').pop().split('\\').pop()
|
||||
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) {
|
||||
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) {
|
||||
@@ -172,10 +196,13 @@ function buildPreferredDatNames(baseName) {
|
||||
return [
|
||||
base + '.dat',
|
||||
base + '_hd.dat',
|
||||
base + '_h_M.dat',
|
||||
base + '_h.dat',
|
||||
base + '_M.dat',
|
||||
base + '_b.dat',
|
||||
base + '_w.dat',
|
||||
base + '_c.dat',
|
||||
base + '_t_M.dat',
|
||||
base + '_t.dat',
|
||||
base + '.thumb.dat',
|
||||
base + '_thumb.dat'
|
||||
@@ -285,8 +312,15 @@ function unwrapWxgf(buffer, ffmpegPath) {
|
||||
|
||||
function decryptCandidate(filePath, aesKey, xorKey, ffmpegPath) {
|
||||
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')) {
|
||||
const extension = detectImageExtension(bytes) || path.extname(filePath).toLowerCase()
|
||||
const extension = path.extname(filePath).toLowerCase()
|
||||
return { data: 'data:' + getMimeType(extension) + ';base64,' + bytes.toString('base64'), filePath }
|
||||
}
|
||||
if (
|
||||
@@ -381,6 +415,10 @@ export class ImageDecryptService {
|
||||
private decodedImageCacheBytes = 0
|
||||
private persistentCachePrunePromise: Promise<void> | null = null
|
||||
private persistentCachePrunePending = false
|
||||
private lastDecodeDiagnostic: ImageDecodeDiagnostic = {
|
||||
code: 'NOT_RUN',
|
||||
detail: '尚未执行图片解析'
|
||||
}
|
||||
|
||||
constructor(
|
||||
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 {
|
||||
if (!existsSync(datPath)) {
|
||||
imageDecryptLog('[ImageDecrypt] file not found:', datPath)
|
||||
this.lastDecodeDiagnostic = {
|
||||
code: 'FILE_NOT_FOUND',
|
||||
detail: '候选图片文件不存在'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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 fileSize = statSync(datPath).size
|
||||
imageDecryptLog(
|
||||
'[ImageDecrypt] dat version:',
|
||||
version,
|
||||
@@ -1231,6 +1289,12 @@ export class ImageDecryptService {
|
||||
imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)')
|
||||
if (!this.aesKey) {
|
||||
imageDecryptLog('[ImageDecrypt] no AES key configured')
|
||||
this.lastDecodeDiagnostic = {
|
||||
code: 'MISSING_AES_KEY',
|
||||
detail: '未配置 AES 图片密钥',
|
||||
datVersion: version,
|
||||
fileSize
|
||||
}
|
||||
return null
|
||||
}
|
||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||
@@ -1238,12 +1302,34 @@ export class ImageDecryptService {
|
||||
} else {
|
||||
// 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
|
||||
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
|
||||
}
|
||||
|
||||
this.lastDecodeDiagnostic = {
|
||||
code: 'SUCCESS',
|
||||
detail: 'DAT 数据已解密,正在识别图片格式',
|
||||
datVersion: version,
|
||||
fileSize
|
||||
}
|
||||
return decrypted
|
||||
} catch (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
|
||||
}
|
||||
}
|
||||
@@ -1253,23 +1339,54 @@ export class ImageDecryptService {
|
||||
*/
|
||||
decryptImageToBase64(datPath: string): string | null {
|
||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||
const data = readFileSync(datPath)
|
||||
const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase()
|
||||
const mimeType = this.getMimeType(ext)
|
||||
return `data:${mimeType};base64,${data.toString('base64')}`
|
||||
try {
|
||||
const data = readFileSync(datPath)
|
||||
const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase()
|
||||
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)
|
||||
if (!decrypted) return null
|
||||
|
||||
const directImage = this.lastDecodeDiagnostic.code === 'DIRECT_IMAGE'
|
||||
const wxgf = this.isWxgfBuffer(decrypted)
|
||||
const unwrapped = this.unwrapWxgf(decrypted)
|
||||
const ext = this.detectImageExtension(unwrapped)
|
||||
if (!ext) {
|
||||
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
|
||||
}
|
||||
|
||||
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')}`
|
||||
}
|
||||
|
||||
@@ -1474,7 +1591,9 @@ export class ImageDecryptService {
|
||||
if (!lower) return ''
|
||||
const file = lower.split('/').pop()?.split('\\').pop() || lower
|
||||
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 {
|
||||
@@ -1490,10 +1609,13 @@ export class ImageDecryptService {
|
||||
return [
|
||||
`${base}.dat`,
|
||||
`${base}_hd.dat`,
|
||||
`${base}_h_M.dat`,
|
||||
`${base}_h.dat`,
|
||||
`${base}_M.dat`,
|
||||
`${base}_b.dat`,
|
||||
`${base}_w.dat`,
|
||||
`${base}_c.dat`,
|
||||
`${base}_t_M.dat`,
|
||||
`${base}_t.dat`,
|
||||
`${base}.thumb.dat`,
|
||||
`${base}_thumb.dat`
|
||||
@@ -1584,7 +1706,7 @@ export class ImageDecryptService {
|
||||
|
||||
private isThumbnailName(fileName: string): boolean {
|
||||
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 {
|
||||
@@ -1592,13 +1714,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private unwrapWxgf(buffer: Buffer): Buffer {
|
||||
if (
|
||||
buffer.length < 20 ||
|
||||
buffer[0] !== 0x77 ||
|
||||
buffer[1] !== 0x78 ||
|
||||
buffer[2] !== 0x67 ||
|
||||
buffer[3] !== 0x66
|
||||
) {
|
||||
if (!this.isWxgfBuffer(buffer)) {
|
||||
return buffer
|
||||
}
|
||||
|
||||
@@ -1619,6 +1735,16 @@ export class ImageDecryptService {
|
||||
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[] {
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
+52
-10
@@ -267,6 +267,7 @@ const isolatedUserData = process.env['WXE_USER_DATA']
|
||||
if (isolatedUserData) app.setPath('userData', isolatedUserData)
|
||||
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
|
||||
null
|
||||
let appShutdownRequested = false
|
||||
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||
const TRAY_MODE =
|
||||
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) => {
|
||||
if (appShutdownRequested) {
|
||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||
}
|
||||
if (dbInitInFlight) return dbInitInFlight
|
||||
|
||||
dbInitInFlight = (async () => {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
if (wcdbBootstrapPromise) await wcdbBootstrapPromise
|
||||
if (appShutdownRequested) {
|
||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||
}
|
||||
const trimmedKey = String(key || '').trim()
|
||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||
const settings = loadSettings()
|
||||
@@ -529,6 +536,10 @@ app.whenReady().then(async () => {
|
||||
return { success: true, monitoring: true }
|
||||
}
|
||||
const nextWechatDb = await WechatDb.create(key, selectedRoot)
|
||||
if (appShutdownRequested) {
|
||||
await nextWechatDb.closeAsync()
|
||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||
}
|
||||
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||
if (resolvedRoot) {
|
||||
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
||||
@@ -538,7 +549,9 @@ app.whenReady().then(async () => {
|
||||
imageKeyRoot: resolvedRoot
|
||||
})
|
||||
}
|
||||
chat.setChatDb(nextWechatDb)
|
||||
if (!chat.setChatDb(nextWechatDb)) {
|
||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||
}
|
||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
|
||||
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||
@@ -1342,15 +1355,44 @@ app.on('window-all-closed', () => {
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
agentHubService.stop()
|
||||
flushBootstrapCacheWritesSync()
|
||||
chat.setChatDb(null)
|
||||
await apiServer.stop().catch(() => undefined)
|
||||
if (tray) {
|
||||
tray.destroy()
|
||||
tray = null
|
||||
}
|
||||
let quitCleanupStarted = false
|
||||
let quitCleanupComplete = false
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
if (quitCleanupComplete) return
|
||||
appShutdownRequested = true
|
||||
event.preventDefault()
|
||||
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 {
|
||||
|
||||
@@ -590,10 +590,10 @@ function parseQuoteMessage(content: string): {
|
||||
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
||||
|
||||
const referMsgXml = content.substring(referMsgStart, referMsgEnd + '</refermsg>'.length)
|
||||
const sender =
|
||||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'displayname')) ||
|
||||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'fromusr')) ||
|
||||
undefined
|
||||
const displayName = sanitizeQuotedContent(extractXmlValue(referMsgXml, 'displayname'))
|
||||
const chatUser = sanitizeQuotedSenderId(extractXmlValue(referMsgXml, 'chatusr'))
|
||||
const fromUser = sanitizeQuotedSenderId(extractXmlValue(referMsgXml, 'fromusr'))
|
||||
const sender = displayName || chatUser || fromUser || undefined
|
||||
const referContent = extractXmlValue(referMsgXml, 'content')
|
||||
const referType = extractXmlValue(referMsgXml, 'type')
|
||||
|
||||
@@ -653,6 +653,10 @@ function sanitizeQuotedContent(content: string): string {
|
||||
return decoded
|
||||
}
|
||||
|
||||
function sanitizeQuotedSenderId(value: string): string {
|
||||
return decodeXmlEntities(String(value || '')).trim()
|
||||
}
|
||||
|
||||
function stripChatroomPrefix(content: string): string {
|
||||
return String(content || '')
|
||||
.replace(/^[0-9a-z_-]+@chatroom:\s*/i, '')
|
||||
|
||||
@@ -55,7 +55,8 @@ export interface FormattedMessage {
|
||||
voiceDataUrl?: string
|
||||
voiceDuration?: number
|
||||
exportMediaUrl?: string
|
||||
exportMediaType?: 'image' | 'video' | 'sticker'
|
||||
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
|
||||
exportMediaName?: string
|
||||
exportShowAvatar?: boolean
|
||||
exportMediaError?: string
|
||||
exportAvatarUrl?: string
|
||||
@@ -109,10 +110,24 @@ function normalizeMsgType(value: string | number | undefined): number {
|
||||
}
|
||||
|
||||
let dbRef: WechatDb | null = null
|
||||
let shutdownRequested = false
|
||||
|
||||
export function setChatDb(db: WechatDb | null): void {
|
||||
export function setChatDb(db: WechatDb | null): boolean {
|
||||
if (shutdownRequested) {
|
||||
db?.close()
|
||||
return false
|
||||
}
|
||||
dbRef?.close()
|
||||
dbRef = db
|
||||
return true
|
||||
}
|
||||
|
||||
export async function closeChatDbForQuit(): Promise<boolean> {
|
||||
shutdownRequested = true
|
||||
const current = dbRef
|
||||
dbRef = null
|
||||
if (!current) return true
|
||||
return current.closeAsync()
|
||||
}
|
||||
|
||||
export function getChatDb(): WechatDb | null {
|
||||
@@ -639,8 +654,7 @@ export function reopenWithRoot(accountRoot: string): boolean {
|
||||
if (!key) return false
|
||||
try {
|
||||
const next = new WechatDb(key, accountRoot)
|
||||
setChatDb(next)
|
||||
return true
|
||||
return setChatDb(next)
|
||||
} catch (error) {
|
||||
console.error('[ChatService] reopen with root failed:', error)
|
||||
return false
|
||||
|
||||
@@ -3,13 +3,18 @@ import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import type {
|
||||
ImageDecoderStatus,
|
||||
ImageDecryptionStatus,
|
||||
ImageDecryptionTestResult,
|
||||
ImageKeyConfigResult,
|
||||
ImageResourceCheck,
|
||||
TestImageDecryptionRequest
|
||||
} from '../../shared/image-decryption'
|
||||
import { ImageDecryptService, inspectImageDecoderStatus } from '../image-decrypt-service'
|
||||
import {
|
||||
ImageDecryptService,
|
||||
inspectImageDecoderStatus,
|
||||
type ImageDecodeDiagnostic
|
||||
} from '../image-decrypt-service'
|
||||
import * as chat from './chat-service'
|
||||
import { validateImageKeyRequest } from './image-key-config-service'
|
||||
import { isWechatRunning } from './wechat-process-status'
|
||||
@@ -57,13 +62,35 @@ export async function inspectImageDecryptionStatus(
|
||||
}
|
||||
}
|
||||
|
||||
export function testImageDecryption(
|
||||
export async function testImageDecryption(
|
||||
request: TestImageDecryptionRequest
|
||||
): ImageDecryptionTestResult {
|
||||
): Promise<ImageDecryptionTestResult> {
|
||||
const startedAt = Date.now()
|
||||
let testedImage:
|
||||
| { md5?: string; datName?: string; sessionId?: string; selection: string }
|
||||
| undefined
|
||||
let filePath: string | undefined
|
||||
let decodeDiagnostic: ImageDecodeDiagnostic | undefined
|
||||
let decoder: ImageDecoderStatus | undefined
|
||||
const finish = (
|
||||
result: Omit<ImageDecryptionTestResult, 'diagnosticLog'>
|
||||
): ImageDecryptionTestResult => ({
|
||||
...result,
|
||||
diagnosticLog: buildImageTestDiagnosticLog({
|
||||
request,
|
||||
result,
|
||||
startedAt,
|
||||
testedImage,
|
||||
filePath,
|
||||
decodeDiagnostic,
|
||||
decoder
|
||||
})
|
||||
})
|
||||
|
||||
const normalized = validateImageKeyRequest(request)
|
||||
if (!normalized.success) return failure('NOT_CONFIGURED', normalized.error)
|
||||
if (!normalized.success) return finish(failure('NOT_CONFIGURED', normalized.error))
|
||||
if (!chat.isReady() || !request.userMd5) {
|
||||
return failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录')
|
||||
return finish(failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录'))
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -72,9 +99,11 @@ export function testImageDecryption(
|
||||
.reverse()
|
||||
.find((message) => message.contentData?.type === 'image')
|
||||
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
|
||||
return failure(
|
||||
'NO_IMAGE_MESSAGE',
|
||||
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
|
||||
return finish(
|
||||
failure(
|
||||
'NO_IMAGE_MESSAGE',
|
||||
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,58 +113,324 @@ export function testImageDecryption(
|
||||
chat.getChatDb()?.getWcdb4Client()
|
||||
)
|
||||
const image = imageMessage.contentData
|
||||
// 测试时优先使用用户在下方"图片资源目录"输入框填写的目录;
|
||||
// 找不到再退回默认 accountDir。
|
||||
testedImage = {
|
||||
md5: image.md5,
|
||||
datName: image.datName,
|
||||
sessionId: imageMessage.sessionId,
|
||||
selection: '所选会话最近 300 条消息中的最后一张图片'
|
||||
}
|
||||
const testAccountDir = normalized.resourceRoot || undefined
|
||||
let filePath = service.findImageFile(image.md5, image.datName, {
|
||||
allowThumbnail: false,
|
||||
accountDir: testAccountDir
|
||||
})
|
||||
if (!filePath)
|
||||
filePath = service.findImageFile(image.md5, image.datName, {
|
||||
allowThumbnail: true,
|
||||
accountDir: testAccountDir
|
||||
})
|
||||
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
|
||||
filePath =
|
||||
(await service.findImageFileAsync(image.md5, image.datName, {
|
||||
allowThumbnail: false,
|
||||
accountDir: testAccountDir,
|
||||
sessionId: imageMessage.sessionId
|
||||
})) || undefined
|
||||
if (!filePath) {
|
||||
filePath =
|
||||
(await service.findImageFileAsync(image.md5, image.datName, {
|
||||
allowThumbnail: true,
|
||||
accountDir: testAccountDir,
|
||||
sessionId: imageMessage.sessionId
|
||||
})) || undefined
|
||||
}
|
||||
if (!filePath) return finish(failure('FILE_NOT_FOUND', '图片文件不存在'))
|
||||
|
||||
const data = service.decryptImageToBase64(filePath)
|
||||
if (!data) {
|
||||
// 三步联动:解密失败 → fileFound/decrypted/readable 都为 false。
|
||||
return {
|
||||
let decoded = await service.decryptImageToBase64WithFallbackAsync(filePath, true)
|
||||
if (!decoded) {
|
||||
// Worker 失败后在主进程做一次同步诊断:既能保留具体失败阶段,
|
||||
// 也能在少数 Worker 启动异常时继续测试普通图片。
|
||||
decoded = service.decryptImageToBase64WithFallback(filePath, true)
|
||||
}
|
||||
|
||||
if (!decoded) {
|
||||
decodeDiagnostic = service.getLastDecodeDiagnostic()
|
||||
if (decodeDiagnostic.code === 'WXGF_REQUIRES_DECODER') {
|
||||
decoder = await inspectImageDecoderStatus()
|
||||
}
|
||||
return finish({
|
||||
success: false,
|
||||
code: 'DECRYPT_FAILED',
|
||||
error: '无法解析媒体文件',
|
||||
fileFound: false,
|
||||
decrypted: false,
|
||||
readable: false
|
||||
}
|
||||
error: getDecodeFailureMessage(decodeDiagnostic, decoder),
|
||||
fileFound: true,
|
||||
decrypted: isDecryptedDiagnostic(decodeDiagnostic.code),
|
||||
readable: false,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
})
|
||||
}
|
||||
const readable = data.startsWith('data:image/')
|
||||
|
||||
filePath = decoded.filePath
|
||||
decodeDiagnostic = buildSuccessDiagnostic(decoded.data, decoded.filePath)
|
||||
const readable = decoded.data.startsWith('data:image/')
|
||||
if (!readable) {
|
||||
// 三步联动:解密成功但字节流不可读 → 前一步打勾(确实找到了 dat),
|
||||
// 但 decrypted/readable 全为 false,让 UI 表达"找到但解析失败"。
|
||||
return {
|
||||
return finish({
|
||||
success: false,
|
||||
code: 'DECRYPT_FAILED',
|
||||
error: '图片解密结果不可读取',
|
||||
fileFound: true,
|
||||
decrypted: false,
|
||||
decrypted: true,
|
||||
readable: false,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
}
|
||||
isThumbnail: service.isThumbnailFile(decoded.filePath)
|
||||
})
|
||||
}
|
||||
return {
|
||||
return finish({
|
||||
success: true,
|
||||
fileFound: true,
|
||||
decrypted: true,
|
||||
readable: true,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
}
|
||||
isThumbnail: service.isThumbnailFile(decoded.filePath)
|
||||
})
|
||||
} catch {
|
||||
return failure('UNKNOWN', '图片解析测试未通过')
|
||||
return finish(failure('UNKNOWN', '图片解析测试未通过'))
|
||||
}
|
||||
}
|
||||
|
||||
function buildSuccessDiagnostic(data: string, filePath: string): ImageDecodeDiagnostic {
|
||||
const format = /^data:image\/([^;]+);/i.exec(data)?.[1]?.toUpperCase()
|
||||
const directImageFormat = inspectDirectImageFormat(filePath)
|
||||
return {
|
||||
code: directImageFormat ? 'DIRECT_IMAGE' : 'SUCCESS',
|
||||
detail: directImageFormat ? 'DAT 文件内容是可直接读取的图片' : '图片解密并识别成功',
|
||||
datVersion: directImageFormat ? undefined : inspectDatVersion(filePath),
|
||||
fileSize: safeFileSize(filePath),
|
||||
imageFormat: format || directImageFormat
|
||||
}
|
||||
}
|
||||
|
||||
function inspectDirectImageFormat(filePath: string): string | undefined {
|
||||
try {
|
||||
const signature = fs.readFileSync(filePath).subarray(0, 12)
|
||||
if (signature[0] === 0xff && signature[1] === 0xd8 && signature[2] === 0xff) return 'JPEG'
|
||||
if (
|
||||
signature[0] === 0x89 &&
|
||||
signature[1] === 0x50 &&
|
||||
signature[2] === 0x4e &&
|
||||
signature[3] === 0x47
|
||||
)
|
||||
return 'PNG'
|
||||
if (
|
||||
signature[0] === 0x47 &&
|
||||
signature[1] === 0x49 &&
|
||||
signature[2] === 0x46 &&
|
||||
signature[3] === 0x38
|
||||
)
|
||||
return 'GIF'
|
||||
if (signature[0] === 0x42 && signature[1] === 0x4d) return 'BMP'
|
||||
if (signature.subarray(0, 4).toString('ascii') === 'RIFF') return 'WEBP'
|
||||
return undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function inspectDatVersion(filePath: string): number | undefined {
|
||||
if (!path.extname(filePath).toLowerCase().includes('dat')) return undefined
|
||||
try {
|
||||
const signature = fs.readFileSync(filePath).subarray(0, 6)
|
||||
return signature.equals(Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])) ? 2 : 0
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function safeFileSize(filePath: string): number | undefined {
|
||||
try {
|
||||
return fs.statSync(filePath).size
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isDecryptedDiagnostic(code: ImageDecodeDiagnostic['code']): boolean {
|
||||
return code === 'WXGF_REQUIRES_DECODER' || code === 'UNKNOWN_IMAGE_FORMAT'
|
||||
}
|
||||
|
||||
function getDecodeFailureMessage(
|
||||
diagnostic: ImageDecodeDiagnostic,
|
||||
decoder?: ImageDecoderStatus
|
||||
): string {
|
||||
switch (diagnostic.code) {
|
||||
case 'UNSUPPORTED_DAT_VERSION':
|
||||
return '仅支持 WeChat 4.0 图片协议,当前图片格式不受支持'
|
||||
case 'MISSING_AES_KEY':
|
||||
return '图片密钥未配置'
|
||||
case 'AES_DECRYPT_FAILED':
|
||||
return '图片密钥与当前账号不匹配,或图片文件已损坏'
|
||||
case 'INVALID_DAT_FILE':
|
||||
return '图片文件不完整或格式异常'
|
||||
case 'WXGF_REQUIRES_DECODER':
|
||||
return decoder?.available
|
||||
? 'WXGF/HEVC 图片转换失败,请复制测试日志反馈'
|
||||
: '该图片需要 FFmpeg 的 HEVC 解码能力'
|
||||
case 'UNKNOWN_IMAGE_FORMAT':
|
||||
return '图片已解密,但当前格式无法识别'
|
||||
default:
|
||||
return '无法解析媒体文件'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildImageTestDiagnosticLog(input: {
|
||||
request: TestImageDecryptionRequest
|
||||
result: Omit<ImageDecryptionTestResult, 'diagnosticLog'>
|
||||
startedAt: number
|
||||
testedImage?: { md5?: string; datName?: string; sessionId?: string; selection: string }
|
||||
filePath?: string
|
||||
decodeDiagnostic?: ImageDecodeDiagnostic
|
||||
decoder?: ImageDecoderStatus
|
||||
}): string {
|
||||
const root = String(input.request.resourceRoot || '').trim()
|
||||
const rootExists = root ? fs.existsSync(root) : false
|
||||
const rootIsDirectory = rootExists ? safeIsDirectory(root) : false
|
||||
const resultCode = input.result.success ? 'SUCCESS' : input.result.code || 'UNKNOWN'
|
||||
return [
|
||||
'WechatExplorer 图片解析测试日志(已脱敏)',
|
||||
`时间:${new Date().toISOString()}`,
|
||||
`应用版本:${safeAppVersion()}`,
|
||||
`运行环境:${process.platform} ${process.arch}`,
|
||||
`测试结果:${input.result.success ? '成功' : '失败'}(${resultCode})`,
|
||||
`耗时:${Date.now() - input.startedAt} ms`,
|
||||
'',
|
||||
'[配置]',
|
||||
`资源目录:${root ? `已填写(末级 ${redactIdentifier(path.basename(root))})` : '未填写'}`,
|
||||
`目录存在:${yesNo(rootExists)}`,
|
||||
`目录可读取:${yesNo(rootIsDirectory)}`,
|
||||
`包含图片目录:${yesNo(rootIsDirectory && hasImageDirectory(root))}`,
|
||||
`AES 密钥:${input.request.aesKey.trim().length === 16 ? '已配置(长度有效,内容未记录)' : '未配置或长度无效'}`,
|
||||
`XOR Key:${/^0x[0-9a-f]{2}$/i.test(input.request.xorKey.trim()) ? '格式有效(内容未记录)' : '格式无效'}`,
|
||||
'',
|
||||
'[测试样本]',
|
||||
`选取方式:${input.testedImage?.selection || '未选取'}`,
|
||||
`会话定位信息:${input.testedImage?.sessionId ? '有' : '无'}`,
|
||||
`图片 MD5:${redactIdentifier(input.testedImage?.md5)}`,
|
||||
`DAT 文件名:${redactFileName(input.testedImage?.datName)}`,
|
||||
'',
|
||||
'[文件查找]',
|
||||
'查找方式:异步会话目录 + Hardlink 索引',
|
||||
`找到文件:${yesNo(input.result.fileFound)}`,
|
||||
`文件来源:${input.filePath ? describeFileSource(input.filePath) : '无'}`,
|
||||
`清晰度:${input.filePath ? (input.result.isThumbnail ? '缩略图' : '原图/高清变体') : '未知'}`,
|
||||
`文件大小:${formatBytes(input.decodeDiagnostic?.fileSize ?? (input.filePath ? safeFileSize(input.filePath) : undefined))}`,
|
||||
`DAT 协议:${formatDatProtocol(input.decodeDiagnostic, input.filePath)}`,
|
||||
'',
|
||||
'[解析结果]',
|
||||
`文件找到:${yesNo(input.result.fileFound)}`,
|
||||
`数据解密:${yesNo(input.result.decrypted)}`,
|
||||
`图片可读:${yesNo(input.result.readable)}`,
|
||||
`诊断代码:${input.decodeDiagnostic?.code || resultCode}`,
|
||||
`诊断说明:${input.decodeDiagnostic?.detail || input.result.error || '无'}`,
|
||||
`图片格式:${input.decodeDiagnostic?.imageFormat || '未识别'}`,
|
||||
`WXGF/HEVC:${input.decodeDiagnostic?.wxgf ? '是' : '否/未检测'}`,
|
||||
`FFmpeg:${formatDecoder(input.decoder)}`,
|
||||
'',
|
||||
`建议:${buildDiagnosticAdvice(resultCode, input.decodeDiagnostic, input.decoder)}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function safeAppVersion(): string {
|
||||
try {
|
||||
return app.getVersion()
|
||||
} catch {
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
function safeIsDirectory(value: string): boolean {
|
||||
try {
|
||||
return fs.statSync(value).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function redactIdentifier(value?: string): string {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return '无'
|
||||
if (normalized.length <= 8) return `${normalized.slice(0, 2)}***`
|
||||
return `${normalized.slice(0, 4)}…${normalized.slice(-4)}`
|
||||
}
|
||||
|
||||
function redactFileName(value?: string): string {
|
||||
const normalized = path.basename(String(value || '').trim())
|
||||
if (!normalized) return '无'
|
||||
const extension = path.extname(normalized)
|
||||
const stem = extension ? normalized.slice(0, -extension.length) : normalized
|
||||
return `${redactIdentifier(stem)}${extension.toLowerCase()}`
|
||||
}
|
||||
|
||||
function describeFileSource(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/').toLowerCase()
|
||||
if (normalized.includes('/msg/attach/')) return 'msg/attach'
|
||||
if (normalized.includes('/cache/')) return 'cache'
|
||||
if (normalized.includes('/filestorage/')) return 'FileStorage'
|
||||
return '其他本地目录(完整路径未记录)'
|
||||
}
|
||||
|
||||
function yesNo(value: boolean): string {
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
|
||||
function formatBytes(value?: number): string {
|
||||
if (!Number.isFinite(value)) return '未知'
|
||||
if ((value as number) < 1024) return `${value} B`
|
||||
return `${((value as number) / 1024).toFixed(1)} KiB`
|
||||
}
|
||||
|
||||
function formatDatVersion(value?: number): string {
|
||||
if (value === 2) return 'WeChat 4.0 V2'
|
||||
if (value === 0) return '不受支持/旧版格式'
|
||||
return '未检测'
|
||||
}
|
||||
|
||||
function formatDatProtocol(diagnostic?: ImageDecodeDiagnostic, filePath?: string): string {
|
||||
if (diagnostic?.code === 'DIRECT_IMAGE') return '明文图片(无需 DAT 解密)'
|
||||
return formatDatVersion(
|
||||
diagnostic?.datVersion ?? (filePath ? inspectDatVersion(filePath) : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function formatDecoder(decoder?: ImageDecoderStatus): string {
|
||||
if (!decoder) return '未检测(当前失败阶段不需要)'
|
||||
if (!decoder.installed) return '未安装'
|
||||
return decoder.available
|
||||
? `可用(${decoder.source},支持 HEVC)`
|
||||
: `已安装但不支持 HEVC(${decoder.source})`
|
||||
}
|
||||
|
||||
function buildDiagnosticAdvice(
|
||||
resultCode: string,
|
||||
diagnostic?: ImageDecodeDiagnostic,
|
||||
decoder?: ImageDecoderStatus
|
||||
): string {
|
||||
if (resultCode === 'SUCCESS') return '图片解析正常,无需处理。'
|
||||
if (resultCode === 'FILE_NOT_FOUND') {
|
||||
return '确认图片资源目录属于当前微信账号,并在最近发送过图片的会话中重新测试。'
|
||||
}
|
||||
switch (diagnostic?.code) {
|
||||
case 'UNSUPPORTED_DAT_VERSION':
|
||||
return '换一张由微信 4.0 接收或发送的近期图片测试。'
|
||||
case 'AES_DECRYPT_FAILED':
|
||||
case 'MISSING_AES_KEY':
|
||||
return '重新获取当前微信账号的图片密钥后再测试。'
|
||||
case 'INVALID_DAT_FILE':
|
||||
return '在微信中重新打开或下载该图片,再重新测试。'
|
||||
case 'WXGF_REQUIRES_DECODER':
|
||||
return decoder?.available
|
||||
? 'FFmpeg 已可用但转换失败,请将本日志发给开发者。'
|
||||
: '安装或重新选择支持 HEVC 的 FFmpeg 后再测试。'
|
||||
case 'UNKNOWN_IMAGE_FORMAT':
|
||||
return '请将本日志发给开发者,并换一张近期普通图片交叉测试。'
|
||||
default:
|
||||
return inputAdviceForCode(resultCode)
|
||||
}
|
||||
}
|
||||
|
||||
function inputAdviceForCode(resultCode: string): string {
|
||||
if (resultCode === 'NO_CONVERSATION') return '先连接微信账号并选择一条聊天记录。'
|
||||
if (resultCode === 'NO_IMAGE_MESSAGE') return '换一个最近 300 条消息内包含图片的会话。'
|
||||
if (resultCode === 'NOT_CONFIGURED') return '检查资源目录、AES 密钥和 XOR Key 格式。'
|
||||
return '请将本日志发给开发者进一步排查。'
|
||||
}
|
||||
|
||||
function hasImageDirectory(accountRoot: string): boolean {
|
||||
if (!accountRoot) return false
|
||||
return [
|
||||
|
||||
+95
-18
@@ -258,6 +258,8 @@ export class Wcdb4Client {
|
||||
private sessionStatusesInFlight: Promise<void> | null = null
|
||||
private sessionStatusesUpdatedAt = 0
|
||||
private sessionCacheGeneration = 0
|
||||
private closing = false
|
||||
private nativeCallsInFlight = new Set<Promise<unknown>>()
|
||||
|
||||
private wcdbShutdown: (() => number) | null = null
|
||||
private wcdbOpenAccount:
|
||||
@@ -535,17 +537,12 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
const handleOut: WcdbHandleOut = [0]
|
||||
const openResult = await new Promise<number>((resolve, reject) => {
|
||||
this.wcdbOpenAccountAsync!.async(
|
||||
this.sessionDbPath,
|
||||
this.key,
|
||||
handleOut,
|
||||
(error: unknown, result: unknown) => {
|
||||
if (error) reject(error)
|
||||
else resolve(Number(result))
|
||||
}
|
||||
)
|
||||
})
|
||||
const openResult = await this.callAsyncCode(
|
||||
this.wcdbOpenAccountAsync,
|
||||
this.sessionDbPath,
|
||||
this.key,
|
||||
handleOut
|
||||
)
|
||||
if (openResult !== 0 || handleOut[0] <= 0) {
|
||||
throw new Error(
|
||||
`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 {
|
||||
this.closing = true
|
||||
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 {
|
||||
this.wcdbShutdown()
|
||||
} catch {
|
||||
// Shutdown is best-effort on app close.
|
||||
// Shutdown is best-effort after all tracked native calls have completed.
|
||||
}
|
||||
}
|
||||
|
||||
this.handle = null
|
||||
this.cachedSessions = null
|
||||
this.cachedChatTables = null
|
||||
this.sessionsInFlight = null
|
||||
this.sessionDisplayNamesInFlight = null
|
||||
this.sessionDisplayNamesHydrated = false
|
||||
this.sessionStatusesInFlight = null
|
||||
this.sessionStatusesUpdatedAt = 0
|
||||
@@ -588,8 +615,32 @@ export class Wcdb4Client {
|
||||
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> {
|
||||
if (!this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) return false
|
||||
if (this.closing || !this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.stopMonitor()
|
||||
this.monitorCallback = callback
|
||||
@@ -604,6 +655,10 @@ export class Wcdb4Client {
|
||||
return false
|
||||
}
|
||||
this.monitorStarted = true
|
||||
if (this.closing) {
|
||||
this.stopMonitor()
|
||||
return false
|
||||
}
|
||||
|
||||
const outName: WcdbVoidOut = [null]
|
||||
const nameResult = await this.callAsyncCode(
|
||||
@@ -637,6 +692,11 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
stopMonitor(): void {
|
||||
this.stopMonitorTransport()
|
||||
this.stopNativeMonitor()
|
||||
}
|
||||
|
||||
private stopMonitorTransport(): void {
|
||||
this.monitorCallback = null
|
||||
|
||||
if (this.monitorConnectTimer) {
|
||||
@@ -651,6 +711,9 @@ export class Wcdb4Client {
|
||||
this.monitorPipeClient.destroy()
|
||||
this.monitorPipeClient = null
|
||||
}
|
||||
}
|
||||
|
||||
private stopNativeMonitor(): void {
|
||||
if (this.monitorStarted && this.wcdbStopMonitorPipe) {
|
||||
try {
|
||||
this.wcdbStopMonitorPipe()
|
||||
@@ -2218,7 +2281,7 @@ export class Wcdb4Client {
|
||||
private callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> {
|
||||
const handle = this.ensureHandle()
|
||||
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) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
@@ -2242,7 +2305,7 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (error) reject(error)
|
||||
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 {
|
||||
if (this.closing) throw new Error('微信数据库正在关闭')
|
||||
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
|
||||
return this.handle
|
||||
}
|
||||
|
||||
@@ -156,6 +156,10 @@ export class WechatDb {
|
||||
this.wcdb4Client.close()
|
||||
}
|
||||
|
||||
public closeAsync(timeoutMs?: number): Promise<boolean> {
|
||||
return this.wcdb4Client.closeAsync(timeoutMs)
|
||||
}
|
||||
|
||||
public getUserMessages(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
|
||||
+29
-76
@@ -30,6 +30,7 @@ import {
|
||||
mergeMessagePages,
|
||||
sortMessagesChronologically
|
||||
} from './utils/message-pages'
|
||||
import { enrichQuotedMessages } from './utils/quoted-messages'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
@@ -70,67 +71,6 @@ const INITIAL_MESSAGE_COUNT = 20
|
||||
const MESSAGE_PAGE_SIZE = 100
|
||||
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
||||
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 => {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
@@ -140,6 +80,16 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
||||
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 = {
|
||||
roomId: string
|
||||
memberCount: number
|
||||
@@ -501,11 +451,12 @@ function App(): React.ReactElement {
|
||||
const loadContacts = async (options?: {
|
||||
waitForAvatars?: boolean
|
||||
onProgress?: (message: string, percent?: number) => void
|
||||
filterKeyword?: string
|
||||
}): Promise<void> => {
|
||||
options?.onProgress?.('正在加载联系人...', 35)
|
||||
const list = await window.api.getContacts()
|
||||
setContacts(list)
|
||||
setFilteredContacts(list)
|
||||
setFilteredContacts(filterContactList(list, options?.filterKeyword || ''))
|
||||
const runId = ++contactAvatarHydrationRunRef.current
|
||||
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
|
||||
if (options?.waitForAvatars) {
|
||||
@@ -886,11 +837,12 @@ function App(): React.ReactElement {
|
||||
const applyGroupMemberMeta = React.useCallback(
|
||||
(contact: Contact | null, baseMessages: Message[]): Message[] => {
|
||||
if (!contact || contact.type !== 'group') return baseMessages
|
||||
const enrichedMessages = enrichQuotedMessages(baseMessages, [
|
||||
...messageHistoryRef.current,
|
||||
...baseMessages
|
||||
])
|
||||
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
|
||||
|
||||
return enrichedMessages.map((message) => {
|
||||
@@ -1347,16 +1299,16 @@ function App(): React.ReactElement {
|
||||
])
|
||||
|
||||
const handleSearchContacts = (keyword: string): void => {
|
||||
if (!keyword) {
|
||||
setFilteredContacts(contacts)
|
||||
} else {
|
||||
const lower = keyword.toLowerCase()
|
||||
const filtered = contacts.filter(
|
||||
(c) =>
|
||||
c.m_nsNickName.toLowerCase().includes(lower) ||
|
||||
c.m_nsUsrName.toLowerCase().includes(lower)
|
||||
)
|
||||
setFilteredContacts(filtered)
|
||||
setFilteredContacts(filterContactList(contacts, keyword))
|
||||
}
|
||||
|
||||
const handleRefreshContacts = async (filterKeyword: string): Promise<void> => {
|
||||
try {
|
||||
await loadContacts({ waitForAvatars: false, filterKeyword })
|
||||
setReportNotice('会话列表已刷新')
|
||||
} catch (error) {
|
||||
console.warn('[Contacts] refresh failed:', error)
|
||||
setReportNotice('会话列表刷新失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1588,6 +1540,7 @@ function App(): React.ReactElement {
|
||||
dbReady={isDatabaseConnected}
|
||||
dbConnecting={isDatabaseConnecting}
|
||||
onOpenSettings={openSettings}
|
||||
onRefresh={handleRefreshContacts}
|
||||
/>
|
||||
<div className="resizer" onMouseDown={startResizing} />
|
||||
<ChatWindow
|
||||
|
||||
@@ -359,12 +359,13 @@ function QuoteBubble({
|
||||
const quotedText = data.quotedContent || data.content || '[引用消息]'
|
||||
const replyText = data.content || data.title || ''
|
||||
const quotedSender = data.quotedSender || data.sender || ''
|
||||
const hasQuotedImage = Boolean(data.quotedImageMd5 || data.quotedImageDatName)
|
||||
|
||||
return (
|
||||
<div className="quote-message">
|
||||
<div className="quoted-message">
|
||||
<div className={`quoted-message${hasQuotedImage ? ' quoted-message-image' : ''}`}>
|
||||
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
|
||||
{data.quotedImageMd5 || data.quotedImageDatName ? (
|
||||
{hasQuotedImage ? (
|
||||
<ImageBubble
|
||||
imageMd5={data.quotedImageMd5}
|
||||
imageDatName={data.quotedImageDatName}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ConversationSidebarProps {
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
onOpenSettings: () => void
|
||||
onRefresh: (filterKeyword: string) => Promise<void>
|
||||
}
|
||||
|
||||
type SectionName = 'groups' | 'folded' | 'contacts'
|
||||
@@ -39,9 +40,11 @@ export function ConversationSidebar({
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
onOpenSettings
|
||||
onOpenSettings,
|
||||
onRefresh
|
||||
}: ConversationSidebarProps): React.ReactElement {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
||||
groups: true,
|
||||
folded: false,
|
||||
@@ -119,12 +122,24 @@ export function ConversationSidebar({
|
||||
onSearch(term)
|
||||
}
|
||||
|
||||
const handleRefresh = async (): Promise<void> => {
|
||||
if (isRefreshing) return
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await onRefresh(searchTerm)
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="conversation-sidebar" style={{ width }}>
|
||||
<ConversationSidebarHeader
|
||||
totalCount={contacts.length}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={() => void handleRefresh()}
|
||||
/>
|
||||
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
||||
<div
|
||||
|
||||
@@ -5,18 +5,36 @@ interface ConversationSidebarHeaderProps {
|
||||
totalCount: number
|
||||
searchValue: string
|
||||
onSearchChange: (value: string) => void
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function ConversationSidebarHeader({
|
||||
totalCount,
|
||||
searchValue,
|
||||
onSearchChange
|
||||
onSearchChange,
|
||||
refreshing,
|
||||
onRefresh
|
||||
}: ConversationSidebarHeaderProps): React.ReactElement {
|
||||
return (
|
||||
<div className="conversation-sidebar-header">
|
||||
<div className="conversation-sidebar-title-row">
|
||||
<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>
|
||||
<ConversationSearch value={searchValue} onChange={onSearchChange} />
|
||||
</div>
|
||||
|
||||
@@ -66,8 +66,10 @@ export function ExportWorkspace({
|
||||
const taskCount = exportTasks.filter((task) => task.status === 'running').length
|
||||
const activeName = displayName(activeContact)
|
||||
const preview = previewMessages.slice(-20)
|
||||
const previewMediaCount = preview.filter((message) =>
|
||||
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '')
|
||||
const previewMediaCount = preview.filter(
|
||||
(message) =>
|
||||
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') ||
|
||||
(message.contentData?.type === 'share' && message.contentData.typeVal === '6')
|
||||
).length
|
||||
const previewBytes = preview.reduce(
|
||||
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
|
||||
@@ -439,7 +441,7 @@ export function ExportWorkspace({
|
||||
<section className="export-section">
|
||||
<h3>资源处理</h3>
|
||||
<label className="export-media-master">
|
||||
<span>包含图片、视频、语音及动态表情</span>
|
||||
<span>包含图片、视频、语音、表情及文件附件</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeMedia}
|
||||
@@ -486,6 +488,7 @@ export function ExportWorkspace({
|
||||
<span>视频资源:可用</span>
|
||||
<span>语音资源:可用</span>
|
||||
<span>表情资源:按需解析</span>
|
||||
<span>文件附件:按需复制</span>
|
||||
</div>
|
||||
<p className="export-helper-text">媒体资源会延长导出时间,缺失资源不会中断任务。</p>
|
||||
<label className="export-media-master">
|
||||
|
||||
@@ -7,6 +7,7 @@ export const messageKinds = [
|
||||
['video', '视频'],
|
||||
['voice', '语音'],
|
||||
['sticker', '表情包'],
|
||||
['file', '文件'],
|
||||
['share', '链接与分享'],
|
||||
['location', '位置'],
|
||||
['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 ContactFilter = 'all' | 'group' | 'user'
|
||||
|
||||
function stepClass(step: StepState): string {
|
||||
switch (step) {
|
||||
@@ -28,22 +31,16 @@ function stepIcon(step: StepState): string {
|
||||
}
|
||||
}
|
||||
|
||||
function pickSteps(result: {
|
||||
fileFound: boolean
|
||||
decrypted: boolean
|
||||
readable: boolean
|
||||
success: boolean
|
||||
}): { find: StepState; decrypt: StepState; read: StepState } {
|
||||
function pickSteps(result: { fileFound: boolean; decrypted: boolean; readable: boolean }): {
|
||||
find: StepState
|
||||
decrypt: StepState
|
||||
read: StepState
|
||||
} {
|
||||
// 三步严格联动:找到失败 → 解密/读取 skipped;解密失败 → 读取 skipped;
|
||||
// 解密成功但不可读 → 读取 fail。
|
||||
if (!result.fileFound) {
|
||||
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
|
||||
}
|
||||
if (!result.success && !result.decrypted && !result.readable) {
|
||||
// 后端把 fileFound=false 的情况也用 success:false 表达;
|
||||
// 此时第一步直接 fail,后两步 skip。
|
||||
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
|
||||
}
|
||||
if (!result.decrypted) {
|
||||
return { find: 'ok', decrypt: 'fail', read: 'skipped' }
|
||||
}
|
||||
@@ -53,50 +50,228 @@ function pickSteps(result: {
|
||||
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({
|
||||
state,
|
||||
batchTest,
|
||||
disabled,
|
||||
canSave,
|
||||
onSelect,
|
||||
onTest,
|
||||
onBatchTest,
|
||||
onStopBatchTest,
|
||||
onCopyLog,
|
||||
onSave
|
||||
}: {
|
||||
state: ImageDecryptionState
|
||||
batchTest: ImageBatchTestState
|
||||
disabled: boolean
|
||||
canSave: boolean
|
||||
onSelect: (value: string) => void
|
||||
onTest: () => void
|
||||
onBatchTest: (contacts: Contact[]) => void
|
||||
onStopBatchTest: () => void
|
||||
onCopyLog: () => void
|
||||
onSave: () => void
|
||||
}): 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 steps = result
|
||||
? pickSteps({
|
||||
fileFound: result.fileFound,
|
||||
decrypted: result.decrypted,
|
||||
readable: result.readable,
|
||||
success: result.success
|
||||
readable: result.readable
|
||||
})
|
||||
: 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 (
|
||||
<section className="settings-card image-test-section">
|
||||
<div>
|
||||
<strong>图片解析测试</strong>
|
||||
<p>选择一条聊天记录测试图片解密能力。</p>
|
||||
<div className="image-test-heading">
|
||||
<div>
|
||||
<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>
|
||||
<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
|
||||
id="image-test-chat"
|
||||
value={state.selectedUserMd5}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onSelect(event.target.value)}
|
||||
>
|
||||
<option value="">请选择会话</option>
|
||||
{state.contacts.map((contact) => (
|
||||
<option key={contact.md5} value={contact.md5}>
|
||||
{contact.m_nsNickName || contact.m_nsUsrName}
|
||||
</option>
|
||||
))}
|
||||
<option value="">请选择包含图片的会话</option>
|
||||
{filteredGroups.length > 0 && (
|
||||
<optgroup label={`群聊(${filteredGroups.length})`}>
|
||||
{filteredGroups.map((contact) => (
|
||||
<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>
|
||||
{filteredContacts.length === 0 && <p className="image-test-empty">没有匹配的会话</p>}
|
||||
|
||||
<div className="image-test-actions">
|
||||
<button
|
||||
className="database-key-primary"
|
||||
@@ -108,7 +283,28 @@ export function ImageTestSection({
|
||||
<button className="database-key-secondary" disabled={!canSave} onClick={onSave}>
|
||||
确认保存
|
||||
</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>
|
||||
<p className="image-batch-warning">
|
||||
批量测试会逐个读取会话数据,每个会话最多测试一张图片;会话较多时耗时较长,可随时停止。
|
||||
</p>
|
||||
|
||||
{steps ? (
|
||||
<ol className="image-step-list">
|
||||
<li className={stepClass(steps.find)}>
|
||||
@@ -130,6 +326,49 @@ export function ImageTestSection({
|
||||
{result && !result.success && result.error ? (
|
||||
<p className="image-inline-error">{result.error}</p>
|
||||
) : 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,30 @@ export interface ImageDecryptionState {
|
||||
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 =
|
||||
| {
|
||||
type: 'LOADED'
|
||||
@@ -77,12 +101,16 @@ export type ImageDecryptionAction =
|
||||
|
||||
export interface ImageDecryptionController {
|
||||
state: ImageDecryptionState
|
||||
batchTest: ImageBatchTestState
|
||||
pageStatus: 'configured' | 'unconfigured' | 'partial'
|
||||
busy: boolean
|
||||
canSave: boolean
|
||||
edit: (field: 'xorKey' | 'aesKey', value: string) => void
|
||||
selectChat: (userMd5: string) => void
|
||||
test: () => Promise<void>
|
||||
testMany: (contacts: Contact[]) => Promise<void>
|
||||
stopBatchTest: () => void
|
||||
copyDiagnostics: () => Promise<void>
|
||||
save: () => Promise<void>
|
||||
autoDetect: () => Promise<void>
|
||||
clear: () => Promise<void>
|
||||
|
||||
+139
-3
@@ -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 { imageDecryptionReducer, initialImageDecryptionState } from './imageDecryptionReducer'
|
||||
import type { ImageDecryptionController } from './types'
|
||||
import type { ImageBatchTestItem, ImageBatchTestState, ImageDecryptionController } from './types'
|
||||
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({
|
||||
selfInfo,
|
||||
onNotice
|
||||
@@ -12,6 +31,9 @@ export function useImageDecryptionController({
|
||||
onNotice: (message: string) => void
|
||||
}): ImageDecryptionController {
|
||||
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> => {
|
||||
try {
|
||||
@@ -55,6 +77,116 @@ export function useImageDecryptionController({
|
||||
})
|
||||
}, [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> => {
|
||||
if (!state.testResult?.success && state.autoPhase !== 'success') return
|
||||
if (state.autoPhase === 'success') dispatch({ type: 'AUTO_SAVE_START' })
|
||||
@@ -125,7 +257,7 @@ export function useImageDecryptionController({
|
||||
onNotice('图片密钥已清除,微信原始数据未受影响')
|
||||
}, [onNotice])
|
||||
|
||||
const busy = ['checking', 'testing', 'clearing'].includes(state.phase)
|
||||
const busy = ['checking', 'testing', 'clearing'].includes(state.phase) || batchTest.running
|
||||
const canSave = Boolean(
|
||||
((state.testResult?.success && state.dirty) || state.autoPhase === 'success') &&
|
||||
state.status?.encryptionAvailable
|
||||
@@ -139,12 +271,16 @@ export function useImageDecryptionController({
|
||||
|
||||
return {
|
||||
state,
|
||||
batchTest,
|
||||
pageStatus,
|
||||
busy,
|
||||
canSave,
|
||||
edit,
|
||||
selectChat,
|
||||
test,
|
||||
testMany,
|
||||
stopBatchTest,
|
||||
copyDiagnostics,
|
||||
save,
|
||||
autoDetect,
|
||||
clear,
|
||||
|
||||
@@ -17,6 +17,16 @@ export function sanitizeImageError(error?: string): string {
|
||||
if (value.includes('unsupported') || value.includes('dat version')) {
|
||||
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('不存在') || value.includes('not found')) return '图片文件不存在'
|
||||
if (value.includes('账号')) return '当前账号不匹配'
|
||||
|
||||
@@ -91,10 +91,14 @@ export function ImageDecryptionPage({
|
||||
<h2 className="settings-section-heading">图片解析测试</h2>
|
||||
<ImageTestSection
|
||||
state={controller.state}
|
||||
batchTest={controller.batchTest}
|
||||
disabled={controller.busy}
|
||||
canSave={controller.canSave}
|
||||
onSelect={controller.selectChat}
|
||||
onTest={() => void controller.test()}
|
||||
onBatchTest={(contacts) => void controller.testMany(contacts)}
|
||||
onStopBatchTest={controller.stopBatchTest}
|
||||
onCopyLog={() => void controller.copyDiagnostics()}
|
||||
onSave={() => void controller.save()}
|
||||
/>
|
||||
|
||||
|
||||
@@ -821,6 +821,12 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.quoted-message-image {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.wechat-message-row.other .quoted-message {
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
.conversation-sidebar-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 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 {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
|
||||
@@ -1223,7 +1223,8 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.image-key-editor input,
|
||||
.image-test-section select {
|
||||
.image-test-section select,
|
||||
.image-test-search {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
@@ -1240,7 +1241,8 @@
|
||||
monospace;
|
||||
}
|
||||
.image-key-editor input:focus,
|
||||
.image-test-section select:focus {
|
||||
.image-test-section select:focus,
|
||||
.image-test-search:focus {
|
||||
border-color: #247a63;
|
||||
box-shadow: 0 0 0 2px rgba(36, 122, 99, 0.09);
|
||||
}
|
||||
@@ -1269,6 +1271,45 @@
|
||||
font-size: 12px;
|
||||
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 {
|
||||
display: block;
|
||||
margin: 18px 0 8px;
|
||||
@@ -1276,11 +1317,64 @@
|
||||
font-size: 12px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 9px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1362,6 +1456,154 @@
|
||||
color: #a84444;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type ExportMessageKind =
|
||||
| 'video'
|
||||
| 'voice'
|
||||
| 'sticker'
|
||||
| 'file'
|
||||
| 'share'
|
||||
| 'location'
|
||||
| 'system'
|
||||
|
||||
@@ -85,4 +85,5 @@ export interface ImageDecryptionTestResult {
|
||||
decrypted: boolean
|
||||
readable: boolean
|
||||
isThumbnail?: boolean
|
||||
diagnosticLog?: string
|
||||
}
|
||||
|
||||
+2
-1
@@ -31,7 +31,8 @@ export interface Message {
|
||||
recalledBy?: string
|
||||
recoveredFromRecallJournal?: boolean
|
||||
exportMediaUrl?: string
|
||||
exportMediaType?: 'image' | 'video' | 'sticker'
|
||||
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
|
||||
exportMediaName?: string
|
||||
exportShowAvatar?: boolean
|
||||
exportMediaError?: 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()
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ vi.mock('../../src/renderer/src/components/image-loader', () => ({
|
||||
}))
|
||||
|
||||
import { ImageBubble } from '../../src/renderer/src/components/ImageBubble'
|
||||
import { RichMessageBubble } from '../../src/renderer/src/components/RichMessageBubble'
|
||||
|
||||
const thumbnail =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
|
||||
@@ -50,4 +51,24 @@ describe('ImageBubble', () => {
|
||||
await userEvent.click(screen.getByText('加载失败'))
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -327,7 +327,8 @@ handle('image:testConfig', () => ({
|
||||
success: true,
|
||||
fileFound: true,
|
||||
decrypted: true,
|
||||
readable: true
|
||||
readable: true,
|
||||
diagnosticLog: 'WechatExplorer 图片解析测试日志(已脱敏)\n测试结果:成功(SUCCESS)'
|
||||
}))
|
||||
handle('image:clearConfig', () => ({ success: true }))
|
||||
handle('image:getDecoderStatus', () => ({
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Message } from '../../src/shared/types'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
documents: '',
|
||||
accountRoot: '',
|
||||
videoPath: '',
|
||||
messages: [] as Message[],
|
||||
imageLookups: [] as { allowThumbnail?: boolean; preferThumbnail?: boolean }[]
|
||||
@@ -18,7 +19,9 @@ vi.mock('electron', () => ({
|
||||
}))
|
||||
vi.mock('../../src/main/services/chat-service', () => ({
|
||||
listMessages: () => structuredClone(state.messages),
|
||||
getChatDb: () => ({ getWcdb4Client: () => ({}) }),
|
||||
getChatDb: () => ({
|
||||
getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot })
|
||||
}),
|
||||
getContactAvatars: () => ({})
|
||||
}))
|
||||
vi.mock('../../src/main/services/image-key-config-service', () => ({
|
||||
@@ -92,12 +95,16 @@ const message = (overrides: Partial<Message>): Message => ({
|
||||
describe('media export flow', () => {
|
||||
beforeEach(() => {
|
||||
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
|
||||
state.accountRoot = join(state.documents, 'fixture-account')
|
||||
state.videoPath = join(state.documents, 'fixture.mp4')
|
||||
writeFileSync(
|
||||
state.videoPath,
|
||||
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
|
||||
)
|
||||
state.imageLookups = []
|
||||
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
||||
mkdirSync(fileMonth, { recursive: true })
|
||||
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
|
||||
state.messages = [
|
||||
message({
|
||||
id: 'voice-ok',
|
||||
@@ -123,6 +130,11 @@ describe('media export flow', () => {
|
||||
id: 'video',
|
||||
type: '视频',
|
||||
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: '脱敏会话',
|
||||
format: 'html',
|
||||
outputName: 'fixture',
|
||||
kinds: ['voice', 'image', 'video'],
|
||||
kinds: ['voice', 'image', 'video', 'file'],
|
||||
includeMedia: true,
|
||||
preferOriginal: true,
|
||||
fallbackThumbnail: true,
|
||||
@@ -161,8 +173,10 @@ describe('media export flow', () => {
|
||||
expect(readFileSync(join(outputDir, 'media/video_4.mp4')).subarray(4, 8).toString()).toBe(
|
||||
'ftyp'
|
||||
)
|
||||
expect(readFileSync(join(outputDir, 'media/file_5_测试附件.txt'), 'utf8')).toBe('附件内容')
|
||||
expect(html).toContain('src="voices/voice_1_1.wav"')
|
||||
expect(html).toContain('src="media/video_4.mp4"')
|
||||
expect(html).toContain('href="media/file_5_测试附件.txt" download')
|
||||
expect(html).toContain('语音文件缺失:本地未找到语音数据')
|
||||
expect(state.imageLookups[0]).toMatchObject({
|
||||
allowThumbnail: false,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
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', () => {
|
||||
afterEach(() => setChatDb(null))
|
||||
@@ -36,4 +41,30 @@ describe('chat service contacts', () => {
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,6 +38,14 @@ describe('export media', () => {
|
||||
id: 'missing',
|
||||
type: '语音',
|
||||
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('语音文件缺失:本地未找到语音数据')
|
||||
expect(html).toContain('class="file-attachment" href="media/file_4_示例附件.zip" download')
|
||||
expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')))
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import { ImageDecryptService } from '../../src/main/image-decrypt-service'
|
||||
|
||||
const aesKey = '0123456789abcdef'
|
||||
const xorKey = 0x40
|
||||
const originalResourcesPath = process.resourcesPath
|
||||
|
||||
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])
|
||||
@@ -35,22 +36,95 @@ function writeV2Dat(file: string): Buffer {
|
||||
}
|
||||
|
||||
describe('DAT image decryption', () => {
|
||||
beforeAll(() => mkdirSync(root, { recursive: true }))
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
||||
beforeAll(() => {
|
||||
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', () => {
|
||||
const file = join(root, 'fixture.dat')
|
||||
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', () => {
|
||||
const file = join(root, 'fixture.dat')
|
||||
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')
|
||||
writeFileSync(legacy, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
|
||||
expect(new ImageDecryptService('0x40', aesKey).decryptImage(legacy)).toBeNull()
|
||||
writeFileSync(legacy, Buffer.from([0x12, 0x34, 0x56, 0x78]))
|
||||
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('不受支持/旧版格式')
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
expect(parseMessageContent('opaque fixture payload', 999)).toEqual({
|
||||
type: 'unknown',
|
||||
|
||||
@@ -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: '测试群成员' })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user