diff --git a/README.md b/README.md index 57b10c3..a299baf 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,6 @@ TraceMemo(迹忆)原名 WechatExplorer,是一次从“微信聊天记录 TraceMemo 交流与售后群二维码

- ## 从你的任务开始 | 我现在想做什么 | 在应用里打开 | 需要准备什么 | @@ -79,7 +78,7 @@ TraceMemo(迹忆)原名 WechatExplorer,是一次从“微信聊天记录 | 把微信语音变成可搜索的文字 | [设置 → 语音转文字](./docs/user-guide/voice.md) | 准备本地语音模型 | | 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI | | 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./docs/user-guide/recall-protection.md) | 默认关闭;开启前先了解写入和性能边界 | -| 直接在微信里向 TraceMemo 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI | +| 直接在微信里向 TraceMemo 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI | | 让 Codex 等外部 Agent 查询微信历史 | [外部 Agent](./docs/agent/overview.md) | 安装 Reader Skill 并配置本机 Token | ## 最核心的三个能力 @@ -191,9 +190,9 @@ flowchart LR ## 支持平台与安装包 -| 平台 | 处理器架构 | Releases 安装包 | -| ------- | ----------------------------- | --------------- | -| Windows | x64 | `-setup.exe` | +| 平台 | 处理器架构 | Releases 安装包 | +| ------- | ------------------------------ | --------------- | +| Windows | x64 | `-setup.exe` | | macOS | Apple Silicon(M 系列、arm64) | `.dmg` | 当前版本不支持 Intel 芯片的 Mac。当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。 @@ -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)。 +从 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)。 完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md) diff --git a/docs/README.md b/docs/README.md index 5eed667..07b74d7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,7 +56,7 @@ TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以 - [macOS 数据访问说明](./platform/macos.md) - [开发、测试与构建](./development/overview.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) 当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。 diff --git a/docs/agent/api-security.md b/docs/agent/api-security.md index 62676ff..df52bc2 100644 --- a/docs/agent/api-security.md +++ b/docs/agent/api-security.md @@ -6,7 +6,7 @@ TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑 ## 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` 是公开健康检查; - 其他所有端点都要求 `Authorization: Bearer `; @@ -19,7 +19,7 @@ TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑 应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如: ```bash -export WECHATEXPLORER_API_TOKEN="" +export TRACEMEMO_API_TOKEN="" ``` ## CORS 与 Origin diff --git a/docs/agent/api.md b/docs/agent/api.md index ed060f9..c7e2fe3 100644 --- a/docs/agent/api.md +++ b/docs/agent/api.md @@ -17,31 +17,31 @@ curl http://127.0.0.1:6131/api/v1/health # 读取数据 -export WECHATEXPLORER_API_TOKEN="<从 API Center 复制的 Token>" -curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \ +export TRACEMEMO_API_TOKEN="<从 API Center 复制的 Token>" +curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \ "http://127.0.0.1:6131/api/v1/recent_chat?limit=20" ``` 不要把 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/current_time` | 本机时间、时区和 Unix 时间戳 | 无 | -| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` | -| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` | -| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 | -| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` | -| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` | -| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` | -| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON | -| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 | -| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` | -| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` | +| 方法 | 路径 | 作用 | 参数/请求体 | +| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- | +| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 | +| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 | +| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` | +| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` | +| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 | +| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` | +| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` | +| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` | +| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON | +| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 | +| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` | +| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` | ### 这些端点与实时机器人有什么关系 @@ -67,7 +67,7 @@ curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \ ```bash 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/chatlog?talker=技术交流群&time=2026-08-07" diff --git a/docs/agent/overview.md b/docs/agent/overview.md index 5541991..7bcd0fb 100644 --- a/docs/agent/overview.md +++ b/docs/agent/overview.md @@ -39,7 +39,7 @@ Reader Skill 是给外部 Agent 的操作说明。安装后,Codex、Claude Cod 2. 打开一级导航“API”(页面为“API Center”),确认本地 API、数据库和 Reader Skill 都可用。 3. 选择目标 Agent,点击“复制安装指令”。 4. 在 Agent 自己的 Skill/配置目录执行或粘贴指令。 -5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `WECHATEXPLORER_API_TOKEN`。 +5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `TRACEMEMO_API_TOKEN`。 6. 先让 Agent 调用 health,再尝试查询最近会话。 详细说明:[Reader Skill](./reader-skill.md)、[Local HTTP API](./api.md)、[API 安全](./api-security.md)。 diff --git a/docs/agent/reader-skill.md b/docs/agent/reader-skill.md index 73add81..4ef66b3 100644 --- a/docs/agent/reader-skill.md +++ b/docs/agent/reader-skill.md @@ -8,7 +8,7 @@ Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Cod 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 自己的本地环境设置: ```bash - export WECHATEXPLORER_API_TOKEN="" + export TRACEMEMO_API_TOKEN="" ``` 6. 先执行 health 检查,再读取数据端点。 @@ -41,7 +41,7 @@ TraceMemo 不会自动把 Token 写进 Agent 配置。重新生成 Token 后, ```bash 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" ``` diff --git a/docs/agent/release-notes-v2.2.0.md b/docs/agent/release-notes-v2.2.0.md index 0b49177..a7298d5 100644 --- a/docs/agent/release-notes-v2.2.0.md +++ b/docs/agent/release-notes-v2.2.0.md @@ -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 用户提供一次安全迁移路径。 -## 升级后继续保留的内容 +## 新的产品身份 -- 微信数据库连接设置和其他用户设置; -- AI Provider、模型配置和已加密 API Key; +- 产品名与 Electron runtime name:`TraceMemo`; +- 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; -- 微信数据库 Key 和图片解密 Key; -- Knowledge 本地索引,包括现有 SQLite、WAL 和 SHM 文件; -- 报告历史、防撤回归档和图片理解结果; -- Renderer Local Storage 和其他 Chromium session 数据; -- `~/.wechatexplorer/wechat-connector/accounts` 中的 Agent Hub 登录凭据。 +- AI Provider Key、微信数据库 Key 和图片解密 Key; +- Agent Hub credential 与同步状态。 -应用启动时会在任何业务模块读取 Electron 路径或使用 `safeStorage` 之前选择数据根,并将 `userData` 与 `sessionData` 设置为同一目录: +Chromium Cache、Code Cache、GPUCache、临时文件、语音模型和其他可重建运行缓存不会为了品牌升级强制复制。 -- `WechatExplorer` 或 v2.1.9 使用的小写 `wechatexplorer` 目录包含有效用户资产时,继续使用对应旧目录; -- 只有 TraceMemo 新目录包含有效用户资产时,使用新目录; -- 多个旧目录或新旧目录同时包含有效用户资产时,不复制、不合并、不覆盖;两个大小写 legacy 目录均有效时确定性优先 `WechatExplorer` 并写入诊断日志; -- 两边都没有有效用户资产时,全新安装使用 TraceMemo 新目录。 +## Knowledge -仅有 `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`; -- Reader Skill 目录和标识:`wechatexplorer-reader`; -- Agent 环境变量:`WECHATEXPLORER_API_TOKEN`; -- Agent Hub 凭据目录:`~/.wechatexplorer`; -- GitHub 仓库地址中的 `WechatExplorer`。 +Token 格式、随机熵、加密方式和 rotation 行为没有变化。如果旧 API Token 因系统安全存储限制无法迁移,应用不会静默生成替代 Token,本地 API 会安全停用并提示用户重试迁移或在 API Center 主动重新生成。AI Provider Key、数据库 Key 和图片 Key 失败时也会明确记录为部分迁移,旧密文保持不变。 -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)。 diff --git a/docs/skill/wechatexplorer-reader/SKILL.md b/docs/skill/tracememo-reader/SKILL.md similarity index 64% rename from docs/skill/wechatexplorer-reader/SKILL.md rename to docs/skill/tracememo-reader/SKILL.md index 40b9209..cd4ce54 100644 --- a/docs/skill/wechatexplorer-reader/SKILL.md +++ b/docs/skill/tracememo-reader/SKILL.md @@ -1,5 +1,5 @@ --- -name: wechatexplorer-reader +name: tracememo-reader 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`。 - `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 放到 URL、回答、日志、Skill 文件或仓库。 - 6131 是普通 Local HTTP API,不是 MCP Server;不要生成 `mcpServers` 配置。 -`wechatexplorer-reader` 是该 Skill 为兼容既有安装而保留的目录和标识名;对用户展示时称为 TraceMemo Reader。 - ## 每次任务前 1. 调用 `/health`,确认服务和数据库状态。 @@ -28,20 +27,20 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的 ## 端点速查 -| 方法 | 路径 | 用途 | -| --- | --- | --- | -| GET | `/health` | 健康和数据库状态 | -| GET | `/current_time` | 本机时间与时区 | -| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` | -| GET | `/chatroom` | 群聊列表;可传 `keyword` | -| GET | `/recent_chat` | 最近会话;可传 `limit` | -| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 | -| GET | `/group_snapshot` | 群成员快照;必填 `md5` | -| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` | -| POST | `/report` | 将已有日报结构渲染为 HTML/PNG | -| GET | `/agent/status` | Agent Hub、连接器和数据库状态 | -| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 | -| POST | `/agent/send` | 已连接机器人发送测试 | +| 方法 | 路径 | 用途 | +| ---- | --------------------- | ------------------------------------------------- | +| GET | `/health` | 健康和数据库状态 | +| GET | `/current_time` | 本机时间与时区 | +| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` | +| GET | `/chatroom` | 群聊列表;可传 `keyword` | +| GET | `/recent_chat` | 最近会话;可传 `limit` | +| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 | +| GET | `/group_snapshot` | 群成员快照;必填 `md5` | +| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` | +| POST | `/report` | 将已有日报结构渲染为 HTML/PNG | +| GET | `/agent/status` | Agent Hub、连接器和数据库状态 | +| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 | +| POST | `/agent/send` | 已连接机器人发送测试 | ## 时间与上下文规则 diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md index 63492e0..ff93b34 100644 --- a/docs/user-guide/getting-started.md +++ b/docs/user-guide/getting-started.md @@ -33,7 +33,7 @@ ### macOS -1. 根据处理器下载对应的 `.dmg`:Apple Silicon(M 系列)选择 `arm64`,Intel Mac 选择 `x64`。 +1. 下载 Apple Silicon(M 系列、`arm64`)版本的 `.dmg`。当前版本不支持 Intel 芯片的 Mac。 2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。 3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。 4. 如果系统提示应用已损坏,可在终端执行: diff --git a/docs/user-guide/privacy.md b/docs/user-guide/privacy.md index 83af4c6..65f986e 100644 --- a/docs/user-guide/privacy.md +++ b/docs/user-guide/privacy.md @@ -14,7 +14,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有 应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。 -防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。为兼容旧版本,磁盘上的用户数据目录可能仍沿用历史名称 `WechatExplorer`。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。 +防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。 ## 什么时候会请求外部服务 diff --git a/docs/user-guide/recall-protection.md b/docs/user-guide/recall-protection.md index 3ed87f0..b475b01 100644 --- a/docs/user-guide/recall-protection.md +++ b/docs/user-guide/recall-protection.md @@ -30,7 +30,7 @@ ## 数据写入与关闭边界 -普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。为兼容旧版本,该目录在磁盘上可能继续显示为历史名称 `WechatExplorer`。 +普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。 关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。 diff --git a/electron-builder.yml b/electron-builder.yml index b81efe5..0fce8e7 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,5 +1,4 @@ -# Keep the legacy appId so existing userData directories and upgrades remain readable. -appId: com.wechatexplorer.app +appId: com.tracememo.app productName: TraceMemo afterPack: scripts/after-pack.cjs directories: @@ -26,8 +25,8 @@ extraResources: to: resources filter: - '**/*' - - from: docs/skill/wechatexplorer-reader - to: skill/wechatexplorer-reader + - from: docs/skill/tracememo-reader + to: skill/tracememo-reader filter: - '**/*' win: @@ -47,9 +46,8 @@ mac: icon: icon.icns entitlementsInherit: build/entitlements.mac.plist extendInfo: - # The bundled WCDB bridge accepts Electron as its internal host name. The - # legacy bundle identifier remains unchanged for upgrade compatibility; - # the public display name is migrated to TraceMemo. + # The bundled WCDB bridge still uses Electron as its internal executable + # compatibility name; the public product and bundle identity are TraceMemo. CFBundleName: Electron CFBundleDisplayName: TraceMemo NSCameraUsageDescription: Application requests access to the device's camera. diff --git a/package.json b/package.json index 228419e..2d9e375 100644 --- a/package.json +++ b/package.json @@ -49,16 +49,15 @@ "test:smoke": "node --test tests/smoke/native-environment.test.mjs", "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: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": "npm run typecheck && npm run build:native-services && electron-vite build", "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: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", "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:beta": "cross-env RELEASE_TYPE=prerelease npm run release", "release:stable": "cross-env RELEASE_TYPE=release npm run release", diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 02978e2..3b4dd14 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -106,7 +106,7 @@ function setPlistValue(plistPath, key, value) { } 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)) { throw new Error(`Missing bundled TraceMemo Reader Skill: ${skillPath}`) } diff --git a/scripts/test-skill-install-instruction.cjs b/scripts/test-skill-install-instruction.cjs index 99b3888..225bc45 100644 --- a/scripts/test-skill-install-instruction.cjs +++ b/scripts/test-skill-install-instruction.cjs @@ -26,8 +26,8 @@ new Function('exports', 'require', 'module', output)(moduleExports, require, { const { buildSkillInstallInstruction } = moduleExports const local = { type: 'local', - directoryPath: 'C:/skill/wechatexplorer-reader', - skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md', + directoryPath: 'C:/skill/tracememo-reader', + skillPath: 'C:/skill/tracememo-reader/SKILL.md', version: 'v1.0' } @@ -44,6 +44,7 @@ for (const [target, expected] of [ }) assert.match(text, new RegExp(expected)) 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, /Authorization: Bearer/) assert.doesNotMatch(text, /mcpServers/) diff --git a/services/wechat-connector/ilink/auth.go b/services/wechat-connector/ilink/auth.go index b8cad41..d4ff488 100644 --- a/services/wechat-connector/ilink/auth.go +++ b/services/wechat-connector/ilink/auth.go @@ -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() (string, error) { +func accountsDir(rootName string) (string, error) { home, err := os.UserHomeDir() if err != nil { 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. @@ -159,13 +186,7 @@ func SaveCredentials(creds *Credentials) error { return nil } -// LoadAllCredentials loads all saved account credentials. -func LoadAllCredentials() ([]*Credentials, error) { - dir, err := AccountsDir() - if err != nil { - return nil, err - } - +func loadCredentialsFromDir(dir string) ([]*Credentials, error) { entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { @@ -191,6 +212,24 @@ func LoadAllCredentials() ([]*Credentials, error) { 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. func CredentialsPath() (string, error) { return AccountsDir() diff --git a/services/wechat-connector/ilink/auth_test.go b/services/wechat-connector/ilink/auth_test.go index fdfa6fa..913ff8f 100644 --- a/services/wechat-connector/ilink/auth_test.go +++ b/services/wechat-connector/ilink/auth_test.go @@ -1,6 +1,7 @@ package ilink import ( + "encoding/json" "os" "path/filepath" "testing" @@ -34,3 +35,46 @@ func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) { 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) + } +} diff --git a/services/wechat-connector/ilink/monitor.go b/services/wechat-connector/ilink/monitor.go index d56832f..ad74a29 100644 --- a/services/wechat-connector/ilink/monitor.go +++ b/services/wechat-connector/ilink/monitor.go @@ -33,12 +33,12 @@ type Monitor struct { // NewMonitor creates a new long-poll monitor. func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) { - home, err := os.UserHomeDir() + accountID := NormalizeAccountID(client.BotID()) + accountsRoot, err := accountDirectoryForID(accountID) if err != nil { return nil, err } - accountID := NormalizeAccountID(client.BotID()) - bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json") + bufPath := filepath.Join(accountsRoot, accountID+".sync.json") m := &Monitor{ client: client, @@ -73,7 +73,7 @@ func (m *Monitor) Run(ctx context.Context) error { log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v", m.failures, maxConsecutiveFailures, backoff, err) if m.failures == maxConsecutiveFailures { - log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from TraceMemo if this persists.", maxConsecutiveFailures) + log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from TraceMemo if this persists.", maxConsecutiveFailures) } select { case <-time.After(backoff): @@ -96,7 +96,7 @@ func (m *Monitor) Run(ctx context.Context) error { } else { // Sync buf already empty but still getting session expired: // the bot token itself has expired. The user needs to re-login. - log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from TraceMemo.") + log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from TraceMemo.") } select { case <-time.After(sessionExpiredBackoff): diff --git a/src/main/api-token-store.ts b/src/main/api-token-store.ts index 07fd5e3..684cd08 100644 --- a/src/main/api-token-store.ts +++ b/src/main/api-token-store.ts @@ -20,6 +20,7 @@ interface TokenReadResult { export class ApiTokenStore { private cachedToken: string | null = null + private automaticGenerationBlockedReason: string | null = null constructor(private readonly filePathOverride?: string) {} @@ -42,10 +43,18 @@ export class ApiTokenStore { available: true, hasToken: result.success && Boolean(result.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 { if (!safeStorage.isEncryptionAvailable()) { return { @@ -59,6 +68,9 @@ export class ApiTokenStore { const current = this.read() if (!current.success) return this.actionError(current.error) if (current.token) return this.actionSuccess() + if (this.automaticGenerationBlockedReason) { + return this.actionError(this.automaticGenerationBlockedReason) + } return this.persist(this.generateToken()) } @@ -70,7 +82,9 @@ export class ApiTokenStore { rotateToken(): ApiTokenActionResult { 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 { diff --git a/src/main/app-data-bootstrap.ts b/src/main/app-data-bootstrap.ts index a056b64..58ad39d 100644 --- a/src/main/app-data-bootstrap.ts +++ b/src/main/app-data-bootstrap.ts @@ -1,19 +1,36 @@ import { app } from 'electron' 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 // settings/cache services can otherwise resolve Electron paths before the -// legacy runtime identity and selected userData are installed. -app.setName(process.platform === 'win32' ? 'WeFlow' : LEGACY_USER_DATA_NAME) +// TraceMemo userData is installed before any consumers. macOS uses the old +// 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 isUserDataIsolated = !isLegacyMigrationHelper && Boolean(isolatedUserData?.trim()) const roots = getUserDataRoots(app.getPath('appData')) -const userDataSelection = selectUserDataRoot({ - ...roots, - isolated: isolatedUserData -}) -const selectedUserData = userDataSelection.selected +const helperUserData = process.env[LEGACY_MIGRATION_USER_DATA_ENV] +const selectedUserData = isLegacyMigrationHelper + ? path.resolve(helperUserData?.trim() || path.join(roots.current, '.legacy-migration-helper')) + : isolatedUserData?.trim() + ? path.resolve(isolatedUserData) + : roots.current app.setPath('userData', selectedUserData) app.setPath('sessionData', selectedUserData) @@ -21,8 +38,19 @@ app.setPath('sessionData', selectedUserData) // Logs are intentionally independent from userData. New TraceMemo logs go to // the new visible directory while historical WechatExplorer logs remain in // 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')) +} else if (process.platform === 'win32') { + app.setPath('logs', path.join(selectedUserData, 'logs')) } -export { roots, selectedUserData, userDataSelection } +export { + isLegacyMigrationHelper, + isUserDataIsolated, + legacyRuntimeName, + runtimeName, + roots, + selectedUserData +} diff --git a/src/main/app-data-migration.ts b/src/main/app-data-migration.ts new file mode 100644 index 0000000..833cb46 --- /dev/null +++ b/src/main/app-data-migration.ts @@ -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 + 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 + 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 { + 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 { + 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 { + 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((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 { + 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 { + 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 { + 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 { + if (!(await fs.pathExists(sourceRoot))) return 'missing' + let copied = false + const visit = async (sourceDirectory: string, targetDirectory: string): Promise => { + 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 { + 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 { + 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((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 { + const items: Record = {} + 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): Promise => { + 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 => { + 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 { + 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 = ` +

正在迁移 WechatExplorer 数据

正在准备迁移…

请不要退出 TraceMemo。旧数据不会被删除。

` + 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 { + 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 + } +} diff --git a/src/main/app-data-paths.ts b/src/main/app-data-paths.ts index c3ae953..c98adc5 100644 --- a/src/main/app-data-paths.ts +++ b/src/main/app-data-paths.ts @@ -3,7 +3,8 @@ import path from 'path' export const LEGACY_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 { legacy: string @@ -97,7 +98,7 @@ function hasKnowledgeDatabase(root: string): boolean { } } -function isExistingDirectory(directoryPath: string): boolean { +export function isExistingDirectory(directoryPath: string): boolean { try { return fs.statSync(directoryPath).isDirectory() } 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 { const first = fs.statSync(firstPath) const second = fs.statSync(secondPath) diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index 4783f1d..fcb4994 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -11,7 +11,9 @@ import { imageFileQuality, imageQualityRank } from '../shared/image-quality' import { loadSettings } from './services/settings-store' 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 => { if (imageDecryptDebugEnabled) console.log(...args) } diff --git a/src/main/index.ts b/src/main/index.ts index a8e8368..ce34ee4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,8 @@ -import { userDataSelection } from './app-data-bootstrap' +import { + isLegacyMigrationHelper, + isUserDataIsolated, + roots as appDataRoots +} from './app-data-bootstrap' import './preload-env' import { app, @@ -116,6 +120,8 @@ import { } from '../shared/windows-runtime' import { KnowledgeSearchService } from './knowledge/knowledge-search-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. // Plain console.error then throws EPIPE on a closed pipe and crashes the IPC @@ -470,6 +476,74 @@ function createWindow(): void { // Electron 初始化完成并准备创建浏览器窗口后,将调用此方法 // 某些 API 只能在此事件发生后使用 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({ modelRoot: join(app.getPath('userData'), 'models', 'sensevoice-small-int8'), databasePath: join(app.getPath('userData'), 'cache', 'voice-transcripts.sqlite'), @@ -513,26 +587,6 @@ app.whenReady().then(async () => { message: 'TraceMemo 启动', 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) => { appLogger.write({ level: 'error', @@ -567,7 +621,7 @@ app.whenReady().then(async () => { }) // 设置应用程序用户模型 ID - electronApp.setAppUserModelId('com.wechatexplorer.app') + electronApp.setAppUserModelId('com.tracememo.app') if (process.platform === 'darwin') app.dock?.setIcon(appIconPath) diff --git a/src/main/key-service-win.ts b/src/main/key-service-win.ts index 33b917d..0772d13 100644 --- a/src/main/key-service-win.ts +++ b/src/main/key-service-win.ts @@ -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 { existsSync, copyFileSync, mkdirSync } from 'fs' -import { execFile, spawn } from 'child_process' +import { execFile } from 'child_process' import { promisify } from 'util' import os from 'os' import crypto from 'crypto' @@ -12,14 +9,19 @@ import { getResourceRoots as getSharedResourceRoots } from './resource-paths' const execFileAsync = promisify(execFile) 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 = | { status: 'success'; key: string; loginRequiredDetected: boolean } | { status: 'process-ended'; loginRequiredDetected: boolean } | { status: 'timeout'; loginRequiredDetected: boolean } export class KeyService { - private readonly isMac = process.platform === 'darwin' private koffi: any = null private lib: any = null private initialized = false @@ -28,19 +30,15 @@ export class KeyService { private getStatusMessage: any = null private cleanupHook: any = null private getLastErrorMsg: any = null - private getImageKeyDll: any = null private lastLoadError = '' // Win32 APIs private kernel32: any = null private user32: any = null - private advapi32: any = null // Kernel32 private OpenProcess: any = null private CloseHandle: any = null - private TerminateProcess: any = null - private QueryFullProcessImageNameW: any = null // User32 private EnumWindows: any = null @@ -50,22 +48,9 @@ export class KeyService { private GetWindowThreadProcessId: any = null private IsWindowVisible: any = null private EnumChildWindows: any = null - private PostMessageW: any = null private WNDENUMPROC_PTR: any = null - // Advapi32 - private RegOpenKeyExW: any = null - private RegQueryValueExW: any = null - private RegCloseKey: any = null - // 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 getResourceRoots(): string[] { @@ -156,11 +141,11 @@ export class KeyService { this.lib = this.koffi.load(dllPath) this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)') 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.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()') - this.getImageKeyDll = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)') - this.initialized = true return true } catch (e) { @@ -186,8 +171,6 @@ export class KeyService { this.kernel32 = this.koffi.load('kernel32.dll') this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32']) this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*']) - this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32']) - this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['void*', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')]) return true } catch (e) { @@ -211,12 +194,26 @@ export class KeyService { this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC) this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t']) - this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t']) - this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t']) - this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int']) + this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', [ + 'void*', + 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.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int']) - this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', ['void*', this.koffi.out('uint32*')]) + this.GetClassNameW = this.user32.func('GetClassNameW', 'int', [ + '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*']) 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 { try { 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 { - 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 { - 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 { try { - const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH']) - const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) + const { stdout } = await execFileAsync('tasklist', [ + '/FI', + `IMAGENAME eq ${imageName}`, + '/FO', + 'CSV', + '/NH' + ]) + const lines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) const pids: number[] = [] for (const line of lines) { if (line.startsWith('INFO:')) continue @@ -418,7 +291,7 @@ export class KeyService { const fallbackPid = await this.waitForWeChatWindow(250) if (fallbackPid) return fallbackPid - await new Promise(r => setTimeout(r, 500)) + await new Promise((r) => setTimeout(r, 500)) } return null } @@ -428,10 +301,10 @@ export class KeyService { } private async pollDbKeyFromHook( - pid: number, - deadline: number, - logs: string[], - onStatus?: (message: string, level: number) => void + pid: number, + deadline: number, + logs: string[], + onStatus?: (message: string, level: number) => void ): Promise { const keyBuffer = Buffer.alloc(128) let loginRequiredDetected = false @@ -441,7 +314,7 @@ export class KeyService { const now = Date.now() if (now >= nextProcessCheckAt) { nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS - if (!await this.isWeChatPidActive(pid)) { + if (!(await this.isWeChatPidActive(pid))) { return { status: 'process-ended', loginRequiredDetected } } } @@ -477,13 +350,17 @@ export class KeyService { private cleanupDbKeyHook(): void { try { this.cleanupHook() - } catch { } + } catch {} } private buildInitHookError(): string { const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '' 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 error @@ -491,13 +368,17 @@ export class KeyService { const statusBuffer = Buffer.alloc(256) 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) : '' return status || '初始化失败' } - private async waitForNextDbKeyPid(deadline: number, onStatus?: (message: string, level: number) => void): Promise { + private async waitForNextDbKeyPid( + deadline: number, + onStatus?: (message: string, level: number) => void + ): Promise { while (this.getRemainingMs(deadline) > 0) { onStatus?.('正在查找微信进程...', 0) 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)) } - private async waitForProcessRestart(deadline: number, onStatus?: (message: string, level: number) => void): Promise { + private async waitForProcessRestart( + deadline: number, + onStatus?: (message: string, level: number) => void + ): Promise { if (!this.shouldRetryAfterProcessLost(deadline)) return null onStatus?.('检测到微信已退出,已清理 Hook,等待重新打开微信...', 0) await this.delayBeforeRetry() return this.waitForNextDbKeyPid(deadline, onStatus) } - private async detectLoginRequiredForLastPid(pid: number | null, loginRequiredDetected: boolean): Promise { + private async detectLoginRequiredForLastPid( + pid: number | null, + loginRequiredDetected: boolean + ): Promise { if (loginRequiredDetected) return true if (!pid) return false - if (!await this.isWeChatPidActive(pid)) return false + if (!(await this.isWeChatPidActive(pid))) return false return await this.detectWeChatLoginRequired(pid) } @@ -535,56 +422,6 @@ export class KeyService { return fallbackPid ?? null } - private async waitForWeChatExit(timeoutMs = 8000): Promise { - 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 { - 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 { - 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 --- private getWindowTitle(hWnd: any): string { @@ -614,7 +451,7 @@ export class KeyService { while (Date.now() - startTime < timeoutMs) { 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 const title = this.getWindowTitle(hWnd) if (!this.isWeChatWindowTitle(title)) return true @@ -633,14 +470,14 @@ export class KeyService { this.koffi.unregister(enumWindowsCallback) if (foundPid) return foundPid - await new Promise(r => setTimeout(r, 500)) + await new Promise((r) => setTimeout(r, 500)) } return null } private collectChildWindowInfos(parent: any): 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 className = this.getClassName(hChild).trim() children.push({ title, className }) @@ -655,7 +492,16 @@ export class KeyService { if (children.length === 0) return false 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 let classMatchCount = 0 @@ -665,12 +511,12 @@ export class KeyService { for (const child of children) { const normalizedTitle = child.title.replace(/\s+/g, '') if (normalizedTitle) { - if (readyTexts.some(marker => normalizedTitle.includes(marker))) return true + if (readyTexts.some((marker) => normalizedTitle.includes(marker))) return true titleMatchCount += 1 } const className = child.className if (className) { - if (readyClassMarkers.some(marker => className.includes(marker))) return true + if (readyClassMarkers.some((marker) => className.includes(marker))) return true if (className.length > 5) { classMatchCount += 1 hasValidClassName = true @@ -685,7 +531,9 @@ export class KeyService { } 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 const keywords = [ '登录', @@ -741,7 +589,7 @@ export class KeyService { const startTime = Date.now() while (Date.now() - startTime < timeoutMs) { 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 const title = this.getWindowTitle(hWnd) if (!this.isWeChatWindowTitle(title)) return true @@ -763,7 +611,7 @@ export class KeyService { this.koffi.unregister(enumWindowsCallback) if (ready) return true - await new Promise(r => setTimeout(r, 500)) + await new Promise((r) => setTimeout(r, 500)) } return true } @@ -771,8 +619,8 @@ export class KeyService { // --- DB Key Logic (core hook/poll flow unchanged) --- async autoGetDbKey( - timeoutMs = 60_000, - onStatus?: (message: string, level: number) => void + timeoutMs = 60_000, + onStatus?: (message: string, level: number) => void ): Promise { if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } if (!this.ensureLoaded()) return { success: false, error: this.getLoadError() } @@ -794,14 +642,14 @@ export class KeyService { onStatus?.('正在检测微信界面组件...', 0) 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) continue } const ok = this.initHook(pid) if (!ok) { - if (!await this.isWeChatPidActive(pid)) { + if (!(await this.isWeChatPidActive(pid))) { this.cleanupDbKeyHook() pid = await this.waitForProcessRestart(deadline, onStatus) continue @@ -828,7 +676,10 @@ export class KeyService { break } - const loginRequired = await this.detectLoginRequiredForLastPid(pid, lastAttemptLoginRequiredDetected) + const loginRequired = await this.detectLoginRequiredForLastPid( + pid, + lastAttemptLoginRequiredDetected + ) if (loginRequired) { return { success: false, @@ -840,81 +691,10 @@ export class KeyService { 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 { - 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( - manualDir?: string, - onProgress?: (message: string) => void, - wxidParam?: string + manualDir?: string, + onProgress?: (message: string) => void, + wxidParam?: string ): Promise { void wxidParam return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress) @@ -935,9 +715,16 @@ export class KeyService { onProgress?.('正在查找模板文件...') let result = await this._findTemplateData(userDir, 32) let { ciphertext, xorKey } = result - const firstDiag = (this as { _imageTemplateDiag?: { - userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number - } })._imageTemplateDiag + const firstDiag = ( + this as { + _imageTemplateDiag?: { + userDir: string + totalTFiles: number + v2Count: number + nonV2Count: number + } + } + )._imageTemplateDiag // 如果找不到密钥,尝试扫描更多文件 if (ciphertext && xorKey === null) { @@ -948,9 +735,17 @@ export class KeyService { if (!ciphertext) { // 用诊断信息给具体提示 - const diag = (this as { _imageTemplateDiag?: { - userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number - } })._imageTemplateDiag || firstDiag + const diag = + ( + this as { + _imageTemplateDiag?: { + userDir: string + totalTFiles: number + v2Count: number + nonV2Count: number + } + } + )._imageTemplateDiag || firstDiag if (!diag || diag.totalTFiles === 0) { return { 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')},正在查找微信进程...`) @@ -1000,7 +799,7 @@ export class KeyService { return { success: true, xorKey, aesKey } } // 等 5 秒再试 - await new Promise(r => setTimeout(r, 5000)) + await new Promise((r) => setTimeout(r, 5000)) } 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 { join } = await import('path') 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) else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) } - } catch { /* 忽略无权限目录 */ } + } catch { + /* 忽略无权限目录 */ + } } const files: string[] = [] @@ -1035,7 +839,11 @@ export class KeyService { // 按修改时间降序 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 @@ -1058,17 +866,24 @@ export class KeyService { } // 提取密文(取第一个有效的) - if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { - ciphertext = data.subarray(0xF, 0x1F) + if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1f) { + ciphertext = data.subarray(0xf, 0x1f) } - } catch { /* 忽略 */ } + } catch { + /* 忽略 */ + } } // 计算 XOR 密钥 let xorKey: number | null = null let maxCount = 0 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 直接告诉用户搜到了什么 @@ -1091,8 +906,19 @@ export class KeyService { if (!this.ensureKernel32()) return null // 直接用已加载的 kernel32 实例,用 uintptr 传地址 - const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t']) - const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')]) + const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', '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 保护标志(只扫可写区域,速度更快) 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 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 try { @@ -1110,7 +936,7 @@ export class KeyService { let addr = 0 const mbi = Buffer.alloc(MBI_SIZE) - while (addr < 0x7FFFFFFFFFFF) { + while (addr < 0x7fffffffffff) { const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE) if (ret === 0) break // MEMORY_BASIC_INFORMATION x64 布局: @@ -1126,11 +952,13 @@ export class KeyService { const state = mbi.readUInt32LE(32) const protect = mbi.readUInt32LE(36) - if (state === MEM_COMMIT && - protect !== PAGE_NOACCESS && - (protect & PAGE_GUARD) === 0 && - (protect & RW_FLAGS) !== 0 && - size <= 50 * 1024 * 1024) { + if ( + state === MEM_COMMIT && + protect !== PAGE_NOACCESS && + (protect & PAGE_GUARD) === 0 && + (protect & RW_FLAGS) !== 0 && + size <= 50 * 1024 * 1024 + ) { regions.push([base, size]) } const next = base + size @@ -1148,7 +976,7 @@ export class KeyService { const [base, size] = regions[i] if (i % 20 === 0) { onProgress?.(`扫描进度 ${i}/${regions.length}...`) - await new Promise(r => setTimeout(r, 1)) // 让出事件循环 + await new Promise((r) => setTimeout(r, 1)) // 让出事件循环 } let offset = 0 @@ -1159,17 +987,29 @@ export class KeyService { const buf = Buffer.alloc(chunkSize) const bytesReadOut = [0] 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字节密钥 const key = this._searchAsciiKey(data, ciphertext) - if (key) { this.CloseHandle(hProcess); return key } + if (key) { + this.CloseHandle(hProcess) + return key + } // 搜索 UTF-16LE 32字节密钥 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)) offset += chunkSize @@ -1187,12 +1027,16 @@ export class KeyService { if (this._isAlphaNum(data[i])) continue let valid = true 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 (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue 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 } @@ -1201,18 +1045,22 @@ export class KeyService { for (let i = 0; i < data.length - 65; i++) { let valid = true 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 const keyBytes = Buffer.alloc(32) 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 } 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 { @@ -1221,12 +1069,14 @@ export class KeyService { decipher.setAutoPadding(false) const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) // 支持 JPEG / PNG / WEBP / WXGF / GIF - 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] === 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 } + } catch { + return false + } } } diff --git a/src/main/legacy-safe-storage-helper.ts b/src/main/legacy-safe-storage-helper.ts new file mode 100644 index 0000000..59d768b --- /dev/null +++ b/src/main/legacy-safe-storage-helper.ts @@ -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 } + imageKeys?: { + version: 1 + accounts: Record + } + legacyDatabaseKey?: string + databaseKeys: Record + failures: LegacySecretFailure[] +} + +function isStringRecord(value: unknown): value is Record { + return ( + Boolean(value) && + typeof value === 'object' && + Object.values(value as Record).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 + } + 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['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))) +} diff --git a/src/main/resource-paths.ts b/src/main/resource-paths.ts index 03c5f79..d31128c 100644 --- a/src/main/resource-paths.ts +++ b/src/main/resource-paths.ts @@ -12,6 +12,7 @@ export function getResourceRoots(): string[] { const execDir = dirname(process.execPath) return unique([ + process.env.TRACEMEMO_RESOURCES_PATH || '', process.env.WECHATEXPLORER_RESOURCES_PATH || '', join(process.cwd(), 'resources'), join(process.cwd(), 'resources', 'resources'), diff --git a/src/main/services/skill-resource-service.ts b/src/main/services/skill-resource-service.ts index 675483f..3712f31 100644 --- a/src/main/services/skill-resource-service.ts +++ b/src/main/services/skill-resource-service.ts @@ -3,10 +3,12 @@ import { existsSync, promises as fs } from 'fs' import { dirname, join } from 'path' import { isPackagedRuntime } from '../runtime-mode' -const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md') -const GITHUB_URL = - 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader' -const SKILL_VERSION = 'v1.1' +const SKILL_RELATIVE_PATHS = [ + join('skill', 'tracememo-reader', 'SKILL.md'), + join('skill', 'wechatexplorer-reader', 'SKILL.md') +] +const GITHUB_URL = 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/tracememo-reader' +const SKILL_VERSION = 'v1.2' type SkillResourceSource = 'development' | 'bundled' @@ -54,19 +56,19 @@ function uniqueCandidates(candidates: SkillCandidate[]): SkillCandidate[] { export function getSkillCandidates(environment?: SkillPathEnvironment): SkillCandidate[] { const runtime = environment || currentEnvironment() - const developmentPaths = [ - join(runtime.appPath, 'docs', SKILL_RELATIVE_PATH), - join(runtime.cwd, 'docs', SKILL_RELATIVE_PATH), - join(dirname(runtime.appPath), 'docs', SKILL_RELATIVE_PATH) - ] + const developmentPaths = SKILL_RELATIVE_PATHS.flatMap((relativePath) => [ + join(runtime.appPath, 'docs', relativePath), + join(runtime.cwd, 'docs', relativePath), + join(dirname(runtime.appPath), 'docs', relativePath) + ]) const execDirectory = dirname(runtime.execPath) - const bundledPaths = [ - join(runtime.resourcesPath, SKILL_RELATIVE_PATH), - join(runtime.resourcesPath, 'resources', SKILL_RELATIVE_PATH), - join(dirname(runtime.appPath), SKILL_RELATIVE_PATH), - join(execDirectory, 'resources', SKILL_RELATIVE_PATH), - join(dirname(execDirectory), 'Resources', SKILL_RELATIVE_PATH) - ] + const bundledPaths = SKILL_RELATIVE_PATHS.flatMap((relativePath) => [ + join(runtime.resourcesPath, relativePath), + join(runtime.resourcesPath, 'resources', relativePath), + join(dirname(runtime.appPath), relativePath), + join(execDirectory, 'resources', relativePath), + join(dirname(execDirectory), 'Resources', relativePath) + ]) return uniqueCandidates( runtime.packaged ? bundledPaths.map((path) => ({ path, source: 'bundled' })) diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 57212e7..ec737c0 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -272,21 +272,38 @@ export function resolveWindowsNativeAccountRoot( .find((candidate) => candidate && isAsciiPath(candidate)) if (!publicRoot || !isAsciiPath(publicRoot)) return accountRoot - // Preserve the legacy ASCII bridge path so existing junctions remain reusable. - const bridgeRoot = path.join(publicRoot, 'WechatExplorer', 'path-bridges') - const bridgePath = path.join( - bridgeRoot, - crypto - .createHash('sha256') - .update(path.resolve(accountRoot).toLowerCase()) - .digest('hex') - .slice(0, 24) - ) + const bridgeId = crypto + .createHash('sha256') + .update(path.resolve(accountRoot).toLowerCase()) + .digest('hex') + .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 { fs.ensureDirSync(bridgeRoot) if (fs.existsSync(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 } fs.removeSync(bridgePath) diff --git a/src/renderer/src/components/SettingsPanel.tsx b/src/renderer/src/components/SettingsPanel.tsx index d2d290c..58a88ed 100644 --- a/src/renderer/src/components/SettingsPanel.tsx +++ b/src/renderer/src/components/SettingsPanel.tsx @@ -478,7 +478,7 @@ export const SettingsPanel: React.FC = ({ API 默认仅监听本机,并通过 Bearer Token 保护数据接口。Token 请在 API Center 中显示或复制。关闭后 Claude / Codex 等客户端无法读取聊天数据。
- 配置文档(兼容路径):docs/skill/wechatexplorer-reader/SKILL.md + 配置文档:docs/skill/tracememo-reader/SKILL.md diff --git a/src/renderer/src/features/api-center/components/SkillDetails.tsx b/src/renderer/src/features/api-center/components/SkillDetails.tsx index 9bfbaa5..559b154 100644 --- a/src/renderer/src/features/api-center/components/SkillDetails.tsx +++ b/src/renderer/src/features/api-center/components/SkillDetails.tsx @@ -21,8 +21,8 @@ export function SkillDetails({
TraceMemo Reader
-
兼容标识
-
wechatexplorer-reader
+
标识
+
tracememo-reader
版本
diff --git a/src/renderer/src/features/api-center/model/skillDistribution.ts b/src/renderer/src/features/api-center/model/skillDistribution.ts index c0bd55b..5e72d3b 100644 --- a/src/renderer/src/features/api-center/model/skillDistribution.ts +++ b/src/renderer/src/features/api-center/model/skillDistribution.ts @@ -12,6 +12,6 @@ export const AGENT_INSTALL_TARGETS: { value: AgentInstallTarget; label: string } ] 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 diff --git a/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts b/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts index 4772e53..b2e2aac 100644 --- a/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts +++ b/src/renderer/src/features/api-center/utils/buildSkillInstallInstruction.ts @@ -33,7 +33,7 @@ export function buildSkillInstallInstruction({ source.type === 'local' ? `${opening(target)}\n\n${source.directoryPath}\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 { diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index 1ebaffe..af713c2 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -4,7 +4,7 @@ const fs = require('fs') const path = require('path') const root = path.resolve(__dirname, '../../..') -const fixture = require(path.join(root, 'tests/fixtures/chat-data.json')) +const fixture = structuredClone(require(path.join(root, 'tests/fixtures/chat-data.json'))) const userData = process.env.WXE_E2E_USER_DATA if (!userData) throw new Error('WXE_E2E_USER_DATA is required') app.setPath('userData', userData) @@ -15,6 +15,151 @@ const VALID_KEY = 'a'.repeat(64) const imageData = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=' 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({ overview: '固定脱敏日报', hero: { @@ -88,7 +233,7 @@ const handle = (channel, fn) => { const startupCache = () => ({ self: fixture.self, contacts, - updatedAt: 1785553200000 + updatedAt: Date.now() }) handle('settings:get', () => ({ settings, settingsPath: path.join(userData, 'settings.json') })) @@ -113,11 +258,11 @@ handle('key:clearSavedDbKey', () => { handle('key:getEnvironment', () => ({ platform: process.platform, osVersion: process.platform === 'win32' ? 'Windows fixture' : 'macOS fixture', - appVersion: 'v2.1.6', + appVersion: 'v2.2.0', wechatVersion: '4.1.9.57', dataStructureVersion: settings.dbRoot === 'fixture-account' ? '微信 4.x(WCDB)' : '未检测到', dataDirectoryDetected: settings.dbRoot === 'fixture-account', - diagnosticSummary: 'TraceMemo: v2.1.6\n数据目录: 已检测到', + diagnosticSummary: 'TraceMemo: v2.2.0\n数据目录: 已检测到', autoDetectSupported: true, wechatRunning: true, accountIdentified: connected, @@ -287,6 +432,45 @@ handle('ai:chat', (messages) => { } 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', () => { 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: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 [ 'export:start', diff --git a/tests/unit/api-token-store.test.ts b/tests/unit/api-token-store.test.ts index 76a6b01..fdcf957 100644 --- a/tests/unit/api-token-store.test.ts +++ b/tests/unit/api-token-store.test.ts @@ -62,4 +62,19 @@ describe('ApiTokenStore', () => { expect(fs.existsSync(filePath)).toBe(false) 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) + }) }) diff --git a/tests/unit/app-data-migration.test.ts b/tests/unit/app-data-migration.test.ts new file mode 100644 index 0000000..ed81ad2 --- /dev/null +++ b/tests/unit/app-data-migration.test.ts @@ -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([ + ['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') + }) +}) diff --git a/tests/unit/runtime-packaging.test.ts b/tests/unit/runtime-packaging.test.ts index 74a70df..c4d1967 100644 --- a/tests/unit/runtime-packaging.test.ts +++ b/tests/unit/runtime-packaging.test.ts @@ -39,11 +39,11 @@ describe('production runtime packaging', () => { it('requires the bundled Reader Skill declared by extraResources', () => { 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') - expect(config).toContain('docs/skill/wechatexplorer-reader') - expect(config).toContain('to: skill/wechatexplorer-reader') + expect(config).toContain('docs/skill/tracememo-reader') + expect(config).toContain('to: skill/tracememo-reader') expect(() => validateReaderSkillRuntime(resources)).toThrow( /Missing bundled TraceMemo Reader Skill/ ) diff --git a/tests/unit/skill-resource-service.test.ts b/tests/unit/skill-resource-service.test.ts index 28a7858..cb57dae 100644 --- a/tests/unit/skill-resource-service.test.ts +++ b/tests/unit/skill-resource-service.test.ts @@ -38,13 +38,13 @@ describe('Reader Skill resource resolution', () => { it('finds the repository Skill from the current working directory in development', () => { const root = fixtureRoot() 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) expect(resolveSkillResourceStatus(runtime)).toMatchObject({ available: true, source: 'development', - version: 'v1.1', + version: 'v1.2', filePath: skillPath, directoryPath: dirname(skillPath) }) @@ -63,12 +63,25 @@ describe('Reader Skill resource resolution', () => { expect(status).toMatchObject({ available: true, source: 'development', - version: 'v1.1', - filePath: join(workspace, 'docs', 'skill', 'wechatexplorer-reader', 'SKILL.md') + version: 'v1.2', + filePath: join(workspace, 'docs', 'skill', 'tracememo-reader', 'SKILL.md') }) }) 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 runtime = environment(root, true) const skillPath = join(runtime.resourcesPath, 'skill', 'wechatexplorer-reader', 'SKILL.md') diff --git a/tests/unit/wcdb-message-shards.test.ts b/tests/unit/wcdb-message-shards.test.ts index 83e3036..44734e8 100644 --- a/tests/unit/wcdb-message-shards.test.ts +++ b/tests/unit/wcdb-message-shards.test.ts @@ -1,4 +1,5 @@ import fs from 'fs-extra' +import crypto from 'crypto' import os from 'os' import path from 'path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -114,6 +115,28 @@ describe('WCDB message shard pagination', () => { expect(first).toBe(second) expect(first).not.toContain('微信聊天记录') + expect(first).toContain(path.join('TraceMemo', 'path-bridges')) 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) + }) })