diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index 428aa14..6cc4ee1 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -93,7 +93,6 @@ jobs: with: node-version: 24 cache: "npm" - - name: Install Dependencies run: npm install @@ -160,7 +159,6 @@ jobs: with: node-version: 24 cache: "npm" - - name: Install Dependencies run: npm install @@ -208,7 +206,6 @@ jobs: with: node-version: 24 cache: "npm" - - name: Install Dependencies run: npm install @@ -256,7 +253,6 @@ jobs: with: node-version: 24 cache: "npm" - - name: Install Dependencies run: npm install diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml index 52aa2d4..71f8ebf 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -120,7 +120,6 @@ jobs: with: node-version: 24 cache: "npm" - - name: Install Dependencies run: npm install @@ -190,7 +189,6 @@ jobs: with: node-version: 24 cache: "npm" - - name: Install Dependencies run: npm install @@ -242,7 +240,6 @@ jobs: with: node-version: 24 cache: "npm" - - name: Install Dependencies run: npm install @@ -294,7 +291,6 @@ jobs: with: node-version: 24 cache: "npm" - - name: Install Dependencies run: npm install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b17ef49..1ff9850 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,6 @@ jobs: with: node-version: 24 cache: "npm" - - name: Install Dependencies run: npm install @@ -84,7 +83,6 @@ jobs: with: node-version: 24 cache: "npm" - - name: Install Dependencies run: npm install @@ -140,7 +138,6 @@ jobs: with: node-version: 24 cache: 'npm' - - name: Install Dependencies run: npm install @@ -191,7 +188,6 @@ jobs: with: node-version: 24 cache: 'npm' - - name: Install Dependencies run: npm install diff --git a/.gitignore b/.gitignore index 920d437..25fdeab 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,4 @@ pnpm-lock.yaml wechat-research-site .codex weflow-web-offical -Insight +/Wedecrypt \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index 2794d19..c69969b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2636,13 +2636,24 @@ function registerIpcHandlers() { // 私聊克隆 - ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => { + ipcMain.handle('image:decrypt', async (_, payload: { + sessionId?: string + imageMd5?: string + imageDatName?: string + createTime?: number + force?: boolean + preferFilePath?: boolean + hardlinkOnly?: boolean + }) => { return imageDecryptService.decryptImage(payload) }) ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string imageMd5?: string imageDatName?: string + createTime?: number + preferFilePath?: boolean + hardlinkOnly?: boolean disableUpdateCheck?: boolean allowCacheIndex?: boolean }) => { @@ -2652,13 +2663,15 @@ function registerIpcHandlers() { 'image:resolveCacheBatch', async ( _, - payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, - options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean } + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>, + options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean } ) => { const list = Array.isArray(payloads) ? payloads : [] const rows = await Promise.all(list.map(async (payload) => { return imageDecryptService.resolveCachedImage({ ...payload, + preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true, + hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true, disableUpdateCheck: options?.disableUpdateCheck === true, allowCacheIndex: options?.allowCacheIndex !== false }) @@ -2670,7 +2683,7 @@ function registerIpcHandlers() { 'image:preload', async ( _, - payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>, options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } ) => { imagePreloadService.enqueue(payloads || [], options) diff --git a/electron/preload.ts b/electron/preload.ts index 9739332..e89080e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -286,22 +286,25 @@ contextBridge.exposeInMainWorld('electronAPI', { // 图片解密 image: { - decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => + decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; force?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }) => ipcRenderer.invoke('image:decrypt', payload), resolveCache: (payload: { sessionId?: string imageMd5?: string imageDatName?: string + createTime?: number + preferFilePath?: boolean + hardlinkOnly?: boolean disableUpdateCheck?: boolean allowCacheIndex?: boolean }) => ipcRenderer.invoke('image:resolveCache', payload), resolveCacheBatch: ( - payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, - options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean } + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>, + options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean } ) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options), preload: ( - payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>, options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } ) => ipcRenderer.invoke('image:preload', payloads, options), onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e6da68f..5d58177 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -486,7 +486,7 @@ class ChatService { return Number.isFinite(parsed) ? parsed : null } - private toCodeOnlyMessage(rawMessage?: string, fallbackCode = -3999): string { + private toCodeOnlyMessage(rawMessage?: string | null, fallbackCode = -3999): string { const code = this.extractErrorCode(rawMessage) ?? fallbackCode return `错误码: ${code}` } @@ -7105,13 +7105,23 @@ class ChatService { return { success: false, error: '未找到消息' } } const msg = msgResult.message + const rawImageInfo = msg.rawContent ? this.parseImageInfo(msg.rawContent) : {} + const imageMd5 = msg.imageMd5 || rawImageInfo.md5 + const imageDatName = msg.imageDatName - // 2. 使用 imageDecryptService 解密图片 + if (!imageMd5 && !imageDatName) { + return { success: false, error: '图片缺少 md5/datName,无法定位原文件' } + } + + // 2. 使用 imageDecryptService 解密图片(仅使用真实图片标识) const result = await this.imageDecryptService.decryptImage({ sessionId, - imageMd5: msg.imageMd5, - imageDatName: msg.imageDatName || String(msg.localId), - force: false + imageMd5, + imageDatName, + createTime: msg.createTime, + force: false, + preferFilePath: true, + hardlinkOnly: true }) if (!result.success || !result.localPath) { @@ -8358,7 +8368,6 @@ class ChatService { if (normalized.length === 0) return [] // 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。 - // 这不是降级或裁剪范围,而是完整遍历所有群并做结果合并。 const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900) const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512 ? Math.floor(maxBytesRaw) @@ -9325,7 +9334,7 @@ class ChatService { latest_ts: this.toSafeInt(item?.latest_ts, 0), anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0), anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0) - })).filter((item) => item.session_id) + })).filter((item: MyFootprintPrivateSession) => item.session_id) const private_segments: MyFootprintPrivateSegment[] = privateSegmentsRaw.map((item: any) => ({ session_id: String(item?.session_id || '').trim(), @@ -9344,7 +9353,7 @@ class ChatService { anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0), displayName: String(item?.displayName || '').trim() || undefined, avatarUrl: String(item?.avatarUrl || '').trim() || undefined - })).filter((item) => item.session_id && item.start_ts > 0) + })).filter((item: MyFootprintPrivateSegment) => item.session_id && item.start_ts > 0) const mentions: MyFootprintMentionItem[] = mentionsRaw.map((item: any) => ({ session_id: String(item?.session_id || '').trim(), @@ -9353,13 +9362,13 @@ class ChatService { sender_username: String(item?.sender_username || '').trim(), message_content: String(item?.message_content || ''), source: String(item?.source || '') - })).filter((item) => item.session_id) + })).filter((item: MyFootprintMentionItem) => item.session_id) const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({ session_id: String(item?.session_id || '').trim(), count: this.toSafeInt(item?.count, 0), latest_ts: this.toSafeInt(item?.latest_ts, 0) - })).filter((item) => item.session_id) + })).filter((item: MyFootprintMentionGroup) => item.session_id) const diagnostics: MyFootprintDiagnostics = { truncated: Boolean(diagnosticsRaw.truncated), diff --git a/electron/services/config.ts b/electron/services/config.ts index f7b6f65..d6c0b39 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -42,7 +42,6 @@ interface ConfigSchema { autoTranscribeVoice: boolean transcribeLanguages: string[] exportDefaultConcurrency: number - exportDefaultImageDeepSearchOnMiss: boolean analyticsExcludedUsernames: string[] // 安全相关 @@ -165,7 +164,6 @@ export class ConfigService { autoTranscribeVoice: false, transcribeLanguages: ['zh'], exportDefaultConcurrency: 4, - exportDefaultImageDeepSearchOnMiss: true, analyticsExcludedUsernames: [], authEnabled: false, authPassword: '', diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 2717718..628c464 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -108,7 +108,6 @@ export interface ExportOptions { sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' exportConcurrency?: number - imageDeepSearchOnMiss?: boolean } const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ @@ -1092,8 +1091,7 @@ class ExportService { private getImageMissingRunCacheKey( sessionId: string, imageMd5?: unknown, - imageDatName?: unknown, - imageDeepSearchOnMiss = true + imageDatName?: unknown ): string | null { const normalizedSessionId = String(sessionId || '').trim() const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase() @@ -1105,8 +1103,7 @@ class ExportService { const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5 ? normalizedImageDatName : '' - const lookupMode = imageDeepSearchOnMiss ? 'deep' : 'hardlink' - return `${lookupMode}\u001f${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}` + return `${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}` } private normalizeEmojiMd5(value: unknown): string | undefined { @@ -3583,7 +3580,6 @@ class ExportService { exportVoiceAsText?: boolean includeVideoPoster?: boolean includeVoiceWithTranscript?: boolean - imageDeepSearchOnMiss?: boolean dirCache?: Set } ): Promise { @@ -3596,8 +3592,7 @@ class ExportService { sessionId, mediaRootDir, mediaRelativePrefix, - options.dirCache, - options.imageDeepSearchOnMiss !== false + options.dirCache ) if (result) { } @@ -3654,8 +3649,7 @@ class ExportService { sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, - dirCache?: Set, - imageDeepSearchOnMiss = true + dirCache?: Set ): Promise { try { const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') @@ -3675,8 +3669,7 @@ class ExportService { const missingRunCacheKey = this.getImageMissingRunCacheKey( sessionId, imageMd5, - imageDatName, - imageDeepSearchOnMiss + imageDatName ) if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) { return null @@ -3686,25 +3679,20 @@ class ExportService { sessionId, imageMd5, imageDatName, + createTime: msg.createTime, force: true, // 导出优先高清,失败再回退缩略图 preferFilePath: true, - hardlinkOnly: !imageDeepSearchOnMiss + hardlinkOnly: true }) if (!result.success || !result.localPath) { console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`) - if (!imageDeepSearchOnMiss) { - console.log(`[Export] 未命中 hardlink(已关闭缺图深度搜索)→ 将显示 [图片] 占位符`) - if (missingRunCacheKey) { - this.mediaRunMissingImageKeys.add(missingRunCacheKey) - } - return null - } // 尝试获取缩略图 const thumbResult = await imageDecryptService.resolveCachedImage({ sessionId, imageMd5, imageDatName, + createTime: msg.createTime, preferFilePath: true }) if (thumbResult.success && thumbResult.localPath) { @@ -5302,7 +5290,6 @@ class ExportService { maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', - imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -5813,7 +5800,6 @@ class ExportService { maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', - imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -6685,7 +6671,6 @@ class ExportService { maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', - imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -7436,7 +7421,6 @@ class ExportService { maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', - imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -7816,7 +7800,6 @@ class ExportService { maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', - imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -8240,7 +8223,6 @@ class ExportService { includeVideoPoster: options.format === 'html', includeVoiceWithTranscript: true, exportVideos: options.exportVideos, - imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index e7a7f83..b5b010f 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -1208,6 +1208,30 @@ class HttpService { const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session')) this.ensureDir(sessionDir) + // 预热图片 hardlink 索引,减少逐条导出时的查找开销 + if (options.exportImages) { + const imageMd5Set = new Set() + for (const msg of messages) { + if (msg.localType !== 3) continue + const imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase() + if (imageMd5) { + imageMd5Set.add(imageMd5) + continue + } + const imageDatName = String(msg.imageDatName || '').trim().toLowerCase() + if (/^[a-f0-9]{32}$/i.test(imageDatName)) { + imageMd5Set.add(imageDatName) + } + } + if (imageMd5Set.size > 0) { + try { + await imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)) + } catch { + // ignore preload failures + } + } + } + for (const msg of messages) { const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options) if (exported) { @@ -1230,27 +1254,50 @@ class HttpService { sessionId: talker, imageMd5: msg.imageMd5, imageDatName: msg.imageDatName, - force: true + createTime: msg.createTime, + force: true, + preferFilePath: true, + hardlinkOnly: true }) - if (result.success && result.localPath) { - let imagePath = result.localPath + + let imagePath = result.success ? result.localPath : undefined + if (!imagePath) { + try { + const cached = await imageDecryptService.resolveCachedImage({ + sessionId: talker, + imageMd5: msg.imageMd5, + imageDatName: msg.imageDatName, + createTime: msg.createTime, + preferFilePath: true, + hardlinkOnly: true + }) + if (cached.success && cached.localPath) { + imagePath = cached.localPath + } + } catch { + // ignore resolve failures + } + } + + if (imagePath) { if (imagePath.startsWith('data:')) { const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/) - if (base64Match) { - const imageBuffer = Buffer.from(base64Match[1], 'base64') - const ext = this.detectImageExt(imageBuffer) - const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) - const fileName = `${fileBase}${ext}` - const targetDir = path.join(sessionDir, 'images') - const fullPath = path.join(targetDir, fileName) - this.ensureDir(targetDir) - if (!fs.existsSync(fullPath)) { - fs.writeFileSync(fullPath, imageBuffer) - } - const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` - return { kind: 'image', fileName, fullPath, relativePath } + if (!base64Match) return null + const imageBuffer = Buffer.from(base64Match[1], 'base64') + const ext = this.detectImageExt(imageBuffer) + const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) + const fileName = `${fileBase}${ext}` + const targetDir = path.join(sessionDir, 'images') + const fullPath = path.join(targetDir, fileName) + this.ensureDir(targetDir) + if (!fs.existsSync(fullPath)) { + fs.writeFileSync(fullPath, imageBuffer) } - } else if (fs.existsSync(imagePath)) { + const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` + return { kind: 'image', fileName, fullPath, relativePath } + } + + if (fs.existsSync(imagePath)) { const imageBuffer = fs.readFileSync(imagePath) const ext = this.detectImageExt(imageBuffer) const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 84c908c..81c6fa1 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -3,10 +3,11 @@ import { basename, dirname, extname, join } from 'path' import { pathToFileURL } from 'url' import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs' import { writeFile, rm, readdir } from 'fs/promises' +import { homedir, tmpdir } from 'os' import crypto from 'crypto' -import { Worker } from 'worker_threads' import { ConfigService } from './config' import { wcdbService } from './wcdbService' +import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt' // 获取 ffmpeg-static 的路径 function getStaticFfmpegPath(): string | null { @@ -34,7 +35,7 @@ function getStaticFfmpegPath(): string | null { } // 方法3: 打包后的路径 - if (app.isPackaged) { + if (app?.isPackaged) { const resourcesPath = process.resourcesPath const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') if (existsSync(packedPath)) { @@ -61,24 +62,25 @@ type CachedImagePayload = { sessionId?: string imageMd5?: string imageDatName?: string + createTime?: number preferFilePath?: boolean + hardlinkOnly?: boolean disableUpdateCheck?: boolean allowCacheIndex?: boolean } type DecryptImagePayload = CachedImagePayload & { force?: boolean - hardlinkOnly?: boolean } export class ImageDecryptService { private configService = new ConfigService() private resolvedCache = new Map() private pending = new Map>() - private readonly defaultV1AesKey = 'cfcd208495d565ef' - private cacheIndexed = false - private cacheIndexing: Promise | null = null private updateFlags = new Map() + private nativeLogged = false + private datNameScanMissAt = new Map() + private readonly datNameScanMissTtlMs = 1200 private logInfo(message: string, meta?: Record): void { if (!this.configService.get('logEnabled')) return @@ -106,7 +108,7 @@ export class ImageDecryptService { private writeLog(line: string): void { try { - const logDir = join(app.getPath('userData'), 'logs') + const logDir = join(this.getUserDataPath(), 'logs') if (!existsSync(logDir)) { mkdirSync(logDir, { recursive: true }) } @@ -117,9 +119,6 @@ export class ImageDecryptService { } async resolveCachedImage(payload: CachedImagePayload): Promise { - if (payload.allowCacheIndex !== false) { - await this.ensureCacheIndexed() - } const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] if (!cacheKey) { @@ -128,17 +127,21 @@ export class ImageDecryptService { for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) if (cached && existsSync(cached) && this.isImageFile(cached)) { - const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) - const isThumb = this.isThumbnailPath(cached) + const upgraded = this.isThumbnailPath(cached) + ? await this.tryPromoteThumbnailCache(payload, key, cached) + : null + const finalPath = upgraded || cached + const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath) + const isThumb = this.isThumbnailPath(finalPath) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { if (!payload.disableUpdateCheck) { - this.triggerUpdateCheck(payload, key, cached) + this.triggerUpdateCheck(payload, key, finalPath) } } else { this.updateFlags.delete(key) } - this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath)) + this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath)) return { success: true, localPath, hasUpdate } } if (cached && !this.isImageFile(cached)) { @@ -146,22 +149,41 @@ export class ImageDecryptService { } } - for (const key of cacheKeys) { - const existing = this.findCachedOutput(key, false, payload.sessionId) - if (existing) { - this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing) - const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) - const isThumb = this.isThumbnailPath(existing) - const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false - if (isThumb) { - if (!payload.disableUpdateCheck) { - this.triggerUpdateCheck(payload, key, existing) - } - } else { - this.updateFlags.delete(key) + const accountDir = this.resolveCurrentAccountDir() + if (accountDir) { + const datPath = await this.resolveDatPath( + accountDir, + payload.imageMd5, + payload.imageDatName, + payload.sessionId, + payload.createTime, + { + allowThumbnail: true, + skipResolvedCache: false, + hardlinkOnly: true + } + ) + if (datPath) { + const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false) + if (existing) { + const upgraded = this.isThumbnailPath(existing) + ? await this.tryPromoteThumbnailCache(payload, cacheKey, existing) + : null + const finalPath = upgraded || existing + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, finalPath) + const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath) + const isThumb = this.isThumbnailPath(finalPath) + const hasUpdate = isThumb ? (this.updateFlags.get(cacheKey) ?? false) : false + if (isThumb) { + if (!payload.disableUpdateCheck) { + this.triggerUpdateCheck(payload, cacheKey, finalPath) + } + } else { + this.updateFlags.delete(cacheKey) + } + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath)) + return { success: true, localPath, hasUpdate } } - this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath)) - return { success: true, localPath, hasUpdate } } } this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) @@ -169,9 +191,6 @@ export class ImageDecryptService { } async decryptImage(payload: DecryptImagePayload): Promise { - if (!payload.hardlinkOnly) { - await this.ensureCacheIndexed() - } const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] if (!cacheKey) { @@ -195,25 +214,17 @@ export class ImageDecryptService { } } - if (!payload.hardlinkOnly) { - for (const key of cacheKeys) { - const existingHd = this.findCachedOutput(key, true, payload.sessionId) - if (!existingHd || this.isThumbnailPath(existingHd)) continue - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd) - this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) - const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath) - this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath)) - this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') - return { success: true, localPath } - } - } } if (!payload.force) { const cached = this.resolvedCache.get(cacheKey) if (cached && existsSync(cached) && this.isImageFile(cached)) { - const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) - this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) + const upgraded = this.isThumbnailPath(cached) + ? await this.tryPromoteThumbnailCache(payload, cacheKey, cached) + : null + const finalPath = upgraded || cached + const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath)) this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') return { success: true, localPath } } @@ -260,13 +271,13 @@ export class ImageDecryptService { for (const row of result.rows) { const md5 = String(row?.md5 || '').trim().toLowerCase() if (!md5) continue - const fullPath = String(row?.data?.full_path || '').trim() - if (!fullPath || !existsSync(fullPath)) continue - this.cacheDatPath(accountDir, md5, fullPath) const fileName = String(row?.data?.file_name || '').trim().toLowerCase() - if (fileName) { - this.cacheDatPath(accountDir, fileName, fullPath) - } + const fullPath = String(row?.data?.full_path || '').trim() + if (!fileName || !fullPath) continue + const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName) + if (!selectedPath || !existsSync(selectedPath)) continue + this.cacheDatPath(accountDir, md5, selectedPath) + this.cacheDatPath(accountDir, fileName, selectedPath) } } catch { // ignore preload failures @@ -307,6 +318,7 @@ export class ImageDecryptService { payload.imageMd5, payload.imageDatName, payload.sessionId, + payload.createTime, { allowThumbnail: false, skipResolvedCache: true, @@ -319,6 +331,7 @@ export class ImageDecryptService { payload.imageMd5, payload.imageDatName, payload.sessionId, + payload.createTime, { allowThumbnail: true, skipResolvedCache: true, @@ -339,6 +352,7 @@ export class ImageDecryptService { payload.imageMd5, payload.imageDatName, payload.sessionId, + payload.createTime, { allowThumbnail: true, skipResolvedCache: false, @@ -368,21 +382,18 @@ export class ImageDecryptService { return { success: true, localPath, isThumb } } - // 查找已缓存的解密文件(hardlink-only 模式下跳过全缓存目录扫描) - if (!payload.hardlinkOnly) { - const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) - if (existing) { - this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) }) - const isHd = this.isHdPath(existing) - // 如果要求高清但找到的是缩略图,继续解密高清图 - if (!(payload.force && !isHd)) { - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) - const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) - const isThumb = this.isThumbnailPath(existing) - this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath)) - this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') - return { success: true, localPath, isThumb } - } + const preferHdCache = Boolean(payload.force && !fallbackToThumbnail) + const existingFast = this.findCachedOutputByDatPath(datPath, payload.sessionId, preferHdCache) + if (existingFast) { + this.logInfo('找到已解密文件(按DAT快速命中)', { existing: existingFast, isHd: this.isHdPath(existingFast) }) + const isHd = this.isHdPath(existingFast) + if (!(payload.force && !isHd)) { + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingFast) + const localPath = this.resolveLocalPathForPayload(existingFast, payload.preferFilePath) + const isThumb = this.isThumbnailPath(existingFast) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingFast, payload.preferFilePath)) + this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') + return { success: true, localPath, isThumb } } } @@ -407,14 +418,20 @@ export class ImageDecryptService { } const aesKeyRaw = imageKeys.aesKey - const aesKey = this.resolveAesKey(aesKeyRaw) + const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : '' + const aesKeyForNative = aesKeyText || undefined - this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey }) + this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) }) this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running') - let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) + const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative) + if (!nativeResult) { + this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'Rust原生解密不可用') + return { success: false, error: 'Rust原生解密不可用或解密失败,请检查 native 模块与密钥配置' } + } + let decrypted: Buffer = nativeResult.data this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running') - // 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据 + // 统一走原有 wxgf/ffmpeg 流程,确保行为与历史版本一致 const wxgfResult = await this.unwrapWxgf(decrypted) decrypted = wxgfResult.data @@ -487,6 +504,13 @@ export class ImageDecryptService { return null } + private resolveCurrentAccountDir(): string | null { + const wxid = this.configService.get('myWxid') + const dbPath = this.configService.get('dbPath') + if (!wxid || !dbPath) return null + return this.resolveAccountDir(dbPath, wxid) + } + /** * 获取解密后的缓存目录(用于查找 hardlink.db) */ @@ -549,6 +573,7 @@ export class ImageDecryptService { imageMd5?: string, imageDatName?: string, sessionId?: string, + createTime?: number, options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean } ): Promise { const allowThumbnail = options?.allowThumbnail ?? true @@ -557,198 +582,87 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] resolveDatPath', { imageMd5, imageDatName, + createTime, allowThumbnail, skipResolvedCache, hardlinkOnly }) - if (!skipResolvedCache) { - if (imageMd5) { - const cached = this.resolvedCache.get(imageMd5) - if (cached && existsSync(cached)) { - const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) - this.cacheDatPath(accountDir, imageMd5, preferred) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred) - return preferred - } + const lookupMd5s = this.collectHardlinkLookupMd5s(imageMd5, imageDatName) + if (lookupMd5s.length === 0) { + const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, imageDatName, sessionId, createTime, allowThumbnail) + if (packedDatFallback) { + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback) + const normalizedFileName = basename(packedDatFallback).toLowerCase() + if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback) + this.logInfo('[ImageDecrypt] datName fallback hit (no hardlink md5)', { + imageMd5, + imageDatName, + selectedPath: packedDatFallback + }) + return packedDatFallback } - if (imageDatName) { - const cached = this.resolvedCache.get(imageDatName) - if (cached && existsSync(cached)) { - const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) - this.cacheDatPath(accountDir, imageDatName, preferred) - if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred) - return preferred - } - } - } - - // 1. 通过 MD5 快速定位 (MsgAttach 目录) - if (!hardlinkOnly && allowThumbnail && imageMd5) { - const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail) - if (res) return res - if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) { - const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail) - if (datNameRes) return datNameRes - } - } - - // 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位 - if (!hardlinkOnly && allowThumbnail && !imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { - const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail) - if (res) return res - } - - // 优先通过 hardlink.db 查询 - if (imageMd5) { - this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) - const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId) - if (hardlinkPath) { - const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail) - const isThumb = this.isThumbnailPath(preferredPath) - if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath }) - this.cacheDatPath(accountDir, imageMd5, preferredPath) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath) - return preferredPath - } - // hardlink 找到的是缩略图,但要求高清图 - // 尝试在同一目录下查找高清图变体(快速查找,不遍历) - const hdPath = this.findHdVariantInSameDir(preferredPath) - if (hdPath) { - this.cacheDatPath(accountDir, imageMd5, hdPath) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) - return hdPath - } - // 没找到高清图,返回 null(不进行全局搜索) - return null - } - this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 }) - if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) { - this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId }) - const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) - if (fallbackPath) { - const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail) - const isThumb = this.isThumbnailPath(preferredPath) - if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath }) - this.cacheDatPath(accountDir, imageDatName, preferredPath) - this.cacheDatPath(accountDir, imageMd5, preferredPath) - return preferredPath - } - // 找到缩略图但要求高清图,尝试同目录查找高清图变体 - const hdPath = this.findHdVariantInSameDir(preferredPath) - if (hdPath) { - this.cacheDatPath(accountDir, imageDatName, hdPath) - this.cacheDatPath(accountDir, imageMd5, hdPath) - return hdPath - } - return null - } - this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) - } - } - - if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { - this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId }) - const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) - if (hardlinkPath) { - const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail) - const isThumb = this.isThumbnailPath(preferredPath) - if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath }) - this.cacheDatPath(accountDir, imageDatName, preferredPath) - return preferredPath - } - // hardlink 找到的是缩略图,但要求高清图 - const hdPath = this.findHdVariantInSameDir(preferredPath) - if (hdPath) { - this.cacheDatPath(accountDir, imageDatName, hdPath) - return hdPath - } - return null - } - this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) - } - - if (hardlinkOnly) { - this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName }) + this.logInfo('[ImageDecrypt] resolveDatPath miss (no hardlink md5)', { imageMd5, imageDatName }) return null } - const searchNames = Array.from( - new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean)) - ) - if (searchNames.length === 0) return null - if (!skipResolvedCache) { - for (const searchName of searchNames) { - const cached = this.resolvedCache.get(searchName) - if (cached && existsSync(cached)) { - const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) - if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred - // 缓存的是缩略图,尝试找高清图 - const hdPath = this.findHdVariantInSameDir(preferred) - if (hdPath) return hdPath - } + const cacheCandidates = Array.from(new Set([ + ...lookupMd5s, + String(imageMd5 || '').trim().toLowerCase(), + String(imageDatName || '').trim().toLowerCase() + ].filter(Boolean))) + for (const cacheKey of cacheCandidates) { + const scopedKey = `${accountDir}|${cacheKey}` + const cached = this.resolvedCache.get(scopedKey) + if (!cached) continue + if (!existsSync(cached)) continue + if (!allowThumbnail && this.isThumbnailPath(cached)) continue + return cached } } - for (const searchName of searchNames) { - const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail) - if (datPath) { - this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, searchName, path: datPath }) - if (imageDatName) this.resolvedCache.set(imageDatName, datPath) - if (imageMd5) this.resolvedCache.set(imageMd5, datPath) - this.cacheDatPath(accountDir, searchName, datPath) - if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, datPath) - if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, datPath) - return datPath - } + for (const lookupMd5 of lookupMd5s) { + this.logInfo('[ImageDecrypt] hardlink lookup', { lookupMd5, sessionId, hardlinkOnly }) + const hardlinkPath = await this.resolveHardlinkPath(accountDir, lookupMd5, sessionId) + if (!hardlinkPath) continue + if (!allowThumbnail && this.isThumbnailPath(hardlinkPath)) continue + + this.cacheDatPath(accountDir, lookupMd5, hardlinkPath) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hardlinkPath) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) + const normalizedFileName = basename(hardlinkPath).toLowerCase() + if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, hardlinkPath) + return hardlinkPath } - for (const searchName of searchNames) { - const normalized = this.normalizeDatBase(searchName) - if (normalized !== searchName.toLowerCase()) { - const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail) - if (normalizedPath) { - this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, searchName, normalized, path: normalizedPath }) - if (imageDatName) this.resolvedCache.set(imageDatName, normalizedPath) - if (imageMd5) this.resolvedCache.set(imageMd5, normalizedPath) - this.cacheDatPath(accountDir, searchName, normalizedPath) - if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, normalizedPath) - if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, normalizedPath) - return normalizedPath - } - } + const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, imageDatName, sessionId, createTime, allowThumbnail) + if (packedDatFallback) { + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback) + const normalizedFileName = basename(packedDatFallback).toLowerCase() + if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback) + this.logInfo('[ImageDecrypt] datName fallback hit (hardlink miss)', { + imageMd5, + imageDatName, + lookupMd5s, + selectedPath: packedDatFallback + }) + return packedDatFallback } - this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames }) + + this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink + datName fallback)', { + imageMd5, + imageDatName, + lookupMd5s + }) return null } - private async resolveThumbnailDatPath( - accountDir: string, - imageMd5?: string, - imageDatName?: string, - sessionId?: string - ): Promise { - if (imageMd5) { - const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId) - if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath - } - - if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { - const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) - if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath - } - - if (!imageDatName) return null - return this.searchDatFile(accountDir, imageDatName, true, true) - } - private async checkHasUpdate( - payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, - cacheKey: string, + payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }, + _cacheKey: string, cachedPath: string ): Promise { if (!cachedPath || !existsSync(cachedPath)) return false @@ -760,37 +674,94 @@ export class ImageDecryptService { const accountDir = this.resolveAccountDir(dbPath, wxid) if (!accountDir) return false - const quickDir = this.getCachedDatDir(accountDir, payload.imageDatName, payload.imageMd5) - if (quickDir) { - const baseName = payload.imageDatName || payload.imageMd5 || cacheKey - const candidate = this.findNonThumbnailVariantInDir(quickDir, baseName) - if (candidate) { - return true - } - } - - const thumbPath = await this.resolveThumbnailDatPath( + const hdPath = await this.resolveDatPath( accountDir, payload.imageMd5, payload.imageDatName, - payload.sessionId + payload.sessionId, + payload.createTime, + { allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true } ) - if (thumbPath) { - const baseName = payload.imageDatName || payload.imageMd5 || cacheKey - const candidate = this.findNonThumbnailVariantInDir(dirname(thumbPath), baseName) - if (candidate) { - return true - } - const searchHit = await this.searchDatFileInDir(dirname(thumbPath), baseName, false) - if (searchHit && this.isNonThumbnailVariantDat(searchHit)) { - return true - } + return Boolean(hdPath) + } + + private async tryPromoteThumbnailCache( + payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean }, + cacheKey: string, + cachedPath: string + ): Promise { + if (!cachedPath || !existsSync(cachedPath)) return null + if (!this.isImageFile(cachedPath)) return null + if (!this.isThumbnailPath(cachedPath)) return null + + const accountDir = this.resolveCurrentAccountDir() + if (!accountDir) return null + + const hdDatPath = await this.resolveDatPath( + accountDir, + payload.imageMd5, + payload.imageDatName, + payload.sessionId, + payload.createTime, + { allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true } + ) + if (!hdDatPath) return null + + const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true) + if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && !this.isThumbnailPath(existingHd)) { + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd) + this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + this.removeThumbnailCacheFile(cachedPath, existingHd) + this.logInfo('[ImageDecrypt] thumbnail cache upgraded', { + cacheKey, + oldPath: cachedPath, + newPath: existingHd, + mode: 'existing' + }) + return existingHd } - return false + + const upgraded = await this.decryptImage({ + sessionId: payload.sessionId, + imageMd5: payload.imageMd5, + imageDatName: payload.imageDatName, + createTime: payload.createTime, + preferFilePath: true, + force: true, + hardlinkOnly: true, + disableUpdateCheck: true + }) + if (!upgraded.success) return null + + const cachedResult = this.resolvedCache.get(cacheKey) + const upgradedPath = (cachedResult && existsSync(cachedResult)) + ? cachedResult + : String(upgraded.localPath || '').trim() + if (!upgradedPath || !existsSync(upgradedPath)) return null + if (!this.isImageFile(upgradedPath) || this.isThumbnailPath(upgradedPath)) return null + + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, upgradedPath) + this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + this.removeThumbnailCacheFile(cachedPath, upgradedPath) + this.logInfo('[ImageDecrypt] thumbnail cache upgraded', { + cacheKey, + oldPath: cachedPath, + newPath: upgradedPath, + mode: 're-decrypt' + }) + return upgradedPath + } + + private removeThumbnailCacheFile(oldPath: string, keepPath?: string): void { + if (!oldPath) return + if (keepPath && oldPath === keepPath) return + if (!existsSync(oldPath)) return + if (!this.isThumbnailPath(oldPath)) return + void rm(oldPath, { force: true }).catch(() => { }) } private triggerUpdateCheck( - payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, + payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }, cacheKey: string, cachedPath: string ): void { @@ -804,50 +775,429 @@ export class ImageDecryptService { - private resolveHardlinkDbPath(accountDir: string): string | null { - const wxid = this.configService.get('myWxid') - const cacheDir = wxid ? this.getDecryptedCacheDir(wxid) : null - const candidates = [ - join(accountDir, 'db_storage', 'hardlink', 'hardlink.db'), - join(accountDir, 'hardlink.db'), - cacheDir ? join(cacheDir, 'hardlink.db') : null - ].filter(Boolean) as string[] - this.logInfo('[ImageDecrypt] hardlink db probe', { accountDir, cacheDir, candidates }) - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate + private collectHardlinkLookupMd5s(imageMd5?: string, imageDatName?: string): string[] { + const keys: string[] = [] + const pushMd5 = (value?: string) => { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized) return + if (!this.looksLikeMd5(normalized)) return + if (!keys.includes(normalized)) keys.push(normalized) } - this.logInfo('[ImageDecrypt] hardlink db missing', { accountDir, cacheDir, candidates }) + + pushMd5(imageMd5) + + const datNameRaw = String(imageDatName || '').trim().toLowerCase() + if (!datNameRaw) return keys + pushMd5(datNameRaw) + const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw + pushMd5(datNameNoExt) + pushMd5(this.normalizeDatBase(datNameNoExt)) + return keys + } + + private resolveDatPathFromParsedDatName( + accountDir: string, + imageDatName?: string, + sessionId?: string, + createTime?: number, + allowThumbnail = true + ): string | null { + const datNameRaw = String(imageDatName || '').trim().toLowerCase() + if (!datNameRaw) return null + const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw + const baseMd5 = this.normalizeDatBase(datNameNoExt) + if (!this.looksLikeMd5(baseMd5)) return null + + const monthKey = this.resolveYearMonthFromCreateTime(createTime) + const missKey = `${accountDir}|scan|${String(sessionId || '').trim()}|${monthKey}|${baseMd5}|${allowThumbnail ? 'all' : 'hd'}` + const lastMiss = this.datNameScanMissAt.get(missKey) || 0 + if (lastMiss && (Date.now() - lastMiss) < this.datNameScanMissTtlMs) { + return null + } + + const sessionMonthCandidates = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime) + if (sessionMonthCandidates.length > 0) { + const orderedSessionMonth = this.sortDatCandidatePaths(sessionMonthCandidates, baseMd5) + for (const candidatePath of orderedSessionMonth) { + if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue + this.datNameScanMissAt.delete(missKey) + this.logInfo('[ImageDecrypt] datName fallback selected (session-month)', { + accountDir, + sessionId, + imageDatName: datNameRaw, + createTime, + monthKey, + baseMd5, + allowThumbnail, + selectedPath: candidatePath + }) + return candidatePath + } + } + + const hasPreciseContext = Boolean(String(sessionId || '').trim() && monthKey) + if (hasPreciseContext) { + this.datNameScanMissAt.set(missKey, Date.now()) + this.logInfo('[ImageDecrypt] datName fallback precise scan miss', { + accountDir, + sessionId, + imageDatName: datNameRaw, + createTime, + monthKey, + baseMd5, + allowThumbnail + }) + return null + } + + const candidates = this.collectDatCandidatesFromAccountDir(accountDir, baseMd5) + if (candidates.length === 0) { + this.datNameScanMissAt.set(missKey, Date.now()) + this.logInfo('[ImageDecrypt] datName fallback scan miss', { + accountDir, + sessionId, + imageDatName: datNameRaw, + createTime, + monthKey, + baseMd5, + allowThumbnail + }) + return null + } + + const ordered = this.sortDatCandidatePaths(candidates, baseMd5) + for (const candidatePath of ordered) { + if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue + this.datNameScanMissAt.delete(missKey) + this.logInfo('[ImageDecrypt] datName fallback selected', { + accountDir, + sessionId, + imageDatName: datNameRaw, + createTime, + monthKey, + baseMd5, + allowThumbnail, + selectedPath: candidatePath + }) + return candidatePath + } + + this.datNameScanMissAt.set(missKey, Date.now()) return null } + private resolveYearMonthFromCreateTime(createTime?: number): string { + const raw = Number(createTime) + if (!Number.isFinite(raw) || raw <= 0) return '' + const ts = raw > 1e12 ? raw : raw * 1000 + const d = new Date(ts) + if (Number.isNaN(d.getTime())) return '' + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + return `${y}-${m}` + } + + private collectDatCandidatesFromSessionMonth( + accountDir: string, + baseMd5: string, + sessionId?: string, + createTime?: number + ): string[] { + const normalizedSessionId = String(sessionId || '').trim() + const monthKey = this.resolveYearMonthFromCreateTime(createTime) + if (!normalizedSessionId || !monthKey) return [] + + const attachRoots = this.getAttachScanRoots(accountDir) + const cacheRoots = this.getMessageCacheScanRoots(accountDir) + const sessionDirs = this.getAttachSessionDirCandidates(normalizedSessionId) + const candidates = new Set() + const budget = { remaining: 600 } + const targetDirs: Array<{ dir: string; depth: number }> = [] + + for (const root of attachRoots) { + for (const sessionDir of sessionDirs) { + targetDirs.push({ dir: join(root, sessionDir, monthKey), depth: 2 }) + targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Img'), depth: 1 }) + targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Image'), depth: 1 }) + } + } + + for (const root of cacheRoots) { + for (const sessionDir of sessionDirs) { + targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir, 'Bubble'), depth: 1 }) + targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir), depth: 2 }) + } + } + + for (const target of targetDirs) { + if (budget.remaining <= 0) break + this.scanDatCandidatesUnderRoot(target.dir, baseMd5, target.depth, candidates, budget) + } + + return Array.from(candidates) + } + + private getAttachScanRoots(accountDir: string): string[] { + const roots: string[] = [] + const push = (value: string) => { + const normalized = String(value || '').trim() + if (!normalized) return + if (!roots.includes(normalized)) roots.push(normalized) + } + + push(join(accountDir, 'msg', 'attach')) + push(join(accountDir, 'attach')) + const parent = dirname(accountDir) + if (parent && parent !== accountDir) { + push(join(parent, 'msg', 'attach')) + push(join(parent, 'attach')) + } + return roots + } + + private getMessageCacheScanRoots(accountDir: string): string[] { + const roots: string[] = [] + const push = (value: string) => { + const normalized = String(value || '').trim() + if (!normalized) return + if (!roots.includes(normalized)) roots.push(normalized) + } + + push(join(accountDir, 'cache')) + const parent = dirname(accountDir) + if (parent && parent !== accountDir) { + push(join(parent, 'cache')) + } + return roots + } + + private getAttachSessionDirCandidates(sessionId: string): string[] { + const normalized = String(sessionId || '').trim() + if (!normalized) return [] + const lower = normalized.toLowerCase() + const cleaned = this.cleanAccountDirName(normalized) + const inputs = Array.from(new Set([normalized, lower, cleaned, cleaned.toLowerCase()].filter(Boolean))) + const results: string[] = [] + const push = (value: string) => { + if (!value) return + if (!results.includes(value)) results.push(value) + } + + for (const item of inputs) { + push(item) + const md5 = crypto.createHash('md5').update(item).digest('hex').toLowerCase() + push(md5) + push(md5.slice(0, 16)) + } + return results + } + + private collectDatCandidatesFromAccountDir(accountDir: string, baseMd5: string): string[] { + const roots = this.getDatScanRoots(accountDir) + const candidates = new Set() + const budget = { remaining: 1400 } + + for (const item of roots) { + if (budget.remaining <= 0) break + this.scanDatCandidatesUnderRoot(item.root, baseMd5, item.maxDepth, candidates, budget) + } + + if (candidates.size === 0 && budget.remaining <= 0) { + this.logInfo('[ImageDecrypt] datName fallback budget exhausted', { + accountDir, + baseMd5, + roots: roots.map((item) => item.root) + }) + } + + return Array.from(candidates) + } + + private getDatScanRoots(accountDir: string): Array<{ root: string; maxDepth: number }> { + const roots: Array<{ root: string; maxDepth: number }> = [] + const push = (root: string, maxDepth: number) => { + const normalized = String(root || '').trim() + if (!normalized) return + if (roots.some((item) => item.root === normalized)) return + roots.push({ root: normalized, maxDepth }) + } + + push(join(accountDir, 'attach'), 4) + push(join(accountDir, 'msg', 'attach'), 4) + push(join(accountDir, 'FileStorage', 'Image'), 3) + push(join(accountDir, 'FileStorage', 'Image2'), 3) + push(join(accountDir, 'FileStorage', 'MsgImg'), 3) + + return roots + } + + private scanDatCandidatesUnderRoot( + rootDir: string, + baseMd5: string, + maxDepth: number, + out: Set, + budget: { remaining: number } + ): void { + if (!rootDir || maxDepth < 0 || budget.remaining <= 0) return + if (!existsSync(rootDir) || !this.isDirectory(rootDir)) return + + const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }] + while (stack.length > 0 && budget.remaining > 0) { + const current = stack.pop() + if (!current) break + budget.remaining -= 1 + + let entries: Array<{ name: string; isFile: () => boolean; isDirectory: () => boolean }> + try { + entries = readdirSync(current.dir, { withFileTypes: true }) + } catch { + continue + } + + for (const entry of entries) { + if (!entry.isFile()) continue + const name = String(entry.name || '') + if (!this.isHardlinkCandidateName(name, baseMd5)) continue + const fullPath = join(current.dir, name) + if (existsSync(fullPath)) out.add(fullPath) + } + + if (current.depth >= maxDepth) continue + for (const entry of entries) { + if (!entry.isDirectory()) continue + const name = String(entry.name || '') + if (!name || name === '.' || name === '..') continue + if (name.startsWith('.')) continue + stack.push({ dir: join(current.dir, name), depth: current.depth + 1 }) + } + } + } + + private sortDatCandidatePaths(paths: string[], baseMd5: string): string[] { + const list = Array.from(new Set(paths.filter(Boolean))) + list.sort((a, b) => { + const nameA = basename(a).toLowerCase() + const nameB = basename(b).toLowerCase() + const priorityA = this.getHardlinkCandidatePriority(nameA, baseMd5) + const priorityB = this.getHardlinkCandidatePriority(nameB, baseMd5) + if (priorityA !== priorityB) return priorityA - priorityB + + let mtimeA = 0 + let mtimeB = 0 + try { + mtimeA = statSync(a).mtimeMs + } catch { } + try { + mtimeB = statSync(b).mtimeMs + } catch { } + if (mtimeA !== mtimeB) return mtimeB - mtimeA + return nameA.localeCompare(nameB) + }) + return list + } + + private isPlainMd5DatName(fileName: string): boolean { + const lower = String(fileName || '').trim().toLowerCase() + if (!lower.endsWith('.dat')) return false + const base = lower.slice(0, -4) + return this.looksLikeMd5(base) + } + + private isHardlinkCandidateName(fileName: string, baseMd5: string): boolean { + const lower = String(fileName || '').trim().toLowerCase() + if (!lower.endsWith('.dat')) return false + const base = lower.slice(0, -4) + if (base === baseMd5) return true + if (base.startsWith(`${baseMd5}_`) || base.startsWith(`${baseMd5}.`)) return true + if (base.length === baseMd5.length + 1 && base.startsWith(baseMd5)) return true + return this.normalizeDatBase(base) === baseMd5 + } + + private getHardlinkCandidatePriority(fileName: string, baseMd5: string): number { + const lower = String(fileName || '').trim().toLowerCase() + if (!lower.endsWith('.dat')) return 999 + const base = lower.slice(0, -4) + + // 无后缀 DAT 最后兜底;优先尝试变体 DAT。 + if (base === baseMd5) return 20 + // _t / .t / _thumb 等缩略图 DAT 仅作次级回退。 + if (this.isThumbnailDat(lower)) return 10 + // 其他非缩略图变体优先。 + return 0 + } + + private resolveHardlinkDatVariants(fullPath: string, baseMd5: string): string[] { + const dirPath = dirname(fullPath) + try { + const entries = readdirSync(dirPath, { withFileTypes: true }) + const candidates = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((name) => this.isHardlinkCandidateName(name, baseMd5)) + .map((name) => join(dirPath, name)) + .filter((candidatePath) => existsSync(candidatePath)) + return this.sortDatCandidatePaths(candidates, baseMd5) + } catch { + return [] + } + } + + private normalizeHardlinkDatPathByFileName(fullPath: string, fileName: string): string { + const normalizedPath = String(fullPath || '').trim() + const normalizedFileName = String(fileName || '').trim().toLowerCase() + if (!normalizedPath || !normalizedFileName.endsWith('.dat')) { + return normalizedPath + } + + // hardlink 记录到具体后缀时(如 _b/.b/_t),直接按记录路径解密。 + if (!this.isPlainMd5DatName(normalizedFileName)) { + return normalizedPath + } + + const base = normalizedFileName.slice(0, -4) + if (!this.looksLikeMd5(base)) { + return normalizedPath + } + + const candidates = this.resolveHardlinkDatVariants(normalizedPath, base) + if (candidates.length > 0) { + return candidates[0] + } + + return normalizedPath + } + private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise { try { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + if (!this.looksLikeMd5(normalizedMd5)) return null const ready = await this.ensureWcdbReady() if (!ready) { this.logInfo('[ImageDecrypt] hardlink db not ready') return null } - const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir) + const resolveResult = await wcdbService.resolveImageHardlink(normalizedMd5, accountDir) if (!resolveResult.success || !resolveResult.data) return null const fileName = String(resolveResult.data.file_name || '').trim() const fullPath = String(resolveResult.data.full_path || '').trim() - if (!fileName) return null + if (!fileName || !fullPath) return null const lowerFileName = String(fileName).toLowerCase() if (lowerFileName.endsWith('.dat')) { - const baseLower = lowerFileName.slice(0, -4) - if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) { + const normalizedBase = this.normalizeDatBase(lowerFileName.slice(0, -4)) + if (!this.looksLikeMd5(normalizedBase)) { this.logInfo('[ImageDecrypt] hardlink fileName rejected', { fileName }) return null } } - if (fullPath && existsSync(fullPath)) { - this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath }) - return fullPath + const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName) + if (existsSync(selectedPath)) { + this.logInfo('[ImageDecrypt] hardlink path hit', { md5: normalizedMd5, fileName, fullPath, selectedPath }) + return selectedPath } - this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 }) + this.logInfo('[ImageDecrypt] hardlink path miss', { md5: normalizedMd5, fileName, fullPath, selectedPath }) return null } catch { // ignore @@ -879,182 +1229,6 @@ export class ImageDecryptService { return value.replace(/'/g, "''") } - private async searchDatFile( - accountDir: string, - datName: string, - allowThumbnail = true, - thumbOnly = false - ): Promise { - const key = `${accountDir}|${datName}` - const cached = this.resolvedCache.get(key) - if (cached && existsSync(cached)) { - const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) - if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred - } - - const root = join(accountDir, 'msg', 'attach') - if (!existsSync(root)) return null - - // 优化1:快速概率性查找 - // 包含:1. 基于文件名的前缀猜测 (旧版) - // 2. 基于日期的最近月份扫描 (新版无索引时) - const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail) - if (fastHit) { - this.resolvedCache.set(key, fastHit) - return fastHit - } - - // 优化2:兜底扫描 (异步非阻塞) - const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly) - if (found) { - this.resolvedCache.set(key, found) - return found - } - return null - } - - /** - * 基于文件名的哈希特征猜测可能的路径 - * 包含:1. 微信旧版结构 filename.substr(0, 2)/... - * 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename - */ - private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): Promise { - const { promises: fs } = require('fs') - const { join } = require('path') - - try { - // --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) --- - const lowerName = datName.toLowerCase() - const baseName = this.normalizeDatBase(lowerName) - const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail) - - const candidates: string[] = [] - if (/^[a-f0-9]{32}$/.test(baseName)) { - const dir1 = baseName.substring(0, 2) - const dir2 = baseName.substring(2, 4) - for (const targetName of targetNames) { - candidates.push( - join(root, dir1, dir2, targetName), - join(root, dir1, dir2, 'Img', targetName), - join(root, dir1, dir2, 'mg', targetName), - join(root, dir1, dir2, 'Image', targetName) - ) - } - } - - for (const path of candidates) { - try { - await fs.access(path) - return path - } catch { } - } - - // --- 策略 B: 新版 Session 哈希路径猜测 --- - try { - const entries = await fs.readdir(root, { withFileTypes: true }) - const sessionDirs = entries - .filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name)) - .map((e: any) => e.name) - - if (sessionDirs.length === 0) return null - - const now = new Date() - const months: string[] = [] - // Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months". - for (let i = 0; i < 24; i++) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1) - const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` - months.push(mStr) - } - - const batchSize = 20 - for (let i = 0; i < sessionDirs.length; i += batchSize) { - const batch = sessionDirs.slice(i, i + batchSize) - const tasks = batch.map(async (sessDir: string) => { - for (const month of months) { - const subDirs = ['Img', 'Image'] - for (const sub of subDirs) { - const dirPath = join(root, sessDir, month, sub) - try { await fs.access(dirPath) } catch { continue } - for (const name of targetNames) { - const p = join(dirPath, name) - try { await fs.access(p); return p } catch { } - } - } - } - return null - }) - const results = await Promise.all(tasks) - const hit = results.find(r => r !== null) - if (hit) return hit - } - } catch { } - - } catch { } - return null - } - - /** - * 在同一目录下查找高清图变体 - * 优先 `_h`,再回退其他非缩略图变体 - */ - private findHdVariantInSameDir(thumbPath: string): string | null { - try { - const dir = dirname(thumbPath) - const fileName = basename(thumbPath) - return this.findPreferredDatVariantInDir(dir, fileName, false) - } catch { } - return null - } - - private async searchDatFileInDir( - dirPath: string, - datName: string, - allowThumbnail = true - ): Promise { - if (!existsSync(dirPath)) return null - return await this.walkForDatInWorker(dirPath, datName.toLowerCase(), 3, allowThumbnail, false) - } - - private async walkForDatInWorker( - root: string, - datName: string, - maxDepth = 4, - allowThumbnail = true, - thumbOnly = false - ): Promise { - const workerPath = join(__dirname, 'imageSearchWorker.js') - return await new Promise((resolve) => { - const worker = new Worker(workerPath, { - workerData: { root, datName, maxDepth, allowThumbnail, thumbOnly } - }) - - const cleanup = () => { - worker.removeAllListeners() - } - - worker.on('message', (msg: any) => { - if (msg && msg.type === 'done') { - cleanup() - void worker.terminate() - resolve(msg.path || null) - return - } - if (msg && msg.type === 'error') { - cleanup() - void worker.terminate() - resolve(null) - } - }) - - worker.on('error', () => { - cleanup() - void worker.terminate() - resolve(null) - }) - }) - } - private stripDatVariantSuffix(base: string): string { const lower = base.toLowerCase() const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c'] @@ -1069,77 +1243,6 @@ export class ImageDecryptService { return lower } - private getDatVariantPriority(name: string): number { - const lower = name.toLowerCase() - const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower - if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 - if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550 - if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520 - if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510 - if (!this.hasXVariant(baseLower)) return 500 - if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 - if (this.isThumbnailDat(lower)) return 100 - return 350 - } - - private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] { - if (!baseName) return [] - const names = [ - `${baseName}_h.dat`, - `${baseName}.h.dat`, - `${baseName}_hd.dat`, - `${baseName}.hd.dat`, - `${baseName}_b.dat`, - `${baseName}.b.dat`, - `${baseName}_w.dat`, - `${baseName}.w.dat`, - `${baseName}.dat`, - `${baseName}_c.dat`, - `${baseName}.c.dat` - ] - if (allowThumbnail) { - names.push( - `${baseName}_thumb.dat`, - `${baseName}.thumb.dat`, - `${baseName}_t.dat`, - `${baseName}.t.dat` - ) - } - return Array.from(new Set(names)) - } - - private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null { - let entries: string[] - try { - entries = readdirSync(dirPath) - } catch { - return null - } - const target = this.normalizeDatBase(baseName.toLowerCase()) - let bestPath: string | null = null - let bestScore = Number.NEGATIVE_INFINITY - for (const entry of entries) { - const lower = entry.toLowerCase() - if (!lower.endsWith('.dat')) continue - if (!allowThumbnail && this.isThumbnailDat(lower)) continue - const baseLower = lower.slice(0, -4) - if (this.normalizeDatBase(baseLower) !== target) continue - const score = this.getDatVariantPriority(lower) - if (score > bestScore) { - bestScore = score - bestPath = join(dirPath, entry) - } - } - return bestPath - } - - private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string { - const lower = datPath.toLowerCase() - if (!lower.endsWith('.dat')) return datPath - const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail) - return preferred || datPath - } - private normalizeDatBase(name: string): string { let base = name.toLowerCase() if (base.endsWith('.dat') || base.endsWith('.jpg')) { @@ -1154,122 +1257,6 @@ export class ImageDecryptService { } } - private hasImageVariantSuffix(baseLower: string): boolean { - return this.stripDatVariantSuffix(baseLower) !== baseLower - } - - private isLikelyImageDatBase(baseLower: string): boolean { - return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower)) - } - - - - private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null { - const allRoots = this.getAllCacheRoots() - const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) - const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] - - // 遍历所有可能的缓存根路径 - for (const root of allRoots) { - // 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg - if (sessionId) { - const sessionDir = join(root, this.sanitizeDirName(sessionId)) - if (existsSync(sessionDir)) { - try { - const dateDirs = readdirSync(sessionDir, { withFileTypes: true }) - .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)) - .map(d => d.name) - .sort() - .reverse() // 最新的日期优先 - - for (const dateDir of dateDirs) { - const imageDir = join(sessionDir, dateDir) - const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd) - if (hit) return hit - } - } catch { } - } - } - - // 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId) - try { - const sessionDirs = readdirSync(root, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => d.name) - - for (const session of sessionDirs) { - const sessionDir = join(root, session) - // 检查是否是日期目录结构 - try { - const subDirs = readdirSync(sessionDir, { withFileTypes: true }) - .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)) - .map(d => d.name) - - for (const dateDir of subDirs) { - const imageDir = join(sessionDir, dateDir) - const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd) - if (hit) return hit - } - } catch { } - } - } catch { } - - // 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg - const oldImageDir = join(root, normalizedKey) - if (existsSync(oldImageDir)) { - const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd) - if (hit) return hit - } - - // 策略4: 最旧的平铺结构 Images/{file}.jpg - for (const ext of extensions) { - const candidate = join(root, `${cacheKey}${ext}`) - if (existsSync(candidate)) return candidate - } - for (const ext of extensions) { - const candidate = join(root, `${cacheKey}_t${ext}`) - if (existsSync(candidate)) return candidate - } - } - - return null - } - - private findCachedOutputInDir( - dirPath: string, - normalizedKey: string, - extensions: string[], - preferHd: boolean - ): string | null { - // 先检查并删除旧的 .hevc 文件(ffmpeg 转换失败时遗留的) - const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`) - const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`) - try { - if (existsSync(hevcThumb)) { - require('fs').unlinkSync(hevcThumb) - } - if (existsSync(hevcHd)) { - require('fs').unlinkSync(hevcHd) - } - } catch { } - - for (const ext of extensions) { - if (preferHd) { - const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) - if (existsSync(hdPath)) return hdPath - } - const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`) - if (existsSync(thumbPath)) return thumbPath - - // 允许返回 _hd 格式(因为它有 _hd 变体后缀) - if (!preferHd) { - const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) - if (existsSync(hdPath)) return hdPath - } - } - return null - } - private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string { const name = basename(datPath) const lower = name.toLowerCase() @@ -1292,6 +1279,52 @@ export class ImageDecryptService { return join(outputDir, `${normalizedBase}${suffix}${ext}`) } + private buildCacheOutputCandidatesFromDat(datPath: string, sessionId?: string, preferHd = false): string[] { + const name = basename(datPath) + const lower = name.toLowerCase() + const base = lower.endsWith('.dat') ? name.slice(0, -4) : name + const normalizedBase = this.normalizeDatBase(base) + const suffixes = preferHd ? ['_hd', '_thumb'] : ['_thumb', '_hd'] + const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] + + const root = this.getCacheRoot() + const contactDir = this.sanitizeDirName(sessionId || 'unknown') + const timeDir = this.resolveTimeDir(datPath) + const currentDir = join(root, contactDir, timeDir) + const legacyDir = join(root, normalizedBase) + const candidates: string[] = [] + + for (const suffix of suffixes) { + for (const ext of extensions) { + candidates.push(join(currentDir, `${normalizedBase}${suffix}${ext}`)) + } + } + + // 兼容旧目录结构 + for (const suffix of suffixes) { + for (const ext of extensions) { + candidates.push(join(legacyDir, `${normalizedBase}${suffix}${ext}`)) + } + } + + // 兼容最旧平铺结构 + for (const ext of extensions) { + candidates.push(join(root, `${normalizedBase}${ext}`)) + candidates.push(join(root, `${normalizedBase}_t${ext}`)) + candidates.push(join(root, `${normalizedBase}_hd${ext}`)) + } + + return candidates + } + + private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null { + const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd) + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + return null + } + private cacheResolvedPaths(cacheKey: string, imageMd5: string | undefined, imageDatName: string | undefined, outputPath: string): void { this.resolvedCache.set(cacheKey, outputPath) if (imageMd5 && imageMd5 !== cacheKey) { @@ -1334,34 +1367,26 @@ export class ImageDecryptService { if (imageDatName) this.updateFlags.delete(imageDatName) } - private getCachedDatDir(accountDir: string, imageDatName?: string, imageMd5?: string): string | null { - const keys = [ - imageDatName ? `${accountDir}|${imageDatName}` : null, - imageDatName ? `${accountDir}|${this.normalizeDatBase(imageDatName)}` : null, - imageMd5 ? `${accountDir}|${imageMd5}` : null - ].filter(Boolean) as string[] - for (const key of keys) { - const cached = this.resolvedCache.get(key) - if (cached && existsSync(cached)) return dirname(cached) + private getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> { + try { + const getter = (BrowserWindow as unknown as { getAllWindows?: () => any[] } | undefined)?.getAllWindows + if (typeof getter !== 'function') return [] + const windows = getter() + if (!Array.isArray(windows)) return [] + return windows.filter((win) => ( + win && + typeof win.isDestroyed === 'function' && + win.webContents && + typeof win.webContents.send === 'function' + )) + } catch { + return [] } - return null - } - - private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null { - return this.findPreferredDatVariantInDir(dirPath, baseName, false) - } - - private isNonThumbnailVariantDat(datPath: string): boolean { - const lower = basename(datPath).toLowerCase() - if (!lower.endsWith('.dat')) return false - if (this.isThumbnailDat(lower)) return false - const baseLower = lower.slice(0, -4) - return this.isLikelyImageDatBase(baseLower) } private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void { const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName } - for (const win of BrowserWindow.getAllWindows()) { + for (const win of this.getActiveWindowsSafely()) { if (!win.isDestroyed()) { win.webContents.send('image:updateAvailable', message) } @@ -1370,7 +1395,7 @@ export class ImageDecryptService { private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, localPath: string): void { const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath } - for (const win of BrowserWindow.getAllWindows()) { + for (const win of this.getActiveWindowsSafely()) { if (!win.isDestroyed()) { win.webContents.send('image:cacheResolved', message) } @@ -1395,261 +1420,45 @@ export class ImageDecryptService { status, message: message || '' } - for (const win of BrowserWindow.getAllWindows()) { + for (const win of this.getActiveWindowsSafely()) { if (!win.isDestroyed()) { win.webContents.send('image:decryptProgress', event) } } } - private async ensureCacheIndexed(): Promise { - if (this.cacheIndexed) return - if (this.cacheIndexing) return this.cacheIndexing - this.cacheIndexing = (async () => { - // 扫描所有可能的缓存根目录 - const allRoots = this.getAllCacheRoots() - this.logInfo('开始索引缓存', { roots: allRoots.length }) - - for (const root of allRoots) { - try { - this.indexCacheDir(root, 3, 0) // 增加深度到 3,支持 sessionId/YYYY-MM 结构 - } catch (e) { - this.logError('索引目录失败', e, { root }) - } - } - - this.logInfo('缓存索引完成', { entries: this.resolvedCache.size }) - this.cacheIndexed = true - this.cacheIndexing = null - })() - return this.cacheIndexing - } - - /** - * 获取所有可能的缓存根路径(用于查找已缓存的图片) - * 包含当前路径、配置路径、旧版本路径 - */ - private getAllCacheRoots(): string[] { - const roots: string[] = [] - const configured = this.configService.get('cachePath') - const documentsPath = app.getPath('documents') - - // 主要路径(当前使用的) - const mainRoot = this.getCacheRoot() - roots.push(mainRoot) - - // 如果配置了自定义路径,也检查其下的 Images - if (configured) { - roots.push(join(configured, 'Images')) - roots.push(join(configured, 'images')) - } - - // 默认路径 - roots.push(join(documentsPath, 'WeFlow', 'Images')) - roots.push(join(documentsPath, 'WeFlow', 'images')) - - // 兼容旧路径(如果有的话) - roots.push(join(documentsPath, 'WeFlowData', 'Images')) - - // 去重并过滤存在的路径 - const uniqueRoots = Array.from(new Set(roots)) - const existingRoots = uniqueRoots.filter(r => existsSync(r)) - - return existingRoots - } - - private indexCacheDir(root: string, maxDepth: number, depth: number): void { - let entries: string[] - try { - entries = readdirSync(root) - } catch { - return - } - const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] - for (const entry of entries) { - const fullPath = join(root, entry) - let stat: ReturnType - try { - stat = statSync(fullPath) - } catch { - continue - } - if (stat.isDirectory()) { - if (depth < maxDepth) { - this.indexCacheDir(fullPath, maxDepth, depth + 1) - } - continue - } - if (!stat.isFile()) continue - const lower = entry.toLowerCase() - const ext = extensions.find((item) => lower.endsWith(item)) - if (!ext) continue - const base = entry.slice(0, -ext.length) - this.addCacheIndex(base, fullPath) - const normalized = this.normalizeDatBase(base) - if (normalized && normalized !== base.toLowerCase()) { - this.addCacheIndex(normalized, fullPath) - } - } - } - - private addCacheIndex(key: string, path: string): void { - const normalizedKey = key.toLowerCase() - const existing = this.resolvedCache.get(normalizedKey) - if (existing) { - const existingIsThumb = this.isThumbnailPath(existing) - const candidateIsThumb = this.isThumbnailPath(path) - if (!existingIsThumb && candidateIsThumb) return - } - this.resolvedCache.set(normalizedKey, path) - } - private getCacheRoot(): string { const configured = this.configService.get('cachePath') const root = configured ? join(configured, 'Images') - : join(app.getPath('documents'), 'WeFlow', 'Images') + : join(this.getDocumentsPath(), 'WeFlow', 'Images') if (!existsSync(root)) { mkdirSync(root, { recursive: true }) } return root } - private resolveAesKey(aesKeyRaw: string): Buffer | null { - const trimmed = aesKeyRaw?.trim() ?? '' - if (!trimmed) return null - return this.asciiKey16(trimmed) - } - - private async decryptDatAuto(datPath: string, xorKey: number, aesKey: Buffer | null): Promise { - const version = this.getDatVersion(datPath) - - if (version === 0) { - return this.decryptDatV3(datPath, xorKey) - } - if (version === 1) { - const key = this.asciiKey16(this.defaultV1AesKey) - return this.decryptDatV4(datPath, xorKey, key) - } - // version === 2 - if (!aesKey || aesKey.length !== 16) { - throw new Error('请到设置配置图片解密密钥') - } - return this.decryptDatV4(datPath, xorKey, aesKey) - } - - private getDatVersion(inputPath: string): number { - if (!existsSync(inputPath)) { - throw new Error('文件不存在') - } - const bytes = readFileSync(inputPath) - if (bytes.length < 6) { - return 0 - } - const signature = bytes.subarray(0, 6) - if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) { - return 1 - } - if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) { - return 2 - } - return 0 - } - - private decryptDatV3(inputPath: string, xorKey: number): Buffer { - const data = readFileSync(inputPath) - const out = Buffer.alloc(data.length) - for (let i = 0; i < data.length; i += 1) { - out[i] = data[i] ^ xorKey - } - return out - } - - private decryptDatV4(inputPath: string, xorKey: number, aesKey: Buffer): Buffer { - const bytes = readFileSync(inputPath) - if (bytes.length < 0x0f) { - throw new Error('文件太小,无法解析') - } - - const header = bytes.subarray(0, 0x0f) - const data = bytes.subarray(0x0f) - const aesSize = this.bytesToInt32(header.subarray(6, 10)) - const xorSize = this.bytesToInt32(header.subarray(10, 14)) - - // AES 数据需要对齐到 16 字节(PKCS7 填充) - // 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充 - const remainder = ((aesSize % 16) + 16) % 16 - const alignedAesSize = aesSize + (16 - remainder) - - if (alignedAesSize > data.length) { - throw new Error('文件格式异常:AES 数据长度超过文件实际长度') - } - - const aesData = data.subarray(0, alignedAesSize) - let unpadded: Buffer = Buffer.alloc(0) - if (aesData.length > 0) { - const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null) - decipher.setAutoPadding(false) - const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()]) - - // 使用 PKCS7 填充移除 - unpadded = this.strictRemovePadding(decrypted) - } - - const remaining = data.subarray(alignedAesSize) - if (xorSize < 0 || xorSize > remaining.length) { - throw new Error('文件格式异常:XOR 数据长度不合法') - } - - let rawData = Buffer.alloc(0) - let xoredData = Buffer.alloc(0) - if (xorSize > 0) { - const rawLength = remaining.length - xorSize - if (rawLength < 0) { - throw new Error('文件格式异常:原始数据长度小于XOR长度') - } - rawData = remaining.subarray(0, rawLength) - const xorData = remaining.subarray(rawLength) - xoredData = Buffer.alloc(xorData.length) - for (let i = 0; i < xorData.length; i += 1) { - xoredData[i] = xorData[i] ^ xorKey - } - } else { - rawData = remaining - xoredData = Buffer.alloc(0) - } - - return Buffer.concat([unpadded, rawData, xoredData]) - } - - private bytesToInt32(bytes: Buffer): number { - if (bytes.length !== 4) { - throw new Error('需要 4 个字节') - } - return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24) - } - - asciiKey16(keyString: string): Buffer { - if (keyString.length < 16) { - throw new Error('AES密钥至少需要 16 个字符') - } - return Buffer.from(keyString, 'ascii').subarray(0, 16) - } - - private strictRemovePadding(data: Buffer): Buffer { - if (!data.length) { - throw new Error('解密结果为空,填充非法') - } - const paddingLength = data[data.length - 1] - if (paddingLength === 0 || paddingLength > 16 || paddingLength > data.length) { - throw new Error('PKCS7 填充长度非法') - } - for (let i = data.length - paddingLength; i < data.length; i += 1) { - if (data[i] !== paddingLength) { - throw new Error('PKCS7 填充内容非法') + private tryDecryptDatWithNative( + datPath: string, + xorKey: number, + aesKey?: string + ): { data: Buffer; ext: string; isWxgf: boolean } | null { + const result = decryptDatViaNative(datPath, xorKey, aesKey) + if (!this.nativeLogged) { + this.nativeLogged = true + if (result) { + this.logInfo('Rust 原生解密已启用', { + addonPath: nativeAddonLocation(), + source: 'native' + }) + } else { + this.logInfo('Rust 原生解密不可用', { + addonPath: nativeAddonLocation(), + source: 'native_unavailable' + }) } } - return data.subarray(0, data.length - paddingLength) + return result } private detectImageExtension(buffer: Buffer): string | null { @@ -1723,91 +1532,6 @@ export class ImageDecryptService { return ext === '.gif' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.webp' } - private compareBytes(a: Buffer, b: Buffer): boolean { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i += 1) { - if (a[i] !== b[i]) return false - } - return true - } - - // 保留原有的批量检测 XOR 密钥方法(用于兼容) - async batchDetectXorKey(dirPath: string, maxFiles: number = 100): Promise { - const keyCount: Map = new Map() - let filesChecked = 0 - - const V1_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]) - const V2_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) - const IMAGE_SIGNATURES: { [key: string]: Buffer } = { - jpg: Buffer.from([0xFF, 0xD8, 0xFF]), - png: Buffer.from([0x89, 0x50, 0x4E, 0x47]), - gif: Buffer.from([0x47, 0x49, 0x46, 0x38]), - bmp: Buffer.from([0x42, 0x4D]), - webp: Buffer.from([0x52, 0x49, 0x46, 0x46]) - } - - const detectXorKeyFromV3 = (header: Buffer): number | null => { - for (const [, signature] of Object.entries(IMAGE_SIGNATURES)) { - const xorKey = header[0] ^ signature[0] - let valid = true - for (let i = 0; i < signature.length && i < header.length; i++) { - if ((header[i] ^ xorKey) !== signature[i]) { - valid = false - break - } - } - if (valid) return xorKey - } - return null - } - - const scanDir = (dir: string) => { - if (filesChecked >= maxFiles) return - try { - const entries = readdirSync(dir, { withFileTypes: true }) - for (const entry of entries) { - if (filesChecked >= maxFiles) return - const fullPath = join(dir, entry.name) - if (entry.isDirectory()) { - scanDir(fullPath) - } else if (entry.name.endsWith('.dat')) { - try { - const header = Buffer.alloc(16) - const fd = require('fs').openSync(fullPath, 'r') - require('fs').readSync(fd, header, 0, 16, 0) - require('fs').closeSync(fd) - - if (header.subarray(0, 6).equals(V1_SIGNATURE) || header.subarray(0, 6).equals(V2_SIGNATURE)) { - continue - } - - const key = detectXorKeyFromV3(header) - if (key !== null) { - keyCount.set(key, (keyCount.get(key) || 0) + 1) - filesChecked++ - } - } catch { } - } - } - } catch { } - } - - scanDir(dirPath) - - if (keyCount.size === 0) return null - - let maxCount = 0 - let mostCommonKey: number | null = null - keyCount.forEach((count, key) => { - if (count > maxCount) { - maxCount = count - mostCommonKey = key - } - }) - - return mostCommonKey - } - /** * 解包 wxgf 格式 * wxgf 是微信的图片格式,内部使用 HEVC 编码 @@ -1857,45 +1581,39 @@ export class ImageDecryptService { * 从 wxgf 数据中提取 HEVC NALU 裸流 */ private extractHevcNalu(buffer: Buffer): Buffer | null { - const nalUnits: Buffer[] = [] + const starts: number[] = [] let i = 4 - while (i < buffer.length - 4) { - if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 && - buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) { - let nalStart = i - let nalEnd = buffer.length + while (i < buffer.length - 3) { + const hasPrefix4 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 && + buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01 + const hasPrefix3 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 && + buffer[i + 2] === 0x01 - for (let j = i + 4; j < buffer.length - 3; j++) { - if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) { - if (buffer[j + 2] === 0x01 || - (buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) { - nalEnd = j - break - } - } - } - - const nalUnit = buffer.subarray(nalStart, nalEnd) - if (nalUnit.length > 3) { - nalUnits.push(nalUnit) - } - i = nalEnd - } else { - i++ + if (hasPrefix4 || hasPrefix3) { + starts.push(i) + i += hasPrefix4 ? 4 : 3 + continue } + i += 1 } - if (nalUnits.length === 0) { - for (let j = 4; j < buffer.length - 4; j++) { - if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 && - buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) { - return buffer.subarray(j) - } - } - return null + if (starts.length === 0) return null + + const nalUnits: Buffer[] = [] + for (let index = 0; index < starts.length; index += 1) { + const start = starts[index] + const end = index + 1 < starts.length ? starts[index + 1] : buffer.length + const hasPrefix4 = buffer[start] === 0x00 && buffer[start + 1] === 0x00 && + buffer[start + 2] === 0x00 && buffer[start + 3] === 0x01 + const prefixLength = hasPrefix4 ? 4 : 3 + const payloadStart = start + prefixLength + if (payloadStart >= end) continue + nalUnits.push(Buffer.from([0x00, 0x00, 0x00, 0x01])) + nalUnits.push(buffer.subarray(payloadStart, end)) } + if (nalUnits.length === 0) return null return Buffer.concat(nalUnits) } @@ -1921,11 +1639,11 @@ export class ImageDecryptService { const ffmpeg = this.getFfmpegPath() this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length }) - const tmpDir = join(app.getPath('temp'), 'weflow_hevc') + const tmpDir = join(this.getTempPath(), 'weflow_hevc') if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }) - const ts = Date.now() - const tmpInput = join(tmpDir, `hevc_${ts}.hevc`) - const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`) + const uniqueId = `${process.pid}_${Date.now()}_${crypto.randomBytes(4).toString('hex')}` + const tmpInput = join(tmpDir, `hevc_${uniqueId}.hevc`) + const tmpOutput = join(tmpDir, `hevc_${uniqueId}.jpg`) try { await writeFile(tmpInput, hevcData) @@ -1933,6 +1651,7 @@ export class ImageDecryptService { // 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测 const attempts: { label: string; inputArgs: string[] }[] = [ { label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] }, + { label: 'h265 raw', inputArgs: ['-f', 'h265', '-i', tmpInput] }, { label: 'auto detect', inputArgs: ['-i', tmpInput] }, ] @@ -1961,6 +1680,7 @@ export class ImageDecryptService { const args = [ '-hide_banner', '-loglevel', 'error', + '-y', ...inputArgs, '-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput ] @@ -2015,11 +1735,6 @@ export class ImageDecryptService { return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat') } - private hasXVariant(base: string): boolean { - const lower = base.toLowerCase() - return this.stripDatVariantSuffix(lower) !== lower - } - private isHdPath(p: string): boolean { return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h') } @@ -2044,42 +1759,40 @@ export class ImageDecryptService { } } - // 保留原有的解密到文件方法(用于兼容) - async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise { - const version = this.getDatVersion(inputPath) - let decrypted: Buffer - - if (version === 0) { - decrypted = this.decryptDatV3(inputPath, xorKey) - } else if (version === 1) { - const key = this.asciiKey16(this.defaultV1AesKey) - decrypted = this.decryptDatV4(inputPath, xorKey, key) - } else { - if (!aesKey || aesKey.length !== 16) { - throw new Error('V4版本需要 16 字节 AES 密钥') - } - decrypted = this.decryptDatV4(inputPath, xorKey, aesKey) + private getElectronPath(name: 'userData' | 'documents' | 'temp'): string | null { + try { + const getter = (app as unknown as { getPath?: (n: string) => string } | undefined)?.getPath + if (typeof getter !== 'function') return null + const value = getter(name) + return typeof value === 'string' && value.trim() ? value : null + } catch { + return null } + } - const outputDir = dirname(outputPath) - if (!existsSync(outputDir)) { - mkdirSync(outputDir, { recursive: true }) - } + private getUserDataPath(): string { + const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() + if (workerUserDataPath) return workerUserDataPath + return this.getElectronPath('userData') || process.cwd() + } - await writeFile(outputPath, decrypted) + private getDocumentsPath(): string { + return this.getElectronPath('documents') || join(homedir(), 'Documents') + } + + private getTempPath(): string { + return this.getElectronPath('temp') || tmpdir() } async clearCache(): Promise<{ success: boolean; error?: string }> { this.resolvedCache.clear() this.pending.clear() this.updateFlags.clear() - this.cacheIndexed = false - this.cacheIndexing = null const configured = this.configService.get('cachePath') const root = configured ? join(configured, 'Images') - : join(app.getPath('documents'), 'WeFlow', 'Images') + : join(this.getDocumentsPath(), 'WeFlow', 'Images') try { if (!existsSync(root)) { diff --git a/electron/services/imagePreloadService.ts b/electron/services/imagePreloadService.ts index 05a772a..4c65bd6 100644 --- a/electron/services/imagePreloadService.ts +++ b/electron/services/imagePreloadService.ts @@ -4,6 +4,7 @@ type PreloadImagePayload = { sessionId?: string imageMd5?: string imageDatName?: string + createTime?: number } type PreloadOptions = { @@ -74,6 +75,9 @@ export class ImagePreloadService { sessionId: task.sessionId, imageMd5: task.imageMd5, imageDatName: task.imageDatName, + createTime: task.createTime, + preferFilePath: true, + hardlinkOnly: true, disableUpdateCheck: !task.allowDecrypt, allowCacheIndex: task.allowCacheIndex }) @@ -82,7 +86,10 @@ export class ImagePreloadService { await imageDecryptService.decryptImage({ sessionId: task.sessionId, imageMd5: task.imageMd5, - imageDatName: task.imageDatName + imageDatName: task.imageDatName, + createTime: task.createTime, + preferFilePath: true, + hardlinkOnly: true }) } catch { // ignore preload failures diff --git a/electron/services/nativeImageDecrypt.ts b/electron/services/nativeImageDecrypt.ts new file mode 100644 index 0000000..bcaacb7 --- /dev/null +++ b/electron/services/nativeImageDecrypt.ts @@ -0,0 +1,110 @@ +import { existsSync } from 'fs' +import { join } from 'path' + +type NativeDecryptResult = { + data: Buffer + ext: string + isWxgf?: boolean + is_wxgf?: boolean +} + +type NativeAddon = { + decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult +} + +let cachedAddon: NativeAddon | null | undefined + +function shouldEnableNative(): boolean { + return process.env.WEFLOW_IMAGE_NATIVE !== '0' +} + +function expandAsarCandidates(filePath: string): string[] { + if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) { + return [filePath] + } + return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath] +} + +function getPlatformDir(): string { + if (process.platform === 'win32') return 'win32' + if (process.platform === 'darwin') return 'macos' + if (process.platform === 'linux') return 'linux' + return process.platform +} + +function getArchDir(): string { + if (process.arch === 'x64') return 'x64' + if (process.arch === 'arm64') return 'arm64' + return process.arch +} + +function getAddonCandidates(): string[] { + const platformDir = getPlatformDir() + const archDir = getArchDir() + const cwd = process.cwd() + const fileNames = [ + `weflow-image-native-${platformDir}-${archDir}.node` + ] + const roots = [ + join(cwd, 'resources', 'wedecrypt', platformDir, archDir), + ...(process.resourcesPath + ? [ + join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir), + join(process.resourcesPath, 'wedecrypt', platformDir, archDir) + ] + : []) + ] + const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name))) + return Array.from(new Set(candidates.flatMap(expandAsarCandidates))) +} + +function loadAddon(): NativeAddon | null { + if (!shouldEnableNative()) return null + if (cachedAddon !== undefined) return cachedAddon + + for (const candidate of getAddonCandidates()) { + if (!existsSync(candidate)) continue + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const addon = require(candidate) as NativeAddon + if (addon && typeof addon.decryptDatNative === 'function') { + cachedAddon = addon + return addon + } + } catch { + // try next candidate + } + } + + cachedAddon = null + return null +} + +export function nativeAddonLocation(): string | null { + for (const candidate of getAddonCandidates()) { + if (existsSync(candidate)) return candidate + } + return null +} + +export function decryptDatViaNative( + inputPath: string, + xorKey: number, + aesKey?: string +): { data: Buffer; ext: string; isWxgf: boolean } | null { + const addon = loadAddon() + if (!addon) return null + + try { + const result = addon.decryptDatNative(inputPath, xorKey, aesKey) + const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf) + if (!result || !Buffer.isBuffer(result.data)) return null + const rawExt = typeof result.ext === 'string' && result.ext.trim() + ? result.ext.trim().toLowerCase() + : '' + const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : '' + return { data: result.data, ext, isWxgf } + } catch { + return null + } +} diff --git a/package-lock.json b/package-lock.json index 0c06ec1..4e94508 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "4.3.0", "hasInstallScript": true, "dependencies": { - "@vscode/sudo-prompt": "^9.3.2", "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "electron-store": "^11.0.2", @@ -28,8 +27,9 @@ "react-router-dom": "^7.14.0", "react-virtuoso": "^4.18.1", "remark-gfm": "^4.0.1", - "sherpa-onnx-node": "^1.12.35", + "sherpa-onnx-node": "^1.10.38", "silk-wasm": "^3.7.1", + "sudo-prompt": "^9.2.1", "wechat-emojis": "^1.0.2", "zustand": "^5.0.2" }, @@ -40,11 +40,11 @@ "@vitejs/plugin-react": "^4.3.4", "electron": "^41.1.1", "electron-builder": "^26.8.1", - "sass": "^1.99.0", + "sass": "^1.98.0", "sharp": "^0.34.5", "typescript": "^6.0.2", - "vite": "^7.3.2", - "vite-plugin-electron": "^0.29.1", + "vite": "^7.0.0", + "vite-plugin-electron": "^0.28.8", "vite-plugin-electron-renderer": "^0.14.6" } }, @@ -3050,12 +3050,6 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@vscode/sudo-prompt": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", - "integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==", - "license": "MIT" - }, "node_modules/@xmldom/xmldom": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", @@ -9462,6 +9456,13 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/sudo-prompt": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz", + "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -10140,9 +10141,9 @@ } }, "node_modules/vite-plugin-electron": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.1.tgz", - "integrity": "sha512-AejNed5BgHFnuw8h5puTa61C6vdP4ydbsbo/uVjH1fTdHAlCDz1+o6pDQ/scQj1udDrGvH01+vTbzQh/vMnR9w==", + "version": "0.28.8", + "resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz", + "integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/package.json b/package.json index 2aac96c..17994ea 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/Jasonzhu1207/WeFlow" + "url": "https://github.com/hicccc77/WeFlow" }, "//": "二改不应改变此处的作者与应用信息", "scripts": { @@ -77,7 +77,7 @@ "appId": "com.WeFlow.app", "publish": { "provider": "github", - "owner": "Jasonzhu1207", + "owner": "hicccc77", "repo": "WeFlow", "releaseType": "release" }, @@ -186,7 +186,8 @@ "node_modules/sherpa-onnx-node/**/*", "node_modules/sherpa-onnx-*/*", "node_modules/sherpa-onnx-*/**/*", - "node_modules/ffmpeg-static/**/*" + "node_modules/ffmpeg-static/**/*", + "resources/wedecrypt/**/*.node" ], "icon": "resources/icon.icns" }, diff --git a/resources/wcdb/linux/x64/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so index 63149bc..f03f6d4 100644 Binary files a/resources/wcdb/linux/x64/libwcdb_api.so and b/resources/wcdb/linux/x64/libwcdb_api.so differ diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib index 5ac39da..2533da3 100644 Binary files a/resources/wcdb/macos/universal/libwcdb_api.dylib and b/resources/wcdb/macos/universal/libwcdb_api.dylib differ diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll index ef07c33..3454b7b 100644 Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and b/resources/wcdb/win32/arm64/wcdb_api.dll differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index 05b6d96..4c5336b 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node b/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node new file mode 100644 index 0000000..f0c1837 Binary files /dev/null and b/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node differ diff --git a/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node b/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node new file mode 100644 index 0000000..02a4881 Binary files /dev/null and b/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node differ diff --git a/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node b/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node new file mode 100644 index 0000000..eebe65e Binary files /dev/null and b/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node differ diff --git a/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node b/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node new file mode 100644 index 0000000..bea4af9 Binary files /dev/null and b/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node differ diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 7af1bc4..af0753d 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -154,6 +154,21 @@ function hasRenderableChatRecordName(value?: string): boolean { return value !== undefined && value !== null && String(value).length > 0 } +function toRenderableImageSrc(path?: string): string | undefined { + const raw = String(path || '').trim() + if (!raw) return undefined + if (/^(data:|blob:|https?:|file:)/i.test(raw)) return raw + + const normalized = raw.replace(/\\/g, '/') + if (/^[a-zA-Z]:\//.test(normalized)) { + return encodeURI(`file:///${normalized}`) + } + if (normalized.startsWith('/')) { + return encodeURI(`file://${normalized}`) + } + return raw +} + function getChatRecordPreviewText(item: ChatRecordItem): string { const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle) if (item.datatype === 17) { @@ -4853,7 +4868,7 @@ function ChatPage(props: ChatPageProps) { const candidates = [...head, ...tail] const queued = preloadImageKeysRef.current const seen = new Set() - const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = [] + const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = [] for (const msg of candidates) { if (payloads.length >= maxPreload) break if (msg.localType !== 3) continue @@ -4867,11 +4882,14 @@ function ChatPage(props: ChatPageProps) { payloads.push({ sessionId: currentSessionId, imageMd5: msg.imageMd5 || undefined, - imageDatName: msg.imageDatName + imageDatName: msg.imageDatName, + createTime: msg.createTime }) } if (payloads.length > 0) { - window.electronAPI.image.preload(payloads).catch(() => { }) + window.electronAPI.image.preload(payloads, { + allowCacheIndex: false + }).catch(() => { }) } }, [currentSessionId, messages]) @@ -5840,7 +5858,10 @@ function ChatPage(props: ChatPageProps) { sessionId: session.username, imageMd5: img.imageMd5, imageDatName: img.imageDatName, - force: true + createTime: img.createTime, + force: true, + preferFilePath: true, + hardlinkOnly: true }) if (r?.success) successCount++ else failCount++ @@ -7882,7 +7903,7 @@ function MessageBubble({ ) const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}` const [imageLocalPath, setImageLocalPath] = useState( - () => imageDataUrlCache.get(imageCacheKey) + () => toRenderableImageSrc(imageDataUrlCache.get(imageCacheKey)) ) const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message) const voiceCacheKey = `voice:${voiceIdentityKey}` @@ -7904,6 +7925,7 @@ function MessageBubble({ const imageUpdateCheckedRef = useRef(null) const imageClickTimerRef = useRef(null) const imageContainerRef = useRef(null) + const imageElementRef = useRef(null) const emojiContainerRef = useRef(null) const imageResizeBaselineRef = useRef(null) const emojiResizeBaselineRef = useRef(null) @@ -8260,19 +8282,27 @@ function MessageBubble({ sessionId: session.username, imageMd5: message.imageMd5 || undefined, imageDatName: message.imageDatName, - force: forceUpdate + createTime: message.createTime, + force: forceUpdate, + preferFilePath: true, + hardlinkOnly: true }) as SharedImageDecryptResult }) if (result.success && result.localPath) { - imageDataUrlCache.set(imageCacheKey, result.localPath) - if (imageLocalPath !== result.localPath) { + const renderPath = toRenderableImageSrc(result.localPath) + if (!renderPath) { + if (!silent) setImageError(true) + return { success: false } + } + imageDataUrlCache.set(imageCacheKey, renderPath) + if (imageLocalPath !== renderPath) { captureImageResizeBaseline() lockImageStageHeight() } - setImageLocalPath(result.localPath) + setImageLocalPath(renderPath) setImageHasUpdate(false) if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) - return result + return { ...result, localPath: renderPath } } } @@ -8297,7 +8327,7 @@ function MessageBubble({ imageDecryptPendingRef.current = false } return { success: false } - }, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight]) + }, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight]) const triggerForceHd = useCallback(() => { if (!message.imageMd5 && !message.imageDatName) return @@ -8352,24 +8382,29 @@ function MessageBubble({ const resolved = await window.electronAPI.image.resolveCache({ sessionId: session.username, imageMd5: message.imageMd5 || undefined, - imageDatName: message.imageDatName + imageDatName: message.imageDatName, + createTime: message.createTime, + preferFilePath: true, + hardlinkOnly: true }) if (resolved?.success && resolved.localPath) { - finalImagePath = resolved.localPath + const renderPath = toRenderableImageSrc(resolved.localPath) + if (!renderPath) return + finalImagePath = renderPath finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath - imageDataUrlCache.set(imageCacheKey, resolved.localPath) - if (imageLocalPath !== resolved.localPath) { + imageDataUrlCache.set(imageCacheKey, renderPath) + if (imageLocalPath !== renderPath) { captureImageResizeBaseline() lockImageStageHeight() } - setImageLocalPath(resolved.localPath) + setImageLocalPath(renderPath) if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath) setImageHasUpdate(Boolean(resolved.hasUpdate)) } } catch { } } - void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath) + void window.electronAPI.window.openImageViewerWindow(toRenderableImageSrc(finalImagePath) || finalImagePath, finalLiveVideoPath) }, [ imageLiveVideoPath, imageLocalPath, @@ -8378,6 +8413,7 @@ function MessageBubble({ lockImageStageHeight, message.imageDatName, message.imageMd5, + message.createTime, requestImageDecrypt, session.username ]) @@ -8391,8 +8427,19 @@ function MessageBubble({ }, []) useEffect(() => { - setImageLoaded(false) - }, [imageLocalPath]) + if (!isImage) return + if (!imageLocalPath) { + setImageLoaded(false) + return + } + + // 某些 file:// 缓存图在 src 切换时可能不会稳定触发 onLoad, + // 这里用 complete/naturalWidth 做一次兜底,避免图片进入 pending 隐身态。 + const img = imageElementRef.current + if (img && img.complete && img.naturalWidth > 0) { + setImageLoaded(true) + } + }, [isImage, imageLocalPath]) useEffect(() => { if (imageLoading) return @@ -8401,7 +8448,7 @@ function MessageBubble({ }, [imageError, imageLoading, imageLocalPath]) useEffect(() => { - if (!isImage || imageLoading) return + if (!isImage || imageLoading || !imageInView) return if (!message.imageMd5 && !message.imageDatName) return if (imageUpdateCheckedRef.current === imageCacheKey) return imageUpdateCheckedRef.current = imageCacheKey @@ -8409,15 +8456,21 @@ function MessageBubble({ window.electronAPI.image.resolveCache({ sessionId: session.username, imageMd5: message.imageMd5 || undefined, - imageDatName: message.imageDatName + imageDatName: message.imageDatName, + createTime: message.createTime, + preferFilePath: true, + hardlinkOnly: true, + allowCacheIndex: false }).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => { if (cancelled) return if (result.success && result.localPath) { - imageDataUrlCache.set(imageCacheKey, result.localPath) - if (!imageLocalPath || imageLocalPath !== result.localPath) { + const renderPath = toRenderableImageSrc(result.localPath) + if (!renderPath) return + imageDataUrlCache.set(imageCacheKey, renderPath) + if (!imageLocalPath || imageLocalPath !== renderPath) { captureImageResizeBaseline() lockImageStageHeight() - setImageLocalPath(result.localPath) + setImageLocalPath(renderPath) setImageError(false) } if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) @@ -8427,7 +8480,7 @@ function MessageBubble({ return () => { cancelled = true } - }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight]) + }, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, message.createTime, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight]) useEffect(() => { if (!isImage) return @@ -8455,15 +8508,17 @@ function MessageBubble({ (payload.imageMd5 && payload.imageMd5 === message.imageMd5) || (payload.imageDatName && payload.imageDatName === message.imageDatName) if (matchesCacheKey) { + const renderPath = toRenderableImageSrc(payload.localPath) + if (!renderPath) return const cachedPath = imageDataUrlCache.get(imageCacheKey) - if (cachedPath !== payload.localPath) { - imageDataUrlCache.set(imageCacheKey, payload.localPath) + if (cachedPath !== renderPath) { + imageDataUrlCache.set(imageCacheKey, renderPath) } - if (imageLocalPath !== payload.localPath) { + if (imageLocalPath !== renderPath) { captureImageResizeBaseline() lockImageStageHeight() } - setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath)) + setImageLocalPath((prev) => (prev === renderPath ? prev : renderPath)) setImageError(false) } }) @@ -9093,6 +9148,7 @@ function MessageBubble({ <>
图片 ( task.payload.scope === 'content' && task.payload.contentType === 'text' ) +const isImageExportTask = (task: ExportTask): boolean => { + if (task.payload.scope === 'sns') { + return Boolean(task.payload.snsOptions?.exportImages) + } + if (task.payload.scope !== 'content') return false + if (task.payload.contentType === 'image') return true + return Boolean(task.payload.options?.exportImages) +} + const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => { if (phase === 'preparing') return 'collect' if (phase === 'writing') return 'write' @@ -1705,6 +1713,24 @@ const TaskCenterModal = memo(function TaskCenterModal({ const currentSessionRatio = task.progress.phaseTotal > 0 ? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal)) : null + const imageTask = isImageExportTask(task) + const imageTimingElapsedMs = imageTask + ? Math.max(0, ( + typeof task.finishedAt === 'number' + ? task.finishedAt + : nowTick + ) - (task.startedAt || task.createdAt)) + : 0 + const imageTimingAvgMs = imageTask && mediaDoneFiles > 0 + ? Math.floor(imageTimingElapsedMs / Math.max(1, mediaDoneFiles)) + : 0 + const imageTimingLabel = imageTask + ? ( + mediaDoneFiles > 0 + ? `图片耗时 ${formatDurationMs(imageTimingElapsedMs)} · 平均 ${imageTimingAvgMs}ms/张` + : `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}` + ) + : '' return (
@@ -1734,6 +1760,11 @@ const TaskCenterModal = memo(function TaskCenterModal({
)} + {imageTimingLabel && task.status !== 'queued' && ( +
+ {imageTimingLabel} +
+ )} {canShowPerfDetail && stageTotals && (
累计耗时 {formatDurationMs(stageTotalMs)} @@ -1903,7 +1934,6 @@ function ExportPage() { const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) - const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true) const [options, setOptions] = useState({ format: 'json', @@ -1924,8 +1954,7 @@ function ExportPage() { excelCompactColumns: true, txtColumns: defaultTxtColumns, displayNamePreference: 'remark', - exportConcurrency: 2, - imageDeepSearchOnMiss: true + exportConcurrency: 2 }) const [exportDialog, setExportDialog] = useState({ @@ -2622,7 +2651,7 @@ function ExportPage() { automationTasksReadyRef.current = false let isReady = true try { - const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([ + const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultAvatars(), @@ -2631,7 +2660,6 @@ function ExportPage() { configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultTxtColumns(), configService.getExportDefaultConcurrency(), - configService.getExportDefaultImageDeepSearchOnMiss(), configService.getExportLastSessionRunMap(), configService.getExportLastContentRunMap(), configService.getExportSessionRecordMap(), @@ -2671,7 +2699,6 @@ function ExportPage() { setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultConcurrency(savedConcurrency ?? 2) - setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true) setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic') setAutomationTasks(automationTaskItem?.tasks || []) automationTasksReadyRef.current = true @@ -2709,8 +2736,7 @@ function ExportPage() { exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, txtColumns, - exportConcurrency: savedConcurrency ?? prev.exportConcurrency, - imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss + exportConcurrency: savedConcurrency ?? prev.exportConcurrency })) } catch (error) { isReady = false @@ -4491,8 +4517,7 @@ function ExportPage() { maxFileSizeMb: prev.maxFileSizeMb, exportVoiceAsText: exportDefaultVoiceAsText, excelCompactColumns: exportDefaultExcelCompactColumns, - exportConcurrency: exportDefaultConcurrency, - imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss + exportConcurrency: exportDefaultConcurrency } if (payload.scope === 'sns') { @@ -4527,8 +4552,7 @@ function ExportPage() { exportDefaultAvatars, exportDefaultMedia, exportDefaultVoiceAsText, - exportDefaultConcurrency, - exportDefaultImageDeepSearchOnMiss + exportDefaultConcurrency ]) const closeExportDialog = useCallback(() => { @@ -4755,7 +4779,6 @@ function ExportPage() { txtColumns: options.txtColumns, displayNamePreference: options.displayNamePreference, exportConcurrency: options.exportConcurrency, - imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, fileNamingMode: exportDefaultFileNamingMode, sessionLayout, sessionNameWithTypePrefix, @@ -5691,8 +5714,6 @@ function ExportPage() { await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) await configService.setExportDefaultTxtColumns(options.txtColumns) await configService.setExportDefaultConcurrency(options.exportConcurrency) - await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss) - setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss) } const openSingleExport = useCallback((session: SessionRow) => { @@ -7393,14 +7414,6 @@ function ExportPage() { const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowMediaSection = !isContentScopeDialog - const shouldRenderImageDeepSearchToggle = exportDialog.scope !== 'sns' && ( - isSessionScopeDialog || - (isContentScopeDialog && exportDialog.contentType === 'image') - ) - const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && ( - (isSessionScopeDialog && options.exportImages) || - (isContentScopeDialog && exportDialog.contentType === 'image') - ) const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像' const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。' const activeDialogFormatLabel = exportDialog.scope === 'sns' @@ -9710,30 +9723,6 @@ function ExportPage() {
)} - {shouldRenderImageDeepSearchToggle && ( -
-
-
-
-
-

缺图时深度搜索

-
关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。
-
- -
-
-
-
- )} - {isSessionScopeDialog && (
diff --git a/src/services/config.ts b/src/services/config.ts index de55af9..a0fe361 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -37,7 +37,6 @@ export const CONFIG_KEYS = { EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns', EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency', - EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss', EXPORT_WRITE_LAYOUT: 'exportWriteLayout', EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled', EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', @@ -548,18 +547,6 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise< await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency) } -// 获取缺图时是否深度搜索(默认导出行为) -export async function getExportDefaultImageDeepSearchOnMiss(): Promise { - const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS) - if (typeof value === 'boolean') return value - return null -} - -// 设置缺图时是否深度搜索(默认导出行为) -export async function setExportDefaultImageDeepSearchOnMiss(enabled: boolean): Promise { - await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled) -} - export type ExportWriteLayout = 'A' | 'B' | 'C' export async function getExportWriteLayout(): Promise { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 244896d..98ca3fa 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -491,24 +491,35 @@ export interface ElectronAPI { } image: { - decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }> + decrypt: (payload: { + sessionId?: string + imageMd5?: string + imageDatName?: string + createTime?: number + force?: boolean + preferFilePath?: boolean + hardlinkOnly?: boolean + }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }> resolveCache: (payload: { sessionId?: string imageMd5?: string imageDatName?: string + createTime?: number + preferFilePath?: boolean + hardlinkOnly?: boolean disableUpdateCheck?: boolean allowCacheIndex?: boolean }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }> resolveCacheBatch: ( - payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, - options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean } + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>, + options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean } ) => Promise<{ success: boolean rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> error?: string }> preload: ( - payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>, options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } ) => Promise onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void @@ -1117,7 +1128,6 @@ export interface ExportOptions { sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' exportConcurrency?: number - imageDeepSearchOnMiss?: boolean } export interface ExportProgress {