Merge pull request #327 from StarsUnsurpass/main

还原了原有的视频解密逻辑
This commit is contained in:
cc
2026-02-28 19:27:39 +08:00
committed by GitHub
8 changed files with 307 additions and 340 deletions

View File

@@ -194,12 +194,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
}
},
// 视频
video: {
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
// 视频
video: {
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
// 数据分析
analytics: {
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),

View File

@@ -4478,27 +4478,77 @@ class ChatService {
}
private resolveAccountDir(dbPath: string, wxid: string): string | null {
const normalized = dbPath.replace(/[\\\\/]+$/, '')
const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase()
const normalized = dbPath.replace(/[\\/]+$/, '')
// 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件)
// 则向上回溯到账号目录
if (basename(normalized).toLowerCase() === 'db_storage') {
return dirname(normalized)
}
const dir = dirname(normalized)
if (basename(dir).toLowerCase() === 'db_storage') {
return dirname(dir)
const candidates: { path: string; mtime: number }[] = []
// 检查直接路径
const direct = join(normalized, cleanedWxid)
if (existsSync(direct) && this.isAccountDir(direct)) {
candidates.push({ path: direct, mtime: this.getDirMtime(direct) })
}
// 否则,dbPath 应该是数据库根目录(如 xwechat_files
// 账号目录应该是 {dbPath}/{wxid}
const accountDirWithWxid = join(normalized, wxid)
if (existsSync(accountDirWithWxid)) {
return accountDirWithWxid
// 检查 dbPath 本身是否就是账号目录
if (this.isAccountDir(normalized)) {
candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) })
}
// 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录)
return normalized
// 扫描 dbPath 下的所有子目录寻找匹配的 wxid
try {
if (existsSync(normalized) && statSync(normalized).isDirectory()) {
const entries = readdirSync(normalized)
for (const entry of entries) {
const entryPath = join(normalized, entry)
try {
if (!statSync(entryPath).isDirectory()) continue
} catch { continue }
const lowerEntry = entry.toLowerCase()
if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) {
if (this.isAccountDir(entryPath)) {
if (!candidates.some(c => c.path === entryPath)) {
candidates.push({ path: entryPath, mtime: this.getDirMtime(entryPath) })
}
}
}
}
}
} catch { }
if (candidates.length === 0) return null
// 按修改时间降序排序,取最新的
candidates.sort((a, b) => b.mtime - a.mtime)
return candidates[0].path
}
private isAccountDir(dirPath: string): boolean {
return (
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
existsSync(join(dirPath, 'msg', 'attach'))
)
}
private getDirMtime(dirPath: string): number {
try {
const stat = statSync(dirPath)
let mtime = stat.mtimeMs
const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image']
for (const sub of subDirs) {
const fullPath = join(dirPath, sub)
if (existsSync(fullPath)) {
try {
mtime = Math.max(mtime, statSync(fullPath).mtimeMs)
} catch { }
}
}
return mtime
} catch {
return 0
}
}
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {

View File

@@ -77,7 +77,8 @@ export class DbPathService {
return (
existsSync(join(entryPath, 'db_storage')) ||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
existsSync(join(entryPath, 'FileStorage', 'Image2')) ||
existsSync(join(entryPath, 'msg', 'attach'))
)
}
@@ -94,22 +95,21 @@ export class DbPathService {
const accountStat = statSync(entryPath)
let latest = accountStat.mtimeMs
const dbPath = join(entryPath, 'db_storage')
if (existsSync(dbPath)) {
const dbStat = statSync(dbPath)
latest = Math.max(latest, dbStat.mtimeMs)
}
const checkSubDirs = [
'db_storage',
join('FileStorage', 'Image'),
join('FileStorage', 'Image2'),
join('msg', 'attach')
]
const imagePath = join(entryPath, 'FileStorage', 'Image')
if (existsSync(imagePath)) {
const imageStat = statSync(imagePath)
latest = Math.max(latest, imageStat.mtimeMs)
}
const image2Path = join(entryPath, 'FileStorage', 'Image2')
if (existsSync(image2Path)) {
const image2Stat = statSync(image2Path)
latest = Math.max(latest, image2Stat.mtimeMs)
for (const sub of checkSubDirs) {
const fullPath = join(entryPath, sub)
if (existsSync(fullPath)) {
try {
const s = statSync(fullPath)
latest = Math.max(latest, s.mtimeMs)
} catch { }
}
}
return latest

View File

@@ -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<void> | null = null
private updateFlags = new Map<string, boolean>()
private noLiveSet = new Set<string>() // 已确认无 live 视频的图片路径
private logInfo(message: string, meta?: Record<string, unknown>): 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,14 +280,14 @@ export class ImageDecryptService {
await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length })
// 对于 hevc 格式,返回错误提示
if (finalExt === '.hevc') {
return {
success: false,
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
error: '此图片为微信新格式 (wxgf),需要安装 ffmpeg 才能显示',
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<string | null> {
private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise<string | null> {
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<void> {
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<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 是微信的图片格式,内部使用 HEVC 编码
@@ -1851,7 +1682,7 @@ export class ImageDecryptService {
}
/**
* wxgf 数据中提取 HEVC NALU 裸流
* 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦
*/
private extractHevcNalu(buffer: Buffer): Buffer | null {
const nalUnits: Buffer[] = []
@@ -1961,6 +1792,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> {
const version = this.getDatVersion(inputPath)
@@ -1973,7 +1842,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)
}

View File

@@ -744,7 +744,8 @@ export class KeyService {
return (
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
existsSync(join(dirPath, 'msg', 'attach'))
)
}
@@ -761,8 +762,8 @@ export class KeyService {
private listAccountDirs(rootDir: string): string[] {
try {
const entries = readdirSync(rootDir)
const high: string[] = []
const low: string[] = []
const candidates: { path: string; mtime: number; isAccount: boolean }[] = []
for (const entry of entries) {
const fullPath = join(rootDir, entry)
try {
@@ -775,18 +776,48 @@ export class KeyService {
continue
}
if (this.isAccountDir(fullPath)) {
high.push(fullPath)
} else {
low.push(fullPath)
}
const isAccount = this.isAccountDir(fullPath)
candidates.push({
path: fullPath,
mtime: this.getDirMtime(fullPath),
isAccount
})
}
return high.length ? high.sort() : low.sort()
// 优先选择有效账号目录,然后按修改时间从新到旧排序
return candidates
.sort((a, b) => {
if (a.isAccount !== b.isAccount) return a.isAccount ? -1 : 1
return b.mtime - a.mtime
})
.map(c => c.path)
} catch {
return []
}
}
private getDirMtime(dirPath: string): number {
try {
const stat = statSync(dirPath)
let mtime = stat.mtimeMs
// 检查几个关键子目录的修改时间,以更准确地反映活动状态
const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image']
for (const sub of subDirs) {
const fullPath = join(dirPath, sub)
if (existsSync(fullPath)) {
try {
mtime = Math.max(mtime, statSync(fullPath).mtimeMs)
} catch { }
}
}
return mtime
} catch {
return 0
}
}
private normalizeExistingDir(inputPath: string): string | null {
const trimmed = inputPath.replace(/[\\\\/]+$/, '')
if (!existsSync(trimmed)) return null

View File

@@ -1,4 +1,4 @@
import { join } from 'path'
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { ConfigService } from './config'
import Database from 'better-sqlite3'
@@ -36,7 +36,7 @@ class VideoService {
* 获取缓存目录(解密后的数据库存放位置)
*/
private getCachePath(): string {
return this.configService.getCacheBasePath()
return this.configService.get('cachePath') || ''
}
/**
@@ -69,6 +69,9 @@ class VideoService {
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
console.log('[VideoService] queryVideoFileName called with MD5:', md5)
console.log('[VideoService] cachePath:', cachePath, 'dbPath:', dbPath, 'wxid:', wxid, 'cleanedWxid:', cleanedWxid)
if (!wxid) return undefined
// 方法1优先在 cachePath 下查找解密后的 hardlink.db
@@ -83,6 +86,7 @@ class VideoService {
for (const p of cacheDbPaths) {
if (existsSync(p)) {
console.log('[VideoService] Found decrypted hardlink.db at:', p)
try {
const db = new Database(p, { readonly: true })
const row = db.prepare(`
@@ -94,10 +98,11 @@ class VideoService {
if (row?.file_name) {
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
console.log('[VideoService] Found video filename via cache:', realMd5)
return realMd5
}
} catch (e) {
// 忽略错误
console.log('[VideoService] Failed to query cached hardlink.db:', e)
}
}
}
@@ -105,46 +110,41 @@ class VideoService {
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
// 检查 dbPath 是否已经包含 wxid
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
const encryptedDbPaths: string[] = []
if (dbPathContainsWxid) {
// dbPath 已包含 wxid不需要再拼接
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
} else {
// dbPath 不包含 wxid需要拼接
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
}
const encryptedDbPaths = [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
]
for (const p of encryptedDbPaths) {
if (existsSync(p)) {
console.log('[VideoService] Found encrypted hardlink.db at:', p)
try {
const escapedMd5 = md5.replace(/'/g, "''")
// 用 md5 字段查询,获取 file_name
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
console.log('[VideoService] Query SQL:', sql)
const result = await wcdbService.execQuery('media', p, sql)
console.log('[VideoService] Query result:', result)
if (result.success && result.rows && result.rows.length > 0) {
const row = result.rows[0]
if (row?.file_name) {
// 提取不带扩展名的文件名作为实际视频 MD5
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
console.log('[VideoService] Found video filename:', realMd5)
return realMd5
}
}
} catch (e) {
// 忽略错误
console.log('[VideoService] Failed to query encrypted hardlink.db via wcdbService:', e)
}
}
}
}
console.log('[VideoService] No matching video found in hardlink.db')
return undefined
}
@@ -167,37 +167,34 @@ class VideoService {
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
console.log('[VideoService] getVideoInfo called with MD5:', videoMd5)
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
console.log('[VideoService] Config - dbPath:', dbPath, 'wxid:', wxid)
if (!dbPath || !wxid || !videoMd5) {
console.log('[VideoService] Missing required params')
return { exists: false }
}
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
console.log('[VideoService] Real video MD5:', realVideoMd5)
// 检查 dbPath 是否已经包含 wxid避免重复拼接
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxid = this.cleanWxid(wxid)
let videoBaseDir: string
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
// dbPath 已经包含 wxid直接使用
videoBaseDir = join(dbPath, 'msg', 'video')
} else {
// dbPath 不包含 wxid需要拼接
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
}
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
console.log('[VideoService] Video base dir:', videoBaseDir)
if (!existsSync(videoBaseDir)) {
console.log('[VideoService] Video base dir does not exist')
return { exists: false }
}
// 遍历年月目录查找视频文件
try {
const allDirs = readdirSync(videoBaseDir)
console.log('[VideoService] Found year-month dirs:', allDirs)
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
const yearMonthDirs = allDirs
@@ -214,8 +211,11 @@ class VideoService {
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
console.log('[VideoService] Checking:', videoPath)
// 检查视频文件是否存在
if (existsSync(videoPath)) {
console.log('[VideoService] Video file found!')
return {
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
@@ -224,8 +224,10 @@ class VideoService {
}
}
}
console.log('[VideoService] Video file not found in any directory')
} catch (e) {
// 忽略错误
console.error('[VideoService] Error searching for video:', e)
}
return { exists: false }
@@ -235,8 +237,10 @@ class VideoService {
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
console.log('[VideoService] parseVideoMd5 called, content length:', content?.length)
// 打印前500字符看看 XML 结构
console.log('[VideoService] XML preview:', content?.substring(0, 500))
if (!content) return undefined
@@ -248,6 +252,7 @@ class VideoService {
while ((match = md5Regex.exec(content)) !== null) {
allMd5s.push(`${match[0]}`)
}
console.log('[VideoService] All MD5 attributes found:', allMd5s)
// 提取 md5用于查询 hardlink.db
// 注意:不是 rawmd5rawmd5 是另一个值
@@ -256,18 +261,23 @@ class VideoService {
// 尝试从videomsg标签中提取md5
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMatch) {
console.log('[VideoService] Found MD5 via videomsg:', videoMsgMatch[1])
return videoMsgMatch[1].toLowerCase()
}
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
console.log('[VideoService] Found MD5 via attribute:', attrMatch[1])
return attrMatch[1].toLowerCase()
}
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5Match) {
console.log('[VideoService] Found MD5 via <md5> tag:', md5Match[1])
return md5Match[1].toLowerCase()
}
console.log('[VideoService] No MD5 found in content')
} catch (e) {
console.error('[VideoService] 解析视频MD5失败:', e)
}

19
package-lock.json generated
View File

@@ -80,6 +80,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -2909,6 +2910,7 @@
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3055,6 +3057,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -3994,6 +3997,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5103,6 +5107,7 @@
"integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "25.1.8",
"builder-util": "25.1.7",
@@ -5290,6 +5295,7 @@
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
@@ -5376,7 +5382,6 @@
"integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "25.1.8",
"archiver": "^5.3.1",
@@ -5390,7 +5395,6 @@
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -5406,7 +5410,6 @@
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -5420,7 +5423,6 @@
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -9150,6 +9152,7 @@
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9159,6 +9162,7 @@
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -9593,6 +9597,7 @@
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -10434,6 +10439,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10881,6 +10887,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -10970,7 +10977,8 @@
"resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
@@ -10996,6 +11004,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View File

@@ -3540,13 +3540,12 @@ function MessageBubble({
const requestVideoInfo = useCallback(async () => {
if (!videoMd5 || videoLoadingRef.current) return
videoLoadingRef.current = true
setVideoLoading(true)
try {
const result = await window.electronAPI.video.getVideoInfo(videoMd5)
if (result && result.success && result.exists) {
setVideoInfo({
exists: result.exists,
videoLoadingRef.current = true
setVideoLoading(true)
try {
const result = await window.electronAPI.video.getVideoInfo(videoMd5)
if (result && result.success && result.exists) {
setVideoInfo({ exists: result.exists,
videoUrl: result.videoUrl,
coverUrl: result.coverUrl,
thumbUrl: result.thumbUrl