Merge branch 'hicccc77:dev' into dev

This commit is contained in:
xuncha
2026-02-28 20:00:06 +08:00
committed by GitHub
13 changed files with 351 additions and 722 deletions

1
.gitignore vendored
View File

@@ -56,6 +56,7 @@ Thumbs.db
*.aps *.aps
wcdb/ wcdb/
xkey/
*info *info
概述.md 概述.md
chatlab-format.md chatlab-format.md

View File

@@ -1783,13 +1783,19 @@ class ChatService {
if (!content) return undefined if (!content) return undefined
try { try {
// 取 md5,这是用于查询 hardlink.db 的值 // 优先取 md5 属性(收到的视频)
const md5 = const md5 = this.extractXmlAttribute(content, 'videomsg', 'md5')
this.extractXmlAttribute(content, 'videomsg', 'md5') || if (md5) return md5.toLowerCase()
this.extractXmlValue(content, 'md5') ||
undefined
return md5?.toLowerCase() // 自己发的视频没有 md5只有 rawmd5
const rawMd5 = this.extractXmlAttribute(content, 'videomsg', 'rawmd5')
if (rawMd5) return rawMd5.toLowerCase()
// 兜底:<md5> 标签
const tagMd5 = this.extractXmlValue(content, 'md5')
if (tagMd5) return tagMd5.toLowerCase()
return undefined
} catch { } catch {
return undefined return undefined
} }

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join } from 'path' import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url' import { pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs' import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
@@ -11,16 +11,7 @@ import { wcdbService } from './wcdbService'
// 获取 ffmpeg-static 的路径 // 获取 ffmpeg-static 的路径
function getStaticFfmpegPath(): string | null { function getStaticFfmpegPath(): string | null {
try { try {
// 优先处理打包后的路径 // 方法1: 直接 require ffmpeg-static
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开发环境
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const ffmpegStatic = require('ffmpeg-static') const ffmpegStatic = require('ffmpeg-static')
@@ -28,12 +19,21 @@ function getStaticFfmpegPath(): string | null {
return ffmpegStatic return ffmpegStatic
} }
// 方法2: 手动构建路径(开发环境备用 // 方法2: 手动构建路径(开发环境)
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(devPath)) { if (existsSync(devPath)) {
return 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 return null
} catch { } catch {
return null return null
@@ -45,7 +45,6 @@ type DecryptResult = {
localPath?: string localPath?: string
error?: string error?: string
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
liveVideoPath?: string // 实况照片的视频路径
} }
type HardlinkState = { type HardlinkState = {
@@ -62,7 +61,6 @@ export class ImageDecryptService {
private cacheIndexed = false private cacheIndexed = false
private cacheIndexing: Promise<void> | null = null private cacheIndexing: Promise<void> | null = null
private updateFlags = new Map<string, boolean>() private updateFlags = new Map<string, boolean>()
private noLiveSet = new Set<string>() // 已确认无 live 视频的图片路径
private logInfo(message: string, meta?: Record<string, unknown>): void { private logInfo(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return if (!this.configService.get('logEnabled')) return
@@ -118,9 +116,8 @@ export class ImageDecryptService {
} else { } else {
this.updateFlags.delete(key) this.updateFlags.delete(key)
} }
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached)
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(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)) { if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(key) this.resolvedCache.delete(key)
@@ -139,9 +136,8 @@ export class ImageDecryptService {
} else { } else {
this.updateFlags.delete(key) this.updateFlags.delete(key)
} }
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(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 }) this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
@@ -155,25 +151,13 @@ export class ImageDecryptService {
return { success: false, error: '缺少图片标识' } 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) { if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey) const cached = this.resolvedCache.get(cacheKey)
if (cached && existsSync(cached) && this.isImageFile(cached)) { if (cached && existsSync(cached) && this.isImageFile(cached)) {
const dataUrl = this.fileToDataUrl(cached) const dataUrl = this.fileToDataUrl(cached)
const localPath = dataUrl || this.filePathToUrl(cached) const localPath = dataUrl || this.filePathToUrl(cached)
const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached)
this.emitCacheResolved(payload, cacheKey, localPath) this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, liveVideoPath } return { success: true, localPath }
} }
if (cached && !this.isImageFile(cached)) { if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(cacheKey) this.resolvedCache.delete(cacheKey)
@@ -251,9 +235,8 @@ export class ImageDecryptService {
const dataUrl = this.fileToDataUrl(existing) const dataUrl = this.fileToDataUrl(existing)
const localPath = dataUrl || this.filePathToUrl(existing) const localPath = dataUrl || this.filePathToUrl(existing)
const isThumb = this.isThumbnailPath(existing) const isThumb = this.isThumbnailPath(existing)
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
this.emitCacheResolved(payload, cacheKey, localPath) 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) await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length }) this.logInfo('解密成功', { outputPath, size: decrypted.length })
// 对于 hevc 格式,返回错误提示
if (finalExt === '.hevc') { if (finalExt === '.hevc') {
return { return {
success: false, success: false,
@@ -305,6 +287,7 @@ export class ImageDecryptService {
isThumb: this.isThumbnailPath(datPath) isThumb: this.isThumbnailPath(datPath)
} }
} }
const isThumb = this.isThumbnailPath(datPath) const isThumb = this.isThumbnailPath(datPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (!isThumb) { if (!isThumb) {
@@ -313,15 +296,7 @@ export class ImageDecryptService {
const dataUrl = this.bufferToDataUrl(decrypted, finalExt) const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
const localPath = dataUrl || this.filePathToUrl(outputPath) const localPath = dataUrl || this.filePathToUrl(outputPath)
this.emitCacheResolved(payload, cacheKey, localPath) this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb }
// 检测实况照片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 }
} catch (e) { } catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: String(e) } return { success: false, error: String(e) }
@@ -357,37 +332,23 @@ export class ImageDecryptService {
* 获取解密后的缓存目录(用于查找 hardlink.db * 获取解密后的缓存目录(用于查找 hardlink.db
*/ */
private getDecryptedCacheDir(wxid: string): string | null { private getDecryptedCacheDir(wxid: string): string | null {
const cachePath = this.configService.get('cachePath')
if (!cachePath) return null
const cleanedWxid = this.cleanAccountDirName(wxid) const cleanedWxid = this.cleanAccountDirName(wxid)
const configured = this.configService.get('cachePath') const cacheAccountDir = join(cachePath, cleanedWxid)
const documentsPath = app.getPath('documents')
const baseCandidates = Array.from(new Set([
configured || '',
join(documentsPath, 'WeFlow'),
join(documentsPath, 'WeFlowData'),
this.configService.getCacheBasePath()
].filter(Boolean)))
for (const base of baseCandidates) { // 检查缓存目录下是否有 hardlink.db
const accountCandidates = Array.from(new Set([ if (existsSync(join(cacheAccountDir, 'hardlink.db'))) {
join(base, wxid), return cacheAccountDir
join(base, cleanedWxid), }
join(base, 'databases', wxid), if (existsSync(join(cachePath, 'hardlink.db'))) {
join(base, 'databases', cleanedWxid) return cachePath
])) }
for (const accountDir of accountCandidates) { const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink')
if (existsSync(join(accountDir, 'hardlink.db'))) { if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) {
return accountDir return cacheHardlinkDir
}
const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink')
if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) {
return hardlinkSubdir
}
}
if (existsSync(join(base, 'hardlink.db'))) {
return base
}
} }
return null return null
} }
@@ -396,8 +357,7 @@ export class ImageDecryptService {
existsSync(join(dirPath, 'hardlink.db')) || existsSync(join(dirPath, 'hardlink.db')) ||
existsSync(join(dirPath, 'db_storage')) || existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) || existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2')) || existsSync(join(dirPath, 'FileStorage', 'Image2'))
existsSync(join(dirPath, 'msg', 'attach'))
) )
} }
@@ -435,14 +395,35 @@ export class ImageDecryptService {
const allowThumbnail = options?.allowThumbnail ?? true const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false const skipResolvedCache = options?.skipResolvedCache ?? false
this.logInfo('[ImageDecrypt] resolveDatPath', { this.logInfo('[ImageDecrypt] resolveDatPath', {
accountDir,
imageMd5, imageMd5,
imageDatName, imageDatName,
sessionId,
allowThumbnail, allowThumbnail,
skipResolvedCache 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 查询 // 优先通过 hardlink.db 查询
if (imageMd5) { if (imageMd5) {
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
@@ -463,12 +444,6 @@ export class ImageDecryptService {
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return 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不进行全局搜索 // 没找到高清图,返回 null不进行全局搜索
return null return null
} }
@@ -486,16 +461,9 @@ export class ImageDecryptService {
// 找到缩略图但要求高清图,尝试同目录查找高清图变体 // 找到缩略图但要求高清图,尝试同目录查找高清图变体
const hdPath = this.findHdVariantInSameDir(fallbackPath) const hdPath = this.findHdVariantInSameDir(fallbackPath)
if (hdPath) { if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
this.cacheDatPath(accountDir, imageDatName, hdPath) this.cacheDatPath(accountDir, imageDatName, hdPath)
return 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 return null
} }
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
@@ -518,17 +486,15 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, hdPath) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath return hdPath
} }
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false)
if (hdInDir) {
this.cacheDatPath(accountDir, imageDatName, hdInDir)
return hdInDir
}
return null return null
} }
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
} }
// force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图 // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
if (!allowThumbnail) {
return null
}
if (!imageDatName) return null if (!imageDatName) return null
if (!skipResolvedCache) { if (!skipResolvedCache) {
@@ -538,8 +504,6 @@ export class ImageDecryptService {
// 缓存的是缩略图,尝试找高清图 // 缓存的是缩略图,尝试找高清图
const hdPath = this.findHdVariantInSameDir(cached) const hdPath = this.findHdVariantInSameDir(cached)
if (hdPath) return hdPath 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(() => { }) }).catch(() => { })
} }
private looksLikeMd5(value: string): boolean {
return /^[a-fA-F0-9]{16,32}$/.test(value)
}
private resolveHardlinkDbPath(accountDir: string): string | null { private resolveHardlinkDbPath(accountDir: string): string | null {
const wxid = this.configService.get('myWxid') const wxid = this.configService.get('myWxid')
@@ -858,7 +820,7 @@ export class ImageDecryptService {
* 包含1. 微信旧版结构 filename.substr(0, 2)/... * 包含1. 微信旧版结构 filename.substr(0, 2)/...
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename * 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
*/ */
private async fastProbabilisticSearch(root: string, datName: string): Promise<string | null> { private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise<string | null> {
const { promises: fs } = require('fs') const { promises: fs } = require('fs')
const { join } = require('path') const { join } = require('path')
@@ -894,7 +856,7 @@ export class ImageDecryptService {
} catch { } } catch { }
} }
// --- 策略 B: 新版 Session 哈希路径猜测 --- // --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 ---
try { try {
const entries = await fs.readdir(root, { withFileTypes: true }) const entries = await fs.readdir(root, { withFileTypes: true })
const sessionDirs = entries 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 { private findHdVariantInSameDir(thumbPath: string): string | null {
try { 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 { private normalizeDatBase(name: string): string {
let base = name.toLowerCase() let base = name.toLowerCase()
if (base.endsWith('.dat') || base.endsWith('.jpg')) { if (base.endsWith('.dat') || base.endsWith('.jpg')) {
@@ -1089,27 +1002,16 @@ export class ImageDecryptService {
return base return base
} }
private sanitizeDirName(name: string): string { private hasImageVariantSuffix(baseLower: string): boolean {
const trimmed = name.trim() return /[._][a-z]$/.test(baseLower)
if (!trimmed) return 'unknown'
return trimmed.replace(/[<>:"/\\|?*]/g, '_')
} }
private resolveTimeDir(datPath: string): string { private isLikelyImageDatBase(baseLower: string): boolean {
const parts = datPath.split(/[\\/]+/) return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
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 findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null { private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
const allRoots = this.getAllCacheRoots() const allRoots = this.getAllCacheRoots()
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
@@ -1344,14 +1246,14 @@ export class ImageDecryptService {
private async ensureCacheIndexed(): Promise<void> { private async ensureCacheIndexed(): Promise<void> {
if (this.cacheIndexed) return if (this.cacheIndexed) return
if (this.cacheIndexing) return this.cacheIndexing if (this.cacheIndexing) return this.cacheIndexing
this.cacheIndexing = new Promise((resolve) => { this.cacheIndexing = (async () => {
// 扫描所有可能的缓存根目录 // 扫描所有可能的缓存根目录
const allRoots = this.getAllCacheRoots() const allRoots = this.getAllCacheRoots()
this.logInfo('开始索引缓存', { roots: allRoots.length }) this.logInfo('开始索引缓存', { roots: allRoots.length })
for (const root of allRoots) { for (const root of allRoots) {
try { try {
this.indexCacheDir(root, 3, 0) // 增加深度到3支持 sessionId/YYYY-MM 结构 this.indexCacheDir(root, 3, 0) // 增加深度到 3支持 sessionId/YYYY-MM 结构
} catch (e) { } catch (e) {
this.logError('索引目录失败', e, { root }) this.logError('索引目录失败', e, { root })
} }
@@ -1360,8 +1262,7 @@ export class ImageDecryptService {
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size }) this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
this.cacheIndexed = true this.cacheIndexed = true
this.cacheIndexing = null this.cacheIndexing = null
resolve() })()
})
return this.cacheIndexing return this.cacheIndexing
} }
@@ -1564,14 +1465,14 @@ export class ImageDecryptService {
private bytesToInt32(bytes: Buffer): number { private bytesToInt32(bytes: Buffer): number {
if (bytes.length !== 4) { if (bytes.length !== 4) {
throw new Error('需要4个字节') throw new Error('需要 4 个字节')
} }
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24) return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
} }
asciiKey16(keyString: string): Buffer { asciiKey16(keyString: string): Buffer {
if (keyString.length < 16) { if (keyString.length < 16) {
throw new Error('AES密钥至少需要16个字符') throw new Error('AES密钥至少需要 16 个字符')
} }
return Buffer.from(keyString, 'ascii').subarray(0, 16) return Buffer.from(keyString, 'ascii').subarray(0, 16)
} }
@@ -1738,76 +1639,6 @@ export class ImageDecryptService {
return mostCommonKey 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<string | null> {
// 只处理 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 格式
* wxgf 是微信的图片格式,内部使用 HEVC 编码 * wxgf 是微信的图片格式,内部使用 HEVC 编码
@@ -1854,7 +1685,7 @@ export class ImageDecryptService {
} }
/** /**
* wxgf 数据中提取 HEVC NALU 裸流 * 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦
*/ */
private extractHevcNalu(buffer: Buffer): Buffer | null { private extractHevcNalu(buffer: Buffer): Buffer | null {
const nalUnits: Buffer[] = [] 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<void> { async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
const version = this.getDatVersion(inputPath) const version = this.getDatVersion(inputPath)
@@ -2018,7 +1887,7 @@ export class ImageDecryptService {
decrypted = this.decryptDatV4(inputPath, xorKey, key) decrypted = this.decryptDatV4(inputPath, xorKey, key)
} else { } else {
if (!aesKey || aesKey.length !== 16) { if (!aesKey || aesKey.length !== 16) {
throw new Error('V4版本需要16字节AES密钥') throw new Error('V4版本需要 16 字节 AES 密钥')
} }
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey) decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
} }

View File

@@ -1,9 +1,8 @@
import { app } from 'electron' import { app } from 'electron'
import { join, dirname, basename } from 'path' import { join, dirname } from 'path'
import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs' import { existsSync, copyFileSync, mkdirSync } from 'fs'
import { execFile, spawn } from 'child_process' import { execFile, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import { Worker } from 'worker_threads'
import os from 'os' import os from 'os'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -20,13 +19,14 @@ export class KeyService {
private getStatusMessage: any = null private getStatusMessage: any = null
private cleanupHook: any = null private cleanupHook: any = null
private getLastErrorMsg: any = null private getLastErrorMsg: any = null
private getImageKeyDll: any = null
// Win32 APIs // Win32 APIs
private kernel32: any = null private kernel32: any = null
private user32: any = null private user32: any = null
private advapi32: any = null private advapi32: any = null
// Kernel32 (已移除内存扫描相关的 API) // Kernel32
private OpenProcess: any = null private OpenProcess: any = null
private CloseHandle: any = null private CloseHandle: any = null
private TerminateProcess: 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.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)')
this.cleanupHook = this.lib.func('bool CleanupHook()') this.cleanupHook = this.lib.func('bool CleanupHook()')
this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()') this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()')
this.getImageKeyDll = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)')
this.initialized = true this.initialized = true
return true return true
@@ -145,8 +146,6 @@ export class KeyService {
try { try {
this.koffi = require('koffi') this.koffi = require('koffi')
this.kernel32 = this.koffi.load('kernel32.dll') this.kernel32 = this.koffi.load('kernel32.dll')
// 直接使用原生支持的 'void*' 替换 'HANDLE',绝对不会再报类型错误
this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32']) this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*']) this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32']) this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32'])
@@ -638,365 +637,68 @@ export class KeyService {
return { success: false, error: '获取密钥超时', logs } return { success: false, error: '获取密钥超时', logs }
} }
// --- Image Key Stuff (Refactored to Multi-core Crypto Brute Force) --- // --- Image Key (通过 DLL 从缓存目录直接获取) ---
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<number, number>()
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<string | null> {
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)
})
}
})
}
async autoGetImageKey( async autoGetImageKey(
manualDir?: string, manualDir?: string,
onProgress?: (message: string) => void onProgress?: (message: string) => void
): Promise<ImageKeyResult> { ): Promise<ImageKeyResult> {
onProgress?.('正在定位微信账号数据目录...') if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
const accountDir = this.resolveAccountDir(manualDir) if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
if (!accountDir) return { success: false, error: '未找到微信账号目录' }
let wxid = basename(accountDir) onProgress?.('正在从缓存目录扫描图片密钥...')
wxid = wxid.replace(/_[0-9a-fA-F]{4}$/, '')
onProgress?.('正在收集并分析加密模板文件...') const resultBuffer = Buffer.alloc(8192)
const templateFiles = this.findTemplateDatFiles(accountDir) const ok = this.getImageKeyDll(resultBuffer, resultBuffer.length)
if (!templateFiles.length) return { success: false, error: '未找到模板文件' }
onProgress?.('正在计算特征 XOR 密钥...') if (!ok) {
const xorKey = this.getXorKey(templateFiles) const errMsg = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '获取图片密钥失败'
if (xorKey == null) return { success: false, error: '无法计算 XOR 密钥' } return { success: false, error: errMsg }
}
onProgress?.('正在读取加密模板区块...') const jsonStr = this.decodeUtf8(resultBuffer)
const ciphertexts = this.getCiphertextsFromTemplate(templateFiles) let parsed: any
if (ciphertexts.length < 2) return { success: false, error: '可用的加密样本不足至少需要2个请确认账号目录下有足够的模板图片' } try {
parsed = JSON.parse(jsonStr)
} catch {
return { success: false, error: '解析密钥数据失败' }
}
onProgress?.(`成功提取 ${ciphertexts.length} 个特征样本,准备交叉校验...`) // 从 manualDir 中提取 wxid 用于精确匹配
onProgress?.(`准备启动 ${os.cpus().length || 4} 线程并发爆破引擎 (基于 wxid: ${wxid})...`) // 前端传入的格式是 dbPath/wxid_xxx_1234取最后一段目录名再清理后缀
let targetWxid: string | null = null
const aesKey = await this.bruteForceAesKey(xorKey, wxid, ciphertexts, (msg) => { if (manualDir) {
onProgress?.(msg) const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
}) // 与 DLL 的 CleanWxid 逻辑一致wxid_a_b_c → wxid_a
const parts = dirName.split('_')
if (!aesKey) { if (parts.length >= 3 && parts[0] === 'wxid') {
return { targetWxid = `${parts[0]}_${parts[1]}`
success: false, } else if (dirName.startsWith('wxid_')) {
error: 'AES 密钥爆破失败,请确认该账号近期是否有接收过图片,或更换账号目录重试' 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
}
} }
} }

View File

@@ -235,9 +235,23 @@ class VideoService {
const videoPath = join(dirPath, `${realVideoMd5}.mp4`) const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
if (existsSync(videoPath)) { if (existsSync(videoPath)) {
this.log('找到视频', { videoPath }) // 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw但封面不带
const coverPath = join(dirPath, `${realVideoMd5}.jpg`) const baseMd5 = realVideoMd5.replace(/_raw$/, '')
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) 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 { return {
videoUrl: videoPath, videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
@@ -247,11 +261,28 @@ class VideoService {
} }
} }
// 没找到,列出第一个目录里的文件帮助排查 // 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个)
if (yearMonthDirs.length > 0) { this.log('未找到视频,开始全目录扫描', {
const firstDir = join(videoBaseDir, yearMonthDirs[0]) lookingForOriginal: `${videoMd5}.mp4`,
const files = readdirSync(firstDir).filter(f => f.endsWith('.mp4')).slice(0, 5) lookingForResolved: `${realVideoMd5}.mp4`,
this.log('未找到视频,最新目录样本', { dir: yearMonthDirs[0], sampleFiles: files, lookingFor: `${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) { } catch (e) {
this.log('getVideoInfo 遍历出错', { error: String(e) }) this.log('getVideoInfo 遍历出错', { error: String(e) })
@@ -265,41 +296,59 @@ class VideoService {
* 根据消息内容解析视频MD5 * 根据消息内容解析视频MD5
*/ */
parseVideoMd5(content: string): string | undefined { parseVideoMd5(content: string): string | undefined {
// 打印前500字符看看 XML 结构
if (!content) return undefined if (!content) return undefined
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
try { try {
// 提取所有可能的 md5 值进行日志 // 收集所有 md5 相关属性,方便对比
const allMd5s: string[] = [] const allMd5Attrs: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
let match let match
while ((match = md5Regex.exec(content)) !== null) { while ((match = md5Regex.exec(content)) !== null) {
allMd5s.push(`${match[0]}`) allMd5Attrs.push(match[0])
}
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
// 方法1从 <videomsg md5="..."> 提取(收到的视频)
const videoMsgMd5Match = /<videomsg[^>]*\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 // 方法2从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5
// 注意:不是 rawmd5rawmd5 是另一个值 const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
// 格式: md5="xxx" 或 <md5>xxx</md5> if (rawMd5Match) {
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
// 尝试从videomsg标签中提取md5 return rawMd5Match[1].toLowerCase()
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMatch) {
return videoMsgMatch[1].toLowerCase()
} }
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) // 方法3任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) { if (attrMatch) {
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
return attrMatch[1].toLowerCase() return attrMatch[1].toLowerCase()
} }
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content) // 方法4<md5>...</md5> 标签
if (md5Match) { const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
return md5Match[1].toLowerCase() 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) { } catch (e) {
console.error('[VideoService] 解析视频MD5失败:', e) this.log('parseVideoMd5 异常', { error: String(e) })
} }
return undefined return undefined

Binary file not shown.

View File

@@ -257,7 +257,8 @@ export function GlobalSessionMonitor() {
const handleActiveSessionRefresh = async (sessionId: string) => { const handleActiveSessionRefresh = async (sessionId: string) => {
// 从 ChatPage 复制/调整的逻辑,以保持集中 // 从 ChatPage 复制/调整的逻辑,以保持集中
const state = useChatStore.getState() 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 const minTime = lastMsg?.createTime || 0
try { try {

View File

@@ -48,18 +48,26 @@
backdrop-filter: none !important; backdrop-filter: none !important;
-webkit-backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
// 确保背景完全不透明(通知是独立窗口,透明背景会穿透) // 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
background: var(--bg-secondary-solid, var(--bg-secondary, #2c2c2c)); background: #ffffff;
color: var(--text-primary, #ffffff); color: #3d3d3d;
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
--border-light: rgba(0, 0, 0, 0.08);
// 色模式强制完全不透明白色背景 // 色模式覆盖
[data-mode="light"] &, [data-mode="dark"] & {
:not([data-mode]) & { background: var(--bg-secondary-solid, #282420);
background: #ffffff !important; 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 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; display: flex;
padding: 16px; padding: 16px;

View File

@@ -768,7 +768,7 @@ function ChatPage(_props: ChatPageProps) {
setIsRefreshingMessages(true) setIsRefreshingMessages(true)
// 找出当前已渲染消息中的最大时间戳(使用 getState 获取最新状态,避免闭包过时导致重复) // 找出当前已渲染消息中的最大时间戳(使用 getState 获取最新状态,避免闭包过时导致重复)
const currentMessages = useChatStore.getState().messages const currentMessages = useChatStore.getState().messages || []
const lastMsg = currentMessages[currentMessages.length - 1] const lastMsg = currentMessages[currentMessages.length - 1]
const minTime = lastMsg?.createTime || 0 const minTime = lastMsg?.createTime || 0
@@ -782,7 +782,7 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.messages && result.messages.length > 0) { if (result.success && result.messages && result.messages.length > 0) {
// 过滤去重:必须对比实时的状态,防止在 handleRefreshMessages 运行期间导致的冲突 // 过滤去重:必须对比实时的状态,防止在 handleRefreshMessages 运行期间导致的冲突
const latestMessages = useChatStore.getState().messages const latestMessages = useChatStore.getState().messages || []
const existingKeys = new Set(latestMessages.map(getMessageKey)) const existingKeys = new Set(latestMessages.map(getMessageKey))
const newOnes = result.messages.filter(m => !existingKeys.has(getMessageKey(m))) const newOnes = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
@@ -823,7 +823,7 @@ function ChatPage(_props: ChatPageProps) {
return return
} }
// 使用实时状态进行去重对比 // 使用实时状态进行去重对比
const latestMessages = useChatStore.getState().messages const latestMessages = useChatStore.getState().messages || []
const existing = new Set(latestMessages.map(getMessageKey)) const existing = new Set(latestMessages.map(getMessageKey))
const lastMsg = latestMessages[latestMessages.length - 1] const lastMsg = latestMessages[latestMessages.length - 1]
const lastTime = lastMsg?.createTime ?? 0 const lastTime = lastMsg?.createTime ?? 0
@@ -1751,7 +1751,7 @@ function ChatPage(_props: ChatPageProps) {
// Range selection with Shift key // Range selection with Shift key
if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) { 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 idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current)
const idx2 = currentMsgs.findIndex(m => m.localId === localId) const idx2 = currentMsgs.findIndex(m => m.localId === localId)
@@ -1821,7 +1821,7 @@ function ChatPage(_props: ChatPageProps) {
const dbPathHint = (msg as any)._db_path const dbPathHint = (msg as any)._db_path
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint) const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint)
if (result.success) { if (result.success) {
const currentMessages = useChatStore.getState().messages const currentMessages = useChatStore.getState().messages || []
const newMessages = currentMessages.filter(m => m.localId !== msg.localId) const newMessages = currentMessages.filter(m => m.localId !== msg.localId)
useChatStore.getState().setMessages(newMessages) useChatStore.getState().setMessages(newMessages)
} else { } else {
@@ -1882,7 +1882,7 @@ function ChatPage(_props: ChatPageProps) {
try { try {
const result = await (window as any).electronAPI.chat.updateMessage(currentSessionId, editingMessage.message.localId, editingMessage.message.createTime, finalContent) const result = await (window as any).electronAPI.chat.updateMessage(currentSessionId, editingMessage.message.localId, editingMessage.message.createTime, finalContent)
if (result.success) { if (result.success) {
const currentMessages = useChatStore.getState().messages const currentMessages = useChatStore.getState().messages || []
const newMessages = currentMessages.map(m => { const newMessages = currentMessages.map(m => {
if (m.localId === editingMessage.message.localId) { if (m.localId === editingMessage.message.localId) {
return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent } return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent }
@@ -1924,7 +1924,7 @@ function ChatPage(_props: ChatPageProps) {
cancelDeleteRef.current = false cancelDeleteRef.current = false
try { try {
const currentMessages = useChatStore.getState().messages const currentMessages = useChatStore.getState().messages || []
const selectedIds = Array.from(selectedMessages) const selectedIds = Array.from(selectedMessages)
const deletedIds = new Set<number>() const deletedIds = new Set<number>()
@@ -1948,7 +1948,7 @@ function ChatPage(_props: ChatPageProps) {
setDeleteProgress({ current: i + 1, total: selectedIds.length }) 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) useChatStore.getState().setMessages(finalMessages)
setIsSelectionMode(false) setIsSelectionMode(false)
@@ -2137,7 +2137,7 @@ function ChatPage(_props: ChatPageProps) {
<div className="empty-sessions"> <div className="empty-sessions">
<MessageSquare /> <MessageSquare />
<p></p> <p></p>
<p className="hint"></p> <p className="hint"></p>
</div> </div>
)} )}
</div> </div>
@@ -2342,7 +2342,7 @@ function ChatPage(_props: ChatPageProps) {
</div> </div>
)} )}
{messages.map((msg, index) => { {(messages || []).map((msg, index) => {
const prevMsg = index > 0 ? messages[index - 1] : undefined const prevMsg = index > 0 ? messages[index - 1] : undefined
const showDateDivider = shouldShowDateDivider(msg, prevMsg) const showDateDivider = shouldShowDateDivider(msg, prevMsg)

View File

@@ -1,11 +1,9 @@
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef } from 'react'
import { NotificationToast, type NotificationData } from '../components/NotificationToast' import { NotificationToast, type NotificationData } from '../components/NotificationToast'
import { useThemeStore } from '../stores/themeStore'
import '../components/NotificationToast.scss' import '../components/NotificationToast.scss'
import './NotificationWindow.scss' import './NotificationWindow.scss'
export default function NotificationWindow() { export default function NotificationWindow() {
const { currentTheme, themeMode } = useThemeStore()
const [notification, setNotification] = useState<NotificationData | null>(null) const [notification, setNotification] = useState<NotificationData | null>(null)
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null) const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
@@ -19,12 +17,6 @@ export default function NotificationWindow() {
const notificationRef = useRef<NotificationData | null>(null) const notificationRef = useRef<NotificationData | null>(null)
// 应用主题到通知窗口
useEffect(() => {
document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', themeMode)
}, [currentTheme, themeMode])
useEffect(() => { useEffect(() => {
notificationRef.current = notification notificationRef.current = notification
}, [notification]) }, [notification])

View File

@@ -774,7 +774,7 @@ function SettingsPage() {
} }
setIsFetchingImageKey(true); setIsFetchingImageKey(true);
setImageKeyPercent(0) setImageKeyPercent(0)
setImageKeyStatus('正在初始化多核爆破引擎...'); setImageKeyStatus('正在初始化...');
setImageKeyProgress(0); // 重置进度 setImageKeyProgress(0); // 重置进度
try { try {
@@ -1377,19 +1377,19 @@ function SettingsPage() {
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} <Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button> </button>
{isFetchingImageKey ? ( {isFetchingImageKey ? (
<div className="brute-force-progress"> <div className="brute-force-progress">
<div className="status-header"> <div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动多核爆破引擎...'}</span> <span className="status-text">{imageKeyStatus || '正在启动...'}</span>
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>} {imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
</div>
{imageKeyPercent !== null && (
<div className="progress-bar-container">
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
</div>
)}
</div> </div>
{imageKeyPercent !== null && (
<div className="progress-bar-container">
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
</div>
)}
</div>
) : ( ) : (
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div> imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
)} )}
</div> </div>
@@ -2113,8 +2113,8 @@ function SettingsPage() {
<label></label> <label></label>
<span className="form-hint">{ <span className="form-hint">{
isLockMode ? '已开启' : isLockMode ? '已开启' :
authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' : authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' :
'未开启 — 请设置密码以开启' '未开启 — 请设置密码以开启'
}</span> }</span>
</div> </div>
{authEnabled && !showDisableLockInput && ( {authEnabled && !showDisableLockInput && (

View File

@@ -746,52 +746,52 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
)} )}
{currentStep.id === 'image' && ( {currentStep.id === 'image' && (
<div className="form-group"> <div className="form-group">
<div className="grid-2"> <div className="grid-2">
<div> <div>
<label className="field-label"> XOR </label> <label className="field-label"> XOR </label>
<input <input
type="text" type="text"
className="field-input" className="field-input"
placeholder="0x..." placeholder="0x..."
value={imageXorKey} value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)} onChange={(e) => setImageXorKey(e.target.value)}
/> />
</div> </div>
<div> <div>
<label className="field-label"> AES </label> <label className="field-label"> AES </label>
<input <input
type="text" type="text"
className="field-input" className="field-input"
placeholder="16位密钥" placeholder="16位密钥"
value={imageAesKey} value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)} onChange={(e) => setImageAesKey(e.target.value)}
/> />
</div>
</div> </div>
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
{isFetchingImageKey ? (
<div className="brute-force-progress">
<div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动多核爆破引擎...'}</span>
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
</div>
{imageKeyPercent !== null && (
<div className="progress-bar-container">
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
</div>
)}
</div>
) : (
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
)}
<div className="field-hint"></div>
</div> </div>
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
{isFetchingImageKey ? (
<div className="brute-force-progress">
<div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
</div>
{imageKeyPercent !== null && (
<div className="progress-bar-container">
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
</div>
)}
</div>
) : (
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
)}
<div className="field-hint"></div>
</div>
)} )}
</div> </div>

View File

@@ -86,15 +86,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (m.localId && m.localId > 0) return `l:${m.localId}` if (m.localId && m.localId > 0) return `l:${m.localId}`
return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}` 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))) const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m)))
if (filtered.length === 0) return state if (filtered.length === 0) return state
return { return {
messages: prepend messages: prepend
? [...filtered, ...state.messages] ? [...filtered, ...currentMessages]
: [...state.messages, ...filtered] : [...currentMessages, ...filtered]
} }
}), }),