From c70e49bf1663199daf02c912f51bb124eada77f6 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Tue, 4 Aug 2026 12:03:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E4=B8=8E=E5=AF=BC=E5=87=BA=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复引用消息名称和图片布局 - 明确单会话图片测试日志范围 - 支持导出文件附件 - 完善图片批测、会话刷新和安全退出 --- src/main/export-html-template.ts | 6 +- src/main/export-service.ts | 21 +- src/main/file-asset-service.ts | 122 ++++++ src/main/image-decrypt-service.ts | 158 +++++++- src/main/index.ts | 62 ++- src/main/message-parser.ts | 12 +- src/main/services/chat-service.ts | 22 +- .../image-decryption-status-service.ts | 375 ++++++++++++++++-- src/main/wcdb4-client.ts | 113 +++++- src/main/wechat-db.ts | 4 + src/renderer/src/App.tsx | 105 ++--- .../src/components/RichMessageBubble.tsx | 5 +- .../conversation/ConversationSidebar.tsx | 17 +- .../ConversationSidebarHeader.tsx | 22 +- .../src/components/export/ExportWorkspace.tsx | 9 +- .../src/components/export/exportUtils.ts | 1 + .../image-decryption/ImageTestSection.tsx | 287 ++++++++++++-- .../settings/image-decryption/types.ts | 28 ++ .../useImageDecryptionController.ts | 142 ++++++- .../settings/image-decryption/utils.ts | 10 + .../settings/pages/ImageDecryptionPage.tsx | 4 + src/renderer/src/styles/chat.scss | 6 + src/renderer/src/styles/conversation.scss | 52 ++- src/renderer/src/styles/settings.scss | 246 +++++++++++- src/renderer/src/utils/quoted-messages.ts | 79 ++++ src/shared/export.ts | 1 + src/shared/image-decryption.ts | 1 + src/shared/types.ts | 3 +- .../conversation-sidebar-header.test.tsx | 38 ++ tests/component/image-bubble.test.tsx | 21 + .../component/image-decryption-test.test.tsx | 183 +++++++++ tests/e2e/support/electron-main.cjs | 3 +- tests/integration/export-media-flow.test.ts | 20 +- tests/unit/chat-service.test.ts | 33 +- tests/unit/export-media.test.ts | 9 + tests/unit/file-asset-service.test.ts | 30 ++ tests/unit/image-decrypt.test.ts | 86 +++- .../unit/image-decryption-diagnostic.test.ts | 117 ++++++ tests/unit/message-parser.test.ts | 13 + tests/unit/quoted-messages.test.ts | 35 ++ tests/unit/wcdb-close.test.ts | 40 ++ 41 files changed, 2320 insertions(+), 221 deletions(-) create mode 100644 src/main/file-asset-service.ts create mode 100644 src/renderer/src/utils/quoted-messages.ts create mode 100644 tests/component/conversation-sidebar-header.test.tsx create mode 100644 tests/component/image-decryption-test.test.tsx create mode 100644 tests/unit/file-asset-service.test.ts create mode 100644 tests/unit/image-decryption-diagnostic.test.ts create mode 100644 tests/unit/quoted-messages.test.ts create mode 100644 tests/unit/wcdb-close.test.ts diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index bd7041d..2d91937 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -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 { ? `` : m.exportMediaUrl && m.exportMediaType === 'sticker' ? `表情包` - : '' + : m.exportMediaUrl && m.exportMediaType === 'file' + ? `${safe(m.exportMediaName || (m.contentData?.type === 'share' ? m.contentData.title : '') || '下载文件')}` + : '' const avatarMarkup = m.exportShowAvatar === false ? '' diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 73b0673..dbe67eb 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -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() 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() 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, diff --git a/src/main/file-asset-service.ts b/src/main/file-asset-service.ts new file mode 100644 index 0000000..ddf2ada --- /dev/null +++ b/src/main/file-asset-service.ts @@ -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) {} + + 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 + } + } +} diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index a646438..cbb85f4 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -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 | 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))) } diff --git a/src/main/index.ts b/src/main/index.ts index c6fb1e1..49b036e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index 5421a2a..5b095ca 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -590,10 +590,10 @@ function parseQuoteMessage(content: string): { if (referMsgStart === -1 || referMsgEnd === -1) return {} const referMsgXml = content.substring(referMsgStart, referMsgEnd + ''.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, '') diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 13494b3..b55a2ad 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -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 { + 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 diff --git a/src/main/services/image-decryption-status-service.ts b/src/main/services/image-decryption-status-service.ts index 6d09f06..8da7a11 100644 --- a/src/main/services/image-decryption-status-service.ts +++ b/src/main/services/image-decryption-status-service.ts @@ -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 { + 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 => ({ + ...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 + 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 [ diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index ea2288a..29576b8 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -258,6 +258,8 @@ export class Wcdb4Client { private sessionStatusesInFlight: Promise | null = null private sessionStatusesUpdatedAt = 0 private sessionCacheGeneration = 0 + private closing = false + private nativeCallsInFlight = new Set>() private wcdbShutdown: (() => number) | null = null private wcdbOpenAccount: @@ -535,17 +537,12 @@ export class Wcdb4Client { } const handleOut: WcdbHandleOut = [0] - const openResult = await new Promise((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 { + 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 { + const pending = Array.from(this.nativeCallsInFlight) + if (pending.length === 0) return Promise.resolve(true) + + return new Promise((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 { - 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(fn: KoffiAsyncFunction, ...args: unknown[]): Promise { const handle = this.ensureHandle() const outJson: WcdbVoidOut = [null] - return new Promise((resolve, reject) => { + return this.createTrackedNativeCall((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 { - return new Promise((resolve, reject) => { + return this.createTrackedNativeCall((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( + executor: (resolve: (value: T) => void, reject: (reason?: unknown) => void) => void + ): Promise { + if (this.closing) return Promise.reject(new Error('微信数据库正在关闭')) + const promise = new Promise(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 } diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index 8640d2d..bde5a9b 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -156,6 +156,10 @@ export class WechatDb { this.wcdb4Client.close() } + public closeAsync(timeoutMs?: number): Promise { + return this.wcdb4Client.closeAsync(timeoutMs) + } + public getUserMessages( userMd5: string, startTime?: number, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index df82b25..88e4fb5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -30,6 +30,7 @@ import { mergeMessagePages, sortMessagesChronologically } from './utils/message-pages' +import { enrichQuotedMessages } from './utils/quoted-messages' const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MAX_WIDTH = 380 @@ -70,67 +71,6 @@ const INITIAL_MESSAGE_COUNT = 20 const MESSAGE_PAGE_SIZE = 100 const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE const EXPORT_PREVIEW_LIMIT = 20 -const normalizeQuotedText = (value: string | undefined): string => - String(value || '') - .replace(/\s+/g, ' ') - .trim() - -const isInternalReferenceSender = (value: string | undefined): boolean => { - const sender = String(value || '').trim() - return ( - !sender || - sender.endsWith('@chatroom') || - sender.startsWith('wxid_') || - /^[a-z0-9_@.-]{12,}$/i.test(sender) - ) -} - -const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]): Message[] => { - const imageDatNameByMd5 = new Map() - const messagesByContent = new Map() - - for (const message of referenceMessages) { - if ( - message.contentData?.type === 'image' && - message.contentData.md5 && - message.contentData.datName - ) { - imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName) - } - const content = normalizeQuotedText(message.content) - if (!content) continue - const candidates = messagesByContent.get(content) || [] - candidates.push(message) - messagesByContent.set(content, candidates) - } - - return messages.map((message) => { - if (message.contentData?.type !== 'quote') return message - const quote = message.contentData - let quotedImageDatName = quote.quotedImageDatName - if (!quotedImageDatName && quote.quotedImageMd5) { - quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5) - } - - let quotedSender = quote.quotedSender - if (isInternalReferenceSender(quotedSender)) { - const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || [] - const source = candidates - .filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity)) - .sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0] - if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name - } - - if (quotedSender === quote.quotedSender && quotedImageDatName === quote.quotedImageDatName) { - return message - } - return { - ...message, - contentData: { ...quote, quotedSender, quotedImageDatName } - } - }) -} - const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => { if (left === right) return true if (left.length !== right.length) return false @@ -140,6 +80,16 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => { return true } +const filterContactList = (list: Contact[], keyword: string): Contact[] => { + const lower = keyword.trim().toLowerCase() + if (!lower) return list + return list.filter((contact) => + [contact.m_nsNickName, contact.m_nsUsrName, contact.remark, contact.wechatNickname].some( + (value) => value?.toLowerCase().includes(lower) + ) + ) +} + type GroupSnapshot = { roomId: string memberCount: number @@ -501,11 +451,12 @@ function App(): React.ReactElement { const loadContacts = async (options?: { waitForAvatars?: boolean onProgress?: (message: string, percent?: number) => void + filterKeyword?: string }): Promise => { options?.onProgress?.('正在加载联系人...', 35) const list = await window.api.getContacts() setContacts(list) - setFilteredContacts(list) + setFilteredContacts(filterContactList(list, options?.filterKeyword || '')) const runId = ++contactAvatarHydrationRunRef.current const hydrate = (): Promise => hydrateContactAvatars(list, runId, options?.onProgress) if (options?.waitForAvatars) { @@ -886,11 +837,12 @@ function App(): React.ReactElement { const applyGroupMemberMeta = React.useCallback( (contact: Contact | null, baseMessages: Message[]): Message[] => { if (!contact || contact.type !== 'group') return baseMessages - const enrichedMessages = enrichQuotedMessages(baseMessages, [ - ...messageHistoryRef.current, - ...baseMessages - ]) const memberMap = groupMemberMetaRef.current[contact.md5] + const enrichedMessages = enrichQuotedMessages( + baseMessages, + [...messageHistoryRef.current, ...baseMessages], + (senderId) => memberMap?.get(senderId)?.nickname + ) if (!memberMap || memberMap.size === 0) return enrichedMessages return enrichedMessages.map((message) => { @@ -1347,16 +1299,16 @@ function App(): React.ReactElement { ]) const handleSearchContacts = (keyword: string): void => { - if (!keyword) { - setFilteredContacts(contacts) - } else { - const lower = keyword.toLowerCase() - const filtered = contacts.filter( - (c) => - c.m_nsNickName.toLowerCase().includes(lower) || - c.m_nsUsrName.toLowerCase().includes(lower) - ) - setFilteredContacts(filtered) + setFilteredContacts(filterContactList(contacts, keyword)) + } + + const handleRefreshContacts = async (filterKeyword: string): Promise => { + try { + await loadContacts({ waitForAvatars: false, filterKeyword }) + setReportNotice('会话列表已刷新') + } catch (error) { + console.warn('[Contacts] refresh failed:', error) + setReportNotice('会话列表刷新失败,请稍后重试') } } @@ -1588,6 +1540,7 @@ function App(): React.ReactElement { dbReady={isDatabaseConnected} dbConnecting={isDatabaseConnecting} onOpenSettings={openSettings} + onRefresh={handleRefreshContacts} />
-
+
{quotedSender && {quotedSender}} - {data.quotedImageMd5 || data.quotedImageDatName ? ( + {hasQuotedImage ? ( void + onRefresh: (filterKeyword: string) => Promise } type SectionName = 'groups' | 'folded' | 'contacts' @@ -39,9 +40,11 @@ export function ConversationSidebar({ selfInfo, dbReady, dbConnecting = false, - onOpenSettings + onOpenSettings, + onRefresh }: ConversationSidebarProps): React.ReactElement { const [searchTerm, setSearchTerm] = useState('') + const [isRefreshing, setIsRefreshing] = useState(false) const [expandedSections, setExpandedSections] = useState>({ groups: true, folded: false, @@ -119,12 +122,24 @@ export function ConversationSidebar({ onSearch(term) } + const handleRefresh = async (): Promise => { + if (isRefreshing) return + setIsRefreshing(true) + try { + await onRefresh(searchTerm) + } finally { + setIsRefreshing(false) + } + } + return (