diff --git a/docs/README.md b/docs/README.md index 9ae512e..5eed667 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,6 +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.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md) 当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。 diff --git a/docs/agent/release-notes-v2.2.0.md b/docs/agent/release-notes-v2.2.0.md new file mode 100644 index 0000000..0b49177 --- /dev/null +++ b/docs/agent/release-notes-v2.2.0.md @@ -0,0 +1,45 @@ +# TraceMemo 2.2.0:品牌升级与本地数据兼容 + +2.2.0 将用户可见品牌从 WechatExplorer 升级为 TraceMemo(迹忆)。这次升级不搬迁或复制用户的 Application Support 数据;macOS 和 Windows 上的旧用户会继续使用原有数据根,以避免设置、密钥和本地索引失联。 + +## 升级后继续保留的内容 + +- 微信数据库连接设置和其他用户设置; +- AI Provider、模型配置和已加密 API Key; +- Local HTTP API Token; +- 微信数据库 Key 和图片解密 Key; +- Knowledge 本地索引,包括现有 SQLite、WAL 和 SHM 文件; +- 报告历史、防撤回归档和图片理解结果; +- Renderer Local Storage 和其他 Chromium session 数据; +- `~/.wechatexplorer/wechat-connector/accounts` 中的 Agent Hub 登录凭据。 + +应用启动时会在任何业务模块读取 Electron 路径或使用 `safeStorage` 之前选择数据根,并将 `userData` 与 `sessionData` 设置为同一目录: + +- `WechatExplorer` 或 v2.1.9 使用的小写 `wechatexplorer` 目录包含有效用户资产时,继续使用对应旧目录; +- 只有 TraceMemo 新目录包含有效用户资产时,使用新目录; +- 多个旧目录或新旧目录同时包含有效用户资产时,不复制、不合并、不覆盖;两个大小写 legacy 目录均有效时确定性优先 `WechatExplorer` 并写入诊断日志; +- 两边都没有有效用户资产时,全新安装使用 TraceMemo 新目录。 + +仅有 `Local State`、Cache、Code Cache 或 GPUCache 等运行时文件,不会被判断为有效用户资产。 + +## 有意保留的兼容标识 + +内部 Electron runtime identity 在 macOS 上继续使用 `WechatExplorer`。前者用于兼容旧 `safeStorage` 密文,后者同时承担旧 `safeStorage` 与 WCDB 运行时兼容;它们都不是未完成的品牌替换。 + +以下历史标识也继续保留: + +- bundle/app identifier:`com.wechatexplorer.app`; +- Reader Skill 目录和标识:`wechatexplorer-reader`; +- Agent 环境变量:`WECHATEXPLORER_API_TOKEN`; +- Agent Hub 凭据目录:`~/.wechatexplorer`; +- GitHub 仓库地址中的 `WechatExplorer`。 + +Local HTTP API 的 endpoint、默认端口 `6131`、Bearer Token 格式、加密方式和 rotation 行为没有因为品牌升级而改变。旧 Token 文件会从旧数据根继续读取,不会仅因升级而重新生成。 + +## Knowledge 与日志 + +Knowledge 不会被复制、移动或自动重建。旧用户继续直接使用原有 Knowledge 目录,因此不需要为了 v2.2.0 重新建立索引。 + +TraceMemo 的新诊断日志写入 TraceMemo 日志目录;WechatExplorer 历史日志保持原位置,不移动、不重命名、不删除。“打开诊断日志目录”会打开当前版本使用的 TraceMemo 日志。 + +更多安全边界见[数据、隐私与安全](../user-guide/privacy.md)和[API 安全](./api-security.md)。 diff --git a/src/main/app-data-bootstrap.ts b/src/main/app-data-bootstrap.ts index 6296711..a056b64 100644 --- a/src/main/app-data-bootstrap.ts +++ b/src/main/app-data-bootstrap.ts @@ -1,10 +1,6 @@ import { app } from 'electron' import path from 'path' -import { - chooseUserDataRoot, - getUserDataRoots, - LEGACY_USER_DATA_NAME -} from './app-data-paths' +import { getUserDataRoots, LEGACY_USER_DATA_NAME, selectUserDataRoot } from './app-data-paths' // This module must remain the first main-process import. Static imports in // settings/cache services can otherwise resolve Electron paths before the @@ -13,10 +9,11 @@ app.setName(process.platform === 'win32' ? 'WeFlow' : LEGACY_USER_DATA_NAME) const isolatedUserData = process.env['WXE_USER_DATA'] const roots = getUserDataRoots(app.getPath('appData')) -const selectedUserData = chooseUserDataRoot({ +const userDataSelection = selectUserDataRoot({ ...roots, isolated: isolatedUserData }) +const selectedUserData = userDataSelection.selected app.setPath('userData', selectedUserData) app.setPath('sessionData', selectedUserData) @@ -28,4 +25,4 @@ if (process.platform === 'darwin') { app.setPath('logs', path.join(app.getPath('home'), 'Library', 'Logs', 'TraceMemo')) } -export { roots, selectedUserData } +export { roots, selectedUserData, userDataSelection } diff --git a/src/main/app-data-paths.ts b/src/main/app-data-paths.ts index 6d6c820..c3ae953 100644 --- a/src/main/app-data-paths.ts +++ b/src/main/app-data-paths.ts @@ -2,10 +2,12 @@ import fs from 'fs' 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 interface UserDataRoots { legacy: string + legacyPackage: string current: string } @@ -13,6 +15,41 @@ export interface UserDataSelectionInput extends UserDataRoots { isolated?: string } +export type UserDataRootKind = 'isolated' | 'legacy-display' | 'legacy-package' | 'current' + +export type UserDataSelectionReason = + | 'isolated-override' + | 'legacy-display-assets' + | 'legacy-package-assets' + | 'legacy-shared-assets' + | 'legacy-conflict-display-preferred' + | 'current-assets' + | 'clean-install' + +export interface UserDataSelection { + selected: string + selectedKind: UserDataRootKind + reason: UserDataSelectionReason + directories: { + legacy: boolean + legacyPackage: boolean + current: boolean + } + assets: { + legacy: boolean + legacyPackage: boolean + current: boolean + } + legacyRootsEquivalent: boolean + legacyConflict: boolean +} + +export interface UserDataSelectionDependencies { + directoryExists: (root: string) => boolean + hasAssets: (root: string) => boolean + areSameDirectory: (first: string, second: string) => boolean +} + function isNonEmptyFile(filePath: string): boolean { try { const stat = fs.statSync(filePath) @@ -37,7 +74,11 @@ function hasPersistentEntries(directoryPath: string): boolean { function hasDatabaseKey(directoryPath: string): boolean { try { return fs.readdirSync(directoryPath, { withFileTypes: true }).some((entry) => { - return entry.isFile() && entry.name.endsWith('.bin') && isNonEmptyFile(path.join(directoryPath, entry.name)) + return ( + entry.isFile() && + entry.name.endsWith('.bin') && + isNonEmptyFile(path.join(directoryPath, entry.name)) + ) }) } catch { return false @@ -56,6 +97,26 @@ function hasKnowledgeDatabase(root: string): boolean { } } +function isExistingDirectory(directoryPath: string): boolean { + try { + return fs.statSync(directoryPath).isDirectory() + } catch { + return false + } +} + +function areSameExistingDirectory(firstPath: string, secondPath: string): boolean { + try { + const first = fs.statSync(firstPath) + const second = fs.statSync(secondPath) + if (!first.isDirectory() || !second.isDirectory()) return false + if (first.ino && first.dev === second.dev && first.ino === second.ino) return true + return fs.realpathSync.native(firstPath) === fs.realpathSync.native(secondPath) + } catch { + return false + } +} + /** * Runtime-only Chromium files are deliberately excluded. A directory is a * valid data root only when it contains at least one user-owned marker. @@ -85,20 +146,106 @@ export function hasValidUserAssets(root: string): boolean { export function getUserDataRoots(appDataPath: string): UserDataRoots { return { legacy: path.join(appDataPath, LEGACY_USER_DATA_NAME), + legacyPackage: path.join(appDataPath, LEGACY_PACKAGE_USER_DATA_NAME), current: path.join(appDataPath, CURRENT_USER_DATA_NAME) } } /** * Select exactly one root. This intentionally does not copy, merge, delete or - * modify either directory. Legacy wins when both roots contain user assets so - * a v2.1.9 upgrade remains deterministic and lossless. + * modify any directory. The visible-name legacy root remains first priority; + * the lowercase v2.1.9 package-name root is the fallback on case-sensitive + * filesystems. If both distinct legacy roots contain assets, the visible-name + * root wins deterministically and the caller receives conflict diagnostics. */ -export function chooseUserDataRoot(input: UserDataSelectionInput): string { +export function selectUserDataRoot( + input: UserDataSelectionInput, + dependencies: UserDataSelectionDependencies = { + directoryExists: isExistingDirectory, + hasAssets: hasValidUserAssets, + areSameDirectory: areSameExistingDirectory + } +): UserDataSelection { const isolated = input.isolated?.trim() - if (isolated) return path.resolve(isolated) + if (isolated) { + return { + selected: path.resolve(isolated), + selectedKind: 'isolated', + reason: 'isolated-override', + directories: { legacy: false, legacyPackage: false, current: false }, + assets: { legacy: false, legacyPackage: false, current: false }, + legacyRootsEquivalent: false, + legacyConflict: false + } + } - if (hasValidUserAssets(input.legacy)) return input.legacy - if (hasValidUserAssets(input.current)) return input.current - return input.current + const directories = { + legacy: dependencies.directoryExists(input.legacy), + legacyPackage: dependencies.directoryExists(input.legacyPackage), + current: dependencies.directoryExists(input.current) + } + const assets = { + legacy: dependencies.hasAssets(input.legacy), + legacyPackage: dependencies.hasAssets(input.legacyPackage), + current: dependencies.hasAssets(input.current) + } + const legacyRootsEquivalent = + directories.legacy && + directories.legacyPackage && + dependencies.areSameDirectory(input.legacy, input.legacyPackage) + + if (assets.legacy) { + const legacyConflict = assets.legacyPackage && !legacyRootsEquivalent + return { + selected: input.legacy, + selectedKind: 'legacy-display', + reason: legacyConflict + ? 'legacy-conflict-display-preferred' + : legacyRootsEquivalent + ? 'legacy-shared-assets' + : 'legacy-display-assets', + directories, + assets, + legacyRootsEquivalent, + legacyConflict + } + } + + if (assets.legacyPackage) { + return { + selected: input.legacyPackage, + selectedKind: 'legacy-package', + reason: 'legacy-package-assets', + directories, + assets, + legacyRootsEquivalent: false, + legacyConflict: false + } + } + + if (assets.current) { + return { + selected: input.current, + selectedKind: 'current', + reason: 'current-assets', + directories, + assets, + legacyRootsEquivalent: false, + legacyConflict: false + } + } + + return { + selected: input.current, + selectedKind: 'current', + reason: 'clean-install', + directories, + assets, + legacyRootsEquivalent: false, + legacyConflict: false + } +} + +export function chooseUserDataRoot(input: UserDataSelectionInput): string { + return selectUserDataRoot(input).selected } diff --git a/src/main/index.ts b/src/main/index.ts index 1940060..a8e8368 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import './app-data-bootstrap' +import { userDataSelection } from './app-data-bootstrap' import './preload-env' import { app, @@ -513,6 +513,26 @@ 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', diff --git a/tests/unit/app-data-paths.test.ts b/tests/unit/app-data-paths.test.ts index 1c4aa32..47450b5 100644 --- a/tests/unit/app-data-paths.test.ts +++ b/tests/unit/app-data-paths.test.ts @@ -2,7 +2,13 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import os from 'os' import path from 'path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { chooseUserDataRoot, getUserDataRoots, hasValidUserAssets } from '../../src/main/app-data-paths' +import { + chooseUserDataRoot, + getUserDataRoots, + hasValidUserAssets, + selectUserDataRoot, + type UserDataRoots +} from '../../src/main/app-data-paths' let root = '' @@ -23,6 +29,18 @@ function writeMarker(filePath: string): void { writeFileSync(filePath, 'fixture') } +function selectWithAssets( + roots: UserDataRoots, + validRoots: string[], + legacyRootsEquivalent = false +): ReturnType { + return selectUserDataRoot(roots, { + directoryExists: (candidate) => validRoots.includes(candidate), + hasAssets: (candidate) => validRoots.includes(candidate), + areSameDirectory: () => legacyRootsEquivalent + }) +} + describe('app data compatibility root selection', () => { it('chooses the new root for a clean install', () => { const roots = fixtureRoots() @@ -31,6 +49,66 @@ describe('app data compatibility root selection', () => { expect(chooseUserDataRoot(roots)).toBe(roots.current) }) + it('chooses WechatExplorer when only the display-name legacy root exists', () => { + const roots = fixtureRoots() + const selection = selectWithAssets(roots, [roots.legacy]) + expect(selection).toMatchObject({ + selected: roots.legacy, + selectedKind: 'legacy-display', + reason: 'legacy-display-assets', + directories: { legacy: true, legacyPackage: false, current: false }, + legacyConflict: false + }) + }) + + it('chooses wechatexplorer when only the v2.1.9 package-name legacy root exists', () => { + const roots = fixtureRoots() + const selection = selectWithAssets(roots, [roots.legacyPackage]) + expect(selection).toMatchObject({ + selected: roots.legacyPackage, + selectedKind: 'legacy-package', + reason: 'legacy-package-assets', + directories: { legacy: false, legacyPackage: true, current: false }, + legacyConflict: false + }) + }) + + it('chooses WechatExplorer deterministically and reports a conflict when both exist', () => { + const roots = fixtureRoots() + const selection = selectWithAssets(roots, [roots.legacy, roots.legacyPackage]) + expect(selection).toMatchObject({ + selected: roots.legacy, + selectedKind: 'legacy-display', + reason: 'legacy-conflict-display-preferred', + directories: { legacy: true, legacyPackage: true, current: false }, + legacyRootsEquivalent: false, + legacyConflict: true + }) + }) + + it('chooses tracememo when neither legacy root exists', () => { + const roots = fixtureRoots() + const selection = selectWithAssets(roots, []) + expect(selection).toMatchObject({ + selected: roots.current, + selectedKind: 'current', + reason: 'clean-install', + directories: { legacy: false, legacyPackage: false, current: false }, + legacyConflict: false + }) + }) + + it('does not report a conflict when both legacy spellings resolve to one directory', () => { + const roots = fixtureRoots() + const selection = selectWithAssets(roots, [roots.legacy, roots.legacyPackage], true) + expect(selection).toMatchObject({ + selected: roots.legacy, + reason: 'legacy-shared-assets', + legacyRootsEquivalent: true, + legacyConflict: false + }) + }) + it('ignores runtime-only Chromium files', () => { const roots = fixtureRoots() mkdirSync(roots.current, { recursive: true })