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')
+ })
+ ])
+ })
+})