mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
fix: 修复媒体导出与 HTML 聊天档案体验
- 修复打包版图片、语音解析不可用,内置 FFmpeg 与必要运行时依赖 - 修复 HTML 导出原图查找、缩略图回退及增量档案媒体解析问题 - 修复本人昵称在软件和 HTML 导出中显示为微信号的问题,并兼容旧档案昵称迁移 - 修复 HTML 语音播放器超出消息气泡的问题 - 优化 HTML 双向滚动加载,大量消息时最多渲染 240 条,避免页面卡顿 - 移除图片解密页面中手动配置 FFmpeg 的相关提示 - 补充图片解密、语音运行时、打包资源和 HTML 导出相关测试
This commit is contained in:
@@ -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('濑岛田井卫')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]:\\/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/**')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user