diff --git a/.gitignore b/.gitignore index 4e55091..8601fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ Thumbs.db *.aps wcdb/ +xkey/ *info 概述.md chatlab-format.md diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 21e5265..c7b1aab 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -105,7 +105,8 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l "senderUsername": "wxid_sender", "mediaType": "image", "mediaFileName": "image_123.jpg", - "mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg" + "mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg", + "mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg" } ] } @@ -140,7 +141,7 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l "timestamp": 1738713600000, "type": 0, "content": "消息内容", - "mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg" + "mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg" } ], "media": { @@ -153,7 +154,59 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l --- -### 3. 获取会话列表 +### 3. 访问导出媒体文件 + +通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。 + +**请求** +``` +GET /api/v1/media/{relativePath} +``` + +**路径参数** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` | + +**支持的媒体类型** + +| 扩展名 | Content-Type | +|--------|-------------| +| `.png` | image/png | +| `.jpg` / `.jpeg` | image/jpeg | +| `.gif` | image/gif | +| `.webp` | image/webp | +| `.wav` | audio/wav | +| `.mp3` | audio/mpeg | +| `.mp4` | video/mp4 | + +**示例请求** +```bash +# 访问导出的图片 +GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg + +# 访问导出的语音 +GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav + +# 访问导出的视频 +GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4 +``` + +**响应** + +成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。 + +失败时返回: +```json +{ "error": "Media not found" } +``` + +> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。 + +--- + +### 4. 获取会话列表 获取所有会话列表。 diff --git a/electron/main.ts b/electron/main.ts index ab9128d..8036e98 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1082,6 +1082,26 @@ function registerIpcHandlers() { return { canceled: false, filePath: result.filePaths[0] } }) + ipcMain.handle('sns:installBlockDeleteTrigger', async () => { + return snsService.installSnsBlockDeleteTrigger() + }) + + ipcMain.handle('sns:uninstallBlockDeleteTrigger', async () => { + return snsService.uninstallSnsBlockDeleteTrigger() + }) + + ipcMain.handle('sns:checkBlockDeleteTrigger', async () => { + return snsService.checkSnsBlockDeleteTrigger() + }) + + ipcMain.handle('sns:deleteSnsPost', async (_, postId: string) => { + return snsService.deleteSnsPost(postId) + }) + + ipcMain.handle('sns:downloadEmoji', async (_, params: { url: string; encryptUrl?: string; aesKey?: string }) => { + return snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey) + }) + // 私聊克隆 diff --git a/electron/preload.ts b/electron/preload.ts index 91bb728..76ad1c7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -194,11 +194,12 @@ contextBridge.exposeInMainWorld('electronAPI', { } }, - // 视频 - video: { - getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), - parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) - }, + // 视频 + video: { + getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), + parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) + }, + // 数据分析 analytics: { getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force), @@ -293,7 +294,12 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('sns:exportProgress') }, - selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir') + selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir'), + installBlockDeleteTrigger: () => ipcRenderer.invoke('sns:installBlockDeleteTrigger'), + uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'), + checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'), + deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId), + downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params) }, // HTTP API 服务 diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 001a855..875be7a 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -76,17 +76,13 @@ class AnalyticsService { const map: Record = {} if (usernames.length === 0) return map + // C++ 层不支持参数绑定,直接内联转义后的字符串值 const chunkSize = 200 for (let i = 0; i < usernames.length; i += chunkSize) { const chunk = usernames.slice(i, i + chunkSize) - // 使用参数化查询防止SQL注入 - const placeholders = chunk.map(() => '?').join(',') - const sql = ` - SELECT username, alias - FROM contact - WHERE username IN (${placeholders}) - ` - const result = await wcdbService.execQuery('contact', null, sql, chunk) + const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',') + const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})` + const result = await wcdbService.execQuery('contact', null, sql) if (!result.success || !result.rows) continue for (const row of result.rows as Record[]) { const username = row.username || '' diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 845a1bd..da878bc 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -34,6 +34,8 @@ export interface ChatSession { lastMsgSender?: string lastSenderDisplayName?: string selfWxid?: string + isFolded?: boolean // 是否已折叠进"折叠的群聊" + isMuted?: boolean // 是否开启免打扰 } export interface Message { @@ -413,12 +415,29 @@ class ChatService { lastMsgType, displayName, avatarUrl, - lastMsgSender: row.last_msg_sender, // 数据库返回字段 - lastSenderDisplayName: row.last_sender_display_name, // 数据库返回字段 + lastMsgSender: row.last_msg_sender, + lastSenderDisplayName: row.last_sender_display_name, selfWxid: myWxid }) } + // 批量拉取 extra_buffer 状态(isFolded/isMuted),不阻塞主流程 + const allUsernames = sessions.map(s => s.username) + try { + const statusResult = await wcdbService.getContactStatus(allUsernames) + if (statusResult.success && statusResult.map) { + for (const s of sessions) { + const st = statusResult.map[s.username] + if (st) { + s.isFolded = st.isFolded + s.isMuted = st.isMuted + } + } + } + } catch { + // 状态获取失败不影响会话列表返回 + } + // 不等待联系人信息加载,直接返回基础会话列表 // 前端可以异步调用 enrichSessionsWithContacts 来补充信息 return { success: true, sessions } @@ -2846,15 +2865,16 @@ class ChatService { private shouldKeepSession(username: string): boolean { if (!username) return false const lowered = username.toLowerCase() - if (lowered.includes('@placeholder') || lowered.includes('foldgroup')) return false + // placeholder_foldgroup 是折叠群入口,需要保留 + if (lowered.includes('@placeholder') && !lowered.includes('foldgroup')) return false if (username.startsWith('gh_')) return false const excludeList = [ 'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle', 'newsapp', 'brandsessionholder', 'brandservicesessionholder', 'notifymessage', 'opencustomerservicemsg', 'notification_messages', - 'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup', - '@helper_folders', '@placeholder_foldgroup' + 'userexperience_alarm', 'helper_folders', + '@helper_folders' ] for (const prefix of excludeList) { @@ -4478,77 +4498,27 @@ class ChatService { } private resolveAccountDir(dbPath: string, wxid: string): string | null { - const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase() - const normalized = dbPath.replace(/[\\/]+$/, '') + const normalized = dbPath.replace(/[\\\\/]+$/, '') - const candidates: { path: string; mtime: number }[] = [] - - // 检查直接路径 - const direct = join(normalized, cleanedWxid) - if (existsSync(direct) && this.isAccountDir(direct)) { - candidates.push({ path: direct, mtime: this.getDirMtime(direct) }) + // 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件) + // 则向上回溯到账号目录 + if (basename(normalized).toLowerCase() === 'db_storage') { + return dirname(normalized) + } + const dir = dirname(normalized) + if (basename(dir).toLowerCase() === 'db_storage') { + return dirname(dir) } - // 检查 dbPath 本身是否就是账号目录 - if (this.isAccountDir(normalized)) { - candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) }) + // 否则,dbPath 应该是数据库根目录(如 xwechat_files) + // 账号目录应该是 {dbPath}/{wxid} + const accountDirWithWxid = join(normalized, wxid) + if (existsSync(accountDirWithWxid)) { + return accountDirWithWxid } - // 扫描 dbPath 下的所有子目录寻找匹配的 wxid - try { - if (existsSync(normalized) && statSync(normalized).isDirectory()) { - const entries = readdirSync(normalized) - for (const entry of entries) { - const entryPath = join(normalized, entry) - try { - if (!statSync(entryPath).isDirectory()) continue - } catch { continue } - - const lowerEntry = entry.toLowerCase() - if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) { - if (this.isAccountDir(entryPath)) { - if (!candidates.some(c => c.path === entryPath)) { - candidates.push({ path: entryPath, mtime: this.getDirMtime(entryPath) }) - } - } - } - } - } - } catch { } - - if (candidates.length === 0) return null - - // 按修改时间降序排序,取最新的 - candidates.sort((a, b) => b.mtime - a.mtime) - return candidates[0].path - } - - private isAccountDir(dirPath: string): boolean { - return ( - existsSync(join(dirPath, 'db_storage')) || - existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) || - existsSync(join(dirPath, 'msg', 'attach')) - ) - } - - private getDirMtime(dirPath: string): number { - try { - const stat = statSync(dirPath) - let mtime = stat.mtimeMs - const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image'] - for (const sub of subDirs) { - const fullPath = join(dirPath, sub) - if (existsSync(fullPath)) { - try { - mtime = Math.max(mtime, statSync(fullPath).mtimeMs) - } catch { } - } - } - return mtime - } catch { - return 0 - } + // 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录) + return normalized } private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise { diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index 122c33a..ee15b02 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -77,8 +77,7 @@ export class DbPathService { return ( existsSync(join(entryPath, 'db_storage')) || existsSync(join(entryPath, 'FileStorage', 'Image')) || - existsSync(join(entryPath, 'FileStorage', 'Image2')) || - existsSync(join(entryPath, 'msg', 'attach')) + existsSync(join(entryPath, 'FileStorage', 'Image2')) ) } @@ -95,21 +94,22 @@ export class DbPathService { const accountStat = statSync(entryPath) let latest = accountStat.mtimeMs - const checkSubDirs = [ - 'db_storage', - join('FileStorage', 'Image'), - join('FileStorage', 'Image2'), - join('msg', 'attach') - ] + const dbPath = join(entryPath, 'db_storage') + if (existsSync(dbPath)) { + const dbStat = statSync(dbPath) + latest = Math.max(latest, dbStat.mtimeMs) + } - for (const sub of checkSubDirs) { - const fullPath = join(entryPath, sub) - if (existsSync(fullPath)) { - try { - const s = statSync(fullPath) - latest = Math.max(latest, s.mtimeMs) - } catch { } - } + const imagePath = join(entryPath, 'FileStorage', 'Image') + if (existsSync(imagePath)) { + const imageStat = statSync(imagePath) + latest = Math.max(latest, imageStat.mtimeMs) + } + + const image2Path = join(entryPath, 'FileStorage', 'Image2') + if (existsSync(image2Path)) { + const image2Stat = statSync(image2Path) + latest = Math.max(latest, image2Stat.mtimeMs) } return latest diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 920b226..bfd50fe 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -665,7 +665,18 @@ class ExportService { case 42: return '[名片]' case 43: return '[视频]' case 47: return '[动画表情]' - case 48: return '[位置]' + case 48: { + const normalized48 = this.normalizeAppMessageContent(content) + const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName') + const locLabel = this.extractXmlAttribute(normalized48, 'location', 'label') || this.extractXmlValue(normalized48, 'label') + const locLat = this.extractXmlAttribute(normalized48, 'location', 'x') || this.extractXmlAttribute(normalized48, 'location', 'latitude') + const locLng = this.extractXmlAttribute(normalized48, 'location', 'y') || this.extractXmlAttribute(normalized48, 'location', 'longitude') + const locParts: string[] = [] + if (locPoiname) locParts.push(locPoiname) + if (locLabel && locLabel !== locPoiname) locParts.push(locLabel) + if (locLat && locLng) locParts.push(`(${locLat},${locLng})`) + return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' + } case 49: { const title = this.extractXmlValue(content, 'title') const type = this.extractXmlValue(content, 'type') @@ -776,12 +787,15 @@ class ExportService { } if (localType === 48) { const normalized = this.normalizeAppMessageContent(safeContent) - const location = - this.extractXmlValue(normalized, 'label') || - this.extractXmlValue(normalized, 'poiname') || - this.extractXmlValue(normalized, 'poiName') || - this.extractXmlValue(normalized, 'name') - return location ? `[定位]${location}` : '[定位]' + const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName') + const locLabel = this.extractXmlAttribute(normalized, 'location', 'label') || this.extractXmlValue(normalized, 'label') + const locLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude') + const locLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude') + const locParts: string[] = [] + if (locPoiname) locParts.push(locPoiname) + if (locLabel && locLabel !== locPoiname) locParts.push(locLabel) + if (locLat && locLng) locParts.push(`(${locLat},${locLng})`) + return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' } if (localType === 50) { return this.parseVoipMessage(safeContent) @@ -979,6 +993,12 @@ class ExportService { return '' } + private extractXmlAttribute(xml: string, tagName: string, attrName: string): string { + const tagRegex = new RegExp(`<${tagName}\\s+[^>]*${attrName}\\s*=\\s*"([^"]*)"`, 'i') + const match = tagRegex.exec(xml) + return match ? match[1] : '' + } + private cleanSystemMessage(content: string): string { if (!content) return '[系统消息]' @@ -2932,7 +2952,7 @@ class ExportService { options.displayNamePreference || 'remark' ) - allMessages.push({ + const msgObj: any = { localId: allMessages.length + 1, createTime: msg.createTime, formattedTime: this.formatTimestamp(msg.createTime), @@ -2944,7 +2964,17 @@ class ExportService { senderDisplayName, source, senderAvatarKey: msg.senderUsername - }) + } + + // 位置消息:附加结构化位置字段 + if (msg.localType === 48) { + if (msg.locationLat != null) msgObj.locationLat = msg.locationLat + if (msg.locationLng != null) msgObj.locationLng = msg.locationLng + if (msg.locationPoiname) msgObj.locationPoiname = msg.locationPoiname + if (msg.locationLabel) msgObj.locationLabel = msg.locationLabel + } + + allMessages.push(msgObj) } allMessages.sort((a, b) => a.createTime - b.createTime) diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index b6168b6..86ea6c9 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -10,6 +10,7 @@ import { chatService, Message } from './chatService' import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { videoService } from './videoService' +import { imageDecryptService } from './imageDecryptService' // ChatLab 格式定义 interface ChatLabHeader { @@ -69,6 +70,7 @@ interface ApiExportedMedia { kind: MediaKind fileName: string fullPath: string + relativePath: string } // ChatLab 消息类型映射 @@ -236,6 +238,8 @@ class HttpService { await this.handleSessions(url, res) } else if (pathname === '/api/v1/contacts') { await this.handleContacts(url, res) + } else if (pathname.startsWith('/api/v1/media/')) { + this.handleMediaRequest(pathname, res) } else { this.sendError(res, 404, 'Not Found') } @@ -245,6 +249,40 @@ class HttpService { } } + private handleMediaRequest(pathname: string, res: http.ServerResponse): void { + const mediaBasePath = this.getApiMediaExportPath() + const relativePath = pathname.replace('/api/v1/media/', '') + const fullPath = path.join(mediaBasePath, relativePath) + + if (!fs.existsSync(fullPath)) { + this.sendError(res, 404, 'Media not found') + return + } + + const ext = path.extname(fullPath).toLowerCase() + const mimeTypes: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.wav': 'audio/wav', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4' + } + const contentType = mimeTypes[ext] || 'application/octet-stream' + + try { + const fileBuffer = fs.readFileSync(fullPath) + res.setHeader('Content-Type', contentType) + res.setHeader('Content-Length', fileBuffer.length) + res.writeHead(200) + res.end(fileBuffer) + } catch (e) { + this.sendError(res, 500, 'Failed to read media file') + } + } + /** * 批量获取消息(循环游标直到满足 limit) * 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标 @@ -380,7 +418,7 @@ class HttpService { const queryOffset = keyword ? 0 : offset const queryLimit = keyword ? 10000 : limit - const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true) + const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false) if (!result.success || !result.messages) { this.sendError(res, 500, result.error || 'Failed to get messages') return @@ -576,19 +614,44 @@ class HttpService { ): Promise { try { if (msg.localType === 3 && options.exportImages) { - const result = await chatService.getImageData(talker, String(msg.localId)) - if (result.success && result.data) { - const imageBuffer = Buffer.from(result.data, 'base64') - const ext = this.detectImageExt(imageBuffer) - const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) - const fileName = `${fileBase}${ext}` - const targetDir = path.join(sessionDir, 'images') - const fullPath = path.join(targetDir, fileName) - this.ensureDir(targetDir) - if (!fs.existsSync(fullPath)) { - fs.writeFileSync(fullPath, imageBuffer) + const result = await imageDecryptService.decryptImage({ + sessionId: talker, + imageMd5: msg.imageMd5, + imageDatName: msg.imageDatName, + force: true + }) + if (result.success && result.localPath) { + let imagePath = result.localPath + if (imagePath.startsWith('data:')) { + const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/) + if (base64Match) { + const imageBuffer = Buffer.from(base64Match[1], 'base64') + const ext = this.detectImageExt(imageBuffer) + const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) + const fileName = `${fileBase}${ext}` + const targetDir = path.join(sessionDir, 'images') + const fullPath = path.join(targetDir, fileName) + this.ensureDir(targetDir) + if (!fs.existsSync(fullPath)) { + fs.writeFileSync(fullPath, imageBuffer) + } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` + return { kind: 'image', fileName, fullPath, relativePath } + } + } else if (fs.existsSync(imagePath)) { + const imageBuffer = fs.readFileSync(imagePath) + const ext = this.detectImageExt(imageBuffer) + const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) + const fileName = `${fileBase}${ext}` + const targetDir = path.join(sessionDir, 'images') + const fullPath = path.join(targetDir, fileName) + this.ensureDir(targetDir) + if (!fs.existsSync(fullPath)) { + fs.copyFileSync(imagePath, fullPath) + } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` + return { kind: 'image', fileName, fullPath, relativePath } } - return { kind: 'image', fileName, fullPath } } } @@ -607,7 +670,8 @@ class HttpService { if (!fs.existsSync(fullPath)) { fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64')) } - return { kind: 'voice', fileName, fullPath } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/voices/${fileName}` + return { kind: 'voice', fileName, fullPath, relativePath } } } @@ -622,7 +686,8 @@ class HttpService { if (!fs.existsSync(fullPath)) { fs.copyFileSync(info.videoUrl, fullPath) } - return { kind: 'video', fileName, fullPath } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/videos/${fileName}` + return { kind: 'video', fileName, fullPath, relativePath } } } @@ -637,7 +702,8 @@ class HttpService { if (!fs.existsSync(fullPath)) { fs.copyFileSync(result.localPath, fullPath) } - return { kind: 'emoji', fileName, fullPath } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/emojis/${fileName}` + return { kind: 'emoji', fileName, fullPath, relativePath } } } } catch (e) { @@ -661,7 +727,8 @@ class HttpService { parsedContent: msg.parsedContent, mediaType: media?.kind, mediaFileName: media?.fileName, - mediaPath: media?.fullPath + mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined, + mediaLocalPath: media?.fullPath } } @@ -784,7 +851,7 @@ class HttpService { type: this.mapMessageType(msg.localType, msg), content: this.getMessageContent(msg), platformMessageId: msg.serverId ? String(msg.serverId) : undefined, - mediaPath: mediaMap.get(msg.localId)?.fullPath + mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined } }) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 62c683a..73601ca 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -283,7 +283,7 @@ export class ImageDecryptService { if (finalExt === '.hevc') { return { success: false, - error: '此图片为微信新格式 (wxgf),需要安装 ffmpeg 才能显示', + error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志', isThumb: this.isThumbnailPath(datPath) } } @@ -1664,21 +1664,24 @@ export class ImageDecryptService { // 提取 HEVC NALU 裸流 const hevcData = this.extractHevcNalu(buffer) - if (!hevcData || hevcData.length < 100) { - return { data: buffer, isWxgf: true } - } + // 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据 + const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4) + this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', { + naluExtracted: !!(hevcData && hevcData.length >= 100), + feedSize: feedData.length + }) // 尝试用 ffmpeg 转换 try { - const jpgData = await this.convertHevcToJpg(hevcData) + const jpgData = await this.convertHevcToJpg(feedData) if (jpgData && jpgData.length > 0) { return { data: jpgData, isWxgf: false } } - } catch { - // ffmpeg 转换失败 + } catch (e) { + this.logError('unwrapWxgf: ffmpeg 转换失败', e) } - return { data: hevcData, isWxgf: true } + return { data: feedData, isWxgf: true } } /** @@ -1745,50 +1748,92 @@ export class ImageDecryptService { /** * 使用 ffmpeg 将 HEVC 裸流转换为 JPG */ - private convertHevcToJpg(hevcData: Buffer): Promise { + private async convertHevcToJpg(hevcData: Buffer): Promise { const ffmpeg = this.getFfmpegPath() this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length }) + const tmpDir = join(app.getPath('temp'), 'weflow_hevc') + if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }) + const ts = Date.now() + const tmpInput = join(tmpDir, `hevc_${ts}.hevc`) + const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`) + + try { + await writeFile(tmpInput, hevcData) + + // 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测 + const attempts: { label: string; inputArgs: string[] }[] = [ + { label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] }, + { label: 'auto detect', inputArgs: ['-i', tmpInput] }, + ] + + for (const attempt of attempts) { + // 清理上一轮的输出 + try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {} + + const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label) + if (result) return result + } + + return null + } catch (e) { + this.logError('ffmpeg 转换异常', e) + return null + } finally { + try { if (existsSync(tmpInput)) require('fs').unlinkSync(tmpInput) } catch {} + try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {} + } + } + + private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise { return new Promise((resolve) => { const { spawn } = require('child_process') - const chunks: Buffer[] = [] const errChunks: Buffer[] = [] - const proc = spawn(ffmpeg, [ - '-hide_banner', - '-loglevel', 'error', - '-f', 'hevc', - '-i', 'pipe:0', - '-vframes', '1', - '-q:v', '3', - '-f', 'mjpeg', - 'pipe:1' - ], { - stdio: ['pipe', 'pipe', 'pipe'], + const args = [ + '-hide_banner', '-loglevel', 'error', + ...inputArgs, + '-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput + ] + this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') }) + + const proc = spawn(ffmpeg, args, { + stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true }) - proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)) proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk)) - proc.on('close', (code: number) => { - if (code === 0 && chunks.length > 0) { - this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length }) - resolve(Buffer.concat(chunks)) - } else { - const errMsg = Buffer.concat(errChunks).toString() - this.logInfo('ffmpeg 转换失败', { code, error: errMsg }) - resolve(null) - } - }) + const timer = setTimeout(() => { + proc.kill('SIGKILL') + this.logError(`ffmpeg [${label}] 超时(15s)`) + resolve(null) + }, 15000) - proc.on('error', (err: Error) => { - this.logInfo('ffmpeg 进程错误', { error: err.message }) + proc.on('close', (code: number) => { + clearTimeout(timer) + if (code === 0 && existsSync(tmpOutput)) { + try { + const jpgBuf = readFileSync(tmpOutput) + if (jpgBuf.length > 0) { + this.logInfo(`ffmpeg [${label}] 成功`, { outputSize: jpgBuf.length }) + resolve(jpgBuf) + return + } + } catch (e) { + this.logError(`ffmpeg [${label}] 读取输出失败`, e) + } + } + const errMsg = Buffer.concat(errChunks).toString().trim() + this.logInfo(`ffmpeg [${label}] 失败`, { code, error: errMsg }) resolve(null) }) - proc.stdin.write(hevcData) - proc.stdin.end() + proc.on('error', (err: Error) => { + clearTimeout(timer) + this.logError(`ffmpeg [${label}] 进程错误`, err) + resolve(null) + }) }) } diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index fb21b2f..2b5d4af 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -1,9 +1,8 @@ import { app } from 'electron' -import { join, dirname, basename } from 'path' -import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs' +import { join, dirname } from 'path' +import { existsSync, copyFileSync, mkdirSync } from 'fs' import { execFile, spawn } from 'child_process' import { promisify } from 'util' -import crypto from 'crypto' import os from 'os' const execFileAsync = promisify(execFile) @@ -20,6 +19,7 @@ export class KeyService { private getStatusMessage: any = null private cleanupHook: any = null private getLastErrorMsg: any = null + private getImageKeyDll: any = null // Win32 APIs private kernel32: any = null @@ -29,9 +29,6 @@ export class KeyService { // Kernel32 private OpenProcess: any = null private CloseHandle: any = null - private VirtualQueryEx: any = null - private ReadProcessMemory: any = null - private MEMORY_BASIC_INFORMATION: any = null private TerminateProcess: any = null private QueryFullProcessImageNameW: any = null @@ -62,50 +59,33 @@ export class KeyService { private getDllPath(): string { const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production' - - // 候选路径列表 const candidates: string[] = [] - // 1. 显式环境变量 (最高优先级) if (process.env.WX_KEY_DLL_PATH) { candidates.push(process.env.WX_KEY_DLL_PATH) } if (isPackaged) { - // 生产环境: 通常在 resources 目录下,但也可能直接在 resources 根目录 candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll')) candidates.push(join(process.resourcesPath, 'wx_key.dll')) } else { - // 开发环境 const cwd = process.cwd() candidates.push(join(cwd, 'resources', 'wx_key.dll')) candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll')) } - // 检查并返回第一个存在的路径 for (const path of candidates) { - if (existsSync(path)) { - return path - } + if (existsSync(path)) return path } - // 如果都没找到,返回最可能的路径以便报错信息有参考 return candidates[0] } - // 检查路径是否为 UNC 路径或网络路径 private isNetworkPath(path: string): boolean { - // UNC 路径以 \\ 开头 - if (path.startsWith('\\\\')) { - return true - } - // 检查是否为网络映射驱动器(简化检测:A: 表示驱动器) - // 注意:这是一个启发式检测,更准确的方式需要调用 GetDriveType Windows API - // 但对于大多数 VM 共享场景,UNC 路径检测已足够 + if (path.startsWith('\\\\')) return true return false } - // 将 DLL 复制到本地临时目录 private localizeNetworkDll(originalPath: string): string { try { const tempDir = join(os.tmpdir(), 'weflow_dll_cache') @@ -113,20 +93,12 @@ export class KeyService { mkdirSync(tempDir, { recursive: true }) } const localPath = join(tempDir, 'wx_key.dll') + if (existsSync(localPath)) return localPath - // 检查是否已经有本地副本,如果有就使用它 - if (existsSync(localPath)) { - - return localPath - } - - copyFileSync(originalPath, localPath) - return localPath } catch (e) { console.error('DLL 本地化失败:', e) - // 如果本地化失败,返回原路径 return originalPath } } @@ -144,9 +116,7 @@ export class KeyService { return false } - // 检查是否为网络路径,如果是则本地化 if (this.isNetworkPath(dllPath)) { - dllPath = this.localizeNetworkDll(dllPath) } @@ -156,18 +126,13 @@ export class KeyService { this.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)') this.cleanupHook = this.lib.func('bool CleanupHook()') this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()') + this.getImageKeyDll = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)') this.initialized = true return true } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e) - const errorStack = e instanceof Error ? e.stack : '' - console.error(`加载 wx_key.dll 失败`) - console.error(` 路径: ${dllPath}`) - console.error(` 错误: ${errorMsg}`) - if (errorStack) { - console.error(` 堆栈: ${errorStack}`) - } + console.error(`加载 wx_key.dll 失败\n 路径: ${dllPath}\n 错误: ${errorMsg}`) return false } } @@ -181,25 +146,10 @@ export class KeyService { try { this.koffi = require('koffi') this.kernel32 = this.koffi.load('kernel32.dll') - - const HANDLE = this.koffi.pointer('HANDLE', this.koffi.opaque()) - this.MEMORY_BASIC_INFORMATION = this.koffi.struct('MEMORY_BASIC_INFORMATION', { - BaseAddress: 'uint64', - AllocationBase: 'uint64', - AllocationProtect: 'uint32', - RegionSize: 'uint64', - State: 'uint32', - Protect: 'uint32', - Type: 'uint32' - }) - - // Use explicit definitions to avoid parser issues - this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32']) - this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE']) - this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32']) - this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')]) - this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64']) - this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))]) + this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32']) + this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*']) + this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32']) + this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['void*', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')]) return true } catch (e) { @@ -219,15 +169,12 @@ export class KeyService { this.koffi = require('koffi') this.user32 = this.koffi.load('user32.dll') - // Callbacks - // Define the prototype and its pointer type const WNDENUMPROC = this.koffi.proto('bool __stdcall (void *hWnd, intptr_t lParam)') this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC) this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t']) this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t']) this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t']) - this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int']) this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*']) this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int']) @@ -247,8 +194,6 @@ export class KeyService { this.koffi = require('koffi') this.advapi32 = this.koffi.load('advapi32.dll') - // Types - // Use intptr_t for HKEY to match system architecture (64-bit safe) const HKEY = this.koffi.alias('HKEY', 'intptr_t') const HKEY_PTR = this.koffi.pointer(HKEY) @@ -274,27 +219,19 @@ export class KeyService { // --- WeChat Process & Path Finding --- - // Helper to read simple registry string private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null { if (!this.ensureAdvapi32()) return null - - // Convert strings to UTF-16 buffers const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2') const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null + const phkResult = Buffer.alloc(8) - const phkResult = Buffer.alloc(8) // Pointer size (64-bit safe) - - if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) { - return null - } + if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) return null const hKey = this.koffi.decode(phkResult, 'uintptr_t') try { const lpcbData = Buffer.alloc(4) - lpcbData.writeUInt32LE(0, 0) // First call to get size? No, RegQueryValueExW expects initialized size or null to get size. - // Usually we call it twice or just provide a big buffer. - // Let's call twice. + lpcbData.writeUInt32LE(0, 0) let ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, null, lpcbData) if (ret !== this.ERROR_SUCCESS) return null @@ -306,7 +243,6 @@ export class KeyService { ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, dataBuf, lpcbData) if (ret !== this.ERROR_SUCCESS) return null - // Read UTF-16 string (remove null terminator) let str = dataBuf.toString('ucs2') if (str.endsWith('\0')) str = str.slice(0, -1) return str @@ -317,7 +253,6 @@ export class KeyService { private async getProcessExecutablePath(pid: number): Promise { if (!this.ensureKernel32()) return null - // 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION const hProcess = this.OpenProcess(0x1000, false, pid) if (!hProcess) return null @@ -341,33 +276,21 @@ export class KeyService { } private async findWeChatInstallPath(): Promise { - // 0. 优先尝试获取正在运行的微信进程路径 try { const pid = await this.findWeChatPid() if (pid) { const runPath = await this.getProcessExecutablePath(pid) - if (runPath && existsSync(runPath)) { - - return runPath - } + if (runPath && existsSync(runPath)) return runPath } } catch (e) { console.error('尝试获取运行中微信路径失败:', e) } - // 1. Registry - Uninstall Keys const uninstallKeys = [ 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', 'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall' ] const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER] - - // NOTE: Scanning subkeys in registry via Koffi is tedious (RegEnumKeyEx). - // Simplified strategy: Check common known registry keys first, then fallback to common paths. - // wx_key searches *all* subkeys of Uninstall, which is robust but complex to port quickly. - // Let's rely on specific Tencent keys first. - - // 2. Tencent specific keys const tencentKeys = [ 'Software\\Tencent\\WeChat', 'Software\\WOW6432Node\\Tencent\\WeChat', @@ -382,16 +305,13 @@ export class KeyService { } } - // 3. Uninstall key exact match (sometimes works) for (const root of roots) { for (const parent of uninstallKeys) { - // Try WeChat specific subkey const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation') if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe') } } - // 4. Common Paths const drives = ['C', 'D', 'E', 'F'] const commonPaths = [ 'Program Files\\Tencent\\WeChat\\WeChat.exe', @@ -424,7 +344,6 @@ export class KeyService { } return null } catch (e) { - console.error(`获取进程失败 (${imageName}):`, e) return null } } @@ -435,7 +354,6 @@ export class KeyService { const pid = await this.findPidByImageName(name) if (pid) return pid } - const fallbackPid = await this.waitForWeChatWindow(5000) return fallbackPid ?? null } @@ -486,14 +404,11 @@ export class KeyService { try { await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe']) await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe']) - } catch (e) { - // Ignore if not found - } + } catch (e) { } return await this.waitForWeChatExit(5000) } - // --- Window Detection --- private getWindowTitle(hWnd: any): string { @@ -574,17 +489,12 @@ export class KeyService { for (const child of children) { const normalizedTitle = child.title.replace(/\s+/g, '') if (normalizedTitle) { - if (readyTexts.some(marker => normalizedTitle.includes(marker))) { - return true - } + if (readyTexts.some(marker => normalizedTitle.includes(marker))) return true titleMatchCount += 1 } - const className = child.className if (className) { - if (readyClassMarkers.some(marker => className.includes(marker))) { - return true - } + if (readyClassMarkers.some(marker => className.includes(marker))) return true if (className.length > 5) { classMatchCount += 1 hasValidClassName = true @@ -630,11 +540,11 @@ export class KeyService { return true } - // --- Main Methods --- + // --- DB Key Logic (Unchanged core flow) --- async autoGetDbKey( - timeoutMs = 60_000, - onStatus?: (message: string, level: number) => void + timeoutMs = 60_000, + onStatus?: (message: string, level: number) => void ): Promise { if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' } @@ -642,7 +552,6 @@ export class KeyService { const logs: string[] = [] - // 1. Find Path onStatus?.('正在定位微信安装路径...', 0) let wechatPath = await this.findWeChatInstallPath() if (!wechatPath) { @@ -651,7 +560,6 @@ export class KeyService { return { success: false, error: err } } - // 2. Restart WeChat onStatus?.('正在关闭微信以进行获取...', 0) const closed = await this.killWeChatProcesses() if (!closed) { @@ -660,7 +568,6 @@ export class KeyService { return { success: false, error: err } } -// 3. Launch onStatus?.('正在启动微信...', 0) const sub = spawn(wechatPath, { detached: true, @@ -669,23 +576,18 @@ export class KeyService { }) sub.unref() -// 4. Wait for Window & Get PID (Crucial change: discover PID from window) onStatus?.('等待微信界面就绪...', 0) const pid = await this.waitForWeChatWindow() - if (!pid) { - return { success: false, error: '启动微信失败或等待界面就绪超时' } - } + if (!pid) return { success: false, error: '启动微信失败或等待界面就绪超时' } onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0) onStatus?.('正在检测微信界面组件...', 0) await this.waitForWeChatWindowComponents(pid, 15000) - // 5. Inject const ok = this.initHook(pid) if (!ok) { const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '' if (error) { - // 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED) if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) { const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行' return { success: false, error: friendlyError } @@ -695,8 +597,8 @@ export class KeyService { const statusBuffer = Buffer.alloc(256) const levelOut = [0] const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut) - ? this.decodeUtf8(statusBuffer) - : '' + ? this.decodeUtf8(statusBuffer) + : '' return { success: false, error: status || '初始化失败' } } @@ -716,9 +618,7 @@ export class KeyService { for (let i = 0; i < 5; i++) { const statusBuffer = Buffer.alloc(256) const levelOut = [0] - if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) { - break - } + if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) break const msg = this.decodeUtf8(statusBuffer) const level = levelOut[0] ?? 0 if (msg) { @@ -726,7 +626,6 @@ export class KeyService { onStatus?.(msg, level) } } - await new Promise((resolve) => setTimeout(resolve, 120)) } } finally { @@ -738,417 +637,68 @@ export class KeyService { return { success: false, error: '获取密钥超时', logs } } - // --- Image Key Stuff (Legacy but kept) --- - - private isAccountDir(dirPath: string): boolean { - return ( - existsSync(join(dirPath, 'db_storage')) || - existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) || - existsSync(join(dirPath, 'msg', 'attach')) - ) - } - - private isPotentialAccountName(name: string): boolean { - const lower = name.toLowerCase() - if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) { - return false - } - if (lower.startsWith('wxid_')) return true - if (/^\d+$/.test(name) && name.length >= 6) return true - return name.length > 5 - } - - private listAccountDirs(rootDir: string): string[] { - try { - const entries = readdirSync(rootDir) - const candidates: { path: string; mtime: number; isAccount: boolean }[] = [] - - for (const entry of entries) { - const fullPath = join(rootDir, entry) - try { - if (!statSync(fullPath).isDirectory()) continue - } catch { - continue - } - - if (!this.isPotentialAccountName(entry)) { - continue - } - - const isAccount = this.isAccountDir(fullPath) - candidates.push({ - path: fullPath, - mtime: this.getDirMtime(fullPath), - isAccount - }) - } - - // 优先选择有效账号目录,然后按修改时间从新到旧排序 - return candidates - .sort((a, b) => { - if (a.isAccount !== b.isAccount) return a.isAccount ? -1 : 1 - return b.mtime - a.mtime - }) - .map(c => c.path) - } catch { - return [] - } - } - - private getDirMtime(dirPath: string): number { - try { - const stat = statSync(dirPath) - let mtime = stat.mtimeMs - - // 检查几个关键子目录的修改时间,以更准确地反映活动状态 - const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image'] - for (const sub of subDirs) { - const fullPath = join(dirPath, sub) - if (existsSync(fullPath)) { - try { - mtime = Math.max(mtime, statSync(fullPath).mtimeMs) - } catch { } - } - } - - return mtime - } catch { - return 0 - } - } - - private normalizeExistingDir(inputPath: string): string | null { - const trimmed = inputPath.replace(/[\\\\/]+$/, '') - if (!existsSync(trimmed)) return null - try { - const stats = statSync(trimmed) - if (stats.isFile()) { - return dirname(trimmed) - } - } catch { - return null - } - return trimmed - } - - private resolveAccountDirFromPath(inputPath: string): string | null { - const normalized = this.normalizeExistingDir(inputPath) - if (!normalized) return null - - if (this.isAccountDir(normalized)) return normalized - - const lower = normalized.toLowerCase() - if (lower.endsWith('db_storage') || lower.endsWith('filestorage') || lower.endsWith('image') || lower.endsWith('image2')) { - const parent = dirname(normalized) - if (this.isAccountDir(parent)) return parent - const grandParent = dirname(parent) - if (this.isAccountDir(grandParent)) return grandParent - } - - const candidates = this.listAccountDirs(normalized) - if (candidates.length) return candidates[0] - return null - } - - private resolveAccountDir(manualDir?: string): string | null { - if (manualDir) { - const resolved = this.resolveAccountDirFromPath(manualDir) - if (resolved) return resolved - } - - const userProfile = process.env.USERPROFILE - if (!userProfile) return null - const roots = [ - join(userProfile, 'Documents', 'xwechat_files'), - join(userProfile, 'Documents', 'WeChat Files') - ] - for (const root of roots) { - if (!existsSync(root)) continue - const candidates = this.listAccountDirs(root) - if (candidates.length) return candidates[0] - } - return null - } - - private findTemplateDatFiles(rootDir: string): string[] { - const files: string[] = [] - const stack = [rootDir] - const maxFiles = 256 - while (stack.length && files.length < maxFiles) { - const dir = stack.pop() as string - let entries: string[] - try { - entries = readdirSync(dir) - } catch { - continue - } - for (const entry of entries) { - const fullPath = join(dir, entry) - let stats: any - try { - stats = statSync(fullPath) - } catch { - continue - } - if (stats.isDirectory()) { - stack.push(fullPath) - } else if (entry.endsWith('_t.dat')) { - files.push(fullPath) - if (files.length >= maxFiles) break - } - } - } - - if (!files.length) return [] - const dateReg = /(\d{4}-\d{2})/ - files.sort((a, b) => { - const ma = a.match(dateReg)?.[1] - const mb = b.match(dateReg)?.[1] - if (ma && mb) return mb.localeCompare(ma) - return 0 - }) - return files.slice(0, 128) - } - - private getXorKey(templateFiles: string[]): number | null { - const counts = new Map() - const tailSignatures = [ - Buffer.from([0xFF, 0xD9]), - Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]) - ] - for (const file of templateFiles) { - try { - const bytes = readFileSync(file) - for (const signature of tailSignatures) { - if (bytes.length < signature.length) continue - const tail = bytes.subarray(bytes.length - signature.length) - const xorKey = tail[0] ^ signature[0] - let valid = true - for (let i = 1; i < signature.length; i++) { - if ((tail[i] ^ xorKey) !== signature[i]) { - valid = false - break - } - } - if (valid) { - counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1) - } - } - } catch { } - } - if (!counts.size) return null - let bestKey: number | null = null - let bestCount = 0 - for (const [key, count] of counts) { - if (count > bestCount) { - bestCount = count - bestKey = key - } - } - return bestKey - } - - private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null { - for (const file of templateFiles) { - try { - const bytes = readFileSync(file) - if (bytes.length < 0x1f) continue - if ( - bytes[0] === 0x07 && - bytes[1] === 0x08 && - bytes[2] === 0x56 && - bytes[3] === 0x32 && - bytes[4] === 0x08 && - bytes[5] === 0x07 - ) { - return bytes.subarray(0x0f, 0x1f) - } - } catch { } - } - return null - } - - private isAlphaNumLower(byte: number): boolean { - // 只匹配小写字母 a-z 和数字 0-9(AES密钥格式) - return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39) - } - - private isUtf16LowerKey(buf: Buffer, start: number): boolean { - if (start + 64 > buf.length) return false - for (let j = 0; j < 32; j++) { - const charByte = buf[start + j * 2] - const nullByte = buf[start + j * 2 + 1] - if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) { - return false - } - } - return true - } - - private verifyKey(ciphertext: Buffer, keyBytes: Buffer): boolean { - try { - const key = keyBytes.subarray(0, 16) - const decipher = crypto.createDecipheriv('aes-128-ecb', key, null) - decipher.setAutoPadding(false) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff - const isPng = decrypted.length >= 8 && - decrypted[0] === 0x89 && - decrypted[1] === 0x50 && - decrypted[2] === 0x4e && - decrypted[3] === 0x47 && - decrypted[4] === 0x0d && - decrypted[5] === 0x0a && - decrypted[6] === 0x1a && - decrypted[7] === 0x0a - return isJpeg || isPng - } catch { - return false - } - } - - private getMemoryRegions(hProcess: any): Array<[number, number]> { - const regions: Array<[number, number]> = [] - const MEM_COMMIT = 0x1000 - const MEM_PRIVATE = 0x20000 - const PAGE_NOACCESS = 0x01 - const PAGE_GUARD = 0x100 - - let address = 0 - const maxAddress = 0x7fffffffffff - while (address >= 0 && address < maxAddress) { - const info: any = {} - const result = this.VirtualQueryEx(hProcess, address, info, this.koffi.sizeof(this.MEMORY_BASIC_INFORMATION)) - if (!result) break - - const state = info.State - const protect = info.Protect - const type = info.Type - const regionSize = Number(info.RegionSize) - // 只收集已提交的私有内存(大幅减少扫描区域) - if (state === MEM_COMMIT && type === MEM_PRIVATE && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) { - regions.push([Number(info.BaseAddress), regionSize]) - } - - const nextAddress = address + regionSize - if (nextAddress <= address) break - address = nextAddress - } - return regions - } - - private readProcessMemory(hProcess: any, address: number, size: number): Buffer | null { - const buffer = Buffer.alloc(size) - const bytesRead = [0] - const ok = this.ReadProcessMemory(hProcess, address, buffer, size, bytesRead) - if (!ok || bytesRead[0] === 0) return null - return buffer.subarray(0, bytesRead[0]) - } - - private async getAesKeyFromMemory( - pid: number, - ciphertext: Buffer, - onProgress?: (current: number, total: number, message: string) => void - ): Promise { - if (!this.ensureKernel32()) return null - const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid) - if (!hProcess) return null - - try { - const allRegions = this.getMemoryRegions(hProcess) - const totalRegions = allRegions.length - let scannedCount = 0 - let skippedCount = 0 - - for (const [baseAddress, regionSize] of allRegions) { - // 跳过太大的内存区域(> 100MB) - if (regionSize > 100 * 1024 * 1024) { - skippedCount++ - continue - } - - scannedCount++ - if (scannedCount % 10 === 0) { - onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`) - await new Promise(resolve => setImmediate(resolve)) - } - - const memory = this.readProcessMemory(hProcess, baseAddress, regionSize) - if (!memory) continue - - // 直接在原始字节中搜索32字节的小写字母数字序列 - for (let i = 0; i < memory.length - 34; i++) { - // 检查前导字符(不是小写字母或数字) - if (this.isAlphaNumLower(memory[i])) continue - - // 检查接下来32个字节是否都是小写字母或数字 - let valid = true - for (let j = 1; j <= 32; j++) { - if (!this.isAlphaNumLower(memory[i + j])) { - valid = false - break - } - } - if (!valid) continue - - // 检查尾部字符(不是小写字母或数字) - if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) { - continue - } - - const keyBytes = memory.subarray(i + 1, i + 33) - if (this.verifyKey(ciphertext, keyBytes)) { - return keyBytes.toString('ascii') - } - } - } - return null - } finally { - try { - this.CloseHandle(hProcess) - } catch { } - } - } + // --- Image Key (通过 DLL 从缓存目录直接获取) --- async autoGetImageKey( - manualDir?: string, - onProgress?: (message: string) => void + manualDir?: string, + onProgress?: (message: string) => void ): Promise { if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' } - if (!this.ensureKernel32()) return { success: false, error: '初始化系统 API 失败' } - onProgress?.('正在定位微信账号目录...') - const accountDir = this.resolveAccountDir(manualDir) - if (!accountDir) return { success: false, error: '未找到微信账号目录' } + onProgress?.('正在从缓存目录扫描图片密钥...') - onProgress?.('正在收集模板文件...') - const templateFiles = this.findTemplateDatFiles(accountDir) - if (!templateFiles.length) return { success: false, error: '未找到模板文件' } + const resultBuffer = Buffer.alloc(8192) + const ok = this.getImageKeyDll(resultBuffer, resultBuffer.length) - onProgress?.('正在计算 XOR 密钥...') - const xorKey = this.getXorKey(templateFiles) - if (xorKey == null) return { success: false, error: '无法计算 XOR 密钥' } + if (!ok) { + const errMsg = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '获取图片密钥失败' + return { success: false, error: errMsg } + } - onProgress?.('正在读取加密模板数据...') - const ciphertext = this.getCiphertextFromTemplate(templateFiles) - if (!ciphertext) return { success: false, error: '无法读取加密模板数据' } + const jsonStr = this.decodeUtf8(resultBuffer) + let parsed: any + try { + parsed = JSON.parse(jsonStr) + } catch { + return { success: false, error: '解析密钥数据失败' } + } - const pid = await this.findWeChatPid() - if (!pid) return { success: false, error: '未检测到微信进程' } - - onProgress?.('正在扫描内存获取 AES 密钥...') - const aesKey = await this.getAesKeyFromMemory(pid, ciphertext, (current, total, msg) => { - onProgress?.(`${msg} (${current}/${total})`) - }) - if (!aesKey) { - return { - success: false, - error: '未能从内存中获取 AES 密钥,请打开朋友圈图片后重试' + // 从 manualDir 中提取 wxid 用于精确匹配 + // 前端传入的格式是 dbPath/wxid_xxx_1234,取最后一段目录名再清理后缀 + let targetWxid: string | null = null + if (manualDir) { + const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '' + // 与 DLL 的 CleanWxid 逻辑一致:wxid_a_b_c → wxid_a + const parts = dirName.split('_') + if (parts.length >= 3 && parts[0] === 'wxid') { + targetWxid = `${parts[0]}_${parts[1]}` + } else if (dirName.startsWith('wxid_')) { + targetWxid = dirName } } - return { success: true, xorKey, aesKey: aesKey.slice(0, 16) } + const accounts: any[] = parsed.accounts ?? [] + if (!accounts.length) { + return { success: false, error: '未找到有效的密钥组合' } + } + + // 优先匹配 wxid,找不到则回退到第一个 + const matchedAccount = targetWxid + ? (accounts.find((a: any) => a.wxid === targetWxid) ?? accounts[0]) + : accounts[0] + + if (!matchedAccount?.keys?.length) { + return { success: false, error: '未找到有效的密钥组合' } + } + + const firstKey = matchedAccount.keys[0] + onProgress?.(`密钥获取成功 (wxid: ${matchedAccount.wxid}, code: ${firstKey.code})`) + + return { + success: true, + xorKey: firstKey.xorKey, + aesKey: firstKey.aesKey + } } -} +} \ No newline at end of file diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index ffc1c23..835850f 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -6,6 +6,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises' import { basename, join } from 'path' import crypto from 'crypto' import { WasmService } from './wasmService' +import zlib from 'zlib' export interface SnsLivePhoto { url: string @@ -28,6 +29,7 @@ export interface SnsMedia { export interface SnsPost { id: string + tid?: string // 数据库主键(雪花 ID),用于精确删除 username: string nickname: string avatarUrl?: string @@ -36,7 +38,7 @@ export interface SnsPost { type?: number media: SnsMedia[] 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 linkTitle?: string linkUrl?: string @@ -122,6 +124,107 @@ const extractVideoKey = (xml: string): string | undefined => { return match ? match[1] : undefined } +/** + * 从 XML 中解析评论信息(含表情包、回复关系) + */ +function parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] { + if (!xml) return [] + + 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; encryptUrl?: string; aesKey?: string }[] + } + const comments: CommentItem[] = [] + + try { + // 支持多种标签格式 + let listMatch = xml.match(/([\s\S]*?)<\/CommentUserList>/i) + if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/commentUserList>/i) + if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/commentList>/i) + if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/comment_user_list>/i) + if (!listMatch) return comments + + const listXml = listMatch[1] + const itemRegex = /<(?:CommentUser|commentUser|comment|user_comment)>([\s\S]*?)<\/(?:CommentUser|commentUser|comment|user_comment)>/gi + let m: RegExpExecArray | null + + while ((m = itemRegex.exec(listXml)) !== null) { + const c = m[1] + + const idMatch = c.match(/<(?:cmtid|commentId|comment_id|id)>([^<]*)<\/(?:cmtid|commentId|comment_id|id)>/i) + const usernameMatch = c.match(/([^<]*)<\/username>/i) + let nicknameMatch = c.match(/([^<]*)<\/nickname>/i) + if (!nicknameMatch) nicknameMatch = c.match(/([^<]*)<\/nickName>/i) + const contentMatch = c.match(/([^<]*)<\/content>/i) + const refIdMatch = c.match(/<(?:refCommentId|replyCommentId|ref_comment_id)>([^<]*)<\/(?:refCommentId|replyCommentId|ref_comment_id)>/i) + const refNickMatch = c.match(/<(?:refNickname|refNickName|replyNickname)>([^<]*)<\/(?:refNickname|refNickName|replyNickname)>/i) + const refUserMatch = c.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 em: RegExpExecArray | null + while ((em = emojiRegex.exec(c)) !== null) { + const ex = em[1] + const externUrl = ex.match(/([^<]*)<\/extern_url>/i) + const cdnUrl = ex.match(/([^<]*)<\/cdn_url>/i) + const plainUrl = ex.match(/([^<]*)<\/url>/i) + const urlMatch = externUrl || cdnUrl || plainUrl + const md5Match = ex.match(/([^<]*)<\/md5>/i) + const wMatch = ex.match(/([^<]*)<\/width>/i) + const hMatch = ex.match(/([^<]*)<\/height>/i) + const encMatch = ex.match(/([^<]*)<\/encrypt_url>/i) + const aesMatch = ex.match(/([^<]*)<\/aes_key>/i) + + const url = urlMatch ? urlMatch[1].trim().replace(/&/g, '&') : '' + const encryptUrl = encMatch ? encMatch[1].trim().replace(/&/g, '&') : undefined + const aesKey = aesMatch ? aesMatch[1].trim() : undefined + + if (url || encryptUrl) { + emojis.push({ + url, + md5: md5Match ? md5Match[1].trim() : '', + width: wMatch ? parseInt(wMatch[1]) : 0, + height: hMatch ? parseInt(hMatch[1]) : 0, + encryptUrl, + aesKey + }) + } + } + + if (nicknameMatch && (contentMatch || emojis.length > 0)) { + const refId = refIdMatch ? refIdMatch[1].trim() : '' + comments.push({ + id: idMatch ? idMatch[1].trim() : `cmt_${Date.now()}_${Math.random()}`, + nickname: nicknameMatch[1].trim(), + username: usernameMatch ? usernameMatch[1].trim() : undefined, + content: contentMatch ? contentMatch[1].trim() : '', + refCommentId: refId === '0' ? '' : refId, + refUsername: refUserMatch ? refUserMatch[1].trim() : undefined, + refNickname: refNickMatch ? refNickMatch[1].trim() : undefined, + emojis: emojis.length > 0 ? emojis : undefined + }) + } + } + + // 二次解析:通过 refUsername 补全 refNickname + const userMap = new Map() + for (const c of comments) { + if (c.username && c.nickname) userMap.set(c.username, c.nickname) + } + for (const c of comments) { + if (!c.refNickname && c.refUsername && c.refCommentId) { + c.refNickname = userMap.get(c.refUsername) + } + } + } catch (e) { + console.error('[SnsService] parseCommentsFromXml 失败:', e) + } + + return comments +} + class SnsService { private configService: ConfigService private contactCache: ContactCacheService @@ -132,6 +235,104 @@ class SnsService { this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string) } + private parseLikesFromXml(xml: string): string[] { + if (!xml) return [] + const likes: string[] = [] + try { + let likeListMatch = xml.match(/([\s\S]*?)<\/LikeUserList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeUserList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/like_user_list>/i) + if (!likeListMatch) return likes + + const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi + let m: RegExpExecArray | null + while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) { + let nick = m[1].match(/([^<]*)<\/nickname>/i) + if (!nick) nick = m[1].match(/([^<]*)<\/nickName>/i) + if (nick) likes.push(nick[1].trim()) + } + } catch (e) { + console.error('[SnsService] 解析点赞失败:', e) + } + return likes + } + + private parseMediaFromXml(xml: string): { media: SnsMedia[]; videoKey?: string } { + if (!xml) return { media: [] } + const media: SnsMedia[] = [] + let videoKey: string | undefined + try { + const encMatch = xml.match(/([\s\S]*?)<\/media>/gi + let mediaMatch: RegExpExecArray | null + while ((mediaMatch = mediaRegex.exec(xml)) !== null) { + const mx = mediaMatch[1] + const urlMatch = mx.match(/]*>([^<]+)<\/url>/i) + const urlTagMatch = mx.match(/]*)>/i) + const thumbMatch = mx.match(/]*>([^<]+)<\/thumb>/i) + const thumbTagMatch = mx.match(/]*)>/i) + + let urlToken: string | undefined, urlKey: string | undefined + let urlMd5: string | undefined, urlEncIdx: string | undefined + if (urlTagMatch?.[1]) { + const a = urlTagMatch[1] + urlToken = a.match(/token="([^"]+)"/i)?.[1] + urlKey = a.match(/key="([^"]+)"/i)?.[1] + urlMd5 = a.match(/md5="([^"]+)"/i)?.[1] + urlEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] + } + let thumbToken: string | undefined, thumbKey: string | undefined, thumbEncIdx: string | undefined + if (thumbTagMatch?.[1]) { + const a = thumbTagMatch[1] + thumbToken = a.match(/token="([^"]+)"/i)?.[1] + thumbKey = a.match(/key="([^"]+)"/i)?.[1] + thumbEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] + } + + const item: SnsMedia = { + url: urlMatch ? urlMatch[1].trim() : '', + thumb: thumbMatch ? thumbMatch[1].trim() : '', + token: urlToken || thumbToken, + key: urlKey || thumbKey, + md5: urlMd5, + encIdx: urlEncIdx || thumbEncIdx + } + + const livePhotoMatch = mx.match(/([\s\S]*?)<\/livePhoto>/i) + if (livePhotoMatch) { + const lx = livePhotoMatch[1] + const lpUrl = lx.match(/]*>([^<]+)<\/url>/i) + const lpUrlTag = lx.match(/]*)>/i) + const lpThumb = lx.match(/]*>([^<]+)<\/thumb>/i) + const lpThumbTag = lx.match(/]*)>/i) + let lpToken: string | undefined, lpKey: string | undefined, lpEncIdx: string | undefined + if (lpUrlTag?.[1]) { + const a = lpUrlTag[1] + lpToken = a.match(/token="([^"]+)"/i)?.[1] + lpKey = a.match(/key="([^"]+)"/i)?.[1] + lpEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] + } + if (!lpToken && lpThumbTag?.[1]) lpToken = lpThumbTag[1].match(/token="([^"]+)"/i)?.[1] + if (!lpKey && lpThumbTag?.[1]) lpKey = lpThumbTag[1].match(/key="([^"]+)"/i)?.[1] + item.livePhoto = { + url: lpUrl ? lpUrl[1].trim() : '', + thumb: lpThumb ? lpThumb[1].trim() : '', + token: lpToken, + key: lpKey, + encIdx: lpEncIdx + } + } + media.push(item) + } + } catch (e) { + console.error('[SnsService] 解析媒体 XML 失败:', e) + } + return { media, videoKey } + } + private getSnsCacheDir(): string { const cachePath = this.configService.getCacheBasePath() const snsCacheDir = join(cachePath, 'sns_cache') @@ -147,7 +348,6 @@ class SnsService { return join(this.getSnsCacheDir(), `${hash}${ext}`) } - // 获取所有发过朋友圈的用户名列表 async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine') if (!result.success || !result.rows) { @@ -159,51 +359,142 @@ class SnsService { return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } } - async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { - const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) + // 安装朋友圈删除拦截 + async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { + return wcdbService.installSnsBlockDeleteTrigger() + } - if (result.success && result.timeline) { - const enrichedTimeline = result.timeline.map((post: any) => { - const contact = this.contactCache.get(post.username) - const isVideoPost = post.type === 15 + // 卸载朋友圈删除拦截 + async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> { + return wcdbService.uninstallSnsBlockDeleteTrigger() + } - // 尝试从 rawXml 中提取视频解密密钥 (针对视频号视频) - const videoKey = extractVideoKey(post.rawXml || '') + // 查询朋友圈删除拦截是否已安装 + async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> { + return wcdbService.checkSnsBlockDeleteTrigger() + } - const fixedMedia = (post.media || []).map((m: any) => ({ - // 如果是视频动态,url 是视频,thumb 是缩略图 - url: fixSnsUrl(m.url, m.token, isVideoPost), - thumb: fixSnsUrl(m.thumb, m.token, false), - md5: m.md5, - token: m.token, - // 只有在视频动态 (Type 15) 下才尝试将 XML 提取的 videoKey 赋予主媒体 - // 对于图片或实况照片的静态部分,应保留原始 m.key (由 DLL/DB 提供),避免由于错误的 Isaac64 密钥导致图片解密损坏 - key: isVideoPost ? (videoKey || m.key) : m.key, - encIdx: m.encIdx || m.enc_idx, - livePhoto: m.livePhoto - ? { - ...m.livePhoto, - url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), - thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false), - token: m.livePhoto.token, - // 实况照片的视频部分优先使用从 XML 提取的 Key - key: videoKey || m.livePhoto.key || m.key, - encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx - } - : undefined + // 从数据库直接删除朋友圈记录 + async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { + return wcdbService.deleteSnsPost(postId) + } + + /** + * 补全 DLL 返回的评论中缺失的 refNickname + * DLL 返回的 refCommentId 是被回复评论的 cmtid + * 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增 + */ + private fixCommentRefs(comments: any[]): any[] { + if (!comments || comments.length === 0) return [] + + // DLL 现在返回完整的评论数据(含 emojis、refNickname) + // 此处做最终的格式化和兜底补全 + const idToNickname = new Map() + comments.forEach((c, idx) => { + if (c.id) idToNickname.set(c.id, c.nickname || '') + // 兜底:按索引映射(部分旧数据 id 可能为空) + idToNickname.set(String(idx + 1), c.nickname || '') + }) + + return comments.map((c) => { + const refId = c.refCommentId + let refNickname = c.refNickname || '' + + if (refId && refId !== '0' && refId !== '' && !refNickname) { + refNickname = idToNickname.get(refId) || '' + } + + // 处理 emojis:过滤掉空的 url 和 encryptUrl + const emojis = (c.emojis || []) + .filter((e: any) => e.url || e.encryptUrl) + .map((e: any) => ({ + url: (e.url || '').replace(/&/g, '&'), + md5: e.md5 || '', + width: e.width || 0, + height: e.height || 0, + encryptUrl: e.encryptUrl ? e.encryptUrl.replace(/&/g, '&') : undefined, + aesKey: e.aesKey || undefined })) - return { - ...post, - avatarUrl: contact?.avatarUrl, - nickname: post.nickname || contact?.displayName || post.username, - media: fixedMedia - } - }) - return { ...result, timeline: enrichedTimeline } + return { + id: c.id || '', + nickname: c.nickname || '', + content: c.content || '', + refCommentId: (refId === '0') ? '' : (refId || ''), + refNickname, + emojis: emojis.length > 0 ? emojis : undefined + } + }) + } + + async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { + const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) + if (!result.success || !result.timeline || result.timeline.length === 0) return result + + // 诊断:测试 execQuery 查 content 字段 + try { + const testResult = await wcdbService.execQuery('sns', null, 'SELECT tid, CAST(content AS TEXT) as ct, typeof(content) as ctype FROM SnsTimeLine ORDER BY tid DESC LIMIT 1') + if (testResult.success && testResult.rows?.[0]) { + const r = testResult.rows[0] + console.log('[SnsService] execQuery 诊断: ctype=', r.ctype, 'ct长度=', r.ct?.length, 'ct前200=', r.ct?.substring(0, 200)) + console.log('[SnsService] ct包含CommentUserList:', r.ct?.includes('CommentUserList')) + } else { + console.log('[SnsService] execQuery 诊断失败:', testResult.error) + } + } catch (e) { + console.log('[SnsService] execQuery 诊断异常:', e) } - return result + const enrichedTimeline = result.timeline.map((post: any) => { + const contact = this.contactCache.get(post.username) + const isVideoPost = post.type === 15 + const videoKey = extractVideoKey(post.rawXml || '') + + const fixedMedia = (post.media || []).map((m: any) => ({ + url: fixSnsUrl(m.url, m.token, isVideoPost), + thumb: fixSnsUrl(m.thumb, m.token, false), + md5: m.md5, + token: m.token, + key: isVideoPost ? (videoKey || m.key) : m.key, + encIdx: m.encIdx || m.enc_idx, + livePhoto: m.livePhoto ? { + ...m.livePhoto, + url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), + thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false), + token: m.livePhoto.token, + key: videoKey || m.livePhoto.key || m.key, + encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx + } : undefined + })) + + // DLL 已返回完整评论数据(含 emojis、refNickname) + // 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析 + const dllComments: any[] = post.comments || [] + const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0) + const rawXml = post.rawXml || '' + + let finalComments: any[] + if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) { + // DLL 数据完整,直接使用 + finalComments = this.fixCommentRefs(dllComments) + } else if (rawXml) { + // 回退:从 rawXml 重新解析(兼容旧版 DLL) + const xmlComments = parseCommentsFromXml(rawXml) + finalComments = xmlComments.length > 0 ? xmlComments : this.fixCommentRefs(dllComments) + } else { + finalComments = this.fixCommentRefs(dllComments) + } + + return { + ...post, + avatarUrl: contact?.avatarUrl, + nickname: post.nickname || contact?.displayName || post.username, + media: fixedMedia, + comments: finalComments + } + }) + + return { ...result, timeline: enrichedTimeline } } async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> { @@ -857,6 +1148,316 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class } }) } + + /** 判断 buffer 是否为有效图片头 */ + private isValidImageBuffer(buf: Buffer): boolean { + if (!buf || buf.length < 12) return false + if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return true + if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return true + if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true + 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 + 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' + } + + /** 构建多种密钥派生方式 */ + 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 { } + 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 数据布局 */ + private buildGcmLayouts(encData: Buffer): { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] { + const layouts: { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] = [] + // 格式 A:GcmData 块格式 + 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) + layouts.push({ nonce, ciphertext: payload.subarray(0, payload.length - 16), tag: payload.subarray(payload.length - 16) }) + } + } + // 格式 B:尾部 [ciphertext][nonce 12B][tag 16B] + if (encData.length > 28) { + layouts.push({ + 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({ + nonce: encData.subarray(0, 12), + ciphertext: encData.subarray(12, encData.length - 16), + tag: encData.subarray(encData.length - 16) + }) + } + // 格式 D:零 nonce + if (encData.length > 16) { + layouts.push({ + 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({ + nonce: encData.subarray(0, 12), + tag: encData.subarray(12, 28), + ciphertext: encData.subarray(28) + }) + } + return layouts + } + + /** 尝试 AES-GCM 解密 */ + 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()]) + if (this.isValidImageBuffer(decrypted)) return decrypted + for (const fn of [zlib.inflateSync, zlib.gunzipSync, zlib.unzipSync]) { + try { + const d = fn(decrypted) + if (this.isValidImageBuffer(d)) return d + } catch { } + } + return decrypted + } catch { + return null + } + } + + /** + * 解密表情数据(多种算法 + 多种密钥派生) + * 移植自 ciphertalk 的逆向实现 + */ + private decryptEmojiAes(encData: Buffer, aesKey: string): 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) + + // 最高优先级:nonce-tail 格式 [ciphertext][nonce 12B][tag 16B] + 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 { 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 字节 + for (const { 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 + } + + // 其他 GCM 布局 + const layouts = this.buildGcmLayouts(encData) + for (const layout of layouts) { + for (const { 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 { key } of keyTries) { + if (key.length !== 16) continue + // CBC: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:前 16 字节作为 IV + if (encData.length > 32) { + try { + const iv = encData.subarray(0, 16) + const dec = crypto.createDecipheriv('aes-128-cbc', key, iv) + dec.setAutoPadding(true) + const result = Buffer.concat([dec.update(encData.subarray(16)), dec.final()]) + if (this.isValidImageBuffer(result)) return result + } 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 doDownloadRaw(targetUrl: string, cacheKey: string, cacheDir: string): Promise { + return new Promise((resolve) => { + try { + const fs = require('fs') + const https = require('https') + const http = require('http') + let fixedUrl = targetUrl.replace(/&/g, '&') + 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 MicroMessenger/7.0.20.1781(0x67001431)', + '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 } + const ext = this.isValidImageBuffer(buffer) ? this.getImageExtFromBuffer(buffer) : '.bin' + const filePath = join(cacheDir, `${cacheKey}${ext}`) + try { + 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) } + }) + } + + /** + * 下载朋友圈评论中的表情包(多种解密算法,移植自 ciphertalk) + */ + async downloadSnsEmoji(url: string, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; error?: string }> { + if (!url && !encryptUrl) return { success: false, error: 'url 不能为空' } + + const fs = require('fs') + const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex') + const cachePath = this.configService.getCacheBasePath() + const emojiDir = join(cachePath, 'sns_emoji_cache') + if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true }) + + // 检查本地缓存 + for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) { + const filePath = join(emojiDir, `${cacheKey}${ext}`) + if (existsSync(filePath)) return { success: true, localPath: filePath } + } + + // 保存解密后的图片 + const saveDecrypted = (buf: Buffer): { success: boolean; localPath?: string } => { + const ext = this.isValidImageBuffer(buf) ? this.getImageExtFromBuffer(buf) : '.gif' + const filePath = join(emojiDir, `${cacheKey}${ext}`) + try { fs.writeFileSync(filePath, buf); return { success: true, localPath: filePath } } + catch { return { success: false } } + } + + // 1. 优先:encryptUrl + aesKey + if (encryptUrl && aesKey) { + const encResult = await this.doDownloadRaw(encryptUrl, cacheKey + '_enc', emojiDir) + if (encResult) { + const encData = fs.readFileSync(encResult) + if (this.isValidImageBuffer(encData)) { + const ext = this.getImageExtFromBuffer(encData) + const filePath = join(emojiDir, `${cacheKey}${ext}`) + fs.writeFileSync(filePath, encData) + try { fs.unlinkSync(encResult) } catch { } + return { success: true, localPath: filePath } + } + const decrypted = this.decryptEmojiAes(encData, aesKey) + if (decrypted) { + try { fs.unlinkSync(encResult) } catch { } + return saveDecrypted(decrypted) + } + try { fs.unlinkSync(encResult) } catch { } + } + } + + // 2. 直接下载 url + if (url) { + const result = await this.doDownloadRaw(url, cacheKey, emojiDir) + if (result) { + const buf = fs.readFileSync(result) + if (this.isValidImageBuffer(buf)) return { success: true, localPath: result } + // 用 aesKey 解密 + if (aesKey) { + const decrypted = this.decryptEmojiAes(buf, aesKey) + if (decrypted) { + try { fs.unlinkSync(result) } catch { } + return saveDecrypted(decrypted) + } + } + try { fs.unlinkSync(result) } catch { } + } + } + + return { success: false, error: '下载表情包失败' } + } } export const snsService = new SnsService() diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index ddbc0c5..40eaa71 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -1,5 +1,6 @@ -import { join } from 'path' -import { existsSync, readdirSync, statSync, readFileSync } from 'fs' +import { join } from 'path' +import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs' +import { app } from 'electron' import { ConfigService } from './config' import Database from 'better-sqlite3' import { wcdbService } from './wcdbService' @@ -18,6 +19,16 @@ class VideoService { this.configService = new ConfigService() } + private log(message: string, meta?: Record): void { + try { + const timestamp = new Date().toISOString() + const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' + const logDir = join(app.getPath('userData'), 'logs') + if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }) + appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8') + } catch {} + } + /** * 获取数据库根目录 */ @@ -36,7 +47,7 @@ class VideoService { * 获取缓存目录(解密后的数据库存放位置) */ private getCachePath(): string { - return this.configService.get('cachePath') || '' + return this.configService.getCacheBasePath() } /** @@ -69,10 +80,12 @@ class VideoService { const wxid = this.getMyWxid() const cleanedWxid = this.cleanWxid(wxid) - console.log('[VideoService] queryVideoFileName called with MD5:', md5) - console.log('[VideoService] cachePath:', cachePath, 'dbPath:', dbPath, 'wxid:', wxid, 'cleanedWxid:', cleanedWxid) + this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath }) - if (!wxid) return undefined + if (!wxid) { + this.log('queryVideoFileName: wxid 为空') + return undefined + } // 方法1:优先在 cachePath 下查找解密后的 hardlink.db if (cachePath) { @@ -86,23 +99,24 @@ class VideoService { for (const p of cacheDbPaths) { if (existsSync(p)) { - console.log('[VideoService] Found decrypted hardlink.db at:', p) try { + this.log('尝试缓存 hardlink.db', { path: p }) const db = new Database(p, { readonly: true }) const row = db.prepare(` - SELECT file_name, md5 FROM video_hardlink_info_v4 - WHERE md5 = ? + SELECT file_name, md5 FROM video_hardlink_info_v4 + WHERE md5 = ? LIMIT 1 `).get(md5) as { file_name: string; md5: string } | undefined db.close() if (row?.file_name) { const realMd5 = row.file_name.replace(/\.[^.]+$/, '') - console.log('[VideoService] Found video filename via cache:', realMd5) + this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 }) return realMd5 } + this.log('缓存 hardlink.db 未命中', { path: p }) } catch (e) { - console.log('[VideoService] Failed to query cached hardlink.db:', e) + this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) }) } } } @@ -110,41 +124,45 @@ class VideoService { // 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db if (dbPath) { - const encryptedDbPaths = [ - join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), - join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') - ] + const dbPathLower = dbPath.toLowerCase() + const wxidLower = wxid.toLowerCase() + const cleanedWxidLower = cleanedWxid.toLowerCase() + const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) + + const encryptedDbPaths: string[] = [] + if (dbPathContainsWxid) { + encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')) + } else { + encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db')) + encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')) + } for (const p of encryptedDbPaths) { if (existsSync(p)) { - console.log('[VideoService] Found encrypted hardlink.db at:', p) try { + this.log('尝试加密 hardlink.db', { path: p }) const escapedMd5 = md5.replace(/'/g, "''") - - // 用 md5 字段查询,获取 file_name const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` - console.log('[VideoService] Query SQL:', sql) - const result = await wcdbService.execQuery('media', p, sql) - console.log('[VideoService] Query result:', result) if (result.success && result.rows && result.rows.length > 0) { const row = result.rows[0] if (row?.file_name) { - // 提取不带扩展名的文件名作为实际视频 MD5 const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '') - console.log('[VideoService] Found video filename:', realMd5) + this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 }) return realMd5 } } + this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) }) } catch (e) { - console.log('[VideoService] Failed to query encrypted hardlink.db via wcdbService:', e) + this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) }) } + } else { + this.log('加密 hardlink.db 不存在', { path: p }) } } } - - console.log('[VideoService] No matching video found in hardlink.db') + this.log('queryVideoFileName: 所有方法均未找到', { md5 }) return undefined } @@ -167,57 +185,61 @@ class VideoService { * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg */ async getVideoInfo(videoMd5: string): Promise { - console.log('[VideoService] getVideoInfo called with MD5:', videoMd5) - const dbPath = this.getDbPath() const wxid = this.getMyWxid() - console.log('[VideoService] Config - dbPath:', dbPath, 'wxid:', wxid) + this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid }) if (!dbPath || !wxid || !videoMd5) { - console.log('[VideoService] Missing required params') + this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 }) return { exists: false } } // 先尝试从数据库查询真正的视频文件名 const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 - console.log('[VideoService] Real video MD5:', realVideoMd5) + this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 }) - const videoBaseDir = join(dbPath, wxid, 'msg', 'video') - console.log('[VideoService] Video base dir:', videoBaseDir) + // 检查 dbPath 是否已经包含 wxid,避免重复拼接 + const dbPathLower = dbPath.toLowerCase() + const wxidLower = wxid.toLowerCase() + const cleanedWxid = this.cleanWxid(wxid) + + let videoBaseDir: string + if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) { + videoBaseDir = join(dbPath, 'msg', 'video') + } else { + videoBaseDir = join(dbPath, wxid, 'msg', 'video') + } + + this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) }) if (!existsSync(videoBaseDir)) { - console.log('[VideoService] Video base dir does not exist') + this.log('getVideoInfo: videoBaseDir 不存在') return { exists: false } } // 遍历年月目录查找视频文件 try { const allDirs = readdirSync(videoBaseDir) - console.log('[VideoService] Found year-month dirs:', allDirs) - - // 支持多种目录格式: YYYY-MM, YYYYMM, 或其他 const yearMonthDirs = allDirs .filter(dir => { const dirPath = join(videoBaseDir, dir) return statSync(dirPath).isDirectory() }) - .sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找 + .sort((a, b) => b.localeCompare(a)) + + this.log('扫描目录', { dirs: yearMonthDirs }) for (const yearMonth of yearMonthDirs) { const dirPath = join(videoBaseDir, yearMonth) - const videoPath = join(dirPath, `${realVideoMd5}.mp4`) - const coverPath = join(dirPath, `${realVideoMd5}.jpg`) - const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) - console.log('[VideoService] Checking:', videoPath) - - // 检查视频文件是否存在 if (existsSync(videoPath)) { - console.log('[VideoService] Video file found!') + this.log('找到视频', { videoPath }) + const coverPath = join(dirPath, `${realVideoMd5}.jpg`) + const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) return { - videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取 + videoUrl: videoPath, coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), exists: true @@ -225,11 +247,17 @@ class VideoService { } } - console.log('[VideoService] Video file not found in any directory') + // 没找到,列出第一个目录里的文件帮助排查 + if (yearMonthDirs.length > 0) { + const firstDir = join(videoBaseDir, yearMonthDirs[0]) + const files = readdirSync(firstDir).filter(f => f.endsWith('.mp4')).slice(0, 5) + this.log('未找到视频,最新目录样本', { dir: yearMonthDirs[0], sampleFiles: files, lookingFor: `${realVideoMd5}.mp4` }) + } } catch (e) { - console.error('[VideoService] Error searching for video:', e) + this.log('getVideoInfo 遍历出错', { error: String(e) }) } + this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 }) return { exists: false } } @@ -237,10 +265,8 @@ class VideoService { * 根据消息内容解析视频MD5 */ parseVideoMd5(content: string): string | undefined { - console.log('[VideoService] parseVideoMd5 called, content length:', content?.length) // 打印前500字符看看 XML 结构 - console.log('[VideoService] XML preview:', content?.substring(0, 500)) if (!content) return undefined @@ -252,7 +278,6 @@ class VideoService { while ((match = md5Regex.exec(content)) !== null) { allMd5s.push(`${match[0]}`) } - console.log('[VideoService] All MD5 attributes found:', allMd5s) // 提取 md5(用于查询 hardlink.db) // 注意:不是 rawmd5,rawmd5 是另一个值 @@ -261,7 +286,6 @@ class VideoService { // 尝试从videomsg标签中提取md5 const videoMsgMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) if (videoMsgMatch) { - console.log('[VideoService] Found MD5 via videomsg:', videoMsgMatch[1]) return videoMsgMatch[1].toLowerCase() } @@ -273,11 +297,8 @@ class VideoService { const md5Match = /([a-fA-F0-9]+)<\/md5>/i.exec(content) if (md5Match) { - console.log('[VideoService] Found MD5 via tag:', md5Match[1]) return md5Match[1].toLowerCase() } - - console.log('[VideoService] No MD5 found in content') } catch (e) { console.error('[VideoService] 解析视频MD5失败:', e) } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 89b5039..1c47b4c 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -3,6 +3,48 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileS // DLL 初始化错误信息,用于帮助用户诊断问题 let lastDllInitError: string | null = null + +/** + * 解析 extra_buffer(protobuf)中的免打扰状态 + * - field 12 (tag 0x60): 值非0 = 免打扰 + * 折叠状态通过 contact.flag & 0x10000000 判断 + */ +function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } { + if (!raw) return { isMuted: false } + // execQuery 返回的 BLOB 列是十六进制字符串,需要先解码 + const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw + if (buf.length === 0) return { isMuted: false } + let isMuted = false + let i = 0 + const len = buf.length + + const readVarint = (): number => { + let result = 0, shift = 0 + while (i < len) { + const b = buf[i++] + result |= (b & 0x7f) << shift + shift += 7 + if (!(b & 0x80)) break + } + return result + } + + while (i < len) { + const tag = readVarint() + const fieldNum = tag >>> 3 + const wireType = tag & 0x07 + if (wireType === 0) { + const val = readVarint() + if (fieldNum === 12 && val !== 0) isMuted = true + } else if (wireType === 2) { + const sz = readVarint() + i += sz + } else if (wireType === 5) { i += 4 + } else if (wireType === 1) { i += 8 + } else { break } + } + return { isMuted } +} export function getLastDllInitError(): string | null { return lastDllInitError } @@ -41,6 +83,7 @@ export class WcdbCore { private wcdbGetMessageTables: any = null private wcdbGetMessageMeta: any = null private wcdbGetContact: any = null + private wcdbGetContactStatus: any = null private wcdbGetMessageTableStats: any = null private wcdbGetAggregateStats: any = null private wcdbGetAvailableYears: any = null @@ -63,6 +106,10 @@ export class WcdbCore { private wcdbGetVoiceData: any = null private wcdbGetSnsTimeline: any = null private wcdbGetSnsAnnualStats: any = null + private wcdbInstallSnsBlockDeleteTrigger: any = null + private wcdbUninstallSnsBlockDeleteTrigger: any = null + private wcdbCheckSnsBlockDeleteTrigger: any = null + private wcdbDeleteSnsPost: any = null private wcdbVerifyUser: any = null private wcdbStartMonitorPipe: any = null private wcdbStopMonitorPipe: any = null @@ -483,6 +530,13 @@ export class WcdbCore { // wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json) this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)') + // wcdb_status wcdb_get_contact_status(wcdb_handle handle, const char* usernames_json, char** out_json) + try { + this.wcdbGetContactStatus = this.lib.func('int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetContactStatus = null + } + // wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json) this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)') @@ -600,6 +654,34 @@ export class WcdbCore { this.wcdbGetSnsAnnualStats = null } + // wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error) + try { + this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)') + } catch { + this.wcdbInstallSnsBlockDeleteTrigger = null + } + + // wcdb_status wcdb_uninstall_sns_block_delete_trigger(wcdb_handle handle, char** out_error) + try { + this.wcdbUninstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_uninstall_sns_block_delete_trigger(int64 handle, _Out_ void** outError)') + } catch { + this.wcdbUninstallSnsBlockDeleteTrigger = null + } + + // wcdb_status wcdb_check_sns_block_delete_trigger(wcdb_handle handle, int32_t* out_installed) + try { + this.wcdbCheckSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_check_sns_block_delete_trigger(int64 handle, _Out_ int32* outInstalled)') + } catch { + this.wcdbCheckSnsBlockDeleteTrigger = null + } + + // wcdb_status wcdb_delete_sns_post(wcdb_handle handle, const char* post_id, char** out_error) + try { + this.wcdbDeleteSnsPost = this.lib.func('int32 wcdb_delete_sns_post(int64 handle, const char* postId, _Out_ void** outError)') + } catch { + this.wcdbDeleteSnsPost = null + } + // Named pipe IPC for monitoring (replaces callback) try { this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()') @@ -1338,6 +1420,36 @@ export class WcdbCore { } } + async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + // 分批查询,避免 SQL 过长(execQuery 不支持参数绑定,直接拼 SQL) + const BATCH = 200 + const map: Record = {} + for (let i = 0; i < usernames.length; i += BATCH) { + const batch = usernames.slice(i, i + BATCH) + const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',') + const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})` + const result = await this.execQuery('contact', null, sql) + if (!result.success || !result.rows) continue + for (const row of result.rows) { + const uname: string = row.username + // 折叠:flag bit 28 (0x10000000) + const flag = parseInt(row.flag ?? '0', 10) + const isFolded = (flag & 0x10000000) !== 0 + // 免打扰:extra_buffer field 12 非0 + const { isMuted } = parseExtraBuffer(row.extra_buffer) + map[uname] = { isFolded, isMuted } + } + } + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } @@ -1813,6 +1925,94 @@ export class WcdbCore { return { success: false, error: String(e) } } } + /** + * 为朋友圈安装删除 + */ + async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + try { + const outPtr = [null] + const status = this.wcdbInstallSnsBlockDeleteTrigger(this.handle, outPtr) + let msg = '' + if (outPtr[0]) { + try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + if (status === 1) { + // DLL 返回 1 表示已安装 + return { success: true, alreadyInstalled: true } + } + if (status !== 0) { + return { success: false, error: msg || `DLL error ${status}` } + } + return { success: true, alreadyInstalled: false } + } catch (e) { + return { success: false, error: String(e) } + } + } + + /** + * 关闭朋友圈删除拦截 + */ + async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + try { + const outPtr = [null] + const status = this.wcdbUninstallSnsBlockDeleteTrigger(this.handle, outPtr) + let msg = '' + if (outPtr[0]) { + try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + if (status !== 0) { + return { success: false, error: msg || `DLL error ${status}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + + /** + * 查询朋友圈删除拦截是否已安装 + */ + async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + try { + const outInstalled = [0] + const status = this.wcdbCheckSnsBlockDeleteTrigger(this.handle, outInstalled) + if (status !== 0) { + return { success: false, error: `DLL error ${status}` } + } + return { success: true, installed: outInstalled[0] === 1 } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前 DLL 版本不支持此功能' } + try { + const outPtr = [null] + const status = this.wcdbDeleteSnsPost(this.handle, postId, outPtr) + let msg = '' + if (outPtr[0]) { + try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + if (status !== 0) { + return { success: false, error: msg || `DLL error ${status}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index da1037d..b8834f6 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -290,6 +290,13 @@ export class WcdbService { return this.callWorker('getContact', { username }) } + /** + * 批量获取联系人 extra_buffer 状态(isFolded/isMuted) + */ + async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + return this.callWorker('getContactStatus', { usernames }) + } + /** * 获取聚合统计数据 */ @@ -416,6 +423,34 @@ export class WcdbService { return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp }) } + /** + * 安装朋友圈删除拦截 + */ + async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { + return this.callWorker('installSnsBlockDeleteTrigger') + } + + /** + * 卸载朋友圈删除拦截 + */ + async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> { + return this.callWorker('uninstallSnsBlockDeleteTrigger') + } + + /** + * 查询朋友圈删除拦截是否已安装 + */ + async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> { + return this.callWorker('checkSnsBlockDeleteTrigger') + } + + /** + * 从数据库直接删除朋友圈记录 + */ + async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { + return this.callWorker('deleteSnsPost', { postId }) + } + /** * 获取 DLL 内部日志 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 31461a7..d95f5f6 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -87,6 +87,9 @@ if (parentPort) { case 'getContact': result = await core.getContact(payload.username) break + case 'getContactStatus': + result = await core.getContactStatus(payload.usernames) + break case 'getAggregateStats': result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp) break @@ -144,6 +147,18 @@ if (parentPort) { case 'getSnsAnnualStats': result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp) break + case 'installSnsBlockDeleteTrigger': + result = await core.installSnsBlockDeleteTrigger() + break + case 'uninstallSnsBlockDeleteTrigger': + result = await core.uninstallSnsBlockDeleteTrigger() + break + case 'checkSnsBlockDeleteTrigger': + result = await core.checkSnsBlockDeleteTrigger() + break + case 'deleteSnsPost': + result = await core.deleteSnsPost(payload.postId) + break case 'getLogs': result = await core.getLogs() break diff --git a/package-lock.json b/package-lock.json index 4ac3dc4..92d10ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2910,7 +2909,6 @@ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3057,7 +3055,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3997,7 +3994,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5107,7 +5103,6 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5295,7 +5290,6 @@ "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "2.3.0", "zrender": "5.6.1" @@ -5382,6 +5376,7 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5395,6 +5390,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5410,6 +5406,7 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5423,6 +5420,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -9152,7 +9150,6 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9162,7 +9159,6 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9597,7 +9593,6 @@ "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -10439,7 +10434,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10887,7 +10881,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10977,8 +10970,7 @@ "resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -11004,7 +10996,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 8035ca0..4dcca7d 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 8952a4b..e03975d 100644 Binary files a/resources/wx_key.dll and b/resources/wx_key.dll differ diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx index ab6fefb..d0cce60 100644 --- a/src/components/GlobalSessionMonitor.tsx +++ b/src/components/GlobalSessionMonitor.tsx @@ -97,6 +97,10 @@ export function GlobalSessionMonitor() { if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) { // 这是新消息事件 + // 免打扰、折叠群、折叠入口不弹通知 + if (newSession.isMuted || newSession.isFolded) continue + if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue + // 1. 群聊过滤自己发送的消息 if (newSession.username.includes('@chatroom')) { // 如果是自己发的消息,不弹通知 @@ -253,7 +257,8 @@ export function GlobalSessionMonitor() { const handleActiveSessionRefresh = async (sessionId: string) => { // 从 ChatPage 复制/调整的逻辑,以保持集中 const state = useChatStore.getState() - const lastMsg = state.messages[state.messages.length - 1] + const msgs = state.messages || [] + const lastMsg = msgs[msgs.length - 1] const minTime = lastMsg?.createTime || 0 try { diff --git a/src/components/NotificationToast.scss b/src/components/NotificationToast.scss index d442af7..a01ab73 100644 --- a/src/components/NotificationToast.scss +++ b/src/components/NotificationToast.scss @@ -48,18 +48,26 @@ backdrop-filter: none !important; -webkit-backdrop-filter: none !important; - // 确保背景完全不透明(通知是独立窗口,透明背景会穿透) - background: var(--bg-secondary-solid, var(--bg-secondary, #2c2c2c)); - color: var(--text-primary, #ffffff); + // 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 上的主题属性 + background: #ffffff; + color: #3d3d3d; + --text-primary: #3d3d3d; + --text-secondary: #666666; + --text-tertiary: #999999; + --border-light: rgba(0, 0, 0, 0.08); - // 浅色模式强制完全不透明白色背景 - [data-mode="light"] &, - :not([data-mode]) & { - background: #ffffff !important; + // 深色模式覆盖 + [data-mode="dark"] & { + background: var(--bg-secondary-solid, #282420); + color: var(--text-primary, #F0EEE9); + --text-primary: #F0EEE9; + --text-secondary: #b3b0aa; + --text-tertiary: #807d78; + --border-light: rgba(255, 255, 255, 0.1); } box-shadow: none !important; // NO SHADOW - border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1)); + border: 1px solid var(--border-light); display: flex; padding: 16px; diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index bf65dca..76972fb 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -1,5 +1,6 @@ -import React, { useState, useMemo } from 'react' -import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal, Trash2 } from 'lucide-react' +import React, { useState, useMemo, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react' import { SnsPost, SnsLinkCardData } from '../../types/sns' import { Avatar } from '../Avatar' import { SnsMediaGrid } from './SnsMediaGrid' @@ -178,14 +179,78 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { ) } +// 表情包内存缓存 +const emojiLocalCache = new Map() + +// 评论表情包组件 +const CommentEmoji: React.FC<{ + emoji: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string } + onPreview?: (src: string) => void +}> = ({ emoji, onPreview }) => { + const cacheKey = emoji.encryptUrl || emoji.url + const [localSrc, setLocalSrc] = useState(() => emojiLocalCache.get(cacheKey) || '') + + useEffect(() => { + if (!cacheKey) return + if (emojiLocalCache.has(cacheKey)) { + setLocalSrc(emojiLocalCache.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, '/')}` + emojiLocalCache.set(cacheKey, fileUrl) + setLocalSrc(fileUrl) + } + } catch { /* 静默失败 */ } + } + load() + return () => { cancelled = true } + }, [cacheKey]) + + if (!localSrc) return null + + return ( + emoji { e.stopPropagation(); onPreview?.(localSrc) }} + style={{ + width: Math.min(emoji.width || 24, 30), + height: Math.min(emoji.height || 24, 30), + verticalAlign: 'middle', + marginLeft: 2, + borderRadius: 4, + cursor: onPreview ? 'pointer' : 'default' + }} + /> + ) +} + interface SnsPostItemProps { post: SnsPost onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onDebug: (post: SnsPost) => void + onDelete?: (postId: string) => void } -export const SnsPostItem: React.FC = ({ post, onPreview, onDebug }) => { +export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete }) => { const [mediaDeleted, setMediaDeleted] = useState(false) + const [dbDeleted, setDbDeleted] = useState(false) + const [deleting, setDeleting] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const linkCard = buildLinkCardData(post) const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url)) const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia @@ -221,8 +286,29 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb }) } + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation() + if (deleting || dbDeleted) return + setShowDeleteConfirm(true) + } + + const handleDeleteConfirm = async () => { + setShowDeleteConfirm(false) + setDeleting(true) + try { + const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id) + if (r.success) { + setDbDeleted(true) + onDelete?.(post.id) + } + } finally { + setDeleting(false) + } + } + return ( -
+ <> +
= ({ post, onPreview, onDeb {formatTime(post.createTime)}
- {mediaDeleted && ( + {(mediaDeleted || dbDeleted) && ( 已删除 )} +
))}
@@ -298,5 +401,24 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb )}
+ + {/* 删除确认弹窗 - 用 Portal 挂到 body,避免父级 transform 影响 fixed 定位 */} + {showDeleteConfirm && createPortal( +
setShowDeleteConfirm(false)}> +
e.stopPropagation()}> +
+ +
+
删除这条记录?
+
将从本地数据库中永久删除,无法恢复。
+
+ + +
+
+
, + document.body + )} + ) } diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index d8c81b9..953341b 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -866,6 +866,73 @@ } } +// Header 双 panel 滑动动画 +.session-header-viewport { + overflow: hidden; + position: relative; + display: flex; + flex-direction: row; + width: 100%; + + .session-header-panel { + flex: 0 0 100%; + width: 100%; + display: flex; + align-items: center; + padding: 16px 16px 12px; + min-height: 56px; + transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); + } + + .main-header { + transform: translateX(0); + justify-content: space-between; + } + + .folded-header { + transform: translateX(0); + } + + &.folded { + .main-header { transform: translateX(-100%); } + .folded-header { transform: translateX(-100%); } + } +} + +.folded-view-header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + + .back-btn { + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + flex-shrink: 0; + + &:hover { + background: var(--bg-hover); + } + } + + .folded-view-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } +} + @keyframes searchExpand { from { opacity: 0; @@ -3264,9 +3331,12 @@ // 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss) // 批量转写确认对话框 -.batch-confirm-modal { +.batch-modal-content.batch-confirm-modal { width: 480px; max-width: 90vw; + max-height: none; + overflow: visible; + overflow-y: visible; .batch-modal-header { display: flex; @@ -3403,6 +3473,74 @@ font-weight: 600; color: var(--primary-color); } + + .batch-concurrency-field { + position: relative; + + .batch-concurrency-trigger { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 9999px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + + &:hover { + border-color: var(--text-tertiary); + } + + &.open { + border-color: var(--primary); + } + + svg { + color: var(--text-tertiary); + transition: transform 0.2s; + } + + &.open svg { + transform: rotate(180deg); + } + } + + .batch-concurrency-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 180px; + background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + z-index: 100; + } + + .batch-concurrency-option { + width: 100%; + text-align: left; + padding: 8px 12px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + color: var(--primary); + font-weight: 500; + } + } + } } } @@ -3460,7 +3598,7 @@ &.btn-primary, &.batch-transcribe-start-btn { background: var(--primary-color); - color: white; + color: #000; &:hover { opacity: 0.9; @@ -3863,4 +4001,135 @@ overflow: hidden; } } +} + +// 折叠群视图 header +.folded-view-header { + display: flex; + align-items: center; + gap: 8px; + padding: 0 4px; + width: 100%; + + .back-btn { + flex-shrink: 0; + color: var(--text-secondary); + &:hover { + color: var(--text-primary); + } + } + + .folded-view-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + } +} + +// 双 panel 滑动容器 +.session-list-viewport { + flex: 1; + overflow: hidden; + position: relative; + display: flex; + flex-direction: row; + // 两个 panel 并排,宽度各 100%,通过 translateX 切换 + width: 100%; + + .session-list-panel { + flex: 0 0 100%; + width: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); + } + + // 默认:main 在视口内,folded 在右侧外 + .main-panel { + transform: translateX(0); + } + .folded-panel { + transform: translateX(0); + } + + // 切换到折叠群视图:两个 panel 同时左移 100% + &.folded { + .main-panel { + transform: translateX(-100%); + } + .folded-panel { + transform: translateX(-100%); + } + } + + .session-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + + &::-webkit-scrollbar { + width: 8px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + &:hover { + background: rgba(0, 0, 0, 0.3); + } + } + } +} + +// 免打扰标识 +.session-item { + &.muted { + .session-name { + color: var(--text-secondary); + } + } + + .session-badges { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + + .mute-icon { + color: var(--text-tertiary, #aaa); + opacity: 0.7; + } + + .unread-badge.muted { + background: var(--text-tertiary, #aaa); + box-shadow: none; + } + } +} + +// 折叠群入口样式 +.session-item.fold-entry { + background: var(--card-inner-bg, rgba(0,0,0,0.03)); + + .fold-entry-avatar { + width: 48px; + height: 48px; + border-radius: 8px; + background: var(--primary-color, #07c160); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: #fff; + } + + .session-name { + font-weight: 500; + } } \ No newline at end of file diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index ee92354..21aa488 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2 } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' @@ -178,15 +178,38 @@ const SessionItem = React.memo(function SessionItem({ onSelect: (session: ChatSession) => void formatTime: (timestamp: number) => string }) { - // 缓存格式化的时间 const timeText = useMemo(() => formatTime(session.lastTimestamp || session.sortTimestamp), [formatTime, session.lastTimestamp, session.sortTimestamp] ) + const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup') + + // 折叠入口:专属名称和图标 + if (isFoldEntry) { + return ( +
onSelect(session)} + > +
+ +
+
+
+ 折叠的群聊 +
+
+ {session.summary || ''} +
+
+
+ ) + } + return (
onSelect(session)} >
{session.summary || '暂无消息'} - {session.unreadCount > 0 && ( - - {session.unreadCount > 99 ? '99+' : session.unreadCount} - - )} +
+ {session.isMuted && } + {session.unreadCount > 0 && ( + + {session.unreadCount > 99 ? '99+' : session.unreadCount} + + )} +
) }, (prevProps, nextProps) => { - // 自定义比较:只在关键属性变化时重渲染 return ( prevProps.session.username === nextProps.session.username && prevProps.session.displayName === nextProps.session.displayName && @@ -221,6 +246,7 @@ const SessionItem = React.memo(function SessionItem({ prevProps.session.unreadCount === nextProps.session.unreadCount && prevProps.session.lastTimestamp === nextProps.session.lastTimestamp && prevProps.session.sortTimestamp === nextProps.session.sortTimestamp && + prevProps.session.isMuted === nextProps.session.isMuted && prevProps.isActive === nextProps.isActive ) }) @@ -288,6 +314,7 @@ function ChatPage(_props: ChatPageProps) { const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) + const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图 const [hasInitialMessages, setHasInitialMessages] = useState(false) const [noMessageTable, setNoMessageTable] = useState(false) const [fallbackDisplayName, setFallbackDisplayName] = useState(null) @@ -318,6 +345,8 @@ function ChatPage(_props: ChatPageProps) { const [batchImageMessages, setBatchImageMessages] = useState(null) const [batchImageDates, setBatchImageDates] = useState([]) const [batchImageSelectedDates, setBatchImageSelectedDates] = useState>(new Set()) + const [batchDecryptConcurrency, setBatchDecryptConcurrency] = useState(6) + const [showConcurrencyDropdown, setShowConcurrencyDropdown] = useState(false) // 批量删除相关状态 const [isDeleting, setIsDeleting] = useState(false) @@ -738,7 +767,7 @@ function ChatPage(_props: ChatPageProps) { setIsRefreshingMessages(true) // 找出当前已渲染消息中的最大时间戳(使用 getState 获取最新状态,避免闭包过时导致重复) - const currentMessages = useChatStore.getState().messages + const currentMessages = useChatStore.getState().messages || [] const lastMsg = currentMessages[currentMessages.length - 1] const minTime = lastMsg?.createTime || 0 @@ -752,7 +781,7 @@ function ChatPage(_props: ChatPageProps) { if (result.success && result.messages && result.messages.length > 0) { // 过滤去重:必须对比实时的状态,防止在 handleRefreshMessages 运行期间导致的冲突 - const latestMessages = useChatStore.getState().messages + const latestMessages = useChatStore.getState().messages || [] const existingKeys = new Set(latestMessages.map(getMessageKey)) const newOnes = result.messages.filter(m => !existingKeys.has(getMessageKey(m))) @@ -793,7 +822,7 @@ function ChatPage(_props: ChatPageProps) { return } // 使用实时状态进行去重对比 - const latestMessages = useChatStore.getState().messages + const latestMessages = useChatStore.getState().messages || [] const existing = new Set(latestMessages.map(getMessageKey)) const lastMsg = latestMessages[latestMessages.length - 1] const lastTime = lastMsg?.createTime ?? 0 @@ -995,6 +1024,11 @@ function ChatPage(_props: ChatPageProps) { // 选择会话 const handleSelectSession = (session: ChatSession) => { + // 点击折叠群入口,切换到折叠群视图 + if (session.username.toLowerCase().includes('placeholder_foldgroup')) { + setFoldedView(true) + return + } if (session.username === currentSessionId) return setCurrentSession(session.username) setCurrentOffset(0) @@ -1011,27 +1045,11 @@ function ChatPage(_props: ChatPageProps) { // 搜索过滤 const handleSearch = (keyword: string) => { setSearchKeyword(keyword) - if (!Array.isArray(sessions)) { - setFilteredSessions([]) - return - } - if (!keyword.trim()) { - setFilteredSessions(sessions) - return - } - const lower = keyword.toLowerCase() - const filtered = sessions.filter(s => - s.displayName?.toLowerCase().includes(lower) || - s.username.toLowerCase().includes(lower) || - s.summary.toLowerCase().includes(lower) - ) - setFilteredSessions(filtered) } // 关闭搜索框 const handleCloseSearch = () => { setSearchKeyword('') - setFilteredSessions(Array.isArray(sessions) ? sessions : []) } // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) @@ -1303,23 +1321,40 @@ function ChatPage(_props: ChatPageProps) { searchKeywordRef.current = searchKeyword }, [searchKeyword]) + // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 useEffect(() => { if (!Array.isArray(sessions)) { setFilteredSessions([]) return } + const visible = sessions.filter(s => { + if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false + return true + }) if (!searchKeyword.trim()) { - setFilteredSessions(sessions) + setFilteredSessions(visible) return } const lower = searchKeyword.toLowerCase() - const filtered = sessions.filter(s => + setFilteredSessions(visible.filter(s => + s.displayName?.toLowerCase().includes(lower) || + s.username.toLowerCase().includes(lower) || + s.summary.toLowerCase().includes(lower) + )) + }, [sessions, searchKeyword, setFilteredSessions]) + + // 折叠群列表(独立计算,供折叠 panel 使用) + const foldedSessions = useMemo(() => { + if (!Array.isArray(sessions)) return [] + const folded = sessions.filter(s => s.isFolded) + if (!searchKeyword.trim() || !foldedView) return folded + const lower = searchKeyword.toLowerCase() + return folded.filter(s => s.displayName?.toLowerCase().includes(lower) || s.username.toLowerCase().includes(lower) || s.summary.toLowerCase().includes(lower) ) - setFilteredSessions(filtered) - }, [sessions, searchKeyword, setFilteredSessions]) + }, [sessions, searchKeyword, foldedView]) // 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算 @@ -1629,29 +1664,44 @@ function ChatPage(_props: ChatPageProps) { let successCount = 0 let failCount = 0 - for (let i = 0; i < images.length; i++) { - const img = images[i] + let completed = 0 + const concurrency = batchDecryptConcurrency + + const decryptOne = async (img: typeof images[0]) => { try { const r = await window.electronAPI.image.decrypt({ sessionId: session.username, imageMd5: img.imageMd5, imageDatName: img.imageDatName, - force: false + force: true }) if (r?.success) successCount++ else failCount++ } catch { failCount++ } - - updateDecryptProgress(i + 1, images.length) - if (i % 5 === 0) { - await new Promise(resolve => setTimeout(resolve, 0)) - } + completed++ + updateDecryptProgress(completed, images.length) } + // 并发池:同时跑 concurrency 个任务 + const pool: Promise[] = [] + for (const img of images) { + const p = decryptOne(img) + pool.push(p) + if (pool.length >= concurrency) { + await Promise.race(pool) + // 移除已完成的 + for (let j = pool.length - 1; j >= 0; j--) { + const settled = await Promise.race([pool[j].then(() => true), Promise.resolve(false)]) + if (settled) pool.splice(j, 1) + } + } + } + await Promise.all(pool) + finishDecrypt(successCount, failCount) - }, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress]) + }, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress]) const batchImageCountByDate = useMemo(() => { const map = new Map() @@ -1690,7 +1740,7 @@ function ChatPage(_props: ChatPageProps) { // Range selection with Shift key if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) { - const currentMsgs = useChatStore.getState().messages + const currentMsgs = useChatStore.getState().messages || [] const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current) const idx2 = currentMsgs.findIndex(m => m.localId === localId) @@ -1760,7 +1810,7 @@ function ChatPage(_props: ChatPageProps) { const dbPathHint = (msg as any)._db_path const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint) if (result.success) { - const currentMessages = useChatStore.getState().messages + const currentMessages = useChatStore.getState().messages || [] const newMessages = currentMessages.filter(m => m.localId !== msg.localId) useChatStore.getState().setMessages(newMessages) } else { @@ -1821,7 +1871,7 @@ function ChatPage(_props: ChatPageProps) { try { const result = await (window as any).electronAPI.chat.updateMessage(currentSessionId, editingMessage.message.localId, editingMessage.message.createTime, finalContent) if (result.success) { - const currentMessages = useChatStore.getState().messages + const currentMessages = useChatStore.getState().messages || [] const newMessages = currentMessages.map(m => { if (m.localId === editingMessage.message.localId) { return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent } @@ -1863,7 +1913,7 @@ function ChatPage(_props: ChatPageProps) { cancelDeleteRef.current = false try { - const currentMessages = useChatStore.getState().messages + const currentMessages = useChatStore.getState().messages || [] const selectedIds = Array.from(selectedMessages) const deletedIds = new Set() @@ -1887,7 +1937,7 @@ function ChatPage(_props: ChatPageProps) { setDeleteProgress({ current: i + 1, total: selectedIds.length }) } - const finalMessages = useChatStore.getState().messages.filter(m => !deletedIds.has(m.localId)) + const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedIds.has(m.localId)) useChatStore.getState().setMessages(finalMessages) setIsSelectionMode(false) @@ -1984,26 +2034,41 @@ function ChatPage(_props: ChatPageProps) { ref={sidebarRef} style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }} > -
-
-
- - handleSearch(e.target.value)} - /> - {searchKeyword && ( - - )} +
+ {/* 普通 header */} +
+
+
+ + handleSearch(e.target.value)} + /> + {searchKeyword && ( + + )} +
+ +
+
+ {/* 折叠群 header */} +
+
+ + + + 折叠的群聊 +
-
@@ -2018,7 +2083,6 @@ function ChatPage(_props: ChatPageProps) { {/* ... (previous content) ... */} {isLoadingSessions ? (
- {/* ... (skeleton items) ... */} {[1, 2, 3, 4, 5].map(i => (
@@ -2029,36 +2093,65 @@ function ChatPage(_props: ChatPageProps) {
))}
- ) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( -
{ - isScrollingRef.current = true - if (sessionScrollTimeoutRef.current) { - clearTimeout(sessionScrollTimeoutRef.current) - } - sessionScrollTimeoutRef.current = window.setTimeout(() => { - isScrollingRef.current = false - sessionScrollTimeoutRef.current = null - }, 200) - }} - > - {filteredSessions.map(session => ( - - ))} -
) : ( -
- -

暂无会话

-

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

+
+ {/* 普通会话列表 */} +
+ {Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( +
{ + isScrollingRef.current = true + if (sessionScrollTimeoutRef.current) { + clearTimeout(sessionScrollTimeoutRef.current) + } + sessionScrollTimeoutRef.current = window.setTimeout(() => { + isScrollingRef.current = false + sessionScrollTimeoutRef.current = null + }, 200) + }} + > + {filteredSessions.map(session => ( + + ))} +
+ ) : ( +
+ +

暂无会话

+

检查你的数据库配置

+
+ )} +
+ + {/* 折叠群列表 */} +
+ {foldedSessions.length > 0 ? ( +
+ {foldedSessions.map(session => ( + + ))} +
+ ) : ( +
+ +

没有折叠的群聊

+
+ )} +
)} @@ -2236,7 +2329,7 @@ function ChatPage(_props: ChatPageProps) {
)} - {messages.map((msg, index) => { + {(messages || []).map((msg, index) => { const prevMsg = index > 0 ? messages[index - 1] : undefined const showDateDivider = shouldShowDateDivider(msg, prevMsg) @@ -2547,6 +2640,39 @@ function ChatPage(_props: ChatPageProps) { 已选: {batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片
+
+ 并发数: +
+ + {showConcurrencyDropdown && ( +
+ {[ + { value: 1, label: '1(最慢,最稳)' }, + { value: 3, label: '3' }, + { value: 6, label: '6(推荐)' }, + { value: 10, label: '10' }, + { value: 20, label: '20(最快,可能卡顿)' }, + ].map(opt => ( + + ))} +
+ )} +
+
@@ -3540,12 +3666,13 @@ function MessageBubble({ const requestVideoInfo = useCallback(async () => { if (!videoMd5 || videoLoadingRef.current) return - videoLoadingRef.current = true - setVideoLoading(true) - try { - const result = await window.electronAPI.video.getVideoInfo(videoMd5) - if (result && result.success && result.exists) { - setVideoInfo({ exists: result.exists, + videoLoadingRef.current = true + setVideoLoading(true) + try { + const result = await window.electronAPI.video.getVideoInfo(videoMd5) + if (result && result.success && result.exists) { + setVideoInfo({ + exists: result.exists, videoUrl: result.videoUrl, coverUrl: result.coverUrl, thumbUrl: result.thumbUrl diff --git a/src/pages/NotificationWindow.tsx b/src/pages/NotificationWindow.tsx index 2e9acd0..deb6616 100644 --- a/src/pages/NotificationWindow.tsx +++ b/src/pages/NotificationWindow.tsx @@ -1,11 +1,9 @@ import { useEffect, useState, useRef } from 'react' import { NotificationToast, type NotificationData } from '../components/NotificationToast' -import { useThemeStore } from '../stores/themeStore' import '../components/NotificationToast.scss' import './NotificationWindow.scss' export default function NotificationWindow() { - const { currentTheme, themeMode } = useThemeStore() const [notification, setNotification] = useState(null) const [prevNotification, setPrevNotification] = useState(null) @@ -19,12 +17,6 @@ export default function NotificationWindow() { const notificationRef = useRef(null) - // 应用主题到通知窗口 - useEffect(() => { - document.documentElement.setAttribute('data-theme', currentTheme) - document.documentElement.setAttribute('data-mode', themeMode) - }, [currentTheme, themeMode]) - useEffect(() => { notificationRef.current = notification }, [notification]) diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index eb54f95..a27d74d 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -2172,4 +2172,71 @@ width: 100%; margin-top: 12px; } +} + +.brute-force-progress { + margin-top: 12px; + padding: 14px 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 12px; + animation: slideUp 0.3s ease; + + .status-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + + .status-text { + font-size: 13px; + color: var(--text-primary); + font-weight: 500; + margin: 0; + // 增加文字呼吸灯效果,表明正在运行 + animation: pulse 2s ease-in-out infinite; + } + + .percent { + font-size: 14px; + color: var(--primary); + font-weight: 700; + font-family: var(--font-mono); + } + } + + .progress-bar-container { + width: 100%; + height: 8px; + background: var(--bg-primary); + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--border-color); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05); + + .fill { + height: 100%; + background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 60%, white) 100%); + border-radius: 4px; + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + + // 流光扫过的高亮特效 + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.3), + transparent + ); + animation: progress-shimmer 1.5s infinite linear; + } + } + } } \ No newline at end of file diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 4361e33..154d528 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -82,6 +82,8 @@ function SettingsPage() { const exportExcelColumnsDropdownRef = useRef(null) const exportConcurrencyDropdownRef = useRef(null) const [cachePath, setCachePath] = useState('') + const [imageKeyProgress, setImageKeyProgress] = useState(0) + const [imageKeyPercent, setImageKeyPercent] = useState(null) const [logEnabled, setLogEnabled] = useState(false) const [whisperModelName, setWhisperModelName] = useState('base') @@ -222,8 +224,28 @@ function SettingsPage() { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { setDbKeyStatus(payload.message) }) - const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => { - setImageKeyStatus(payload.message) + + const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => { + let msg = payload.message; + let pct = payload.percent; + + // 如果后端没有显式传 percent,则用正则从字符串中提取如 "(12.5%)" + if (pct === undefined) { + const match = msg.match(/\(([\d.]+)%\)/); + if (match) { + pct = parseFloat(match[1]); + // 将百分比从文本中剥离,让 UI 更清爽 + msg = msg.replace(/\s*\([\d.]+%\)/, ''); + } + } + + setImageKeyStatus(msg); + if (pct !== undefined) { + setImageKeyPercent(pct); + } else if (msg.includes('启动多核') || msg.includes('定位') || msg.includes('准备')) { + // 预热阶段 + setImageKeyPercent(0); + } }) return () => { removeDb?.() @@ -745,15 +767,18 @@ function SettingsPage() { } const handleAutoGetImageKey = async () => { - if (isFetchingImageKey) return + if (isFetchingImageKey) return; if (!dbPath) { - showMessage('请先选择数据库目录', false) - return + showMessage('请先选择数据库目录', false); + return; } - setIsFetchingImageKey(true) - setImageKeyStatus('正在准备获取图片密钥...') + setIsFetchingImageKey(true); + setImageKeyPercent(0) + setImageKeyStatus('正在初始化...'); + setImageKeyProgress(0); // 重置进度 + try { - const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath + const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath; const result = await window.electronAPI.key.autoGetImageKey(accountPath) if (result.success && result.aesKey) { if (typeof result.xorKey === 'number') { @@ -1351,8 +1376,21 @@ function SettingsPage() { - {imageKeyStatus &&
{imageKeyStatus}
} - {isFetchingImageKey &&
正在扫描内存,请稍候...
} + {isFetchingImageKey ? ( +
+
+ {imageKeyStatus || '正在启动...'} + {imageKeyPercent !== null && {imageKeyPercent.toFixed(1)}%} +
+ {imageKeyPercent !== null && ( +
+
+
+ )} +
+ ) : ( + imageKeyStatus &&
{imageKeyStatus}
+ )}
@@ -2075,8 +2113,8 @@ function SettingsPage() { { isLockMode ? '已开启' : - authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' : - '未开启 — 请设置密码以开启' + authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' : + '未开启 — 请设置密码以开启' }
{authEnabled && !showDisableLockInput && ( diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index a88f842..e9620ae 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -190,6 +190,32 @@ background: var(--bg-tertiary); border-color: var(--text-secondary); } + + &.delete-btn:hover { + color: #ff4d4f; + border-color: rgba(255, 77, 79, 0.4); + background: rgba(255, 77, 79, 0.08); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + + .post-protected-badge { + display: flex; + align-items: center; + gap: 3px; + opacity: 0; + transition: opacity 0.2s; + color: var(--color-success, #4caf50); + font-size: 11px; + font-weight: 500; + padding: 3px 7px; + border-radius: 5px; + background: rgba(76, 175, 80, 0.08); + border: 1px solid rgba(76, 175, 80, 0.2); } } @@ -197,6 +223,258 @@ opacity: 1; } +.sns-post-item:hover .post-protected-badge { + opacity: 1; +} + +// 删除确认弹窗 +.sns-confirm-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(2px); +} + +.sns-confirm-dialog { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 28px 28px 22px; + width: 300px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + + .sns-confirm-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: rgba(255, 77, 79, 0.1); + color: #ff4d4f; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 4px; + } + + .sns-confirm-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + + .sns-confirm-desc { + font-size: 13px; + color: var(--text-secondary); + text-align: center; + line-height: 1.5; + margin-bottom: 8px; + } + + .sns-confirm-actions { + display: flex; + gap: 10px; + width: 100%; + margin-top: 4px; + + button { + flex: 1; + height: 36px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border-color); + transition: all 0.15s; + } + + .sns-confirm-cancel { + background: var(--bg-tertiary); + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .sns-confirm-ok { + background: #ff4d4f; + color: #fff; + border-color: #ff4d4f; + + &:hover { + background: #ff7875; + border-color: #ff7875; + } + } + } +} + +// 朋友圈防删除插件对话框 +.sns-protect-dialog { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 16px; + width: 340px; + padding: 32px 28px 24px; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + + .sns-protect-close { + position: absolute; + top: 14px; + right: 14px; + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--text-primary); + background: var(--bg-hover); + } + } + + .sns-protect-hero { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-bottom: 20px; + } + + .sns-protect-icon-wrap { + width: 64px; + height: 64px; + border-radius: 18px; + background: var(--bg-tertiary); + color: var(--text-tertiary); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; + + &.active { + background: rgba(76, 175, 80, 0.12); + color: var(--color-success, #4caf50); + } + } + + .sns-protect-title { + font-size: 17px; + font-weight: 600; + color: var(--text-primary); + } + + .sns-protect-status-badge { + font-size: 12px; + font-weight: 500; + padding: 3px 10px; + border-radius: 20px; + + &.on { + background: rgba(76, 175, 80, 0.12); + color: var(--color-success, #4caf50); + } + + &.off { + background: var(--bg-tertiary); + color: var(--text-tertiary); + } + } + + .sns-protect-desc { + font-size: 13px; + color: var(--text-secondary); + text-align: center; + line-height: 1.6; + margin-bottom: 16px; + } + + .sns-protect-feedback { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + padding: 8px 12px; + border-radius: 8px; + width: 100%; + margin-bottom: 14px; + box-sizing: border-box; + + &.success { + background: rgba(76, 175, 80, 0.1); + color: var(--color-success, #4caf50); + } + + &.error { + background: rgba(244, 67, 54, 0.1); + color: var(--color-error, #f44336); + } + } + + .sns-protect-actions { + width: 100%; + } + + .sns-protect-btn { + width: 100%; + height: 40px; + border-radius: 10px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + transition: all 0.15s; + + &.primary { + background: var(--color-primary, #1677ff); + color: #fff; + + &:hover:not(:disabled) { + filter: brightness(1.1); + } + } + + &.danger { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-color); + + &:hover:not(:disabled) { + background: rgba(255, 77, 79, 0.08); + color: #ff4d4f; + border-color: rgba(255, 77, 79, 0.3); + } + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + .post-text { font-size: 15px; line-height: 1.6; @@ -322,6 +600,13 @@ .comment-colon { margin-right: 4px; } + + .comment-custom-emoji { + display: inline-block; + vertical-align: middle; + border-radius: 4px; + margin-left: 2px; + } } } } @@ -950,7 +1235,7 @@ display: flex; &:hover { - background: rgba(0, 0, 0, 0.05); + background: var(--bg-primary); color: var(--text-primary); } } @@ -992,7 +1277,7 @@ Export Dialog ========================================= */ .export-dialog { - background: rgba(255, 255, 255, 0.88); + background: var(--bg-secondary); border-radius: var(--sns-border-radius-lg); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); width: 480px; @@ -1028,7 +1313,7 @@ display: flex; &:hover { - background: rgba(0, 0, 0, 0.05); + background: var(--bg-primary); color: var(--text-primary); } diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 56f9e28..452931c 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react' -import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react' +import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff } from 'lucide-react' import JumpToDateDialog from '../components/JumpToDateDialog' import './SnsPage.scss' import { SnsPost } from '../types/sns' @@ -46,6 +46,12 @@ export default function SnsPage() { const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null) const [showYearMonthPicker, setShowYearMonthPicker] = useState(false) + // 触发器相关状态 + const [showTriggerDialog, setShowTriggerDialog] = useState(false) + const [triggerInstalled, setTriggerInstalled] = useState(null) + const [triggerLoading, setTriggerLoading] = useState(false) + const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + const postsContainerRef = useRef(null) const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) @@ -56,7 +62,6 @@ export default function SnsPage() { useEffect(() => { postsRef.current = posts }, [posts]) - // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 useLayoutEffect(() => { const snapshot = scrollAdjustmentRef.current; @@ -285,6 +290,25 @@ export default function SnsPage() {

朋友圈

+
@@ -426,6 +451,101 @@ export default function SnsPage() {
)} + {/* 朋友圈防删除插件对话框 */} + {showTriggerDialog && ( +
{ setShowTriggerDialog(false); setTriggerMessage(null) }}> +
e.stopPropagation()}> + + + {/* 顶部图标区 */} +
+
+ {triggerLoading + ? + : triggerInstalled + ? + : + } +
+
朋友圈防删除
+
+ {triggerLoading ? '检查中…' : triggerInstalled ? '已启用' : '未启用'} +
+
+ + {/* 说明 */} +
+ 启用后,WeFlow将拦截朋友圈删除操作
已同步的动态不会从本地数据库中消失
新的动态仍可正常同步。 +
+ + {/* 操作反馈 */} + {triggerMessage && ( +
+ {triggerMessage.type === 'success' ? : } + {triggerMessage.text} +
+ )} + + {/* 操作按钮 */} +
+ {!triggerInstalled ? ( + + ) : ( + + )} +
+
+
+ )} + {/* 导出对话框 */} {showExportDialog && (
!isExporting && setShowExportDialog(false)}> diff --git a/src/pages/WelcomePage.scss b/src/pages/WelcomePage.scss index 395b24e..3890db4 100644 --- a/src/pages/WelcomePage.scss +++ b/src/pages/WelcomePage.scss @@ -803,3 +803,79 @@ opacity: 1; } } + + +.brute-force-progress { + margin-top: 16px; + padding: 14px 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 12px; + animation: slideUp 0.3s ease; + + .status-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + + .status-text { + font-size: 13px; + color: var(--text-primary); + font-weight: 500; + margin: 0; + animation: pulse 2s ease-in-out infinite; + } + + .percent { + font-size: 14px; + color: var(--primary); + font-weight: 700; + font-family: var(--font-mono); + } + } + + .progress-bar-container { + width: 100%; + height: 8px; + background: var(--bg-primary); + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--border-color); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05); + + .fill { + height: 100%; + background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 60%, white) 100%); + border-radius: 4px; + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.3), + transparent + ); + animation: progress-shimmer 1.5s infinite linear; + } + } + } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +@keyframes progress-shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} \ No newline at end of file diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index a1e556b..9a43cef 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -48,6 +48,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const [dbKeyStatus, setDbKeyStatus] = useState('') const [imageKeyStatus, setImageKeyStatus] = useState('') const [isManualStartPrompt, setIsManualStartPrompt] = useState(false) + const [imageKeyPercent, setImageKeyPercent] = useState(null) // 安全相关 state const [enableAuth, setEnableAuth] = useState(false) @@ -111,8 +112,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { setDbKeyStatus(payload.message) }) - const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => { - setImageKeyStatus(payload.message) + const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => { + let msg = payload.message; + let pct = payload.percent; + + // 解析文本中的百分比 + if (pct === undefined) { + const match = msg.match(/\(([\d.]+)%\)/); + if (match) { + pct = parseFloat(match[1]); + msg = msg.replace(/\s*\([\d.]+%\)/, ''); + } + } + + setImageKeyStatus(msg); + if (pct !== undefined) { + setImageKeyPercent(pct); + } else if (msg.includes('启动多核') || msg.includes('定位') || msg.includes('准备')) { + setImageKeyPercent(0); + } }) return () => { removeDb?.() @@ -297,6 +315,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { } setIsFetchingImageKey(true) setError('') + setImageKeyPercent(0) setImageKeyStatus('正在准备获取图片密钥...') try { // 拼接完整的账号目录,确保 KeyService 能准确找到模板文件 @@ -752,10 +771,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
- {imageKeyStatus &&
{imageKeyStatus}
} + {isFetchingImageKey ? ( +
+
+ {imageKeyStatus || '正在启动...'} + {imageKeyPercent !== null && {imageKeyPercent.toFixed(1)}%} +
+ {imageKeyPercent !== null && ( +
+
+
+ )} +
+ ) : ( + imageKeyStatus &&
{imageKeyStatus}
+ )} +
请在微信中打开几张图片后再点击获取
)} diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index 0e166c9..3e4b6d1 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -86,15 +86,16 @@ export const useChatStore = create((set, get) => ({ if (m.localId && m.localId > 0) return `l:${m.localId}` return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}` } - const existingKeys = new Set(state.messages.map(getMsgKey)) + const currentMessages = state.messages || [] + const existingKeys = new Set(currentMessages.map(getMsgKey)) const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m))) if (filtered.length === 0) return state return { messages: prepend - ? [...filtered, ...state.messages] - : [...state.messages, ...filtered] + ? [...filtered, ...currentMessages] + : [...currentMessages, ...filtered] } }), diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index b00f3c0..ee0c0f1 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -500,7 +500,7 @@ export interface ElectronAPI { } }> likes: Array - comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }> + comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> }> rawXml?: string }> error?: string @@ -520,6 +520,11 @@ export interface ElectronAPI { onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> + installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> + uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> + checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> + deleteSnsPost: (postId: string) => Promise<{ success: boolean; error?: string }> + downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; localPath?: string; error?: string }> } http: { start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }> diff --git a/src/types/models.ts b/src/types/models.ts index b03e088..7eec446 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -12,6 +12,8 @@ export interface ChatSession { lastMsgSender?: string lastSenderDisplayName?: string selfWxid?: string // Helper field to avoid extra API calls + isFolded?: boolean // 是否已折叠进"折叠的群聊" + isMuted?: boolean // 是否开启免打扰 } // 联系人 @@ -51,6 +53,7 @@ export interface Message { imageDatName?: string emojiCdnUrl?: string emojiMd5?: string + emojiLocalPath?: string // 本地缓存路径(转发表情包无 CDN URL 时使用) voiceDurationSeconds?: number videoMd5?: string // 引用消息 diff --git a/src/types/sns.ts b/src/types/sns.ts index b909433..9193385 100644 --- a/src/types/sns.ts +++ b/src/types/sns.ts @@ -16,16 +16,27 @@ export interface SnsMedia { livePhoto?: SnsLivePhoto } +export interface SnsCommentEmoji { + url: string + md5: string + width: number + height: number + encryptUrl?: string + aesKey?: string +} + export interface SnsComment { id: string nickname: string content: string refCommentId: string refNickname?: string + emojis?: SnsCommentEmoji[] } export interface SnsPost { id: string + tid?: string // 数据库主键(雪花 ID),用于精确删除 username: string nickname: string avatarUrl?: string @@ -38,6 +49,7 @@ export interface SnsPost { rawXml?: string linkTitle?: string linkUrl?: string + isProtected?: boolean // 是否受保护(已安装时标记) } export interface SnsLinkCardData {