feat: 完成 TraceMemo v2.2.0 品牌身份与数据迁移升级

- 将 WechatExplorer 产品身份升级为 TraceMemo
- 更新 appId、Runtime Identity、Reader Skill 和 API 环境变量
- 增加旧用户数据、Knowledge、Token 与 AI Provider 安全迁移
- 保留 Windows WeFlow 和旧版配置兼容
- 完善首次启动迁移测试及 v2.2.0 发布文档
- 移除 macOS Intel x64 构建与发布支持
This commit is contained in:
Wxw-Gu
2026-08-11 17:52:57 +08:00
parent 0c1d859e1b
commit cf3f115124
39 changed files with 1837 additions and 545 deletions
+3 -2
View File
@@ -67,7 +67,6 @@ TraceMemo(迹忆)原名 WechatExplorer,是一次从“微信聊天记录
<img src="./public/二维码.jpg" alt="TraceMemo 交流与售后群二维码" width="280" /> <img src="./public/二维码.jpg" alt="TraceMemo 交流与售后群二维码" width="280" />
</p> </p>
## 从你的任务开始 ## 从你的任务开始
| 我现在想做什么 | 在应用里打开 | 需要准备什么 | | 我现在想做什么 | 在应用里打开 | 需要准备什么 |
@@ -192,7 +191,7 @@ flowchart LR
## 支持平台与安装包 ## 支持平台与安装包
| 平台 | 处理器架构 | Releases 安装包 | | 平台 | 处理器架构 | Releases 安装包 |
| ------- | ----------------------------- | --------------- | | ------- | ------------------------------ | --------------- |
| Windows | x64 | `-setup.exe` | | Windows | x64 | `-setup.exe` |
| macOS | Apple SiliconM 系列、arm64 | `.dmg` | | macOS | Apple SiliconM 系列、arm64 | `.dmg` |
@@ -210,6 +209,8 @@ flowchart LR
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。 Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
从 WechatExplorer v2.1.9 升级时,TraceMemo v2.2.0 会在首次启动检测旧设置、Knowledge、Token、AI Provider 和 Agent 数据,并在用户确认后复制到新的 TraceMemo 数据目录。迁移不会覆盖已有 TraceMemo 数据,也不会删除旧目录;详情见 [v2.2.0 正式品牌身份与安全升级迁移](./docs/agent/release-notes-v2.2.0.md)。
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。 如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md) 完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
+1 -1
View File
@@ -56,7 +56,7 @@ TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以
- [macOS 数据访问说明](./platform/macos.md) - [macOS 数据访问说明](./platform/macos.md)
- [开发、测试与构建](./development/overview.md) - [开发、测试与构建](./development/overview.md)
- [本地启动排障](./development/local-startup-troubleshooting.md) - [本地启动排障](./development/local-startup-troubleshooting.md)
- [v2.2.0 品牌升级与本地数据兼容](./agent/release-notes-v2.2.0.md) - [v2.2.0 正式品牌身份与安全升级迁移](./agent/release-notes-v2.2.0.md)
- [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md) - [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md)
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。 当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
+2 -2
View File
@@ -6,7 +6,7 @@ TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑
## Bearer Token ## Bearer Token
文档中的 `WECHATEXPLORER_API_TOKEN` 是历史兼容环境变量名;TraceMemo v2.2.0 继续支持这一名称,以免已安装的 Reader Skill 和 Agent 配置失效 新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量
- `/api/v1/health` 是公开健康检查; - `/api/v1/health` 是公开健康检查;
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>` - 其他所有端点都要求 `Authorization: Bearer <TOKEN>`
@@ -19,7 +19,7 @@ TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如: 应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
```bash ```bash
export WECHATEXPLORER_API_TOKEN="<TOKEN>" export TRACEMEMO_API_TOKEN="<TOKEN>"
``` ```
## CORS 与 Origin ## CORS 与 Origin
+5 -5
View File
@@ -17,19 +17,19 @@
curl http://127.0.0.1:6131/api/v1/health curl http://127.0.0.1:6131/api/v1/health
# 读取数据 # 读取数据
export WECHATEXPLORER_API_TOKEN="<从 API Center 复制的 Token>" export TRACEMEMO_API_TOKEN="<从 API Center 复制的 Token>"
curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20" "http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
``` ```
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。 不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
`WECHATEXPLORER_API_TOKEN` 是历史兼容环境变量名,TraceMemo v2.2.0 继续沿用它以保持 Reader Skill 和外部 Agent 兼容 新配置必须优先使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可在 v2.2.0 兼容期内继续读取 `WECHATEXPLORER_API_TOKEN`;如果两个变量都存在,以新变量为准
## 端点 ## 端点
| 方法 | 路径 | 作用 | 参数/请求体 | | 方法 | 路径 | 作用 | 参数/请求体 |
| --- | --- | --- | --- | | ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 | | GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 | | GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter``type=user\|group` | | GET | `/api/v1/contact` | 联系人和群聊列表 | `filter``type=user\|group` |
@@ -67,7 +67,7 @@ curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \
```bash ```bash
BASE="http://127.0.0.1:6131/api/v1" BASE="http://127.0.0.1:6131/api/v1"
AUTH="Authorization: Bearer $WECHATEXPLORER_API_TOKEN" AUTH="Authorization: Bearer ${TRACEMEMO_API_TOKEN:-$WECHATEXPLORER_API_TOKEN}"
curl -H "$AUTH" "$BASE/resolve?q=技术交流群" curl -H "$AUTH" "$BASE/resolve?q=技术交流群"
curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07" curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
+1 -1
View File
@@ -39,7 +39,7 @@ Reader Skill 是给外部 Agent 的操作说明。安装后,Codex、Claude Cod
2. 打开一级导航“API”(页面为“API Center”),确认本地 API、数据库和 Reader Skill 都可用。 2. 打开一级导航“API”(页面为“API Center”),确认本地 API、数据库和 Reader Skill 都可用。
3. 选择目标 Agent,点击“复制安装指令”。 3. 选择目标 Agent,点击“复制安装指令”。
4. 在 Agent 自己的 Skill/配置目录执行或粘贴指令。 4. 在 Agent 自己的 Skill/配置目录执行或粘贴指令。
5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `WECHATEXPLORER_API_TOKEN` 5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `TRACEMEMO_API_TOKEN`
6. 先让 Agent 调用 health,再尝试查询最近会话。 6. 先让 Agent 调用 health,再尝试查询最近会话。
详细说明:[Reader Skill](./reader-skill.md)、[Local HTTP API](./api.md)、[API 安全](./api-security.md)。 详细说明:[Reader Skill](./reader-skill.md)、[Local HTTP API](./api.md)、[API 安全](./api-security.md)。
+3 -3
View File
@@ -8,7 +8,7 @@ Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Cod
Reader Skill 只负责“外部 Agent 主动查询历史微信数据”。它不负责二维码登录、监听微信实时消息、接收机器人消息或管理 Agent Hub。想让机器人收到微信消息后处理并回复,请阅读[Agent Hub](./agent-hub.md)。 Reader Skill 只负责“外部 Agent 主动查询历史微信数据”。它不负责二维码登录、监听微信实时消息、接收机器人消息或管理 Agent Hub。想让机器人收到微信消息后处理并回复,请阅读[Agent Hub](./agent-hub.md)。
Reader Skill 的展示名称已迁移为 TraceMemo Reader;安装目录和 `WECHATEXPLORER_API_TOKEN` 环境变量仍保留历史兼容标识,以便旧 Agent 配置继续工作 正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 可在 v2.2.0 兼容期内继续使用旧变量
## 推荐安装流程 ## 推荐安装流程
@@ -19,7 +19,7 @@ Reader Skill 的展示名称已迁移为 TraceMemo Reader;安装目录和 `WEC
5. 在 API Center 复制 Token,在 Agent 自己的本地环境设置: 5. 在 API Center 复制 Token,在 Agent 自己的本地环境设置:
```bash ```bash
export WECHATEXPLORER_API_TOKEN="<YOUR_API_TOKEN>" export TRACEMEMO_API_TOKEN="<YOUR_API_TOKEN>"
``` ```
6. 先执行 health 检查,再读取数据端点。 6. 先执行 health 检查,再读取数据端点。
@@ -41,7 +41,7 @@ TraceMemo 不会自动把 Token 写进 Agent 配置。重新生成 Token 后,
```bash ```bash
curl http://127.0.0.1:6131/api/v1/health curl http://127.0.0.1:6131/api/v1/health
curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20" "http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
``` ```
+52 -28
View File
@@ -1,45 +1,69 @@
# TraceMemo 2.2.0品牌升级与本地数据兼容 # TraceMemo 2.2.0正式品牌身份与安全升级迁移
2.2.0 将用户可见品牌从 WechatExplorer 升级为 TraceMemo(迹忆)。这次升级不搬迁或复制用户的 Application Support 数据;macOS 和 Windows 上的旧用户会继续使用原有数据根,以避免设置、密钥和本地索引失联 TraceMemo(迹忆)原名 WechatExplorer。v2.2.0 不只更新用户可见名称,也正式启用新的应用身份、数据目录、Reader Skill 和默认 Agent 环境变量,同时为 v2.1.9 用户提供一次安全迁移路径
## 升级后继续保留的内容 ## 新的产品身份
- 微信数据库连接设置和其他用户设置 - 产品名与 Electron runtime name`TraceMemo`
- AI Provider、模型配置和已加密 API Key - bundle/app identifier`com.tracememo.app`
- macOS userData`~/Library/Application Support/TraceMemo`
- macOS 日志:`~/Library/Logs/TraceMemo`
- Reader Skill`tracememo-reader`
- Agent API Token 环境变量:`TRACEMEMO_API_TOKEN`
- Agent Hub 凭据目录:`~/.tracememo/wechat-connector/accounts`
## v2.1.9 升级迁移
首次启动 TraceMemo 时,如果检测到包含有效用户资产的旧数据目录,应用会询问是否立即迁移:
- `WechatExplorer`
- v2.1.9 在区分大小写文件系统上可能使用的 `wechatexplorer`
两个旧目录都有效时,应用确定性优先选择 `WechatExplorer` 并写入诊断日志,不合并目录。选择“以后迁移”不会删除或修改旧数据,下次启动仍可继续处理。
迁移遵循以下安全边界:
- 只复制明确列出的用户资产,不复制整个 Application Support
- TraceMemo 已存在的文件或目录绝不覆盖;
- 每一项迁移可重复执行,已完成项会跳过;
- 迁移失败只清理本次创建的 staging,旧目录和旧文件始终保留;
- 不移动、不删除旧目录,不修改微信数据库或 Knowledge schema。
## 迁移的用户资产
- 设置、微信数据库连接路径和 AI Provider 元数据;
- Knowledge 本地索引;
- 报告历史、防撤回归档、图片理解结果和 Renderer Local Storage
- Local HTTP API Token - Local HTTP API Token
- 微信数据库 Key 和图片解密 Key; - AI Provider Key、微信数据库 Key 和图片解密 Key;
- Knowledge 本地索引,包括现有 SQLite、WAL 和 SHM 文件; - Agent Hub credential 与同步状态。
- 报告历史、防撤回归档和图片理解结果;
- Renderer Local Storage 和其他 Chromium session 数据;
- `~/.wechatexplorer/wechat-connector/accounts` 中的 Agent Hub 登录凭据。
应用启动时会在任何业务模块读取 Electron 路径或使用 `safeStorage` 之前选择数据根,并将 `userData``sessionData` 设置为同一目录: Chromium Cache、Code Cache、GPUCache、临时文件、语音模型和其他可重建运行缓存不会为了品牌升级强制复制。
- `WechatExplorer` 或 v2.1.9 使用的小写 `wechatexplorer` 目录包含有效用户资产时,继续使用对应旧目录; ## Knowledge
- 只有 TraceMemo 新目录包含有效用户资产时,使用新目录;
- 多个旧目录或新旧目录同时包含有效用户资产时,不复制、不合并、不覆盖;两个大小写 legacy 目录均有效时确定性优先 `WechatExplorer` 并写入诊断日志;
- 两边都没有有效用户资产时,全新安装使用 TraceMemo 新目录。
仅有 `Local State`、Cache、Code Cache 或 GPUCache 等运行时文件,不会被判断为有效用户资产 Knowledge 以完整目录为单位复制。每个账号的 `knowledge.sqlite``knowledge.sqlite-wal``knowledge.sqlite-shm` 会一起进入同一个 staging;复制后先核对主库及 companion 文件,再对 staging 数据库执行 SQLite `integrity_check`。只有验证通过后才放入 TraceMemo 数据根
## 有意保留的兼容标识 迁移过程不会打开、修改或删除真实旧 Knowledge。失败时旧索引仍可用于重新迁移,不要求用户重新建立 2.47GB 级别的索引。
内部 Electron runtime identity 在 macOS 上继续使用 `WechatExplorer`。前者用于兼容旧 `safeStorage` 密文,后者同时承担旧 `safeStorage` 与 WCDB 运行时兼容;它们都不是未完成的品牌替换。 ## Token 与加密 Key
以下历史标识也继续保留: `safeStorage` 密文不会原样复制到新数据目录。TraceMemo 会启动一个隔离的 legacy helpermacOS 使用旧 `WechatExplorer` identityhelper 只在内存中解密并校验旧 Token/Key,再通过专用进程管道交给主进程重新加密;macOS 主进程使用 TraceMemo identity. 明文不会写入磁盘、环境变量或日志。
- bundle/app identifier`com.wechatexplorer.app` Token 格式、随机熵、加密方式和 rotation 行为没有变化。如果旧 API Token 因系统安全存储限制无法迁移,应用不会静默生成替代 Token,本地 API 会安全停用并提示用户重试迁移或在 API Center 主动重新生成。AI Provider Key、数据库 Key 和图片 Key 失败时也会明确记录为部分迁移,旧密文保持不变。
- Reader Skill 目录和标识:`wechatexplorer-reader`
- Agent 环境变量:`WECHATEXPLORER_API_TOKEN`
- Agent Hub 凭据目录:`~/.wechatexplorer`
- GitHub 仓库地址中的 `WechatExplorer`
Local HTTP API 的 endpoint、默认端口 `6131`、Bearer Token 格式、加密方式和 rotation 行为没有因为品牌升级而改变。旧 Token 文件会从旧数据根继续读取,不会仅因升级而重新生成。 ## API、Agent 与 Skill 兼容
## Knowledge 与日志 Local HTTP API 继续使用 `127.0.0.1:6131``/api/v1/*`Bearer Token 格式不变。
Knowledge 不会被复制、移动或自动重建。旧用户继续直接使用原有 Knowledge 目录,因此不需要为了 v2.2.0 重新建立索引 新安装和新文档默认使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可以在一个兼容版本内继续使用 `WECHATEXPLORER_API_TOKEN`。正式随应用分发的 Skill 已更名为 `tracememo-reader`,资源解析仍可读取旧 `wechatexplorer-reader` 目录作为 fallback
TraceMemo 的新诊断日志写入 TraceMemo 日志目录;WechatExplorer 历史日志保持原位置,不移动、不重命名、不删除。“打开诊断日志目录”会打开当前版本使用的 TraceMemo 日志 Agent Hub 新凭据写入 `~/.tracememo`。如果迁移尚未完成且新目录没有凭据,connector 会只读回退到 `~/.wechatexplorer`;新版本不会清理或删除旧目录
## 日志与 Documents
TraceMemo 新日志写入新的日志目录,“设置 → 关于 → 打开诊断日志目录”会打开当前 TraceMemo 日志。历史 WechatExplorer 日志保持原位置,不搬迁、不重命名、不删除。
`Documents/TraceMemo` 用于新导出和 Emoji 数据;历史 `Documents/WechatExplorer` 不删除,并继续提供兼容读取。
更多安全边界见[数据、隐私与安全](../user-guide/privacy.md)和[API 安全](./api-security.md)。 更多安全边界见[数据、隐私与安全](../user-guide/privacy.md)和[API 安全](./api-security.md)。
@@ -1,5 +1,5 @@
--- ---
name: wechatexplorer-reader name: tracememo-reader
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据。当用户要求查看微信消息、查找联系人或群聊、总结聊天、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。 description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据。当用户要求查看微信消息、查找联系人或群聊、总结聊天、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
--- ---
@@ -11,13 +11,12 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
- Base URL 默认是 `http://127.0.0.1:6131/api/v1` - Base URL 默认是 `http://127.0.0.1:6131/api/v1`
- `GET /health` 不需要 Token。 - `GET /health` 不需要 Token。
- 其他端点必须带 `Authorization: Bearer $WECHATEXPLORER_API_TOKEN` - 其他端点必须带 `Authorization: Bearer $TRACEMEMO_API_TOKEN`
- 新配置优先读取 `TRACEMEMO_API_TOKEN`;为兼容已安装的旧 Reader,可在新变量缺失时回退到 `WECHATEXPLORER_API_TOKEN`
- Token 由用户在 TraceMemo → API Center 显示/复制,并放在 Agent 自己的本地环境中。 - Token 由用户在 TraceMemo → API Center 显示/复制,并放在 Agent 自己的本地环境中。
- 不要把 Token 放到 URL、回答、日志、Skill 文件或仓库。 - 不要把 Token 放到 URL、回答、日志、Skill 文件或仓库。
- 6131 是普通 Local HTTP API,不是 MCP Server;不要生成 `mcpServers` 配置。 - 6131 是普通 Local HTTP API,不是 MCP Server;不要生成 `mcpServers` 配置。
`wechatexplorer-reader` 是该 Skill 为兼容既有安装而保留的目录和标识名;对用户展示时称为 TraceMemo Reader。
## 每次任务前 ## 每次任务前
1. 调用 `/health`,确认服务和数据库状态。 1. 调用 `/health`,确认服务和数据库状态。
@@ -29,7 +28,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
## 端点速查 ## 端点速查
| 方法 | 路径 | 用途 | | 方法 | 路径 | 用途 |
| --- | --- | --- | | ---- | --------------------- | ------------------------------------------------- |
| GET | `/health` | 健康和数据库状态 | | GET | `/health` | 健康和数据库状态 |
| GET | `/current_time` | 本机时间与时区 | | GET | `/current_time` | 本机时间与时区 |
| GET | `/contact` | 联系人/群聊列表;可传 `filter``type` | | GET | `/contact` | 联系人/群聊列表;可传 `filter``type` |
+1 -1
View File
@@ -33,7 +33,7 @@
### macOS ### macOS
1. 根据处理器下载对应的 `.dmg`Apple SiliconM 系列)选择 `arm64`Intel Mac 选择 `x64` 1. 下载 Apple SiliconM 系列`arm64`)版本的 `.dmg`。当前版本不支持 Intel 芯片的 Mac
2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。 2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。 3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
4. 如果系统提示应用已损坏,可在终端执行: 4. 如果系统提示应用已损坏,可在终端执行:
+1 -1
View File
@@ -14,7 +14,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。 应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。为兼容旧版本,磁盘上的用户数据目录可能仍沿用历史名称 `WechatExplorer`。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。 防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
## 什么时候会请求外部服务 ## 什么时候会请求外部服务
+1 -1
View File
@@ -30,7 +30,7 @@
## 数据写入与关闭边界 ## 数据写入与关闭边界
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。为兼容旧版本,该目录在磁盘上可能继续显示为历史名称 `WechatExplorer` 普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。 关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
+5 -7
View File
@@ -1,5 +1,4 @@
# Keep the legacy appId so existing userData directories and upgrades remain readable. appId: com.tracememo.app
appId: com.wechatexplorer.app
productName: TraceMemo productName: TraceMemo
afterPack: scripts/after-pack.cjs afterPack: scripts/after-pack.cjs
directories: directories:
@@ -26,8 +25,8 @@ extraResources:
to: resources to: resources
filter: filter:
- '**/*' - '**/*'
- from: docs/skill/wechatexplorer-reader - from: docs/skill/tracememo-reader
to: skill/wechatexplorer-reader to: skill/tracememo-reader
filter: filter:
- '**/*' - '**/*'
win: win:
@@ -47,9 +46,8 @@ mac:
icon: icon.icns icon: icon.icns
entitlementsInherit: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist
extendInfo: extendInfo:
# The bundled WCDB bridge accepts Electron as its internal host name. The # The bundled WCDB bridge still uses Electron as its internal executable
# legacy bundle identifier remains unchanged for upgrade compatibility; # compatibility name; the public product and bundle identity are TraceMemo.
# the public display name is migrated to TraceMemo.
CFBundleName: Electron CFBundleName: Electron
CFBundleDisplayName: TraceMemo CFBundleDisplayName: TraceMemo
NSCameraUsageDescription: Application requests access to the device's camera. NSCameraUsageDescription: Application requests access to the device's camera.
+2 -3
View File
@@ -49,16 +49,15 @@
"test:smoke": "node --test tests/smoke/native-environment.test.mjs", "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 arm64",
"build:native-services": "npm run build:wechat-connector", "build:native-services": "npm run build:wechat-connector",
"build": "npm run typecheck && npm run build:native-services && electron-vite build", "build": "npm run typecheck && npm run build:native-services && electron-vite build",
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs", "postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir", "build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64", "build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64",
"build:mac:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64", "build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"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 --arm64 --publish always",
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg: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 && npm run prepare:ffmpeg: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:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
"release:stable": "cross-env RELEASE_TYPE=release npm run release", "release:stable": "cross-env RELEASE_TYPE=release npm run release",
+1 -1
View File
@@ -106,7 +106,7 @@ function setPlistValue(plistPath, key, value) {
} }
function validateReaderSkillRuntime(runtimeResources) { function validateReaderSkillRuntime(runtimeResources) {
const skillPath = path.join(runtimeResources, 'skill', 'wechatexplorer-reader', 'SKILL.md') const skillPath = path.join(runtimeResources, 'skill', 'tracememo-reader', 'SKILL.md')
if (!existsSync(skillPath)) { if (!existsSync(skillPath)) {
throw new Error(`Missing bundled TraceMemo Reader Skill: ${skillPath}`) throw new Error(`Missing bundled TraceMemo Reader Skill: ${skillPath}`)
} }
+3 -2
View File
@@ -26,8 +26,8 @@ new Function('exports', 'require', 'module', output)(moduleExports, require, {
const { buildSkillInstallInstruction } = moduleExports const { buildSkillInstallInstruction } = moduleExports
const local = { const local = {
type: 'local', type: 'local',
directoryPath: 'C:/skill/wechatexplorer-reader', directoryPath: 'C:/skill/tracememo-reader',
skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md', skillPath: 'C:/skill/tracememo-reader/SKILL.md',
version: 'v1.0' version: 'v1.0'
} }
@@ -44,6 +44,7 @@ for (const [target, expected] of [
}) })
assert.match(text, new RegExp(expected)) assert.match(text, new RegExp(expected))
assert.match(text, /http:\/\/127\.0\.0\.1:6131\/api\/v1\/health/) assert.match(text, /http:\/\/127\.0\.0\.1:6131\/api\/v1\/health/)
assert.match(text, /TRACEMEMO_API_TOKEN/)
assert.match(text, /WECHATEXPLORER_API_TOKEN/) assert.match(text, /WECHATEXPLORER_API_TOKEN/)
assert.match(text, /Authorization: Bearer/) assert.match(text, /Authorization: Bearer/)
assert.doesNotMatch(text, /mcpServers/) assert.doesNotMatch(text, /mcpServers/)
+49 -10
View File
@@ -78,13 +78,40 @@ func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status strin
} }
} }
// AccountsDir returns the directory where account credentials are stored. func accountsDir(rootName string) (string, error) {
func AccountsDir() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), nil return filepath.Join(home, rootName, "wechat-connector", "accounts"), nil
}
// AccountsDir returns the TraceMemo directory where new credentials are stored.
func AccountsDir() (string, error) {
return accountsDir(".tracememo")
}
// LegacyAccountsDir is read-only compatibility for v2.1.9 and earlier.
func LegacyAccountsDir() (string, error) {
return accountsDir(".wechatexplorer")
}
func accountDirectoryForID(accountID string) (string, error) {
current, err := AccountsDir()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(current, accountID+".json")); err == nil {
return current, nil
}
legacy, err := LegacyAccountsDir()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(legacy, accountID+".json")); err == nil {
return legacy, nil
}
return current, nil
} }
// NormalizeAccountID converts raw bot ID to filesystem-safe format. // NormalizeAccountID converts raw bot ID to filesystem-safe format.
@@ -159,13 +186,7 @@ func SaveCredentials(creds *Credentials) error {
return nil return nil
} }
// LoadAllCredentials loads all saved account credentials. func loadCredentialsFromDir(dir string) ([]*Credentials, error) {
func LoadAllCredentials() ([]*Credentials, error) {
dir, err := AccountsDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -191,6 +212,24 @@ func LoadAllCredentials() ([]*Credentials, error) {
return result, nil return result, nil
} }
// LoadAllCredentials loads TraceMemo credentials first and falls back to the
// untouched WechatExplorer directory for one-version upgrade compatibility.
func LoadAllCredentials() ([]*Credentials, error) {
current, err := AccountsDir()
if err != nil {
return nil, err
}
credentials, err := loadCredentialsFromDir(current)
if err != nil || len(credentials) > 0 {
return credentials, err
}
legacy, err := LegacyAccountsDir()
if err != nil {
return nil, err
}
return loadCredentialsFromDir(legacy)
}
// CredentialsPath returns the path for display purposes. // CredentialsPath returns the path for display purposes.
func CredentialsPath() (string, error) { func CredentialsPath() (string, error) {
return AccountsDir() return AccountsDir()
@@ -1,6 +1,7 @@
package ilink package ilink
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -34,3 +35,46 @@ func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
t.Fatalf("old sync state still exists: %v", err) t.Fatalf("old sync state still exists: %v", err)
} }
} }
func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".tracememo", "wechat-connector", "accounts")
if dir != want {
t.Fatalf("AccountsDir() = %q, want %q", dir, want)
}
}
func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
legacyDir, err := LegacyAccountsDir()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
t.Fatal(err)
}
legacy := &Credentials{ILinkBotID: "legacy@im.bot", BotToken: "legacy-token"}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacyDir, NormalizeAccountID(legacy.ILinkBotID)+".json"), data, 0o600); err != nil {
t.Fatal(err)
}
accounts, err := LoadAllCredentials()
if err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].BotToken != legacy.BotToken {
t.Fatalf("accounts = %#v", accounts)
}
if _, err := os.Stat(legacyDir); err != nil {
t.Fatalf("legacy directory changed or removed: %v", err)
}
}
+3 -3
View File
@@ -33,12 +33,12 @@ type Monitor struct {
// NewMonitor creates a new long-poll monitor. // NewMonitor creates a new long-poll monitor.
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) { func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
home, err := os.UserHomeDir() accountID := NormalizeAccountID(client.BotID())
accountsRoot, err := accountDirectoryForID(accountID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
accountID := NormalizeAccountID(client.BotID()) bufPath := filepath.Join(accountsRoot, accountID+".sync.json")
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json")
m := &Monitor{ m := &Monitor{
client: client, client: client,
+16 -2
View File
@@ -20,6 +20,7 @@ interface TokenReadResult {
export class ApiTokenStore { export class ApiTokenStore {
private cachedToken: string | null = null private cachedToken: string | null = null
private automaticGenerationBlockedReason: string | null = null
constructor(private readonly filePathOverride?: string) {} constructor(private readonly filePathOverride?: string) {}
@@ -42,10 +43,18 @@ export class ApiTokenStore {
available: true, available: true,
hasToken: result.success && Boolean(result.token), hasToken: result.success && Boolean(result.token),
maskedToken: MASKED_TOKEN, maskedToken: MASKED_TOKEN,
...(result.success ? {} : { error: result.error }) ...(result.success
? result.token || !this.automaticGenerationBlockedReason
? {}
: { error: this.automaticGenerationBlockedReason }
: { error: result.error })
} }
} }
setAutomaticGenerationBlocked(reason?: string): void {
this.automaticGenerationBlockedReason = reason?.trim() || null
}
ensureToken(): ApiTokenActionResult { ensureToken(): ApiTokenActionResult {
if (!safeStorage.isEncryptionAvailable()) { if (!safeStorage.isEncryptionAvailable()) {
return { return {
@@ -59,6 +68,9 @@ export class ApiTokenStore {
const current = this.read() const current = this.read()
if (!current.success) return this.actionError(current.error) if (!current.success) return this.actionError(current.error)
if (current.token) return this.actionSuccess() if (current.token) return this.actionSuccess()
if (this.automaticGenerationBlockedReason) {
return this.actionError(this.automaticGenerationBlockedReason)
}
return this.persist(this.generateToken()) return this.persist(this.generateToken())
} }
@@ -70,7 +82,9 @@ export class ApiTokenStore {
rotateToken(): ApiTokenActionResult { rotateToken(): ApiTokenActionResult {
if (!safeStorage.isEncryptionAvailable()) return this.ensureToken() if (!safeStorage.isEncryptionAvailable()) return this.ensureToken()
return this.persist(this.generateToken()) const result = this.persist(this.generateToken())
if (result.success) this.automaticGenerationBlockedReason = null
return result
} }
getTokenForAuthentication(): string | null { getTokenForAuthentication(): string | null {
+38 -10
View File
@@ -1,19 +1,36 @@
import { app } from 'electron' import { app } from 'electron'
import path from 'path' import path from 'path'
import { getUserDataRoots, LEGACY_USER_DATA_NAME, selectUserDataRoot } from './app-data-paths' import { getUserDataRoots, LEGACY_USER_DATA_NAME, TRACE_MEMO_RUNTIME_NAME } from './app-data-paths'
export const LEGACY_MIGRATION_HELPER_ENV = 'TRACEMEMO_LEGACY_MIGRATION_HELPER'
export const LEGACY_MIGRATION_SOURCE_ENV = 'TRACEMEMO_LEGACY_MIGRATION_SOURCE'
export const LEGACY_MIGRATION_USER_DATA_ENV = 'TRACEMEMO_LEGACY_MIGRATION_USER_DATA'
export const LEGACY_MIGRATION_RESULT_FD_ENV = 'TRACEMEMO_LEGACY_MIGRATION_RESULT_FD'
// This module must remain the first main-process import. Static imports in // This module must remain the first main-process import. Static imports in
// settings/cache services can otherwise resolve Electron paths before the // settings/cache services can otherwise resolve Electron paths before the
// legacy runtime identity and selected userData are installed. // TraceMemo userData is installed before any consumers. macOS uses the old
app.setName(process.platform === 'win32' ? 'WeFlow' : LEGACY_USER_DATA_NAME) // safeStorage identity only inside the helper; Windows keeps in both
// processes because WCDB requires that technical runtime name.
const isLegacyMigrationHelper = process.env[LEGACY_MIGRATION_HELPER_ENV] === '1'
const legacyRuntimeName = process.platform === 'win32' ? 'WeFlow' : LEGACY_USER_DATA_NAME
const runtimeName =
process.platform === 'win32'
? 'WeFlow'
: isLegacyMigrationHelper
? legacyRuntimeName
: TRACE_MEMO_RUNTIME_NAME
app.setName(runtimeName)
const isolatedUserData = process.env['WXE_USER_DATA'] const isolatedUserData = process.env['WXE_USER_DATA']
const isUserDataIsolated = !isLegacyMigrationHelper && Boolean(isolatedUserData?.trim())
const roots = getUserDataRoots(app.getPath('appData')) const roots = getUserDataRoots(app.getPath('appData'))
const userDataSelection = selectUserDataRoot({ const helperUserData = process.env[LEGACY_MIGRATION_USER_DATA_ENV]
...roots, const selectedUserData = isLegacyMigrationHelper
isolated: isolatedUserData ? path.resolve(helperUserData?.trim() || path.join(roots.current, '.legacy-migration-helper'))
}) : isolatedUserData?.trim()
const selectedUserData = userDataSelection.selected ? path.resolve(isolatedUserData)
: roots.current
app.setPath('userData', selectedUserData) app.setPath('userData', selectedUserData)
app.setPath('sessionData', selectedUserData) app.setPath('sessionData', selectedUserData)
@@ -21,8 +38,19 @@ app.setPath('sessionData', selectedUserData)
// Logs are intentionally independent from userData. New TraceMemo logs go to // Logs are intentionally independent from userData. New TraceMemo logs go to
// the new visible directory while historical WechatExplorer logs remain in // the new visible directory while historical WechatExplorer logs remain in
// place and are never moved or renamed. // place and are never moved or renamed.
if (process.platform === 'darwin') { if (isLegacyMigrationHelper) {
app.setPath('logs', path.join(selectedUserData, 'logs'))
} else if (process.platform === 'darwin') {
app.setPath('logs', path.join(app.getPath('home'), 'Library', 'Logs', 'TraceMemo')) app.setPath('logs', path.join(app.getPath('home'), 'Library', 'Logs', 'TraceMemo'))
} else if (process.platform === 'win32') {
app.setPath('logs', path.join(selectedUserData, 'logs'))
} }
export { roots, selectedUserData, userDataSelection } export {
isLegacyMigrationHelper,
isUserDataIsolated,
legacyRuntimeName,
runtimeName,
roots,
selectedUserData
}
+676
View File
@@ -0,0 +1,676 @@
import { app, BrowserWindow, dialog, safeStorage } from 'electron'
import { spawn } from 'child_process'
import { constants as fsConstants } from 'fs'
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import { randomUUID } from 'crypto'
import {
LEGACY_MIGRATION_HELPER_ENV,
LEGACY_MIGRATION_RESULT_FD_ENV,
LEGACY_MIGRATION_SOURCE_ENV,
LEGACY_MIGRATION_USER_DATA_ENV
} from './app-data-bootstrap'
import {
hasValidUserAssets,
selectUserDataRoot,
type UserDataRoots,
type UserDataSelection
} from './app-data-paths'
import type { LegacySecretBundle } from './legacy-safe-storage-helper'
const MIGRATION_STATE_FILE = 'tracememo-migration-v1.json'
const TOKEN_MIGRATION_BLOCK_MESSAGE =
'检测到旧版 API Token 尚未完成迁移。请重试数据迁移,或在 API Center 主动重新生成 Token。'
const FILE_ASSETS = [
'settings.json',
'ai-providers.json',
'image-insights.json',
'group-exit-monitor.json'
] as const
const DIRECTORY_ASSETS = [
'knowledge',
'reports',
'recall-archive',
'Local Storage',
'digital-twin',
'group-exit-monitor'
] as const
export type MigrationItemStatus = 'migrated' | 'skipped' | 'missing' | 'failed'
export type MigrationStatus = 'deferred' | 'in-progress' | 'partial' | 'completed'
export interface MigrationState {
version: 1
status: MigrationStatus
sourceRoot: string
updatedAt: string
items: Record<string, MigrationItemStatus>
secretFailures: string[]
}
export interface MigrationAssessment {
selection: UserDataSelection
sourceRoot?: string
currentHasAssets: boolean
state?: MigrationState
shouldPrompt: boolean
reason:
| 'clean-install'
| 'legacy-empty'
| 'current-data-present'
| 'migration-completed'
| 'legacy-assets-detected'
| 'migration-resumable'
}
export interface MigrationExecutionResult {
state: MigrationState
tokenGenerationBlocked: boolean
tokenBlockReason?: string
}
export interface MigrationFlowResult {
assessment: MigrationAssessment
action: 'none' | 'deferred' | 'migrated'
execution?: MigrationExecutionResult
tokenGenerationBlocked: boolean
tokenBlockReason?: string
}
export interface MigrationDependencies {
decryptLegacySecrets: (sourceRoot: string) => Promise<LegacySecretBundle>
agentRoots: () => { legacy: string; current: string }
now: () => Date
onProgress?: (message: string) => void
}
function migrationStatePath(targetRoot: string): string {
return path.join(targetRoot, MIGRATION_STATE_FILE)
}
function readMigrationState(targetRoot: string): MigrationState | undefined {
try {
const state = fs.readJsonSync(migrationStatePath(targetRoot)) as MigrationState
if (
state.version !== 1 ||
!['deferred', 'in-progress', 'partial', 'completed'].includes(state.status) ||
typeof state.sourceRoot !== 'string'
) {
return undefined
}
return state
} catch {
return undefined
}
}
async function writeMigrationState(targetRoot: string, state: MigrationState): Promise<void> {
await fs.ensureDir(targetRoot)
const statePath = migrationStatePath(targetRoot)
const tempPath = `${statePath}.tmp-${process.pid}-${randomUUID()}`
try {
await fs.writeJson(tempPath, state, { spaces: 2, mode: 0o600 })
await fs.chmod(tempPath, 0o600)
await fs.move(tempPath, statePath, { overwrite: true })
} finally {
await fs.remove(tempPath).catch(() => undefined)
}
}
function isLegacySelection(selection: UserDataSelection): boolean {
return selection.selectedKind === 'legacy-display' || selection.selectedKind === 'legacy-package'
}
export function assessMigration(roots: UserDataRoots): MigrationAssessment {
const selection = selectUserDataRoot(roots)
const state = readMigrationState(roots.current)
const selectedLegacyRoot = isLegacySelection(selection) ? selection.selected : undefined
const stateSource = state?.sourceRoot
const resumableStateSource =
stateSource &&
[roots.legacy, roots.legacyPackage].includes(stateSource) &&
hasValidUserAssets(stateSource)
? stateSource
: undefined
const sourceRoot = resumableStateSource || selectedLegacyRoot
const currentHasAssets = hasValidUserAssets(roots.current)
if (state?.status === 'completed') {
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: false,
reason: 'migration-completed'
}
}
if (sourceRoot && state && ['deferred', 'in-progress', 'partial'].includes(state.status)) {
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: true,
reason: 'migration-resumable'
}
}
if (!sourceRoot) {
return {
selection,
currentHasAssets,
state,
shouldPrompt: false,
reason:
selection.directories.legacy || selection.directories.legacyPackage
? 'legacy-empty'
: 'clean-install'
}
}
if (currentHasAssets) {
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: false,
reason: 'current-data-present'
}
}
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: true,
reason: 'legacy-assets-detected'
}
}
async function copyFileWithoutOverwrite(
sourceRoot: string,
targetRoot: string,
relativePath: string,
stagingRoot: string
): Promise<MigrationItemStatus> {
const sourcePath = path.join(sourceRoot, relativePath)
const targetPath = path.join(targetRoot, relativePath)
if (!(await fs.pathExists(sourcePath))) return 'missing'
if (await fs.pathExists(targetPath)) return 'skipped'
const stagedPath = path.join(stagingRoot, relativePath)
await fs.ensureDir(path.dirname(stagedPath))
await fs.copy(sourcePath, stagedPath, {
overwrite: false,
errorOnExist: true,
preserveTimestamps: true
})
if (await fs.pathExists(targetPath)) return 'skipped'
await fs.ensureDir(path.dirname(targetPath))
await fs.move(stagedPath, targetPath, { overwrite: false })
return 'migrated'
}
async function integrityCheckKnowledgeDatabase(databasePath: string): Promise<void> {
const script = `
const { DatabaseSync } = require('node:sqlite')
const database = new DatabaseSync(process.argv[1], { readOnly: true })
try {
const rows = database.prepare('PRAGMA integrity_check').all()
const result = String(rows[0]?.integrity_check || rows[0]?.['integrity_check(1)'] || '').toLowerCase()
if (rows.length !== 1 || result !== 'ok') process.exitCode = 2
} finally {
database.close()
}
`
await new Promise<void>((resolve, reject) => {
const child = spawn(process.execPath, ['-e', script, databasePath], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true
})
let stderr = ''
child.stderr?.on('data', (chunk: Buffer) => {
if (stderr.length < 4096) stderr += chunk.toString('utf8')
})
child.once('error', reject)
child.once('exit', (code) => {
if (code === 0) resolve()
else reject(new Error(`Knowledge SQLite integrity_check failed (${code}): ${stderr.trim()}`))
})
})
}
async function verifyKnowledgeCopy(sourcePath: string, stagedPath: string): Promise<void> {
const accounts = await fs.readdir(sourcePath, { withFileTypes: true })
for (const account of accounts) {
if (!account.isDirectory()) continue
const sourceDatabase = path.join(sourcePath, account.name, 'knowledge.sqlite')
if (!(await fs.pathExists(sourceDatabase))) continue
const stagedDatabase = path.join(stagedPath, account.name, 'knowledge.sqlite')
if (!(await fs.pathExists(stagedDatabase)))
throw new Error('Knowledge database copy is missing')
for (const suffix of ['', '-wal', '-shm']) {
const sourceFile = `${sourceDatabase}${suffix}`
if (!(await fs.pathExists(sourceFile))) continue
const stagedFile = `${stagedDatabase}${suffix}`
if (!(await fs.pathExists(stagedFile)))
throw new Error(`Knowledge companion missing: ${suffix}`)
const [sourceStat, stagedStat] = await Promise.all([fs.stat(sourceFile), fs.stat(stagedFile)])
if (sourceStat.size !== stagedStat.size)
throw new Error(`Knowledge copy size mismatch: ${suffix}`)
}
await integrityCheckKnowledgeDatabase(stagedDatabase)
}
}
async function copyDirectoryWithoutOverwrite(
sourceRoot: string,
targetRoot: string,
relativePath: string,
stagingRoot: string
): Promise<MigrationItemStatus> {
const sourcePath = path.join(sourceRoot, relativePath)
const targetPath = path.join(targetRoot, relativePath)
if (!(await fs.pathExists(sourcePath))) return 'missing'
if (await fs.pathExists(targetPath)) return 'skipped'
const stagedPath = path.join(stagingRoot, relativePath)
await fs.ensureDir(path.dirname(stagedPath))
await fs.copy(sourcePath, stagedPath, {
overwrite: false,
errorOnExist: true,
preserveTimestamps: true
})
if (relativePath === 'knowledge') await verifyKnowledgeCopy(sourcePath, stagedPath)
if (await fs.pathExists(targetPath)) return 'skipped'
await fs.move(stagedPath, targetPath, { overwrite: false })
return 'migrated'
}
async function writeEncryptedWithoutOverwrite(
targetRoot: string,
relativePath: string,
plainText: string
): Promise<MigrationItemStatus> {
const targetPath = path.join(targetRoot, relativePath)
if (await fs.pathExists(targetPath)) return 'skipped'
if (!safeStorage.isEncryptionAvailable()) throw new Error('系统安全存储不可用')
const tempPath = `${targetPath}.tmp-${process.pid}-${randomUUID()}`
try {
await fs.ensureDir(path.dirname(targetPath))
await fs.writeFile(tempPath, safeStorage.encryptString(plainText), { mode: 0o600 })
await fs.chmod(tempPath, 0o600)
if (await fs.pathExists(targetPath)) return 'skipped'
await fs.move(tempPath, targetPath, { overwrite: false })
return 'migrated'
} finally {
await fs.remove(tempPath).catch(() => undefined)
}
}
async function copyMissingTree(
sourceRoot: string,
targetRoot: string
): Promise<MigrationItemStatus> {
if (!(await fs.pathExists(sourceRoot))) return 'missing'
let copied = false
const visit = async (sourceDirectory: string, targetDirectory: string): Promise<void> => {
await fs.ensureDir(targetDirectory)
for (const entry of await fs.readdir(sourceDirectory, { withFileTypes: true })) {
const sourcePath = path.join(sourceDirectory, entry.name)
const targetPath = path.join(targetDirectory, entry.name)
if (entry.isDirectory()) {
await visit(sourcePath, targetPath)
} else if (entry.isFile() && !(await fs.pathExists(targetPath))) {
await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_EXCL)
const stat = await fs.stat(sourcePath)
await fs.chmod(targetPath, stat.mode & 0o777)
copied = true
}
}
}
await visit(sourceRoot, targetRoot)
return copied ? 'migrated' : 'skipped'
}
export function getAgentCredentialRoots(homePath = os.homedir()): {
legacy: string
current: string
} {
return {
legacy: path.join(homePath, '.wechatexplorer', 'wechat-connector', 'accounts'),
current: path.join(homePath, '.tracememo', 'wechat-connector', 'accounts')
}
}
async function hasEncryptedLegacyAssets(sourceRoot: string): Promise<boolean> {
for (const relativePath of [
'local-api-token.bin',
'ai-provider-keys.bin',
'wechat-image-keys.bin',
'wechat-db-key.bin'
]) {
if (await fs.pathExists(path.join(sourceRoot, relativePath))) return true
}
const databaseKeyRoot = path.join(sourceRoot, 'database-keys')
if (!(await fs.pathExists(databaseKeyRoot))) return false
return (await fs.readdir(databaseKeyRoot, { withFileTypes: true })).some(
(entry) => entry.isFile() && entry.name.endsWith('.bin')
)
}
export async function runLegacySecretHelper(sourceRoot: string): Promise<LegacySecretBundle> {
const helperUserData = await fs.mkdtemp(
path.join(app.getPath('temp'), 'tracememo-legacy-safe-storage-')
)
const helperArgs = app.isPackaged ? [] : process.argv.slice(1)
try {
return await new Promise<LegacySecretBundle>((resolve, reject) => {
const child = spawn(process.execPath, helperArgs, {
env: {
...process.env,
[LEGACY_MIGRATION_HELPER_ENV]: '1',
[LEGACY_MIGRATION_SOURCE_ENV]: sourceRoot,
[LEGACY_MIGRATION_USER_DATA_ENV]: helperUserData,
[LEGACY_MIGRATION_RESULT_FD_ENV]: '3'
},
stdio: ['ignore', 'ignore', 'pipe', 'pipe'],
windowsHide: true
})
const chunks: Buffer[] = []
let totalBytes = 0
let settled = false
const finish = (error?: Error, value?: LegacySecretBundle): void => {
if (settled) return
settled = true
clearTimeout(timeout)
if (error) reject(error)
else resolve(value!)
}
const resultPipe = child.stdio[3]
resultPipe?.on('data', (chunk: Buffer) => {
totalBytes += chunk.length
if (totalBytes > 5 * 1024 * 1024) {
child.kill()
finish(new Error('legacy secret helper result is too large'))
return
}
chunks.push(chunk)
})
child.once('error', (error) => finish(error))
child.once('exit', (code) => {
if (code !== 0) {
finish(new Error(`legacy secret helper exited with code ${code}`))
return
}
try {
finish(
undefined,
JSON.parse(Buffer.concat(chunks).toString('utf8')) as LegacySecretBundle
)
} catch {
finish(new Error('legacy secret helper returned invalid data'))
}
})
const timeout = setTimeout(() => {
child.kill()
finish(new Error('legacy secret helper timed out'))
}, 30_000)
})
} finally {
await fs.remove(helperUserData).catch(() => undefined)
}
}
export async function executeMigration(
sourceRoot: string,
targetRoot: string,
dependencies: MigrationDependencies = {
decryptLegacySecrets: runLegacySecretHelper,
agentRoots: () => getAgentCredentialRoots(),
now: () => new Date()
}
): Promise<MigrationExecutionResult> {
const items: Record<string, MigrationItemStatus> = {}
const secretFailures: string[] = []
const timestamp = (): string => dependencies.now().toISOString()
const state: MigrationState = {
version: 1,
status: 'in-progress',
sourceRoot,
updatedAt: timestamp(),
items,
secretFailures
}
await writeMigrationState(targetRoot, state)
const stagingRoot = path.join(targetRoot, '.tracememo-migration-staging-v1')
await fs.remove(stagingRoot).catch(() => undefined)
const runItem = async (name: string, task: () => Promise<MigrationItemStatus>): Promise<void> => {
dependencies.onProgress?.(
name === 'knowledge' ? '正在安全迁移 Knowledge,数据较大时需要几分钟…' : `正在迁移 ${name}`
)
try {
items[name] = await task()
} catch {
items[name] = 'failed'
}
state.updatedAt = timestamp()
await writeMigrationState(targetRoot, state)
}
try {
for (const relativePath of FILE_ASSETS) {
await runItem(relativePath, () =>
copyFileWithoutOverwrite(sourceRoot, targetRoot, relativePath, stagingRoot)
)
}
for (const relativePath of DIRECTORY_ASSETS) {
await runItem(relativePath, () =>
copyDirectoryWithoutOverwrite(sourceRoot, targetRoot, relativePath, stagingRoot)
)
}
const agentRoots = dependencies.agentRoots()
await runItem('agent-credentials', () => copyMissingTree(agentRoots.legacy, agentRoots.current))
let secrets: LegacySecretBundle = { databaseKeys: {}, failures: [] }
if (await hasEncryptedLegacyAssets(sourceRoot)) {
try {
secrets = await dependencies.decryptLegacySecrets(sourceRoot)
secretFailures.push(...secrets.failures.map((failure) => failure.asset))
} catch {
secretFailures.push('safeStorage-helper')
}
}
const migrateSecret = async (
relativePath: string,
plainText: string | undefined
): Promise<void> => {
const sourceExists = await fs.pathExists(path.join(sourceRoot, relativePath))
if (!sourceExists) {
items[relativePath] = 'missing'
return
}
if (plainText === undefined) {
items[relativePath] = 'failed'
if (!secretFailures.includes(relativePath)) secretFailures.push(relativePath)
return
}
await runItem(relativePath, () =>
writeEncryptedWithoutOverwrite(targetRoot, relativePath, plainText)
)
}
await migrateSecret('local-api-token.bin', secrets.token)
await migrateSecret(
'ai-provider-keys.bin',
secrets.aiProviderKeys ? JSON.stringify(secrets.aiProviderKeys) : undefined
)
await migrateSecret(
'wechat-image-keys.bin',
secrets.imageKeys ? JSON.stringify(secrets.imageKeys) : undefined
)
await migrateSecret('wechat-db-key.bin', secrets.legacyDatabaseKey)
const databaseKeySource = path.join(sourceRoot, 'database-keys')
if (await fs.pathExists(databaseKeySource)) {
for (const entry of await fs.readdir(databaseKeySource, { withFileTypes: true })) {
if (!entry.isFile() || !/^[0-9a-f]{64}\.bin$/i.test(entry.name)) continue
const relativePath = path.join('database-keys', entry.name)
await migrateSecret(relativePath, secrets.databaseKeys[entry.name])
}
}
} finally {
await fs.remove(stagingRoot).catch(() => undefined)
}
const failed = Object.values(items).some((status) => status === 'failed')
state.status = failed || secretFailures.length > 0 ? 'partial' : 'completed'
state.updatedAt = timestamp()
state.secretFailures = Array.from(new Set(secretFailures))
await writeMigrationState(targetRoot, state)
const tokenGenerationBlocked =
(await fs.pathExists(path.join(sourceRoot, 'local-api-token.bin'))) &&
!(await fs.pathExists(path.join(targetRoot, 'local-api-token.bin')))
return {
state,
tokenGenerationBlocked,
...(tokenGenerationBlocked ? { tokenBlockReason: TOKEN_MIGRATION_BLOCK_MESSAGE } : {})
}
}
async function createMigrationProgressWindow(): Promise<BrowserWindow | null> {
if (process.platform !== 'darwin') return null
const window = new BrowserWindow({
width: 460,
height: 260,
show: false,
resizable: false,
minimizable: false,
maximizable: false,
closable: false,
title: 'TraceMemo 数据迁移',
backgroundColor: '#f5f5f7',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
const html = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><style>
html,body{height:100%;margin:0}body{display:grid;place-items:center;background:#f5f5f7;color:#202124;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center}
main{width:360px}.spinner{width:30px;height:30px;margin:0 auto 20px;border:3px solid #d9dddf;border-top-color:#00796b;border-radius:50%;animation:spin .9s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
h1{font-size:18px;margin:0 0 12px}p{line-height:1.6;margin:0}.hint{margin-top:14px;color:#697177;font-size:12px}
</style></head><body><main><div class="spinner"></div><h1>正在迁移 WechatExplorer 数据</h1><p id="status">正在准备迁移…</p><p class="hint">请不要退出 TraceMemo。旧数据不会被删除。</p></main></body></html>`
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
window.setProgressBar(2, { mode: 'indeterminate' })
window.show()
return window
}
function updateMigrationProgress(window: BrowserWindow | null, message: string): void {
if (!window || window.isDestroyed()) return
void window.webContents
.executeJavaScript(
`document.getElementById('status').textContent = ${JSON.stringify(message)}`,
true
)
.catch(() => undefined)
}
function closeMigrationProgress(window: BrowserWindow | null): void {
if (!window || window.isDestroyed()) return
window.setProgressBar(-1)
window.setClosable(true)
window.destroy()
}
export async function runFirstLaunchMigration(roots: UserDataRoots): Promise<MigrationFlowResult> {
const assessment = assessMigration(roots)
if (!assessment.shouldPrompt || !assessment.sourceRoot) {
return { assessment, action: 'none', tokenGenerationBlocked: false }
}
const sourceLabel = path.basename(assessment.sourceRoot)
const conflictNote = assessment.selection.legacyConflict
? '\n\n检测到两个旧数据目录,将确定性使用 WechatExplorer;另一个目录不会修改。'
: ''
const response = await dialog.showMessageBox({
type: 'question',
title: '迁移 WechatExplorer 数据',
message: '检测到 WechatExplorer 数据',
detail:
`TraceMemo 可以从 ${sourceLabel} 迁移设置、Knowledge、本地索引、API Token、AI Provider、报告和 Agent 配置。` +
'\n\n迁移只复制缺失的用户资产,不会覆盖 TraceMemo 已有数据,也不会删除旧目录。' +
conflictNote,
buttons: ['立即迁移', '以后迁移'],
defaultId: 0,
cancelId: 1,
noLink: true
})
if (response.response !== 0) {
const state: MigrationState = {
version: 1,
status: 'deferred',
sourceRoot: assessment.sourceRoot,
updatedAt: new Date().toISOString(),
items: assessment.state?.items || {},
secretFailures: assessment.state?.secretFailures || []
}
await writeMigrationState(roots.current, state)
const tokenGenerationBlocked = await fs.pathExists(
path.join(assessment.sourceRoot, 'local-api-token.bin')
)
return {
assessment,
action: 'deferred',
tokenGenerationBlocked,
...(tokenGenerationBlocked ? { tokenBlockReason: TOKEN_MIGRATION_BLOCK_MESSAGE } : {})
}
}
const progressWindow = await createMigrationProgressWindow()
let execution: MigrationExecutionResult
try {
execution = await executeMigration(assessment.sourceRoot, roots.current, {
decryptLegacySecrets: runLegacySecretHelper,
agentRoots: () => getAgentCredentialRoots(),
now: () => new Date(),
onProgress: (message) => updateMigrationProgress(progressWindow, message)
})
updateMigrationProgress(progressWindow, '迁移完成,正在启动 TraceMemo…')
const messageBoxOptions = {
type: execution.state.status === 'completed' ? ('info' as const) : ('warning' as const),
title: 'TraceMemo 数据迁移',
message:
execution.state.status === 'completed'
? 'WechatExplorer 数据迁移完成'
: '部分数据未能迁移',
detail:
execution.state.status === 'completed'
? '核心用户资产已复制到 TraceMemo。旧目录仍完整保留。'
: '旧目录没有被修改。请保留旧数据并在下次启动时重试;无法迁移的 API Token 不会被静默替换。',
buttons: ['好']
}
if (progressWindow && !progressWindow.isDestroyed()) {
await dialog.showMessageBox(progressWindow, messageBoxOptions)
} else {
await dialog.showMessageBox(messageBoxOptions)
}
} finally {
closeMigrationProgress(progressWindow)
}
return {
assessment,
action: 'migrated',
execution,
tokenGenerationBlocked: execution.tokenGenerationBlocked,
tokenBlockReason: execution.tokenBlockReason
}
}
+4 -3
View File
@@ -3,7 +3,8 @@ import path from 'path'
export const LEGACY_USER_DATA_NAME = 'WechatExplorer' export const LEGACY_USER_DATA_NAME = 'WechatExplorer'
export const LEGACY_PACKAGE_USER_DATA_NAME = 'wechatexplorer' export const LEGACY_PACKAGE_USER_DATA_NAME = 'wechatexplorer'
export const CURRENT_USER_DATA_NAME = 'tracememo' export const CURRENT_USER_DATA_NAME = 'TraceMemo'
export const TRACE_MEMO_RUNTIME_NAME = 'TraceMemo'
export interface UserDataRoots { export interface UserDataRoots {
legacy: string legacy: string
@@ -97,7 +98,7 @@ function hasKnowledgeDatabase(root: string): boolean {
} }
} }
function isExistingDirectory(directoryPath: string): boolean { export function isExistingDirectory(directoryPath: string): boolean {
try { try {
return fs.statSync(directoryPath).isDirectory() return fs.statSync(directoryPath).isDirectory()
} catch { } catch {
@@ -105,7 +106,7 @@ function isExistingDirectory(directoryPath: string): boolean {
} }
} }
function areSameExistingDirectory(firstPath: string, secondPath: string): boolean { export function areSameExistingDirectory(firstPath: string, secondPath: string): boolean {
try { try {
const first = fs.statSync(firstPath) const first = fs.statSync(firstPath)
const second = fs.statSync(secondPath) const second = fs.statSync(secondPath)
+3 -1
View File
@@ -11,7 +11,9 @@ import { imageFileQuality, imageQualityRank } from '../shared/image-quality'
import { loadSettings } from './services/settings-store' import { loadSettings } from './services/settings-store'
import { Wcdb4Client } from './wcdb4-client' import { Wcdb4Client } from './wcdb4-client'
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1' const imageDecryptDebugEnabled =
process.env['TRACEMEMO_DEBUG_IMAGE'] === '1' ||
(!process.env['TRACEMEMO_DEBUG_IMAGE'] && process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1')
const imageDecryptLog = (...args: unknown[]): void => { const imageDecryptLog = (...args: unknown[]): void => {
if (imageDecryptDebugEnabled) console.log(...args) if (imageDecryptDebugEnabled) console.log(...args)
} }
+76 -22
View File
@@ -1,4 +1,8 @@
import { userDataSelection } from './app-data-bootstrap' import {
isLegacyMigrationHelper,
isUserDataIsolated,
roots as appDataRoots
} from './app-data-bootstrap'
import './preload-env' import './preload-env'
import { import {
app, app,
@@ -116,6 +120,8 @@ import {
} from '../shared/windows-runtime' } from '../shared/windows-runtime'
import { KnowledgeSearchService } from './knowledge/knowledge-search-service' import { KnowledgeSearchService } from './knowledge/knowledge-search-service'
import { AiSearchPipelineService } from './services/ai-search-pipeline-service' import { AiSearchPipelineService } from './services/ai-search-pipeline-service'
import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
import { runFirstLaunchMigration } from './app-data-migration'
// 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
@@ -470,6 +476,74 @@ function createWindow(): void {
// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法 // Electron 初始化完成并准备创建浏览器窗口后,将调用此方法
// 某些 API 只能在此事件发生后使用 // 某些 API 只能在此事件发生后使用
app.whenReady().then(async () => { app.whenReady().then(async () => {
if (isLegacyMigrationHelper) {
try {
runLegacySafeStorageHelper()
app.exit(0)
} catch {
app.exit(1)
}
return
}
try {
const migration = isUserDataIsolated ? null : await runFirstLaunchMigration(appDataRoots)
apiTokenStore.setAutomaticGenerationBlocked(migration?.tokenBlockReason)
if (!migration) {
appLogger.write({
level: 'info',
scope: 'app-data-migration',
message: '隔离 userData 已启用,跳过真实用户数据迁移'
})
} else {
appLogger.write({
level: migration.assessment.selection.legacyConflict ? 'warn' : 'info',
scope: 'app-data-migration',
message: migration.assessment.selection.legacyConflict
? '检测到两个独立的 legacy userData,已按兼容优先级选择 WechatExplorer 作为迁移源'
: 'TraceMemo 数据身份检查完成',
details: {
action: migration.action,
reason: migration.assessment.reason,
sourceRoot: migration.assessment.sourceRoot,
targetRoot: appDataRoots.current,
legacyExists: migration.assessment.selection.directories.legacy,
legacyPackageExists: migration.assessment.selection.directories.legacyPackage,
legacyAssets: migration.assessment.selection.assets.legacy,
legacyPackageAssets: migration.assessment.selection.assets.legacyPackage,
legacyRootsEquivalent: migration.assessment.selection.legacyRootsEquivalent,
legacyConflict: migration.assessment.selection.legacyConflict,
migrationStatus: migration.execution?.state.status,
tokenGenerationBlocked: migration.tokenGenerationBlocked
}
})
}
} catch (error) {
const targetToken = join(appDataRoots.current, 'local-api-token.bin')
const legacyTokenExists = [appDataRoots.legacy, appDataRoots.legacyPackage].some((root) =>
existsSync(join(root, 'local-api-token.bin'))
)
if (!existsSync(targetToken) && legacyTokenExists) {
apiTokenStore.setAutomaticGenerationBlocked(
'旧版 API Token 迁移未完成,本地 API 已安全停用。请保留旧数据并重新启动迁移。'
)
}
appLogger.write({
level: 'error',
scope: 'app-data-migration',
message: 'TraceMemo 数据迁移初始化失败',
details: { error: error instanceof Error ? error.message : String(error) }
})
await dialog.showMessageBox({
type: 'warning',
title: 'TraceMemo 数据迁移',
message: '旧数据迁移未能启动',
detail:
'旧目录没有被修改或删除。请保留 WechatExplorer 数据并重新启动 TraceMemo;旧 API Token 不会被静默替换。',
buttons: ['好']
})
}
voiceRecognition = new VoiceRecognitionUseCase({ voiceRecognition = new VoiceRecognitionUseCase({
modelRoot: join(app.getPath('userData'), 'models', 'sensevoice-small-int8'), modelRoot: join(app.getPath('userData'), 'models', 'sensevoice-small-int8'),
databasePath: join(app.getPath('userData'), 'cache', 'voice-transcripts.sqlite'), databasePath: join(app.getPath('userData'), 'cache', 'voice-transcripts.sqlite'),
@@ -513,26 +587,6 @@ app.whenReady().then(async () => {
message: 'TraceMemo 启动', message: 'TraceMemo 启动',
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() } details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
}) })
appLogger.write({
level: userDataSelection.legacyConflict ? 'warn' : 'info',
scope: 'app-data-bootstrap',
message: userDataSelection.legacyConflict
? '检测到两个独立的 legacy userData,已按兼容优先级选择 WechatExplorer'
: 'userData 路径选择完成',
details: {
selectedPath: userDataSelection.selected,
selectedKind: userDataSelection.selectedKind,
reason: userDataSelection.reason,
legacyExists: userDataSelection.directories.legacy,
legacyPackageExists: userDataSelection.directories.legacyPackage,
currentExists: userDataSelection.directories.current,
legacyAssets: userDataSelection.assets.legacy,
legacyPackageAssets: userDataSelection.assets.legacyPackage,
currentAssets: userDataSelection.assets.current,
legacyRootsEquivalent: userDataSelection.legacyRootsEquivalent,
legacyConflict: userDataSelection.legacyConflict
}
})
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
appLogger.write({ appLogger.write({
level: 'error', level: 'error',
@@ -567,7 +621,7 @@ app.whenReady().then(async () => {
}) })
// 设置应用程序用户模型 ID // 设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.wechatexplorer.app') electronApp.setAppUserModelId('com.tracememo.app')
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath) if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
+193 -343
View File
@@ -1,9 +1,6 @@
// @ts-nocheck
// Windows native bridge adapted from WeFlow. Koffi Win32 callbacks and optional
// helpers are intentionally dynamic; keep this file isolated from strict TS.
import { join, dirname, delimiter } from 'path' import { join, dirname, delimiter } from 'path'
import { existsSync, copyFileSync, mkdirSync } from 'fs' import { existsSync, copyFileSync, mkdirSync } from 'fs'
import { execFile, spawn } from 'child_process' import { execFile } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import os from 'os' import os from 'os'
import crypto from 'crypto' import crypto from 'crypto'
@@ -12,14 +9,19 @@ import { getResourceRoots as getSharedResourceRoots } from './resource-paths'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string } type ImageKeyResult = {
success: boolean
xorKey?: number
aesKey?: string
verified?: boolean
error?: string
}
type DbKeyPollResult = type DbKeyPollResult =
| { status: 'success'; key: string; loginRequiredDetected: boolean } | { status: 'success'; key: string; loginRequiredDetected: boolean }
| { status: 'process-ended'; loginRequiredDetected: boolean } | { status: 'process-ended'; loginRequiredDetected: boolean }
| { status: 'timeout'; loginRequiredDetected: boolean } | { status: 'timeout'; loginRequiredDetected: boolean }
export class KeyService { export class KeyService {
private readonly isMac = process.platform === 'darwin'
private koffi: any = null private koffi: any = null
private lib: any = null private lib: any = null
private initialized = false private initialized = false
@@ -28,19 +30,15 @@ export class KeyService {
private getStatusMessage: any = null private getStatusMessage: any = null
private cleanupHook: any = null private cleanupHook: any = null
private getLastErrorMsg: any = null private getLastErrorMsg: any = null
private getImageKeyDll: any = null
private lastLoadError = '' private lastLoadError = ''
// Win32 APIs // Win32 APIs
private kernel32: any = null private kernel32: any = null
private user32: any = null private user32: any = null
private advapi32: any = null
// Kernel32 // Kernel32
private OpenProcess: any = null private OpenProcess: any = null
private CloseHandle: any = null private CloseHandle: any = null
private TerminateProcess: any = null
private QueryFullProcessImageNameW: any = null
// User32 // User32
private EnumWindows: any = null private EnumWindows: any = null
@@ -50,22 +48,9 @@ export class KeyService {
private GetWindowThreadProcessId: any = null private GetWindowThreadProcessId: any = null
private IsWindowVisible: any = null private IsWindowVisible: any = null
private EnumChildWindows: any = null private EnumChildWindows: any = null
private PostMessageW: any = null
private WNDENUMPROC_PTR: any = null private WNDENUMPROC_PTR: any = null
// Advapi32
private RegOpenKeyExW: any = null
private RegQueryValueExW: any = null
private RegCloseKey: any = null
// Constants // Constants
private readonly PROCESS_ALL_ACCESS = 0x1F0FFF
private readonly PROCESS_TERMINATE = 0x0001
private readonly KEY_READ = 0x20019
private readonly HKEY_LOCAL_MACHINE = 0x80000002
private readonly HKEY_CURRENT_USER = 0x80000001
private readonly ERROR_SUCCESS = 0
private readonly WM_CLOSE = 0x0010
private readonly DB_KEY_PROCESS_CHECK_INTERVAL_MS = 1000 private readonly DB_KEY_PROCESS_CHECK_INTERVAL_MS = 1000
private getResourceRoots(): string[] { private getResourceRoots(): string[] {
@@ -156,11 +141,11 @@ export class KeyService {
this.lib = this.koffi.load(dllPath) this.lib = this.koffi.load(dllPath)
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)') this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
this.pollKeyData = this.lib.func('bool PollKeyData(_Out_ char *keyBuffer, int bufferSize)') this.pollKeyData = this.lib.func('bool PollKeyData(_Out_ char *keyBuffer, int bufferSize)')
this.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)') this.getStatusMessage = this.lib.func(
'bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)'
)
this.cleanupHook = this.lib.func('bool CleanupHook()') this.cleanupHook = this.lib.func('bool CleanupHook()')
this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()') this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()')
this.getImageKeyDll = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)')
this.initialized = true this.initialized = true
return true return true
} catch (e) { } catch (e) {
@@ -186,8 +171,6 @@ export class KeyService {
this.kernel32 = this.koffi.load('kernel32.dll') this.kernel32 = this.koffi.load('kernel32.dll')
this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32']) this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*']) this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32'])
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['void*', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
return true return true
} catch (e) { } catch (e) {
@@ -211,12 +194,26 @@ export class KeyService {
this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC) this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC)
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t']) this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t']) this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', [
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t']) 'void*',
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int']) this.WNDENUMPROC_PTR,
'intptr_t'
])
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', [
'void*',
this.koffi.out('uint16*'),
'int'
])
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*']) this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int']) this.GetClassNameW = this.user32.func('GetClassNameW', 'int', [
this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', ['void*', this.koffi.out('uint32*')]) 'void*',
this.koffi.out('uint16*'),
'int'
])
this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', [
'void*',
this.koffi.out('uint32*')
])
this.IsWindowVisible = this.user32.func('IsWindowVisible', 'bool', ['void*']) this.IsWindowVisible = this.user32.func('IsWindowVisible', 'bool', ['void*'])
return true return true
@@ -226,26 +223,6 @@ export class KeyService {
} }
} }
private ensureAdvapi32(): boolean {
if (this.advapi32) return true
try {
this.koffi = require('koffi')
this.advapi32 = this.koffi.load('advapi32.dll')
const HKEY = this.koffi.alias('HKEY', 'intptr_t')
const HKEY_PTR = this.koffi.pointer(HKEY)
this.RegOpenKeyExW = this.advapi32.func('RegOpenKeyExW', 'long', [HKEY, 'uint16*', 'uint32', 'uint32', this.koffi.out(HKEY_PTR)])
this.RegQueryValueExW = this.advapi32.func('RegQueryValueExW', 'long', [HKEY, 'uint16*', 'uint32*', this.koffi.out('uint32*'), this.koffi.out('uint8*'), this.koffi.out('uint32*')])
this.RegCloseKey = this.advapi32.func('RegCloseKey', 'long', [HKEY])
return true
} catch (e) {
console.error('初始化 advapi32 失败:', e)
return false
}
}
private decodeCString(ptr: any): string { private decodeCString(ptr: any): string {
try { try {
if (typeof ptr === 'string') return ptr if (typeof ptr === 'string') return ptr
@@ -255,123 +232,19 @@ export class KeyService {
} }
} }
// --- WeChat Process & Path Finding ---
private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null {
if (!this.ensureAdvapi32()) return null
const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2')
const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null
const phkResult = Buffer.alloc(8)
if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) return null
const hKey = this.koffi.decode(phkResult, 'uintptr_t')
try {
const lpcbData = Buffer.alloc(4)
lpcbData.writeUInt32LE(0, 0)
let ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, null, lpcbData)
if (ret !== this.ERROR_SUCCESS) return null
const size = lpcbData.readUInt32LE(0)
if (size === 0) return null
const dataBuf = Buffer.alloc(size)
ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, dataBuf, lpcbData)
if (ret !== this.ERROR_SUCCESS) return null
let str = dataBuf.toString('ucs2')
if (str.endsWith('\0')) str = str.slice(0, -1)
return str
} finally {
this.RegCloseKey(hKey)
}
}
private async getProcessExecutablePath(pid: number): Promise<string | null> {
if (!this.ensureKernel32()) return null
const hProcess = this.OpenProcess(0x1000, false, pid)
if (!hProcess) return null
try {
const sizeBuf = Buffer.alloc(4)
sizeBuf.writeUInt32LE(1024, 0)
const pathBuf = Buffer.alloc(1024 * 2)
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
if (ret) {
const len = sizeBuf.readUInt32LE(0)
return pathBuf.toString('ucs2', 0, len * 2)
}
return null
} catch (e) {
console.error('获取进程路径失败:', e)
return null
} finally {
this.CloseHandle(hProcess)
}
}
private async findWeChatInstallPath(): Promise<string | null> {
try {
const pid = await this.findWeChatPid()
if (pid) {
const runPath = await this.getProcessExecutablePath(pid)
if (runPath && existsSync(runPath)) return runPath
}
} catch (e) {
console.error('尝试获取运行中微信路径失败:', e)
}
const uninstallKeys = [
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'
]
const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER]
const tencentKeys = [
'Software\\Tencent\\WeChat',
'Software\\WOW6432Node\\Tencent\\WeChat',
'Software\\Tencent\\Weixin',
]
for (const root of roots) {
for (const key of tencentKeys) {
const path = this.readRegistryString(root, key, 'InstallPath')
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
if (path && existsSync(join(path, 'WeChat.exe'))) return join(path, 'WeChat.exe')
}
}
for (const root of roots) {
for (const parent of uninstallKeys) {
const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation')
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
}
}
const drives = ['C', 'D', 'E', 'F']
const commonPaths = [
'Program Files\\Tencent\\WeChat\\WeChat.exe',
'Program Files (x86)\\Tencent\\WeChat\\WeChat.exe',
'Program Files\\Tencent\\Weixin\\Weixin.exe',
'Program Files (x86)\\Tencent\\Weixin\\Weixin.exe'
]
for (const drive of drives) {
for (const p of commonPaths) {
const full = join(drive + ':\\', p)
if (existsSync(full)) return full
}
}
return null
}
private async findPidsByImageName(imageName: string): Promise<number[]> { private async findPidsByImageName(imageName: string): Promise<number[]> {
try { try {
const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH']) const { stdout } = await execFileAsync('tasklist', [
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) '/FI',
`IMAGENAME eq ${imageName}`,
'/FO',
'CSV',
'/NH'
])
const lines = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const pids: number[] = [] const pids: number[] = []
for (const line of lines) { for (const line of lines) {
if (line.startsWith('INFO:')) continue if (line.startsWith('INFO:')) continue
@@ -418,7 +291,7 @@ export class KeyService {
const fallbackPid = await this.waitForWeChatWindow(250) const fallbackPid = await this.waitForWeChatWindow(250)
if (fallbackPid) return fallbackPid if (fallbackPid) return fallbackPid
await new Promise(r => setTimeout(r, 500)) await new Promise((r) => setTimeout(r, 500))
} }
return null return null
} }
@@ -441,7 +314,7 @@ export class KeyService {
const now = Date.now() const now = Date.now()
if (now >= nextProcessCheckAt) { if (now >= nextProcessCheckAt) {
nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS
if (!await this.isWeChatPidActive(pid)) { if (!(await this.isWeChatPidActive(pid))) {
return { status: 'process-ended', loginRequiredDetected } return { status: 'process-ended', loginRequiredDetected }
} }
} }
@@ -483,7 +356,11 @@ export class KeyService {
private buildInitHookError(): string { private buildInitHookError(): string {
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '' const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
if (error) { if (error) {
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) { if (
error.includes('0xC0000022') ||
error.includes('ACCESS_DENIED') ||
error.includes('打开目标进程失败')
) {
return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行' return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
} }
return error return error
@@ -491,13 +368,17 @@ export class KeyService {
const statusBuffer = Buffer.alloc(256) const statusBuffer = Buffer.alloc(256)
const levelOut = [0] const levelOut = [0]
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut) const status =
this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
? this.decodeUtf8(statusBuffer) ? this.decodeUtf8(statusBuffer)
: '' : ''
return status || '初始化失败' return status || '初始化失败'
} }
private async waitForNextDbKeyPid(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> { private async waitForNextDbKeyPid(
deadline: number,
onStatus?: (message: string, level: number) => void
): Promise<number | null> {
while (this.getRemainingMs(deadline) > 0) { while (this.getRemainingMs(deadline) > 0) {
onStatus?.('正在查找微信进程...', 0) onStatus?.('正在查找微信进程...', 0)
const pid = await this.waitForWeChatPid(Math.min(this.getRemainingMs(deadline), 30_000)) const pid = await this.waitForWeChatPid(Math.min(this.getRemainingMs(deadline), 30_000))
@@ -514,17 +395,23 @@ export class KeyService {
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
} }
private async waitForProcessRestart(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> { private async waitForProcessRestart(
deadline: number,
onStatus?: (message: string, level: number) => void
): Promise<number | null> {
if (!this.shouldRetryAfterProcessLost(deadline)) return null if (!this.shouldRetryAfterProcessLost(deadline)) return null
onStatus?.('检测到微信已退出,已清理 Hook,等待重新打开微信...', 0) onStatus?.('检测到微信已退出,已清理 Hook,等待重新打开微信...', 0)
await this.delayBeforeRetry() await this.delayBeforeRetry()
return this.waitForNextDbKeyPid(deadline, onStatus) return this.waitForNextDbKeyPid(deadline, onStatus)
} }
private async detectLoginRequiredForLastPid(pid: number | null, loginRequiredDetected: boolean): Promise<boolean> { private async detectLoginRequiredForLastPid(
pid: number | null,
loginRequiredDetected: boolean
): Promise<boolean> {
if (loginRequiredDetected) return true if (loginRequiredDetected) return true
if (!pid) return false if (!pid) return false
if (!await this.isWeChatPidActive(pid)) return false if (!(await this.isWeChatPidActive(pid))) return false
return await this.detectWeChatLoginRequired(pid) return await this.detectWeChatLoginRequired(pid)
} }
@@ -535,56 +422,6 @@ export class KeyService {
return fallbackPid ?? null return fallbackPid ?? null
} }
private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const runningPids = await this.findWeChatPids()
if (runningPids.length === 0) return true
await new Promise(r => setTimeout(r, 400))
}
return false
}
private async closeWeChatWindows(): Promise<boolean> {
if (!this.ensureUser32()) return false
let requested = false
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
const className = this.getClassName(hWnd)
const classLower = (className || '').toLowerCase()
const isWeChatWindow = this.isWeChatWindowTitle(title) || classLower.includes('wechat') || classLower.includes('weixin')
if (!isWeChatWindow) return true
requested = true
try {
this.PostMessageW?.(hWnd, this.WM_CLOSE, 0, 0)
} catch { }
return true
}, this.WNDENUMPROC_PTR)
this.EnumWindows(enumWindowsCallback, 0)
this.koffi.unregister(enumWindowsCallback)
return requested
}
private async killWeChatProcesses(): Promise<boolean> {
const requested = await this.closeWeChatWindows()
if (requested) {
const gracefulOk = await this.waitForWeChatExit(1500)
if (gracefulOk) return true
}
try {
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
} catch (e) { }
return await this.waitForWeChatExit(5000)
}
// --- Window Detection --- // --- Window Detection ---
private getWindowTitle(hWnd: any): string { private getWindowTitle(hWnd: any): string {
@@ -614,7 +451,7 @@ export class KeyService {
while (Date.now() - startTime < timeoutMs) { while (Date.now() - startTime < timeoutMs) {
let foundPid: number | null = null let foundPid: number | null = null
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => { const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd) const title = this.getWindowTitle(hWnd)
if (!this.isWeChatWindowTitle(title)) return true if (!this.isWeChatWindowTitle(title)) return true
@@ -633,14 +470,14 @@ export class KeyService {
this.koffi.unregister(enumWindowsCallback) this.koffi.unregister(enumWindowsCallback)
if (foundPid) return foundPid if (foundPid) return foundPid
await new Promise(r => setTimeout(r, 500)) await new Promise((r) => setTimeout(r, 500))
} }
return null return null
} }
private collectChildWindowInfos(parent: any): Array<{ title: string; className: string }> { private collectChildWindowInfos(parent: any): Array<{ title: string; className: string }> {
const children: Array<{ title: string; className: string }> = [] const children: Array<{ title: string; className: string }> = []
const enumChildCallback = this.koffi.register((hChild: any, lp: any) => { const enumChildCallback = this.koffi.register((hChild: any, _lp: any) => {
const title = this.getWindowTitle(hChild).trim() const title = this.getWindowTitle(hChild).trim()
const className = this.getClassName(hChild).trim() const className = this.getClassName(hChild).trim()
children.push({ title, className }) children.push({ title, className })
@@ -655,7 +492,16 @@ export class KeyService {
if (children.length === 0) return false if (children.length === 0) return false
const readyTexts = ['聊天', '登录', '账号'] const readyTexts = ['聊天', '登录', '账号']
const readyClassMarkers = ['WeChat', 'Weixin', 'TXGuiFoundation', 'Qt5', 'ChatList', 'MainWnd', 'BrowserWnd', 'ListView'] const readyClassMarkers = [
'WeChat',
'Weixin',
'TXGuiFoundation',
'Qt5',
'ChatList',
'MainWnd',
'BrowserWnd',
'ListView'
]
const readyChildCountThreshold = 14 const readyChildCountThreshold = 14
let classMatchCount = 0 let classMatchCount = 0
@@ -665,12 +511,12 @@ export class KeyService {
for (const child of children) { for (const child of children) {
const normalizedTitle = child.title.replace(/\s+/g, '') const normalizedTitle = child.title.replace(/\s+/g, '')
if (normalizedTitle) { if (normalizedTitle) {
if (readyTexts.some(marker => normalizedTitle.includes(marker))) return true if (readyTexts.some((marker) => normalizedTitle.includes(marker))) return true
titleMatchCount += 1 titleMatchCount += 1
} }
const className = child.className const className = child.className
if (className) { if (className) {
if (readyClassMarkers.some(marker => className.includes(marker))) return true if (readyClassMarkers.some((marker) => className.includes(marker))) return true
if (className.length > 5) { if (className.length > 5) {
classMatchCount += 1 classMatchCount += 1
hasValidClassName = true hasValidClassName = true
@@ -685,7 +531,9 @@ export class KeyService {
} }
private isLoginRelatedText(value: string): boolean { private isLoginRelatedText(value: string): boolean {
const normalized = String(value || '').replace(/\s+/g, '').toLowerCase() const normalized = String(value || '')
.replace(/\s+/g, '')
.toLowerCase()
if (!normalized) return false if (!normalized) return false
const keywords = [ const keywords = [
'登录', '登录',
@@ -741,7 +589,7 @@ export class KeyService {
const startTime = Date.now() const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) { while (Date.now() - startTime < timeoutMs) {
let ready = false let ready = false
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => { const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd) const title = this.getWindowTitle(hWnd)
if (!this.isWeChatWindowTitle(title)) return true if (!this.isWeChatWindowTitle(title)) return true
@@ -763,7 +611,7 @@ export class KeyService {
this.koffi.unregister(enumWindowsCallback) this.koffi.unregister(enumWindowsCallback)
if (ready) return true if (ready) return true
await new Promise(r => setTimeout(r, 500)) await new Promise((r) => setTimeout(r, 500))
} }
return true return true
} }
@@ -794,14 +642,14 @@ export class KeyService {
onStatus?.('正在检测微信界面组件...', 0) onStatus?.('正在检测微信界面组件...', 0)
await this.waitForWeChatWindowComponents(pid, Math.min(15000, this.getRemainingMs(deadline))) await this.waitForWeChatWindowComponents(pid, Math.min(15000, this.getRemainingMs(deadline)))
if (!await this.isWeChatPidActive(pid)) { if (!(await this.isWeChatPidActive(pid))) {
pid = await this.waitForProcessRestart(deadline, onStatus) pid = await this.waitForProcessRestart(deadline, onStatus)
continue continue
} }
const ok = this.initHook(pid) const ok = this.initHook(pid)
if (!ok) { if (!ok) {
if (!await this.isWeChatPidActive(pid)) { if (!(await this.isWeChatPidActive(pid))) {
this.cleanupDbKeyHook() this.cleanupDbKeyHook()
pid = await this.waitForProcessRestart(deadline, onStatus) pid = await this.waitForProcessRestart(deadline, onStatus)
continue continue
@@ -828,7 +676,10 @@ export class KeyService {
break break
} }
const loginRequired = await this.detectLoginRequiredForLastPid(pid, lastAttemptLoginRequiredDetected) const loginRequired = await this.detectLoginRequiredForLastPid(
pid,
lastAttemptLoginRequiredDetected
)
if (loginRequired) { if (loginRequired) {
return { return {
success: false, success: false,
@@ -840,77 +691,6 @@ export class KeyService {
return { success: false, error: '获取密钥超时', logs } return { success: false, error: '获取密钥超时', logs }
} }
private cleanWxid(wxid: string): string {
const first = wxid.indexOf('_')
if (first === -1) return wxid
const second = wxid.indexOf('_', first + 1)
if (second === -1) return wxid
return wxid.substring(0, second)
}
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
const cleanedWxid = this.cleanWxid(wxid)
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
const aesKey = md5Full.substring(0, 16)
return { xorKey, aesKey }
}
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
try {
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise<string[]> {
const candidates: string[] = []
const pushUnique = (value: string) => {
const v = String(value || '').trim()
if (!v || candidates.includes(v)) return
candidates.push(v)
}
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
if (manualDir) {
const normalized = manualDir.replace(/[\\/]+$/, '')
const dirName = normalized.split(/[\\/]/).pop() ?? ''
if (dirName.startsWith('wxid_')) pushUnique(dirName)
// 仅支持 WeChat 4.0:路径识别只匹配 xwechat_files
const marker = normalized.match(/[\\/]xwechat_files/i)
if (marker) {
const root = normalized.slice(0, marker.index! + marker[0].length)
try {
const { readdirSync, statSync } = await import('fs')
const { join } = await import('path')
for (const entry of readdirSync(root)) {
if (!entry.startsWith('wxid_')) continue
const full = join(root, entry)
try {
if (statSync(full).isDirectory()) pushUnique(entry)
} catch { }
}
} catch { }
}
}
pushUnique('unknown')
return candidates
}
async autoGetImageKey( async autoGetImageKey(
manualDir?: string, manualDir?: string,
onProgress?: (message: string) => void, onProgress?: (message: string) => void,
@@ -935,9 +715,16 @@ export class KeyService {
onProgress?.('正在查找模板文件...') onProgress?.('正在查找模板文件...')
let result = await this._findTemplateData(userDir, 32) let result = await this._findTemplateData(userDir, 32)
let { ciphertext, xorKey } = result let { ciphertext, xorKey } = result
const firstDiag = (this as { _imageTemplateDiag?: { const firstDiag = (
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number this as {
} })._imageTemplateDiag _imageTemplateDiag?: {
userDir: string
totalTFiles: number
v2Count: number
nonV2Count: number
}
}
)._imageTemplateDiag
// 如果找不到密钥,尝试扫描更多文件 // 如果找不到密钥,尝试扫描更多文件
if (ciphertext && xorKey === null) { if (ciphertext && xorKey === null) {
@@ -948,9 +735,17 @@ export class KeyService {
if (!ciphertext) { if (!ciphertext) {
// 用诊断信息给具体提示 // 用诊断信息给具体提示
const diag = (this as { _imageTemplateDiag?: { const diag =
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number (
} })._imageTemplateDiag || firstDiag this as {
_imageTemplateDiag?: {
userDir: string
totalTFiles: number
v2Count: number
nonV2Count: number
}
}
)._imageTemplateDiag || firstDiag
if (!diag || diag.totalTFiles === 0) { if (!diag || diag.totalTFiles === 0) {
return { return {
success: false, success: false,
@@ -978,7 +773,11 @@ export class KeyService {
'请在微信中查看更多图片后再试。' '请在微信中查看更多图片后再试。'
} }
} }
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' } if (xorKey === null)
return {
success: false,
error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片'
}
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
@@ -1000,7 +799,7 @@ export class KeyService {
return { success: true, xorKey, aesKey } return { success: true, xorKey, aesKey }
} }
// 等 5 秒再试 // 等 5 秒再试
await new Promise(r => setTimeout(r, 5000)) await new Promise((r) => setTimeout(r, 5000))
} }
return { return {
@@ -1012,7 +811,10 @@ export class KeyService {
} }
} }
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { private async _findTemplateData(
userDir: string,
limit: number = 32
): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
const { readdirSync, readFileSync, statSync } = await import('fs') const { readdirSync, readFileSync, statSync } = await import('fs')
const { join } = await import('path') const { join } = await import('path')
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
@@ -1027,7 +829,9 @@ export class KeyService {
if (entry.isDirectory()) collect(full, results, maxFiles) if (entry.isDirectory()) collect(full, results, maxFiles)
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
} }
} catch { /* 忽略无权限目录 */ } } catch {
/* 忽略无权限目录 */
}
} }
const files: string[] = [] const files: string[] = []
@@ -1035,7 +839,11 @@ export class KeyService {
// 按修改时间降序 // 按修改时间降序
files.sort((a, b) => { files.sort((a, b) => {
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } try {
return statSync(b).mtimeMs - statSync(a).mtimeMs
} catch {
return 0
}
}) })
let ciphertext: Buffer | null = null let ciphertext: Buffer | null = null
@@ -1058,17 +866,24 @@ export class KeyService {
} }
// 提取密文(取第一个有效的) // 提取密文(取第一个有效的)
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1f) {
ciphertext = data.subarray(0xF, 0x1F) ciphertext = data.subarray(0xf, 0x1f)
}
} catch {
/* 忽略 */
} }
} catch { /* 忽略 */ }
} }
// 计算 XOR 密钥 // 计算 XOR 密钥
let xorKey: number | null = null let xorKey: number | null = null
let maxCount = 0 let maxCount = 0
for (const [key, count] of Object.entries(tailCounts)) { for (const [key, count] of Object.entries(tailCounts)) {
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k } if (count > maxCount) {
maxCount = count
const [x, y] = key.split('_').map(Number)
const k = x ^ 0xff
if (k === (y ^ 0xd9)) xorKey = k
}
} }
// 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么 // 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么
@@ -1091,8 +906,19 @@ export class KeyService {
if (!this.ensureKernel32()) return null if (!this.ensureKernel32()) return null
// 直接用已加载的 kernel32 实例,用 uintptr 传地址 // 直接用已加载的 kernel32 实例,用 uintptr 传地址
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t']) const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', [
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')]) 'void*',
'uintptr',
'void*',
'size_t'
])
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', [
'void*',
'uintptr',
'void*',
'size_t',
this.koffi.out('size_t*')
])
// RW 保护标志(只扫可写区域,速度更快) // RW 保护标志(只扫可写区域,速度更快)
const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
@@ -1101,7 +927,7 @@ export class KeyService {
const PAGE_GUARD = 0x100 const PAGE_GUARD = 0x100
const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64 const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64
const hProcess = this.OpenProcess(0x1F0FFF, false, pid) const hProcess = this.OpenProcess(0x1f0fff, false, pid)
if (!hProcess) return null if (!hProcess) return null
try { try {
@@ -1110,7 +936,7 @@ export class KeyService {
let addr = 0 let addr = 0
const mbi = Buffer.alloc(MBI_SIZE) const mbi = Buffer.alloc(MBI_SIZE)
while (addr < 0x7FFFFFFFFFFF) { while (addr < 0x7fffffffffff) {
const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE) const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE)
if (ret === 0) break if (ret === 0) break
// MEMORY_BASIC_INFORMATION x64 布局: // MEMORY_BASIC_INFORMATION x64 布局:
@@ -1126,11 +952,13 @@ export class KeyService {
const state = mbi.readUInt32LE(32) const state = mbi.readUInt32LE(32)
const protect = mbi.readUInt32LE(36) const protect = mbi.readUInt32LE(36)
if (state === MEM_COMMIT && if (
state === MEM_COMMIT &&
protect !== PAGE_NOACCESS && protect !== PAGE_NOACCESS &&
(protect & PAGE_GUARD) === 0 && (protect & PAGE_GUARD) === 0 &&
(protect & RW_FLAGS) !== 0 && (protect & RW_FLAGS) !== 0 &&
size <= 50 * 1024 * 1024) { size <= 50 * 1024 * 1024
) {
regions.push([base, size]) regions.push([base, size])
} }
const next = base + size const next = base + size
@@ -1148,7 +976,7 @@ export class KeyService {
const [base, size] = regions[i] const [base, size] = regions[i]
if (i % 20 === 0) { if (i % 20 === 0) {
onProgress?.(`扫描进度 ${i}/${regions.length}...`) onProgress?.(`扫描进度 ${i}/${regions.length}...`)
await new Promise(r => setTimeout(r, 1)) // 让出事件循环 await new Promise((r) => setTimeout(r, 1)) // 让出事件循环
} }
let offset = 0 let offset = 0
@@ -1159,17 +987,29 @@ export class KeyService {
const buf = Buffer.alloc(chunkSize) const buf = Buffer.alloc(chunkSize)
const bytesReadOut = [0] const bytesReadOut = [0]
const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut) const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut)
if (!ok || bytesReadOut[0] === 0) { offset += chunkSize; trailing = null; continue } if (!ok || bytesReadOut[0] === 0) {
offset += chunkSize
trailing = null
continue
}
const data: Buffer = trailing ? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])]) : buf.subarray(0, bytesReadOut[0]) const data: Buffer = trailing
? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])])
: buf.subarray(0, bytesReadOut[0])
// 搜索 ASCII 32字节密钥 // 搜索 ASCII 32字节密钥
const key = this._searchAsciiKey(data, ciphertext) const key = this._searchAsciiKey(data, ciphertext)
if (key) { this.CloseHandle(hProcess); return key } if (key) {
this.CloseHandle(hProcess)
return key
}
// 搜索 UTF-16LE 32字节密钥 // 搜索 UTF-16LE 32字节密钥
const key16 = this._searchUtf16Key(data, ciphertext) const key16 = this._searchUtf16Key(data, ciphertext)
if (key16) { this.CloseHandle(hProcess); return key16 } if (key16) {
this.CloseHandle(hProcess)
return key16
}
trailing = data.subarray(Math.max(0, data.length - OVERLAP)) trailing = data.subarray(Math.max(0, data.length - OVERLAP))
offset += chunkSize offset += chunkSize
@@ -1187,12 +1027,16 @@ export class KeyService {
if (this._isAlphaNum(data[i])) continue if (this._isAlphaNum(data[i])) continue
let valid = true let valid = true
for (let j = 1; j <= 32; j++) { for (let j = 1; j <= 32; j++) {
if (!this._isAlphaNum(data[i + j])) { valid = false; break } if (!this._isAlphaNum(data[i + j])) {
valid = false
break
}
} }
if (!valid) continue if (!valid) continue
if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue
const keyBytes = data.subarray(i + 1, i + 33) const keyBytes = data.subarray(i + 1, i + 33)
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) if (this._verifyAesKey(keyBytes, ciphertext))
return keyBytes.toString('ascii').substring(0, 16)
} }
return null return null
} }
@@ -1201,18 +1045,22 @@ export class KeyService {
for (let i = 0; i < data.length - 65; i++) { for (let i = 0; i < data.length - 65; i++) {
let valid = true let valid = true
for (let j = 0; j < 32; j++) { for (let j = 0; j < 32; j++) {
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break } if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) {
valid = false
break
}
} }
if (!valid) continue if (!valid) continue
const keyBytes = Buffer.alloc(32) const keyBytes = Buffer.alloc(32)
for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2] for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2]
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) if (this._verifyAesKey(keyBytes, ciphertext))
return keyBytes.toString('ascii').substring(0, 16)
} }
return null return null
} }
private _isAlphaNum(b: number): boolean { private _isAlphaNum(b: number): boolean {
return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39) return (b >= 0x61 && b <= 0x7a) || (b >= 0x41 && b <= 0x5a) || (b >= 0x30 && b <= 0x39)
} }
private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean { private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean {
@@ -1221,12 +1069,14 @@ export class KeyService {
decipher.setAutoPadding(false) decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
// 支持 JPEG / PNG / WEBP / WXGF / GIF // 支持 JPEG / PNG / WEBP / WXGF / GIF
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true if (dec[0] === 0xff && dec[1] === 0xd8 && dec[2] === 0xff) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4e && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false return false
} catch { return false } } catch {
return false
}
} }
} }
+124
View File
@@ -0,0 +1,124 @@
import { safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import { LEGACY_MIGRATION_RESULT_FD_ENV, LEGACY_MIGRATION_SOURCE_ENV } from './app-data-bootstrap'
import { isValidDatabaseKey } from './database-key-store'
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
export interface LegacySecretFailure {
asset: string
error: string
}
export interface LegacySecretBundle {
token?: string
aiProviderKeys?: { version: 1; keys: Record<string, string> }
imageKeys?: {
version: 1
accounts: Record<string, { xorKey: string; aesKey: string; updatedAt: number }>
}
legacyDatabaseKey?: string
databaseKeys: Record<string, string>
failures: LegacySecretFailure[]
}
function isStringRecord(value: unknown): value is Record<string, string> {
return (
Boolean(value) &&
typeof value === 'object' &&
Object.values(value as Record<string, unknown>).every((item) => typeof item === 'string')
)
}
function decryptFile(filePath: string): string {
return safeStorage.decryptString(fs.readFileSync(filePath))
}
export function decryptLegacySecrets(sourceRoot: string): LegacySecretBundle {
const result: LegacySecretBundle = { databaseKeys: {}, failures: [] }
if (!safeStorage.isEncryptionAvailable()) {
result.failures.push({ asset: 'safeStorage', error: '系统安全存储不可用' })
return result
}
const readSecret = (
asset: string,
relativePath: string,
apply: (plain: string) => void
): void => {
const filePath = path.join(sourceRoot, relativePath)
if (!fs.pathExistsSync(filePath)) return
try {
apply(decryptFile(filePath))
} catch {
result.failures.push({ asset, error: '旧加密数据无法解密或格式无效' })
}
}
readSecret('local-api-token.bin', 'local-api-token.bin', (plain) => {
if (!TOKEN_PATTERN.test(plain)) throw new Error('invalid token')
result.token = plain
})
readSecret('ai-provider-keys.bin', 'ai-provider-keys.bin', (plain) => {
const parsed = JSON.parse(plain) as { version?: unknown; keys?: unknown }
if (parsed.version !== 1 || !isStringRecord(parsed.keys))
throw new Error('invalid provider keys')
result.aiProviderKeys = { version: 1, keys: parsed.keys }
})
readSecret('wechat-image-keys.bin', 'wechat-image-keys.bin', (plain) => {
const parsed = JSON.parse(plain) as {
version?: unknown
accounts?: Record<string, { xorKey?: unknown; aesKey?: unknown; updatedAt?: unknown }>
}
if (
parsed.version !== 1 ||
!parsed.accounts ||
Object.values(parsed.accounts).some(
(entry) =>
!entry ||
typeof entry.xorKey !== 'string' ||
typeof entry.aesKey !== 'string' ||
typeof entry.updatedAt !== 'number'
)
) {
throw new Error('invalid image keys')
}
result.imageKeys = {
version: 1,
accounts: parsed.accounts as NonNullable<LegacySecretBundle['imageKeys']>['accounts']
}
})
readSecret('wechat-db-key.bin', 'wechat-db-key.bin', (plain) => {
if (!isValidDatabaseKey(plain)) throw new Error('invalid legacy database key')
result.legacyDatabaseKey = plain.trim().replace(/^0x/i, '')
})
const databaseKeysRoot = path.join(sourceRoot, 'database-keys')
if (fs.pathExistsSync(databaseKeysRoot)) {
for (const entry of fs.readdirSync(databaseKeysRoot, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.bin')) continue
readSecret(`database-keys/${entry.name}`, path.join('database-keys', entry.name), (plain) => {
if (!isValidDatabaseKey(plain)) throw new Error('invalid database key')
result.databaseKeys[entry.name] = plain.trim().replace(/^0x/i, '')
})
}
}
return result
}
export function writeLegacySecretHelperResult(result: LegacySecretBundle): void {
const fd = Number(process.env[LEGACY_MIGRATION_RESULT_FD_ENV] || '3')
if (!Number.isInteger(fd) || fd < 3) throw new Error('invalid migration result pipe')
fs.writeFileSync(fd, JSON.stringify(result), 'utf8')
}
export function runLegacySafeStorageHelper(): void {
const sourceRoot = process.env[LEGACY_MIGRATION_SOURCE_ENV]?.trim()
if (!sourceRoot) throw new Error('legacy migration source is missing')
writeLegacySecretHelperResult(decryptLegacySecrets(path.resolve(sourceRoot)))
}
+1
View File
@@ -12,6 +12,7 @@ export function getResourceRoots(): string[] {
const execDir = dirname(process.execPath) const execDir = dirname(process.execPath)
return unique([ return unique([
process.env.TRACEMEMO_RESOURCES_PATH || '',
process.env.WECHATEXPLORER_RESOURCES_PATH || '', process.env.WECHATEXPLORER_RESOURCES_PATH || '',
join(process.cwd(), 'resources'), join(process.cwd(), 'resources'),
join(process.cwd(), 'resources', 'resources'), join(process.cwd(), 'resources', 'resources'),
+18 -16
View File
@@ -3,10 +3,12 @@ import { existsSync, promises as fs } from 'fs'
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { isPackagedRuntime } from '../runtime-mode' import { isPackagedRuntime } from '../runtime-mode'
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md') const SKILL_RELATIVE_PATHS = [
const GITHUB_URL = join('skill', 'tracememo-reader', 'SKILL.md'),
'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader' join('skill', 'wechatexplorer-reader', 'SKILL.md')
const SKILL_VERSION = 'v1.1' ]
const GITHUB_URL = 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/tracememo-reader'
const SKILL_VERSION = 'v1.2'
type SkillResourceSource = 'development' | 'bundled' type SkillResourceSource = 'development' | 'bundled'
@@ -54,19 +56,19 @@ function uniqueCandidates(candidates: SkillCandidate[]): SkillCandidate[] {
export function getSkillCandidates(environment?: SkillPathEnvironment): SkillCandidate[] { export function getSkillCandidates(environment?: SkillPathEnvironment): SkillCandidate[] {
const runtime = environment || currentEnvironment() const runtime = environment || currentEnvironment()
const developmentPaths = [ const developmentPaths = SKILL_RELATIVE_PATHS.flatMap((relativePath) => [
join(runtime.appPath, 'docs', SKILL_RELATIVE_PATH), join(runtime.appPath, 'docs', relativePath),
join(runtime.cwd, 'docs', SKILL_RELATIVE_PATH), join(runtime.cwd, 'docs', relativePath),
join(dirname(runtime.appPath), 'docs', SKILL_RELATIVE_PATH) join(dirname(runtime.appPath), 'docs', relativePath)
] ])
const execDirectory = dirname(runtime.execPath) const execDirectory = dirname(runtime.execPath)
const bundledPaths = [ const bundledPaths = SKILL_RELATIVE_PATHS.flatMap((relativePath) => [
join(runtime.resourcesPath, SKILL_RELATIVE_PATH), join(runtime.resourcesPath, relativePath),
join(runtime.resourcesPath, 'resources', SKILL_RELATIVE_PATH), join(runtime.resourcesPath, 'resources', relativePath),
join(dirname(runtime.appPath), SKILL_RELATIVE_PATH), join(dirname(runtime.appPath), relativePath),
join(execDirectory, 'resources', SKILL_RELATIVE_PATH), join(execDirectory, 'resources', relativePath),
join(dirname(execDirectory), 'Resources', SKILL_RELATIVE_PATH) join(dirname(execDirectory), 'Resources', relativePath)
] ])
return uniqueCandidates( return uniqueCandidates(
runtime.packaged runtime.packaged
? bundledPaths.map((path) => ({ path, source: 'bundled' })) ? bundledPaths.map((path) => ({ path, source: 'bundled' }))
+24 -7
View File
@@ -272,21 +272,38 @@ export function resolveWindowsNativeAccountRoot(
.find((candidate) => candidate && isAsciiPath(candidate)) .find((candidate) => candidate && isAsciiPath(candidate))
if (!publicRoot || !isAsciiPath(publicRoot)) return accountRoot if (!publicRoot || !isAsciiPath(publicRoot)) return accountRoot
// Preserve the legacy ASCII bridge path so existing junctions remain reusable. const bridgeId = crypto
const bridgeRoot = path.join(publicRoot, 'WechatExplorer', 'path-bridges')
const bridgePath = path.join(
bridgeRoot,
crypto
.createHash('sha256') .createHash('sha256')
.update(path.resolve(accountRoot).toLowerCase()) .update(path.resolve(accountRoot).toLowerCase())
.digest('hex') .digest('hex')
.slice(0, 24) .slice(0, 24)
) const legacyBridgePath = path.join(publicRoot, 'WechatExplorer', 'path-bridges', bridgeId)
try {
if (fs.existsSync(legacyBridgePath)) {
const legacyTarget = fs.realpathSync.native(legacyBridgePath)
const canonicalAccountRoot = fs.realpathSync.native(accountRoot)
if (
path.resolve(legacyTarget).toLowerCase() ===
path.resolve(canonicalAccountRoot).toLowerCase()
) {
return legacyBridgePath
}
}
} catch {
// An unreadable legacy junction is left untouched; TraceMemo creates its own bridge below.
}
const bridgeRoot = path.join(publicRoot, 'TraceMemo', 'path-bridges')
const bridgePath = path.join(bridgeRoot, bridgeId)
try { try {
fs.ensureDirSync(bridgeRoot) fs.ensureDirSync(bridgeRoot)
if (fs.existsSync(bridgePath)) { if (fs.existsSync(bridgePath)) {
const existingTarget = fs.realpathSync.native(bridgePath) const existingTarget = fs.realpathSync.native(bridgePath)
if (path.resolve(existingTarget).toLowerCase() === path.resolve(accountRoot).toLowerCase()) { const canonicalAccountRoot = fs.realpathSync.native(accountRoot)
if (
path.resolve(existingTarget).toLowerCase() ===
path.resolve(canonicalAccountRoot).toLowerCase()
) {
return bridgePath return bridgePath
} }
fs.removeSync(bridgePath) fs.removeSync(bridgePath)
@@ -478,7 +478,7 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
API Bearer Token Token API Center API Bearer Token Token API Center
Claude / Codex Claude / Codex
<br /> <br />
:<code>docs/skill/wechatexplorer-reader/SKILL.md</code> <code>docs/skill/tracememo-reader/SKILL.md</code>
</div> </div>
</section> </section>
@@ -21,8 +21,8 @@ export function SkillDetails({
<dd>TraceMemo Reader</dd> <dd>TraceMemo Reader</dd>
</div> </div>
<div> <div>
<dt></dt> <dt></dt>
<dd>wechatexplorer-reader</dd> <dd>tracememo-reader</dd>
</div> </div>
<div> <div>
<dt></dt> <dt></dt>
@@ -12,6 +12,6 @@ export const AGENT_INSTALL_TARGETS: { value: AgentInstallTarget; label: string }
] ]
export const githubLatestUrl = export const githubLatestUrl =
'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader' 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/tracememo-reader'
export const githubVersionedInstallUrl: string | null = null export const githubVersionedInstallUrl: string | null = null
@@ -33,7 +33,7 @@ export function buildSkillInstallInstruction({
source.type === 'local' source.type === 'local'
? `${opening(target)}\n\n${source.directoryPath}\n\n请先阅读该目录中的 SKILL.md,然后调用:` ? `${opening(target)}\n\n${source.directoryPath}\n\n请先阅读该目录中的 SKILL.md,然后调用:`
: `请从以下地址安装 TraceMemo Reader Skill\n\n${source.installUrl}\n\n阅读 SKILL.md 后,调用:` : `请从以下地址安装 TraceMemo Reader Skill\n\n${source.installUrl}\n\n阅读 SKILL.md 后,调用:`
return `${sourceText}\n\n${healthUrl}\n\n先调用公开的 health 接口验证服务。然后请用户在 TraceMemo → API Center → API Token 中点击“复制 Token”,并把 Token 配置为 Agent 本机环境变量 WECHATEXPLORER_API_TOKEN。读取联系人、会话或聊天记录时,必须发送 Authorization: Bearer $WECHATEXPLORER_API_TOKEN。此服务是 Local HTTP API,不是 MCP Server。安装完成后告诉我验证结果。` return `${sourceText}\n\n${healthUrl}\n\n先调用公开的 health 接口验证服务。然后请用户在 TraceMemo → API Center → API Token 中点击“复制 Token”,并把 Token 配置为 Agent 本机环境变量 TRACEMEMO_API_TOKEN。读取联系人、会话或聊天记录时,必须发送 Authorization: Bearer $TRACEMEMO_API_TOKEN。若旧配置尚未升级,可兼容读取 WECHATEXPLORER_API_TOKEN,但新配置必须优先使用 TRACEMEMO_API_TOKEN。此服务是 Local HTTP API,不是 MCP Server。安装完成后告诉我验证结果。`
} }
export function buildSkillVerificationPrompt(): string { export function buildSkillVerificationPrompt(): string {
+189 -5
View File
@@ -4,7 +4,7 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const root = path.resolve(__dirname, '../../..') const root = path.resolve(__dirname, '../../..')
const fixture = require(path.join(root, 'tests/fixtures/chat-data.json')) const fixture = structuredClone(require(path.join(root, 'tests/fixtures/chat-data.json')))
const userData = process.env.WXE_E2E_USER_DATA const userData = process.env.WXE_E2E_USER_DATA
if (!userData) throw new Error('WXE_E2E_USER_DATA is required') if (!userData) throw new Error('WXE_E2E_USER_DATA is required')
app.setPath('userData', userData) app.setPath('userData', userData)
@@ -15,6 +15,151 @@ const VALID_KEY = 'a'.repeat(64)
const imageData = const imageData =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=' 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=' const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
const formatFixtureDateTime = (timestampSeconds) => {
const date = new Date(timestampSeconds * 1000)
const pad = (value) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
const allFixtureMessages = Object.values(fixture.messages).flat()
const latestFixtureTime = Math.max(...allFixtureMessages.map((message) => message.createTime || 0))
const fixtureTimeOffset = Math.floor(Date.now() / 1000) - 3600 - latestFixtureTime
for (const message of allFixtureMessages) {
message.createTime = (message.createTime || latestFixtureTime) + fixtureTimeOffset
message.datetime = formatFixtureDateTime(message.createTime)
}
const emptyTimings = () => ({
queryUnderstandingMs: 0,
contactResolutionMs: 0,
knowledgeSearchMs: 0,
workerIpcMs: 0,
workerBootMs: 0,
dispatchMs: 0,
workerSqlMs: 0,
responseSerializeMs: 0,
responseTransferMs: 0,
workerQueueMs: 0,
workerExecutionMs: 0,
globalCountMs: 0,
voiceCoverageMs: 0,
wcdbQueueMs: 0,
wcdbExecutionMs: 0,
senderEnrichmentMs: 0,
ipcMs: 0,
serializationMs: 0,
otherMs: 0,
ftsMs: 0,
chunkExpandMs: 0,
messageLoadMs: 0,
rankingMs: 0,
candidateRankingMs: 0,
evidenceBuildMs: 0,
aggregationMs: 0,
contextPreparationMs: 0,
agentDecisionMs: 0,
agentToolMs: 0,
aiGenerationMs: 0,
totalMs: 1
})
const aiSearchResult = (request) => {
const failure = process.env.WXE_E2E_AI_FAILURE
const evidence = [
{
id: 'E1',
chunkId: 'fixture-chunk',
conversationId: 'group-regular-md5',
conversationName: '产品测试群',
conversationType: 'group',
startTime: fixture.messages['group-regular-md5'][0].createTime * 1000,
endTime: fixture.messages['group-regular-md5'][0].createTime * 1000,
messageId: 'msg-text',
senderId: 'wxid_fixture_member',
sender: '测试成员',
timestamp: fixture.messages['group-regular-md5'][0].createTime * 1000,
messageIds: ['msg-text'],
sourceKind: 'text',
text: '这是一条脱敏测试消息',
score: 1
}
]
return {
requestId: request.requestId,
status: failure ? 'ai_failed' : 'completed',
plan: {
intent: 'general',
keywords: ['测试'],
variants: [],
source: 'local',
scopeLabel: '全局搜索',
rangeLabel: '近 30 天',
timeRange: {
startTime: Math.floor(Date.now() / 1000) - 30 * 86400,
endTime: Math.floor(Date.now() / 1000),
label: '近 30 天',
reason: 'E2E fixture',
source: 'ui'
},
contactNames: []
},
knowledge: {
source: 'knowledge',
state: 'ready',
indexedMessageCount: allFixtureMessages.length,
indexedChunkCount: 1,
totalMessages: allFixtureMessages.length,
voiceCoverage: {
voiceMessageCount: 1,
transcribedVoiceCount: 1,
failedVoiceCount: 0,
voiceCoverageComplete: true
}
},
candidateEvidenceCount: 1,
retrieval: {
intent: 'general',
timeRange: {
startTime: Math.floor(Date.now() / 1000) - 30 * 86400,
endTime: Math.floor(Date.now() / 1000),
label: '近 30 天',
reason: 'E2E fixture',
source: 'ui'
},
retrievalMode: 'global_fts',
candidateCount: 1,
uniqueCandidateCount: 1,
sourceMessageCount: allFixtureMessages.length,
sourceCoverage: 'complete',
isComplete: true,
fallbackUsed: false,
suspicious: false
},
evidence,
contextEvidenceCount: 1,
aggregation: {
messageCount: 1,
peopleCount: 1,
conversationCount: 1,
people: [],
conversations: []
},
agent: { mode: 'fallback', toolCalls: 0, trace: [], fallbackReason: 'E2E fixture' },
citationValidation: { status: 'valid', invalidCitationIds: [] },
timings: emptyTimings(),
answer: failure ? undefined : '固定假回答:测试数据中的核心流程正常。',
ai: {
providerName: '本地假服务',
modelName: '固定响应模型',
inputTokens: 10,
inputTokensEstimated: false
},
error: failure ? `本地假服务错误 ${failure}` : undefined,
errorStage: failure ? 'ai_generating' : undefined,
elapsedMs: 1
}
}
const reportJson = JSON.stringify({ const reportJson = JSON.stringify({
overview: '固定脱敏日报', overview: '固定脱敏日报',
hero: { hero: {
@@ -88,7 +233,7 @@ const handle = (channel, fn) => {
const startupCache = () => ({ const startupCache = () => ({
self: fixture.self, self: fixture.self,
contacts, contacts,
updatedAt: 1785553200000 updatedAt: Date.now()
}) })
handle('settings:get', () => ({ settings, settingsPath: path.join(userData, 'settings.json') })) handle('settings:get', () => ({ settings, settingsPath: path.join(userData, 'settings.json') }))
@@ -113,11 +258,11 @@ handle('key:clearSavedDbKey', () => {
handle('key:getEnvironment', () => ({ handle('key:getEnvironment', () => ({
platform: process.platform, platform: process.platform,
osVersion: process.platform === 'win32' ? 'Windows fixture' : 'macOS fixture', osVersion: process.platform === 'win32' ? 'Windows fixture' : 'macOS fixture',
appVersion: 'v2.1.6', appVersion: 'v2.2.0',
wechatVersion: '4.1.9.57', wechatVersion: '4.1.9.57',
dataStructureVersion: settings.dbRoot === 'fixture-account' ? '微信 4.xWCDB' : '未检测到', dataStructureVersion: settings.dbRoot === 'fixture-account' ? '微信 4.xWCDB' : '未检测到',
dataDirectoryDetected: settings.dbRoot === 'fixture-account', dataDirectoryDetected: settings.dbRoot === 'fixture-account',
diagnosticSummary: 'TraceMemo: v2.1.6\n数据目录: 已检测到', diagnosticSummary: 'TraceMemo: v2.2.0\n数据目录: 已检测到',
autoDetectSupported: true, autoDetectSupported: true,
wechatRunning: true, wechatRunning: true,
accountIdentified: connected, accountIdentified: connected,
@@ -287,6 +432,45 @@ handle('ai:chat', (messages) => {
} }
return { success: true, data: '固定假回答:测试数据中的核心流程正常。' } return { success: true, data: '固定假回答:测试数据中的核心流程正常。' }
}) })
handle('knowledge:getStatus', () => ({
accountId: fixture.self.wxid,
state: 'ready',
indexedMessageCount: allFixtureMessages.length,
indexedChunkCount: 1,
sourceMessageCount: allFixtureMessages.length,
processedMessages: allFixtureMessages.length,
totalMessages: allFixtureMessages.length,
estimatedRemainingMs: 0,
databaseBytes: 1024,
walBytes: 0,
shmBytes: 0
}))
handle('knowledge:startIndex', () => ({ success: true }))
handle('knowledge:search', () => ({
source: 'knowledge',
state: 'ready',
evidence: [],
indexedMessageCount: allFixtureMessages.length,
indexedChunkCount: 1,
totalMessages: allFixtureMessages.length,
timings: {
workerIpcMs: 0,
workerBootMs: 0,
dispatchMs: 0,
workerSqlMs: 0,
responseTransferMs: 0,
responseSerializeMs: 0,
ftsMs: 0,
messageLoadMs: 0,
chunkExpandMs: 0,
rankingMs: 0,
totalMs: 0
}
}))
handle('ai-search:getProviderStatus', () => ({ configured: true, requiresConsent: false }))
handle('ai-search:authorizeExternalProvider', () => ({ success: true }))
handle('ai-search:run', (request) => aiSearchResult(request))
handle('ai-search:cancel', () => ({ cancelled: true }))
handle('report:export', () => { handle('report:export', () => {
const htmlPath = path.join(userData, 'fixture-report.html') const htmlPath = path.join(userData, 'fixture-report.html')
@@ -424,7 +608,7 @@ handle('accounts:discover', (inputPath) =>
) )
handle('agent-hub:getStatus', () => ({ state: 'disconnected', connected: false })) handle('agent-hub:getStatus', () => ({ state: 'disconnected', connected: false }))
handle('agent-hub:getLogs', () => []) handle('agent-hub:getLogs', () => [])
handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.1.6' })) handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.2.0' }))
for (const channel of [ for (const channel of [
'export:start', 'export:start',
+15
View File
@@ -62,4 +62,19 @@ describe('ApiTokenStore', () => {
expect(fs.existsSync(filePath)).toBe(false) expect(fs.existsSync(filePath)).toBe(false)
expect(store.revealToken().token).toBeUndefined() expect(store.revealToken().token).toBeUndefined()
}) })
it('does not silently replace a legacy token when migration is deferred', () => {
const store = new ApiTokenStore(filePath)
store.setAutomaticGenerationBlocked('legacy token migration pending')
expect(store.ensureToken()).toMatchObject({
success: false,
hasToken: false,
error: 'legacy token migration pending'
})
expect(fs.existsSync(filePath)).toBe(false)
expect(store.rotateToken()).toMatchObject({ success: true, hasToken: true })
expect(fs.existsSync(filePath)).toBe(true)
})
}) })
+183
View File
@@ -0,0 +1,183 @@
import fs from 'fs-extra'
import { DatabaseSync } from 'node:sqlite'
import os from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const electronState = vi.hoisted(() => ({
paths: new Map<string, string>([
['appData', '/tmp/tracememo-migration-test-app-data'],
['temp', '/tmp'],
['home', '/tmp']
]),
name: ''
}))
vi.mock('electron', () => ({
app: {
isPackaged: true,
setName: (name: string) => {
electronState.name = name
},
getPath: (name: string) => electronState.paths.get(name) || '/tmp',
setPath: (name: string, value: string) => electronState.paths.set(name, value)
},
dialog: { showMessageBox: vi.fn() },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(value, 'utf8').reverse(),
decryptString: (value: Buffer) => Buffer.from(value).reverse().toString('utf8')
}
}))
import { assessMigration, executeMigration } from '../../src/main/app-data-migration'
import { getUserDataRoots } from '../../src/main/app-data-paths'
let root = ''
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'tracememo-migration-'))
})
afterEach(() => {
fs.removeSync(root)
})
function roots() {
return getUserDataRoots(path.join(root, 'Application Support'))
}
function writeFixture(filePath: string, content = 'fixture'): void {
fs.ensureDirSync(path.dirname(filePath))
fs.writeFileSync(filePath, content)
}
describe('TraceMemo app data migration', () => {
it('treats a new TraceMemo install as clean when no legacy directory exists', () => {
const assessment = assessMigration(roots())
expect(assessment).toMatchObject({
shouldPrompt: false,
reason: 'clean-install',
currentHasAssets: false
})
expect(assessment.selection.selected).toBe(roots().current)
})
it('ignores an empty WechatExplorer legacy directory', () => {
const fixture = roots()
fs.ensureDirSync(fixture.legacy)
expect(assessMigration(fixture)).toMatchObject({
shouldPrompt: false,
reason: 'legacy-empty'
})
})
it('detects legacy settings but never proposes overwriting valid TraceMemo data', () => {
const fixture = roots()
writeFixture(path.join(fixture.legacy, 'settings.json'), '{"dbRoot":"legacy"}')
expect(assessMigration(fixture)).toMatchObject({
shouldPrompt: true,
reason: 'legacy-assets-detected',
sourceRoot: fixture.legacy
})
writeFixture(path.join(fixture.current, 'settings.json'), '{"dbRoot":"current"}')
expect(assessMigration(fixture)).toMatchObject({
shouldPrompt: false,
reason: 'current-data-present'
})
})
it('copies Settings, Knowledge companions, Token, Provider keys and Agent credentials', async () => {
const fixture = roots()
const source = fixture.legacy
const target = fixture.current
writeFixture(path.join(source, 'settings.json'), '{"dbRoot":"legacy-db"}')
writeFixture(path.join(source, 'ai-providers.json'), '{"version":1,"providers":[]}')
writeFixture(path.join(source, 'local-api-token.bin'), 'legacy-encrypted-token')
writeFixture(path.join(source, 'ai-provider-keys.bin'), 'legacy-encrypted-provider')
const knowledgeDirectory = path.join(source, 'knowledge', 'account-hash')
fs.ensureDirSync(knowledgeDirectory)
const knowledgePath = path.join(knowledgeDirectory, 'knowledge.sqlite')
const database = new DatabaseSync(knowledgePath)
database.exec('PRAGMA journal_mode=WAL')
database.exec('PRAGMA wal_autocheckpoint=0')
database.exec('CREATE TABLE messages (id INTEGER PRIMARY KEY, text TEXT NOT NULL)')
database.exec("INSERT INTO messages(text) VALUES ('migration fixture')")
expect(fs.existsSync(`${knowledgePath}-wal`)).toBe(true)
expect(fs.existsSync(`${knowledgePath}-shm`)).toBe(true)
const agentLegacy = path.join(root, 'home', '.wechatexplorer', 'accounts')
const agentCurrent = path.join(root, 'home', '.tracememo', 'accounts')
writeFixture(path.join(agentLegacy, 'account.json'), '{"token":"credential"}')
writeFixture(path.join(agentLegacy, 'account.sync.json'), '{"cursor":"1"}')
try {
const result = await executeMigration(source, target, {
decryptLegacySecrets: async () => ({
token: 'A'.repeat(43),
aiProviderKeys: { version: 1, keys: { provider: 'provider-secret' } },
databaseKeys: {},
failures: []
}),
agentRoots: () => ({ legacy: agentLegacy, current: agentCurrent }),
now: () => new Date('2026-08-11T00:00:00.000Z')
})
expect(result.state.status).toBe('completed')
expect(fs.readFileSync(path.join(target, 'settings.json'), 'utf8')).toContain('legacy-db')
expect(
fs.existsSync(path.join(target, 'knowledge', 'account-hash', 'knowledge.sqlite'))
).toBe(true)
expect(
fs.existsSync(path.join(target, 'knowledge', 'account-hash', 'knowledge.sqlite-wal'))
).toBe(true)
expect(
fs.existsSync(path.join(target, 'knowledge', 'account-hash', 'knowledge.sqlite-shm'))
).toBe(true)
expect(
Buffer.from(fs.readFileSync(path.join(target, 'local-api-token.bin')))
.reverse()
.toString('utf8')
).toBe('A'.repeat(43))
expect(
JSON.parse(
Buffer.from(fs.readFileSync(path.join(target, 'ai-provider-keys.bin')))
.reverse()
.toString('utf8')
)
).toEqual({ version: 1, keys: { provider: 'provider-secret' } })
expect(fs.readFileSync(path.join(agentCurrent, 'account.json'), 'utf8')).toContain(
'credential'
)
expect(fs.existsSync(path.join(source, 'settings.json'))).toBe(true)
expect(
fs.existsSync(path.join(source, 'knowledge', 'account-hash', 'knowledge.sqlite'))
).toBe(true)
} finally {
database.close()
}
})
it('is idempotent and does not overwrite existing TraceMemo assets', async () => {
const fixture = roots()
writeFixture(path.join(fixture.legacy, 'settings.json'), '{"dbRoot":"legacy"}')
writeFixture(path.join(fixture.current, 'settings.json'), '{"dbRoot":"current"}')
const result = await executeMigration(fixture.legacy, fixture.current, {
decryptLegacySecrets: async () => ({ databaseKeys: {}, failures: [] }),
agentRoots: () => ({
legacy: path.join(root, 'agent-legacy'),
current: path.join(root, 'agent-current')
}),
now: () => new Date('2026-08-11T00:00:00.000Z')
})
expect(result.state.items['settings.json']).toBe('skipped')
expect(fs.readFileSync(path.join(fixture.current, 'settings.json'), 'utf8')).toContain(
'current'
)
expect(fs.readFileSync(path.join(fixture.legacy, 'settings.json'), 'utf8')).toContain('legacy')
})
})
+3 -3
View File
@@ -39,11 +39,11 @@ describe('production runtime packaging', () => {
it('requires the bundled Reader Skill declared by extraResources', () => { it('requires the bundled Reader Skill declared by extraResources', () => {
const resources = join(root, 'reader-skill-resources') const resources = join(root, 'reader-skill-resources')
const skillPath = join(resources, 'skill', 'wechatexplorer-reader', 'SKILL.md') const skillPath = join(resources, 'skill', 'tracememo-reader', 'SKILL.md')
const config = readFileSync(resolve(__dirname, '../../electron-builder.yml'), 'utf8') const config = readFileSync(resolve(__dirname, '../../electron-builder.yml'), 'utf8')
expect(config).toContain('docs/skill/wechatexplorer-reader') expect(config).toContain('docs/skill/tracememo-reader')
expect(config).toContain('to: skill/wechatexplorer-reader') expect(config).toContain('to: skill/tracememo-reader')
expect(() => validateReaderSkillRuntime(resources)).toThrow( expect(() => validateReaderSkillRuntime(resources)).toThrow(
/Missing bundled TraceMemo Reader Skill/ /Missing bundled TraceMemo Reader Skill/
) )
+17 -4
View File
@@ -38,13 +38,13 @@ describe('Reader Skill resource resolution', () => {
it('finds the repository Skill from the current working directory in development', () => { it('finds the repository Skill from the current working directory in development', () => {
const root = fixtureRoot() const root = fixtureRoot()
const runtime = environment(root, false) const runtime = environment(root, false)
const skillPath = join(runtime.cwd, 'docs', 'skill', 'wechatexplorer-reader', 'SKILL.md') const skillPath = join(runtime.cwd, 'docs', 'skill', 'tracememo-reader', 'SKILL.md')
writeSkill(skillPath) writeSkill(skillPath)
expect(resolveSkillResourceStatus(runtime)).toMatchObject({ expect(resolveSkillResourceStatus(runtime)).toMatchObject({
available: true, available: true,
source: 'development', source: 'development',
version: 'v1.1', version: 'v1.2',
filePath: skillPath, filePath: skillPath,
directoryPath: dirname(skillPath) directoryPath: dirname(skillPath)
}) })
@@ -63,12 +63,25 @@ describe('Reader Skill resource resolution', () => {
expect(status).toMatchObject({ expect(status).toMatchObject({
available: true, available: true,
source: 'development', source: 'development',
version: 'v1.1', version: 'v1.2',
filePath: join(workspace, 'docs', 'skill', 'wechatexplorer-reader', 'SKILL.md') filePath: join(workspace, 'docs', 'skill', 'tracememo-reader', 'SKILL.md')
}) })
}) })
it('uses the extraResources Skill directory in a packaged runtime', () => { it('uses the extraResources Skill directory in a packaged runtime', () => {
const root = fixtureRoot()
const runtime = environment(root, true)
const skillPath = join(runtime.resourcesPath, 'skill', 'tracememo-reader', 'SKILL.md')
writeSkill(skillPath)
expect(resolveSkillResourceStatus(runtime)).toMatchObject({
available: true,
source: 'bundled',
filePath: skillPath
})
})
it('falls back to the legacy Skill directory for one compatibility release', () => {
const root = fixtureRoot() const root = fixtureRoot()
const runtime = environment(root, true) const runtime = environment(root, true)
const skillPath = join(runtime.resourcesPath, 'skill', 'wechatexplorer-reader', 'SKILL.md') const skillPath = join(runtime.resourcesPath, 'skill', 'wechatexplorer-reader', 'SKILL.md')
+23
View File
@@ -1,4 +1,5 @@
import fs from 'fs-extra' import fs from 'fs-extra'
import crypto from 'crypto'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -114,6 +115,28 @@ describe('WCDB message shard pagination', () => {
expect(first).toBe(second) expect(first).toBe(second)
expect(first).not.toContain('微信聊天记录') expect(first).not.toContain('微信聊天记录')
expect(first).toContain(path.join('TraceMemo', 'path-bridges'))
expect(fs.realpathSync(first)).toBe(fs.realpathSync(accountRoot)) expect(fs.realpathSync(first)).toBe(fs.realpathSync(accountRoot))
}) })
it('reuses an existing legacy Windows path bridge without creating a new one', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-legacy-path-bridge-'))
temporaryDirectories.push(root)
const publicRoot = path.join(root, 'Public')
const accountRoot = path.join(root, '微信聊天记录', 'wxid_legacy')
fs.ensureDirSync(path.join(accountRoot, 'db_storage'))
const bridgeId = crypto
.createHash('sha256')
.update(path.resolve(accountRoot).toLowerCase())
.digest('hex')
.slice(0, 24)
const legacyBridge = path.join(publicRoot, 'WechatExplorer', 'path-bridges', bridgeId)
fs.ensureDirSync(path.dirname(legacyBridge))
fs.symlinkSync(accountRoot, legacyBridge, 'junction')
expect(resolveWindowsNativeAccountRoot(accountRoot, { platform: 'win32', publicRoot })).toBe(
legacyBridge
)
expect(fs.existsSync(path.join(publicRoot, 'TraceMemo'))).toBe(false)
})
}) })