diff --git a/.gitignore b/.gitignore index 909852e..a4774ec 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,5 @@ WeFolw upx native-dlls MyCoolInstaller -resources/whisper \ No newline at end of file +resources/whisper +xkey diff --git a/README.md b/README.md index c1d6c54..9d59cb7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **一款现代化的微信聊天记录查看与分析工具** [![License](https://img.shields.io/badge/license-CC--BY--NC--SA--4.0-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-2.2.5-green.svg)](package.json) +[![Version](https://img.shields.io/badge/version-2.2.7-green.svg)](package.json) [![Platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows)]() [![Electron](https://img.shields.io/badge/Electron-39-47848F.svg?logo=electron)]() [![React](https://img.shields.io/badge/React-19-61DAFB.svg?logo=react)]() diff --git a/electron/main.ts b/electron/main.ts index ca9ed35..7bd2fe9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -11,7 +11,7 @@ import { dbPathService } from './services/dbPathService' import { wcdbService } from './services/wcdbService' import { dataManagementService } from './services/dataManagementService' import { imageDecryptService } from './services/imageDecryptService' -import { imageKeyService } from './services/imageKeyService' +// imageKeyService 已废弃,图片密钥获取现在通过 wxKeyService.getImageKey() 走 DLL 本地扫描 import { chatService } from './services/chatService' import { analyticsService } from './services/analyticsService' import { groupAnalyticsService } from './services/groupAnalyticsService' @@ -20,6 +20,7 @@ import { exportService, ExportOptions } from './services/exportService' import { activationService } from './services/activationService' import { LogService } from './services/logService' import { videoService } from './services/videoService' + import { voiceTranscribeService } from './services/voiceTranscribeService' import { voiceTranscribeServiceWhisper } from './services/voiceTranscribeServiceWhisper' import { windowsHelloService, WindowsHelloResult } from './services/windowsHelloService' @@ -191,6 +192,7 @@ function createWindow() { win.loadFile(join(__dirname, '../dist/index.html')) } + return win } @@ -345,13 +347,16 @@ function createGroupAnalyticsWindow() { /** * 创建独立的朋友圈窗口 */ -function createMomentsWindow() { - // 如果已存在,聚焦到现有窗口 +function createMomentsWindow(filterUsername?: string) { + // 如果已存在,聚焦到现有窗口并发送筛选 if (momentsWindow && !momentsWindow.isDestroyed()) { if (momentsWindow.isMinimized()) { momentsWindow.restore() } momentsWindow.focus() + if (filterUsername) { + momentsWindow.webContents.send('moments:filterUser', filterUsername) + } return momentsWindow } @@ -389,8 +394,9 @@ function createMomentsWindow() { const themeParams = getThemeQueryParams() // 加载朋友圈页面 + const filterParam = filterUsername ? `&filterUsername=${encodeURIComponent(filterUsername)}` : '' if (process.env.VITE_DEV_SERVER_URL) { - momentsWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}?${themeParams}#/moments-window`) + momentsWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}?${themeParams}${filterParam}#/moments-window`) momentsWindow.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { @@ -403,9 +409,11 @@ function createMomentsWindow() { } }) } else { + const query: Record = { theme: configService?.get('theme') || 'cloud-dancer', mode: configService?.get('themeMode') || 'light' } + if (filterUsername) query.filterUsername = filterUsername momentsWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/moments-window', - query: { theme: configService?.get('theme') || 'cloud-dancer', mode: configService?.get('themeMode') || 'light' } + query }) } @@ -737,14 +745,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { nodeIntegration: false, webSecurity: false // 允许加载本地文件 }, - titleBarStyle: 'hidden', // 无边框 + titleBarStyle: 'hidden', titleBarOverlay: { color: '#00000000', symbolColor: '#ffffff', height: 32 }, show: false, - backgroundColor: '#000000', // 黑色背景 + backgroundColor: '#000000', autoHideMenuBar: true }) @@ -752,15 +760,11 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { win.show() }) - // 获取主题参数 const themeParams = getThemeQueryParams() - - // 加载图片查看页面 const imageParam = `imagePath=${encodeURIComponent(imagePath)}` const liveVideoParam = liveVideoPath ? `&liveVideoPath=${encodeURIComponent(liveVideoPath)}` : '' const queryParams = `${themeParams}&${imageParam}${liveVideoParam}` - if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${queryParams}`) @@ -1247,8 +1251,19 @@ function registerIpcHandlers() { }) // 打开图片查看窗口 - ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string, liveVideoPath?: string) => { - createImageViewerWindow(imagePath, liveVideoPath) + ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string, liveVideoPath?: string, imageList?: Array<{ imagePath: string; liveVideoPath?: string }>) => { + const win = createImageViewerWindow(imagePath, liveVideoPath) + if (imageList && imageList.length > 1) { + const currentIndex = imageList.findIndex(item => item.imagePath === imagePath) + win.webContents.once('did-finish-load', () => { + if (!win.isDestroyed()) { + win.webContents.send('imageViewer:setImageList', { + imageList, + currentIndex: currentIndex >= 0 ? currentIndex : 0 + }) + } + }) + } }) // 打开视频播放窗口 @@ -1723,35 +1738,135 @@ function registerIpcHandlers() { } }) - // 图片密钥获取(从内存) - ipcMain.handle('imageKey:getImageKeys', async (event, userDir: string) => { - logService?.info('ImageKey', '开始获取图片密钥', { userDir }) + // 视频号相关 + ipcMain.handle('video:parseChannelVideo', async (_, content: string) => { try { - // 获取微信 PID - const pid = wxKeyService.getWeChatPid() - if (!pid) { - logService?.error('ImageKey', '微信进程未运行') - return { success: false, error: '微信进程未运行,请先启动微信并登录' } - } + const videoInfo = videoService.parseChannelVideoFromXml(content) + return { success: true, videoInfo } + } catch (e) { + return { success: false, error: String(e) } + } + }) - const result = await imageKeyService.getImageKeys( - userDir, - pid, - (msg) => { - event.sender.send('imageKey:progress', msg) + ipcMain.handle('video:downloadChannelVideo', async (event, videoInfo: any, key?: string) => { + try { + const result = await videoService.downloadChannelVideo( + videoInfo, + key, + (progress) => { + // 发送进度更新到渲染进程 + event.sender.send('video:downloadProgress', { + objectId: videoInfo.objectId, + ...progress + }) } ) + return result + } catch (e: any) { + return { success: false, error: e.message || String(e) } + } + }) - if (result.success) { - logService?.info('ImageKey', '图片密钥获取成功', { - hasXorKey: result.xorKey !== undefined, - hasAesKey: !!result.aesKey - }) - } else { - logService?.error('ImageKey', '图片密钥获取失败', { error: result.error }) + // 图片密钥获取(通过 DLL 从缓存目录获取 code,用前端 wxid 计算密钥) + ipcMain.handle('imageKey:getImageKeys', async (event, userDir: string) => { + logService?.info('ImageKey', '开始获取图片密钥(DLL 本地扫描模式)', { userDir }) + try { + // 初始化 DLL + const initSuccess = await wxKeyService.initialize() + if (!initSuccess) { + logService?.error('ImageKey', 'DLL 初始化失败') + return { success: false, error: 'wx_key.dll 未加载,请确认 DLL 存在' } } - return result + event.sender.send('imageKey:progress', '正在从缓存目录扫描图片密钥...') + + // 调用 DLL 的 GetImageKey + // DLL 会从 kvcomm 缓存目录获取 code(这部分始终正确) + // 但 DLL 的 wxid 发现只搜索固定默认路径,用户自定义存储位置时会找错 + const dllResult = wxKeyService.getImageKey() + if (!dllResult.success || !dllResult.json) { + logService?.error('ImageKey', 'DLL GetImageKey 失败', { error: dllResult.error }) + return { success: false, error: dllResult.error || '获取图片密钥失败' } + } + + // 解析 JSON 结果 + let parsed: any + try { + parsed = JSON.parse(dllResult.json) + } catch { + logService?.error('ImageKey', '解析 DLL 返回数据失败', { json: dllResult.json.substring(0, 200) }) + return { success: false, error: '解析密钥数据失败' } + } + + // 从任意账号提取 code 列表(code 来自 kvcomm,与 wxid 无关,所有账号都一样) + const accounts: any[] = parsed.accounts ?? [] + if (!accounts.length || !accounts[0]?.keys?.length) { + return { success: false, error: '未找到有效的密钥码(kvcomm 缓存为空)' } + } + + const codes: number[] = accounts[0].keys.map((k: any) => k.code) + logService?.info('ImageKey', `提取到 ${codes.length} 个密钥码`, { + codes, + dllFoundWxids: accounts.map((a: any) => a.wxid) + }) + + // 从 userDir 提取前端已配置好的正确 wxid + // 格式: "D:\weixin\xwechat_files\wxid_xxx" → "wxid_xxx" + let targetWxid = '' + if (userDir) { + const dirName = userDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '' + if (dirName.startsWith('wxid_')) { + targetWxid = dirName + } + } + + if (!targetWxid) { + // 无法从 userDir 提取 wxid,回退到 DLL 发现的第一个 + targetWxid = accounts[0].wxid + logService?.warn('ImageKey', '无法从 userDir 提取 wxid,使用 DLL 发现的', { targetWxid }) + } + + // CleanWxid: 与 xkey 保持一致,截断到第二个下划线 + // wxid_g4pshorcc0r529_da6c → wxid_g4pshorcc0r529 + // wxid_7x2qsltkns1m22 → wxid_7x2qsltkns1m22(不变,只有两段) + const cleanWxid = (wxid: string): string => { + const first = wxid.indexOf('_') + if (first === -1) return wxid + const second = wxid.indexOf('_', first + 1) + if (second === -1) return wxid + return wxid.substring(0, second) + } + const cleanedWxid = cleanWxid(targetWxid) + + logService?.info('ImageKey', 'wxid 处理', { + original: targetWxid, + cleaned: cleanedWxid + }) + + // 用 cleanedWxid + code 计算密钥(与 xkey 算法一致) + // xorKey = code & 0xFF + // aesKey = MD5(code.toString() + cleanedWxid).substring(0, 16) + const crypto = require('crypto') + const code = codes[0] + const xorKey = code & 0xFF + const dataToHash = code.toString() + cleanedWxid + const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex') + const aesKey = md5Full.substring(0, 16) + + event.sender.send('imageKey:progress', `密钥获取成功 (wxid: ${targetWxid}, code: ${code})`) + + logService?.info('ImageKey', '图片密钥获取成功', { + wxid: targetWxid, + code, + xorKey, + aesKey + }) + + return { + success: true, + xorKey, + aesKey + } } catch (e) { logService?.error('ImageKey', '图片密钥获取异常', { error: String(e) }) return { success: false, error: String(e) } @@ -1947,6 +2062,11 @@ function registerIpcHandlers() { return result }) + ipcMain.handle('sns:downloadEmoji', async (_, params: { url: string; encryptUrl?: string; aesKey?: string }) => { + const { snsService } = await import('./services/snsService') + return snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey) + }) + ipcMain.handle('sns:downloadImage', async (_, params: { url: string; key?: string | number }) => { const { snsService } = await import('./services/snsService') const { dialog } = await import('electron') @@ -1987,6 +2107,9 @@ function registerIpcHandlers() { ipcMain.handle('sns:writeExportFile', async (_, filePath: string, content: string) => { try { const fs = await import('fs/promises') + const path = await import('path') + // 确保目录存在 + await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, content, 'utf-8') return { success: true } } catch (e: any) { @@ -1995,18 +2118,57 @@ function registerIpcHandlers() { }) // 将朋友圈媒体保存到导出目录 - ipcMain.handle('sns:saveMediaToDir', async (_, params: { url: string; key?: string | number; outputDir: string; index: number }) => { + ipcMain.handle('sns:saveMediaToDir', async (_, params: { url: string; key?: string | number; outputDir: string; index: number; md5?: string; isAvatar?: boolean; username?: string; isEmoji?: boolean; encryptUrl?: string; aesKey?: string }) => { try { const { snsService } = await import('./services/snsService') const fs = await import('fs/promises') const path = await import('path') + const crypto = await import('crypto') - // 确保 media 子目录存在 + // 确保导出目录和 media 子目录存在 const mediaDir = path.join(params.outputDir, 'media') await fs.mkdir(mediaDir, { recursive: true }) - // 下载并解密媒体 - const result = await snsService.downloadImage(params.url, params.key) + // 生成基于内容的唯一文件名 + let baseName: string + if (params.isAvatar && params.username) { + // 头像:用 avatar_username + baseName = `avatar_${params.username.replace(/[^a-zA-Z0-9_]/g, '_')}` + } else if (params.isEmoji) { + // 表情包:用 MD5(或者 encryptUrl/url 的 hash)加上 emoji 前缀 + const hashTarget = params.md5 || params.encryptUrl || params.url + baseName = `emoji_${params.md5 || crypto.createHash('md5').update(hashTarget).digest('hex')}` + } else if (params.md5) { + // 有 MD5 直接使用 + baseName = params.md5 + } else { + // 没有 MD5,用 URL 的 hash + baseName = crypto.createHash('md5').update(params.url).digest('hex') + } + + // 如果是表情包,走单独的下载接口 + if (params.isEmoji) { + const result = await snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey) + if (!result.success || !result.localPath) { + return { success: false, error: result.error || '表情包下载失败' } + } + + const ext = path.extname(result.localPath) || '.gif' + const fileName = `${baseName}${ext}` + const filePath = path.join(mediaDir, fileName) + + // 如果文件已存在则跳过 + try { + await fs.access(filePath) + return { success: true, fileName } + } catch { } + + await fs.copyFile(result.localPath, filePath) + return { success: true, fileName } + } + + // 默认走下载并解密媒体,传入 md5 提高缓存命中率 + const result = await snsService.downloadImage(params.url, params.key, params.md5) if (!result.success) { return { success: false, error: result.error || '下载失败' } @@ -2019,9 +2181,17 @@ function registerIpcHandlers() { else if (result.contentType?.includes('webp')) ext = '.webp' else if (result.contentType?.includes('video')) ext = '.mp4' - const fileName = `media_${params.index}${ext}` + const fileName = `${baseName}${ext}` const filePath = path.join(mediaDir, fileName) + // 如果文件已存在则跳过(避免重复下载) + try { + await fs.access(filePath) + return { success: true, fileName } + } catch { + // 文件不存在,继续下载 + } + if (result.data) { // 有二进制数据,直接写入 await fs.writeFile(filePath, result.data) @@ -2098,8 +2268,8 @@ function registerIpcHandlers() { }) // 打开朋友圈窗口 - ipcMain.handle('window:openMomentsWindow', async () => { - createMomentsWindow() + ipcMain.handle('window:openMomentsWindow', async (_event, filterUsername?: string) => { + createMomentsWindow(filterUsername) return true }) diff --git a/electron/preload.ts b/electron/preload.ts index db9ac21..2f581b1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -67,7 +67,11 @@ contextBridge.exposeInMainWorld('electronAPI', { maximize: () => ipcRenderer.send('window:maximize'), close: () => ipcRenderer.send('window:close'), openChatWindow: () => ipcRenderer.invoke('window:openChatWindow'), - openMomentsWindow: () => ipcRenderer.invoke('window:openMomentsWindow'), + openMomentsWindow: (filterUsername?: string) => ipcRenderer.invoke('window:openMomentsWindow', filterUsername), + onMomentsFilterUser: (callback: (username: string) => void) => { + ipcRenderer.on('moments:filterUser', (_, username) => callback(username)) + return () => ipcRenderer.removeAllListeners('moments:filterUser') + }, openGroupAnalyticsWindow: () => ipcRenderer.invoke('window:openGroupAnalyticsWindow'), openAnnualReportWindow: (year: number) => ipcRenderer.invoke('window:openAnnualReportWindow', year), openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), @@ -77,7 +81,7 @@ contextBridge.exposeInMainWorld('electronAPI', { isChatWindowOpen: () => ipcRenderer.invoke('window:isChatWindowOpen'), closeChatWindow: () => ipcRenderer.invoke('window:closeChatWindow'), setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options), - openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), + openImageViewerWindow: (imagePath: string, liveVideoPath?: string, imageList?: Array<{ imagePath: string; liveVideoPath?: string }>) => ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath, imageList), openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), openBrowserWindow: (url: string, title?: string) => ipcRenderer.invoke('window:openBrowserWindow', url, title), openAISummaryWindow: (sessionId: string, sessionName: string) => ipcRenderer.invoke('window:openAISummaryWindow', sessionId, sessionName), @@ -89,6 +93,11 @@ contextBridge.exposeInMainWorld('electronAPI', { onSplashFadeOut: (callback: () => void) => { ipcRenderer.on('splash:fadeOut', () => callback()) return () => ipcRenderer.removeAllListeners('splash:fadeOut') + }, + onImageListUpdate: (callback: (data: { imageList: Array<{ imagePath: string; liveVideoPath?: string }>, currentIndex: number }) => void) => { + const listener = (_: any, data: any) => callback(data) + ipcRenderer.on('imageViewer:setImageList', listener) + return () => { ipcRenderer.removeListener('imageViewer:setImageList', listener) } } }, @@ -196,9 +205,17 @@ contextBridge.exposeInMainWorld('electronAPI', { video: { getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), readFile: (videoPath: string) => ipcRenderer.invoke('video:readFile', videoPath), - parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) + parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content), + parseChannelVideo: (content: string) => ipcRenderer.invoke('video:parseChannelVideo', content), + downloadChannelVideo: (videoInfo: any, key?: string) => ipcRenderer.invoke('video:downloadChannelVideo', videoInfo, key), + onDownloadProgress: (callback: (progress: any) => void) => { + const listener = (_: any, progress: any) => callback(progress) + ipcRenderer.on('video:downloadProgress', listener) + return () => ipcRenderer.removeListener('video:downloadProgress', listener) + } }, + // 图片密钥获取 imageKey: { getImageKeys: (userDir: string) => ipcRenderer.invoke('imageKey:getImageKeys', userDir), @@ -256,9 +273,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('sns:proxyImage', params), downloadImage: (params: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', params), + downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => + ipcRenderer.invoke('sns:downloadEmoji', params), writeExportFile: (filePath: string, content: string) => ipcRenderer.invoke('sns:writeExportFile', filePath, content), - saveMediaToDir: (params: { url: string; key?: string | number; outputDir: string; index: number }) => + saveMediaToDir: (params: { url: string; key?: string | number; outputDir: string; index: number; md5?: string; isAvatar?: boolean; username?: string; isEmoji?: boolean; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:saveMediaToDir', params) }, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 294b196..7bb9b8a 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -47,6 +47,8 @@ export interface Message { quotedContent?: string quotedSender?: string quotedImageMd5?: string + quotedEmojiMd5?: string + quotedEmojiCdnUrl?: string // 图片相关 imageMd5?: string imageDatName?: string @@ -112,6 +114,7 @@ class ChatService extends EventEmitter { private emoticonDb: Database.Database | null = null private emotionDb: Database.Database | null = null private headImageDb: Database.Database | null = null + private miscDb: Database.Database | null = null private messageDbCache: Map = new Map() private dbDir: string | null = null @@ -323,6 +326,11 @@ class ChatService extends EventEmitter { this.headImageDb = new Database(headImageDbPath, { readonly: true }) } + const miscDbPath = path.join(dbDir, 'misc.db') + if (fs.existsSync(miscDbPath)) { + this.miscDb = new Database(miscDbPath, { readonly: true }) + } + // 连接时强制清除所有缓存,确保获取最新数据 // 这解决了增量更新后重新打开窗口时数据不刷新的问题 this.sessionTableCache.clear() @@ -350,6 +358,7 @@ class ChatService extends EventEmitter { this.emoticonDb?.close() this.emotionDb?.close() this.headImageDb?.close() + this.miscDb?.close() this.messageDbCache.forEach(db => { try { db.close() } catch { } }) @@ -1096,6 +1105,8 @@ class ChatService extends EventEmitter { let quotedContent: string | undefined let quotedSender: string | undefined let quotedImageMd5: string | undefined + let quotedEmojiMd5: string | undefined + let quotedEmojiCdnUrl: string | undefined let imageMd5: string | undefined let imageDatName: string | undefined let isLivePhoto: boolean | undefined @@ -1126,6 +1137,8 @@ class ChatService extends EventEmitter { quotedContent = quoteInfo.content quotedSender = quoteInfo.sender quotedImageMd5 = quoteInfo.imageMd5 + quotedEmojiMd5 = quoteInfo.emojiMd5 + quotedEmojiCdnUrl = quoteInfo.emojiCdnUrl } // 解析文件消息 (localType === 49 且 XML 中 type=6) @@ -1180,6 +1193,8 @@ class ChatService extends EventEmitter { quotedContent, quotedSender, quotedImageMd5, + quotedEmojiMd5, + quotedEmojiCdnUrl, imageMd5, imageDatName, isLivePhoto, @@ -1588,6 +1603,8 @@ class ChatService extends EventEmitter { let quotedContent: string | undefined let quotedSender: string | undefined let quotedImageMd5: string | undefined + let quotedEmojiMd5: string | undefined + let quotedEmojiCdnUrl: string | undefined let imageMd5: string | undefined let imageDatName: string | undefined let isLivePhoto: boolean | undefined @@ -1615,6 +1632,8 @@ class ChatService extends EventEmitter { quotedContent = quoteInfo.content quotedSender = quoteInfo.sender quotedImageMd5 = quoteInfo.imageMd5 + quotedEmojiMd5 = quoteInfo.emojiMd5 + quotedEmojiCdnUrl = quoteInfo.emojiCdnUrl } let fileName: string | undefined @@ -1667,6 +1686,8 @@ class ChatService extends EventEmitter { quotedContent, quotedSender, quotedImageMd5, + quotedEmojiMd5, + quotedEmojiCdnUrl, imageMd5, imageDatName, isLivePhoto, @@ -1801,18 +1822,23 @@ class ChatService extends EventEmitter { return '[图片]' case 34: return '[语音消息]' - case 42: - return '[名片]' + case 42: { + const nickname = content.match(/nickname="([^"]*)"/)?.[1] + return nickname ? `[名片] ${nickname}` : '[名片]' + } case 43: return '[视频]' case 47: return '[动画表情]' - case 48: - return '[位置]' + case 48: { + const poiname = content.match(/poiname="([^"]*)"/)?.[1] + const label = content.match(/label="([^"]*)"/)?.[1] + return poiname ? `[位置] ${poiname}` : label ? `[位置] ${label}` : '[位置]' + } case 49: return this.parseType49(content) case 50: - return '[通话]' + return this.parseVoipMessage(content) case 10000: return this.cleanSystemMessage(content) case 244813135921: @@ -1872,6 +1898,25 @@ class ChatService extends EventEmitter { return '[转账]' } + // 红包消息 + if (type === '2001') { + const greeting = this.extractXmlValue(content, 'receivertitle') || this.extractXmlValue(content, 'sendertitle') + return greeting ? `[红包] ${greeting}` : '[红包]' + } + + // 微信礼物 + if (type === '115') { + const wish = this.extractXmlValue(content, 'wishmessage') + const skutitle = this.extractXmlValue(content, 'skutitle') + return skutitle ? `[微信礼物] ${wish || '送你一份心意'} - ${skutitle}` : `[微信礼物] ${wish || '送你一份心意'}` + } + + // 音乐分享 + if (type === '3') { + const des = this.extractXmlValue(content, 'des') + return title ? `[音乐] ${title}${des ? ` - ${des}` : ''}` : '[音乐]' + } + if (title) { switch (type) { case '5': @@ -2178,7 +2223,7 @@ class ChatService extends EventEmitter { /** * 解析引用消息 */ - private parseQuoteMessage(content: string): { content?: string; sender?: string; imageMd5?: string } { + private parseQuoteMessage(content: string): { content?: string; sender?: string; imageMd5?: string; emojiMd5?: string; emojiCdnUrl?: string } { try { // 提取 refermsg 部分 const referMsgStart = content.indexOf('') @@ -2212,8 +2257,9 @@ class ChatService extends EventEmitter { break case '3': displayContent = '[图片]' - // 尝试从引用的内容 XML 中提取图片 MD5 - const innerMd5 = this.extractXmlValue(referContent, 'md5') + // 尝试从引用的内容 XML 中提取图片 MD5(标签或属性) + const innerMd5 = this.extractXmlValue(referContent, 'md5') || + (referContent.match(/\bmd5="([a-f0-9]+)"/i)?.[1]) imageMd5 = innerMd5 || undefined break case '34': @@ -2224,7 +2270,14 @@ class ChatService extends EventEmitter { break case '47': displayContent = '[动画表情]' - break + // 提取表情包信息用于引用显示 + const emojiInfo = this.parseEmojiInfo(referContent) + return { + content: displayContent, + sender: displayName, + emojiMd5: emojiInfo.md5, + emojiCdnUrl: emojiInfo.cdnUrl + } case '49': const appTitle = this.extractXmlValue(referContent, 'title') displayContent = appTitle || '[链接]' @@ -2281,6 +2334,11 @@ class ChatService extends EventEmitter { return result } + private parseVoipMessage(content: string): string { + const msg = this.extractXmlValue(content, 'msg') + return msg || '通话' + } + private getMessageTypeLabel(localType: number): string { const labels: Record = { 1: '[文本]', @@ -2866,6 +2924,158 @@ class ChatService extends EventEmitter { } } + /** + * 从 misc.db 获取 UIN(微信账号ID) + * UIN 用于表情包缓存解密的密钥派生 + */ + async getUinFromMiscDb(): Promise { + try { + if (!this.miscDb) { + const connectResult = await this.connect() + if (!connectResult.success) { + return null + } + } + + if (!this.miscDb) { + return null + } + + // 尝试从 DBInfo 表获取 UIN + try { + const row = this.miscDb.prepare(` + SELECT value FROM DBInfo WHERE key = 'uin' + `).get() as any + + if (row && row.value) { + return String(row.value) + } + } catch { + // DBInfo 表可能不存在或结构不同 + } + + // 备选:尝试从其他可能的表获取 UIN + try { + const tables = this.miscDb.prepare( + "SELECT name FROM sqlite_master WHERE type='table'" + ).all() as any[] + + for (const table of tables) { + const tableName = table.name + if (tableName.toLowerCase().includes('info') || tableName.toLowerCase().includes('account')) { + try { + const columns = this.miscDb.prepare(`PRAGMA table_info(${tableName})`).all() as any[] + const columnNames = columns.map((c: any) => c.name) + + if (columnNames.includes('uin')) { + const uinRow = this.miscDb.prepare(`SELECT uin FROM ${tableName} LIMIT 1`).get() as any + if (uinRow && uinRow.uin) { + return String(uinRow.uin) + } + } + } catch { + // 跳过无法查询的表 + } + } + } + } catch { + // 无法扫描表 + } + + return null + } catch (e) { + console.error('ChatService: 从 misc.db 获取 UIN 失败:', e) + return null + } + } + + /** + * 获取表情包缓存解密所需的 UIN 和 keyString + * - UIN: 从 misc.db 获取,或从配置读取 + * - keyString: 使用 myWxid(已在配置中) + */ + async getEmoticonDecryptionParams(): Promise<{ uin: string | null; keyString: string | null }> { + try { + // 优先从 misc.db 自动获取 UIN + let uin = await this.getUinFromMiscDb() + + // 如果自动获取失败,尝试从配置读取 + if (!uin) { + uin = this.configService.get('emoticonUin') || null + } + + // keyString 使用 myWxid + const keyString = this.configService.get('myWxid') || null + + return { uin, keyString } + } catch (e) { + console.error('ChatService: 获取表情包解密参数失败:', e) + return { uin: null, keyString: null } + } + } + + /** + * 解密表情包缓存文件 + * 使用 AES-128-CBC (IV=Key) + XOR 掩码 + * 密钥派生: MD5(str(UIN) + keyString + "EMOTICON") → 小写十六进制 → 前16字符 + */ + async decryptEmoticonCache(filePath: string): Promise { + try { + if (!fs.existsSync(filePath)) { + return null + } + + // 获取解密参数 + const params = await this.getEmoticonDecryptionParams() + if (!params.uin || !params.keyString) { + console.warn('ChatService: 缺少表情包解密参数 (UIN 或 keyString)') + return null + } + + // 读取加密文件 + const encryptedBuffer = fs.readFileSync(filePath) + if (encryptedBuffer.length === 0) { + return null + } + + const crypto = require('crypto') + + // 密钥派生: MD5(str(UIN) + keyString + "EMOTICON") + const keyMaterial = String(params.uin) + params.keyString + 'EMOTICON' + const keyHash = crypto.createHash('md5').update(keyMaterial).digest('hex').toLowerCase() + const keyHex = keyHash.substring(0, 16) + const key = Buffer.from(keyHex, 'utf8') + + // 复制缓冲区以便修改 + const workBuffer = Buffer.from(encryptedBuffer) + + // 应用 XOR 掩码到前32字节 + // XOR 掩码是密钥的重复 + const xorMask = Buffer.alloc(32) + for (let i = 0; i < 32; i++) { + xorMask[i] = key[i % key.length] + } + + for (let i = 0; i < Math.min(32, workBuffer.length); i++) { + workBuffer[i] ^= xorMask[i] + } + + // AES-128-CBC 解密,IV = Key + const decipher = crypto.createDecipheriv('aes-128-cbc', key, key) + decipher.setAutoPadding(true) + + const decrypted = Buffer.concat([ + decipher.update(workBuffer), + decipher.final() + ]) + + return decrypted + } catch (e) { + console.error('ChatService: 表情包缓存解密失败:', e) + return null + } + } + /** * 获取商店表情包的备选 URL 列表 * 尝试不同的域名和扩展名组合 diff --git a/electron/services/config.ts b/electron/services/config.ts index 7af4ba4..b98015e 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -13,6 +13,10 @@ interface ConfigSchema { imageXorKey: string imageAesKey: string + // 表情包缓存解密相关(逆向 Wexin.dll 确认) + emoticonUin: string // 微信 UIN(数字) + emoticonKeyString: string // vfunc@32 返回的字符串 + // 缓存相关 cachePath: string lastOpenedDb: string @@ -71,6 +75,8 @@ const defaults: ConfigSchema = { myWxid: '', imageXorKey: '', imageAesKey: '', + emoticonUin: '', + emoticonKeyString: '', cachePath: '', lastOpenedDb: '', lastSession: '', diff --git a/electron/services/dataManagementService.ts b/electron/services/dataManagementService.ts index 47098c9..4047e4c 100644 --- a/electron/services/dataManagementService.ts +++ b/electron/services/dataManagementService.ts @@ -5,6 +5,7 @@ import { ConfigService } from './config' import { wechatDecryptService } from './decryptService' import { imageDecryptService } from './imageDecryptService' import { chatService } from './chatService' +import { snsService } from './snsService' // 文件系统监听器类型 type FileWatcher = fs.FSWatcher | null @@ -380,6 +381,7 @@ class DataManagementService { } const filesToUpdate = scanResult.databases.filter(db => db.needsUpdate) + if (filesToUpdate.length === 0) { return { success: true, successCount: 0, failCount: 0 } } @@ -436,6 +438,10 @@ class DataManagementService { // 在备份/覆盖文件前,先关闭该数据库的连接,释放文件锁 chatService.closeDatabase(file.fileName) + // sns.db 由 snsService 单独管理,也需要关闭 + if (file.fileName.toLowerCase() === 'sns.db') { + snsService.closeSnsDb() + } // 等待文件句柄释放 await new Promise(resolve => setTimeout(resolve, 100)) @@ -474,6 +480,8 @@ class DataManagementService { if (!backupSuccess) { // 重试失败,跳过这个文件 + console.warn(`[增量同步] 备份失败,跳过文件: ${file.fileName}`) + failCount++ continue } } @@ -558,8 +566,16 @@ class DataManagementService { if (result.success) { successCount++ - // const time = new Date().toLocaleTimeString() - // console.log(`[${time}] [增量同步] 同步成功: ${file.fileName}`) // 减少日志 + + // sns.db 特殊处理:合并旧数据,防止"三天可见"等设置导致朋友圈数据丢失 + if (file.fileName.toLowerCase() === 'sns.db' && fs.existsSync(backupPath) && fs.existsSync(file.decryptedPath!)) { + try { + await this.mergeSnsTimeline(file.decryptedPath!, backupPath) + } catch (e) { + console.warn('[增量同步] sns.db 数据合并失败,不影响更新:', e) + } + } + if (fs.existsSync(backupPath)) { try { fs.unlinkSync(backupPath) } catch { } } @@ -595,6 +611,56 @@ class DataManagementService { + /** + * 合并 sns.db 朋友圈数据,防止"三天可见"等设置导致旧数据丢失 + * 将旧备份中存在但新数据库中不存在的 SnsTimeLine 记录合并回来 + */ + private async mergeSnsTimeline(newDbPath: string, oldBackupPath: string): Promise { + const Database = require('better-sqlite3') + let newDb: any = null + let oldDb: any = null + + try { + newDb = new Database(newDbPath) + oldDb = new Database(oldBackupPath, { readonly: true }) + + // 获取新旧数据库的 tid 集合 + const newTids = new Set( + (newDb.prepare('SELECT tid FROM SnsTimeLine').all() as any[]).map((r: any) => String(r.tid)) + ) + const oldRows = oldDb.prepare('SELECT tid, user_name, content FROM SnsTimeLine').all() as any[] + + // 找出旧数据库中有但新数据库中没有的记录 + const missingRows = oldRows.filter((r: any) => !newTids.has(String(r.tid))) + + if (missingRows.length === 0) { + return + } + + console.log(`[增量同步] sns.db 合并: 发现 ${missingRows.length} 条旧朋友圈数据需要保留`) + + // 批量插入缺失的记录 + const insert = newDb.prepare( + 'INSERT OR IGNORE INTO SnsTimeLine (tid, user_name, content) VALUES (?, ?, ?)' + ) + + const insertMany = newDb.transaction((rows: any[]) => { + for (const row of rows) { + insert.run(row.tid, row.user_name, row.content) + } + }) + + insertMany(missingRows) + console.log(`[增量同步] sns.db 合并完成: 已恢复 ${missingRows.length} 条朋友圈数据`) + } catch (e: any) { + // 如果表结构不匹配等问题,记录但不中断 + console.warn('[增量同步] sns.db 合并异常:', e?.message || e) + } finally { + try { oldDb?.close() } catch {} + try { newDb?.close() } catch {} + } + } + /** * 发送进度到前端(发送到所有窗口,确保主窗口能收到) */ diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index b37e1c9..6f99945 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -51,6 +51,7 @@ export class ImageDecryptService { private cacheIndexed = false private cacheIndexing: Promise | null = null private updateFlags = new Map() + private notFoundCache = new Set() // 失败缓存,避免重复查询 async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise { // 不再等待缓存索引,直接查找 @@ -59,7 +60,7 @@ export class ImageDecryptService { if (!cacheKey) { return { success: false, error: '缺少图片标识' } } - + // 1. 先检查内存缓存(最快) for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) @@ -99,12 +100,12 @@ export class ImageDecryptService { return { success: true, localPath, hasUpdate, liveVideoPath } } } - + // 3. 后台启动完整索引(不阻塞当前请求) if (!this.cacheIndexed && !this.cacheIndexing) { void this.ensureCacheIndexed() } - + return { success: false, error: '未找到缓存图片' } } @@ -114,11 +115,16 @@ export class ImageDecryptService { return { success: false, error: '缺少图片标识' } } + // 失败缓存:跳过已知找不到的图片(force 时忽略,允许重试) + if (!payload.force && this.notFoundCache.has(cacheKey)) { + return { success: false, error: '未找到图片文件' } + } + // 即使 force=true,也先检查是否有高清图缓存 if (payload.force) { // 快速查找高清图缓存 - const hdCached = this.findCachedOutputFast(cacheKey, payload.sessionId, true) || - this.findCachedOutput(cacheKey, payload.sessionId, true) + const hdCached = this.findCachedOutputFast(cacheKey, payload.sessionId, true) || + this.findCachedOutput(cacheKey, payload.sessionId, true) if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached)) { const localPath = this.filePathToUrl(hdCached) const liveVideoPath = this.checkLiveVideoCache(hdCached) @@ -182,6 +188,7 @@ export class ImageDecryptService { return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } } if (!datPath) { + this.notFoundCache.add(cacheKey) console.warn(`[ImageDecrypt] 未找到图片文件: ${payload.imageDatName || payload.imageMd5} sessionId=${payload.sessionId}`) return { success: false, error: '未找到图片文件' } } @@ -242,6 +249,16 @@ export class ImageDecryptService { const finalExt = ext || '.jpg' + // 图片完整性校验:检测解密后的数据是否有完整的结束标记 + const isImageComplete = this.verifyImageComplete(decrypted, finalExt) + + // 诊断日志:记录关键数据以便定位半白图片的根因 + const datSize = statSync(datPath).size + const datVersion = this.getDatVersion(datPath) + if (!isImageComplete) { + console.warn(`[ImageDecrypt] 图片不完整! cacheKey=${cacheKey} datPath=${datPath} datSize=${datSize} version=V${datVersion === 0 ? '3' : datVersion === 1 ? '4v1' : '4v2'} decryptedSize=${decrypted.length} ext=${finalExt} headHex=${decrypted.subarray(0, 8).toString('hex')} tailHex=${decrypted.subarray(Math.max(0, decrypted.length - 8)).toString('hex')}`) + } + const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId) await writeFile(outputPath, decrypted) @@ -253,9 +270,13 @@ export class ImageDecryptService { } const isThumb = this.isThumbnailPath(datPath) - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) - if (!isThumb) { - this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + + // 如果图片是完整的,才缓存路径映射(不完整的下次重新解密) + if (isImageComplete) { + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) + if (!isThumb) { + this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + } } // 对于 hevc 格式,返回错误提示用户安装 ffmpeg @@ -966,6 +987,21 @@ export class ImageDecryptService { const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] + // 校验缓存文件是否存在且完整,不完整的自动删除 + const validateCached = (filePath: string): boolean => { + if (!existsSync(filePath)) return false + try { + const size = statSync(filePath).size + if (size <= 100) { unlinkSync(filePath); return false } + if (!this.isFileTailValid(filePath, size)) { + console.warn(`[ImageDecrypt] 发现不完整缓存图片,已删除: ${filePath} (size=${size})`) + unlinkSync(filePath) + return false + } + return true + } catch { return false } + } + // 遍历所有可能的缓存根路径 for (const root of allRoots) { // 新目录结构: Images/{sessionId}/{年-月}/{文件名}_thumb.jpg 或 _hd.jpg @@ -987,13 +1023,13 @@ export class ImageDecryptService { for (const ext of extensions) { if (preferHd) { const hdPath = join(imageDir, `${normalizedKey}_hd${ext}`) - if (existsSync(hdPath)) return hdPath + if (validateCached(hdPath)) return hdPath } const thumbPath = join(imageDir, `${normalizedKey}_thumb${ext}`) - if (existsSync(thumbPath)) return thumbPath + if (validateCached(thumbPath)) return thumbPath if (!preferHd) { const hdPath = join(imageDir, `${normalizedKey}_hd${ext}`) - if (existsSync(hdPath)) return hdPath + if (validateCached(hdPath)) return hdPath } } } @@ -1022,13 +1058,13 @@ export class ImageDecryptService { for (const ext of extensions) { if (preferHd) { const hdPath = join(imageDir, `${normalizedKey}_hd${ext}`) - if (existsSync(hdPath)) return hdPath + if (validateCached(hdPath)) return hdPath } const thumbPath = join(imageDir, `${normalizedKey}_thumb${ext}`) - if (existsSync(thumbPath)) return thumbPath + if (validateCached(thumbPath)) return thumbPath if (!preferHd) { const hdPath = join(imageDir, `${normalizedKey}_hd${ext}`) - if (existsSync(hdPath)) return hdPath + if (validateCached(hdPath)) return hdPath } } } @@ -1044,13 +1080,13 @@ export class ImageDecryptService { for (const ext of extensions) { if (preferHd) { const hdPath = join(oldImageDir, `${normalizedKey}_hd${ext}`) - if (existsSync(hdPath)) return hdPath + if (validateCached(hdPath)) return hdPath } const thumbPath = join(oldImageDir, `${normalizedKey}_thumb${ext}`) - if (existsSync(thumbPath)) return thumbPath + if (validateCached(thumbPath)) return thumbPath if (!preferHd) { const hdPath = join(oldImageDir, `${normalizedKey}_hd${ext}`) - if (existsSync(hdPath)) return hdPath + if (validateCached(hdPath)) return hdPath } } } @@ -1058,7 +1094,7 @@ export class ImageDecryptService { // 兼容最旧的平铺结构 for (const ext of extensions) { const candidate = join(root, `${cacheKey}${ext}`) - if (existsSync(candidate)) return candidate + if (validateCached(candidate)) return candidate } } @@ -1071,11 +1107,11 @@ export class ImageDecryptService { */ private findCachedOutputFast(cacheKey: string, sessionId?: string, preferHd: boolean = false): string | null { if (!sessionId) return null - + const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] const allRoots = this.getAllCacheRoots() - + // 构造最近 3 个月的日期目录 const now = new Date() const recentMonths: string[] = [] @@ -1088,10 +1124,10 @@ export class ImageDecryptService { for (const root of allRoots) { for (const dateDir of recentMonths) { const imageDir = join(root, sessionId, dateDir) - + // 批量构造所有可能的路径 const candidates: string[] = [] - + if (preferHd) { // 优先高清图 for (const ext of extensions) { @@ -1109,10 +1145,25 @@ export class ImageDecryptService { candidates.push(join(imageDir, `${normalizedKey}_hd${ext}`)) } } - - // 检查文件是否存在 + + // 检查文件是否存在且图片数据完整 for (const candidate of candidates) { - if (existsSync(candidate)) return candidate + if (existsSync(candidate)) { + try { + const size = statSync(candidate).size + if (size <= 100) { + unlinkSync(candidate) + continue + } + // 快速校验图片末尾完整性(只读最后 64 字节) + if (this.isFileTailValid(candidate, size)) { + return candidate + } + // 图片末尾不完整(半截图),删除后让系统重新解密 + console.warn(`[ImageDecrypt] 发现不完整缓存图片,已删除: ${candidate} (size=${size})`) + unlinkSync(candidate) + } catch { } + } } } } @@ -1120,6 +1171,63 @@ export class ImageDecryptService { return null } + /** + * 快速校验缓存图片文件末尾是否完整 + * 只读取最后 64 字节进行检查,开销极小 + */ + private isFileTailValid(filePath: string, fileSize: number): boolean { + try { + const ext = filePath.toLowerCase() + const fs = require('fs') + const fd = fs.openSync(filePath, 'r') + + if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) { + // JPEG: 末尾应有 EOI marker (0xFF 0xD9) + const tailSize = Math.min(fileSize, 64) + const buf = Buffer.alloc(tailSize) + fs.readSync(fd, buf, 0, tailSize, fileSize - tailSize) + // 检查末尾是否有 EOI marker + for (let i = buf.length - 2; i >= 0; i--) { + if (buf[i] === 0xFF && buf[i + 1] === 0xD9) { + fs.closeSync(fd) + return true + } + } + // 可能是 Motion Photo(JPEG + MP4 拼接),检查文件头是否合法 JPEG + const headBuf = Buffer.alloc(3) + fs.readSync(fd, headBuf, 0, 3, 0) + fs.closeSync(fd) + // 只要文件头是 FFD8FF 且大于 1KB,认为是有效的(可能是 Motion Photo) + if (headBuf[0] === 0xFF && headBuf[1] === 0xD8 && headBuf[2] === 0xFF && fileSize > 1024) { + return true + } + return false + } + + if (ext.endsWith('.png')) { + // PNG: 末尾应有 IEND chunk + const buf = Buffer.alloc(12) + fs.readSync(fd, buf, 0, 12, fileSize - 12) + fs.closeSync(fd) + return buf[4] === 0x49 && buf[5] === 0x45 && buf[6] === 0x4E && buf[7] === 0x44 + } + + if (ext.endsWith('.gif')) { + // GIF: 末尾应有 0x3B + const buf = Buffer.alloc(1) + fs.readSync(fd, buf, 0, 1, fileSize - 1) + fs.closeSync(fd) + return buf[0] === 0x3B + } + + fs.closeSync(fd) + // WebP 等其他格式暂不校验末尾 + return true + } catch { + return true // 读取失败时不阻塞,放行 + } + } + /** * 清理旧的 .hevc 文件(ffmpeg 转换失败时遗留的) */ @@ -1814,6 +1922,56 @@ export class ImageDecryptService { return null } + /** + * 验证解密后的图片数据是否完整 + * JPEG: 末尾应有 EOI marker (0xFF 0xD9) + * PNG: 末尾应有 IEND chunk + * GIF: 末尾应有 trailer (0x3B) + * 不完整的图片不应该被缓存,下次重新解密可能拿到完整数据 + */ + private verifyImageComplete(data: Buffer, ext: string): boolean { + if (!data || data.length < 100) return false + + const lowerExt = ext.toLowerCase() + + if (lowerExt === '.jpg' || lowerExt === '.jpeg') { + // JPEG: 检查是否存在 EOI marker (0xFF 0xD9) + // 从末尾往前搜索(有些 JPEG 在 EOI 后有少量附加数据) + const searchLen = Math.min(data.length, 64) + for (let i = data.length - 2; i >= data.length - searchLen; i--) { + if (data[i] === 0xFF && data[i + 1] === 0xD9) { + return true + } + } + // Motion Photo 情况:JPEG 后面紧跟 MP4,EOI 在中间位置 + const quarterStart = Math.floor(data.length * 3 / 4) + for (let i = quarterStart; i < data.length - 1; i++) { + if (data[i] === 0xFF && data[i + 1] === 0xD9) { + return true + } + } + return false + } + + if (lowerExt === '.png') { + // PNG: 末尾应有 IEND chunk (... 49 45 4E 44 AE 42 60 82) + if (data.length < 12) return false + const tail = data.subarray(data.length - 12) + if (tail[4] === 0x49 && tail[5] === 0x45 && tail[6] === 0x4E && tail[7] === 0x44) { + return true + } + return false + } + + if (lowerExt === '.gif') { + // GIF: 末尾应有 trailer byte (0x3B) + return data[data.length - 1] === 0x3B + } + + // WebP 和其他格式暂不做细粒度校验,仅检查最低大小 + return data.length > 100 + } + private bufferToDataUrl(buffer: Buffer, ext: string): string | null { const mimeType = this.mimeFromExtension(ext) if (!mimeType) return null @@ -1889,7 +2047,7 @@ export class ImageDecryptService { } if (videoOffset === null || videoOffset <= 100) return null if (buf[videoOffset + 4] !== 0x66 || buf[videoOffset + 5] !== 0x74 || - buf[videoOffset + 6] !== 0x79 || buf[videoOffset + 7] !== 0x70) return null + buf[videoOffset + 6] !== 0x79 || buf[videoOffset + 7] !== 0x70) return null return videoOffset } @@ -2074,7 +2232,7 @@ export class ImageDecryptService { if (entry.isDirectory()) { walk(full) } else if (this.isThumbnailPath(full)) { - try { unlinkSync(full); deleted++ } catch {} + try { unlinkSync(full); deleted++ } catch { } } } } diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 1b31b5d..a0015d9 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync } from 'fs' import { readFile, writeFile } from 'fs/promises' import { join, dirname } from 'path' import crypto from 'crypto' +import zlib from 'zlib' import { chatService } from './chatService' import Database from 'better-sqlite3' import { app } from 'electron' @@ -28,6 +29,8 @@ export interface SnsMedia { thumbKey?: string // 缩略图的解密密钥(可能和原图不同) encIdx?: string livePhoto?: SnsLivePhoto + width?: number // 媒体原始宽度(从 XML 提取) + height?: number // 媒体原始高度(从 XML 提取) } export interface SnsShareInfo { @@ -52,7 +55,7 @@ export interface SnsPost { media: SnsMedia[] shareInfo?: SnsShareInfo likes: string[] - comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] + comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] rawXml?: string } @@ -380,21 +383,8 @@ class SnsService { // 打开解密后的数据库(不需要密钥) this.snsDb = new Database(snsDbPath, { readonly: true }) - // 测试连接并查看表结构 - const testResult = this.snsDb.prepare('SELECT COUNT(*) as count FROM SnsTimeLine').get() as { count: number } - console.log(`[SnsService] 数据库打开成功,SnsTimeLine 表共有 ${testResult.count} 条记录`) - - // 查看所有表 - const tables = this.snsDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() - console.log('[SnsService] 数据库中的所有表:', tables.map((t: any) => t.name).join(', ')) - - // 查看表结构 - const tableInfo = this.snsDb.prepare('PRAGMA table_info(SnsTimeLine)').all() - console.log('[SnsService] SnsTimeLine 表结构:', tableInfo) - - // 查看一些样本数据的字段 - const sampleRows = this.snsDb.prepare('SELECT tid, user_name, LENGTH(content) as content_len FROM SnsTimeLine LIMIT 5').all() - console.log('[SnsService] 样本数据:', sampleRows) + // 测试连接 + this.snsDb.prepare('SELECT COUNT(*) as count FROM SnsTimeLine').get() return true } catch (error) { @@ -404,6 +394,20 @@ class SnsService { } } + /** + * 关闭 SNS 数据库连接,释放文件锁 + */ + closeSnsDb(): void { + if (this.snsDb) { + try { + this.snsDb.close() + } catch (e) { + // 忽略关闭错误 + } + this.snsDb = null + } + } + /** * 从 XML 中解析点赞信息 */ @@ -425,12 +429,17 @@ class SnsService { likeListMatch = xml.match(/([\s\S]*?)<\/likeList>/i) } + // 方式4: 尝试查找 (下划线格式,来自 LocalExtraInfo) + if (!likeListMatch) { + likeListMatch = xml.match(/([\s\S]*?)<\/like_user_list>/i) + } + if (!likeListMatch) return likes const likeListXml = likeListMatch[1] - // 提取所有 标签 - const likeUserRegex = /<(?:LikeUser|likeUser)>([\s\S]*?)<\/(?:LikeUser|likeUser)>/gi + // 提取所有 标签 + const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi let likeUserMatch while ((likeUserMatch = likeUserRegex.exec(likeListXml)) !== null) { @@ -456,10 +465,11 @@ class SnsService { /** * 从 XML 中解析评论信息 */ - private parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] { + private parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number }[] }[] { if (!xml) return [] - const comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] = [] + type CommentItem = { id: string; nickname: string; username?: string; content: string; refCommentId: string; refUsername?: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number }[] } + const comments: CommentItem[] = [] try { // 方式1: 查找 标签 let commentListMatch = xml.match(/([\s\S]*?)<\/CommentUserList>/i) @@ -474,45 +484,108 @@ class SnsService { commentListMatch = xml.match(/([\s\S]*?)<\/commentList>/i) } + // 方式4: 尝试查找 (下划线格式,来自 LocalExtraInfo) + if (!commentListMatch) { + commentListMatch = xml.match(/([\s\S]*?)<\/comment_user_list>/i) + } + if (!commentListMatch) return comments const commentListXml = commentListMatch[1] - // 提取所有 标签 - const commentUserRegex = /<(?:CommentUser|commentUser|comment)>([\s\S]*?)<\/(?:CommentUser|commentUser|comment)>/gi + // 提取所有评论标签(支持多种格式) + const commentUserRegex = /<(?:CommentUser|commentUser|comment|user_comment)>([\s\S]*?)<\/(?:CommentUser|commentUser|comment|user_comment)>/gi let commentUserMatch while ((commentUserMatch = commentUserRegex.exec(commentListXml)) !== null) { const commentUserXml = commentUserMatch[1] - // 提取评论 ID(可能是 cmtid, commentId, id) - let idMatch = commentUserXml.match(/<(?:cmtid|commentId|id)>([^<]*)<\/(?:cmtid|commentId|id)>/i) + // 提取评论 ID(支持 cmtid, commentId, id, comment_id) + const idMatch = commentUserXml.match(/<(?:cmtid|commentId|comment_id|id)>([^<]*)<\/(?:cmtid|commentId|comment_id|id)>/i) - // 提取昵称(可能是 nickname 或 nickName) + // 提取用户名/wxid(用于回复关系解析) + const usernameMatch = commentUserXml.match(/([^<]*)<\/username>/i) + + // 提取昵称 let nicknameMatch = commentUserXml.match(/([^<]*)<\/nickname>/i) if (!nicknameMatch) { nicknameMatch = commentUserXml.match(/([^<]*)<\/nickName>/i) } - // 提取评论内容 + // 提取评论内容(content 可能为空,比如纯表情包评论) const contentMatch = commentUserXml.match(/([^<]*)<\/content>/i) - // 提取回复的评论 ID(如果是回复) - const refCommentIdMatch = commentUserXml.match(/<(?:refCommentId|replyCommentId)>([^<]*)<\/(?:refCommentId|replyCommentId)>/i) + // 提取回复的评论 ID(支持下划线格式 ref_comment_id) + const refCommentIdMatch = commentUserXml.match(/<(?:refCommentId|replyCommentId|ref_comment_id)>([^<]*)<\/(?:refCommentId|replyCommentId|ref_comment_id)>/i) // 提取被回复者昵称 - let refNicknameMatch = commentUserXml.match(/<(?:refNickname|refNickName|replyNickname)>([^<]*)<\/(?:refNickname|refNickName|replyNickname)>/i) + const refNicknameMatch = commentUserXml.match(/<(?:refNickname|refNickName|replyNickname)>([^<]*)<\/(?:refNickname|refNickName|replyNickname)>/i) - if (nicknameMatch && contentMatch) { + // 提取被回复者用户名(下划线格式 ref_username) + const refUsernameMatch = commentUserXml.match(/([^<]*)<\/ref_username>/i) + + // 提取表情包信息 + const emojis: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] = [] + const emojiRegex = /([\s\S]*?)<\/emojiinfo>/gi + let emojiMatch + while ((emojiMatch = emojiRegex.exec(commentUserXml)) !== null) { + const emojiXml = emojiMatch[1] + // 优先 extern_url(公开可访问),其次 cdn_url,最后 url + const externUrlMatch = emojiXml.match(/([^<]*)<\/extern_url>/i) + const cdnUrlMatch = emojiXml.match(/([^<]*)<\/cdn_url>/i) + const plainUrlMatch = emojiXml.match(/([^<]*)<\/url>/i) + const emojiUrlMatch = externUrlMatch || cdnUrlMatch || plainUrlMatch + const emojiMd5Match = emojiXml.match(/([^<]*)<\/md5>/i) + const emojiWidthMatch = emojiXml.match(/([^<]*)<\/width>/i) + const emojiHeightMatch = emojiXml.match(/([^<]*)<\/height>/i) + // 加密 URL 和 AES 密钥(用于解密回退) + const encryptUrlMatch = emojiXml.match(/([^<]*)<\/encrypt_url>/i) + const aesKeyMatch = emojiXml.match(/([^<]*)<\/aes_key>/i) + + const url = emojiUrlMatch ? emojiUrlMatch[1].trim().replace(/&/g, '&') : '' + const encryptUrl = encryptUrlMatch ? encryptUrlMatch[1].trim().replace(/&/g, '&') : undefined + const aesKey = aesKeyMatch ? aesKeyMatch[1].trim() : undefined + + if (url || encryptUrl) { + emojis.push({ + url, + md5: emojiMd5Match ? emojiMd5Match[1].trim() : '', + width: emojiWidthMatch ? parseInt(emojiWidthMatch[1]) : 0, + height: emojiHeightMatch ? parseInt(emojiHeightMatch[1]) : 0, + encryptUrl, + aesKey + }) + } + } + + // 昵称存在即可(content 可能为空但有表情包) + if (nicknameMatch && (contentMatch || emojis.length > 0)) { + const refCommentId = refCommentIdMatch ? refCommentIdMatch[1].trim() : '' comments.push({ id: idMatch ? idMatch[1].trim() : `comment_${Date.now()}_${Math.random()}`, nickname: nicknameMatch[1].trim(), - content: contentMatch[1].trim(), - refCommentId: refCommentIdMatch ? refCommentIdMatch[1].trim() : '', - refNickname: refNicknameMatch ? refNicknameMatch[1].trim() : undefined + username: usernameMatch ? usernameMatch[1].trim() : undefined, + content: contentMatch ? contentMatch[1].trim() : '', + refCommentId: (refCommentId === '0') ? '' : refCommentId, + refUsername: refUsernameMatch ? refUsernameMatch[1].trim() : undefined, + refNickname: refNicknameMatch ? refNicknameMatch[1].trim() : undefined, + emojis: emojis.length > 0 ? emojis : undefined }) } } + + // 第二遍:通过 refUsername 解析被回复者昵称(如果 refNickname 为空) + const usernameToNickname = new Map() + for (const c of comments) { + if (c.username && c.nickname) { + usernameToNickname.set(c.username, c.nickname) + } + } + for (const c of comments) { + if (!c.refNickname && c.refUsername && c.refCommentId) { + c.refNickname = usernameToNickname.get(c.refUsername) + } + } } catch (error) { console.error('[SnsService] 解析评论失败:', error) } @@ -586,6 +659,26 @@ class SnsService { if (encIdxMatch) thumbEncIdx = encIdxMatch[1] } + // 提取宽高() + const sizeMatch = mediaXml.match(/]*width="(\d+)"[^>]*height="(\d+)"/i) + || mediaXml.match(/]*height="(\d+)"[^>]*width="(\d+)"/i) + let mediaWidth: number | undefined + let mediaHeight: number | undefined + if (sizeMatch) { + const w = parseInt(sizeMatch[1]) + const h = parseInt(sizeMatch[2]) + // width/height 顺序可能被 height-first 正则颠倒,做修正 + const sizeWMatch = mediaXml.match(/width="(\d+)"/i) + const sizeHMatch = mediaXml.match(/height="(\d+)"/i) + if (sizeWMatch && sizeHMatch) { + mediaWidth = parseInt(sizeWMatch[1]) || undefined + mediaHeight = parseInt(sizeHMatch[1]) || undefined + } else { + mediaWidth = w || undefined + mediaHeight = h || undefined + } + } + const mediaItem: SnsMedia = { url: urlMatch ? urlMatch[1].trim() : '', thumb: thumbMatch ? thumbMatch[1].trim() : '', @@ -593,7 +686,9 @@ class SnsService { key: urlKey || thumbKey, // 原图的 key thumbKey: thumbKey, // 缩略图的 key(可能和原图不同) md5: urlMd5, - encIdx: urlEncIdx || thumbEncIdx + encIdx: urlEncIdx || thumbEncIdx, + width: mediaWidth, + height: mediaHeight } // 检查是否有实况照片 @@ -655,6 +750,466 @@ class SnsService { return { media, videoKey } } + /** + * 获取表情包缓存目录(与聊天共用同一目录) + */ + private getEmojiCacheDir(): string { + const cachePath = this.configService.getCacheBasePath() + const emojiDir = join(cachePath, 'Emojis') + if (!existsSync(emojiDir)) { + mkdirSync(emojiDir, { recursive: true }) + } + return emojiDir + } + + /** + * 解密表情数据(从 Weixin.dll 逆向得到的算法) + * + * 核心发现(sub_1845E6DB0 / c2c_response.cc): + * nonce = key 的前 12 字节,数据格式 = [ciphertext][auth_tag (16B)] + * 备选格式:GcmData 块、尾部 nonce、前置 nonce 等 + * 解密后可能需要 zlib 解压(AesGcmDecryptWithUncompress) + */ + private decryptEmojiAes( + encData: Buffer, + aesKey: string, + debug?: { cacheKey: string; source: 'encrypt_url' | 'plain_url' } + ): Buffer | null { + if (encData.length <= 16) { + return null + } + + const keyTries = this.buildKeyTries(aesKey) + const tag = encData.subarray(encData.length - 16) + const ciphertext = encData.subarray(0, encData.length - 16) + + // ★ 最高优先级:IDA 确认的 nonce-tail 格式 [ciphertext][nonce 12B][tag 16B] + // 来源:Weixin.dll sub_182687C70 (mmcrypto::AesGcmDecrypt) + // AAD 为空(sub_180C800F0 mode=13 传 0,0),支持 AES-128/256 + if (encData.length > 28) { + const nonceTail = encData.subarray(encData.length - 28, encData.length - 16) + const tagTail = encData.subarray(encData.length - 16) + const cipherTail = encData.subarray(0, encData.length - 28) + for (const { name, key } of keyTries) { + if (key.length !== 16 && key.length !== 32) continue + const result = this.tryGcmDecrypt(key, nonceTail, cipherTail, tagTail) + if (result) { + return result + } + } + } + + // 次优先级:nonce = key 前 12 字节,data = [ciphertext][tag 16B] + // 来源:Weixin.dll sub_1845E6DB0 (CDN c2c_response decrypt) + for (const { name, key } of keyTries) { + if (key.length !== 16 && key.length !== 32) continue + const nonce = key.subarray(0, 12) + const result = this.tryGcmDecrypt(key, nonce, ciphertext, tag) + if (result) { + return result + } + } + + // 其他备选布局 + const layouts = this.buildGcmLayouts(encData) + for (const layout of layouts) { + for (const { name, key } of keyTries) { + if (key.length !== 16 && key.length !== 32) continue + const result = this.tryGcmDecrypt(key, layout.nonce, layout.ciphertext, layout.tag) + if (result) { + return result + } + } + } + + // ★ 回退:尝试 AES-128-CBC / AES-128-ECB + for (const { name, key } of keyTries) { + if (key.length !== 16) continue + // CBC 变体 1(IDA sub_180C80320):IV = key 本身 + if (encData.length >= 16 && encData.length % 16 === 0) { + try { + const dec = crypto.createDecipheriv('aes-128-cbc', key, key) + dec.setAutoPadding(true) + const result = Buffer.concat([dec.update(encData), dec.final()]) + if (this.isValidImageBuffer(result)) { + return result + } + for (const fn of [zlib.inflateSync, zlib.gunzipSync]) { + try { + const d = fn(result) + if (this.isValidImageBuffer(d)) { + return d + } + } catch { } + } + } catch { } + } + // CBC 变体 2:前 16 字节作为 IV + if (encData.length > 32) { + try { + const iv = encData.subarray(0, 16) + const cbcData = encData.subarray(16) + const dec = crypto.createDecipheriv('aes-128-cbc', key, iv) + dec.setAutoPadding(true) + const result = Buffer.concat([dec.update(cbcData), dec.final()]) + if (this.isValidImageBuffer(result)) { + return result + } + // CBC + zlib + for (const fn of [zlib.inflateSync, zlib.gunzipSync]) { + try { + const d = fn(result) + if (this.isValidImageBuffer(d)) { + return d + } + } catch { } + } + } catch { } + } + // ECB + try { + const dec = crypto.createDecipheriv('aes-128-ecb', key, null) + dec.setAutoPadding(true) + const result = Buffer.concat([dec.update(encData), dec.final()]) + if (this.isValidImageBuffer(result)) { + return result + } + } catch { } + } + + return null + } + + /** 构建密钥派生列表 */ + private buildKeyTries(aesKey: string): { name: string; key: Buffer }[] { + const keyTries: { name: string; key: Buffer }[] = [] + const hexStr = aesKey.replace(/\s/g, '') + if (hexStr.length >= 32 && /^[0-9a-fA-F]+$/.test(hexStr)) { + try { + const keyBuf = Buffer.from(hexStr.slice(0, 32), 'hex') + if (keyBuf.length === 16) keyTries.push({ name: 'hex-decode', key: keyBuf }) + } catch { } + // ★ IDA 发现:WeChat 可能直接用 hex 字符串作为 32 字节密钥 → AES-256-GCM + // sub_182584DB0 支持 key_len=16/24/32,std::string 传递时长度为 32 + const rawKey = Buffer.from(hexStr.slice(0, 32), 'utf8') + if (rawKey.length === 32) keyTries.push({ name: 'raw-hex-str-32', key: rawKey }) + } + if (aesKey.length >= 16) { + keyTries.push({ name: 'utf8-16', key: Buffer.from(aesKey, 'utf8').subarray(0, 16) }) + } + keyTries.push({ name: 'md5', key: crypto.createHash('md5').update(aesKey).digest() }) + try { + const b64Buf = Buffer.from(aesKey, 'base64') + if (b64Buf.length >= 16) keyTries.push({ name: 'base64', key: b64Buf.subarray(0, 16) }) + } catch { } + return keyTries + } + + /** 构建多种 GCM 数据布局(nonce + ciphertext + tag 的不同拆分方式) */ + private buildGcmLayouts(encData: Buffer): { name: string; nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] { + const layouts: { name: string; nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] = [] + + // 格式 A:GcmData 块格式 — magic \xAB GcmData \xAB\x00 (10B), nonce at offset 19 (12B), payload at offset 63 + if (encData.length > 63 && encData[0] === 0xAB && encData[8] === 0xAB && encData[9] === 0x00) { + const payloadSize = encData.readUInt32LE(10) + if (payloadSize > 16 && 63 + payloadSize <= encData.length) { + const nonce = encData.subarray(19, 31) + const payload = encData.subarray(63, 63 + payloadSize) + const tag = payload.subarray(payload.length - 16) + const ciphertext = payload.subarray(0, payload.length - 16) + layouts.push({ name: 'gcmdata-block', nonce, ciphertext, tag }) + } + } + + // 格式 B:尾部格式 [ciphertext][nonce 12B][tag 16B](mmcrypto::AesGcmDecrypt) + if (encData.length > 28) { + layouts.push({ + name: 'nonce-tail', + ciphertext: encData.subarray(0, encData.length - 28), + nonce: encData.subarray(encData.length - 28, encData.length - 16), + tag: encData.subarray(encData.length - 16) + }) + } + + // 格式 C:前置格式 [nonce 12B][ciphertext][tag 16B] + if (encData.length > 28) { + layouts.push({ + name: 'nonce-head', + nonce: encData.subarray(0, 12), + ciphertext: encData.subarray(12, encData.length - 16), + tag: encData.subarray(encData.length - 16) + }) + } + + // 格式 D:零 nonce,[ciphertext][tag 16B] + if (encData.length > 16) { + layouts.push({ + name: 'zero-nonce', + nonce: Buffer.alloc(12, 0), + ciphertext: encData.subarray(0, encData.length - 16), + tag: encData.subarray(encData.length - 16) + }) + } + + // 格式 E:前置格式 [nonce 12B][tag 16B][ciphertext] + if (encData.length > 28) { + layouts.push({ + name: 'nonce-tag-head', + nonce: encData.subarray(0, 12), + tag: encData.subarray(12, 28), + ciphertext: encData.subarray(28) + }) + } + + return layouts + } + + /** 尝试 AES-GCM 解密,根据 key 长度自动选择 128/256,auth tag 通过即返回 */ + private tryGcmDecrypt(key: Buffer, nonce: Buffer, ciphertext: Buffer, tag: Buffer): Buffer | null { + try { + const algo = key.length === 32 ? 'aes-256-gcm' : 'aes-128-gcm' + const decipher = crypto.createDecipheriv(algo, key, nonce) + decipher.setAuthTag(tag) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + + // auth tag 通过 → 解密正确 + if (this.isValidImageBuffer(decrypted)) return decrypted + + // 尝试 zlib 解压(AesGcmDecryptWithUncompress) + for (const fn of [zlib.inflateSync, zlib.gunzipSync, zlib.unzipSync]) { + try { + const decompressed = fn(decrypted) + if (this.isValidImageBuffer(decompressed)) return decompressed + } catch { } + } + + // GCM auth tag 通过但不是已知图片格式,仍然返回(可能是 lottie/tgs 等) + console.log('[SnsService] GCM auth tag 通过但非已知图片格式', { + size: decrypted.length, + headHex: decrypted.subarray(0, 16).toString('hex') + }) + return decrypted + } catch { + return null + } + } + + /** 判断 buffer 是否为有效图片头(GIF/PNG/JPEG/WebP) */ + private isValidImageBuffer(buf: Buffer): boolean { + if (!buf || buf.length < 12) return false + if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return true // GIF + if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return true // PNG + if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true // JPEG + if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true // WebP + return false + } + + /** 根据图片头返回扩展名 */ + private getImageExtFromBuffer(buf: Buffer): string { + if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif' + if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return '.png' + if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return '.jpg' + if (buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return '.webp' + return '.gif' + } + + /** + * 下载朋友圈评论表情包到本地缓存 + * 解密说明:优先用 XML 里的 encrypt_url + aes_key(AES-128-GCM,数据格式=[密文][nonce 12B][tag 16B],解密后 zlib 解压); + * 若只有普通 url 且下载下来是加密数据,会尝试用设置里的「图片 AES 密钥」解密。 + */ + async downloadSnsEmoji(url: string, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; error?: string }> { + if (!url && !encryptUrl) return { success: false, error: 'url 不能为空' } + + const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex') + const cacheDir = this.getEmojiCacheDir() + const fs = require('fs') + + + + // 检查本地是否已有缓存 + const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg'] + for (const ext of extensions) { + const filePath = join(cacheDir, `${cacheKey}${ext}`) + if (existsSync(filePath)) { + return { success: true, localPath: filePath } + } + } + + // 1. 优先:有 encrypt_url + aes_key 时,下载加密内容并用多种密钥派生尝试 AES 解密 + if (encryptUrl && aesKey) { + const encResult = await this.doDownloadRaw(encryptUrl, cacheKey + '_enc', cacheDir) + if (encResult) { + const encData = fs.readFileSync(encResult) + // 有些情况下 encrypt_url 直接返回明文图片 + if (this.isValidImageBuffer(encData)) { + const ext = this.getImageExtFromBuffer(encData) + const filePath = join(cacheDir, `${cacheKey}${ext}`) + fs.writeFileSync(filePath, encData) + try { fs.unlinkSync(encResult) } catch { } + return { success: true, localPath: filePath } + } + const decrypted = this.decryptEmojiAes(encData, aesKey) + if (decrypted) { + const ext = this.isValidImageBuffer(decrypted) + ? this.getImageExtFromBuffer(decrypted) + : '.gif' // GCM auth tag 通过但非已知图片格式,默认 .gif + const filePath = join(cacheDir, `${cacheKey}${ext}`) + fs.writeFileSync(filePath, decrypted) + try { fs.unlinkSync(encResult) } catch { } + return { success: true, localPath: filePath } + } + this.decryptEmojiAes(encData, aesKey, { cacheKey, source: 'encrypt_url' }) + try { fs.unlinkSync(encResult) } catch { } + } + // encrypt_url 下载失败或解密失败,继续尝试普通 url + } + + // 2. 直接下载 extern_url / cdn_url + if (url) { + const result = await this.doDownloadRaw(url, cacheKey, cacheDir) + if (result) { + const buf = fs.readFileSync(result) + if (this.isValidImageBuffer(buf)) { + return { success: true, localPath: result } + } + // 若有 XML 的 aes_key,优先用同一密钥解密(plain url 有时也返回加密数据) + if (aesKey) { + const decrypted = this.decryptEmojiAes(buf, aesKey) + if (decrypted) { + const ext = this.isValidImageBuffer(decrypted) + ? this.getImageExtFromBuffer(decrypted) + : '.gif' + const filePath = join(cacheDir, `${cacheKey}${ext}`) + fs.writeFileSync(filePath, decrypted) + try { fs.unlinkSync(result) } catch { } + return { success: true, localPath: filePath } + } + this.decryptEmojiAes(buf, aesKey, { cacheKey, source: 'plain_url' }) + } + // 再尝试用设置里的图片 AES 密钥解密(多种派生) + const imageAesKey = this.configService.get('imageAesKey') + const keyStr = typeof imageAesKey === 'string' ? imageAesKey.trim() : '' + if (keyStr.length >= 16) { + const keyTries: Buffer[] = [ + Buffer.from(keyStr, 'ascii').subarray(0, 16), + crypto.createHash('md5').update(keyStr).digest(), + ] + for (const keyBuf of keyTries) { + try { + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBuf, null) + decipher.setAutoPadding(true) + const decrypted = Buffer.concat([decipher.update(buf), decipher.final()]) + if (this.isValidImageBuffer(decrypted)) { + const ext = this.getImageExtFromBuffer(decrypted) + const filePath = join(cacheDir, `${cacheKey}${ext}`) + fs.writeFileSync(filePath, decrypted) + try { fs.unlinkSync(result) } catch { } + return { success: true, localPath: filePath } + } + } catch { /* next */ } + } + try { + const decipher = crypto.createDecipheriv('aes-128-ecb', keyTries[0], null) + decipher.setAutoPadding(false) + let decrypted = Buffer.concat([decipher.update(buf), decipher.final()]) + if (decrypted.length > 0 && decrypted[decrypted.length - 1] >= 1 && decrypted[decrypted.length - 1] <= 16) { + const pad = decrypted[decrypted.length - 1] + const tail = decrypted.subarray(-pad) + if (tail.every((b: number) => b === pad)) decrypted = decrypted.subarray(0, decrypted.length - pad) + } + if (this.isValidImageBuffer(decrypted)) { + const ext = this.getImageExtFromBuffer(decrypted) + const filePath = join(cacheDir, `${cacheKey}${ext}`) + fs.writeFileSync(filePath, decrypted) + try { fs.unlinkSync(result) } catch { } + return { success: true, localPath: filePath } + } + } catch { /* ignore */ } + } + try { fs.unlinkSync(result) } catch { } + } + } + + return { success: false, error: '下载失败' } + } + + /** + * 下载原始文件到缓存目录 + */ + private doDownloadRaw(url: string, cacheKey: string, cacheDir: string): Promise { + return new Promise((resolve) => { + try { + let fixedUrl = url.replace(/&/g, '&') + // 微信 CDN 使用 HTTP,不强制转 HTTPS(部分 CDN 不支持 HTTPS 会导致下载失败) + const https = require('https') + const http = require('http') + const urlObj = new URL(fixedUrl) + const protocol = fixedUrl.startsWith('https') ? https : http + + const options = { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x67001431) NetType/WIFI WindowsWechat/3.9.11.17(0x63090b11)', + 'Accept': '*/*', + 'Connection': 'keep-alive' + }, + rejectUnauthorized: false, + timeout: 15000 + } + + const request = protocol.get(fixedUrl, options, (response: any) => { + if ([301, 302, 303, 307].includes(response.statusCode)) { + const redirectUrl = response.headers.location + if (redirectUrl) { + const full = redirectUrl.startsWith('http') ? redirectUrl : `${urlObj.protocol}//${urlObj.host}${redirectUrl}` + this.doDownloadRaw(full, cacheKey, cacheDir).then(resolve) + return + } + } + + if (response.statusCode !== 200) { + resolve(null) + return + } + + const chunks: Buffer[] = [] + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + response.on('end', () => { + const buffer = Buffer.concat(chunks) + if (buffer.length === 0) { resolve(null); return } + + let ext = '.gif' + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) ext = '.gif' + else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) ext = '.png' + else if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) ext = '.jpg' + else if (buffer.length >= 12 && buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) ext = '.webp' + + const filePath = join(cacheDir, `${cacheKey}${ext}`) + try { + require('fs').writeFileSync(filePath, buffer) + resolve(filePath) + } catch { + resolve(null) + } + }) + response.on('error', () => { + resolve(null) + }) + }) + + request.on('error', () => { + resolve(null) + }) + request.setTimeout(15000, () => { + request.destroy() + resolve(null) + }) + } catch { + resolve(null) + } + }) + } + private getSnsCacheDir(): string { const cachePath = this.configService.getCacheBasePath() const snsCacheDir = join(cachePath, 'sns_cache') @@ -664,38 +1219,62 @@ class SnsService { return snsCacheDir } - private getCacheFilePath(url: string): string { - const hash = crypto.createHash('md5').update(url).digest('hex') + private getCacheFilePath(url: string, md5?: string): string { + const hash = md5 || crypto.createHash('md5').update(url).digest('hex') const ext = isVideoUrl(url) ? '.mp4' : '.jpg' return join(this.getSnsCacheDir(), `${hash}${ext}`) } async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { - // 优先尝试使用 DLL 实时读取(推荐) + // 优先尝试使用 DLL execQuery 直接查 SnsTimeLine 表(获取完整 XML,包含评论/点赞/表情包) try { - const dllResult = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) + let sql = 'SELECT tid, user_name, content FROM SnsTimeLine WHERE 1=1' - if (dllResult.success && dllResult.timeline) { - // DLL 返回的数据已经包含了点赞和评论,直接使用 - const enrichedTimeline = await Promise.all(dllResult.timeline.map(async (post: any) => { - // 获取头像 - const avatarInfo = await chatService.getContactAvatar(post.username) + if (usernames && usernames.length > 0) { + const escaped = usernames.map(u => `'${u.replace(/'/g, "''")}'`).join(',') + sql += ` AND user_name IN (${escaped})` + } + if (keyword) { + sql += ` AND content LIKE '%${keyword.replace(/'/g, "''")}%'` + } - // 从 rawXml 中提取视频密钥 - const videoKey = extractVideoKey(post.rawXml || '') + // 时间范围过滤 + if (startTime) { + sql += ` AND CAST(SUBSTR(CAST(content AS TEXT), INSTR(CAST(content AS TEXT), '') + 12, 10) AS INTEGER) >= ${startTime}` + } + if (endTime) { + sql += ` AND CAST(SUBSTR(CAST(content AS TEXT), INSTR(CAST(content AS TEXT), '') + 12, 10) AS INTEGER) <= ${endTime}` + } - // 修正媒体 URL - const fixedMedia = (post.media || []).map((m: any) => { + sql += ' ORDER BY tid DESC LIMIT ' + limit + ' OFFSET ' + offset + + const queryResult = await wcdbService.execQuery('sns', '', sql) + + if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) { + const timeline: SnsPost[] = await Promise.all(queryResult.rows.map(async (row: any) => { + const xmlContent = row.content || '' + const contact = await chatService.getContact(row.user_name) + const avatarInfo = await chatService.getContactAvatar(row.user_name) + + const { media, videoKey } = this.parseMediaFromXml(xmlContent) + + // 提取基本信息 + const createTimeMatch = xmlContent.match(/(\d+)<\/createTime>/i) + const idMatch = xmlContent.match(/(\d+)<\/id>/i) + const contentDescMatch = xmlContent.match(/]*)?>([^<]*)<\/contentDesc>/i) + const typeMatch = xmlContent.match(/(\d+)<\/type>/i) + + const fixedMedia = media.map((m) => { const isMediaVideo = isVideoUrl(m.url) - return { url: fixSnsUrl(m.url, m.token, isMediaVideo), thumb: fixSnsUrl(m.thumb, m.token, false), md5: m.md5, token: m.token, key: isMediaVideo ? (videoKey || m.key) : m.key, - thumbKey: m.thumbKey, encIdx: m.encIdx, + width: m.width, + height: m.height, livePhoto: m.livePhoto ? { url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false), @@ -707,18 +1286,29 @@ class SnsService { } }) + const likes = this.parseLikesFromXml(xmlContent) + const comments = this.parseCommentsFromXml(xmlContent) + return { - ...post, + id: idMatch ? idMatch[1] : String(row.tid), + username: row.user_name, + nickname: contact?.remark || contact?.nickName || contact?.alias || row.user_name, avatarUrl: avatarInfo?.avatarUrl, + createTime: createTimeMatch ? parseInt(createTimeMatch[1]) : 0, + contentDesc: contentDescMatch ? contentDescMatch[1] : '', + type: typeMatch ? parseInt(typeMatch[1]) : 1, media: fixedMedia, - shareInfo: extractShareInfo(post.rawXml || '') + shareInfo: extractShareInfo(xmlContent), + likes, + comments, + rawXml: xmlContent } })) - return { success: true, timeline: enrichedTimeline } + return { success: true, timeline } } } catch (dllError) { - console.warn('[SnsService] DLL 读取失败,尝试使用解密后的数据库:', dllError) + console.warn('[SnsService] execQuery 读取失败,尝试使用解密后的数据库:', dllError) } // 回退:使用解密后的数据库(数据可能不是最新的) @@ -727,13 +1317,7 @@ class SnsService { } try { - // 先查询总记录数,用于调试 - const countStmt = this.snsDb!.prepare('SELECT COUNT(*) as total FROM SnsTimeLine') - const countResult = countStmt.get() as { total: number } - console.log(`[SnsService] 数据库总记录数: ${countResult.total}`) - // 构建 SQL 查询 - // 注意:表名是 SnsTimeLine,字段是 tid, user_name, content let sql = 'SELECT tid, user_name, content FROM SnsTimeLine WHERE 1=1' const params: any[] = [] @@ -749,33 +1333,23 @@ class SnsService { params.push(`%${keyword}%`) } - // 时间范围过滤(需要从 XML 中提取 createTime) - // 暂时跳过时间过滤,因为时间在 XML 中 + // 时间范围过滤 + if (startTime) { + sql += ` AND CAST(SUBSTR(CAST(content AS TEXT), INSTR(CAST(content AS TEXT), '') + 12, 10) AS INTEGER) >= ?` + params.push(startTime) + } + if (endTime) { + sql += ` AND CAST(SUBSTR(CAST(content AS TEXT), INSTR(CAST(content AS TEXT), '') + 12, 10) AS INTEGER) <= ?` + params.push(endTime) + } // 排序和分页(按 tid 降序,tid 越大越新) sql += ' ORDER BY tid DESC LIMIT ? OFFSET ?' params.push(limit, offset) - console.log(`[SnsService] SQL 查询: ${sql}`) - console.log(`[SnsService] 参数: limit=${limit}, offset=${offset}, usernames=${usernames?.length || 0}, keyword=${keyword || 'none'}`) - const stmt = this.snsDb!.prepare(sql) const rows = stmt.all(...params) as any[] - console.log(`[SnsService] 查询返回 ${rows.length} 条记录`) - - // 检查第一条记录的内容 - if (rows.length > 0) { - const firstRow = rows[0] - console.log(`[SnsService] 第一条记录: tid=${firstRow.tid}, user_name=${firstRow.user_name}, content长度=${firstRow.content?.length || 0}`) - - // 检查 content 是否为空 - const emptyContentCount = rows.filter(r => !r.content || r.content.trim().length === 0).length - if (emptyContentCount > 0) { - console.warn(`[SnsService] 警告: ${emptyContentCount} 条记录的 content 字段为空`) - } - } - // 解析每条记录 const timeline: SnsPost[] = await Promise.all(rows.map(async (row) => { const contact = await chatService.getContact(row.user_name) @@ -846,17 +1420,6 @@ class SnsService { const likes = this.parseLikesFromXml(xmlContent) const comments = this.parseCommentsFromXml(xmlContent) - // 临时调试:打印第一条动态的 XML 看看结构 - if (offset === 0 && rows.indexOf(row) === 0) { - console.log('[SnsService] 第一条动态的 XML 片段(点赞评论部分):') - const likeMatch = xmlContent.match(/[\s\S]*?<\/LikeUserList>/i) - const commentMatch = xmlContent.match(/[\s\S]*?<\/CommentUserList>/i) - if (likeMatch) console.log('点赞:', likeMatch[0].substring(0, 500)) - if (commentMatch) console.log('评论:', commentMatch[0].substring(0, 500)) - console.log('解析结果 - 点赞:', likes) - console.log('解析结果 - 评论:', comments) - } - return { id: snsId, username: row.user_name, @@ -880,10 +1443,10 @@ class SnsService { } } - async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; localPath?: string; error?: string }> { + async proxyImage(url: string, key?: string | number, md5?: string): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; localPath?: string; error?: string }> { if (!url) return { success: false, error: 'url 不能为空' } - const result = await this.fetchAndDecryptImage(url, key) + const result = await this.fetchAndDecryptImage(url, key, md5) if (result.success) { // 视频返回文件路径 if (result.contentType?.startsWith('video/')) { @@ -902,15 +1465,15 @@ class SnsService { return { success: false, error: result.error } } - async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { - return this.fetchAndDecryptImage(url, key) + async downloadImage(url: string, key?: string | number, md5?: string): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { + return this.fetchAndDecryptImage(url, key, md5) } - private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { + private async fetchAndDecryptImage(url: string, key?: string | number, md5?: string): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { if (!url) return { success: false, error: 'url 不能为空' } const isVideo = isVideoUrl(url) - const cachePath = this.getCacheFilePath(url) + const cachePath = this.getCacheFilePath(url, md5) // 1. 检查缓存(优先返回本地文件) if (existsSync(cachePath)) { @@ -998,7 +1561,7 @@ class SnsService { // 验证 MP4 签名 ('ftyp' at offset 4) const ftyp = raw.subarray(4, 8).toString('ascii') if (ftyp !== 'ftyp') { - console.warn('[SnsService] 视频解密后签名验证失败,ftyp:', ftyp) + // 签名验证失败,静默处理 } } catch (err) { console.error(`[SnsService] 视频解密出错: ${err}`) diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 7de4e04..2ae5f4a 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -1,8 +1,12 @@ import { dirname, join } from 'path' -import { existsSync, readdirSync, statSync, readFileSync } from 'fs' +import { existsSync, readdirSync, statSync, readFileSync, mkdirSync, createWriteStream } from 'fs' +import { writeFile } from 'fs/promises' import { ConfigService } from './config' import Database from 'better-sqlite3' import { app } from 'electron' +import { Isaac64 } from './isaac64' +import https from 'https' +import http from 'http' export interface VideoInfo { videoUrl?: string // 视频文件路径(用�?readFile�? @@ -11,6 +15,33 @@ export interface VideoInfo { exists: boolean } +export interface ChannelVideoInfo { + objectId: string + title: string + author: string + avatar?: string + videoUrl: string + thumbUrl?: string + coverUrl?: string + duration?: number + width?: number + height?: number + decodeKey?: string +} + +export interface DownloadProgress { + downloaded: number + total: number + percentage: number +} + +export interface DownloadResult { + success: boolean + filePath?: string + error?: string + needsKey?: boolean // 是否需要解密 key +} + class VideoService { private configService: ConfigService @@ -243,6 +274,293 @@ class VideoService { return undefined } + + /** + * 从聊天消息 XML 中解析视频号信息 + */ + parseChannelVideoFromXml(content: string): ChannelVideoInfo | undefined { + if (!content) return undefined + + try { + // 提取 finderFeed 内容 + const finderMatch = /([\s\S]*?)<\/finderFeed>/i.exec(content) + if (!finderMatch) return undefined + + const finderXml = finderMatch[1] + + // 提取基本信息 + const objectIdMatch = /[\s\S]*?[\s\S]*?<\/objectId>/i.exec(finderXml) + const nicknameMatch = /[\s\S]*?[\s\S]*?<\/nickname>/i.exec(finderXml) + const descMatch = /[\s\S]*?[\s\S]*?<\/desc>/i.exec(finderXml) + const avatarMatch = /[\s\S]*?[\s\S]*?<\/avatar>/i.exec(finderXml) + + if (!objectIdMatch) return undefined + + const objectId = objectIdMatch[1] + const author = nicknameMatch ? nicknameMatch[1] : '未知作者' + const title = descMatch ? descMatch[1] : '视频号视频' + const avatar = avatarMatch ? avatarMatch[1] : undefined + + // 提取媒体信息 + const mediaListMatch = /([\s\S]*?)<\/mediaList>/i.exec(finderXml) + if (!mediaListMatch) return undefined + + const mediaXml = mediaListMatch[1] + const urlMatch = /[\s\S]*?[\s\S]*?<\/url>/i.exec(mediaXml) + const thumbUrlMatch = /[\s\S]*?[\s\S]*?<\/thumbUrl>/i.exec(mediaXml) + const coverUrlMatch = /[\s\S]*?[\s\S]*?<\/coverUrl>/i.exec(mediaXml) + const durationMatch = /[\s\S]*?[\s\S]*?<\/videoPlayDuration>/i.exec(mediaXml) + const widthMatch = /[\s\S]*?[\s\S]*?<\/width>/i.exec(mediaXml) + const heightMatch = /[\s\S]*?[\s\S]*?<\/height>/i.exec(mediaXml) + const decodeKeyMatch = /[\s\S]*?[\s\S]*?<\/decodeKey>/i.exec(mediaXml) + + if (!urlMatch) return undefined + + return { + objectId, + title, + author, + avatar, + videoUrl: urlMatch[1], + thumbUrl: thumbUrlMatch ? thumbUrlMatch[1] : undefined, + coverUrl: coverUrlMatch ? coverUrlMatch[1] : undefined, + duration: durationMatch ? parseInt(durationMatch[1]) : undefined, + width: widthMatch ? parseInt(widthMatch[1]) : undefined, + height: heightMatch ? parseInt(heightMatch[1]) : undefined, + decodeKey: decodeKeyMatch ? decodeKeyMatch[1] : undefined + } + } catch (e) { + console.error('解析视频号信息失败:', e) + return undefined + } + } + + /** + * 下载视频号视频 + * @param videoInfo 视频信息 + * @param key 解密密钥(可选) + * @param onProgress 进度回调 + */ + async downloadChannelVideo( + videoInfo: ChannelVideoInfo, + key?: string, + onProgress?: (progress: DownloadProgress) => void + ): Promise { + try { + console.log('[ChannelVideo] 开始下载:', videoInfo.objectId) + console.log('[ChannelVideo] 完整URL:', videoInfo.videoUrl) + + if (!videoInfo.videoUrl) { + console.error('[ChannelVideo] videoUrl 为空') + return { success: false, error: '视频地址为空' } + } + + // 创建下载目录 + const cachePath = this.getCachePath() + const channelDir = join(cachePath, 'channel_videos', this.sanitizeFilename(videoInfo.author)) + + if (!existsSync(channelDir)) { + mkdirSync(channelDir, { recursive: true }) + } + + // 生成文件名 + const filename = `${this.sanitizeFilename(videoInfo.title)}_${videoInfo.objectId}.mp4` + const filePath = join(channelDir, filename) + + // 检查文件是否已存在 + if (existsSync(filePath)) { + return { + success: true, + filePath + } + } + + // 下载视频 + const tempPath = filePath + '.tmp' + const downloaded = await this.downloadFile(videoInfo.videoUrl, tempPath, onProgress) + + if (downloaded !== true) { + const msg = downloaded === 400 || downloaded === 403 ? '链接已过期,无法下载' : '下载失败' + return { success: false, error: msg } + } + + // 检查下载的文件大小 + const stat = require('fs').statSync(tempPath) + console.log('[ChannelVideo] 下载完成, 文件大小:', stat.size) + + // TODO: 后续实现解密(需要通过 JS Hook 获取 decodeKey) + + // 重命名为最终文件 + require('fs').renameSync(tempPath, filePath) + + return { + success: true, + filePath + } + } catch (e: any) { + console.error('下载视频号视频失败:', e) + return { + success: false, + error: e.message || '下载失败' + } + } + } + + /** + * 下载文件 + */ + private downloadFile( + url: string, + destPath: string, + onProgress?: (progress: DownloadProgress) => void + ): Promise { + return new Promise((resolve) => { + const doRequest = (currentUrl: string, redirectsLeft: number) => { + try { + console.log('[ChannelVideo] 请求URL:', currentUrl.substring(0, 120), '剩余重定向:', redirectsLeft) + const parsedUrl = new URL(currentUrl) + const reqPath = parsedUrl.pathname + parsedUrl.search + console.log('[ChannelVideo] 解析path长度:', reqPath.length, 'search长度:', parsedUrl.search.length) + const proto = parsedUrl.protocol === 'https:' ? https : http + const reqOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname + parsedUrl.search, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat', + 'Referer': 'https://channels.weixin.qq.com/' + } + } + + proto.get(reqOptions, (response) => { + console.log('[ChannelVideo] 响应状态:', response.statusCode) + // 处理重定向 + if ([301, 302, 303, 307, 308].includes(response.statusCode!) && response.headers.location) { + if (redirectsLeft <= 0) { + console.error('重定向次数过多') + resolve(0) + return + } + doRequest(response.headers.location, redirectsLeft - 1) + return + } + + if (response.statusCode !== 200) { + console.error('下载失败,状态码:', response.statusCode) + resolve(response.statusCode || 0) + return + } + + const totalSize = parseInt(response.headers['content-length'] || '0', 10) + let downloadedSize = 0 + const fileStream = createWriteStream(destPath) + + response.on('data', (chunk) => { + downloadedSize += chunk.length + if (onProgress && totalSize > 0) { + onProgress({ + downloaded: downloadedSize, + total: totalSize, + percentage: (downloadedSize / totalSize) * 100 + }) + } + }) + + response.pipe(fileStream) + + fileStream.on('finish', () => { + fileStream.close() + resolve(true) + }) + + fileStream.on('error', (err) => { + console.error('写入文件失败:', err) + fileStream.close() + resolve(0) + }) + }).on('error', (err) => { + console.error('下载请求失败:', err) + resolve(0) + }) + } catch (e) { + console.error('下载异常:', e) + resolve(0) + } + } + + doRequest(url, 5) + }) + } + + /** + * 检查视频是否加密 + * 通过检查文件头部特征判断 + */ + private async checkIfEncrypted(filePath: string): Promise { + try { + const fd = require('fs').openSync(filePath, 'r') + const header = Buffer.alloc(12) + require('fs').readSync(fd, header, 0, 12, 0) + require('fs').closeSync(fd) + + const sig = header.toString('ascii', 4, 8) + console.log('[ChannelVideo] 文件头签名:', sig, '前12字节hex:', header.toString('hex')) + + // MP4 box types that indicate a valid video file + if (['ftyp', 'mdat', 'moov', 'free', 'skip', 'wide'].includes(sig)) { + return false + } + // 也检查前4字节是否是常见视频格式 + const head4 = header.toString('hex', 0, 4) + if (head4 === '1a45dfa3' || head4 === '464c5601') { // WebM / FLV + return false + } + return true + } catch (e) { + console.error('检查加密状态失败:', e) + return false + } + } + + /** + * 使用 ISAAC64 解密视频号视频 + * 只解密前 128KB + */ + private async decryptChannelVideo(filePath: string, key: string): Promise { + try { + const buffer = readFileSync(filePath) + const prefixLen = 131072 // 128KB + + if (buffer.length === 0) return false + + // 生成解密密钥流 + const isaac = new Isaac64(key) + const keystream = isaac.generateKeystreamBE(Math.min(prefixLen, buffer.length)) + + // XOR 解密前 128KB + const decryptLen = Math.min(prefixLen, buffer.length) + for (let i = 0; i < decryptLen; i++) { + buffer[i] ^= keystream[i] + } + + // 写回文件 + await writeFile(filePath, buffer) + return true + } catch (e) { + console.error('解密视频失败:', e) + return false + } + } + + /** + * 清理文件名中的非法字符 + */ + private sanitizeFilename(filename: string): string { + return filename + .replace(/[<>:"/\\|?*]/g, '_') // 替换非法字符 + .replace(/\s+/g, '_') // 替换空格 + .substring(0, 100) // 限制长度 + } } export const videoService = new VideoService() diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index ebcc8bc..13e8cc6 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -17,6 +17,7 @@ export class WcdbService { private wcdbGetSessions: any = null private wcdbGetLogs: any = null private wcdbGetSnsTimeline: any = null + private wcdbExecQuery: any = null /** * 获取 DLL 路径 @@ -127,6 +128,9 @@ export class WcdbService { // wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 start_time, int32 end_time, char** out_json) this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)') + // wcdb_status wcdb_exec_query(wcdb_handle handle, const char* db_kind, const char* db_path, const char* sql, char** out_json) + this.wcdbExecQuery = this.lib.func('int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)') + // 初始化 const initResult = this.wcdbInit() if (initResult !== 0) { @@ -337,6 +341,32 @@ export class WcdbService { } } + /** + * 执行原始 SQL 查询 + */ + async execQuery(kind: string, path: string, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> { + if (!this.initialized || this.handle === null) { + return { success: false, error: 'WCDB 未初始化' } + } + + try { + const outJson = [null] + const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outJson) + + if (result !== 0 || !outJson[0]) { + return { success: false, error: `执行查询失败 (错误码: ${result})` } + } + + const jsonStr = this.koffi.decode(outJson[0], 'char', -1) + this.wcdbFreeString(outJson[0]) + + const rows = JSON.parse(jsonStr) + return { success: true, rows } + } catch (e: any) { + return { success: false, error: e.message } + } + } + /** * 解密朋友圈图片(使用纯 JS 实现,不依赖 DLL) */ diff --git a/electron/services/wxKeyService.ts b/electron/services/wxKeyService.ts index 9995dc4..33e4c9b 100644 --- a/electron/services/wxKeyService.ts +++ b/electron/services/wxKeyService.ts @@ -18,7 +18,7 @@ export class WxKeyService { const resourcesPath = app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources') - + return join(resourcesPath, 'wx_key.dll') } @@ -41,7 +41,7 @@ export class WxKeyService { try { const result = execSync('tasklist /FI "IMAGENAME eq Weixin.exe" /FO CSV /NH', { encoding: 'utf8' }) const lines = result.trim().split('\n') - + for (const line of lines) { if (line.toLowerCase().includes('weixin.exe')) { const parts = line.split(',') @@ -123,7 +123,7 @@ export class WxKeyService { continue } } - } catch {} + } catch { } // 常见路径 - 只查找 Weixin.exe const drives = ['C', 'D', 'E', 'F'] @@ -155,10 +155,10 @@ export class WxKeyService { try { spawn(wechatPath, [], { detached: true, stdio: 'ignore' }).unref() - + // 等待微信启动 await new Promise(resolve => setTimeout(resolve, 2000)) - + return this.isWeChatRunning() } catch { return false @@ -171,7 +171,7 @@ export class WxKeyService { async waitForWeChatWindow(maxWaitSeconds = 15): Promise { for (let i = 0; i < maxWaitSeconds * 2; i++) { await new Promise(resolve => setTimeout(resolve, 500)) - + // 检查 Weixin.exe 或 WeChat.exe 进程 const pid = this.getWeChatPid() if (pid !== null) { @@ -188,9 +188,9 @@ export class WxKeyService { try { const koffi = require('koffi') const dllPath = this.getDllPath() - + console.log('加载 DLL:', dllPath) - + if (!existsSync(dllPath)) { console.error('DLL 文件不存在:', dllPath) return false @@ -219,14 +219,14 @@ export class WxKeyService { try { const koffi = require('koffi') - + this.onKeyReceived = onKeyReceived this.onStatus = onStatus || null // 定义函数 const InitializeHook = this.lib.func('bool InitializeHook(uint32_t)') const success = InitializeHook(targetPid) - + if (success) { this.startPolling() } @@ -243,7 +243,7 @@ export class WxKeyService { */ private startPolling(): void { this.stopPolling() - + this.pollingTimer = setInterval(() => { this.pollData() }, 100) @@ -267,16 +267,16 @@ export class WxKeyService { try { const koffi = require('koffi') - + // 定义函数 const PollKeyData = this.lib.func('bool PollKeyData(char*, int32_t)') const GetStatusMessage = this.lib.func('bool GetStatusMessage(char*, int32_t, int32_t*)') - + // 轮询密钥 const keyBuffer = Buffer.alloc(65) if (PollKeyData(keyBuffer, 65)) { const key = keyBuffer.toString('utf8').replace(/\0/g, '').trim() - + if (key && this.onKeyReceived) { this.onKeyReceived(key) } @@ -286,11 +286,11 @@ export class WxKeyService { for (let i = 0; i < 5; i++) { const statusBuffer = Buffer.alloc(256) const levelBuffer = Buffer.alloc(4) - + if (GetStatusMessage(statusBuffer, 256, levelBuffer)) { const status = statusBuffer.toString('utf8').replace(/\0/g, '').trim() const level = levelBuffer.readInt32LE(0) - + if (this.onStatus) { this.onStatus(status, level) } @@ -308,7 +308,7 @@ export class WxKeyService { */ uninstallHook(): boolean { this.stopPolling() - + if (!this.lib) { return false } @@ -347,6 +347,42 @@ export class WxKeyService { this.onStatus = null } + /** + * 获取图片解密密钥(通过 DLL 本地文件扫描,秒级返回,无需微信进程运行) + * 从 kvcomm 缓存目录的 statistic 文件中提取唯一码,计算 XOR 和 AES 密钥 + */ + getImageKey(): { success: boolean; json?: string; error?: string } { + if (!this.lib) { + return { success: false, error: 'DLL 未加载' } + } + + try { + const koffi = require('koffi') + const GetImageKeyFn = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)') + const GetLastErrorMsgFn = this.lib.func('const char* GetLastErrorMsg()') + + const resultBuffer = Buffer.alloc(8192) + const ok = GetImageKeyFn(resultBuffer, resultBuffer.length) + + if (!ok) { + let errMsg = '获取图片密钥失败' + try { + const errPtr = GetLastErrorMsgFn() + if (errPtr) { + errMsg = typeof errPtr === 'string' ? errPtr : koffi.decode(errPtr, 'char', -1) + } + } catch { } + return { success: false, error: errMsg } + } + + const nullIdx = resultBuffer.indexOf(0) + const json = resultBuffer.toString('utf8', 0, nullIdx > -1 ? nullIdx : undefined).trim() + return { success: true, json } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 检测当前登录的微信账号 * 通过扫描数据库目录下的账号目录,根据最近修改时间判断当前活跃账号 @@ -370,13 +406,13 @@ export class WxKeyService { // 遍历数据库目录下的所有账号目录 const entries = readdirSync(dbPath, { withFileTypes: true }) - + for (const entry of entries) { if (!entry.isDirectory()) continue - + const accountDirName = entry.name const accountDir = join(dbPath, accountDirName) - + // 检查是否是有效的账号目录(包含 db_storage) const dbStorageDir = join(accountDir, 'db_storage') if (!existsSync(dbStorageDir)) continue @@ -387,7 +423,7 @@ export class WxKeyService { // 获取账号目录的最近活动时间 const modifiedTime = this.getAccountModifiedTime(accountDir) const timeDiff = Math.abs(now - modifiedTime) - + // 检查是否在时间范围内 if (timeDiff <= maxTimeDiffMs) { if (!bestMatch || timeDiff < bestMatch.timeDiff) { @@ -416,12 +452,12 @@ export class WxKeyService { // 如果没有在时间范围内的账号,但有备选账号,询问用户是否使用 if (fallbackMatch) { // 如果只有一个有效账号,直接使用(不管时间差) - if (entries.filter(e => e.isDirectory() && - existsSync(join(dbPath, e.name, 'db_storage')) && - !this.isSystemDirectory(e.name)).length === 1) { + if (entries.filter(e => e.isDirectory() && + existsSync(join(dbPath, e.name, 'db_storage')) && + !this.isSystemDirectory(e.name)).length === 1) { return { wxid: fallbackMatch.wxid, dbPath: fallbackMatch.dbPath } } - + // 如果时间差在24小时内,自动使用这个账号 if (fallbackMatch.timeDiff <= 24 * 60 * 60 * 1000) { return { wxid: fallbackMatch.wxid, dbPath: fallbackMatch.dbPath } diff --git a/package.json b/package.json index d4cbf87..3a8b1d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ciphertalk", - "version": "2.2.5", + "version": "2.2.7", "description": "密语 - 微信聊天记录查看工具", "author": "ILoveBingLu", "license": "CC-BY-NC-SA-4.0", diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index e9e509e..0ec075d 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/resources/wx_key.dll b/resources/wx_key.dll index 09c4cbd..8510ce2 100644 Binary files a/resources/wx_key.dll and b/resources/wx_key.dll differ diff --git a/src/components/ImagePreview.scss b/src/components/ImagePreview.scss index a3adf11..757d614 100644 --- a/src/components/ImagePreview.scss +++ b/src/components/ImagePreview.scss @@ -33,8 +33,8 @@ .image-preview-close { position: fixed; - top: 20px; - right: 20px; + top: 48px; + right: 48px; width: 44px; height: 44px; border-radius: 50%; diff --git a/src/components/WhatsNewModal.tsx b/src/components/WhatsNewModal.tsx index 192b86e..e92b513 100644 --- a/src/components/WhatsNewModal.tsx +++ b/src/components/WhatsNewModal.tsx @@ -1,3 +1,4 @@ +//更新说明!!! import { Package, Image, Mic, Filter, Send, Aperture } from 'lucide-react' import './WhatsNewModal.scss' @@ -11,13 +12,13 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) { { icon: , title: '优化', - desc: '修复并优化部分内容。' + desc: '别管优化了什么,反正是优化了好多,记不清了。' }, - { - icon: , - title: '聊天内图片', - desc: '支持查看谷歌标准实况图片(iOS端与大疆等实况图片,发送后实况暂不支持)。' - } + // { + // icon: , + // title: '聊天内图片', + // desc: '支持查看谷歌标准实况图片(iOS端与大疆等实况图片,发送后实况暂不支持)。' + // } // { // icon: , // title: '语音导出', @@ -28,11 +29,11 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) { // title: '分类导出', // desc: '导出时可按群聊或个人聊天筛选,支持日期范围过滤。' // } - // { - // icon: , - // title: '朋友圈', - // desc: '新增朋友圈功能!' - // } + { + icon: , + title: '朋友圈', + desc: '评论内的表情包已完成解密!' + } ] const handleTelegram = () => { diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 4d43ca3..c0126ec 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -624,6 +624,8 @@ border: 1px solid var(--border-color); cursor: default; line-height: 1.4; + display: flex; + align-items: center; } .quote-text { @@ -642,6 +644,16 @@ font-weight: 500; opacity: 0.9; } + + .quote-image-thumb { + width: 36px; + height: 36px; + object-fit: cover; + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; + margin-left: 8px; + } } .time-divider, @@ -1577,6 +1589,16 @@ // 群聊发送者名称 // 链接/分享消息卡片 +.card-badge { + margin-left: auto; + font-size: 10px; + color: var(--text-tertiary); + background: var(--bg-tertiary); + padding: 1px 6px; + border-radius: 8px; + white-space: nowrap; +} + .link-message { width: 280px; background: var(--card-bg); @@ -1652,6 +1674,143 @@ } } } + + &--cover { + width: 200px; + } + + .link-cover { + height: 260px; + overflow: hidden; + + img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + } + } + + .link-source { + padding: 6px 12px 8px; + font-size: 11px; + color: var(--text-tertiary); + border-top: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 4px; + + .link-source-avatar { + width: 16px; + height: 16px; + border-radius: 50%; + } + } +} + +// 小程序消息卡片 +.miniprogram-card { + width: 240px; + background: var(--card-bg); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-color); + + .miniprogram-header { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 12px 4px; + + .miniprogram-icon { + width: 18px; + height: 18px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + } + + .miniprogram-icon-placeholder { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--bg-tertiary); + flex-shrink: 0; + } + + .miniprogram-name { + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .miniprogram-title { + padding: 4px 12px 8px; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .miniprogram-cover { + overflow: hidden; + + .miniprogram-cover-img { + width: calc(100% - 24px); + margin: 0 12px 8px; + display: block; + object-fit: cover; + border-radius: 6px; + } + + .miniprogram-cover-placeholder { + width: calc(100% - 24px); + height: 100px; + margin: 0 12px 8px; + border-radius: 6px; + background: var(--bg-tertiary); + } + + .miniprogram-cover-icon { + width: calc(100% - 24px); + height: 100px; + margin: 0 12px 8px; + border-radius: 6px; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + + img { + width: 40px; + height: 40px; + border-radius: 8px; + object-fit: cover; + } + } + } + + .miniprogram-footer { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border-top: 1px solid var(--border-color); + font-size: 11px; + color: var(--text-tertiary); + + .miniprogram-logo { + opacity: 0.6; + } + } } // 适配发送/接收样式(跟随主题色) @@ -1871,6 +2030,345 @@ } } +// 红包卡片 +.hongbao-message { + width: 240px; + background: linear-gradient(135deg, #e25b4a 0%, #c94535 100%); + border-radius: 12px; + padding: 14px 16px; + display: flex; + gap: 12px; + align-items: center; + cursor: default; + + .hongbao-icon { + flex-shrink: 0; + svg { + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1)); + } + } + + .hongbao-info { + flex: 1; + color: white; + + .hongbao-greeting { + font-size: 15px; + font-weight: 500; + margin-bottom: 6px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + } + + .hongbao-label { + font-size: 12px; + opacity: 0.8; + } + } +} + +.gift-message { + width: 240px; + background: linear-gradient(135deg, #f7a8b8 0%, #e88fa0 100%); + border-radius: 12px; + padding: 14px 16px; + cursor: default; + + .gift-img { + width: 100%; + border-radius: 8px; + margin-bottom: 10px; + object-fit: cover; + } + + .gift-info { + color: white; + + .gift-wish { + font-size: 15px; + font-weight: 500; + margin-bottom: 4px; + } + + .gift-name { + font-size: 12px; + opacity: 0.9; + margin-bottom: 2px; + } + + .gift-price { + font-size: 13px; + font-weight: 600; + margin-bottom: 4px; + } + + .gift-label { + font-size: 12px; + opacity: 0.7; + } + } +} + +.music-message { + width: 240px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + display: flex; + overflow: hidden; + cursor: pointer; + + &:hover { + opacity: 0.85; + } + + .music-cover { + width: 80px; + align-self: stretch; + flex-shrink: 0; + background: var(--hover-bg); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .music-info { + flex: 1; + min-width: 0; + overflow: hidden; + padding: 10px; + + .music-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .music-artist { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .music-source { + font-size: 11px; + color: var(--text-tertiary); + margin-top: 2px; + } + } +} + +.contact-card-message { + width: 240px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 12px; + display: flex; + align-items: center; + gap: 10px; + position: relative; + + .contact-card-avatar { + width: 40px; + height: 40px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; + background: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .contact-card-info { + flex: 1; + min-width: 0; + + .contact-card-name { + font-size: 14px; + font-weight: 500; + } + + .contact-card-detail { + font-size: 11px; + color: var(--text-tertiary); + margin-top: 2px; + } + } + + .contact-card-badge { + position: absolute; + bottom: 0; + left: 0; + right: 0; + text-align: center; + font-size: 10px; + color: var(--text-tertiary); + border-top: 1px solid var(--border-color); + padding: 4px 0; + border-radius: 0 0 12px 12px; + } + + padding-bottom: 28px; +} + +.location-message { + width: 240px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + + .location-text { + padding: 12px; + display: flex; + gap: 8px; + } + + .location-icon { + flex-shrink: 0; + color: #e25b4a; + margin-top: 2px; + } + + .location-info { + flex: 1; + min-width: 0; + + .location-name { + font-size: 14px; + font-weight: 500; + margin-bottom: 2px; + } + + .location-label { + font-size: 11px; + color: var(--text-tertiary); + line-height: 1.4; + } + } + + .location-map { + position: relative; + height: 100px; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + + [data-mode="dark"] & { + filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9); + } + } + + .location-pin { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -90%); + filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); + } + } +} + +// 视频号卡片 +.channel-video-card { + width: 200px; + background: var(--card-bg); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-color); + position: relative; + + .channel-video-cover { + position: relative; + width: 100%; + height: 260px; + background: #000; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .channel-video-cover-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: #666; + } + + .channel-video-duration { + position: absolute; + bottom: 6px; + right: 6px; + background: rgba(0, 0, 0, 0.6); + color: white; + font-size: 11px; + padding: 1px 5px; + border-radius: 3px; + } + } + + .channel-video-info { + padding: 8px 10px; + + .channel-video-title { + font-size: 13px; + color: var(--text-primary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 4px; + } + + .channel-video-author { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--text-secondary); + + .channel-video-avatar { + width: 16px; + height: 16px; + border-radius: 50%; + } + } + } +} + // 群公告消息 .announcement-message { display: flex; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 612caa1..42b49fa 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { createPortal } from 'react-dom' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive, Users, Mic, CheckCircle, XCircle } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive, Users, Mic, CheckCircle, XCircle, Download, Phone, Aperture, MapPin, UserRound } from 'lucide-react' import { useChatStore } from '../stores/chatStore' import { useUpdateStatusStore } from '../stores/updateStatusStore' import ChatBackground from '../components/ChatBackground' @@ -1388,6 +1388,15 @@ function ChatPage(_props: ChatPageProps) { > + {!isGroupChat(currentSession.username) && ( + + )} + {imageList.length > 1 && ( + <> +
+ {currentIndex + 1} / {imageList.length} + + )} @@ -389,7 +431,7 @@ export default function ImageWindow() { }} > Preview )} + + {imageList.length > 1 && ( + <> + {canGoPrev && ( + + )} + {canGoNext && ( + + )} + + )} ) diff --git a/src/pages/MomentsWindow.scss b/src/pages/MomentsWindow.scss index cf04d64..21ee4a7 100644 --- a/src/pages/MomentsWindow.scss +++ b/src/pages/MomentsWindow.scss @@ -402,16 +402,7 @@ position: relative; } - .sns-notice-banner { - background: rgba(255, 152, 0, 0.1); - color: #ff9800; - padding: 8px 16px; - font-size: 12px; - display: flex; - align-items: center; - gap: 8px; - border-bottom: 1px solid rgba(255, 152, 0, 0.2); - } + .moments-content { flex: 1; @@ -660,7 +651,7 @@ &.media-count-2, &.media-count-4 { grid-template-columns: repeat(2, 1fr); - max-width: 70%; + max-width: 50%; } &.media-count-3, @@ -670,6 +661,7 @@ &.media-count-8, &.media-count-9 { grid-template-columns: repeat(3, 1fr); + max-width: 60%; } .media-item { @@ -756,32 +748,18 @@ } } - // Video Decrypting Overlay - .video-loading-overlay { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(8px); - color: white; - gap: 12px; - z-index: 5; + // 骨架屏:用于加载中和视口外的占位,带 shimmer 动画 + .media-skeleton { + width: 100%; + height: 100%; + min-height: 80px; border-radius: 8px; - - .spin-icon { - animation: spin 1s linear infinite; - opacity: 0.9; - } - - span { - font-size: 13px; - font-weight: 500; - letter-spacing: 0.5px; - text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); - } + background: linear-gradient(90deg, + var(--bg-tertiary) 25%, + var(--bg-secondary, rgba(128, 128, 128, 0.12)) 50%, + var(--bg-tertiary) 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; } // Frosted glass live photo icon (bottom right) @@ -827,6 +805,36 @@ min-height: 100px; } } + + .download-btn-overlay { + position: absolute; + top: 6px; + right: 6px; + width: 28px; + height: 28px; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.2s ease; + cursor: pointer; + z-index: 4; + + &:hover { + background: rgba(0, 0, 0, 0.5); + transform: scale(1.1); + } + } + + &:hover .download-btn-overlay { + opacity: 1; + } } .video-badge-container, @@ -841,36 +849,6 @@ gap: 4px; } - .download-btn-overlay { - position: absolute; - top: 6px; - right: 6px; - width: 28px; - height: 28px; - background: rgba(0, 0, 0, 0.3); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - color: white; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: all 0.2s ease; - cursor: pointer; - z-index: 4; - - &:hover { - background: rgba(0, 0, 0, 0.5); - transform: scale(1.1); - } - } - - &:hover .download-btn-overlay { - opacity: 1; - } - } .post-share-card { @@ -1176,32 +1154,87 @@ align-items: center; gap: 8px; + .export-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + margin: 0; + border-radius: 999px; + border: none; + background: var(--primary); + color: white; + font-size: 12px; + font-weight: 500; + line-height: 1; + cursor: pointer; + transition: filter 0.15s ease; + + &:hover { + filter: brightness(1.15); + } + } + + .divider { + width: 1px; + height: 16px; + background: var(--border-color); + } + button { background: none; border: none; - color: var(--text-primary); // Make sure it follows theme + color: var(--text-primary); cursor: pointer; padding: 6px; border-radius: 6px; display: flex; align-items: center; justify-content: center; + position: relative; &:hover { - background: var(--hover-bg); // Use theme variable + background: var(--hover-bg); } &.active { background: var(--active-bg, rgba(0, 0, 0, 0.1)); color: var(--accent-color); } + + // 自定义 tooltip + &[data-tooltip] { + &::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%) translateY(4px); + padding: 4px 10px; + border-radius: 6px; + font-size: 12px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease, transform 0.15s ease; + background: var(--tooltip-bg, rgba(0, 0, 0, 0.75)); + color: var(--tooltip-color, #fff); + z-index: 999; + } + + &:hover::after { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + } } .icon-btn { width: 30px; height: 30px; } -} + // Modal and dialogs .modal-overlay { @@ -1624,4 +1657,14 @@ } } } +} + +@keyframes skeleton-shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } } \ No newline at end of file diff --git a/src/pages/MomentsWindow.tsx b/src/pages/MomentsWindow.tsx index a735bba..80269a4 100644 --- a/src/pages/MomentsWindow.tsx +++ b/src/pages/MomentsWindow.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { Loader2, RefreshCw, Search, Calendar, User, X, Filter, AlertTriangle, Play, Download, Heart, Copy, Link, Music, FileDown } from 'lucide-react' import { ImagePreview } from '../components/ImagePreview' import { LivePhotoIcon } from '../components/LivePhotoIcon' -import { parseWechatEmoji } from '../utils/wechatEmoji' +import { parseWechatEmoji, parseWechatEmojiHtml } from '../utils/wechatEmoji' import TitleBar from '../components/TitleBar' import JumpToDateDialog from '../components/JumpToDateDialog' import DateRangePicker from '../components/DateRangePicker' @@ -86,8 +86,11 @@ const formatXml = (xml: string) => { } } +// 缓存已解密的媒体路径,用于构建图片列表传给查看器 +const mediaPathCache = new Map() + // 媒体项组件 -const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => { +const MediaItem = ({ media, isSingle, allMedia, onPreview, onMediaDeleted }: { media: any; isSingle?: boolean; allMedia?: any[]; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => { const [error, setError] = useState(false) const [deleted, setDeleted] = useState(false) const [thumbSrc, setThumbSrc] = useState('') @@ -97,12 +100,42 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview const [isVisible, setIsVisible] = useState(false) const imgRef = useRef(null) - const { url, thumb, livePhoto } = media + const { url, thumb, livePhoto, width: rawWidth, height: rawHeight } = media const isLive = !!livePhoto const targetUrl = thumb || url const isVideo = url && isVideoUrl(url) + // 骨架屏宽高比和具体尺寸(用于单图模式,防止 max-content width 退化为 0) + let skeletonStyle: React.CSSProperties | undefined = undefined + if (rawWidth && rawHeight && rawWidth > 0 && rawHeight > 0) { + if (isSingle) { + // 单图模式下,根据原始宽高和最大容器限制计算展示宽高 + const MAX_W = 260 + const MAX_H = 400 + let w = rawWidth + let h = rawHeight + if (w > MAX_W) { + h = h * (MAX_W / w) + w = MAX_W + } + if (h > MAX_H) { + w = w * (MAX_H / h) + h = MAX_H + } + skeletonStyle = { width: w, height: h } + } else { + skeletonStyle = { aspectRatio: `${rawWidth} / ${rawHeight}` } + } + } else { + // 缺失宽高参数时的 fallback 骨架屏,确保一定有占位 + if (isSingle) { + skeletonStyle = { width: 260, height: 260 } + } else { + skeletonStyle = { aspectRatio: '1 / 1' } + } + } + // Intersection Observer 懒加载 useEffect(() => { if (!imgRef.current) return @@ -224,21 +257,24 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview if (cancelled) return if (result.success) { + let resolvedPath = '' if (result.localPath) { // 使用本地文件路径(file:// 协议) - const localUrl = result.localPath.startsWith('file:') + resolvedPath = result.localPath.startsWith('file:') ? result.localPath : `file://${result.localPath.replace(/\\/g, '/')}` - setThumbSrc(localUrl) } else if (result.dataUrl) { // 回退:使用 base64 dataUrl - setThumbSrc(result.dataUrl) + resolvedPath = result.dataUrl } else if (result.videoPath) { // 兼容:某些情况下可能返回 videoPath - const localUrl = result.videoPath.startsWith('file:') + resolvedPath = result.videoPath.startsWith('file:') ? result.videoPath : `file://${result.videoPath.replace(/\\/g, '/')}` - setThumbSrc(localUrl) + } + if (resolvedPath) { + setThumbSrc(resolvedPath) + mediaPathCache.set(targetUrl, { imagePath: resolvedPath }) } } else { setDeleted(true) @@ -252,16 +288,23 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview }).then((res: any) => { if (cancelled) return if (res.success) { + let liveLocalUrl = '' if (res.videoPath) { - const localUrl = res.videoPath.startsWith('file:') + liveLocalUrl = res.videoPath.startsWith('file:') ? res.videoPath : `file://${res.videoPath.replace(/\\/g, '/')}` - setLiveVideoPath(localUrl) } else if (res.localPath) { - const localUrl = res.localPath.startsWith('file:') + liveLocalUrl = res.localPath.startsWith('file:') ? res.localPath : `file://${res.localPath.replace(/\\/g, '/')}` - setLiveVideoPath(localUrl) + } + if (liveLocalUrl) { + setLiveVideoPath(liveLocalUrl) + // 更新缓存中的 liveVideoPath + const cached = mediaPathCache.get(targetUrl) + if (cached) { + cached.liveVideoPath = liveLocalUrl + } } } }).catch((e: any) => { }) @@ -313,10 +356,27 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview // 图片:使用图片查看器窗口 if (thumbSrc) { const localPath = thumbSrc.replace(/^file:\/\//, '') - // 如果是 Live Photo,传递视频路径(即使还没加载完也传递,让查看器知道这是 Live Photo) const liveVideoLocalPath = isLive && liveVideoPath ? liveVideoPath.replace(/^file:\/\//, '') : undefined - await window.electronAPI.window.openImageViewerWindow(localPath, liveVideoLocalPath) + // 从缓存构建同一条动态的图片列表 + let imageList: Array<{ imagePath: string; liveVideoPath?: string }> | undefined + if (allMedia && allMedia.length > 1) { + const list: Array<{ imagePath: string; liveVideoPath?: string }> = [] + for (const m of allMedia) { + if (m.url && isVideoUrl(m.url)) continue // 跳过视频 + const key = m.thumb || m.url + const cached = mediaPathCache.get(key) + if (cached) { + list.push({ + imagePath: cached.imagePath.replace(/^file:\/\//, ''), + liveVideoPath: cached.liveVideoPath?.replace(/^file:\/\//, '') + }) + } + } + if (list.length > 1) imageList = list + } + + await window.electronAPI.window.openImageViewerWindow(localPath, liveVideoLocalPath, imageList) } } } catch (error) { @@ -328,7 +388,11 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview if (deleted) { return ( -
+
已删除 @@ -341,17 +405,12 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview
{!isVisible ? ( -
- -
- ) : isVideo && isDecrypting ? ( -
- - 解密中... -
+ // 尚未进入视口:骨架屏占位(保持宽高比) +
) : displaySrc ? ( 加载失败
) : ( -
- -
+ // 可见但仍在加载中/解密中:shimmer 骨架屏 +
)} {isVisible && isVideo && !isDecrypting && ( @@ -469,6 +527,77 @@ const ShareThumb = ({ shareInfo }: { shareInfo: SnsShareInfo }) => { ) } +// 表情包内存缓存:url/encryptUrl → file:// 本地路径 +const emojiCache = new Map() + +// 评论表情包组件(先查内存缓存,再查本地文件,最后才下载) +const CommentEmoji = ({ emoji, onPreview }: { + emoji: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string } + onPreview?: (src: string) => void +}) => { + const cacheKey = emoji.encryptUrl || emoji.url + const [localSrc, setLocalSrc] = useState(() => emojiCache.get(cacheKey) || '') + + useEffect(() => { + if (!cacheKey) return + if (emojiCache.has(cacheKey)) { + setLocalSrc(emojiCache.get(cacheKey)!) + return + } + let cancelled = false + + const load = async () => { + try { + const res = await window.electronAPI.sns.downloadEmoji({ + url: emoji.url, + encryptUrl: emoji.encryptUrl, + aesKey: emoji.aesKey + }) + if (cancelled) return + if (res.success && res.localPath) { + const fileUrl = res.localPath.startsWith('file:') + ? res.localPath + : `file://${res.localPath.replace(/\\/g, '/')}` + emojiCache.set(cacheKey, fileUrl) + setLocalSrc(fileUrl) + } + } catch (e) { + // 静默失败 + } + } + + load() + return () => { cancelled = true } + }, [cacheKey]) + + if (!localSrc) return null + + return ( + emoji { + e.preventDefault() + e.stopPropagation() + onPreview?.(localSrc) + }} + style={{ + width: Math.min(emoji.width || 36, 30), + height: Math.min(emoji.height || 36, 30), + verticalAlign: 'middle', + marginLeft: 2, + borderRadius: 6, + cursor: 'pointer', + pointerEvents: 'auto', + position: 'relative', + zIndex: 5 + }} + /> + ) +} + // 朋友圈长文折叠展开组件 const ExpandableText = ({ content }: { content: string }) => { const [isExpanded, setIsExpanded] = useState(false) @@ -522,11 +651,15 @@ function MomentsWindow() { // 筛选状态 const [searchKeyword, setSearchKeyword] = useState('') - const [selectedUsernames, setSelectedUsernames] = useState([]) + const [selectedUsernames, setSelectedUsernames] = useState(() => { + const p = new URLSearchParams(window.location.search) + const u = p.get('filterUsername') + return u ? [u] : [] + }) const [jumpTargetDate, setJumpTargetDate] = useState(undefined) const [contacts, setContacts] = useState([]) const [contactSearch, setContactSearch] = useState('') - const [isSidebarOpen, setIsSidebarOpen] = useState(true) + const [isSidebarOpen, setIsSidebarOpen] = useState(() => !new URLSearchParams(window.location.search).get('filterUsername')) const [showJumpDialog, setShowJumpDialog] = useState(false) // 其他状态 @@ -549,6 +682,14 @@ function MomentsWindow() { const sentinelRef = useRef(null) const isInitialLoad = useRef(true) + // 监听已有窗口收到的筛选消息 + useEffect(() => { + const cleanup = window.electronAPI?.window?.onMomentsFilterUser?.((username: string) => { + setSelectedUsernames([username]) + }) + return () => cleanup?.() + }, []) + // 处理滚动,当有新筛选项时回滚到顶部 useEffect(() => { if (!isInitialLoad.current) { @@ -746,13 +887,18 @@ function MomentsWindow() { if (exporting) return try { - // 弹出保存对话框 - const result = await window.electronAPI.dialog.saveFile({ - title: '导出朋友圈', - defaultPath: `朋友圈导出_${new Date().toISOString().slice(0, 10)}.html`, - filters: [{ name: 'HTML', extensions: ['html'] }] + // 弹出选择目录对话框 + const result = await window.electronAPI.dialog.openFile({ + title: '选择导出目录', + properties: ['openDirectory'] }) - if (!result || result.canceled || !result.filePath) return + if (!result || result.canceled || !result.filePaths || result.filePaths.length === 0) return + + // 在选择的目录下创建带时间戳的子文件夹 + const now = new Date() + const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}` + const exportDirName = `朋友圈_${ts}` + const exportDir = `${result.filePaths[0].replace(/\\/g, '/')}/${exportDirName}` setExporting(true) setExportProgress({ current: 0, total: 0, status: '正在获取动态数据...' }) @@ -793,32 +939,58 @@ function MomentsWindow() { // 第二阶段:如果需要图片/视频,批量下载到同级 media 目录 const imageCache = new Map() // url -> 相对路径 (media/xxx.jpg) const avatarMap = new Map() // username -> 相对路径 (media/avatar_xxx.jpg) + const emojiCache = new Map() // url/encryptUrl -> 相对路径 - const htmlDir = result.filePath.replace(/\\/g, '/').replace(/\/[^/]+$/, '') + const htmlDir = exportDir const mediaDir = `${htmlDir}/media` if (exportOptions.includeImages) { - // 收集所有媒体和头像 - const allMediaUrls: { url: string; key?: string; type: 'media' | 'avatar'; username?: string }[] = [] + // 收集所有媒体、头像和表情包 + const allMediaUrls: { url: string; key?: string; type: 'media' | 'avatar' | 'emoji'; username?: string; md5?: string; encryptUrl?: string; aesKey?: string }[] = [] - // 1. 媒体 + // 1. 媒体(使用 md5 或 URL 作为唯一标识去重) + const mediaUrlSet = new Set() for (const p of allPosts) { if (p.media) { for (const m of p.media) { const thumbUrl = m.thumb || m.url - if (thumbUrl) { - allMediaUrls.push({ url: thumbUrl, key: m.key, type: 'media' }) + if (thumbUrl && !mediaUrlSet.has(thumbUrl)) { + mediaUrlSet.add(thumbUrl) + allMediaUrls.push({ url: thumbUrl, key: m.key, type: 'media', md5: m.md5 }) } // 普通视频 - if (m.url && isVideoUrl(m.url) && m.url !== thumbUrl) { - allMediaUrls.push({ url: m.url, key: m.key, type: 'media' }) + if (m.url && isVideoUrl(m.url) && m.url !== thumbUrl && !mediaUrlSet.has(m.url)) { + mediaUrlSet.add(m.url) + allMediaUrls.push({ url: m.url, key: m.key, type: 'media', md5: m.md5 }) } // 实况照片视频 - if (m.livePhoto && m.livePhoto.url) { + if (m.livePhoto && m.livePhoto.url && !mediaUrlSet.has(m.livePhoto.url)) { + mediaUrlSet.add(m.livePhoto.url) allMediaUrls.push({ url: m.livePhoto.url, key: m.livePhoto.key || m.key, type: 'media' }) } } } + + // 收集评论中的表情包 + if (p.comments) { + for (const c of p.comments) { + if (c.emojis) { + for (const emoji of c.emojis) { + const emojiId = emoji.md5 || emoji.encryptUrl || emoji.url + if (emojiId && !mediaUrlSet.has(emojiId)) { + mediaUrlSet.add(emojiId) + allMediaUrls.push({ + type: 'emoji', + url: emoji.url, + encryptUrl: emoji.encryptUrl, + aesKey: emoji.aesKey, + md5: emoji.md5 + }) + } + } + } + } + } } // 2. 唯一头像 @@ -844,13 +1016,24 @@ function MomentsWindow() { url: item.url, key: item.key, outputDir: htmlDir, - index: globalIdx + index: globalIdx, + md5: item.md5, + isAvatar: item.type === 'avatar', + username: item.username, + isEmoji: item.type === 'emoji', + encryptUrl: item.encryptUrl, + aesKey: item.aesKey }) if (res.success && res.fileName) { if (item.type === 'media') { imageCache.set(item.url, `media/${res.fileName}`) - } else if (item.username) { + } else if (item.type === 'avatar' && item.username) { avatarMap.set(item.username, `media/${res.fileName}`) + } else if (item.type === 'emoji') { + const cacheKey = item.encryptUrl || item.url + if (cacheKey) { + emojiCache.set(cacheKey, `media/${res.fileName}`) + } } } } catch (e) { @@ -937,11 +1120,21 @@ function MomentsWindow() { let commentsHtml = '' if (p.comments && p.comments.length > 0) { - const items = p.comments.map((c: any) => { + const items = await Promise.all(p.comments.map(async (c: any) => { const reply = c.refNickname ? `回复${escHtml(c.refNickname)}` : '' - return `
${escHtml(c.nickname)}${reply}:${escHtml(c.content)}
` - }).join('') - commentsHtml = `
${items}
` + let emojiHtml = '' + if (c.emojis && c.emojis.length > 0) { + emojiHtml = c.emojis.map((emoji: any) => { + const cacheKey = emoji.encryptUrl || emoji.url + const fileUrl = imageCache.get(cacheKey) || emojiCache.get(cacheKey) || '' // 对于导出的表情包,我们可能需要使用对应的路径,由于表情包独立下载可能没存到导出的媒体库,先保留内存路径 + if (!fileUrl) return '' + return `` + }).join('') + } + const cContentHtml = await parseWechatEmojiHtml(c.content) + return `
${escHtml(c.nickname)}${reply}:${cContentHtml}${emojiHtml}
` + })) + commentsHtml = `
${items.join('')}
` } const avatarFile = p.username ? avatarMap.get(p.username) : null @@ -949,6 +1142,8 @@ function MomentsWindow() { ? `
` : `
${escHtml(p.nickname[0] || '?')}
` + const pContentHtml = await parseWechatEmojiHtml(p.contentDesc) + postsHtml += `
${avatarHtml} @@ -957,7 +1152,7 @@ function MomentsWindow() { ${escHtml(p.nickname)} ${formatDate(p.createTime)}
- ${p.contentDesc ? `
${escHtml(p.contentDesc)}
` : ''} + ${p.contentDesc ? `
${pContentHtml}
` : ''} ${mediaHtml} ${shareHtml} ${likesHtml} @@ -1153,15 +1348,15 @@ document.querySelectorAll('.vi video').forEach(function(v) { ` - // 通过 IPC 直接写入用户选择的路径 + // 写入 index.html 到导出目录 + const htmlFilePath = `${exportDir}/index.html` setExportProgress({ current: allPosts.length, total: allPosts.length, status: '正在写入文件...' }) - const writeResult = await window.electronAPI.sns.writeExportFile(result.filePath, fullHtml) + const writeResult = await window.electronAPI.sns.writeExportFile(htmlFilePath, fullHtml) if (writeResult.success) { setExportProgress({ current: allPosts.length, total: allPosts.length, status: '导出完成!' }) - // 导出完成后打开文件所在目录 - const dir = result.filePath.replace(/\\/g, '/').replace(/\/[^/]+$/, '') - window.electronAPI.shell.openPath(dir) + // 导出完成后打开导出目录 + window.electronAPI.shell.openPath(exportDir) } else { setExportProgress({ current: 0, total: 0, status: `写入失败: ${writeResult.error}` }) } @@ -1192,14 +1387,19 @@ document.querySelectorAll('.vi video').forEach(function(v) { title="朋友圈" rightContent={
+ +
-
@@ -1303,21 +1503,12 @@ document.querySelectorAll('.vi video').forEach(function(v) { 重置所有筛选 -
{/* 主内容区 */}
-
- - 由于技术限制,当前无法解密显示部分图片与视频等加密资源文件 -
-
{isLoading ? (
@@ -1388,6 +1579,8 @@ document.querySelectorAll('.vi video').forEach(function(v) { setPreviewImage({ src, isVideo, liveVideoPath })} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setDeletedPostIds(prev => new Set(prev).add(post.id)) : undefined} /> @@ -1461,7 +1654,12 @@ document.querySelectorAll('.vi video').forEach(function(v) { )} : - {parseWechatEmoji(comment.content)} + + {comment.content && parseWechatEmoji(comment.content)} + {comment.emojis && comment.emojis.map((emoji: any, eidx: number) => ( + setPreviewImage({ src })} /> + ))} +
))}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index bdd42b3..ef8c781 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1168,18 +1168,10 @@ function SettingsPage() { } setIsGettingImageKey(true) - setImageKeyStatus('正在检查微信进程...') + setImageKeyStatus('正在从缓存目录扫描图片密钥...') try { - const isRunning = await window.electronAPI.wxKey.isWeChatRunning() - if (!isRunning) { - showMessage('请先启动微信并登录', false) - setImageKeyStatus('') - setIsGettingImageKey(false) - return - } - - // 构建用户目录路径 + // 构建用户目录路径(用于 wxid 匹配) const userDir = `${dbPath}\\${wxid}` const removeListener = window.electronAPI.imageKey.onProgress((msg) => { @@ -1227,12 +1219,12 @@ function SettingsPage() { const [isDownloadingWhisperModel, setIsDownloadingWhisperModel] = useState(false) const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0) const [useWhisperGpu, setUseWhisperGpu] = useState(false) - + // GPU 组件状态 const [gpuComponentsStatus, setGpuComponentsStatus] = useState<{ installed: boolean; missingFiles?: string[]; gpuDir?: string } | null>(null) const [isDownloadingGpuComponents, setIsDownloadingGpuComponents] = useState(false) const [gpuDownloadProgress, setGpuDownloadProgress] = useState({ overallProgress: 0, currentFile: '' }) - + // ========== STT 模式切换 ========== const [sttMode, setSttMode] = useState<'cpu' | 'gpu'>('cpu') @@ -1245,12 +1237,12 @@ function SettingsPage() { checkGpuComponents() } }, [activeTab]) - + const loadSttMode = async () => { const savedMode = await window.electronAPI.config.get('sttMode') as 'cpu' | 'gpu' | undefined setSttMode(savedMode || 'cpu') } - + const handleSttModeChange = async (mode: 'cpu' | 'gpu') => { setSttMode(mode) await window.electronAPI.config.set('sttMode', mode) @@ -1414,7 +1406,7 @@ function SettingsPage() { const handleDownloadGpuComponents = async () => { if (isDownloadingGpuComponents) return - + // 检查是否设置了缓存目录 if (!cachePath) { showMessage('请先设置缓存目录', false) @@ -1462,14 +1454,14 @@ function SettingsPage() {
{/* STT 模式切换器 */}
- -
)} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e7fb462..044860e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,6 +1,11 @@ import type { ChatSession, Message, Contact, ContactInfo } from './models' import type { SummaryResult } from './ai' +export interface ImageListItem { + imagePath: string + liveVideoPath?: string +} + export interface ElectronAPI { window: { minimize: () => void @@ -9,7 +14,8 @@ export interface ElectronAPI { splashReady: () => void onSplashFadeOut?: (callback: () => void) => () => void openChatWindow: () => Promise - openMomentsWindow: () => Promise + openMomentsWindow: (filterUsername?: string) => Promise + onMomentsFilterUser: (callback: (username: string) => void) => () => void openGroupAnalyticsWindow: () => Promise openAnnualReportWindow: (year: number) => Promise openAgreementWindow: () => Promise @@ -19,12 +25,13 @@ export interface ElectronAPI { isChatWindowOpen: () => Promise closeChatWindow: () => Promise setTitleBarOverlay: (options: { symbolColor: string }) => void - openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise + openImageViewerWindow: (imagePath: string, liveVideoPath?: string, imageList?: ImageListItem[]) => Promise openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise openBrowserWindow: (url: string, title?: string) => Promise resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise openAISummaryWindow: (sessionId: string, sessionName: string) => Promise openChatHistoryWindow: (sessionId: string, messageId: number) => Promise + onImageListUpdate: (callback: (data: { imageList: ImageListItem[], currentIndex: number }) => void) => () => void } config: { get: (key: string) => Promise @@ -192,6 +199,34 @@ export interface ElectronAPI { error?: string md5?: string }> + parseChannelVideo: (content: string) => Promise<{ + success: boolean + error?: string + videoInfo?: { + objectId: string + title: string + author: string + avatar?: string + videoUrl: string + thumbUrl?: string + coverUrl?: string + duration?: number + width?: number + height?: number + } + }> + downloadChannelVideo: (videoInfo: any, key?: string) => Promise<{ + success: boolean + filePath?: string + error?: string + needsKey?: boolean + }> + onDownloadProgress: (callback: (progress: { + objectId: string + downloaded: number + total: number + percentage: number + }) => void) => () => void } imageKey: { getImageKeys: (userDir: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }> @@ -316,8 +351,13 @@ export interface ElectronAPI { success: boolean error?: string }> + downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{ + success: boolean + localPath?: string + error?: string + }> writeExportFile: (filePath: string, content: string) => Promise<{ success: boolean; error?: string }> - saveMediaToDir: (params: { url: string; key?: string | number; outputDir: string; index: number }) => Promise<{ success: boolean; fileName?: string; error?: string }> + saveMediaToDir: (params: { url: string; key?: string | number; outputDir: string; index: number; md5?: string; isAvatar?: boolean; username?: string; isEmoji?: boolean; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; fileName?: string; error?: string }> } analytics: { getOverallStatistics: () => Promise<{ diff --git a/src/types/models.ts b/src/types/models.ts index 9693cd9..7b22224 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -55,6 +55,8 @@ export interface Message { quotedContent?: string quotedSender?: string quotedImageMd5?: string + quotedEmojiMd5?: string + quotedEmojiCdnUrl?: string // 视频相关 videoMd5?: string videoDuration?: number // 视频时长(秒) diff --git a/src/utils/wechatEmoji.tsx b/src/utils/wechatEmoji.tsx index 4698fc0..164ddf9 100644 --- a/src/utils/wechatEmoji.tsx +++ b/src/utils/wechatEmoji.tsx @@ -1,8 +1,8 @@ import React from 'react' import { getEmojiPath, hasEmoji, type EmojiName } from 'wechat-emojis' -// 微信表情名称到图片的映射正则 -const emojiPattern = /\[([^\]]+)\]/g +// 微信表情名称到图片的映射正则(不使用模块级带 g 标志的正则,避免 async 函数中 lastIndex 被并发修改) +const EMOJI_PATTERN_SOURCE = '\\[([^\\]]+)\\]' /** * 获取表情图片的完整URL @@ -20,24 +20,22 @@ function getEmojiUrl(name: string): string | null { */ export function parseWechatEmoji(text: string): React.ReactNode { if (!text) return text - + const parts: React.ReactNode[] = [] let lastIndex = 0 let match: RegExpExecArray | null - - // 重置正则 - emojiPattern.lastIndex = 0 - + const emojiPattern = new RegExp(EMOJI_PATTERN_SOURCE, 'g') + while ((match = emojiPattern.exec(text)) !== null) { const emojiName = match[1] - + // 检查是否是有效的微信表情 if (hasEmoji(emojiName)) { // 添加表情前的文本 if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)) } - + // 添加表情图片 const emojiUrl = getEmojiUrl(emojiName) if (emojiUrl) { @@ -54,16 +52,16 @@ export function parseWechatEmoji(text: string): React.ReactNode { // 如果获取路径失败,保留原文本 parts.push(match[0]) } - + lastIndex = match.index + match[0].length } } - + // 添加剩余文本 if (lastIndex < text.length) { parts.push(text.slice(lastIndex)) } - + return parts.length > 0 ? parts : text } @@ -72,10 +70,87 @@ export function parseWechatEmoji(text: string): React.ReactNode { */ export function hasWechatEmoji(text: string): boolean { if (!text) return false - emojiPattern.lastIndex = 0 + const emojiPattern = new RegExp(EMOJI_PATTERN_SOURCE, 'g') let match: RegExpExecArray | null while ((match = emojiPattern.exec(text)) !== null) { if (hasEmoji(match[1])) return true } return false } + +// 缓存 base64 数据,避免重复 fetch +const emojiBase64Cache = new Map() + +/** + * 将文本中的微信表情 [xxx] 转换为 Base64 图片的 HTML 字符串 (常用于导出离线访问) + */ +export async function parseWechatEmojiHtml(text: string): Promise { + if (!text) return text + + const parts: string[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null + const emojiPattern = new RegExp(EMOJI_PATTERN_SOURCE, 'g') + + while ((match = emojiPattern.exec(text)) !== null) { + const emojiName = match[1] + + if (hasEmoji(emojiName)) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')) + } + + const relativePath = getEmojiPath(emojiName as EmojiName) + if (relativePath) { + // 构建完整的 URL,支持热更新 dev (/) 和打包后 prod (./) + // 使用 encodeURI 处理中文名文件 (如 "失望.png") + const baseUrl = import.meta.env.BASE_URL || '/' + const rawPath = `${baseUrl}wechat-emojis/${relativePath.replace('assets/', '')}` + const emojiUri = encodeURI(rawPath.replace(/\/\//g, '/')) + + let base64 = emojiBase64Cache.get(emojiUri) + if (!base64) { + try { + const res = await fetch(emojiUri) + const contentType = res.headers.get('content-type') || '' + + // 确保请求成功,并且返回的是图片而不是 HTML 回退页面 + if (res.ok && contentType.includes('image')) { + const blob = await res.blob() + base64 = await new Promise((resolve) => { + const reader = new FileReader() + reader.onloadend = () => resolve(reader.result as string) + reader.readAsDataURL(blob) + }) + if (base64) { + emojiBase64Cache.set(emojiUri, base64) + } + } else { + console.warn(`[Emoji] Failed to fetch: ${emojiUri}, status: ${res.status}, type: ${contentType}`) + } + } catch (e) { + console.error(`[Emoji] Fetch error for: ${emojiUri}`, e) + } + } + + if (base64) { + parts.push(`[${emojiName}]`) + } else { + // 如果获取失败,保留纯文本,避免渲染出损坏的图片造成“出现两个表情(破图+文字)”的情况 + parts.push(`[${emojiName}]`) + } + } else { + parts.push(`[${emojiName}]`) + } + + lastIndex = match.index + match[0].length + } + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')) + } + + return parts.length > 0 ? parts.join('') : text +} +