Compare commits

...

51 Commits

Author SHA1 Message Date
xuncha
a965890916 Merge pull request #349 from hicccc77/dev
Dev
2026-03-02 16:45:32 +08:00
xuncha
b07bbd68d7 Merge pull request #348 from xunchahaha:main
Main
2026-03-02 16:45:12 +08:00
xuncha
3d4a79aac6 修复图片解密 修复密钥获取 2026-03-02 16:44:09 +08:00
cc
e30c4cc644 Merge pull request #341 from hicccc77/dev
更新文档
2026-02-28 23:22:15 +08:00
cc
d317be3ad3 更新文档 2026-02-28 23:21:27 +08:00
cc
1b078bd2fd Merge pull request #340 from hicccc77/dev
Dev
2026-02-28 23:17:43 +08:00
cc
1d84ed1614 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-02-28 23:17:16 +08:00
cc
114476d74c 更新文档 2026-02-28 23:16:42 +08:00
xuncha
fb8663fb24 Merge pull request #338 from hicccc77/dev
Dev
2026-02-28 21:19:49 +08:00
xuncha
3a9be771b4 Merge pull request #337 from xunchahaha/dev
Dev
2026-02-28 21:19:21 +08:00
xuncha
b2ef8f5cd2 修复 2026-02-28 21:18:57 +08:00
xuncha
83d501ae9b Merge branch 'hicccc77:dev' into dev 2026-02-28 20:00:06 +08:00
xuncha
c555566c9d 修复日期跳转 2026-02-28 19:58:44 +08:00
cc
264f9a380b Merge pull request #335 from hicccc77/main
Dev
2026-02-28 19:49:06 +08:00
cc
33d5951a14 修复视频索引逻辑 2026-02-28 19:47:35 +08:00
cc
68c4e43e05 Merge pull request #334 from hicccc77/dev
Dev
2026-02-28 19:31:46 +08:00
cc
54510f1c18 Merge branch 'main' into dev 2026-02-28 19:30:57 +08:00
cc
940234c743 Merge pull request #327 from StarsUnsurpass/main
还原了原有的视频解密逻辑
2026-02-28 19:27:39 +08:00
cc
b31ab46d11 修复通知内部分组件显示异常;修复结束引导后无法正确连接后端服务的问题;优化了图片密钥的解析速度 2026-02-28 19:26:54 +08:00
ace
c359821844 fix(electron): 修复 imageDecryptService 中的中文乱码、语法错误和 TypeScript 检查错误 2026-02-28 18:22:11 +08:00
cc
d49cf08e21 优化 2026-02-28 17:56:48 +08:00
cc
0f4cd23989 Merge pull request #332 from xunchahaha/dev
Dev
2026-02-28 17:56:20 +08:00
xuncha
e12451911b 导出新增位置消息 2026-02-28 17:33:24 +08:00
xuncha
b26f8cc43c 修复批量解密图片逻辑 加快速度 2026-02-28 17:32:28 +08:00
xuncha
d63c37cd78 视频解密丰富日志 方便定位 2026-02-28 16:51:18 +08:00
xuncha
c88aa2c9d8 修复图片解密 2026-02-28 16:44:55 +08:00
xuncha
4d5c744583 修复 2026-02-28 16:28:46 +08:00
xuncha
5033c5c7b7 Merge branch 'hicccc77:dev' into dev 2026-02-28 16:23:36 +08:00
cc
5a1f2ffac7 修复报错 2026-02-28 16:11:13 +08:00
xuncha
8eecb592e6 优化图片密钥获取
feat: brute aes key within 2 minutes
2026-02-28 13:47:14 +08:00
xuncha
fb188d6aaa Merge branch 'hicccc77:dev' into dev 2026-02-28 13:19:42 +08:00
H3CoF6
0d33fe8fe4 feat: update welcome page and fix handle error 2026-02-28 05:37:19 +08:00
H3CoF6
5b3b8b5bc3 feat: add progress 2026-02-28 05:00:42 +08:00
H3CoF6
17de7f2e56 feat: first trial for brute aes_key 2026-02-28 03:07:29 +08:00
cc
03aec7a34e 支持折叠的群聊判定 2026-02-28 00:21:25 +08:00
cc
266d68be22 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-02-27 20:44:12 +08:00
cc
bfbdefe773 更新服务文件 2026-02-27 20:44:08 +08:00
ace
5e96cdb1d6 恢复了原有的视频解密逻辑 2026-02-27 16:07:27 +08:00
xuncha
19ee47ceb2 Merge branch 'dev' of https://github.com/xunchahaha/WeFlow into dev 2026-02-27 14:15:57 +08:00
xuncha
2823607146 更新文档 2026-02-27 14:15:31 +08:00
xuncha
1869abd9df Merge pull request #326 from hicccc77/revert-324-main
Revert "修复了图片解密失败的问题"
2026-02-27 14:13:34 +08:00
xuncha
f070d184ea Revert "修复了图片解密失败的问题" 2026-02-27 14:13:04 +08:00
xuncha
d59d552aae Merge pull request #325 from vipxlm/feat/media-http-and-sort-fix
feat: 支持通过本地 HTTP 访问导出媒体 https://github.com/hicccc77/WeFlow/issues/322
fix: 修复消息排序问题
2026-02-27 14:08:55 +08:00
xuncha
a370531f1d Merge pull request #324 from StarsUnsurpass/main
图片解密wxid后加上后缀 简单重写了视频解密
2026-02-27 13:50:45 +08:00
cc
9ae1b455f4 支持朋友圈防撤回;修复朋友圈回复嵌套关系错误;支持朋友圈评论表情解析;支持删除本地朋友圈记录 2026-02-27 13:40:13 +08:00
ace
ec0eb64ffd 修复了图片解密失败的问题 2026-02-27 11:12:05 +08:00
hanyu
f31886e1ab feat: 支持通过本地 HTTP 访问导出媒体并修复消息排序问题 2026-02-26 19:51:39 +08:00
cc
7365831ec1 Merge pull request #321 from hicccc77/dev
新增启动页面;修复转发表情包无法索引的问题;修复群回复中消息溢出错误;修复群消息中消息类型判定错误
2026-02-26 19:43:12 +08:00
cc
4a09b682b2 新增启动页面;修复转发表情包无法索引的问题;修复群回复中消息溢出错误;修复群消息中消息类型判定错误 2026-02-26 19:40:26 +08:00
cc
afbd52a91e Merge pull request #314 from hicccc77/dev
修复图片密钥在部分情况下无法索引到正确数据的问题
2026-02-26 11:13:45 +08:00
cc
1c6e14acb4 修复图片密钥在部分情况下无法索引到正确数据的问题 2026-02-26 11:13:16 +08:00
42 changed files with 3741 additions and 1114 deletions

1
.gitignore vendored
View File

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

View File

@@ -41,7 +41,28 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
- 年度报告与可视化概览
- 导出聊天记录为 HTML 等格式
- HTTP API 接口(供开发者集成)
- 查看完整能力清单:[详细功能](#详细功能清单)
## 快速开始
若你只想使用成品版本,可前往 Release 下载并安装。
## 详细功能清单
当前版本已支持以下能力:
| 功能模块 | 说明 |
|---------|------|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
| **年度报告** | 生成按年统计的年度报告,或跨年度的长期历史报告 |
| **双人报告** | 选择指定好友,基于双方聊天记录生成专属分析报告 |
| **消息导出** | 将微信聊天记录导出为多种格式JSON、HTML、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
| **朋友圈** | 解密朋友圈图片、视频、实况;导出朋友圈内容;拦截朋友圈的删除与隐藏操作;突破时间访问限制 |
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API便于对接外部系统、自动化脚本与二次开发 |
## HTTP API
@@ -55,13 +76,9 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
- **访问地址**`http://127.0.0.1:5031`
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
完整接口文档:[点击查看](docs/HTTP-API.md)
## 快速开始
若你只想使用成品版本,可前往 Release 下载并安装。
## 面向开发者
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:

View File

@@ -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. 获取会话列表
获取所有会话列表。

View File

@@ -82,6 +82,8 @@ let configService: ConfigService | null = null
// 协议窗口实例
let agreementWindow: BrowserWindow | null = null
let onboardingWindow: BrowserWindow | null = null
// Splash 启动窗口
let splashWindow: BrowserWindow | null = null
const keyService = new KeyService()
let mainWindowReady = false
@@ -122,9 +124,10 @@ function createWindow(options: { autoShow?: boolean } = {}) {
})
// 窗口准备好后显示
// Splash 模式下不在这里 show由启动流程统一控制
win.once('ready-to-show', () => {
mainWindowReady = true
if (autoShow || shouldShowMain) {
if (autoShow && !splashWindow) {
win.show()
}
})
@@ -250,6 +253,73 @@ function createAgreementWindow() {
return agreementWindow
}
/**
* 创建 Splash 启动窗口
* 使用纯 HTML 页面,不依赖 React确保极速显示
*/
function createSplashWindow(): BrowserWindow {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
splashWindow = new BrowserWindow({
width: 760,
height: 460,
resizable: false,
frame: false,
transparent: true,
backgroundColor: '#00000000',
hasShadow: false,
center: true,
skipTaskbar: false,
icon: iconPath,
webPreferences: {
contextIsolation: true,
nodeIntegration: false
// 不需要 preload —— 通过 executeJavaScript 单向推送进度
},
show: false
})
if (isDev) {
splashWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}splash.html`)
} else {
splashWindow.loadFile(join(__dirname, '../dist/splash.html'))
}
splashWindow.once('ready-to-show', () => {
splashWindow?.show()
})
splashWindow.on('closed', () => {
splashWindow = null
})
return splashWindow
}
/**
* 向 Splash 窗口发送进度更新
*/
function updateSplashProgress(percent: number, text: string, indeterminate = false) {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.webContents
.executeJavaScript(`updateProgress(${percent}, ${JSON.stringify(text)}, ${indeterminate})`)
.catch(() => {})
}
}
/**
* 关闭 Splash 窗口
*/
function closeSplash() {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.close()
splashWindow = null
}
}
/**
* 创建首次引导窗口
*/
@@ -1012,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)
})
// 私聊克隆
@@ -1443,10 +1533,10 @@ function registerIpcHandlers() {
})
})
ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string) => {
ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string, wxid?: string) => {
return keyService.autoGetImageKey(manualDir, (message) => {
event.sender.send('key:imageKeyStatus', { message })
})
}, wxid)
})
// HTTP API 服务
@@ -1508,26 +1598,70 @@ function checkForUpdatesOnStartup() {
}, 3000)
}
app.whenReady().then(() => {
app.whenReady().then(async () => {
// 立即创建 Splash 窗口,确保用户尽快看到反馈
createSplashWindow()
// 等待 Splash 页面加载完成后再推送进度
if (splashWindow) {
await new Promise<void>((resolve) => {
if (splashWindow!.webContents.isLoading()) {
splashWindow!.webContents.once('did-finish-load', () => resolve())
} else {
resolve()
}
})
splashWindow.webContents
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
.catch(() => {})
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
// 初始化配置服务
updateSplashProgress(5, '正在加载配置...')
configService = new ConfigService()
// 将用户主题配置推送给 Splash 窗口
if (splashWindow && !splashWindow.isDestroyed()) {
const themeId = configService.get('themeId') || 'cloud-dancer'
const themeMode = configService.get('theme') || 'system'
splashWindow.webContents
.executeJavaScript(`applyTheme(${JSON.stringify(themeId)}, ${JSON.stringify(themeMode)})`)
.catch(() => {})
}
await delay(200)
// 设置资源路径
updateSplashProgress(10, '正在初始化...')
const candidateResources = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const fallbackResources = join(process.cwd(), 'resources')
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
const userDataPath = app.getPath('userData')
await delay(200)
// 初始化数据库服务
updateSplashProgress(18, '正在初始化...')
wcdbService.setPaths(resourcesPath, userDataPath)
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
await delay(200)
// 注册 IPC 处理器
updateSplashProgress(25, '正在初始化...')
registerIpcHandlers()
await delay(200)
// 检查配置状态
const onboardingDone = configService.get('onboardingDone')
shouldShowMain = onboardingDone === true
mainWindow = createWindow({ autoShow: shouldShowMain })
if (!onboardingDone) {
createOnboardingWindow()
}
// 创建主窗口(不显示,由启动流程统一控制)
updateSplashProgress(30, '正在加载界面...')
mainWindow = createWindow({ autoShow: false })
// 解决朋友圈图片无法加载问题(添加 Referer
// 配置网络服务
session.defaultSession.webRequest.onBeforeSendHeaders(
{
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
@@ -1538,7 +1672,31 @@ app.whenReady().then(() => {
}
)
// 启动时检测更新
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
updateSplashProgress(30, '正在加载界面...', true)
await new Promise<void>((resolve) => {
if (mainWindowReady) {
resolve()
} else {
mainWindow!.once('ready-to-show', () => {
mainWindowReady = true
resolve()
})
}
})
// 加载完成,收尾
updateSplashProgress(100, '启动完成')
await new Promise((resolve) => setTimeout(resolve, 250))
closeSplash()
if (!onboardingDone) {
createOnboardingWindow()
} else {
mainWindow?.show()
}
// 启动时检测更新(不阻塞启动)
checkForUpdatesOnStartup()
app.on('activate', () => {

View File

@@ -113,7 +113,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 密钥获取
key: {
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
autoGetImageKey: (manualDir?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir),
autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid),
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
@@ -294,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 服务

View File

@@ -76,17 +76,13 @@ class AnalyticsService {
const map: Record<string, string> = {}
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<string, any>[]) {
const username = row.username || ''

View File

@@ -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 }
@@ -991,12 +1010,34 @@ class ChatService {
}
console.warn(`[ChatService] 表情包数据库未命中: md5=${msg.emojiMd5}, db=${dbPath}`)
// 数据库未命中时,尝试从本地 emoji 缓存目录查找(转发的表情包只有 md5无 CDN URL
this.findEmojiInLocalCache(msg)
} catch (e) {
console.error(`[ChatService] 恢复表情包失败: md5=${msg.emojiMd5}`, e)
}
}
/**
* 从本地 WeFlow emoji 缓存目录按 md5 查找文件
*/
private findEmojiInLocalCache(msg: Message): void {
if (!msg.emojiMd5) return
const cacheDir = this.getEmojiCacheDir()
if (!existsSync(cacheDir)) return
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
for (const ext of extensions) {
const filePath = join(cacheDir, `${msg.emojiMd5}${ext}`)
if (existsSync(filePath)) {
msg.emojiLocalPath = filePath
// 同步写入内存缓存,避免重复查找
emojiCache.set(msg.emojiMd5, filePath)
return
}
}
}
/**
* 查找 emoticon.db 路径
*/
@@ -1338,6 +1379,9 @@ class ChatService {
chatRecordList = type49Info.chatRecordList
transferPayerUsername = type49Info.transferPayerUsername
transferReceiverUsername = type49Info.transferReceiverUsername
// 引用消息appmsg type=57的 quotedContent/quotedSender
if (type49Info.quotedContent !== undefined) quotedContent = type49Info.quotedContent
if (type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
const quoteInfo = this.parseQuoteMessage(content)
quotedContent = quoteInfo.content
@@ -1381,6 +1425,8 @@ class ChatService {
chatRecordList = chatRecordList || type49Info.chatRecordList
transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername
transferReceiverUsername = transferReceiverUsername || type49Info.transferReceiverUsername
if (!quotedContent && type49Info.quotedContent !== undefined) quotedContent = type49Info.quotedContent
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
}
messages.push({
@@ -1549,7 +1595,17 @@ class ChatService {
private parseType49(content: string): string {
const title = this.extractXmlValue(content, 'title')
const type = this.extractXmlValue(content, 'type')
// 从 appmsg 直接子节点提取 type避免匹配到 refermsg 内部的 <type>
let type = ''
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
if (appmsgMatch) {
const inner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
if (typeMatch) type = typeMatch[1].trim()
}
if (!type) type = this.extractXmlValue(content, 'type')
const normalized = content.toLowerCase()
const locationLabel =
this.extractXmlAttribute(content, 'location', 'label') ||
@@ -1727,13 +1783,19 @@ class ChatService {
if (!content) return undefined
try {
// 取 md5,这是用于查询 hardlink.db 的值
const md5 =
this.extractXmlAttribute(content, 'videomsg', 'md5') ||
this.extractXmlValue(content, 'md5') ||
undefined
// 优先取 md5 属性(收到的视频)
const md5 = this.extractXmlAttribute(content, 'videomsg', 'md5')
if (md5) return md5.toLowerCase()
return md5?.toLowerCase()
// 自己发的视频没有 md5只有 rawmd5
const rawMd5 = this.extractXmlAttribute(content, 'videomsg', 'rawmd5')
if (rawMd5) return rawMd5.toLowerCase()
// 兜底:<md5> 标签
const tagMd5 = this.extractXmlValue(content, 'md5')
if (tagMd5) return tagMd5.toLowerCase()
return undefined
} catch {
return undefined
}
@@ -1964,6 +2026,8 @@ class ChatService {
*/
private parseType49Message(content: string): {
xmlType?: string
quotedContent?: string
quotedSender?: string
linkTitle?: string
linkUrl?: string
linkThumb?: string
@@ -2008,8 +2072,20 @@ class ChatService {
try {
if (!content) return {}
// 提取 appmsg 的 type
const xmlType = this.extractXmlValue(content, 'type')
// 提取 appmsg 直接子节点的 type,避免匹配到 refermsg 内部的 <type>
// 先尝试从 <appmsg>...</appmsg> 块内提取,再用正则跳过嵌套标签
let xmlType = ''
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
if (appmsgMatch) {
// 在 appmsg 内容中,找第一个 <type> 但跳过在子元素内部的(如 refermsg > type
// 策略去掉所有嵌套块refermsg、patMsg 等),再提取 type
const appmsgInner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
if (typeMatch) xmlType = typeMatch[1].trim()
}
if (!xmlType) xmlType = this.extractXmlValue(content, 'type')
if (!xmlType) return {}
const result: any = { xmlType }
@@ -2126,6 +2202,12 @@ class ChatService {
result.appMsgKind = 'transfer'
} else if (xmlType === '87') {
result.appMsgKind = 'announcement'
} else if (xmlType === '57') {
// 引用回复消息,解析 refermsg
result.appMsgKind = 'quote'
const quoteInfo = this.parseQuoteMessage(content)
result.quotedContent = quoteInfo.content
result.quotedSender = quoteInfo.sender
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
result.appMsgKind = 'official-link'
} else if (url) {
@@ -2789,15 +2871,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) {
@@ -3355,9 +3438,10 @@ class ChatService {
const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId)
if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' }
// 4. 获取解密密钥
const xorKeyRaw = this.configService.get('imageXorKey')
const aesKeyRaw = this.configService.get('imageAesKey') || msg.aesKey
// 4. 获取解密密钥(优先使用当前 wxid 对应的密钥)
const imageKeys = this.configService.getImageKeysForCurrentWxid()
const xorKeyRaw = imageKeys.xorKey
const aesKeyRaw = imageKeys.aesKey || msg.aesKey
if (!xorKeyRaw) return { success: false, error: '未配置图片 XOR 密钥,请在设置中自动获取' }

View File

@@ -637,6 +637,27 @@ export class ConfigService {
// === 工具方法 ===
/**
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置
*/
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
const wxid = this.get('myWxid')
if (wxid) {
const wxidConfigs = this.get('wxidConfigs')
const cfg = wxidConfigs?.[wxid]
if (cfg && (cfg.imageXorKey !== undefined || cfg.imageAesKey)) {
return {
xorKey: cfg.imageXorKey ?? this.get('imageXorKey'),
aesKey: cfg.imageAesKey ?? this.get('imageAesKey')
}
}
}
return {
xorKey: this.get('imageXorKey'),
aesKey: this.get('imageAesKey')
}
}
getCacheBasePath(): string {
return join(app.getPath('userData'), 'cache')
}

View File

@@ -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)

View File

@@ -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<string, string> = {
'.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<ApiExportedMedia | null> {
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
}
})

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron'
import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
@@ -11,7 +11,29 @@ import { wcdbService } from './wcdbService'
// 获取 ffmpeg-static 的路径
function getStaticFfmpegPath(): string | null {
try {
// 优先处理打包后的路径
// 方法1: 直接 require ffmpeg-static
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ffmpegStatic = require('ffmpeg-static')
if (typeof ffmpegStatic === 'string') {
// 修复:如果路径包含 app.asar打包后自动替换为 app.asar.unpacked
let fixedPath = ffmpegStatic
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
}
if (existsSync(fixedPath)) {
return fixedPath
}
}
// 方法2: 手动构建路径(开发环境)
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(devPath)) {
return devPath
}
// 方法3: 打包后的路径
if (app.isPackaged) {
const resourcesPath = process.resourcesPath
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
@@ -20,20 +42,6 @@ function getStaticFfmpegPath(): string | null {
}
}
// 方法1: 直接 require ffmpeg-static开发环境
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ffmpegStatic = require('ffmpeg-static')
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
return ffmpegStatic
}
// 方法2: 手动构建路径(开发环境备用)
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(devPath)) {
return devPath
}
return null
} catch {
return null
@@ -45,7 +53,6 @@ type DecryptResult = {
localPath?: string
error?: string
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
liveVideoPath?: string // 实况照片的视频路径
}
type HardlinkState = {
@@ -62,7 +69,6 @@ export class ImageDecryptService {
private cacheIndexed = false
private cacheIndexing: Promise<void> | null = null
private updateFlags = new Map<string, boolean>()
private noLiveSet = new Set<string>() // 已确认无 live 视频的图片路径
private logInfo(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return
@@ -118,9 +124,8 @@ export class ImageDecryptService {
} else {
this.updateFlags.delete(key)
}
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached)
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate, liveVideoPath }
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(key)
@@ -139,9 +144,8 @@ export class ImageDecryptService {
} else {
this.updateFlags.delete(key)
}
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate, liveVideoPath }
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
}
}
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
@@ -155,25 +159,13 @@ export class ImageDecryptService {
return { success: false, error: '缺少图片标识' }
}
if (payload.force) {
const hdCached = this.findCachedOutput(cacheKey, true, payload.sessionId)
if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached) && !this.isThumbnailPath(hdCached)) {
const dataUrl = this.fileToDataUrl(hdCached)
const localPath = dataUrl || this.filePathToUrl(hdCached)
const liveVideoPath = this.checkLiveVideoCache(hdCached)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb: false, liveVideoPath }
}
}
if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const dataUrl = this.fileToDataUrl(cached)
const localPath = dataUrl || this.filePathToUrl(cached)
const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, liveVideoPath }
return { success: true, localPath }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(cacheKey)
@@ -251,13 +243,14 @@ export class ImageDecryptService {
const dataUrl = this.fileToDataUrl(existing)
const localPath = dataUrl || this.filePathToUrl(existing)
const isThumb = this.isThumbnailPath(existing)
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb, liveVideoPath }
return { success: true, localPath, isThumb }
}
}
const xorKeyRaw = this.configService.get('imageXorKey') as unknown
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
const imageKeys = this.configService.getImageKeysForCurrentWxid()
const xorKeyRaw = imageKeys.xorKey
// 支持十六进制格式(如 0x53和十进制格式
let xorKey: number
if (typeof xorKeyRaw === 'number') {
@@ -274,7 +267,7 @@ export class ImageDecryptService {
return { success: false, error: '未配置图片解密密钥' }
}
const aesKeyRaw = this.configService.get('imageAesKey')
const aesKeyRaw = imageKeys.aesKey
const aesKey = this.resolveAesKey(aesKeyRaw)
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
@@ -297,14 +290,14 @@ export class ImageDecryptService {
await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length })
// 对于 hevc 格式,返回错误提示
if (finalExt === '.hevc') {
return {
success: false,
error: '此图片为微信新格式(wxgf)需要安装 ffmpeg 才能显示',
error: '此图片为微信新格式(wxgf)ffmpeg 转换失败,请检查日志',
isThumb: this.isThumbnailPath(datPath)
}
}
const isThumb = this.isThumbnailPath(datPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (!isThumb) {
@@ -313,15 +306,7 @@ export class ImageDecryptService {
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
const localPath = dataUrl || this.filePathToUrl(outputPath)
this.emitCacheResolved(payload, cacheKey, localPath)
// 检测实况照片Motion Photo
let liveVideoPath: string | undefined
if (!isThumb && (finalExt === '.jpg' || finalExt === '.jpeg')) {
const videoPath = await this.extractMotionPhotoVideo(outputPath, decrypted)
if (videoPath) liveVideoPath = this.filePathToUrl(videoPath)
}
return { success: true, localPath, isThumb, liveVideoPath }
return { success: true, localPath, isThumb }
} catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: String(e) }
@@ -357,37 +342,23 @@ export class ImageDecryptService {
* 获取解密后的缓存目录(用于查找 hardlink.db
*/
private getDecryptedCacheDir(wxid: string): string | null {
const cachePath = this.configService.get('cachePath')
if (!cachePath) return null
const cleanedWxid = this.cleanAccountDirName(wxid)
const configured = this.configService.get('cachePath')
const documentsPath = app.getPath('documents')
const baseCandidates = Array.from(new Set([
configured || '',
join(documentsPath, 'WeFlow'),
join(documentsPath, 'WeFlowData'),
this.configService.getCacheBasePath()
].filter(Boolean)))
const cacheAccountDir = join(cachePath, cleanedWxid)
for (const base of baseCandidates) {
const accountCandidates = Array.from(new Set([
join(base, wxid),
join(base, cleanedWxid),
join(base, 'databases', wxid),
join(base, 'databases', cleanedWxid)
]))
for (const accountDir of accountCandidates) {
if (existsSync(join(accountDir, 'hardlink.db'))) {
return accountDir
}
const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink')
if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) {
return hardlinkSubdir
}
}
if (existsSync(join(base, 'hardlink.db'))) {
return base
}
// 检查缓存目录下是否有 hardlink.db
if (existsSync(join(cacheAccountDir, 'hardlink.db'))) {
return cacheAccountDir
}
if (existsSync(join(cachePath, 'hardlink.db'))) {
return cachePath
}
const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink')
if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) {
return cacheHardlinkDir
}
return null
}
@@ -396,8 +367,7 @@ export class ImageDecryptService {
existsSync(join(dirPath, 'hardlink.db')) ||
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
existsSync(join(dirPath, 'msg', 'attach'))
existsSync(join(dirPath, 'FileStorage', 'Image2'))
)
}
@@ -421,7 +391,7 @@ export class ImageDecryptService {
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
@@ -435,14 +405,35 @@ export class ImageDecryptService {
const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false
this.logInfo('[ImageDecrypt] resolveDatPath', {
accountDir,
imageMd5,
imageDatName,
sessionId,
allowThumbnail,
skipResolvedCache
})
if (!skipResolvedCache) {
if (imageMd5) {
const cached = this.resolvedCache.get(imageMd5)
if (cached && existsSync(cached)) return cached
}
if (imageDatName) {
const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) return cached
}
}
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
if (imageMd5) {
const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail)
if (res) return res
}
// 2. 如果 imageDatName 看起来像 MD5也尝试快速定位
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail)
if (res) return res
}
// 优先通过 hardlink.db 查询
if (imageMd5) {
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
@@ -463,12 +454,6 @@ export class ImageDecryptService {
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false)
if (hdInDir) {
this.cacheDatPath(accountDir, imageMd5, hdInDir)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir)
return hdInDir
}
// 没找到高清图,返回 null不进行全局搜索
return null
}
@@ -486,16 +471,9 @@ export class ImageDecryptService {
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
const hdPath = this.findHdVariantInSameDir(fallbackPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
const hdInDir = await this.searchDatFileInDir(dirname(fallbackPath), imageDatName || imageMd5 || '', false)
if (hdInDir) {
this.cacheDatPath(accountDir, imageMd5, hdInDir)
this.cacheDatPath(accountDir, imageDatName, hdInDir)
return hdInDir
}
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
@@ -518,17 +496,15 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false)
if (hdInDir) {
this.cacheDatPath(accountDir, imageDatName, hdInDir)
return hdInDir
}
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
// force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
if (!allowThumbnail) {
return null
}
if (!imageDatName) return null
if (!skipResolvedCache) {
@@ -538,8 +514,6 @@ export class ImageDecryptService {
// 缓存的是缩略图,尝试找高清图
const hdPath = this.findHdVariantInSameDir(cached)
if (hdPath) return hdPath
const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false)
if (hdInDir) return hdInDir
}
}
@@ -640,9 +614,7 @@ export class ImageDecryptService {
}).catch(() => { })
}
private looksLikeMd5(value: string): boolean {
return /^[a-fA-F0-9]{16,32}$/.test(value)
}
private resolveHardlinkDbPath(accountDir: string): string | null {
const wxid = this.configService.get('myWxid')
@@ -858,7 +830,7 @@ export class ImageDecryptService {
* 包含1. 微信旧版结构 filename.substr(0, 2)/...
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
*/
private async fastProbabilisticSearch(root: string, datName: string): Promise<string | null> {
private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise<string | null> {
const { promises: fs } = require('fs')
const { join } = require('path')
@@ -894,7 +866,7 @@ export class ImageDecryptService {
} catch { }
}
// --- 策略 B: 新版 Session 哈希路径猜测 ---
// --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 ---
try {
const entries = await fs.readdir(root, { withFileTypes: true })
const sessionDirs = entries
@@ -947,7 +919,7 @@ export class ImageDecryptService {
/**
* 在同一目录下查找高清图变体
* 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat
* 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat
*/
private findHdVariantInSameDir(thumbPath: string): string | null {
try {
@@ -1029,55 +1001,6 @@ export class ImageDecryptService {
})
}
private matchesDatName(fileName: string, datName: string): boolean {
const lower = fileName.toLowerCase()
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
const normalizedBase = this.normalizeDatBase(base)
const normalizedTarget = this.normalizeDatBase(datName.toLowerCase())
if (normalizedBase === normalizedTarget) return true
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`, 'i')
if (pattern.test(lower)) return true
return lower.endsWith('.dat') && lower.includes(datName)
}
private scoreDatName(fileName: string): number {
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
return 2
}
private isThumbnailDat(fileName: string): boolean {
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
}
private hasXVariant(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
private isThumbnailPath(filePath: string): boolean {
const lower = basename(filePath).toLowerCase()
if (this.isThumbnailDat(lower)) return true
const ext = extname(lower)
const base = ext ? lower.slice(0, -ext.length) : lower
// 支持新命名 _thumb 和旧命名 _t
return base.endsWith('_t') || base.endsWith('_thumb')
}
private isHdPath(filePath: string): boolean {
const lower = basename(filePath).toLowerCase()
const ext = extname(lower)
const base = ext ? lower.slice(0, -ext.length) : lower
return base.endsWith('_hd') || base.endsWith('_h')
}
private hasImageVariantSuffix(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
private isLikelyImageDatBase(baseLower: string): boolean {
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
}
private normalizeDatBase(name: string): string {
let base = name.toLowerCase()
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
@@ -1089,27 +1012,16 @@ export class ImageDecryptService {
return base
}
private sanitizeDirName(name: string): string {
const trimmed = name.trim()
if (!trimmed) return 'unknown'
return trimmed.replace(/[<>:"/\\|?*]/g, '_')
private hasImageVariantSuffix(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
private resolveTimeDir(datPath: string): string {
const parts = datPath.split(/[\\/]+/)
for (const part of parts) {
if (/^\d{4}-\d{2}$/.test(part)) return part
}
try {
const stat = statSync(datPath)
const year = stat.mtime.getFullYear()
const month = String(stat.mtime.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
} catch {
return 'unknown-time'
}
private isLikelyImageDatBase(baseLower: string): boolean {
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
}
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
const allRoots = this.getAllCacheRoots()
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
@@ -1344,14 +1256,14 @@ export class ImageDecryptService {
private async ensureCacheIndexed(): Promise<void> {
if (this.cacheIndexed) return
if (this.cacheIndexing) return this.cacheIndexing
this.cacheIndexing = new Promise((resolve) => {
this.cacheIndexing = (async () => {
// 扫描所有可能的缓存根目录
const allRoots = this.getAllCacheRoots()
this.logInfo('开始索引缓存', { roots: allRoots.length })
for (const root of allRoots) {
try {
this.indexCacheDir(root, 3, 0) // 增加深度到3支持 sessionId/YYYY-MM 结构
this.indexCacheDir(root, 3, 0) // 增加深度到 3支持 sessionId/YYYY-MM 结构
} catch (e) {
this.logError('索引目录失败', e, { root })
}
@@ -1360,8 +1272,7 @@ export class ImageDecryptService {
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
this.cacheIndexed = true
this.cacheIndexing = null
resolve()
})
})()
return this.cacheIndexing
}
@@ -1564,14 +1475,14 @@ export class ImageDecryptService {
private bytesToInt32(bytes: Buffer): number {
if (bytes.length !== 4) {
throw new Error('需要4个字节')
throw new Error('需要 4 个字节')
}
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
}
asciiKey16(keyString: string): Buffer {
if (keyString.length < 16) {
throw new Error('AES密钥至少需要16个字符')
throw new Error('AES密钥至少需要 16 个字符')
}
return Buffer.from(keyString, 'ascii').subarray(0, 16)
}
@@ -1738,76 +1649,6 @@ export class ImageDecryptService {
return mostCommonKey
}
/**
* 检查图片对应的 live 视频缓存,返回 file:// URL 或 undefined
* 已确认无 live 的路径会被记录,下次直接跳过
*/
private checkLiveVideoCache(imagePath: string): string | undefined {
if (this.noLiveSet.has(imagePath)) return undefined
const livePath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
if (existsSync(livePath)) return this.filePathToUrl(livePath)
this.noLiveSet.add(imagePath)
return undefined
}
/**
* 检测并分离 Motion Photo实况照片
* Google Motion Photo = JPEG + MP4 拼接在一起
* 返回视频文件路径,如果不是实况照片则返回 null
*/
private async extractMotionPhotoVideo(imagePath: string, imageBuffer: Buffer): Promise<string | null> {
// 只处理 JPEG 文件
if (imageBuffer.length < 8) return null
if (imageBuffer[0] !== 0xff || imageBuffer[1] !== 0xd8) return null
// 从末尾向前搜索 MP4 ftyp 原子签名
// ftyp 原子结构: [4字节大小][ftyp(66 74 79 70)][品牌...]
// 实际起始位置在 ftyp 前4字节大小字段
const ftypSig = [0x66, 0x74, 0x79, 0x70] // 'ftyp'
let videoOffset: number | null = null
const searchEnd = Math.max(0, imageBuffer.length - 8)
for (let i = searchEnd; i > 0; i--) {
if (imageBuffer[i] === ftypSig[0] &&
imageBuffer[i + 1] === ftypSig[1] &&
imageBuffer[i + 2] === ftypSig[2] &&
imageBuffer[i + 3] === ftypSig[3]) {
// ftyp 前4字节是 box size实际 MP4 从这里开始
videoOffset = i - 4
break
}
}
// 备用:从 XMP 元数据中读取偏移量
if (videoOffset === null || videoOffset <= 0) {
try {
const text = imageBuffer.toString('latin1')
const match = text.match(/MediaDataOffset="(\d+)"/i) || text.match(/MicroVideoOffset="(\d+)"/i)
if (match) {
const offset = parseInt(match[1], 10)
if (offset > 0 && offset < imageBuffer.length) {
videoOffset = imageBuffer.length - offset
}
}
} catch { }
}
if (videoOffset === null || videoOffset <= 100) return null
// 验证视频部分确实以有效 MP4 数据开头
const videoStart = imageBuffer[videoOffset + 4] === 0x66 &&
imageBuffer[videoOffset + 5] === 0x74 &&
imageBuffer[videoOffset + 6] === 0x79 &&
imageBuffer[videoOffset + 7] === 0x70
if (!videoStart) return null
// 写出视频文件
const videoPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
const videoBuffer = imageBuffer.slice(videoOffset)
await writeFile(videoPath, videoBuffer)
return videoPath
}
/**
* 解包 wxgf 格式
* wxgf 是微信的图片格式,内部使用 HEVC 编码
@@ -1833,25 +1674,28 @@ 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 }
}
/**
* wxgf 数据中提取 HEVC NALU 裸流
* 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦
*/
private extractHevcNalu(buffer: Buffer): Buffer | null {
const nalUnits: Buffer[] = []
@@ -1914,53 +1758,133 @@ export class ImageDecryptService {
/**
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
*/
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
private async convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
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<Buffer | null> {
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)
})
})
}
private looksLikeMd5(s: string): boolean {
return /^[a-f0-9]{32}$/i.test(s)
}
private isThumbnailDat(name: string): boolean {
const lower = name.toLowerCase()
return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat')
}
private hasXVariant(base: string): boolean {
const lower = base.toLowerCase()
return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t')
}
private isHdPath(p: string): boolean {
return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h')
}
private isThumbnailPath(p: string): boolean {
const lower = p.toLowerCase()
return lower.includes('_thumb') || lower.includes('_t') || lower.includes('.t.')
}
private sanitizeDirName(s: string): string {
return s.replace(/[<>:"/\\|?*]/g, '_').trim() || 'unknown'
}
private resolveTimeDir(filePath: string): string {
try {
const stats = statSync(filePath)
const d = new Date(stats.mtime)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
} catch {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
}
// 保留原有的解密到文件方法(用于兼容)
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
const version = this.getDatVersion(inputPath)
@@ -1973,7 +1897,7 @@ export class ImageDecryptService {
decrypted = this.decryptDatV4(inputPath, xorKey, key)
} else {
if (!aesKey || aesKey.length !== 16) {
throw new Error('V4版本需要16字节AES密钥')
throw new Error('V4版本需要 16 字节 AES 密钥')
}
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
}

View File

@@ -1,10 +1,10 @@
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'
import crypto from 'crypto'
const execFileAsync = promisify(execFile)
@@ -20,6 +20,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 +30,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 +60,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 +94,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 +117,7 @@ export class KeyService {
return false
}
// 检查是否为网络路径,如果是则本地化
if (this.isNetworkPath(dllPath)) {
dllPath = this.localizeNetworkDll(dllPath)
}
@@ -156,18 +127,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 +147,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 +170,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 +195,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 +220,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 +244,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 +254,6 @@ export class KeyService {
private async getProcessExecutablePath(pid: number): Promise<string | null> {
if (!this.ensureKernel32()) return null
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
const hProcess = this.OpenProcess(0x1000, false, pid)
if (!hProcess) return null
@@ -341,33 +277,21 @@ export class KeyService {
}
private async findWeChatInstallPath(): Promise<string | null> {
// 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 +306,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 +345,6 @@ export class KeyService {
}
return null
} catch (e) {
console.error(`获取进程失败 (${imageName}):`, e)
return null
}
}
@@ -435,7 +355,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 +405,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 +490,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 +541,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<DbKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
@@ -642,7 +553,6 @@ export class KeyService {
const logs: string[] = []
// 1. Find Path
onStatus?.('正在定位微信安装路径...', 0)
let wechatPath = await this.findWeChatInstallPath()
if (!wechatPath) {
@@ -651,7 +561,6 @@ export class KeyService {
return { success: false, error: err }
}
// 2. Restart WeChat
onStatus?.('正在关闭微信以进行获取...', 0)
const closed = await this.killWeChatProcesses()
if (!closed) {
@@ -660,7 +569,6 @@ export class KeyService {
return { success: false, error: err }
}
// 3. Launch
onStatus?.('正在启动微信...', 0)
const sub = spawn(wechatPath, {
detached: true,
@@ -669,23 +577,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 +598,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 +619,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 +627,6 @@ export class KeyService {
onStatus?.(msg, level)
}
}
await new Promise((resolve) => setTimeout(resolve, 120))
}
} finally {
@@ -738,386 +638,97 @@ export class KeyService {
return { success: false, error: '获取密钥超时', logs }
}
// --- Image Key Stuff (Legacy but kept) ---
// --- Image Key (通过 DLL 从缓存目录获取 code用前端 wxid 计算密钥) ---
private isAccountDir(dirPath: string): boolean {
return (
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
)
}
private isPotentialAccountName(name: string): boolean {
const lower = name.toLowerCase()
if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) {
return false
}
if (lower.startsWith('wxid_')) return true
if (/^\d+$/.test(name) && name.length >= 6) return true
return name.length > 5
}
private listAccountDirs(rootDir: string): string[] {
try {
const entries = readdirSync(rootDir)
const high: string[] = []
const low: string[] = []
for (const entry of entries) {
const fullPath = join(rootDir, entry)
try {
if (!statSync(fullPath).isDirectory()) continue
} catch {
continue
}
if (!this.isPotentialAccountName(entry)) {
continue
}
if (this.isAccountDir(fullPath)) {
high.push(fullPath)
} else {
low.push(fullPath)
}
}
return high.length ? high.sort() : low.sort()
} catch {
return []
}
}
private normalizeExistingDir(inputPath: string): string | null {
const trimmed = inputPath.replace(/[\\\\/]+$/, '')
if (!existsSync(trimmed)) return null
try {
const stats = statSync(trimmed)
if (stats.isFile()) {
return dirname(trimmed)
}
} catch {
return null
}
return trimmed
}
private resolveAccountDirFromPath(inputPath: string): string | null {
const normalized = this.normalizeExistingDir(inputPath)
if (!normalized) return null
if (this.isAccountDir(normalized)) return normalized
const lower = normalized.toLowerCase()
if (lower.endsWith('db_storage') || lower.endsWith('filestorage') || lower.endsWith('image') || lower.endsWith('image2')) {
const parent = dirname(normalized)
if (this.isAccountDir(parent)) return parent
const grandParent = dirname(parent)
if (this.isAccountDir(grandParent)) return grandParent
}
const candidates = this.listAccountDirs(normalized)
if (candidates.length) return candidates[0]
return null
}
private resolveAccountDir(manualDir?: string): string | null {
if (manualDir) {
const resolved = this.resolveAccountDirFromPath(manualDir)
if (resolved) return resolved
}
const userProfile = process.env.USERPROFILE
if (!userProfile) return null
const roots = [
join(userProfile, 'Documents', 'xwechat_files'),
join(userProfile, 'Documents', 'WeChat Files')
]
for (const root of roots) {
if (!existsSync(root)) continue
const candidates = this.listAccountDirs(root)
if (candidates.length) return candidates[0]
}
return null
}
private findTemplateDatFiles(rootDir: string): string[] {
const files: string[] = []
const stack = [rootDir]
const maxFiles = 32
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, 16)
}
private getXorKey(templateFiles: string[]): number | null {
const counts = new Map<number, number>()
const tailSignatures = [
Buffer.from([0xFF, 0xD9]),
Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82])
]
for (const file of templateFiles) {
try {
const bytes = readFileSync(file)
for (const signature of tailSignatures) {
if (bytes.length < signature.length) continue
const tail = bytes.subarray(bytes.length - signature.length)
const xorKey = tail[0] ^ signature[0]
let valid = true
for (let i = 1; i < signature.length; i++) {
if ((tail[i] ^ xorKey) !== signature[i]) {
valid = false
break
}
}
if (valid) {
counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1)
}
}
} catch { }
}
if (!counts.size) return null
let bestKey: number | null = null
let bestCount = 0
for (const [key, count] of counts) {
if (count > bestCount) {
bestCount = count
bestKey = key
}
}
return bestKey
}
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-9AES密钥格式
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<string | null> {
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 { }
}
private cleanWxid(wxid: string): string {
// 截断到第二个下划线: wxid_g4pshorcc0r529_da6c → wxid_g4pshorcc0r529
const first = wxid.indexOf('_')
if (first === -1) return wxid
const second = wxid.indexOf('_', first + 1)
if (second === -1) return wxid
return wxid.substring(0, second)
}
async autoGetImageKey(
manualDir?: string,
onProgress?: (message: string) => void
manualDir?: string,
onProgress?: (message: string) => void,
wxidParam?: string
): Promise<ImageKeyResult> {
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: '未检测到微信进程' }
// 从任意账号提取 code 列表code 来自 kvcomm与 wxid 无关,所有账号都一样)
const accounts: any[] = parsed.accounts ?? []
if (!accounts.length || !accounts[0]?.keys?.length) {
return { success: false, error: '未找到有效的密钥码kvcomm 缓存为空)' }
}
onProgress?.('正在扫描内存获取 AES 密钥...')
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext, (current, total, msg) => {
onProgress?.(`${msg} (${current}/${total})`)
})
if (!aesKey) {
return {
success: false,
error: '未能从内存中获取 AES 密钥,请打开朋友圈图片后重试'
const codes: number[] = accounts[0].keys.map((k: any) => k.code)
console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid))
// 优先级: 1. 直接传入的wxidParam 2. 从manualDir提取 3. DLL返回的wxid可能是unknown
let targetWxid = ''
// 方案1: 直接使用传入的wxidParam最优先
if (wxidParam && wxidParam.startsWith('wxid_')) {
targetWxid = wxidParam
console.log('[ImageKey] 使用直接传入的 wxid:', targetWxid)
}
// 方案2: 从 manualDir 提取前端已配置好的正确 wxid
// 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234"
if (!targetWxid && manualDir) {
const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
if (dirName.startsWith('wxid_')) {
targetWxid = dirName
console.log('[ImageKey] 从 manualDir 提取 wxid:', targetWxid)
}
}
return { success: true, xorKey, aesKey: aesKey.slice(0, 16) }
// 方案3: 回退到 DLL 发现的第一个(可能是 unknown
if (!targetWxid) {
targetWxid = accounts[0].wxid
console.log('[ImageKey] 无法获取 wxid使用 DLL 发现的:', targetWxid)
}
// CleanWxid: 截断到第二个下划线,与 xkey 算法一致
const cleanedWxid = this.cleanWxid(targetWxid)
console.log('[ImageKey] wxid:', targetWxid, '→ cleaned:', cleanedWxid)
// 用 cleanedWxid + code 本地计算密钥
// xorKey = code & 0xFF
// aesKey = MD5(code.toString() + cleanedWxid).substring(0, 16)
const code = codes[0]
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
const aesKey = md5Full.substring(0, 16)
onProgress?.(`密钥获取成功 (wxid: ${targetWxid}, code: ${code})`)
console.log('[ImageKey] 计算结果: xorKey=', xorKey, 'aesKey=', aesKey)
return {
success: true,
xorKey,
aesKey
}
}
}
}

View File

@@ -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(/<CommentUserList>([\s\S]*?)<\/CommentUserList>/i)
if (!listMatch) listMatch = xml.match(/<commentUserList>([\s\S]*?)<\/commentUserList>/i)
if (!listMatch) listMatch = xml.match(/<commentList>([\s\S]*?)<\/commentList>/i)
if (!listMatch) listMatch = xml.match(/<comment_user_list>([\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>([^<]*)<\/username>/i)
let nicknameMatch = c.match(/<nickname>([^<]*)<\/nickname>/i)
if (!nicknameMatch) nicknameMatch = c.match(/<nickName>([^<]*)<\/nickName>/i)
const contentMatch = c.match(/<content>([^<]*)<\/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>([^<]*)<\/ref_username>/i)
// 解析表情包
const emojis: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] = []
const emojiRegex = /<emojiinfo>([\s\S]*?)<\/emojiinfo>/gi
let em: RegExpExecArray | null
while ((em = emojiRegex.exec(c)) !== null) {
const ex = em[1]
const externUrl = ex.match(/<extern_url>([^<]*)<\/extern_url>/i)
const cdnUrl = ex.match(/<cdn_url>([^<]*)<\/cdn_url>/i)
const plainUrl = ex.match(/<url>([^<]*)<\/url>/i)
const urlMatch = externUrl || cdnUrl || plainUrl
const md5Match = ex.match(/<md5>([^<]*)<\/md5>/i)
const wMatch = ex.match(/<width>([^<]*)<\/width>/i)
const hMatch = ex.match(/<height>([^<]*)<\/height>/i)
const encMatch = ex.match(/<encrypt_url>([^<]*)<\/encrypt_url>/i)
const aesMatch = ex.match(/<aes_key>([^<]*)<\/aes_key>/i)
const url = urlMatch ? urlMatch[1].trim().replace(/&amp;/g, '&') : ''
const encryptUrl = encMatch ? encMatch[1].trim().replace(/&amp;/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<string, string>()
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(/<LikeUserList>([\s\S]*?)<\/LikeUserList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<likeUserList>([\s\S]*?)<\/likeUserList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<likeList>([\s\S]*?)<\/likeList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<like_user_list>([\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>([^<]*)<\/nickname>/i)
if (!nick) nick = m[1].match(/<nickName>([^<]*)<\/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(/<enc\s+key="(\d+)"/i)
if (encMatch) videoKey = encMatch[1]
const mediaRegex = /<media>([\s\S]*?)<\/media>/gi
let mediaMatch: RegExpExecArray | null
while ((mediaMatch = mediaRegex.exec(xml)) !== null) {
const mx = mediaMatch[1]
const urlMatch = mx.match(/<url[^>]*>([^<]+)<\/url>/i)
const urlTagMatch = mx.match(/<url([^>]*)>/i)
const thumbMatch = mx.match(/<thumb[^>]*>([^<]+)<\/thumb>/i)
const thumbTagMatch = mx.match(/<thumb([^>]*)>/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(/<livePhoto>([\s\S]*?)<\/livePhoto>/i)
if (livePhotoMatch) {
const lx = livePhotoMatch[1]
const lpUrl = lx.match(/<url[^>]*>([^<]+)<\/url>/i)
const lpUrlTag = lx.match(/<url([^>]*)>/i)
const lpThumb = lx.match(/<thumb[^>]*>([^<]+)<\/thumb>/i)
const lpThumbTag = lx.match(/<thumb([^>]*)>/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<string, string>()
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(/&amp;/g, '&'),
md5: e.md5 || '',
width: e.width || 0,
height: e.height || 0,
encryptUrl: e.encryptUrl ? e.encryptUrl.replace(/&amp;/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 }[] = []
// 格式 AGcmData 块格式
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
// CBCIV = 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<string | null> {
return new Promise((resolve) => {
try {
const fs = require('fs')
const https = require('https')
const http = require('http')
let fixedUrl = targetUrl.replace(/&amp;/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()

View File

@@ -1,5 +1,6 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
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<string, unknown>): 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 {}
}
/**
* 获取数据库根目录
*/
@@ -69,7 +80,12 @@ class VideoService {
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
if (!wxid) return undefined
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath })
if (!wxid) {
this.log('queryVideoFileName: wxid 为空')
return undefined
}
// 方法1优先在 cachePath 下查找解密后的 hardlink.db
if (cachePath) {
@@ -84,20 +100,23 @@ class VideoService {
for (const p of cacheDbPaths) {
if (existsSync(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(/\.[^.]+$/, '')
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
return realMd5
}
this.log('缓存 hardlink.db 未命中', { path: p })
} catch (e) {
// 忽略错误
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
}
}
}
@@ -105,7 +124,6 @@ class VideoService {
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
// 检查 dbPath 是否已经包含 wxid
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
@@ -113,10 +131,8 @@ class VideoService {
const encryptedDbPaths: string[] = []
if (dbPathContainsWxid) {
// dbPath 已包含 wxid不需要再拼接
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
} else {
// dbPath 不包含 wxid需要拼接
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
}
@@ -124,27 +140,29 @@ class VideoService {
for (const p of encryptedDbPaths) {
if (existsSync(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`
const result = await wcdbService.execQuery('media', p, sql)
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(/\.[^.]+$/, '')
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) {
// 忽略错误
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
}
} else {
this.log('加密 hardlink.db 不存在', { path: p })
}
}
}
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
return undefined
}
@@ -170,12 +188,16 @@ class VideoService {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
if (!dbPath || !wxid || !videoMd5) {
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
return { exists: false }
}
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
// 检查 dbPath 是否已经包含 wxid避免重复拼接
const dbPathLower = dbPath.toLowerCase()
@@ -184,50 +206,89 @@ class VideoService {
let videoBaseDir: string
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
// dbPath 已经包含 wxid直接使用
videoBaseDir = join(dbPath, 'msg', 'video')
} else {
// dbPath 不包含 wxid需要拼接
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
}
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
if (!existsSync(videoBaseDir)) {
this.log('getVideoInfo: videoBaseDir 不存在')
return { exists: false }
}
// 遍历年月目录查找视频文件
try {
const allDirs = readdirSync(videoBaseDir)
// 支持多种目录格式: 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`)
// 检查视频文件是否存在
if (existsSync(videoPath)) {
// 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw但封面不带
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
const coverPath = join(dirPath, `${baseMd5}.jpg`)
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
// 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名
const allFiles = readdirSync(dirPath)
const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase()))
this.log('找到视频,相关文件列表', {
videoPath,
coverExists: existsSync(coverPath),
thumbExists: existsSync(thumbPath),
relatedFiles,
coverPath,
thumbPath
})
return {
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
}
// 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个)
this.log('未找到视频,开始全目录扫描', {
lookingForOriginal: `${videoMd5}.mp4`,
lookingForResolved: `${realVideoMd5}.mp4`,
hardlinkResolved: realVideoMd5 !== videoMd5
})
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
try {
const allFiles = readdirSync(dirPath)
const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10)
// 检查原始 md5 是否部分匹配前8位
const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase()))
this.log(`目录 ${yearMonth} 扫描结果`, {
totalFiles: allFiles.length,
mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length,
sampleMp4: mp4Files,
partialMatchByOriginalMd5: partialMatch
})
} catch (e) {
this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) })
}
}
} catch (e) {
// 忽略错误
this.log('getVideoInfo 遍历出错', { error: String(e) })
}
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
return { exists: false }
}
@@ -235,41 +296,59 @@ class VideoService {
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
// 打印前500字符看看 XML 结构
if (!content) return undefined
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
try {
// 提取所有可能的 md5 值进行日志
const allMd5s: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
// 收集所有 md5 相关属性,方便对比
const allMd5Attrs: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
let match
while ((match = md5Regex.exec(content)) !== null) {
allMd5s.push(`${match[0]}`)
allMd5Attrs.push(match[0])
}
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
// 方法1从 <videomsg md5="..."> 提取(收到的视频)
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMd5Match) {
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
return videoMsgMd5Match[1].toLowerCase()
}
// 提取 md5用于查询 hardlink.db
// 注意:不是 rawmd5rawmd5 是另一个值
// 格式: md5="xxx" 或 <md5>xxx</md5>
// 尝试从videomsg标签中提取md5
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMatch) {
return videoMsgMatch[1].toLowerCase()
// 方法2从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Match) {
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
return rawMd5Match[1].toLowerCase()
}
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
// 方法3任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
return attrMatch[1].toLowerCase()
}
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5Match) {
return md5Match[1].toLowerCase()
// 方法4<md5>...</md5> 标签
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5TagMatch) {
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
return md5TagMatch[1].toLowerCase()
}
// 方法5兜底取 rawmd5 属性(任意位置)
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Fallback) {
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
return rawMd5Fallback[1].toLowerCase()
}
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
} catch (e) {
console.error('[VideoService] 解析视频MD5失败:', e)
this.log('parseVideoMd5 异常', { error: String(e) })
}
return undefined

View File

@@ -3,6 +3,48 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileS
// DLL 初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
/**
* 解析 extra_bufferprotobuf中的免打扰状态
* - 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<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
// 分批查询,避免 SQL 过长execQuery 不支持参数绑定,直接拼 SQL
const BATCH = 200
const map: Record<string, { isFolded: boolean; isMuted: boolean }> = {}
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 未连接' }

View File

@@ -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<string, { isFolded: boolean; isMuted: boolean }>; 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 内部日志
*/

View File

@@ -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

249
public/splash.html Normal file
View File

@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WeFlow</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: transparent;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
user-select: none;
-webkit-app-region: drag;
}
.splash {
width: 100%; height: 100%;
border-radius: 20px;
display: flex;
flex-direction: column;
}
/* 品牌区 */
.brand {
padding: 48px 52px 0;
display: flex;
align-items: center;
gap: 18px;
animation: fadeIn 0.4s ease both;
}
.logo {
width: 56px; height: 56px;
border-radius: 14px;
flex-shrink: 0;
}
.app-name {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.3px;
}
.app-desc {
font-size: 12px;
margin-top: 5px;
opacity: 0.6;
}
.spacer { flex: 1; }
/* 底部进度区 */
.bottom {
padding: 0 48px 40px;
animation: fadeIn 0.4s ease 0.1s both;
}
/* 进度条轨道 */
.progress-track {
width: 100%;
height: 2px;
border-radius: 2px;
margin-bottom: 12px;
position: relative;
overflow: hidden;
}
/* 进度条填充 */
.progress-fill {
height: 100%;
width: 0%;
border-radius: 2px;
position: relative;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
/* 扫光:只在有进度时显示,不循环 */
.progress-fill::after {
content: '';
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
animation: sweep 1.2s ease-out forwards;
opacity: 0;
}
/* 等待阶段:进度条末端呼吸光点 */
.progress-fill.waiting::before {
content: '';
position: absolute;
top: -1px; right: -2px;
width: 6px; height: 4px;
border-radius: 50%;
background: inherit;
filter: blur(2px);
animation: pulse 1.5s ease-in-out infinite;
}
.bottom-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-text {
font-size: 11px;
opacity: 0.38;
}
.version {
font-size: 11px;
opacity: 0.25;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sweep {
0% { opacity: 0; transform: translateX(-100%); }
20% { opacity: 1; }
80% { opacity: 1; }
100% { opacity: 0; transform: translateX(100%); }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scaleX(1); }
50% { opacity: 1; transform: scaleX(1.8); }
}
</style>
</head>
<body>
<div class="splash" id="splash">
<div class="brand">
<img class="logo" src="./logo.png" alt="WeFlow" />
<div class="brand-text">
<div class="app-name" id="appName">WeFlow</div>
<div class="app-desc" id="appDesc">微信聊天记录管理工具</div>
</div>
</div>
<div class="spacer"></div>
<div class="bottom">
<div class="progress-track" id="progressTrack">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="bottom-row">
<div class="progress-text" id="progressText">正在启动...</div>
<div class="version" id="versionText"></div>
</div>
</div>
</div>
<script>
var themes = {
'cloud-dancer': {
light: { primary: '#8B7355', bg: '#F0EEE9', bgEnd: '#E5E1DA', text: '#3d3d3d', desc: '#8B7355' },
dark: { primary: '#C9A86C', bg: '#1a1816', bgEnd: '#252220', text: '#F0EEE9', desc: '#C9A86C' }
},
'corundum-blue': {
light: { primary: '#4A6670', bg: '#E8EEF0', bgEnd: '#D8E4E8', text: '#3d3d3d', desc: '#4A6670' },
dark: { primary: '#6A9AAA', bg: '#141a1c', bgEnd: '#1e2a2e', text: '#E0EEF2', desc: '#6A9AAA' }
},
'kiwi-green': {
light: { primary: '#7A9A5C', bg: '#E8F0E4', bgEnd: '#D8E8D2', text: '#3d3d3d', desc: '#7A9A5C' },
dark: { primary: '#9ABA7C', bg: '#161a14', bgEnd: '#222a1e', text: '#E8F0E4', desc: '#9ABA7C' }
},
'spicy-red': {
light: { primary: '#8B4049', bg: '#F0E8E8', bgEnd: '#E8D8D8', text: '#3d3d3d', desc: '#8B4049' },
dark: { primary: '#C06068', bg: '#1a1416', bgEnd: '#261e20', text: '#F2E8EA', desc: '#C06068' }
},
'teal-water': {
light: { primary: '#5A8A8A', bg: '#E4F0F0', bgEnd: '#D2E8E8', text: '#3d3d3d', desc: '#5A8A8A' },
dark: { primary: '#7ABAAA', bg: '#121a1a', bgEnd: '#1a2626', text: '#E0F2EE', desc: '#7ABAAA' }
},
'blossom-dream': {
light: { primary: '#D4849A', primaryEnd: '#D4849A', bg: '#FCF9FB', bgMid: '#F8F2F8', bgEnd: '#F2F6FB', text: '#2E2633', desc: '#D4849A' },
dark: { primary: '#C670C3', primaryEnd: '#8A60C0', bg: '#120B16', bgMid: '#1A1020', bgEnd: '#0E0B18', text: '#F2EAF4', desc: '#C670C3' }
}
};
function applyTheme(themeId, mode) {
var t = themes[themeId] || themes['cloud-dancer'];
var isDark = mode === 'dark';
if (mode === 'system') isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var c = isDark ? t.dark : t.light;
var el = document.getElementById('splash');
var fill = document.getElementById('progressFill');
if (themeId === 'blossom-dream') {
if (isDark) {
// 深色
el.style.background =
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '28 0%, transparent 70%), ' +
'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
} else {
// 浅色
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
}
// 进度条
fill.style.background = 'linear-gradient(90deg, ' + c.primary + ' 0%, ' + c.primaryEnd + ' 100%)';
} else {
if (isDark) {
el.style.background =
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '22 0%, transparent 70%), ' +
'linear-gradient(145deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
} else {
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
}
fill.style.background = c.primary;
}
document.getElementById('appName').style.color = c.text;
document.getElementById('appDesc').style.color = c.desc;
document.getElementById('progressText').style.color = c.text;
document.getElementById('versionText').style.color = c.text;
document.getElementById('progressTrack').style.background = c.primary + (isDark ? '25' : '18');
}
// percent: 实际进度值waiting: 是否处于等待阶段
function updateProgress(percent, text, waiting) {
var fill = document.getElementById('progressFill');
var label = document.getElementById('progressText');
if (fill) {
fill.style.width = percent + '%';
if (waiting) {
fill.classList.add('waiting');
} else {
fill.classList.remove('waiting');
// 触发扫光:重置动画
fill.style.animation = 'none';
fill.offsetHeight;
fill.style.animation = '';
}
}
if (label && text) label.textContent = text;
}
function setVersion(ver) {
var el = document.getElementById('versionText');
if (el) el.textContent = 'v' + ver;
}
applyTheme('cloud-dancer', 'light');
</script>
</body>
</html>

Binary file not shown.

Binary file not shown.

View File

@@ -4,6 +4,48 @@
flex-direction: column;
background: var(--bg-primary);
animation: appFadeIn 0.35s ease-out;
position: relative;
overflow: hidden;
}
// 繁花如梦:底色层(::before+ 光晕层(::after分离避免 blur 吃掉边缘
[data-theme="blossom-dream"] .app-container {
background: transparent;
}
// ::before 纯底色,不模糊
[data-theme="blossom-dream"] .app-container::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: -2;
background: var(--bg-primary);
}
// ::after 光晕层,模糊叠加在底色上
[data-theme="blossom-dream"] .app-container::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: -1;
background:
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-peach) 0%, transparent 65%),
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
filter: blur(80px);
opacity: 0.75;
}
// 深色模式光晕更克制
[data-theme="blossom-dream"][data-mode="dark"] .app-container::after {
background:
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-purple) 0%, transparent 65%),
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
filter: blur(100px);
opacity: 0.2;
}
.window-drag-region {

View File

@@ -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 {

View File

@@ -7,10 +7,12 @@
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-light);
// 浅色模式下使用不透明背景,避免透明窗口中通知过于透明
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
[data-mode="light"] &,
:not([data-mode]) & {
background: rgba(255, 255, 255, 1);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
border-radius: 12px;
@@ -46,12 +48,26 @@
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
// 确保背景不透明
background: var(--bg-secondary, #2c2c2c);
color: var(--text-primary, #ffffff);
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
background: #ffffff;
color: #3d3d3d;
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
--border-light: rgba(0, 0, 0, 0.08);
// 深色模式覆盖
[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;

View File

@@ -103,4 +103,31 @@
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
[data-theme="blossom-dream"] .sidebar {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.4);
}
[data-theme="blossom-dream"][data-mode="dark"] .sidebar {
background: rgba(34, 30, 36, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
// 激活项:主品牌色纵向微渐变
[data-theme="blossom-dream"] .nav-item.active {
background: linear-gradient(180deg, #D4849A 0%, #C4748A 100%);
}
// 深色激活项:用藕粉色,背景深灰底 + 粉色文字/图标(高阶玩法)
[data-theme="blossom-dream"][data-mode="dark"] .nav-item.active {
background: rgba(209, 158, 187, 0.15);
color: #D19EBB;
border: 1px solid rgba(209, 158, 187, 0.2);
}

View File

@@ -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<string, string>()
// 评论表情包组件
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<string>(() => 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 (
<img
src={localSrc}
alt="emoji"
className="comment-custom-emoji"
draggable={false}
onClick={(e) => { 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<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ 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<SnsPostItemProps> = ({ 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 (
<div className={`sns-post-item ${mediaDeleted ? 'post-deleted' : ''}`}>
<>
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
<div className="post-avatar-col">
<Avatar
src={post.avatarUrl}
@@ -239,12 +325,20 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
<span className="post-time">{formatTime(post.createTime)}</span>
</div>
<div className="post-header-actions">
{mediaDeleted && (
{(mediaDeleted || dbDeleted) && (
<span className="post-deleted-badge">
<Trash2 size={12} />
<span></span>
</span>
)}
<button
className="icon-btn-ghost debug-btn delete-btn"
onClick={handleDeleteClick}
disabled={deleting || dbDeleted}
title="从数据库删除此条记录"
>
<Trash2 size={14} />
</button>
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
e.stopPropagation();
onDebug(post);
@@ -289,7 +383,16 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
</>
)}
<span className="comment-colon"></span>
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
{c.content && (
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
)}
{c.emojis && c.emojis.map((emoji, ei) => (
<CommentEmoji
key={ei}
emoji={emoji}
onPreview={(src) => onPreview(src)}
/>
))}
</div>
))}
</div>
@@ -298,5 +401,24 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
)}
</div>
</div>
{/* 删除确认弹窗 - 用 Portal 挂到 body避免父级 transform 影响 fixed 定位 */}
{showDeleteConfirm && createPortal(
<div className="sns-confirm-overlay" onClick={() => setShowDeleteConfirm(false)}>
<div className="sns-confirm-dialog" onClick={(e) => e.stopPropagation()}>
<div className="sns-confirm-icon">
<Trash2 size={22} />
</div>
<div className="sns-confirm-title"></div>
<div className="sns-confirm-desc"></div>
<div className="sns-confirm-actions">
<button className="sns-confirm-cancel" onClick={() => setShowDeleteConfirm(false)}></button>
<button className="sns-confirm-ok" onClick={handleDeleteConfirm}></button>
</div>
</div>
</div>,
document.body
)}
</>
)
}

View File

@@ -10,6 +10,12 @@
gap: 8px;
}
// 繁花如梦:标题栏毛玻璃
[data-theme="blossom-dream"] .title-bar {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.title-logo {
width: 20px;
height: 20px;

View File

@@ -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;
@@ -2243,6 +2310,18 @@
.quoted-text {
color: var(--text-secondary);
white-space: pre-wrap;
.quoted-type-label {
font-style: italic;
opacity: 0.8;
}
.quoted-emoji-image {
width: 40px;
height: 40px;
vertical-align: middle;
object-fit: contain;
}
}
}
@@ -2897,7 +2976,6 @@
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
color: var(--text-secondary);
font-size: 13px;
@@ -3253,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;
@@ -3392,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;
}
}
}
}
}
@@ -3449,7 +3598,7 @@
&.btn-primary,
&.batch-transcribe-start-btn {
background: var(--primary-color);
color: white;
color: #000;
&:hover {
opacity: 0.9;
@@ -3852,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;
}
}

View File

@@ -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 (
<div
className={`session-item fold-entry`}
onClick={() => onSelect(session)}
>
<div className="fold-entry-avatar">
<FolderClosed size={22} />
</div>
<div className="session-info">
<div className="session-top">
<span className="session-name"></span>
</div>
<div className="session-bottom">
<span className="session-summary">{session.summary || ''}</span>
</div>
</div>
</div>
)
}
return (
<div
className={`session-item ${isActive ? 'active' : ''}`}
className={`session-item ${isActive ? 'active' : ''} ${session.isMuted ? 'muted' : ''}`}
onClick={() => onSelect(session)}
>
<Avatar
@@ -202,17 +225,19 @@ const SessionItem = React.memo(function SessionItem({
</div>
<div className="session-bottom">
<span className="session-summary">{session.summary || '暂无消息'}</span>
{session.unreadCount > 0 && (
<span className="unread-badge">
{session.unreadCount > 99 ? '99+' : session.unreadCount}
</span>
)}
<div className="session-badges">
{session.isMuted && <BellOff size={12} className="mute-icon" />}
{session.unreadCount > 0 && (
<span className={`unread-badge ${session.isMuted ? 'muted' : ''}`}>
{session.unreadCount > 99 ? '99+' : session.unreadCount}
</span>
)}
</div>
</div>
</div>
</div>
)
}, (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
)
})
@@ -274,6 +300,7 @@ function ChatPage(_props: ChatPageProps) {
const [jumpStartTime, setJumpStartTime] = useState(0)
const [jumpEndTime, setJumpEndTime] = useState(0)
const [showJumpDialog, setShowJumpDialog] = useState(false)
const isDateJumpRef = useRef(false)
const [messageDates, setMessageDates] = useState<Set<string>>(new Set())
const [loadingDates, setLoadingDates] = useState(false)
const messageDatesCache = useRef<Map<string, Set<string>>>(new Map())
@@ -288,6 +315,7 @@ function ChatPage(_props: ChatPageProps) {
const [copiedField, setCopiedField] = useState<string | null>(null)
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图
const [hasInitialMessages, setHasInitialMessages] = useState(false)
const [noMessageTable, setNoMessageTable] = useState(false)
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
@@ -318,6 +346,8 @@ function ChatPage(_props: ChatPageProps) {
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
const [batchDecryptConcurrency, setBatchDecryptConcurrency] = useState(6)
const [showConcurrencyDropdown, setShowConcurrencyDropdown] = useState(false)
// 批量删除相关状态
const [isDeleting, setIsDeleting] = useState(false)
@@ -738,7 +768,7 @@ function ChatPage(_props: ChatPageProps) {
setIsRefreshingMessages(true)
// 找出当前已渲染消息中的最大时间戳(使用 getState 获取最新状态,避免闭包过时导致重复)
const currentMessages = useChatStore.getState().messages
const currentMessages = useChatStore.getState().messages || []
const lastMsg = currentMessages[currentMessages.length - 1]
const minTime = lastMsg?.createTime || 0
@@ -752,7 +782,7 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.messages && result.messages.length > 0) {
// 过滤去重:必须对比实时的状态,防止在 handleRefreshMessages 运行期间导致的冲突
const latestMessages = useChatStore.getState().messages
const latestMessages = useChatStore.getState().messages || []
const existingKeys = new Set(latestMessages.map(getMessageKey))
const newOnes = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
@@ -793,7 +823,7 @@ function ChatPage(_props: ChatPageProps) {
return
}
// 使用实时状态进行去重对比
const latestMessages = useChatStore.getState().messages
const latestMessages = useChatStore.getState().messages || []
const existing = new Set(latestMessages.map(getMessageKey))
const lastMsg = latestMessages[latestMessages.length - 1]
const lastTime = lastMsg?.createTime ?? 0
@@ -829,7 +859,7 @@ function ChatPage(_props: ChatPageProps) {
const currentBatchSizeRef = useRef(50)
// 加载消息
const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => {
const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0, ascending = false) => {
const listEl = messageListRef.current
const session = sessionMapRef.current.get(sessionId)
const unreadCount = session?.unreadCount ?? 0
@@ -863,7 +893,7 @@ function ChatPage(_props: ChatPageProps) {
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
try {
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) as {
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime, ascending) as {
success: boolean;
messages?: Message[];
hasMore?: boolean;
@@ -901,10 +931,15 @@ function ChatPage(_props: ChatPageProps) {
}
}
// 首次加载滚动到底部
// 日期跳转时滚动到顶部,否则滚动到底部
requestAnimationFrame(() => {
if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
if (isDateJumpRef.current) {
messageListRef.current.scrollTop = 0
isDateJumpRef.current = false
} else {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
}
}
})
} else {
@@ -937,13 +972,18 @@ function ChatPage(_props: ChatPageProps) {
})
}
}
setHasMoreMessages(result.hasMore ?? false)
// 如果是按 endTime 跳转加载,且结果刚好满批,可能后面(更晚)还有消息
if (offset === 0) {
if (endTime > 0) {
setHasMoreLater(true)
} else {
setHasMoreLater(false)
// 日期跳转(ascending=true):不往上加载更早的,往下加载更晚的
if (ascending) {
setHasMoreMessages(false)
setHasMoreLater(result.hasMore ?? false)
} else {
setHasMoreMessages(result.hasMore ?? false)
if (offset === 0) {
if (endTime > 0) {
setHasMoreLater(true)
} else {
setHasMoreLater(false)
}
}
}
setCurrentOffset(offset + result.messages.length)
@@ -995,6 +1035,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 +1056,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 +1332,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 +1675,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<void>[] = []
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<string, number>()
@@ -1690,7 +1751,7 @@ function ChatPage(_props: ChatPageProps) {
// Range selection with Shift key
if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) {
const currentMsgs = useChatStore.getState().messages
const currentMsgs = useChatStore.getState().messages || []
const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current)
const idx2 = currentMsgs.findIndex(m => m.localId === localId)
@@ -1760,7 +1821,7 @@ function ChatPage(_props: ChatPageProps) {
const dbPathHint = (msg as any)._db_path
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint)
if (result.success) {
const currentMessages = useChatStore.getState().messages
const currentMessages = useChatStore.getState().messages || []
const newMessages = currentMessages.filter(m => m.localId !== msg.localId)
useChatStore.getState().setMessages(newMessages)
} else {
@@ -1821,7 +1882,7 @@ function ChatPage(_props: ChatPageProps) {
try {
const result = await (window as any).electronAPI.chat.updateMessage(currentSessionId, editingMessage.message.localId, editingMessage.message.createTime, finalContent)
if (result.success) {
const currentMessages = useChatStore.getState().messages
const currentMessages = useChatStore.getState().messages || []
const newMessages = currentMessages.map(m => {
if (m.localId === editingMessage.message.localId) {
return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent }
@@ -1863,7 +1924,7 @@ function ChatPage(_props: ChatPageProps) {
cancelDeleteRef.current = false
try {
const currentMessages = useChatStore.getState().messages
const currentMessages = useChatStore.getState().messages || []
const selectedIds = Array.from(selectedMessages)
const deletedIds = new Set<number>()
@@ -1887,7 +1948,7 @@ function ChatPage(_props: ChatPageProps) {
setDeleteProgress({ current: i + 1, total: selectedIds.length })
}
const finalMessages = useChatStore.getState().messages.filter(m => !deletedIds.has(m.localId))
const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedIds.has(m.localId))
useChatStore.getState().setMessages(finalMessages)
setIsSelectionMode(false)
@@ -1984,26 +2045,41 @@ function ChatPage(_props: ChatPageProps) {
ref={sidebarRef}
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
>
<div className="session-header">
<div className="search-row">
<div className="search-box expanded">
<Search size={14} />
<input
ref={searchInputRef}
type="text"
placeholder="搜索"
value={searchKeyword}
onChange={(e) => handleSearch(e.target.value)}
/>
{searchKeyword && (
<button className="close-search" onClick={handleCloseSearch}>
<X size={12} />
</button>
)}
<div className={`session-header session-header-viewport ${foldedView ? 'folded' : ''}`}>
{/* 普通 header */}
<div className="session-header-panel main-header">
<div className="search-row">
<div className="search-box expanded">
<Search size={14} />
<input
ref={searchInputRef}
type="text"
placeholder="搜索"
value={searchKeyword}
onChange={(e) => handleSearch(e.target.value)}
/>
{searchKeyword && (
<button className="close-search" onClick={handleCloseSearch}>
<X size={12} />
</button>
)}
</div>
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
</button>
</div>
</div>
{/* 折叠群 header */}
<div className="session-header-panel folded-header">
<div className="folded-view-header">
<button className="icon-btn back-btn" onClick={() => setFoldedView(false)}>
<ChevronLeft size={18} />
</button>
<span className="folded-view-title">
<Users size={14} />
</span>
</div>
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
</button>
</div>
</div>
@@ -2018,7 +2094,6 @@ function ChatPage(_props: ChatPageProps) {
{/* ... (previous content) ... */}
{isLoadingSessions ? (
<div className="loading-sessions">
{/* ... (skeleton items) ... */}
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar" />
@@ -2029,36 +2104,65 @@ function ChatPage(_props: ChatPageProps) {
</div>
))}
</div>
) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
<div
className="session-list"
ref={sessionListRef}
onScroll={() => {
isScrollingRef.current = true
if (sessionScrollTimeoutRef.current) {
clearTimeout(sessionScrollTimeoutRef.current)
}
sessionScrollTimeoutRef.current = window.setTimeout(() => {
isScrollingRef.current = false
sessionScrollTimeoutRef.current = null
}, 200)
}}
>
{filteredSessions.map(session => (
<SessionItem
key={session.username}
session={session}
isActive={currentSessionId === session.username}
onSelect={handleSelectSession}
formatTime={formatSessionTime}
/>
))}
</div>
) : (
<div className="empty-sessions">
<MessageSquare />
<p></p>
<p className="hint"></p>
<div className={`session-list-viewport ${foldedView ? 'folded' : ''}`}>
{/* 普通会话列表 */}
<div className="session-list-panel main-panel">
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
<div
className="session-list"
ref={sessionListRef}
onScroll={() => {
isScrollingRef.current = true
if (sessionScrollTimeoutRef.current) {
clearTimeout(sessionScrollTimeoutRef.current)
}
sessionScrollTimeoutRef.current = window.setTimeout(() => {
isScrollingRef.current = false
sessionScrollTimeoutRef.current = null
}, 200)
}}
>
{filteredSessions.map(session => (
<SessionItem
key={session.username}
session={session}
isActive={currentSessionId === session.username}
onSelect={handleSelectSession}
formatTime={formatSessionTime}
/>
))}
</div>
) : (
<div className="empty-sessions">
<MessageSquare />
<p></p>
<p className="hint"></p>
</div>
)}
</div>
{/* 折叠群列表 */}
<div className="session-list-panel folded-panel">
{foldedSessions.length > 0 ? (
<div className="session-list">
{foldedSessions.map(session => (
<SessionItem
key={session.username}
session={session}
isActive={currentSessionId === session.username}
onSelect={handleSelectSession}
formatTime={formatSessionTime}
/>
))}
</div>
) : (
<div className="empty-sessions">
<Users size={32} />
<p></p>
</div>
)}
</div>
</div>
)}
@@ -2177,11 +2281,13 @@ function ChatPage(_props: ChatPageProps) {
onClose={() => setShowJumpDialog(false)}
onSelect={(date) => {
if (!currentSessionId) return
const start = Math.floor(date.setHours(0, 0, 0, 0) / 1000)
const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000)
isDateJumpRef.current = true
setCurrentOffset(0)
setJumpStartTime(0)
setJumpStartTime(start)
setJumpEndTime(end)
loadMessages(currentSessionId, 0, 0, end)
loadMessages(currentSessionId, 0, start, end, true)
}}
messageDates={messageDates}
loadingDates={loadingDates}
@@ -2236,7 +2342,7 @@ function ChatPage(_props: ChatPageProps) {
</div>
)}
{messages.map((msg, index) => {
{(messages || []).map((msg, index) => {
const prevMsg = index > 0 ? messages[index - 1] : undefined
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
@@ -2547,6 +2653,39 @@ function ChatPage(_props: ChatPageProps) {
<span className="label">:</span>
<span className="value">{batchImageSelectedDates.size} {batchImageSelectedCount} </span>
</div>
<div className="info-item">
<span className="label">:</span>
<div className="batch-concurrency-field">
<button
type="button"
className={`batch-concurrency-trigger ${showConcurrencyDropdown ? 'open' : ''}`}
onClick={() => setShowConcurrencyDropdown(!showConcurrencyDropdown)}
>
<span>{batchDecryptConcurrency === 1 ? '1最慢最稳' : batchDecryptConcurrency === 6 ? '6推荐' : batchDecryptConcurrency === 20 ? '20最快可能卡顿' : String(batchDecryptConcurrency)}</span>
<ChevronDown size={14} />
</button>
{showConcurrencyDropdown && (
<div className="batch-concurrency-dropdown">
{[
{ value: 1, label: '1最慢最稳' },
{ value: 3, label: '3' },
{ value: 6, label: '6推荐' },
{ value: 10, label: '10' },
{ value: 20, label: '20最快可能卡顿' },
].map(opt => (
<button
key={opt.value}
type="button"
className={`batch-concurrency-option ${batchDecryptConcurrency === opt.value ? 'active' : ''}`}
onClick={() => { setBatchDecryptConcurrency(opt.value); setShowConcurrencyDropdown(false) }}
>
{opt.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="batch-warning">
<AlertCircle size={16} />
@@ -2780,6 +2919,31 @@ const voiceTranscriptCache = new Map<string, string>()
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
// 引用消息中的动画表情组件
function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
const cacheKey = md5 || cdnUrl
const [localPath, setLocalPath] = useState<string | undefined>(() => emojiDataUrlCache.get(cacheKey))
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
useEffect(() => {
if (localPath || loading || error) return
setLoading(true)
window.electronAPI.chat.downloadEmoji(cdnUrl, md5).then((result: { success: boolean; localPath?: string }) => {
if (result.success && result.localPath) {
emojiDataUrlCache.set(cacheKey, result.localPath)
setLocalPath(result.localPath)
} else {
setError(true)
}
}).catch(() => setError(true)).finally(() => setLoading(false))
}, [cdnUrl, md5, cacheKey, localPath, loading, error])
if (error || (!loading && !localPath)) return <span className="quoted-type-label">[]</span>
if (loading) return <span className="quoted-type-label">[]</span>
return <img src={localPath} alt="动画表情" className="quoted-emoji-image" />
}
// 消息气泡组件
function MessageBubble({
message,
@@ -2901,7 +3065,7 @@ function MessageBubble({
// 从缓存获取表情包 data URL
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
const [emojiLocalPath, setEmojiLocalPath] = useState<string | undefined>(
() => emojiDataUrlCache.get(cacheKey)
() => emojiDataUrlCache.get(cacheKey) || message.emojiLocalPath
)
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
@@ -3036,10 +3200,15 @@ function MessageBubble({
// 自动下载表情包
useEffect(() => {
if (emojiLocalPath) return
// 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况)
if (isEmoji && message.emojiLocalPath && !emojiLocalPath) {
setEmojiLocalPath(message.emojiLocalPath)
return
}
if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) {
downloadEmoji()
}
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
}, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError])
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
if (!isImage) return
@@ -3971,11 +4140,13 @@ function MessageBubble({
// 通话消息
if (isCall) {
return (
<div className="call-message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
<span>{message.parsedContent || '[通话]'}</span>
<div className="bubble-content">
<div className="call-message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
<span>{message.parsedContent || '[通话]'}</span>
</div>
</div>
)
}
@@ -4043,11 +4214,39 @@ function MessageBubble({
const replyText = q('title') || cleanMessageContent(message.parsedContent) || ''
const referContent = q('refermsg > content') || ''
const referSender = q('refermsg > displayname') || ''
const referType = q('refermsg > type') || ''
// 根据被引用消息类型渲染对应内容
const renderReferContent = () => {
// 动画表情:解析嵌套 XML 提取 cdnurl 渲染
if (referType === '47') {
try {
const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml')
const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || ''
const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || ''
if (cdnUrl) return <QuotedEmoji cdnUrl={cdnUrl} md5={md5} />
} catch { /* 解析失败降级 */ }
return <span className="quoted-type-label">[]</span>
}
// 各类型名称映射
const typeLabels: Record<string, string> = {
'3': '图片', '34': '语音', '43': '视频',
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
}
if (referType && typeLabels[referType]) {
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
}
// 普通文本或未知类型
return <>{renderTextWithEmoji(cleanMessageContent(referContent))}</>
}
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
<span className="quoted-text">{renderReferContent()}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
@@ -4143,6 +4342,22 @@ function MessageBubble({
</div>
)
if (kind === 'quote') {
// 引用回复消息appMsgKind='quote'xmlType=57
const replyText = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || ''
const referContent = message.quotedContent || q('refermsg > content') || ''
const referSender = message.quotedSender || q('refermsg > displayname') || ''
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
}
if (kind === 'red-packet') {
// 专属红包卡片
const greeting = (() => {
@@ -4347,6 +4562,44 @@ function MessageBubble({
console.error('解析 AppMsg 失败:', e)
}
// 引用回复消息 (type=57),防止被误判为链接
if (appMsgType === '57') {
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanMessageContent(message.parsedContent) || ''
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || ''
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
const renderReferContent2 = () => {
if (referType === '47') {
try {
const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml')
const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || ''
const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || ''
if (cdnUrl) return <QuotedEmoji cdnUrl={cdnUrl} md5={md5} />
} catch { /* 解析失败降级 */ }
return <span className="quoted-type-label">[]</span>
}
const typeLabels: Record<string, string> = {
'3': '图片', '34': '语音', '43': '视频',
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
}
if (referType && typeLabels[referType]) {
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
}
return <>{renderTextWithEmoji(cleanMessageContent(referContent))}</>
}
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderReferContent2()}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
}
// 群公告消息 (type=87)
if (appMsgType === '87') {
const announcementText = textAnnouncement || desc || '群公告'
@@ -4579,7 +4832,7 @@ function MessageBubble({
if (isEmoji) {
// ... (keep existing emoji logic)
// 没有 cdnUrl 或加载失败,显示占位符
if (!message.emojiCdnUrl || emojiError) {
if ((!message.emojiCdnUrl && !message.emojiLocalPath) || emojiError) {
return (
<div className="emoji-unavailable">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">

View File

@@ -29,7 +29,7 @@
.blob-1 {
width: 400px;
height: 400px;
background: rgba(139, 115, 85, 0.25);
background: rgba(var(--primary-rgb), 0.25);
top: -100px;
left: -50px;
animation-duration: 25s;
@@ -38,7 +38,7 @@
.blob-2 {
width: 350px;
height: 350px;
background: rgba(139, 115, 85, 0.15);
background: rgba(var(--primary-rgb), 0.15);
bottom: -50px;
right: -50px;
animation-duration: 30s;
@@ -74,7 +74,7 @@
margin: 0 0 16px;
color: var(--text-primary);
letter-spacing: -2px;
background: linear-gradient(135deg, var(--text-primary) 0%, rgba(139, 115, 85, 0.8) 100%);
background: linear-gradient(135deg, var(--primary) 0%, rgba(var(--primary-rgb), 0.6) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;

View File

@@ -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<NotificationData | null>(null)
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
@@ -19,12 +17,6 @@ export default function NotificationWindow() {
const notificationRef = useRef<NotificationData | null>(null)
// 应用主题到通知窗口
useEffect(() => {
document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', themeMode)
}, [currentTheme, themeMode])
useEffect(() => {
notificationRef.current = notification
}, [notification])

View File

@@ -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;
}
}
}
}

View File

@@ -82,6 +82,8 @@ function SettingsPage() {
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('')
const [imageKeyProgress, setImageKeyProgress] = useState(0)
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(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,16 +767,19 @@ 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 result = await window.electronAPI.key.autoGetImageKey(accountPath)
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') {
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
@@ -939,8 +964,16 @@ function SettingsPage() {
<div className="theme-grid">
{themes.map((theme) => (
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
<div className="theme-preview" style={{ background: effectiveMode === 'dark' ? 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)' : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)` }}>
<div className="theme-accent" style={{ background: theme.primaryColor }} />
<div className="theme-preview" style={{
background: effectiveMode === 'dark'
? (theme.id === 'blossom-dream' ? 'linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%)' : 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)` : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)`)
}}>
<div className="theme-accent" style={{
background: theme.accentColor
? `linear-gradient(135deg, ${theme.primaryColor} 0%, ${theme.accentColor} 100%)`
: theme.primaryColor
}} />
</div>
<div className="theme-info">
<span className="theme-name">{theme.name}</span>
@@ -1343,8 +1376,21 @@ function SettingsPage() {
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
{imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>}
{isFetchingImageKey && <div className="form-hint status-text">...</div>}
{isFetchingImageKey ? (
<div className="brute-force-progress">
<div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
</div>
{imageKeyPercent !== null && (
<div className="progress-bar-container">
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
</div>
)}
</div>
) : (
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
)}
</div>
<div className="form-group">
@@ -2067,8 +2113,8 @@ function SettingsPage() {
<label></label>
<span className="form-hint">{
isLockMode ? '已开启' :
authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' :
'未开启 — 请设置密码以开启'
authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' :
'未开启 — 请设置密码以开启'
}</span>
</div>
{authEnabled && !showDisableLockInput && (

View File

@@ -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);
}

View File

@@ -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<boolean | null>(null)
const [triggerLoading, setTriggerLoading] = useState(false)
const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const postsContainerRef = useRef<HTMLDivElement>(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() {
<div className="feed-header">
<h2></h2>
<div className="header-actions">
<button
onClick={async () => {
setTriggerMessage(null)
setShowTriggerDialog(true)
setTriggerLoading(true)
try {
const r = await window.electronAPI.sns.checkBlockDeleteTrigger()
setTriggerInstalled(r.success ? (r.installed ?? false) : false)
} catch {
setTriggerInstalled(false)
} finally {
setTriggerLoading(false)
}
}}
className="icon-btn"
title="朋友圈保护插件"
>
<Shield size={20} />
</button>
<button
onClick={() => {
setExportResult(null)
@@ -329,7 +353,7 @@ export default function SnsPage() {
{posts.map(post => (
<SnsPostItem
key={post.id}
post={post}
post={{ ...post, isProtected: triggerInstalled === true }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
@@ -338,6 +362,7 @@ export default function SnsPage() {
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => setPosts(prev => prev.filter(p => p.id !== postId))}
/>
))}
</div>
@@ -426,6 +451,101 @@ export default function SnsPage() {
</div>
)}
{/* 朋友圈防删除插件对话框 */}
{showTriggerDialog && (
<div className="modal-overlay" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>
<div className="sns-protect-dialog" onClick={(e) => e.stopPropagation()}>
<button className="close-btn sns-protect-close" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>
<X size={18} />
</button>
{/* 顶部图标区 */}
<div className="sns-protect-hero">
<div className={`sns-protect-icon-wrap ${triggerInstalled ? 'active' : ''}`}>
{triggerLoading
? <RefreshCw size={28} className="spinning" />
: triggerInstalled
? <Shield size={28} />
: <ShieldOff size={28} />
}
</div>
<div className="sns-protect-title"></div>
<div className={`sns-protect-status-badge ${triggerInstalled ? 'on' : 'off'}`}>
{triggerLoading ? '检查中…' : triggerInstalled ? '已启用' : '未启用'}
</div>
</div>
{/* 说明 */}
<div className="sns-protect-desc">
WeFlow将拦截朋友圈删除操作<br/><br/>
</div>
{/* 操作反馈 */}
{triggerMessage && (
<div className={`sns-protect-feedback ${triggerMessage.type}`}>
{triggerMessage.type === 'success' ? <CheckCircle size={14} /> : <AlertCircle size={14} />}
<span>{triggerMessage.text}</span>
</div>
)}
{/* 操作按钮 */}
<div className="sns-protect-actions">
{!triggerInstalled ? (
<button
className="sns-protect-btn primary"
disabled={triggerLoading}
onClick={async () => {
setTriggerLoading(true)
setTriggerMessage(null)
try {
const r = await window.electronAPI.sns.installBlockDeleteTrigger()
if (r.success) {
setTriggerInstalled(true)
setTriggerMessage({ type: 'success', text: r.alreadyInstalled ? '插件已存在,无需重复安装' : '已启用朋友圈防删除保护' })
} else {
setTriggerMessage({ type: 'error', text: r.error || '安装失败' })
}
} catch (e: any) {
setTriggerMessage({ type: 'error', text: e.message || String(e) })
} finally {
setTriggerLoading(false)
}
}}
>
<Shield size={15} />
</button>
) : (
<button
className="sns-protect-btn danger"
disabled={triggerLoading}
onClick={async () => {
setTriggerLoading(true)
setTriggerMessage(null)
try {
const r = await window.electronAPI.sns.uninstallBlockDeleteTrigger()
if (r.success) {
setTriggerInstalled(false)
setTriggerMessage({ type: 'success', text: '已关闭朋友圈防删除保护' })
} else {
setTriggerMessage({ type: 'error', text: r.error || '卸载失败' })
}
} catch (e: any) {
setTriggerMessage({ type: 'error', text: e.message || String(e) })
} finally {
setTriggerLoading(false)
}
}}
>
<ShieldOff size={15} />
</button>
)}
</div>
</div>
</div>
)}
{/* 导出对话框 */}
{showExportDialog && (
<div className="modal-overlay" onClick={() => !isExporting && setShowExportDialog(false)}>

View File

@@ -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%); }
}

View File

@@ -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<number | null>(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,11 +315,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
setIsFetchingImageKey(true)
setError('')
setImageKeyPercent(0)
setImageKeyStatus('正在准备获取图片密钥...')
try {
// 拼接完整的账号目录,确保 KeyService 能准确找到模板文件
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') {
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
@@ -752,10 +771,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</div>
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
{isFetchingImageKey ? '扫描中...' : '自动获取图片密钥'}
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
{imageKeyStatus && <div className="status-message">{imageKeyStatus}</div>}
{isFetchingImageKey ? (
<div className="brute-force-progress">
<div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
</div>
{imageKeyPercent !== null && (
<div className="progress-bar-container">
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
</div>
)}
</div>
) : (
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
)}
<div className="field-hint"></div>
</div>
)}

View File

@@ -86,15 +86,16 @@ export const useChatStore = create<ChatState>((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]
}
}),

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water'
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' | 'blossom-dream'
export type ThemeMode = 'light' | 'dark' | 'system'
export interface ThemeInfo {
@@ -10,6 +10,8 @@ export interface ThemeInfo {
description: string
primaryColor: string
bgColor: string
// 可选副色,用于多彩主题的渐变预览
accentColor?: string
}
export const themes: ThemeInfo[] = [
@@ -20,6 +22,14 @@ export const themes: ThemeInfo[] = [
primaryColor: '#8B7355',
bgColor: '#F0EEE9'
},
{
id: 'blossom-dream',
name: '繁花如梦',
description: '晨曦花境 · 夜阑幽梦',
primaryColor: '#D4849A',
bgColor: '#FCF9FB',
accentColor: '#FFBE98'
},
{
id: 'corundum-blue',
name: '刚玉蓝',

View File

@@ -153,6 +153,43 @@
--sent-card-bg: var(--primary);
}
// 繁花如梦 - 浅色(晨曦花境)
[data-theme="blossom-dream"][data-mode="light"],
[data-theme="blossom-dream"]:not([data-mode]) {
// 三色定义(供伪元素光晕使用,饱和度提高以便在底色上可见)
--blossom-pink: #F0A0B8;
--blossom-peach: #FFB07A;
--blossom-blue: #90B8E0;
// 主品牌色Pantone 粉晶 Rose Quartz
--primary: #D4849A;
--primary-rgb: 212, 132, 154;
--primary-hover: #C4748A;
--primary-light: rgba(212, 132, 154, 0.12);
// 背景三层:主背景最深(相对),面板次之,卡片最白
--bg-primary: #F5EDF2;
--bg-secondary: rgba(255, 255, 255, 0.82);
--bg-tertiary: rgba(212, 132, 154, 0.06);
--bg-hover: rgba(212, 132, 154, 0.09);
// 文字:提高对比度,主色接近纯黑只带微弱紫调
--text-primary: #1E1A22;
--text-secondary: #6B5F70;
--text-tertiary: #9A8A9E;
// 边框:粉色半透明,有存在感但不强硬
--border-color: rgba(212, 132, 154, 0.18);
--bg-gradient: linear-gradient(150deg, #F5EDF2 0%, #F0EAF6 50%, #EAF0F8 100%);
--primary-gradient: linear-gradient(135deg, #D4849A 0%, #E8A8B8 100%);
// 卡片:高不透明度白,与背景形成明显层次
--card-bg: rgba(255, 255, 255, 0.88);
--card-inner-bg: rgba(255, 255, 255, 0.95);
--sent-card-bg: var(--primary);
}
// ==================== 深色主题 ====================
// 云上舞白 - 深色
@@ -163,6 +200,7 @@
--primary-light: rgba(201, 168, 108, 0.15);
--bg-primary: #1a1816;
--bg-secondary: rgba(40, 36, 32, 0.9);
--bg-secondary-solid: #282420;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #F0EEE9;
@@ -184,6 +222,7 @@
--primary-light: rgba(106, 154, 170, 0.15);
--bg-primary: #141a1c;
--bg-secondary: rgba(30, 40, 44, 0.9);
--bg-secondary-solid: #1e282c;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #E8EEF0;
@@ -205,6 +244,7 @@
--primary-light: rgba(154, 186, 124, 0.15);
--bg-primary: #161a14;
--bg-secondary: rgba(34, 42, 30, 0.9);
--bg-secondary-solid: #222a1e;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #E8F0E4;
@@ -226,6 +266,7 @@
--primary-light: rgba(192, 96, 104, 0.15);
--bg-primary: #1a1416;
--bg-secondary: rgba(42, 32, 34, 0.9);
--bg-secondary-solid: #2a2022;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #F0E8E8;
@@ -247,6 +288,7 @@
--primary-light: rgba(122, 186, 170, 0.15);
--bg-primary: #121a1a;
--bg-secondary: rgba(28, 42, 42, 0.9);
--bg-secondary-solid: #1c2a2a;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #E4F0F0;
@@ -260,6 +302,43 @@
--sent-card-bg: var(--primary);
}
// 繁花如梦 - 深色(夜阑幽梦)
[data-theme="blossom-dream"][data-mode="dark"] {
// 光晕色(供伪元素使用,降低饱和度避免刺眼)
--blossom-pink: #C670C3;
--blossom-purple: #5F4B8B;
--blossom-blue: #3A2A50;
// 主品牌色:藕粉/烟紫粉,降饱和度不刺眼
--primary: #D19EBB;
--primary-rgb: 209, 158, 187;
--primary-hover: #DDB0C8;
--primary-light: rgba(209, 158, 187, 0.15);
// 背景三层:极深黑灰底(去掉紫薯色),面板略浅,卡片再浅一级
--bg-primary: #151316;
--bg-secondary: rgba(34, 30, 36, 0.92);
--bg-secondary-solid: #221E24;
--bg-tertiary: rgba(255, 255, 255, 0.04);
--bg-hover: rgba(209, 158, 187, 0.1);
// 文字
--text-primary: #F0EAF4;
--text-secondary: #A898AE;
--text-tertiary: #6A5870;
// 边框:极细白色内发光,剥离层级
--border-color: rgba(255, 255, 255, 0.07);
--bg-gradient: linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%);
--primary-gradient: linear-gradient(135deg, #D19EBB 0%, #A878A8 100%);
// 卡片:比面板更亮一档,用深灰而非紫色
--card-bg: rgba(42, 38, 46, 0.92);
--card-inner-bg: rgba(52, 48, 56, 0.96);
--sent-card-bg: var(--primary);
}
// 重置样式
* {
margin: 0;

View File

@@ -66,7 +66,7 @@ export interface ElectronAPI {
}
key: {
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
autoGetImageKey: (manualDir?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => () => void
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
}
@@ -500,7 +500,7 @@ export interface ElectronAPI {
}
}>
likes: Array<string>
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 }>

View File

@@ -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
// 引用消息

View File

@@ -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 {