Compare commits

...
9 Commits
Author SHA1 Message Date
Wxw-Gu 08e1294e5d feat: 完善多账号连接诊断与聊天媒体导出
- 新增微信账号发现、环境诊断和分步数据库连接引导
- 支持按账号安全保存数据库密钥及快速切换账号
- 完善 WCDB 历史消息分片读取和分页状态提示
- 支持导出图片、视频和语音,提供原图优先及缩略图回退
- 更新安装指引、兼容版本说明和相关自动化测试
2026-08-03 10:43:14 +08:00
电摇小子andWxw-Gu 224308f0e0 fix: 修复 macOS 会话昵称显示
(cherry picked from commit be092c64e528cfc363f0ddd32c480eea5532799f)
2026-08-03 10:08:59 +08:00
电摇小子andWxw-Gu 3e57a8432d test: 建立桌面端自动化回归测试体系并完善跨平台 CI
(cherry picked from commit 74267ae2f63f8256c3da84b04f5b330e6e9d4c67)
2026-08-03 10:08:28 +08:00
电摇小子andWxw-Gu b4f909a597 fix: 修复语音首播并完善消息解析与会话兼容性
(cherry picked from commit 214090192f5dfc91cdec0269bad434d3e22394d8)
2026-08-03 10:04:45 +08:00
电摇小子andWxw-Gu ee7dc11e92 fix: 优化数据库启动与设置页响应性能
(cherry picked from commit f6614548b0e742bc703b2a5aaa5060720985ed27)
2026-08-03 10:04:19 +08:00
电摇小子andWxw-Gu 90bf1aed90 fix: 支持 wxgf 原图解密并修复缩略图缓存刷新
- 增加 wxgf/HEVC 图片转换支持
- 增加 FFmpeg 跨平台安装、目录填写与能力检测
- 避免缩略图占用原图缓存,下载原图后可即时刷新
- 移除聊天图片的缩略图角标

(cherry picked from commit 4cee159a65496bd30dd690b568c47a120f3fff30)
2026-08-03 10:03:55 +08:00
电摇小子andWxw-Gu a3955d691d fix: 优化 Windows 启动性能与聊天图片缓存
- 增加聊天图片磁盘持久化缓存,重启后直接复用
- 将 DAT 图片解密移至 Worker,避免阻塞主进程
- 增加图片加载优先级和并发控制
- 优化会话目录与图片文件的异步查找
- 使用本地媒体协议加载缓存图片,减少 Base64 IPC 开销
- 优化启动缓存与数据库初始化流程,降低窗口未响应时间

(cherry picked from commit 82bcc8d32ca674de38c745cc9925ed2309887dc7)
2026-08-03 10:03:19 +08:00
Wxw-Gu 8a6d443acc chore: 提升版本 修改打包命令 2026-07-31 11:40:41 +08:00
Wxw-Gu f0601cdc85 feat: 重构README 新增引导功能 2026-07-31 11:26:23 +08:00
103 changed files with 8983 additions and 1072 deletions
+4
View File
@@ -22,3 +22,7 @@ VITE_FILTER_MSG_TYPES=
# AES Key: 16-character string, derived from wxid and code # AES Key: 16-character string, derived from wxid and code
VITE_IMAGE_XOR_KEY= VITE_IMAGE_XOR_KEY=
VITE_IMAGE_AES_KEY= VITE_IMAGE_AES_KEY=
# Electron E2E test window close delay in milliseconds.
# Local default: 2000 (2 seconds). Set to 0 for immediate close.
WXE_E2E_CLOSE_DELAY_MS=2000
+76
View File
@@ -0,0 +1,76 @@
name: Tests
on:
push:
pull_request:
jobs:
desktop-tests:
name: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 7.33.7
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: actions/setup-go@v5
with:
go-version-file: services/wechat-connector/go.mod
cache-dependency-path: services/wechat-connector/go.sum
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm typecheck
- name: Unit tests
run: pnpm test:unit
- name: Component tests
run: pnpm test:component
- name: IPC integration tests
run: pnpm test:integration
- name: Skill installation instruction tests
run: pnpm test:skill-install
- name: WeChat connector tests
run: pnpm test:wechat-connector
- name: Build Electron test application
run: pnpm test:e2e:build
- name: Electron E2E tests
run: pnpm exec playwright test --grep-invert @visual
env:
WXE_E2E_CLOSE_DELAY_MS: 0
- name: Platform visual regression
run: pnpm exec playwright test tests/e2e/visual.spec.ts
env:
WXE_E2E_CLOSE_DELAY_MS: 0
- name: Upload Playwright diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-${{ matrix.os }}
path: |
test-results/
playwright-report/
if-no-files-found: ignore
retention-days: 14
+3
View File
@@ -6,6 +6,9 @@ out
.DS_Store .DS_Store
.eslintcache .eslintcache
*.log* *.log*
coverage/
playwright-report/
test-results/
resources/connectors/wechat/ resources/connectors/wechat/
.omc .omc
.codex/ .codex/
+345 -83
View File
@@ -1,145 +1,318 @@
# WechatExplorer # WechatExplorer
macOS / Windows 微信聊天记录查看与 AI 分析工具。 <p align="center">
是一个基于 Electron + React + TypeScript 开发的本地微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供 AI 智能检索、群聊总结和多格式聊天记录导出功能。 <img src="./build/icon.png" width="120" alt="WechatExplorer Logo" />
</p>
## 项目说明 <h2 align="center">让 AI 读懂你的微信</h2>
本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。 <p align="center">
本地优先的 AI 微信助手<br />
聊天记录查看 · AI 问问微信 · 群聊日报 · Agent · 本地 API
</p>
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。 <p align="center">
<img src="https://img.shields.io/github/stars/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="GitHub stars" />
<img src="https://img.shields.io/github/downloads/Wxw-Gu/WechatExplorer/total?style=for-the-badge" alt="GitHub downloads" />
<img src="https://img.shields.io/github/v/release/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="Latest release" />
</p>
> macOS 支持相对稳定;Windows 已初步支持 但因聊天记录大/机械硬盘等问题 会有所卡顿,仍在持续兼容不同微信版本与本地目录结构。 <p align="center">
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>📦 下载最新版</b></a>
·
<a href="./docs/user-guide/getting-started.md"><b>🚀 第一次使用</b></a>
·
<a href="./docs/user-guide/getting-started.md#遇到问题"><b>📖 使用说明</b></a>
</p>
## ✨ 功能特性 > ⭐ 如果这个项目帮助到了你,欢迎点一个 Star,支持项目持续更新。
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。 <p align="center">
- **AI 智能检索**: 支持全局搜索、指定会话搜索和自然语言提问,帮助快速定位聊天主题与相关证据。 <img src="./public/software-1.png" alt="WechatExplorer AI 微信助手界面" />
- **消息防撤回**: 可在设置中开启,归档并高亮查看对方已撤回消息。 </p>
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
- **实用信息与资源**: 提取分享的链接、资源等信息。
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
- **有趣对话或金句**: 收录群内的精彩对话。
- **问题与解答**: 整理群内的问答内容。
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
- **词云/关键词**: 可视化展示群聊关键词。
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
- **数据导出**: 支持将聊天记录导出为 HTML、CSV、JSON 和 Markdown,支持按时间范围导出并打开文件所在文件夹。
- **诊断日志**: 可在设置的高级选项中控制诊断日志,方便排查运行异常。
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
## 📸 预览 > 像问 ChatGPT 一样,直接询问你的微信聊天记录。
### 日报模板 WechatExplorer 是一个基于 Electron + React + TypeScript 开发的本地优先 AI 微信助手。它不只是查看聊天记录,而是把聊天内容变成可以搜索、总结、分析和交给 Agent 使用的信息。
**支持:**
微信聊天记录查看、AI 微信助手、AI 群聊日报、MCP、Agent、本地 API
## ✨ 为什么选择 WechatExplorer
-**像 ChatGPT 一样搜索整个微信**:用自然语言提问,快速找到聊天上下文。
-**AI 自动生成群聊日报**:自动整理热点、资源、问答和待跟进事项。
-**Agent 可直接读取微信聊天**:支持 Codex、Claude Code、MCP 等 AI 工作流。
-**本地数据库优先**:聊天数据默认保存在本机,不会自动上传。
-**支持微信 3.x / 4.x**:不同微信版本提供对应版本支持。
-**多格式导出**:支持 HTML、Markdown、CSV 和 JSON。
## 🚀 第一次使用
软件已经内置完整的新手引导,通常按下面三步即可开始:
```text
下载软件
连接微信
开始问你的微信
```
首次启动会自动进入「第一次使用」页面。连接成功后,软件会显示「开始探索你的微信」;进入主界面后,还可以随时点击左下角「新手引导」重新查看。
## 📸 功能预览
### AI 群聊日报
<details> <details>
<summary>点击查看完整日报模板</summary> <summary>点击查看完整日报模板</summary>
<br /> <br />
<img src="./public/report-template-1.png" alt="完整日报模板" /> <img src="./public/report-template-1.png" alt="完整群聊日报模板" />
</details> </details>
### AI 群聊日报界面 ### AI 问问微信
<img src="./public/software-1.png" alt="AI 群聊日报页面" /> <img src="./public/ai-search.png" alt="AI 问问微信页面" />
### 检索界面 ### 本地 API 与 Agent
<img src="./public/ai-search.png" alt="检索页面" /> <img src="./public/software-2.png" alt="本地 API 与 Agent 页面" />
### 本地 API 与 Reader Skill ## 🎯 它能帮你做什么
<img src="./public/software-2.png" alt="本地 API 与 Reader Skill 页面" /> ### 🤖 AI 问问微信
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases) 直接向自己的微信提问:
## 📖 使用方法 > “去年我和老板聊过哪些关于涨薪的事情?”
>
> “技术群这周讨论了哪些问题?”
>
> “帮我找到张三发过的项目地址。”
安装、获取数据库密钥、连接微信数据及常见问题,请查看: ### 📰 AI 群聊日报
### [👉 WechatExplorer 完整使用教程](./docs/user-guide/getting-started.md) 选择一个群聊和时间范围,自动生成:
教程包含 macOS 与 Windows 的分步截图,以及数据目录、SIP、图片解密密钥和自动获取失败的排查方法。 - ✅ 今日热点
- ✅ 一句话总结
- ✅ 资源汇总
- ✅ 问答整理
- ✅ 活跃榜
- ✅ 词云与关键词
> 微信 4.0+ 在 macOS / Windows 上已支持部分能力,目前仍在持续适配。如需其他成熟方案,也可参考 [WeFlow](https://github.com/hicccc77/WeFlow) 和 [Chatlog](https://github.com/sjzar/chatlog)。 <details>
<summary>展开查看日报的完整模块</summary>
## 🛠️ 开发配置(可选) - **今日讨论热点**:梳理群内主要话题,支持热度标签。
- **一句话速览**:首屏突出今日核心结论与待跟进事项。
- **实用信息与资源**:提取分享的链接、资源等信息。
- **重要消息汇总**:标记并展示重要消息,带发送者头像。
- **有趣对话或金句**:收录群内的精彩对话。
- **问题与解答**:整理群内的问答内容。
- **尚未解决 / 今日剧情线**:适合工作群和项目群的回顾与跟进。
- **今日群相册 / 语音时长榜 / 临时群友称号**:让图片、语音和氛围型内容也能参与日报。
- **群内数据可视化**:消息热度条形图、话唠榜 TOP5、活跃时间线。
- **词云 / 关键词**:可视化展示群聊关键词。
</details>
本地开发需要 Node.js(推荐 v16+)和 pnpm 7 支持导出 HTML 与 PNG,也支持图片理解和图片生成
### 环境变量 ### 📂 查看聊天
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。 浏览微信好友和群聊的聊天记录,支持查看:
| 变量名 | 说明 | 示例 | - 文本
| ----------------------- | --------------------------- | --------------------------- | - 图片
| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` | - 视频
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` | - 语音
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` | - 文件
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
## 🤖 AI 集成(本地 HTTP API 同时支持头像显示、全局搜索、指定会话搜索、消息防撤回和上下文定位。
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。 ### 📤 导出聊天
支持按会话和时间范围导出聊天记录为 HTML、CSV、JSON 或 Markdown,并可以打开文件所在文件夹。
### 🤖 Agent
通过本地 HTTP API 和内置 Reader Skill,让 Codex、Claude Code 等 Agent 在本机服务运行并获得授权后读取、总结聊天数据。
## 🚀 规划与未来(Roadmap
WechatExplorer 仍在持续演进,未来会围绕 **AI 大模型 + 微信 + Agent** 持续完善能力。
下面是正在设计或计划中的部分功能(不代表发布时间)。
<details>
<summary>点击展开未来规划</summary>
### 🚧 人物镜像(Persona
根据长期聊天记录生成每个人的沟通画像:
- 兴趣标签
- 常聊话题
- 表达风格
- 个性化沟通参考
### 🚧 AI 长期记忆
让 AI 持续理解你的聊天历史,在不同时间跨度内建立上下文,支持长期事项追踪和连续对话。
### 🚧 微信卡片分享
将 AI 日报生成可点击的微信卡片消息,而不仅仅是图片,方便在群聊中传播与查看。
<p align="center">
<img src="./public/微信卡片分享.png" alt="微信卡片分享示例" width="520" />
</p>
### 🚧 退群自动监控
自动记录群聊成员变动:
- 谁加入群聊
- 谁退出群聊
- 变动发生的时间
- 群成员变动记录
<p align="center">
<img src="./public/退群监控.png" alt="退群自动监控示例" width="720" />
</p>
### 💡 更多 AI 能力
包括会议纪要、聊天知识库、长期事项追踪、个人成长分析等更多探索。
WechatExplorer 希望不仅仅是一个聊天记录查看工具,更希望成为一个能够理解、整理和协助管理微信信息的 AI 工作平台。
如果你有好的想法,欢迎提交 Issue 或 Pull Request,一起把它做得更好。
</details>
## ⚙️ 快速开始
### 下载并安装
当前 WechatExplorer / 迹忆版本:`v2.1.6`
应用安装包:[WechatExplorer GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`macOS 按处理器架构选择对应 `.dmg`
| 系统 | 已测试的微信客户端 |
| ------- | ------------------------------------------------------------------------------------------------- |
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) |
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) |
微信客户端来自上表对应的第三方版本存档,请自行核对来源与文件完整性。
正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录;但应用缓存和本地设置可能随版本升级变化。升级前仍建议使用微信官方迁移或备份功能备份重要记录,不要将唯一副本保存在单一设备。
### 连接微信
按照软件内置的「第一次使用」引导完成连接:
1. 确认微信数据目录。
2. 让微信停在登录页面。
3. 点击“开始获取”,按提示完成连接。
Windows 已完整支持,不需要关闭 SIP。macOS 首次自动获取数据库密钥前,需要关闭 SIP 并完成系统授权。
### 配置 AI
进入「设置 → AI 模型」,添加模型服务商并填写 API Key,保存并测试成功后即可使用「问问微信」和「日报」。支持:
- OpenAI
- DeepSeek
- Claude
- Moonshot
- OpenAI 兼容接口
### 下一步
| 你想做什么 | 从哪里开始 |
| ----------------- | ---------------------------------------------------------------- |
| 重新查看连接步骤 | 点击左下角「新手引导」 |
| 直接向微信提问 | 打开「问问微信」 |
| 生成群聊日报 | 打开「日报」 |
| 浏览聊天记录 | 打开「档案」 |
| 导出聊天记录 | 打开「导出」 |
| 让 Agent 读取微信 | [Reader Skill 文档](./docs/skill/wechatexplorer-reader/SKILL.md) |
## 🖥️ 支持平台与微信版本
- **Windows**:已完整支持 Windows x64,不需要关闭 SIP。
- **macOS**:支持 Intel 和 Apple Silicon;首次自动获取数据库密钥前,需要关闭 SIP 并完成系统授权。
- **微信 3.0**:请使用 [v1.1.0 版本](https://github.com/Wxw-Gu/WechatExplorer/releases/tag/v1.1.0)。
- **微信 4.0**:使用当前 Releases 中的最新版。
不同微信版本、账号和数据目录可能存在差异,遇到连接问题时请优先参考 [使用说明](./docs/user-guide/getting-started.md)。
## 🔒 隐私与权限
- WechatExplorer 只读取你有权访问的本机微信数据。
- 不使用 AI 时,应用不会因为读取聊天记录而自动上传聊天内容。
- 使用 AI 问问微信、日报或图片理解时,相关内容会发送到你配置的模型服务。
- 本地 API 默认监听 `127.0.0.1`,无鉴权;请按可信网络范围配置。
- 消息防撤回、图片解密和数据库密钥等能力都应只用于你有权访问的数据。
## 🔌 高级能力:本地 HTTP API 与 Agent
<details>
<summary>展开本地 HTTP API、Reader Skill 和 Agent 说明</summary>
WechatExplorer 内置一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`,纯本地、无鉴权。完成数据库连接后,API 会自动启用。
### 启用本地 API ### 启用本地 API
API 服务在 WechatExplorer 启动时自动启用,**不需要任何配置**。只需要: 1. 安装并启动 WechatExplorer
2. 完成首次密钥配置,解锁 WCDB 数据库。
1. 安装并启动 WechatExplorer 3.`http://127.0.0.1:6131` 使用本地 API。
2. 完成首次密钥配置(主窗口第一步),解锁 WCDB 数据库
3. API 即在 `http://127.0.0.1:6131` 可用
### 7×24 提供 API(菜单栏常驻模式) ### 7×24 提供 API(菜单栏常驻模式)
默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,启用菜单栏模式: 默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,可以启用菜单栏模式:
```bash ```bash
# 任选一种方式
WXE_TRAY=1 open /Applications/WechatExplorer.app WXE_TRAY=1 open /Applications/WechatExplorer.app
/Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray /Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray
``` ```
启用后: 启用后:
- macOS dock 图标自动隐藏 - macOS Dock 图标自动隐藏
- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态 - 菜单栏出现 WechatExplorer 图标,可重新打开主窗口、查看 API 状态
- 主窗口关闭后 API 服务继续运行 - 主窗口关闭后 API 服务继续运行
### API 端点一览 ### API 端点一览
| 端点 | 说明 | | 端点 | 说明 |
| ------------------------------------------------ | --------------------------------------- | | ------------------------------------------------ | --------------------------------------- |
| `GET /api/v1/health` | 健康检查 | | `GET /api/v1/health` | 健康检查 |
| `GET /api/v1/current_time` | 获取当前本地时间用于"今天/昨天"换算 | | `GET /api/v1/current_time` | 获取当前本地时间用于今天 / 昨天换算 |
| `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 | | `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 |
| `GET /api/v1/chatroom?keyword=xxx` | 搜索群聊 | | `GET /api/v1/chatroom?keyword=xxx` | 搜索群聊 |
| `GET /api/v1/chatlog?talker=xxx&time=2026-07-03` | 聊天记录 | | `GET /api/v1/chatlog?talker=xxx&time=2026-07-03` | 聊天记录 |
| `GET /api/v1/group_snapshot?md5=xxx` | 群成员快照 | | `GET /api/v1/group_snapshot?md5=xxx` | 群成员快照 |
| `GET /api/v1/resolve?q=群昵称` | 把昵称/wxid/md5 解析成 md5 | | `GET /api/v1/resolve?q=群昵称` | 把昵称wxidmd5 解析成 md5 |
详细参数、返回结构时间格式见 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。 详细参数、返回结构时间格式见 [Reader Skill 文档](./docs/skill/wechatexplorer-reader/SKILL.md)。
### 安装 Reader Skill,让 Agent 读取和总结群聊 ### 安装 Reader Skill,让 Agent 读取和总结群聊
WechatExplorer 已内置 **Reader Skill**,无需手动复制仓库中的 `SKILL.md` WechatExplorer 已内置 Reader Skill,无需手动复制仓库中的 `SKILL.md`
1. 启动 WechatExplorer,并确认数据库已连接、本地 API 已运行。 1. 启动 WechatExplorer,并确认数据库已连接、本地 API 已运行。
2. 打开应用内的 **API** 页面。 2. 打开应用内的「API」页面。
3. 在“快速接入”中选择 **Codex****Claude Code** 3. 在“快速接入”中选择 CodexClaude Code。
4. 点击复制安装指令,将指令粘贴给对应 Agent 执行。 4. 点击复制安装指令,将指令粘贴给对应 Agent 执行。
5. 安装完成后,可以直接向 Agent 提问: 5. 安装完成后,可以直接向 Agent 提问:
> “今天技术交流群聊了什么?” > “今天技术交流群聊了什么?”
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。详细接口说明仍可查看 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。 Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。
### curl 调试示例(可选) ### curl 调试示例(可选)
@@ -149,7 +322,7 @@ Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊
# 健康检查 # 健康检查
curl http://127.0.0.1:6131/api/v1/health curl http://127.0.0.1:6131/api/v1/health
# 今天 摸鱼交流群 的聊天记录 # 今天摸鱼交流群的聊天记录
curl -G "http://127.0.0.1:6131/api/v1/chatlog" \ curl -G "http://127.0.0.1:6131/api/v1/chatlog" \
--data-urlencode "talker=摸鱼交流群" \ --data-urlencode "talker=摸鱼交流群" \
--data-urlencode "time=$(date +%Y-%m-%d)" --data-urlencode "time=$(date +%Y-%m-%d)"
@@ -159,11 +332,73 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
--data-urlencode "q=摸鱼交流群" --data-urlencode "q=摸鱼交流群"
``` ```
</details>
## 🛠️ 开发配置(可选)
<details>
<summary>展开开发配置、环境变量和构建命令</summary>
本地开发需要 Node.js(建议当前 LTS)和 pnpm 7+
```bash
pnpm install
pnpm dev
```
本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户不需要配置 `.env`,也可以直接在软件“设置”里填写 AI 和图片解密配置。
### 环境变量
| 变量名 | 说明 | 示例 |
| ----------------------- | ----------------------------- | --------------------------- |
| `VITE_DB_KEY` | 微信数据库密钥(32 字节 hex) | `YOUR_DB_KEY_HERE` |
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥(hex 格式) | `0x40` |
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥(16 字符) | `YOUR_AES_KEY_HERE` |
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
常用命令:
```bash
pnpm typecheck # 类型检查
pnpm lint # ESLint 检查
pnpm build # 构建
pnpm build:win # 构建 Windows x64 安装包
```
</details>
## ❓ FAQ
<details>
<summary>展开常见问题</summary>
### 我已经连接成功,怎么重新查看教程?
点击左下角「新手引导」。首次连接流程、AI 配置入口、群聊日报、问问微信和完整教程都会再次展示。
### 微信 3.0 应该下载哪个版本?
请使用 [v1.1.0 版本](https://github.com/Wxw-Gu/WechatExplorer/releases/tag/v1.1.0)。微信 4.0 用户使用当前 Releases 中的最新版。
### AI 问问微信或群聊日报不可用怎么办?
进入「设置 → AI 模型」,添加模型服务商并填写 API Key,确认 Base URL 和模型名称正确,然后保存并测试连接。
### 连接失败怎么办?
请先查看 [使用说明](./docs/user-guide/getting-started.md) 的“遇到问题”部分,重点确认微信数据目录、微信登录状态、微信版本和 macOS SIP 设置。
</details>
## ⚠️ 免责声明 ## ⚠️ 免责声明
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。 本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议,并仅处理你有权访问的数据
## Star History ## Star History
<a href="https://www.star-history.com/?repos=Wxw-Gu%2FWechatExplorer&type=date&legend=top-left"> <a href="https://www.star-history.com/?repos=Wxw-Gu%2FWechatExplorer&type=date&legend=top-left">
<picture> <picture>
@@ -173,14 +408,41 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
</picture> </picture>
</a> </a>
## 🔗 参考致谢 ## 致谢
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer) <details>
- [WeFlow](https://github.com/hicccc77/WeFlow) <summary>展开致谢与参考项目</summary>
- [chatlog](https://github.com/sjzar/chatlog)
## 📱 交流与反馈 WechatExplorer 在开发过程中参考了多个优秀的开源项目,感谢这些项目作者的工作与分享。
特别感谢:
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
- 提供了微信数据库解析相关思路。
- **[WeFlow](https://github.com/hicccc77/WeFlow)**
- 参考了数据库密钥获取、图片解密等实现思路。
- **[chatlog](https://github.com/sjzar/chatlog)**
- 提供了聊天记录导出与数据处理方面的参考。
在此基础上,WechatExplorer 进行了重新设计与实现,包括:
- AI 问问微信
- AI 群聊日报
- 本地 HTTP API
- Reader Skill
- Agent Hub
- 新手引导
- Electron + React 全新界面
- 本地优先 AI 工作流
感谢所有开源作者。
</details>
## 💬 交流与反馈
请先完成 [第一次使用与问题排查](./docs/user-guide/getting-started.md),再查看问题排查和 FAQ。只有自助排查仍无法解决时,再扫码进入交流群。
<p align="center"> <p align="center">
<img src="./public/二维码.jpg" alt="WechatExplorer 交流二维码" width="280" /> <img src="./public/二维码.jpg" alt="WechatExplorer 交流与售后群二维码" width="280" />
</p> </p>
+194 -65
View File
@@ -1,121 +1,250 @@
# WechatExplorer 使用教程 # WechatExplorer:第一次使用与问题排查
本文介绍如何安装 WechatExplorer、自动获取微信数据库密钥,并完成首次连接 这份说明解决三件事:第一次连接微信、连接成功后如何开始使用,以及遇到问题时如何自助排查
## 1. 使用前准备 如果你已经进入软件,忘记了连接步骤,可以直接点击左下角「新手引导」,重新查看首次连接流程、AI 配置入口和群聊日报入口。
### 支持的版本 ## 你现在要做什么
| 系统 | 已测试的微信版本 | 说明 | - [我第一次使用,想连接微信](#第一次连接微信)
| --- | --- | --- | - [我已经连接成功,下一步做什么](#连接成功后做什么)
| macOS | `4.1.8.100` | 支持相对稳定;自动获取密钥前需要关闭 SIP | - [我想重新查看引导](#重新查看新手引导)
| Windows | `4.1.9.57` | 已初步支持;不同安装路径和数据目录可能仍需手动调整 | - [我想配置 AI](#配置-ai)
- [我遇到问题](#遇到问题)
- [我想让 Agent 读取微信](#接入-api-reader-skill-或-agent)
- macOS 微信下载:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) > 正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录。应用缓存和本地设置可能随版本升级发生变化。系统故障、磁盘异常、误操作和微信自身迁移不受本应用控制,因此升级前仍建议使用微信官方迁移或备份功能备份重要聊天记录,不要将唯一副本保存在单一设备。
- Windows 微信下载:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
- WechatExplorer 下载:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)
> [!IMPORTANT] ## 开始前确认
> WechatExplorer 必须取得本机微信数据库密钥才能读取聊天记录。请仅处理你有权访问的数据。
### macOS:关闭 SIP | 系统 | 已测试的微信客户端 | 需要注意 |
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前需要关闭 SIP 并完成授权 |
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 已完整支持;首次使用时请确认微信数据目录 |
macOS 自动获取密钥前需要关闭 SIP,具体操作见 [macOS 关闭 SIP 教程](../mac-disable-sip.md) - WechatExplorer 当前面向微信 4.0 数据结构
- Windows 不需要关闭 SIP。
- macOS 首次自动获取数据库密钥需要按页面提示完成系统授权。
- WechatExplorer 必须取得当前微信账号对应的数据库密钥才能读取聊天记录。
- 请只处理你有权访问的微信数据。
关闭 SIP 会降低系统安全性。建议了解风险后再操作,并在不再需要自动获取密钥时重新开启 WechatExplorer / 迹忆应用安装包:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`macOS 按处理器架构选择对应 `.dmg`。微信客户端请使用上方“已测试的微信客户端”链接
## 2. 安装 WechatExplorer ## 第一次连接微信
### macOS ### 1. 安装 WechatExplorer
#### Windows
1. 从 Releases 下载 Windows `-setup.exe` 安装包。
2. 双击安装包,按向导完成安装。
3. 启动 WechatExplorer。
#### macOS
1. 从 Releases 下载 `.dmg` 文件。 1. 从 Releases 下载 `.dmg` 文件。
2. 打开 DMG,将 WechatExplorer 拖入“应用程序”文件夹。 2. 打开 DMG,将 WechatExplorer 拖入“应用程序”文件夹。
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。 3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
4. 如果系统提示应用已损坏,在终端执行: 4. 如果系统提示应用已损坏,在终端执行:
```bash ```bash
xattr -cr "/Applications/WechatExplorer.app" xattr -cr "/Applications/WechatExplorer.app"
``` ```
### Windows 5. 如果这是第一次在 macOS 上自动获取数据库密钥,先完成 [关闭 SIP 教程](../mac-disable-sip.md)。关闭 SIP 会降低系统安全性,完成密钥配置后建议重新开启。
1. 从 Releases 下载 `-setup.exe` 安装包。 ### 2. 按软件内引导连接微信
2. 双击安装,并按安装向导完成操作。
## 3. 自动获取密钥 首次启动会自动进入「第一次使用」页面。页面会根据当前系统显示连接方式和注意事项:
### 第一步:确认微信数据目录 <p align="center">
<img src="../../public/setup-page.png" alt="第一次使用连接页面" width="820" />
</p>
启动 WechatExplorer 后,先检查页面中的“存储路径”是否正确。 通常按下面三步操作即可:
![确认微信数据目录](./images/initial-setup.png) 1. **确认微信数据目录**:自动识别不准确时,在页面中修改存储路径。
2. **让微信停在登录页面**:如果微信已经登录,先退出微信登录,不只是关闭窗口。
3. **点击开始获取**:软件会尝试获取数据库密钥。按提示可以登录后,再回到微信完成登录。
Windows 当前不会扫描二级目录。如果没有正确识别微信数据,请进入“设置”,手动选择微信数据所在目录 Windows 已完整支持,不需要关闭 SIP。macOS 首次获取密钥前,需要按页面提示完成授权并关闭 SIP
![Windows 自定义数据目录](./images/windows-data-path.png) ### 3. 连接成功
### 第二步:让微信停留在登录页面 连接成功后,软件会进入聊天档案,并显示「开始探索你的微信」引导:
如果微信已经登录,请先退出登录;然后重新打开微信,让它停留在未登录页面,暂时不要点击登录。 <p align="center">
<img src="../../public/first-use-welcome.png" alt="连接成功后的新手引导" width="760" />
</p>
![微信未登录页面](./images/wechat-login-window.png) 这里推荐先体验「AI 群聊日报」,也可以直接查看聊天、问问微信或配置 AI 模型。
### 第三步:开始获取密钥 ## 连接成功后做什么
返回 WechatExplorer,点击“自动获取密钥”。 ### AI 问问微信
- **Windows**:看到“Hook 注入成功”后,返回微信完成登录。 打开「问问微信」,用自然语言向自己的微信提问,例如:
- **macOS**:系统会弹出授权提示,请输入当前 macOS 用户密码并完成授权,然后返回微信完成登录。
![macOS 授权页面](./images/macos-authorization.png) - “技术群这周讨论了哪些问题?”
- “帮我找到张三发过的项目地址。”
- “去年我和老板聊过哪些关于涨薪的事情?”
> 点击“自动获取密钥”前,微信必须停留在登录页面。WechatExplorer 提示可以登录后,再回到微信完成登录 如果还没有配置 AI,点击「设置 → AI 模型」添加模型服务商并测试连接
### 第四步:完成连接 ### AI 群聊日报
如果系统环境和微信版本符合要求,WechatExplorer 会自动填写数据库密钥并连接数据库。连接成功后即可查看、搜索和导出聊天记录,也可以配置 AI 服务生成群聊总结 1. 打开「日报」
2. 选择一个群聊和时间范围。
3. 按需要选择日报内容和模板。
4. 开始生成,完成后查看或导出 HTML 与 PNG。
![密钥获取完成](./images/setup-complete.png) 日报会整理讨论摘要、关键主题、重要消息、资源、问题和待跟进事项,并保留证据来源。
## 4. 图片解密密钥 ### 查看聊天
微信 4.0 及以上版本的图片通常以 `.dat` 文件存储,显示图片还需要: 1. 打开「档案」。
2. 选择好友或群聊。
3. 浏览历史消息,也可以按关键词定位会话。
### 导出聊天
打开「导出」,选择联系人或群聊、时间范围和格式。支持 HTML、CSV、JSON 和 Markdown。
## 重新查看新手引导
连接成功后,首次弹窗关闭不会影响功能使用。需要重新查看时,点击主界面左下角的「新手引导」:
<p align="center">
<img src="../../public/guide-entry.png" alt="主界面左下角新手引导入口" width="760" />
</p>
新手引导会再次展示:
- AI 群聊日报入口。
- 查看聊天记录入口。
- 问问微信入口。
- AI 模型配置入口。
- 完整使用教程入口。
## 配置 AI
WechatExplorer 支持 OpenAI 兼容接口,也提供 DeepSeek、OpenAI、Claude、Moonshot 等常用配置方式。
1. 进入「设置 → AI 模型」。
2. 添加模型服务商并填写 API Key。
3. 确认 Base URL 和模型名称正确。
4. 保存并测试连接。
5. 返回「问问微信」或「日报」重试。
AI 功能使用你配置的模型服务。相关聊天内容会按请求发送给该服务;是否启用以及使用哪一个服务由你决定。
## 遇到问题
先判断你遇到的现象,再按对应路径处理。
| 现象 | 优先检查 |
| --------------------------------- | -------------------------------------------------------- |
| 软件打不开 | macOS 安全提示或应用损坏处理;Windows 重新运行安装包 |
| 找不到微信数据 | 在首次连接页面或设置中确认数据目录,Windows 检查目录层级 |
| 获取不到数据库密钥 | 微信是否停留在登录页面、微信和应用是否同时运行 |
| 数据库连接失败 | 当前账号是否匹配、微信版本是否兼容、数据库目录是否正确 |
| 已连接但图片不显示 | 配置图片 XOR Key 和 AES Key |
| AI 问问微信或日报不可用 | 在「设置 → AI 模型」配置并测试模型服务 |
| API、Reader Skill 或 Agent 不可用 | 先连接数据库,再确认 API 服务状态和对应配置 |
### 软件打不开
#### macOS
- 出现“无法打开,因为开发者无法验证”:前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
- 出现“应用已损坏”:确认应用位于“应用程序”目录,再执行:
```bash
xattr -cr "/Applications/WechatExplorer.app"
```
#### Windows
确认下载的是 Releases 中的 `-setup.exe` 安装包,并按安装向导完成安装。Windows 不需要关闭 SIP。
### 找不到微信数据
在首次连接页面确认“存储路径”。如果没有自动识别:
1. 打开「设置」。
2. 手动选择微信数据所在目录。
3. 返回连接页面,重新测试连接。
Windows 当前不会扫描二级目录,请确认目录没有多选或少选一层目录。
### 获取不到数据库密钥
按顺序检查:
1. 微信版本是否与上方已测试版本一致。
2. 点击“开始获取”时,微信是否停留在未登录页面。
3. 微信和 WechatExplorer 是否都保持运行。
4. 微信数据目录是否准确。
5. macOS 是否已关闭 SIP 并完成系统授权。
仍然失败时,可以在连接页面切换为“高级用户:已有数据库密钥?手动连接”,粘贴从其他兼容工具中取得的数据库密钥。手动输入的密钥必须与当前微信账号匹配。
### 数据库连接失败或账号不匹配
数据库密钥与微信账号绑定。请确认:
- 当前微信登录的是获取密钥时对应的账号。
- WechatExplorer 选择的是该账号的数据目录。
- 没有把其他账号或旧数据目录的密钥粘贴进来。
### 已连接但图片无法显示
微信 4.0 的图片通常以 `.dat` 文件存储。显示图片还需要:
- **XOR Key**:单字节十六进制值,例如 `0x40`。 - **XOR Key**:单字节十六进制值,例如 `0x40`。
- **AES Key**:用于 AES-128-ECB 解密的 16 字符字符串。 - **AES Key**:用于 AES-128-ECB 解密的 16 字符字符串。
可以通过以下方式配置: 进入「设置 → 图片解密密钥」,选择自动获取或手动填写。也可以从 WeFlow 或 Chatlog 的设置中导出后填写。文字聊天记录不受图片密钥影响。
1. 使用首次连接页面的“自动获取密钥”。 ### 我已经连接成功,怎么重新查看教程?
2. 在“设置 → 图片解密密钥”中自动获取或手动填写。
3. 从 WeFlow 或 Chatlog 的设置中导出后手动填写。
数据库连接成功但图片无法显示时,请优先检查这两项密钥 点击左下角「新手引导」
## 5. 常见问题 首次连接流程、AI 配置入口、群聊日报、问问微信和完整教程都会再次展示。
### 自动获取密钥失败 ## 接入 API、Reader Skill 或 Agent
请依次确认: 这是高级使用路径,请先完成数据库连接并熟悉「问问微信、日报、档案、导出」的基础流程。
1. 微信版本是否与上方已测试版本一致。 ### Reader Skill
2. 点击“自动获取密钥”时,微信是否停留在未登录页面。
3. 微信数据目录是否正确;Windows 用户尤其需要检查是否多选或少选了一层目录。
4. macOS 是否已按教程关闭 SIP,并完成系统授权。
5. 微信和 WechatExplorer 是否都保持运行。
仍然失败时,可以切换到“手动输入”,粘贴从其他兼容工具中取得的数据库密钥 1. 打开应用的「API」页面
2. 确认本地 API 已运行;如果已停止,点击“启动服务”。
3. 在“快速接入”中选择 Codex 或 Claude Code。
4. 复制安装指令,粘贴给对应 Agent 执行。
5. 安装完成后,让 Agent 读取和总结本地聊天。
### Windows 使用时卡顿 本地 API 默认地址为 `http://127.0.0.1:6131`,默认仅监听本机且无鉴权。详细端点和参数见 [Reader Skill 文档](../skill/wechatexplorer-reader/SKILL.md)。
Windows 支持仍处于初步阶段,不同微信版本、安装路径、数据目录和权限环境可能存在差异。建议优先使用上方已测试的微信版本。 ### Agent Hub
### 数据会上传吗? 应用内的「Agent」页面用于管理 WechatExplorer 的 Agent 连接与运行状态,属于高级功能。
聊天数据库在本机读取和处理。只有使用 AI 总结功能时,相关聊天内容才会按你配置的模型服务发送;是否启用以及使用哪个服务由你决定。 ## 数据与隐私
## 6. 下一步 - WechatExplorer 只读取你有权访问的本机微信数据。
- 不使用 AI 时,应用不会因为读取聊天记录而自动上传聊天内容。
- 使用 AI 问问微信、日报或图片理解时,相关内容会发送到你配置的模型服务。
- 本地 API 默认监听 `127.0.0.1`,且无鉴权。不要将它暴露在不可信的局域网环境中。
- 在应用“设置”中填写兼容 OpenAI API 的模型服务和 API Key,使用 AI 总结功能。 ## 仍然无法解决?
- 在应用的 **API** 页面安装 Reader Skill,让 Codex 或 Claude Code 读取和总结本地群聊。
- 本地 API 的端点和调试方法见项目 [README](../../README.md#ai-集成本地-http-api)。 请先完成上面的自助排查,再进入交流/售后群。提问时一次性提供:
1. 操作系统和版本。
2. 微信版本。
3. WechatExplorer 版本。
4. 当前处于哪一步,以及完整错误信息。
5. 必要截图;请遮挡账号、数据库密钥、API Key 和其他敏感信息。
交流二维码位于项目 [README](../../README.md) 文末。
+21 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "wechatexplorer", "name": "wechatexplorer",
"version": "2.1.5", "version": "2.1.6",
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手", "description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
"keywords": [ "keywords": [
"wechat", "wechat",
@@ -17,6 +17,7 @@
}, },
"main": "./out/main/index.js", "main": "./out/main/index.js",
"scripts": { "scripts": {
"test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:wechat-connector && pnpm test:e2e:build && playwright test",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint --cache .", "lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
@@ -28,6 +29,13 @@
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev", "dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...", "test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:component": "vitest run --config vitest.component.config.ts",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:e2e:build": "electron-vite build",
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
"build:wechat-connector": "node scripts/build-wechat-connector.cjs", "build:wechat-connector": "node scripts/build-wechat-connector.cjs",
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64", "build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64",
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64", "build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64",
@@ -41,6 +49,8 @@
"release": "npm run release:mac && npm run release:win", "release": "npm run release:mac && npm run release:win",
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always", "release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
"release:win": "npm run typecheck && npm run build:wechat-connector:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64 --publish always", "release:win": "npm run typecheck && npm run build:wechat-connector:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64 --publish always",
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux" "build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
}, },
"dependencies": { "dependencies": {
@@ -48,6 +58,7 @@
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0", "@koromix/koffi-win32-x64": "3.1.0",
"@tanstack/react-virtual": "^3.14.6", "@tanstack/react-virtual": "^3.14.6",
"cross-env": "^10.1.0",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
"fzstd": "^0.1.1", "fzstd": "^0.1.1",
@@ -61,12 +72,18 @@
"@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/tsconfig": "^2.0.0",
"@playwright/test": "^1.62.1",
"@rollup/rollup-darwin-arm64": "^4.62.2", "@rollup/rollup-darwin-arm64": "^4.62.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/node": "^22.19.1", "@types/node": "^22.19.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.1.10",
"electron": "^43.0.0", "electron": "^43.0.0",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
@@ -74,12 +91,14 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"jsdom": "^30.0.1",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"sass": "^1.102.0", "sass": "^1.102.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6" "vite": "^7.2.6",
"vitest": "^4.1.10"
}, },
"pnpm": { "pnpm": {
"supportedArchitectures": { "supportedArchitectures": {
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
testMatch: /.*\.spec\.ts/,
timeout: 45_000,
expect: { timeout: 8_000 },
fullyParallel: false,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI
? [['line'], ['html', { outputFolder: 'playwright-report', open: 'never' }]]
: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
outputDir: 'test-results',
snapshotPathTemplate: 'tests/e2e/__screenshots__/{platform}/{testFilePath}/{arg}{ext}',
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
}
})
+819 -11
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+2 -1
View File
@@ -13,6 +13,7 @@ const sanitize = (value: unknown, depth = 0): unknown => {
return value return value
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***') .replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***') .replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
.replace(/\b(?:0x)?[a-f0-9]{64}\b/gi, '***')
.slice(0, 2000) .slice(0, 2000)
} }
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1)) if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1))
@@ -56,7 +57,7 @@ export class AppLogger {
mode: isPackagedRuntime() ? 'packaged' : 'development', mode: isPackagedRuntime() ? 'packaged' : 'development',
level: entry.level, level: entry.level,
scope: String(entry.scope || 'app').slice(0, 80), scope: String(entry.scope || 'app').slice(0, 80),
message: String(entry.message || '').slice(0, 500), message: String(sanitize(entry.message || '')).slice(0, 500),
details: sanitize(entry.details || {}) details: sanitize(entry.details || {})
} }
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' }) fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
+64 -16
View File
@@ -1,6 +1,7 @@
import { app, safeStorage } from 'electron' import { app, safeStorage } from 'electron'
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'path' import path from 'path'
import crypto from 'crypto'
import type { DatabaseKeyStorageResult } from '../shared/database-key' import type { DatabaseKeyStorageResult } from '../shared/database-key'
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '') const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
@@ -9,32 +10,42 @@ export const isValidDatabaseKey = (value: string): boolean =>
/^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value)) /^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value))
export class DatabaseKeyStore { export class DatabaseKeyStore {
private get filePath(): string { private get legacyFilePath(): string {
return path.join(app.getPath('userData'), 'wechat-db-key.bin') return path.join(app.getPath('userData'), 'wechat-db-key.bin')
} }
async getStatus(): Promise<{ saved: boolean; encryptionAvailable: boolean }> { private get directoryPath(): string {
return path.join(app.getPath('userData'), 'database-keys')
}
private filePath(accountRoot: string): string {
const normalized = path.resolve(accountRoot).toLowerCase()
const id = crypto.createHash('sha256').update(normalized).digest('hex')
return path.join(this.directoryPath, `${id}.bin`)
}
async getStatus(accountRoot: string): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
return { return {
saved: await fs.pathExists(this.filePath), saved: Boolean(accountRoot) && (await fs.pathExists(this.filePath(accountRoot))),
encryptionAvailable: safeStorage.isEncryptionAvailable() encryptionAvailable: safeStorage.isEncryptionAvailable()
} }
} }
async load(): Promise<DatabaseKeyStorageResult> { async load(accountRoot: string): Promise<DatabaseKeyStorageResult> {
try { try {
const status = await this.getStatus() const status = await this.getStatus(accountRoot)
if (!status.saved) return { success: true, ...status } if (!status.saved) return { success: true, ...status }
if (!status.encryptionAvailable) { if (!status.encryptionAvailable) {
return { success: false, error: '系统安全存储不可用', ...status } return { success: false, error: '系统安全存储不可用', ...status }
} }
const encrypted = await fs.readFile(this.filePath) const encrypted = await fs.readFile(this.filePath(accountRoot))
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted)) const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
if (!isValidDatabaseKey(key)) { if (!isValidDatabaseKey(key)) {
return { success: false, error: '已保存的密钥格式无效', ...status } return { success: false, error: '已保存的密钥格式无效', ...status }
} }
return { success: true, key, ...status } return { success: true, key, ...status }
} catch (error) { } catch (error) {
const status = await this.getStatus() const status = await this.getStatus(accountRoot)
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@@ -43,13 +54,49 @@ export class DatabaseKeyStore {
} }
} }
async save(rawKey: string): Promise<DatabaseKeyStorageResult> { async loadLegacy(): Promise<DatabaseKeyStorageResult> {
const saved = await fs.pathExists(this.legacyFilePath)
const encryptionAvailable = safeStorage.isEncryptionAvailable()
if (!saved) return { success: true, saved, encryptionAvailable }
if (!encryptionAvailable) {
return { success: false, error: '系统安全存储不可用', saved, encryptionAvailable }
}
try {
const key = normalizeDatabaseKey(
safeStorage.decryptString(await fs.readFile(this.legacyFilePath))
)
return isValidDatabaseKey(key)
? { success: true, key, saved, encryptionAvailable }
: { success: false, error: '旧版密钥格式无效', saved, encryptionAvailable }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
saved,
encryptionAvailable
}
}
}
async clearLegacy(): Promise<void> {
await fs.remove(this.legacyFilePath)
}
async save(accountRoot: string, rawKey: string): Promise<DatabaseKeyStorageResult> {
const key = normalizeDatabaseKey(rawKey) const key = normalizeDatabaseKey(rawKey)
if (!accountRoot.trim()) {
return {
success: false,
error: '请先选择微信账号',
saved: false,
encryptionAvailable: safeStorage.isEncryptionAvailable()
}
}
if (!isValidDatabaseKey(key)) { if (!isValidDatabaseKey(key)) {
return { return {
success: false, success: false,
error: '密钥必须是 64 位十六进制字符', error: '密钥必须是 64 位十六进制字符',
saved: await fs.pathExists(this.filePath), saved: await fs.pathExists(this.filePath(accountRoot)),
encryptionAvailable: safeStorage.isEncryptionAvailable() encryptionAvailable: safeStorage.isEncryptionAvailable()
} }
} }
@@ -57,29 +104,30 @@ export class DatabaseKeyStore {
return { return {
success: false, success: false,
error: '系统安全存储不可用', error: '系统安全存储不可用',
saved: await fs.pathExists(this.filePath), saved: await fs.pathExists(this.filePath(accountRoot)),
encryptionAvailable: false encryptionAvailable: false
} }
} }
try { try {
await fs.ensureDir(path.dirname(this.filePath)) const filePath = this.filePath(accountRoot)
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 }) await fs.ensureDir(this.directoryPath)
await fs.chmod(this.filePath, 0o600) await fs.writeFile(filePath, safeStorage.encryptString(key), { mode: 0o600 })
await fs.chmod(filePath, 0o600)
return { success: true, key, saved: true, encryptionAvailable: true } return { success: true, key, saved: true, encryptionAvailable: true }
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
saved: await fs.pathExists(this.filePath), saved: await fs.pathExists(this.filePath(accountRoot)),
encryptionAvailable: true encryptionAvailable: true
} }
} }
} }
async clear(): Promise<{ success: boolean; error?: string }> { async clear(accountRoot: string): Promise<{ success: boolean; error?: string }> {
try { try {
await fs.remove(this.filePath) if (accountRoot) await fs.remove(this.filePath(accountRoot))
return { success: true } return { success: true }
} catch (error) { } catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) } return { success: false, error: error instanceof Error ? error.message : String(error) }
+7 -4
View File
@@ -1,6 +1,6 @@
import type { Message } from '../shared/types' import type { Message } from '../shared/types'
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}` export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.media-status{margin-top:8px;padding:6px 8px;border-left:3px solid #b27a18;background:#fff8e8;color:#79530f;font-size:12px;line-height:1.5}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}.lightbox-close{position:fixed;top:20px;right:20px;z-index:11;width:42px;height:42px;border:1px solid #ffffff66;border-radius:50%;background:#14231dcc;color:#fff;font-size:30px;line-height:1;cursor:pointer}`
const safe = (value: unknown): string => const safe = (value: unknown): string =>
String(value ?? '').replace( String(value ?? '').replace(
/[&<>"']/g, /[&<>"']/g,
@@ -14,7 +14,10 @@ export function renderExportPage(name: string, messages: Message[]): string {
? `<img src="${safe(m.img)}" alt="">` ? `<img src="${safe(m.img)}" alt="">`
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1)) : safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
const audio = m.voiceDataUrl const audio = m.voiceDataUrl
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${m.voiceDataUrl}"></audio></div>` ? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${safe(m.voiceDataUrl)}"></audio></div>`
: ''
const mediaStatus = m.exportMediaError
? `<div class="media-status">${safe(m.exportMediaError)}</div>`
: '' : ''
const quote = const quote =
m.contentData?.type === 'quote' m.contentData?.type === 'quote'
@@ -34,8 +37,8 @@ export function renderExportPage(name: string, messages: Message[]): string {
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>` : `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
const isPat = m.contentData?.type === 'system' && m.contentData.pat const isPat = m.contentData?.type === 'system' && m.contentData.pat
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '') const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>` return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div>${mediaStatus}</div></div></article>`
}) })
.join('') .join('')
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)box.classList.remove('open')});update()})()</script></body></html>` return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><button class="lightbox-close" id="lightbox-close" type="button" aria-label="关闭图片预览">×</button><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image'),closeButton=document.querySelector('#lightbox-close');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const closeLightbox=()=>{box.classList.remove('open');zoom=1;updateZoom()};const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)closeLightbox()});closeButton.addEventListener('click',closeLightbox);document.addEventListener('keydown',event=>{if(event.key==='Escape')closeLightbox()});update()})()</script></body></html>`
} }
+134 -43
View File
@@ -16,6 +16,7 @@ import { ImageDecryptService } from './image-decrypt-service'
import { ImageKeyConfigService } from './services/image-key-config-service' import { ImageKeyConfigService } from './services/image-key-config-service'
import { VideoAssetService } from './video-asset-service' import { VideoAssetService } from './video-asset-service'
import { StickerService } from './sticker-service' import { StickerService } from './sticker-service'
import { getImageExportAttempts } from '../shared/export-media'
const jobs = new Set<string>() const jobs = new Set<string>()
const safeFilePart = (value: string): string => const safeFilePart = (value: string): string =>
@@ -26,6 +27,10 @@ const exportStamp = (): string => {
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}` return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
} }
const imageKeys = new ImageKeyConfigService() const imageKeys = new ImageKeyConfigService()
const keepMediaError = (request: ExportRequest, message: Message, error: string): void => {
if (request.keepMissing !== false) message.exportMediaError = error
}
function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null { function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
const match = /^data:([^;]+);base64,(.+)$/s.exec(data) const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
if (!match) return null if (!match) return null
@@ -105,11 +110,20 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
if (format === 'json') if (format === 'json')
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2) return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
if (format === 'markdown') if (format === 'markdown')
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}\n`).join('\n')}` return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}`
return [ return [
'时间,发送者,类型,内容', '时间,发送者,类型,内容,媒体路径,媒体状态',
...messages.map((m) => ...messages.map((m) =>
[m.datetime, m.name || (m.isSender ? '我' : '联系人'), m.type, m.content].map(csv).join(',') [
m.datetime,
m.name || (m.isSender ? '我' : '联系人'),
m.type,
m.content,
m.exportMediaUrl || m.voiceDataUrl || '',
m.exportMediaError || ''
]
.map(csv)
.join(',')
) )
].join('\n') ].join('\n')
} }
@@ -126,9 +140,19 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
.listMessages(request.userMd5, request.startTime, request.endTime) .listMessages(request.userMd5, request.startTime, request.endTime)
.filter((m) => request.kinds.includes(kindOf(m))) .filter((m) => request.kinds.includes(kindOf(m)))
for (const message of messages) { for (const message of messages) {
message.exportMediaUrl = undefined
message.exportMediaType = undefined
message.exportMediaError = undefined
message.voiceDataUrl = undefined
message.exportShowAvatar = request.includeAvatars !== false message.exportShowAvatar = request.includeAvatars !== false
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
if (mappedName) message.name = mappedName if (mappedName) message.name = mappedName
if (
request.format !== 'html' &&
['image', 'video', 'voice', 'sticker'].includes(kindOf(message))
) {
message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件'
}
} }
send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 }) send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 })
if (!jobs.has(request.jobId)) { if (!jobs.has(request.jobId)) {
@@ -181,25 +205,46 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
: null : null
if (voiceService) { if (voiceService) {
for (const [index, message] of messages.entries()) { for (const [index, message] of messages.entries()) {
if ( if (kindOf(message) !== 'voice') continue
kindOf(message) !== 'voice' || if (!message.sessionId || message.localId == null || !message.createTime) {
!message.sessionId || keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
!message.localId ||
!message.createTime
)
continue continue
const voice = await voiceService.resolveVoice( }
message.sessionId, try {
message.localId, const voice = await voiceService.resolveVoice(
message.createTime, message.sessionId,
message.serverId message.localId,
) message.createTime,
if (!voice.success || !voice.data) continue message.serverId
const voiceName = `voice_${index + 1}_${message.localId}.wav` )
const audioBuffer = Buffer.from(voice.data, 'base64') if (!voice.success || !voice.data) {
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) const detail = voice.error || '未知原因'
message.voiceDataUrl = `voices/${voiceName}` const reason = /未找到|不存在|获取语音数据失败/.test(detail)
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2))) ? `语音文件缺失:${detail}`
: /Silk|解码|数据为空/.test(detail)
? `语音解析失败:${detail}`
: `语音格式不支持或读取失败:${detail}`
keepMediaError(request, message, reason)
continue
}
const voiceName = `voice_${index + 1}_${message.localId}.wav`
const audioBuffer = Buffer.from(voice.data, 'base64')
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
message.voiceDataUrl = `voices/${voiceName}`
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
} catch (error) {
keepMediaError(
request,
message,
`语音文件写入失败:${error instanceof Error ? error.message : String(error)}`
)
}
}
} else if (request.includeMedia) {
for (const message of messages) {
if (kindOf(message) === 'voice') {
keepMediaError(request, message, '数据库未连接,无法读取本地语音')
}
} }
} }
for (const [index, message] of messages.entries()) { for (const [index, message] of messages.entries()) {
@@ -228,34 +273,78 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
}) })
continue continue
} }
if (message.contentData.type === 'image' && imageService) { if (message.contentData.type === 'image') {
const file = imageService.findImageFile( if (!imageService) {
message.contentData.md5, keepMediaError(request, message, '未配置图片解密密钥,无法导出图片')
message.contentData.datName, } else {
{ allowThumbnail: true } let fileFound = false
) let decryptedImage: { data: string; filePath: string } | null = null
const decrypted = file ? imageService.decryptImageToBase64WithFallback(file, true) : null let usedFallback = false
const decoded = decrypted ? decodeDataUrl(decrypted.data) : null for (const attempt of getImageExportAttempts(request)) {
if (decoded) { const file = imageService.findImageFile(
const name = `image_${index + 1}.${decoded.extension}` message.contentData.md5,
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) message.contentData.datName,
message.exportMediaUrl = `media/${name}` {
message.exportMediaType = 'image' allowThumbnail: attempt.allowThumbnail,
preferThumbnail: attempt.preferThumbnail,
sessionId: message.sessionId
}
)
if (!file) continue
fileFound = true
const decrypted = imageService.decryptImageToBase64WithFallback(
file,
attempt.allowThumbnail
)
if (!decrypted) continue
decryptedImage = decrypted
usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath)
break
}
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
if (decoded) {
const name = `image_${index + 1}.${decoded.extension}`
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'image'
if (usedFallback) {
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
}
} else if (!fileFound) {
keepMediaError(
request,
message,
request.fallbackThumbnail === false
? '原图文件缺失,未启用缩略图降级'
: '原图和缩略图文件均缺失'
)
} else {
keepMediaError(request, message, '图片解析失败或当前格式不支持')
}
} }
} else if (message.contentData.type === 'video' && videoService) { } else if (message.contentData.type === 'video') {
const hashes = [ const hashes = [
message.contentData.md5, message.contentData.md5,
message.contentData.newMd5, message.contentData.newMd5,
message.contentData.rawMd5 message.contentData.rawMd5
].filter((value): value is string => Boolean(value)) ].filter((value): value is string => Boolean(value))
const resolved = videoService.resolve(hashes) if (!videoService) {
const token = resolved.url?.split('/').pop() keepMediaError(request, message, '数据库未连接,无法定位本地视频')
const source = token ? videoService.pathForToken(token) : undefined } else if (hashes.length === 0) {
if (source) { keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
const name = `video_${index + 1}.mp4` } else {
await fs.copyFile(source, join(outputDir, 'media', name)) const resolved = videoService.resolve(hashes)
message.exportMediaUrl = `media/${name}` const source = resolved.url ? videoService.pathForUrl(resolved.url) : undefined
message.exportMediaType = 'video' if (!resolved.success || !source) {
keepMediaError(request, message, resolved.error || '视频文件缺失或已移动')
} else if (extname(source).toLowerCase() !== '.mp4') {
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
} else {
const name = `video_${index + 1}.mp4`
await fs.copyFile(source, join(outputDir, 'media', name))
message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'video'
}
} }
} else if (message.contentData.type === 'sticker' && stickerService) { } else if (message.contentData.type === 'sticker' && stickerService) {
const stickerSource = message.contentData.url || message.contentData.thumbUrl const stickerSource = message.contentData.url || message.contentData.thumbUrl
@@ -270,6 +359,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
message.exportMediaUrl = `media/${name}` message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'sticker' message.exportMediaType = 'sticker'
} else {
keepMediaError(request, message, result.error || '表情资源缺失或下载失败')
} }
} }
send({ send({
File diff suppressed because it is too large Load Diff
+382 -96
View File
@@ -11,7 +11,7 @@ import {
dialog, dialog,
protocol protocol
} from 'electron' } from 'electron'
import { join } from 'path' import { dirname, join } from 'path'
import { existsSync, promises as fsPromises } from 'fs' import { existsSync, promises as fsPromises } from 'fs'
import { extname } from 'path' import { extname } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
@@ -21,7 +21,12 @@ import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
import { VoiceService } from './voice-service' import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service' import { StickerService } from './sticker-service'
import { parseMessageContent } from './message-parser' import { parseMessageContent } from './message-parser'
import { ImageDecryptService } from './image-decrypt-service' import {
ImageDecryptService,
inspectImageDecoderExecutable,
inspectImageDecoderStatus,
type DecodedImage
} from './image-decrypt-service'
import { exportGroupReport } from './group-report-service' import { exportGroupReport } from './group-report-service'
import { import {
deleteGeneratedReport, deleteGeneratedReport,
@@ -53,13 +58,25 @@ import * as chat from './services/chat-service'
import { apiServer } from './http-server' import { apiServer } from './http-server'
import { skillResourceService } from './services/skill-resource-service' import { skillResourceService } from './services/skill-resource-service'
import { testLocalApiRequest } from './services/local-api-test-service' import { testLocalApiRequest } from './services/local-api-test-service'
import { isWindowsWechatRunning } from './services/wechat-process-status' import { isWechatRunning } from './services/wechat-process-status'
import { import {
inspectImageDecryptionStatus, inspectImageDecryptionStatus,
testImageDecryption testImageDecryption
} from './services/image-decryption-status-service' } from './services/image-decryption-status-service'
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption' import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store' import {
loadSettings,
saveSettings,
getSettingsPath,
AppSettings,
validateDbRoot
} from './services/settings-store'
import {
detectDataStructureVersion,
detectWechatVersion,
getOsVersionLabel
} from './services/connection-diagnostics'
import { buildSafeDiagnosticSummary } from '../shared/connection-diagnostics'
import { import {
flushBootstrapCacheWritesSync, flushBootstrapCacheWritesSync,
getBootstrapCache, getBootstrapCache,
@@ -83,6 +100,7 @@ import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-
import { VideoAssetService } from './video-asset-service' import { VideoAssetService } from './video-asset-service'
import { cancelExport, revealExport, runExport } from './export-service' import { cancelExport, revealExport, runExport } from './export-service'
import type { ExportRequest } from '../shared/export' import type { ExportRequest } from '../shared/export'
import { discoverAccounts } from './services/account-discovery'
// electron-vite can close the child's stdout/stderr after spawning Electron. // electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC // Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
@@ -103,6 +121,68 @@ let recallArchiveMonitor: RecallArchiveMonitor | null = null
let recallProtectionGeneration = 0 let recallProtectionGeneration = 0
let recallJournalTimer: NodeJS.Timeout | null = null let recallJournalTimer: NodeJS.Timeout | null = null
let wcdbBootstrapPromise: Promise<unknown> | null = null let wcdbBootstrapPromise: Promise<unknown> | null = null
type ColdImageLoadItem = {
priority: number
sequence: number
run: () => Promise<unknown>
resolve: (value: unknown) => void
reject: (reason: unknown) => void
}
const coldImageLoadQueue: ColdImageLoadItem[] = []
let activeColdImageLoads = 0
let coldImageLoadSequence = 0
let coldImageLoadTimer: NodeJS.Timeout | null = null
let nextColdImageLoadAt = 0
const COLD_IMAGE_LOAD_GAP_MS = 100
const MAX_CONCURRENT_COLD_IMAGE_LOADS = 2
function pumpColdImageLoads(): void {
if (activeColdImageLoads >= MAX_CONCURRENT_COLD_IMAGE_LOADS || coldImageLoadQueue.length === 0) {
return
}
const waitMs = Math.max(0, nextColdImageLoadAt - Date.now())
if (waitMs > 0) {
if (!coldImageLoadTimer) {
coldImageLoadTimer = setTimeout(() => {
coldImageLoadTimer = null
pumpColdImageLoads()
}, waitMs)
}
return
}
coldImageLoadQueue.sort(
(left, right) => left.priority - right.priority || left.sequence - right.sequence
)
const item = coldImageLoadQueue.shift()
if (!item) return
activeColdImageLoads += 1
nextColdImageLoadAt = Date.now() + COLD_IMAGE_LOAD_GAP_MS
void item
.run()
.then(item.resolve, item.reject)
.finally(() => {
activeColdImageLoads -= 1
pumpColdImageLoads()
})
pumpColdImageLoads()
}
function enqueueColdImageLoad<T>(task: () => Promise<T> | T, priority = 0): Promise<T> {
return new Promise<T>((resolve, reject) => {
coldImageLoadQueue.push({
priority,
sequence: coldImageLoadSequence++,
run: async () => task(),
resolve: (value) => resolve(value as T),
reject
})
pumpColdImageLoads()
})
}
function configureRecallProtection( function configureRecallProtection(
wcdb4Client: Wcdb4Client, wcdb4Client: Wcdb4Client,
@@ -199,9 +279,58 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
} }
} }
function getImageMediaService(): VideoAssetService | null {
if (videoAssetService) return videoAssetService
const client = chat.getChatDb()?.getWcdb4Client()
if (!client) return null
videoAssetService = new VideoAssetService(client)
return videoAssetService
}
function getLocalMediaMimeType(filePath: string): string {
switch (extname(filePath).toLowerCase()) {
case '.mp4':
return 'video/mp4'
case '.jpg':
case '.jpeg':
return 'image/jpeg'
case '.png':
return 'image/png'
case '.gif':
return 'image/gif'
case '.webp':
return 'image/webp'
case '.bmp':
return 'image/bmp'
default:
return 'application/octet-stream'
}
}
function buildImageResponse(image: DecodedImage): {
success: true
data: string
isThumb: boolean
filePath: string
mimeType?: string
} {
const mediaService = image.cacheFilePath ? getImageMediaService() : null
const data =
mediaService && image.cacheFilePath && existsSync(image.cacheFilePath)
? mediaService.createLocalMediaUrl(image.cacheFilePath)
: image.data
return {
success: true,
data,
isThumb: image.isThumbnail,
filePath: image.filePath,
mimeType: image.mimeType
}
}
async function createLocalMediaResponse(request: Request, filePath: string): Promise<Response> { async function createLocalMediaResponse(request: Request, filePath: string): Promise<Response> {
const { size } = await fsPromises.stat(filePath) const { size } = await fsPromises.stat(filePath)
const mimeType = extname(filePath).toLowerCase() === '.mp4' ? 'video/mp4' : 'image/jpeg' const mimeType = getLocalMediaMimeType(filePath)
const commonHeaders = { const commonHeaders = {
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
'Content-Type': mimeType, 'Content-Type': mimeType,
@@ -292,8 +421,7 @@ function createWindow(): void {
// 某些 API 只能在此事件发生后使用 // 某些 API 只能在此事件发生后使用
app.whenReady().then(async () => { app.whenReady().then(async () => {
protocol.handle('wxe-media', async (request) => { protocol.handle('wxe-media', async (request) => {
const token = new URL(request.url).pathname.replace(/^\/+/, '') const filePath = videoAssetService?.pathForUrl(request.url)
const filePath = videoAssetService?.pathForToken(token)
if (!filePath) return new Response('Not found', { status: 404 }) if (!filePath) return new Response('Not found', { status: 404 })
try { try {
return await createLocalMediaResponse(request, filePath) return await createLocalMediaResponse(request, filePath)
@@ -364,26 +492,45 @@ app.whenReady().then(async () => {
return clearCache(scope) return clearCache(scope)
}) })
ipcMain.handle('db:init', async (_, key: string) => { ipcMain.handle('db:init', async (_, key: string, accountRoot?: string) => {
if (dbInitInFlight) return dbInitInFlight if (dbInitInFlight) return dbInitInFlight
dbInitInFlight = (async () => { dbInitInFlight = (async () => {
const startedAt = Date.now()
try { try {
if (wcdbBootstrapPromise) await wcdbBootstrapPromise if (wcdbBootstrapPromise) await wcdbBootstrapPromise
const trimmedKey = String(key || '').trim() const trimmedKey = String(key || '').trim()
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`) console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
const settings = loadSettings() const settings = loadSettings()
const selectedRoot = String(accountRoot || settings.dbRoot || '').trim()
const rootValidation = validateDbRoot(selectedRoot)
if (!rootValidation.valid) {
return {
success: false,
code: 'ROOT_UNAVAILABLE',
error: rootValidation.error,
monitoring: false
}
}
if (!existsSync(join(selectedRoot, 'db_storage'))) {
return {
success: false,
code: 'ACCOUNT_SELECTION_REQUIRED',
error: '请先明确选择一个微信账号',
monitoring: false
}
}
if ( if (
chat.isReady() && chat.isReady() &&
chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') && chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') &&
(!settings.dbRoot || chat.getCurrentAccountRoot() === settings.dbRoot) chat.getCurrentAccountRoot() === selectedRoot
) { ) {
console.log('[WCDB4] db:init reuse current connection') console.log('[WCDB4] db:init reuse current connection')
return { success: true, monitoring: true } return { success: true, monitoring: true }
} }
const nextWechatDb = await WechatDb.create(key, settings.dbRoot) const nextWechatDb = await WechatDb.create(key, selectedRoot)
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot() const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
if (resolvedRoot && resolvedRoot !== settings.dbRoot) { if (resolvedRoot) {
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录 // 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
saveSettings({ saveSettings({
...settings, ...settings,
@@ -393,25 +540,28 @@ app.whenReady().then(async () => {
} }
chat.setChatDb(nextWechatDb) chat.setChatDb(nextWechatDb)
const wcdb4Client = nextWechatDb.getWcdb4Client() const wcdb4Client = nextWechatDb.getWcdb4Client()
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled) configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
voiceService = new VoiceService(wcdb4Client) voiceService = new VoiceService(wcdb4Client)
stickerService = new StickerService(wcdb4Client) stickerService = new StickerService(wcdb4Client)
videoAssetService = new VideoAssetService(wcdb4Client) videoAssetService = new VideoAssetService(wcdb4Client)
const monitoring = wcdb4Client.startMonitor((type, json) => { const monitoring = await wcdb4Client.startMonitor((type, json) => {
wcdb4Client.invalidateSessionCache() wcdb4Client.invalidateSessionCache()
recallArchiveMonitor?.handleDatabaseChange(json) recallArchiveMonitor?.handleDatabaseChange(json)
for (const window of BrowserWindow.getAllWindows()) { for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json }) if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
} }
}) })
setImmediate(() => { const recentSession = sessions[0]
const recentSession = wcdb4Client.getSessions()[0] if (recentSession?.username) {
if (!recentSession?.username) return
void wcdb4Client void wcdb4Client
.getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 }) .getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 })
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error)) .catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
}) }
imageDecryptService = null imageDecryptService = null
console.log(
`[WCDB4] db:init ready sessions=${sessions.length} monitoring=${monitoring} cost=${Date.now() - startedAt}ms`
)
return { success: true, monitoring } return { success: true, monitoring }
} catch (error) { } catch (error) {
console.error('Failed to init DB:', error) console.error('Failed to init DB:', error)
@@ -424,19 +574,43 @@ app.whenReady().then(async () => {
return dbInitInFlight return dbInitInFlight
}) })
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load()) ipcMain.handle('accounts:discover', (_, inputPath: string) =>
discoverAccounts(inputPath, databaseKeyStore, chat.getCurrentAccountRoot())
)
ipcMain.handle('key:getSavedDbKey', async (_, accountRoot: string) => {
const selectedRoot = String(accountRoot || '').trim()
const scoped = await databaseKeyStore.load(selectedRoot)
if (scoped.saved || !selectedRoot) return scoped
const legacy = await databaseKeyStore.loadLegacy()
if (!legacy.success || !legacy.key) return scoped
const validation = await chat.testConnection(legacy.key, selectedRoot)
if (!validation.success) return scoped
const migrated = await databaseKeyStore.save(selectedRoot, legacy.key)
if (migrated.success) await databaseKeyStore.clearLegacy()
return migrated
})
ipcMain.handle('key:getEnvironment', async () => { ipcMain.handle('key:getEnvironment', async () => {
const storage = await databaseKeyStore.getStatus() const storage = await databaseKeyStore.getStatus(
chat.getCurrentAccountRoot() || loadSettings().dbRoot
)
const self = chat.getSelfAccountInfo() const self = chat.getSelfAccountInfo()
return { const settings = loadSettings()
const environment = {
platform: process.platform, platform: process.platform,
osVersion: getOsVersionLabel(),
appVersion: `v${app.getVersion()}`,
wechatVersion: await detectWechatVersion(),
dataStructureVersion: detectDataStructureVersion(settings.dbRoot),
dataDirectoryDetected: validateDbRoot(settings.dbRoot).valid,
autoDetectSupported: process.platform === 'win32', autoDetectSupported: process.platform === 'win32',
wechatRunning: await isWindowsWechatRunning(), wechatRunning: await isWechatRunning(),
accountIdentified: Boolean(self?.wxid), accountIdentified: Boolean(self?.wxid),
dbConnected: chat.isReady(), dbConnected: chat.isReady(),
encryptionAvailable: storage.encryptionAvailable encryptionAvailable: storage.encryptionAvailable
} }
return { ...environment, diagnosticSummary: buildSafeDiagnosticSummary(environment) }
}) })
ipcMain.handle('key:readClipboardDbKey', () => { ipcMain.handle('key:readClipboardDbKey', () => {
@@ -447,36 +621,47 @@ app.whenReady().then(async () => {
} }
}) })
ipcMain.handle('key:pasteAndSaveDbKey', async () => { ipcMain.handle('key:pasteAndSaveDbKey', async (_, accountRoot: string) => {
const clipboardKey = clipboard.readText().trim() const clipboardKey = clipboard.readText().trim()
return databaseKeyStore.save(clipboardKey) return databaseKeyStore.save(String(accountRoot || ''), clipboardKey)
}) })
ipcMain.handle('key:saveDbKey', async (_, key: string) => ipcMain.handle('key:saveDbKey', async (_, accountRoot: string, key: string) =>
databaseKeyStore.save(String(key || '')) databaseKeyStore.save(String(accountRoot || ''), String(key || ''))
) )
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear()) ipcMain.handle('key:clearSavedDbKey', async (_, accountRoot: string) =>
databaseKeyStore.clear(String(accountRoot || ''))
)
ipcMain.handle('key:autoGetDbKey', async (event, options?: { save?: boolean }) => { ipcMain.handle(
const onStatus = (message: string): void => { 'key:autoGetDbKey',
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message }) async (event, accountRoot: string, options?: { save?: boolean }) => {
const onStatus = (message: string): void => {
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
}
const result =
process.platform === 'win32'
? await keyServiceWin.autoGetDbKey(60_000, onStatus)
: await keyServiceMac.autoGetDbKey(onStatus)
if (!result.success || !result.key) return result
const selectedRoot = String(accountRoot || '').trim()
if (!selectedRoot) return { success: false, error: '请先选择微信账号' }
const validation = await chat.testConnection(result.key, selectedRoot)
if (!validation.success) {
return { ...result, success: false, key: undefined, error: '获取到的密钥不属于所选账号' }
}
if (options?.save === false) return result
const saved = await databaseKeyStore.save(selectedRoot, result.key)
return {
...result,
saved: saved.success,
warning: saved.success ? undefined : saved.error
}
} }
const result = )
process.platform === 'win32'
? await keyServiceWin.autoGetDbKey(60_000, onStatus)
: await keyServiceMac.autoGetDbKey(onStatus)
if (!result.success || !result.key) return result
if (options?.save === false) return result
const saved = await databaseKeyStore.save(result.key)
return {
...result,
saved: saved.success,
warning: saved.success ? undefined : saved.error
}
})
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => { ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
const settings = loadSettings() const settings = loadSettings()
@@ -546,6 +731,67 @@ app.whenReady().then(async () => {
return result return result
}) })
ipcMain.handle('image:selectDecoder', async (event) => {
const settings = loadSettings()
const owner = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(owner!, {
title: '选择 FFmpeg 解压或安装目录',
defaultPath: settings.ffmpegPath ? dirname(settings.ffmpegPath) : app.getPath('downloads'),
properties: ['openDirectory']
})
if (result.canceled) return { success: false, canceled: true }
const selectedDirectory = result.filePaths[0]
if (!selectedDirectory) return { success: false, canceled: true }
const executable = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const candidates = [
join(selectedDirectory, executable),
join(selectedDirectory, 'bin', executable)
]
let selectedPath = ''
for (const candidate of candidates) {
if (!existsSync(candidate)) continue
const inspection = await inspectImageDecoderExecutable(candidate)
if (inspection.installed) {
selectedPath = candidate
break
}
}
if (!selectedPath) {
return {
success: false,
canceled: false,
error: '所选目录中没有找到 FFmpeg,请选择解压后的文件夹或其中的 bin 文件夹。'
}
}
saveSettings({ ...settings, ffmpegPath: selectedPath })
return {
success: true,
canceled: false,
status: await inspectImageDecoderStatus(selectedPath)
}
})
ipcMain.handle('image:getDecoderStatus', () => inspectImageDecoderStatus())
ipcMain.handle('image:openDecoderDownload', async () => {
const url =
process.platform === 'win32'
? 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip'
: process.platform === 'darwin'
? 'https://brew.sh/'
: 'https://ffmpeg.org/download.html'
try {
await shell.openExternal(url)
return { success: true }
} catch {
return { success: false, error: '无法打开下载页面,请检查系统默认浏览器设置。' }
}
})
ipcMain.handle('db:getBootstrapCache', () => { ipcMain.handle('db:getBootstrapCache', () => {
if (!chat.isReady()) return null if (!chat.isReady()) return null
return getBootstrapCache(chat.getCurrentAccountRoot()) return getBootstrapCache(chat.getCurrentAccountRoot())
@@ -574,19 +820,19 @@ app.whenReady().then(async () => {
} }
) )
ipcMain.handle('db:getContacts', (_, filter?: string) => { ipcMain.handle('db:getContacts', async (_, filter?: string) => {
const accountRoot = chat.getCurrentAccountRoot() const accountRoot = chat.getCurrentAccountRoot()
const contacts = accountRoot const contacts = accountRoot
? mergeCachedContactAvatars(accountRoot, chat.listContacts(filter)) ? mergeCachedContactAvatars(accountRoot, await chat.listContactsAsync(filter))
: chat.listContacts(filter) : await chat.listContactsAsync(filter)
if (!filter && chat.isReady() && accountRoot) { if (!filter && chat.isReady() && accountRoot) {
saveBootstrapContacts(accountRoot, contacts) saveBootstrapContacts(accountRoot, contacts)
} }
return contacts return contacts
}) })
ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) => { ipcMain.handle('db:getContactAvatars', async (_, usernames: string[]) => {
const avatars = chat.getContactAvatars(usernames) const avatars = await chat.getContactAvatars(usernames)
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars) if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
return avatars return avatars
}) })
@@ -643,9 +889,15 @@ app.whenReady().then(async () => {
aiProviderService.migrateLegacy(config) aiProviderService.migrateLegacy(config)
) )
ipcMain.handle('copy-image', async (_, base64String) => { ipcMain.handle('copy-image', async (_, imageSource: unknown) => {
try { try {
const image = nativeImage.createFromDataURL(base64String) if (typeof imageSource !== 'string' || !imageSource) {
return { success: false, error: 'Image source is empty' }
}
const image = imageSource.startsWith('wxe-media://')
? nativeImage.createFromPath(getImageMediaService()?.pathForUrl(imageSource) || '')
: nativeImage.createFromDataURL(imageSource)
if (image.isEmpty()) return { success: false, error: 'Image source is invalid' }
clipboard.writeImage(image) clipboard.writeImage(image)
return { success: true } return { success: true }
} catch (error: unknown) { } catch (error: unknown) {
@@ -716,68 +968,102 @@ app.whenReady().then(async () => {
imageMd5?: string, imageMd5?: string,
imageDatNameOrThumb?: string | boolean, imageDatNameOrThumb?: string | boolean,
_sessionId?: string, _sessionId?: string,
options?: { force?: boolean; preferThumbnail?: boolean } options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
) => { ) => {
void _sessionId let service = imageDecryptService
if (!imageDecryptService) { if (!service) {
const { xorKey, aesKey } = getConfiguredImageKeys() const { xorKey, aesKey } = getConfiguredImageKeys()
if (!aesKey) { // Reading an already-decoded cache entry does not require the AES key.
return { success: false, error: '未配置图片解密密钥' } // Before db:init resolves the account identity, secure storage may not
} // expose that key yet, so keep this cache-only service local.
imageDecryptService = new ImageDecryptService( service = new ImageDecryptService(
xorKey, xorKey,
aesKey, aesKey,
chat.getChatDb()?.getWcdb4Client() chat.getChatDb()?.getWcdb4Client(),
loadSettings().dbRoot
) )
if (aesKey) imageDecryptService = service
} }
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
const force = options?.force === true const force = options?.force === true
const preferThumbnail = options?.preferThumbnail === true const preferThumbnail = options?.preferThumbnail === true
const priority = Number.isFinite(options?.priority) ? Number(options?.priority) : 0
const imageCacheKey = [ const imageCacheKey = [
imageMd5 || '', imageMd5 || '',
imageDatName || '', imageDatName || '',
force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto' force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto'
].join('|') ].join('|')
const cachedImage = imageDecryptService.getCachedDecodedImage(imageCacheKey) const mediaService = getImageMediaService()
if (cachedImage) { const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
return { includeData: !mediaService
success: true,
data: cachedImage.data,
isThumb: cachedImage.isThumbnail,
filePath: cachedImage.filePath
}
}
let filePath = force
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
: null
if (!filePath) {
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
allowThumbnail: true,
preferThumbnail
})
}
if (!filePath) {
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
}
const decrypted = imageDecryptService.decryptImageToBase64WithFallback(filePath, true)
if (!decrypted) {
return { success: false, error: '图片解密失败' }
}
const result = {
success: true,
data: decrypted.data,
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
filePath: decrypted.filePath
}
imageDecryptService.cacheDecodedImage(imageCacheKey, {
data: result.data,
filePath: result.filePath,
isThumbnail: result.isThumb
}) })
return result if (cachedImage && (!force || !cachedImage.isThumbnail)) {
return buildImageResponse(cachedImage)
}
return enqueueColdImageLoad(async () => {
// Disk cache was already checked without waiting for database startup.
// Only a real miss needs the initialized WCDB client and hardlink index.
if (dbInitInFlight) {
await dbInitInFlight.catch(() => undefined)
}
let coldService = imageDecryptService
if (!coldService) {
const { xorKey, aesKey } = getConfiguredImageKeys()
if (!aesKey) return { success: false, error: '未配置图片解密密钥' }
coldService = new ImageDecryptService(
xorKey,
aesKey,
chat.getChatDb()?.getWcdb4Client(),
loadSettings().dbRoot
)
imageDecryptService = coldService
}
// A previous queued request may have populated the cache while this one waited.
const queuedMediaService = getImageMediaService()
const queuedCacheHit = await coldService.getCachedDecodedImage(imageCacheKey, {
includeData: !queuedMediaService
})
if (queuedCacheHit && (!force || !queuedCacheHit.isThumbnail)) {
return buildImageResponse(queuedCacheHit)
}
let filePath = force
? await coldService.findImageFileAsync(imageMd5, imageDatName, {
allowThumbnail: false,
sessionId: _sessionId
})
: null
if (!filePath) {
filePath = await coldService.findImageFileAsync(imageMd5, imageDatName, {
allowThumbnail: true,
preferThumbnail,
sessionId: _sessionId
})
}
if (!filePath) {
return {
success: false,
error: force ? '未找到原图或缩略图文件' : '未找到图片文件'
}
}
const decrypted = await coldService.decryptImageToBase64WithFallbackAsync(filePath, true)
if (!decrypted) {
return { success: false, error: '图片解密失败' }
}
const decodedImage: DecodedImage = {
data: decrypted.data,
filePath: decrypted.filePath,
isThumbnail: coldService.isThumbnailFile(decrypted.filePath)
}
await coldService.cacheDecodedImage(imageCacheKey, decodedImage)
return buildImageResponse(decodedImage)
}, priority)
} }
) )
+112 -5
View File
@@ -16,6 +16,19 @@ type ShareContent = {
appname?: string appname?: string
typeVal?: string typeVal?: string
} }
type ForwardedMessageItem = {
messageType: number
sender?: string
sentAt?: string
text: string
nested?: ForwardedMessageItem[]
}
type ForwardBundleContent = {
type: 'forwardBundle'
title: string
description?: string
items: ForwardedMessageItem[]
}
type MiniProgramContent = { type MiniProgramContent = {
type: 'miniProgram' type: 'miniProgram'
title: string title: string
@@ -82,7 +95,7 @@ type SystemContent = {
recallTime?: number recallTime?: number
} }
} }
type UnknownContent = { type: 'unknown'; raw: string } type UnknownContent = { type: 'unknown'; raw: string; messageType?: string | number }
export type ParsedContent = export type ParsedContent =
| TextContent | TextContent
@@ -90,6 +103,7 @@ export type ParsedContent =
| LocationContent | LocationContent
| CardContent | CardContent
| ShareContent | ShareContent
| ForwardBundleContent
| MiniProgramContent | MiniProgramContent
| RedPacketContent | RedPacketContent
| VoipContent | VoipContent
@@ -108,6 +122,10 @@ export function parseMessageContent(content: string, messageType: number): Parse
const normalized = content.trim() const normalized = content.trim()
switch (messageType) { switch (messageType) {
case 1:
return { type: 'text', content: normalized }
case 34:
return { type: 'voice' }
case 3: case 3:
return parseImageMessage(normalized) return parseImageMessage(normalized)
case 42: case 42:
@@ -126,7 +144,7 @@ export function parseMessageContent(content: string, messageType: number): Parse
case 10002: case 10002:
return parseSystemMessage(normalized) return parseSystemMessage(normalized)
default: default:
return { type: 'text', content: normalized } return { type: 'unknown', raw: normalized, messageType }
} }
} }
@@ -412,6 +430,9 @@ function parseLocationMessage(content: string): ParsedContent {
function parseShareMessage(content: string): ParsedContent { function parseShareMessage(content: string): ParsedContent {
const appMsgType = extractAppMsgType(content) const appMsgType = extractAppMsgType(content)
if (appMsgType === '19' || /<recorditem\b|<dataitem\b/i.test(content)) {
return parseForwardBundle(content)
}
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) { if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
const sticker = parseStickerMessage(content) const sticker = parseStickerMessage(content)
if (sticker.type === 'sticker') return sticker if (sticker.type === 'sticker') return sticker
@@ -469,6 +490,94 @@ function parseShareMessage(content: string): ParsedContent {
return { type: 'share', title, des, url, appname, typeVal } return { type: 'share', title, des, url, appname, typeVal }
} }
function parseForwardBundle(content: string): ForwardBundleContent {
const normalized = decodeXmlEntities(stripChatroomPrefix(content))
const title = decodeXmlEntities(extractXmlValue(normalized, 'title')) || '聊天记录'
const description = decodeXmlEntities(extractXmlValue(normalized, 'des')) || undefined
const containers = Array.from(
normalized.matchAll(/<recorditem\b[^>]*>([\s\S]*?)<\/recorditem>/gi),
(match) => match[1] || ''
)
const sources = containers.length ? containers : [normalized]
const items = dedupeForwardedItems(sources.flatMap((source) => parseForwardedItems(source)))
return { type: 'forwardBundle', title, description, items }
}
function parseForwardedItems(container: string, depth = 0): ForwardedMessageItem[] {
if (!container || depth > 4) return []
const variants = new Set<string>([container, decodeXmlEntities(container)])
for (const match of container.matchAll(/<!\[CDATA\[([\s\S]*?)\]\]>/g)) {
if (match[1]) variants.add(decodeXmlEntities(match[1]))
}
const items: ForwardedMessageItem[] = []
for (const variant of variants) {
for (const match of variant.matchAll(/<dataitem\b([^>]*)>([\s\S]*?)<\/dataitem>/gi)) {
const attributes = match[1] || ''
const body = match[2] || ''
const attrType = /datatype\s*=\s*["']?(\d+)/i.exec(attributes)?.[1]
const messageType = Number.parseInt(attrType || extractXmlValue(body, 'datatype') || '0', 10)
const sender = decodeXmlEntities(extractXmlValue(body, 'sourcename')) || undefined
const sentAt = extractXmlValue(body, 'sourcetime') || undefined
const title = decodeXmlEntities(extractXmlValue(body, 'datatitle'))
const description = decodeXmlEntities(
extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content')
)
const nestedXml = extractXmlBody(body, 'recordxml')
const nested =
messageType === 17 && nestedXml
? parseForwardedItems(decodeXmlEntities(nestedXml), depth + 1)
: undefined
const text = description || title || forwardedTypeLabel(messageType)
if (!sender && !text && !nested?.length) continue
items.push({
messageType: Number.isFinite(messageType) ? messageType : 0,
sender,
sentAt,
text: text || '[消息]',
nested: nested?.length ? nested : undefined
})
}
}
return dedupeForwardedItems(items)
}
function dedupeForwardedItems(items: ForwardedMessageItem[]): ForwardedMessageItem[] {
const seen = new Set<string>()
return items.filter((item) => {
const key = `${item.messageType}|${item.sender || ''}|${item.sentAt || ''}|${item.text}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function forwardedTypeLabel(messageType: number): string {
switch (messageType) {
case 3:
return '[图片]'
case 34:
return '[语音]'
case 43:
return '[视频]'
case 47:
return '[表情包]'
case 8:
case 49:
return '[文件或分享]'
case 17:
return '[聊天记录]'
default:
return '[消息]'
}
}
function extractXmlBody(xml: string, tagName: string): string {
const match = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i').exec(xml)
if (!match?.[1]) return ''
return match[1].replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/, '$1').trim()
}
function parseQuoteMessage(content: string): { function parseQuoteMessage(content: string): {
content?: string content?: string
sender?: string sender?: string
@@ -713,9 +822,7 @@ export function parseImageDatNameFromRow(row: Record<string, unknown>): string |
return hexMatch?.[1]?.toLowerCase() return hexMatch?.[1]?.toLowerCase()
} }
export function parseImageBufferDataUrlFromRow( export function parseImageBufferDataUrlFromRow(row: Record<string, unknown>): string | undefined {
row: Record<string, unknown>
): string | undefined {
const raw = pickRowString(row, [ const raw = pickRowString(row, [
'ImgBuf', 'ImgBuf',
'imgBuf', 'imgBuf',
+58
View File
@@ -0,0 +1,58 @@
import crypto from 'crypto'
import fs from 'fs-extra'
import path from 'path'
import type { AccountDiscoveryResult, WechatAccountCandidate } from '../../shared/database-key'
import { DatabaseKeyStore } from '../database-key-store'
import { getBootstrapCache } from './bootstrap-cache'
import { validateDbRoot } from './settings-store'
function accountId(accountRoot: string): string {
return crypto.createHash('sha256').update(path.resolve(accountRoot).toLowerCase()).digest('hex')
}
export async function discoverAccounts(
inputPath: string,
keyStore: DatabaseKeyStore,
currentAccountRoot?: string
): Promise<AccountDiscoveryResult> {
const validation = validateDbRoot(inputPath)
if (!validation.valid) return { success: false, accounts: [], error: validation.error }
const normalizedInput = path.resolve(inputPath)
const isAccount = await fs.pathExists(path.join(normalizedInput, 'db_storage'))
const roots = isAccount
? [normalizedInput]
: (await fs.readdir(normalizedInput, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(normalizedInput, entry.name))
.filter((candidate) => fs.existsSync(path.join(candidate, 'db_storage')))
const accounts: WechatAccountCandidate[] = await Promise.all(
roots.map(async (accountRoot) => {
const cached = getBootstrapCache(accountRoot)?.self
return {
id: accountId(accountRoot),
accountRoot,
directoryName: path.basename(accountRoot),
wxid: cached?.wxid,
nickname: cached?.nickname,
avatar: cached?.avatar,
hasSavedDbKey: (await keyStore.getStatus(accountRoot)).saved,
loginStatus: currentAccountRoot
? path.resolve(currentAccountRoot).toLowerCase() ===
path.resolve(accountRoot).toLowerCase()
? 'current'
: 'other'
: 'unknown',
selectedByInput: isAccount
}
})
)
return {
success: true,
inputKind: isAccount ? 'account' : 'root',
accounts,
preselectedAccountId: isAccount ? accounts[0]?.id : undefined
}
}
+407 -189
View File
@@ -24,135 +24,364 @@ export interface CachedGroupSnapshot {
}[] }[]
} }
interface CachedMessageBucket { interface StartupCacheFile {
version: 2
platform: NodeJS.Platform
accountRoot: string
updatedAt: number
self?: CachedSelfInfo
contacts: Contact[]
}
interface CachedMessageBucketFile {
version: 2
platform: NodeJS.Platform
accountRoot: string
cacheKey: string
updatedAt: number updatedAt: number
startTime?: number startTime?: number
endTime?: number endTime?: number
items: Message[] items: Message[]
} }
interface BootstrapCacheFile { interface CachedGroupSnapshotFile {
version: 1 version: 2
platform: NodeJS.Platform platform: NodeJS.Platform
accountRoot: string accountRoot: string
userMd5: string
updatedAt: number updatedAt: number
self?: CachedSelfInfo snapshot: CachedGroupSnapshot
contacts?: Contact[]
messages?: Record<string, CachedMessageBucket>
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
} }
const CACHE_VERSION = 1 interface ScheduledWrite {
value: unknown
revision: number
generation: number
cleanupFile?: string
prune?: { directory: string; maxFiles: number }
}
interface AccountCachePaths {
root: string
startup: string
messages: string
groups: string
legacy: string
}
const CACHE_VERSION = 2
const MAX_MESSAGE_BUCKETS = 768 const MAX_MESSAGE_BUCKETS = 768
const MAX_GROUP_SNAPSHOTS = 768
const MAX_MESSAGES_PER_BUCKET = 120 const MAX_MESSAGES_PER_BUCKET = 120
const MAX_MEMORY_MESSAGE_BUCKETS = 32
const MAX_MEMORY_GROUP_SNAPSHOTS = 32
const WRITE_DEBOUNCE_MS = 300 const WRITE_DEBOUNCE_MS = 300
const memoryCache = new Map<string, BootstrapCacheFile>() const PRUNE_INTERVAL_MS = 30_000
const startupMemory = new Map<string, StartupCacheFile>()
const messageMemory = new Map<string, CachedMessageBucketFile>()
const groupMemory = new Map<string, CachedGroupSnapshotFile>()
const writeTimers = new Map<string, NodeJS.Timeout>() const writeTimers = new Map<string, NodeJS.Timeout>()
const writeQueues = new Map<string, Promise<void>>() const writeQueues = new Map<string, Promise<void>>()
const scheduledWrites = new Map<string, ScheduledWrite>()
const writeRevisions = new Map<string, number>()
const lastPrunedAt = new Map<string, number>()
let cacheGeneration = 0
function normalizeRoot(accountRoot?: string): string { function normalizeRoot(accountRoot?: string): string {
return String(accountRoot || '').trim() return String(accountRoot || '').trim()
} }
function getCacheFile(accountRoot?: string): string { function digest(value: string): string {
const normalizedRoot = normalizeRoot(accountRoot) || 'default' return crypto.createHash('sha1').update(value).digest('hex').slice(0, 24)
const hash = crypto
.createHash('sha1')
.update(`${process.platform}:${normalizedRoot}`)
.digest('hex')
.slice(0, 16)
return path.join(
app.getPath('userData'),
'cache',
'bootstrap',
`${process.platform}-${hash}.json`
)
} }
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null { function getAccountCachePaths(accountRoot: string): AccountCachePaths {
const normalizedRoot = normalizeRoot(accountRoot) const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null const accountKey = digest(`${process.platform}:${normalizedRoot}`)
const file = getCacheFile(normalizedRoot) const bootstrapRoot = path.join(app.getPath('userData'), 'cache', 'bootstrap')
const cached = memoryCache.get(file) const root = path.join(bootstrapRoot, `${process.platform}-${accountKey}`)
if (cached) return cached return {
try { root,
if (!fs.existsSync(file)) return null startup: path.join(root, 'startup.json'),
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile> messages: path.join(root, 'messages'),
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null groups: path.join(root, 'groups'),
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null legacy: path.join(bootstrapRoot, `${process.platform}-${accountKey.slice(0, 16)}.json`)
const result: BootstrapCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Number(raw.updatedAt) || 0,
self: raw.self,
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {},
groupSnapshots:
raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {}
}
memoryCache.set(file, result)
return result
} catch (error) {
console.warn('[BootstrapCache] read failed:', error)
return null
} }
} }
function writeCacheFile(cache: BootstrapCacheFile): void {
const file = getCacheFile(cache.accountRoot)
memoryCache.set(file, cache)
const existingTimer = writeTimers.get(file)
if (existingTimer) clearTimeout(existingTimer)
writeTimers.set(
file,
setTimeout(() => {
writeTimers.delete(file)
const serialized = JSON.stringify(memoryCache.get(file) || cache)
const previous = writeQueues.get(file) || Promise.resolve()
const next = previous
.catch(() => undefined)
.then(async () => {
await fs.ensureDir(path.dirname(file))
await fs.writeFile(file, serialized, 'utf8')
})
.catch((error) => {
console.warn('[BootstrapCache] write failed:', error)
})
.finally(() => {
if (writeQueues.get(file) === next) writeQueues.delete(file)
})
writeQueues.set(file, next)
}, WRITE_DEBOUNCE_MS)
)
}
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const existing = readCacheFile(normalizedRoot)
if (existing) return existing
const created: BootstrapCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: [],
messages: {},
groupSnapshots: {}
}
memoryCache.set(getCacheFile(normalizedRoot), created)
return created
}
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string { function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}` return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
} }
function cachedMessageIdentity(message: Message): string { function getMessageCacheFile(accountRoot: string, cacheKey: string): string {
if (message.localId) return `local:${message.localId}` return path.join(getAccountCachePaths(accountRoot).messages, `${digest(cacheKey)}.json`)
if (message.serverId) return `server:${message.serverId}` }
return `id:${message.id}`
function getGroupCacheFile(accountRoot: string, userMd5: string): string {
return path.join(getAccountCachePaths(accountRoot).groups, `${digest(userMd5)}.json`)
}
function readScheduledValue<T>(file: string): T | null {
const scheduled = scheduledWrites.get(file)
return scheduled ? (scheduled.value as T) : null
}
function touchMemory<T>(memory: Map<string, T>, file: string, value: T, maxEntries: number): void {
memory.delete(file)
memory.set(file, value)
while (memory.size > maxEntries) {
const oldest = memory.keys().next().value
if (!oldest) break
memory.delete(oldest)
}
}
function isCurrentAccountFile(
value: {
version?: number
platform?: NodeJS.Platform
accountRoot?: string
},
accountRoot: string
): boolean {
return (
value.version === CACHE_VERSION &&
value.platform === process.platform &&
normalizeRoot(value.accountRoot) === normalizeRoot(accountRoot)
)
}
function readStartupCacheFile(accountRoot: string): StartupCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const file = getAccountCachePaths(normalizedRoot).startup
const scheduled = readScheduledValue<StartupCacheFile>(file)
if (scheduled) return scheduled
const memory = startupMemory.get(file)
if (memory) return memory
try {
if (!fs.existsSync(file)) return null
const raw = fs.readJsonSync(file) as Partial<StartupCacheFile>
if (!isCurrentAccountFile(raw, normalizedRoot)) return null
const result: StartupCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Number(raw.updatedAt) || 0,
self: raw.self,
contacts: Array.isArray(raw.contacts) ? raw.contacts : []
}
startupMemory.set(file, result)
return result
} catch (error) {
console.warn('[BootstrapCache] startup read failed:', error)
return null
}
}
function readMessageBucketFile(
accountRoot: string,
cacheKey: string
): CachedMessageBucketFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const file = getMessageCacheFile(normalizedRoot, cacheKey)
const scheduled = readScheduledValue<CachedMessageBucketFile>(file)
if (scheduled) return scheduled
const memory = messageMemory.get(file)
if (memory) {
touchMemory(messageMemory, file, memory, MAX_MEMORY_MESSAGE_BUCKETS)
return memory
}
try {
if (!fs.existsSync(file)) return null
const raw = fs.readJsonSync(file) as Partial<CachedMessageBucketFile>
if (!isCurrentAccountFile(raw, normalizedRoot) || raw.cacheKey !== cacheKey) return null
const result: CachedMessageBucketFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
cacheKey,
updatedAt: Number(raw.updatedAt) || 0,
startTime: raw.startTime,
endTime: raw.endTime,
items: Array.isArray(raw.items) ? raw.items : []
}
touchMemory(messageMemory, file, result, MAX_MEMORY_MESSAGE_BUCKETS)
return result
} catch (error) {
console.warn('[BootstrapCache] message read failed:', error)
return null
}
}
function readGroupSnapshotFile(
accountRoot: string,
userMd5: string
): CachedGroupSnapshotFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const file = getGroupCacheFile(normalizedRoot, userMd5)
const scheduled = readScheduledValue<CachedGroupSnapshotFile>(file)
if (scheduled) return scheduled
const memory = groupMemory.get(file)
if (memory) {
touchMemory(groupMemory, file, memory, MAX_MEMORY_GROUP_SNAPSHOTS)
return memory
}
try {
if (!fs.existsSync(file)) return null
const raw = fs.readJsonSync(file) as Partial<CachedGroupSnapshotFile>
if (!isCurrentAccountFile(raw, normalizedRoot) || raw.userMd5 !== userMd5 || !raw.snapshot) {
return null
}
const result: CachedGroupSnapshotFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
userMd5,
updatedAt: Number(raw.updatedAt) || 0,
snapshot: raw.snapshot
}
touchMemory(groupMemory, file, result, MAX_MEMORY_GROUP_SNAPSHOTS)
return result
} catch (error) {
console.warn('[BootstrapCache] group snapshot read failed:', error)
return null
}
}
async function pruneCacheDirectory(directory: string, maxFiles: number): Promise<void> {
const now = Date.now()
if (now - (lastPrunedAt.get(directory) || 0) < PRUNE_INTERVAL_MS) return
lastPrunedAt.set(directory, now)
try {
const names = (await fs.readdir(directory)).filter((name) => name.endsWith('.json'))
if (names.length <= maxFiles) return
const entries = await Promise.all(
names.map(async (name) => {
const file = path.join(directory, name)
const stat = await fs.stat(file)
return { file, modifiedAt: stat.mtimeMs }
})
)
const expired = entries
.sort((left, right) => right.modifiedAt - left.modifiedAt)
.slice(maxFiles)
await Promise.all(
expired.map(async ({ file }) => {
messageMemory.delete(file)
groupMemory.delete(file)
await fs.remove(file)
})
)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[BootstrapCache] prune failed:', error)
}
}
}
function queueWrite(file: string): void {
const scheduled = scheduledWrites.get(file)
if (!scheduled) return
const previous = writeQueues.get(file) || Promise.resolve()
const next = previous
.catch(() => undefined)
.then(async () => {
const current = scheduledWrites.get(file)
if (
!current ||
current.revision !== scheduled.revision ||
current.generation !== cacheGeneration
) {
return
}
const tempFile = `${file}.${process.pid}.${scheduled.revision}.tmp`
await fs.ensureDir(path.dirname(file))
await fs.writeFile(tempFile, JSON.stringify(current.value), 'utf8')
const latest = scheduledWrites.get(file)
if (
!latest ||
latest.revision !== scheduled.revision ||
latest.generation !== cacheGeneration
) {
await fs.remove(tempFile)
return
}
await fs.move(tempFile, file, { overwrite: true })
const completed = scheduledWrites.get(file)
if (
completed?.revision === scheduled.revision &&
completed.generation === scheduled.generation
) {
scheduledWrites.delete(file)
}
if (current.cleanupFile) await fs.remove(current.cleanupFile)
if (current.prune) {
void pruneCacheDirectory(current.prune.directory, current.prune.maxFiles)
}
})
.catch((error) => {
console.warn('[BootstrapCache] write failed:', error)
})
.finally(() => {
if (writeQueues.get(file) === next) writeQueues.delete(file)
})
writeQueues.set(file, next)
}
function scheduleWrite(
file: string,
value: unknown,
options?: { cleanupFile?: string; prune?: { directory: string; maxFiles: number } }
): void {
const existingTimer = writeTimers.get(file)
if (existingTimer) clearTimeout(existingTimer)
const revision = (writeRevisions.get(file) || 0) + 1
writeRevisions.set(file, revision)
scheduledWrites.set(file, {
value,
revision,
generation: cacheGeneration,
cleanupFile: options?.cleanupFile,
prune: options?.prune
})
writeTimers.set(
file,
setTimeout(() => {
writeTimers.delete(file)
queueWrite(file)
}, WRITE_DEBOUNCE_MS)
)
}
function loadOrCreateStartupCache(accountRoot: string): StartupCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const existing = readStartupCacheFile(normalizedRoot)
if (existing) return existing
const created: StartupCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: []
}
startupMemory.set(getAccountCachePaths(normalizedRoot).startup, created)
return created
}
function writeStartupCache(cache: StartupCacheFile): void {
const paths = getAccountCachePaths(cache.accountRoot)
startupMemory.set(paths.startup, cache)
scheduleWrite(paths.startup, cache, { cleanupFile: paths.legacy })
} }
function containsLegacyMisparsedAppMessage(items: Message[]): boolean { function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
@@ -160,8 +389,7 @@ function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
const content = message.contentData const content = message.contentData
if (content?.type === 'system' && content.raw) { if (content?.type === 'system' && content.raw) {
return ( return (
/<weappinfo\b/i.test(content.raw) && /<weappinfo\b/i.test(content.raw) && /<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
/<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
) )
} }
if ( if (
@@ -176,27 +404,16 @@ function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
}) })
} }
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
const entries = Object.entries(messages)
if (entries.length <= MAX_MESSAGE_BUCKETS) return
entries
.sort((left, right) => (right[1].updatedAt || 0) - (left[1].updatedAt || 0))
.slice(MAX_MESSAGE_BUCKETS)
.forEach(([key]) => {
delete messages[key]
})
}
export function getBootstrapCache(accountRoot?: string): { export function getBootstrapCache(accountRoot?: string): {
self?: CachedSelfInfo self?: CachedSelfInfo
contacts: Contact[] contacts: Contact[]
updatedAt: number updatedAt: number
} | null { } | null {
const cache = readCacheFile(accountRoot) const cache = readStartupCacheFile(normalizeRoot(accountRoot))
if (!cache) return null if (!cache) return null
return { return {
self: cache.self, self: cache.self,
contacts: cache.contacts || [], contacts: cache.contacts,
updatedAt: cache.updatedAt updatedAt: cache.updatedAt
} }
} }
@@ -212,8 +429,8 @@ function isRawContactName(contact: Contact): boolean {
} }
export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] { export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] {
const cache = readCacheFile(accountRoot) const cache = readStartupCacheFile(accountRoot)
if (!cache?.contacts?.length) return contacts if (!cache?.contacts.length) return contacts
const avatarByUsername = new Map( const avatarByUsername = new Map(
cache.contacts cache.contacts
.filter((contact) => contact.m_nsUsrName && contact.avatar) .filter((contact) => contact.m_nsUsrName && contact.avatar)
@@ -241,23 +458,23 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
} }
export function saveBootstrapSelf(accountRoot: string, self: CachedSelfInfo): void { export function saveBootstrapSelf(accountRoot: string, self: CachedSelfInfo): void {
const cache = loadOrCreate(accountRoot) const cache = loadOrCreateStartupCache(accountRoot)
if (!cache) return if (!cache) return
cache.self = self cache.self = self
cache.updatedAt = Date.now() cache.updatedAt = Date.now()
writeCacheFile(cache) writeStartupCache(cache)
} }
export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void { export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void {
const cache = loadOrCreate(accountRoot) const cache = loadOrCreateStartupCache(accountRoot)
if (!cache) return if (!cache) return
const avatarByUsername = new Map( const avatarByUsername = new Map(
(cache.contacts || []) cache.contacts
.filter((contact) => contact.m_nsUsrName && contact.avatar) .filter((contact) => contact.m_nsUsrName && contact.avatar)
.map((contact) => [contact.m_nsUsrName, contact.avatar as string]) .map((contact) => [contact.m_nsUsrName, contact.avatar as string])
) )
const nameByUsername = new Map( const nameByUsername = new Map(
(cache.contacts || []) cache.contacts
.filter( .filter(
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact) (contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
) )
@@ -275,12 +492,12 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]):
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName : nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
})) }))
cache.updatedAt = Date.now() cache.updatedAt = Date.now()
writeCacheFile(cache) writeStartupCache(cache)
} }
export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<string, string>): void { export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<string, string>): void {
const cache = loadOrCreate(accountRoot) const cache = loadOrCreateStartupCache(accountRoot)
if (!cache || !cache.contacts?.length) return if (!cache?.contacts.length) return
let changed = false let changed = false
cache.contacts = cache.contacts.map((contact) => { cache.contacts = cache.contacts.map((contact) => {
const avatar = avatars[contact.m_nsUsrName] const avatar = avatars[contact.m_nsUsrName]
@@ -290,7 +507,7 @@ export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<strin
}) })
if (!changed) return if (!changed) return
cache.updatedAt = Date.now() cache.updatedAt = Date.now()
writeCacheFile(cache) writeStartupCache(cache)
} }
export function getCachedMessages( export function getCachedMessages(
@@ -299,9 +516,9 @@ export function getCachedMessages(
startTime?: number, startTime?: number,
endTime?: number endTime?: number
): Message[] { ): Message[] {
const cache = readCacheFile(accountRoot) return (
const bucket = cache?.messages?.[messageBucketKey(userMd5, startTime, endTime)] readMessageBucketFile(accountRoot, messageBucketKey(userMd5, startTime, endTime))?.items || []
return bucket?.items || [] )
} }
export function getCachedMessagePage( export function getCachedMessagePage(
@@ -310,35 +527,12 @@ export function getCachedMessagePage(
startTime?: number, startTime?: number,
endTime?: number endTime?: number
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } { ): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
const cache = readCacheFile(accountRoot) const bucket = readMessageBucketFile(accountRoot, messageBucketKey(userMd5, startTime, endTime))
const key = messageBucketKey(userMd5, startTime, endTime) const messages = bucket?.items || []
let bucket = cache?.messages?.[key]
if (!bucket && cache?.messages && startTime === undefined && endTime === undefined) {
const merged = new Map<string, Message>()
for (const [cachedKey, candidate] of Object.entries(cache.messages)) {
if (!cachedKey.startsWith(`${userMd5}:`)) continue
for (const message of candidate.items || []) {
merged.set(cachedMessageIdentity(message), message)
}
}
const migratedMessages = Array.from(merged.values())
.sort((left, right) => (left.createTime || 0) - (right.createTime || 0))
.slice(-MAX_MESSAGES_PER_BUCKET)
if (migratedMessages.length > 0) {
bucket = {
updatedAt: Date.now(),
items: migratedMessages
}
cache.messages[key] = bucket
cache.updatedAt = Date.now()
pruneMessageBuckets(cache.messages)
writeCacheFile(cache)
}
}
return { return {
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []), hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(messages),
messages: bucket?.items || [], messages,
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot groupSnapshot: readGroupSnapshotFile(accountRoot, userMd5)?.snapshot
} }
} }
@@ -347,33 +541,22 @@ export function saveCachedGroupSnapshot(
userMd5: string, userMd5: string,
snapshot: CachedGroupSnapshot snapshot: CachedGroupSnapshot
): void { ): void {
const cache = loadOrCreate(accountRoot) const normalizedRoot = normalizeRoot(accountRoot)
if (!cache) return if (!normalizedRoot || !userMd5) return
cache.groupSnapshots ||= {} const paths = getAccountCachePaths(normalizedRoot)
cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot } const file = getGroupCacheFile(normalizedRoot, userMd5)
cache.updatedAt = Date.now() const value: CachedGroupSnapshotFile = {
writeCacheFile(cache) version: CACHE_VERSION,
} platform: process.platform,
accountRoot: normalizedRoot,
export function flushBootstrapCacheWritesSync(): void { userMd5,
for (const [file, cache] of memoryCache) { updatedAt: Date.now(),
const timer = writeTimers.get(file) snapshot
if (timer) clearTimeout(timer)
writeTimers.delete(file)
try {
fs.ensureDirSync(path.dirname(file))
fs.writeFileSync(file, JSON.stringify(cache), 'utf8')
} catch (error) {
console.warn('[BootstrapCache] flush failed:', error)
}
} }
} touchMemory(groupMemory, file, value, MAX_MEMORY_GROUP_SNAPSHOTS)
scheduleWrite(file, value, {
export function clearBootstrapCache(): void { prune: { directory: paths.groups, maxFiles: MAX_GROUP_SNAPSHOTS }
for (const timer of writeTimers.values()) clearTimeout(timer) })
writeTimers.clear()
writeQueues.clear()
memoryCache.clear()
} }
export function saveCachedMessages( export function saveCachedMessages(
@@ -383,17 +566,52 @@ export function saveCachedMessages(
endTime: number | undefined, endTime: number | undefined,
messages: Message[] messages: Message[]
): void { ): void {
const cache = loadOrCreate(accountRoot) const normalizedRoot = normalizeRoot(accountRoot)
if (!cache) return if (!normalizedRoot || !userMd5) return
const nextMessages = cache.messages || {} const cacheKey = messageBucketKey(userMd5, startTime, endTime)
nextMessages[messageBucketKey(userMd5, startTime, endTime)] = { const paths = getAccountCachePaths(normalizedRoot)
const file = getMessageCacheFile(normalizedRoot, cacheKey)
const value: CachedMessageBucketFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
cacheKey,
updatedAt: Date.now(), updatedAt: Date.now(),
startTime, startTime,
endTime, endTime,
items: messages.slice(-MAX_MESSAGES_PER_BUCKET) items: messages.slice(-MAX_MESSAGES_PER_BUCKET)
} }
pruneMessageBuckets(nextMessages) touchMemory(messageMemory, file, value, MAX_MEMORY_MESSAGE_BUCKETS)
cache.messages = nextMessages scheduleWrite(file, value, {
cache.updatedAt = Date.now() prune: { directory: paths.messages, maxFiles: MAX_MESSAGE_BUCKETS }
writeCacheFile(cache) })
}
export function flushBootstrapCacheWritesSync(): void {
for (const timer of writeTimers.values()) clearTimeout(timer)
writeTimers.clear()
const writes = Array.from(scheduledWrites.entries())
scheduledWrites.clear()
for (const [file, scheduled] of writes) {
try {
fs.ensureDirSync(path.dirname(file))
fs.writeFileSync(file, JSON.stringify(scheduled.value), 'utf8')
if (scheduled.cleanupFile) fs.removeSync(scheduled.cleanupFile)
} catch (error) {
console.warn('[BootstrapCache] flush failed:', error)
}
}
}
export function clearBootstrapCache(): void {
cacheGeneration += 1
for (const timer of writeTimers.values()) clearTimeout(timer)
writeTimers.clear()
scheduledWrites.clear()
writeQueues.clear()
writeRevisions.clear()
lastPrunedAt.clear()
startupMemory.clear()
messageMemory.clear()
groupMemory.clear()
} }
+41 -18
View File
@@ -37,6 +37,8 @@ export interface FormattedContact {
avatar?: string avatar?: string
wechatNickname?: string wechatNickname?: string
remark?: string remark?: string
isFolded?: boolean
isMuted?: boolean
} }
export interface FormattedMessage { export interface FormattedMessage {
@@ -140,7 +142,9 @@ export function listContacts(filter?: string): FormattedContact[] {
type: isGroup ? 'group' : 'user', type: isGroup ? 'group' : 'user',
avatar: typeof user.avatar === 'string' ? user.avatar : undefined, avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
wechatNickname: user.wechatNickname, wechatNickname: user.wechatNickname,
remark: user.remark remark: user.remark,
isFolded: user.isFolded,
isMuted: user.isMuted
}) })
} }
@@ -172,13 +176,24 @@ export function listContacts(filter?: string): FormattedContact[] {
return contacts return contacts
} }
export function getContactAvatars(usernames: string[]): Record<string, string> { export async function listContactsAsync(filter?: string): Promise<FormattedContact[]> {
if (!dbRef) return []
await dbRef.getWcdb4Client().getSessionsAsync({
// macOS session rows frequently contain only wxid/chatroom ids. Hydrate
// contact display names before exposing the list to the renderer.
hydrateDisplayNames: true,
hydrateStatuses: true
})
return listContacts(filter)
}
export async function getContactAvatars(usernames: string[]): Promise<Record<string, string>> {
if (!dbRef) return {} if (!dbRef) return {}
const normalized = Array.from( const normalized = Array.from(
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean)) new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
) )
if (normalized.length === 0) return {} if (normalized.length === 0) return {}
return dbRef.getWcdb4Client().getAvatarUrls(normalized) return dbRef.getWcdb4Client().getAvatarUrlsAsync(normalized)
} }
function listSourceMessages( function listSourceMessages(
@@ -245,7 +260,13 @@ function listSourceMessages(
const patContent = const patContent =
system.type === 'system' system.type === 'system'
? { ...system, pat: true } ? { ...system, pat: true }
: { type: 'system' as const, content: String(content || '').replace(/<[^>]+>/g, '').trim(), pat: true } : {
type: 'system' as const,
content: String(content || '')
.replace(/<[^>]+>/g, '')
.trim(),
pat: true
}
contentData = patContent contentData = patContent
content = patContent.content content = patContent.content
displayType = '系统消息' displayType = '系统消息'
@@ -259,11 +280,9 @@ function listSourceMessages(
try { try {
const isQuotePayload = /<refermsg\b/i.test(content) const isQuotePayload = /<refermsg\b/i.test(content)
const hasStickerPayload = const hasStickerPayload =
/<(?:emoji|sticker|emoticon)\b/i.test(content) || /<(?:emoji|sticker|emoticon)\b/i.test(content) || /<type>\s*47\s*<\/type>/i.test(content)
/<type>\s*47\s*<\/type>/i.test(content)
const rowSticker = const rowSticker =
inferredMsgType === 47 || inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
(inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
? parseStickerMessageFromRow(msg, content) ? parseStickerMessageFromRow(msg, content)
: undefined : undefined
const parsedContent = parseMessageContent(content, inferredMsgType) const parsedContent = parseMessageContent(content, inferredMsgType)
@@ -295,7 +314,7 @@ function listSourceMessages(
if (parsed.type === 'system') { if (parsed.type === 'system') {
content = parsed.content content = parsed.content
contentData = parsed contentData = parsed
} else if (parsed.type !== 'unknown') { } else {
content = '' content = ''
} }
if (parsed.type === 'image') { if (parsed.type === 'image') {
@@ -305,8 +324,7 @@ function listSourceMessages(
contentData = { contentData = {
...parsed, ...parsed,
thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg), thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg),
thumbDataUrl: thumbDataUrl: parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
} }
} else if (parsed.type !== 'system') { } else if (parsed.type !== 'system') {
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) { if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
@@ -321,6 +339,11 @@ function listSourceMessages(
if (parsed.type === 'sticker') displayType = '表情包' if (parsed.type === 'sticker') displayType = '表情包'
if (parsed.type === 'miniProgram') displayType = '小程序' if (parsed.type === 'miniProgram') displayType = '小程序'
if (parsed.type === 'redPacket') displayType = '微信红包' if (parsed.type === 'redPacket') displayType = '微信红包'
if (parsed.type === 'forwardBundle') displayType = '合并转发'
if (parsed.type === 'unknown') {
displayType = '不支持的消息'
contentData = { ...parsed, messageType: msgType }
}
if (parsed.type === 'share') { if (parsed.type === 'share') {
if (parsed.typeVal === '5') displayType = '公众号链接' if (parsed.typeVal === '5') displayType = '公众号链接'
if (parsed.typeVal === '6') displayType = '文件' if (parsed.typeVal === '6') displayType = '文件'
@@ -345,6 +368,12 @@ function listSourceMessages(
} }
} }
if (!contentData && !MSG_TYPE_DICT[msgType] && msgType !== 0) {
contentData = { type: 'unknown', raw: rawContent, messageType: msgType }
content = ''
displayType = '不支持的消息'
}
if (msgType === 34) content = '[语音消息]' if (msgType === 34) content = '[语音消息]'
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered']) const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
@@ -397,13 +426,7 @@ export async function listMessagesAsync(
): Promise<FormattedMessage[]> { ): Promise<FormattedMessage[]> {
if (!dbRef) return [] if (!dbRef) return []
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options) const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
const sourceMessages = listSourceMessages( const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options, rawMessages)
userMd5,
startTime,
endTime,
options,
rawMessages
)
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || '' const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
recordRecallArchiveMessages(userMd5, username, sourceMessages) recordRecallArchiveMessages(userMd5, username, sourceMessages)
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit) return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
@@ -0,0 +1,69 @@
import { execFile } from 'child_process'
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import { promisify } from 'util'
import { isUsableDbRoot } from './settings-store'
const execFileAsync = promisify(execFile)
const platformLabel = (): string => {
if (process.platform === 'win32') return `Windows ${os.release()} (${process.arch})`
if (process.platform === 'darwin') return `macOS ${os.release()} (${process.arch})`
return `${process.platform} ${os.release()} (${process.arch})`
}
async function detectWindowsWechatVersion(): Promise<string> {
const script = [
'$process = Get-Process Weixin,WeChat -ErrorAction SilentlyContinue | Where-Object Path | Select-Object -First 1',
'$candidate = if ($process) { $process.Path } else {',
" @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ } | ForEach-Object { Join-Path $_ 'Tencent\\WeChat\\WeChat.exe' } | Where-Object { Test-Path $_ } | Select-Object -First 1",
'}',
'if ($candidate) { (Get-Item -LiteralPath $candidate).VersionInfo.ProductVersion }'
].join('; ')
try {
const { stdout } = await execFileAsync(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', script],
{ timeout: 3000, windowsHide: true }
)
return stdout.trim() || '未检测到'
} catch {
return '未检测到'
}
}
async function detectMacWechatVersion(): Promise<string> {
const candidates = [
'/Applications/WeChat.app/Contents/Info',
path.join(os.homedir(), 'Applications/WeChat.app/Contents/Info')
]
for (const candidate of candidates) {
if (!fs.existsSync(`${candidate}.plist`)) continue
try {
const { stdout } = await execFileAsync(
'/usr/bin/defaults',
['read', candidate, 'CFBundleShortVersionString'],
{ timeout: 3000 }
)
if (stdout.trim()) return stdout.trim()
} catch {
// Continue to the next known installation location.
}
}
return '未检测到'
}
export async function detectWechatVersion(): Promise<string> {
if (process.platform === 'win32') return detectWindowsWechatVersion()
if (process.platform === 'darwin') return detectMacWechatVersion()
return '未检测到'
}
export function detectDataStructureVersion(dbRoot: string): string {
return isUsableDbRoot(dbRoot) ? '微信 4.xWCDB' : '未检测到'
}
export function getOsVersionLabel(): string {
return platformLabel()
}
@@ -9,7 +9,7 @@ import type {
ImageResourceCheck, ImageResourceCheck,
TestImageDecryptionRequest TestImageDecryptionRequest
} from '../../shared/image-decryption' } from '../../shared/image-decryption'
import { ImageDecryptService } from '../image-decrypt-service' import { ImageDecryptService, inspectImageDecoderStatus } from '../image-decrypt-service'
import * as chat from './chat-service' import * as chat from './chat-service'
import { validateImageKeyRequest } from './image-key-config-service' import { validateImageKeyRequest } from './image-key-config-service'
import { isWechatRunning } from './wechat-process-status' import { isWechatRunning } from './wechat-process-status'
@@ -25,6 +25,10 @@ export async function inspectImageDecryptionStatus(
fs.existsSync(path.join(accountRoot, 'cache')) || fs.existsSync(path.join(accountRoot, 'cache')) ||
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')) fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
const dbConnected = chat.isReady() const dbConnected = chat.isReady()
const [wechatRunning, decoder] = await Promise.all([
isWechatRunning(),
inspectImageDecoderStatus()
])
return { return {
configured: config.configured, configured: config.configured,
@@ -36,9 +40,10 @@ export async function inspectImageDecryptionStatus(
updatedAt: config.updatedAt, updatedAt: config.updatedAt,
platform: process.platform, platform: process.platform,
autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin', autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin',
wechatRunning: await isWechatRunning(), wechatRunning,
accountIdentified: Boolean(chat.getSelfAccountInfo()?.wxid), accountIdentified: Boolean(chat.getSelfAccountInfo()?.wxid),
cacheState: canUseCacheRoot() ? 'normal' : 'unavailable', cacheState: canUseCacheRoot() ? 'normal' : 'unavailable',
decoder,
resources: { resources: {
imageIndex: check(dbConnected, dbConnected ? '可用' : '数据库尚未连接'), imageIndex: check(dbConnected, dbConnected ? '可用' : '数据库尚未连接'),
imageDirectory: check(imageDirectoryFound, imageDirectoryFound ? '已找到' : '未找到'), imageDirectory: check(imageDirectoryFound, imageDirectoryFound ? '已找到' : '未找到'),
+18 -1
View File
@@ -28,6 +28,7 @@ export interface AppSettings {
imageXorKey: string imageXorKey: string
imageAesKey: string imageAesKey: string
imageKeyFallbackDisabled: boolean imageKeyFallbackDisabled: boolean
ffmpegPath: string
recallProtectionEnabled: boolean recallProtectionEnabled: boolean
debugEnabled: boolean debugEnabled: boolean
autoLogin: boolean autoLogin: boolean
@@ -85,7 +86,7 @@ function unique(values: string[]): string[] {
return Array.from(new Set(values)) return Array.from(new Set(values))
} }
function isUsableDbRoot(candidate?: string): boolean { export function isUsableDbRoot(candidate?: string): boolean {
if (!candidate || !fs.existsSync(candidate)) return false if (!candidate || !fs.existsSync(candidate)) return false
if (fs.existsSync(path.join(candidate, 'db_storage'))) return true if (fs.existsSync(path.join(candidate, 'db_storage'))) return true
try { try {
@@ -97,6 +98,21 @@ function isUsableDbRoot(candidate?: string): boolean {
} }
} }
export function validateDbRoot(candidate?: string): { valid: boolean; error?: string } {
const root = String(candidate || '').trim()
if (!root) return { valid: false, error: '微信数据目录为空,请重新选择目录' }
if (!fs.existsSync(root)) {
return { valid: false, error: '微信数据目录不存在,请检查路径或重新选择目录' }
}
if (!isUsableDbRoot(root)) {
return {
valid: false,
error: '所选目录中未找到微信 4.x 数据库(db_storage),请选择 xwechat_files 或账号目录'
}
}
return { valid: true }
}
const defaultDbRoot = getDefaultDbRoot() const defaultDbRoot = getDefaultDbRoot()
const DEFAULT_SETTINGS: AppSettings = { const DEFAULT_SETTINGS: AppSettings = {
@@ -108,6 +124,7 @@ const DEFAULT_SETTINGS: AppSettings = {
imageXorKey: '', imageXorKey: '',
imageAesKey: '', imageAesKey: '',
imageKeyFallbackDisabled: false, imageKeyFallbackDisabled: false,
ffmpegPath: '',
recallProtectionEnabled: false, recallProtectionEnabled: false,
debugEnabled: false, debugEnabled: false,
autoLogin: ['1', 'true', 'yes', 'on'].includes( autoLogin: ['1', 'true', 'yes', 'on'].includes(
+27 -3
View File
@@ -5,8 +5,15 @@ import https from 'https'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
import { Wcdb4Client } from './wcdb4-client' import { Wcdb4Client } from './wcdb4-client'
import { classifyStickerHttpFailure, StickerFailureCode } from '../shared/sticker'
type StickerResult = { success: boolean; data?: string; error?: string } type StickerResult = {
success: boolean
data?: string
error?: string
failureCode?: StickerFailureCode
httpStatus?: number
}
const downloadCache = new Map<string, Promise<StickerResult>>() const downloadCache = new Map<string, Promise<StickerResult>>()
@@ -129,15 +136,24 @@ export class StickerService {
const redirectUrl = response.headers.location const redirectUrl = response.headers.location
if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) { if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) {
const nextUrl = new URL(redirectUrl, url).toString() const nextUrl = new URL(redirectUrl, url).toString()
response.resume()
this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve) this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve)
return return
} }
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
const statusCode = Number(response.statusCode || 0)
const failure = classifyStickerHttpFailure(statusCode, url)
response.resume()
console.warn( console.warn(
`[StickerService] download failed: HTTP ${response.statusCode}; md5=${cacheKey}; url=${url}` `[StickerService] download failed code=${failure.code} status=${statusCode} md5=${cacheKey} host=${this.getUrlHost(url)}`
) )
resolve({ success: false, error: `表情包下载失败: HTTP ${response.statusCode}` }) resolve({
success: false,
error: failure.message,
failureCode: failure.code,
httpStatus: statusCode
})
return return
} }
@@ -198,6 +214,14 @@ export class StickerService {
} }
} }
private getUrlHost(url: string): string {
try {
return new URL(url).hostname || 'unknown'
} catch {
return 'unknown'
}
}
private toDataUrl(buffer: Buffer, ext: string): string { private toDataUrl(buffer: Buffer, ext: string): string {
const mimeTypes: Record<string, string> = { const mimeTypes: Record<string, string> = {
'.gif': 'image/gif', '.gif': 'image/gif',
+29 -7
View File
@@ -10,6 +10,7 @@ type VideoAsset = {
export class VideoAssetService { export class VideoAssetService {
private readonly urlTokens = new Map<string, string>() private readonly urlTokens = new Map<string, string>()
private readonly fileTokens = new Map<string, string>()
private index: Map<string, VideoAsset> | null = null private index: Map<string, VideoAsset> | null = null
constructor(private readonly client: Wcdb4Client) {} constructor(private readonly client: Wcdb4Client) {}
@@ -48,8 +49,8 @@ export class VideoAssetService {
if (!asset) continue if (!asset) continue
return { return {
success: true, success: true,
url: this.createUrl(asset.filePath), url: this.createLocalMediaUrl(asset.filePath),
poster: asset.posterPath ? this.createUrl(asset.posterPath) : undefined poster: asset.posterPath ? this.createLocalMediaUrl(asset.posterPath) : undefined
} }
} }
return { success: false, error: '本地未找到该视频文件' } return { success: false, error: '本地未找到该视频文件' }
@@ -61,12 +62,33 @@ export class VideoAssetService {
return filePath return filePath
} }
private createUrl(filePath: string): string { pathForUrl(url: string): string | undefined {
try {
const parsed = new URL(url)
if (parsed.protocol !== 'wxe-media:' || parsed.hostname !== 'local') return undefined
return this.pathForToken(parsed.pathname.replace(/^\/+/, ''))
} catch {
return undefined
}
}
createLocalMediaUrl(filePath: string): string {
const normalizedPath = path.resolve(filePath)
const existingToken = this.fileTokens.get(normalizedPath)
if (existingToken && this.urlTokens.get(existingToken) === normalizedPath) {
return `wxe-media://local/${existingToken}`
}
const token = crypto.randomBytes(18).toString('hex') const token = crypto.randomBytes(18).toString('hex')
this.urlTokens.set(token, filePath) this.urlTokens.set(token, normalizedPath)
if (this.urlTokens.size > 500) { this.fileTokens.set(normalizedPath, token)
const first = this.urlTokens.keys().next().value if (this.urlTokens.size > 2048) {
if (first) this.urlTokens.delete(first) const oldestToken = this.urlTokens.keys().next().value
if (oldestToken) {
const oldestPath = this.urlTokens.get(oldestToken)
this.urlTokens.delete(oldestToken)
if (oldestPath) this.fileTokens.delete(oldestPath)
}
} }
return `wxe-media://local/${token}` return `wxe-media://local/${token}`
} }
+349 -24
View File
@@ -12,6 +12,8 @@ export interface Wcdb4Session {
avatar?: string avatar?: string
wechatNickname?: string wechatNickname?: string
remark?: string remark?: string
isFolded?: boolean
isMuted?: boolean
raw: Record<string, unknown> raw: Record<string, unknown>
} }
@@ -32,6 +34,11 @@ export interface Wcdb4MessageQueryOptions {
limit?: number limit?: number
} }
export interface Wcdb4SessionQueryOptions {
hydrateDisplayNames?: boolean
hydrateStatuses?: boolean
}
type Wcdb4MessageStore = { type Wcdb4MessageStore = {
tableName: string tableName: string
dbPath: string dbPath: string
@@ -173,9 +180,12 @@ export function bootstrapWcdbNativeAsync(
) => number ) => number
const resourceRoots = Array.from( const resourceRoots = Array.from(
new Set( new Set(
[libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter( [
Boolean libDir,
) path.dirname(libDir),
process.env.WCDB_RESOURCES_PATH || '',
...getResourceRoots()
].filter(Boolean)
) )
) )
let initOk = false let initOk = false
@@ -238,9 +248,16 @@ export class Wcdb4Client {
private handle: number | null = null private handle: number | null = null
private displayNameCache = new Map<string, string>() private displayNameCache = new Map<string, string>()
private avatarCache = new Map<string, string>() private avatarCache = new Map<string, string>()
private sessionStatusCache = new Map<string, { isFolded: boolean; isMuted: boolean }>()
private groupNicknameCache = new Map<string, Map<string, string>>() private groupNicknameCache = new Map<string, Map<string, string>>()
private cachedSessions: Wcdb4Session[] | null = null private cachedSessions: Wcdb4Session[] | null = null
private cachedChatTables: { name: string; db_number: string }[] | null = null private cachedChatTables: { name: string; db_number: string }[] | null = null
private sessionsInFlight: Promise<Wcdb4Session[]> | null = null
private sessionDisplayNamesInFlight: Promise<void> | null = null
private sessionDisplayNamesHydrated = false
private sessionStatusesInFlight: Promise<void> | null = null
private sessionStatusesUpdatedAt = 0
private sessionCacheGeneration = 0
private wcdbShutdown: (() => number) | null = null private wcdbShutdown: (() => number) | null = null
private wcdbOpenAccount: private wcdbOpenAccount:
@@ -268,6 +285,12 @@ export class Wcdb4Client {
private wcdbGetAvatarUrls: private wcdbGetAvatarUrls:
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number) | ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
| null = null | null = null
private wcdbGetContactStatus:
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
| null = null
private wcdbGetHeadImageBuffers:
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
| null = null
private wcdbExecQuery: private wcdbExecQuery:
| ((handle: number, kind: string, dbPath: string, sql: string, outJson: WcdbVoidOut) => number) | ((handle: number, kind: string, dbPath: string, sql: string, outJson: WcdbVoidOut) => number)
| null = null | null = null
@@ -531,7 +554,11 @@ export class Wcdb4Client {
this.handle = handleOut[0] this.handle = handleOut[0]
if (this.wcdbSetMyWxid) { if (this.wcdbSetMyWxid) {
try { try {
this.wcdbSetMyWxid(this.handle, this.wxid) await this.callAsyncCode(
this.wcdbSetMyWxid as unknown as KoffiAsyncFunction,
this.handle,
this.wxid
)
} catch { } catch {
// Optional helper. Failure does not block message reads. // Optional helper. Failure does not block message reads.
} }
@@ -552,19 +579,25 @@ export class Wcdb4Client {
this.handle = null this.handle = null
this.cachedSessions = null this.cachedSessions = null
this.sessionDisplayNamesHydrated = false
this.sessionStatusesInFlight = null
this.sessionStatusesUpdatedAt = 0
this.displayNameCache.clear() this.displayNameCache.clear()
this.avatarCache.clear() this.avatarCache.clear()
this.sessionStatusCache.clear()
this.groupNicknameCache.clear() this.groupNicknameCache.clear()
} }
startMonitor(callback: (type: string, json: string) => void): boolean { async startMonitor(callback: (type: string, json: string) => void): Promise<boolean> {
if (!this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) return false if (!this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) return false
this.stopMonitor() this.stopMonitor()
this.monitorCallback = callback this.monitorCallback = callback
try { try {
const startResult = this.wcdbStartMonitorPipe() const startResult = await this.callAsyncCode(
this.wcdbStartMonitorPipe as unknown as KoffiAsyncFunction
)
if (startResult !== 0) { if (startResult !== 0) {
this.monitorCallback = null this.monitorCallback = null
console.warn(`[WCDB4] wcdb_start_monitor_pipe 失败,错误码: ${startResult}`) console.warn(`[WCDB4] wcdb_start_monitor_pipe 失败,错误码: ${startResult}`)
@@ -573,7 +606,10 @@ export class Wcdb4Client {
this.monitorStarted = true this.monitorStarted = true
const outName: WcdbVoidOut = [null] const outName: WcdbVoidOut = [null]
const nameResult = this.wcdbGetMonitorPipeName(outName) const nameResult = await this.callAsyncCode(
this.wcdbGetMonitorPipeName as unknown as KoffiAsyncFunction,
outName
)
if (nameResult !== 0 || !outName[0]) { if (nameResult !== 0 || !outName[0]) {
console.warn(`[WCDB4] wcdb_get_monitor_pipe_name 失败,错误码: ${nameResult}`) console.warn(`[WCDB4] wcdb_get_monitor_pipe_name 失败,错误码: ${nameResult}`)
this.stopMonitor() this.stopMonitor()
@@ -702,22 +738,107 @@ export class Wcdb4Client {
.map((row) => this.normalizeSession(row)) .map((row) => this.normalizeSession(row))
.filter((session) => session.username) .filter((session) => session.username)
this.hydrateDisplayNames(
sessions
.filter((session) => this.shouldHydrateSessionDisplayName(session))
.map((session) => session.username)
)
this.cachedSessions = sessions.map((session) => ({ this.cachedSessions = sessions.map((session) => ({
...session, ...session,
nickname: this.displayNameCache.get(session.username) || session.nickname || session.username nickname: this.displayNameCache.get(session.username) || session.nickname || session.username
})) }))
this.sessionDisplayNamesHydrated = false
return this.cachedSessions return this.cachedSessions
} }
async getSessionsAsync(options: Wcdb4SessionQueryOptions = {}): Promise<Wcdb4Session[]> {
const hydrateDisplayNames = options.hydrateDisplayNames !== false
if (this.cachedSessions) {
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
return this.cachedSessions
}
if (this.sessionsInFlight) {
await this.sessionsInFlight
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
return this.cachedSessions || []
}
if (!this.wcdbGetSessions) return []
const generation = this.sessionCacheGeneration
const request = (async (): Promise<Wcdb4Session[]> => {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbGetSessions as unknown as KoffiAsyncFunction
)
const sessions = (Array.isArray(rows) ? rows : [])
.map((row) => this.normalizeSession(row))
.filter((session) => session.username)
if (generation === this.sessionCacheGeneration) this.cachedSessions = sessions
return sessions
})()
this.sessionsInFlight = request
try {
await request
} finally {
if (this.sessionsInFlight === request) this.sessionsInFlight = null
}
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
return this.cachedSessions || []
}
private async refreshSessionStatusesAsync(): Promise<void> {
if (Date.now() - this.sessionStatusesUpdatedAt < 5 * 60 * 1000) return
if (this.sessionStatusesInFlight) {
await this.sessionStatusesInFlight
return
}
const sessions = this.cachedSessions
if (!sessions?.length || !this.wcdbGetContactStatus) return
const groupUsernames = sessions
.map((session) => session.username)
.filter((username) => username.endsWith('@chatroom'))
if (!groupUsernames.length) {
this.sessionStatusesUpdatedAt = Date.now()
return
}
const request = (async (): Promise<void> => {
try {
const map = await this.callJsonAsync<
Record<string, { isFolded?: boolean; isMuted?: boolean }>
>(
this.wcdbGetContactStatus as unknown as KoffiAsyncFunction,
JSON.stringify(groupUsernames)
)
for (const username of groupUsernames) {
const status = map?.[username]
this.sessionStatusCache.set(username, {
isFolded: Boolean(status?.isFolded),
isMuted: Boolean(status?.isMuted)
})
}
if (this.cachedSessions) {
this.cachedSessions = this.cachedSessions.map((session) => {
const status = this.sessionStatusCache.get(session.username)
return status ? { ...session, ...status } : session
})
}
this.sessionStatusesUpdatedAt = Date.now()
} catch (error) {
console.warn('[WCDB4] session status lookup failed:', error)
}
})()
this.sessionStatusesInFlight = request
try {
await request
} finally {
if (this.sessionStatusesInFlight === request) this.sessionStatusesInFlight = null
}
}
invalidateSessionCache(): void { invalidateSessionCache(): void {
this.sessionCacheGeneration += 1
this.cachedSessions = null this.cachedSessions = null
this.cachedChatTables = null this.cachedChatTables = null
this.sessionsInFlight = null
this.sessionDisplayNamesHydrated = false
} }
getChatTables(): { name: string; db_number: string }[] { getChatTables(): { name: string; db_number: string }[] {
@@ -817,9 +938,27 @@ export class Wcdb4Client {
): Promise<Wcdb4Message[]> { ): Promise<Wcdb4Message[]> {
const startedAt = Date.now() const startedAt = Date.now()
const maxRows = this.normalizeMessageLimit(options.limit) const maxRows = this.normalizeMessageLimit(options.limit)
const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows) let cursorMessages: Wcdb4Message[] = []
try {
cursorMessages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
} catch (error) {
console.warn(`[WCDB4] async cursor messages failed username=${username}:`, error)
}
if (endTime && (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery)) {
throw new Error('当前数据服务无法检查历史消息分片,请更新应用或核对微信数据版本')
}
// Older pages may live in message shards that the native cursor does not
// enumerate. A bounded query must inspect all matching stores so history
// cannot silently stop at a shard boundary.
let tableMessages: Wcdb4Message[] = []
if (endTime || cursorMessages.length === 0) {
tableMessages = await this.getMessagesByTableScanAsync(username, startTime, endTime, maxRows)
}
const messages = this.mergeMessageRows(cursorMessages, tableMessages, maxRows)
console.log( console.log(
`[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms` `[WCDB4] getMessages async username=${username} rows=${messages.length} cursor=${cursorMessages.length} tables=${tableMessages.length} cost=${Date.now() - startedAt}ms`
) )
return messages return messages
} }
@@ -877,6 +1016,71 @@ export class Wcdb4Client {
return this.finalizeMessages(username, allRows, startTime, endTime, limit) return this.finalizeMessages(username, allRows, startTime, endTime, limit)
} }
private async getMessagesByTableScanAsync(
username: string,
startTime?: number,
endTime?: number,
limit?: number
): Promise<Wcdb4Message[]> {
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
let tables: Wcdb4MessageStore[] = []
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction,
username
)
tables = (Array.isArray(rows) ? rows : [])
.map((row) => ({
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
}))
.filter((row) => row.tableName && row.dbPath)
} catch (error) {
console.warn(`[WCDB4] async message table stats failed username=${username}:`, error)
throw new Error(
`无法读取历史消息分片信息:${error instanceof Error ? error.message : String(error)}`
)
}
const begin = this.normalizeTimestamp(startTime || 0)
const end = this.normalizeTimestamp(endTime || 0)
const where = [
begin > 0 ? `"create_time" >= ${begin}` : '',
end > 0 ? `"create_time" <= ${end}` : ''
].filter(Boolean)
const whereSql = where.length ? ` WHERE ${where.join(' AND ')}` : ''
const rowLimit = limit || 5000
const order = limit ? 'DESC' : 'ASC'
const allRows: Record<string, unknown>[] = []
let successfulTables = 0
for (const table of tables) {
try {
const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}`
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'message',
table.dbPath,
sql
)
successfulTables += 1
if (Array.isArray(rows)) allRows.push(...rows)
} catch (error) {
console.warn(
`[WCDB4] async message table scan failed username=${username} db=${table.dbPath} table=${table.tableName}:`,
error
)
}
}
if (tables.length > 0 && successfulTables === 0) {
throw new Error('历史消息分片均读取失败,请检查数据目录或微信数据版本')
}
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
}
installRecallJournal(usernames: string[]): { installed: number; failed: number } { installRecallJournal(usernames: string[]): { installed: number; failed: number } {
const stores = new Map<string, Wcdb4MessageStore>() const stores = new Map<string, Wcdb4MessageStore>()
for (const username of this.uniq(usernames)) { for (const username of this.uniq(usernames)) {
@@ -1066,6 +1270,52 @@ export class Wcdb4Client {
return result return result
} }
async getAvatarUrlsAsync(usernames: string[]): Promise<Record<string, string>> {
const normalized = this.uniq(usernames)
await this.hydrateAvatarUrlsAsync(normalized)
const localCandidates = normalized.filter((username) => {
const avatar = this.avatarCache.get(username)
return !avatar || !avatar.startsWith('data:')
})
if (localCandidates.length && this.wcdbGetHeadImageBuffers) {
try {
const buffers = await this.callJsonAsync<Record<string, string>>(
this.wcdbGetHeadImageBuffers as unknown as KoffiAsyncFunction,
JSON.stringify(localCandidates)
)
for (const [username, hex] of Object.entries(buffers || {})) {
const avatar = this.avatarHexToDataUrl(hex)
if (avatar) this.avatarCache.set(username, avatar)
}
} catch (error) {
console.warn('[WCDB4] local avatar fallback failed:', error)
}
}
const result: Record<string, string> = {}
for (const username of normalized) {
const avatar = this.avatarCache.get(username)
if (avatar) result[username] = avatar
}
return result
}
private avatarHexToDataUrl(value: string): string | undefined {
const hex = String(value || '').trim()
if (!hex || hex.length % 2 !== 0 || !/^[a-f0-9]+$/i.test(hex)) return undefined
const buffer = Buffer.from(hex, 'hex')
let mime = 'image/jpeg'
if (buffer.length >= 8 && buffer.subarray(1, 4).toString('ascii') === 'PNG') mime = 'image/png'
if (
buffer.length >= 12 &&
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
mime = 'image/webp'
}
return `data:${mime};base64,${buffer.toString('base64')}`
}
getMyGroupNickname(chatroomId: string): string | undefined { getMyGroupNickname(chatroomId: string): string | undefined {
const groupNicknames = this.getGroupNicknames(chatroomId) const groupNicknames = this.getGroupNicknames(chatroomId)
for (const candidate of this.getMyUsernameCandidates()) { for (const candidate of this.getMyUsernameCandidates()) {
@@ -1432,9 +1682,10 @@ export class Wcdb4Client {
const nicknames = new Map<string, string>() const nicknames = new Map<string, string>()
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
const rows = await this.callJsonAsync< const rows = await this.callJsonAsync<Record<string, string> | Record<string, unknown>[]>(
Record<string, string> | Record<string, unknown>[] this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction,
>(this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId) chatroomId
)
this.readStringMap(rows, [ this.readStringMap(rows, [
'nickname', 'nickname',
'nickName', 'nickName',
@@ -1527,6 +1778,25 @@ export class Wcdb4Client {
} }
} }
async resolveImageHardlinkAsync(md5: string): Promise<Wcdb4ImageHardlink | null> {
if (!this.wcdbResolveImageHardlink) return null
const normalizedMd5 = String(md5 || '')
.trim()
.toLowerCase()
if (!normalizedMd5) return null
try {
return await this.callJsonAsync<Wcdb4ImageHardlink>(
this.wcdbResolveImageHardlink as unknown as KoffiAsyncFunction,
normalizedMd5,
this.accountRoot
)
} catch (error) {
console.warn('[WCDB4] async image hardlink resolve failed:', error)
return null
}
}
resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null { resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null {
if (!this.wcdbResolveVideoHardlink) return null if (!this.wcdbResolveVideoHardlink) return null
const normalizedMd5 = String(md5 || '') const normalizedMd5 = String(md5 || '')
@@ -1681,6 +1951,22 @@ export class Wcdb4Client {
this.wcdbGetAvatarUrls = null this.wcdbGetAvatarUrls = null
} }
try {
this.wcdbGetContactStatus = lib.func(
'int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)'
) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number
} catch {
this.wcdbGetContactStatus = null
}
try {
this.wcdbGetHeadImageBuffers = lib.func(
'int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)'
) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number
} catch {
this.wcdbGetHeadImageBuffers = null
}
try { try {
this.wcdbExecQuery = lib.func( this.wcdbExecQuery = lib.func(
'int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)' 'int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)'
@@ -2004,7 +2290,16 @@ export class Wcdb4Client {
'contactRemark', 'contactRemark',
'contact_remark' 'contact_remark'
]) ])
return { username, nickname, wechatNickname, remark, raw: row } const status = this.sessionStatusCache.get(username)
return {
username,
nickname,
wechatNickname,
remark,
isFolded: status?.isFolded,
isMuted: status?.isMuted,
raw: row
}
} }
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message { private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
@@ -2133,9 +2428,10 @@ export class Wcdb4Client {
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username)) const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
if (missing.length === 0) return if (missing.length === 0) return
try { try {
const rows = await this.callJsonAsync< const rows = await this.callJsonAsync<Record<string, string> | Record<string, unknown>[]>(
Record<string, string> | Record<string, unknown>[] this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction,
>(this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing)) JSON.stringify(missing)
)
this.readStringMap(rows, [ this.readStringMap(rows, [
'nickname', 'nickname',
'displayName', 'displayName',
@@ -2148,6 +2444,34 @@ export class Wcdb4Client {
} }
} }
private async ensureSessionDisplayNamesAsync(): Promise<void> {
if (this.sessionDisplayNamesHydrated || !this.cachedSessions) return
if (this.sessionDisplayNamesInFlight) return this.sessionDisplayNamesInFlight
const generation = this.sessionCacheGeneration
const sessions = this.cachedSessions
const request = (async (): Promise<void> => {
await this.hydrateDisplayNamesAsync(
sessions
.filter((session) => this.shouldHydrateSessionDisplayName(session))
.map((session) => session.username)
)
if (generation !== this.sessionCacheGeneration || this.cachedSessions !== sessions) return
this.cachedSessions = sessions.map((session) => ({
...session,
nickname:
this.displayNameCache.get(session.username) || session.nickname || session.username
}))
this.sessionDisplayNamesHydrated = true
})()
this.sessionDisplayNamesInFlight = request
try {
await request
} finally {
if (this.sessionDisplayNamesInFlight === request) this.sessionDisplayNamesInFlight = null
}
}
private hydrateAvatarUrls(usernames: string[]): void { private hydrateAvatarUrls(usernames: string[]): void {
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username)) const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
if (missing.length === 0) return if (missing.length === 0) return
@@ -2187,9 +2511,10 @@ export class Wcdb4Client {
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username)) const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
if (missing.length === 0) return if (missing.length === 0) return
try { try {
const rows = await this.callJsonAsync< const rows = await this.callJsonAsync<Record<string, string> | Record<string, unknown>[]>(
Record<string, string> | Record<string, unknown>[] this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction,
>(this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing)) JSON.stringify(missing)
)
this.readStringMap(rows, [ this.readStringMap(rows, [
'avatarUrl', 'avatarUrl',
'avatar_url', 'avatar_url',
+6 -7
View File
@@ -6,6 +6,8 @@ export interface UserContact {
avatar?: string avatar?: string
wechatNickname?: string wechatNickname?: string
remark?: string remark?: string
isFolded?: boolean
isMuted?: boolean
} }
export interface WechatMessage { export interface WechatMessage {
@@ -80,7 +82,9 @@ export class WechatDb {
nickname: session.nickname || session.username, nickname: session.nickname || session.username,
avatar: session.avatar, avatar: session.avatar,
wechatNickname: session.wechatNickname, wechatNickname: session.wechatNickname,
remark: session.remark remark: session.remark,
isFolded: session.isFolded,
isMuted: session.isMuted
})) }))
.filter((contact) => { .filter((contact) => {
if (!keyword) return true if (!keyword) return true
@@ -176,12 +180,7 @@ export class WechatDb {
this.ensureChatTableMapping() this.ensureChatTableMapping()
const username = this.chatMd5ToUsername.get(userMd5) const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return [] if (!username) return []
const messages = await this.wcdb4Client.getMessagesAsync( const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options)
username,
startTime,
endTime,
options
)
return messages.map((message) => ({ ...message, ...message.raw })) return messages.map((message) => ({ ...message, ...message.raw }))
} }
+32 -9
View File
@@ -11,9 +11,12 @@ import {
import type { import type {
DatabaseKeyEnvironment, DatabaseKeyEnvironment,
DatabaseKeyStorageResult, DatabaseKeyStorageResult,
DatabaseKeyValidationResult DatabaseKeyValidationResult,
AccountDiscoveryResult
} from '../shared/database-key' } from '../shared/database-key'
import type { import type {
ImageDecoderSelectionResult,
ImageDecoderStatus,
ImageDecryptionStatus, ImageDecryptionStatus,
ImageDecryptionTestResult, ImageDecryptionTestResult,
ImageKeyConfigResult, ImageKeyConfigResult,
@@ -113,8 +116,10 @@ declare global {
getCacheSummary: () => Promise<CacheSummary> getCacheSummary: () => Promise<CacheSummary>
clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise<CacheSummary> clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise<CacheSummary>
initDb: ( initDb: (
key: string key: string,
accountRoot: string
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }> ) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
discoverAccounts: (inputPath: string) => Promise<AccountDiscoveryResult>
getBootstrapCache: () => Promise<{ getBootstrapCache: () => Promise<{
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string } self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
contacts: Contact[] contacts: Contact[]
@@ -205,7 +210,7 @@ declare global {
imageMd5?: string, imageMd5?: string,
imageDatNameOrThumb?: string | boolean, imageDatNameOrThumb?: string | boolean,
sessionId?: string, sessionId?: string,
options?: { force?: boolean; preferThumbnail?: boolean } options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
) => Promise<{ ) => Promise<{
success: boolean success: boolean
data?: string data?: string
@@ -219,7 +224,13 @@ declare global {
getSticker: ( getSticker: (
cdnUrl?: string, cdnUrl?: string,
md5?: string md5?: string
) => Promise<{ success: boolean; data?: string; error?: string }> ) => Promise<{
success: boolean
data?: string
error?: string
failureCode?: import('../shared/sticker').StickerFailureCode
httpStatus?: number
}>
startExport: (request: ExportRequest) => Promise<ExportResult> startExport: (request: ExportRequest) => Promise<ExportResult>
cancelExport: (jobId: string) => Promise<{ success: boolean }> cancelExport: (jobId: string) => Promise<{ success: boolean }>
revealExport: (path: string) => Promise<{ success: boolean; error?: string }> revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
@@ -231,14 +242,17 @@ declare global {
) => Promise<SaveGeneratedReportResult> ) => Promise<SaveGeneratedReportResult>
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult> deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
getSavedDbKey: () => Promise<DatabaseKeyStorageResult> getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment> getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
readDatabaseKeyClipboard: () => Promise<{ readDatabaseKeyClipboard: () => Promise<{
success: boolean success: boolean
value?: string value?: string
error?: string error?: string
}> }>
autoGetDbKey: (options?: { save?: boolean }) => Promise<{ autoGetDbKey: (
accountRoot: string,
options?: { save?: boolean }
) => Promise<{
success: boolean success: boolean
key?: string key?: string
error?: string error?: string
@@ -260,6 +274,7 @@ declare global {
apiHost: string apiHost: string
apiPort: number apiPort: number
imageKeyRoot: string imageKeyRoot: string
ffmpegPath: string
recallProtectionEnabled: boolean recallProtectionEnabled: boolean
debugEnabled: boolean debugEnabled: boolean
autoLogin: boolean autoLogin: boolean
@@ -273,14 +288,19 @@ declare global {
}> }>
getImageKeyConfig: () => Promise<ImageKeyConfigResult> getImageKeyConfig: () => Promise<ImageKeyConfigResult>
getImageDecryptionStatus: () => Promise<ImageDecryptionStatus> getImageDecryptionStatus: () => Promise<ImageDecryptionStatus>
selectImageDecoder: () => Promise<ImageDecoderSelectionResult>
getImageDecoderStatus: () => Promise<ImageDecoderStatus>
openImageDecoderDownload: () => Promise<{ success: boolean; error?: string }>
saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise<ImageKeyConfigResult> saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise<ImageKeyConfigResult>
testImageDecryption: ( testImageDecryption: (
request: TestImageDecryptionRequest request: TestImageDecryptionRequest
) => Promise<ImageDecryptionTestResult> ) => Promise<ImageDecryptionTestResult>
clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }> clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }>
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> pasteAndSaveDbKey: (
saveDbKey: (key: string) => Promise<DatabaseKeyStorageResult> accountRoot: string
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }> ) => Promise<{ success: boolean; key?: string; error?: string }>
saveDbKey: (accountRoot: string, key: string) => Promise<DatabaseKeyStorageResult>
clearSavedDbKey: (accountRoot: string) => Promise<{ success: boolean; error?: string }>
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
@@ -291,6 +311,7 @@ declare global {
apiHost: string apiHost: string
apiPort: number apiPort: number
imageKeyRoot: string imageKeyRoot: string
ffmpegPath: string
recallProtectionEnabled: boolean recallProtectionEnabled: boolean
debugEnabled: boolean debugEnabled: boolean
autoLogin: boolean autoLogin: boolean
@@ -310,6 +331,7 @@ declare global {
apiHost: string apiHost: string
apiPort: number apiPort: number
imageKeyRoot: string imageKeyRoot: string
ffmpegPath: string
recallProtectionEnabled: boolean recallProtectionEnabled: boolean
debugEnabled: boolean debugEnabled: boolean
autoLogin: boolean autoLogin: boolean
@@ -327,6 +349,7 @@ declare global {
apiHost: string apiHost: string
apiPort: number apiPort: number
imageKeyRoot: string imageKeyRoot: string
ffmpegPath: string
recallProtectionEnabled: boolean recallProtectionEnabled: boolean
debugEnabled: boolean debugEnabled: boolean
autoLogin: boolean autoLogin: boolean
+20 -7
View File
@@ -20,6 +20,8 @@ import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateState } from '../shared/app-update' import type { AppUpdateState } from '../shared/app-update'
import type { CacheSummary } from '../shared/cache' import type { CacheSummary } from '../shared/cache'
import type { ExportRequest, ExportJobProgress } from '../shared/export' import type { ExportRequest, ExportJobProgress } from '../shared/export'
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
import type { AccountDiscoveryResult } from '../shared/database-key'
// 渲染器的自定义 API // 渲染器的自定义 API
const api = { const api = {
@@ -39,7 +41,9 @@ const api = {
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'), getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise<CacheSummary> => clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise<CacheSummary> =>
ipcRenderer.invoke('cache:clear', scope), ipcRenderer.invoke('cache:clear', scope),
initDb: (key: string) => ipcRenderer.invoke('db:init', key), initDb: (key: string, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot),
discoverAccounts: (inputPath: string): Promise<AccountDiscoveryResult> =>
ipcRenderer.invoke('accounts:discover', inputPath),
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'), getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'), getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter), getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
@@ -76,7 +80,7 @@ const api = {
imageMd5?: string, imageMd5?: string,
imageDatNameOrThumb?: string | boolean, imageDatNameOrThumb?: string | boolean,
sessionId?: string, sessionId?: string,
options?: { force?: boolean; preferThumbnail?: boolean } options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options), ) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes), getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5), getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
@@ -97,20 +101,29 @@ const api = {
deleteGeneratedReport: (reportId: string) => deleteGeneratedReport: (reportId: string) =>
ipcRenderer.invoke('report:deleteGenerated', reportId), ipcRenderer.invoke('report:deleteGenerated', reportId),
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'), getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot),
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'), getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'), readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
autoGetDbKey: (options?: { save?: boolean }) => ipcRenderer.invoke('key:autoGetDbKey', options), autoGetDbKey: (accountRoot: string, options?: { save?: boolean }) =>
ipcRenderer.invoke('key:autoGetDbKey', accountRoot, options),
autoGetImageKey: (options?: { save?: boolean }) => autoGetImageKey: (options?: { save?: boolean }) =>
ipcRenderer.invoke('key:autoGetImageKey', options), ipcRenderer.invoke('key:autoGetImageKey', options),
getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'), getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'),
getImageDecryptionStatus: () => ipcRenderer.invoke('image:getStatus'), getImageDecryptionStatus: () => ipcRenderer.invoke('image:getStatus'),
selectImageDecoder: (): Promise<ImageDecoderSelectionResult> =>
ipcRenderer.invoke('image:selectDecoder'),
getImageDecoderStatus: (): Promise<ImageDecoderStatus> =>
ipcRenderer.invoke('image:getDecoderStatus'),
openImageDecoderDownload: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('image:openDecoderDownload'),
saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request), saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request),
testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request), testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request),
clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'), clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'),
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'), pasteAndSaveDbKey: (accountRoot: string) =>
saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key), ipcRenderer.invoke('key:pasteAndSaveDbKey', accountRoot),
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'), saveDbKey: (accountRoot: string, key: string) =>
ipcRenderer.invoke('key:saveDbKey', accountRoot, key),
clearSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:clearSavedDbKey', accountRoot),
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => { onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
const listener = ( const listener = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
+324 -39
View File
@@ -20,12 +20,36 @@ import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportG
import { SummaryDateRange, SummaryMessageType } from './utils/group-report' import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
import { Contact, Message } from '../../shared/types' import { Contact, Message } from '../../shared/types'
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage' import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
import { FirstUseWelcome } from './components/FirstUseWelcome'
import { ExportWorkspace } from './components/export/ExportWorkspace' import { ExportWorkspace } from './components/export/ExportWorkspace'
import { AISearchWorkspace } from './components/search/AISearchWorkspace' import { AISearchWorkspace } from './components/search/AISearchWorkspace'
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export' import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key'
import {
getMessageIdentity,
mergeMessagePages,
sortMessagesChronologically
} from './utils/message-pages'
const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380 const SIDEBAR_MAX_WIDTH = 380
const DATABASE_CONNECT_TIMEOUT_MS = 30_000
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs)
promise.then(
(value) => {
window.clearTimeout(timer)
resolve(value)
},
(error) => {
window.clearTimeout(timer)
reject(error)
}
)
})
}
function getDevelopmentDatabaseKey(): string { function getDevelopmentDatabaseKey(): string {
if (!import.meta.env.DEV) return '' if (!import.meta.env.DEV) return ''
@@ -39,18 +63,13 @@ interface SelfInfo {
accountRoot: string accountRoot: string
} }
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md' const MAC_KEY_FAQ_URL = 'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/mac-disable-sip.md'
const FIRST_USE_WELCOME_SEEN_KEY = 'wxe_first_use_welcome_seen'
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000 const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
const INITIAL_MESSAGE_COUNT = 20 const INITIAL_MESSAGE_COUNT = 20
const MESSAGE_PAGE_SIZE = 100 const MESSAGE_PAGE_SIZE = 100
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
const EXPORT_PREVIEW_LIMIT = 20 const EXPORT_PREVIEW_LIMIT = 20
const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
const normalizeQuotedText = (value: string | undefined): string => const normalizeQuotedText = (value: string | undefined): string =>
String(value || '') String(value || '')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
@@ -192,27 +211,16 @@ const buildSyntheticGroupMessages = (
void buildSyntheticGroupMessages void buildSyntheticGroupMessages
const sortMessagesChronologically = (items: Message[]): Message[] =>
[...items].sort((left, right) => {
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
if (timeDelta !== 0) return timeDelta
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
})
const mergeMessagePages = (older: Message[], current: Message[]): Message[] => {
const merged = new Map<string, Message>()
for (const message of [...older, ...current]) merged.set(getMessageIdentity(message), message)
return sortMessagesChronologically(Array.from(merged.values()))
}
function App(): React.ReactElement { function App(): React.ReactElement {
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false) const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
const [isDatabaseConnecting, setIsDatabaseConnecting] = useState(false)
const [dbKey, setDbKey] = useState(getDevelopmentDatabaseKey) const [dbKey, setDbKey] = useState(getDevelopmentDatabaseKey)
const [contacts, setContacts] = useState<Contact[]>([]) const [contacts, setContacts] = useState<Contact[]>([])
const [selectedContact, setSelectedContact] = useState<Contact | null>(null) const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
const [isMessagesLoading, setIsMessagesLoading] = useState(false) const [isMessagesLoading, setIsMessagesLoading] = useState(false)
const [messageHistoryStatus, setMessageHistoryStatus] = useState<'idle' | 'end' | 'error'>('idle')
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([]) const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
const [contentFilter, setContentFilter] = useState('') const [contentFilter, setContentFilter] = useState('')
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
@@ -220,10 +228,16 @@ function App(): React.ReactElement {
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal') const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
const [showDbKey, setShowDbKey] = useState(false) const [showDbKey, setShowDbKey] = useState(false)
const [dbRootInput, setDbRootInput] = useState('') const [dbRootInput, setDbRootInput] = useState('')
const [discoveredAccounts, setDiscoveredAccounts] = useState<WechatAccountCandidate[]>([])
const [selectedAccountId, setSelectedAccountId] = useState('')
const selectedAccount = discoveredAccounts.find((account) => account.id === selectedAccountId)
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false) const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
const [databaseConnectionMode, setDatabaseConnectionMode] = useState<DatabaseConnectionMode>( const [databaseConnectionMode, setDatabaseConnectionMode] = useState<DatabaseConnectionMode>(
getDevelopmentDatabaseKey() ? 'manual' : 'automatic' getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
) )
const [connectionGuideStep, setConnectionGuideStep] = useState<1 | 2 | 3 | 4 | 5 | 6>(1)
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
const connectionOperationRef = React.useRef(0)
const [activePage, setActivePage] = useState<AppPage>('archive') const [activePage, setActivePage] = useState<AppPage>('archive')
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null) const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database') const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
@@ -256,6 +270,7 @@ function App(): React.ReactElement {
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading') const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null) const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
const [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null) const [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null)
const [showFirstUseWelcome, setShowFirstUseWelcome] = useState(false)
const [appearanceSettings, setAppearanceSettings] = React.useState<{ const [appearanceSettings, setAppearanceSettings] = React.useState<{
theme: 'system' | 'light' | 'dark' theme: 'system' | 'light' | 'dark'
compactMode: boolean compactMode: boolean
@@ -288,6 +303,34 @@ function App(): React.ReactElement {
}) })
}) })
}, []) }, [])
const refreshConnectionEnvironment = React.useCallback(async (): Promise<void> => {
const root = dbRootInput.trim()
try {
if (root) {
const discovery = await window.api.discoverAccounts(root)
if (!discovery.success) throw new Error(discovery.error || '账号目录识别失败')
setDiscoveredAccounts(discovery.accounts)
setSelectedAccountId(discovery.preselectedAccountId || '')
}
const environment = await window.api.getDatabaseKeyEnvironment()
setDatabaseEnvironment(environment)
setDbKeyStatus('环境检查已更新')
setDbKeyStatusKind('normal')
} catch (error) {
setDbKeyStatus(
error instanceof Error ? `环境检查失败:${error.message}` : '环境检查失败,请重试'
)
setDbKeyStatusKind('error')
}
}, [dbRootInput])
React.useEffect(() => {
void window.api
.getDatabaseKeyEnvironment()
.then(setDatabaseEnvironment)
.catch(() => undefined)
}, [])
React.useEffect(() => { React.useEffect(() => {
const loadAIConfig = async (): Promise<void> => { const loadAIConfig = async (): Promise<void> => {
try { try {
@@ -543,13 +586,26 @@ function App(): React.ReactElement {
if (active && settingsResult.settings.dbRoot) { if (active && settingsResult.settings.dbRoot) {
setDbRootInput(settingsResult.settings.dbRoot) setDbRootInput(settingsResult.settings.dbRoot)
} }
const discovery = settingsResult.settings.dbRoot
? await window.api.discoverAccounts(settingsResult.settings.dbRoot)
: { success: false, accounts: [] }
if (active && discovery.success) {
setDiscoveredAccounts(discovery.accounts)
setSelectedAccountId(discovery.preselectedAccountId || '')
}
const startupAccountRoot = discovery.success
? discovery.accounts.find((account) => account.id === discovery.preselectedAccountId)
?.accountRoot
: undefined
const autoLoginEnabled = settingsResult.settings.autoLogin const autoLoginEnabled = settingsResult.settings.autoLogin
// 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。 // 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。
const envKey = getDevelopmentDatabaseKey() const envKey = getDevelopmentDatabaseKey()
// 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。 // 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。
let savedKey = '' let savedKey = ''
if (!envKey) { if (!envKey) {
const result = await window.api.getSavedDbKey() const result = startupAccountRoot
? await window.api.getSavedDbKey(startupAccountRoot)
: { success: true, saved: false, encryptionAvailable: true }
if (result.success && result.key) savedKey = result.key if (result.success && result.key) savedKey = result.key
} }
const key = envKey || savedKey const key = envKey || savedKey
@@ -579,7 +635,12 @@ function App(): React.ReactElement {
if (!autoLoginEnabled) return if (!autoLoginEnabled) return
try { try {
const startupCacheReady = await loadStartupCache() const startupCacheReady = await loadStartupCache()
const initPromise = window.api.initDb(key) setIsDatabaseConnecting(true)
if (!startupAccountRoot) {
setBootState('login')
return
}
const initPromise = window.api.initDb(key, startupAccountRoot)
if (startupCacheReady) { if (startupCacheReady) {
setIsAuthenticated(true) setIsAuthenticated(true)
setIsDatabaseConnected(false) setIsDatabaseConnected(false)
@@ -596,13 +657,19 @@ function App(): React.ReactElement {
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsDatabaseConnected(true) setIsDatabaseConnected(true)
setDbKeyStatus('已连接数据库') setDbKeyStatus('已连接数据库')
// Cached contacts/self info are enough for startup. Native refresh is // The cached list paints first. Refresh lightweight session flags and
// intentionally user-triggered so it cannot freeze the first session. // missing avatars after the database is connected.
void loadContacts({ waitForAvatars: false }).catch((error) => {
console.warn('[Startup] background contact refresh failed:', error)
})
}) })
.catch((error) => { .catch((error) => {
console.warn('[Startup] background database init failed:', error) console.warn('[Startup] background database init failed:', error)
setDbKeyStatusKind('error') setDbKeyStatusKind('error')
}) })
.finally(() => {
if (active) setIsDatabaseConnecting(false)
})
return return
} }
const result = await initPromise const result = await initPromise
@@ -635,6 +702,8 @@ function App(): React.ReactElement {
setDbKeyStatus(`自动连接失败: ${message}`) setDbKeyStatus(`自动连接失败: ${message}`)
setDbKeyStatusKind('error') setDbKeyStatusKind('error')
setBootState('login') setBootState('login')
} finally {
if (active) setIsDatabaseConnecting(false)
} }
} }
void attemptAutoConnect() void attemptAutoConnect()
@@ -656,12 +725,36 @@ function App(): React.ReactElement {
void loadGeneratedReports() void loadGeneratedReports()
}, [isAuthenticated, loadGeneratedReports]) }, [isAuthenticated, loadGeneratedReports])
const handleLogin = async (keyInput?: string): Promise<void> => { const handleLogin = async (keyInput?: string, accountRootInput?: string): Promise<void> => {
const keyToUse = keyInput || dbKey const keyToUse = keyInput || dbKey
if (!keyToUse) return let accountRoot = accountRootInput || selectedAccount?.accountRoot
setBootState('connecting') if (!keyToUse || isDatabaseConnecting) return
if (!accountRoot) {
const discovery = await window.api.discoverAccounts(dbRootInput.trim())
if (!discovery.success) {
setDbKeyStatus(discovery.error || '微信数据目录不可用')
setDbKeyStatusKind('error')
return
}
if (!discovery.preselectedAccountId) {
setDiscoveredAccounts(discovery.accounts)
setDbKeyStatus('请选择要连接的微信账号')
setDbKeyStatusKind('error')
return
}
const account = discovery.accounts.find(
(candidate) => candidate.id === discovery.preselectedAccountId
)
if (!account) return
setDiscoveredAccounts(discovery.accounts)
setSelectedAccountId(account.id)
accountRoot = account.accountRoot
}
const operationId = ++connectionOperationRef.current
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(6)
setIsDatabaseConnecting(true)
// 持久化用户手动指定的微信聊天文件路径,供 db:init 读取 settings.dbRoot // 持久化用户手动指定的微信聊天文件路径,供 db:init 读取 settings.dbRoot
const trimmedRoot = dbRootInput.trim() const trimmedRoot = accountRoot
if (trimmedRoot) { if (trimmedRoot) {
try { try {
await window.api.setSettings({ dbRoot: trimmedRoot }) await window.api.setSettings({ dbRoot: trimmedRoot })
@@ -682,7 +775,12 @@ function App(): React.ReactElement {
detail: '正在打开 WCDB 数据库', detail: '正在打开 WCDB 数据库',
percent: 15 percent: 15
}) })
const result = await window.api.initDb(keyToUse) const result = await withTimeout(
window.api.initDb(keyToUse, trimmedRoot),
DATABASE_CONNECT_TIMEOUT_MS,
'数据库连接超时,请检查数据目录后重试'
)
if (operationId !== connectionOperationRef.current) return
const success = typeof result === 'boolean' ? result : result.success const success = typeof result === 'boolean' ? result : result.success
if (success) { if (success) {
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
@@ -693,8 +791,9 @@ function App(): React.ReactElement {
percent: 25 percent: 25
}) })
const hasBootstrap = await loadBootstrapCache() const hasBootstrap = await loadBootstrapCache()
if (operationId !== connectionOperationRef.current) return
// 持久化手动输入的密钥,供下次启动继续使用 // 持久化手动输入的密钥,供下次启动继续使用
void window.api.saveDbKey(keyToUse).catch(() => undefined) void window.api.saveDbKey(trimmedRoot, keyToUse).catch(() => undefined)
void window.api.getSettings().then((current) => { void window.api.getSettings().then((current) => {
if (!current.settings.autoLoginPreferenceSet) { if (!current.settings.autoLoginPreferenceSet) {
void window.api.setSettings({ autoLogin: true }) void window.api.setSettings({ autoLogin: true })
@@ -728,20 +827,29 @@ function App(): React.ReactElement {
}) })
setIsDatabaseConnected(true) setIsDatabaseConnected(true)
setBootState('login') setBootState('login')
maybeShowFirstUseWelcome()
window.setTimeout(() => { window.setTimeout(() => {
setStartupProgress(null) setStartupProgress(null)
}, 500) }, 500)
} else { } else {
const error = typeof result === 'boolean' ? '' : result.error const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(error || '数据库连接失败,请检查密钥和数据目录后重试')
setDbKeyStatusKind('error')
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
setBootState('login') setBootState('login')
setStartupProgress(null) setStartupProgress(null)
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
setDbKeyStatus(
error instanceof Error ? `数据库连接失败:${error.message}` : '数据库连接失败,请重试'
)
setDbKeyStatusKind('error')
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
setBootState('login') setBootState('login')
setStartupProgress(null) setStartupProgress(null)
alert('Error connecting to database') } finally {
if (operationId === connectionOperationRef.current) setIsDatabaseConnecting(false)
} }
} }
@@ -861,31 +969,42 @@ function App(): React.ReactElement {
const handleAutoGetDbKey = async (): Promise<void> => { const handleAutoGetDbKey = async (): Promise<void> => {
if (isFetchingDbKey) return if (isFetchingDbKey) return
const operationId = ++connectionOperationRef.current
setConnectionGuideStep(4)
setIsFetchingDbKey(true) setIsFetchingDbKey(true)
setDbKeyStatus('正在准备获取密钥...') setDbKeyStatus('正在准备获取密钥...')
setDbKeyStatusKind('normal') setDbKeyStatusKind('normal')
setShowMacKeyFaq(false) setShowMacKeyFaq(false)
try { try {
const result = await window.api.autoGetDbKey() if (!selectedAccount) throw new Error('请先选择微信账号')
const result = await window.api.autoGetDbKey(selectedAccount.accountRoot)
if (operationId !== connectionOperationRef.current) return
if (!result.success || !result.key) { if (!result.success || !result.key) {
setShowMacKeyFaq(result.code === 'SCAN_FAILED') setShowMacKeyFaq(result.code === 'SCAN_FAILED')
throw new Error(result.error || '获取密钥失败') throw new Error(result.error || '获取密钥失败')
} }
setDbKey(result.key) setDbKey(result.key)
setDatabaseConnectionMode('manual') setConnectionGuideStep(5)
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取') setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
setDbKeyStatusKind(result.saved ? 'success' : 'normal') setDbKeyStatusKind(result.saved ? 'success' : 'normal')
} catch (error) { } catch (error) {
if (operationId !== connectionOperationRef.current) return
setDbKeyStatus(error instanceof Error ? error.message : String(error)) setDbKeyStatus(error instanceof Error ? error.message : String(error))
setDbKeyStatusKind('error') setDbKeyStatusKind('error')
setConnectionGuideStep(3)
} finally { } finally {
setIsFetchingDbKey(false) if (operationId === connectionOperationRef.current) setIsFetchingDbKey(false)
} }
} }
const handlePasteAndSaveDbKey = async (): Promise<void> => { const handlePasteAndSaveDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false) setShowMacKeyFaq(false)
const result = await window.api.pasteAndSaveDbKey() if (!selectedAccount) {
setDbKeyStatus('请先选择微信账号')
setDbKeyStatusKind('error')
return
}
const result = await window.api.pasteAndSaveDbKey(selectedAccount.accountRoot)
if (result.success && result.key) { if (result.success && result.key) {
setDbKey(result.key) setDbKey(result.key)
setDatabaseConnectionMode('manual') setDatabaseConnectionMode('manual')
@@ -899,7 +1018,8 @@ function App(): React.ReactElement {
const handleClearSavedDbKey = async (): Promise<void> => { const handleClearSavedDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false) setShowMacKeyFaq(false)
const result = await window.api.clearSavedDbKey() if (!selectedAccount) return
const result = await window.api.clearSavedDbKey(selectedAccount.accountRoot)
if (!result.success) { if (!result.success) {
setDbKeyStatus(result.error || '清除密钥失败') setDbKeyStatus(result.error || '清除密钥失败')
setDbKeyStatusKind('error') setDbKeyStatusKind('error')
@@ -914,6 +1034,7 @@ function App(): React.ReactElement {
const handleReturnToLogin = (): void => { const handleReturnToLogin = (): void => {
setIsAuthenticated(false) setIsAuthenticated(false)
setIsDatabaseConnected(false) setIsDatabaseConnected(false)
setIsDatabaseConnecting(false)
setBootState('login') setBootState('login')
setDatabaseConnectionMode(dbKey ? 'manual' : 'automatic') setDatabaseConnectionMode(dbKey ? 'manual' : 'automatic')
setActivePage('archive') setActivePage('archive')
@@ -931,12 +1052,54 @@ function App(): React.ReactElement {
setStartupProgress(null) setStartupProgress(null)
} }
const handleSwitchAccount = async (account: WechatAccountCandidate): Promise<void> => {
connectionOperationRef.current += 1
await window.api.disconnectDb({ closeNative: true })
setIsAuthenticated(false)
setIsDatabaseConnected(false)
setSelectedContact(null)
setMessages([])
setContacts([])
setFilteredContacts([])
setContentFilter('')
setSelfInfo(null)
setReportSourceContact(null)
setExportTasks([])
messageHistoryRef.current = []
messagesRef.current = []
selectedContactMd5Ref.current = ''
currentGroupSnapshotRef.current = null
groupMemberMetaRef.current = {}
syntheticGroupMessagesRef.current = {}
setDiscoveredAccounts((current) =>
current.some((item) => item.id === account.id) ? current : [account]
)
setSelectedAccountId(account.id)
setDbRootInput(account.accountRoot)
await window.api.setSettings({ dbRoot: account.accountRoot, imageKeyRoot: account.accountRoot })
const saved = await window.api.getSavedDbKey(account.accountRoot)
if (!saved.success || !saved.key) {
setDbKey('')
setDatabaseConnectionMode('automatic')
setBootState('login')
setConnectionGuideStep(3)
setDbKeyStatus('该账号尚无可用密钥,请为所选账号获取密钥')
setDbKeyStatusKind('normal')
return
}
setDbKey(saved.key)
setDatabaseConnectionMode('manual')
setBootState('login')
await handleLogin(saved.key, account.accountRoot)
}
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => { const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
setArchiveJumpTime(null) setArchiveJumpTime(null)
setSelectedContact(contact) setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5 selectedContactMd5Ref.current = contact.md5
currentGroupSnapshotRef.current = null currentGroupSnapshotRef.current = null
setIsMessagesLoading(true) setIsMessagesLoading(true)
setMessageHistoryStatus('idle')
const cachedPage = await window.api.getCachedMessagePage(contact.md5) const cachedPage = await window.api.getCachedMessagePage(contact.md5)
const cachedMsgs = cachedPage.messages const cachedMsgs = cachedPage.messages
if (selectedContactMd5Ref.current !== contact.md5) return if (selectedContactMd5Ref.current !== contact.md5) return
@@ -1048,7 +1211,7 @@ function App(): React.ReactElement {
const handleLoadOlderMessages = async (): Promise<void> => { const handleLoadOlderMessages = async (): Promise<void> => {
const contact = selectedContact const contact = selectedContact
if (!contact || messagesRef.current.length === 0) return if (!contact || messagesRef.current.length === 0 || messageHistoryStatus === 'end') return
if (messagePrefetchRef.current) await messagePrefetchRef.current if (messagePrefetchRef.current) await messagePrefetchRef.current
if (selectedContactMd5Ref.current !== contact.md5) return if (selectedContactMd5Ref.current !== contact.md5) return
const currentMessages = messagesRef.current const currentMessages = messagesRef.current
@@ -1085,6 +1248,7 @@ function App(): React.ReactElement {
limit: MESSAGE_PAGE_SIZE limit: MESSAGE_PAGE_SIZE
}) })
if (selectedContactMd5Ref.current !== contact.md5) return if (selectedContactMd5Ref.current !== contact.md5) return
setMessageHistoryStatus(olderMessages.length === 0 ? 'end' : 'idle')
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages) messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
setMessages((current) => setMessages((current) =>
applyGroupMemberMeta( applyGroupMemberMeta(
@@ -1094,6 +1258,7 @@ function App(): React.ReactElement {
) )
} catch (error) { } catch (error) {
console.warn('[Messages] older page load failed:', error) console.warn('[Messages] older page load failed:', error)
if (selectedContactMd5Ref.current === contact.md5) setMessageHistoryStatus('error')
} finally { } finally {
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false) if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
} }
@@ -1222,6 +1387,45 @@ function App(): React.ReactElement {
setActivePage('settings') setActivePage('settings')
} }
const dismissFirstUseWelcome = (): void => {
try {
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
} catch {
// The welcome prompt is optional and should not interrupt normal use.
}
setShowFirstUseWelcome(false)
}
const maybeShowFirstUseWelcome = (): void => {
try {
if (localStorage.getItem(FIRST_USE_WELCOME_SEEN_KEY) === '1') return
} catch {
// If localStorage is unavailable, still show the one-time prompt for this session.
}
setShowFirstUseWelcome(true)
}
const openFirstUseSearch = (): void => {
dismissFirstUseWelcome()
setActivePage('search')
}
const openFirstUseReport = (): void => {
dismissFirstUseWelcome()
setReportWorkspaceView('configure')
setSelectedReportId(null)
setActivePage('report')
}
const openFirstUseAISettings = (): void => {
dismissFirstUseWelcome()
openModelSettings()
}
const openFirstUseGuide = (): void => {
setShowFirstUseWelcome(true)
}
const openReport = (reportId: string): void => { const openReport = (reportId: string): void => {
setSelectedReportId(reportId) setSelectedReportId(reportId)
setReportWorkspaceView('result') setReportWorkspaceView('result')
@@ -1382,6 +1586,7 @@ function App(): React.ReactElement {
width={sidebarWidth} width={sidebarWidth}
selfInfo={selfInfo} selfInfo={selfInfo}
dbReady={isDatabaseConnected} dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onOpenSettings={openSettings} onOpenSettings={openSettings}
/> />
<div className="resizer" onMouseDown={startResizing} /> <div className="resizer" onMouseDown={startResizing} />
@@ -1390,6 +1595,7 @@ function App(): React.ReactElement {
contact={selectedContact} contact={selectedContact}
messages={messages} messages={messages}
isLoadingMessages={isMessagesLoading} isLoadingMessages={isMessagesLoading}
messageHistoryStatus={messageHistoryStatus}
contentFilter={contentFilter} contentFilter={contentFilter}
onContentFilterChange={setContentFilter} onContentFilterChange={setContentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)} onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
@@ -1411,6 +1617,7 @@ function App(): React.ReactElement {
selectedReportId={selectedReportId} selectedReportId={selectedReportId}
selfInfo={selfInfo} selfInfo={selfInfo}
dbReady={isDatabaseConnected} dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onSelectReport={openReport} onSelectReport={openReport}
onCreateReport={openReportConfigure} onCreateReport={openReportConfigure}
onDeleteReport={handleDeleteReport} onDeleteReport={handleDeleteReport}
@@ -1433,6 +1640,7 @@ function App(): React.ReactElement {
selectedContact={reportSourceContact} selectedContact={reportSourceContact}
selfInfo={selfInfo} selfInfo={selfInfo}
dbReady={isDatabaseConnected} dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onSelectContact={handleSelectReportSource} onSelectContact={handleSelectReportSource}
onOpenSettings={openSettings} onOpenSettings={openSettings}
/> />
@@ -1503,6 +1711,7 @@ function App(): React.ReactElement {
onCategoryChange={setSettingsCategory} onCategoryChange={setSettingsCategory}
selfInfo={selfInfo} selfInfo={selfInfo}
dbReady={isDatabaseConnected} dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
dbKey={dbKey} dbKey={dbKey}
onDbKeyChange={setDbKey} onDbKeyChange={setDbKey}
onDatabaseConnectionChange={setIsDatabaseConnected} onDatabaseConnectionChange={setIsDatabaseConnected}
@@ -1514,6 +1723,7 @@ function App(): React.ReactElement {
onNotice={setReportNotice} onNotice={setReportNotice}
onOpenSettings={openSettings} onOpenSettings={openSettings}
onAppearanceChange={handleAppearanceChange} onAppearanceChange={handleAppearanceChange}
onSwitchAccount={handleSwitchAccount}
/> />
) )
case 'search': case 'search':
@@ -1628,15 +1838,80 @@ function App(): React.ReactElement {
dbRoot={dbRootInput} dbRoot={dbRootInput}
showDbKey={showDbKey} showDbKey={showDbKey}
isFetching={isFetchingDbKey} isFetching={isFetchingDbKey}
isConnecting={isDatabaseConnecting}
guideStep={connectionGuideStep}
environment={databaseEnvironment}
accounts={discoveredAccounts}
selectedAccountId={selectedAccountId}
status={dbKeyStatus} status={dbKeyStatus}
statusKind={dbKeyStatusKind} statusKind={dbKeyStatusKind}
showMacKeyFaq={showMacKeyFaq} showMacKeyFaq={showMacKeyFaq}
macKeyFaqUrl={MAC_KEY_FAQ_URL} macKeyFaqUrl={MAC_KEY_FAQ_URL}
onModeChange={setDatabaseConnectionMode} onModeChange={setDatabaseConnectionMode}
onDbKeyChange={setDbKey} onDbKeyChange={setDbKey}
onDbRootChange={setDbRootInput} onDbRootChange={(value) => {
setDbRootInput(value)
setDiscoveredAccounts([])
setSelectedAccountId('')
}}
onSelectAccount={(account) => {
setSelectedAccountId(account.id)
setDbKey('')
void window.api.getSavedDbKey(account.accountRoot).then((result) => {
if (result.success && result.key) setDbKey(result.key)
})
}}
onSelectDbRoot={() => {
void window.api.selectDbRoot().then((result) => {
if (!result.canceled && result.path) {
setDbRootInput(result.path)
void window.api.discoverAccounts(result.path).then((discovery) => {
if (!discovery.success) {
setDiscoveredAccounts([])
setSelectedAccountId('')
setDbKeyStatus(discovery.error || '账号目录识别失败')
setDbKeyStatusKind('error')
return
}
setDiscoveredAccounts(discovery.accounts)
setSelectedAccountId(discovery.preselectedAccountId || '')
})
}
})
}}
onToggleDbKey={() => setShowDbKey((visible) => !visible)} onToggleDbKey={() => setShowDbKey((visible) => !visible)}
onAutoGetKey={handleAutoGetDbKey} onAutoGetKey={handleAutoGetDbKey}
onRefreshEnvironment={() => void refreshConnectionEnvironment()}
onGuideNext={() =>
setConnectionGuideStep((current) => (current === 1 ? 2 : current === 2 ? 3 : current))
}
onGuideBack={() =>
setConnectionGuideStep((current) =>
current === 5 ? 3 : current > 1 ? ((current - 1) as 1 | 2 | 3 | 4 | 5 | 6) : 1
)
}
onGuideCancel={() => {
connectionOperationRef.current += 1
setIsFetchingDbKey(false)
setIsDatabaseConnecting(false)
setBootState('login')
setStartupProgress(null)
setConnectionGuideStep(1)
setDbKeyStatus('已取消,可以重新检查环境')
setDbKeyStatusKind('normal')
}}
onValidateConnection={() => void handleLogin()}
onCopyDiagnostics={() => {
if (!databaseEnvironment?.diagnosticSummary) {
setDbKeyStatus('诊断信息尚未准备好,请先重新检查环境')
setDbKeyStatusKind('error')
return
}
void window.api.copyText(databaseEnvironment.diagnosticSummary).then(() => {
setDbKeyStatus('脱敏诊断摘要已复制')
setDbKeyStatusKind('success')
})
}}
onManualConnect={() => handleLogin()} onManualConnect={() => handleLogin()}
onPasteKey={handlePasteAndSaveDbKey} onPasteKey={handlePasteAndSaveDbKey}
onClearKey={handleClearSavedDbKey} onClearKey={handleClearSavedDbKey}
@@ -1649,12 +1924,22 @@ function App(): React.ReactElement {
activePage={activePage} activePage={activePage}
selfInfo={selfInfo} selfInfo={selfInfo}
dbReady={isDatabaseConnected} dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onPageChange={handlePageChange} onPageChange={handlePageChange}
onOpenSettings={openSettings} onOpenSettings={openSettings}
onOpenGuide={openFirstUseGuide}
appearanceTheme={appearanceSettings.theme} appearanceTheme={appearanceSettings.theme}
compactMode={appearanceSettings.compactMode} compactMode={appearanceSettings.compactMode}
> >
{reportNotice && <div className="app-toast">{reportNotice}</div>} {reportNotice && <div className="app-toast">{reportNotice}</div>}
{showFirstUseWelcome && (
<FirstUseWelcome
onDismiss={dismissFirstUseWelcome}
onOpenSearch={openFirstUseSearch}
onOpenReport={openFirstUseReport}
onOpenAISettings={openFirstUseAISettings}
/>
)}
{renderCurrentWorkspace()} {renderCurrentWorkspace()}
</AppShell> </AppShell>
) )
@@ -10,6 +10,7 @@ interface ChatWindowProps {
contact: Contact | null contact: Contact | null
messages: Message[] messages: Message[]
isLoadingMessages?: boolean isLoadingMessages?: boolean
messageHistoryStatus?: 'idle' | 'end' | 'error'
contentFilter?: string contentFilter?: string
onContentFilterChange?: (keyword: string) => void onContentFilterChange?: (keyword: string) => void
onRefresh?: () => void onRefresh?: () => void
@@ -25,6 +26,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
contact, contact,
messages, messages,
isLoadingMessages, isLoadingMessages,
messageHistoryStatus,
contentFilter, contentFilter,
onContentFilterChange, onContentFilterChange,
onRefresh, onRefresh,
@@ -205,6 +207,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
messages={filteredMessages} messages={filteredMessages}
hiddenMessageCount={0} hiddenMessageCount={0}
isLoadingMessages={isLoadingMessages} isLoadingMessages={isLoadingMessages}
messageHistoryStatus={messageHistoryStatus}
isGroupChat={isGroupChat} isGroupChat={isGroupChat}
showAvatar={showAvatar} showAvatar={showAvatar}
listRef={messageListRef} listRef={messageListRef}
@@ -1,4 +1,8 @@
import React from 'react' import React from 'react'
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key'
const GUIDE_URL =
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
export type DatabaseConnectionMode = 'automatic' | 'manual' export type DatabaseConnectionMode = 'automatic' | 'manual'
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error' export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
@@ -10,6 +14,11 @@ interface DatabaseConnectionPageProps {
dbRoot: string dbRoot: string
showDbKey: boolean showDbKey: boolean
isFetching: boolean isFetching: boolean
isConnecting: boolean
guideStep: 1 | 2 | 3 | 4 | 5 | 6
environment?: DatabaseKeyEnvironment
accounts: WechatAccountCandidate[]
selectedAccountId: string
status: string status: string
statusKind: DatabaseConnectionStatusKind statusKind: DatabaseConnectionStatusKind
showMacKeyFaq: boolean showMacKeyFaq: boolean
@@ -17,8 +26,16 @@ interface DatabaseConnectionPageProps {
onModeChange: (mode: DatabaseConnectionMode) => void onModeChange: (mode: DatabaseConnectionMode) => void
onDbKeyChange: (value: string) => void onDbKeyChange: (value: string) => void
onDbRootChange: (value: string) => void onDbRootChange: (value: string) => void
onSelectAccount: (account: WechatAccountCandidate) => void
onSelectDbRoot: () => void
onToggleDbKey: () => void onToggleDbKey: () => void
onAutoGetKey: () => void onAutoGetKey: () => void
onRefreshEnvironment: () => void
onGuideNext: () => void
onGuideBack: () => void
onGuideCancel: () => void
onValidateConnection: () => void
onCopyDiagnostics: () => void
onManualConnect: () => void onManualConnect: () => void
onPasteKey: () => void onPasteKey: () => void
onClearKey: () => void onClearKey: () => void
@@ -89,6 +106,11 @@ export function DatabaseConnectionPage({
dbRoot, dbRoot,
showDbKey, showDbKey,
isFetching, isFetching,
isConnecting,
guideStep,
environment,
accounts = [],
selectedAccountId = '',
status, status,
statusKind, statusKind,
showMacKeyFaq, showMacKeyFaq,
@@ -96,8 +118,16 @@ export function DatabaseConnectionPage({
onModeChange, onModeChange,
onDbKeyChange, onDbKeyChange,
onDbRootChange, onDbRootChange,
onSelectAccount,
onSelectDbRoot,
onToggleDbKey, onToggleDbKey,
onAutoGetKey, onAutoGetKey,
onRefreshEnvironment,
onGuideNext,
onGuideBack,
onGuideCancel,
onValidateConnection,
onCopyDiagnostics,
onManualConnect, onManualConnect,
onPasteKey, onPasteKey,
onClearKey onClearKey
@@ -116,9 +146,9 @@ export function DatabaseConnectionPage({
<LineIcon name="database" /> <LineIcon name="database" />
</div> </div>
<h1>WechatExplorer</h1> <h1>WechatExplorer</h1>
<p className="database-login-tagline"></p> <p className="database-login-tagline"> AI </p>
<p className="database-login-description"> <p className="database-login-description">
使 AI
</p> </p>
<div className="database-login-promises"> <div className="database-login-promises">
<div> <div>
@@ -131,7 +161,7 @@ export function DatabaseConnectionPage({
</div> </div>
<div> <div>
<LineIcon name="cloud" /> <LineIcon name="cloud" />
<span></span> <span>AI </span>
</div> </div>
</div> </div>
</div> </div>
@@ -140,6 +170,42 @@ export function DatabaseConnectionPage({
<section className="database-login-workspace" aria-label="数据库连接"> <section className="database-login-workspace" aria-label="数据库连接">
<div className="database-login-panel"> <div className="database-login-panel">
<div className="database-login-start">
<p className="database-login-eyebrow">使</p>
<h2></h2>
<p></p>
<ol>
<li>
<span>1</span>
<div>
<strong></strong>
<small></small>
</div>
</li>
<li>
<span>2</span>
<div>
<strong></strong>
<small></small>
</div>
</li>
<li>
<span>3</span>
<div>
<strong></strong>
<small></small>
</div>
</li>
</ol>
<a
className="database-login-guide-link"
href={GUIDE_URL}
target="_blank"
rel="noreferrer"
>
5
</a>
</div>
<div className="database-login-tabs" role="tablist" aria-label="连接方式"> <div className="database-login-tabs" role="tablist" aria-label="连接方式">
<button <button
type="button" type="button"
@@ -148,21 +214,26 @@ export function DatabaseConnectionPage({
className={mode === 'automatic' ? 'active' : ''} className={mode === 'automatic' ? 'active' : ''}
onClick={() => onModeChange('automatic')} onClick={() => onModeChange('automatic')}
> >
</button> </button>
<button <button
type="button" type="button"
role="tab" role="tab"
aria-selected={mode === 'manual'} aria-selected={mode === 'manual'}
className={mode === 'manual' ? 'active' : ''} className={`database-login-manual-tab ${mode === 'manual' ? 'active' : ''}`}
onClick={() => onModeChange('manual')} onClick={() => onModeChange('manual')}
> >
</button> </button>
</div> </div>
{mode === 'automatic' ? ( {mode === 'automatic' ? (
<div className="database-login-auto" role="tabpanel"> <div className="database-login-auto" role="tabpanel">
<div className="database-login-guide-progress" aria-label={`连接进度 ${guideStep}/6`}>
{Array.from({ length: 6 }, (_, index) => (
<span key={index} className={index + 1 <= guideStep ? 'active' : ''} />
))}
</div>
<div className={`database-login-state-card ${statusKind}`}> <div className={`database-login-state-card ${statusKind}`}>
<div className="database-login-state-heading"> <div className="database-login-state-heading">
<span className="database-login-state-icon"> <span className="database-login-state-icon">
@@ -170,69 +241,229 @@ export function DatabaseConnectionPage({
</span> </span>
<div> <div>
<strong> <strong>
{statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'} {statusKind === 'error'
? '当前步骤未完成'
: [
'检查本机环境',
'让微信停在登录页面',
'确认开始准备',
`正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`,
'现在可以登录微信',
'验证数据库连接'
][guideStep - 1]}
</strong> </strong>
<p> <p>
{statusKind === 'error' {statusKind === 'error'
? status ? status
: status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'} : status ||
[
'确认下方检测结果;没有找到目录时可以手动选择。',
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
'开始后请按页面提示完成系统授权。',
'正在准备连接组件,请不要关闭微信或 WechatExplorer。',
'请回到微信完成登录,登录成功后再回来验证。',
'正在验证密钥和本地数据库,请稍候。'
][guideStep - 1]}
</p> </p>
</div> </div>
</div> </div>
<dl className="database-login-diagnostics"> {guideStep === 1 && (
<div> <>
<dt></dt> <dl className="database-login-diagnostics">
<dd>{isFetching ? '正在检测' : '等待检测'}</dd> <div>
</div> <dt></dt>
<div> <dd>{environment?.osVersion || (isMac ? 'macOS' : 'Windows')}</dd>
<dt> </div>
<div>
<StoragePathHelp /> <dt></dt>
</dt> <dd>{environment?.wechatVersion || '未检测到'}</dd>
<dd> </div>
<span className="database-login-path-input-wrap"> <div>
<input <dt></dt>
type="text" <dd>{environment?.dataStructureVersion || '未检测到'}</dd>
value={dbRoot} </div>
onChange={(event) => onDbRootChange(event.target.value)} <div>
placeholder={defaultPath} <dt>
title={dbRoot || defaultPath}
aria-label="微信数据存储路径" <StoragePathHelp />
spellCheck={false} </dt>
onFocus={(event) => event.currentTarget.select()} <dd>
/> <span className="database-login-path-input-wrap">
<span className="database-login-path-value" role="status"> <input
{dbRoot || defaultPath} type="text"
</span> value={dbRoot}
</span> onChange={(event) => onDbRootChange(event.target.value)}
</dd> placeholder={defaultPath}
</div> title={dbRoot || defaultPath}
<div> aria-label="微信数据存储路径"
<dt></dt> spellCheck={false}
<dd>{statusKind === 'error' ? '无法连接' : '准备连接'}</dd> onFocus={(event) => event.currentTarget.select()}
</div> />
</dl> <span className="database-login-path-value" role="status">
{dbRoot || defaultPath}
</span>
</span>
<button
type="button"
className="database-login-path-select"
onClick={onSelectDbRoot}
disabled={isFetching || isConnecting}
>
</button>
</dd>
</div>
<div>
<dt></dt>
<dd>{environment?.wechatRunning ? '运行中' : '未检测到'}</dd>
</div>
</dl>
{accounts.length > 0 && (
<section className="database-account-list" aria-label="选择微信账号">
<h3></h3>
{accounts.map((account) => (
<button
type="button"
key={account.id}
className={`database-account-card ${selectedAccountId === account.id ? 'selected' : ''}`}
aria-pressed={selectedAccountId === account.id}
onClick={() => onSelectAccount(account)}
>
<span className="database-account-avatar">
{account.avatar ? (
<img src={account.avatar} alt="" />
) : (
(account.nickname || '?').charAt(0)
)}
</span>
<span className="database-account-identity">
<strong>{account.nickname || '昵称未识别'}</strong>
<small>{account.wxid || 'wxid 未识别'}</small>
<code title={account.accountRoot}>{account.accountRoot}</code>
</span>
<span className="database-account-status">
{account.hasSavedDbKey ? '已有可用密钥' : '尚无可用密钥'}
<small>
{account.loginStatus === 'current'
? '当前已连接账号'
: account.loginStatus === 'other'
? '非当前账号'
: '登录状态未确认'}
</small>
</span>
</button>
))}
<button
type="button"
className="database-login-secondary"
onClick={onSelectDbRoot}
disabled={isFetching || isConnecting}
>
</button>
</section>
)}
</>
)}
</div> </div>
<button {guideStep === 1 && (
type="button" <>
className="database-login-primary" <button
onClick={onAutoGetKey} type="button"
disabled={isFetching} className="database-login-primary"
> onClick={onGuideNext}
{isFetching disabled={!selectedAccountId}
? '正在获取密钥…' >
: statusKind === 'error'
? '重新检测' </button>
: '自动获取密钥'} <button
</button> type="button"
{showMacKeyFaq && ( className="database-login-secondary"
onClick={onRefreshEnvironment}
>
</button>
<button
type="button"
className="database-login-text-action"
onClick={onCopyDiagnostics}
>
</button>
</>
)}
{guideStep === 2 && (
<button type="button" className="database-login-primary" onClick={onGuideNext}>
</button>
)}
{guideStep === 3 && (
<button type="button" className="database-login-primary" onClick={onAutoGetKey}>
</button>
)}
{guideStep === 4 && (
<button type="button" className="database-login-primary" disabled>
</button>
)}
{guideStep === 5 && (
<button
type="button"
className="database-login-primary"
onClick={onValidateConnection}
disabled={!dbKey || isConnecting}
>
{isConnecting ? '正在验证…' : '微信已登录,验证连接'}
</button>
)}
{guideStep === 6 && (
<button type="button" className="database-login-primary" disabled>
</button>
)}
{guideStep > 1 && !isFetching && !isConnecting && (
<div className="database-login-guide-actions">
<button type="button" onClick={onGuideBack}>
</button>
<button type="button" onClick={onGuideCancel}>
</button>
</div>
)}
{(isFetching || isConnecting) && (
<button
type="button"
className="database-login-text-action"
onClick={onGuideCancel}
>
</button>
)}
<p className="database-login-platform-note">
{isMac ? (
<>
macOS SIP{' '}
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
</a>
</>
) : (
'Windows 已完整支持,不需要关闭 SIP。'
)}
</p>
{showMacKeyFaq && isMac && (
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer"> <a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
·
</a> </a>
)} )}
</div> </div>
) : ( ) : (
<div className="database-login-manual" role="tabpanel"> <div className="database-login-manual" role="tabpanel">
<p className="database-login-manual-note">
使
</p>
<div className="database-login-field"> <div className="database-login-field">
<label htmlFor="database-login-key"></label> <label htmlFor="database-login-key"></label>
<div className="database-login-key-input"> <div className="database-login-key-input">
@@ -261,15 +492,21 @@ export function DatabaseConnectionPage({
<StoragePathHelp /> <StoragePathHelp />
</label> </label>
<input <div className="database-login-root-control">
id="database-login-root" <input
value={dbRoot} id="database-login-root"
onChange={(event) => onDbRootChange(event.target.value)} aria-label="微信数据目录"
placeholder={defaultPath} value={dbRoot}
title={dbRoot || defaultPath} onChange={(event) => onDbRootChange(event.target.value)}
spellCheck={false} placeholder={defaultPath}
onFocus={(event) => event.currentTarget.select()} title={dbRoot || defaultPath}
/> spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
/>
<button type="button" onClick={onSelectDbRoot} disabled={isConnecting}>
</button>
</div>
</div> </div>
)} )}
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>} {status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
@@ -277,11 +514,25 @@ export function DatabaseConnectionPage({
type="button" type="button"
className="database-login-primary" className="database-login-primary"
onClick={onManualConnect} onClick={onManualConnect}
disabled={!keyIsValid} disabled={!keyIsValid || isConnecting}
> >
{isConnecting ? '正在连接…' : '连接数据库'}
</button> </button>
<button type="button" className="database-login-secondary" onClick={onPasteKey}> {isConnecting && (
<button
type="button"
className="database-login-text-action"
onClick={onGuideCancel}
>
</button>
)}
<button
type="button"
className="database-login-secondary"
onClick={onPasteKey}
disabled={isConnecting}
>
</button> </button>
</div> </div>
@@ -0,0 +1,78 @@
import React from 'react'
interface FirstUseWelcomeProps {
onDismiss: () => void
onOpenSearch: () => void
onOpenReport: () => void
onOpenAISettings: () => void
}
const GUIDE_URL =
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
export function FirstUseWelcome({
onDismiss,
onOpenSearch,
onOpenReport,
onOpenAISettings
}: FirstUseWelcomeProps): React.ReactElement {
return (
<div className="first-use-welcome-overlay" role="presentation">
<section
className="first-use-welcome"
role="dialog"
aria-modal="true"
aria-labelledby="first-use-welcome-title"
>
<button
type="button"
className="first-use-welcome-close"
onClick={onDismiss}
aria-label="关闭欢迎提示"
>
×
</button>
<div className="first-use-welcome-mark" aria-hidden="true">
</div>
<p className="first-use-welcome-eyebrow"></p>
<h2 id="first-use-welcome-title"></h2>
<p className="first-use-welcome-lead">
AI
</p>
<button type="button" className="first-use-welcome-feature" onClick={onOpenReport}>
<span className="first-use-welcome-feature-icon" aria-hidden="true">
</span>
<span className="first-use-welcome-feature-copy">
<strong> AI </strong>
<small></small>
</span>
<span className="first-use-welcome-feature-arrow" aria-hidden="true">
</span>
</button>
<div className="first-use-welcome-secondary-actions">
<button type="button" onClick={onDismiss}>
</button>
<button type="button" onClick={onOpenSearch}>
</button>
</div>
<div className="first-use-welcome-footer">
<span> AI</span>
<button type="button" onClick={onOpenAISettings}>
AI
</button>
<a href={GUIDE_URL} target="_blank" rel="noreferrer">
使
</a>
</div>
</section>
</div>
)
}
+68 -74
View File
@@ -25,97 +25,94 @@ export function ImageBubble({
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [upgrading, setUpgrading] = useState(false) const [upgrading, setUpgrading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
const [usingFallback, setUsingFallback] = useState(false) const [usingFallback, setUsingFallback] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const mountedRef = useRef(true) const mountedRef = useRef(true)
const backgroundUpgradeRef = useRef(false)
useEffect(() => { useEffect(() => {
mountedRef.current = true
return () => { return () => {
mountedRef.current = false mountedRef.current = false
} }
}, []) }, [])
const upgradeOriginalInBackground = useCallback(() => { const loadImage = useCallback(
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return async (priority = 0) => {
backgroundUpgradeRef.current = true if (imageUrl) return
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1) if (!imageMd5 && !imageDatName) {
.then((original) => { if (fallbackUrl) {
if (!mountedRef.current) return setImageUrl(fallbackUrl)
setImageUrl(original.data) setUsingFallback(true)
setIsThumbnail(false) setError(null)
}) return
.catch(() => undefined) }
}, [imageDatName, imageMd5, isThumbnail, sessionId]) setError('缺少图片标识')
const loadImage = useCallback(async () => {
if (imageUrl || loading) return
if (!imageMd5 && !imageDatName) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
return return
} }
setError('缺少图片标识') if (loading) {
return if (priority === 0) {
} void requestImage(
imageMd5,
setLoading(true) imageDatName,
try { sessionId,
const result = await requestImage( { preferThumbnail: true },
imageMd5, priority
imageDatName, ).catch(() => undefined)
sessionId, }
{ preferThumbnail: true }, return
0
)
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
setError(null)
if (result.isThumbnail) upgradeOriginalInBackground()
} catch (error) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError(error instanceof Error ? error.message : '加载图片失败')
} }
} finally {
setLoading(false) setLoading(true)
} try {
}, [ const result = await requestImage(
fallbackUrl, imageMd5,
imageDatName, imageDatName,
imageMd5, sessionId,
imageUrl, { preferThumbnail: true },
loading, priority
sessionId, )
upgradeOriginalInBackground if (!mountedRef.current) return
]) setImageUrl(result.data)
setUsingFallback(false)
setError(null)
} catch (error) {
if (!mountedRef.current) return
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError(error instanceof Error ? error.message : '加载图片失败')
}
} finally {
if (mountedRef.current) setLoading(false)
}
},
[fallbackUrl, imageDatName, imageMd5, imageUrl, loading, sessionId]
)
useEffect(() => { useEffect(() => {
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground() if (imageUrl || error) return
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
useEffect(() => {
if (imageUrl || loading || error) return
const element = containerRef.current const element = containerRef.current
if (!element || typeof IntersectionObserver === 'undefined') { if (!element || typeof IntersectionObserver === 'undefined') {
const timer = window.setTimeout(() => void loadImage(), 0) const timer = window.setTimeout(() => void loadImage(0), 0)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
} }
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return const entry = entries.find((candidate) => candidate.isIntersecting)
if (!entry) return
const rect = entry.boundingClientRect
const isInViewport =
rect.bottom >= 0 &&
rect.top <= window.innerHeight &&
rect.right >= 0 &&
rect.left <= window.innerWidth
observer.disconnect() observer.disconnect()
void loadImage() void loadImage(isInViewport ? 0 : 1)
}, },
{ rootMargin: '400px 0px' } { rootMargin: loading ? '0px' : '400px 0px' }
) )
observer.observe(element) observer.observe(element)
return () => observer.disconnect() return () => observer.disconnect()
@@ -143,10 +140,9 @@ export function ImageBubble({
setUpgrading(true) setUpgrading(true)
try { try {
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0) const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
if (result.data.startsWith('data:image/')) { if (result.data.startsWith('data:image/') || result.data.startsWith('wxe-media://')) {
setImageUrl(result.data) setImageUrl(result.data)
setUsingFallback(false) setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
setError(null) setError(null)
onImageClick?.(result.data) onImageClick?.(result.data)
return return
@@ -162,7 +158,7 @@ export function ImageBubble({
if (loading) { if (loading) {
return ( return (
<div className="image-bubble image-loading" onClick={handleClick}> <div ref={containerRef} className="image-bubble image-loading" onClick={handleClick}>
<div className="image-loading-skeleton" aria-hidden /> <div className="image-loading-skeleton" aria-hidden />
<div className="image-quality-badge"></div> <div className="image-quality-badge"></div>
</div> </div>
@@ -171,7 +167,7 @@ export function ImageBubble({
if (error) { if (error) {
return ( return (
<div className="image-bubble image-error" onClick={loadImage}> <div className="image-bubble image-error" onClick={() => void loadImage(0)}>
<div className="image-error-text">{error || '图片未缓存'}</div> <div className="image-error-text">{error || '图片未缓存'}</div>
<div className="image-quality-badge"></div> <div className="image-quality-badge"></div>
</div> </div>
@@ -194,9 +190,7 @@ export function ImageBubble({
alt="图片" alt="图片"
className={`image-content ${usingFallback ? 'image-fallback' : ''}`} className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
/> />
{(upgrading || isThumbnail) && ( {upgrading && <div className="image-quality-badge"></div>}
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
)}
<div className="image-actions"> <div className="image-actions">
<button className="image-action-btn" onClick={handleCopy} title="复制图片"> <button className="image-action-btn" onClick={handleCopy} title="复制图片">
@@ -24,13 +24,11 @@ export function RichMessageBubble({
return <CardBubble data={contentData} /> return <CardBubble data={contentData} />
case 'share': case 'share':
return <ShareBubble data={contentData} /> return <ShareBubble data={contentData} />
case 'forwardBundle':
return <ForwardBundleBubble data={contentData} />
case 'miniProgram': case 'miniProgram':
return ( return (
<MiniProgramBubble <MiniProgramBubble data={contentData} sessionId={sessionId} onImageClick={onImageClick} />
data={contentData}
sessionId={sessionId}
onImageClick={onImageClick}
/>
) )
case 'redPacket': case 'redPacket':
return <RedPacketBubble data={contentData} /> return <RedPacketBubble data={contentData} />
@@ -44,8 +42,11 @@ export function RichMessageBubble({
return <SystemBubble data={contentData} /> return <SystemBubble data={contentData} />
case 'unknown': case 'unknown':
return ( return (
<div className="message-text"> <div className="unsupported-message">
{renderWechatEmojiText((contentData as { raw?: string }).raw || '[未知消息]')} <strong></strong>
<span>
{(contentData as { messageType?: string | number }).messageType || '未知'}
</span>
</div> </div>
) )
default: default:
@@ -53,6 +54,56 @@ export function RichMessageBubble({
} }
} }
function ForwardBundleBubble({
data
}: {
data: Extract<ParsedContent, { type: 'forwardBundle' }>
}): JSX.Element {
const [expanded, setExpanded] = useState(false)
const visibleItems = expanded ? data.items : data.items.slice(0, 3)
const hiddenCount = Math.max(0, data.items.length - visibleItems.length)
return (
<div className="forward-bundle-message">
<button
type="button"
className="forward-bundle-header"
onClick={() => setExpanded(!expanded)}
>
<span>{data.title || '聊天记录'}</span>
<small>
{data.items.length ? `${data.items.length} 条消息` : data.description || '聊天记录'}
</small>
</button>
<div className="forward-bundle-list">
{visibleItems.length ? (
visibleItems.map((item, index) => (
<div
className="forward-bundle-item"
key={`${item.sender || ''}-${item.sentAt || ''}-${index}`}
>
{item.sender && <b>{item.sender}</b>}
<span>{renderWechatEmojiText(item.text, 24)}</span>
{item.nested?.length ? <small> {item.nested.length} </small> : null}
</div>
))
) : (
<div className="forward-bundle-empty"></div>
)}
</div>
{(hiddenCount > 0 || expanded) && data.items.length > 3 ? (
<button
type="button"
className="forward-bundle-toggle"
onClick={() => setExpanded(!expanded)}
>
{expanded ? '收起' : `展开其余 ${hiddenCount}`}
</button>
) : null}
</div>
)
}
function LocationBubble({ function LocationBubble({
data data
}: { }: {
@@ -161,12 +212,7 @@ function MiniProgramBubble({
/> />
</div> </div>
) : data.iconUrl ? ( ) : data.iconUrl ? (
<img <img className="mini-program-icon" src={data.iconUrl} alt="" referrerPolicy="no-referrer" />
className="mini-program-icon"
src={data.iconUrl}
alt=""
referrerPolicy="no-referrer"
/>
) : null} ) : null}
<div className="mini-program-footer"> <div className="mini-program-footer">
<span aria-hidden></span> <span aria-hidden></span>
@@ -242,6 +288,7 @@ function StickerBubble({
) )
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl) const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [errorText, setErrorText] = useState('')
useEffect(() => { useEffect(() => {
if (!cacheKey || displayUrl || error) return if (!cacheKey || displayUrl || error) return
@@ -255,12 +302,17 @@ function StickerBubble({
stickerDataUrlCache.set(cacheKey, result.data) stickerDataUrlCache.set(cacheKey, result.data)
setDisplayUrl(result.data) setDisplayUrl(result.data)
setError(false) setError(false)
setErrorText('')
} else { } else {
setError(true) setError(true)
setErrorText(result.error || '表情包未缓存')
} }
}) })
.catch(() => { .catch(() => {
if (!cancelled) setError(true) if (!cancelled) {
setError(true)
setErrorText('表情包加载失败')
}
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false)
@@ -289,7 +341,7 @@ function StickerBubble({
return ( return (
<div className="sticker-message"> <div className="sticker-message">
<div className="sticker-placeholder">{error ? '表情包未缓存' : '表情包'}</div> <div className="sticker-placeholder">{error ? errorText || '表情包未缓存' : '表情包'}</div>
{md5 && <div className="sticker-md5">MD5: {md5}</div>} {md5 && <div className="sticker-md5">MD5: {md5}</div>}
</div> </div>
) )
+100 -112
View File
@@ -16,15 +16,16 @@ export function VoicePlayer({
sessionId, sessionId,
localId, localId,
createTime, createTime,
svrId svrId,
duration
}: VoicePlayerProps): JSX.Element { }: VoicePlayerProps): JSX.Element {
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [audioUrl, setAudioUrl] = useState<string | null>(null) const [audioUrl, setAudioUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [audioDuration, setAudioDuration] = useState<number | undefined>(undefined) const [audioDuration, setAudioDuration] = useState<number | undefined>(duration)
const [shouldAutoPlay, setShouldAutoPlay] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null) const audioRef = useRef<HTMLAudioElement | null>(null)
const objectUrlRef = useRef<string | null>(null)
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => { const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
if (globalCurrentAudio && globalCurrentAudio !== audio) { if (globalCurrentAudio && globalCurrentAudio !== audio) {
@@ -35,126 +36,113 @@ export function VoicePlayer({
globalCurrentAudio = audio globalCurrentAudio = audio
}, []) }, [])
const handlePlayPause = useCallback(async () => { const playAudio = useCallback(
// 如果还没有音频数据,先获取 async (audio: HTMLAudioElement): Promise<void> => {
if (!audioUrl && !loading) {
setLoading(true)
setShouldAutoPlay(true)
console.log('[VoicePlayer] fetching voice data:', { sessionId, localId, createTime })
try {
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
console.log('[VoicePlayer] got result:', result)
if (result.success && result.data) {
console.log('[VoicePlayer] setting audioUrl, data length:', result.data.length)
// 使用 Blob URL 替代 data URL,绕过 CSP 限制
const byteCharacters = atob(result.data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: 'audio/wav' })
const blobUrl = URL.createObjectURL(blob)
console.log('[VoicePlayer] created blob URL:', blobUrl)
setAudioUrl(blobUrl)
} else {
console.log('[VoicePlayer] getVoiceData failed:', result.error)
setError(result.error || '获取语音数据失败')
setShouldAutoPlay(false)
}
} catch (e) {
console.log('[VoicePlayer] exception:', e)
setError('加载语音失败')
setShouldAutoPlay(false)
}
setLoading(false)
return
}
if (!audioRef.current) {
console.log('[VoicePlayer] no audioRef')
return
}
const audio = audioRef.current
if (isPlaying) {
audio.pause()
setIsPlaying(false)
globalStopCallback = null
} else {
stopCurrentAndPlay(audio) stopCurrentAndPlay(audio)
audio try {
.play() await audio.play()
.then(() => { setError(null)
console.log('[VoicePlayer] play() succeeded')
})
.catch((e) => {
console.log('[VoicePlayer] play() failed:', e)
})
setIsPlaying(true)
globalStopCallback = () => {
setIsPlaying(false)
audio.currentTime = 0
}
}
}, [audioUrl, loading, isPlaying, sessionId, localId, createTime, svrId, stopCurrentAndPlay])
useEffect(() => {
if (!audioUrl) return
let audio = audioRef.current
if (!audio) {
audio = new Audio(audioUrl)
audioRef.current = audio
}
const audioEl = audio!
audioEl.addEventListener('loadedmetadata', () => {
setAudioDuration(audioEl.duration)
console.log('[VoicePlayer] loadedmetadata, duration:', audioEl.duration)
})
audioEl.addEventListener('ended', () => {
setIsPlaying(false)
globalStopCallback = null
})
audioEl.addEventListener('timeupdate', () => {
if (audioEl.duration && isFinite(audioEl.duration)) {
setAudioDuration(audioEl.duration)
}
})
audioEl.addEventListener('canplay', () => {
console.log('[VoicePlayer] canplay event, shouldAutoPlay:', shouldAutoPlay)
if (shouldAutoPlay && audioRef.current) {
setShouldAutoPlay(false)
stopCurrentAndPlay(audioRef.current)
audioRef.current.play()
setIsPlaying(true) setIsPlaying(true)
globalStopCallback = () => { globalStopCallback = () => {
setIsPlaying(false) setIsPlaying(false)
if (audioRef.current) { audio.currentTime = 0
audioRef.current.currentTime = 0
}
} }
} catch (playError) {
if (globalCurrentAudio === audio) {
globalCurrentAudio = null
globalStopCallback = null
}
setIsPlaying(false)
setError('语音播放失败,请重试')
console.warn('[VoicePlayer] play failed:', playError)
} }
}) },
[stopCurrentAndPlay]
)
return () => { const createAudio = useCallback((blobUrl: string): HTMLAudioElement => {
if (audioRef.current) { const audio = new Audio()
audioRef.current.pause() audio.preload = 'auto'
audioRef.current.src = '' audio.src = blobUrl
audioRef.current = null audio.onloadedmetadata = () => {
} if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration)
if (globalCurrentAudio === audioRef.current) { }
audio.ontimeupdate = () => {
if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration)
}
audio.onended = () => {
setIsPlaying(false)
if (globalCurrentAudio === audio) {
globalCurrentAudio = null globalCurrentAudio = null
globalStopCallback = null globalStopCallback = null
} }
} }
}, [audioUrl, shouldAutoPlay, stopCurrentAndPlay]) audioRef.current = audio
objectUrlRef.current = blobUrl
return audio
}, [])
const handlePlayPause = useCallback(async () => {
if (loading) return
let audio = audioRef.current
if (!audio) {
setLoading(true)
setError(null)
try {
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
if (result.success && result.data) {
const byteCharacters = atob(result.data)
const byteArray = new Uint8Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteArray[i] = byteCharacters.charCodeAt(i)
}
const blob = new Blob([byteArray], { type: 'audio/wav' })
const blobUrl = URL.createObjectURL(blob)
setAudioUrl(blobUrl)
audio = createAudio(blobUrl)
await playAudio(audio)
} else {
setError(result.error || '获取语音数据失败')
}
} catch (loadError) {
console.warn('[VoicePlayer] load failed:', loadError)
setError('加载语音失败')
} finally {
setLoading(false)
}
return
}
if (isPlaying) {
audio.pause()
setIsPlaying(false)
if (globalCurrentAudio === audio) {
globalCurrentAudio = null
globalStopCallback = null
}
} else {
await playAudio(audio)
}
}, [createAudio, createTime, isPlaying, loading, localId, playAudio, sessionId, svrId])
useEffect(() => {
return () => {
const audio = audioRef.current
if (audio) {
audio.pause()
audio.removeAttribute('src')
audio.load()
}
if (globalCurrentAudio === audio) {
globalCurrentAudio = null
globalStopCallback = null
}
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
audioRef.current = null
}
}, [])
const formatDuration = (seconds: number | undefined): string => { const formatDuration = (seconds: number | undefined): string => {
if (!seconds || !isFinite(seconds)) return '0:00' if (!seconds || !isFinite(seconds)) return '0:00'
@@ -10,6 +10,7 @@ interface SelfInfo {
interface AccountSummaryProps { interface AccountSummaryProps {
selfInfo: SelfInfo | null selfInfo: SelfInfo | null
dbReady: boolean dbReady: boolean
dbConnecting?: boolean
compact?: boolean compact?: boolean
onClick?: () => void onClick?: () => void
} }
@@ -17,13 +18,20 @@ interface AccountSummaryProps {
export function AccountSummary({ export function AccountSummary({
selfInfo, selfInfo,
dbReady, dbReady,
dbConnecting = false,
compact = false, compact = false,
onClick onClick
}: AccountSummaryProps): React.ReactElement { }: AccountSummaryProps): React.ReactElement {
const showAccount = Boolean(selfInfo && (dbReady || dbConnecting))
const displayName = const displayName =
dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接' showAccount && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
const subtitle = dbReady && selfInfo ? selfInfo.wxid : '打开设置' const subtitle = showAccount && selfInfo ? selfInfo.wxid : '打开设置'
const statusText = dbReady ? '数据库已连接' : '数据库未连接' const statusText = dbReady
? '数据库已连接'
: dbConnecting
? '正在连接数据库'
: '数据库未连接'
const statusClass = dbReady ? 'ready' : dbConnecting ? 'connecting' : ''
const initial = (displayName || '?').charAt(0) const initial = (displayName || '?').charAt(0)
const title = `${displayName}\n${subtitle}` const title = `${displayName}\n${subtitle}`
const avatar = ( const avatar = (
@@ -33,7 +41,7 @@ export function AccountSummary({
) : ( ) : (
initial initial
)} )}
<span className={`account-summary-status ${dbReady ? 'ready' : ''}`} aria-hidden /> <span className={`account-summary-status ${statusClass}`} aria-hidden />
</span> </span>
) )
@@ -52,7 +60,7 @@ export function AccountSummary({
<span className="account-summary-name">{displayName}</span> <span className="account-summary-name">{displayName}</span>
<span className="account-summary-meta">{subtitle}</span> <span className="account-summary-meta">{subtitle}</span>
<span className="account-summary-state"> <span className="account-summary-state">
<span className={`account-summary-state-dot ${dbReady ? 'ready' : ''}`} aria-hidden /> <span className={`account-summary-state-dot ${statusClass}`} aria-hidden />
{statusText} {statusText}
</span> </span>
</span> </span>
@@ -31,7 +31,9 @@ const RICH_MESSAGE_TYPES = [
'引用消息', '引用消息',
'通话', '通话',
'表情包', '表情包',
'系统消息' '系统消息',
'合并转发',
'不支持的消息'
] ]
export function MessageBubble({ export function MessageBubble({
@@ -46,7 +48,8 @@ export function MessageBubble({
const isVoice = message.type === '语音' const isVoice = message.type === '语音'
const isImage = message.type === '图片' const isImage = message.type === '图片'
const isVideo = message.type === '视频' const isVideo = message.type === '视频'
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type) const isRichMedia =
RICH_MESSAGE_TYPES.includes(message.type) || message.contentData?.type === 'unknown'
const hoverTime = formatMessageTime(message) const hoverTime = formatMessageTime(message)
return ( return (
@@ -63,6 +66,8 @@ export function MessageBubble({
sessionId={message.sessionId} sessionId={message.sessionId}
localId={message.localId || 0} localId={message.localId || 0}
createTime={message.createTime || 0} createTime={message.createTime || 0}
svrId={message.serverId}
duration={message.voiceDuration}
/> />
) : isImage && message.contentData && message.contentData.type === 'image' ? ( ) : isImage && message.contentData && message.contentData.type === 'image' ? (
<ImageBubble <ImageBubble
@@ -9,6 +9,7 @@ interface MessageListProps {
messages: Message[] messages: Message[]
hiddenMessageCount: number hiddenMessageCount: number
isLoadingMessages?: boolean isLoadingMessages?: boolean
messageHistoryStatus?: 'idle' | 'end' | 'error'
isGroupChat: boolean isGroupChat: boolean
showAvatar: boolean showAvatar: boolean
listRef: React.RefObject<HTMLDivElement | null> listRef: React.RefObject<HTMLDivElement | null>
@@ -24,6 +25,7 @@ export function MessageList({
messages, messages,
hiddenMessageCount, hiddenMessageCount,
isLoadingMessages, isLoadingMessages,
messageHistoryStatus,
isGroupChat, isGroupChat,
showAvatar, showAvatar,
listRef, listRef,
@@ -119,6 +121,18 @@ export function MessageList({
return ( return (
<div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}> <div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}>
{isLoadingMessages && <div className="message-loading-pill">...</div>} {isLoadingMessages && <div className="message-loading-pill">...</div>}
{messageHistoryStatus === 'end' && (
<div className="wechat-system-message-row">
<div className="wechat-system-message"></div>
</div>
)}
{messageHistoryStatus === 'error' && (
<div className="wechat-system-message-row">
<div className="wechat-system-message">
</div>
</div>
)}
{hiddenMessageCount > 0 && ( {hiddenMessageCount > 0 && (
<div className="wechat-system-message-row"> <div className="wechat-system-message-row">
<div className="wechat-system-message"> <div className="wechat-system-message">
@@ -1,4 +1,4 @@
import React from 'react' import React, { useState } from 'react'
import { Contact } from '../../../../shared/types' import { Contact } from '../../../../shared/types'
interface ConversationItemProps { interface ConversationItemProps {
@@ -16,6 +16,26 @@ export function ConversationItem({
const wxid = contact.m_nsUsrName const wxid = contact.m_nsUsrName
const displayName = nickname || wxid || '未命名会话' const displayName = nickname || wxid || '未命名会话'
const initial = (displayName || wxid || '?').charAt(0) const initial = (displayName || wxid || '?').charAt(0)
const [repairedAvatar, setRepairedAvatar] = useState<{ username: string; source: string }>()
const [failedAvatar, setFailedAvatar] = useState<{ username: string; source: string }>()
const repairedSource = repairedAvatar?.username === wxid ? repairedAvatar.source : undefined
const avatar = repairedSource || contact.avatar
const avatarFailed = failedAvatar?.username === wxid && failedAvatar.source === avatar
const handleAvatarError = (): void => {
if (!avatar || avatarFailed) return
setFailedAvatar({ username: wxid, source: avatar })
if (contact.type !== 'group' || avatar.startsWith('data:')) return
void window.api
.getContactAvatars([wxid])
.then((avatars) => {
const fallback = avatars[wxid]
if (!fallback || fallback === avatar) return
setRepairedAvatar({ username: wxid, source: fallback })
setFailedAvatar(undefined)
})
.catch(() => undefined)
}
return ( return (
<button <button
@@ -26,13 +46,14 @@ export function ConversationItem({
> >
<span className="conversation-item-active-mark" aria-hidden /> <span className="conversation-item-active-mark" aria-hidden />
<span className="conversation-item-avatar"> <span className="conversation-item-avatar">
{contact.avatar ? ( {avatar && !avatarFailed ? (
<img <img
src={contact.avatar} src={avatar}
alt={displayName} alt={displayName}
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={handleAvatarError}
/> />
) : ( ) : (
initial initial
@@ -21,10 +21,11 @@ export interface ConversationSidebarProps {
width: number width: number
selfInfo: SelfInfo | null selfInfo: SelfInfo | null
dbReady: boolean dbReady: boolean
dbConnecting?: boolean
onOpenSettings: () => void onOpenSettings: () => void
} }
type SectionName = 'groups' | 'contacts' type SectionName = 'groups' | 'folded' | 'contacts'
type ConversationRow = type ConversationRow =
| { kind: 'header'; id: string; title: string; count: number; section: SectionName } | { kind: 'header'; id: string; title: string; count: number; section: SectionName }
| { kind: 'contact'; id: string; contact: Contact } | { kind: 'contact'; id: string; contact: Contact }
@@ -37,29 +38,73 @@ export function ConversationSidebar({
width, width,
selfInfo, selfInfo,
dbReady, dbReady,
dbConnecting = false,
onOpenSettings onOpenSettings
}: ConversationSidebarProps): React.ReactElement { }: ConversationSidebarProps): React.ReactElement {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({ const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
groups: true, groups: true,
folded: false,
contacts: false contacts: false
}) })
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null)
const groups = contacts.filter((contact) => contact.type === 'group') const groups = contacts.filter((contact) => contact.type === 'group' && !contact.isFolded)
const foldedGroups = contacts.filter((contact) => contact.type === 'group' && contact.isFolded)
const users = contacts.filter((contact) => contact.type === 'user') const users = contacts.filter((contact) => contact.type === 'user')
const rows = useMemo<ConversationRow[]>( const rows = useMemo<ConversationRow[]>(
() => [ () => [
{ kind: 'header', id: 'groups-header', title: '群聊', count: groups.length, section: 'groups' }, {
kind: 'header',
id: 'groups-header',
title: '群聊',
count: groups.length,
section: 'groups'
},
...(expandedSections.groups ...(expandedSections.groups
? groups.map((contact) => ({ kind: 'contact' as const, id: `group-${contact.md5}`, contact })) ? groups.map((contact) => ({
kind: 'contact' as const,
id: `group-${contact.md5}`,
contact
}))
: []), : []),
{ kind: 'header', id: 'contacts-header', title: '联系人', count: users.length, section: 'contacts' }, ...(foldedGroups.length
? [
{
kind: 'header' as const,
id: 'folded-header',
title: '折叠群聊',
count: foldedGroups.length,
section: 'folded' as const
},
...(expandedSections.folded
? foldedGroups.map((contact) => ({
kind: 'contact' as const,
id: `folded-${contact.md5}`,
contact
}))
: [])
]
: []),
{
kind: 'header',
id: 'contacts-header',
title: '联系人',
count: users.length,
section: 'contacts'
},
...(expandedSections.contacts ...(expandedSections.contacts
? users.map((contact) => ({ kind: 'contact' as const, id: `user-${contact.md5}`, contact })) ? users.map((contact) => ({ kind: 'contact' as const, id: `user-${contact.md5}`, contact }))
: []) : [])
], ],
[expandedSections.contacts, expandedSections.groups, groups, users] [
expandedSections.contacts,
expandedSections.folded,
expandedSections.groups,
foldedGroups,
groups,
users
]
) )
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: rows.length, count: rows.length,
@@ -82,7 +127,10 @@ export function ConversationSidebar({
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
/> />
<div ref={listRef} className="conversation-list" aria-label="会话列表"> <div ref={listRef} className="conversation-list" aria-label="会话列表">
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}> <div
className="conversation-virtual-content"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => { {virtualizer.getVirtualItems().map((virtualItem) => {
const row = rows[virtualItem.index] const row = rows[virtualItem.index]
if (!row) return null if (!row) return null
@@ -93,9 +141,15 @@ export function ConversationSidebar({
key={virtualItem.key} key={virtualItem.key}
type="button" type="button"
className="conversation-section-header conversation-virtual-row" className="conversation-section-header conversation-virtual-row"
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }} style={{
transform: `translateY(${virtualItem.start}px)`,
height: `${virtualItem.size}px`
}}
onClick={() => onClick={() =>
setExpandedSections((current) => ({ ...current, [row.section]: !current[row.section] })) setExpandedSections((current) => ({
...current,
[row.section]: !current[row.section]
}))
} }
> >
<span className="conversation-section-chevron" aria-hidden="true"> <span className="conversation-section-chevron" aria-hidden="true">
@@ -103,7 +157,9 @@ export function ConversationSidebar({
<path d={expanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'} /> <path d={expanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'} />
</svg> </svg>
</span> </span>
<span className="conversation-section-title">{row.title} ({row.count})</span> <span className="conversation-section-title">
{row.title} ({row.count})
</span>
</button> </button>
) )
} }
@@ -111,7 +167,10 @@ export function ConversationSidebar({
<div <div
key={virtualItem.key} key={virtualItem.key}
className="conversation-virtual-row" className="conversation-virtual-row"
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }} style={{
transform: `translateY(${virtualItem.start}px)`,
height: `${virtualItem.size}px`
}}
> >
<ConversationItem <ConversationItem
contact={row.contact} contact={row.contact}
@@ -124,7 +183,12 @@ export function ConversationSidebar({
</div> </div>
</div> </div>
<div className="conversation-sidebar-account"> <div className="conversation-sidebar-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} /> <AccountSummary
selfInfo={selfInfo}
dbReady={dbReady}
dbConnecting={dbConnecting}
onClick={onOpenSettings}
/>
</div> </div>
</aside> </aside>
) )
@@ -41,7 +41,7 @@ export function ExportWorkspace({
const [includeAvatars, setIncludeAvatars] = useState(true) const [includeAvatars, setIncludeAvatars] = useState(true)
const [preferOriginal, setPreferOriginal] = useState(true) const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true) const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
const [keepMissing, setKeepMissing] = useState(false) const [keepMissing, setKeepMissing] = useState(true)
const [format, setFormat] = useState<ExportFormat>('csv') const [format, setFormat] = useState<ExportFormat>('csv')
const [zip, setZip] = useState(false) const [zip, setZip] = useState(false)
const [fileName, setFileName] = useState('') const [fileName, setFileName] = useState('')
@@ -158,6 +158,8 @@ export function ExportWorkspace({
const handleStart = async (): Promise<void> => { const handleStart = async (): Promise<void> => {
if (!activeContact || status === 'running') return if (!activeContact || status === 'running') return
// Runs only from the export button event; a fresh id is required for each job.
// eslint-disable-next-line react-hooks/purity
const nextJobId = `export-${Date.now()}` const nextJobId = `export-${Date.now()}`
setJobId(nextJobId) setJobId(nextJobId)
setProgress(null) setProgress(null)
@@ -204,6 +206,9 @@ export function ExportWorkspace({
: undefined, : undefined,
kinds: Array.from(selectedKinds) as ExportMessageKind[], kinds: Array.from(selectedKinds) as ExportMessageKind[],
includeMedia, includeMedia,
preferOriginal,
fallbackThumbnail,
keepMissing,
includeAvatars, includeAvatars,
avatarUrls: exportAvatarUrls, avatarUrls: exportAvatarUrls,
nameMode, nameMode,
@@ -303,20 +308,39 @@ export function ExportWorkspace({
<h3></h3> <h3></h3>
<div className="export-format-grid"> <div className="export-format-grid">
{formatOrder.map((value) => ( {formatOrder.map((value) => (
<button key={value} type="button" className={format === value ? 'active' : ''} onClick={() => setFormat(value)}> <button
key={value}
type="button"
className={format === value ? 'active' : ''}
onClick={() => setFormat(value)}
>
<strong>{formatLabels[value].label}</strong> <strong>{formatLabels[value].label}</strong>
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>} {formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
</button> </button>
))} ))}
</div> </div>
<p className="export-helper-text">CSV HTML </p> <p className="export-helper-text">
CSV HTML
</p>
{format === 'html' && ( {format === 'html' && (
<div className="export-html-options"> <div className="export-html-options">
<label> <label>
<input type="radio" name="html-package-top" checked={!zip} onChange={() => setZip(false)} /> HTML <input
type="radio"
name="html-package-top"
checked={!zip}
onChange={() => setZip(false)}
/>{' '}
HTML
</label> </label>
<label> <label>
<input type="radio" name="html-package-top" checked={zip} onChange={() => setZip(true)} /> HTML ZIP <input
type="radio"
name="html-package-top"
checked={zip}
onChange={() => setZip(true)}
/>{' '}
HTML ZIP
</label> </label>
</div> </div>
)} )}
@@ -383,19 +407,13 @@ export function ExportWorkspace({
<h3></h3> <h3></h3>
<div className="export-kind-grid"> <div className="export-kind-grid">
{messageKinds.map(([value, label]) => ( {messageKinds.map(([value, label]) => (
<label key={value} className={`export-check-row ${value === 'video' ? 'unsupported' : ''}`}> <label key={value} className="export-check-row">
<input <input
type="checkbox" type="checkbox"
checked={value !== 'video' && selectedKinds.has(value)} checked={selectedKinds.has(value)}
disabled={value === 'video'}
onChange={() => toggleKind(value)} onChange={() => toggleKind(value)}
/> />
<span>{label}</span> <span>{label}</span>
{value === 'video' && (
<span className="export-unsupported-hint" title="当前版本暂不支持视频导出" aria-label="当前版本暂不支持视频导出">
!
</span>
)}
</label> </label>
))} ))}
</div> </div>
@@ -424,12 +442,14 @@ export function ExportWorkspace({
<span></span> <span></span>
<input <input
type="checkbox" type="checkbox"
checked={includeMedia} checked={includeMedia}
disabled={format !== 'html'} disabled={format !== 'html'}
onChange={(event) => setIncludeMedia(event.target.checked)} onChange={(event) => setIncludeMedia(event.target.checked)}
/> />
</label> </label>
<div className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}> <div
className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}
>
<label className="export-check-row"> <label className="export-check-row">
<input <input
type="checkbox" type="checkbox"
@@ -458,7 +478,9 @@ export function ExportWorkspace({
<span></span> <span></span>
</label> </label>
</div> </div>
<p className="export-helper-text"> HTML CSVJSON Markdown </p> <p className="export-helper-text">
HTML CSVJSON Markdown
</p>
<div className="export-resource-statuses"> <div className="export-resource-statuses">
<span></span> <span></span>
<span></span> <span></span>
+32 -4
View File
@@ -9,13 +9,17 @@ export type ImageLoadOptions = {
} }
type QueueItem = { type QueueItem = {
requestKey: string
priority: number priority: number
run: () => Promise<LoadedImage> run: () => Promise<LoadedImage>
resolve: (value: LoadedImage) => void resolve: (value: LoadedImage) => void
reject: (error: Error) => void reject: (error: Error) => void
} }
const MAX_CONCURRENT_IMAGE_LOADS = 3 // Cache probes are cheap asynchronous IPC calls. Cold decrypts are serialized
// in the main process, so renderer concurrency only controls how quickly disk
// cache hits can become visible after a restart.
const MAX_CONCURRENT_IMAGE_LOADS = 32
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024 const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
const imageCache = new Map<string, LoadedImage>() const imageCache = new Map<string, LoadedImage>()
const imageCacheSizes = new Map<string, number>() const imageCacheSizes = new Map<string, number>()
@@ -55,6 +59,12 @@ function getCachedImage(
for (const key of keys) { for (const key of keys) {
const cached = imageCache.get(key) const cached = imageCache.get(key)
if (!cached) continue if (!cached) continue
if (options.force && cached.isThumbnail) {
imageCache.delete(key)
imageCacheBytes -= imageCacheSizes.get(key) || 0
imageCacheSizes.delete(key)
continue
}
imageCache.delete(key) imageCache.delete(key)
imageCache.set(key, cached) imageCache.set(key, cached)
return cached return cached
@@ -77,6 +87,10 @@ function cacheImage(
options: ImageLoadOptions, options: ImageLoadOptions,
image: LoadedImage image: LoadedImage
): void { ): void {
// A forced original request may temporarily fall back to a thumbnail. Do not
// let that fallback prevent a later retry after WeChat downloads the original.
if (options.force && image.isThumbnail) return
const keys = cacheKeys(imageMd5, imageDatName, options) const keys = cacheKeys(imageMd5, imageDatName, options)
const size = image.data.length * 2 const size = image.data.length * 2
for (const key of keys) { for (const key of keys) {
@@ -99,6 +113,8 @@ function cacheImage(
} }
function pumpImageQueue(): void { function pumpImageQueue(): void {
if (activeImageLoads >= MAX_CONCURRENT_IMAGE_LOADS || imageQueue.length === 0) return
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) { while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
imageQueue.sort((left, right) => left.priority - right.priority) imageQueue.sort((left, right) => left.priority - right.priority)
const item = imageQueue.shift() const item = imageQueue.shift()
@@ -114,6 +130,10 @@ function pumpImageQueue(): void {
} }
} }
function isSupportedImageUrl(value: string | undefined): value is string {
return Boolean(value?.startsWith('data:image/') || value?.startsWith('wxe-media://'))
}
export function getCachedLoadedImage( export function getCachedLoadedImage(
imageMd5?: string, imageMd5?: string,
imageDatName?: string, imageDatName?: string,
@@ -136,16 +156,24 @@ export function requestImage(
if (!identity) return Promise.reject(new Error('缺少图片标识')) if (!identity) return Promise.reject(new Error('缺少图片标识'))
const requestKey = `${identity}:${cacheMode(options)}` const requestKey = `${identity}:${cacheMode(options)}`
const existingRequest = imageRequests.get(requestKey) const existingRequest = imageRequests.get(requestKey)
if (existingRequest) return existingRequest if (existingRequest) {
const queuedItem = imageQueue.find((item) => item.requestKey === requestKey)
if (queuedItem && priority < queuedItem.priority) queuedItem.priority = priority
return existingRequest
}
const request = new Promise<LoadedImage>((resolve, reject) => { const request = new Promise<LoadedImage>((resolve, reject) => {
imageQueue.push({ imageQueue.push({
requestKey,
priority, priority,
resolve, resolve,
reject, reject,
run: async () => { run: async () => {
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options) const result = await window.api.getImage(imageMd5, imageDatName, sessionId, {
if (!result.success || !result.data?.startsWith('data:image/')) { ...options,
priority
})
if (!result.success || !isSupportedImageUrl(result.data)) {
throw new Error(result.error || '加载图片失败') throw new Error(result.error || '加载图片失败')
} }
const loadedImage = { const loadedImage = {
@@ -15,8 +15,10 @@ interface AppShellProps {
activePage: AppPage activePage: AppPage
selfInfo: SelfInfo | null selfInfo: SelfInfo | null
dbReady: boolean dbReady: boolean
dbConnecting?: boolean
onPageChange: (page: AppPage) => void onPageChange: (page: AppPage) => void
onOpenSettings: () => void onOpenSettings: () => void
onOpenGuide: () => void
appearanceTheme?: 'system' | 'light' | 'dark' appearanceTheme?: 'system' | 'light' | 'dark'
compactMode?: boolean compactMode?: boolean
children: React.ReactNode children: React.ReactNode
@@ -34,8 +36,10 @@ export function AppShell({
activePage, activePage,
selfInfo, selfInfo,
dbReady, dbReady,
dbConnecting = false,
onPageChange, onPageChange,
onOpenSettings, onOpenSettings,
onOpenGuide,
appearanceTheme = 'system', appearanceTheme = 'system',
compactMode = false, compactMode = false,
children children
@@ -47,8 +51,27 @@ export function AppShell({
<aside className="app-primary-rail"> <aside className="app-primary-rail">
<BrandLogo /> <BrandLogo />
<PrimaryNavigation activePage={activePage} onPageChange={onPageChange} /> <PrimaryNavigation activePage={activePage} onPageChange={onPageChange} />
<button
type="button"
className="app-guide-launcher"
onClick={onOpenGuide}
title="重新打开新手引导"
>
<span className="app-guide-launcher-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M12 3 14.2 9.8 21 12l-6.8 2.2L12 21l-2.2-6.8L3 12l6.8-2.2L12 3Z" />
</svg>
</span>
<span className="app-guide-launcher-label"></span>
</button>
<div className="app-rail-account"> <div className="app-rail-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} compact onClick={onOpenSettings} /> <AccountSummary
selfInfo={selfInfo}
dbReady={dbReady}
dbConnecting={dbConnecting}
compact
onClick={onOpenSettings}
/>
</div> </div>
</aside> </aside>
<main className="app-shell-main" aria-label={activeItem?.label || '工作区'}> <main className="app-shell-main" aria-label={activeItem?.label || '工作区'}>
@@ -7,7 +7,7 @@ export interface NavigationItem {
export const PRIMARY_NAV_ITEMS: NavigationItem[] = [ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
{ id: 'archive', label: '档案' }, { id: 'archive', label: '档案' },
{ id: 'search', label: '检索' }, { id: 'search', label: '问问微信' },
{ id: 'report', label: '日报' }, { id: 'report', label: '日报' },
{ id: 'agent-hub', label: 'Agent' }, { id: 'agent-hub', label: 'Agent' },
{ id: 'export', label: '导出' }, { id: 'export', label: '导出' },
@@ -14,6 +14,7 @@ interface ReportHistorySidebarProps {
selectedReportId: string | null selectedReportId: string | null
selfInfo: SelfInfo | null selfInfo: SelfInfo | null
dbReady: boolean dbReady: boolean
dbConnecting?: boolean
onSelectReport: (reportId: string) => void onSelectReport: (reportId: string) => void
onCreateReport: () => void onCreateReport: () => void
onDeleteReport: (reportId: string) => Promise<{ success: boolean; error?: string }> onDeleteReport: (reportId: string) => Promise<{ success: boolean; error?: string }>
@@ -80,6 +81,7 @@ export function ReportHistorySidebar({
selectedReportId, selectedReportId,
selfInfo, selfInfo,
dbReady, dbReady,
dbConnecting = false,
onSelectReport, onSelectReport,
onCreateReport, onCreateReport,
onDeleteReport, onDeleteReport,
@@ -202,7 +204,12 @@ export function ReportHistorySidebar({
)} )}
</div> </div>
<div className="report-history-account"> <div className="report-history-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} /> <AccountSummary
selfInfo={selfInfo}
dbReady={dbReady}
dbConnecting={dbConnecting}
onClick={onOpenSettings}
/>
</div> </div>
{pendingDelete && ( {pendingDelete && (
<div className="report-delete-confirm" role="dialog" aria-modal="true"> <div className="report-delete-confirm" role="dialog" aria-modal="true">
@@ -14,6 +14,7 @@ interface ReportSourceSidebarProps {
selectedContact: Contact | null selectedContact: Contact | null
selfInfo: SelfInfo | null selfInfo: SelfInfo | null
dbReady: boolean dbReady: boolean
dbConnecting?: boolean
onSelectContact: (contact: Contact) => void onSelectContact: (contact: Contact) => void
onOpenSettings: () => void onOpenSettings: () => void
} }
@@ -23,6 +24,7 @@ export function ReportSourceSidebar({
selectedContact, selectedContact,
selfInfo, selfInfo,
dbReady, dbReady,
dbConnecting = false,
onSelectContact, onSelectContact,
onOpenSettings onOpenSettings
}: ReportSourceSidebarProps): React.ReactElement { }: ReportSourceSidebarProps): React.ReactElement {
@@ -95,6 +97,7 @@ export function ReportSourceSidebar({
<AccountSummary <AccountSummary
selfInfo={selfInfo} selfInfo={selfInfo}
dbReady={dbReady} dbReady={dbReady}
dbConnecting={dbConnecting}
onClick={onOpenSettings} onClick={onOpenSettings}
/> />
</div> </div>
@@ -641,7 +641,7 @@ export function AISearchWorkspace({
<header className="ai-search-header"> <header className="ai-search-header">
<div> <div>
<span className="ai-search-kicker">WechatExplorer · LOCAL INTELLIGENCE</span> <span className="ai-search-kicker">WechatExplorer · LOCAL INTELLIGENCE</span>
<h1>AI </h1> <h1></h1>
<p></p> <p></p>
</div> </div>
<div className="ai-search-header-actions"> <div className="ai-search-header-actions">
@@ -19,6 +19,7 @@ export function SettingsWorkspace({
onCategoryChange, onCategoryChange,
selfInfo, selfInfo,
dbReady, dbReady,
dbConnecting = false,
dbKey, dbKey,
onDbKeyChange, onDbKeyChange,
onDatabaseConnectionChange, onDatabaseConnectionChange,
@@ -29,12 +30,14 @@ export function SettingsWorkspace({
onAIRuntimeChange, onAIRuntimeChange,
onNotice, onNotice,
onOpenSettings, onOpenSettings,
onAppearanceChange onAppearanceChange,
onSwitchAccount
}: { }: {
selectedCategory: SettingsCategoryId selectedCategory: SettingsCategoryId
onCategoryChange: (id: SettingsCategoryId) => void onCategoryChange: (id: SettingsCategoryId) => void
selfInfo: SettingsSelfInfo | null selfInfo: SettingsSelfInfo | null
dbReady: boolean dbReady: boolean
dbConnecting?: boolean
dbKey: string dbKey: string
onDbKeyChange: (key: string) => void onDbKeyChange: (key: string) => void
onDatabaseConnectionChange: (connected: boolean) => void onDatabaseConnectionChange: (connected: boolean) => void
@@ -45,8 +48,61 @@ export function SettingsWorkspace({
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
onNotice: (message: string) => void onNotice: (message: string) => void
onOpenSettings: () => void onOpenSettings: () => void
onAppearanceChange: (settings: { theme: 'system' | 'light' | 'dark'; compactMode: boolean }) => void onAppearanceChange: (settings: {
theme: 'system' | 'light' | 'dark'
compactMode: boolean
}) => void
onSwitchAccount: (
account: import('../../../../shared/database-key').WechatAccountCandidate
) => Promise<void>
}): React.ReactElement { }): React.ReactElement {
const renderSelectedPage = (): React.ReactElement => {
switch (selectedCategory) {
case 'account-database':
return (
<AccountDatabasePage
dbKey={dbKey}
dbReady={dbReady}
dbConnecting={dbConnecting}
selfInfo={selfInfo}
onNotice={onNotice}
onSwitchAccount={onSwitchAccount}
/>
)
case 'database-key':
return (
<DatabaseKeyPage
dbKey={dbKey}
dbReady={dbReady}
selfInfo={selfInfo}
onDbKeyChange={onDbKeyChange}
onDatabaseConnectionChange={onDatabaseConnectionChange}
onSelfInfoChange={onSelfInfoChange}
onContactsChange={onContactsChange}
onFilteredContactsChange={onFilteredContactsChange}
onReturnToLogin={onReturnToLogin}
onNotice={onNotice}
/>
)
case 'image-key':
return <ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
case 'ai-model':
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
case 'recall-protection':
return <RecallProtectionPage onNotice={onNotice} />
case 'advanced':
return <AdvancedPage onNotice={onNotice} />
case 'cache-cleanup':
return <CacheCleanupPage onNotice={onNotice} />
case 'appearance':
return <AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
case 'about':
return <AboutPage onNotice={onNotice} />
default:
return <SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
}
}
return ( return (
<div className="settings-workspace"> <div className="settings-workspace">
<SettingsSidebar <SettingsSidebar
@@ -54,70 +110,10 @@ export function SettingsWorkspace({
onSelect={onCategoryChange} onSelect={onCategoryChange}
selfInfo={selfInfo} selfInfo={selfInfo}
dbReady={dbReady} dbReady={dbReady}
dbConnecting={dbConnecting}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
/> />
<div <div className="settings-page-panel active">{renderSelectedPage()}</div>
className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}
>
<AccountDatabasePage
dbKey={dbKey}
dbReady={dbReady}
selfInfo={selfInfo}
onNotice={onNotice}
/>
</div>
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
<DatabaseKeyPage
dbKey={dbKey}
dbReady={dbReady}
selfInfo={selfInfo}
onDbKeyChange={onDbKeyChange}
onDatabaseConnectionChange={onDatabaseConnectionChange}
onSelfInfoChange={onSelfInfoChange}
onContactsChange={onContactsChange}
onFilteredContactsChange={onFilteredContactsChange}
onReturnToLogin={onReturnToLogin}
onNotice={onNotice}
/>
</div>
<div className={`settings-page-panel ${selectedCategory === 'image-key' ? 'active' : ''}`}>
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'ai-model' ? 'active' : ''}`}>
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
</div>
<div
className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}
>
<RecallProtectionPage onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'advanced' ? 'active' : ''}`}>
<AdvancedPage onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'cache-cleanup' ? 'active' : ''}`}>
<CacheCleanupPage onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'appearance' ? 'active' : ''}`}>
<AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'about' ? 'active' : ''}`}>
<AboutPage onNotice={onNotice} />
</div>
{![
'account-database',
'database-key',
'image-key',
'ai-model',
'recall-protection',
'advanced',
'cache-cleanup',
'appearance',
'about'
].includes(selectedCategory) && (
<div className="settings-page-panel active">
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
</div>
)}
</div> </div>
) )
} }
@@ -25,7 +25,8 @@ export function AccountOverview({
isChecking, isChecking,
onCheck, onCheck,
onOpenDirectory, onOpenDirectory,
onCopyDirectory onCopyDirectory,
onSwitchAccount
}: { }: {
selfInfo: SettingsSelfInfo | null selfInfo: SettingsSelfInfo | null
connectionStatus: ConnectionOverviewStatus connectionStatus: ConnectionOverviewStatus
@@ -34,6 +35,7 @@ export function AccountOverview({
onCheck: () => void onCheck: () => void
onOpenDirectory: () => void onOpenDirectory: () => void
onCopyDirectory: () => void onCopyDirectory: () => void
onSwitchAccount: () => void
}): React.ReactElement { }): React.ReactElement {
const accountRoot = selfInfo?.accountRoot || '' const accountRoot = selfInfo?.accountRoot || ''
return ( return (
@@ -83,6 +85,9 @@ export function AccountOverview({
> >
</button> </button>
<button type="button" className="api-secondary-button" onClick={onSwitchAccount}>
</button>
</div> </div>
<div className="settings-account-root"> <div className="settings-account-root">
@@ -13,11 +13,13 @@ import type { ConnectionCheckState } from './types'
export function useAccountDatabaseController({ export function useAccountDatabaseController({
dbKey, dbKey,
dbReady, dbReady,
dbConnecting = false,
selfInfo, selfInfo,
onNotice onNotice
}: { }: {
dbKey: string dbKey: string
dbReady: boolean dbReady: boolean
dbConnecting?: boolean
selfInfo: SettingsSelfInfo | null selfInfo: SettingsSelfInfo | null
onNotice: (message: string) => void onNotice: (message: string) => void
}) { }) {
@@ -28,7 +30,7 @@ export function useAccountDatabaseController({
useEffect(() => { useEffect(() => {
let active = true let active = true
void window.api void window.api
.getImageDecryptionStatus() .getImageKeyConfig()
.then((result) => { .then((result) => {
if (active) setHasImageKey(result.configured) if (active) setHasImageKey(result.configured)
}) })
@@ -58,8 +60,11 @@ export function useAccountDatabaseController({
[checkState, dbKey, dbReady, hasImageKey, selfInfo] [checkState, dbKey, dbReady, hasImageKey, selfInfo]
) )
const connectionStatus = useMemo( const connectionStatus = useMemo(
() => getConnectionOverviewStatus({ dbReady, checkState, diagnostics }), () =>
[checkState, dbReady, diagnostics] dbConnecting
? ('checking' as const)
: getConnectionOverviewStatus({ dbReady, checkState, diagnostics }),
[checkState, dbConnecting, dbReady, diagnostics]
) )
const lastCheckedLabel = formatConnectionCheckedAt(checkState, clock) const lastCheckedLabel = formatConnectionCheckedAt(checkState, clock)
@@ -121,7 +126,7 @@ export function useAccountDatabaseController({
diagnostics, diagnostics,
connectionStatus, connectionStatus,
checkState, checkState,
isChecking: checkState.status === 'checking', isChecking: dbConnecting || checkState.status === 'checking',
lastCheckedLabel, lastCheckedLabel,
testConnection, testConnection,
openAccountDirectory, openAccountDirectory,
@@ -8,12 +8,14 @@ export function SettingsSidebar({
onSelect, onSelect,
selfInfo, selfInfo,
dbReady, dbReady,
dbConnecting = false,
onOpenSettings onOpenSettings
}: { }: {
selectedId: SettingsCategoryId selectedId: SettingsCategoryId
onSelect: (id: SettingsCategoryId) => void onSelect: (id: SettingsCategoryId) => void
selfInfo: SettingsSelfInfo | null selfInfo: SettingsSelfInfo | null
dbReady: boolean dbReady: boolean
dbConnecting?: boolean
onOpenSettings: () => void onOpenSettings: () => void
}): React.ReactElement { }): React.ReactElement {
const [keyword, setKeyword] = useState('') const [keyword, setKeyword] = useState('')
@@ -57,7 +59,12 @@ export function SettingsSidebar({
))} ))}
</div> </div>
<div className="settings-sidebar-account"> <div className="settings-sidebar-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} /> <AccountSummary
selfInfo={selfInfo}
dbReady={dbReady}
dbConnecting={dbConnecting}
onClick={onOpenSettings}
/>
</div> </div>
</aside> </aside>
) )
@@ -36,14 +36,14 @@ export function useDatabaseKeyController({
}, []) }, [])
const refreshStorage = useCallback(async (): Promise<void> => { const refreshStorage = useCallback(async (): Promise<void> => {
const result = await window.api.getSavedDbKey() const result = await window.api.getSavedDbKey(selfInfo?.accountRoot || '')
dispatch({ dispatch({
type: 'STORAGE_LOADED', type: 'STORAGE_LOADED',
saved: result.saved, saved: result.saved,
encryptionAvailable: result.encryptionAvailable, encryptionAvailable: result.encryptionAvailable,
error: result.success ? undefined : result.error error: result.success ? undefined : result.error
}) })
}, []) }, [selfInfo?.accountRoot])
useEffect(() => { useEffect(() => {
void Promise.all([refreshStorage(), refreshEnvironment()]) void Promise.all([refreshStorage(), refreshEnvironment()])
@@ -109,7 +109,8 @@ export function useDatabaseKeyController({
const saveKey = useCallback(async (): Promise<void> => { const saveKey = useCallback(async (): Promise<void> => {
if (state.status !== 'valid') return if (state.status !== 'valid') return
dispatch({ type: 'SAVE_START' }) dispatch({ type: 'SAVE_START' })
const saved = await window.api.saveDbKey(dbKey) const accountRoot = selfInfo?.accountRoot || ''
const saved = await window.api.saveDbKey(accountRoot, dbKey)
if (!saved.success || !saved.key) { if (!saved.success || !saved.key) {
dispatch({ dispatch({
type: 'SAVE_ERROR', type: 'SAVE_ERROR',
@@ -117,12 +118,12 @@ export function useDatabaseKeyController({
}) })
return return
} }
const stored = await window.api.getSavedDbKey() const stored = await window.api.getSavedDbKey(accountRoot)
if (!stored.success || !stored.saved || !stored.key) { if (!stored.success || !stored.saved || !stored.key) {
dispatch({ type: 'SAVE_ERROR', error: '无法确认密钥保存状态' }) dispatch({ type: 'SAVE_ERROR', error: '无法确认密钥保存状态' })
return return
} }
const initialized = await window.api.initDb(stored.key) const initialized = await window.api.initDb(stored.key, accountRoot)
const connected = typeof initialized === 'boolean' ? initialized : initialized.success const connected = typeof initialized === 'boolean' ? initialized : initialized.success
onDbKeyChange(stored.key) onDbKeyChange(stored.key)
onDatabaseConnectionChange(connected) onDatabaseConnectionChange(connected)
@@ -148,13 +149,14 @@ export function useDatabaseKeyController({
onNotice, onNotice,
onSelfInfoChange, onSelfInfoChange,
refreshEnvironment, refreshEnvironment,
selfInfo?.accountRoot,
state.status state.status
]) ])
const autoDetectKey = useCallback(async (): Promise<void> => { const autoDetectKey = useCallback(async (): Promise<void> => {
dispatch({ type: 'AUTO_START' }) dispatch({ type: 'AUTO_START' })
await refreshEnvironment() await refreshEnvironment()
const result = await window.api.autoGetDbKey({ save: false }) const result = await window.api.autoGetDbKey(selfInfo?.accountRoot || '', { save: false })
if (!result.success || !result.key) { if (!result.success || !result.key) {
dispatch({ type: 'AUTO_ERROR', error: result.error || '暂未找到有效密钥' }) dispatch({ type: 'AUTO_ERROR', error: result.error || '暂未找到有效密钥' })
return return
@@ -162,11 +164,11 @@ export function useDatabaseKeyController({
onDbKeyChange(result.key) onDbKeyChange(result.key)
dispatch({ type: 'AUTO_SUCCESS' }) dispatch({ type: 'AUTO_SUCCESS' })
await runValidation(result.key) await runValidation(result.key)
}, [onDbKeyChange, refreshEnvironment, runValidation]) }, [onDbKeyChange, refreshEnvironment, runValidation, selfInfo?.accountRoot])
const clearSavedKey = useCallback(async (): Promise<void> => { const clearSavedKey = useCallback(async (): Promise<void> => {
dispatch({ type: 'CLEAR_START' }) dispatch({ type: 'CLEAR_START' })
const result = await window.api.clearSavedDbKey() const result = await window.api.clearSavedDbKey(selfInfo?.accountRoot || '')
if (!result.success) { if (!result.success) {
dispatch({ type: 'CLEAR_ERROR', error: '清除密钥失败' }) dispatch({ type: 'CLEAR_ERROR', error: '清除密钥失败' })
return return
@@ -187,7 +189,8 @@ export function useDatabaseKeyController({
onFilteredContactsChange, onFilteredContactsChange,
onNotice, onNotice,
onSelfInfoChange, onSelfInfoChange,
refreshEnvironment refreshEnvironment,
selfInfo?.accountRoot
]) ])
const returnToLogin = useCallback(async (): Promise<void> => { const returnToLogin = useCallback(async (): Promise<void> => {
@@ -0,0 +1,182 @@
import { useEffect, useState } from 'react'
import type { ImageDecoderStatus } from '../../../../../shared/image-decryption'
interface ImageDecoderRequirementNoticeProps {
status?: ImageDecoderStatus
onNotice: (message: string) => void
}
export function ImageDecoderRequirementNotice({
status,
onNotice
}: ImageDecoderRequirementNoticeProps): React.ReactElement {
const platform = window.electron.process.platform
const [currentStatus, setCurrentStatus] = useState(status)
const [selecting, setSelecting] = useState(false)
const [checking, setChecking] = useState(false)
const [error, setError] = useState<string>()
useEffect(() => setCurrentStatus(status), [status])
const selectDirectory = async (): Promise<void> => {
setSelecting(true)
setError(undefined)
try {
const result = await window.api.selectImageDecoder()
if (result.canceled) return
if (!result.success || !result.status) {
setError(result.error || '没有在所选文件夹中找到 FFmpeg,请重新选择。')
return
}
setCurrentStatus(result.status)
onNotice(
result.status.available
? 'FFmpeg 安装目录已保存,原图支持可以使用'
: 'FFmpeg 安装目录已保存,但原图支持未通过检测'
)
} catch {
setError('无法打开目录选择窗口,请稍后重试。')
} finally {
setSelecting(false)
}
}
const checkOriginalSupport = async (): Promise<void> => {
setChecking(true)
setError(undefined)
try {
const nextStatus = await window.api.getImageDecoderStatus()
setCurrentStatus(nextStatus)
onNotice(nextStatus.available ? '原图支持检测通过' : '原图支持检测未通过')
} catch {
setError('原图支持检测失败,请稍后重试。')
} finally {
setChecking(false)
}
}
const openDownload = async (): Promise<void> => {
setError(undefined)
try {
const result = await window.api.openImageDecoderDownload()
if (!result.success) setError(result.error || '无法打开 FFmpeg 下载页面')
} catch {
setError('无法打开 FFmpeg 下载页面,请检查系统默认浏览器设置。')
}
}
const installed = currentStatus?.installed === true
const supported = currentStatus?.available === true
const downloadLabel = platform === 'darwin' ? '打开 Homebrew 官网' : '下载 FFmpeg'
return (
<section
className={`image-decoder-requirement ${supported ? 'ready' : ''}`}
aria-label="FFmpeg 原图支持"
>
<span className="image-decoder-requirement-icon" aria-hidden>
{supported ? '✓' : '!'}
</span>
<div className="image-decoder-requirement-body">
<div className="image-decoder-requirement-heading">
<strong>FFmpeg </strong>
<span>
{supported ? '原图支持可用' : installed ? 'FFmpeg 已安装' : '需要安装 FFmpeg'}
</span>
</div>
<p> wxgf/HEVC FFmpeg</p>
<div className="image-decoder-checks">
<div>
<span className={`image-decoder-check-index ${installed ? 'complete' : ''}`}>1</span>
<div>
<strong>FFmpeg </strong>
<small title={currentStatus?.directory}>
{currentStatus?.directory || '尚未检测到 FFmpeg 安装目录'}
</small>
</div>
<b className={installed ? 'success' : ''}>{installed ? '已检测' : '未检测'}</b>
</div>
<div>
<span className={`image-decoder-check-index ${supported ? 'complete' : ''}`}>2</span>
<div>
<strong></strong>
<small>
{supported
? '已支持 wxgf/HEVC 特殊原图转换'
: installed
? '尚未检测到 HEVC 原图转换能力'
: '请先安装并检测 FFmpeg 目录'}
</small>
</div>
<b className={supported ? 'success' : ''}>{supported ? '已通过' : '未通过'}</b>
</div>
</div>
<div className="image-decoder-requirement-actions">
<button
type="button"
className="settings-header-action"
onClick={() => void openDownload()}
>
{downloadLabel}
</button>
<button
type="button"
className="settings-primary-button"
disabled={selecting}
onClick={() => void selectDirectory()}
>
{selecting ? '正在保存…' : '填写 FFmpeg 安装目录'}
</button>
<button
type="button"
className="settings-header-action"
disabled={!installed || checking}
onClick={() => void checkOriginalSupport()}
>
{checking ? '正在检测…' : '检测原图支持'}
</button>
</div>
<div className="image-decoder-platform-help">
{platform === 'win32' ? (
<>
<strong>Windows </strong>
<p>
PowerShell <code>(Get-Command ffmpeg).Source</code>CMD{' '}
<code>where ffmpeg</code> bin
</p>
</>
) : platform === 'darwin' ? (
<>
<strong>macOS </strong>
<p>
<code>brew install ffmpeg</code>
brew Homebrew {' '}
<code>which ffmpeg</code> {' '}
<code>/opt/homebrew/bin</code> <code>/usr/local/bin</code>
</p>
</>
) : (
<>
<strong>Linux </strong>
<p>
FFmpeg <code>which ffmpeg</code>{' '}
</p>
</>
)}
</div>
{error && (
<p className="image-decoder-requirement-error" role="alert">
{error}
</p>
)}
</div>
</section>
)
}
@@ -5,6 +5,7 @@ import { LocalPrivacyNotice } from '../account-database/LocalPrivacyNotice'
import { useAccountDatabaseController } from '../account-database/useAccountDatabaseController' import { useAccountDatabaseController } from '../account-database/useAccountDatabaseController'
import type { ConnectionOverviewStatus } from '../account-database/types' import type { ConnectionOverviewStatus } from '../account-database/types'
import type { SettingsSelfInfo } from '../model/types' import type { SettingsSelfInfo } from '../model/types'
import type { WechatAccountCandidate } from '../../../../../shared/database-key'
const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = { const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = {
checking: '正在检测', checking: '正在检测',
@@ -17,16 +18,28 @@ const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = {
export function AccountDatabasePage({ export function AccountDatabasePage({
dbKey, dbKey,
dbReady, dbReady,
dbConnecting = false,
selfInfo, selfInfo,
onNotice onNotice,
onSwitchAccount
}: { }: {
dbKey: string dbKey: string
dbReady: boolean dbReady: boolean
dbConnecting?: boolean
selfInfo: SettingsSelfInfo | null selfInfo: SettingsSelfInfo | null
onNotice: (message: string) => void onNotice: (message: string) => void
onSwitchAccount: (account: WechatAccountCandidate) => Promise<void>
}): React.ReactElement { }): React.ReactElement {
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice }) const controller = useAccountDatabaseController({
dbKey,
dbReady,
dbConnecting,
selfInfo,
onNotice
})
const [autoLogin, setAutoLogin] = useState(false) const [autoLogin, setAutoLogin] = useState(false)
const [switching, setSwitching] = useState(false)
const [accounts, setAccounts] = useState<WechatAccountCandidate[]>([])
useEffect(() => { useEffect(() => {
let active = true let active = true
@@ -48,6 +61,18 @@ export function AccountDatabasePage({
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接') onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
} }
const openAccountSwitcher = async (): Promise<void> => {
if (!selfInfo?.accountRoot) return
const parentRoot = selfInfo.accountRoot.replace(/[\\/][^\\/]+[\\/]?$/, '')
const result = await window.api.discoverAccounts(parentRoot)
if (!result.success) {
onNotice(result.error || '无法读取账号列表')
return
}
setAccounts(result.accounts)
setSwitching(true)
}
return ( return (
<div className="settings-page"> <div className="settings-page">
<header className="settings-page-header"> <header className="settings-page-header">
@@ -71,7 +96,45 @@ export function AccountDatabasePage({
onCheck={() => void controller.testConnection()} onCheck={() => void controller.testConnection()}
onOpenDirectory={() => void controller.openAccountDirectory()} onOpenDirectory={() => void controller.openAccountDirectory()}
onCopyDirectory={() => void controller.copyAccountDirectory()} onCopyDirectory={() => void controller.copyAccountDirectory()}
onSwitchAccount={() => void openAccountSwitcher()}
/> />
{switching && (
<section className="settings-card database-account-list" aria-label="切换微信账号">
<h2></h2>
{accounts.map((account) => (
<button
type="button"
key={account.id}
className="database-account-card"
disabled={account.accountRoot === selfInfo?.accountRoot}
onClick={() => void onSwitchAccount(account).then(() => setSwitching(false))}
>
<span className="database-account-avatar">
{account.avatar ? (
<img src={account.avatar} alt="" />
) : (
(account.nickname || '?').charAt(0)
)}
</span>
<span className="database-account-identity">
<strong>{account.nickname || '昵称未识别'}</strong>
<small>{account.wxid || 'wxid 未识别'}</small>
<code>{account.accountRoot}</code>
</span>
<span className="database-account-status">
{account.hasSavedDbKey ? '已有可用密钥' : '需要获取密钥'}
</span>
</button>
))}
<button
type="button"
className="api-secondary-button"
onClick={() => setSwitching(false)}
>
</button>
</section>
)}
<h2 className="settings-section-heading"></h2> <h2 className="settings-section-heading"></h2>
<ConnectionHealthSection <ConnectionHealthSection
diagnostics={controller.diagnostics} diagnostics={controller.diagnostics}
@@ -1,6 +1,7 @@
import type { SettingsSelfInfo } from '../model/types' import type { SettingsSelfInfo } from '../model/types'
import { AutoDetectImageKeySection } from '../image-decryption/AutoDetectImageKeySection' import { AutoDetectImageKeySection } from '../image-decryption/AutoDetectImageKeySection'
import { DangerZone } from '../image-decryption/DangerZone' import { DangerZone } from '../image-decryption/DangerZone'
import { ImageDecoderRequirementNotice } from '../image-decryption/ImageDecoderRequirementNotice'
import { ImageDecryptStatus } from '../image-decryption/ImageDecryptStatus' import { ImageDecryptStatus } from '../image-decryption/ImageDecryptStatus'
import { ImageKeyConfiguration } from '../image-decryption/ImageKeyConfiguration' import { ImageKeyConfiguration } from '../image-decryption/ImageKeyConfiguration'
import { ImageTestSection } from '../image-decryption/ImageTestSection' import { ImageTestSection } from '../image-decryption/ImageTestSection'
@@ -55,6 +56,11 @@ export function ImageDecryptionPage({
</div> </div>
</section> </section>
<ImageDecoderRequirementNotice
status={controller.state.status?.decoder}
onNotice={onNotice}
/>
<h2 className="settings-section-heading"></h2> <h2 className="settings-section-heading"></h2>
<ImageDecryptStatus <ImageDecryptStatus
state={controller.state} state={controller.state}
+283 -3
View File
@@ -1178,15 +1178,106 @@
} }
.database-login-workspace { .database-login-workspace {
box-sizing: border-box;
display: grid; display: grid;
place-items: center; align-items: start;
justify-items: center;
min-width: 0; min-width: 0;
padding: 48px clamp(40px, 7vw, 96px); min-height: 0;
overflow-y: auto;
padding: 28px clamp(40px, 7vw, 96px);
background: #fff; background: #fff;
} }
.database-login-panel { .database-login-panel {
width: min(520px, 100%); width: min(520px, 100%);
margin: auto 0;
}
.database-login-start {
margin-bottom: 14px;
h2 {
margin: 0;
color: var(--login-text);
font-size: 22px;
line-height: 29px;
letter-spacing: -0.02em;
}
> p:not(.database-login-eyebrow) {
margin: 7px 0 18px;
color: var(--login-muted);
font-size: 12px;
line-height: 18px;
}
ol {
display: grid;
gap: 9px;
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: flex-start;
gap: 10px;
color: #35423d;
> span {
width: 20px;
height: 20px;
flex: 0 0 20px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--login-primary-dark);
background: #e2f0eb;
font-size: 11px;
font-weight: 700;
}
strong,
small {
display: block;
}
strong {
font-size: 12px;
line-height: 17px;
}
small {
margin-top: 1px;
color: var(--login-muted);
font-size: 11px;
line-height: 16px;
}
}
}
.database-login-eyebrow {
margin: 0 0 5px;
color: var(--login-primary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.database-login-guide-link {
display: inline-block;
margin-top: 15px;
color: var(--login-primary);
font-size: 11px;
line-height: 17px;
text-decoration: none;
}
.database-login-guide-link:hover {
text-decoration: underline;
} }
.database-login-tabs { .database-login-tabs {
@@ -1194,7 +1285,7 @@
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 4px; gap: 4px;
padding: 4px; padding: 4px;
margin-bottom: 28px; margin-bottom: 18px;
border: 1px solid var(--login-border); border: 1px solid var(--login-border);
border-radius: 8px; border-radius: 8px;
background: #e9eeeb; background: #e9eeeb;
@@ -1217,6 +1308,16 @@
box-shadow: 0 1px 3px rgba(24, 28, 27, 0.08); box-shadow: 0 1px 3px rgba(24, 28, 27, 0.08);
} }
.database-login-tabs .database-login-manual-tab {
color: #7a8580;
font-size: 11px;
font-weight: 500;
}
.database-login-tabs .database-login-manual-tab.active {
color: #58645f;
}
.database-login-state-card { .database-login-state-card {
padding: 18px; padding: 18px;
border: 1px solid var(--login-border); border: 1px solid var(--login-border);
@@ -1309,6 +1410,58 @@
text-align: right; text-align: right;
} }
.database-login-guide-progress {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 5px;
margin: 0 0 12px;
}
.database-login-guide-progress span {
height: 4px;
border-radius: 2px;
background: #dce4df;
}
.database-login-guide-progress span.active {
background: var(--login-primary);
}
.database-login-guide-actions {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
}
.database-login-guide-actions button,
.database-login-text-action {
padding: 5px 0;
border: 0;
color: var(--login-primary-dark);
background: transparent;
cursor: pointer;
font-size: 11px;
}
.database-login-text-action {
display: block;
width: 100%;
margin-top: 10px;
text-align: center;
}
.database-login-path-select {
margin-top: 5px;
padding: 4px 8px;
border: 1px solid var(--login-border);
border-radius: 5px;
color: var(--login-primary-dark);
background: #fff;
cursor: pointer;
font-size: 11px;
}
.database-login-path-input-wrap { .database-login-path-input-wrap {
position: relative; position: relative;
display: block; display: block;
@@ -1396,6 +1549,33 @@
text-decoration: none; text-decoration: none;
} }
.database-login-platform-note {
margin: 12px 0 0;
color: #77817d;
font-size: 11px;
line-height: 17px;
text-align: center;
}
.database-login-platform-note a {
color: var(--login-primary);
text-decoration: none;
}
.database-login-platform-note a:hover {
text-decoration: underline;
}
.database-login-manual-note {
margin: 0 0 20px;
padding: 10px 12px;
border-radius: 7px;
color: #65706b;
background: var(--login-surface-low);
font-size: 11px;
line-height: 17px;
}
.database-login-manual { .database-login-manual {
padding-top: 2px; padding-top: 2px;
} }
@@ -1468,6 +1648,7 @@
} }
.database-login-field > input, .database-login-field > input,
.database-login-root-control,
.database-login-key-input { .database-login-key-input {
width: 100%; width: 100%;
min-height: 42px; min-height: 42px;
@@ -1476,6 +1657,37 @@
background: #fff; background: #fff;
} }
.database-login-root-control {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
overflow: hidden;
}
.database-login-root-control input {
min-width: 0;
padding: 0 12px;
border: 0;
outline: 0;
color: var(--login-text);
background: transparent;
font-size: 13px;
}
.database-login-root-control button {
padding: 0 12px;
border: 0;
border-left: 1px solid var(--login-border);
color: var(--login-primary-dark);
background: var(--login-surface-low);
cursor: pointer;
}
.database-login-root-control button:disabled,
.database-login-path-select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.database-login-field > input { .database-login-field > input {
padding: 0 12px; padding: 0 12px;
color: var(--login-text); color: var(--login-text);
@@ -1489,6 +1701,7 @@
} }
.database-login-key-input:focus-within, .database-login-key-input:focus-within,
.database-login-root-control:focus-within,
.database-login-field > input:focus { .database-login-field > input:focus {
border-color: var(--login-primary); border-color: var(--login-primary);
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1); box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
@@ -1841,3 +2054,70 @@
pointer-events: none; pointer-events: none;
} }
} }
.database-account-list {
display: grid;
gap: 10px;
margin: 14px 0;
h3 {
margin: 0;
font-size: 14px;
}
}
.database-account-card {
width: 100%;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px;
border: 1px solid var(--border-color, #d8e2dc);
border-radius: 8px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
&.selected {
border-color: #176b57;
box-shadow: 0 0 0 2px #176b5720;
}
}
.database-account-avatar {
width: 44px;
height: 44px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 6px;
background: #dcebe4;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.database-account-identity,
.database-account-status {
display: grid;
gap: 3px;
min-width: 0;
small,
code {
color: var(--text-secondary, #68766f);
}
code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.database-account-status {
text-align: right;
}
+58
View File
@@ -144,6 +144,56 @@ body {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.app-guide-launcher {
box-sizing: border-box;
width: 100%;
min-height: 52px;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
margin: 0 0 8px;
padding: 5px 0;
border: 0;
border-radius: var(--wxex-radius-md);
color: var(--wxex-text-secondary);
background: transparent;
cursor: pointer;
font-family: var(--wxex-font);
-webkit-app-region: no-drag;
&:hover {
color: var(--wxex-brand);
background: var(--wxex-brand-soft);
}
}
.app-guide-launcher-icon {
width: 22px;
height: 22px;
display: grid;
place-items: center;
svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.app-guide-launcher-label {
font-size: 11px;
line-height: 15px;
font-weight: 600;
white-space: nowrap;
}
.account-summary { .account-summary {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@@ -203,6 +253,10 @@ body {
&.ready { &.ready {
background: var(--wxex-success); background: var(--wxex-success);
} }
&.connecting {
background: var(--wxex-brand);
}
} }
.account-summary:not(.compact) .account-summary-avatar .account-summary-status { .account-summary:not(.compact) .account-summary-avatar .account-summary-status {
@@ -256,6 +310,10 @@ body {
&.ready { &.ready {
background: var(--wxex-success); background: var(--wxex-success);
} }
&.connecting {
background: var(--wxex-brand);
}
} }
.account-summary-settings { .account-summary-settings {
+91
View File
@@ -329,3 +329,94 @@
.voip-status { .voip-status {
font-size: 13px; font-size: 13px;
} }
.forward-bundle-message {
width: min(320px, 56vw);
overflow: hidden;
}
.forward-bundle-header,
.forward-bundle-toggle {
width: 100%;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
}
.forward-bundle-header {
display: grid;
gap: 3px;
padding: 0 0 9px;
border-bottom: 1px solid var(--wxex-border);
span {
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
color: var(--wxex-text-muted);
font-size: 11px;
}
}
.forward-bundle-list {
display: grid;
gap: 8px;
padding: 9px 0;
}
.forward-bundle-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 3px 6px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
b {
color: var(--wxex-text-primary);
font-weight: 600;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
grid-column: 2;
color: var(--wxex-text-muted);
}
}
.forward-bundle-empty,
.unsupported-message span {
color: var(--wxex-text-muted);
font-size: 12px;
}
.forward-bundle-toggle {
padding: 8px 0 0;
border-top: 1px solid var(--wxex-border);
color: var(--wxex-brand);
font-size: 12px;
text-align: center;
}
.unsupported-message {
display: grid;
min-width: 150px;
gap: 4px;
strong {
font-size: 13px;
font-weight: 600;
}
}
+164
View File
@@ -956,6 +956,170 @@
.image-decryption-content { .image-decryption-content {
padding-bottom: 48px; padding-bottom: 48px;
} }
.image-decoder-requirement {
display: flex;
gap: 14px;
align-items: flex-start;
margin-top: 14px;
padding: 18px;
border: 1px solid #ead2a8;
border-radius: 8px;
background: #fff9ee;
color: #4b4439;
}
.image-decoder-requirement.ready {
border-color: #bfddd2;
background: #f2f8f5;
}
.image-decoder-requirement-icon {
display: grid;
width: 22px;
height: 22px;
flex: 0 0 22px;
place-items: center;
border-radius: 50%;
background: #b87524;
color: #fff;
font-size: 13px;
font-weight: 700;
}
.image-decoder-requirement.ready .image-decoder-requirement-icon {
background: #247a63;
}
.image-decoder-requirement-body {
min-width: 0;
flex: 1;
}
.image-decoder-requirement-heading {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.image-decoder-requirement-heading strong {
color: #61451f;
font-size: 14px;
}
.image-decoder-requirement.ready .image-decoder-requirement-heading strong {
color: #294b41;
}
.image-decoder-requirement-heading span {
flex: 0 0 auto;
border-radius: 999px;
padding: 3px 8px;
background: rgba(184, 117, 36, 0.1);
color: #8b5d23;
font-size: 11px;
}
.image-decoder-requirement.ready .image-decoder-requirement-heading span {
background: #e1f0ea;
color: #247a63;
}
.image-decoder-requirement p {
margin: 7px 0 12px;
color: #6d665c;
font-size: 13px;
line-height: 1.6;
}
.image-decoder-requirement.ready p {
color: #5d6d66;
}
.image-decoder-checks {
margin: 2px 0 14px;
border-top: 1px solid rgba(139, 103, 47, 0.16);
}
.image-decoder-checks > div {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-height: 48px;
border-bottom: 1px solid rgba(139, 103, 47, 0.16);
}
.image-decoder-check-index {
display: grid;
width: 20px;
height: 20px;
place-items: center;
border-radius: 50%;
background: rgba(184, 117, 36, 0.12);
color: #8b5d23;
font-size: 11px;
font-weight: 700;
}
.image-decoder-check-index.complete {
background: #e1f0ea;
color: #247a63;
}
.image-decoder-checks strong,
.image-decoder-checks small {
display: block;
}
.image-decoder-checks strong {
color: #4f4a42;
font-size: 12px;
}
.image-decoder-checks small {
overflow: hidden;
margin-top: 2px;
color: #81796d;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.image-decoder-checks b {
color: #9a7750;
font-size: 11px;
font-weight: 600;
}
.image-decoder-checks b.success {
color: #247a63;
}
.image-decoder-requirement-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 9px;
}
.image-decoder-requirement-actions button {
min-height: 34px;
}
.image-decoder-requirement small {
display: block;
color: #81796d;
font-size: 11px;
line-height: 1.6;
}
.image-decoder-platform-help {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(139, 103, 47, 0.16);
}
.image-decoder-platform-help strong {
color: #5b5145;
font-size: 12px;
}
.image-decoder-platform-help p {
margin: 5px 0 0;
color: #71695f;
font-size: 12px;
line-height: 1.75;
}
.image-decoder-platform-help code {
border-radius: 4px;
padding: 1px 5px;
background: rgba(117, 82, 31, 0.1);
color: #68491f;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
overflow-wrap: anywhere;
}
.image-decoder-requirement .image-decoder-requirement-error {
margin: 10px 0 0;
color: #b34848;
font-size: 12px;
}
.image-decrypt-badge.unconfigured { .image-decrypt-badge.unconfigured {
background: #f1f3f2; background: #f1f3f2;
color: #66706b; color: #66706b;
+238
View File
@@ -137,6 +137,244 @@
transition: width 0.18s ease-out; transition: width 0.18s ease-out;
} }
/* 首次连接成功后的下一步引导 */
.first-use-welcome-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
place-items: center;
padding: 24px;
background: rgba(16, 27, 23, 0.42);
backdrop-filter: blur(4px);
animation: settings-fade-in 0.16s ease-out;
}
.first-use-welcome {
position: relative;
width: min(520px, 100%);
padding: 34px;
border: 1px solid rgba(203, 222, 214, 0.9);
border-radius: 18px;
background: #fff;
box-shadow: 0 24px 70px rgba(16, 35, 28, 0.24);
animation: settings-pop-in 0.18s ease-out;
}
.first-use-welcome-close {
position: absolute;
top: 14px;
right: 16px;
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
color: #7c8882;
background: transparent;
cursor: pointer;
font-size: 23px;
line-height: 1;
}
.first-use-welcome-close:hover {
color: #26342d;
background: #f0f4f1;
}
.first-use-welcome-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
margin-bottom: 18px;
border-radius: 13px;
color: #fff;
background: linear-gradient(135deg, #176b57, #42a27f);
box-shadow: 0 7px 16px rgba(23, 107, 87, 0.22);
font-size: 22px;
}
.first-use-welcome-eyebrow {
margin: 0 0 5px;
color: #247a63;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
.first-use-welcome h2 {
margin: 0;
color: #1f2c26;
font-size: 25px;
line-height: 34px;
letter-spacing: -0.025em;
}
.first-use-welcome-lead {
margin: 8px 0 24px;
color: #66756d;
font-size: 13px;
line-height: 21px;
}
.first-use-welcome-actions {
display: grid;
gap: 9px;
}
.first-use-welcome-feature {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
width: 100%;
padding: 17px 16px;
border: 1px solid #247a63;
border-radius: 12px;
color: #fff;
background: linear-gradient(135deg, #247a63, #176b57);
cursor: pointer;
text-align: left;
transition:
border-color 0.15s ease,
background 0.15s ease,
transform 0.15s ease;
&:hover {
border-color: #00604c;
background: linear-gradient(135deg, #176b57, #00604c);
transform: translateY(-1px);
}
}
.first-use-welcome-feature-icon {
width: 34px;
height: 34px;
flex: 0 0 34px;
display: grid;
place-items: center;
border-radius: 10px;
color: #176b57;
background: #fff;
font-size: 18px;
}
.first-use-welcome-feature-copy {
min-width: 0;
flex: 1;
}
.first-use-welcome-feature-arrow {
flex: 0 0 auto;
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.first-use-welcome-feature-copy strong,
.first-use-welcome-feature-copy small {
display: block;
}
.first-use-welcome-feature-copy strong {
font-size: 14px;
line-height: 20px;
}
.first-use-welcome-feature-copy small {
margin-top: 2px;
color: rgba(255, 255, 255, 0.76);
font-size: 11px;
line-height: 16px;
}
.first-use-welcome-secondary-actions {
display: flex;
gap: 22px;
margin: 16px 0 22px;
}
.first-use-welcome-secondary-actions button {
padding: 0;
border: 0;
color: #53645b;
background: transparent;
cursor: pointer;
font-size: 12px;
}
.first-use-welcome-secondary-actions button:hover {
color: #247a63;
text-decoration: underline;
}
.first-use-welcome-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 23px;
padding-top: 16px;
border-top: 1px solid #edf1ef;
span,
button,
a {
color: #68766f;
background: transparent;
font-size: 11px;
text-decoration: none;
}
button {
padding: 0;
border: 0;
cursor: pointer;
}
button:hover,
a:hover {
color: #247a63;
text-decoration: underline;
}
}
@media (max-width: 640px), (max-height: 560px) {
.first-use-welcome-overlay {
display: block;
overflow: auto;
padding: 18px;
}
.first-use-welcome {
width: auto;
margin: 0 auto;
padding: 26px 22px 22px;
}
.first-use-welcome h2 {
font-size: 22px;
line-height: 29px;
}
.first-use-welcome-feature {
align-items: flex-start;
flex-wrap: wrap;
gap: 10px;
}
.first-use-welcome-feature-arrow {
width: 100%;
margin-left: 44px;
}
.first-use-welcome-footer {
align-items: flex-start;
flex-direction: column;
gap: 9px;
}
}
/* 设置面板 */ /* 设置面板 */
.settings-overlay { .settings-overlay {
position: fixed; position: fixed;
+20
View File
@@ -0,0 +1,20 @@
import type { Message } from '../../../shared/types'
export const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
export const sortMessagesChronologically = (items: Message[]): Message[] =>
[...items].sort((left, right) => {
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
if (timeDelta !== 0) return timeDelta
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
})
export const mergeMessagePages = (older: Message[], current: Message[]): Message[] => {
const merged = new Map<string, Message>()
for (const message of [...older, ...current]) merged.set(getMessageIdentity(message), message)
return sortMessagesChronologically(Array.from(merged.values()))
}
+16
View File
@@ -0,0 +1,16 @@
import type { DatabaseKeyEnvironment } from './database-key'
export function buildSafeDiagnosticSummary(
environment: Omit<DatabaseKeyEnvironment, 'diagnosticSummary'>
): string {
return [
`WechatExplorer: ${environment.appVersion}`,
`操作系统: ${environment.osVersion}`,
`微信客户端: ${environment.wechatVersion}`,
`数据结构: ${environment.dataStructureVersion}`,
`数据目录: ${environment.dataDirectoryDetected ? '已检测到' : '未检测到'}`,
`微信进程: ${environment.wechatRunning ? '运行中' : '未运行'}`,
`数据库连接: ${environment.dbConnected ? '已连接' : '未连接'}`,
`安全存储: ${environment.encryptionAvailable ? '可用' : '不可用'}`
].join('\n')
}
+28
View File
@@ -24,8 +24,36 @@ export interface DatabaseKeyStorageResult {
encryptionAvailable: boolean encryptionAvailable: boolean
} }
export type AccountLoginStatus = 'current' | 'other' | 'unknown'
export interface WechatAccountCandidate {
id: string
accountRoot: string
directoryName: string
wxid?: string
nickname?: string
avatar?: string
hasSavedDbKey: boolean
loginStatus: AccountLoginStatus
selectedByInput: boolean
}
export interface AccountDiscoveryResult {
success: boolean
inputKind?: 'root' | 'account'
accounts: WechatAccountCandidate[]
preselectedAccountId?: string
error?: string
}
export interface DatabaseKeyEnvironment { export interface DatabaseKeyEnvironment {
platform: NodeJS.Platform platform: NodeJS.Platform
osVersion: string
appVersion: string
wechatVersion: string
dataStructureVersion: string
dataDirectoryDetected: boolean
diagnosticSummary: string
autoDetectSupported: boolean autoDetectSupported: boolean
wechatRunning: boolean wechatRunning: boolean
accountIdentified: boolean accountIdentified: boolean
+22
View File
@@ -0,0 +1,22 @@
import type { ExportRequest } from './export'
export type ImageExportAttempt = {
allowThumbnail: boolean
preferThumbnail: boolean
fallback: boolean
}
export function getImageExportAttempts(
request: Pick<ExportRequest, 'preferOriginal' | 'fallbackThumbnail'>
): ImageExportAttempt[] {
if (request.preferOriginal === false) {
return [{ allowThumbnail: true, preferThumbnail: true, fallback: false }]
}
const attempts: ImageExportAttempt[] = [
{ allowThumbnail: false, preferThumbnail: false, fallback: false }
]
if (request.fallbackThumbnail !== false) {
attempts.push({ allowThumbnail: true, preferThumbnail: true, fallback: true })
}
return attempts
}
+3
View File
@@ -23,6 +23,9 @@ export interface ExportRequest {
endTime?: number endTime?: number
kinds: ExportMessageKind[] kinds: ExportMessageKind[]
includeMedia: boolean includeMedia: boolean
preferOriginal?: boolean
fallbackThumbnail?: boolean
keepMissing?: boolean
includeAvatars?: boolean includeAvatars?: boolean
avatarUrls?: Record<string, string> avatarUrls?: Record<string, string>
nameMode?: ExportNameMode nameMode?: ExportNameMode
+17
View File
@@ -1,5 +1,21 @@
export type ImageKeySource = 'secure-storage' | 'legacy-settings' | 'environment' | 'none' export type ImageKeySource = 'secure-storage' | 'legacy-settings' | 'environment' | 'none'
export type ImageResourceState = 'available' | 'unavailable' | 'unknown' export type ImageResourceState = 'available' | 'unavailable' | 'unknown'
export type ImageDecoderSource = 'selected' | 'environment' | 'bundled' | 'system' | 'none'
export interface ImageDecoderStatus {
installed: boolean
available: boolean
source: ImageDecoderSource
selected: boolean
directory?: string
}
export interface ImageDecoderSelectionResult {
success: boolean
canceled: boolean
status?: ImageDecoderStatus
error?: string
}
export interface ImageKeyConfigResult { export interface ImageKeyConfigResult {
success: boolean success: boolean
@@ -33,6 +49,7 @@ export interface ImageDecryptionStatus {
wechatRunning: boolean wechatRunning: boolean
accountIdentified: boolean accountIdentified: boolean
cacheState: 'normal' | 'unavailable' cacheState: 'normal' | 'unavailable'
decoder: ImageDecoderStatus
resources: { resources: {
imageIndex: ImageResourceCheck imageIndex: ImageResourceCheck
imageDirectory: ImageResourceCheck imageDirectory: ImageResourceCheck
+55
View File
@@ -0,0 +1,55 @@
export type StickerFailureCode =
| 'link_expired'
| 'authentication_required'
| 'access_denied'
| 'resource_removed'
| 'rate_limited'
| 'http_error'
export interface StickerHttpFailure {
code: StickerFailureCode
message: string
}
export function classifyStickerHttpFailure(
statusCode: number,
url: string,
now = Date.now()
): StickerHttpFailure {
if (statusCode === 401) {
return { code: 'authentication_required', message: '表情链接需要微信授权' }
}
if (statusCode === 403) {
const expiresAt = readExpiryTimestamp(url)
if (expiresAt !== undefined && expiresAt <= now) {
return { code: 'link_expired', message: '表情链接已过期' }
}
return { code: 'access_denied', message: '表情链接已失效或需要微信授权' }
}
if (statusCode === 404 || statusCode === 410) {
return { code: 'resource_removed', message: '表情资源已删除或失效' }
}
if (statusCode === 429) {
return { code: 'rate_limited', message: '表情下载请求过于频繁' }
}
return { code: 'http_error', message: `表情包下载失败: HTTP ${statusCode}` }
}
function readExpiryTimestamp(value: string): number | undefined {
try {
const url = new URL(value)
for (const key of ['expire', 'expires', 'expiry', 'deadline']) {
const raw = url.searchParams.get(key)
if (!raw) continue
const numeric = Number(raw)
if (Number.isFinite(numeric) && numeric > 0) {
return numeric > 10_000_000_000 ? numeric : numeric * 1000
}
const parsed = Date.parse(raw)
if (Number.isFinite(parsed)) return parsed
}
} catch {
// Invalid URLs have no trustworthy expiry metadata.
}
return undefined
}
+17 -1
View File
@@ -6,6 +6,8 @@ export interface Contact {
avatar?: string avatar?: string
wechatNickname?: string wechatNickname?: string
remark?: string remark?: string
isFolded?: boolean
isMuted?: boolean
} }
export interface Message { export interface Message {
@@ -53,6 +55,19 @@ type ShareContent = {
appname?: string appname?: string
typeVal?: string typeVal?: string
} }
export type ForwardedMessageItem = {
messageType: number
sender?: string
sentAt?: string
text: string
nested?: ForwardedMessageItem[]
}
type ForwardBundleContent = {
type: 'forwardBundle'
title: string
description?: string
items: ForwardedMessageItem[]
}
type MiniProgramContent = { type MiniProgramContent = {
type: 'miniProgram' type: 'miniProgram'
title: string title: string
@@ -119,7 +134,7 @@ type SystemContent = {
recallTime?: number recallTime?: number
} }
} }
type UnknownContent = { type: 'unknown'; raw: string } type UnknownContent = { type: 'unknown'; raw: string; messageType?: string | number }
export type ParsedContent = export type ParsedContent =
| TextContent | TextContent
@@ -127,6 +142,7 @@ export type ParsedContent =
| LocationContent | LocationContent
| CardContent | CardContent
| ShareContent | ShareContent
| ForwardBundleContent
| MiniProgramContent | MiniProgramContent
| RedPacketContent | RedPacketContent
| VoipContent | VoipContent
@@ -0,0 +1,136 @@
import { render, screen, type RenderResult } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ComponentProps } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { DatabaseConnectionPage } from '../../src/renderer/src/components/DatabaseConnectionPage'
function renderPage(
overrides: Partial<ComponentProps<typeof DatabaseConnectionPage>> = {}
): RenderResult & { props: ComponentProps<typeof DatabaseConnectionPage> } {
const props = {
platform: 'win32',
mode: 'manual' as const,
dbKey: '',
dbRoot: '',
showDbKey: false,
isFetching: false,
isConnecting: false,
guideStep: 1 as const,
environment: {
platform: 'win32',
osVersion: 'Windows fixture',
appVersion: 'v2.1.6',
wechatVersion: '4.1.9.57',
dataStructureVersion: '微信 4.xWCDB',
dataDirectoryDetected: true,
diagnosticSummary: 'WechatExplorer: v2.1.6',
autoDetectSupported: true,
wechatRunning: true,
accountIdentified: false,
dbConnected: false,
encryptionAvailable: true
},
accounts: [
{
id: 'account-a',
accountRoot: 'C:\\fixture\\account-a',
directoryName: 'account-a',
nickname: '脱敏账号 A',
wxid: 'wxid_fixture_a',
hasSavedDbKey: true,
loginStatus: 'unknown' as const,
selectedByInput: true
}
],
selectedAccountId: 'account-a',
status: '',
statusKind: 'normal' as const,
showMacKeyFaq: false,
macKeyFaqUrl: 'https://fixture.invalid/mac',
onModeChange: vi.fn(),
onDbKeyChange: vi.fn(),
onDbRootChange: vi.fn(),
onSelectAccount: vi.fn(),
onSelectDbRoot: vi.fn(),
onToggleDbKey: vi.fn(),
onAutoGetKey: vi.fn(),
onRefreshEnvironment: vi.fn(),
onGuideNext: vi.fn(),
onGuideBack: vi.fn(),
onGuideCancel: vi.fn(),
onValidateConnection: vi.fn(),
onCopyDiagnostics: vi.fn(),
onManualConnect: vi.fn(),
onPasteKey: vi.fn(),
onClearKey: vi.fn(),
...overrides
}
return { props, ...render(<DatabaseConnectionPage {...props} />) }
}
describe('DatabaseConnectionPage', () => {
it('keeps connect disabled until a valid 64-character key is supplied', () => {
const { rerender, props } = renderPage()
expect(screen.getByRole('button', { name: '连接数据库' })).toBeDisabled()
rerender(<DatabaseConnectionPage {...props} dbKey={'a'.repeat(64)} />)
expect(screen.getByRole('button', { name: '连接数据库' })).toBeEnabled()
})
it('shows a recoverable error and keeps form actions available', async () => {
const onManualConnect = vi.fn()
renderPage({
dbKey: 'b'.repeat(64),
status: '数据库密钥无效,请重新输入',
statusKind: 'error',
onManualConnect
})
expect(screen.getByText('数据库密钥无效,请重新输入')).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: '连接数据库' }))
expect(onManualConnect).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: '从剪贴板粘贴并安全保存' })).toBeEnabled()
})
it('restores directory editing and selection after a failed connection', async () => {
const onDbRootChange = vi.fn()
const onSelectDbRoot = vi.fn()
renderPage({
dbKey: 'b'.repeat(64),
dbRoot: 'Z:\\missing-wechat-data',
status: '微信数据目录不存在,请重新选择目录',
statusKind: 'error',
onDbRootChange,
onSelectDbRoot
})
await userEvent.clear(screen.getByLabelText('微信数据目录'))
await userEvent.type(screen.getByLabelText('微信数据目录'), 'C:\\fixture-account')
await userEvent.click(screen.getByRole('button', { name: '选择目录' }))
expect(onDbRootChange).toHaveBeenCalled()
expect(onSelectDbRoot).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: '连接数据库' })).toBeEnabled()
})
it('supports forward, back, cancel and safe diagnostic actions in onboarding', async () => {
const onGuideNext = vi.fn()
const onCopyDiagnostics = vi.fn()
const { rerender, props } = renderPage({
mode: 'automatic',
guideStep: 1,
onGuideNext,
onCopyDiagnostics
})
expect(screen.getByText('4.1.9.57')).toBeVisible()
expect(screen.getByText('微信 4.xWCDB')).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: '复制脱敏诊断摘要' }))
await userEvent.click(screen.getByRole('button', { name: '检查完成,继续' }))
expect(onCopyDiagnostics).toHaveBeenCalledOnce()
expect(onGuideNext).toHaveBeenCalledOnce()
rerender(<DatabaseConnectionPage {...props} mode="automatic" guideStep={2} />)
expect(screen.getByRole('button', { name: '我已准备好' })).toBeEnabled()
expect(screen.getByRole('button', { name: '返回上一步' })).toBeEnabled()
expect(screen.getByRole('button', { name: '取消并重新检查' })).toBeEnabled()
})
})
+18
View File
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { EmptyConversationState } from '../../src/renderer/src/components/chat/EmptyConversationState'
import { SettingsEmptyState } from '../../src/renderer/src/features/settings/components/SettingsEmptyState'
describe('empty states', () => {
it('explains how to leave an empty archive without pretending data failed', () => {
render(<EmptyConversationState />)
expect(screen.getByRole('heading', { name: '选择一条消息' })).toBeVisible()
expect(screen.getByText('从左侧选择群聊或联系人以浏览历史记录')).toBeVisible()
})
it('labels an unavailable settings section explicitly', () => {
render(<SettingsEmptyState label="测试设置" />)
expect(screen.getByRole('heading', { name: '测试设置' })).toBeVisible()
expect(screen.getByText('该设置将在后续阶段接入。')).toBeVisible()
})
})
+53
View File
@@ -0,0 +1,53 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const requestImage = vi.fn()
vi.mock('../../src/renderer/src/components/image-loader', () => ({
getCachedLoadedImage: vi.fn(() => undefined),
requestImage: (...args: unknown[]) => requestImage(...args)
}))
import { ImageBubble } from '../../src/renderer/src/components/ImageBubble'
const thumbnail =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
const original = `${thumbnail}original`
describe('ImageBubble', () => {
beforeEach(() => {
requestImage.mockReset()
window.api = { copyImage: vi.fn().mockResolvedValue({ success: true }) } as typeof window.api
})
it('loads a thumbnail lazily, then requests the original when opened', async () => {
requestImage
.mockResolvedValueOnce({ data: thumbnail, isThumbnail: true })
.mockResolvedValueOnce({ data: original, isThumbnail: false })
const onImageClick = vi.fn()
render(
<ImageBubble
imageMd5="fixture-image"
imageDatName="fixture.dat"
sessionId="fixture-session"
onImageClick={onImageClick}
/>
)
const image = await screen.findByAltText('图片')
expect(image).toHaveAttribute('src', thumbnail)
await userEvent.click(image)
await waitFor(() => expect(onImageClick).toHaveBeenCalledWith(original))
expect(requestImage.mock.calls[1][3]).toMatchObject({ force: true })
})
it('shows an explicit error and allows retry', async () => {
requestImage.mockRejectedValueOnce(new Error('不支持的 DAT 版本'))
render(<ImageBubble imageMd5="unsupported" />)
expect(await screen.findByText('不支持的 DAT 版本')).toBeVisible()
requestImage.mockResolvedValueOnce({ data: thumbnail, isThumbnail: true })
await userEvent.click(screen.getByText('加载失败'))
expect(await screen.findByAltText('图片')).toBeVisible()
})
})
+21
View File
@@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { PrimaryNavigation } from '../../src/renderer/src/components/layout/PrimaryNavigation'
import { PRIMARY_NAV_ITEMS } from '../../src/renderer/src/components/layout/navigation'
describe('PrimaryNavigation', () => {
it('shows every real top-level page exactly once and emits the selected page', async () => {
const onPageChange = vi.fn()
render(<PrimaryNavigation activePage="archive" onPageChange={onPageChange} />)
const navigation = screen.getByRole('navigation', { name: '一级导航' })
expect(navigation).toBeInTheDocument()
for (const item of PRIMARY_NAV_ITEMS) {
expect(screen.getAllByRole('button', { name: item.label })).toHaveLength(1)
}
await userEvent.click(screen.getByRole('button', { name: '设置' }))
expect(onPageChange).toHaveBeenCalledWith('settings')
})
})
+47
View File
@@ -0,0 +1,47 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { VoicePlayer } from '../../src/renderer/src/components/VoicePlayer'
const play = vi.fn(() => Promise.resolve())
const pause = vi.fn()
class FakeAudio {
preload = ''
src = ''
duration = 1
currentTime = 0
onloadedmetadata: (() => void) | null = null
ontimeupdate: (() => void) | null = null
onended: (() => void) | null = null
play = play
pause = pause
load = vi.fn()
removeAttribute = vi.fn()
}
describe('VoicePlayer', () => {
beforeEach(() => {
play.mockClear()
pause.mockClear()
vi.stubGlobal('Audio', FakeAudio)
window.api = {
getVoiceData: vi.fn().mockResolvedValue({
success: true,
data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
})
} as typeof window.api
})
it('waits for decrypted bytes and calls play on the first click', async () => {
const { container } = render(
<VoicePlayer sessionId="filehelper" localId={11} createTime={1785553200} duration={1} />
)
await userEvent.click(container.querySelector('.voice-message') as HTMLElement)
await waitFor(() => expect(window.api.getVoiceData).toHaveBeenCalledOnce())
await waitFor(() => expect(play).toHaveBeenCalledOnce())
expect(container.querySelector('.voice-icon')).toHaveClass('playing')
expect(screen.queryByText('当前版本暂不支持播放')).not.toBeInTheDocument()
})
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+326
View File
@@ -0,0 +1,326 @@
import { expect, test } from '@playwright/test'
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'fs'
import { tmpdir } from 'os'
import { resolve } from 'path'
import { launchTestApp } from './support/electron'
test('APP-01 first launch renders a usable connection screen without uncaught errors', async () => {
const fixture = await launchTestApp({ mode: 'disconnected' })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await expect(fixture.page.getByRole('heading', { name: 'WechatExplorer' })).toBeVisible()
await expect(fixture.page.getByRole('main')).not.toBeEmpty()
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('KEY-01 KEY-02 invalid key remains recoverable and valid key enters the app', async () => {
const fixture = await launchTestApp({ mode: 'disconnected' })
try {
await fixture.page.getByRole('tab', { name: /高级用户/ }).click()
const keyInput = fixture.page.getByLabel('数据库密钥')
await keyInput.fill('b'.repeat(64))
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
await expect(fixture.page.getByText('数据库密钥无效')).toBeVisible()
await expect(keyInput).toBeVisible()
await keyInput.fill('a'.repeat(64))
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
} finally {
await fixture.close()
}
})
test('P0-01 an invalid directory can be corrected and retried without restarting', async () => {
test.skip(process.platform !== 'win32', 'Manual database directory editing is Windows-only')
const fixture = await launchTestApp({ mode: 'disconnected' })
try {
await fixture.page.getByRole('tab', { name: /高级用户/ }).click()
await fixture.page.getByLabel('数据库密钥').fill('a'.repeat(64))
await fixture.page.getByLabel('微信数据目录').fill('Z:\\missing-wechat-data')
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
await expect(fixture.page.getByText('微信数据目录不存在,请重新选择目录')).toBeVisible()
await expect(fixture.page.getByLabel('微信数据目录')).toBeEditable()
await expect(fixture.page.getByRole('button', { name: '选择目录' })).toBeEnabled()
await fixture.page.getByRole('button', { name: '选择目录' }).click()
await expect(fixture.page.getByLabel('微信数据目录')).toHaveValue('fixture-account')
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
} finally {
await fixture.close()
}
})
test('P2-01 P2-02 guided connection exposes safe diagnostics and completes all stages', async () => {
const fixture = await launchTestApp({ mode: 'disconnected' })
try {
await expect(fixture.page.getByText('4.1.9.57')).toBeVisible()
await expect(fixture.page.getByText('微信 4.xWCDB')).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '复制脱敏诊断摘要' })).toBeEnabled()
await fixture.page.getByRole('button', { name: '检查完成,继续' }).click()
await fixture.page.getByRole('button', { name: '我已准备好' }).click()
await fixture.page.getByRole('button', { name: '开始准备连接组件' }).click()
await expect(fixture.page.getByRole('button', { name: '微信已登录,验证连接' })).toBeEnabled()
await fixture.page.getByRole('button', { name: '微信已登录,验证连接' }).click()
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
} finally {
await fixture.close()
}
})
test('KEY-03 changing one key does not invalidate archive data or unrelated settings', async () => {
const fixture = await launchTestApp()
try {
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
const result = await fixture.page.evaluate(async () => {
const before = await window.api.getContacts()
const image = await window.api.saveImageKeyConfig({
resourceRoot: 'fixture-account',
xorKey: '0x41',
aesKey: 'fedcba9876543210'
})
const after = await window.api.getContacts()
const database = await window.api.getSavedDbKey()
return { before, after, image, database }
})
expect(result.image.success).toBe(true)
expect(result.before).toEqual(result.after)
expect(result.database.saved).toBe(true)
} finally {
await fixture.close()
}
})
test('NAV-01 NAV-02 every top-level page is unique and switchable', async () => {
const fixture = await launchTestApp()
const labels = ['档案', '问问微信', '日报', 'Agent', '导出', 'API', '设置']
try {
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
await expect(navigation).toBeVisible()
for (const label of labels) {
await expect(navigation.getByRole('button', { name: label })).toHaveCount(1)
await navigation.getByRole('button', { name: label }).click()
await expect(fixture.page.locator(`main.app-shell-main[aria-label="${label}"]`)).toBeVisible()
}
} finally {
await fixture.close()
}
})
test('ARCH-01 ARCH-02 folded chats and supported message types are represented explicitly', async () => {
const fixture = await launchTestApp()
try {
await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible()
await fixture.page.getByText('产品测试群', { exact: true }).click()
await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible()
await expect(fixture.page.getByText('暂不支持此消息', { exact: true })).toBeVisible()
await expect(fixture.page.getByAltText('图片')).toBeVisible()
await fixture.page.locator('.image-bubble.image-loaded').click()
await expect(fixture.page.getByText('图片查看', { exact: true })).toBeVisible()
await fixture.page.locator('.image-viewer-overlay').click({ position: { x: 5, y: 5 } })
await fixture.page.getByRole('button', { name: '折叠群聊 (1)' }).click()
await expect(fixture.page.getByText('折叠群聊样本', { exact: true })).toBeVisible()
} finally {
await fixture.close()
}
})
test('MEDIA-01 and merged forwards work on the first interaction', async () => {
const fixture = await launchTestApp()
try {
await fixture.page.getByRole('button', { name: '联系人 (1)' }).click()
await fixture.page.getByText('文件传输助手', { exact: true }).click()
await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible()
await fixture.page.evaluate(() => {
Object.defineProperty(window, '__wxePlayCount', {
configurable: true,
value: 0,
writable: true
})
HTMLMediaElement.prototype.play = async function () {
;(window as Window & { __wxePlayCount: number }).__wxePlayCount += 1
}
HTMLMediaElement.prototype.pause = function () {
return undefined
}
HTMLMediaElement.prototype.load = function () {
return undefined
}
})
await fixture.page.locator('.voice-message').click()
await expect(fixture.page.locator('.voice-icon')).toHaveClass(/playing/)
expect(
await fixture.page.evaluate(
() => (window as Window & { __wxePlayCount: number }).__wxePlayCount
)
).toBe(1)
} finally {
await fixture.close()
}
})
test('MEDIA-02 MEDIA-04 return accurate unsupported and HTTP 403 reasons', async () => {
const fixture = await launchTestApp()
try {
const result = await fixture.page.evaluate(async () => ({
image: await window.api.getImage('unsupported'),
sticker: await window.api.getSticker(
'https://fixture.invalid/403?token=secret',
'b'.repeat(32)
)
}))
expect(result.image).toMatchObject({ success: false, error: '不支持的 DAT 版本' })
expect(result.sticker).toMatchObject({
success: false,
failureCode: 'access_denied',
httpStatus: 403
})
} finally {
await fixture.close()
}
})
test('ASK-01 uses the local fixed AI service and keeps evidence in the UI', async () => {
const fixture = await launchTestApp()
try {
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试群讨论了什么?')
await fixture.page.getByRole('button', { name: '开始分析' }).click()
await expect(fixture.page.getByText(/固定假回答:测试数据中的核心流程正常/)).toBeVisible({
timeout: 15_000
})
} finally {
await fixture.close()
}
})
test('ASK-02 AI failures are recoverable and do not break the archive', async () => {
const fixture = await launchTestApp({ aiFailure: '429' })
try {
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试')
await fixture.page.getByRole('button', { name: '开始分析' }).click()
await expect(fixture.page.getByText(/本地假服务错误 429/)).toBeVisible()
await fixture.page.getByRole('button', { name: '档案' }).click()
await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible()
} finally {
await fixture.close()
}
})
test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets', async () => {
const fixture = await launchTestApp()
try {
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
await expect(generate).toBeEnabled()
await generate.click()
await expect(fixture.page.getByAltText('产品测试群 群聊日报')).toBeVisible({
timeout: 15_000
})
const exported = await fixture.page.evaluate(async () =>
window.api.exportGroupReport({
report: {} as never,
metadata: {} as never,
templateId: 'v1'
})
)
expect(exported.success).toBe(true)
expect(exported.imageDataUrl).toMatch(/^data:image\/png;base64,/)
expect(existsSync(exported.htmlPath!)).toBe(true)
expect(existsSync(exported.pngPath!)).toBe(true)
expect(statSync(exported.pngPath!).size).toBeGreaterThan(20)
} finally {
await fixture.close()
}
})
test('REPORT-03 report failure is retryable and leaves other pages usable', async () => {
const fixture = await launchTestApp({ aiFailure: '401' })
try {
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled()
await fixture.page.getByRole('button', { name: '档案' }).click()
await expect(fixture.page.locator('main.app-shell-main[aria-label="档案"]')).toBeVisible()
await expect(
fixture.page.locator('.conversation-item-name').filter({ hasText: '产品测试群' }).first()
).toBeVisible()
} finally {
await fixture.close()
}
})
test('CACHE-01 corrupt startup cache degrades to native fixture data', async () => {
const fixture = await launchTestApp({ corruptCache: true })
try {
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible()
} finally {
await fixture.close()
}
})
test('PERF-01 repeated startup with 1500 sessions remains bounded and responsive', async () => {
test.setTimeout(60_000)
const userData = mkdtempSync(resolve(tmpdir(), 'wxe-e2e-perf-'))
try {
for (let run = 0; run < 2; run += 1) {
const startedAt = Date.now()
const fixture = await launchTestApp({ userData, largeContacts: 1500 })
try {
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible({
timeout: 10_000
})
expect(Date.now() - startedAt).toBeLessThan(10_000)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await expect(fixture.page.locator('main.app-shell-main[aria-label="设置"]')).toBeVisible()
} finally {
await fixture.close()
}
}
} finally {
rmSync(userData, { recursive: true, force: true })
}
})
test('KEY-04 e2e diagnostic log does not contain a supplied key', async () => {
const fixture = await launchTestApp()
const key = 'c'.repeat(64)
try {
await fixture.page.evaluate(
(databaseKey) =>
window.api.writeAppLog({
level: 'error',
scope: 'key-test',
message: `fixture key=${databaseKey}`
}),
key
)
const logPath = resolve(fixture.userData, 'logs/e2e.log')
const content = readFileSync(logPath, 'utf8')
expect(content).not.toContain(key)
} finally {
await fixture.close()
}
})
+435
View File
@@ -0,0 +1,435 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { app, BrowserWindow, ipcMain } = require('electron')
const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '../../..')
const fixture = require(path.join(root, 'tests/fixtures/chat-data.json'))
const userData = process.env.WXE_E2E_USER_DATA
if (!userData) throw new Error('WXE_E2E_USER_DATA is required')
app.setPath('userData', userData)
app.setPath('logs', path.join(userData, 'logs'))
app.commandLine.appendSwitch('disable-gpu')
const VALID_KEY = 'a'.repeat(64)
const imageData =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
const reportJson = JSON.stringify({
overview: '固定脱敏日报',
hero: {
headline: '产品测试群日报',
summary: '测试消息已完成自动整理。',
keyTakeaway: '核心流程可用',
pendingNote: '',
statusLine: '今日形成 1 个结论'
},
topics: [
{
title: '自动化测试',
timeRange: '10:00-10:02',
heat: '中',
participants: ['测试成员'],
summary: '讨论了脱敏自动化测试。',
conclusions: [{ text: '核心流程可用', sourceMessageIds: ['msg-text'] }],
keywords: ['测试'],
sourceMessageIds: ['msg-text']
}
],
resources: [],
importantMessages: [],
quotes: [],
qa: [],
todos: [],
unresolved: [],
storylines: [],
reversals: [],
participantChains: [],
keywords: ['测试']
})
let connected = process.env.WXE_E2E_MODE !== 'disconnected'
let savedKey = connected ? VALID_KEY : ''
let settings = {
dbRoot: 'fixture-account',
apiEnabled: false,
apiHost: '127.0.0.1',
apiPort: 5031,
imageKeyRoot: 'fixture-account',
ffmpegPath: '',
recallProtectionEnabled: false,
debugEnabled: false,
autoLogin: connected,
autoLoginPreferenceSet: true,
appearanceTheme: 'light',
compactMode: false,
showStartupProgress: false,
imageXorKey: '0x40',
imageAesKey: '0123456789abcdef'
}
const extraContacts = Number(process.env.WXE_E2E_LARGE_CONTACTS || 0)
const contacts = [...fixture.contacts]
for (let index = 0; index < extraContacts; index += 1) {
contacts.push({
m_nsUsrName: `fixture_${index}`,
m_nsNickName: `性能样本 ${index}`,
md5: `fixture-contact-${index}`,
type: index % 5 === 0 ? 'group' : 'user'
})
}
const handlers = new Map()
const handle = (channel, fn) => {
handlers.set(channel, fn)
ipcMain.handle(channel, async (event, ...args) => fn(...args))
}
const startupCache = () => ({
self: fixture.self,
contacts,
updatedAt: 1785553200000
})
handle('settings:get', () => ({ settings, settingsPath: path.join(userData, 'settings.json') }))
handle('settings:set', (patch) => {
settings = { ...settings, ...patch }
return { settings, settingsPath: path.join(userData, 'settings.json') }
})
handle('key:getSavedDbKey', () => ({
success: true,
key: savedKey || undefined,
saved: Boolean(savedKey),
encryptionAvailable: true
}))
handle('key:saveDbKey', (_accountRoot, key) => {
savedKey = String(key || '')
return { success: true, key: savedKey, saved: true, encryptionAvailable: true }
})
handle('key:clearSavedDbKey', () => {
savedKey = ''
return { success: true }
})
handle('key:getEnvironment', () => ({
platform: process.platform,
osVersion: process.platform === 'win32' ? 'Windows fixture' : 'macOS fixture',
appVersion: 'v2.1.6',
wechatVersion: '4.1.9.57',
dataStructureVersion: settings.dbRoot === 'fixture-account' ? '微信 4.xWCDB' : '未检测到',
dataDirectoryDetected: settings.dbRoot === 'fixture-account',
diagnosticSummary: 'WechatExplorer: v2.1.6\n数据目录: 已检测到',
autoDetectSupported: true,
wechatRunning: true,
accountIdentified: connected,
dbConnected: connected,
encryptionAvailable: true
}))
handle('key:readClipboardDbKey', () => ({ success: true, value: VALID_KEY }))
handle('key:pasteAndSaveDbKey', () => ({ success: true, key: VALID_KEY }))
handle('key:autoGetDbKey', () => ({ success: true, key: VALID_KEY, saved: false }))
handle('key:autoGetImageKey', () => ({
success: true,
xorKey: 64,
aesKey: '0123456789abcdef',
verified: true
}))
handle('db:init', (key, accountRoot) => {
if (settings.dbRoot === 'Z:\\missing-wechat-data') {
connected = false
return {
success: false,
code: 'ROOT_UNAVAILABLE',
error: '微信数据目录不存在,请重新选择目录',
monitoring: false
}
}
if (key !== VALID_KEY) {
connected = false
return { success: false, error: '数据库密钥无效', monitoring: false }
}
connected = true
settings.dbRoot = accountRoot || settings.dbRoot
return { success: true, monitoring: true }
})
handle('db:testConnection', (key) =>
key === VALID_KEY
? { success: true, wxid: fixture.self.wxid, accountRoot: fixture.self.accountRoot }
: { success: false, code: 'DATABASE_OPEN_FAILED', error: '数据库密钥无效' }
)
handle('db:disconnect', () => {
connected = false
return { success: true }
})
handle('db:getStartupCache', () =>
process.env.WXE_E2E_CORRUPT_CACHE === '1' ? null : startupCache()
)
handle('db:getBootstrapCache', () =>
process.env.WXE_E2E_CORRUPT_CACHE === '1' ? null : startupCache()
)
handle('db:getContacts', (filter) => {
const query = String(filter || '').toLowerCase()
return query
? contacts.filter((contact) => contact.m_nsNickName.toLowerCase().includes(query))
: contacts
})
handle('db:getContactAvatars', (usernames) =>
Object.fromEntries(
contacts
.filter((contact) => usernames.includes(contact.m_nsUsrName) && contact.avatar)
.map((contact) => [contact.m_nsUsrName, contact.avatar])
)
)
handle('settings:getSelf', () => ({ ready: true, info: fixture.self }))
handle('db:getCachedMessages', (md5) => fixture.messages[md5] || [])
handle('db:getCachedMessagePage', (md5) => ({
hit: true,
messages: fixture.messages[md5] || [],
groupSnapshot: null
}))
handle('db:getMessages', (md5, startTime, endTime, options) => {
let messages = fixture.messages[md5] || []
if (startTime) messages = messages.filter((message) => (message.createTime || 0) >= startTime)
if (endTime) messages = messages.filter((message) => (message.createTime || 0) <= endTime)
if (options && options.limit) messages = messages.slice(-options.limit)
return messages
})
handle('db:getGroupSnapshot', (md5) =>
md5.startsWith('group-')
? {
roomId: md5,
memberCount: 1,
members: [
{
wxid: 'wxid_fixture_member',
nickname: '测试成员',
groupNickname: '测试成员',
wechatNickname: '测试成员',
remark: '',
avatar: ''
}
]
}
: null
)
handle('db:getImage', (md5, datName, sessionId, options) =>
md5 === 'unsupported'
? { success: false, error: '不支持的 DAT 版本' }
: {
success: true,
data: imageData,
isThumb: !options?.force,
filePath: path.join(userData, options?.force ? 'original.png' : 'thumbnail.png')
}
)
handle('db:getVoiceData', () => ({ success: true, data: voiceData }))
handle('db:getSticker', (url) =>
String(url || '').includes('403')
? {
success: false,
error: '表情链接已失效或需要微信授权',
failureCode: 'access_denied',
httpStatus: 403
}
: { success: true, data: imageData }
)
handle('db:parseMessage', (content, messageType) =>
messageType === 1
? { type: 'text', content: String(content) }
: { type: 'unknown', raw: String(content), messageType }
)
handle('ai:getRuntimeConfig', () => ({
providerId: 'fixture-provider',
providerName: '本地假服务',
model: 'fixture-model',
modelName: '固定响应模型',
configured: true,
status: 'connected',
timeoutMs: 5000
}))
handle('ai:listProviders', () => ({
success: true,
providers: [],
defaultProviderId: 'fixture-provider'
}))
handle('ai:migrateLegacy', () => ({ success: true, providers: [] }))
handle('ai:chat', (messages) => {
const failure = process.env.WXE_E2E_AI_FAILURE
if (failure) return { success: false, error: `本地假服务错误 ${failure}` }
const system = String(messages?.[0]?.content || '')
if (system.includes('本地聊天检索规划器')) {
return {
success: true,
data: '{"intent":"general","keywords":["测试"],"variants":[]}'
}
}
if (system.includes('微信群聊日报编辑') || system.includes('JSON 格式修复器')) {
return { success: true, data: reportJson, usage: { input: 10, output: 20, total: 30 } }
}
return { success: true, data: '固定假回答:测试数据中的核心流程正常。' }
})
handle('report:export', () => {
const htmlPath = path.join(userData, 'fixture-report.html')
const pngPath = path.join(userData, 'fixture-report.png')
fs.writeFileSync(htmlPath, '<!doctype html><h1>固定脱敏日报</h1>', 'utf8')
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
})
handle('report:listGenerated', () => ({ success: true, reports: [] }))
handle('report:saveGenerated', (request) => ({
success: true,
record: { id: 'fixture-report-record', ...request }
}))
handle('report:deleteGenerated', () => ({ success: true }))
handle('report:reveal', () => ({ success: true }))
handle('copy-image', () => ({ success: true }))
handle('api:copyText', () => ({ success: true }))
handle('app-log:write', (entry) => {
const safe = JSON.stringify(entry)
.replace(/\b(?:0x)?[a-f0-9]{64}\b/gi, '***')
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
fs.mkdirSync(path.join(userData, 'logs'), { recursive: true })
fs.appendFileSync(path.join(userData, 'logs', 'e2e.log'), `${safe}\n`, 'utf8')
})
handle('app-log:getPath', () => path.join(userData, 'logs', 'e2e.log'))
handle('app-log:reveal', () => undefined)
handle('cache:getSummary', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 }))
handle('cache:clear', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 }))
handle('api:getStatus', () => ({ running: false, host: settings.apiHost, port: settings.apiPort }))
handle('api:start', () => ({ running: true, host: settings.apiHost, port: settings.apiPort }))
handle('api:stop', () => ({ running: false, host: settings.apiHost, port: settings.apiPort }))
handle('api:toggle', (enabled) => ({
running: enabled,
host: settings.apiHost,
port: settings.apiPort
}))
handle('image:getConfig', () => ({
success: true,
configured: true,
saved: true,
encryptionAvailable: true,
source: 'secure-storage',
resourceRoot: settings.imageKeyRoot,
xorKey: settings.imageXorKey,
aesKey: settings.imageAesKey
}))
handle('image:saveConfig', (request) => ({
success: true,
configured: true,
saved: true,
encryptionAvailable: true,
source: 'secure-storage',
...request
}))
handle('image:testConfig', () => ({
success: true,
fileFound: true,
decrypted: true,
readable: true
}))
handle('image:clearConfig', () => ({ success: true }))
handle('image:getDecoderStatus', () => ({
installed: true,
available: true,
source: 'system',
selected: false
}))
handle('image:getStatus', () => ({
configured: true,
saved: true,
encryptionAvailable: true,
source: 'secure-storage',
resourceRoot: settings.imageKeyRoot,
platform: process.platform,
autoDetectSupported: true,
wechatRunning: true,
accountIdentified: true,
cacheState: 'normal',
decoder: { installed: true, available: true, source: 'system', selected: false },
resources: Object.fromEntries(
['imageIndex', 'imageDirectory', 'thumbnail', 'original', 'sticker', 'video'].map((name) => [
name,
{ state: 'available', detail: 'fixture' }
])
)
}))
handle('settings:selectDbRoot', () => ({ canceled: false, path: 'fixture-account' }))
handle('accounts:discover', (inputPath) =>
inputPath === 'Z:\\missing-wechat-data'
? { success: false, accounts: [], error: '微信数据目录不存在,请重新选择目录' }
: {
success: true,
inputKind: 'account',
preselectedAccountId: 'fixture-account-id',
accounts: [
{
id: 'fixture-account-id',
accountRoot: inputPath || 'fixture-account',
directoryName: 'fixture-account',
wxid: fixture.self.wxid,
nickname: fixture.self.nickname,
avatar: fixture.self.avatar,
hasSavedDbKey: Boolean(savedKey),
loginStatus: connected ? 'current' : 'unknown',
selectedByInput: true
}
]
}
)
handle('agent-hub:getStatus', () => ({ state: 'disconnected', connected: false }))
handle('agent-hub:getLogs', () => [])
handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.1.6' }))
for (const channel of [
'export:start',
'export:cancel',
'export:reveal',
'settings:openAccountRoot',
'db:reopenWithRoot',
'api:skillStatus',
'api:readSkill',
'api:revealSkill',
'api:openSkillGithub',
'api:testLocalRequest',
'image:selectDecoder',
'image:openDecoderDownload',
'app-update:check',
'app-update:download',
'app-update:install',
'agent-hub:clearLogs',
'agent-hub:startLogin',
'agent-hub:cancelLogin',
'agent-hub:reconnect',
'agent-hub:disconnect',
'agent-hub:selectTestImage',
'image:listCandidates',
'image:analyze',
'image:getInsight',
'image:listInsights',
'db:search',
'db:getVideo'
]) {
if (!handlers.has(channel))
handle(channel, () => ({ success: true, candidates: [], insights: [] }))
}
app.whenReady().then(() => {
const window = new BrowserWindow({
width: 1440,
height: 960,
show: false,
backgroundColor: '#ffffff',
webPreferences: {
preload: path.join(root, 'out/preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
})
window.once('ready-to-show', () => window.show())
window.loadFile(path.join(root, 'out/renderer/index.html'))
})
app.on('window-all-closed', () => app.quit())
+57
View File
@@ -0,0 +1,57 @@
import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { resolve } from 'path'
import { loadEnv } from 'vite'
const DEFAULT_WINDOW_CLOSE_DELAY_MS = 2000
export interface TestApplication {
app: ElectronApplication
page: Page
userData: string
close: () => Promise<void>
}
export async function launchTestApp(
options: {
mode?: 'connected' | 'disconnected'
userData?: string
largeContacts?: number
corruptCache?: boolean
aiFailure?: string
} = {}
): Promise<TestApplication> {
const ownsDirectory = !options.userData
const userData = options.userData || mkdtempSync(resolve(tmpdir(), 'wxe-e2e-'))
const localTestEnv = loadEnv('test', process.cwd(), 'WXE_E2E_')
const configuredCloseDelay = Number(
process.env.WXE_E2E_CLOSE_DELAY_MS ?? localTestEnv.WXE_E2E_CLOSE_DELAY_MS
)
const closeDelayMs = Number.isFinite(configuredCloseDelay)
? Math.max(0, configuredCloseDelay)
: DEFAULT_WINDOW_CLOSE_DELAY_MS
const app = await electron.launch({
args: [resolve('tests/e2e/support/electron-main.cjs')],
env: {
...process.env,
WXE_E2E_USER_DATA: userData,
WXE_E2E_MODE: options.mode || 'connected',
WXE_E2E_LARGE_CONTACTS: String(options.largeContacts || 0),
WXE_E2E_CORRUPT_CACHE: options.corruptCache ? '1' : '0',
WXE_E2E_AI_FAILURE: options.aiFailure || ''
}
})
const page = await app.firstWindow()
await page.waitForLoadState('domcontentloaded')
return {
app,
page,
userData,
close: async () => {
if (!page.isClosed() && closeDelayMs > 0) await page.waitForTimeout(closeDelayMs)
await app.close().catch(() => undefined)
if (ownsDirectory) rmSync(userData, { recursive: true, force: true })
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test'
import { existsSync } from 'fs'
import { resolve } from 'path'
import { launchTestApp } from './support/electron'
const baselineDirectory = resolve(`tests/e2e/__screenshots__/${process.platform}/visual.spec.ts`)
test.skip(
!existsSync(baselineDirectory) && process.env.WXE_UPDATE_VISUAL_BASELINES !== '1',
`No reviewed ${process.platform} visual baseline is committed yet`
)
test('NAV-01 login page visual @visual', async () => {
const fixture = await launchTestApp({ mode: 'disconnected' })
try {
await expect(fixture.page.getByRole('heading', { name: 'WechatExplorer' })).toBeVisible()
await expect(fixture.page).toHaveScreenshot('login-page.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('ARCH-01 archive page visual @visual', async () => {
const fixture = await launchTestApp()
try {
await fixture.page.getByText('产品测试群', { exact: true }).click()
await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible()
await expect(fixture.page).toHaveScreenshot('archive-page.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
+97
View File
@@ -0,0 +1,97 @@
{
"self": {
"wxid": "wxid_fixture_self",
"nickname": "测试账号",
"accountRoot": "fixture-account"
},
"contacts": [
{
"m_nsUsrName": "group_regular@chatroom",
"m_nsNickName": "产品测试群",
"md5": "group-regular-md5",
"type": "group",
"avatar": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII="
},
{
"m_nsUsrName": "group_folded@chatroom",
"m_nsNickName": "折叠群聊样本",
"md5": "group-folded-md5",
"type": "group",
"isFolded": true
},
{
"m_nsUsrName": "filehelper",
"m_nsNickName": "文件传输助手",
"md5": "file-helper-md5",
"type": "user"
}
],
"messages": {
"group-regular-md5": [
{
"id": "msg-text",
"from": "user",
"type": "普通文本",
"datetime": "2026-08-01 10:00:00",
"content": "这是一条脱敏测试消息",
"isSender": false,
"name": "测试成员",
"senderId": "wxid_fixture_member",
"createTime": 1785549600,
"contentData": { "type": "text", "content": "这是一条脱敏测试消息" }
},
{
"id": "msg-image",
"from": "user",
"type": "图片",
"datetime": "2026-08-01 10:01:00",
"content": "[图片]",
"isSender": false,
"localId": 2,
"createTime": 1785549660,
"sessionId": "group_regular@chatroom",
"contentData": { "type": "image", "md5": "fixture-image-md5", "datName": "fixture.dat" }
},
{
"id": "msg-unknown",
"from": "user",
"type": "不支持的消息",
"datetime": "2026-08-01 10:02:00",
"content": "[未知消息]",
"isSender": false,
"createTime": 1785549720,
"contentData": { "type": "unknown", "raw": "fixture-unknown" }
}
],
"file-helper-md5": [
{
"id": "msg-voice",
"from": "user",
"type": "语音",
"datetime": "2026-08-01 11:00:00",
"content": "[语音]",
"isSender": false,
"localId": 11,
"createTime": 1785553200,
"sessionId": "filehelper",
"contentData": { "type": "voice", "duration": 1 }
},
{
"id": "msg-forward",
"from": "user",
"type": "合并转发",
"datetime": "2026-08-01 11:01:00",
"content": "[合并转发]",
"isSender": false,
"createTime": 1785553260,
"contentData": {
"type": "forwardBundle",
"title": "转发多条内容",
"items": [
{ "messageType": 1, "sender": "测试成员", "sentAt": "11:00", "text": "脱敏转发内容" }
]
}
}
]
}
}
+173
View File
@@ -0,0 +1,173 @@
import { dirname, join } from 'path'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Message } from '../../src/shared/types'
const state = vi.hoisted(() => ({
documents: '',
videoPath: '',
messages: [] as Message[],
imageLookups: [] as { allowThumbnail?: boolean; preferThumbnail?: boolean }[]
}))
vi.mock('electron', () => ({
app: { getPath: () => state.documents },
shell: { showItemInFolder: vi.fn() },
BrowserWindow: class {}
}))
vi.mock('../../src/main/services/chat-service', () => ({
listMessages: () => structuredClone(state.messages),
getChatDb: () => ({ getWcdb4Client: () => ({}) }),
getContactAvatars: () => ({})
}))
vi.mock('../../src/main/services/image-key-config-service', () => ({
ImageKeyConfigService: class {
getConfig(): { aesKey: string; xorKey: string } {
return { aesKey: '0123456789abcdef', xorKey: '0x40' }
}
}
}))
vi.mock('../../src/main/voice-service', () => ({
VoiceService: class {
async resolveVoice(
_sessionId: string,
localId: number
): Promise<{ success: boolean; data?: string; error?: string }> {
return localId === 1
? {
success: true,
data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
}
: { success: false, error: '本地未找到语音数据' }
}
}
}))
vi.mock('../../src/main/image-decrypt-service', () => ({
ImageDecryptService: class {
findImageFile(
_md5: string,
_datName: string,
options: { allowThumbnail?: boolean; preferThumbnail?: boolean }
): string {
state.imageLookups.push(options)
return 'fixture-original.dat'
}
decryptImageToBase64WithFallback(): { data: string; filePath: string } {
return {
data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=',
filePath: 'fixture-original.dat'
}
}
isThumbnailFile(): boolean {
return false
}
}
}))
vi.mock('../../src/main/video-asset-service', () => ({
VideoAssetService: class {
resolve(): { success: boolean; url: string } {
return { success: true, url: 'wxe-media://local/fixture-video' }
}
pathForUrl(): string {
return state.videoPath
}
}
}))
vi.mock('../../src/main/sticker-service', () => ({
StickerService: class {}
}))
const message = (overrides: Partial<Message>): Message => ({
id: 'fixture',
from: 'fixture',
type: '普通文本',
datetime: '2026-08-01 10:00:00',
content: '',
isSender: false,
createTime: 1_785_549_600,
...overrides
})
describe('media export flow', () => {
beforeEach(() => {
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
state.videoPath = join(state.documents, 'fixture.mp4')
writeFileSync(
state.videoPath,
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
)
state.imageLookups = []
state.messages = [
message({
id: 'voice-ok',
type: '语音',
sessionId: 'fixture-session',
localId: 1,
contentData: { type: 'voice', duration: 1 }
}),
message({
id: 'voice-missing',
type: '语音',
sessionId: 'fixture-session',
localId: 2,
contentData: { type: 'voice', duration: 1 }
}),
message({
id: 'image',
type: '图片',
sessionId: 'fixture-session',
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'fixture.dat' }
}),
message({
id: 'video',
type: '视频',
contentData: { type: 'video', md5: 'b'.repeat(32) }
})
]
})
afterEach(() => rmSync(state.documents, { recursive: true, force: true }))
it('writes playable relative assets, keeps failures, and requests the original image first', async () => {
const { runExport } = await import('../../src/main/export-service')
const progress: unknown[] = []
const win = {
isDestroyed: () => false,
webContents: { send: (...args: unknown[]) => progress.push(args) }
}
const result = await runExport(
{
jobId: 'fixture-export',
userMd5: 'fixture-user',
name: '脱敏会话',
format: 'html',
outputName: 'fixture',
kinds: ['voice', 'image', 'video'],
includeMedia: true,
preferOriginal: true,
fallbackThumbnail: true,
keepMissing: true
},
win as never
)
expect(result.success).toBe(true)
const html = readFileSync(result.outputPath!, 'utf8')
const outputDir = dirname(result.outputPath!)
expect(readFileSync(join(outputDir, 'voices/voice_1_1.wav')).subarray(0, 4).toString()).toBe(
'RIFF'
)
expect(readFileSync(join(outputDir, 'media/video_4.mp4')).subarray(4, 8).toString()).toBe(
'ftyp'
)
expect(html).toContain('src="voices/voice_1_1.wav"')
expect(html).toContain('src="media/video_4.mp4"')
expect(html).toContain('语音文件缺失:本地未找到语音数据')
expect(state.imageLookups[0]).toMatchObject({
allowThumbnail: false,
preferThumbnail: false
})
expect(progress.length).toBeGreaterThan(0)
})
})
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const invoke = vi.fn()
const on = vi.fn()
const removeListener = vi.fn()
const exposeInMainWorld = vi.fn()
vi.mock('electron', () => ({
contextBridge: { exposeInMainWorld },
ipcRenderer: { invoke, on, removeListener }
}))
vi.mock('@electron-toolkit/preload', () => ({ electronAPI: { fixture: true } }))
async function loadApi(): Promise<typeof window.api> {
vi.resetModules()
exposeInMainWorld.mockClear()
Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true })
await import('../../src/preload/index')
const exposed = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')
if (!exposed) throw new Error('preload did not expose api')
return exposed[1] as typeof window.api
}
describe('preload IPC contract', () => {
beforeEach(() => {
invoke.mockReset()
on.mockReset()
removeListener.mockReset()
})
it('forwards message and media parameters to the exact main channels', async () => {
const api = await loadApi()
invoke.mockResolvedValue({ success: true })
await api.getMessages('fixture-user', 10, 20, { limit: 50 })
expect(invoke).toHaveBeenLastCalledWith('db:getMessages', 'fixture-user', 10, 20, {
limit: 50
})
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
force: true,
priority: 0
})
expect(invoke).toHaveBeenLastCalledWith(
'db:getImage',
'fixture-md5',
'fixture.dat',
'fixture-session',
{ force: true, priority: 0 }
)
})
it('preserves key API return values without exposing ipcRenderer', async () => {
const api = await loadApi()
invoke.mockResolvedValueOnce({ success: false, code: 'DATABASE_OPEN_FAILED' })
await expect(api.testConnection('b'.repeat(64), 'fixture-root')).resolves.toEqual({
success: false,
code: 'DATABASE_OPEN_FAILED'
})
expect(invoke).toHaveBeenCalledWith('db:testConnection', 'b'.repeat(64), 'fixture-root')
expect(api).not.toHaveProperty('ipcRenderer')
expect(api).not.toHaveProperty('send')
})
it('unsubscribes the same listener registered for native database changes', async () => {
const api = await loadApi()
const callback = vi.fn()
const unsubscribe = api.onWcdbChange(callback)
expect(on).toHaveBeenCalledWith('wcdb-change', expect.any(Function))
const listener = on.mock.calls.at(-1)?.[1]
listener({}, { type: 'insert', json: '{"fixture":true}' })
expect(callback).toHaveBeenCalledWith({ type: 'insert', json: '{"fixture":true}' })
unsubscribe()
expect(removeListener).toHaveBeenCalledWith('wcdb-change', listener)
})
})
+15
View File
@@ -0,0 +1,15 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => cleanup())
Object.defineProperty(globalThis.URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:wxe-test-audio')
})
Object.defineProperty(globalThis.URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn()
})
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
const enabled = process.env.WXE_REAL_DATA_SMOKE === '1'
test(
'native WCDB opens a disposable fixture account on this machine',
{ skip: enabled ? false : 'set WXE_REAL_DATA_SMOKE=1 and WXE_SMOKE_DB_ROOT to opt in' },
() => {
const root = process.env.WXE_SMOKE_DB_ROOT || ''
assert.ok(root, 'WXE_SMOKE_DB_ROOT is required')
assert.ok(fs.existsSync(root), 'WXE_SMOKE_DB_ROOT must exist')
}
)
test('system permission prompts are verified manually on a clean OS account', {
skip: 'manual smoke checklist: docs/testing.md'
})
test('signed installer install, upgrade and uninstall are verified manually', {
skip: 'manual smoke checklist: docs/testing.md'
})
+69
View File
@@ -0,0 +1,69 @@
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocked = vi.hoisted(() => ({
userData: `${process.env.TEMP || process.env.TMP || '.'}/wxe-account-discovery-tests`
}))
vi.mock('electron', () => ({
app: { getPath: () => mocked.userData }
}))
import { discoverAccounts } from '../../src/main/services/account-discovery'
describe('account discovery', () => {
let root: string
const keyStore = {
getStatus: vi.fn(async (accountRoot: string) => ({
saved: accountRoot.endsWith('account-b'),
encryptionAvailable: true
}))
}
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'wxe-accounts-'))
await Promise.all(
['account-a', 'account-b', 'account-c'].map((name) =>
fs.ensureDir(path.join(root, name, 'db_storage'))
)
)
await fs.ensureDir(path.join(root, 'Backup'))
})
afterEach(async () => {
await fs.remove(root)
await fs.remove(mocked.userData)
})
it('rejects an invalid Backup directory without continuing', async () => {
const result = await discoverAccounts(path.join(root, 'Backup'), keyStore as never)
expect(result.success).toBe(false)
expect(result.accounts).toEqual([])
})
it('lists every direct account and never preselects one from a root directory', async () => {
const result = await discoverAccounts(root, keyStore as never)
expect(result.success).toBe(true)
expect(result.accounts.map((account) => account.directoryName).sort()).toEqual([
'account-a',
'account-b',
'account-c'
])
expect(result.preselectedAccountId).toBeUndefined()
expect(
result.accounts.find((account) => account.directoryName === 'account-b')?.hasSavedDbKey
).toBe(true)
})
it('preselects a directly selected account directory while retaining its card', async () => {
const accountRoot = path.join(root, 'account-c')
const result = await discoverAccounts(accountRoot, keyStore as never)
expect(result.success).toBe(true)
expect(result.accounts).toHaveLength(1)
expect(result.accounts[0].accountRoot).toBe(accountRoot)
expect(result.preselectedAccountId).toBe(result.accounts[0].id)
expect(result.accounts[0].selectedByInput).toBe(true)
})
})
+81
View File
@@ -0,0 +1,81 @@
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Contact, Message } from '../../src/shared/types'
const userData = mkdtempSync(join(tmpdir(), 'wxe-bootstrap-test-'))
vi.mock('electron', () => ({
app: { getPath: () => userData }
}))
import {
clearBootstrapCache,
flushBootstrapCacheWritesSync,
getBootstrapCache,
getCachedMessages,
saveBootstrapContacts,
saveCachedMessages
} from '../../src/main/services/bootstrap-cache'
const accountRoot = 'fixture-account-root'
const contact: Contact = {
m_nsUsrName: 'fixture-user',
m_nsNickName: '脱敏联系人',
md5: 'fixture-md5',
type: 'user'
}
function findFile(name: string): string {
const visit = (directory: string): string | null => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const file = join(directory, entry.name)
if (entry.isDirectory()) {
const nested = visit(file)
if (nested) return nested
} else if (entry.name === name) return file
}
return null
}
const result = visit(userData)
if (!result) throw new Error(`${name} was not written`)
return result
}
describe('bootstrap cache', () => {
beforeAll(() => rmSync(userData, { recursive: true, force: true }))
beforeEach(() => clearBootstrapCache())
afterAll(() => rmSync(userData, { recursive: true, force: true }))
it('persists contacts and caps each message bucket', () => {
saveBootstrapContacts(accountRoot, [contact])
const messages: Message[] = Array.from({ length: 140 }, (_, index) => ({
id: String(index),
from: 'user',
type: '文本',
datetime: '2026-08-01 10:00:00',
content: `fixture-${index}`,
isSender: false,
createTime: index + 1
}))
saveCachedMessages(accountRoot, contact.md5, undefined, undefined, messages)
flushBootstrapCacheWritesSync()
clearBootstrapCache()
expect(getBootstrapCache(accountRoot)?.contacts).toEqual([contact])
const cached = getCachedMessages(accountRoot, contact.md5)
expect(cached).toHaveLength(120)
expect(cached[0].id).toBe('20')
})
it('degrades to a cache miss when persisted JSON is corrupted', () => {
saveBootstrapContacts(accountRoot, [contact])
flushBootstrapCacheWritesSync()
const startup = findFile('startup.json')
expect(readFileSync(startup, 'utf8')).toContain('fixture-user')
writeFileSync(startup, '{broken', 'utf8')
clearBootstrapCache()
expect(getBootstrapCache(accountRoot)).toBeNull()
})
})
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WechatDb } from '../../src/main/wechat-db'
import { listContactsAsync, setChatDb } from '../../src/main/services/chat-service'
describe('chat service contacts', () => {
afterEach(() => setChatDb(null))
it('hydrates display names before returning macOS-style session ids', async () => {
const session = {
username: '57101206391@chatroom',
nickname: '57101206391@chatroom'
}
const getSessionsAsync = vi.fn(async (options: { hydrateDisplayNames?: boolean }) => {
if (options.hydrateDisplayNames) session.nickname = '测试群聊'
return [session]
})
const fakeDb = {
close: vi.fn(),
md5: () => 'fixture-md5',
getAllGroupContacts: () => ({ fixture: session.nickname }),
getUserList: () => [
{
m_nsUsrName: session.username,
nickname: session.nickname
}
],
getWcdb4Client: () => ({ getSessionsAsync })
} as unknown as WechatDb
setChatDb(fakeDb)
const contacts = await listContactsAsync()
expect(getSessionsAsync).toHaveBeenCalledWith({
hydrateDisplayNames: true,
hydrateStatuses: true
})
expect(contacts[0]?.m_nsNickName).toBe('测试群聊')
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { buildSafeDiagnosticSummary } from '../../src/shared/connection-diagnostics'
describe('connection diagnostics', () => {
it('contains useful versions and readiness without secrets or full account paths', () => {
const summary = buildSafeDiagnosticSummary({
platform: 'win32',
osVersion: 'Windows 11 fixture',
appVersion: 'v2.1.6',
wechatVersion: '4.1.9.57',
dataStructureVersion: '微信 4.xWCDB',
dataDirectoryDetected: true,
autoDetectSupported: true,
wechatRunning: true,
accountIdentified: true,
dbConnected: false,
encryptionAvailable: true
})
expect(summary).toContain('WechatExplorer: v2.1.6')
expect(summary).toContain('微信客户端: 4.1.9.57')
expect(summary).not.toContain('0123456789abcdef')
expect(summary).not.toContain('C:\\Users\\fixture\\xwechat_files\\wxid_secret')
expect(summary).not.toContain('wxid_')
})
})
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { renderExportPage } from '../../src/main/export-html-template'
import { getImageExportAttempts } from '../../src/shared/export-media'
import type { Message } from '../../src/shared/types'
const baseMessage = (overrides: Partial<Message>): Message => ({
id: 'fixture-message',
from: 'fixture',
type: '文本',
datetime: '2026-08-01 10:00:00',
content: '',
isSender: false,
...overrides
})
describe('export media', () => {
it('always attempts the original before an explicitly enabled thumbnail fallback', () => {
const first = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true })
const repeated = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true })
expect(first).toEqual([
{ allowThumbnail: false, preferThumbnail: false, fallback: false },
{ allowThumbnail: true, preferThumbnail: true, fallback: true }
])
expect(repeated).toEqual(first)
})
it('renders movable relative audio and video assets plus accurate missing-media details', () => {
const html = renderExportPage('脱敏导出', [
baseMessage({ id: 'voice', type: '语音', voiceDataUrl: 'voices/voice_1.wav' }),
baseMessage({
id: 'video',
type: '视频',
exportMediaType: 'video',
exportMediaUrl: 'media/video_2.mp4'
}),
baseMessage({
id: 'missing',
type: '语音',
exportMediaError: '语音文件缺失:本地未找到语音数据'
})
])
expect(html).toContain(
'audio class="audio" controls preload="metadata" src="voices/voice_1.wav"'
)
expect(html).toContain('video class="media-image" controls src="media/video_2.mp4"')
expect(html).toContain('语音文件缺失:本地未找到语音数据')
expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/)
})
it('renders explicit and keyboard-accessible lightbox closing controls', () => {
const html = renderExportPage('图片预览', [
baseMessage({ id: 'image', type: '图片', exportMediaUrl: 'media/image.jpg' })
])
expect(html).toContain('aria-label="关闭图片预览"')
expect(html).toContain("closeButton.addEventListener('click',closeLightbox)")
expect(html).toContain('if(event.target===box)closeLightbox()')
expect(html).toContain("if(event.key==='Escape')closeLightbox()")
})
})
+56
View File
@@ -0,0 +1,56 @@
import crypto from 'crypto'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
const root = mkdtempSync(join(tmpdir(), 'wxe-image-test-'))
vi.mock('electron', () => ({ app: { getPath: () => root } }))
vi.mock('../../src/main/services/settings-store', () => ({
loadSettings: () => ({ ffmpegPath: '' })
}))
vi.mock('../../src/main/wcdb4-client', () => ({ Wcdb4Client: class {} }))
import { ImageDecryptService } from '../../src/main/image-decrypt-service'
const aesKey = '0123456789abcdef'
const xorKey = 0x40
function writeV2Dat(file: string): Buffer {
const aesPlain = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
const padded = Buffer.concat([aesPlain, Buffer.alloc(16, 16)])
const cipher = crypto.createCipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii'), null)
cipher.setAutoPadding(false)
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()])
const raw = Buffer.from([13, 14])
const tailPlain = Buffer.from([15, 16])
const tailCipher = Buffer.from(tailPlain.map((value) => value ^ xorKey))
const header = Buffer.alloc(15)
Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]).copy(header)
header.writeInt32LE(aesPlain.length, 6)
header.writeInt32LE(tailPlain.length, 10)
writeFileSync(file, Buffer.concat([header, encrypted, raw, tailCipher]))
return Buffer.concat([aesPlain, raw, tailPlain])
}
describe('DAT image decryption', () => {
beforeAll(() => mkdirSync(root, { recursive: true }))
afterAll(() => rmSync(root, { recursive: true, force: true }))
it('decrypts a synthetic V2 AES/raw/XOR fixture', () => {
const file = join(root, 'fixture.dat')
const expected = writeV2Dat(file)
expect(new ImageDecryptService('0x40', aesKey).decryptImage(file)).toEqual(expected)
})
it('rejects the wrong AES key and unsupported legacy signatures accurately', () => {
const file = join(root, 'fixture.dat')
writeV2Dat(file)
expect(new ImageDecryptService('0x40', 'fedcba9876543210').decryptImage(file)).toBeNull()
const legacy = join(root, 'legacy.dat')
writeFileSync(legacy, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
expect(new ImageDecryptService('0x40', aesKey).decryptImage(legacy)).toBeNull()
})
})
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { getPath: () => 'fixture-settings' },
safeStorage: { isEncryptionAvailable: () => false }
}))
import {
isDatabaseKeyFormatValid,
mapAutoDetectPhase,
normalizeDatabaseKey
} from '../../src/renderer/src/features/settings/database-key/utils'
import {
normalizeImageXorKey,
validateImageKeyRequest
} from '../../src/main/services/image-key-config-service'
describe('database key validation', () => {
it('normalizes a prefixed key without accepting the wrong length', () => {
const key = `0x${'a'.repeat(64)}`
expect(normalizeDatabaseKey(key)).toBe('a'.repeat(64))
expect(isDatabaseKeyFormatValid(key)).toBe(true)
expect(isDatabaseKeyFormatValid('a'.repeat(63))).toBe(false)
expect(isDatabaseKeyFormatValid('z'.repeat(64))).toBe(false)
})
it('maps automatic detection progress into stable phases', () => {
expect(mapAutoDetectPhase('正在查找微信进程')).toBeGreaterThan(0)
expect(mapAutoDetectPhase('已获取数据库密钥')).toBe(5)
})
})
describe('image key validation', () => {
it.each([
[64, '0x40'],
['64', '0x40'],
['0xff', '0xFF'],
['', '0x40']
])('normalizes %s to %s', (input, expected) => {
expect(normalizeImageXorKey(input)).toBe(expected)
})
it('keeps database and image key validation independent', () => {
const result = validateImageKeyRequest({
resourceRoot: ' fixture-root ',
xorKey: '64',
aesKey: '0123456789abcdef'
})
expect(result).toEqual({
success: true,
resourceRoot: 'fixture-root',
xorKey: '0x40',
aesKey: '0123456789abcdef'
})
expect(
validateImageKeyRequest({ resourceRoot: 'fixture-root', xorKey: '999', aesKey: 'short' })
.success
).toBe(false)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import type { Message } from '../../src/shared/types'
import { mergeMessagePages } from '../../src/renderer/src/utils/message-pages'
const makeMessage = (id: string, createTime: number): Message => ({
id,
from: 'user',
type: '文本',
datetime: new Date(createTime * 1000).toISOString(),
content: id,
isSender: false,
createTime
})
describe('message pagination', () => {
it('sorts older pages and removes overlapping records', () => {
const merged = mergeMessagePages(
[makeMessage('oldest', 1), makeMessage('overlap', 2)],
[makeMessage('overlap', 2), makeMessage('latest', 3)]
)
expect(merged.map((message) => message.id)).toEqual(['oldest', 'overlap', 'latest'])
})
it('keeps cross-year pages continuous through the earliest fixture record', () => {
const page2025 = [makeMessage('2025', 1_735_689_600), makeMessage('2026', 1_767_225_600)]
const page2017 = [makeMessage('2017', 1_483_228_800), makeMessage('2025', 1_735_689_600)]
const merged = mergeMessagePages(page2017, page2025)
expect(merged.map((message) => message.id)).toEqual(['2017', '2025', '2026'])
expect(new Set(merged.map((message) => message.id)).size).toBe(merged.length)
})
})
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { parseMessageContent } from '../../src/main/message-parser'
describe('message parser', () => {
it('parses image, voice and sticker messages without confusing their types', () => {
expect(parseMessageContent('<img md5="0123456789abcdef0123456789abcdef" />', 3)).toMatchObject({
type: 'image',
md5: '0123456789abcdef0123456789abcdef'
})
expect(parseMessageContent('voice fixture', 34)).toEqual({ type: 'voice' })
expect(
parseMessageContent(
'<emoji md5="abcdefabcdefabcdefabcdefabcdefab" cdnurl="https://fixture.invalid/a" />',
47
)
).toMatchObject({ type: 'sticker', md5: 'abcdefabcdefabcdefabcdefabcdefab' })
})
it('parses merged forwards and preserves nested visible text', () => {
const parsed = parseMessageContent(
'<appmsg><type>19</type><title>转发多条内容</title><recorditem><dataitem datatype="1"><sourcename>测试成员</sourcename><datadesc>脱敏内容</datadesc></dataitem></recorditem></appmsg>',
49
)
expect(parsed.type).toBe('forwardBundle')
if (parsed.type === 'forwardBundle') {
expect(parsed.title).toBe('转发多条内容')
expect(parsed.items.map((item) => item.text).join(' ')).toContain('脱敏内容')
}
})
it('uses an explicit unknown type for unsupported messages', () => {
expect(parseMessageContent('opaque fixture payload', 999)).toEqual({
type: 'unknown',
raw: 'opaque fixture payload',
messageType: 999
})
})
})
+81
View File
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Message } from '../../src/shared/types'
import {
buildMessageGroups,
formatMessageTime
} from '../../src/renderer/src/components/chat/messageGrouping'
import {
buildSearchCacheKey,
parseSearchCacheKey,
readSearchCache,
writeSearchCache
} from '../../src/renderer/src/components/search/searchUtils'
const message = (id: string, createTime: number, from = 'user'): Message => ({
id,
from,
type: '文本',
datetime: new Date(createTime * 1000).toISOString(),
content: id,
isSender: from === 'assistant',
senderId: from,
createTime
})
describe('message grouping and dates', () => {
it('groups adjacent messages but keeps system and distant messages separate', () => {
const groups = buildMessageGroups([
message('one', 1000),
message('two', 1060),
{ ...message('system', 1070, 'system'), type: '系统消息' },
message('three', 2000)
])
expect(groups.map((group) => group.messages.map((item) => item.id))).toEqual([
['one', 'two'],
['system'],
['three']
])
})
it('formats today and yesterday deterministically', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-01T12:00:00+08:00'))
expect(
formatMessageTime(
message('today', Math.floor(Date.parse('2026-08-01T10:00:00+08:00') / 1000))
)
).toContain('今天')
expect(
formatMessageTime(
message('yesterday', Math.floor(Date.parse('2026-07-31T10:00:00+08:00') / 1000))
)
).toContain('昨天')
vi.useRealTimers()
})
})
describe('search cache', () => {
beforeEach(() => localStorage.clear())
it('normalizes the key and survives invalid persisted state', () => {
const key = buildSearchCacheKey('global', '', '7d', ' Windows 性能 ')
expect(parseSearchCacheKey(key)).toMatchObject({ query: 'windows 性能', range: '7d' })
localStorage.setItem('wxe_ai_search_cache_v1', '{broken')
expect(readSearchCache(key)).toBeNull()
})
it('writes and reads an isolated cache record', () => {
const key = buildSearchCacheKey('conversation', 'fixture-contact', 'today', '图片')
const record = {
version: 1 as const,
key,
query: '图片',
answer: '固定假回答',
evidence: [],
createdAt: 1
}
writeSearchCache(record)
expect(readSearchCache(key)).toMatchObject({ answer: '固定假回答' })
})
})
+53
View File
@@ -0,0 +1,53 @@
import { mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { classifyStickerHttpFailure } from '../../src/shared/sticker'
const logs = mkdtempSync(join(tmpdir(), 'wxe-log-test-'))
vi.mock('electron', () => ({
app: { getPath: () => logs, isPackaged: true },
shell: { showItemInFolder: vi.fn() }
}))
import { AppLogger } from '../../src/main/app-logger'
describe('sensitive logging', () => {
afterAll(() => rmSync(logs, { recursive: true, force: true }))
it('does not persist database keys, API keys or bearer tokens', () => {
const databaseKey = 'a'.repeat(64)
const logger = new AppLogger()
logger.write({
level: 'error',
scope: 'fixture',
message: `database open failed key=${databaseKey}`,
details: {
databaseKey,
apiKey: 'sk-fixture-secret-value',
authorization: 'Bearer fixture-token-value'
}
})
const persisted = readFileSync(logger.logPath, 'utf8')
expect(persisted).not.toContain(databaseKey)
expect(persisted).not.toContain('sk-fixture-secret-value')
expect(persisted).not.toContain('fixture-token-value')
expect(persisted).toContain('***')
})
})
describe('sticker HTTP failures', () => {
it('distinguishes expired, unauthorized, removed and rate-limited resources', () => {
expect(classifyStickerHttpFailure(403, 'https://fixture.invalid/a?expires=1', 2_000).code).toBe(
'link_expired'
)
expect(classifyStickerHttpFailure(403, 'https://fixture.invalid/a').code).toBe('access_denied')
expect(classifyStickerHttpFailure(401, 'https://fixture.invalid/a').code).toBe(
'authentication_required'
)
expect(classifyStickerHttpFailure(410, 'https://fixture.invalid/a').code).toBe(
'resource_removed'
)
expect(classifyStickerHttpFailure(429, 'https://fixture.invalid/a').code).toBe('rate_limited')
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest'
import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client'
const message = (id: string, year: number): Wcdb4Message => ({
mesLocalID: id,
serverId: `server-${id}`,
mesDes: 0,
messageType: '1',
msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)),
msgContent: `fixture-${year}`,
raw: {}
})
describe('WCDB message shard pagination', () => {
it('merges cursor and all-store rows for a bounded cross-year page', async () => {
const cursor = vi.fn(async () => [message('2025', 2025)])
const tableScan = vi.fn(async () => [message('2017', 2017), message('2025', 2025)])
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
wcdbGetMessageTableStats: vi.fn(),
wcdbExecQuery: vi.fn(),
getMessagesByCursorAsync: cursor,
getMessagesByTableScanAsync: tableScan
}) as Wcdb4Client
const result = await client.getMessagesAsync(
'fixture@chatroom',
undefined,
Math.floor(Date.UTC(2026, 0, 1) / 1000),
{ limit: 20 }
)
expect(tableScan).toHaveBeenCalledOnce()
expect(result.map((item) => item.msgContent)).toEqual(['fixture-2017', 'fixture-2025'])
})
it('reports an unsupported shard query instead of claiming history ended', async () => {
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
getMessagesByCursorAsync: vi.fn(async () => [])
}) as Wcdb4Client
await expect(
client.getMessagesAsync('fixture@chatroom', undefined, 1_767_225_600, { limit: 20 })
).rejects.toThrow('无法检查历史消息分片')
})
})

Some files were not shown because too many files have changed in this diff Show More