fix: 修复媒体导出与 HTML 聊天档案体验

- 修复打包版图片、语音解析不可用,内置 FFmpeg 与必要运行时依赖
- 修复 HTML 导出原图查找、缩略图回退及增量档案媒体解析问题
- 修复本人昵称在软件和 HTML 导出中显示为微信号的问题,并兼容旧档案昵称迁移
- 修复 HTML 语音播放器超出消息气泡的问题
- 优化 HTML 双向滚动加载,大量消息时最多渲染 240 条,避免页面卡顿
- 移除图片解密页面中手动配置 FFmpeg 的相关提示
- 补充图片解密、语音运行时、打包资源和 HTML 导出相关测试
This commit is contained in:
Wxw-Gu
2026-08-04 17:03:07 +08:00
parent c6587c517a
commit 0a3d930298
24 changed files with 949 additions and 515 deletions
+27
View File
@@ -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('濑岛田井卫')
})
})
+34
View File
@@ -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(
+20 -7
View File
@@ -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]:\\/)
})
+49 -11
View File
@@ -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
)
})
})
+52
View File
@@ -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/**')
})
})
+49
View File
@@ -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')
})
])
})
})