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