diff --git a/electron-builder.yml b/electron-builder.yml index 9a7997e..9999579 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -15,6 +15,8 @@ extraMetadata: main: out/main/index.js asarUnpack: - resources/** + - node_modules/ffmpeg-static/** + - node_modules/silk-wasm/** extraResources: # Includes the optional WeChat connector binary for the target platform. - from: resources diff --git a/package.json b/package.json index e5cdf73..67fe05f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wechatexplorer", - "version": "2.1.7", + "version": "2.1.8", "description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手", "keywords": [ "wechat", @@ -60,6 +60,7 @@ "@tanstack/react-virtual": "^3.14.6", "cross-env": "^10.1.0", "electron-updater": "^6.6.2", + "ffmpeg-static": "5.3.0", "fs-extra": "^11.3.2", "fzstd": "^0.1.1", "jsonrepair": "^3.15.0", @@ -113,7 +114,8 @@ }, "onlyBuiltDependencies": [ "electron", - "esbuild" + "esbuild", + "ffmpeg-static" ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc19f3a..414630e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3,6 +3,7 @@ lockfileVersion: 5.4 onlyBuiltDependencies: - electron - esbuild + - ffmpeg-static specifiers: '@electron-toolkit/eslint-config-prettier': ^3.0.0 @@ -33,6 +34,7 @@ specifiers: eslint-plugin-react: ^7.37.5 eslint-plugin-react-hooks: ^7.0.1 eslint-plugin-react-refresh: ^0.4.24 + ffmpeg-static: 5.3.0 fs-extra: ^11.3.2 fzstd: ^0.1.1 jsdom: ^30.0.1 @@ -56,6 +58,7 @@ dependencies: '@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq cross-env: 10.1.0 electron-updater: 6.8.9 + ffmpeg-static: 5.3.0 fs-extra: 11.3.2 fzstd: 0.1.1 jsonrepair: 3.15.0 @@ -405,6 +408,16 @@ packages: engines: {node: '>=20.19.0'} dev: true + /@derhuerst/http-basic/8.2.4: + resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==} + engines: {node: '>=6.0.0'} + dependencies: + caseless: 0.12.0 + concat-stream: 2.0.0 + http-response-object: 3.0.2 + parse-cache-control: 1.0.1 + dev: false + /@develar/schema-utils/2.6.5: resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} @@ -1600,6 +1613,10 @@ packages: resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} dev: true + /@types/node/10.17.60: + resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + dev: false + /@types/node/22.19.2: resolution: {integrity: sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==} dependencies: @@ -1915,7 +1932,6 @@ packages: debug: 4.4.3 transitivePeerDependencies: - supports-color - dev: true /agent-base/7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} @@ -2232,7 +2248,6 @@ packages: /buffer-from/1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - dev: true /buffer/5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -2369,6 +2384,10 @@ packages: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} dev: true + /caseless/0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + dev: false + /chai/6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -2481,6 +2500,16 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true + /concat-stream/2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + dev: false + /config-file-ts/0.2.8-rc1: resolution: {integrity: sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==} dependencies: @@ -2850,7 +2879,6 @@ packages: /env-paths/2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} - dev: true /env-paths/3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} @@ -3274,6 +3302,19 @@ packages: picomatch: 4.0.5 dev: true + /ffmpeg-static/5.3.0: + resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==} + engines: {node: '>=16'} + requiresBuild: true + dependencies: + '@derhuerst/http-basic': 8.2.4 + env-paths: 2.2.1 + https-proxy-agent: 5.0.1 + progress: 2.0.3 + transitivePeerDependencies: + - supports-color + dev: false + /file-entry-cache/8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3646,6 +3687,12 @@ packages: - supports-color dev: true + /http-response-object/3.0.2: + resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==} + dependencies: + '@types/node': 10.17.60 + dev: false + /http2-wrapper/1.0.3: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} @@ -3662,7 +3709,6 @@ packages: debug: 4.4.3 transitivePeerDependencies: - supports-color - dev: true /https-proxy-agent/7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} @@ -3747,7 +3793,6 @@ packages: /inherits/2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true /internal-slot/1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} @@ -4659,6 +4704,10 @@ packages: callsites: 3.1.0 dev: true + /parse-cache-control/1.0.1: + resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==} + dev: false + /parse5/8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} dependencies: @@ -4872,7 +4921,6 @@ packages: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 - dev: true /readdirp/5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} @@ -5020,7 +5068,6 @@ packages: /safe-buffer/5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - dev: true /safe-push-apply/1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} @@ -5354,7 +5401,6 @@ packages: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: safe-buffer: 5.2.1 - dev: true /strip-ansi/6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} @@ -5567,6 +5613,10 @@ packages: reflect.getprototypeof: 1.0.10 dev: true + /typedarray/0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + dev: false + /typescript-eslint/8.49.0_ohw7ybem3stexzc2ktgcr2xgzi: resolution: {integrity: sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5658,7 +5708,6 @@ packages: /util-deprecate/1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: true /verror/1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 83ae541..83295a1 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -1,15 +1,61 @@ -const { existsSync, renameSync } = require('node:fs') +const { chmodSync, existsSync, renameSync } = require('node:fs') const { execFileSync } = require('node:child_process') const path = require('node:path') const COMPATIBILITY_NAME = 'Electron' const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)'] +function getRuntimeResources(context) { + const productName = context.packager.appInfo.productFilename + return context.electronPlatformName === 'darwin' + ? path.join(context.appOutDir, `${productName}.app`, 'Contents', 'Resources') + : path.join(context.appOutDir, 'resources') +} + +function validateSilkWasmRuntime(runtimeResources) { + const packagePath = path.join(runtimeResources, 'app.asar.unpacked', 'node_modules', 'silk-wasm') + const requiredFiles = [ + path.join(packagePath, 'package.json'), + path.join(packagePath, 'lib', 'index.cjs'), + path.join(packagePath, 'lib', 'silk.wasm') + ] + const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath)) + if (missingFiles.length > 0) { + throw new Error(`Missing unpacked silk-wasm runtime: ${missingFiles.join(', ')}`) + } +} + +function validateFfmpegRuntime(runtimeResources, platform = process.platform) { + const executable = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' + const ffmpegPath = path.join( + runtimeResources, + 'app.asar.unpacked', + 'node_modules', + 'ffmpeg-static', + executable + ) + if (!existsSync(ffmpegPath)) { + throw new Error(`Missing unpacked ffmpeg-static runtime: ${ffmpegPath}`) + } + if (platform !== 'win32') chmodSync(ffmpegPath, 0o755) + return ffmpegPath +} + function setPlistValue(plistPath, key, value) { execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath]) } exports.default = async function afterPack(context) { + const runtimeResources = getRuntimeResources(context) + validateSilkWasmRuntime(runtimeResources) + const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName) + + if (context.electronPlatformName === 'darwin') { + execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], { + stdio: 'ignore' + }) + } + if (context.electronPlatformName === 'win32') { const koffiNative = path.join( context.appOutDir, @@ -69,3 +115,7 @@ exports.default = async function afterPack(context) { setPlistValue(plistPath, 'CFBundleName', targetName) } } + +exports.getRuntimeResources = getRuntimeResources +exports.validateFfmpegRuntime = validateFfmpegRuntime +exports.validateSilkWasmRuntime = validateSilkWasmRuntime diff --git a/scripts/prepare-electron-runtime.cjs b/scripts/prepare-electron-runtime.cjs index 86299d9..221d6a7 100644 --- a/scripts/prepare-electron-runtime.cjs +++ b/scripts/prepare-electron-runtime.cjs @@ -1,12 +1,8 @@ const fs = require('node:fs') +const { execFileSync } = require('node:child_process') const path = require('node:path') -const runtimeNames = [ - 'msvcp140.dll', - 'msvcp140_1.dll', - 'vcruntime140.dll', - 'vcruntime140_1.dll' -] +const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll'] function copyIfDifferent(sourcePath, targetPath) { const source = fs.statSync(sourcePath) @@ -23,7 +19,24 @@ function copyIfDifferent(sourcePath, targetPath) { return true } +function prepareFfmpegRuntime() { + let ffmpegPath = '' + try { + ffmpegPath = require('ffmpeg-static') || '' + } catch { + return + } + if (!ffmpegPath || !fs.existsSync(ffmpegPath) || process.platform === 'win32') return + fs.chmodSync(ffmpegPath, 0o755) + if (process.platform === 'darwin') { + execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], { + stdio: 'ignore' + }) + } +} + function main() { + prepareFfmpegRuntime() if (process.platform !== 'win32') return const projectRoot = path.resolve(__dirname, '..') diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index 1bdfc83..38bb627 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -147,6 +147,7 @@ body { } .avatar img { width: 100%; height: 100%; object-fit: cover; } .bubble { + min-width: 0; max-width: min(78%, 760px); padding: 13px 15px; border: 1px solid var(--border); @@ -157,9 +158,8 @@ body { .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, .audio { width: 260px; } -.audio-wrap { min-width: 260px; } -.audio { display: block; height: 38px; } +.audio-wrap { width: 260px; max-width: 100%; min-width: 0; } +.audio { display: block; width: 100%; max-width: 100%; height: 38px; } .media-status { margin-top: 8px; padding: 6px 8px; @@ -263,7 +263,7 @@ body { border-bottom-color: var(--accent); } .bubble { max-width: calc(100vw - 92px); } - .audio-wrap, .audio { width: min(260px, calc(100vw - 130px)); min-width: 0; } + .audio-wrap { width: min(260px, calc(100vw - 130px)); } } ` @@ -296,9 +296,19 @@ const renderExportScript = (name: string): string => ` let filtered = [] let windowStart = 0 let windowEnd = 0 - let loading = false + let scrollLoadPending = false + let scrollLoadSuppressed = false + let lastScrollTop = 0 let zoom = 1 + const nextFrame = (callback) => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(callback) + } else { + window.setTimeout(callback, 0) + } + } + const esc = (value) => String(value ?? '').replace( /[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] || character @@ -423,6 +433,18 @@ const renderExportScript = (name: string): string => ` const shown = Math.max(0, windowEnd - windowStart) count.textContent = '已显示 ' + shown + ' / 筛选 ' + filtered.length + ' / 全部 ' + allMessages.length } + const setScrollTop = (value) => { + scrollLoadSuppressed = true + const previousBehavior = list.style.scrollBehavior + list.style.scrollBehavior = 'auto' + list.scrollTop = value + lastScrollTop = list.scrollTop + nextFrame(() => { + list.style.scrollBehavior = previousBehavior + lastScrollTop = list.scrollTop + scrollLoadSuppressed = false + }) + } const renderWindow = (anchorIndex, anchorOffset) => { const visible = filtered.slice(windowStart, windowEnd) const before = windowStart > 0 ? '
向上滚动加载更早消息
' : '' @@ -432,7 +454,7 @@ const renderExportScript = (name: string): string => ` : '
没有符合条件的消息
可以更换筛选条件或关键词
' if (Number.isInteger(anchorIndex)) { const anchor = list.querySelector('.message[data-index="' + anchorIndex + '"]') - if (anchor) list.scrollTop = anchor.offsetTop - anchorOffset + if (anchor) setScrollTop(anchor.offsetTop - anchorOffset) } updateCount() updateActiveMonth() @@ -445,7 +467,7 @@ const renderExportScript = (name: string): string => ` windowEnd = Math.min(filtered.length, PAGE_SIZE) } renderWindow() - list.scrollTop = preferLatest ? list.scrollHeight : 0 + setScrollTop(preferLatest ? list.scrollHeight : 0) } const applyFilters = () => { const term = query.value.trim().toLowerCase() @@ -464,7 +486,7 @@ const renderExportScript = (name: string): string => ` windowStart = Math.max(0, windowEnd - PAGE_SIZE) renderWindow() const target = list.querySelector('.message[data-index="' + index + '"]') - list.scrollTop = target ? Math.max(0, target.offsetTop - 24) : 0 + setScrollTop(target ? Math.max(0, target.offsetTop - 24) : 0) timeline.querySelectorAll('.timeline-month').forEach((button) => { button.classList.toggle('active', button.dataset.month === key) }) @@ -485,18 +507,31 @@ const renderExportScript = (name: string): string => ` renderWindow(anchorIndex, anchorOffset) } + const scheduleWindowSlide = (direction) => { + if (scrollLoadPending) return + scrollLoadPending = true + nextFrame(() => { + if (direction < 0 ? windowStart > 0 : windowEnd < filtered.length) { + slideWindow(direction) + } + scrollLoadPending = false + }) + } list.addEventListener('scroll', () => { - if (loading) return - if (list.scrollTop < 180 && windowStart > 0) { - loading = true - slideWindow(-1) - loading = false - } else if (list.scrollHeight - list.scrollTop - list.clientHeight < 240 && windowEnd < filtered.length) { - loading = true - slideWindow(1) - loading = false - } + const currentTop = list.scrollTop + const movingUp = currentTop < lastScrollTop + const nearTop = currentTop < 180 + const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240 + lastScrollTop = currentTop + if (scrollLoadSuppressed) return + if (movingUp && nearTop) scheduleWindowSlide(-1) + else if (!movingUp && nearBottom) scheduleWindowSlide(1) }) + list.addEventListener('wheel', (event) => { + if (!scrollLoadSuppressed && event.deltaY < 0 && list.scrollTop <= 1) { + scheduleWindowSlide(-1) + } + }, { passive: true }) query.addEventListener('input', applyFilters) filters.addEventListener('click', (event) => { const button = event.target.closest('[data-kind]') diff --git a/src/main/export-service.ts b/src/main/export-service.ts index cd9fa9f..461d85b 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -19,6 +19,7 @@ import { VideoAssetService } from './video-asset-service' import { StickerService } from './sticker-service' import { getImageExportAttempts } from '../shared/export-media' import { FileAssetService } from './file-asset-service' +import { mergeCachedSelfInfo } from './services/bootstrap-cache' const jobs = new Set() const safeFilePart = (value: string): string => @@ -104,6 +105,26 @@ export function mergeHtmlArchiveMessages( }) } +export function normalizeHtmlArchiveSelfNames( + messages: Message[], + selfInfo: { wxid: string; nickname: string } | null +): Message[] { + const wxid = String(selfInfo?.wxid || '').trim() + const nickname = String(selfInfo?.nickname || '').trim() + if (!nickname || nickname === wxid || /^wxid_/i.test(nickname)) return messages + return messages.map((message) => { + if (!message.isSender) return message + const currentName = String(message.name || '').trim() + const senderId = String(message.senderId || '').trim() + const usesRawAccount = + !currentName || + currentName === wxid || + (senderId === wxid && currentName === senderId) || + /^wxid_/i.test(currentName) + return usesRawAccount ? { ...message, name: nickname } : message + }) +} + export async function readHtmlArchive( outputDir: string, sourceId: string, @@ -282,6 +303,15 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro const messages = ( await chat.listMessagesAsync(request.userMd5, request.startTime, request.endTime) ).filter((message) => request.kinds.includes(kindOf(message))) + const rawSelfInfo = await chat.getSelfAccountInfoAsync() + const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null + const isUsableSelfName = (value: string | undefined): value is string => { + const name = String(value || '').trim() + if (!name) return false + if (name === selfInfo?.wxid) return false + if (/^wxid_/i.test(name)) return false + return true + } for (const message of messages) { message.exportMediaUrl = undefined message.exportMediaType = undefined @@ -290,7 +320,11 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro message.voiceDataUrl = undefined message.exportShowAvatar = request.includeAvatars !== false const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined - if (mappedName) message.name = mappedName + if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) { + message.name = mappedName + } else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) { + message.name = selfInfo.nickname + } if ( request.format !== 'html' && ['image', 'video', 'voice', 'sticker', 'file'].includes(kindOf(message)) @@ -431,21 +465,31 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro let decryptedImage: { data: string; filePath: string } | null = null let usedFallback = false for (const attempt of getImageExportAttempts(request)) { - const file = imageService.findImageFile( + const file = await imageService.findImageFileAsync( message.contentData.md5, message.contentData.datName, { allowThumbnail: attempt.allowThumbnail, preferThumbnail: attempt.preferThumbnail, - sessionId: message.sessionId + sessionId: message.sessionId, + sessionMd5: request.userMd5, + createTime: message.createTime } ) if (!file) continue fileFound = true - const decrypted = imageService.decryptImageToBase64WithFallback( + let decrypted = await imageService.decryptImageToBase64WithFallbackAsync( file, attempt.allowThumbnail ) + // Worker 启动异常时,普通 JPEG/PNG 仍可由主进程同步解析; + // WXGF/HEVC 原图则以 Worker 的 FFmpeg 结果为准。 + if (!decrypted) { + decrypted = imageService.decryptImageToBase64WithFallback( + file, + attempt.allowThumbnail + ) + } if (!decrypted) continue decryptedImage = decrypted usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath) @@ -536,12 +580,17 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75) }) } + const mergedMessages = mergeHtmlArchiveMessages( + previousArchive.messages, + messages, + request.userMd5 + ) const archive: HtmlExportArchive = { version: 1, sourceId: request.userMd5, name: request.name, exportedAt: new Date().toISOString(), - messages: mergeHtmlArchiveMessages(previousArchive.messages, messages, request.userMd5) + messages: normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo) } await fs.writeFile(outputPath, renderExportPage(request.name), 'utf8') await writeHtmlArchive(outputDir, archive) diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index cbb85f4..e197b21 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -5,6 +5,7 @@ import os from 'os' import { app } from 'electron' import { execFile } from 'child_process' import { Worker } from 'worker_threads' +import ffmpegStaticPath from 'ffmpeg-static' import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption' import { loadSettings } from './services/settings-store' import { Wcdb4Client } from './wcdb4-client' @@ -48,6 +49,8 @@ type ImageFindOptions = { accountDir?: string preferThumbnail?: boolean sessionId?: string + sessionMd5?: string + createTime?: number } const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024 @@ -72,9 +75,17 @@ function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCa const selected = String(selectedPath || '').trim() const environment = String(process.env['FFMPEG_BIN'] || '').trim() const executable = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' + const staticExecutable = String(ffmpegStaticPath || '') + .replace('app.asar', 'app.asar.unpacked') + .trim() const candidates: FfmpegCandidate[] = [ ...(selected ? [{ executable: selected, source: 'selected' as const }] : []), ...(environment ? [{ executable: environment, source: 'environment' as const }] : []), + ...(staticExecutable ? [{ executable: staticExecutable, source: 'bundled' as const }] : []), + { + executable: join(process.resourcesPath, 'resources', 'ffmpeg', executable), + source: 'bundled' + }, { executable: join(process.resourcesPath, 'ffmpeg', executable), source: 'bundled' @@ -83,6 +94,12 @@ function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCa executable: join(process.cwd(), 'resources', 'ffmpeg', executable), source: 'bundled' }, + ...(process.platform === 'darwin' + ? [ + { executable: `/opt/homebrew/bin/${executable}`, source: 'system' as const }, + { executable: `/usr/local/bin/${executable}`, source: 'system' as const } + ] + : []), { executable: process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg', source: 'system' } ] @@ -96,10 +113,12 @@ function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCa function resolveFfmpegExecutable(): string { for (const candidate of getFfmpegCandidates()) { - if (candidate.source === 'environment' || candidate.source === 'system') { - return candidate.executable + const pathLike = candidate.executable.includes('/') || candidate.executable.includes('\\') + if (pathLike) { + if (existsSync(candidate.executable)) return candidate.executable + continue } - if (existsSync(candidate.executable)) return candidate.executable + return candidate.executable } return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' } @@ -131,8 +150,7 @@ export async function inspectImageDecoderExecutable( const decoders = await runImageDecoderCommand(executable, ['-hide_banner', '-decoders']) return { installed: true, - supportsHevc: - decoders.success && /^\s*[A-Z.]{6}\s+hevc\s/im.test(decoders.output) + supportsHevc: decoders.success && /^\s*[A-Z.]{6}\s+hevc\s/im.test(decoders.output) } } @@ -187,7 +205,7 @@ function normalizeDatBase(value) { function isThumbnailName(fileName) { const lower = fileName.toLowerCase() - return /(?:_t(?:_m)?|_thumb|\.thumb)\.dat$/i.test(lower) + return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower) } function buildPreferredDatNames(baseName) { @@ -263,26 +281,63 @@ function unwrapWxgf(buffer, ffmpegPath) { } } - let hevcOffset = -1 - for (let index = 4; index < Math.min(buffer.length - 4, 4096); index += 1) { - if ( - buffer[index] === 0x00 && - buffer[index + 1] === 0x00 && - buffer[index + 2] === 0x00 && - buffer[index + 3] === 0x01 - ) { - hevcOffset = index - break + function findHevcPartitions(data) { + if (data.length < 15) return [] + const headerLength = data[4] + if (headerLength < 5 || headerLength >= data.length) return [] + + for (const pattern of [Buffer.from([0, 0, 0, 1]), Buffer.from([0, 0, 1])]) { + const partitions = [] + let searchOffset = headerLength + while (searchOffset < data.length) { + const relativeIndex = data.subarray(searchOffset).indexOf(pattern) + if (relativeIndex < 0) break + const offset = searchOffset + relativeIndex + if (offset < 4) { + searchOffset = offset + 1 + continue + } + const size = data.readUInt32BE(offset - 4) + if (size === 0 || offset + size > data.length) { + searchOffset = offset + 1 + continue + } + partitions.push({ offset, size }) + searchOffset = offset + size + } + if (partitions.length > 0) return partitions + } + return [] + } + + const partitions = findHevcPartitions(buffer) + let hevcData = null + if (partitions.length > 0) { + const largest = partitions.reduce((best, current) => + current.size > best.size ? current : best + ) + hevcData = buffer.subarray(largest.offset, largest.offset + largest.size) + } else { + for (let index = 4; index < Math.min(buffer.length - 4, 4096); index += 1) { + if ( + buffer[index] === 0x00 && + buffer[index + 1] === 0x00 && + buffer[index + 2] === 0x00 && + buffer[index + 3] === 0x01 + ) { + hevcData = buffer.subarray(index) + break + } } } - if (hevcOffset < 0 || !ffmpegPath) return buffer + if (!hevcData || !ffmpegPath) return buffer const nonce = process.pid + '-' + Date.now() + '-' + crypto.randomBytes(4).toString('hex') const tempBase = path.join(os.tmpdir(), 'wxe-wxgf-' + nonce) const inputPath = tempBase + '.hevc' const outputPath = tempBase + '.png' try { - fs.writeFileSync(inputPath, buffer.subarray(hevcOffset)) + fs.writeFileSync(inputPath, hevcData) childProcess.execFileSync( ffmpegPath, [ @@ -501,22 +556,19 @@ export class ImageDecryptService { /** * 根据 md5 查找图片文件 (WechatExplorer 风格) */ - findImageFile( - md5?: string, - imageDatName?: string, - options?: ImageFindOptions - ): string | null { + findImageFile(md5?: string, imageDatName?: string, options?: ImageFindOptions): string | null { const allowThumbnail = options?.allowThumbnail !== false const normalizedMd5 = this.normalizeDatBase(md5 || '') const normalizedDatName = this.normalizeDatBase(imageDatName || '') - const sessionDirectory = this.getSessionDirectoryName(options?.sessionId) + const sessionDirectory = this.getSessionDirectoryName(options?.sessionMd5 || options?.sessionId) const pathCacheKey = [ normalizedMd5, normalizedDatName, allowThumbnail ? 'thumb' : 'original', options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original', options?.accountDir || '', - sessionDirectory + sessionDirectory, + options?.createTime || 0 ].join('|') const cachedPath = this.imagePathCache.get(pathCacheKey) if (cachedPath && existsSync(cachedPath)) return cachedPath @@ -537,7 +589,8 @@ export class ImageDecryptService { imageDatName: normalizedDatName, accountDir, allowThumbnail, - sessionDirectory + sessionDirectory, + createTime: options?.createTime }) const attachDir = join(accountDir, 'msg', 'attach') @@ -545,6 +598,16 @@ export class ImageDecryptService { // identifies the original image rather than the local file. const searchKeys = this.uniq([normalizedDatName, normalizedMd5]) + if (sessionDirectory && allowThumbnail && options?.preferThumbnail) { + const bubblePreview = this.findBubblePreview( + accountDir, + searchKeys, + sessionDirectory, + options?.createTime + ) + if (bubblePreview) return rememberPath(bubblePreview) + } + // Message rows already identify their conversation. Prefer that small, // deterministic directory before consulting the native hardlink database. if (sessionDirectory && existsSync(attachDir)) { @@ -554,7 +617,8 @@ export class ImageDecryptService { key, allowThumbnail, options?.preferThumbnail, - sessionDirectory + sessionDirectory, + options?.createTime ) if (scopedHit) return rememberPath(scopedHit) } @@ -834,7 +898,7 @@ export class ImageDecryptService { imageDatName?: string, options?: ImageFindOptions ): Promise { - const sessionDirectory = this.getSessionDirectoryName(options?.sessionId) + const sessionDirectory = this.getSessionDirectoryName(options?.sessionMd5 || options?.sessionId) if (!sessionDirectory) return this.findImageFile(md5, imageDatName, options) const allowThumbnail = options?.allowThumbnail !== false @@ -846,7 +910,8 @@ export class ImageDecryptService { allowThumbnail ? 'thumb' : 'original', options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original', options?.accountDir || '', - sessionDirectory + sessionDirectory, + options?.createTime || 0 ].join('|') const cachedPath = this.imagePathCache.get(pathCacheKey) if (cachedPath && existsSync(cachedPath)) return cachedPath @@ -862,15 +927,28 @@ export class ImageDecryptService { if (!accountDir) return null const attachDir = join(accountDir, 'msg', 'attach') - if (normalizedDatName && existsSync(attachDir)) { - const scopedHit = await this.findImageInSessionDirectoryAsync( - attachDir, - normalizedDatName, - allowThumbnail, - options?.preferThumbnail, - sessionDirectory + const searchKeys = this.uniq([normalizedDatName, normalizedMd5]) + if (allowThumbnail && options?.preferThumbnail) { + const bubblePreview = await this.findBubblePreviewAsync( + accountDir, + searchKeys, + sessionDirectory, + options?.createTime ) - if (scopedHit) return rememberPath(scopedHit) + if (bubblePreview) return rememberPath(bubblePreview) + } + if (existsSync(attachDir)) { + for (const key of searchKeys) { + const scopedHit = await this.findImageInSessionDirectoryAsync( + attachDir, + key, + allowThumbnail, + options?.preferThumbnail, + sessionDirectory, + options?.createTime + ) + if (scopedHit) return rememberPath(scopedHit) + } } for (const key of this.uniq([normalizedMd5, normalizedDatName])) { @@ -895,7 +973,8 @@ export class ImageDecryptService { datName: string, allowThumbnail: boolean, preferThumbnail: boolean | undefined, - sessionDirectory: string + sessionDirectory: string, + createTime?: number ): Promise { const normalized = this.normalizeDatBase(datName) if (!normalized || !sessionDirectory) return null @@ -911,6 +990,7 @@ export class ImageDecryptService { return null } + monthDirectories = this.prioritizeImageMonth(monthDirectories, createTime) const variants = this.buildPreferredDatNames(normalized) for (const month of monthDirectories) { const candidates = ['Img', 'Image', 'image'].flatMap((subDirectory) => @@ -926,10 +1006,7 @@ export class ImageDecryptService { return null } - private isValidPersistentImageMeta( - metadata: PersistentImageMeta, - cacheKey: string - ): boolean { + private isValidPersistentImageMeta(metadata: PersistentImageMeta, cacheKey: string): boolean { return ( metadata !== null && typeof metadata === 'object' && @@ -1040,9 +1117,7 @@ export class ImageDecryptService { const entries = await fsPromises.readdir(cacheDir, { withFileTypes: true }) const payloadNames = entries .filter( - (entry) => - entry.isFile() && - /^[a-f0-9]{64}\.(?:jpe?g|png|gif|bmp|webp)$/i.test(entry.name) + (entry) => entry.isFile() && /^[a-f0-9]{64}\.(?:jpe?g|png|gif|bmp|webp)$/i.test(entry.name) ) .map((entry) => entry.name) const payloads = ( @@ -1079,9 +1154,7 @@ export class ImageDecryptService { } const remainingPayloadKeys = new Set( - payloads - .slice(payloads.length - totalFiles) - .map((payload) => payload.name.slice(0, 64)) + payloads.slice(payloads.length - totalFiles).map((payload) => payload.name.slice(0, 64)) ) const staleMetadata = entries.filter( (entry) => @@ -1099,7 +1172,8 @@ export class ImageDecryptService { datName: string, allowThumbnail = true, preferThumbnail = false, - sessionDirectory = '' + sessionDirectory = '', + createTime?: number ): string | null { const normalized = this.normalizeDatBase(datName) if (!normalized) return null @@ -1129,18 +1203,13 @@ export class ImageDecryptService { ? existsSync(join(attachDir, sessionDirectory)) ? [sessionDirectory] : [] - : readdirSync(attachDir).filter( - (name) => name.length === 32 && /^[a-f0-9]+$/i.test(name) - ) - - const now = new Date() - const months: string[] = [] - for (let i = 0; i < 24; i++) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1) - months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`) - } + : readdirSync(attachDir).filter((name) => name.length === 32 && /^[a-f0-9]+$/i.test(name)) for (const sessDir of sessionDirs) { + const sessionRoot = join(attachDir, sessDir) + const months = sessionDirectory + ? this.getImageMonthDirectories(sessionRoot, createTime) + : this.getRecentImageMonths(24) for (const month of months) { for (const sub of ['Img', 'Image', 'image']) { const imgDir = join(attachDir, sessDir, month, sub) @@ -1603,6 +1672,101 @@ export class ImageDecryptService { return crypto.createHash('md5').update(value).digest('hex') } + private getImageMonth(createTime?: number): string { + if (!createTime || !Number.isFinite(createTime)) return '' + const date = new Date(createTime * 1000) + if (Number.isNaN(date.getTime())) return '' + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + } + + private prioritizeImageMonth(months: string[], createTime?: number): string[] { + const preferred = this.getImageMonth(createTime) + if (!preferred || !months.includes(preferred)) return months + return [preferred, ...months.filter((month) => month !== preferred)] + } + + private getImageMonthDirectories(root: string, createTime?: number): string[] { + try { + const months = readdirSync(root) + .filter((name) => /^\d{4}-\d{2}$/.test(name) && existsSync(join(root, name))) + .sort((left, right) => right.localeCompare(left)) + return this.prioritizeImageMonth(months, createTime) + } catch { + return [] + } + } + + private getRecentImageMonths(count: number): string[] { + const now = new Date() + return Array.from({ length: count }, (_, index) => { + const date = new Date(now.getFullYear(), now.getMonth() - index, 1) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + }) + } + + private findBubblePreview( + accountDir: string, + imageKeys: string[], + sessionDirectory: string, + createTime?: number + ): string | null { + const cacheRoot = join(accountDir, 'cache') + const months = this.getImageMonthDirectories(cacheRoot, createTime) + const previewNames = imageKeys.flatMap((key) => [ + `${key}_b.dat`, + `${key}_w.dat`, + `${key}_c.dat`, + `${key}_t_M.dat`, + `${key}_t.dat` + ]) + for (const month of months) { + const bubbleDir = join(cacheRoot, month, 'Message', sessionDirectory, 'Bubble') + const found = this.getLargestExistingPath( + previewNames.map((name) => join(bubbleDir, name)), + true, + true + ) + if (found) return found + } + return null + } + + private async findBubblePreviewAsync( + accountDir: string, + imageKeys: string[], + sessionDirectory: string, + createTime?: number + ): Promise { + const cacheRoot = join(accountDir, 'cache') + let months: string[] + try { + months = (await fsPromises.readdir(cacheRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name)) + .map((entry) => entry.name) + .sort((left, right) => right.localeCompare(left)) + } catch { + return null + } + months = this.prioritizeImageMonth(months, createTime) + const previewNames = imageKeys.flatMap((key) => [ + `${key}_b.dat`, + `${key}_w.dat`, + `${key}_c.dat`, + `${key}_t_M.dat`, + `${key}_t.dat` + ]) + for (const month of months) { + const bubbleDir = join(cacheRoot, month, 'Message', sessionDirectory, 'Bubble') + const found = await this.getLargestExistingPathAsync( + previewNames.map((name) => join(bubbleDir, name)), + true, + true + ) + if (found) return found + } + return null + } + private buildPreferredDatNames(baseName: string): string[] { const base = this.normalizeDatBase(baseName) if (!base) return [] @@ -1697,16 +1861,14 @@ export class ImageDecryptService { const thumbnail = sized.find((entry) => this.isThumbnailName(basename(entry.candidate))) if (thumbnail) return thumbnail.candidate } - const nonThumbnail = sized.find( - (entry) => !this.isThumbnailName(basename(entry.candidate)) - ) + const nonThumbnail = sized.find((entry) => !this.isThumbnailName(basename(entry.candidate))) if (nonThumbnail) return nonThumbnail.candidate return allowThumbnail ? sized[0]?.candidate || null : null } private isThumbnailName(fileName: string): boolean { const lower = fileName.toLowerCase() - return /(?:_t(?:_m)?|_thumb|\.thumb)\.dat$/i.test(lower) + return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower) } isThumbnailFile(filePath: string): boolean { diff --git a/src/main/index.ts b/src/main/index.ts index 49b036e..26921a9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -84,6 +84,7 @@ import { getCachedMessages, mergeBootstrapAvatars, mergeCachedContactAvatars, + mergeCachedSelfInfo, saveBootstrapContacts, saveBootstrapSelf, saveCachedGroupSnapshot, @@ -1221,9 +1222,10 @@ app.whenReady().then(async () => { } }) - ipcMain.handle('settings:getSelf', () => { - const info = chat.getSelfAccountInfo() - if (!info) return { ready: false } + ipcMain.handle('settings:getSelf', async () => { + const rawInfo = await chat.getSelfAccountInfoAsync() + if (!rawInfo) return { ready: false } + const info = mergeCachedSelfInfo(rawInfo.accountRoot, rawInfo) if (chat.isReady()) saveBootstrapSelf(chat.getCurrentAccountRoot(), info) return { ready: true, info } }) @@ -1232,7 +1234,7 @@ app.whenReady().then(async () => { return chat.testConnection(key, accountRoot) }) - ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => { + ipcMain.handle('db:reopenWithRoot', async (_, accountRoot: string) => { const ok = chat.reopenWithRoot(accountRoot) if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' } // 同步 imageKeyRoot,避免自动获取扫描到旧目录 @@ -1240,7 +1242,8 @@ app.whenReady().then(async () => { if (accountRoot && accountRoot !== settings.imageKeyRoot) { saveSettings({ ...settings, imageKeyRoot: accountRoot }) } - const info = chat.getSelfAccountInfo() + const rawInfo = await chat.getSelfAccountInfoAsync() + const info = rawInfo ? mergeCachedSelfInfo(rawInfo.accountRoot, rawInfo) : null return { success: true, info } }) diff --git a/src/main/services/bootstrap-cache.ts b/src/main/services/bootstrap-cache.ts index e73d8fd..03a5c7f 100644 --- a/src/main/services/bootstrap-cache.ts +++ b/src/main/services/bootstrap-cache.ts @@ -428,6 +428,37 @@ function isRawContactName(contact: Contact): boolean { return false } +function accountRootCandidates(accountRoot: string): Set { + const directory = path.basename(normalizeRoot(accountRoot)) + const suffixMatch = directory.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return new Set([directory, suffixMatch?.[1] || ''].filter(Boolean)) +} + +export function mergeCachedSelfInfo(accountRoot: string, self: CachedSelfInfo): CachedSelfInfo { + const cache = readStartupCacheFile(accountRoot) + if (!cache) return self + const identifiers = accountRootCandidates(accountRoot) + if (self.wxid) identifiers.add(self.wxid) + const isRawSelfName = (value?: string): boolean => { + const name = String(value || '').trim() + return !name || name === '我' || identifiers.has(name) + } + if (!isRawSelfName(self.nickname)) return self + + const cachedContact = cache.contacts.find( + (contact) => identifiers.has(contact.m_nsUsrName) && !isRawContactName(contact) + ) + const cachedNickname = !isRawSelfName(cache.self?.nickname) + ? cache.self?.nickname + : cachedContact?.m_nsNickName + if (!cachedNickname) return self + return { + ...self, + nickname: cachedNickname, + avatar: self.avatar || cache.self?.avatar || cachedContact?.avatar + } +} + export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] { const cache = readStartupCacheFile(accountRoot) if (!cache?.contacts.length) return contacts diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index b55a2ad..67da5ec 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -565,6 +565,18 @@ export function getSelfAccountInfo(): SelfAccountInfo | null { } } +export async function getSelfAccountInfoAsync(): Promise { + const current = dbRef + if (!current) return null + try { + await current.getWcdb4Client().getSessionsAsync({ hydrateDisplayNames: true }) + } catch { + // Nickname hydration is best-effort; the synchronous fallback still returns the account id. + } + if (dbRef !== current) return getSelfAccountInfo() + return getSelfAccountInfo() +} + export function testConnection(key: string, accountRoot?: string): DatabaseKeyValidationResult { const probeKey = key.replace(/^0x/i, '').trim() if (!/^[0-9a-f]{64}$/i.test(probeKey)) { diff --git a/src/main/services/image-decryption-status-service.ts b/src/main/services/image-decryption-status-service.ts index 8da7a11..91fc8e8 100644 --- a/src/main/services/image-decryption-status-service.ts +++ b/src/main/services/image-decryption-status-service.ts @@ -124,14 +124,18 @@ export async function testImageDecryption( (await service.findImageFileAsync(image.md5, image.datName, { allowThumbnail: false, accountDir: testAccountDir, - sessionId: imageMessage.sessionId + sessionId: imageMessage.sessionId, + sessionMd5: request.userMd5, + createTime: imageMessage.createTime })) || undefined if (!filePath) { filePath = (await service.findImageFileAsync(image.md5, image.datName, { allowThumbnail: true, accountDir: testAccountDir, - sessionId: imageMessage.sessionId + sessionId: imageMessage.sessionId, + sessionMd5: request.userMd5, + createTime: imageMessage.createTime })) || undefined } if (!filePath) return finish(failure('FILE_NOT_FOUND', '图片文件不存在')) diff --git a/src/main/voice-service.ts b/src/main/voice-service.ts index 0d6fb1c..6b69933 100644 --- a/src/main/voice-service.ts +++ b/src/main/voice-service.ts @@ -1,9 +1,52 @@ import { app } from 'electron' import { join } from 'path' import { existsSync } from 'fs' +import { createRequire } from 'module' import { Wcdb4Client } from './wcdb4-client' import { isPackagedRuntime } from './runtime-mode' +const nodeRequire = createRequire(import.meta.url) + +export type SilkWasmRuntimeLocation = { + packagePath: string + wasmPath: string + source: 'unpacked' | 'resources' | 'asar' | 'development' +} + +export function getSilkWasmRuntimeLocations(options?: { + packaged?: boolean + resourcesPath?: string + appPath?: string +}): SilkWasmRuntimeLocation[] { + const packaged = options?.packaged ?? isPackagedRuntime() + const resourcesPath = options?.resourcesPath ?? process.resourcesPath + const appPath = options?.appPath ?? app.getAppPath() + const location = ( + packagePath: string, + source: SilkWasmRuntimeLocation['source'] + ): SilkWasmRuntimeLocation => ({ + packagePath, + wasmPath: join(packagePath, 'lib', 'silk.wasm'), + source + }) + + if (!packaged) { + return [location(join(appPath, 'node_modules', 'silk-wasm'), 'development')] + } + + return [ + location(join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm'), 'unpacked'), + location(join(resourcesPath, 'node_modules', 'silk-wasm'), 'resources'), + location(join(appPath, 'node_modules', 'silk-wasm'), 'asar') + ] +} + +export function findSilkWasmRuntimeLocation( + locations: SilkWasmRuntimeLocation[] +): SilkWasmRuntimeLocation | null { + return locations.find((location) => existsSync(location.wasmPath)) || null +} + export class VoiceService { private wcdb4Client: Wcdb4Client private voiceCache = new Map() @@ -101,35 +144,23 @@ export class VoiceService { private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise { try { - let wasmPath: string - if (isPackagedRuntime()) { - wasmPath = join( - process.resourcesPath, - 'app.asar.unpacked', - 'node_modules', - 'silk-wasm', - 'lib', - 'silk.wasm' + const locations = getSilkWasmRuntimeLocations() + const runtime = findSilkWasmRuntimeLocation(locations) + if (!runtime) { + console.error( + '[VoiceService] silk.wasm not found. checked:', + locations.map((location) => location.wasmPath) ) - if (!existsSync(wasmPath)) { - wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') - } - } else { - wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') - } - - if (!existsSync(wasmPath)) { - console.error('[VoiceService] silk.wasm not found at:', wasmPath) return null } - // eslint-disable-next-line @typescript-eslint/no-require-imports - const silkWasm = require('silk-wasm') + const silkWasm = nodeRequire(runtime.packagePath) if (!silkWasm || !silkWasm.decode) { - console.error('[VoiceService] silk-wasm module invalid') + console.error('[VoiceService] silk-wasm module invalid:', runtime.packagePath) return null } + console.log('[VoiceService] using silk-wasm runtime:', runtime.source) const result = await silkWasm.decode(silkData, sampleRate) return Buffer.from(result.data) } catch (e) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 88e4fb5..37c3b25 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -610,9 +610,11 @@ function App(): React.ReactElement { setDbKeyStatus('已连接数据库') // The cached list paints first. Refresh lightweight session flags and // missing avatars after the database is connected. - void loadContacts({ waitForAvatars: false }).catch((error) => { - console.warn('[Startup] background contact refresh failed:', error) - }) + void loadContacts({ waitForAvatars: false }) + .then(() => refreshSelfInfo(3)) + .catch((error) => { + console.warn('[Startup] background contact refresh failed:', error) + }) }) .catch((error) => { console.warn('[Startup] background database init failed:', error) @@ -638,7 +640,8 @@ function App(): React.ReactElement { if (hasBootstrap) { setIsAuthenticated(true) } else { - await Promise.all([loadContacts(), refreshSelfInfo(3)]) + await loadContacts() + await refreshSelfInfo(3) setIsAuthenticated(true) } } else { @@ -761,13 +764,14 @@ function App(): React.ReactElement { if (hasBootstrap) { // Cached contacts are sufficient for the first paint. Refresh native data in the background. setIsAuthenticated(true) - void Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)]).catch( - (error) => { + void loadContacts({ waitForAvatars: false }) + .then(() => refreshSelfInfo(3)) + .catch((error) => { console.warn('[Startup] background refresh failed:', error) - } - ) + }) } else { - await Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)]) + await loadContacts({ waitForAvatars: false }) + await refreshSelfInfo(3) setIsAuthenticated(true) } setStartupProgress({ diff --git a/src/renderer/src/features/settings/image-decryption/ImageDecoderRequirementNotice.tsx b/src/renderer/src/features/settings/image-decryption/ImageDecoderRequirementNotice.tsx deleted file mode 100644 index 1a81b03..0000000 --- a/src/renderer/src/features/settings/image-decryption/ImageDecoderRequirementNotice.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useEffect, useState } from 'react' -import type { ImageDecoderStatus } from '../../../../../shared/image-decryption' - -interface ImageDecoderRequirementNoticeProps { - status?: ImageDecoderStatus - onNotice: (message: string) => void -} - -export function ImageDecoderRequirementNotice({ - status, - onNotice -}: ImageDecoderRequirementNoticeProps): React.ReactElement { - const platform = window.electron.process.platform - const [currentStatus, setCurrentStatus] = useState(status) - const [selecting, setSelecting] = useState(false) - const [checking, setChecking] = useState(false) - const [error, setError] = useState() - - useEffect(() => setCurrentStatus(status), [status]) - - const selectDirectory = async (): Promise => { - setSelecting(true) - setError(undefined) - try { - const result = await window.api.selectImageDecoder() - if (result.canceled) return - if (!result.success || !result.status) { - setError(result.error || '没有在所选文件夹中找到 FFmpeg,请重新选择。') - return - } - setCurrentStatus(result.status) - onNotice( - result.status.available - ? 'FFmpeg 安装目录已保存,原图支持可以使用' - : 'FFmpeg 安装目录已保存,但原图支持未通过检测' - ) - } catch { - setError('无法打开目录选择窗口,请稍后重试。') - } finally { - setSelecting(false) - } - } - - const checkOriginalSupport = async (): Promise => { - setChecking(true) - setError(undefined) - try { - const nextStatus = await window.api.getImageDecoderStatus() - setCurrentStatus(nextStatus) - onNotice(nextStatus.available ? '原图支持检测通过' : '原图支持检测未通过') - } catch { - setError('原图支持检测失败,请稍后重试。') - } finally { - setChecking(false) - } - } - - const openDownload = async (): Promise => { - setError(undefined) - try { - const result = await window.api.openImageDecoderDownload() - if (!result.success) setError(result.error || '无法打开 FFmpeg 下载页面') - } catch { - setError('无法打开 FFmpeg 下载页面,请检查系统默认浏览器设置。') - } - } - - const installed = currentStatus?.installed === true - const supported = currentStatus?.available === true - const downloadLabel = platform === 'darwin' ? '打开 Homebrew 官网' : '下载 FFmpeg' - - return ( -
- - {supported ? '✓' : '!'} - -
-
- FFmpeg 原图支持 - - {supported ? '原图支持可用' : installed ? 'FFmpeg 已安装' : '需要安装 FFmpeg'} - -
- -

如需打开少量 wxgf/HEVC 特殊原图,必须安装 FFmpeg。未安装不会影响聊天记录和普通图片。

- -
-
- 1 -
- FFmpeg 安装目录 - - {currentStatus?.directory || '尚未检测到 FFmpeg 安装目录'} - -
- {installed ? '已检测' : '未检测'} -
-
- 2 -
- 原图支持检测 - - {supported - ? '已支持 wxgf/HEVC 特殊原图转换' - : installed - ? '尚未检测到 HEVC 原图转换能力' - : '请先安装并检测 FFmpeg 目录'} - -
- {supported ? '已通过' : '未通过'} -
-
- -
- - - -
- -
- {platform === 'win32' ? ( - <> - Windows 安装与定位 -

- 下载后右键压缩包选择“全部解压”,再填写解压后的文件夹。如果已经安装但没有自动找到,可在 - PowerShell 输入 (Get-Command ffmpeg).Source,或在命令提示符(CMD)输入{' '} - where ffmpeg,然后选择返回路径所在的 bin 文件夹。 -

- - ) : platform === 'darwin' ? ( - <> - macOS 安装与定位 -

- 打开“终端”并输入 brew install ffmpeg。如果提示没有 - brew,请先通过上方按钮打开 Homebrew 官网完成安装。安装后输入{' '} - which ffmpeg,再选择返回路径所在的文件夹, 通常是{' '} - /opt/homebrew/bin/usr/local/bin。 -

- - ) : ( - <> - Linux 安装与定位 -

- 通过系统包管理器安装 FFmpeg,执行 which ffmpeg{' '} - 查看位置,再选择返回路径所在的文件夹。 -

- - )} -
- - {error && ( -

- {error} -

- )} -
-
- ) -} diff --git a/src/renderer/src/features/settings/pages/ImageDecryptionPage.tsx b/src/renderer/src/features/settings/pages/ImageDecryptionPage.tsx index 981e03a..da387ea 100644 --- a/src/renderer/src/features/settings/pages/ImageDecryptionPage.tsx +++ b/src/renderer/src/features/settings/pages/ImageDecryptionPage.tsx @@ -1,7 +1,6 @@ import type { SettingsSelfInfo } from '../model/types' import { AutoDetectImageKeySection } from '../image-decryption/AutoDetectImageKeySection' import { DangerZone } from '../image-decryption/DangerZone' -import { ImageDecoderRequirementNotice } from '../image-decryption/ImageDecoderRequirementNotice' import { ImageDecryptStatus } from '../image-decryption/ImageDecryptStatus' import { ImageKeyConfiguration } from '../image-decryption/ImageKeyConfiguration' import { ImageTestSection } from '../image-decryption/ImageTestSection' @@ -56,11 +55,6 @@ export function ImageDecryptionPage({ - -

图片解密状态

div { - display: grid; - grid-template-columns: 24px minmax(0, 1fr) auto; - gap: 10px; - align-items: center; - min-height: 48px; - border-bottom: 1px solid rgba(139, 103, 47, 0.16); -} -.image-decoder-check-index { - display: grid; - width: 20px; - height: 20px; - place-items: center; - border-radius: 50%; - background: rgba(184, 117, 36, 0.12); - color: #8b5d23; - font-size: 11px; - font-weight: 700; -} -.image-decoder-check-index.complete { - background: #e1f0ea; - color: #247a63; -} -.image-decoder-checks strong, -.image-decoder-checks small { - display: block; -} -.image-decoder-checks strong { - color: #4f4a42; - font-size: 12px; -} -.image-decoder-checks small { - overflow: hidden; - margin-top: 2px; - color: #81796d; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 11px; -} -.image-decoder-checks b { - color: #9a7750; - font-size: 11px; - font-weight: 600; -} -.image-decoder-checks b.success { - color: #247a63; -} -.image-decoder-requirement-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 9px; -} -.image-decoder-requirement-actions button { - min-height: 34px; -} -.image-decoder-requirement small { - display: block; - color: #81796d; - font-size: 11px; - line-height: 1.6; -} -.image-decoder-platform-help { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid rgba(139, 103, 47, 0.16); -} -.image-decoder-platform-help strong { - color: #5b5145; - font-size: 12px; -} -.image-decoder-platform-help p { - margin: 5px 0 0; - color: #71695f; - font-size: 12px; - line-height: 1.75; -} -.image-decoder-platform-help code { - border-radius: 4px; - padding: 1px 5px; - background: rgba(117, 82, 31, 0.1); - color: #68491f; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - overflow-wrap: anywhere; -} -.image-decoder-requirement .image-decoder-requirement-error { - margin: 10px 0 0; - color: #b34848; - font-size: 12px; -} .image-decrypt-badge.unconfigured { background: #f1f3f2; color: #66706b; diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts index d8a4190..b6a5297 100644 --- a/tests/integration/export-media-flow.test.ts +++ b/tests/integration/export-media-flow.test.ts @@ -9,7 +9,13 @@ const state = vi.hoisted(() => ({ accountRoot: '', videoPath: '', messages: [] as Message[], - imageLookups: [] as { allowThumbnail?: boolean; preferThumbnail?: boolean }[] + imageLookups: [] as { + allowThumbnail?: boolean + preferThumbnail?: boolean + sessionId?: string + sessionMd5?: string + createTime?: number + }[] })) vi.mock('electron', () => ({ @@ -23,7 +29,12 @@ vi.mock('../../src/main/services/chat-service', () => ({ getChatDb: () => ({ getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot }) }), - getContactAvatars: () => ({}) + getContactAvatars: () => ({}), + getSelfAccountInfoAsync: async () => ({ + wxid: 'a969409112', + nickname: '濑岛田井卫', + accountRoot: state.accountRoot + }) })) vi.mock('../../src/main/services/image-key-config-service', () => ({ ImageKeyConfigService: class { @@ -52,17 +63,42 @@ vi.mock('../../src/main/image-decrypt-service', () => ({ findImageFile( _md5: string, _datName: string, - options: { allowThumbnail?: boolean; preferThumbnail?: boolean } + options: { + allowThumbnail?: boolean + preferThumbnail?: boolean + sessionId?: string + sessionMd5?: string + createTime?: number + } ): string { state.imageLookups.push(options) return 'fixture-original.dat' } + async findImageFileAsync( + md5: string, + datName: string, + options: { + allowThumbnail?: boolean + preferThumbnail?: boolean + sessionId?: string + sessionMd5?: string + createTime?: number + } + ): Promise { + return this.findImageFile(md5, datName, options) + } decryptImageToBase64WithFallback(): { data: string; filePath: string } { return { data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=', filePath: 'fixture-original.dat' } } + async decryptImageToBase64WithFallbackAsync(): Promise<{ + data: string + filePath: string + }> { + return this.decryptImageToBase64WithFallback() + } isThumbnailFile(): boolean { return false } @@ -200,7 +236,10 @@ describe('media export flow', () => { expect(missingVoice.exportMediaError).toBe('语音文件缺失:本地未找到语音数据') expect(state.imageLookups[0]).toMatchObject({ allowThumbnail: false, - preferThumbnail: false + preferThumbnail: false, + sessionId: 'fixture-session', + sessionMd5: 'fixture-user', + createTime: 1_785_549_600 }) expect(progress.length).toBeGreaterThan(0) }) @@ -301,4 +340,91 @@ describe('media export flow', () => { expect(exportMessageKey(first, 'fixture-user')).toBe(exportMessageKey(second, 'fixture-user')) }) + + it('replaces a raw sender account with the hydrated self nickname', async () => { + const { runExport } = await import('../../src/main/export-service') + const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } + state.messages = [ + message({ + id: 'self-message', + isSender: true, + senderId: 'a969409112', + name: 'a969409112', + content: '本人消息' + }) + ] + + const result = await runExport( + { + jobId: 'self-name', + userMd5: 'fixture-user', + name: '本人昵称', + format: 'html', + outputName: 'self-name', + kinds: ['text'], + includeMedia: false, + nameMap: { a969409112: 'a969409112' } + }, + win as never + ) + + expect(result.success).toBe(true) + expect(readArchive(result.outputPath!).messages[0]?.name).toBe('濑岛田井卫') + }) + + it('migrates raw self names already stored in an incremental HTML archive', async () => { + const { runExport } = await import('../../src/main/export-service') + const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } + const request = { + userMd5: 'fixture-user', + name: '增量昵称', + format: 'html' as const, + outputName: 'incremental-self-name', + kinds: ['text'] as const, + includeMedia: false + } + state.messages = [ + message({ + id: 'old-self', + isSender: true, + senderId: 'a969409112', + name: 'a969409112', + content: '旧消息', + createTime: 1_785_549_600 + }) + ] + const first = await runExport( + { ...request, jobId: 'incremental-self-first', kinds: [...request.kinds] }, + win as never + ) + expect(first.success).toBe(true) + + const firstData = readArchive(first.outputPath!) + firstData.messages[0].name = 'a969409112' + writeFileSync( + join(dirname(first.outputPath!), 'data', 'messages.js'), + `window.__WECHAT_EXPORT__ = ${JSON.stringify(firstData)};\n`, + 'utf8' + ) + state.messages = [ + message({ + id: 'new-self', + isSender: true, + senderId: 'a969409112', + name: '濑岛田井卫', + content: '新消息', + createTime: 1_785_549_700 + }) + ] + const second = await runExport( + { ...request, jobId: 'incremental-self-second', kinds: [...request.kinds] }, + win as never + ) + + expect(second.success).toBe(true) + expect(readArchive(second.outputPath!).messages.map((item) => item.name)).toEqual([ + '濑岛田井卫', + '濑岛田井卫' + ]) + }) }) diff --git a/tests/unit/bootstrap-cache.test.ts b/tests/unit/bootstrap-cache.test.ts index 5e09045..b5dad83 100644 --- a/tests/unit/bootstrap-cache.test.ts +++ b/tests/unit/bootstrap-cache.test.ts @@ -15,7 +15,9 @@ import { flushBootstrapCacheWritesSync, getBootstrapCache, getCachedMessages, + mergeCachedSelfInfo, saveBootstrapContacts, + saveBootstrapSelf, saveCachedMessages } from '../../src/main/services/bootstrap-cache' @@ -78,4 +80,29 @@ describe('bootstrap cache', () => { clearBootstrapCache() expect(getBootstrapCache(accountRoot)).toBeNull() }) + + it('reuses a hydrated contact nickname when cached self info only contains the account id', () => { + const selfRoot = '/fixture/a969409112_d784' + saveBootstrapContacts(selfRoot, [ + { + m_nsUsrName: 'a969409112', + m_nsNickName: '濑岛田井卫', + md5: 'self-md5', + type: 'user' + } + ]) + saveBootstrapSelf(selfRoot, { + wxid: 'a969409112', + nickname: 'a969409112', + accountRoot: selfRoot + }) + + expect( + mergeCachedSelfInfo(selfRoot, { + wxid: 'a969409112', + nickname: 'a969409112', + accountRoot: selfRoot + }).nickname + ).toBe('濑岛田井卫') + }) }) diff --git a/tests/unit/chat-service.test.ts b/tests/unit/chat-service.test.ts index 6278f16..d1d6eca 100644 --- a/tests/unit/chat-service.test.ts +++ b/tests/unit/chat-service.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { WechatDb } from '../../src/main/wechat-db' import { closeChatDbForQuit, + getSelfAccountInfoAsync, isReady, listContactsAsync, setChatDb @@ -42,6 +43,39 @@ describe('chat service contacts', () => { expect(contacts[0]?.m_nsNickName).toBe('测试群聊') }) + it('hydrates the current account nickname before returning self info', async () => { + const session = { username: 'a969409112', nickname: 'a969409112' } + const client = { + getSessionsAsync: vi.fn(async () => { + session.nickname = '濑岛田井卫' + return [session] + }), + getAccountRoot: () => '/fixture/a969409112_d784', + getMyUsernameCandidates: () => ['a969409112'], + getUsernameByMd5: () => undefined, + md5: () => 'fixture-md5', + getMyAvatarUrl: () => undefined + } + const fakeDb = { + close: vi.fn(), + md5: () => 'fixture-md5', + getAllGroupContacts: () => ({}), + getUserList: () => [ + { + m_nsUsrName: session.username, + nickname: session.nickname + } + ], + getWcdb4Client: () => client + } as unknown as WechatDb + + setChatDb(fakeDb) + const info = await getSelfAccountInfoAsync() + + expect(client.getSessionsAsync).toHaveBeenCalledWith({ hydrateDisplayNames: true }) + expect(info).toMatchObject({ wxid: 'a969409112', nickname: '濑岛田井卫' }) + }) + it('detaches the database immediately and awaits native cleanup on quit', async () => { let finishClose: ((value: boolean) => void) | undefined const closeAsync = vi.fn( diff --git a/tests/unit/export-media.test.ts b/tests/unit/export-media.test.ts index 43c7e81..5bda0ab 100644 --- a/tests/unit/export-media.test.ts +++ b/tests/unit/export-media.test.ts @@ -31,13 +31,15 @@ describe('export media', () => { expect(html).toContain('placeholder="搜索发送者或消息内容…"') expect(html).toContain('filtered.slice(windowStart, windowEnd)') expect(html).toContain('windowStart = Math.max(0, windowEnd - PAGE_SIZE)') + expect(html).toContain('scheduleWindowSlide') + expect(html).toContain("list.addEventListener('wheel'") expect(html).toContain('date.getSeconds()') const inlineScript = inlineScriptOf(html) expect(inlineScript).toBeTruthy() expect(() => new Function(inlineScript)).not.toThrow() }) - it('initially renders only one page and searches the full archive dataset', () => { + it('keeps a bounded DOM while loading older and newer messages in both directions', async () => { const html = renderExportPage('大量消息') const dom = new JSDOM(html, { runScripts: 'outside-only' }) const messages = Array.from( @@ -69,12 +71,21 @@ describe('export media', () => { '已显示 240 / 筛选 500 / 全部 500' ) const list = dom.window.document.querySelector('#messages')! - for (let index = 0; index < 5; index += 1) { - list.dispatchEvent(new dom.window.Event('scroll')) - expect(dom.window.document.querySelectorAll('.message').length).toBeLessThanOrEqual( - EXPORT_PAGE_SIZE - ) - } + expect(list.querySelector('.message')?.getAttribute('data-index')).toBe('260') + + await new Promise((resolve) => dom.window.setTimeout(resolve, 10)) + list.dispatchEvent(new dom.window.WheelEvent('wheel', { deltaY: -100 })) + await new Promise((resolve) => dom.window.setTimeout(resolve, 10)) + expect(list.querySelector('.message')?.getAttribute('data-index')).toBe('140') + expect(dom.window.document.querySelectorAll('.message').length).toBe(EXPORT_PAGE_SIZE) + + await new Promise((resolve) => dom.window.setTimeout(resolve, 20)) + Object.defineProperty(list, 'scrollTop', { configurable: true, writable: true, value: 1_000 }) + list.dispatchEvent(new dom.window.Event('scroll')) + await new Promise((resolve) => dom.window.setTimeout(resolve, 10)) + expect(list.querySelector('.message')?.getAttribute('data-index')).toBe('260') + expect(dom.window.document.querySelectorAll('.message').length).toBe(EXPORT_PAGE_SIZE) + const search = dom.window.document.querySelector('#query') as HTMLInputElement search.value = 'needle' search.dispatchEvent(new dom.window.Event('input')) @@ -91,6 +102,8 @@ describe('export media', () => { expect(html).toContain('class="file-attachment" href="') expect(html).toContain('class="quote-reference"') expect(html).toContain('message.exportMediaError') + expect(html).toContain('.audio-wrap { width: 260px; max-width: 100%; min-width: 0; }') + expect(html).toContain('.audio { display: block; width: 100%; max-width: 100%; height: 38px; }') expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/) }) diff --git a/tests/unit/image-decrypt.test.ts b/tests/unit/image-decrypt.test.ts index 5cd3ab2..3fe52ba 100644 --- a/tests/unit/image-decrypt.test.ts +++ b/tests/unit/image-decrypt.test.ts @@ -83,22 +83,31 @@ describe('DAT image decryption', () => { it('finds modern _M variants and reads plain images stored with a DAT extension', async () => { const accountRoot = join(root, 'modern-account') - const sessionId = '77705c31c50e8a4242a9d527fe9433de' - const imageDirectory = join(accountRoot, 'msg', 'attach', sessionId, '2025-10', 'Img') + const sessionMd5 = '77705c31c50e8a4242a9d527fe9433de' + const imageDirectory = join(accountRoot, 'msg', 'attach', sessionMd5, '2025-10', 'Img') mkdirSync(imageDirectory, { recursive: true }) const imageBase = '9718e38ad90f57f9e833d17ff2373abd' const mediumFile = join(imageDirectory, `${imageBase}_M.dat`) writeFileSync(mediumFile, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])) + const newerDirectory = join(accountRoot, 'msg', 'attach', sessionMd5, '2026-08', 'Img') + mkdirSync(newerDirectory, { recursive: true }) + writeFileSync(join(newerDirectory, `${imageBase}_M.dat`), Buffer.from([0xff, 0xd8, 0xff, 0])) + + const findOptions = { + allowThumbnail: false, + accountDir: accountRoot, + sessionId: 'stale-session-name', + sessionMd5, + createTime: Math.floor(new Date(2025, 9, 15).getTime() / 1000) + } + const syncService = new ImageDecryptService('0x40', aesKey) + expect(syncService.findImageFile(undefined, imageBase, findOptions)).toBe(mediumFile) const service = new ImageDecryptService('0x40', aesKey) - await expect( - service.findImageFileAsync(undefined, imageBase, { - allowThumbnail: false, - accountDir: accountRoot, - sessionId - }) - ).resolves.toBe(mediumFile) + await expect(service.findImageFileAsync(undefined, imageBase, findOptions)).resolves.toBe( + mediumFile + ) expect(service.decryptImageToBase64(mediumFile)).toMatch(/^data:image\/png;base64,/) expect(service.getLastDecodeDiagnostic()).toMatchObject({ code: 'DIRECT_IMAGE', @@ -116,15 +125,44 @@ describe('DAT image decryption', () => { service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, { allowThumbnail: false, accountDir: accountRoot, - sessionId + sessionMd5 }) ).resolves.toBeNull() await expect( service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, { allowThumbnail: true, accountDir: accountRoot, - sessionId + sessionMd5 }) ).resolves.toBe(thumbnailFile) + + const bubbleBase = '5e1f000000000000000000000000cafe' + const bubbleDirectory = join(accountRoot, 'cache', '2025-10', 'Message', sessionMd5, 'Bubble') + mkdirSync(bubbleDirectory, { recursive: true }) + const bubblePreview = join(bubbleDirectory, `${bubbleBase}_b.dat`) + writeFileSync(bubblePreview, Buffer.from([0xff, 0xd8, 0xff, 0])) + expect(service.isThumbnailFile(bubblePreview)).toBe(true) + const bubbleOptions = { + allowThumbnail: true, + preferThumbnail: true, + accountDir: accountRoot, + sessionMd5, + createTime: Math.floor(new Date(2025, 9, 15).getTime() / 1000) + } + const syncBubbleService = new ImageDecryptService('0x40', aesKey) + expect(syncBubbleService.findImageFile(undefined, bubbleBase, bubbleOptions)).toBe( + bubblePreview + ) + await expect( + service.findImageFileAsync(undefined, bubbleBase, { + allowThumbnail: false, + accountDir: accountRoot, + sessionMd5, + createTime: Math.floor(new Date(2025, 9, 15).getTime() / 1000) + }) + ).resolves.toBeNull() + await expect(service.findImageFileAsync(undefined, bubbleBase, bubbleOptions)).resolves.toBe( + bubblePreview + ) }) }) diff --git a/tests/unit/runtime-packaging.test.ts b/tests/unit/runtime-packaging.test.ts new file mode 100644 index 0000000..37ee4ba --- /dev/null +++ b/tests/unit/runtime-packaging.test.ts @@ -0,0 +1,52 @@ +import { createRequire } from 'module' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { dirname, join, resolve } from 'path' +import { afterAll, describe, expect, it } from 'vitest' + +const nodeRequire = createRequire(import.meta.url) +const { validateFfmpegRuntime, validateSilkWasmRuntime } = nodeRequire( + '../../scripts/after-pack.cjs' +) as { + validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void + validateSilkWasmRuntime: (runtimeResources: string) => void +} +const root = mkdtempSync(join(tmpdir(), 'wxe-runtime-package-')) + +describe('production runtime packaging', () => { + afterAll(() => rmSync(root, { recursive: true, force: true })) + + it('requires the complete unpacked silk-wasm runtime', () => { + const packagePath = join(root, 'resources', 'app.asar.unpacked', 'node_modules', 'silk-wasm') + mkdirSync(join(packagePath, 'lib'), { recursive: true }) + writeFileSync(join(packagePath, 'package.json'), '{}') + writeFileSync(join(packagePath, 'lib', 'index.cjs'), 'module.exports = {}') + + expect(() => validateSilkWasmRuntime(join(root, 'resources'))).toThrow(/silk\.wasm/) + writeFileSync(join(packagePath, 'lib', 'silk.wasm'), Buffer.from([0, 97, 115, 109])) + expect(() => validateSilkWasmRuntime(join(root, 'resources'))).not.toThrow() + }) + + it('keeps silk-wasm in electron-builder asarUnpack', () => { + const config = readFileSync(resolve(__dirname, '../../electron-builder.yml'), 'utf8') + expect(config).toContain('node_modules/silk-wasm/**') + }) + + it('requires and unpacks the bundled ffmpeg-static executable', () => { + const resources = join(root, 'ffmpeg-resources') + const ffmpegPath = join( + resources, + 'app.asar.unpacked', + 'node_modules', + 'ffmpeg-static', + 'ffmpeg' + ) + expect(() => validateFfmpegRuntime(resources, 'darwin')).toThrow(/ffmpeg-static/) + mkdirSync(dirname(ffmpegPath), { recursive: true }) + writeFileSync(ffmpegPath, 'fixture') + expect(() => validateFfmpegRuntime(resources, 'darwin')).not.toThrow() + + const config = readFileSync(resolve(__dirname, '../../electron-builder.yml'), 'utf8') + expect(config).toContain('node_modules/ffmpeg-static/**') + }) +}) diff --git a/tests/unit/voice-runtime.test.ts b/tests/unit/voice-runtime.test.ts new file mode 100644 index 0000000..016e1b9 --- /dev/null +++ b/tests/unit/voice-runtime.test.ts @@ -0,0 +1,49 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterAll, describe, expect, it, vi } from 'vitest' + +const root = mkdtempSync(join(tmpdir(), 'wxe-voice-runtime-')) + +vi.mock('electron', () => ({ app: { getAppPath: () => join(root, 'development-app') } })) +vi.mock('../../src/main/runtime-mode', () => ({ isPackagedRuntime: () => false })) +vi.mock('../../src/main/wcdb4-client', () => ({ Wcdb4Client: class {} })) + +import { + findSilkWasmRuntimeLocation, + getSilkWasmRuntimeLocations +} from '../../src/main/voice-service' + +function writeWasm(packagePath: string): void { + mkdirSync(join(packagePath, 'lib'), { recursive: true }) + writeFileSync(join(packagePath, 'lib', 'silk.wasm'), Buffer.from([0, 97, 115, 109])) +} + +describe('silk-wasm runtime discovery', () => { + afterAll(() => rmSync(root, { recursive: true, force: true })) + + it('prefers the unpacked package and still recognizes the legacy asar layout', () => { + const resourcesPath = join(root, 'Resources') + const appPath = join(resourcesPath, 'app.asar') + const asarPackage = join(appPath, 'node_modules', 'silk-wasm') + writeWasm(asarPackage) + + const locations = getSilkWasmRuntimeLocations({ packaged: true, resourcesPath, appPath }) + expect(findSilkWasmRuntimeLocation(locations)).toMatchObject({ source: 'asar' }) + + writeWasm(join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm')) + expect(findSilkWasmRuntimeLocation(locations)).toMatchObject({ source: 'unpacked' }) + }) + + it('uses the regular node_modules package in development', () => { + const appPath = join(root, 'development-app') + const locations = getSilkWasmRuntimeLocations({ packaged: false, appPath }) + + expect(locations).toEqual([ + expect.objectContaining({ + source: 'development', + packagePath: join(appPath, 'node_modules', 'silk-wasm') + }) + ]) + }) +})