diff --git a/.gitignore b/.gitignore index 756b0dd..8a16b47 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,10 @@ WeFlow WxKey-CC upx native-dlls +native/image-decrypt/ +native/image-decrypt/target resources/whisper xkey skills .claude/ +.tmp diff --git a/CHANGELOG.md b/CHANGELOG.md index c51daa6..abc51cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,27 @@ ### 变更 - 暂无 +## [4.2.0] - 2026-04-21 + +### 新增 +- 新增 CipherTalk 自研图片 DAT 原生解密模块接入,支持 Windows x64 与 macOS arm64 预编译 `.node` 资源,并在打包时按平台保留对应产物。 +- 新增图片 native 解密运行时检查与同步脚本,便于验证本机原生模块是否可加载、是否为当前平台正确产物。 +- 聊天图片消息新增 XML 宽高解析,支持从 `cdnthumbwidth/cdnthumbheight`、`cdnmidwidth/cdnmidheight`、`cdnhdwidth/cdnhdheight` 等字段提取比例信息。 + +### 优化 +- 图片 DAT 解密链路改为 native 优先、TypeScript 兜底,保留原有 V3/V4 解密兼容路径、wxgf 后处理、缓存命中、高清图回退和实况照片提取逻辑。 +- 聊天图片解密中、未解密、未配置密钥和已解密状态统一按图片比例渲染占位,减少图片加载前后的布局跳动。 +- 图片查看器打开时会参考消息中的图片宽高预设窗口尺寸,图片真实加载后继续按实际尺寸校正。 +- 聊天消息列表改为动态高度虚拟列表,屏幕外消息 DOM 和图片节点会自动卸载,降低长会话滚动时的内存与渲染压力。 +- 顶部历史消息改为接近顶部并向上滚动时提前加载,同时加入滚动锚点恢复,减少加载更早消息后的跳屏。 +- 优化聊天滚动事件更新频率,避免每次滚动都触发不必要的 React 状态更新。 + +### 修复 +- 修复虚拟列表初始挂载时被误判为滚到顶部,导致打开聊天后历史加载与滚底逻辑互相打架、界面上下晃动的问题。 +- 修复部分 wxgf/HEVC 图片解码后出现纯白图的问题,避免错误缓存影响后续显示。 +- 修复图片 native 解密调试日志和聊天表匹配日志默认过多输出的问题,改为仅在调试环境变量启用时打印。 +- 修复图片 DAT 路径搜索、缓存检查与写入耗时较高时缺少定位信息的问题,保留可按需开启的耗时诊断。 + ## [4.1.9] - 2026-04-11 ### 修复 diff --git a/README.md b/README.md index 2957c42..3426926 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **一款现代化的微信聊天记录查看与分析工具** [![License](https://img.shields.io/badge/license-CC--BY--NC--SA--4.0-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-4.1.8-green.svg)](package.json) +[![Version](https://img.shields.io/badge/version-4.2.0-green.svg)](package.json) [![Platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows)]() [![Electron](https://img.shields.io/badge/Electron-39-47848F.svg?logo=electron)]() [![React](https://img.shields.io/badge/React-19-61DAFB.svg?logo=react)]() diff --git a/electron/main.ts b/electron/main.ts index fb8c18b..c3d4cbd 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2243,8 +2243,7 @@ function registerIpcHandlers() { ipcMain.handle('imageDecrypt:decryptImage', async (_, inputPath: string, outputPath: string, xorKey: number, aesKey?: string) => { try { logService?.info('ImageDecrypt', '开始解密图片', { inputPath, outputPath }) - const aesKeyBuffer = aesKey ? imageDecryptService.asciiKey16(aesKey) : undefined - await imageDecryptService.decryptToFile(inputPath, outputPath, xorKey, aesKeyBuffer) + await imageDecryptService.decryptToFile(inputPath, outputPath, xorKey, aesKey) logService?.info('ImageDecrypt', '图片解密成功', { outputPath }) return { success: true } } catch (e) { diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 8f1a6ff..575fb54 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -892,7 +892,7 @@ class ChatService extends EventEmitter { } } - if (tables.length > 0) { + if (tables.length > 0 && process.env.CIPHERTALK_CHAT_DEBUG === '1') { const sample = tables.slice(0, 8).map(t => t.name).join(', ') console.warn(`[ChatService] 未匹配到消息表: session=${sessionId}, hash=${hash}, tables=${tables.length}, sample=[${sample}]`) } diff --git a/electron/services/dataManagementService.ts b/electron/services/dataManagementService.ts index 2e91383..e8d1527 100644 --- a/electron/services/dataManagementService.ts +++ b/electron/services/dataManagementService.ts @@ -1042,7 +1042,7 @@ class DataManagementService { let successCount = 0 let failCount = 0 const totalFiles = pendingImages.length - const aesKeyBuffer = aesKeyStr ? imageDecryptService.asciiKey16(String(aesKeyStr)) : Buffer.alloc(16) + const aesKeyText = aesKeyStr ? String(aesKeyStr) : undefined // 分批处理,每批 50 个,避免内存溢出 const BATCH_SIZE = 50 @@ -1067,7 +1067,7 @@ class DataManagementService { const outputRelativePath = relativePath.replace(/\.dat$/, '') // 解密图片 - const decrypted = imageDecryptService.decryptDatFile(img.filePath, xorKey, aesKeyBuffer) + const decrypted = imageDecryptService.decryptDatFile(img.filePath, xorKey, aesKeyText) // 检测图片格式 const ext = this.detectImageFormat(decrypted) @@ -1213,14 +1213,14 @@ class DataManagementService { const outputRelativePath = relativePath.replace(/\.dat$/, '') // 解密图片 - const aesKeyBuffer = aesKeyStr ? imageDecryptService.asciiKey16(String(aesKeyStr)) : undefined + const aesKeyText = aesKeyStr ? String(aesKeyStr) : undefined console.log('解密图片:', filePath) console.log('XOR Key:', xorKey.toString(16)) console.log('AES Key String:', aesKeyStr) console.log('AES Key Buffer:', aesKeyBuffer?.toString('hex')) console.log('图片版本:', imageDecryptService.getDatVersion(filePath)) - const decrypted = imageDecryptService.decryptDatFile(filePath, xorKey, aesKeyBuffer || Buffer.alloc(16)) + const decrypted = imageDecryptService.decryptDatFile(filePath, xorKey, aesKeyText) // 检测图片格式 const ext = this.detectImageFormat(decrypted) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index c185d50..651ea6c 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -11,6 +11,7 @@ import { promisify } from 'util' import { ConfigService } from './config' import { getDefaultCachePath as getPlatformDefaultCachePath } from './platformService' import { getDocumentsPath, getExePath } from './runtimePaths' +import { decryptDatViaNative, nativeAddonLocation, nativeAddonMetadata, nativeDecryptEnabled } from './nativeImageDecrypt' const execFileAsync = promisify(execFile) @@ -43,10 +44,22 @@ type HardlinkState = { dirTable?: string } +type DatDecryptOutcome = { + data: Buffer + source: 'native' | 'ts' + fallbackReason?: string +} + +type ResolveDatDiagnostics = { + source?: string +} + export class ImageDecryptService { private configService = new ConfigService() private hardlinkCache = new Map() private resolvedCache = new Map() + private sessionDatDirCache = new Map() + private sessionDatRootCache = new Map() private pending = new Map>() private noLiveSet = new Set() private readonly defaultV1AesKey = 'cfcd208495d565ef' @@ -54,6 +67,7 @@ export class ImageDecryptService { private cacheIndexing: Promise | null = null private updateFlags = new Map() private notFoundCache = new Set() // 失败缓存,避免重复查询 + private nativeLogged = false async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise { // 不再等待缓存索引,直接查找 @@ -66,7 +80,7 @@ export class ImageDecryptService { // 1. 先检查内存缓存(最快) for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) - if (cached && existsSync(cached) && this.isImageFile(cached)) { + if (cached && this.validateCachedImageFile(cached)) { const localPath = this.filePathToUrl(cached) const isThumb = this.isThumbnailPath(cached) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false @@ -79,7 +93,7 @@ export class ImageDecryptService { this.emitCacheResolved(payload, key, localPath) return { success: true, localPath, hasUpdate, liveVideoPath } } - if (cached && !this.isImageFile(cached)) { + if (cached && !this.validateCachedImageFile(cached)) { this.resolvedCache.delete(key) } } @@ -127,7 +141,7 @@ export class ImageDecryptService { // 快速查找高清图缓存 const hdCached = this.findCachedOutputFast(cacheKey, payload.sessionId, true) || this.findCachedOutput(cacheKey, payload.sessionId, true) - if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached)) { + if (hdCached && this.validateCachedImageFile(hdCached)) { const localPath = this.filePathToUrl(hdCached) const liveVideoPath = this.checkLiveVideoCache(hdCached) return { success: true, localPath, isThumb: false, liveVideoPath } @@ -135,12 +149,12 @@ export class ImageDecryptService { } else { // 常规缓存检查(可能返回缩略图) const cached = this.resolvedCache.get(cacheKey) - if (cached && existsSync(cached) && this.isImageFile(cached)) { + if (cached && this.validateCachedImageFile(cached)) { const localPath = this.filePathToUrl(cached) const liveVideoPath = this.checkLiveVideoCache(cached) return { success: true, localPath, liveVideoPath } } - if (cached && !this.isImageFile(cached)) { + if (cached && !this.validateCachedImageFile(cached)) { this.resolvedCache.delete(cacheKey) } } @@ -163,6 +177,23 @@ export class ImageDecryptService { payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }, cacheKey: string ): Promise { + const totalStartedAt = Date.now() + let resolveDatMs = 0 + let cacheLookupMs = 0 + let decryptMs = 0 + let wxgfMs = 0 + let writeMs = 0 + let motionVideoMs = 0 + let thumbnailCleanupMs = 0 + let datPath: string | null = null + const datDiagnostics: ResolveDatDiagnostics = {} + let decryptSource: 'native' | 'ts' | 'none' = 'none' + let fallbackReason: string | undefined + let finalExtForLog: string | undefined + let nativeFallbackUsed = false + let usedCachedOutput = false + let wxgfDetected = false + try { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') @@ -176,36 +207,107 @@ export class ImageDecryptService { return { success: false, error: '未找到账号目录' } } - const datPath = await this.resolveDatPath( + const resolveDatStartedAt = Date.now() + datPath = await this.resolveDatPath( accountDir, payload.imageMd5, payload.imageDatName, payload.sessionId, - { allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) } + { allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) }, + datDiagnostics ) + resolveDatMs = Date.now() - resolveDatStartedAt // 如果要求高清图但没找到,直接返回提示 if (!datPath && payload.force) { console.warn(`[ImageDecrypt] 未找到高清图: ${payload.imageDatName || payload.imageMd5}`) + this.logDecryptTiming({ + cacheKey, + payload, + datPath, + resolveSource: datDiagnostics.source, + resolveDatMs, + cacheLookupMs, + decryptMs, + wxgfMs, + writeMs, + motionVideoMs, + thumbnailCleanupMs, + decryptSource, + fallbackReason, + finalExt: finalExtForLog, + usedCachedOutput, + nativeFallbackUsed, + wxgfDetected, + status: 'missing_hd', + totalMs: Date.now() - totalStartedAt + }) return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } } if (!datPath) { this.notFoundCache.add(cacheKey) console.warn(`[ImageDecrypt] 未找到图片文件: ${payload.imageDatName || payload.imageMd5} sessionId=${payload.sessionId}`) + this.logDecryptTiming({ + cacheKey, + payload, + datPath, + resolveSource: datDiagnostics.source, + resolveDatMs, + cacheLookupMs, + decryptMs, + wxgfMs, + writeMs, + motionVideoMs, + thumbnailCleanupMs, + decryptSource, + fallbackReason, + finalExt: finalExtForLog, + usedCachedOutput, + nativeFallbackUsed, + wxgfDetected, + status: 'missing_dat', + totalMs: Date.now() - totalStartedAt + }) return { success: false, error: '未找到图片文件' } } if (!extname(datPath).toLowerCase().includes('dat')) { + this.cacheSessionDatRoot(accountDir, payload.sessionId, datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) const localPath = this.filePathToUrl(datPath) const isThumb = this.isThumbnailPath(datPath) this.emitCacheResolved(payload, cacheKey, localPath) + this.logDecryptTiming({ + cacheKey, + payload, + datPath, + resolveSource: datDiagnostics.source, + resolveDatMs, + cacheLookupMs, + decryptMs, + wxgfMs, + writeMs, + motionVideoMs, + thumbnailCleanupMs, + decryptSource, + fallbackReason, + finalExt: extname(datPath).toLowerCase(), + usedCachedOutput, + nativeFallbackUsed, + wxgfDetected, + status: 'plain_file', + totalMs: Date.now() - totalStartedAt + }) return { success: true, localPath, isThumb, liveVideoPath: !isThumb ? this.checkLiveVideoCache(datPath) : undefined } } // 查找已缓存的解密文件 - const existing = this.findCachedOutput(cacheKey, payload.sessionId, payload.force) + const cacheLookupStartedAt = Date.now() + const existing = this.findCachedOutputFast(cacheKey, payload.sessionId, payload.force) || + (!payload.sessionId ? this.findCachedOutput(cacheKey, payload.sessionId, payload.force) : null) + cacheLookupMs = Date.now() - cacheLookupStartedAt if (existing) { + usedCachedOutput = true const isHd = this.isHdPath(existing) // 如果要求高清但找到的是缩略图,继续解密高清图 if (!(payload.force && !isHd)) { @@ -213,6 +315,27 @@ export class ImageDecryptService { const localPath = this.filePathToUrl(existing) const isThumb = this.isThumbnailPath(existing) this.emitCacheResolved(payload, cacheKey, localPath) + this.logDecryptTiming({ + cacheKey, + payload, + datPath, + resolveSource: datDiagnostics.source, + resolveDatMs, + cacheLookupMs, + decryptMs, + wxgfMs, + writeMs, + motionVideoMs, + thumbnailCleanupMs, + decryptSource, + fallbackReason, + finalExt: extname(existing).toLowerCase(), + usedCachedOutput, + nativeFallbackUsed, + wxgfDetected, + status: 'cache_hit', + totalMs: Date.now() - totalStartedAt + }) return { success: true, localPath, isThumb, liveVideoPath: !isThumb ? this.checkLiveVideoCache(existing) : undefined } } } @@ -235,41 +358,71 @@ export class ImageDecryptService { } const aesKeyRaw = this.configService.get('imageAesKey') + const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : '' const aesKey = this.resolveAesKey(aesKeyRaw) - let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) + const decryptStartedAt = Date.now() + let decryptOutcome = await this.decryptDatAuto(datPath, xorKey, aesKey, aesKeyText) + decryptMs += Date.now() - decryptStartedAt + decryptSource = decryptOutcome.source + fallbackReason = decryptOutcome.fallbackReason + let decrypted = decryptOutcome.data - // 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据 - const wxgfResult = await this.unwrapWxgf(decrypted) + const unwrapStartedAt = Date.now() + let wxgfResult = await this.unwrapWxgf(decrypted) + wxgfMs += Date.now() - unwrapStartedAt + wxgfDetected = wxgfResult.isWxgf decrypted = wxgfResult.data let ext = this.detectImageExtension(decrypted) - // 如果是 wxgf 格式且没检测到扩展名 if (wxgfResult.isWxgf && !ext) { - // wxgf 格式需要 ffmpeg 转换,如果转换失败则无法显示 ext = '.hevc' } + if (!ext && decryptOutcome.source === 'native') { + console.warn(`[ImageDecrypt] Native DAT 解密结果无效,回退 TS 逻辑: ${datPath} reason=${decryptOutcome.fallbackReason || 'invalid_output'}`) + nativeFallbackUsed = true + fallbackReason = decryptOutcome.fallbackReason || 'invalid_output' + const fallbackDecryptStartedAt = Date.now() + decryptOutcome = this.decryptDatLegacy(datPath, xorKey, aesKey) + decryptMs += Date.now() - fallbackDecryptStartedAt + decryptSource = decryptOutcome.source + decrypted = decryptOutcome.data + const fallbackUnwrapStartedAt = Date.now() + wxgfResult = await this.unwrapWxgf(decrypted) + wxgfMs += Date.now() - fallbackUnwrapStartedAt + wxgfDetected = wxgfResult.isWxgf + decrypted = wxgfResult.data + ext = this.detectImageExtension(decrypted) + if (wxgfResult.isWxgf && !ext) { + ext = '.hevc' + } + } + const finalExt = ext || '.jpg' + finalExtForLog = finalExt // 图片完整性校验:检测解密后的数据是否有完整的结束标记 const isImageComplete = this.verifyImageComplete(decrypted, finalExt) - // 诊断日志:记录关键数据以便定位半白图片的根因 - const datSize = statSync(datPath).size - const datVersion = this.getDatVersion(datPath) if (!isImageComplete) { + const datSize = statSync(datPath).size + const datVersion = this.getDatVersion(datPath) console.warn(`[ImageDecrypt] 图片不完整! cacheKey=${cacheKey} datPath=${datPath} datSize=${datSize} version=V${datVersion === 0 ? '3' : datVersion === 1 ? '4v1' : '4v2'} decryptedSize=${decrypted.length} ext=${finalExt} headHex=${decrypted.subarray(0, 8).toString('hex')} tailHex=${decrypted.subarray(Math.max(0, decrypted.length - 8)).toString('hex')}`) } const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId) + const writeStartedAt = Date.now() await writeFile(outputPath, decrypted) + writeMs = Date.now() - writeStartedAt // 检测实况照片(Motion Photo) let liveVideoPath: string | undefined if (!this.isThumbnailPath(datPath) && (finalExt === '.jpg' || finalExt === '.jpeg')) { + const motionStartedAt = Date.now() const vp = await this.extractMotionPhotoVideo(outputPath, decrypted) + motionVideoMs = Date.now() - motionStartedAt if (vp) liveVideoPath = this.filePathToUrl(vp) } @@ -277,16 +430,40 @@ export class ImageDecryptService { // 如果图片是完整的,才缓存路径映射(不完整的下次重新解密) if (isImageComplete) { + this.cacheSessionDatRoot(accountDir, payload.sessionId, datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) if (!isThumb) { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) - this.deleteThumbnailByKeys(this.getCacheKeys(payload)) + const thumbnailCleanupStartedAt = Date.now() + this.deleteThumbnailByKeysInDir(this.getCacheKeys(payload), dirname(outputPath)) + thumbnailCleanupMs = Date.now() - thumbnailCleanupStartedAt } } // 对于 hevc 格式,返回错误提示用户安装 ffmpeg if (finalExt === '.hevc') { console.warn(`[ImageDecrypt] 检测到 wxgf/hevc 格式图片,但未启用转换或转换失败: ${cacheKey}`) + this.logDecryptTiming({ + cacheKey, + payload, + datPath, + resolveSource: datDiagnostics.source, + resolveDatMs, + cacheLookupMs, + decryptMs, + wxgfMs, + writeMs, + motionVideoMs, + thumbnailCleanupMs, + decryptSource, + fallbackReason, + finalExt: finalExtForLog, + usedCachedOutput, + nativeFallbackUsed, + wxgfDetected, + status: 'hevc_unavailable', + totalMs: Date.now() - totalStartedAt + }) return { success: false, error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示。请运行: winget install ffmpeg', @@ -296,14 +473,123 @@ export class ImageDecryptService { const localPath = this.filePathToUrl(outputPath) this.emitCacheResolved(payload, cacheKey, localPath) + this.logDecryptTiming({ + cacheKey, + payload, + datPath, + resolveSource: datDiagnostics.source, + resolveDatMs, + cacheLookupMs, + decryptMs, + wxgfMs, + writeMs, + motionVideoMs, + thumbnailCleanupMs, + decryptSource, + fallbackReason, + finalExt: finalExtForLog, + usedCachedOutput, + nativeFallbackUsed, + wxgfDetected, + status: 'success', + totalMs: Date.now() - totalStartedAt + }) return { success: true, localPath, isThumb, liveVideoPath } } catch (e) { + this.logDecryptTiming({ + cacheKey, + payload, + datPath, + resolveSource: datDiagnostics.source, + resolveDatMs, + cacheLookupMs, + decryptMs, + wxgfMs, + writeMs, + motionVideoMs, + thumbnailCleanupMs, + decryptSource, + fallbackReason, + finalExt: finalExtForLog, + usedCachedOutput, + nativeFallbackUsed, + wxgfDetected, + status: 'error', + totalMs: Date.now() - totalStartedAt, + error: String(e) + }) console.error(`[ImageDecrypt] 解密异常: ${cacheKey}`, e) return { success: false, error: String(e) } } } + private logDecryptTiming(details: { + cacheKey: string + payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean } + datPath: string | null + resolveSource?: string + resolveDatMs: number + cacheLookupMs: number + decryptMs: number + wxgfMs: number + writeMs: number + motionVideoMs: number + thumbnailCleanupMs: number + decryptSource: 'native' | 'ts' | 'none' + fallbackReason?: string + finalExt?: string + usedCachedOutput: boolean + nativeFallbackUsed: boolean + wxgfDetected: boolean + status: 'success' | 'cache_hit' | 'plain_file' | 'missing_dat' | 'missing_hd' | 'hevc_unavailable' | 'error' + totalMs: number + error?: string + }): void { + if (process.env.CIPHERTALK_IMAGE_DECRYPT_DEBUG !== '1') return + + const shouldLog = + details.status !== 'success' || + details.totalMs >= 300 || + details.resolveDatMs >= 120 || + details.cacheLookupMs >= 80 || + details.decryptMs >= 100 || + details.wxgfMs >= 100 || + details.writeMs >= 80 || + details.motionVideoMs >= 120 || + details.thumbnailCleanupMs >= 80 || + details.nativeFallbackUsed || + details.resolveSource === 'search' || + details.resolveSource === 'search_normalized' + + if (!shouldLog) return + + console.info('[ImageDecrypt] 耗时分析', { + cacheKey: details.cacheKey, + sessionId: details.payload.sessionId, + imageDatName: details.payload.imageDatName, + imageMd5: details.payload.imageMd5, + force: Boolean(details.payload.force), + status: details.status, + datPath: details.datPath, + resolveSource: details.resolveSource || 'unknown', + usedCachedOutput: details.usedCachedOutput, + decryptSource: details.decryptSource, + fallbackReason: details.fallbackReason || null, + wxgfDetected: details.wxgfDetected, + finalExt: details.finalExt || null, + totalMs: details.totalMs, + resolveDatMs: details.resolveDatMs, + cacheLookupMs: details.cacheLookupMs, + decryptMs: details.decryptMs, + wxgfMs: details.wxgfMs, + writeMs: details.writeMs, + motionVideoMs: details.motionVideoMs, + thumbnailCleanupMs: details.thumbnailCleanupMs, + error: details.error || null + }) + } + private resolveAccountDir(dbPath: string, wxid: string): string | null { const cleanedWxid = this.cleanAccountDirName(wxid) const normalized = dbPath.replace(/[\\/]+$/, '') @@ -412,7 +698,8 @@ export class ImageDecryptService { imageMd5?: string, imageDatName?: string, sessionId?: string, - options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean } + options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean }, + diagnostics?: ResolveDatDiagnostics ): Promise { const allowThumbnail = options?.allowThumbnail ?? true const skipResolvedCache = options?.skipResolvedCache ?? false @@ -423,6 +710,8 @@ export class ImageDecryptService { if (hardlinkPath) { const isThumb = this.isThumbnailPath(hardlinkPath) if (allowThumbnail || !isThumb) { + diagnostics && (diagnostics.source = 'hardlink') + this.cacheSessionDatRoot(accountDir, sessionId, hardlinkPath) this.cacheDatPath(accountDir, imageMd5, hardlinkPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) return hardlinkPath @@ -431,6 +720,8 @@ export class ImageDecryptService { // 尝试在同一目录下查找高清图变体(快速查找) const hdPath = this.findHdVariantInSameDir(hardlinkPath) if (hdPath) { + diagnostics && (diagnostics.source = 'hardlink_hd_same_dir') + this.cacheSessionDatRoot(accountDir, sessionId, hdPath) this.cacheDatPath(accountDir, imageMd5, hdPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath @@ -438,6 +729,8 @@ export class ImageDecryptService { // 同目录没找到高清图,尝试在该目录下搜索 const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false) if (hdInDir) { + diagnostics && (diagnostics.source = 'hardlink_hd_dir_scan') + this.cacheSessionDatRoot(accountDir, sessionId, hdInDir) this.cacheDatPath(accountDir, imageMd5, hdInDir) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir) return hdInDir @@ -452,18 +745,24 @@ export class ImageDecryptService { if (hardlinkPath) { const isThumb = this.isThumbnailPath(hardlinkPath) if (allowThumbnail || !isThumb) { + diagnostics && (diagnostics.source = 'hardlink') + this.cacheSessionDatRoot(accountDir, sessionId, hardlinkPath) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) return hardlinkPath } // hardlink 找到的是缩略图,但要求高清图 const hdPath = this.findHdVariantInSameDir(hardlinkPath) if (hdPath) { + diagnostics && (diagnostics.source = 'hardlink_hd_same_dir') + this.cacheSessionDatRoot(accountDir, sessionId, hdPath) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } // 同目录没找到高清图,尝试在该目录下搜索 const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName, false) if (hdInDir) { + diagnostics && (diagnostics.source = 'hardlink_hd_dir_scan') + this.cacheSessionDatRoot(accountDir, sessionId, hdInDir) this.cacheDatPath(accountDir, imageDatName, hdInDir) return hdInDir } @@ -477,19 +776,102 @@ export class ImageDecryptService { if (!skipResolvedCache) { const cached = this.resolvedCache.get(imageDatName) if (cached && existsSync(cached)) { - if (allowThumbnail || !this.isThumbnailPath(cached)) return cached + if (allowThumbnail || !this.isThumbnailPath(cached)) { + diagnostics && (diagnostics.source = 'resolved_cache') + this.cacheSessionDatRoot(accountDir, sessionId, cached) + return cached + } // 缓存的是缩略图,尝试找高清图 const hdPath = this.findHdVariantInSameDir(cached) - if (hdPath) return hdPath + if (hdPath) { + diagnostics && (diagnostics.source = 'resolved_cache_hd_same_dir') + this.cacheSessionDatRoot(accountDir, sessionId, hdPath) + return hdPath + } // 同目录没找到,尝试在该目录下搜索 const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false) - if (hdInDir) return hdInDir + if (hdInDir) { + diagnostics && (diagnostics.source = 'resolved_cache_hd_dir_scan') + this.cacheSessionDatRoot(accountDir, sessionId, hdInDir) + return hdInDir + } + } + } + + const sessionHashRoot = this.resolveSessionHashDatRoot(accountDir, sessionId) + if (sessionHashRoot) { + const sessionHashPath = this.searchDatInSessionRoot(sessionHashRoot, imageDatName, allowThumbnail) + if (sessionHashPath) { + diagnostics && (diagnostics.source = 'session_hash_root') + this.cacheSessionDatRoot(accountDir, sessionId, sessionHashPath) + this.resolvedCache.set(imageDatName, sessionHashPath) + this.cacheDatPath(accountDir, imageDatName, sessionHashPath) + return sessionHashPath + } + const normalized = this.normalizeDatBase(imageDatName) + if (normalized !== imageDatName.toLowerCase()) { + const normalizedSessionHashPath = this.searchDatInSessionRoot(sessionHashRoot, normalized, allowThumbnail) + if (normalizedSessionHashPath) { + diagnostics && (diagnostics.source = 'session_hash_root_normalized') + this.cacheSessionDatRoot(accountDir, sessionId, normalizedSessionHashPath) + this.resolvedCache.set(imageDatName, normalizedSessionHashPath) + this.cacheDatPath(accountDir, imageDatName, normalizedSessionHashPath) + return normalizedSessionHashPath + } + } + } + + const cachedSessionDir = this.getCachedSessionDatDir(accountDir, sessionId) + if (cachedSessionDir) { + const directSessionPath = this.searchDatInKnownDir(cachedSessionDir, imageDatName, allowThumbnail) + if (directSessionPath) { + diagnostics && (diagnostics.source = 'session_dir_cache') + this.cacheSessionDatRoot(accountDir, sessionId, directSessionPath) + this.resolvedCache.set(imageDatName, directSessionPath) + this.cacheDatPath(accountDir, imageDatName, directSessionPath) + return directSessionPath + } + const normalized = this.normalizeDatBase(imageDatName) + if (normalized !== imageDatName.toLowerCase()) { + const normalizedDirectSessionPath = this.searchDatInKnownDir(cachedSessionDir, normalized, allowThumbnail) + if (normalizedDirectSessionPath) { + diagnostics && (diagnostics.source = 'session_dir_cache_normalized') + this.cacheSessionDatRoot(accountDir, sessionId, normalizedDirectSessionPath) + this.resolvedCache.set(imageDatName, normalizedDirectSessionPath) + this.cacheDatPath(accountDir, imageDatName, normalizedDirectSessionPath) + return normalizedDirectSessionPath + } + } + } + + const cachedSessionRoot = this.getCachedSessionDatRoot(accountDir, sessionId) + if (cachedSessionRoot) { + const sessionPath = this.searchDatInSessionRoot(cachedSessionRoot, imageDatName, allowThumbnail) + if (sessionPath) { + diagnostics && (diagnostics.source = 'session_root_cache') + this.cacheSessionDatRoot(accountDir, sessionId, sessionPath) + this.resolvedCache.set(imageDatName, sessionPath) + this.cacheDatPath(accountDir, imageDatName, sessionPath) + return sessionPath + } + const normalized = this.normalizeDatBase(imageDatName) + if (normalized !== imageDatName.toLowerCase()) { + const normalizedSessionPath = this.searchDatInSessionRoot(cachedSessionRoot, normalized, allowThumbnail) + if (normalizedSessionPath) { + diagnostics && (diagnostics.source = 'session_root_cache_normalized') + this.cacheSessionDatRoot(accountDir, sessionId, normalizedSessionPath) + this.resolvedCache.set(imageDatName, normalizedSessionPath) + this.cacheDatPath(accountDir, imageDatName, normalizedSessionPath) + return normalizedSessionPath + } } } // 只有在 hardlink 完全没有记录时才搜索文件夹 const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail) if (datPath) { + diagnostics && (diagnostics.source = 'search') + this.cacheSessionDatRoot(accountDir, sessionId, datPath) this.resolvedCache.set(imageDatName, datPath) this.cacheDatPath(accountDir, imageDatName, datPath) return datPath @@ -498,6 +880,8 @@ export class ImageDecryptService { if (normalized !== imageDatName.toLowerCase()) { const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail) if (normalizedPath) { + diagnostics && (diagnostics.source = 'search_normalized') + this.cacheSessionDatRoot(accountDir, sessionId, normalizedPath) this.resolvedCache.set(imageDatName, normalizedPath) this.cacheDatPath(accountDir, imageDatName, normalizedPath) return normalizedPath @@ -515,20 +899,19 @@ export class ImageDecryptService { const dir = dirname(thumbPath) const fileName = basename(thumbPath).toLowerCase() - // 提取基础名称(去掉 _t.dat 或 .t.dat) + // Extract base name by stripping _t/_t_W/_t_NW/.t and similar thumbnail suffixes let baseName = fileName - if (baseName.endsWith('_t.dat')) { - baseName = baseName.slice(0, -6) - } else if (baseName.endsWith('.t.dat')) { - baseName = baseName.slice(0, -6) - } else { - return null - } + if (baseName.endsWith('.dat')) baseName = baseName.slice(0, -4) + const thumbMatch = baseName.match(/^(.+?)[_.]t(?:_[a-z]+)?$/i) + if (!thumbMatch) return null + baseName = thumbMatch[1] - // 尝试查找高清图变体 + // Try HD variants including _h_W, _h_NW etc. const variants = [ `${baseName}_h.dat`, `${baseName}.h.dat`, + `${baseName}_h_w.dat`, + `${baseName}_h_nw.dat`, `${baseName}.dat` ] @@ -783,11 +1166,8 @@ export class ImageDecryptService { let baseName = lowerName if (baseName.endsWith('.dat')) { baseName = baseName.slice(0, -4) - if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) { - baseName = baseName.slice(0, -3) - } else if (baseName.endsWith('_thumb')) { - baseName = baseName.slice(0, -6) - } + // Strip variant suffixes like _t, _h, _t_W, _t_NW, _h_W, _hd, _thumb + baseName = baseName.replace(/[_.](?:t|h|hd|thumb)(?:_[a-z]+)?$/, '') } const candidates: string[] = [] @@ -939,8 +1319,7 @@ export class ImageDecryptService { private isThumbnailDat(fileName: string): boolean { const lower = fileName.toLowerCase() return ( - lower.includes('.t.dat') || - lower.includes('_t.dat') || + /[._]t(?:_[a-z]+)?\.dat$/.test(lower) || lower.includes('_thumb.dat') ) } @@ -995,17 +1374,7 @@ export class ImageDecryptService { // 校验缓存文件是否存在且完整,不完整的自动删除 const validateCached = (filePath: string): boolean => { - if (!existsSync(filePath)) return false - try { - const size = statSync(filePath).size - if (size <= 100) { unlinkSync(filePath); return false } - if (!this.isFileTailValid(filePath, size)) { - console.warn(`[ImageDecrypt] 发现不完整缓存图片,已删除: ${filePath} (size=${size})`) - unlinkSync(filePath) - return false - } - return true - } catch { return false } + return this.validateCachedImageFile(filePath) } // 遍历所有可能的缓存根路径 @@ -1154,22 +1523,7 @@ export class ImageDecryptService { // 检查文件是否存在且图片数据完整 for (const candidate of candidates) { - if (existsSync(candidate)) { - try { - const size = statSync(candidate).size - if (size <= 100) { - unlinkSync(candidate) - continue - } - // 快速校验图片末尾完整性(只读最后 64 字节) - if (this.isFileTailValid(candidate, size)) { - return candidate - } - // 图片末尾不完整(半截图),删除后让系统重新解密 - console.warn(`[ImageDecrypt] 发现不完整缓存图片,已删除: ${candidate} (size=${size})`) - unlinkSync(candidate) - } catch { } - } + if (this.validateCachedImageFile(candidate)) return candidate } } } @@ -1181,6 +1535,43 @@ export class ImageDecryptService { * 快速校验缓存图片文件末尾是否完整 * 只读取最后 64 字节进行检查,开销极小 */ + private validateCachedImageFile(filePath: string): boolean { + if (!existsSync(filePath) || !this.isImageFile(filePath)) return false + try { + const size = statSync(filePath).size + if (size <= 100) { + unlinkSync(filePath) + return false + } + if (this.isSuspiciousBlankCachedImage(filePath, size)) { + console.warn(`[ImageDecrypt] 发现疑似 wxgf 白图缓存,已删除并触发重解: ${filePath} (size=${size})`) + unlinkSync(filePath) + return false + } + if (!this.isFileTailValid(filePath, size)) { + console.warn(`[ImageDecrypt] 发现不完整缓存图片,已删除: ${filePath} (size=${size})`) + unlinkSync(filePath) + return false + } + return true + } catch { + return false + } + } + + private isSuspiciousBlankCachedImage(filePath: string, fileSize: number): boolean { + const ext = extname(filePath).toLowerCase() + if (ext !== '.jpg' && ext !== '.jpeg') return false + if (fileSize > 8 * 1024) return false + + try { + const data = readFileSync(filePath) + return this.isProbablyBlankConvertedJpeg(data) + } catch { + return false + } + } + private isFileTailValid(filePath: string, fileSize: number): boolean { try { const ext = filePath.toLowerCase() @@ -1328,6 +1719,118 @@ export class ImageDecryptService { } } + private cacheSessionDatRoot(accountDir: string, sessionId: string | undefined, datPath: string): void { + if (!sessionId || !datPath) return + const dirKey = `${accountDir}|${sessionId}` + const datDir = dirname(datPath) + if (existsSync(datDir)) { + this.sessionDatDirCache.set(dirKey, datDir) + } + const root = this.extractSessionDatRoot(accountDir, datPath) + if (!root || !existsSync(root)) return + this.sessionDatRootCache.set(dirKey, root) + } + + private getCachedSessionDatDir(accountDir: string, sessionId?: string): string | null { + if (!sessionId) return null + const key = `${accountDir}|${sessionId}` + const cached = this.sessionDatDirCache.get(key) + if (cached && existsSync(cached)) return cached + if (cached) this.sessionDatDirCache.delete(key) + return null + } + + private getCachedSessionDatRoot(accountDir: string, sessionId?: string): string | null { + if (!sessionId) return null + const key = `${accountDir}|${sessionId}` + const cached = this.sessionDatRootCache.get(key) + if (cached && existsSync(cached)) return cached + if (cached) this.sessionDatRootCache.delete(key) + return null + } + + private resolveSessionHashDatRoot(accountDir: string, sessionId?: string): string | null { + if (!sessionId) return null + const root = join(accountDir, 'msg', 'attach', crypto.createHash('md5').update(sessionId).digest('hex')) + return existsSync(root) ? root : null + } + + private getLikelyDatFileNames(datName: string, allowThumbnail = true): string[] { + const lower = datName.toLowerCase() + const normalized = this.normalizeDatBase(lower) + const names = [ + lower.endsWith('.dat') ? lower : `${lower}.dat`, + `${normalized}.dat`, + `${normalized}_h.dat`, + `${normalized}.h.dat`, + `${normalized}_hd.dat` + ] + if (allowThumbnail) { + names.push(`${normalized}_t.dat`, `${normalized}.t.dat`, `${normalized}_thumb.dat`) + } + return Array.from(new Set(names.filter(Boolean))) + } + + private searchDatInKnownDir(dirPath: string, datName: string, allowThumbnail = true): string | null { + if (!existsSync(dirPath)) return null + const candidateNames = this.getLikelyDatFileNames(datName, allowThumbnail) + for (const candidateName of candidateNames) { + const candidatePath = join(dirPath, candidateName) + if (!existsSync(candidatePath)) continue + if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue + return candidatePath + } + return null + } + + private searchDatInSessionRoot(sessionRoot: string, datName: string, allowThumbnail = true): string | null { + if (!existsSync(sessionRoot)) return null + + let monthDirs: string[] + try { + monthDirs = readdirSync(sessionRoot, { withFileTypes: true }) + .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)) + .map(d => d.name) + .sort() + .reverse() + .slice(0, 6) + } catch { + return null + } + + const candidateNames = this.getLikelyDatFileNames(datName, allowThumbnail) + const subDirs = ['Img', 'Image', 'mg'] + for (const monthDir of monthDirs) { + for (const subDir of subDirs) { + const imageDir = join(sessionRoot, monthDir, subDir) + const matched = this.searchDatInKnownDir(imageDir, datName, allowThumbnail) + if (matched) return matched + for (const candidateName of candidateNames) { + const candidatePath = join(imageDir, candidateName) + if (!existsSync(candidatePath)) continue + if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue + return candidatePath + } + } + } + return null + } + + private extractSessionDatRoot(accountDir: string, datPath: string): string | null { + const attachRoot = join(accountDir, 'msg', 'attach') + const normalizedAttachRoot = attachRoot.toLowerCase() + const normalizedDatPath = datPath.toLowerCase() + if (!normalizedDatPath.startsWith(normalizedAttachRoot)) { + return dirname(datPath) + } + + const relative = datPath.slice(attachRoot.length).replace(/^[\\/]+/, '') + if (!relative) return dirname(datPath) + const parts = relative.split(/[\\/]+/).filter(Boolean) + if (parts.length === 0) return dirname(datPath) + return join(attachRoot, parts[0]) + } + private clearUpdateFlags(cacheKey: string, imageMd5?: string, imageDatName?: string): void { this.updateFlags.delete(cacheKey) if (imageMd5) this.updateFlags.delete(imageMd5) @@ -1393,6 +1896,39 @@ export class ImageDecryptService { return deleted } + private deleteThumbnailByKeysInDir(keys: string[], dirPath: string): number { + if (keys.length === 0 || !dirPath || !existsSync(dirPath)) return 0 + const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] + const normalizedKeys = Array.from(new Set( + keys + .map(k => this.normalizeDatBase(k.toLowerCase())) + .filter(Boolean) + )) + if (normalizedKeys.length === 0) return 0 + + let deleted = 0 + for (const key of normalizedKeys) { + for (const ext of extensions) { + const candidate = join(dirPath, `${key}_thumb${ext}`) + if (!existsSync(candidate)) continue + try { + unlinkSync(candidate) + deleted++ + } catch { } + } + } + + for (const [cacheKey, resolvedPath] of this.resolvedCache.entries()) { + const lowerKey = this.normalizeDatBase(cacheKey.toLowerCase()) + if (!normalizedKeys.includes(lowerKey)) continue + if (!this.isThumbnailPath(resolvedPath)) continue + if (dirname(resolvedPath) !== dirPath) continue + this.resolvedCache.delete(cacheKey) + } + + return deleted + } + private getCachedDatDir(accountDir: string, imageDatName?: string, imageMd5?: string): string | null { const keys = [ imageDatName ? `${accountDir}|${imageDatName}` : null, @@ -1562,36 +2098,26 @@ export class ImageDecryptService { 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) + private async decryptDatAuto( + datPath: string, + xorKey: number, + aesKey: Buffer | null, + aesKeyText?: string + ): Promise { + const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyText) + if (nativeResult && this.looksLikeNativeImagePayload(nativeResult.data)) { + return { data: nativeResult.data, source: 'native' } } - if (version === 1) { - const key = this.asciiKey16(this.defaultV1AesKey) - return this.decryptDatV4(datPath, xorKey, key) + const fallbackReason = nativeResult ? 'invalid_native_payload' : 'native_unavailable' + if (nativeResult || nativeDecryptEnabled()) { + console.warn(`[ImageDecrypt] Native DAT 解密不可用,回退 TS: ${datPath} reason=${fallbackReason}`) } - // version === 2 - if (!aesKey || aesKey.length !== 16) { - throw new Error('请到设置配置图片解密密钥') - } - return this.decryptDatV4(datPath, xorKey, aesKey) + return this.decryptDatLegacy(datPath, xorKey, aesKey, fallbackReason) } - public decryptDatFile(inputPath: string, xorKey: number, aesKey?: Buffer): Buffer { - const version = this.getDatVersion(inputPath) - if (version === 0) { - return this.decryptDatV3(inputPath, xorKey) - } else if (version === 1) { - const key = this.asciiKey16(this.defaultV1AesKey) - return this.decryptDatV4(inputPath, xorKey, key) - } else { - if (!aesKey || aesKey.length !== 16) { - throw new Error('V4版本需要16字节AES密钥') - } - return this.decryptDatV4(inputPath, xorKey, aesKey) - } + public decryptDatFile(inputPath: string, xorKey: number, aesKey?: Buffer | string): Buffer { + const { buffer, text } = this.normalizeAesKeyInput(aesKey) + return this.decryptDatFileInternal(inputPath, xorKey, buffer, text) } public getDatVersion(inputPath: string): number { @@ -1612,6 +2138,99 @@ export class ImageDecryptService { return 0 } + private decryptDatFileInternal(inputPath: string, xorKey: number, aesKey: Buffer | null, aesKeyText?: string): Buffer { + const outcome = this.tryDecryptDatWithNative(inputPath, xorKey, aesKeyText) + if (outcome && this.looksLikeNativeImagePayload(outcome.data)) { + return outcome.data + } + if (outcome || nativeDecryptEnabled()) { + const reason = outcome ? 'invalid_native_payload' : 'native_unavailable' + console.warn(`[ImageDecrypt] Native 文件解密不可用,回退 TS: ${inputPath} reason=${reason}`) + } + return this.decryptDatLegacy(inputPath, xorKey, aesKey).data + } + + private decryptDatLegacy( + inputPath: string, + xorKey: number, + aesKey: Buffer | null, + fallbackReason?: string + ): DatDecryptOutcome { + const version = this.getDatVersion(inputPath) + if (version === 0) { + return { data: this.decryptDatV3(inputPath, xorKey), source: 'ts', fallbackReason } + } + if (version === 1) { + const key = this.asciiKey16(this.defaultV1AesKey) + return { data: this.decryptDatV4(inputPath, xorKey, key), source: 'ts', fallbackReason } + } + if (!aesKey || aesKey.length !== 16) { + throw new Error('请到设置配置图片解密密钥') + } + return { data: this.decryptDatV4(inputPath, xorKey, aesKey), source: 'ts', fallbackReason } + } + + private normalizeAesKeyInput(aesKey?: Buffer | string): { buffer: Buffer | null; text: string } { + if (typeof aesKey === 'string') { + const text = aesKey.trim() + return { + buffer: text ? this.asciiKey16(text) : null, + text + } + } + return { + buffer: aesKey ?? null, + text: '' + } + } + + private tryDecryptDatWithNative( + inputPath: string, + xorKey: number, + aesKeyText?: string + ): { data: Buffer; ext: string; isWxgf: boolean } | null { + const result = decryptDatViaNative(inputPath, xorKey, aesKeyText || undefined) + if (!this.nativeLogged) { + this.nativeLogged = true + if (process.env.CIPHERTALK_IMAGE_DECRYPT_DEBUG === '1') { + if (result) { + const metadata = nativeAddonMetadata() + console.info('[ImageDecrypt] Native DAT 解密已启用', { + addonPath: nativeAddonLocation(), + source: 'native', + platform: process.platform, + arch: process.arch, + moduleName: metadata?.name || 'unknown', + moduleVersion: metadata?.version || 'unknown', + moduleVendor: metadata?.vendor || 'unknown' + }) + } else { + const metadata = nativeAddonMetadata() + console.info('[ImageDecrypt] Native DAT 解密不可用', { + addonPath: nativeAddonLocation(), + source: 'native_unavailable', + platform: process.platform, + arch: process.arch, + moduleName: metadata?.name || 'unknown', + moduleVersion: metadata?.version || 'unknown', + moduleVendor: metadata?.vendor || 'unknown' + }) + } + } + } + return result + } + + private looksLikeNativeImagePayload(data: Buffer): boolean { + if (!Buffer.isBuffer(data) || data.length < 4) return false + if (data.length >= 20 && + data[0] === 0x77 && data[1] === 0x78 && + data[2] === 0x67 && data[3] === 0x66) { + return true + } + return this.detectImageExtension(data) !== null + } + private decryptDatV3(inputPath: string, xorKey: number): Buffer { const data = readFileSync(inputPath) const out = Buffer.alloc(data.length) @@ -1738,27 +2357,30 @@ export class ImageDecryptService { } } - // 提取 HEVC NALU 裸流 - const hevcData = this.extractHevcNalu(buffer) - // console.log(`[ImageDecrypt] wxgf buffer=${buffer.length} hevcData=${hevcData?.length}`) - - if (!hevcData || hevcData.length < 100) { - console.warn(`[ImageDecrypt] HEVC NALU 提取失败或数据过短: buffer=${buffer.length} hevc=${hevcData?.length ?? 0}`) + // 提取 HEVC NALU 裸流。部分 wxgf 内会有多段 still-image HEVC,首段可能是白色占位帧。 + const hevcStreams = this.extractHevcNaluStreams(buffer) + if (hevcStreams.length === 0) { + console.warn(`[ImageDecrypt] HEVC NALU 提取失败或数据过短: buffer=${buffer.length}`) return { data: buffer, isWxgf: true } } - // 尝试用 ffmpeg 转换 - try { + let fallbackJpg: Buffer | null = null + for (const hevcData of hevcStreams) { const jpgData = await this.convertHevcToJpg(hevcData) if (jpgData && jpgData.length > 0) { - return { data: jpgData, isWxgf: false } + if (!this.isProbablyBlankConvertedJpeg(jpgData)) { + return { data: jpgData, isWxgf: false } + } + fallbackJpg ||= jpgData } - } catch (e) { - console.error('[ImageDecrypt] unwrapWxgf 转换过程异常:', e) + } + + if (fallbackJpg) { + return { data: fallbackJpg, isWxgf: false } } // ffmpeg 失败,返回原始 HEVC 数据 - return { data: hevcData, isWxgf: true } + return { data: hevcStreams[0], isWxgf: true } } /** @@ -1775,7 +2397,28 @@ export class ImageDecryptService { * - PPS (34): 图像参数集 * - IDR (19/20): 关键帧 */ - private extractHevcNalu(buffer: Buffer): Buffer | null { + private extractHevcNaluStreams(buffer: Buffer): Buffer[] { + const nalUnits = this.extractHevcNaluUnits(buffer) + if (nalUnits.length === 0) return [] + + const groups: Buffer[][] = [] + let current: Buffer[] = [] + for (const unit of nalUnits) { + const unitType = this.getHevcNalType(unit) + if (unitType === 32 && current.length > 0) { + groups.push(current) + current = [] + } + current.push(unit) + } + if (current.length > 0) groups.push(current) + + return groups + .map(group => this.mergeHevcNaluUnits(group)) + .filter(stream => stream.length >= 100) + } + + private extractHevcNaluUnits(buffer: Buffer): Buffer[] { const nalUnits: Buffer[] = [] let i = 4 // 跳过 "wxgf" 头 @@ -1840,14 +2483,69 @@ export class ImageDecryptService { 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 [buffer.subarray(j + 4)] } } - return null + return [] } - // 合并所有 NAL 单元 - return Buffer.concat(nalUnits) + return nalUnits + } + + private mergeHevcNaluUnits(nalUnits: Buffer[]): Buffer { + const chunks: Buffer[] = [] + for (const unit of nalUnits) { + chunks.push(Buffer.from([0x00, 0x00, 0x00, 0x01]), unit) + } + return Buffer.concat(chunks) + } + + private getHevcNalType(unit: Buffer): number | null { + if (!unit.length) return null + return (unit[0] >> 1) & 0x3f + } + + private isProbablyBlankConvertedJpeg(data: Buffer): boolean { + const dimensions = this.getJpegDimensions(data) + if (!dimensions) return false + const pixels = dimensions.width * dimensions.height + if (pixels < 50_000) return false + return data.length * 100 < pixels * 4 + } + + private getJpegDimensions(data: Buffer): { width: number; height: number } | null { + if (data.length < 12 || data[0] !== 0xff || data[1] !== 0xd8) return null + + let offset = 2 + while (offset + 9 < data.length) { + if (data[offset] !== 0xff) { + offset += 1 + continue + } + while (offset < data.length && data[offset] === 0xff) offset += 1 + if (offset >= data.length) return null + + const marker = data[offset] + offset += 1 + if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { + continue + } + if (offset + 2 > data.length) return null + + const segmentLength = data.readUInt16BE(offset) + if (segmentLength < 2 || offset + segmentLength > data.length) return null + + const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc + if (isSof && segmentLength >= 7) { + const height = data.readUInt16BE(offset + 3) + const width = data.readUInt16BE(offset + 5) + return width > 0 && height > 0 ? { width, height } : null + } + + offset += segmentLength + } + + return null } /** @@ -2207,21 +2905,9 @@ 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) - } + async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer | string): Promise { + const { buffer, text } = this.normalizeAesKeyInput(aesKey) + const decrypted = this.decryptDatFileInternal(inputPath, xorKey, buffer, text) const outputDir = dirname(outputPath) if (!existsSync(outputDir)) { diff --git a/electron/services/nativeImageDecrypt.ts b/electron/services/nativeImageDecrypt.ts new file mode 100644 index 0000000..93dd693 --- /dev/null +++ b/electron/services/nativeImageDecrypt.ts @@ -0,0 +1,155 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' + +const CURRENT_ADDON_NAME = 'ciphertalk-image-native' + +type NativeDecryptResult = { + data: Buffer + ext: string + isWxgf?: boolean + is_wxgf?: boolean +} + +type NativeAddon = { + decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult +} + +type NativeAddonMetadata = { + name?: string + version?: string + vendor?: string + source?: string + platforms?: string[] +} + +let cachedAddon: NativeAddon | null | undefined +let cachedMetadata: NativeAddonMetadata | null | undefined + +function shouldEnableNative(): boolean { + return process.env.CIPHERTALK_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 fileName = `${CURRENT_ADDON_NAME}-${platformDir}-${archDir}.node` + const roots = [ + join(cwd, 'resources', 'wedecrypt'), + ...(process.resourcesPath + ? [ + join(process.resourcesPath, 'resources', 'wedecrypt'), + join(process.resourcesPath, 'wedecrypt') + ] + : []) + ] + const candidates = roots.map((root) => join(root, fileName)) + 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 +} + +function getMetadataCandidates(): string[] { + const cwd = process.cwd() + const candidates = [ + join(cwd, 'resources', 'wedecrypt', 'manifest.json'), + ...(process.resourcesPath + ? [ + join(process.resourcesPath, 'resources', 'wedecrypt', 'manifest.json'), + join(process.resourcesPath, 'wedecrypt', 'manifest.json') + ] + : []) + ] + return Array.from(new Set(candidates.flatMap(expandAsarCandidates))) +} + +export function nativeAddonMetadata(): NativeAddonMetadata | null { + if (cachedMetadata !== undefined) return cachedMetadata + + for (const candidate of getMetadataCandidates()) { + if (!existsSync(candidate)) continue + try { + const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as NativeAddonMetadata + cachedMetadata = parsed + return parsed + } catch { + // try next candidate + } + } + + cachedMetadata = null + return null +} + +export function nativeAddonLocation(): string | null { + for (const candidate of getAddonCandidates()) { + if (existsSync(candidate)) return candidate + } + return null +} + +export function nativeDecryptEnabled(): boolean { + return shouldEnableNative() +} + +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 71d65d7..0a27bc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ciphertalk", - "version": "4.1.8", + "version": "4.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ciphertalk", - "version": "4.1.8", + "version": "4.2.0", "hasInstallScript": true, "license": "CC-BY-NC-SA-4.0", "dependencies": { diff --git a/package.json b/package.json index 6931d8a..48e2667 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ciphertalk", - "version": "4.1.9", + "version": "4.2.0", "description": "密语 - 微信聊天记录查看工具", "author": "ILoveBingLu", "license": "CC-BY-NC-SA-4.0", @@ -10,6 +10,11 @@ "icon:mac": "bash scripts/build-macos-icon.sh", "native:macos": "bash native-dlls/build-macos.sh", "native:macos:check": "node scripts/check-macos-native.js", + "native:image:check": "cargo check --manifest-path native/image-decrypt/Cargo.toml", + "native:image:build": "cargo build --manifest-path native/image-decrypt/Cargo.toml --release", + "native:image:check:current": "node scripts/check-image-native.cjs", + "native:image:sync": "node scripts/sync-image-native.cjs", + "native:image:build:sync": "npm run native:image:build && npm run native:image:sync", "build:prepare:mac": "node scripts/patch-dmg-builder.cjs", "build:prepare": "node scripts/update-readme-version.js && node scripts/prepare-release-announcement.js", "prebuild": "node scripts/update-readme-version.js && node scripts/prepare-release-announcement.js", @@ -214,6 +219,8 @@ "node_modules/sherpa-onnx-node/**/*", "node_modules/koffi/**/*", "dist-electron/workers/**/*", + "resources/wedecrypt/*.node", + "resources/wedecrypt/**/*.node", "resources/**/*" ] } diff --git a/resources/wedecrypt/ciphertalk-image-native-macos-arm64.node b/resources/wedecrypt/ciphertalk-image-native-macos-arm64.node new file mode 100644 index 0000000..313539f Binary files /dev/null and b/resources/wedecrypt/ciphertalk-image-native-macos-arm64.node differ diff --git a/resources/wedecrypt/ciphertalk-image-native-win32-x64.node b/resources/wedecrypt/ciphertalk-image-native-win32-x64.node new file mode 100644 index 0000000..cd7bdc2 Binary files /dev/null and b/resources/wedecrypt/ciphertalk-image-native-win32-x64.node differ diff --git a/resources/wedecrypt/manifest.json b/resources/wedecrypt/manifest.json new file mode 100644 index 0000000..b48ce3b --- /dev/null +++ b/resources/wedecrypt/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "ciphertalk-image-native", + "version": "source-present-selfbuilt", + "vendor": "CipherTalk", + "source": "native/image-decrypt", + "activeBinaries": { + "win32-x64": "self-built-from-repo-source", + "macos-arm64": "self-built-from-repo-source" + }, + "platforms": [ + "win32-x64", + "macos-arm64" + ] +} diff --git a/scripts/check-image-native.cjs b/scripts/check-image-native.cjs new file mode 100644 index 0000000..f04f956 --- /dev/null +++ b/scripts/check-image-native.cjs @@ -0,0 +1,43 @@ +const fs = require('node:fs') +const path = require('node:path') + +const rootDir = path.resolve(__dirname, '..') +const baseDir = path.join(rootDir, 'resources', 'wedecrypt') +const addonName = 'ciphertalk-image-native' + +function resolvePlatformDir(value = process.platform) { + if (value === 'win32') return 'win32' + if (value === 'darwin' || value === 'macos') return 'macos' + if (value === 'linux') return 'linux' + throw new Error(`Unsupported platform: ${value}`) +} + +function resolveArchDir(value = process.arch) { + if (value === 'x64') return 'x64' + if (value === 'arm64') return 'arm64' + throw new Error(`Unsupported arch: ${value}`) +} + +function main() { + const platformDir = resolvePlatformDir(process.env.CIPHERTALK_IMAGE_NATIVE_PLATFORM || process.platform) + const archDir = resolveArchDir(process.env.CIPHERTALK_IMAGE_NATIVE_ARCH || process.arch) + const addonPath = path.join(baseDir, `${addonName}-${platformDir}-${archDir}.node`) + + console.log(`[image-native-check] target: ${platformDir}/${archDir}`) + console.log(`[image-native-check] addon path: ${addonPath}`) + + if (!fs.existsSync(addonPath)) { + console.error('[image-native-check] missing image native addon') + process.exit(2) + } + + const stat = fs.statSync(addonPath) + if (!stat.isFile() || stat.size <= 0) { + console.error('[image-native-check] invalid image native addon') + process.exit(3) + } + + console.log(`[image-native-check] ok (${stat.size} bytes)`) +} + +main() diff --git a/scripts/check-macos-native.js b/scripts/check-macos-native.js index ebb98b3..8e80cf4 100644 --- a/scripts/check-macos-native.js +++ b/scripts/check-macos-native.js @@ -3,6 +3,7 @@ const path = require('path') const rootDir = path.resolve(__dirname, '..') const macosDir = path.join(rootDir, 'resources', 'macos') +const imageNativeBaseDir = path.join(rootDir, 'resources', 'wedecrypt') const requiredArtifacts = [ { name: 'libwx_key.dylib', type: 'file', generated: true }, @@ -65,6 +66,20 @@ function main() { process.exit(2) } + const imageNativeArch = process.env.CIPHERTALK_IMAGE_NATIVE_ARCH || process.arch + const imageNativeAddon = path.join( + imageNativeBaseDir, + `ciphertalk-image-native-macos-${imageNativeArch}.node` + ) + + const imageNativeStat = statSafe(imageNativeAddon) + if (!imageNativeStat || !imageNativeStat.isFile()) { + console.error(`[macos-native-check] missing image native addon: ${imageNativeAddon}`) + process.exit(3) + } + + console.log(`[macos-native-check] image native addon ok: ${imageNativeAddon} (${imageNativeStat.size} bytes)`) + console.log('[macos-native-check] all required macOS native artifacts are present') } diff --git a/scripts/clean-locales.js b/scripts/clean-locales.js index 26f7d07..feb3a85 100644 --- a/scripts/clean-locales.js +++ b/scripts/clean-locales.js @@ -1,12 +1,92 @@ const fs = require('fs'); const path = require('path'); +const { Arch } = require('electron-builder'); + +const IMAGE_NATIVE_PREFIX = 'ciphertalk-image-native-'; +const IMAGE_NATIVE_SUFFIX = '.node'; + +function resolveNativePlatform(electronPlatformName) { + if (electronPlatformName === 'darwin') return 'macos'; + if (electronPlatformName === 'win32') return 'win32'; + if (electronPlatformName === 'linux') return 'linux'; + return electronPlatformName; +} + +function resolveNativeArch(arch) { + if (typeof arch === 'string') return arch; + if (typeof arch === 'number' && Arch[arch]) return Arch[arch]; + return process.arch; +} + +function uniqueExistingDirs(candidates) { + return Array.from(new Set(candidates)).filter((targetPath) => fs.existsSync(targetPath)); +} + +function rewriteNativeManifest(manifestPath, targetKey) { + if (!fs.existsSync(manifestPath)) return; + + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const nextActiveBinaries = {}; + if (manifest.activeBinaries && manifest.activeBinaries[targetKey]) { + nextActiveBinaries[targetKey] = manifest.activeBinaries[targetKey]; + } + manifest.activeBinaries = nextActiveBinaries; + manifest.platforms = Object.keys(nextActiveBinaries); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + console.log(`已收敛 image native manifest 到当前平台: ${targetKey}`); + } catch (error) { + console.warn(`收敛 image native manifest 失败: ${manifestPath}`, error); + } +} + +function pruneImageNativeAddons(context) { + const platformDir = resolveNativePlatform(context.electronPlatformName); + const archDir = resolveNativeArch(context.arch); + const targetFileName = `${IMAGE_NATIVE_PREFIX}${platformDir}-${archDir}${IMAGE_NATIVE_SUFFIX}`; + const targetKey = `${platformDir}-${archDir}`; + const productName = context.packager?.appInfo?.productFilename || 'CipherTalk'; + const resourceRoots = uniqueExistingDirs([ + path.join(context.appOutDir, 'resources'), + path.join(context.appOutDir, 'Contents', 'Resources'), + path.join(context.appOutDir, `${productName}.app`, 'Contents', 'Resources') + ]); + + for (const resourceRoot of resourceRoots) { + for (const nativeDir of [ + path.join(resourceRoot, 'resources', 'wedecrypt'), + path.join(resourceRoot, 'wedecrypt') + ]) { + if (!fs.existsSync(nativeDir)) continue; + + const nativeFiles = fs.readdirSync(nativeDir) + .filter((file) => file.startsWith(IMAGE_NATIVE_PREFIX) && file.endsWith(IMAGE_NATIVE_SUFFIX)); + if (nativeFiles.length === 0) continue; + + if (!nativeFiles.includes(targetFileName)) { + console.warn(`未找到当前平台 image native addon,跳过裁剪: ${targetFileName}`); + continue; + } + + let deletedCount = 0; + for (const file of nativeFiles) { + if (file === targetFileName) continue; + fs.rmSync(path.join(nativeDir, file), { force: true }); + deletedCount++; + } + + rewriteNativeManifest(path.join(nativeDir, 'manifest.json'), targetKey); + console.log(`已裁剪 image native addon,仅保留 ${targetFileName},删除 ${deletedCount} 个无关文件。`); + } + } +} exports.default = async function (context) { // context.appOutDir 是打包后的临时解压目录 const localesDir = path.join(context.appOutDir, 'locales'); if (fs.existsSync(localesDir)) { - console.log('🧹 正在清理多余的 Chromium 语言包...'); + console.log('正在清理多余的 Chromium 语言包...'); const files = fs.readdirSync(localesDir); // 只保留中文(简体/繁体)和英文 @@ -22,9 +102,11 @@ exports.default = async function (context) { deletedCount++; } } - console.log(`✅ 已删除 ${deletedCount} 个无关语言包,仅保留中英文。`); + console.log(`已删除 ${deletedCount} 个无关语言包,仅保留中英文。`); } + pruneImageNativeAddons(context); + if (context.electronPlatformName === 'darwin') { const productName = context.packager?.appInfo?.productFilename || 'CipherTalk'; const launcherCandidates = [ @@ -35,7 +117,7 @@ exports.default = async function (context) { for (const launcherPath of launcherCandidates) { if (!fs.existsSync(launcherPath)) continue; fs.chmodSync(launcherPath, 0o755); - console.log(`✅ 已确保 macOS MCP 启动器可执行: ${launcherPath}`); + console.log(`已确保 macOS MCP 启动器可执行: ${launcherPath}`); break; } } diff --git a/scripts/sync-image-native.cjs b/scripts/sync-image-native.cjs new file mode 100644 index 0000000..bc4aa1f --- /dev/null +++ b/scripts/sync-image-native.cjs @@ -0,0 +1,126 @@ +const fs = require('node:fs') +const path = require('node:path') + +const projectRoot = path.resolve(__dirname, '..') +const crateRoot = path.join(projectRoot, 'native', 'image-decrypt') +const releaseDir = path.join(crateRoot, 'target', 'release') +const addonName = 'ciphertalk-image-native' + +function parseArgs(argv) { + const parsed = {} + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (!arg.startsWith('--')) continue + const key = arg.slice(2) + const next = argv[i + 1] + if (!next || next.startsWith('--')) { + parsed[key] = '1' + continue + } + parsed[key] = next + i += 1 + } + return parsed +} + +function resolvePlatformDir(value = process.platform) { + if (value === 'win32') return 'win32' + if (value === 'darwin' || value === 'macos') return 'macos' + if (value === 'linux') return 'linux' + throw new Error(`Unsupported platform: ${value}`) +} + +function resolveArchDir(value = process.arch) { + if (value === 'x64') return 'x64' + if (value === 'arm64') return 'arm64' + throw new Error(`Unsupported arch: ${value}`) +} + +function resolveBuiltLibrary(platformDir, customLibPath) { + if (customLibPath) { + return path.resolve(projectRoot, customLibPath) + } + if (platformDir === 'win32') { + return path.join(releaseDir, 'ciphertalk_image_native.dll') + } + if (platformDir === 'macos') { + return path.join(releaseDir, 'libciphertalk_image_native.dylib') + } + if (platformDir === 'linux') { + return path.join(releaseDir, 'libciphertalk_image_native.so') + } + throw new Error(`Unsupported platform: ${platformDir}`) +} + +function removeLegacyOutput(platformDir, archDir, outputName) { + const legacyDir = path.join(projectRoot, 'resources', 'wedecrypt', platformDir, archDir) + const legacyPath = path.join(legacyDir, outputName) + if (fs.existsSync(legacyPath)) { + fs.rmSync(legacyPath, { force: true }) + } + if (fs.existsSync(legacyDir) && fs.readdirSync(legacyDir).length === 0) { + fs.rmSync(legacyDir, { recursive: true, force: true }) + } + const platformDirPath = path.join(projectRoot, 'resources', 'wedecrypt', platformDir) + if (fs.existsSync(platformDirPath) && fs.readdirSync(platformDirPath).length === 0) { + fs.rmSync(platformDirPath, { recursive: true, force: true }) + } +} + +function buildManifest() { + const baseDir = path.join(projectRoot, 'resources', 'wedecrypt') + const matrix = [ + ['win32', 'x64'], + ['win32', 'arm64'], + ['macos', 'x64'], + ['macos', 'arm64'], + ['linux', 'x64'], + ['linux', 'arm64'] + ] + + const activeBinaries = {} + const platforms = [] + for (const [platformDir, archDir] of matrix) { + const filePath = path.join(baseDir, `${addonName}-${platformDir}-${archDir}.node`) + if (!fs.existsSync(filePath)) continue + const key = `${platformDir}-${archDir}` + activeBinaries[key] = 'self-built-from-repo-source' + platforms.push(key) + } + + const manifest = { + name: addonName, + version: 'source-present-selfbuilt', + vendor: 'CipherTalk', + source: 'native/image-decrypt', + activeBinaries, + platforms + } + + fs.mkdirSync(baseDir, { recursive: true }) + fs.writeFileSync(path.join(baseDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`) +} + +function main() { + const args = parseArgs(process.argv.slice(2)) + const platformDir = resolvePlatformDir(args.platform || process.env.CIPHERTALK_IMAGE_NATIVE_PLATFORM || process.platform) + const archDir = resolveArchDir(args.arch || process.env.CIPHERTALK_IMAGE_NATIVE_ARCH || process.arch) + const builtLibrary = resolveBuiltLibrary(platformDir, args.lib || process.env.CIPHERTALK_IMAGE_NATIVE_LIB) + + if (!fs.existsSync(builtLibrary)) { + throw new Error(`Built library not found: ${builtLibrary}`) + } + + const outputDir = path.join(projectRoot, 'resources', 'wedecrypt') + const outputName = `${addonName}-${platformDir}-${archDir}.node` + const outputPath = path.join(outputDir, outputName) + + fs.mkdirSync(outputDir, { recursive: true }) + fs.copyFileSync(builtLibrary, outputPath) + removeLegacyOutput(platformDir, archDir, outputName) + buildManifest() + + console.log(`[sync-image-native] synced ${builtLibrary} -> ${outputPath}`) +} + +main() diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index ee4bced..29bf431 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -270,6 +270,10 @@ function ChatPage(_props: ChatPageProps) { const searchInputRef = useRef(null) const sidebarRef = useRef(null) const messagesRef = useRef([]) + const isLoadingMoreRef = useRef(false) + const lastScrollTopRef = useRef(0) + const scrollToBottomAfterRenderRef = useRef(false) + const scrollRestoreTimersRef = useRef([]) const currentSessionIdRef = useRef(null) const lastUpdateTimeRef = useRef(0) const updateTimerRef = useRef(null) @@ -354,6 +358,87 @@ function ChatPage(_props: ChatPageProps) { setTimeout(() => setTopToast(null), 2000) }, []) + useEffect(() => { + isLoadingMoreRef.current = isLoadingMore + }, [isLoadingMore]) + + const getMessageDomKey = useCallback((message: Message): string => { + return [ + message.serverId ?? '', + message.localId ?? '', + message.createTime ?? '', + message.sortSeq ?? '' + ].join('-') + }, []) + + const findMessageWrapperByKey = useCallback((listEl: HTMLElement, key: string): HTMLElement | null => { + const wrappers = Array.from(listEl.querySelectorAll('.message-wrapper[data-message-key]')) + return wrappers.find(el => el.dataset.messageKey === key) || null + }, []) + + const captureScrollAnchor = useCallback((): { key: string; top: number } | null => { + const listEl = messageListRef.current + if (!listEl) return null + + const listRect = listEl.getBoundingClientRect() + const wrappers = Array.from(listEl.querySelectorAll('.message-wrapper[data-message-key]')) + const anchorEl = wrappers.find((el) => { + const rect = el.getBoundingClientRect() + return rect.bottom >= listRect.top + 12 + }) + + const key = anchorEl?.dataset.messageKey + if (!anchorEl || !key) return null + + return { + key, + top: anchorEl.getBoundingClientRect().top - listRect.top + } + }, []) + + const clearScrollRestoreTimers = useCallback(() => { + for (const timer of scrollRestoreTimersRef.current) { + window.clearTimeout(timer) + } + scrollRestoreTimersRef.current = [] + }, []) + + const restoreScrollAnchor = useCallback((anchor: { key: string; top: number } | null) => { + if (!anchor) return + + clearScrollRestoreTimers() + + const restore = () => { + const listEl = messageListRef.current + if (!listEl) return + const anchorEl = findMessageWrapperByKey(listEl, anchor.key) + if (!anchorEl) return + + const listTop = listEl.getBoundingClientRect().top + const currentTop = anchorEl.getBoundingClientRect().top - listTop + const delta = currentTop - anchor.top + if (Math.abs(delta) > 1) { + listEl.scrollTop += delta + } + } + + requestAnimationFrame(() => { + restore() + scrollRestoreTimersRef.current = [ + window.setTimeout(() => { + restore() + isLoadingMoreRef.current = false + }, 150) + ] + }) + }, [clearScrollRestoreTimers, findMessageWrapperByKey]) + + useEffect(() => { + return () => { + clearScrollRestoreTimers() + } + }, [clearScrollRestoreTimers]) + const copyText = useCallback(async (text: string) => { try { await navigator.clipboard.writeText(text || '') @@ -568,11 +653,12 @@ function ChatPage(_props: ChatPageProps) { // 标记用户正在操作(首次加载) isUserOperatingRef.current = true } else { + if (isLoadingMoreRef.current) return + isLoadingMoreRef.current = true setLoadingMore(true) } - // 记录加载前的第一条消息元素 - const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null + const anchor = offset > 0 ? captureScrollAnchor() : null try { // 确保连接已建立(如果未连接,先连接) @@ -590,18 +676,10 @@ function ChatPage(_props: ChatPageProps) { if (result.success && result.messages) { if (offset === 0) { setMessages(result.messages) - // 首次加载滚动到底部 (瞬间) - requestAnimationFrame(() => { - scrollToBottom(false) - }) + scrollToBottomAfterRenderRef.current = true } else { appendMessages(result.messages, true) - // 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置 - if (firstMsgEl && listEl) { - requestAnimationFrame(() => { - listEl.scrollTop = firstMsgEl.offsetTop - 80 - }) - } + restoreScrollAnchor(anchor) } setHasMoreMessages(result.hasMore ?? false) setCurrentOffset(offset + result.messages.length) @@ -611,6 +689,9 @@ function ChatPage(_props: ChatPageProps) { } finally { setLoadingMessages(false) setLoadingMore(false) + if (offset > 0) { + isLoadingMoreRef.current = false + } // 加载完成后,延迟重置用户操作标记(给一点缓冲时间) if (offset === 0) { setTimeout(() => { @@ -714,11 +795,11 @@ function ChatPage(_props: ChatPageProps) { // 滚动加载更多 + 显示/隐藏回到底部按钮 const loadMoreMessagesInDateJumpMode = useCallback(async () => { - if (!currentSessionId || dateJumpCursorSortSeq === null || isLoadingMore || !hasMoreMessages) return + if (!currentSessionId || dateJumpCursorSortSeq === null || isLoadingMoreRef.current || !hasMoreMessages) return - const listEl = messageListRef.current - const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null + const anchor = captureScrollAnchor() + isLoadingMoreRef.current = true setLoadingMore(true) try { const result = await window.electronAPI.chat.getMessagesBefore( @@ -756,11 +837,7 @@ function ChatPage(_props: ChatPageProps) { setHasMoreMessages(result.hasMore ?? false) } - if (firstMsgEl && listEl) { - requestAnimationFrame(() => { - listEl.scrollTop = firstMsgEl.offsetTop - 80 - }) - } + restoreScrollAnchor(anchor) } else { setHasMoreMessages(false) } @@ -768,22 +845,24 @@ function ChatPage(_props: ChatPageProps) { console.error('日期跳转模式加载更多失败:', e) } finally { setLoadingMore(false) + isLoadingMoreRef.current = false } }, [ currentSessionId, dateJumpCursorSortSeq, dateJumpCursorCreateTime, dateJumpCursorLocalId, - isLoadingMore, hasMoreMessages, appendMessages, + captureScrollAnchor, + restoreScrollAnchor, setHasMoreMessages, setLoadingMore ]) // 日期跳转模式:向下滑动加载更新的消息 const loadMoreMessagesAfterInDateJumpMode = useCallback(async () => { - if (!currentSessionId || dateJumpCursorSortSeqEnd === null || isLoadingMore || !hasMoreMessagesAfter) return + if (!currentSessionId || dateJumpCursorSortSeqEnd === null || isLoadingMoreRef.current || !hasMoreMessagesAfter) return const listEl = messageListRef.current if (!listEl) return @@ -792,6 +871,7 @@ function ChatPage(_props: ChatPageProps) { const oldScrollHeight = listEl.scrollHeight const oldScrollTop = listEl.scrollTop + isLoadingMoreRef.current = true setLoadingMore(true) try { const result = await window.electronAPI.chat.getMessagesAfter( @@ -845,13 +925,13 @@ function ChatPage(_props: ChatPageProps) { console.error('日期跳转模式向下加载失败:', e) } finally { setLoadingMore(false) + isLoadingMoreRef.current = false } }, [ currentSessionId, dateJumpCursorSortSeqEnd, dateJumpCursorCreateTimeEnd, dateJumpCursorLocalIdEnd, - isLoadingMore, hasMoreMessagesAfter, appendMessages ]) @@ -860,17 +940,19 @@ function ChatPage(_props: ChatPageProps) { if (!messageListRef.current) return const { scrollTop, clientHeight, scrollHeight } = messageListRef.current + const isScrollingUp = scrollTop < lastScrollTopRef.current - 4 + lastScrollTopRef.current = scrollTop // 显示回到底部按钮:距离底部超过 300px const distanceFromBottom = scrollHeight - scrollTop - clientHeight setShowScrollToBottom(distanceFromBottom > 300) - if (!isLoadingMore && currentSessionId) { - const topThreshold = clientHeight * 0.3 + if (!isLoadingMoreRef.current && currentSessionId) { + const topThreshold = Math.max(clientHeight * 2, 1200) const bottomThreshold = clientHeight * 0.3 - // 向上滑动:加载更早的消息 - if (scrollTop < topThreshold && hasMoreMessages) { + // 向上滑动:提前加载更早的消息,不等用户真正顶到顶部 + if (isScrollingUp && scrollTop < topThreshold && hasMoreMessages) { if (isDateJumpMode) { loadMoreMessagesInDateJumpMode() } else { @@ -884,7 +966,6 @@ function ChatPage(_props: ChatPageProps) { } } }, [ - isLoadingMore, hasMoreMessages, hasMoreMessagesAfter, currentSessionId, @@ -897,20 +978,27 @@ function ChatPage(_props: ChatPageProps) { // 滚动到底部 const scrollToBottom = useCallback((smooth: boolean | React.MouseEvent = true) => { if (messageListRef.current) { - // 如果传入的是事件对象,默认为 smooth const isSmooth = typeof smooth === 'boolean' ? smooth : true; - if (isSmooth) { - messageListRef.current.scrollTo({ - top: messageListRef.current.scrollHeight, - behavior: 'smooth' - }) + messageListRef.current.scrollTo({ top: messageListRef.current.scrollHeight, behavior: 'smooth' }) } else { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } } }, []) + // Scroll to bottom after initial message render + useEffect(() => { + if (scrollToBottomAfterRenderRef.current) { + scrollToBottomAfterRenderRef.current = false + requestAnimationFrame(() => { + if (messageListRef.current) { + messageListRef.current.scrollTop = messageListRef.current.scrollHeight + } + }) + } + }, [messages]) + // 日期跳转处理 const handleJumpToDate = useCallback(async () => { if (!selectedDate || !currentSessionId || isJumpingToDate) return @@ -1963,8 +2051,14 @@ function ChatPage(_props: ChatPageProps) { // 系统消息居中显示 const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') + const messageDomKey = getMessageDomKey(msg) + return ( -
+
{showDateDivider && (
{formatDateDivider(msg.createTime)} @@ -2993,6 +3087,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h useEffect(() => { if (!isImage || !imageContainerRef.current) return + const scrollRoot = imageContainerRef.current.closest('.message-list') as HTMLElement | null const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { @@ -3003,7 +3098,8 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h }) }, { - rootMargin: '1200px 0px', // 提前加载,减少滚动到位后的等待 + root: scrollRoot, + rootMargin: '2800px 0px', // 提前约数屏预热,向上滚动时先解密上方图片 threshold: 0 } )