fix: 优化 Windows 启动性能与聊天图片缓存

- 增加聊天图片磁盘持久化缓存,重启后直接复用
- 将 DAT 图片解密移至 Worker,避免阻塞主进程
- 增加图片加载优先级和并发控制
- 优化会话目录与图片文件的异步查找
- 使用本地媒体协议加载缓存图片,减少 Base64 IPC 开销
- 优化启动缓存与数据库初始化流程,降低窗口未响应时间

(cherry picked from commit 82bcc8d32ca674de38c745cc9925ed2309887dc7)
This commit is contained in:
电摇小子
2026-08-03 10:03:19 +08:00
committed by Wxw-Gu
parent 8a6d443acc
commit a3955d691d
10 changed files with 1615 additions and 362 deletions
+800 -27
View File
@@ -1,7 +1,9 @@
import { basename, dirname, extname, join } from 'path' import { basename, dirname, extname, join, resolve } from 'path'
import { existsSync, readFileSync, statSync, readdirSync } from 'fs' import { existsSync, readFileSync, statSync, readdirSync, promises as fsPromises } from 'fs'
import crypto from 'crypto' import crypto from 'crypto'
import os from 'os' import os from 'os'
import { app } from 'electron'
import { Worker } from 'worker_threads'
import { Wcdb4Client } from './wcdb4-client' import { Wcdb4Client } from './wcdb4-client'
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1' const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
@@ -9,13 +11,207 @@ const imageDecryptLog = (...args: unknown[]): void => {
if (imageDecryptDebugEnabled) console.log(...args) if (imageDecryptDebugEnabled) console.log(...args)
} }
type DecodedImage = { export type DecodedImage = {
data: string data: string
filePath: string filePath: string
isThumbnail: boolean isThumbnail: boolean
cacheFilePath?: string
mimeType?: string
}
type ImageFindOptions = {
allowThumbnail?: boolean
accountDir?: string
preferThumbnail?: boolean
sessionId?: string
} }
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024 const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
const MAX_PERSISTENT_IMAGE_CACHE_BYTES = 512 * 1024 * 1024
const MAX_PERSISTENT_IMAGE_CACHE_FILES = 512
const PERSISTENT_IMAGE_CACHE_VERSION = 2
interface PersistentImageMeta {
version: number
sourcePath: string
sourceSize: number
sourceMtimeMs: number
fileName: string
cacheSize: number
mimeType: string
isThumbnail: boolean
}
const IMAGE_DECRYPT_WORKER_SOURCE = String.raw`
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { parentPort, workerData } = require('node:worker_threads')
function normalizeDatBase(value) {
const lower = String(value || '').trim().toLowerCase()
if (!lower) return ''
const file = lower.split('/').pop().split('\\').pop()
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '')
}
function isThumbnailName(fileName) {
const lower = fileName.toLowerCase()
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
}
function buildPreferredDatNames(baseName) {
const base = normalizeDatBase(baseName)
if (!base) return []
return [
base + '.dat',
base + '_hd.dat',
base + '_h.dat',
base + '_b.dat',
base + '_w.dat',
base + '_c.dat',
base + '_t.dat',
base + '.thumb.dat',
base + '_thumb.dat'
]
}
function detectImageExtension(buffer) {
if (buffer.length < 4) return null
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return '.png'
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) return '.gif'
if (buffer[0] === 0x42 && buffer[1] === 0x4d) return '.bmp'
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) return '.webp'
return null
}
function getMimeType(extension) {
if (extension === '.png') return 'image/png'
if (extension === '.gif') return 'image/gif'
if (extension === '.bmp') return 'image/bmp'
if (extension === '.webp') return 'image/webp'
return 'image/jpeg'
}
function strictRemovePadding(buffer) {
if (buffer.length === 0) return buffer
const paddingLength = buffer[buffer.length - 1]
if (paddingLength <= 0 || paddingLength > 16 || paddingLength > buffer.length) return buffer
for (let index = buffer.length - paddingLength; index < buffer.length; index += 1) {
if (buffer[index] !== paddingLength) return buffer
}
return buffer.subarray(0, buffer.length - paddingLength)
}
function unwrapWxgf(buffer) {
if (
buffer.length < 20 ||
buffer[0] !== 0x77 ||
buffer[1] !== 0x78 ||
buffer[2] !== 0x67 ||
buffer[3] !== 0x66
) {
return buffer
}
for (let index = 4; index < Math.min(buffer.length - 12, 4096); index += 1) {
if (buffer[index] === 0xff && buffer[index + 1] === 0xd8 && buffer[index + 2] === 0xff) {
return buffer.subarray(index)
}
if (
buffer[index] === 0x89 &&
buffer[index + 1] === 0x50 &&
buffer[index + 2] === 0x4e &&
buffer[index + 3] === 0x47
) {
return buffer.subarray(index)
}
}
return buffer
}
function decryptCandidate(filePath, aesKey, xorKey) {
const bytes = fs.readFileSync(filePath)
if (!path.extname(filePath).toLowerCase().includes('dat')) {
const extension = detectImageExtension(bytes) || path.extname(filePath).toLowerCase()
return { data: 'data:' + getMimeType(extension) + ';base64,' + bytes.toString('base64'), filePath }
}
if (
bytes.length < 15 ||
bytes[0] !== 0x07 ||
bytes[1] !== 0x08 ||
bytes[2] !== 0x56 ||
bytes[3] !== 0x32 ||
bytes[4] !== 0x08 ||
bytes[5] !== 0x07 ||
!aesKey
) {
return null
}
const payload = bytes.subarray(15)
const aesSize = bytes.readInt32LE(6)
const xorSize = bytes.readInt32LE(10)
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > payload.length) return null
const aesData = payload.subarray(0, alignedAesSize)
let unpadded = Buffer.alloc(0)
if (aesData.length > 0) {
const key = Buffer.from(aesKey, 'ascii').subarray(0, 16)
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
decipher.setAutoPadding(false)
unpadded = strictRemovePadding(Buffer.concat([decipher.update(aesData), decipher.final()]))
}
const remaining = payload.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) return null
const rawLength = remaining.length - xorSize
const rawData = remaining.subarray(0, rawLength)
const xorData = remaining.subarray(rawLength)
const xorPlain = Buffer.allocUnsafe(xorData.length)
for (let index = 0; index < xorData.length; index += 1) {
xorPlain[index] = xorData[index] ^ xorKey
}
const image = unwrapWxgf(Buffer.concat([unpadded, rawData, xorPlain]))
const extension = detectImageExtension(image)
if (!extension) return null
return {
data: 'data:' + getMimeType(extension) + ';base64,' + image.toString('base64'),
filePath
}
}
function collectCandidates(datPath, allowThumbnail) {
const candidates = [datPath]
if (!path.extname(datPath).toLowerCase().includes('dat')) return candidates
const directory = path.dirname(datPath)
const base = normalizeDatBase(path.basename(datPath))
const siblings = buildPreferredDatNames(base)
.filter((name) => allowThumbnail || !isThumbnailName(name))
.map((name) => path.join(directory, name))
.filter((candidate) => fs.existsSync(candidate))
.sort((left, right) => {
const thumbnailOrder = Number(isThumbnailName(path.basename(left))) - Number(isThumbnailName(path.basename(right)))
return thumbnailOrder || fs.statSync(right).size - fs.statSync(left).size
})
return Array.from(new Set(candidates.concat(siblings)))
}
let result = null
for (const candidate of collectCandidates(workerData.datPath, workerData.allowThumbnail)) {
try {
result = decryptCandidate(candidate, workerData.aesKey, workerData.xorKey)
if (result) break
} catch {
// Try the next local quality variant.
}
}
parentPort.postMessage(result)
`
export class ImageDecryptService { export class ImageDecryptService {
private xorKey: number = 0 private xorKey: number = 0
@@ -26,8 +222,15 @@ export class ImageDecryptService {
private imagePathCache = new Map<string, string>() private imagePathCache = new Map<string, string>()
private decodedImageCache = new Map<string, DecodedImage>() private decodedImageCache = new Map<string, DecodedImage>()
private decodedImageCacheBytes = 0 private decodedImageCacheBytes = 0
private persistentCachePrunePromise: Promise<void> | null = null
private persistentCachePrunePending = false
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) { constructor(
xorKey: string,
aesKey: string,
wcdb4Client?: Wcdb4Client | null,
configuredAccountDir?: string
) {
// 解析 XOR Key (支持 0x40 或 64 格式) // 解析 XOR Key (支持 0x40 或 64 格式)
const xorHex = xorKey.trim().toLowerCase() const xorHex = xorKey.trim().toLowerCase()
if (xorHex.startsWith('0x')) { if (xorHex.startsWith('0x')) {
@@ -39,6 +242,12 @@ export class ImageDecryptService {
// AES Key 直接使用 // AES Key 直接使用
this.aesKey = aesKey.trim() this.aesKey = aesKey.trim()
this.wcdb4Client = wcdb4Client || null this.wcdb4Client = wcdb4Client || null
const accountDir = this.wcdb4Client?.getAccountRoot() || configuredAccountDir
if (accountDir && existsSync(accountDir)) {
this.cachedAccountDir = accountDir
this.accountDirResolved = true
}
} }
/** /**
@@ -96,17 +305,19 @@ export class ImageDecryptService {
findImageFile( findImageFile(
md5?: string, md5?: string,
imageDatName?: string, imageDatName?: string,
options?: { allowThumbnail?: boolean; accountDir?: string; preferThumbnail?: boolean } options?: ImageFindOptions
): string | null { ): string | null {
const allowThumbnail = options?.allowThumbnail !== false const allowThumbnail = options?.allowThumbnail !== false
const normalizedMd5 = this.normalizeDatBase(md5 || '') const normalizedMd5 = this.normalizeDatBase(md5 || '')
const normalizedDatName = this.normalizeDatBase(imageDatName || '') const normalizedDatName = this.normalizeDatBase(imageDatName || '')
const sessionDirectory = this.getSessionDirectoryName(options?.sessionId)
const pathCacheKey = [ const pathCacheKey = [
normalizedMd5, normalizedMd5,
normalizedDatName, normalizedDatName,
allowThumbnail ? 'thumb' : 'original', allowThumbnail ? 'thumb' : 'original',
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original', options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
options?.accountDir || '' options?.accountDir || '',
sessionDirectory
].join('|') ].join('|')
const cachedPath = this.imagePathCache.get(pathCacheKey) const cachedPath = this.imagePathCache.get(pathCacheKey)
if (cachedPath && existsSync(cachedPath)) return cachedPath if (cachedPath && existsSync(cachedPath)) return cachedPath
@@ -126,9 +337,30 @@ export class ImageDecryptService {
md5: normalizedMd5, md5: normalizedMd5,
imageDatName: normalizedDatName, imageDatName: normalizedDatName,
accountDir, accountDir,
allowThumbnail allowThumbnail,
sessionDirectory
}) })
const attachDir = join(accountDir, 'msg', 'attach')
// The attach directory stores DAT filenames, while the message MD5 often
// identifies the original image rather than the local file.
const searchKeys = this.uniq([normalizedDatName, normalizedMd5])
// Message rows already identify their conversation. Prefer that small,
// deterministic directory before consulting the native hardlink database.
if (sessionDirectory && existsSync(attachDir)) {
for (const key of searchKeys) {
const scopedHit = this.fastProbabilisticSearch(
attachDir,
key,
allowThumbnail,
options?.preferThumbnail,
sessionDirectory
)
if (scopedHit) return rememberPath(scopedHit)
}
}
for (const key of this.uniq([normalizedMd5, normalizedDatName])) { for (const key of this.uniq([normalizedMd5, normalizedDatName])) {
const hardlink = this.wcdb4Client?.resolveImageHardlink(key) const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : '' const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
@@ -146,7 +378,6 @@ export class ImageDecryptService {
} }
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/ // 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
const attachDir = join(accountDir, 'msg', 'attach')
if (!existsSync(attachDir)) { if (!existsSync(attachDir)) {
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir) imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
return rememberPath( return rememberPath(
@@ -159,17 +390,18 @@ export class ImageDecryptService {
) )
} }
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
if (searchKeys.length === 0) return null if (searchKeys.length === 0) return null
for (const key of searchKeys) { if (!sessionDirectory) {
const directHit = this.fastProbabilisticSearch( for (const key of searchKeys) {
attachDir, const directHit = this.fastProbabilisticSearch(
key, attachDir,
allowThumbnail, key,
options?.preferThumbnail allowThumbnail,
) options?.preferThumbnail
if (directHit) return rememberPath(directHit) )
if (directHit) return rememberPath(directHit)
}
} }
const legacyHit = this.findImageFileInLegacyDirs( const legacyHit = this.findImageFileInLegacyDirs(
@@ -184,15 +416,41 @@ export class ImageDecryptService {
return null return null
} }
getCachedDecodedImage(key: string): DecodedImage | null { async getCachedDecodedImage(
key: string,
options: { includeData?: boolean } = {}
): Promise<DecodedImage | null> {
const cached = this.decodedImageCache.get(key) const cached = this.decodedImageCache.get(key)
if (!cached) return null if (cached) {
this.decodedImageCache.delete(key) this.decodedImageCache.delete(key)
this.decodedImageCache.set(key, cached) this.decodedImageCache.set(key, cached)
return cached return cached
}
const persistent = await this.getPersistentDecodedImage(key, options.includeData !== false)
if (!persistent) return null
if (persistent.data) this.putDecodedImageInMemory(key, persistent)
return persistent
} }
cacheDecodedImage(key: string, image: DecodedImage): void { async cacheDecodedImage(key: string, image: DecodedImage): Promise<void> {
this.putDecodedImageInMemory(key, image)
try {
const persisted = await this.writePersistentDecodedImage(key, image)
if (persisted) {
const current = this.decodedImageCache.get(key)
if (current) {
current.cacheFilePath = persisted.cacheFilePath
current.mimeType = persisted.mimeType
}
}
} catch (error) {
imageDecryptLog('[ImageDecrypt] persistent cache write failed:', error)
}
}
private putDecodedImageInMemory(key: string, image: DecodedImage): void {
const size = image.data.length * 2 const size = image.data.length * 2
const previous = this.decodedImageCache.get(key) const previous = this.decodedImageCache.get(key)
if (previous) { if (previous) {
@@ -213,11 +471,436 @@ export class ImageDecryptService {
} }
} }
private getPersistentCacheDir(): string | null {
try {
return join(app.getPath('userData'), 'cache', 'images')
} catch (error) {
imageDecryptLog('[ImageDecrypt] persistent cache path unavailable:', error)
return null
}
}
private getPersistentCacheKey(key: string): string {
const accountDir = this.getAccountDir() || this.wcdb4Client?.getAccountRoot() || ''
const resolvedAccountDir = accountDir ? resolve(accountDir) : ''
const accountScope =
process.platform === 'win32'
? resolvedAccountDir.replace(/\\/g, '/').toLowerCase()
: resolvedAccountDir
const scope = JSON.stringify({
version: PERSISTENT_IMAGE_CACHE_VERSION,
accountDir: accountScope,
key
})
return crypto.createHash('sha256').update(scope).digest('hex')
}
private async getPersistentDecodedImage(
key: string,
includeData: boolean
): Promise<DecodedImage | null> {
const cacheDir = this.getPersistentCacheDir()
if (!cacheDir) return null
const cacheKey = this.getPersistentCacheKey(key)
const metadataPath = join(cacheDir, `${cacheKey}.json`)
let metadata: PersistentImageMeta | null = null
try {
metadata = JSON.parse(await fsPromises.readFile(metadataPath, 'utf8')) as PersistentImageMeta
if (!this.isValidPersistentImageMeta(metadata, cacheKey)) {
throw new Error('invalid persistent image metadata')
}
const sourceStat = await fsPromises.stat(metadata.sourcePath)
if (
!sourceStat.isFile() ||
sourceStat.size !== metadata.sourceSize ||
Math.trunc(sourceStat.mtimeMs) !== Math.trunc(metadata.sourceMtimeMs)
) {
throw new Error('persistent image source changed')
}
const cacheFilePath = join(cacheDir, metadata.fileName)
const cacheStat = await fsPromises.stat(cacheFilePath)
if (!cacheStat.isFile() || cacheStat.size <= 0 || cacheStat.size !== metadata.cacheSize) {
throw new Error('persistent image payload changed')
}
const payload = includeData
? await fsPromises.readFile(cacheFilePath)
: await this.readImageSignature(cacheFilePath)
const extension = this.detectImageExtension(payload)
if (!extension || this.getMimeType(extension) !== metadata.mimeType) {
throw new Error('persistent image payload is invalid')
}
const now = new Date()
void Promise.all([
fsPromises.utimes(cacheFilePath, now, now),
fsPromises.utimes(metadataPath, now, now)
]).catch(() => undefined)
return {
data: includeData ? `data:${metadata.mimeType};base64,${payload.toString('base64')}` : '',
filePath: metadata.sourcePath,
isThumbnail: metadata.isThumbnail,
cacheFilePath,
mimeType: metadata.mimeType
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
imageDecryptLog('[ImageDecrypt] persistent cache miss:', error)
}
await this.removePersistentCacheEntry(cacheDir, cacheKey, metadata?.fileName)
return null
}
}
private async writePersistentDecodedImage(
key: string,
image: DecodedImage
): Promise<{ cacheFilePath: string; mimeType: string } | null> {
const cacheDir = this.getPersistentCacheDir()
if (!cacheDir || !image.filePath) return null
const payload = await this.getDecodedImagePayload(image)
if (!payload) return null
const extension = this.detectImageExtension(payload)
if (!extension) return null
const sourceStat = await fsPromises.stat(image.filePath)
if (!sourceStat.isFile()) return null
const cacheKey = this.getPersistentCacheKey(key)
const fileName = `${cacheKey}${extension}`
const cacheFilePath = join(cacheDir, fileName)
const metadataPath = join(cacheDir, `${cacheKey}.json`)
const mimeType = this.getMimeType(extension)
const nonce = `${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`
const payloadTempPath = join(cacheDir, `${cacheKey}.${nonce}.tmp`)
const metadataTempPath = join(cacheDir, `${cacheKey}.${nonce}.json.tmp`)
const metadata: PersistentImageMeta = {
version: PERSISTENT_IMAGE_CACHE_VERSION,
sourcePath: image.filePath,
sourceSize: sourceStat.size,
sourceMtimeMs: sourceStat.mtimeMs,
fileName,
cacheSize: payload.length,
mimeType,
isThumbnail: image.isThumbnail
}
await fsPromises.mkdir(cacheDir, { recursive: true })
try {
await fsPromises.writeFile(payloadTempPath, payload)
await this.replaceFileAtomically(payloadTempPath, cacheFilePath)
await fsPromises.writeFile(metadataTempPath, JSON.stringify(metadata))
await this.replaceFileAtomically(metadataTempPath, metadataPath)
await this.removeOtherPersistentPayloads(cacheDir, cacheKey, fileName)
} finally {
await Promise.allSettled([
fsPromises.rm(payloadTempPath, { force: true }),
fsPromises.rm(metadataTempPath, { force: true })
])
}
this.schedulePersistentCachePrune()
return { cacheFilePath, mimeType }
}
private async getDecodedImagePayload(image: DecodedImage): Promise<Buffer | null> {
const separatorIndex = image.data.indexOf(',')
if (separatorIndex > 0) {
const header = image.data.slice(0, separatorIndex)
if (/^data:image\/[a-z0-9.+-]+;base64$/i.test(header)) {
const payload = Buffer.from(image.data.slice(separatorIndex + 1), 'base64')
return payload.length > 0 ? payload : null
}
}
if (image.cacheFilePath) {
try {
return await fsPromises.readFile(image.cacheFilePath)
} catch {
return null
}
}
return null
}
async findImageFileAsync(
md5?: string,
imageDatName?: string,
options?: ImageFindOptions
): Promise<string | null> {
const sessionDirectory = this.getSessionDirectoryName(options?.sessionId)
if (!sessionDirectory) return this.findImageFile(md5, imageDatName, options)
const allowThumbnail = options?.allowThumbnail !== false
const normalizedMd5 = this.normalizeDatBase(md5 || '')
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
const pathCacheKey = [
normalizedMd5,
normalizedDatName,
allowThumbnail ? 'thumb' : 'original',
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
options?.accountDir || '',
sessionDirectory
].join('|')
const cachedPath = this.imagePathCache.get(pathCacheKey)
if (cachedPath && existsSync(cachedPath)) return cachedPath
const rememberPath = (filePath: string | null): string | null => {
if (filePath) this.imagePathCache.set(pathCacheKey, filePath)
return filePath
}
const accountDir =
options?.accountDir && existsSync(options.accountDir)
? options.accountDir
: this.getAccountDir()
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
)
if (scopedHit) return rememberPath(scopedHit)
}
for (const key of this.uniq([normalizedMd5, normalizedDatName])) {
const hardlink = await this.wcdb4Client?.resolveImageHardlinkAsync(key)
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
if (!fullPath || !existsSync(fullPath)) continue
const selected = this.getPreferredDatVariantPath(
fullPath,
allowThumbnail,
options?.preferThumbnail
)
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
return rememberPath(selected)
}
}
return null
}
private async findImageInSessionDirectoryAsync(
attachDir: string,
datName: string,
allowThumbnail: boolean,
preferThumbnail: boolean | undefined,
sessionDirectory: string
): Promise<string | null> {
const normalized = this.normalizeDatBase(datName)
if (!normalized || !sessionDirectory) return null
const sessionRoot = join(attachDir, sessionDirectory)
let monthDirectories: string[]
try {
monthDirectories = (await fsPromises.readdir(sessionRoot, { 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
}
const variants = this.buildPreferredDatNames(normalized)
for (const month of monthDirectories) {
const candidates = ['Img', 'Image', 'image'].flatMap((subDirectory) =>
variants.map((variant) => join(sessionRoot, month, subDirectory, variant))
)
const found = await this.getLargestExistingPathAsync(
candidates,
allowThumbnail,
preferThumbnail
)
if (found) return found
}
return null
}
private isValidPersistentImageMeta(
metadata: PersistentImageMeta,
cacheKey: string
): boolean {
return (
metadata !== null &&
typeof metadata === 'object' &&
metadata.version === PERSISTENT_IMAGE_CACHE_VERSION &&
typeof metadata.sourcePath === 'string' &&
metadata.sourcePath.length > 0 &&
Number.isFinite(metadata.sourceSize) &&
metadata.sourceSize >= 0 &&
Number.isFinite(metadata.sourceMtimeMs) &&
typeof metadata.fileName === 'string' &&
basename(metadata.fileName) === metadata.fileName &&
metadata.fileName.startsWith(`${cacheKey}.`) &&
Number.isFinite(metadata.cacheSize) &&
metadata.cacheSize > 0 &&
typeof metadata.mimeType === 'string' &&
metadata.mimeType.startsWith('image/') &&
typeof metadata.isThumbnail === 'boolean'
)
}
private async readImageSignature(filePath: string): Promise<Buffer> {
const handle = await fsPromises.open(filePath, 'r')
try {
const signature = Buffer.alloc(16)
const { bytesRead } = await handle.read(signature, 0, signature.length, 0)
return signature.subarray(0, bytesRead)
} finally {
await handle.close()
}
}
private async replaceFileAtomically(tempPath: string, targetPath: string): Promise<void> {
try {
await fsPromises.rename(tempPath, targetPath)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== 'EEXIST' && code !== 'EPERM') throw error
await fsPromises.rm(targetPath, { force: true })
await fsPromises.rename(tempPath, targetPath)
}
}
private async removeOtherPersistentPayloads(
cacheDir: string,
cacheKey: string,
keepFileName: string
): Promise<void> {
const names = await fsPromises.readdir(cacheDir)
const obsolete = names.filter(
(name) =>
name !== keepFileName &&
name.startsWith(`${cacheKey}.`) &&
/^\.(?:jpe?g|png|gif|bmp|webp)$/i.test(name.slice(cacheKey.length))
)
await Promise.allSettled(
obsolete.map((name) => fsPromises.rm(join(cacheDir, name), { force: true }))
)
}
private async removePersistentCacheEntry(
cacheDir: string,
cacheKey: string,
fileName?: string
): Promise<void> {
const candidates = new Set<string>([`${cacheKey}.json`])
if (fileName && basename(fileName) === fileName && fileName.startsWith(`${cacheKey}.`)) {
candidates.add(fileName)
} else {
try {
const names = await fsPromises.readdir(cacheDir)
for (const name of names) {
if (
name.startsWith(`${cacheKey}.`) &&
/^\.(?:jpe?g|png|gif|bmp|webp)$/i.test(name.slice(cacheKey.length))
) {
candidates.add(name)
}
}
} catch {
return
}
}
await Promise.allSettled(
Array.from(candidates, (name) => fsPromises.rm(join(cacheDir, name), { force: true }))
)
}
private schedulePersistentCachePrune(): void {
if (this.persistentCachePrunePromise) {
this.persistentCachePrunePending = true
return
}
this.persistentCachePrunePromise = this.prunePersistentCache()
.catch((error) => imageDecryptLog('[ImageDecrypt] persistent cache prune failed:', error))
.finally(() => {
this.persistentCachePrunePromise = null
if (this.persistentCachePrunePending) {
this.persistentCachePrunePending = false
this.schedulePersistentCachePrune()
}
})
}
private async prunePersistentCache(): Promise<void> {
const cacheDir = this.getPersistentCacheDir()
if (!cacheDir) return
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)
)
.map((entry) => entry.name)
const payloads = (
await Promise.all(
payloadNames.map(async (name) => {
try {
const stat = await fsPromises.stat(join(cacheDir, name))
return { name, size: stat.size, mtimeMs: stat.mtimeMs }
} catch {
return null
}
})
)
)
.filter((entry): entry is { name: string; size: number; mtimeMs: number } => entry !== null)
.sort((left, right) => left.mtimeMs - right.mtimeMs)
let totalBytes = payloads.reduce((total, entry) => total + entry.size, 0)
let totalFiles = payloads.length
for (const payload of payloads) {
if (
totalFiles <= MAX_PERSISTENT_IMAGE_CACHE_FILES &&
totalBytes <= MAX_PERSISTENT_IMAGE_CACHE_BYTES
) {
break
}
const cacheKey = payload.name.slice(0, 64)
await Promise.allSettled([
fsPromises.rm(join(cacheDir, payload.name), { force: true }),
fsPromises.rm(join(cacheDir, `${cacheKey}.json`), { force: true })
])
totalFiles -= 1
totalBytes -= payload.size
}
const remainingPayloadKeys = new Set(
payloads
.slice(payloads.length - totalFiles)
.map((payload) => payload.name.slice(0, 64))
)
const staleMetadata = entries.filter(
(entry) =>
entry.isFile() &&
/^[a-f0-9]{64}\.json$/i.test(entry.name) &&
!remainingPayloadKeys.has(entry.name.slice(0, 64))
)
await Promise.allSettled(
staleMetadata.map((entry) => fsPromises.rm(join(cacheDir, entry.name), { force: true }))
)
}
private fastProbabilisticSearch( private fastProbabilisticSearch(
attachDir: string, attachDir: string,
datName: string, datName: string,
allowThumbnail = true, allowThumbnail = true,
preferThumbnail = false preferThumbnail = false,
sessionDirectory = ''
): string | null { ): string | null {
const normalized = this.normalizeDatBase(datName) const normalized = this.normalizeDatBase(datName)
if (!normalized) return null if (!normalized) return null
@@ -243,9 +926,13 @@ export class ImageDecryptService {
} }
try { try {
const sessionDirs = readdirSync(attachDir).filter( const sessionDirs = sessionDirectory
(name) => name.length === 32 && /^[a-f0-9]+$/i.test(name) ? existsSync(join(attachDir, sessionDirectory))
) ? [sessionDirectory]
: []
: readdirSync(attachDir).filter(
(name) => name.length === 32 && /^[a-f0-9]+$/i.test(name)
)
const now = new Date() const now = new Date()
const months: string[] = [] const months: string[] = []
@@ -462,6 +1149,54 @@ export class ImageDecryptService {
return null return null
} }
async decryptImageToBase64WithFallbackAsync(
datPath: string,
allowThumbnail = true
): Promise<{ data: string; filePath: string } | null> {
if (!existsSync(datPath)) return null
return new Promise((resolve) => {
let settled = false
const worker = new Worker(IMAGE_DECRYPT_WORKER_SOURCE, {
eval: true,
workerData: {
datPath,
allowThumbnail,
xorKey: this.xorKey,
aesKey: this.aesKey
}
})
const timeout = setTimeout(() => {
imageDecryptLog('[ImageDecrypt] worker timed out:', datPath)
void worker.terminate()
finish(null)
}, 30_000)
const finish = (result: { data: string; filePath: string } | null): void => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(result)
}
worker.once('message', (value: unknown) => {
if (
value &&
typeof value === 'object' &&
typeof (value as { data?: unknown }).data === 'string' &&
typeof (value as { filePath?: unknown }).filePath === 'string'
) {
finish(value as { data: string; filePath: string })
return
}
finish(null)
})
worker.once('error', (error) => {
imageDecryptLog('[ImageDecrypt] worker failed:', error)
finish(null)
})
worker.once('exit', () => finish(null))
})
}
/** /**
* 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。 * 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。
* 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。 * 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。
@@ -584,6 +1319,13 @@ export class ImageDecryptService {
return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '').toLowerCase() return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '').toLowerCase()
} }
private getSessionDirectoryName(sessionId?: string): string {
const value = String(sessionId || '').trim()
if (!value) return ''
if (/^[a-f0-9]{32}$/i.test(value)) return value.toLowerCase()
return crypto.createHash('md5').update(value).digest('hex')
}
private buildPreferredDatNames(baseName: string): string[] { private buildPreferredDatNames(baseName: string): string[] {
const base = this.normalizeDatBase(baseName) const base = this.normalizeDatBase(baseName)
if (!base) return [] if (!base) return []
@@ -651,6 +1393,37 @@ export class ImageDecryptService {
return existing[0]?.candidate || null return existing[0]?.candidate || null
} }
private async getLargestExistingPathAsync(
paths: string[],
allowThumbnail: boolean,
preferThumbnail = false
): Promise<string | null> {
const sized = (
await Promise.all(
paths.map(async (candidate) => {
try {
const stat = await fsPromises.stat(candidate)
return stat.isFile() ? { candidate, size: stat.size } : null
} catch {
return null
}
})
)
)
.filter((entry): entry is { candidate: string; size: number } => entry !== null)
.sort((left, right) => right.size - left.size)
if (preferThumbnail) {
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))
)
if (nonThumbnail) return nonThumbnail.candidate
return allowThumbnail ? sized[0]?.candidate || null : null
}
private isThumbnailName(fileName: string): boolean { private isThumbnailName(fileName: string): boolean {
const lower = fileName.toLowerCase() const lower = fileName.toLowerCase()
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat') return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
+211 -60
View File
@@ -21,7 +21,7 @@ import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
import { VoiceService } from './voice-service' import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service' import { StickerService } from './sticker-service'
import { parseMessageContent } from './message-parser' import { parseMessageContent } from './message-parser'
import { ImageDecryptService } from './image-decrypt-service' import { ImageDecryptService, type DecodedImage } from './image-decrypt-service'
import { exportGroupReport } from './group-report-service' import { exportGroupReport } from './group-report-service'
import { import {
deleteGeneratedReport, deleteGeneratedReport,
@@ -103,6 +103,71 @@ let recallArchiveMonitor: RecallArchiveMonitor | null = null
let recallProtectionGeneration = 0 let recallProtectionGeneration = 0
let recallJournalTimer: NodeJS.Timeout | null = null let recallJournalTimer: NodeJS.Timeout | null = null
let wcdbBootstrapPromise: Promise<unknown> | null = null let wcdbBootstrapPromise: Promise<unknown> | null = null
type ColdImageLoadItem = {
priority: number
sequence: number
run: () => Promise<unknown>
resolve: (value: unknown) => void
reject: (reason: unknown) => void
}
const coldImageLoadQueue: ColdImageLoadItem[] = []
let activeColdImageLoads = 0
let coldImageLoadSequence = 0
let coldImageLoadTimer: NodeJS.Timeout | null = null
let nextColdImageLoadAt = 0
const COLD_IMAGE_LOAD_GAP_MS = 100
const MAX_CONCURRENT_COLD_IMAGE_LOADS = 2
function pumpColdImageLoads(): void {
if (
activeColdImageLoads >= MAX_CONCURRENT_COLD_IMAGE_LOADS ||
coldImageLoadQueue.length === 0
) {
return
}
const waitMs = Math.max(0, nextColdImageLoadAt - Date.now())
if (waitMs > 0) {
if (!coldImageLoadTimer) {
coldImageLoadTimer = setTimeout(() => {
coldImageLoadTimer = null
pumpColdImageLoads()
}, waitMs)
}
return
}
coldImageLoadQueue.sort(
(left, right) => left.priority - right.priority || left.sequence - right.sequence
)
const item = coldImageLoadQueue.shift()
if (!item) return
activeColdImageLoads += 1
nextColdImageLoadAt = Date.now() + COLD_IMAGE_LOAD_GAP_MS
void item
.run()
.then(item.resolve, item.reject)
.finally(() => {
activeColdImageLoads -= 1
pumpColdImageLoads()
})
pumpColdImageLoads()
}
function enqueueColdImageLoad<T>(task: () => Promise<T> | T, priority = 0): Promise<T> {
return new Promise<T>((resolve, reject) => {
coldImageLoadQueue.push({
priority,
sequence: coldImageLoadSequence++,
run: async () => task(),
resolve: (value) => resolve(value as T),
reject
})
pumpColdImageLoads()
})
}
function configureRecallProtection( function configureRecallProtection(
wcdb4Client: Wcdb4Client, wcdb4Client: Wcdb4Client,
@@ -199,9 +264,58 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
} }
} }
function getImageMediaService(): VideoAssetService | null {
if (videoAssetService) return videoAssetService
const client = chat.getChatDb()?.getWcdb4Client()
if (!client) return null
videoAssetService = new VideoAssetService(client)
return videoAssetService
}
function getLocalMediaMimeType(filePath: string): string {
switch (extname(filePath).toLowerCase()) {
case '.mp4':
return 'video/mp4'
case '.jpg':
case '.jpeg':
return 'image/jpeg'
case '.png':
return 'image/png'
case '.gif':
return 'image/gif'
case '.webp':
return 'image/webp'
case '.bmp':
return 'image/bmp'
default:
return 'application/octet-stream'
}
}
function buildImageResponse(image: DecodedImage): {
success: true
data: string
isThumb: boolean
filePath: string
mimeType?: string
} {
const mediaService = image.cacheFilePath ? getImageMediaService() : null
const data =
mediaService && image.cacheFilePath && existsSync(image.cacheFilePath)
? mediaService.createLocalMediaUrl(image.cacheFilePath)
: image.data
return {
success: true,
data,
isThumb: image.isThumbnail,
filePath: image.filePath,
mimeType: image.mimeType
}
}
async function createLocalMediaResponse(request: Request, filePath: string): Promise<Response> { async function createLocalMediaResponse(request: Request, filePath: string): Promise<Response> {
const { size } = await fsPromises.stat(filePath) const { size } = await fsPromises.stat(filePath)
const mimeType = extname(filePath).toLowerCase() === '.mp4' ? 'video/mp4' : 'image/jpeg' const mimeType = getLocalMediaMimeType(filePath)
const commonHeaders = { const commonHeaders = {
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
'Content-Type': mimeType, 'Content-Type': mimeType,
@@ -292,8 +406,7 @@ function createWindow(): void {
// 某些 API 只能在此事件发生后使用 // 某些 API 只能在此事件发生后使用
app.whenReady().then(async () => { app.whenReady().then(async () => {
protocol.handle('wxe-media', async (request) => { protocol.handle('wxe-media', async (request) => {
const token = new URL(request.url).pathname.replace(/^\/+/, '') const filePath = videoAssetService?.pathForUrl(request.url)
const filePath = videoAssetService?.pathForToken(token)
if (!filePath) return new Response('Not found', { status: 404 }) if (!filePath) return new Response('Not found', { status: 404 })
try { try {
return await createLocalMediaResponse(request, filePath) return await createLocalMediaResponse(request, filePath)
@@ -393,24 +506,24 @@ app.whenReady().then(async () => {
} }
chat.setChatDb(nextWechatDb) chat.setChatDb(nextWechatDb)
const wcdb4Client = nextWechatDb.getWcdb4Client() const wcdb4Client = nextWechatDb.getWcdb4Client()
const sessions = await wcdb4Client.getSessionsAsync()
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled) configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
voiceService = new VoiceService(wcdb4Client) voiceService = new VoiceService(wcdb4Client)
stickerService = new StickerService(wcdb4Client) stickerService = new StickerService(wcdb4Client)
videoAssetService = new VideoAssetService(wcdb4Client) videoAssetService = new VideoAssetService(wcdb4Client)
const monitoring = wcdb4Client.startMonitor((type, json) => { const monitoring = await wcdb4Client.startMonitor((type, json) => {
wcdb4Client.invalidateSessionCache() wcdb4Client.invalidateSessionCache()
recallArchiveMonitor?.handleDatabaseChange(json) recallArchiveMonitor?.handleDatabaseChange(json)
for (const window of BrowserWindow.getAllWindows()) { for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json }) if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
} }
}) })
setImmediate(() => { const recentSession = sessions[0]
const recentSession = wcdb4Client.getSessions()[0] if (recentSession?.username) {
if (!recentSession?.username) return
void wcdb4Client void wcdb4Client
.getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 }) .getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 })
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error)) .catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
}) }
imageDecryptService = null imageDecryptService = null
return { success: true, monitoring } return { success: true, monitoring }
} catch (error) { } catch (error) {
@@ -574,11 +687,11 @@ app.whenReady().then(async () => {
} }
) )
ipcMain.handle('db:getContacts', (_, filter?: string) => { ipcMain.handle('db:getContacts', async (_, filter?: string) => {
const accountRoot = chat.getCurrentAccountRoot() const accountRoot = chat.getCurrentAccountRoot()
const contacts = accountRoot const contacts = accountRoot
? mergeCachedContactAvatars(accountRoot, chat.listContacts(filter)) ? mergeCachedContactAvatars(accountRoot, await chat.listContactsAsync(filter))
: chat.listContacts(filter) : await chat.listContactsAsync(filter)
if (!filter && chat.isReady() && accountRoot) { if (!filter && chat.isReady() && accountRoot) {
saveBootstrapContacts(accountRoot, contacts) saveBootstrapContacts(accountRoot, contacts)
} }
@@ -643,9 +756,15 @@ app.whenReady().then(async () => {
aiProviderService.migrateLegacy(config) aiProviderService.migrateLegacy(config)
) )
ipcMain.handle('copy-image', async (_, base64String) => { ipcMain.handle('copy-image', async (_, imageSource: unknown) => {
try { try {
const image = nativeImage.createFromDataURL(base64String) if (typeof imageSource !== 'string' || !imageSource) {
return { success: false, error: 'Image source is empty' }
}
const image = imageSource.startsWith('wxe-media://')
? nativeImage.createFromPath(getImageMediaService()?.pathForUrl(imageSource) || '')
: nativeImage.createFromDataURL(imageSource)
if (image.isEmpty()) return { success: false, error: 'Image source is invalid' }
clipboard.writeImage(image) clipboard.writeImage(image)
return { success: true } return { success: true }
} catch (error: unknown) { } catch (error: unknown) {
@@ -716,68 +835,100 @@ app.whenReady().then(async () => {
imageMd5?: string, imageMd5?: string,
imageDatNameOrThumb?: string | boolean, imageDatNameOrThumb?: string | boolean,
_sessionId?: string, _sessionId?: string,
options?: { force?: boolean; preferThumbnail?: boolean } options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
) => { ) => {
void _sessionId let service = imageDecryptService
if (!imageDecryptService) { if (!service) {
const { xorKey, aesKey } = getConfiguredImageKeys() const { xorKey, aesKey } = getConfiguredImageKeys()
if (!aesKey) { // Reading an already-decoded cache entry does not require the AES key.
return { success: false, error: '未配置图片解密密钥' } // Before db:init resolves the account identity, secure storage may not
} // expose that key yet, so keep this cache-only service local.
imageDecryptService = new ImageDecryptService( service = new ImageDecryptService(
xorKey, xorKey,
aesKey, aesKey,
chat.getChatDb()?.getWcdb4Client() chat.getChatDb()?.getWcdb4Client(),
loadSettings().dbRoot
) )
if (aesKey) imageDecryptService = service
} }
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
const force = options?.force === true const force = options?.force === true
const preferThumbnail = options?.preferThumbnail === true const preferThumbnail = options?.preferThumbnail === true
const priority = Number.isFinite(options?.priority) ? Number(options?.priority) : 0
const imageCacheKey = [ const imageCacheKey = [
imageMd5 || '', imageMd5 || '',
imageDatName || '', imageDatName || '',
force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto' force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto'
].join('|') ].join('|')
const cachedImage = imageDecryptService.getCachedDecodedImage(imageCacheKey) const mediaService = getImageMediaService()
if (cachedImage) { const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
return { includeData: !mediaService
success: true,
data: cachedImage.data,
isThumb: cachedImage.isThumbnail,
filePath: cachedImage.filePath
}
}
let filePath = force
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
: null
if (!filePath) {
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
allowThumbnail: true,
preferThumbnail
})
}
if (!filePath) {
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
}
const decrypted = imageDecryptService.decryptImageToBase64WithFallback(filePath, true)
if (!decrypted) {
return { success: false, error: '图片解密失败' }
}
const result = {
success: true,
data: decrypted.data,
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
filePath: decrypted.filePath
}
imageDecryptService.cacheDecodedImage(imageCacheKey, {
data: result.data,
filePath: result.filePath,
isThumbnail: result.isThumb
}) })
return result if (cachedImage) {
return buildImageResponse(cachedImage)
}
return enqueueColdImageLoad(async () => {
// Disk cache was already checked without waiting for database startup.
// Only a real miss needs the initialized WCDB client and hardlink index.
if (dbInitInFlight) {
await dbInitInFlight.catch(() => undefined)
}
let coldService = imageDecryptService
if (!coldService) {
const { xorKey, aesKey } = getConfiguredImageKeys()
if (!aesKey) return { success: false, error: '未配置图片解密密钥' }
coldService = new ImageDecryptService(
xorKey,
aesKey,
chat.getChatDb()?.getWcdb4Client(),
loadSettings().dbRoot
)
imageDecryptService = coldService
}
// A previous queued request may have populated the cache while this one waited.
const queuedMediaService = getImageMediaService()
const queuedCacheHit = await coldService.getCachedDecodedImage(imageCacheKey, {
includeData: !queuedMediaService
})
if (queuedCacheHit) return buildImageResponse(queuedCacheHit)
let filePath = force
? await coldService.findImageFileAsync(imageMd5, imageDatName, {
allowThumbnail: false,
sessionId: _sessionId
})
: null
if (!filePath) {
filePath = await coldService.findImageFileAsync(imageMd5, imageDatName, {
allowThumbnail: true,
preferThumbnail,
sessionId: _sessionId
})
}
if (!filePath) {
return {
success: false,
error: force ? '未找到原图或缩略图文件' : '未找到图片文件'
}
}
const decrypted = await coldService.decryptImageToBase64WithFallbackAsync(filePath, true)
if (!decrypted) {
return { success: false, error: '图片解密失败' }
}
const decodedImage: DecodedImage = {
data: decrypted.data,
filePath: decrypted.filePath,
isThumbnail: coldService.isThumbnailFile(decrypted.filePath)
}
await coldService.cacheDecodedImage(imageCacheKey, decodedImage)
return buildImageResponse(decodedImage)
}, priority)
} }
) )
+407 -189
View File
@@ -24,135 +24,364 @@ export interface CachedGroupSnapshot {
}[] }[]
} }
interface CachedMessageBucket { interface StartupCacheFile {
version: 2
platform: NodeJS.Platform
accountRoot: string
updatedAt: number
self?: CachedSelfInfo
contacts: Contact[]
}
interface CachedMessageBucketFile {
version: 2
platform: NodeJS.Platform
accountRoot: string
cacheKey: string
updatedAt: number updatedAt: number
startTime?: number startTime?: number
endTime?: number endTime?: number
items: Message[] items: Message[]
} }
interface BootstrapCacheFile { interface CachedGroupSnapshotFile {
version: 1 version: 2
platform: NodeJS.Platform platform: NodeJS.Platform
accountRoot: string accountRoot: string
userMd5: string
updatedAt: number updatedAt: number
self?: CachedSelfInfo snapshot: CachedGroupSnapshot
contacts?: Contact[]
messages?: Record<string, CachedMessageBucket>
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
} }
const CACHE_VERSION = 1 interface ScheduledWrite {
value: unknown
revision: number
generation: number
cleanupFile?: string
prune?: { directory: string; maxFiles: number }
}
interface AccountCachePaths {
root: string
startup: string
messages: string
groups: string
legacy: string
}
const CACHE_VERSION = 2
const MAX_MESSAGE_BUCKETS = 768 const MAX_MESSAGE_BUCKETS = 768
const MAX_GROUP_SNAPSHOTS = 768
const MAX_MESSAGES_PER_BUCKET = 120 const MAX_MESSAGES_PER_BUCKET = 120
const MAX_MEMORY_MESSAGE_BUCKETS = 32
const MAX_MEMORY_GROUP_SNAPSHOTS = 32
const WRITE_DEBOUNCE_MS = 300 const WRITE_DEBOUNCE_MS = 300
const memoryCache = new Map<string, BootstrapCacheFile>() const PRUNE_INTERVAL_MS = 30_000
const startupMemory = new Map<string, StartupCacheFile>()
const messageMemory = new Map<string, CachedMessageBucketFile>()
const groupMemory = new Map<string, CachedGroupSnapshotFile>()
const writeTimers = new Map<string, NodeJS.Timeout>() const writeTimers = new Map<string, NodeJS.Timeout>()
const writeQueues = new Map<string, Promise<void>>() const writeQueues = new Map<string, Promise<void>>()
const scheduledWrites = new Map<string, ScheduledWrite>()
const writeRevisions = new Map<string, number>()
const lastPrunedAt = new Map<string, number>()
let cacheGeneration = 0
function normalizeRoot(accountRoot?: string): string { function normalizeRoot(accountRoot?: string): string {
return String(accountRoot || '').trim() return String(accountRoot || '').trim()
} }
function getCacheFile(accountRoot?: string): string { function digest(value: string): string {
const normalizedRoot = normalizeRoot(accountRoot) || 'default' return crypto.createHash('sha1').update(value).digest('hex').slice(0, 24)
const hash = crypto
.createHash('sha1')
.update(`${process.platform}:${normalizedRoot}`)
.digest('hex')
.slice(0, 16)
return path.join(
app.getPath('userData'),
'cache',
'bootstrap',
`${process.platform}-${hash}.json`
)
} }
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null { function getAccountCachePaths(accountRoot: string): AccountCachePaths {
const normalizedRoot = normalizeRoot(accountRoot) const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null const accountKey = digest(`${process.platform}:${normalizedRoot}`)
const file = getCacheFile(normalizedRoot) const bootstrapRoot = path.join(app.getPath('userData'), 'cache', 'bootstrap')
const cached = memoryCache.get(file) const root = path.join(bootstrapRoot, `${process.platform}-${accountKey}`)
if (cached) return cached return {
try { root,
if (!fs.existsSync(file)) return null startup: path.join(root, 'startup.json'),
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile> messages: path.join(root, 'messages'),
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null groups: path.join(root, 'groups'),
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null legacy: path.join(bootstrapRoot, `${process.platform}-${accountKey.slice(0, 16)}.json`)
const result: BootstrapCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Number(raw.updatedAt) || 0,
self: raw.self,
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {},
groupSnapshots:
raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {}
}
memoryCache.set(file, result)
return result
} catch (error) {
console.warn('[BootstrapCache] read failed:', error)
return null
} }
} }
function writeCacheFile(cache: BootstrapCacheFile): void {
const file = getCacheFile(cache.accountRoot)
memoryCache.set(file, cache)
const existingTimer = writeTimers.get(file)
if (existingTimer) clearTimeout(existingTimer)
writeTimers.set(
file,
setTimeout(() => {
writeTimers.delete(file)
const serialized = JSON.stringify(memoryCache.get(file) || cache)
const previous = writeQueues.get(file) || Promise.resolve()
const next = previous
.catch(() => undefined)
.then(async () => {
await fs.ensureDir(path.dirname(file))
await fs.writeFile(file, serialized, 'utf8')
})
.catch((error) => {
console.warn('[BootstrapCache] write failed:', error)
})
.finally(() => {
if (writeQueues.get(file) === next) writeQueues.delete(file)
})
writeQueues.set(file, next)
}, WRITE_DEBOUNCE_MS)
)
}
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const existing = readCacheFile(normalizedRoot)
if (existing) return existing
const created: BootstrapCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: [],
messages: {},
groupSnapshots: {}
}
memoryCache.set(getCacheFile(normalizedRoot), created)
return created
}
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string { function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}` return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
} }
function cachedMessageIdentity(message: Message): string { function getMessageCacheFile(accountRoot: string, cacheKey: string): string {
if (message.localId) return `local:${message.localId}` return path.join(getAccountCachePaths(accountRoot).messages, `${digest(cacheKey)}.json`)
if (message.serverId) return `server:${message.serverId}` }
return `id:${message.id}`
function getGroupCacheFile(accountRoot: string, userMd5: string): string {
return path.join(getAccountCachePaths(accountRoot).groups, `${digest(userMd5)}.json`)
}
function readScheduledValue<T>(file: string): T | null {
const scheduled = scheduledWrites.get(file)
return scheduled ? (scheduled.value as T) : null
}
function touchMemory<T>(memory: Map<string, T>, file: string, value: T, maxEntries: number): void {
memory.delete(file)
memory.set(file, value)
while (memory.size > maxEntries) {
const oldest = memory.keys().next().value
if (!oldest) break
memory.delete(oldest)
}
}
function isCurrentAccountFile(
value: {
version?: number
platform?: NodeJS.Platform
accountRoot?: string
},
accountRoot: string
): boolean {
return (
value.version === CACHE_VERSION &&
value.platform === process.platform &&
normalizeRoot(value.accountRoot) === normalizeRoot(accountRoot)
)
}
function readStartupCacheFile(accountRoot: string): StartupCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const file = getAccountCachePaths(normalizedRoot).startup
const scheduled = readScheduledValue<StartupCacheFile>(file)
if (scheduled) return scheduled
const memory = startupMemory.get(file)
if (memory) return memory
try {
if (!fs.existsSync(file)) return null
const raw = fs.readJsonSync(file) as Partial<StartupCacheFile>
if (!isCurrentAccountFile(raw, normalizedRoot)) return null
const result: StartupCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Number(raw.updatedAt) || 0,
self: raw.self,
contacts: Array.isArray(raw.contacts) ? raw.contacts : []
}
startupMemory.set(file, result)
return result
} catch (error) {
console.warn('[BootstrapCache] startup read failed:', error)
return null
}
}
function readMessageBucketFile(
accountRoot: string,
cacheKey: string
): CachedMessageBucketFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const file = getMessageCacheFile(normalizedRoot, cacheKey)
const scheduled = readScheduledValue<CachedMessageBucketFile>(file)
if (scheduled) return scheduled
const memory = messageMemory.get(file)
if (memory) {
touchMemory(messageMemory, file, memory, MAX_MEMORY_MESSAGE_BUCKETS)
return memory
}
try {
if (!fs.existsSync(file)) return null
const raw = fs.readJsonSync(file) as Partial<CachedMessageBucketFile>
if (!isCurrentAccountFile(raw, normalizedRoot) || raw.cacheKey !== cacheKey) return null
const result: CachedMessageBucketFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
cacheKey,
updatedAt: Number(raw.updatedAt) || 0,
startTime: raw.startTime,
endTime: raw.endTime,
items: Array.isArray(raw.items) ? raw.items : []
}
touchMemory(messageMemory, file, result, MAX_MEMORY_MESSAGE_BUCKETS)
return result
} catch (error) {
console.warn('[BootstrapCache] message read failed:', error)
return null
}
}
function readGroupSnapshotFile(
accountRoot: string,
userMd5: string
): CachedGroupSnapshotFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const file = getGroupCacheFile(normalizedRoot, userMd5)
const scheduled = readScheduledValue<CachedGroupSnapshotFile>(file)
if (scheduled) return scheduled
const memory = groupMemory.get(file)
if (memory) {
touchMemory(groupMemory, file, memory, MAX_MEMORY_GROUP_SNAPSHOTS)
return memory
}
try {
if (!fs.existsSync(file)) return null
const raw = fs.readJsonSync(file) as Partial<CachedGroupSnapshotFile>
if (!isCurrentAccountFile(raw, normalizedRoot) || raw.userMd5 !== userMd5 || !raw.snapshot) {
return null
}
const result: CachedGroupSnapshotFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
userMd5,
updatedAt: Number(raw.updatedAt) || 0,
snapshot: raw.snapshot
}
touchMemory(groupMemory, file, result, MAX_MEMORY_GROUP_SNAPSHOTS)
return result
} catch (error) {
console.warn('[BootstrapCache] group snapshot read failed:', error)
return null
}
}
async function pruneCacheDirectory(directory: string, maxFiles: number): Promise<void> {
const now = Date.now()
if (now - (lastPrunedAt.get(directory) || 0) < PRUNE_INTERVAL_MS) return
lastPrunedAt.set(directory, now)
try {
const names = (await fs.readdir(directory)).filter((name) => name.endsWith('.json'))
if (names.length <= maxFiles) return
const entries = await Promise.all(
names.map(async (name) => {
const file = path.join(directory, name)
const stat = await fs.stat(file)
return { file, modifiedAt: stat.mtimeMs }
})
)
const expired = entries
.sort((left, right) => right.modifiedAt - left.modifiedAt)
.slice(maxFiles)
await Promise.all(
expired.map(async ({ file }) => {
messageMemory.delete(file)
groupMemory.delete(file)
await fs.remove(file)
})
)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[BootstrapCache] prune failed:', error)
}
}
}
function queueWrite(file: string): void {
const scheduled = scheduledWrites.get(file)
if (!scheduled) return
const previous = writeQueues.get(file) || Promise.resolve()
const next = previous
.catch(() => undefined)
.then(async () => {
const current = scheduledWrites.get(file)
if (
!current ||
current.revision !== scheduled.revision ||
current.generation !== cacheGeneration
) {
return
}
const tempFile = `${file}.${process.pid}.${scheduled.revision}.tmp`
await fs.ensureDir(path.dirname(file))
await fs.writeFile(tempFile, JSON.stringify(current.value), 'utf8')
const latest = scheduledWrites.get(file)
if (
!latest ||
latest.revision !== scheduled.revision ||
latest.generation !== cacheGeneration
) {
await fs.remove(tempFile)
return
}
await fs.move(tempFile, file, { overwrite: true })
const completed = scheduledWrites.get(file)
if (
completed?.revision === scheduled.revision &&
completed.generation === scheduled.generation
) {
scheduledWrites.delete(file)
}
if (current.cleanupFile) await fs.remove(current.cleanupFile)
if (current.prune) {
void pruneCacheDirectory(current.prune.directory, current.prune.maxFiles)
}
})
.catch((error) => {
console.warn('[BootstrapCache] write failed:', error)
})
.finally(() => {
if (writeQueues.get(file) === next) writeQueues.delete(file)
})
writeQueues.set(file, next)
}
function scheduleWrite(
file: string,
value: unknown,
options?: { cleanupFile?: string; prune?: { directory: string; maxFiles: number } }
): void {
const existingTimer = writeTimers.get(file)
if (existingTimer) clearTimeout(existingTimer)
const revision = (writeRevisions.get(file) || 0) + 1
writeRevisions.set(file, revision)
scheduledWrites.set(file, {
value,
revision,
generation: cacheGeneration,
cleanupFile: options?.cleanupFile,
prune: options?.prune
})
writeTimers.set(
file,
setTimeout(() => {
writeTimers.delete(file)
queueWrite(file)
}, WRITE_DEBOUNCE_MS)
)
}
function loadOrCreateStartupCache(accountRoot: string): StartupCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const existing = readStartupCacheFile(normalizedRoot)
if (existing) return existing
const created: StartupCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: []
}
startupMemory.set(getAccountCachePaths(normalizedRoot).startup, created)
return created
}
function writeStartupCache(cache: StartupCacheFile): void {
const paths = getAccountCachePaths(cache.accountRoot)
startupMemory.set(paths.startup, cache)
scheduleWrite(paths.startup, cache, { cleanupFile: paths.legacy })
} }
function containsLegacyMisparsedAppMessage(items: Message[]): boolean { function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
@@ -160,8 +389,7 @@ function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
const content = message.contentData const content = message.contentData
if (content?.type === 'system' && content.raw) { if (content?.type === 'system' && content.raw) {
return ( return (
/<weappinfo\b/i.test(content.raw) && /<weappinfo\b/i.test(content.raw) && /<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
/<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
) )
} }
if ( if (
@@ -176,27 +404,16 @@ function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
}) })
} }
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
const entries = Object.entries(messages)
if (entries.length <= MAX_MESSAGE_BUCKETS) return
entries
.sort((left, right) => (right[1].updatedAt || 0) - (left[1].updatedAt || 0))
.slice(MAX_MESSAGE_BUCKETS)
.forEach(([key]) => {
delete messages[key]
})
}
export function getBootstrapCache(accountRoot?: string): { export function getBootstrapCache(accountRoot?: string): {
self?: CachedSelfInfo self?: CachedSelfInfo
contacts: Contact[] contacts: Contact[]
updatedAt: number updatedAt: number
} | null { } | null {
const cache = readCacheFile(accountRoot) const cache = readStartupCacheFile(normalizeRoot(accountRoot))
if (!cache) return null if (!cache) return null
return { return {
self: cache.self, self: cache.self,
contacts: cache.contacts || [], contacts: cache.contacts,
updatedAt: cache.updatedAt updatedAt: cache.updatedAt
} }
} }
@@ -212,8 +429,8 @@ function isRawContactName(contact: Contact): boolean {
} }
export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] { export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] {
const cache = readCacheFile(accountRoot) const cache = readStartupCacheFile(accountRoot)
if (!cache?.contacts?.length) return contacts if (!cache?.contacts.length) return contacts
const avatarByUsername = new Map( const avatarByUsername = new Map(
cache.contacts cache.contacts
.filter((contact) => contact.m_nsUsrName && contact.avatar) .filter((contact) => contact.m_nsUsrName && contact.avatar)
@@ -241,23 +458,23 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
} }
export function saveBootstrapSelf(accountRoot: string, self: CachedSelfInfo): void { export function saveBootstrapSelf(accountRoot: string, self: CachedSelfInfo): void {
const cache = loadOrCreate(accountRoot) const cache = loadOrCreateStartupCache(accountRoot)
if (!cache) return if (!cache) return
cache.self = self cache.self = self
cache.updatedAt = Date.now() cache.updatedAt = Date.now()
writeCacheFile(cache) writeStartupCache(cache)
} }
export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void { export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void {
const cache = loadOrCreate(accountRoot) const cache = loadOrCreateStartupCache(accountRoot)
if (!cache) return if (!cache) return
const avatarByUsername = new Map( const avatarByUsername = new Map(
(cache.contacts || []) cache.contacts
.filter((contact) => contact.m_nsUsrName && contact.avatar) .filter((contact) => contact.m_nsUsrName && contact.avatar)
.map((contact) => [contact.m_nsUsrName, contact.avatar as string]) .map((contact) => [contact.m_nsUsrName, contact.avatar as string])
) )
const nameByUsername = new Map( const nameByUsername = new Map(
(cache.contacts || []) cache.contacts
.filter( .filter(
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact) (contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
) )
@@ -275,12 +492,12 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]):
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName : nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
})) }))
cache.updatedAt = Date.now() cache.updatedAt = Date.now()
writeCacheFile(cache) writeStartupCache(cache)
} }
export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<string, string>): void { export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<string, string>): void {
const cache = loadOrCreate(accountRoot) const cache = loadOrCreateStartupCache(accountRoot)
if (!cache || !cache.contacts?.length) return if (!cache?.contacts.length) return
let changed = false let changed = false
cache.contacts = cache.contacts.map((contact) => { cache.contacts = cache.contacts.map((contact) => {
const avatar = avatars[contact.m_nsUsrName] const avatar = avatars[contact.m_nsUsrName]
@@ -290,7 +507,7 @@ export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<strin
}) })
if (!changed) return if (!changed) return
cache.updatedAt = Date.now() cache.updatedAt = Date.now()
writeCacheFile(cache) writeStartupCache(cache)
} }
export function getCachedMessages( export function getCachedMessages(
@@ -299,9 +516,9 @@ export function getCachedMessages(
startTime?: number, startTime?: number,
endTime?: number endTime?: number
): Message[] { ): Message[] {
const cache = readCacheFile(accountRoot) return (
const bucket = cache?.messages?.[messageBucketKey(userMd5, startTime, endTime)] readMessageBucketFile(accountRoot, messageBucketKey(userMd5, startTime, endTime))?.items || []
return bucket?.items || [] )
} }
export function getCachedMessagePage( export function getCachedMessagePage(
@@ -310,35 +527,12 @@ export function getCachedMessagePage(
startTime?: number, startTime?: number,
endTime?: number endTime?: number
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } { ): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
const cache = readCacheFile(accountRoot) const bucket = readMessageBucketFile(accountRoot, messageBucketKey(userMd5, startTime, endTime))
const key = messageBucketKey(userMd5, startTime, endTime) const messages = bucket?.items || []
let bucket = cache?.messages?.[key]
if (!bucket && cache?.messages && startTime === undefined && endTime === undefined) {
const merged = new Map<string, Message>()
for (const [cachedKey, candidate] of Object.entries(cache.messages)) {
if (!cachedKey.startsWith(`${userMd5}:`)) continue
for (const message of candidate.items || []) {
merged.set(cachedMessageIdentity(message), message)
}
}
const migratedMessages = Array.from(merged.values())
.sort((left, right) => (left.createTime || 0) - (right.createTime || 0))
.slice(-MAX_MESSAGES_PER_BUCKET)
if (migratedMessages.length > 0) {
bucket = {
updatedAt: Date.now(),
items: migratedMessages
}
cache.messages[key] = bucket
cache.updatedAt = Date.now()
pruneMessageBuckets(cache.messages)
writeCacheFile(cache)
}
}
return { return {
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []), hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(messages),
messages: bucket?.items || [], messages,
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot groupSnapshot: readGroupSnapshotFile(accountRoot, userMd5)?.snapshot
} }
} }
@@ -347,33 +541,22 @@ export function saveCachedGroupSnapshot(
userMd5: string, userMd5: string,
snapshot: CachedGroupSnapshot snapshot: CachedGroupSnapshot
): void { ): void {
const cache = loadOrCreate(accountRoot) const normalizedRoot = normalizeRoot(accountRoot)
if (!cache) return if (!normalizedRoot || !userMd5) return
cache.groupSnapshots ||= {} const paths = getAccountCachePaths(normalizedRoot)
cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot } const file = getGroupCacheFile(normalizedRoot, userMd5)
cache.updatedAt = Date.now() const value: CachedGroupSnapshotFile = {
writeCacheFile(cache) version: CACHE_VERSION,
} platform: process.platform,
accountRoot: normalizedRoot,
export function flushBootstrapCacheWritesSync(): void { userMd5,
for (const [file, cache] of memoryCache) { updatedAt: Date.now(),
const timer = writeTimers.get(file) snapshot
if (timer) clearTimeout(timer)
writeTimers.delete(file)
try {
fs.ensureDirSync(path.dirname(file))
fs.writeFileSync(file, JSON.stringify(cache), 'utf8')
} catch (error) {
console.warn('[BootstrapCache] flush failed:', error)
}
} }
} touchMemory(groupMemory, file, value, MAX_MEMORY_GROUP_SNAPSHOTS)
scheduleWrite(file, value, {
export function clearBootstrapCache(): void { prune: { directory: paths.groups, maxFiles: MAX_GROUP_SNAPSHOTS }
for (const timer of writeTimers.values()) clearTimeout(timer) })
writeTimers.clear()
writeQueues.clear()
memoryCache.clear()
} }
export function saveCachedMessages( export function saveCachedMessages(
@@ -383,17 +566,52 @@ export function saveCachedMessages(
endTime: number | undefined, endTime: number | undefined,
messages: Message[] messages: Message[]
): void { ): void {
const cache = loadOrCreate(accountRoot) const normalizedRoot = normalizeRoot(accountRoot)
if (!cache) return if (!normalizedRoot || !userMd5) return
const nextMessages = cache.messages || {} const cacheKey = messageBucketKey(userMd5, startTime, endTime)
nextMessages[messageBucketKey(userMd5, startTime, endTime)] = { const paths = getAccountCachePaths(normalizedRoot)
const file = getMessageCacheFile(normalizedRoot, cacheKey)
const value: CachedMessageBucketFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
cacheKey,
updatedAt: Date.now(), updatedAt: Date.now(),
startTime, startTime,
endTime, endTime,
items: messages.slice(-MAX_MESSAGES_PER_BUCKET) items: messages.slice(-MAX_MESSAGES_PER_BUCKET)
} }
pruneMessageBuckets(nextMessages) touchMemory(messageMemory, file, value, MAX_MEMORY_MESSAGE_BUCKETS)
cache.messages = nextMessages scheduleWrite(file, value, {
cache.updatedAt = Date.now() prune: { directory: paths.messages, maxFiles: MAX_MESSAGE_BUCKETS }
writeCacheFile(cache) })
}
export function flushBootstrapCacheWritesSync(): void {
for (const timer of writeTimers.values()) clearTimeout(timer)
writeTimers.clear()
const writes = Array.from(scheduledWrites.entries())
scheduledWrites.clear()
for (const [file, scheduled] of writes) {
try {
fs.ensureDirSync(path.dirname(file))
fs.writeFileSync(file, JSON.stringify(scheduled.value), 'utf8')
if (scheduled.cleanupFile) fs.removeSync(scheduled.cleanupFile)
} catch (error) {
console.warn('[BootstrapCache] flush failed:', error)
}
}
}
export function clearBootstrapCache(): void {
cacheGeneration += 1
for (const timer of writeTimers.values()) clearTimeout(timer)
writeTimers.clear()
scheduledWrites.clear()
writeQueues.clear()
writeRevisions.clear()
lastPrunedAt.clear()
startupMemory.clear()
messageMemory.clear()
groupMemory.clear()
} }
+6
View File
@@ -172,6 +172,12 @@ export function listContacts(filter?: string): FormattedContact[] {
return contacts return contacts
} }
export async function listContactsAsync(filter?: string): Promise<FormattedContact[]> {
if (!dbRef) return []
await dbRef.getWcdb4Client().getSessionsAsync()
return listContacts(filter)
}
export function getContactAvatars(usernames: string[]): Record<string, string> { export function getContactAvatars(usernames: string[]): Record<string, string> {
if (!dbRef) return {} if (!dbRef) return {}
const normalized = Array.from( const normalized = Array.from(
+29 -7
View File
@@ -10,6 +10,7 @@ type VideoAsset = {
export class VideoAssetService { export class VideoAssetService {
private readonly urlTokens = new Map<string, string>() private readonly urlTokens = new Map<string, string>()
private readonly fileTokens = new Map<string, string>()
private index: Map<string, VideoAsset> | null = null private index: Map<string, VideoAsset> | null = null
constructor(private readonly client: Wcdb4Client) {} constructor(private readonly client: Wcdb4Client) {}
@@ -48,8 +49,8 @@ export class VideoAssetService {
if (!asset) continue if (!asset) continue
return { return {
success: true, success: true,
url: this.createUrl(asset.filePath), url: this.createLocalMediaUrl(asset.filePath),
poster: asset.posterPath ? this.createUrl(asset.posterPath) : undefined poster: asset.posterPath ? this.createLocalMediaUrl(asset.posterPath) : undefined
} }
} }
return { success: false, error: '本地未找到该视频文件' } return { success: false, error: '本地未找到该视频文件' }
@@ -61,12 +62,33 @@ export class VideoAssetService {
return filePath return filePath
} }
private createUrl(filePath: string): string { pathForUrl(url: string): string | undefined {
try {
const parsed = new URL(url)
if (parsed.protocol !== 'wxe-media:' || parsed.hostname !== 'local') return undefined
return this.pathForToken(parsed.pathname.replace(/^\/+/, ''))
} catch {
return undefined
}
}
createLocalMediaUrl(filePath: string): string {
const normalizedPath = path.resolve(filePath)
const existingToken = this.fileTokens.get(normalizedPath)
if (existingToken && this.urlTokens.get(existingToken) === normalizedPath) {
return `wxe-media://local/${existingToken}`
}
const token = crypto.randomBytes(18).toString('hex') const token = crypto.randomBytes(18).toString('hex')
this.urlTokens.set(token, filePath) this.urlTokens.set(token, normalizedPath)
if (this.urlTokens.size > 500) { this.fileTokens.set(normalizedPath, token)
const first = this.urlTokens.keys().next().value if (this.urlTokens.size > 2048) {
if (first) this.urlTokens.delete(first) const oldestToken = this.urlTokens.keys().next().value
if (oldestToken) {
const oldestPath = this.urlTokens.get(oldestToken)
this.urlTokens.delete(oldestToken)
if (oldestPath) this.fileTokens.delete(oldestPath)
}
} }
return `wxe-media://local/${token}` return `wxe-media://local/${token}`
} }
+70 -4
View File
@@ -241,6 +241,8 @@ export class Wcdb4Client {
private groupNicknameCache = new Map<string, Map<string, string>>() private groupNicknameCache = new Map<string, Map<string, string>>()
private cachedSessions: Wcdb4Session[] | null = null private cachedSessions: Wcdb4Session[] | null = null
private cachedChatTables: { name: string; db_number: string }[] | null = null private cachedChatTables: { name: string; db_number: string }[] | null = null
private sessionsInFlight: Promise<Wcdb4Session[]> | null = null
private sessionCacheGeneration = 0
private wcdbShutdown: (() => number) | null = null private wcdbShutdown: (() => number) | null = null
private wcdbOpenAccount: private wcdbOpenAccount:
@@ -531,7 +533,11 @@ export class Wcdb4Client {
this.handle = handleOut[0] this.handle = handleOut[0]
if (this.wcdbSetMyWxid) { if (this.wcdbSetMyWxid) {
try { try {
this.wcdbSetMyWxid(this.handle, this.wxid) await this.callAsyncCode(
this.wcdbSetMyWxid as unknown as KoffiAsyncFunction,
this.handle,
this.wxid
)
} catch { } catch {
// Optional helper. Failure does not block message reads. // Optional helper. Failure does not block message reads.
} }
@@ -557,14 +563,16 @@ export class Wcdb4Client {
this.groupNicknameCache.clear() this.groupNicknameCache.clear()
} }
startMonitor(callback: (type: string, json: string) => void): boolean { async startMonitor(callback: (type: string, json: string) => void): Promise<boolean> {
if (!this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) return false if (!this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) return false
this.stopMonitor() this.stopMonitor()
this.monitorCallback = callback this.monitorCallback = callback
try { try {
const startResult = this.wcdbStartMonitorPipe() const startResult = await this.callAsyncCode(
this.wcdbStartMonitorPipe as unknown as KoffiAsyncFunction
)
if (startResult !== 0) { if (startResult !== 0) {
this.monitorCallback = null this.monitorCallback = null
console.warn(`[WCDB4] wcdb_start_monitor_pipe 失败,错误码: ${startResult}`) console.warn(`[WCDB4] wcdb_start_monitor_pipe 失败,错误码: ${startResult}`)
@@ -573,7 +581,10 @@ export class Wcdb4Client {
this.monitorStarted = true this.monitorStarted = true
const outName: WcdbVoidOut = [null] const outName: WcdbVoidOut = [null]
const nameResult = this.wcdbGetMonitorPipeName(outName) const nameResult = await this.callAsyncCode(
this.wcdbGetMonitorPipeName as unknown as KoffiAsyncFunction,
outName
)
if (nameResult !== 0 || !outName[0]) { if (nameResult !== 0 || !outName[0]) {
console.warn(`[WCDB4] wcdb_get_monitor_pipe_name 失败,错误码: ${nameResult}`) console.warn(`[WCDB4] wcdb_get_monitor_pipe_name 失败,错误码: ${nameResult}`)
this.stopMonitor() this.stopMonitor()
@@ -715,9 +726,45 @@ export class Wcdb4Client {
return this.cachedSessions return this.cachedSessions
} }
async getSessionsAsync(): Promise<Wcdb4Session[]> {
if (this.cachedSessions) return this.cachedSessions
if (this.sessionsInFlight) return this.sessionsInFlight
if (!this.wcdbGetSessions) return []
const generation = this.sessionCacheGeneration
const request = (async (): Promise<Wcdb4Session[]> => {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbGetSessions as unknown as KoffiAsyncFunction
)
const sessions = (Array.isArray(rows) ? rows : [])
.map((row) => this.normalizeSession(row))
.filter((session) => session.username)
await this.hydrateDisplayNamesAsync(
sessions
.filter((session) => this.shouldHydrateSessionDisplayName(session))
.map((session) => session.username)
)
const hydrated = sessions.map((session) => ({
...session,
nickname:
this.displayNameCache.get(session.username) || session.nickname || session.username
}))
if (generation === this.sessionCacheGeneration) this.cachedSessions = hydrated
return hydrated
})()
this.sessionsInFlight = request
try {
return await request
} finally {
if (this.sessionsInFlight === request) this.sessionsInFlight = null
}
}
invalidateSessionCache(): void { invalidateSessionCache(): void {
this.sessionCacheGeneration += 1
this.cachedSessions = null this.cachedSessions = null
this.cachedChatTables = null this.cachedChatTables = null
this.sessionsInFlight = null
} }
getChatTables(): { name: string; db_number: string }[] { getChatTables(): { name: string; db_number: string }[] {
@@ -1527,6 +1574,25 @@ export class Wcdb4Client {
} }
} }
async resolveImageHardlinkAsync(md5: string): Promise<Wcdb4ImageHardlink | null> {
if (!this.wcdbResolveImageHardlink) return null
const normalizedMd5 = String(md5 || '')
.trim()
.toLowerCase()
if (!normalizedMd5) return null
try {
return await this.callJsonAsync<Wcdb4ImageHardlink>(
this.wcdbResolveImageHardlink as unknown as KoffiAsyncFunction,
normalizedMd5,
this.accountRoot
)
} catch (error) {
console.warn('[WCDB4] async image hardlink resolve failed:', error)
return null
}
}
resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null { resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null {
if (!this.wcdbResolveVideoHardlink) return null if (!this.wcdbResolveVideoHardlink) return null
const normalizedMd5 = String(md5 || '') const normalizedMd5 = String(md5 || '')
+1 -1
View File
@@ -205,7 +205,7 @@ declare global {
imageMd5?: string, imageMd5?: string,
imageDatNameOrThumb?: string | boolean, imageDatNameOrThumb?: string | boolean,
sessionId?: string, sessionId?: string,
options?: { force?: boolean; preferThumbnail?: boolean } options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
) => Promise<{ ) => Promise<{
success: boolean success: boolean
data?: string data?: string
+1 -1
View File
@@ -76,7 +76,7 @@ const api = {
imageMd5?: string, imageMd5?: string,
imageDatNameOrThumb?: string | boolean, imageDatNameOrThumb?: string | boolean,
sessionId?: string, sessionId?: string,
options?: { force?: boolean; preferThumbnail?: boolean } options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options), ) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes), getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5), getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
+68 -69
View File
@@ -29,93 +29,92 @@ export function ImageBubble({
const [usingFallback, setUsingFallback] = useState(false) const [usingFallback, setUsingFallback] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const mountedRef = useRef(true) const mountedRef = useRef(true)
const backgroundUpgradeRef = useRef(false)
useEffect(() => { useEffect(() => {
mountedRef.current = true
return () => { return () => {
mountedRef.current = false mountedRef.current = false
} }
}, []) }, [])
const upgradeOriginalInBackground = useCallback(() => { const loadImage = useCallback(
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return async (priority = 0) => {
backgroundUpgradeRef.current = true if (imageUrl) return
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1) if (!imageMd5 && !imageDatName) {
.then((original) => { if (fallbackUrl) {
if (!mountedRef.current) return setImageUrl(fallbackUrl)
setImageUrl(original.data) setUsingFallback(true)
setIsThumbnail(false) setError(null)
}) return
.catch(() => undefined) }
}, [imageDatName, imageMd5, isThumbnail, sessionId]) setError('缺少图片标识')
const loadImage = useCallback(async () => {
if (imageUrl || loading) return
if (!imageMd5 && !imageDatName) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
return return
} }
setError('缺少图片标识') if (loading) {
return if (priority === 0) {
} void requestImage(
imageMd5,
setLoading(true) imageDatName,
try { sessionId,
const result = await requestImage( { preferThumbnail: true },
imageMd5, priority
imageDatName, ).catch(() => undefined)
sessionId, }
{ preferThumbnail: true }, return
0
)
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
setError(null)
if (result.isThumbnail) upgradeOriginalInBackground()
} catch (error) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError(error instanceof Error ? error.message : '加载图片失败')
} }
} finally {
setLoading(false) setLoading(true)
} try {
}, [ const result = await requestImage(
fallbackUrl, imageMd5,
imageDatName, imageDatName,
imageMd5, sessionId,
imageUrl, { preferThumbnail: true },
loading, priority
sessionId, )
upgradeOriginalInBackground if (!mountedRef.current) return
]) setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
setError(null)
} catch (error) {
if (!mountedRef.current) return
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError(error instanceof Error ? error.message : '加载图片失败')
}
} finally {
if (mountedRef.current) setLoading(false)
}
},
[fallbackUrl, imageDatName, imageMd5, imageUrl, loading, sessionId]
)
useEffect(() => { useEffect(() => {
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground() if (imageUrl || error) return
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
useEffect(() => {
if (imageUrl || loading || error) return
const element = containerRef.current const element = containerRef.current
if (!element || typeof IntersectionObserver === 'undefined') { if (!element || typeof IntersectionObserver === 'undefined') {
const timer = window.setTimeout(() => void loadImage(), 0) const timer = window.setTimeout(() => void loadImage(0), 0)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
} }
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return const entry = entries.find((candidate) => candidate.isIntersecting)
if (!entry) return
const rect = entry.boundingClientRect
const isInViewport =
rect.bottom >= 0 &&
rect.top <= window.innerHeight &&
rect.right >= 0 &&
rect.left <= window.innerWidth
observer.disconnect() observer.disconnect()
void loadImage() void loadImage(isInViewport ? 0 : 1)
}, },
{ rootMargin: '400px 0px' } { rootMargin: loading ? '0px' : '400px 0px' }
) )
observer.observe(element) observer.observe(element)
return () => observer.disconnect() return () => observer.disconnect()
@@ -143,7 +142,7 @@ export function ImageBubble({
setUpgrading(true) setUpgrading(true)
try { try {
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0) const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
if (result.data.startsWith('data:image/')) { if (result.data.startsWith('data:image/') || result.data.startsWith('wxe-media://')) {
setImageUrl(result.data) setImageUrl(result.data)
setUsingFallback(false) setUsingFallback(false)
setIsThumbnail(result.isThumbnail) setIsThumbnail(result.isThumbnail)
@@ -162,7 +161,7 @@ export function ImageBubble({
if (loading) { if (loading) {
return ( return (
<div className="image-bubble image-loading" onClick={handleClick}> <div ref={containerRef} className="image-bubble image-loading" onClick={handleClick}>
<div className="image-loading-skeleton" aria-hidden /> <div className="image-loading-skeleton" aria-hidden />
<div className="image-quality-badge"></div> <div className="image-quality-badge"></div>
</div> </div>
@@ -171,7 +170,7 @@ export function ImageBubble({
if (error) { if (error) {
return ( return (
<div className="image-bubble image-error" onClick={loadImage}> <div className="image-bubble image-error" onClick={() => void loadImage(0)}>
<div className="image-error-text">{error || '图片未缓存'}</div> <div className="image-error-text">{error || '图片未缓存'}</div>
<div className="image-quality-badge"></div> <div className="image-quality-badge"></div>
</div> </div>
+22 -4
View File
@@ -9,13 +9,17 @@ export type ImageLoadOptions = {
} }
type QueueItem = { type QueueItem = {
requestKey: string
priority: number priority: number
run: () => Promise<LoadedImage> run: () => Promise<LoadedImage>
resolve: (value: LoadedImage) => void resolve: (value: LoadedImage) => void
reject: (error: Error) => void reject: (error: Error) => void
} }
const MAX_CONCURRENT_IMAGE_LOADS = 3 // Cache probes are cheap asynchronous IPC calls. Cold decrypts are serialized
// in the main process, so renderer concurrency only controls how quickly disk
// cache hits can become visible after a restart.
const MAX_CONCURRENT_IMAGE_LOADS = 32
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024 const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
const imageCache = new Map<string, LoadedImage>() const imageCache = new Map<string, LoadedImage>()
const imageCacheSizes = new Map<string, number>() const imageCacheSizes = new Map<string, number>()
@@ -99,6 +103,8 @@ function cacheImage(
} }
function pumpImageQueue(): void { function pumpImageQueue(): void {
if (activeImageLoads >= MAX_CONCURRENT_IMAGE_LOADS || imageQueue.length === 0) return
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) { while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
imageQueue.sort((left, right) => left.priority - right.priority) imageQueue.sort((left, right) => left.priority - right.priority)
const item = imageQueue.shift() const item = imageQueue.shift()
@@ -114,6 +120,10 @@ function pumpImageQueue(): void {
} }
} }
function isSupportedImageUrl(value: string | undefined): value is string {
return Boolean(value?.startsWith('data:image/') || value?.startsWith('wxe-media://'))
}
export function getCachedLoadedImage( export function getCachedLoadedImage(
imageMd5?: string, imageMd5?: string,
imageDatName?: string, imageDatName?: string,
@@ -136,16 +146,24 @@ export function requestImage(
if (!identity) return Promise.reject(new Error('缺少图片标识')) if (!identity) return Promise.reject(new Error('缺少图片标识'))
const requestKey = `${identity}:${cacheMode(options)}` const requestKey = `${identity}:${cacheMode(options)}`
const existingRequest = imageRequests.get(requestKey) const existingRequest = imageRequests.get(requestKey)
if (existingRequest) return existingRequest if (existingRequest) {
const queuedItem = imageQueue.find((item) => item.requestKey === requestKey)
if (queuedItem && priority < queuedItem.priority) queuedItem.priority = priority
return existingRequest
}
const request = new Promise<LoadedImage>((resolve, reject) => { const request = new Promise<LoadedImage>((resolve, reject) => {
imageQueue.push({ imageQueue.push({
requestKey,
priority, priority,
resolve, resolve,
reject, reject,
run: async () => { run: async () => {
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options) const result = await window.api.getImage(imageMd5, imageDatName, sessionId, {
if (!result.success || !result.data?.startsWith('data:image/')) { ...options,
priority
})
if (!result.success || !isSupportedImageUrl(result.data)) {
throw new Error(result.error || '加载图片失败') throw new Error(result.error || '加载图片失败')
} }
const loadedImage = { const loadedImage = {