mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
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:
@@ -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 Silicon(M 系列、arm64) | `.dmg` |
|
| macOS | Apple Silicon(M 系列、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
@@ -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 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 helper:macOS 使用旧 `WechatExplorer` identity,helper 只在内存中解密并校验旧 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` |
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
1. 根据处理器下载对应的 `.dmg`:Apple Silicon(M 系列)选择 `arm64`,Intel Mac 选择 `x64`。
|
1. 下载 Apple Silicon(M 系列、`arm64`)版本的 `.dmg`。当前版本不支持 Intel 芯片的 Mac。
|
||||||
2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
|
2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
|
||||||
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||||
4. 如果系统提示应用已损坏,可在终端执行:
|
4. 如果系统提示应用已损坏,可在终端执行:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
|
|||||||
|
|
||||||
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
|
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
|
||||||
|
|
||||||
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。为兼容旧版本,磁盘上的用户数据目录可能仍沿用历史名称 `WechatExplorer`。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
|
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
|
||||||
|
|
||||||
## 什么时候会请求外部服务
|
## 什么时候会请求外部服务
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
## 数据写入与关闭边界
|
## 数据写入与关闭边界
|
||||||
|
|
||||||
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。为兼容旧版本,该目录在磁盘上可能继续显示为历史名称 `WechatExplorer`。
|
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。
|
||||||
|
|
||||||
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
|
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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' }))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.x(WCDB)' : '未检测到',
|
dataStructureVersion: settings.dbRoot === 'fixture-account' ? '微信 4.x(WCDB)' : '未检测到',
|
||||||
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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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/
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user