mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
fix: 优化 Windows 启动性能与聊天图片缓存
- 增加聊天图片磁盘持久化缓存,重启后直接复用 - 将 DAT 图片解密移至 Worker,避免阻塞主进程 - 增加图片加载优先级和并发控制 - 优化会话目录与图片文件的异步查找 - 使用本地媒体协议加载缓存图片,减少 Base64 IPC 开销 - 优化启动缓存与数据库初始化流程,降低窗口未响应时间 (cherry picked from commit 82bcc8d32ca674de38c745cc9925ed2309887dc7)
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
import { existsSync, readFileSync, statSync, readdirSync } from 'fs'
|
||||
import { basename, dirname, extname, join, resolve } from 'path'
|
||||
import { existsSync, readFileSync, statSync, readdirSync, promises as fsPromises } from 'fs'
|
||||
import crypto from 'crypto'
|
||||
import os from 'os'
|
||||
import { app } from 'electron'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
|
||||
@@ -9,13 +11,207 @@ const imageDecryptLog = (...args: unknown[]): void => {
|
||||
if (imageDecryptDebugEnabled) console.log(...args)
|
||||
}
|
||||
|
||||
type DecodedImage = {
|
||||
export type DecodedImage = {
|
||||
data: string
|
||||
filePath: string
|
||||
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_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 {
|
||||
private xorKey: number = 0
|
||||
@@ -26,8 +222,15 @@ export class ImageDecryptService {
|
||||
private imagePathCache = new Map<string, string>()
|
||||
private decodedImageCache = new Map<string, DecodedImage>()
|
||||
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 格式)
|
||||
const xorHex = xorKey.trim().toLowerCase()
|
||||
if (xorHex.startsWith('0x')) {
|
||||
@@ -39,6 +242,12 @@ export class ImageDecryptService {
|
||||
// AES Key 直接使用
|
||||
this.aesKey = aesKey.trim()
|
||||
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(
|
||||
md5?: string,
|
||||
imageDatName?: string,
|
||||
options?: { allowThumbnail?: boolean; accountDir?: string; preferThumbnail?: boolean }
|
||||
options?: ImageFindOptions
|
||||
): string | null {
|
||||
const allowThumbnail = options?.allowThumbnail !== false
|
||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||
const sessionDirectory = this.getSessionDirectoryName(options?.sessionId)
|
||||
const pathCacheKey = [
|
||||
normalizedMd5,
|
||||
normalizedDatName,
|
||||
allowThumbnail ? 'thumb' : 'original',
|
||||
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
|
||||
options?.accountDir || ''
|
||||
options?.accountDir || '',
|
||||
sessionDirectory
|
||||
].join('|')
|
||||
const cachedPath = this.imagePathCache.get(pathCacheKey)
|
||||
if (cachedPath && existsSync(cachedPath)) return cachedPath
|
||||
@@ -126,9 +337,30 @@ export class ImageDecryptService {
|
||||
md5: normalizedMd5,
|
||||
imageDatName: normalizedDatName,
|
||||
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])) {
|
||||
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
||||
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
||||
@@ -146,7 +378,6 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
|
||||
const attachDir = join(accountDir, 'msg', 'attach')
|
||||
if (!existsSync(attachDir)) {
|
||||
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
||||
return rememberPath(
|
||||
@@ -159,17 +390,18 @@ export class ImageDecryptService {
|
||||
)
|
||||
}
|
||||
|
||||
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
|
||||
if (searchKeys.length === 0) return null
|
||||
|
||||
for (const key of searchKeys) {
|
||||
const directHit = this.fastProbabilisticSearch(
|
||||
attachDir,
|
||||
key,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
if (directHit) return rememberPath(directHit)
|
||||
if (!sessionDirectory) {
|
||||
for (const key of searchKeys) {
|
||||
const directHit = this.fastProbabilisticSearch(
|
||||
attachDir,
|
||||
key,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
if (directHit) return rememberPath(directHit)
|
||||
}
|
||||
}
|
||||
|
||||
const legacyHit = this.findImageFileInLegacyDirs(
|
||||
@@ -184,15 +416,41 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
getCachedDecodedImage(key: string): DecodedImage | null {
|
||||
async getCachedDecodedImage(
|
||||
key: string,
|
||||
options: { includeData?: boolean } = {}
|
||||
): Promise<DecodedImage | null> {
|
||||
const cached = this.decodedImageCache.get(key)
|
||||
if (!cached) return null
|
||||
this.decodedImageCache.delete(key)
|
||||
this.decodedImageCache.set(key, cached)
|
||||
return cached
|
||||
if (cached) {
|
||||
this.decodedImageCache.delete(key)
|
||||
this.decodedImageCache.set(key, 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 previous = this.decodedImageCache.get(key)
|
||||
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(
|
||||
attachDir: string,
|
||||
datName: string,
|
||||
allowThumbnail = true,
|
||||
preferThumbnail = false
|
||||
preferThumbnail = false,
|
||||
sessionDirectory = ''
|
||||
): string | null {
|
||||
const normalized = this.normalizeDatBase(datName)
|
||||
if (!normalized) return null
|
||||
@@ -243,9 +926,13 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionDirs = readdirSync(attachDir).filter(
|
||||
(name) => name.length === 32 && /^[a-f0-9]+$/i.test(name)
|
||||
)
|
||||
const sessionDirs = sessionDirectory
|
||||
? existsSync(join(attachDir, sessionDirectory))
|
||||
? [sessionDirectory]
|
||||
: []
|
||||
: readdirSync(attachDir).filter(
|
||||
(name) => name.length === 32 && /^[a-f0-9]+$/i.test(name)
|
||||
)
|
||||
|
||||
const now = new Date()
|
||||
const months: string[] = []
|
||||
@@ -462,6 +1149,54 @@ export class ImageDecryptService {
|
||||
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)。
|
||||
* 老 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()
|
||||
}
|
||||
|
||||
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[] {
|
||||
const base = this.normalizeDatBase(baseName)
|
||||
if (!base) return []
|
||||
@@ -651,6 +1393,37 @@ export class ImageDecryptService {
|
||||
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 {
|
||||
const lower = fileName.toLowerCase()
|
||||
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
|
||||
|
||||
+211
-60
@@ -21,7 +21,7 @@ import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
|
||||
import { VoiceService } from './voice-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
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 {
|
||||
deleteGeneratedReport,
|
||||
@@ -103,6 +103,71 @@ let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||
let recallProtectionGeneration = 0
|
||||
let recallJournalTimer: NodeJS.Timeout | 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(
|
||||
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> {
|
||||
const { size } = await fsPromises.stat(filePath)
|
||||
const mimeType = extname(filePath).toLowerCase() === '.mp4' ? 'video/mp4' : 'image/jpeg'
|
||||
const mimeType = getLocalMediaMimeType(filePath)
|
||||
const commonHeaders = {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Type': mimeType,
|
||||
@@ -292,8 +406,7 @@ function createWindow(): void {
|
||||
// 某些 API 只能在此事件发生后使用
|
||||
app.whenReady().then(async () => {
|
||||
protocol.handle('wxe-media', async (request) => {
|
||||
const token = new URL(request.url).pathname.replace(/^\/+/, '')
|
||||
const filePath = videoAssetService?.pathForToken(token)
|
||||
const filePath = videoAssetService?.pathForUrl(request.url)
|
||||
if (!filePath) return new Response('Not found', { status: 404 })
|
||||
try {
|
||||
return await createLocalMediaResponse(request, filePath)
|
||||
@@ -393,24 +506,24 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
chat.setChatDb(nextWechatDb)
|
||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||
const sessions = await wcdb4Client.getSessionsAsync()
|
||||
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||
voiceService = new VoiceService(wcdb4Client)
|
||||
stickerService = new StickerService(wcdb4Client)
|
||||
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
||||
const monitoring = await wcdb4Client.startMonitor((type, json) => {
|
||||
wcdb4Client.invalidateSessionCache()
|
||||
recallArchiveMonitor?.handleDatabaseChange(json)
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||
}
|
||||
})
|
||||
setImmediate(() => {
|
||||
const recentSession = wcdb4Client.getSessions()[0]
|
||||
if (!recentSession?.username) return
|
||||
const recentSession = sessions[0]
|
||||
if (recentSession?.username) {
|
||||
void wcdb4Client
|
||||
.getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 })
|
||||
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
|
||||
})
|
||||
}
|
||||
imageDecryptService = null
|
||||
return { success: true, monitoring }
|
||||
} 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 contacts = accountRoot
|
||||
? mergeCachedContactAvatars(accountRoot, chat.listContacts(filter))
|
||||
: chat.listContacts(filter)
|
||||
? mergeCachedContactAvatars(accountRoot, await chat.listContactsAsync(filter))
|
||||
: await chat.listContactsAsync(filter)
|
||||
if (!filter && chat.isReady() && accountRoot) {
|
||||
saveBootstrapContacts(accountRoot, contacts)
|
||||
}
|
||||
@@ -643,9 +756,15 @@ app.whenReady().then(async () => {
|
||||
aiProviderService.migrateLegacy(config)
|
||||
)
|
||||
|
||||
ipcMain.handle('copy-image', async (_, base64String) => {
|
||||
ipcMain.handle('copy-image', async (_, imageSource: unknown) => {
|
||||
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)
|
||||
return { success: true }
|
||||
} catch (error: unknown) {
|
||||
@@ -716,68 +835,100 @@ app.whenReady().then(async () => {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
_sessionId?: string,
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
) => {
|
||||
void _sessionId
|
||||
if (!imageDecryptService) {
|
||||
let service = imageDecryptService
|
||||
if (!service) {
|
||||
const { xorKey, aesKey } = getConfiguredImageKeys()
|
||||
if (!aesKey) {
|
||||
return { success: false, error: '未配置图片解密密钥' }
|
||||
}
|
||||
imageDecryptService = new ImageDecryptService(
|
||||
// Reading an already-decoded cache entry does not require the AES key.
|
||||
// Before db:init resolves the account identity, secure storage may not
|
||||
// expose that key yet, so keep this cache-only service local.
|
||||
service = new ImageDecryptService(
|
||||
xorKey,
|
||||
aesKey,
|
||||
chat.getChatDb()?.getWcdb4Client()
|
||||
chat.getChatDb()?.getWcdb4Client(),
|
||||
loadSettings().dbRoot
|
||||
)
|
||||
if (aesKey) imageDecryptService = service
|
||||
}
|
||||
|
||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||
const force = options?.force === true
|
||||
const preferThumbnail = options?.preferThumbnail === true
|
||||
const priority = Number.isFinite(options?.priority) ? Number(options?.priority) : 0
|
||||
const imageCacheKey = [
|
||||
imageMd5 || '',
|
||||
imageDatName || '',
|
||||
force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto'
|
||||
].join('|')
|
||||
const cachedImage = imageDecryptService.getCachedDecodedImage(imageCacheKey)
|
||||
if (cachedImage) {
|
||||
return {
|
||||
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
|
||||
const mediaService = getImageMediaService()
|
||||
const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
|
||||
includeData: !mediaService
|
||||
})
|
||||
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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
items: Message[]
|
||||
}
|
||||
|
||||
interface BootstrapCacheFile {
|
||||
version: 1
|
||||
interface CachedGroupSnapshotFile {
|
||||
version: 2
|
||||
platform: NodeJS.Platform
|
||||
accountRoot: string
|
||||
userMd5: string
|
||||
updatedAt: number
|
||||
self?: CachedSelfInfo
|
||||
contacts?: Contact[]
|
||||
messages?: Record<string, CachedMessageBucket>
|
||||
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
|
||||
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_GROUP_SNAPSHOTS = 768
|
||||
const MAX_MESSAGES_PER_BUCKET = 120
|
||||
const MAX_MEMORY_MESSAGE_BUCKETS = 32
|
||||
const MAX_MEMORY_GROUP_SNAPSHOTS = 32
|
||||
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 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 {
|
||||
return String(accountRoot || '').trim()
|
||||
}
|
||||
|
||||
function getCacheFile(accountRoot?: string): string {
|
||||
const normalizedRoot = normalizeRoot(accountRoot) || 'default'
|
||||
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 digest(value: string): string {
|
||||
return crypto.createHash('sha1').update(value).digest('hex').slice(0, 24)
|
||||
}
|
||||
|
||||
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||
function getAccountCachePaths(accountRoot: string): AccountCachePaths {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getCacheFile(normalizedRoot)
|
||||
const cached = memoryCache.get(file)
|
||||
if (cached) return cached
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
|
||||
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
|
||||
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
|
||||
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
|
||||
const accountKey = digest(`${process.platform}:${normalizedRoot}`)
|
||||
const bootstrapRoot = path.join(app.getPath('userData'), 'cache', 'bootstrap')
|
||||
const root = path.join(bootstrapRoot, `${process.platform}-${accountKey}`)
|
||||
return {
|
||||
root,
|
||||
startup: path.join(root, 'startup.json'),
|
||||
messages: path.join(root, 'messages'),
|
||||
groups: path.join(root, 'groups'),
|
||||
legacy: path.join(bootstrapRoot, `${process.platform}-${accountKey.slice(0, 16)}.json`)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
||||
}
|
||||
|
||||
function cachedMessageIdentity(message: Message): string {
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
if (message.serverId) return `server:${message.serverId}`
|
||||
return `id:${message.id}`
|
||||
function getMessageCacheFile(accountRoot: string, cacheKey: string): string {
|
||||
return path.join(getAccountCachePaths(accountRoot).messages, `${digest(cacheKey)}.json`)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -160,8 +389,7 @@ function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
|
||||
const content = message.contentData
|
||||
if (content?.type === 'system' && content.raw) {
|
||||
return (
|
||||
/<weappinfo\b/i.test(content.raw) &&
|
||||
/<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
|
||||
/<weappinfo\b/i.test(content.raw) && /<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
|
||||
)
|
||||
}
|
||||
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): {
|
||||
self?: CachedSelfInfo
|
||||
contacts: Contact[]
|
||||
updatedAt: number
|
||||
} | null {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const cache = readStartupCacheFile(normalizeRoot(accountRoot))
|
||||
if (!cache) return null
|
||||
return {
|
||||
self: cache.self,
|
||||
contacts: cache.contacts || [],
|
||||
contacts: cache.contacts,
|
||||
updatedAt: cache.updatedAt
|
||||
}
|
||||
}
|
||||
@@ -212,8 +429,8 @@ function isRawContactName(contact: Contact): boolean {
|
||||
}
|
||||
|
||||
export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
if (!cache?.contacts?.length) return contacts
|
||||
const cache = readStartupCacheFile(accountRoot)
|
||||
if (!cache?.contacts.length) return contacts
|
||||
const avatarByUsername = new Map(
|
||||
cache.contacts
|
||||
.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 {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
const cache = loadOrCreateStartupCache(accountRoot)
|
||||
if (!cache) return
|
||||
cache.self = self
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
writeStartupCache(cache)
|
||||
}
|
||||
|
||||
export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
const cache = loadOrCreateStartupCache(accountRoot)
|
||||
if (!cache) return
|
||||
const avatarByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
cache.contacts
|
||||
.filter((contact) => contact.m_nsUsrName && contact.avatar)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.avatar as string])
|
||||
)
|
||||
const nameByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
cache.contacts
|
||||
.filter(
|
||||
(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
|
||||
}))
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
writeStartupCache(cache)
|
||||
}
|
||||
|
||||
export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<string, string>): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache || !cache.contacts?.length) return
|
||||
const cache = loadOrCreateStartupCache(accountRoot)
|
||||
if (!cache?.contacts.length) return
|
||||
let changed = false
|
||||
cache.contacts = cache.contacts.map((contact) => {
|
||||
const avatar = avatars[contact.m_nsUsrName]
|
||||
@@ -290,7 +507,7 @@ export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<strin
|
||||
})
|
||||
if (!changed) return
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
writeStartupCache(cache)
|
||||
}
|
||||
|
||||
export function getCachedMessages(
|
||||
@@ -299,9 +516,9 @@ export function getCachedMessages(
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Message[] {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const bucket = cache?.messages?.[messageBucketKey(userMd5, startTime, endTime)]
|
||||
return bucket?.items || []
|
||||
return (
|
||||
readMessageBucketFile(accountRoot, messageBucketKey(userMd5, startTime, endTime))?.items || []
|
||||
)
|
||||
}
|
||||
|
||||
export function getCachedMessagePage(
|
||||
@@ -310,35 +527,12 @@ export function getCachedMessagePage(
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const key = messageBucketKey(userMd5, startTime, endTime)
|
||||
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)
|
||||
}
|
||||
}
|
||||
const bucket = readMessageBucketFile(accountRoot, messageBucketKey(userMd5, startTime, endTime))
|
||||
const messages = bucket?.items || []
|
||||
return {
|
||||
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []),
|
||||
messages: bucket?.items || [],
|
||||
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
|
||||
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(messages),
|
||||
messages,
|
||||
groupSnapshot: readGroupSnapshotFile(accountRoot, userMd5)?.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,33 +541,22 @@ export function saveCachedGroupSnapshot(
|
||||
userMd5: string,
|
||||
snapshot: CachedGroupSnapshot
|
||||
): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
cache.groupSnapshots ||= {}
|
||||
cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot }
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
|
||||
export function flushBootstrapCacheWritesSync(): void {
|
||||
for (const [file, cache] of memoryCache) {
|
||||
const timer = writeTimers.get(file)
|
||||
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)
|
||||
}
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot || !userMd5) return
|
||||
const paths = getAccountCachePaths(normalizedRoot)
|
||||
const file = getGroupCacheFile(normalizedRoot, userMd5)
|
||||
const value: CachedGroupSnapshotFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
userMd5,
|
||||
updatedAt: Date.now(),
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
export function clearBootstrapCache(): void {
|
||||
for (const timer of writeTimers.values()) clearTimeout(timer)
|
||||
writeTimers.clear()
|
||||
writeQueues.clear()
|
||||
memoryCache.clear()
|
||||
touchMemory(groupMemory, file, value, MAX_MEMORY_GROUP_SNAPSHOTS)
|
||||
scheduleWrite(file, value, {
|
||||
prune: { directory: paths.groups, maxFiles: MAX_GROUP_SNAPSHOTS }
|
||||
})
|
||||
}
|
||||
|
||||
export function saveCachedMessages(
|
||||
@@ -383,17 +566,52 @@ export function saveCachedMessages(
|
||||
endTime: number | undefined,
|
||||
messages: Message[]
|
||||
): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
const nextMessages = cache.messages || {}
|
||||
nextMessages[messageBucketKey(userMd5, startTime, endTime)] = {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot || !userMd5) return
|
||||
const cacheKey = 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(),
|
||||
startTime,
|
||||
endTime,
|
||||
items: messages.slice(-MAX_MESSAGES_PER_BUCKET)
|
||||
}
|
||||
pruneMessageBuckets(nextMessages)
|
||||
cache.messages = nextMessages
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
touchMemory(messageMemory, file, value, MAX_MEMORY_MESSAGE_BUCKETS)
|
||||
scheduleWrite(file, value, {
|
||||
prune: { directory: paths.messages, maxFiles: MAX_MESSAGE_BUCKETS }
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -172,6 +172,12 @@ export function listContacts(filter?: string): FormattedContact[] {
|
||||
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> {
|
||||
if (!dbRef) return {}
|
||||
const normalized = Array.from(
|
||||
|
||||
@@ -10,6 +10,7 @@ type VideoAsset = {
|
||||
|
||||
export class VideoAssetService {
|
||||
private readonly urlTokens = new Map<string, string>()
|
||||
private readonly fileTokens = new Map<string, string>()
|
||||
private index: Map<string, VideoAsset> | null = null
|
||||
|
||||
constructor(private readonly client: Wcdb4Client) {}
|
||||
@@ -48,8 +49,8 @@ export class VideoAssetService {
|
||||
if (!asset) continue
|
||||
return {
|
||||
success: true,
|
||||
url: this.createUrl(asset.filePath),
|
||||
poster: asset.posterPath ? this.createUrl(asset.posterPath) : undefined
|
||||
url: this.createLocalMediaUrl(asset.filePath),
|
||||
poster: asset.posterPath ? this.createLocalMediaUrl(asset.posterPath) : undefined
|
||||
}
|
||||
}
|
||||
return { success: false, error: '本地未找到该视频文件' }
|
||||
@@ -61,12 +62,33 @@ export class VideoAssetService {
|
||||
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')
|
||||
this.urlTokens.set(token, filePath)
|
||||
if (this.urlTokens.size > 500) {
|
||||
const first = this.urlTokens.keys().next().value
|
||||
if (first) this.urlTokens.delete(first)
|
||||
this.urlTokens.set(token, normalizedPath)
|
||||
this.fileTokens.set(normalizedPath, token)
|
||||
if (this.urlTokens.size > 2048) {
|
||||
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}`
|
||||
}
|
||||
|
||||
@@ -241,6 +241,8 @@ export class Wcdb4Client {
|
||||
private groupNicknameCache = new Map<string, Map<string, string>>()
|
||||
private cachedSessions: Wcdb4Session[] | 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 wcdbOpenAccount:
|
||||
@@ -531,7 +533,11 @@ export class Wcdb4Client {
|
||||
this.handle = handleOut[0]
|
||||
if (this.wcdbSetMyWxid) {
|
||||
try {
|
||||
this.wcdbSetMyWxid(this.handle, this.wxid)
|
||||
await this.callAsyncCode(
|
||||
this.wcdbSetMyWxid as unknown as KoffiAsyncFunction,
|
||||
this.handle,
|
||||
this.wxid
|
||||
)
|
||||
} catch {
|
||||
// Optional helper. Failure does not block message reads.
|
||||
}
|
||||
@@ -557,14 +563,16 @@ export class Wcdb4Client {
|
||||
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
|
||||
|
||||
this.stopMonitor()
|
||||
this.monitorCallback = callback
|
||||
|
||||
try {
|
||||
const startResult = this.wcdbStartMonitorPipe()
|
||||
const startResult = await this.callAsyncCode(
|
||||
this.wcdbStartMonitorPipe as unknown as KoffiAsyncFunction
|
||||
)
|
||||
if (startResult !== 0) {
|
||||
this.monitorCallback = null
|
||||
console.warn(`[WCDB4] wcdb_start_monitor_pipe 失败,错误码: ${startResult}`)
|
||||
@@ -573,7 +581,10 @@ export class Wcdb4Client {
|
||||
this.monitorStarted = true
|
||||
|
||||
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]) {
|
||||
console.warn(`[WCDB4] wcdb_get_monitor_pipe_name 失败,错误码: ${nameResult}`)
|
||||
this.stopMonitor()
|
||||
@@ -715,9 +726,45 @@ export class Wcdb4Client {
|
||||
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 {
|
||||
this.sessionCacheGeneration += 1
|
||||
this.cachedSessions = null
|
||||
this.cachedChatTables = null
|
||||
this.sessionsInFlight = null
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!this.wcdbResolveVideoHardlink) return null
|
||||
const normalizedMd5 = String(md5 || '')
|
||||
|
||||
Vendored
+1
-1
@@ -205,7 +205,7 @@ declare global {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
|
||||
@@ -76,7 +76,7 @@ const api = {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
|
||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||
|
||||
@@ -29,93 +29,92 @@ export function ImageBubble({
|
||||
const [usingFallback, setUsingFallback] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const backgroundUpgradeRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const upgradeOriginalInBackground = useCallback(() => {
|
||||
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return
|
||||
backgroundUpgradeRef.current = true
|
||||
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1)
|
||||
.then((original) => {
|
||||
if (!mountedRef.current) return
|
||||
setImageUrl(original.data)
|
||||
setIsThumbnail(false)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [imageDatName, imageMd5, isThumbnail, sessionId])
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
if (imageUrl || loading) return
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
const loadImage = useCallback(
|
||||
async (priority = 0) => {
|
||||
if (imageUrl) return
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setError('缺少图片标识')
|
||||
return
|
||||
}
|
||||
setError('缺少图片标识')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await requestImage(
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
{ preferThumbnail: true },
|
||||
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 : '加载图片失败')
|
||||
if (loading) {
|
||||
if (priority === 0) {
|
||||
void requestImage(
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
{ preferThumbnail: true },
|
||||
priority
|
||||
).catch(() => undefined)
|
||||
}
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [
|
||||
fallbackUrl,
|
||||
imageDatName,
|
||||
imageMd5,
|
||||
imageUrl,
|
||||
loading,
|
||||
sessionId,
|
||||
upgradeOriginalInBackground
|
||||
])
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await requestImage(
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
{ preferThumbnail: true },
|
||||
priority
|
||||
)
|
||||
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(() => {
|
||||
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
|
||||
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageUrl || loading || error) return
|
||||
if (imageUrl || error) return
|
||||
const element = containerRef.current
|
||||
if (!element || typeof IntersectionObserver === 'undefined') {
|
||||
const timer = window.setTimeout(() => void loadImage(), 0)
|
||||
const timer = window.setTimeout(() => void loadImage(0), 0)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(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()
|
||||
void loadImage()
|
||||
void loadImage(isInViewport ? 0 : 1)
|
||||
},
|
||||
{ rootMargin: '400px 0px' }
|
||||
{ rootMargin: loading ? '0px' : '400px 0px' }
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
@@ -143,7 +142,7 @@ export function ImageBubble({
|
||||
setUpgrading(true)
|
||||
try {
|
||||
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)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(result.isThumbnail)
|
||||
@@ -162,7 +161,7 @@ export function ImageBubble({
|
||||
|
||||
if (loading) {
|
||||
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-quality-badge">加载中</div>
|
||||
</div>
|
||||
@@ -171,7 +170,7 @@ export function ImageBubble({
|
||||
|
||||
if (error) {
|
||||
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-quality-badge">加载失败</div>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,17 @@ export type ImageLoadOptions = {
|
||||
}
|
||||
|
||||
type QueueItem = {
|
||||
requestKey: string
|
||||
priority: number
|
||||
run: () => Promise<LoadedImage>
|
||||
resolve: (value: LoadedImage) => 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 imageCache = new Map<string, LoadedImage>()
|
||||
const imageCacheSizes = new Map<string, number>()
|
||||
@@ -99,6 +103,8 @@ function cacheImage(
|
||||
}
|
||||
|
||||
function pumpImageQueue(): void {
|
||||
if (activeImageLoads >= MAX_CONCURRENT_IMAGE_LOADS || imageQueue.length === 0) return
|
||||
|
||||
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
|
||||
imageQueue.sort((left, right) => left.priority - right.priority)
|
||||
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(
|
||||
imageMd5?: string,
|
||||
imageDatName?: string,
|
||||
@@ -136,16 +146,24 @@ export function requestImage(
|
||||
if (!identity) return Promise.reject(new Error('缺少图片标识'))
|
||||
const requestKey = `${identity}:${cacheMode(options)}`
|
||||
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) => {
|
||||
imageQueue.push({
|
||||
requestKey,
|
||||
priority,
|
||||
resolve,
|
||||
reject,
|
||||
run: async () => {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options)
|
||||
if (!result.success || !result.data?.startsWith('data:image/')) {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, {
|
||||
...options,
|
||||
priority
|
||||
})
|
||||
if (!result.success || !isSupportedImageUrl(result.data)) {
|
||||
throw new Error(result.error || '加载图片失败')
|
||||
}
|
||||
const loadedImage = {
|
||||
|
||||
Reference in New Issue
Block a user