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

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