diff --git a/.gitignore b/.gitignore index 4e55091..8601fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ Thumbs.db *.aps wcdb/ +xkey/ *info 概述.md chatlab-format.md diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index da878bc..a3269f4 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1783,13 +1783,19 @@ class ChatService { if (!content) return undefined try { - // 提取 md5,这是用于查询 hardlink.db 的值 - const md5 = - this.extractXmlAttribute(content, 'videomsg', 'md5') || - this.extractXmlValue(content, 'md5') || - undefined + // 优先取 md5 属性(收到的视频) + const md5 = this.extractXmlAttribute(content, 'videomsg', 'md5') + if (md5) return md5.toLowerCase() - return md5?.toLowerCase() + // 自己发的视频没有 md5,只有 rawmd5 + const rawMd5 = this.extractXmlAttribute(content, 'videomsg', 'rawmd5') + if (rawMd5) return rawMd5.toLowerCase() + + // 兜底: 标签 + const tagMd5 = this.extractXmlValue(content, 'md5') + if (tagMd5) return tagMd5.toLowerCase() + + return undefined } catch { return undefined } diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 15cbad7..73601ca 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow } from 'electron' import { basename, dirname, extname, join } from 'path' import { pathToFileURL } from 'url' import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs' @@ -11,16 +11,7 @@ import { wcdbService } from './wcdbService' // 获取 ffmpeg-static 的路径 function getStaticFfmpegPath(): string | null { try { - // 优先处理打包后的路径 - if (app.isPackaged) { - const resourcesPath = process.resourcesPath - const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') - if (existsSync(packedPath)) { - return packedPath - } - } - - // 方法1: 直接 require ffmpeg-static(开发环境) + // 方法1: 直接 require ffmpeg-static // eslint-disable-next-line @typescript-eslint/no-var-requires const ffmpegStatic = require('ffmpeg-static') @@ -28,12 +19,21 @@ function getStaticFfmpegPath(): string | null { return ffmpegStatic } - // 方法2: 手动构建路径(开发环境备用) + // 方法2: 手动构建路径(开发环境) const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') if (existsSync(devPath)) { return devPath } + // 方法3: 打包后的路径 + if (app.isPackaged) { + const resourcesPath = process.resourcesPath + const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') + if (existsSync(packedPath)) { + return packedPath + } + } + return null } catch { return null @@ -45,7 +45,6 @@ type DecryptResult = { localPath?: string error?: string isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) - liveVideoPath?: string // 实况照片的视频路径 } type HardlinkState = { @@ -62,7 +61,6 @@ export class ImageDecryptService { private cacheIndexed = false private cacheIndexing: Promise | null = null private updateFlags = new Map() - private noLiveSet = new Set() // 已确认无 live 视频的图片路径 private logInfo(message: string, meta?: Record): void { if (!this.configService.get('logEnabled')) return @@ -118,9 +116,8 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached) this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached)) - return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate, liveVideoPath } + return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(key) @@ -139,9 +136,8 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing) this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing)) - return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate, liveVideoPath } + return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } } } this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) @@ -155,25 +151,13 @@ export class ImageDecryptService { return { success: false, error: '缺少图片标识' } } - if (payload.force) { - const hdCached = this.findCachedOutput(cacheKey, true, payload.sessionId) - if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached) && !this.isThumbnailPath(hdCached)) { - const dataUrl = this.fileToDataUrl(hdCached) - const localPath = dataUrl || this.filePathToUrl(hdCached) - const liveVideoPath = this.checkLiveVideoCache(hdCached) - this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, isThumb: false, liveVideoPath } - } - } - if (!payload.force) { const cached = this.resolvedCache.get(cacheKey) if (cached && existsSync(cached) && this.isImageFile(cached)) { const dataUrl = this.fileToDataUrl(cached) const localPath = dataUrl || this.filePathToUrl(cached) - const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached) this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, liveVideoPath } + return { success: true, localPath } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(cacheKey) @@ -251,9 +235,8 @@ export class ImageDecryptService { const dataUrl = this.fileToDataUrl(existing) const localPath = dataUrl || this.filePathToUrl(existing) const isThumb = this.isThumbnailPath(existing) - const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing) this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, isThumb, liveVideoPath } + return { success: true, localPath, isThumb } } } @@ -297,7 +280,6 @@ export class ImageDecryptService { await writeFile(outputPath, decrypted) this.logInfo('解密成功', { outputPath, size: decrypted.length }) - // 对于 hevc 格式,返回错误提示 if (finalExt === '.hevc') { return { success: false, @@ -305,6 +287,7 @@ export class ImageDecryptService { isThumb: this.isThumbnailPath(datPath) } } + const isThumb = this.isThumbnailPath(datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) if (!isThumb) { @@ -313,15 +296,7 @@ export class ImageDecryptService { const dataUrl = this.bufferToDataUrl(decrypted, finalExt) const localPath = dataUrl || this.filePathToUrl(outputPath) this.emitCacheResolved(payload, cacheKey, localPath) - - // 检测实况照片(Motion Photo) - let liveVideoPath: string | undefined - if (!isThumb && (finalExt === '.jpg' || finalExt === '.jpeg')) { - const videoPath = await this.extractMotionPhotoVideo(outputPath, decrypted) - if (videoPath) liveVideoPath = this.filePathToUrl(videoPath) - } - - return { success: true, localPath, isThumb, liveVideoPath } + return { success: true, localPath, isThumb } } catch (e) { this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: String(e) } @@ -357,37 +332,23 @@ export class ImageDecryptService { * 获取解密后的缓存目录(用于查找 hardlink.db) */ private getDecryptedCacheDir(wxid: string): string | null { + const cachePath = this.configService.get('cachePath') + if (!cachePath) return null + const cleanedWxid = this.cleanAccountDirName(wxid) - const configured = this.configService.get('cachePath') - const documentsPath = app.getPath('documents') - const baseCandidates = Array.from(new Set([ - configured || '', - join(documentsPath, 'WeFlow'), - join(documentsPath, 'WeFlowData'), - this.configService.getCacheBasePath() - ].filter(Boolean))) + const cacheAccountDir = join(cachePath, cleanedWxid) - for (const base of baseCandidates) { - const accountCandidates = Array.from(new Set([ - join(base, wxid), - join(base, cleanedWxid), - join(base, 'databases', wxid), - join(base, 'databases', cleanedWxid) - ])) - for (const accountDir of accountCandidates) { - if (existsSync(join(accountDir, 'hardlink.db'))) { - return accountDir - } - const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink') - if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) { - return hardlinkSubdir - } - } - if (existsSync(join(base, 'hardlink.db'))) { - return base - } + // 检查缓存目录下是否有 hardlink.db + if (existsSync(join(cacheAccountDir, 'hardlink.db'))) { + return cacheAccountDir + } + if (existsSync(join(cachePath, 'hardlink.db'))) { + return cachePath + } + const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink') + if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) { + return cacheHardlinkDir } - return null } @@ -396,8 +357,7 @@ export class ImageDecryptService { existsSync(join(dirPath, 'hardlink.db')) || existsSync(join(dirPath, 'db_storage')) || existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) || - existsSync(join(dirPath, 'msg', 'attach')) + existsSync(join(dirPath, 'FileStorage', 'Image2')) ) } @@ -421,7 +381,7 @@ export class ImageDecryptService { const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const cleaned = suffixMatch ? suffixMatch[1] : trimmed - + return cleaned } @@ -435,14 +395,35 @@ export class ImageDecryptService { const allowThumbnail = options?.allowThumbnail ?? true const skipResolvedCache = options?.skipResolvedCache ?? false this.logInfo('[ImageDecrypt] resolveDatPath', { - accountDir, imageMd5, imageDatName, - sessionId, allowThumbnail, skipResolvedCache }) + if (!skipResolvedCache) { + if (imageMd5) { + const cached = this.resolvedCache.get(imageMd5) + if (cached && existsSync(cached)) return cached + } + if (imageDatName) { + const cached = this.resolvedCache.get(imageDatName) + if (cached && existsSync(cached)) return cached + } + } + + // 1. 通过 MD5 快速定位 (MsgAttach 目录) + if (imageMd5) { + const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail) + if (res) return res + } + + // 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位 + if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { + const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail) + if (res) return res + } + // 优先通过 hardlink.db 查询 if (imageMd5) { this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) @@ -463,12 +444,6 @@ export class ImageDecryptService { if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } - const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false) - if (hdInDir) { - this.cacheDatPath(accountDir, imageMd5, hdInDir) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir) - return hdInDir - } // 没找到高清图,返回 null(不进行全局搜索) return null } @@ -486,16 +461,9 @@ export class ImageDecryptService { // 找到缩略图但要求高清图,尝试同目录查找高清图变体 const hdPath = this.findHdVariantInSameDir(fallbackPath) if (hdPath) { - this.cacheDatPath(accountDir, imageMd5, hdPath) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } - const hdInDir = await this.searchDatFileInDir(dirname(fallbackPath), imageDatName || imageMd5 || '', false) - if (hdInDir) { - this.cacheDatPath(accountDir, imageMd5, hdInDir) - this.cacheDatPath(accountDir, imageDatName, hdInDir) - return hdInDir - } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) @@ -518,17 +486,15 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } - const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false) - if (hdInDir) { - this.cacheDatPath(accountDir, imageDatName, hdInDir) - return hdInDir - } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } - // force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图 + // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) + if (!allowThumbnail) { + return null + } if (!imageDatName) return null if (!skipResolvedCache) { @@ -538,8 +504,6 @@ export class ImageDecryptService { // 缓存的是缩略图,尝试找高清图 const hdPath = this.findHdVariantInSameDir(cached) if (hdPath) return hdPath - const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false) - if (hdInDir) return hdInDir } } @@ -640,9 +604,7 @@ export class ImageDecryptService { }).catch(() => { }) } - private looksLikeMd5(value: string): boolean { - return /^[a-fA-F0-9]{16,32}$/.test(value) - } + private resolveHardlinkDbPath(accountDir: string): string | null { const wxid = this.configService.get('myWxid') @@ -858,7 +820,7 @@ export class ImageDecryptService { * 包含:1. 微信旧版结构 filename.substr(0, 2)/... * 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename */ - private async fastProbabilisticSearch(root: string, datName: string): Promise { + private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise { const { promises: fs } = require('fs') const { join } = require('path') @@ -894,7 +856,7 @@ export class ImageDecryptService { } catch { } } - // --- 策略 B: 新版 Session 哈希路径猜测 --- + // --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 --- try { const entries = await fs.readdir(root, { withFileTypes: true }) const sessionDirs = entries @@ -947,7 +909,7 @@ export class ImageDecryptService { /** * 在同一目录下查找高清图变体 - * 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat + * 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat */ private findHdVariantInSameDir(thumbPath: string): string | null { try { @@ -1029,55 +991,6 @@ export class ImageDecryptService { }) } - private matchesDatName(fileName: string, datName: string): boolean { - const lower = fileName.toLowerCase() - const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower - const normalizedBase = this.normalizeDatBase(base) - const normalizedTarget = this.normalizeDatBase(datName.toLowerCase()) - if (normalizedBase === normalizedTarget) return true - const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`, 'i') - if (pattern.test(lower)) return true - return lower.endsWith('.dat') && lower.includes(datName) - } - - private scoreDatName(fileName: string): number { - if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1 - if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1 - return 2 - } - - private isThumbnailDat(fileName: string): boolean { - return fileName.includes('.t.dat') || fileName.includes('_t.dat') - } - - private hasXVariant(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) - } - - private isThumbnailPath(filePath: string): boolean { - const lower = basename(filePath).toLowerCase() - if (this.isThumbnailDat(lower)) return true - const ext = extname(lower) - const base = ext ? lower.slice(0, -ext.length) : lower - // 支持新命名 _thumb 和旧命名 _t - return base.endsWith('_t') || base.endsWith('_thumb') - } - - private isHdPath(filePath: string): boolean { - const lower = basename(filePath).toLowerCase() - const ext = extname(lower) - const base = ext ? lower.slice(0, -ext.length) : lower - return base.endsWith('_hd') || base.endsWith('_h') - } - - private hasImageVariantSuffix(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) - } - - private isLikelyImageDatBase(baseLower: string): boolean { - return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower) - } - private normalizeDatBase(name: string): string { let base = name.toLowerCase() if (base.endsWith('.dat') || base.endsWith('.jpg')) { @@ -1089,27 +1002,16 @@ export class ImageDecryptService { return base } - private sanitizeDirName(name: string): string { - const trimmed = name.trim() - if (!trimmed) return 'unknown' - return trimmed.replace(/[<>:"/\\|?*]/g, '_') + private hasImageVariantSuffix(baseLower: string): boolean { + return /[._][a-z]$/.test(baseLower) } - private resolveTimeDir(datPath: string): string { - const parts = datPath.split(/[\\/]+/) - for (const part of parts) { - if (/^\d{4}-\d{2}$/.test(part)) return part - } - try { - const stat = statSync(datPath) - const year = stat.mtime.getFullYear() - const month = String(stat.mtime.getMonth() + 1).padStart(2, '0') - return `${year}-${month}` - } catch { - return 'unknown-time' - } + private isLikelyImageDatBase(baseLower: string): boolean { + return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower) } + + private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null { const allRoots = this.getAllCacheRoots() const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) @@ -1344,14 +1246,14 @@ export class ImageDecryptService { private async ensureCacheIndexed(): Promise { if (this.cacheIndexed) return if (this.cacheIndexing) return this.cacheIndexing - this.cacheIndexing = new Promise((resolve) => { + 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 结构 + this.indexCacheDir(root, 3, 0) // 增加深度到 3,支持 sessionId/YYYY-MM 结构 } catch (e) { this.logError('索引目录失败', e, { root }) } @@ -1360,8 +1262,7 @@ export class ImageDecryptService { this.logInfo('缓存索引完成', { entries: this.resolvedCache.size }) this.cacheIndexed = true this.cacheIndexing = null - resolve() - }) + })() return this.cacheIndexing } @@ -1564,14 +1465,14 @@ export class ImageDecryptService { private bytesToInt32(bytes: Buffer): number { if (bytes.length !== 4) { - throw new Error('需要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个字符') + throw new Error('AES密钥至少需要 16 个字符') } return Buffer.from(keyString, 'ascii').subarray(0, 16) } @@ -1738,76 +1639,6 @@ export class ImageDecryptService { return mostCommonKey } - /** - * 检查图片对应的 live 视频缓存,返回 file:// URL 或 undefined - * 已确认无 live 的路径会被记录,下次直接跳过 - */ - private checkLiveVideoCache(imagePath: string): string | undefined { - if (this.noLiveSet.has(imagePath)) return undefined - const livePath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4') - if (existsSync(livePath)) return this.filePathToUrl(livePath) - this.noLiveSet.add(imagePath) - return undefined - } - - /** - * 检测并分离 Motion Photo(实况照片) - * Google Motion Photo = JPEG + MP4 拼接在一起 - * 返回视频文件路径,如果不是实况照片则返回 null - */ - private async extractMotionPhotoVideo(imagePath: string, imageBuffer: Buffer): Promise { - // 只处理 JPEG 文件 - if (imageBuffer.length < 8) return null - if (imageBuffer[0] !== 0xff || imageBuffer[1] !== 0xd8) return null - - // 从末尾向前搜索 MP4 ftyp 原子签名 - // ftyp 原子结构: [4字节大小][ftyp(66 74 79 70)][品牌...] - // 实际起始位置在 ftyp 前4字节(大小字段) - const ftypSig = [0x66, 0x74, 0x79, 0x70] // 'ftyp' - let videoOffset: number | null = null - - const searchEnd = Math.max(0, imageBuffer.length - 8) - for (let i = searchEnd; i > 0; i--) { - if (imageBuffer[i] === ftypSig[0] && - imageBuffer[i + 1] === ftypSig[1] && - imageBuffer[i + 2] === ftypSig[2] && - imageBuffer[i + 3] === ftypSig[3]) { - // ftyp 前4字节是 box size,实际 MP4 从这里开始 - videoOffset = i - 4 - break - } - } - - // 备用:从 XMP 元数据中读取偏移量 - if (videoOffset === null || videoOffset <= 0) { - try { - const text = imageBuffer.toString('latin1') - const match = text.match(/MediaDataOffset="(\d+)"/i) || text.match(/MicroVideoOffset="(\d+)"/i) - if (match) { - const offset = parseInt(match[1], 10) - if (offset > 0 && offset < imageBuffer.length) { - videoOffset = imageBuffer.length - offset - } - } - } catch { } - } - - if (videoOffset === null || videoOffset <= 100) return null - - // 验证视频部分确实以有效 MP4 数据开头 - const videoStart = imageBuffer[videoOffset + 4] === 0x66 && - imageBuffer[videoOffset + 5] === 0x74 && - imageBuffer[videoOffset + 6] === 0x79 && - imageBuffer[videoOffset + 7] === 0x70 - if (!videoStart) return null - - // 写出视频文件 - const videoPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4') - const videoBuffer = imageBuffer.slice(videoOffset) - await writeFile(videoPath, videoBuffer) - return videoPath - } - /** * 解包 wxgf 格式 * wxgf 是微信的图片格式,内部使用 HEVC 编码 @@ -1854,7 +1685,7 @@ export class ImageDecryptService { } /** - * 从 wxgf 数据中提取 HEVC NALU 裸流 + * 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦 */ private extractHevcNalu(buffer: Buffer): Buffer | null { const nalUnits: Buffer[] = [] @@ -2006,6 +1837,44 @@ export class ImageDecryptService { }) } + private looksLikeMd5(s: string): boolean { + return /^[a-f0-9]{32}$/i.test(s) + } + + private isThumbnailDat(name: string): boolean { + const lower = name.toLowerCase() + return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat') + } + + private hasXVariant(base: string): boolean { + const lower = base.toLowerCase() + return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t') + } + + private isHdPath(p: string): boolean { + return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h') + } + + private isThumbnailPath(p: string): boolean { + const lower = p.toLowerCase() + return lower.includes('_thumb') || lower.includes('_t') || lower.includes('.t.') + } + + private sanitizeDirName(s: string): string { + return s.replace(/[<>:"/\\|?*]/g, '_').trim() || 'unknown' + } + + private resolveTimeDir(filePath: string): string { + try { + const stats = statSync(filePath) + const d = new Date(stats.mtime) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + } catch { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + } + } + // 保留原有的解密到文件方法(用于兼容) async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise { const version = this.getDatVersion(inputPath) @@ -2018,7 +1887,7 @@ export class ImageDecryptService { decrypted = this.decryptDatV4(inputPath, xorKey, key) } else { if (!aesKey || aesKey.length !== 16) { - throw new Error('V4版本需要16字节AES密钥') + throw new Error('V4版本需要 16 字节 AES 密钥') } decrypted = this.decryptDatV4(inputPath, xorKey, aesKey) } diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 5fe0dd8..2b5d4af 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -1,9 +1,8 @@ import { app } from 'electron' -import { join, dirname, basename } from 'path' -import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs' +import { join, dirname } from 'path' +import { existsSync, copyFileSync, mkdirSync } from 'fs' import { execFile, spawn } from 'child_process' import { promisify } from 'util' -import { Worker } from 'worker_threads' import os from 'os' const execFileAsync = promisify(execFile) @@ -20,13 +19,14 @@ export class KeyService { private getStatusMessage: any = null private cleanupHook: any = null private getLastErrorMsg: any = null + private getImageKeyDll: any = null // Win32 APIs private kernel32: any = null private user32: any = null private advapi32: any = null - // Kernel32 (已移除内存扫描相关的 API) + // Kernel32 private OpenProcess: any = null private CloseHandle: any = null private TerminateProcess: any = null @@ -126,6 +126,7 @@ export class KeyService { this.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)') this.cleanupHook = this.lib.func('bool CleanupHook()') this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()') + this.getImageKeyDll = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)') this.initialized = true return true @@ -145,8 +146,6 @@ export class KeyService { try { this.koffi = require('koffi') this.kernel32 = this.koffi.load('kernel32.dll') - - // 直接使用原生支持的 'void*' 替换 'HANDLE',绝对不会再报类型错误 this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32']) this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*']) this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32']) @@ -638,365 +637,68 @@ export class KeyService { return { success: false, error: '获取密钥超时', logs } } - // --- Image Key Stuff (Refactored to Multi-core Crypto Brute Force) --- - - private isAccountDir(dirPath: string): boolean { - return ( - existsSync(join(dirPath, 'db_storage')) || - existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) - ) - } - - private isPotentialAccountName(name: string): boolean { - const lower = name.toLowerCase() - if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) return false - if (lower.startsWith('wxid_')) return true - if (/^\d+$/.test(name) && name.length >= 6) return true - return name.length > 5 - } - - private listAccountDirs(rootDir: string): string[] { - try { - const entries = readdirSync(rootDir) - const high: string[] = [] - const low: string[] = [] - for (const entry of entries) { - const fullPath = join(rootDir, entry) - try { - if (!statSync(fullPath).isDirectory()) continue - } catch { continue } - - if (!this.isPotentialAccountName(entry)) continue - - if (this.isAccountDir(fullPath)) high.push(fullPath) - else low.push(fullPath) - } - return high.length ? high.sort() : low.sort() - } catch { - return [] - } - } - - private normalizeExistingDir(inputPath: string): string | null { - const trimmed = inputPath.replace(/[\\\\/]+$/, '') - if (!existsSync(trimmed)) return null - try { - const stats = statSync(trimmed) - if (stats.isFile()) return dirname(trimmed) - } catch { - return null - } - return trimmed - } - - private resolveAccountDirFromPath(inputPath: string): string | null { - const normalized = this.normalizeExistingDir(inputPath) - if (!normalized) return null - - if (this.isAccountDir(normalized)) return normalized - - const lower = normalized.toLowerCase() - if (lower.endsWith('db_storage') || lower.endsWith('filestorage') || lower.endsWith('image') || lower.endsWith('image2')) { - const parent = dirname(normalized) - if (this.isAccountDir(parent)) return parent - const grandParent = dirname(parent) - if (this.isAccountDir(grandParent)) return grandParent - } - - const candidates = this.listAccountDirs(normalized) - if (candidates.length) return candidates[0] - return null - } - - private resolveAccountDir(manualDir?: string): string | null { - if (manualDir) { - const resolved = this.resolveAccountDirFromPath(manualDir) - if (resolved) return resolved - } - - const userProfile = process.env.USERPROFILE - if (!userProfile) return null - const roots = [ - join(userProfile, 'Documents', 'xwechat_files'), - join(userProfile, 'Documents', 'WeChat Files') - ] - for (const root of roots) { - if (!existsSync(root)) continue - const candidates = this.listAccountDirs(root) - if (candidates.length) return candidates[0] - } - return null - } - - private findTemplateDatFiles(rootDir: string): string[] { - const files: string[] = [] - const stack = [rootDir] - const maxFiles = 256 - while (stack.length && files.length < maxFiles) { - const dir = stack.pop() as string - let entries: string[] - try { - entries = readdirSync(dir) - } catch { continue } - for (const entry of entries) { - const fullPath = join(dir, entry) - let stats: any - try { - stats = statSync(fullPath) - } catch { continue } - if (stats.isDirectory()) { - stack.push(fullPath) - } else if (entry.endsWith('_t.dat')) { - files.push(fullPath) - if (files.length >= maxFiles) break - } - } - } - - if (!files.length) return [] - const dateReg = /(\d{4}-\d{2})/ - files.sort((a, b) => { - const ma = a.match(dateReg)?.[1] - const mb = b.match(dateReg)?.[1] - if (ma && mb) return mb.localeCompare(ma) - return 0 - }) - return files.slice(0, 128) - } - - private getXorKey(templateFiles: string[]): number | null { - const counts = new Map() - const tailSignatures = [ - Buffer.from([0xFF, 0xD9]), - Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]) - ] - for (const file of templateFiles) { - try { - const bytes = readFileSync(file) - for (const signature of tailSignatures) { - if (bytes.length < signature.length) continue - const tail = bytes.subarray(bytes.length - signature.length) - const xorKey = tail[0] ^ signature[0] - let valid = true - for (let i = 1; i < signature.length; i++) { - if ((tail[i] ^ xorKey) !== signature[i]) { - valid = false - break - } - } - if (valid) counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1) - } - } catch { } - } - if (!counts.size) return null - let bestKey: number | null = null - let bestCount = 0 - for (const [key, count] of counts) { - if (count > bestCount) { - bestCount = count - bestKey = key - } - } - return bestKey - } - - // 改为返回 Buffer 数组,收集最多2个样本用于双重校验 - private getCiphertextsFromTemplate(templateFiles: string[]): Buffer[] { - const ciphertexts: Buffer[] = [] - for (const file of templateFiles) { - try { - const bytes = readFileSync(file) - if (bytes.length < 0x1f) continue - // 匹配微信 DAT 文件的特定头部特征 - if ( - bytes[0] === 0x07 && bytes[1] === 0x08 && bytes[2] === 0x56 && - bytes[3] === 0x32 && bytes[4] === 0x08 && bytes[5] === 0x07 - ) { - ciphertexts.push(bytes.subarray(0x0f, 0x1f)) - // 收集到 2 个样本就足够做双重校验了 - if (ciphertexts.length >= 2) break - } - } catch { } - } - return ciphertexts - } - - private async bruteForceAesKey( - xorKey: number, - wxid: string, - ciphertexts: Buffer[], - onProgress?: (msg: string) => void - ): Promise { - const numCores = os.cpus().length || 4 - const totalCombinations = 1 << 24 // 16,777,216 种可能性 - const chunkSize = Math.ceil(totalCombinations / numCores) - - onProgress?.(`准备启动 ${numCores} 个线程进行极速爆破...`) - - const workerCode = ` - const { parentPort, workerData } = require('worker_threads'); - const crypto = require('crypto'); - - const { start, end, xorKey, wxid, cipherHexList } = workerData; - const ciphertexts = cipherHexList.map(hex => Buffer.from(hex, 'hex')); - - function verifyKey(cipher, keyStr) { - try { - const decipher = crypto.createDecipheriv('aes-128-ecb', keyStr, null); - decipher.setAutoPadding(false); - const decrypted = Buffer.concat([decipher.update(cipher), decipher.final()]); - const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff; - const isPng = decrypted.length >= 8 && decrypted[0] === 0x89 && decrypted[1] === 0x50 && decrypted[2] === 0x4e && decrypted[3] === 0x47; - return isJpeg || isPng; - } catch { - return false; - } - } - - let found = null; - for (let upper = end - 1; upper >= start; upper--) { - // 我就写 -- - if (upper % 100000 === 0 && upper !== start) { - parentPort.postMessage({ type: 'progress', scanned: 100000 }); - } - - const number = (upper * 256) + xorKey; - - // 1. 无符号整数校验 - const strUnsigned = number.toString(10) + wxid; - const md5Unsigned = crypto.createHash('md5').update(strUnsigned).digest('hex').slice(0, 16); - - let isValidUnsigned = true; - for (const cipher of ciphertexts) { - if (!verifyKey(cipher, md5Unsigned)) { - isValidUnsigned = false; - break; - } - } - if (isValidUnsigned) { - found = md5Unsigned; - break; - } - - // 2. 带符号整数校验 (溢出边界情况) - if (number > 0x7FFFFFFF) { - const strSigned = (number | 0).toString(10) + wxid; - const md5Signed = crypto.createHash('md5').update(strSigned).digest('hex').slice(0, 16); - - let isValidSigned = true; - for (const cipher of ciphertexts) { - if (!verifyKey(cipher, md5Signed)) { - isValidSigned = false; - break; - } - } - if (isValidSigned) { - found = md5Signed; - break; - } - } - } - - if (found) { - parentPort.postMessage({ type: 'success', key: found }); - } else { - parentPort.postMessage({ type: 'done' }); - } - ` - - return new Promise((resolve) => { - let activeWorkers = numCores - let resolved = false - let totalScanned = 0 // 总进度计数器 - const workers: Worker[] = [] - - const cleanup = () => { - for (const w of workers) w.terminate() - } - - for (let i = 0; i < numCores; i++) { - const start = i * chunkSize - const end = Math.min(start + chunkSize, totalCombinations) - - const worker = new Worker(workerCode, { - eval: true, - workerData: { - start, - end, - xorKey, - wxid, - cipherHexList: ciphertexts.map(c => c.toString('hex')) // 传入数组 - } - }) - workers.push(worker) - - worker.on('message', (msg) => { - if (!msg) return - if (msg.type === 'progress') { - totalScanned += msg.scanned - const percent = ((totalScanned / totalCombinations) * 100).toFixed(1) - // 优化文案,并确保包含 (xx.x%) 供前端解析 - onProgress?.(`多核爆破引擎运行中:已扫描 ${(totalScanned / 10000).toFixed(0)} 万个密钥空间 (${percent}%)`) - } else if (msg.type === 'success' && !resolved) { - resolved = true - cleanup() - resolve(msg.key) - } else if (msg.type === 'done') { - // 单个 worker 跑完了没有找到(计数统一在 exit 事件处理) - } - }) - - worker.on('error', (err) => { - console.error('Worker error:', err) - }) - - // 统一在 exit 事件中做完成计数,避免 done/error + exit 双重递减 - worker.on('exit', () => { - activeWorkers-- - if (activeWorkers === 0 && !resolved) resolve(null) - }) - } - }) - } + // --- Image Key (通过 DLL 从缓存目录直接获取) --- async autoGetImageKey( manualDir?: string, onProgress?: (message: string) => void ): Promise { - onProgress?.('正在定位微信账号数据目录...') - const accountDir = this.resolveAccountDir(manualDir) - if (!accountDir) return { success: false, error: '未找到微信账号目录' } + if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } + if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' } - let wxid = basename(accountDir) - wxid = wxid.replace(/_[0-9a-fA-F]{4}$/, '') + onProgress?.('正在从缓存目录扫描图片密钥...') - onProgress?.('正在收集并分析加密模板文件...') - const templateFiles = this.findTemplateDatFiles(accountDir) - if (!templateFiles.length) return { success: false, error: '未找到模板文件' } + const resultBuffer = Buffer.alloc(8192) + const ok = this.getImageKeyDll(resultBuffer, resultBuffer.length) - onProgress?.('正在计算特征 XOR 密钥...') - const xorKey = this.getXorKey(templateFiles) - if (xorKey == null) return { success: false, error: '无法计算 XOR 密钥' } + if (!ok) { + const errMsg = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '获取图片密钥失败' + return { success: false, error: errMsg } + } - onProgress?.('正在读取加密模板区块...') - const ciphertexts = this.getCiphertextsFromTemplate(templateFiles) - if (ciphertexts.length < 2) return { success: false, error: '可用的加密样本不足(至少需要2个),请确认账号目录下有足够的模板图片' } + const jsonStr = this.decodeUtf8(resultBuffer) + let parsed: any + try { + parsed = JSON.parse(jsonStr) + } catch { + return { success: false, error: '解析密钥数据失败' } + } - onProgress?.(`成功提取 ${ciphertexts.length} 个特征样本,准备交叉校验...`) - onProgress?.(`准备启动 ${os.cpus().length || 4} 线程并发爆破引擎 (基于 wxid: ${wxid})...`) - - const aesKey = await this.bruteForceAesKey(xorKey, wxid, ciphertexts, (msg) => { - onProgress?.(msg) - }) - - if (!aesKey) { - return { - success: false, - error: 'AES 密钥爆破失败,请确认该账号近期是否有接收过图片,或更换账号目录重试' + // 从 manualDir 中提取 wxid 用于精确匹配 + // 前端传入的格式是 dbPath/wxid_xxx_1234,取最后一段目录名再清理后缀 + let targetWxid: string | null = null + if (manualDir) { + const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '' + // 与 DLL 的 CleanWxid 逻辑一致:wxid_a_b_c → wxid_a + const parts = dirName.split('_') + if (parts.length >= 3 && parts[0] === 'wxid') { + targetWxid = `${parts[0]}_${parts[1]}` + } else if (dirName.startsWith('wxid_')) { + targetWxid = dirName } } - return { success: true, xorKey, aesKey } + const accounts: any[] = parsed.accounts ?? [] + if (!accounts.length) { + return { success: false, error: '未找到有效的密钥组合' } + } + + // 优先匹配 wxid,找不到则回退到第一个 + const matchedAccount = targetWxid + ? (accounts.find((a: any) => a.wxid === targetWxid) ?? accounts[0]) + : accounts[0] + + if (!matchedAccount?.keys?.length) { + return { success: false, error: '未找到有效的密钥组合' } + } + + const firstKey = matchedAccount.keys[0] + onProgress?.(`密钥获取成功 (wxid: ${matchedAccount.wxid}, code: ${firstKey.code})`) + + return { + success: true, + xorKey: firstKey.xorKey, + aesKey: firstKey.aesKey + } } } \ No newline at end of file diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 913c874..1893eab 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -235,9 +235,23 @@ class VideoService { const videoPath = join(dirPath, `${realVideoMd5}.mp4`) if (existsSync(videoPath)) { - this.log('找到视频', { videoPath }) - const coverPath = join(dirPath, `${realVideoMd5}.jpg`) - const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) + // 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw,但封面不带) + const baseMd5 = realVideoMd5.replace(/_raw$/, '') + const coverPath = join(dirPath, `${baseMd5}.jpg`) + const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`) + + // 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名 + const allFiles = readdirSync(dirPath) + const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase())) + this.log('找到视频,相关文件列表', { + videoPath, + coverExists: existsSync(coverPath), + thumbExists: existsSync(thumbPath), + relatedFiles, + coverPath, + thumbPath + }) + return { videoUrl: videoPath, coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), @@ -247,11 +261,28 @@ class VideoService { } } - // 没找到,列出第一个目录里的文件帮助排查 - if (yearMonthDirs.length > 0) { - const firstDir = join(videoBaseDir, yearMonthDirs[0]) - const files = readdirSync(firstDir).filter(f => f.endsWith('.mp4')).slice(0, 5) - this.log('未找到视频,最新目录样本', { dir: yearMonthDirs[0], sampleFiles: files, lookingFor: `${realVideoMd5}.mp4` }) + // 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个) + this.log('未找到视频,开始全目录扫描', { + lookingForOriginal: `${videoMd5}.mp4`, + lookingForResolved: `${realVideoMd5}.mp4`, + hardlinkResolved: realVideoMd5 !== videoMd5 + }) + for (const yearMonth of yearMonthDirs) { + const dirPath = join(videoBaseDir, yearMonth) + try { + const allFiles = readdirSync(dirPath) + const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10) + // 检查原始 md5 是否部分匹配(前8位) + const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase())) + this.log(`目录 ${yearMonth} 扫描结果`, { + totalFiles: allFiles.length, + mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length, + sampleMp4: mp4Files, + partialMatchByOriginalMd5: partialMatch + }) + } catch (e) { + this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) }) + } } } catch (e) { this.log('getVideoInfo 遍历出错', { error: String(e) }) @@ -265,41 +296,59 @@ class VideoService { * 根据消息内容解析视频MD5 */ parseVideoMd5(content: string): string | undefined { - - // 打印前500字符看看 XML 结构 - if (!content) return undefined + // 打印原始 XML 前 800 字符,帮助排查自己发的视频结构 + this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) }) + try { - // 提取所有可能的 md5 值进行日志 - const allMd5s: string[] = [] - const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi + // 收集所有 md5 相关属性,方便对比 + const allMd5Attrs: string[] = [] + const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi let match while ((match = md5Regex.exec(content)) !== null) { - allMd5s.push(`${match[0]}`) + allMd5Attrs.push(match[0]) + } + this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs }) + + // 方法1:从 提取(收到的视频) + const videoMsgMd5Match = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (videoMsgMd5Match) { + this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] }) + return videoMsgMd5Match[1].toLowerCase() } - // 提取 md5(用于查询 hardlink.db) - // 注意:不是 rawmd5,rawmd5 是另一个值 - // 格式: md5="xxx" 或 xxx - - // 尝试从videomsg标签中提取md5 - const videoMsgMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (videoMsgMatch) { - return videoMsgMatch[1].toLowerCase() + // 方法2:从 提取(自己发的视频,没有 md5 只有 rawmd5) + const rawMd5Match = /]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (rawMd5Match) { + this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] }) + return rawMd5Match[1].toLowerCase() } - const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + // 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等) + const attrMatch = /(?([a-fA-F0-9]+)<\/md5>/i.exec(content) - if (md5Match) { - return md5Match[1].toLowerCase() + // 方法4:... 标签 + const md5TagMatch = /([a-fA-F0-9]+)<\/md5>/i.exec(content) + if (md5TagMatch) { + this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] }) + return md5TagMatch[1].toLowerCase() } + + // 方法5:兜底取 rawmd5 属性(任意位置) + const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (rawMd5Fallback) { + this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] }) + return rawMd5Fallback[1].toLowerCase() + } + + this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length }) } catch (e) { - console.error('[VideoService] 解析视频MD5失败:', e) + this.log('parseVideoMd5 异常', { error: String(e) }) } return undefined diff --git a/resources/wx_key.dll b/resources/wx_key.dll index 8952a4b..e03975d 100644 Binary files a/resources/wx_key.dll and b/resources/wx_key.dll differ diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx index 561bb45..d0cce60 100644 --- a/src/components/GlobalSessionMonitor.tsx +++ b/src/components/GlobalSessionMonitor.tsx @@ -257,7 +257,8 @@ export function GlobalSessionMonitor() { const handleActiveSessionRefresh = async (sessionId: string) => { // 从 ChatPage 复制/调整的逻辑,以保持集中 const state = useChatStore.getState() - const lastMsg = state.messages[state.messages.length - 1] + const msgs = state.messages || [] + const lastMsg = msgs[msgs.length - 1] const minTime = lastMsg?.createTime || 0 try { diff --git a/src/components/NotificationToast.scss b/src/components/NotificationToast.scss index d442af7..a01ab73 100644 --- a/src/components/NotificationToast.scss +++ b/src/components/NotificationToast.scss @@ -48,18 +48,26 @@ backdrop-filter: none !important; -webkit-backdrop-filter: none !important; - // 确保背景完全不透明(通知是独立窗口,透明背景会穿透) - background: var(--bg-secondary-solid, var(--bg-secondary, #2c2c2c)); - color: var(--text-primary, #ffffff); + // 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 上的主题属性 + background: #ffffff; + color: #3d3d3d; + --text-primary: #3d3d3d; + --text-secondary: #666666; + --text-tertiary: #999999; + --border-light: rgba(0, 0, 0, 0.08); - // 浅色模式强制完全不透明白色背景 - [data-mode="light"] &, - :not([data-mode]) & { - background: #ffffff !important; + // 深色模式覆盖 + [data-mode="dark"] & { + background: var(--bg-secondary-solid, #282420); + color: var(--text-primary, #F0EEE9); + --text-primary: #F0EEE9; + --text-secondary: #b3b0aa; + --text-tertiary: #807d78; + --border-light: rgba(255, 255, 255, 0.1); } box-shadow: none !important; // NO SHADOW - border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1)); + border: 1px solid var(--border-light); display: flex; padding: 16px; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index faed02c..b2373dd 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -768,7 +768,7 @@ function ChatPage(_props: ChatPageProps) { setIsRefreshingMessages(true) // 找出当前已渲染消息中的最大时间戳(使用 getState 获取最新状态,避免闭包过时导致重复) - const currentMessages = useChatStore.getState().messages + const currentMessages = useChatStore.getState().messages || [] const lastMsg = currentMessages[currentMessages.length - 1] const minTime = lastMsg?.createTime || 0 @@ -782,7 +782,7 @@ function ChatPage(_props: ChatPageProps) { if (result.success && result.messages && result.messages.length > 0) { // 过滤去重:必须对比实时的状态,防止在 handleRefreshMessages 运行期间导致的冲突 - const latestMessages = useChatStore.getState().messages + const latestMessages = useChatStore.getState().messages || [] const existingKeys = new Set(latestMessages.map(getMessageKey)) const newOnes = result.messages.filter(m => !existingKeys.has(getMessageKey(m))) @@ -823,7 +823,7 @@ function ChatPage(_props: ChatPageProps) { return } // 使用实时状态进行去重对比 - const latestMessages = useChatStore.getState().messages + const latestMessages = useChatStore.getState().messages || [] const existing = new Set(latestMessages.map(getMessageKey)) const lastMsg = latestMessages[latestMessages.length - 1] const lastTime = lastMsg?.createTime ?? 0 @@ -1751,7 +1751,7 @@ function ChatPage(_props: ChatPageProps) { // Range selection with Shift key if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) { - const currentMsgs = useChatStore.getState().messages + const currentMsgs = useChatStore.getState().messages || [] const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current) const idx2 = currentMsgs.findIndex(m => m.localId === localId) @@ -1821,7 +1821,7 @@ function ChatPage(_props: ChatPageProps) { const dbPathHint = (msg as any)._db_path const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint) if (result.success) { - const currentMessages = useChatStore.getState().messages + const currentMessages = useChatStore.getState().messages || [] const newMessages = currentMessages.filter(m => m.localId !== msg.localId) useChatStore.getState().setMessages(newMessages) } else { @@ -1882,7 +1882,7 @@ function ChatPage(_props: ChatPageProps) { try { const result = await (window as any).electronAPI.chat.updateMessage(currentSessionId, editingMessage.message.localId, editingMessage.message.createTime, finalContent) if (result.success) { - const currentMessages = useChatStore.getState().messages + const currentMessages = useChatStore.getState().messages || [] const newMessages = currentMessages.map(m => { if (m.localId === editingMessage.message.localId) { return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent } @@ -1924,7 +1924,7 @@ function ChatPage(_props: ChatPageProps) { cancelDeleteRef.current = false try { - const currentMessages = useChatStore.getState().messages + const currentMessages = useChatStore.getState().messages || [] const selectedIds = Array.from(selectedMessages) const deletedIds = new Set() @@ -1948,7 +1948,7 @@ function ChatPage(_props: ChatPageProps) { setDeleteProgress({ current: i + 1, total: selectedIds.length }) } - const finalMessages = useChatStore.getState().messages.filter(m => !deletedIds.has(m.localId)) + const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedIds.has(m.localId)) useChatStore.getState().setMessages(finalMessages) setIsSelectionMode(false) @@ -2137,7 +2137,7 @@ function ChatPage(_props: ChatPageProps) {

暂无会话

-

请先在数据管理页面解密数据库

+

检查你的数据库配置

)} @@ -2342,7 +2342,7 @@ function ChatPage(_props: ChatPageProps) { )} - {messages.map((msg, index) => { + {(messages || []).map((msg, index) => { const prevMsg = index > 0 ? messages[index - 1] : undefined const showDateDivider = shouldShowDateDivider(msg, prevMsg) diff --git a/src/pages/NotificationWindow.tsx b/src/pages/NotificationWindow.tsx index 2e9acd0..deb6616 100644 --- a/src/pages/NotificationWindow.tsx +++ b/src/pages/NotificationWindow.tsx @@ -1,11 +1,9 @@ import { useEffect, useState, useRef } from 'react' import { NotificationToast, type NotificationData } from '../components/NotificationToast' -import { useThemeStore } from '../stores/themeStore' import '../components/NotificationToast.scss' import './NotificationWindow.scss' export default function NotificationWindow() { - const { currentTheme, themeMode } = useThemeStore() const [notification, setNotification] = useState(null) const [prevNotification, setPrevNotification] = useState(null) @@ -19,12 +17,6 @@ export default function NotificationWindow() { const notificationRef = useRef(null) - // 应用主题到通知窗口 - useEffect(() => { - document.documentElement.setAttribute('data-theme', currentTheme) - document.documentElement.setAttribute('data-mode', themeMode) - }, [currentTheme, themeMode]) - useEffect(() => { notificationRef.current = notification }, [notification]) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index faa558f..154d528 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -774,7 +774,7 @@ function SettingsPage() { } setIsFetchingImageKey(true); setImageKeyPercent(0) - setImageKeyStatus('正在初始化多核爆破引擎...'); + setImageKeyStatus('正在初始化...'); setImageKeyProgress(0); // 重置进度 try { @@ -1377,19 +1377,19 @@ function SettingsPage() { {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} {isFetchingImageKey ? ( -
-
- {imageKeyStatus || '正在启动多核爆破引擎...'} - {imageKeyPercent !== null && {imageKeyPercent.toFixed(1)}%} -
- {imageKeyPercent !== null && ( -
-
-
- )} +
+
+ {imageKeyStatus || '正在启动...'} + {imageKeyPercent !== null && {imageKeyPercent.toFixed(1)}%}
+ {imageKeyPercent !== null && ( +
+
+
+ )} +
) : ( - imageKeyStatus &&
{imageKeyStatus}
+ imageKeyStatus &&
{imageKeyStatus}
)}
@@ -2113,8 +2113,8 @@ function SettingsPage() { { isLockMode ? '已开启' : - authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' : - '未开启 — 请设置密码以开启' + authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' : + '未开启 — 请设置密码以开启' } {authEnabled && !showDisableLockInput && ( diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 4623d2f..9a43cef 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -746,52 +746,52 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { )} {currentStep.id === 'image' && ( -
-
-
- - setImageXorKey(e.target.value)} - /> -
-
- - setImageAesKey(e.target.value)} - /> -
+
+
+
+ + setImageXorKey(e.target.value)} + /> +
+
+ + setImageAesKey(e.target.value)} + />
- - - - {isFetchingImageKey ? ( -
-
- {imageKeyStatus || '正在启动多核爆破引擎...'} - {imageKeyPercent !== null && {imageKeyPercent.toFixed(1)}%} -
- {imageKeyPercent !== null && ( -
-
-
- )} -
- ) : ( - imageKeyStatus &&
{imageKeyStatus}
- )} - -
请在微信中打开几张图片后再点击获取
+ + + + {isFetchingImageKey ? ( +
+
+ {imageKeyStatus || '正在启动...'} + {imageKeyPercent !== null && {imageKeyPercent.toFixed(1)}%} +
+ {imageKeyPercent !== null && ( +
+
+
+ )} +
+ ) : ( + imageKeyStatus &&
{imageKeyStatus}
+ )} + +
请在微信中打开几张图片后再点击获取
+
)}
diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index 0e166c9..3e4b6d1 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -86,15 +86,16 @@ export const useChatStore = create((set, get) => ({ if (m.localId && m.localId > 0) return `l:${m.localId}` return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}` } - const existingKeys = new Set(state.messages.map(getMsgKey)) + const currentMessages = state.messages || [] + const existingKeys = new Set(currentMessages.map(getMsgKey)) const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m))) if (filtered.length === 0) return state return { messages: prepend - ? [...filtered, ...state.messages] - : [...state.messages, ...filtered] + ? [...filtered, ...currentMessages] + : [...currentMessages, ...filtered] } }),