mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
fix: 完善聊天解析与导出体验
- 修复引用消息名称和图片布局 - 明确单会话图片测试日志范围 - 支持导出文件附件 - 完善图片批测、会话刷新和安全退出
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { Message } from '../shared/types'
|
||||
|
||||
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.media-status{margin-top:8px;padding:6px 8px;border-left:3px solid #b27a18;background:#fff8e8;color:#79530f;font-size:12px;line-height:1.5}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}.lightbox-close{position:fixed;top:20px;right:20px;z-index:11;width:42px;height:42px;border:1px solid #ffffff66;border-radius:50%;background:#14231dcc;color:#fff;font-size:30px;line-height:1;cursor:pointer}`
|
||||
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.media-status{margin-top:8px;padding:6px 8px;border-left:3px solid #b27a18;background:#fff8e8;color:#79530f;font-size:12px;line-height:1.5}.file-attachment{display:flex;align-items:center;gap:10px;min-width:220px;padding:11px 13px;border:1px solid var(--border);border-radius:10px;background:#f6faf8;color:var(--accent);font-weight:650;text-decoration:none;word-break:break-all}.file-attachment:before{content:'文件';display:grid;place-items:center;width:34px;height:34px;flex:0 0 auto;border-radius:8px;background:#dcebe4;color:var(--accent);font-size:11px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}.lightbox-close{position:fixed;top:20px;right:20px;z-index:11;width:42px;height:42px;border:1px solid #ffffff66;border-radius:50%;background:#14231dcc;color:#fff;font-size:30px;line-height:1;cursor:pointer}`
|
||||
const safe = (value: unknown): string =>
|
||||
String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
@@ -30,7 +30,9 @@ export function renderExportPage(name: string, messages: Message[]): string {
|
||||
? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>`
|
||||
: m.exportMediaUrl && m.exportMediaType === 'sticker'
|
||||
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="表情包">`
|
||||
: ''
|
||||
: m.exportMediaUrl && m.exportMediaType === 'file'
|
||||
? `<a class="file-attachment" href="${safe(m.exportMediaUrl)}" download>${safe(m.exportMediaName || (m.contentData?.type === 'share' ? m.contentData.title : '') || '下载文件')}</a>`
|
||||
: ''
|
||||
const avatarMarkup =
|
||||
m.exportShowAvatar === false
|
||||
? ''
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||
import { VideoAssetService } from './video-asset-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
import { getImageExportAttempts } from '../shared/export-media'
|
||||
import { FileAssetService } from './file-asset-service'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
@@ -87,6 +88,7 @@ async function readAvatarAsset(
|
||||
const kindOf = (message: Message): ExportMessageKind => {
|
||||
const type = message.contentData?.type
|
||||
if (type === 'system' && message.contentData?.pat) return 'text'
|
||||
if (type === 'share' && message.contentData.typeVal === '6') return 'file'
|
||||
if (
|
||||
type === 'image' ||
|
||||
type === 'video' ||
|
||||
@@ -142,6 +144,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
for (const message of messages) {
|
||||
message.exportMediaUrl = undefined
|
||||
message.exportMediaType = undefined
|
||||
message.exportMediaName = undefined
|
||||
message.exportMediaError = undefined
|
||||
message.voiceDataUrl = undefined
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
@@ -149,7 +152,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
if (mappedName) message.name = mappedName
|
||||
if (
|
||||
request.format !== 'html' &&
|
||||
['image', 'video', 'voice', 'sticker'].includes(kindOf(message))
|
||||
['image', 'video', 'voice', 'sticker', 'file'].includes(kindOf(message))
|
||||
) {
|
||||
message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件'
|
||||
}
|
||||
@@ -198,6 +201,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
: null
|
||||
const videoService = client ? new VideoAssetService(client) : null
|
||||
const stickerService = client ? new StickerService(client) : null
|
||||
const fileService = client ? new FileAssetService(client) : null
|
||||
const exportedAvatars = new Map<string, string>()
|
||||
const voiceService =
|
||||
request.includeMedia && chat.getChatDb()
|
||||
@@ -362,6 +366,21 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
} else {
|
||||
keepMediaError(request, message, result.error || '表情资源缺失或下载失败')
|
||||
}
|
||||
} else if (message.contentData.type === 'share' && message.contentData.typeVal === '6') {
|
||||
if (!fileService) {
|
||||
keepMediaError(request, message, '数据库未连接,无法定位本地文件附件')
|
||||
} else {
|
||||
const resolved = fileService.resolve(message.contentData.title, message.createTime)
|
||||
if (!resolved.success || !resolved.filePath || !resolved.fileName) {
|
||||
keepMediaError(request, message, resolved.error || '本地文件附件缺失')
|
||||
} else {
|
||||
const name = `file_${index + 1}_${safeFilePart(resolved.fileName)}`
|
||||
await fs.copyFile(resolved.filePath, join(outputDir, 'media', name))
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'file'
|
||||
message.exportMediaName = message.contentData.title || resolved.fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
export type FileAssetResult = {
|
||||
success: boolean
|
||||
filePath?: string
|
||||
fileName?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const monthName = (timestamp?: number): string => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export class FileAssetService {
|
||||
private index:
|
||||
| {
|
||||
root: string
|
||||
files: { filePath: string; fileName: string; month: string; mtimeMs: number }[]
|
||||
}
|
||||
| undefined
|
||||
|
||||
constructor(private readonly client: Pick<Wcdb4Client, 'getAccountRoot'>) {}
|
||||
|
||||
resolve(fileTitle: string, createTime?: number): FileAssetResult {
|
||||
const normalizedTitle = path.basename(
|
||||
String(fileTitle || '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
)
|
||||
if (!normalizedTitle) return { success: false, error: '文件名为空,无法定位本地附件' }
|
||||
|
||||
const configuredRoot = path.resolve(this.client.getAccountRoot(), 'msg', 'file')
|
||||
if (!fs.existsSync(configuredRoot)) {
|
||||
return { success: false, error: '本地文件附件目录不存在' }
|
||||
}
|
||||
const root = fs.realpathSync(configuredRoot)
|
||||
|
||||
const preferredMonth = monthName(createTime)
|
||||
const extension = path.extname(normalizedTitle)
|
||||
const stem = normalizedTitle.slice(0, normalizedTitle.length - extension.length)
|
||||
const duplicatePattern = new RegExp(
|
||||
`^${escapeRegExp(stem)}(?:\\(\\d+\\))?${escapeRegExp(extension)}$`,
|
||||
'i'
|
||||
)
|
||||
const expectedTime = createTime ? createTime * 1000 : 0
|
||||
const candidates = this.getIndex(root).filter(({ fileName }) => duplicatePattern.test(fileName))
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
const leftPreferred = left.month === preferredMonth ? 1 : 0
|
||||
const rightPreferred = right.month === preferredMonth ? 1 : 0
|
||||
if (leftPreferred !== rightPreferred) return rightPreferred - leftPreferred
|
||||
const leftExact = left.fileName === normalizedTitle ? 1 : 0
|
||||
const rightExact = right.fileName === normalizedTitle ? 1 : 0
|
||||
if (leftExact !== rightExact) return rightExact - leftExact
|
||||
if (expectedTime) {
|
||||
const timeDifference =
|
||||
Math.abs(left.mtimeMs - expectedTime) - Math.abs(right.mtimeMs - expectedTime)
|
||||
if (timeDifference) return timeDifference
|
||||
}
|
||||
return left.fileName.localeCompare(right.fileName)
|
||||
})
|
||||
|
||||
const selected = candidates[0]
|
||||
if (!selected) return { success: false, error: `本地未找到文件附件:${normalizedTitle}` }
|
||||
return { success: true, filePath: selected.filePath, fileName: selected.fileName }
|
||||
}
|
||||
|
||||
private isSafeChild(root: string, candidate: string): boolean {
|
||||
return candidate === root || candidate.startsWith(`${root}${path.sep}`)
|
||||
}
|
||||
|
||||
private getIndex(
|
||||
root: string
|
||||
): { filePath: string; fileName: string; month: string; mtimeMs: number }[] {
|
||||
if (this.index?.root === root) return this.index.files
|
||||
const files: { filePath: string; fileName: string; month: string; mtimeMs: number }[] = []
|
||||
for (const month of fs.readdirSync(root)) {
|
||||
const monthPath = path.resolve(root, month)
|
||||
if (!this.isSafeChild(root, monthPath) || !this.isDirectory(monthPath)) continue
|
||||
for (const fileName of fs.readdirSync(monthPath)) {
|
||||
const candidate = path.resolve(monthPath, fileName)
|
||||
if (!this.isSafeChild(root, candidate) || !this.isFile(candidate)) continue
|
||||
const filePath = fs.realpathSync(candidate)
|
||||
if (!this.isSafeChild(root, filePath)) continue
|
||||
files.push({ filePath, fileName, month, mtimeMs: this.mtimeMs(filePath) })
|
||||
}
|
||||
}
|
||||
this.index = { root, files }
|
||||
return files
|
||||
}
|
||||
|
||||
private isDirectory(filePath: string): boolean {
|
||||
try {
|
||||
return fs.statSync(filePath).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private isFile(filePath: string): boolean {
|
||||
try {
|
||||
return fs.statSync(filePath).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private mtimeMs(filePath: string): number {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,27 @@ export type DecodedImage = {
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export type ImageDecodeDiagnosticCode =
|
||||
| 'NOT_RUN'
|
||||
| 'FILE_NOT_FOUND'
|
||||
| 'DIRECT_IMAGE'
|
||||
| 'UNSUPPORTED_DAT_VERSION'
|
||||
| 'MISSING_AES_KEY'
|
||||
| 'AES_DECRYPT_FAILED'
|
||||
| 'INVALID_DAT_FILE'
|
||||
| 'WXGF_REQUIRES_DECODER'
|
||||
| 'UNKNOWN_IMAGE_FORMAT'
|
||||
| 'SUCCESS'
|
||||
|
||||
export interface ImageDecodeDiagnostic {
|
||||
code: ImageDecodeDiagnosticCode
|
||||
detail: string
|
||||
datVersion?: number
|
||||
fileSize?: number
|
||||
imageFormat?: string
|
||||
wxgf?: boolean
|
||||
}
|
||||
|
||||
type ImageFindOptions = {
|
||||
allowThumbnail?: boolean
|
||||
accountDir?: string
|
||||
@@ -158,12 +179,15 @@ function normalizeDatBase(value) {
|
||||
if (!lower) return ''
|
||||
const file = lower.split('/').pop().split('\\').pop()
|
||||
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
|
||||
return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '')
|
||||
return withoutDat.replace(
|
||||
/(_thumb|\.thumb|_hd|\.hd|_h_m|_h|\.h|_t_m|_t|\.t|_m|_b|_w|_c)$/i,
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
function isThumbnailName(fileName) {
|
||||
const lower = fileName.toLowerCase()
|
||||
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
|
||||
return /(?:_t(?:_m)?|_thumb|\.thumb)\.dat$/i.test(lower)
|
||||
}
|
||||
|
||||
function buildPreferredDatNames(baseName) {
|
||||
@@ -172,10 +196,13 @@ function buildPreferredDatNames(baseName) {
|
||||
return [
|
||||
base + '.dat',
|
||||
base + '_hd.dat',
|
||||
base + '_h_M.dat',
|
||||
base + '_h.dat',
|
||||
base + '_M.dat',
|
||||
base + '_b.dat',
|
||||
base + '_w.dat',
|
||||
base + '_c.dat',
|
||||
base + '_t_M.dat',
|
||||
base + '_t.dat',
|
||||
base + '.thumb.dat',
|
||||
base + '_thumb.dat'
|
||||
@@ -285,8 +312,15 @@ function unwrapWxgf(buffer, ffmpegPath) {
|
||||
|
||||
function decryptCandidate(filePath, aesKey, xorKey, ffmpegPath) {
|
||||
const bytes = fs.readFileSync(filePath)
|
||||
const directExtension = detectImageExtension(bytes)
|
||||
if (directExtension) {
|
||||
return {
|
||||
data: 'data:' + getMimeType(directExtension) + ';base64,' + bytes.toString('base64'),
|
||||
filePath
|
||||
}
|
||||
}
|
||||
if (!path.extname(filePath).toLowerCase().includes('dat')) {
|
||||
const extension = detectImageExtension(bytes) || path.extname(filePath).toLowerCase()
|
||||
const extension = path.extname(filePath).toLowerCase()
|
||||
return { data: 'data:' + getMimeType(extension) + ';base64,' + bytes.toString('base64'), filePath }
|
||||
}
|
||||
if (
|
||||
@@ -381,6 +415,10 @@ export class ImageDecryptService {
|
||||
private decodedImageCacheBytes = 0
|
||||
private persistentCachePrunePromise: Promise<void> | null = null
|
||||
private persistentCachePrunePending = false
|
||||
private lastDecodeDiagnostic: ImageDecodeDiagnostic = {
|
||||
code: 'NOT_RUN',
|
||||
detail: '尚未执行图片解析'
|
||||
}
|
||||
|
||||
constructor(
|
||||
xorKey: string,
|
||||
@@ -407,6 +445,10 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
getLastDecodeDiagnostic(): ImageDecodeDiagnostic {
|
||||
return { ...this.lastDecodeDiagnostic }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号目录
|
||||
*/
|
||||
@@ -1211,11 +1253,27 @@ export class ImageDecryptService {
|
||||
decryptImage(datPath: string): Buffer | null {
|
||||
if (!existsSync(datPath)) {
|
||||
imageDecryptLog('[ImageDecrypt] file not found:', datPath)
|
||||
this.lastDecodeDiagnostic = {
|
||||
code: 'FILE_NOT_FOUND',
|
||||
detail: '候选图片文件不存在'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const source = readFileSync(datPath)
|
||||
const directExtension = this.detectImageExtension(source)
|
||||
if (directExtension) {
|
||||
this.lastDecodeDiagnostic = {
|
||||
code: 'DIRECT_IMAGE',
|
||||
detail: 'DAT 文件内容是可直接读取的图片',
|
||||
fileSize: source.length,
|
||||
imageFormat: directExtension.replace(/^\./, '').toUpperCase()
|
||||
}
|
||||
return source
|
||||
}
|
||||
const version = this.getDatVersion(datPath)
|
||||
const fileSize = statSync(datPath).size
|
||||
imageDecryptLog(
|
||||
'[ImageDecrypt] dat version:',
|
||||
version,
|
||||
@@ -1231,6 +1289,12 @@ export class ImageDecryptService {
|
||||
imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)')
|
||||
if (!this.aesKey) {
|
||||
imageDecryptLog('[ImageDecrypt] no AES key configured')
|
||||
this.lastDecodeDiagnostic = {
|
||||
code: 'MISSING_AES_KEY',
|
||||
detail: '未配置 AES 图片密钥',
|
||||
datVersion: version,
|
||||
fileSize
|
||||
}
|
||||
return null
|
||||
}
|
||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||
@@ -1238,12 +1302,34 @@ export class ImageDecryptService {
|
||||
} else {
|
||||
// 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
|
||||
imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version)
|
||||
this.lastDecodeDiagnostic = {
|
||||
code: 'UNSUPPORTED_DAT_VERSION',
|
||||
detail: '不是受支持的 WeChat 4.0 DAT 图片格式',
|
||||
datVersion: version,
|
||||
fileSize
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
this.lastDecodeDiagnostic = {
|
||||
code: 'SUCCESS',
|
||||
detail: 'DAT 数据已解密,正在识别图片格式',
|
||||
datVersion: version,
|
||||
fileSize
|
||||
}
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
imageDecryptLog('[ImageDecrypt] decrypt error:', error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const aesFailure = /padding|bad decrypt|decrypt/i.test(message)
|
||||
this.lastDecodeDiagnostic = {
|
||||
code: aesFailure ? 'AES_DECRYPT_FAILED' : 'INVALID_DAT_FILE',
|
||||
detail: aesFailure
|
||||
? 'AES 解密校验失败,密钥可能与当前账号不匹配'
|
||||
: 'DAT 文件结构异常或文件不完整',
|
||||
datVersion: 2,
|
||||
fileSize: existsSync(datPath) ? statSync(datPath).size : undefined
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1253,23 +1339,54 @@ export class ImageDecryptService {
|
||||
*/
|
||||
decryptImageToBase64(datPath: string): string | null {
|
||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||
const data = readFileSync(datPath)
|
||||
const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase()
|
||||
const mimeType = this.getMimeType(ext)
|
||||
return `data:${mimeType};base64,${data.toString('base64')}`
|
||||
try {
|
||||
const data = readFileSync(datPath)
|
||||
const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase()
|
||||
const mimeType = this.getMimeType(ext)
|
||||
this.lastDecodeDiagnostic = {
|
||||
code: 'DIRECT_IMAGE',
|
||||
detail: '文件本身是可直接读取的图片',
|
||||
fileSize: data.length,
|
||||
imageFormat: ext.replace(/^\./, '').toUpperCase()
|
||||
}
|
||||
return `data:${mimeType};base64,${data.toString('base64')}`
|
||||
} catch {
|
||||
this.lastDecodeDiagnostic = {
|
||||
code: 'FILE_NOT_FOUND',
|
||||
detail: '候选图片文件无法读取'
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const decrypted = this.decryptImage(datPath)
|
||||
if (!decrypted) return null
|
||||
|
||||
const directImage = this.lastDecodeDiagnostic.code === 'DIRECT_IMAGE'
|
||||
const wxgf = this.isWxgfBuffer(decrypted)
|
||||
const unwrapped = this.unwrapWxgf(decrypted)
|
||||
const ext = this.detectImageExtension(unwrapped)
|
||||
if (!ext) {
|
||||
imageDecryptLog('[ImageDecrypt] unknown image format')
|
||||
this.lastDecodeDiagnostic = {
|
||||
...this.lastDecodeDiagnostic,
|
||||
code: wxgf ? 'WXGF_REQUIRES_DECODER' : 'UNKNOWN_IMAGE_FORMAT',
|
||||
detail: wxgf
|
||||
? '已解密为 WXGF/HEVC 数据,但 FFmpeg 转换未成功'
|
||||
: '数据已解密,但无法识别为常见图片格式',
|
||||
wxgf
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const mimeType = this.getMimeType(ext)
|
||||
this.lastDecodeDiagnostic = {
|
||||
...this.lastDecodeDiagnostic,
|
||||
code: directImage ? 'DIRECT_IMAGE' : 'SUCCESS',
|
||||
detail: directImage ? 'DAT 文件内容是可直接读取的图片' : '图片解密并识别成功',
|
||||
imageFormat: ext.replace(/^\./, '').toUpperCase(),
|
||||
wxgf
|
||||
}
|
||||
return `data:${mimeType};base64,${unwrapped.toString('base64')}`
|
||||
}
|
||||
|
||||
@@ -1474,7 +1591,9 @@ export class ImageDecryptService {
|
||||
if (!lower) return ''
|
||||
const file = lower.split('/').pop()?.split('\\').pop() || lower
|
||||
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
|
||||
return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '').toLowerCase()
|
||||
return withoutDat
|
||||
.replace(/(_thumb|\.thumb|_hd|\.hd|_h_m|_h|\.h|_t_m|_t|\.t|_m|_b|_w|_c)$/i, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
private getSessionDirectoryName(sessionId?: string): string {
|
||||
@@ -1490,10 +1609,13 @@ export class ImageDecryptService {
|
||||
return [
|
||||
`${base}.dat`,
|
||||
`${base}_hd.dat`,
|
||||
`${base}_h_M.dat`,
|
||||
`${base}_h.dat`,
|
||||
`${base}_M.dat`,
|
||||
`${base}_b.dat`,
|
||||
`${base}_w.dat`,
|
||||
`${base}_c.dat`,
|
||||
`${base}_t_M.dat`,
|
||||
`${base}_t.dat`,
|
||||
`${base}.thumb.dat`,
|
||||
`${base}_thumb.dat`
|
||||
@@ -1584,7 +1706,7 @@ export class ImageDecryptService {
|
||||
|
||||
private isThumbnailName(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
|
||||
return /(?:_t(?:_m)?|_thumb|\.thumb)\.dat$/i.test(lower)
|
||||
}
|
||||
|
||||
isThumbnailFile(filePath: string): boolean {
|
||||
@@ -1592,13 +1714,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private unwrapWxgf(buffer: Buffer): Buffer {
|
||||
if (
|
||||
buffer.length < 20 ||
|
||||
buffer[0] !== 0x77 ||
|
||||
buffer[1] !== 0x78 ||
|
||||
buffer[2] !== 0x67 ||
|
||||
buffer[3] !== 0x66
|
||||
) {
|
||||
if (!this.isWxgfBuffer(buffer)) {
|
||||
return buffer
|
||||
}
|
||||
|
||||
@@ -1619,6 +1735,16 @@ export class ImageDecryptService {
|
||||
return buffer
|
||||
}
|
||||
|
||||
private isWxgfBuffer(buffer: Buffer): boolean {
|
||||
return (
|
||||
buffer.length >= 20 &&
|
||||
buffer[0] === 0x77 &&
|
||||
buffer[1] === 0x78 &&
|
||||
buffer[2] === 0x67 &&
|
||||
buffer[3] === 0x66
|
||||
)
|
||||
}
|
||||
|
||||
private uniq(values: string[]): string[] {
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
+52
-10
@@ -267,6 +267,7 @@ const isolatedUserData = process.env['WXE_USER_DATA']
|
||||
if (isolatedUserData) app.setPath('userData', isolatedUserData)
|
||||
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
|
||||
null
|
||||
let appShutdownRequested = false
|
||||
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||
const TRAY_MODE =
|
||||
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
|
||||
@@ -493,12 +494,18 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
ipcMain.handle('db:init', async (_, key: string, accountRoot?: string) => {
|
||||
if (appShutdownRequested) {
|
||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||
}
|
||||
if (dbInitInFlight) return dbInitInFlight
|
||||
|
||||
dbInitInFlight = (async () => {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
if (wcdbBootstrapPromise) await wcdbBootstrapPromise
|
||||
if (appShutdownRequested) {
|
||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||
}
|
||||
const trimmedKey = String(key || '').trim()
|
||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||
const settings = loadSettings()
|
||||
@@ -529,6 +536,10 @@ app.whenReady().then(async () => {
|
||||
return { success: true, monitoring: true }
|
||||
}
|
||||
const nextWechatDb = await WechatDb.create(key, selectedRoot)
|
||||
if (appShutdownRequested) {
|
||||
await nextWechatDb.closeAsync()
|
||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||
}
|
||||
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||
if (resolvedRoot) {
|
||||
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
||||
@@ -538,7 +549,9 @@ app.whenReady().then(async () => {
|
||||
imageKeyRoot: resolvedRoot
|
||||
})
|
||||
}
|
||||
chat.setChatDb(nextWechatDb)
|
||||
if (!chat.setChatDb(nextWechatDb)) {
|
||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||
}
|
||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
|
||||
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||
@@ -1342,15 +1355,44 @@ app.on('window-all-closed', () => {
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
agentHubService.stop()
|
||||
flushBootstrapCacheWritesSync()
|
||||
chat.setChatDb(null)
|
||||
await apiServer.stop().catch(() => undefined)
|
||||
if (tray) {
|
||||
tray.destroy()
|
||||
tray = null
|
||||
}
|
||||
let quitCleanupStarted = false
|
||||
let quitCleanupComplete = false
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
if (quitCleanupComplete) return
|
||||
appShutdownRequested = true
|
||||
event.preventDefault()
|
||||
if (quitCleanupStarted) return
|
||||
quitCleanupStarted = true
|
||||
console.log('[Shutdown] cleanup started')
|
||||
|
||||
void (async () => {
|
||||
agentHubService.stop()
|
||||
flushBootstrapCacheWritesSync()
|
||||
const [, nativeCallsDrained] = await Promise.all([
|
||||
apiServer.stop().catch(() => undefined),
|
||||
chat.closeChatDbForQuit().catch(() => false)
|
||||
])
|
||||
if (!nativeCallsDrained) {
|
||||
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
|
||||
}
|
||||
if (tray) {
|
||||
tray.destroy()
|
||||
tray = null
|
||||
}
|
||||
console.log('[Shutdown] cleanup completed')
|
||||
})()
|
||||
.catch((error) => {
|
||||
console.warn('[Shutdown] cleanup failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
quitCleanupComplete = true
|
||||
// The first quit request has already been cancelled so cleanup can finish safely.
|
||||
// app.exit() avoids starting a second before-quit cycle that can leave Electron alive
|
||||
// on macOS even after every application service has stopped.
|
||||
console.log('[Shutdown] exiting application')
|
||||
app.exit(0)
|
||||
})
|
||||
})
|
||||
|
||||
function showMainWindow(): void {
|
||||
|
||||
@@ -590,10 +590,10 @@ function parseQuoteMessage(content: string): {
|
||||
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
||||
|
||||
const referMsgXml = content.substring(referMsgStart, referMsgEnd + '</refermsg>'.length)
|
||||
const sender =
|
||||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'displayname')) ||
|
||||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'fromusr')) ||
|
||||
undefined
|
||||
const displayName = sanitizeQuotedContent(extractXmlValue(referMsgXml, 'displayname'))
|
||||
const chatUser = sanitizeQuotedSenderId(extractXmlValue(referMsgXml, 'chatusr'))
|
||||
const fromUser = sanitizeQuotedSenderId(extractXmlValue(referMsgXml, 'fromusr'))
|
||||
const sender = displayName || chatUser || fromUser || undefined
|
||||
const referContent = extractXmlValue(referMsgXml, 'content')
|
||||
const referType = extractXmlValue(referMsgXml, 'type')
|
||||
|
||||
@@ -653,6 +653,10 @@ function sanitizeQuotedContent(content: string): string {
|
||||
return decoded
|
||||
}
|
||||
|
||||
function sanitizeQuotedSenderId(value: string): string {
|
||||
return decodeXmlEntities(String(value || '')).trim()
|
||||
}
|
||||
|
||||
function stripChatroomPrefix(content: string): string {
|
||||
return String(content || '')
|
||||
.replace(/^[0-9a-z_-]+@chatroom:\s*/i, '')
|
||||
|
||||
@@ -55,7 +55,8 @@ export interface FormattedMessage {
|
||||
voiceDataUrl?: string
|
||||
voiceDuration?: number
|
||||
exportMediaUrl?: string
|
||||
exportMediaType?: 'image' | 'video' | 'sticker'
|
||||
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
|
||||
exportMediaName?: string
|
||||
exportShowAvatar?: boolean
|
||||
exportMediaError?: string
|
||||
exportAvatarUrl?: string
|
||||
@@ -109,10 +110,24 @@ function normalizeMsgType(value: string | number | undefined): number {
|
||||
}
|
||||
|
||||
let dbRef: WechatDb | null = null
|
||||
let shutdownRequested = false
|
||||
|
||||
export function setChatDb(db: WechatDb | null): void {
|
||||
export function setChatDb(db: WechatDb | null): boolean {
|
||||
if (shutdownRequested) {
|
||||
db?.close()
|
||||
return false
|
||||
}
|
||||
dbRef?.close()
|
||||
dbRef = db
|
||||
return true
|
||||
}
|
||||
|
||||
export async function closeChatDbForQuit(): Promise<boolean> {
|
||||
shutdownRequested = true
|
||||
const current = dbRef
|
||||
dbRef = null
|
||||
if (!current) return true
|
||||
return current.closeAsync()
|
||||
}
|
||||
|
||||
export function getChatDb(): WechatDb | null {
|
||||
@@ -639,8 +654,7 @@ export function reopenWithRoot(accountRoot: string): boolean {
|
||||
if (!key) return false
|
||||
try {
|
||||
const next = new WechatDb(key, accountRoot)
|
||||
setChatDb(next)
|
||||
return true
|
||||
return setChatDb(next)
|
||||
} catch (error) {
|
||||
console.error('[ChatService] reopen with root failed:', error)
|
||||
return false
|
||||
|
||||
@@ -3,13 +3,18 @@ import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import type {
|
||||
ImageDecoderStatus,
|
||||
ImageDecryptionStatus,
|
||||
ImageDecryptionTestResult,
|
||||
ImageKeyConfigResult,
|
||||
ImageResourceCheck,
|
||||
TestImageDecryptionRequest
|
||||
} from '../../shared/image-decryption'
|
||||
import { ImageDecryptService, inspectImageDecoderStatus } from '../image-decrypt-service'
|
||||
import {
|
||||
ImageDecryptService,
|
||||
inspectImageDecoderStatus,
|
||||
type ImageDecodeDiagnostic
|
||||
} from '../image-decrypt-service'
|
||||
import * as chat from './chat-service'
|
||||
import { validateImageKeyRequest } from './image-key-config-service'
|
||||
import { isWechatRunning } from './wechat-process-status'
|
||||
@@ -57,13 +62,35 @@ export async function inspectImageDecryptionStatus(
|
||||
}
|
||||
}
|
||||
|
||||
export function testImageDecryption(
|
||||
export async function testImageDecryption(
|
||||
request: TestImageDecryptionRequest
|
||||
): ImageDecryptionTestResult {
|
||||
): Promise<ImageDecryptionTestResult> {
|
||||
const startedAt = Date.now()
|
||||
let testedImage:
|
||||
| { md5?: string; datName?: string; sessionId?: string; selection: string }
|
||||
| undefined
|
||||
let filePath: string | undefined
|
||||
let decodeDiagnostic: ImageDecodeDiagnostic | undefined
|
||||
let decoder: ImageDecoderStatus | undefined
|
||||
const finish = (
|
||||
result: Omit<ImageDecryptionTestResult, 'diagnosticLog'>
|
||||
): ImageDecryptionTestResult => ({
|
||||
...result,
|
||||
diagnosticLog: buildImageTestDiagnosticLog({
|
||||
request,
|
||||
result,
|
||||
startedAt,
|
||||
testedImage,
|
||||
filePath,
|
||||
decodeDiagnostic,
|
||||
decoder
|
||||
})
|
||||
})
|
||||
|
||||
const normalized = validateImageKeyRequest(request)
|
||||
if (!normalized.success) return failure('NOT_CONFIGURED', normalized.error)
|
||||
if (!normalized.success) return finish(failure('NOT_CONFIGURED', normalized.error))
|
||||
if (!chat.isReady() || !request.userMd5) {
|
||||
return failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录')
|
||||
return finish(failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录'))
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -72,9 +99,11 @@ export function testImageDecryption(
|
||||
.reverse()
|
||||
.find((message) => message.contentData?.type === 'image')
|
||||
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
|
||||
return failure(
|
||||
'NO_IMAGE_MESSAGE',
|
||||
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
|
||||
return finish(
|
||||
failure(
|
||||
'NO_IMAGE_MESSAGE',
|
||||
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,58 +113,324 @@ export function testImageDecryption(
|
||||
chat.getChatDb()?.getWcdb4Client()
|
||||
)
|
||||
const image = imageMessage.contentData
|
||||
// 测试时优先使用用户在下方"图片资源目录"输入框填写的目录;
|
||||
// 找不到再退回默认 accountDir。
|
||||
testedImage = {
|
||||
md5: image.md5,
|
||||
datName: image.datName,
|
||||
sessionId: imageMessage.sessionId,
|
||||
selection: '所选会话最近 300 条消息中的最后一张图片'
|
||||
}
|
||||
const testAccountDir = normalized.resourceRoot || undefined
|
||||
let filePath = service.findImageFile(image.md5, image.datName, {
|
||||
allowThumbnail: false,
|
||||
accountDir: testAccountDir
|
||||
})
|
||||
if (!filePath)
|
||||
filePath = service.findImageFile(image.md5, image.datName, {
|
||||
allowThumbnail: true,
|
||||
accountDir: testAccountDir
|
||||
})
|
||||
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
|
||||
filePath =
|
||||
(await service.findImageFileAsync(image.md5, image.datName, {
|
||||
allowThumbnail: false,
|
||||
accountDir: testAccountDir,
|
||||
sessionId: imageMessage.sessionId
|
||||
})) || undefined
|
||||
if (!filePath) {
|
||||
filePath =
|
||||
(await service.findImageFileAsync(image.md5, image.datName, {
|
||||
allowThumbnail: true,
|
||||
accountDir: testAccountDir,
|
||||
sessionId: imageMessage.sessionId
|
||||
})) || undefined
|
||||
}
|
||||
if (!filePath) return finish(failure('FILE_NOT_FOUND', '图片文件不存在'))
|
||||
|
||||
const data = service.decryptImageToBase64(filePath)
|
||||
if (!data) {
|
||||
// 三步联动:解密失败 → fileFound/decrypted/readable 都为 false。
|
||||
return {
|
||||
let decoded = await service.decryptImageToBase64WithFallbackAsync(filePath, true)
|
||||
if (!decoded) {
|
||||
// Worker 失败后在主进程做一次同步诊断:既能保留具体失败阶段,
|
||||
// 也能在少数 Worker 启动异常时继续测试普通图片。
|
||||
decoded = service.decryptImageToBase64WithFallback(filePath, true)
|
||||
}
|
||||
|
||||
if (!decoded) {
|
||||
decodeDiagnostic = service.getLastDecodeDiagnostic()
|
||||
if (decodeDiagnostic.code === 'WXGF_REQUIRES_DECODER') {
|
||||
decoder = await inspectImageDecoderStatus()
|
||||
}
|
||||
return finish({
|
||||
success: false,
|
||||
code: 'DECRYPT_FAILED',
|
||||
error: '无法解析媒体文件',
|
||||
fileFound: false,
|
||||
decrypted: false,
|
||||
readable: false
|
||||
}
|
||||
error: getDecodeFailureMessage(decodeDiagnostic, decoder),
|
||||
fileFound: true,
|
||||
decrypted: isDecryptedDiagnostic(decodeDiagnostic.code),
|
||||
readable: false,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
})
|
||||
}
|
||||
const readable = data.startsWith('data:image/')
|
||||
|
||||
filePath = decoded.filePath
|
||||
decodeDiagnostic = buildSuccessDiagnostic(decoded.data, decoded.filePath)
|
||||
const readable = decoded.data.startsWith('data:image/')
|
||||
if (!readable) {
|
||||
// 三步联动:解密成功但字节流不可读 → 前一步打勾(确实找到了 dat),
|
||||
// 但 decrypted/readable 全为 false,让 UI 表达"找到但解析失败"。
|
||||
return {
|
||||
return finish({
|
||||
success: false,
|
||||
code: 'DECRYPT_FAILED',
|
||||
error: '图片解密结果不可读取',
|
||||
fileFound: true,
|
||||
decrypted: false,
|
||||
decrypted: true,
|
||||
readable: false,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
}
|
||||
isThumbnail: service.isThumbnailFile(decoded.filePath)
|
||||
})
|
||||
}
|
||||
return {
|
||||
return finish({
|
||||
success: true,
|
||||
fileFound: true,
|
||||
decrypted: true,
|
||||
readable: true,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
}
|
||||
isThumbnail: service.isThumbnailFile(decoded.filePath)
|
||||
})
|
||||
} catch {
|
||||
return failure('UNKNOWN', '图片解析测试未通过')
|
||||
return finish(failure('UNKNOWN', '图片解析测试未通过'))
|
||||
}
|
||||
}
|
||||
|
||||
function buildSuccessDiagnostic(data: string, filePath: string): ImageDecodeDiagnostic {
|
||||
const format = /^data:image\/([^;]+);/i.exec(data)?.[1]?.toUpperCase()
|
||||
const directImageFormat = inspectDirectImageFormat(filePath)
|
||||
return {
|
||||
code: directImageFormat ? 'DIRECT_IMAGE' : 'SUCCESS',
|
||||
detail: directImageFormat ? 'DAT 文件内容是可直接读取的图片' : '图片解密并识别成功',
|
||||
datVersion: directImageFormat ? undefined : inspectDatVersion(filePath),
|
||||
fileSize: safeFileSize(filePath),
|
||||
imageFormat: format || directImageFormat
|
||||
}
|
||||
}
|
||||
|
||||
function inspectDirectImageFormat(filePath: string): string | undefined {
|
||||
try {
|
||||
const signature = fs.readFileSync(filePath).subarray(0, 12)
|
||||
if (signature[0] === 0xff && signature[1] === 0xd8 && signature[2] === 0xff) return 'JPEG'
|
||||
if (
|
||||
signature[0] === 0x89 &&
|
||||
signature[1] === 0x50 &&
|
||||
signature[2] === 0x4e &&
|
||||
signature[3] === 0x47
|
||||
)
|
||||
return 'PNG'
|
||||
if (
|
||||
signature[0] === 0x47 &&
|
||||
signature[1] === 0x49 &&
|
||||
signature[2] === 0x46 &&
|
||||
signature[3] === 0x38
|
||||
)
|
||||
return 'GIF'
|
||||
if (signature[0] === 0x42 && signature[1] === 0x4d) return 'BMP'
|
||||
if (signature.subarray(0, 4).toString('ascii') === 'RIFF') return 'WEBP'
|
||||
return undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function inspectDatVersion(filePath: string): number | undefined {
|
||||
if (!path.extname(filePath).toLowerCase().includes('dat')) return undefined
|
||||
try {
|
||||
const signature = fs.readFileSync(filePath).subarray(0, 6)
|
||||
return signature.equals(Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])) ? 2 : 0
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function safeFileSize(filePath: string): number | undefined {
|
||||
try {
|
||||
return fs.statSync(filePath).size
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isDecryptedDiagnostic(code: ImageDecodeDiagnostic['code']): boolean {
|
||||
return code === 'WXGF_REQUIRES_DECODER' || code === 'UNKNOWN_IMAGE_FORMAT'
|
||||
}
|
||||
|
||||
function getDecodeFailureMessage(
|
||||
diagnostic: ImageDecodeDiagnostic,
|
||||
decoder?: ImageDecoderStatus
|
||||
): string {
|
||||
switch (diagnostic.code) {
|
||||
case 'UNSUPPORTED_DAT_VERSION':
|
||||
return '仅支持 WeChat 4.0 图片协议,当前图片格式不受支持'
|
||||
case 'MISSING_AES_KEY':
|
||||
return '图片密钥未配置'
|
||||
case 'AES_DECRYPT_FAILED':
|
||||
return '图片密钥与当前账号不匹配,或图片文件已损坏'
|
||||
case 'INVALID_DAT_FILE':
|
||||
return '图片文件不完整或格式异常'
|
||||
case 'WXGF_REQUIRES_DECODER':
|
||||
return decoder?.available
|
||||
? 'WXGF/HEVC 图片转换失败,请复制测试日志反馈'
|
||||
: '该图片需要 FFmpeg 的 HEVC 解码能力'
|
||||
case 'UNKNOWN_IMAGE_FORMAT':
|
||||
return '图片已解密,但当前格式无法识别'
|
||||
default:
|
||||
return '无法解析媒体文件'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildImageTestDiagnosticLog(input: {
|
||||
request: TestImageDecryptionRequest
|
||||
result: Omit<ImageDecryptionTestResult, 'diagnosticLog'>
|
||||
startedAt: number
|
||||
testedImage?: { md5?: string; datName?: string; sessionId?: string; selection: string }
|
||||
filePath?: string
|
||||
decodeDiagnostic?: ImageDecodeDiagnostic
|
||||
decoder?: ImageDecoderStatus
|
||||
}): string {
|
||||
const root = String(input.request.resourceRoot || '').trim()
|
||||
const rootExists = root ? fs.existsSync(root) : false
|
||||
const rootIsDirectory = rootExists ? safeIsDirectory(root) : false
|
||||
const resultCode = input.result.success ? 'SUCCESS' : input.result.code || 'UNKNOWN'
|
||||
return [
|
||||
'WechatExplorer 图片解析测试日志(已脱敏)',
|
||||
`时间:${new Date().toISOString()}`,
|
||||
`应用版本:${safeAppVersion()}`,
|
||||
`运行环境:${process.platform} ${process.arch}`,
|
||||
`测试结果:${input.result.success ? '成功' : '失败'}(${resultCode})`,
|
||||
`耗时:${Date.now() - input.startedAt} ms`,
|
||||
'',
|
||||
'[配置]',
|
||||
`资源目录:${root ? `已填写(末级 ${redactIdentifier(path.basename(root))})` : '未填写'}`,
|
||||
`目录存在:${yesNo(rootExists)}`,
|
||||
`目录可读取:${yesNo(rootIsDirectory)}`,
|
||||
`包含图片目录:${yesNo(rootIsDirectory && hasImageDirectory(root))}`,
|
||||
`AES 密钥:${input.request.aesKey.trim().length === 16 ? '已配置(长度有效,内容未记录)' : '未配置或长度无效'}`,
|
||||
`XOR Key:${/^0x[0-9a-f]{2}$/i.test(input.request.xorKey.trim()) ? '格式有效(内容未记录)' : '格式无效'}`,
|
||||
'',
|
||||
'[测试样本]',
|
||||
`选取方式:${input.testedImage?.selection || '未选取'}`,
|
||||
`会话定位信息:${input.testedImage?.sessionId ? '有' : '无'}`,
|
||||
`图片 MD5:${redactIdentifier(input.testedImage?.md5)}`,
|
||||
`DAT 文件名:${redactFileName(input.testedImage?.datName)}`,
|
||||
'',
|
||||
'[文件查找]',
|
||||
'查找方式:异步会话目录 + Hardlink 索引',
|
||||
`找到文件:${yesNo(input.result.fileFound)}`,
|
||||
`文件来源:${input.filePath ? describeFileSource(input.filePath) : '无'}`,
|
||||
`清晰度:${input.filePath ? (input.result.isThumbnail ? '缩略图' : '原图/高清变体') : '未知'}`,
|
||||
`文件大小:${formatBytes(input.decodeDiagnostic?.fileSize ?? (input.filePath ? safeFileSize(input.filePath) : undefined))}`,
|
||||
`DAT 协议:${formatDatProtocol(input.decodeDiagnostic, input.filePath)}`,
|
||||
'',
|
||||
'[解析结果]',
|
||||
`文件找到:${yesNo(input.result.fileFound)}`,
|
||||
`数据解密:${yesNo(input.result.decrypted)}`,
|
||||
`图片可读:${yesNo(input.result.readable)}`,
|
||||
`诊断代码:${input.decodeDiagnostic?.code || resultCode}`,
|
||||
`诊断说明:${input.decodeDiagnostic?.detail || input.result.error || '无'}`,
|
||||
`图片格式:${input.decodeDiagnostic?.imageFormat || '未识别'}`,
|
||||
`WXGF/HEVC:${input.decodeDiagnostic?.wxgf ? '是' : '否/未检测'}`,
|
||||
`FFmpeg:${formatDecoder(input.decoder)}`,
|
||||
'',
|
||||
`建议:${buildDiagnosticAdvice(resultCode, input.decodeDiagnostic, input.decoder)}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function safeAppVersion(): string {
|
||||
try {
|
||||
return app.getVersion()
|
||||
} catch {
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
function safeIsDirectory(value: string): boolean {
|
||||
try {
|
||||
return fs.statSync(value).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function redactIdentifier(value?: string): string {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return '无'
|
||||
if (normalized.length <= 8) return `${normalized.slice(0, 2)}***`
|
||||
return `${normalized.slice(0, 4)}…${normalized.slice(-4)}`
|
||||
}
|
||||
|
||||
function redactFileName(value?: string): string {
|
||||
const normalized = path.basename(String(value || '').trim())
|
||||
if (!normalized) return '无'
|
||||
const extension = path.extname(normalized)
|
||||
const stem = extension ? normalized.slice(0, -extension.length) : normalized
|
||||
return `${redactIdentifier(stem)}${extension.toLowerCase()}`
|
||||
}
|
||||
|
||||
function describeFileSource(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/').toLowerCase()
|
||||
if (normalized.includes('/msg/attach/')) return 'msg/attach'
|
||||
if (normalized.includes('/cache/')) return 'cache'
|
||||
if (normalized.includes('/filestorage/')) return 'FileStorage'
|
||||
return '其他本地目录(完整路径未记录)'
|
||||
}
|
||||
|
||||
function yesNo(value: boolean): string {
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
|
||||
function formatBytes(value?: number): string {
|
||||
if (!Number.isFinite(value)) return '未知'
|
||||
if ((value as number) < 1024) return `${value} B`
|
||||
return `${((value as number) / 1024).toFixed(1)} KiB`
|
||||
}
|
||||
|
||||
function formatDatVersion(value?: number): string {
|
||||
if (value === 2) return 'WeChat 4.0 V2'
|
||||
if (value === 0) return '不受支持/旧版格式'
|
||||
return '未检测'
|
||||
}
|
||||
|
||||
function formatDatProtocol(diagnostic?: ImageDecodeDiagnostic, filePath?: string): string {
|
||||
if (diagnostic?.code === 'DIRECT_IMAGE') return '明文图片(无需 DAT 解密)'
|
||||
return formatDatVersion(
|
||||
diagnostic?.datVersion ?? (filePath ? inspectDatVersion(filePath) : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function formatDecoder(decoder?: ImageDecoderStatus): string {
|
||||
if (!decoder) return '未检测(当前失败阶段不需要)'
|
||||
if (!decoder.installed) return '未安装'
|
||||
return decoder.available
|
||||
? `可用(${decoder.source},支持 HEVC)`
|
||||
: `已安装但不支持 HEVC(${decoder.source})`
|
||||
}
|
||||
|
||||
function buildDiagnosticAdvice(
|
||||
resultCode: string,
|
||||
diagnostic?: ImageDecodeDiagnostic,
|
||||
decoder?: ImageDecoderStatus
|
||||
): string {
|
||||
if (resultCode === 'SUCCESS') return '图片解析正常,无需处理。'
|
||||
if (resultCode === 'FILE_NOT_FOUND') {
|
||||
return '确认图片资源目录属于当前微信账号,并在最近发送过图片的会话中重新测试。'
|
||||
}
|
||||
switch (diagnostic?.code) {
|
||||
case 'UNSUPPORTED_DAT_VERSION':
|
||||
return '换一张由微信 4.0 接收或发送的近期图片测试。'
|
||||
case 'AES_DECRYPT_FAILED':
|
||||
case 'MISSING_AES_KEY':
|
||||
return '重新获取当前微信账号的图片密钥后再测试。'
|
||||
case 'INVALID_DAT_FILE':
|
||||
return '在微信中重新打开或下载该图片,再重新测试。'
|
||||
case 'WXGF_REQUIRES_DECODER':
|
||||
return decoder?.available
|
||||
? 'FFmpeg 已可用但转换失败,请将本日志发给开发者。'
|
||||
: '安装或重新选择支持 HEVC 的 FFmpeg 后再测试。'
|
||||
case 'UNKNOWN_IMAGE_FORMAT':
|
||||
return '请将本日志发给开发者,并换一张近期普通图片交叉测试。'
|
||||
default:
|
||||
return inputAdviceForCode(resultCode)
|
||||
}
|
||||
}
|
||||
|
||||
function inputAdviceForCode(resultCode: string): string {
|
||||
if (resultCode === 'NO_CONVERSATION') return '先连接微信账号并选择一条聊天记录。'
|
||||
if (resultCode === 'NO_IMAGE_MESSAGE') return '换一个最近 300 条消息内包含图片的会话。'
|
||||
if (resultCode === 'NOT_CONFIGURED') return '检查资源目录、AES 密钥和 XOR Key 格式。'
|
||||
return '请将本日志发给开发者进一步排查。'
|
||||
}
|
||||
|
||||
function hasImageDirectory(accountRoot: string): boolean {
|
||||
if (!accountRoot) return false
|
||||
return [
|
||||
|
||||
+95
-18
@@ -258,6 +258,8 @@ export class Wcdb4Client {
|
||||
private sessionStatusesInFlight: Promise<void> | null = null
|
||||
private sessionStatusesUpdatedAt = 0
|
||||
private sessionCacheGeneration = 0
|
||||
private closing = false
|
||||
private nativeCallsInFlight = new Set<Promise<unknown>>()
|
||||
|
||||
private wcdbShutdown: (() => number) | null = null
|
||||
private wcdbOpenAccount:
|
||||
@@ -535,17 +537,12 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
const handleOut: WcdbHandleOut = [0]
|
||||
const openResult = await new Promise<number>((resolve, reject) => {
|
||||
this.wcdbOpenAccountAsync!.async(
|
||||
this.sessionDbPath,
|
||||
this.key,
|
||||
handleOut,
|
||||
(error: unknown, result: unknown) => {
|
||||
if (error) reject(error)
|
||||
else resolve(Number(result))
|
||||
}
|
||||
)
|
||||
})
|
||||
const openResult = await this.callAsyncCode(
|
||||
this.wcdbOpenAccountAsync,
|
||||
this.sessionDbPath,
|
||||
this.key,
|
||||
handleOut
|
||||
)
|
||||
if (openResult !== 0 || handleOut[0] <= 0) {
|
||||
throw new Error(
|
||||
`wcdb_open_account failed, code=${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}`
|
||||
@@ -566,19 +563,49 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closing = true
|
||||
this.stopMonitor()
|
||||
if (this.handle === null || !this.wcdbShutdown) return
|
||||
const canShutdownNative = this.nativeCallsInFlight.size === 0
|
||||
if (!canShutdownNative) {
|
||||
console.warn(
|
||||
`[WCDB4] skip synchronous native shutdown; ${this.nativeCallsInFlight.size} async call(s) still running`
|
||||
)
|
||||
}
|
||||
this.finishClose(canShutdownNative)
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
async closeAsync(timeoutMs = 8_000): Promise<boolean> {
|
||||
this.closing = true
|
||||
this.stopMonitorTransport()
|
||||
const drained = await this.waitForNativeCalls(timeoutMs)
|
||||
this.stopNativeMonitor()
|
||||
if (!drained) {
|
||||
console.warn(
|
||||
`[WCDB4] native calls did not drain within ${timeoutMs}ms; skip wcdb_shutdown during app quit`
|
||||
)
|
||||
}
|
||||
this.finishClose(drained)
|
||||
return drained
|
||||
}
|
||||
|
||||
private finishClose(shutdownNative: boolean): void {
|
||||
if (
|
||||
shutdownNative &&
|
||||
this.handle !== null &&
|
||||
this.wcdbShutdown &&
|
||||
process.platform !== 'win32'
|
||||
) {
|
||||
try {
|
||||
this.wcdbShutdown()
|
||||
} catch {
|
||||
// Shutdown is best-effort on app close.
|
||||
// Shutdown is best-effort after all tracked native calls have completed.
|
||||
}
|
||||
}
|
||||
|
||||
this.handle = null
|
||||
this.cachedSessions = null
|
||||
this.cachedChatTables = null
|
||||
this.sessionsInFlight = null
|
||||
this.sessionDisplayNamesInFlight = null
|
||||
this.sessionDisplayNamesHydrated = false
|
||||
this.sessionStatusesInFlight = null
|
||||
this.sessionStatusesUpdatedAt = 0
|
||||
@@ -588,8 +615,32 @@ export class Wcdb4Client {
|
||||
this.groupNicknameCache.clear()
|
||||
}
|
||||
|
||||
private waitForNativeCalls(timeoutMs: number): Promise<boolean> {
|
||||
const pending = Array.from(this.nativeCallsInFlight)
|
||||
if (pending.length === 0) return Promise.resolve(true)
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false
|
||||
const finish = (drained: boolean): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve(drained)
|
||||
}
|
||||
const timer = setTimeout(() => finish(false), timeoutMs)
|
||||
void Promise.allSettled(pending).then(() => {
|
||||
// Koffi promise resolution can resume higher-level async cleanup (for example,
|
||||
// closing a message cursor) in the same event-loop turn. Give those continuations
|
||||
// one full turn before shutting down the process-wide WCDB runtime.
|
||||
setImmediate(() => finish(this.nativeCallsInFlight.size === 0))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async startMonitor(callback: (type: string, json: string) => void): Promise<boolean> {
|
||||
if (!this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) return false
|
||||
if (this.closing || !this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.stopMonitor()
|
||||
this.monitorCallback = callback
|
||||
@@ -604,6 +655,10 @@ export class Wcdb4Client {
|
||||
return false
|
||||
}
|
||||
this.monitorStarted = true
|
||||
if (this.closing) {
|
||||
this.stopMonitor()
|
||||
return false
|
||||
}
|
||||
|
||||
const outName: WcdbVoidOut = [null]
|
||||
const nameResult = await this.callAsyncCode(
|
||||
@@ -637,6 +692,11 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
stopMonitor(): void {
|
||||
this.stopMonitorTransport()
|
||||
this.stopNativeMonitor()
|
||||
}
|
||||
|
||||
private stopMonitorTransport(): void {
|
||||
this.monitorCallback = null
|
||||
|
||||
if (this.monitorConnectTimer) {
|
||||
@@ -651,6 +711,9 @@ export class Wcdb4Client {
|
||||
this.monitorPipeClient.destroy()
|
||||
this.monitorPipeClient = null
|
||||
}
|
||||
}
|
||||
|
||||
private stopNativeMonitor(): void {
|
||||
if (this.monitorStarted && this.wcdbStopMonitorPipe) {
|
||||
try {
|
||||
this.wcdbStopMonitorPipe()
|
||||
@@ -2218,7 +2281,7 @@ export class Wcdb4Client {
|
||||
private callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> {
|
||||
const handle = this.ensureHandle()
|
||||
const outJson: WcdbVoidOut = [null]
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
return this.createTrackedNativeCall<T>((resolve, reject) => {
|
||||
fn.async(handle, ...args, outJson, (error: unknown, result: unknown) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
@@ -2242,7 +2305,7 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
private callAsyncCode(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
return this.createTrackedNativeCall<number>((resolve, reject) => {
|
||||
fn.async(...args, (error: unknown, result: unknown) => {
|
||||
if (error) reject(error)
|
||||
else resolve(Number(result))
|
||||
@@ -2250,7 +2313,21 @@ export class Wcdb4Client {
|
||||
})
|
||||
}
|
||||
|
||||
private createTrackedNativeCall<T>(
|
||||
executor: (resolve: (value: T) => void, reject: (reason?: unknown) => void) => void
|
||||
): Promise<T> {
|
||||
if (this.closing) return Promise.reject(new Error('微信数据库正在关闭'))
|
||||
const promise = new Promise<T>(executor)
|
||||
this.nativeCallsInFlight.add(promise)
|
||||
void promise.then(
|
||||
() => this.nativeCallsInFlight.delete(promise),
|
||||
() => this.nativeCallsInFlight.delete(promise)
|
||||
)
|
||||
return promise
|
||||
}
|
||||
|
||||
private ensureHandle(): number {
|
||||
if (this.closing) throw new Error('微信数据库正在关闭')
|
||||
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
|
||||
return this.handle
|
||||
}
|
||||
|
||||
@@ -156,6 +156,10 @@ export class WechatDb {
|
||||
this.wcdb4Client.close()
|
||||
}
|
||||
|
||||
public closeAsync(timeoutMs?: number): Promise<boolean> {
|
||||
return this.wcdb4Client.closeAsync(timeoutMs)
|
||||
}
|
||||
|
||||
public getUserMessages(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
|
||||
Reference in New Issue
Block a user