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