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