Files
WechatExplorer/src/main/app-data-migration.ts
T
Wxw-Gu cf3f115124 feat: 完成 TraceMemo v2.2.0 品牌身份与数据迁移升级
- 将 WechatExplorer 产品身份升级为 TraceMemo
- 更新 appId、Runtime Identity、Reader Skill 和 API 环境变量
- 增加旧用户数据、Knowledge、Token 与 AI Provider 安全迁移
- 保留 Windows WeFlow 和旧版配置兼容
- 完善首次启动迁移测试及 v2.2.0 发布文档
- 移除 macOS Intel x64 构建与发布支持
2026-08-11 17:52:57 +08:00

677 lines
23 KiB
TypeScript

import { app, BrowserWindow, dialog, safeStorage } from 'electron'
import { spawn } from 'child_process'
import { constants as fsConstants } from 'fs'
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import { randomUUID } from 'crypto'
import {
LEGACY_MIGRATION_HELPER_ENV,
LEGACY_MIGRATION_RESULT_FD_ENV,
LEGACY_MIGRATION_SOURCE_ENV,
LEGACY_MIGRATION_USER_DATA_ENV
} from './app-data-bootstrap'
import {
hasValidUserAssets,
selectUserDataRoot,
type UserDataRoots,
type UserDataSelection
} from './app-data-paths'
import type { LegacySecretBundle } from './legacy-safe-storage-helper'
const MIGRATION_STATE_FILE = 'tracememo-migration-v1.json'
const TOKEN_MIGRATION_BLOCK_MESSAGE =
'检测到旧版 API Token 尚未完成迁移。请重试数据迁移,或在 API Center 主动重新生成 Token。'
const FILE_ASSETS = [
'settings.json',
'ai-providers.json',
'image-insights.json',
'group-exit-monitor.json'
] as const
const DIRECTORY_ASSETS = [
'knowledge',
'reports',
'recall-archive',
'Local Storage',
'digital-twin',
'group-exit-monitor'
] as const
export type MigrationItemStatus = 'migrated' | 'skipped' | 'missing' | 'failed'
export type MigrationStatus = 'deferred' | 'in-progress' | 'partial' | 'completed'
export interface MigrationState {
version: 1
status: MigrationStatus
sourceRoot: string
updatedAt: string
items: Record<string, MigrationItemStatus>
secretFailures: string[]
}
export interface MigrationAssessment {
selection: UserDataSelection
sourceRoot?: string
currentHasAssets: boolean
state?: MigrationState
shouldPrompt: boolean
reason:
| 'clean-install'
| 'legacy-empty'
| 'current-data-present'
| 'migration-completed'
| 'legacy-assets-detected'
| 'migration-resumable'
}
export interface MigrationExecutionResult {
state: MigrationState
tokenGenerationBlocked: boolean
tokenBlockReason?: string
}
export interface MigrationFlowResult {
assessment: MigrationAssessment
action: 'none' | 'deferred' | 'migrated'
execution?: MigrationExecutionResult
tokenGenerationBlocked: boolean
tokenBlockReason?: string
}
export interface MigrationDependencies {
decryptLegacySecrets: (sourceRoot: string) => Promise<LegacySecretBundle>
agentRoots: () => { legacy: string; current: string }
now: () => Date
onProgress?: (message: string) => void
}
function migrationStatePath(targetRoot: string): string {
return path.join(targetRoot, MIGRATION_STATE_FILE)
}
function readMigrationState(targetRoot: string): MigrationState | undefined {
try {
const state = fs.readJsonSync(migrationStatePath(targetRoot)) as MigrationState
if (
state.version !== 1 ||
!['deferred', 'in-progress', 'partial', 'completed'].includes(state.status) ||
typeof state.sourceRoot !== 'string'
) {
return undefined
}
return state
} catch {
return undefined
}
}
async function writeMigrationState(targetRoot: string, state: MigrationState): Promise<void> {
await fs.ensureDir(targetRoot)
const statePath = migrationStatePath(targetRoot)
const tempPath = `${statePath}.tmp-${process.pid}-${randomUUID()}`
try {
await fs.writeJson(tempPath, state, { spaces: 2, mode: 0o600 })
await fs.chmod(tempPath, 0o600)
await fs.move(tempPath, statePath, { overwrite: true })
} finally {
await fs.remove(tempPath).catch(() => undefined)
}
}
function isLegacySelection(selection: UserDataSelection): boolean {
return selection.selectedKind === 'legacy-display' || selection.selectedKind === 'legacy-package'
}
export function assessMigration(roots: UserDataRoots): MigrationAssessment {
const selection = selectUserDataRoot(roots)
const state = readMigrationState(roots.current)
const selectedLegacyRoot = isLegacySelection(selection) ? selection.selected : undefined
const stateSource = state?.sourceRoot
const resumableStateSource =
stateSource &&
[roots.legacy, roots.legacyPackage].includes(stateSource) &&
hasValidUserAssets(stateSource)
? stateSource
: undefined
const sourceRoot = resumableStateSource || selectedLegacyRoot
const currentHasAssets = hasValidUserAssets(roots.current)
if (state?.status === 'completed') {
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: false,
reason: 'migration-completed'
}
}
if (sourceRoot && state && ['deferred', 'in-progress', 'partial'].includes(state.status)) {
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: true,
reason: 'migration-resumable'
}
}
if (!sourceRoot) {
return {
selection,
currentHasAssets,
state,
shouldPrompt: false,
reason:
selection.directories.legacy || selection.directories.legacyPackage
? 'legacy-empty'
: 'clean-install'
}
}
if (currentHasAssets) {
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: false,
reason: 'current-data-present'
}
}
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: true,
reason: 'legacy-assets-detected'
}
}
async function copyFileWithoutOverwrite(
sourceRoot: string,
targetRoot: string,
relativePath: string,
stagingRoot: string
): Promise<MigrationItemStatus> {
const sourcePath = path.join(sourceRoot, relativePath)
const targetPath = path.join(targetRoot, relativePath)
if (!(await fs.pathExists(sourcePath))) return 'missing'
if (await fs.pathExists(targetPath)) return 'skipped'
const stagedPath = path.join(stagingRoot, relativePath)
await fs.ensureDir(path.dirname(stagedPath))
await fs.copy(sourcePath, stagedPath, {
overwrite: false,
errorOnExist: true,
preserveTimestamps: true
})
if (await fs.pathExists(targetPath)) return 'skipped'
await fs.ensureDir(path.dirname(targetPath))
await fs.move(stagedPath, targetPath, { overwrite: false })
return 'migrated'
}
async function integrityCheckKnowledgeDatabase(databasePath: string): Promise<void> {
const script = `
const { DatabaseSync } = require('node:sqlite')
const database = new DatabaseSync(process.argv[1], { readOnly: true })
try {
const rows = database.prepare('PRAGMA integrity_check').all()
const result = String(rows[0]?.integrity_check || rows[0]?.['integrity_check(1)'] || '').toLowerCase()
if (rows.length !== 1 || result !== 'ok') process.exitCode = 2
} finally {
database.close()
}
`
await new Promise<void>((resolve, reject) => {
const child = spawn(process.execPath, ['-e', script, databasePath], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true
})
let stderr = ''
child.stderr?.on('data', (chunk: Buffer) => {
if (stderr.length < 4096) stderr += chunk.toString('utf8')
})
child.once('error', reject)
child.once('exit', (code) => {
if (code === 0) resolve()
else reject(new Error(`Knowledge SQLite integrity_check failed (${code}): ${stderr.trim()}`))
})
})
}
async function verifyKnowledgeCopy(sourcePath: string, stagedPath: string): Promise<void> {
const accounts = await fs.readdir(sourcePath, { withFileTypes: true })
for (const account of accounts) {
if (!account.isDirectory()) continue
const sourceDatabase = path.join(sourcePath, account.name, 'knowledge.sqlite')
if (!(await fs.pathExists(sourceDatabase))) continue
const stagedDatabase = path.join(stagedPath, account.name, 'knowledge.sqlite')
if (!(await fs.pathExists(stagedDatabase)))
throw new Error('Knowledge database copy is missing')
for (const suffix of ['', '-wal', '-shm']) {
const sourceFile = `${sourceDatabase}${suffix}`
if (!(await fs.pathExists(sourceFile))) continue
const stagedFile = `${stagedDatabase}${suffix}`
if (!(await fs.pathExists(stagedFile)))
throw new Error(`Knowledge companion missing: ${suffix}`)
const [sourceStat, stagedStat] = await Promise.all([fs.stat(sourceFile), fs.stat(stagedFile)])
if (sourceStat.size !== stagedStat.size)
throw new Error(`Knowledge copy size mismatch: ${suffix}`)
}
await integrityCheckKnowledgeDatabase(stagedDatabase)
}
}
async function copyDirectoryWithoutOverwrite(
sourceRoot: string,
targetRoot: string,
relativePath: string,
stagingRoot: string
): Promise<MigrationItemStatus> {
const sourcePath = path.join(sourceRoot, relativePath)
const targetPath = path.join(targetRoot, relativePath)
if (!(await fs.pathExists(sourcePath))) return 'missing'
if (await fs.pathExists(targetPath)) return 'skipped'
const stagedPath = path.join(stagingRoot, relativePath)
await fs.ensureDir(path.dirname(stagedPath))
await fs.copy(sourcePath, stagedPath, {
overwrite: false,
errorOnExist: true,
preserveTimestamps: true
})
if (relativePath === 'knowledge') await verifyKnowledgeCopy(sourcePath, stagedPath)
if (await fs.pathExists(targetPath)) return 'skipped'
await fs.move(stagedPath, targetPath, { overwrite: false })
return 'migrated'
}
async function writeEncryptedWithoutOverwrite(
targetRoot: string,
relativePath: string,
plainText: string
): Promise<MigrationItemStatus> {
const targetPath = path.join(targetRoot, relativePath)
if (await fs.pathExists(targetPath)) return 'skipped'
if (!safeStorage.isEncryptionAvailable()) throw new Error('系统安全存储不可用')
const tempPath = `${targetPath}.tmp-${process.pid}-${randomUUID()}`
try {
await fs.ensureDir(path.dirname(targetPath))
await fs.writeFile(tempPath, safeStorage.encryptString(plainText), { mode: 0o600 })
await fs.chmod(tempPath, 0o600)
if (await fs.pathExists(targetPath)) return 'skipped'
await fs.move(tempPath, targetPath, { overwrite: false })
return 'migrated'
} finally {
await fs.remove(tempPath).catch(() => undefined)
}
}
async function copyMissingTree(
sourceRoot: string,
targetRoot: string
): Promise<MigrationItemStatus> {
if (!(await fs.pathExists(sourceRoot))) return 'missing'
let copied = false
const visit = async (sourceDirectory: string, targetDirectory: string): Promise<void> => {
await fs.ensureDir(targetDirectory)
for (const entry of await fs.readdir(sourceDirectory, { withFileTypes: true })) {
const sourcePath = path.join(sourceDirectory, entry.name)
const targetPath = path.join(targetDirectory, entry.name)
if (entry.isDirectory()) {
await visit(sourcePath, targetPath)
} else if (entry.isFile() && !(await fs.pathExists(targetPath))) {
await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_EXCL)
const stat = await fs.stat(sourcePath)
await fs.chmod(targetPath, stat.mode & 0o777)
copied = true
}
}
}
await visit(sourceRoot, targetRoot)
return copied ? 'migrated' : 'skipped'
}
export function getAgentCredentialRoots(homePath = os.homedir()): {
legacy: string
current: string
} {
return {
legacy: path.join(homePath, '.wechatexplorer', 'wechat-connector', 'accounts'),
current: path.join(homePath, '.tracememo', 'wechat-connector', 'accounts')
}
}
async function hasEncryptedLegacyAssets(sourceRoot: string): Promise<boolean> {
for (const relativePath of [
'local-api-token.bin',
'ai-provider-keys.bin',
'wechat-image-keys.bin',
'wechat-db-key.bin'
]) {
if (await fs.pathExists(path.join(sourceRoot, relativePath))) return true
}
const databaseKeyRoot = path.join(sourceRoot, 'database-keys')
if (!(await fs.pathExists(databaseKeyRoot))) return false
return (await fs.readdir(databaseKeyRoot, { withFileTypes: true })).some(
(entry) => entry.isFile() && entry.name.endsWith('.bin')
)
}
export async function runLegacySecretHelper(sourceRoot: string): Promise<LegacySecretBundle> {
const helperUserData = await fs.mkdtemp(
path.join(app.getPath('temp'), 'tracememo-legacy-safe-storage-')
)
const helperArgs = app.isPackaged ? [] : process.argv.slice(1)
try {
return await new Promise<LegacySecretBundle>((resolve, reject) => {
const child = spawn(process.execPath, helperArgs, {
env: {
...process.env,
[LEGACY_MIGRATION_HELPER_ENV]: '1',
[LEGACY_MIGRATION_SOURCE_ENV]: sourceRoot,
[LEGACY_MIGRATION_USER_DATA_ENV]: helperUserData,
[LEGACY_MIGRATION_RESULT_FD_ENV]: '3'
},
stdio: ['ignore', 'ignore', 'pipe', 'pipe'],
windowsHide: true
})
const chunks: Buffer[] = []
let totalBytes = 0
let settled = false
const finish = (error?: Error, value?: LegacySecretBundle): void => {
if (settled) return
settled = true
clearTimeout(timeout)
if (error) reject(error)
else resolve(value!)
}
const resultPipe = child.stdio[3]
resultPipe?.on('data', (chunk: Buffer) => {
totalBytes += chunk.length
if (totalBytes > 5 * 1024 * 1024) {
child.kill()
finish(new Error('legacy secret helper result is too large'))
return
}
chunks.push(chunk)
})
child.once('error', (error) => finish(error))
child.once('exit', (code) => {
if (code !== 0) {
finish(new Error(`legacy secret helper exited with code ${code}`))
return
}
try {
finish(
undefined,
JSON.parse(Buffer.concat(chunks).toString('utf8')) as LegacySecretBundle
)
} catch {
finish(new Error('legacy secret helper returned invalid data'))
}
})
const timeout = setTimeout(() => {
child.kill()
finish(new Error('legacy secret helper timed out'))
}, 30_000)
})
} finally {
await fs.remove(helperUserData).catch(() => undefined)
}
}
export async function executeMigration(
sourceRoot: string,
targetRoot: string,
dependencies: MigrationDependencies = {
decryptLegacySecrets: runLegacySecretHelper,
agentRoots: () => getAgentCredentialRoots(),
now: () => new Date()
}
): Promise<MigrationExecutionResult> {
const items: Record<string, MigrationItemStatus> = {}
const secretFailures: string[] = []
const timestamp = (): string => dependencies.now().toISOString()
const state: MigrationState = {
version: 1,
status: 'in-progress',
sourceRoot,
updatedAt: timestamp(),
items,
secretFailures
}
await writeMigrationState(targetRoot, state)
const stagingRoot = path.join(targetRoot, '.tracememo-migration-staging-v1')
await fs.remove(stagingRoot).catch(() => undefined)
const runItem = async (name: string, task: () => Promise<MigrationItemStatus>): Promise<void> => {
dependencies.onProgress?.(
name === 'knowledge' ? '正在安全迁移 Knowledge,数据较大时需要几分钟…' : `正在迁移 ${name}…`
)
try {
items[name] = await task()
} catch {
items[name] = 'failed'
}
state.updatedAt = timestamp()
await writeMigrationState(targetRoot, state)
}
try {
for (const relativePath of FILE_ASSETS) {
await runItem(relativePath, () =>
copyFileWithoutOverwrite(sourceRoot, targetRoot, relativePath, stagingRoot)
)
}
for (const relativePath of DIRECTORY_ASSETS) {
await runItem(relativePath, () =>
copyDirectoryWithoutOverwrite(sourceRoot, targetRoot, relativePath, stagingRoot)
)
}
const agentRoots = dependencies.agentRoots()
await runItem('agent-credentials', () => copyMissingTree(agentRoots.legacy, agentRoots.current))
let secrets: LegacySecretBundle = { databaseKeys: {}, failures: [] }
if (await hasEncryptedLegacyAssets(sourceRoot)) {
try {
secrets = await dependencies.decryptLegacySecrets(sourceRoot)
secretFailures.push(...secrets.failures.map((failure) => failure.asset))
} catch {
secretFailures.push('safeStorage-helper')
}
}
const migrateSecret = async (
relativePath: string,
plainText: string | undefined
): Promise<void> => {
const sourceExists = await fs.pathExists(path.join(sourceRoot, relativePath))
if (!sourceExists) {
items[relativePath] = 'missing'
return
}
if (plainText === undefined) {
items[relativePath] = 'failed'
if (!secretFailures.includes(relativePath)) secretFailures.push(relativePath)
return
}
await runItem(relativePath, () =>
writeEncryptedWithoutOverwrite(targetRoot, relativePath, plainText)
)
}
await migrateSecret('local-api-token.bin', secrets.token)
await migrateSecret(
'ai-provider-keys.bin',
secrets.aiProviderKeys ? JSON.stringify(secrets.aiProviderKeys) : undefined
)
await migrateSecret(
'wechat-image-keys.bin',
secrets.imageKeys ? JSON.stringify(secrets.imageKeys) : undefined
)
await migrateSecret('wechat-db-key.bin', secrets.legacyDatabaseKey)
const databaseKeySource = path.join(sourceRoot, 'database-keys')
if (await fs.pathExists(databaseKeySource)) {
for (const entry of await fs.readdir(databaseKeySource, { withFileTypes: true })) {
if (!entry.isFile() || !/^[0-9a-f]{64}\.bin$/i.test(entry.name)) continue
const relativePath = path.join('database-keys', entry.name)
await migrateSecret(relativePath, secrets.databaseKeys[entry.name])
}
}
} finally {
await fs.remove(stagingRoot).catch(() => undefined)
}
const failed = Object.values(items).some((status) => status === 'failed')
state.status = failed || secretFailures.length > 0 ? 'partial' : 'completed'
state.updatedAt = timestamp()
state.secretFailures = Array.from(new Set(secretFailures))
await writeMigrationState(targetRoot, state)
const tokenGenerationBlocked =
(await fs.pathExists(path.join(sourceRoot, 'local-api-token.bin'))) &&
!(await fs.pathExists(path.join(targetRoot, 'local-api-token.bin')))
return {
state,
tokenGenerationBlocked,
...(tokenGenerationBlocked ? { tokenBlockReason: TOKEN_MIGRATION_BLOCK_MESSAGE } : {})
}
}
async function createMigrationProgressWindow(): Promise<BrowserWindow | null> {
if (process.platform !== 'darwin') return null
const window = new BrowserWindow({
width: 460,
height: 260,
show: false,
resizable: false,
minimizable: false,
maximizable: false,
closable: false,
title: 'TraceMemo 数据迁移',
backgroundColor: '#f5f5f7',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
const html = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><style>
html,body{height:100%;margin:0}body{display:grid;place-items:center;background:#f5f5f7;color:#202124;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center}
main{width:360px}.spinner{width:30px;height:30px;margin:0 auto 20px;border:3px solid #d9dddf;border-top-color:#00796b;border-radius:50%;animation:spin .9s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
h1{font-size:18px;margin:0 0 12px}p{line-height:1.6;margin:0}.hint{margin-top:14px;color:#697177;font-size:12px}
</style></head><body><main><div class="spinner"></div><h1>正在迁移 WechatExplorer 数据</h1><p id="status">正在准备迁移…</p><p class="hint">请不要退出 TraceMemo。旧数据不会被删除。</p></main></body></html>`
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
window.setProgressBar(2, { mode: 'indeterminate' })
window.show()
return window
}
function updateMigrationProgress(window: BrowserWindow | null, message: string): void {
if (!window || window.isDestroyed()) return
void window.webContents
.executeJavaScript(
`document.getElementById('status').textContent = ${JSON.stringify(message)}`,
true
)
.catch(() => undefined)
}
function closeMigrationProgress(window: BrowserWindow | null): void {
if (!window || window.isDestroyed()) return
window.setProgressBar(-1)
window.setClosable(true)
window.destroy()
}
export async function runFirstLaunchMigration(roots: UserDataRoots): Promise<MigrationFlowResult> {
const assessment = assessMigration(roots)
if (!assessment.shouldPrompt || !assessment.sourceRoot) {
return { assessment, action: 'none', tokenGenerationBlocked: false }
}
const sourceLabel = path.basename(assessment.sourceRoot)
const conflictNote = assessment.selection.legacyConflict
? '\n\n检测到两个旧数据目录,将确定性使用 WechatExplorer;另一个目录不会修改。'
: ''
const response = await dialog.showMessageBox({
type: 'question',
title: '迁移 WechatExplorer 数据',
message: '检测到 WechatExplorer 数据',
detail:
`TraceMemo 可以从 ${sourceLabel} 迁移设置、Knowledge、本地索引、API Token、AI Provider、报告和 Agent 配置。` +
'\n\n迁移只复制缺失的用户资产,不会覆盖 TraceMemo 已有数据,也不会删除旧目录。' +
conflictNote,
buttons: ['立即迁移', '以后迁移'],
defaultId: 0,
cancelId: 1,
noLink: true
})
if (response.response !== 0) {
const state: MigrationState = {
version: 1,
status: 'deferred',
sourceRoot: assessment.sourceRoot,
updatedAt: new Date().toISOString(),
items: assessment.state?.items || {},
secretFailures: assessment.state?.secretFailures || []
}
await writeMigrationState(roots.current, state)
const tokenGenerationBlocked = await fs.pathExists(
path.join(assessment.sourceRoot, 'local-api-token.bin')
)
return {
assessment,
action: 'deferred',
tokenGenerationBlocked,
...(tokenGenerationBlocked ? { tokenBlockReason: TOKEN_MIGRATION_BLOCK_MESSAGE } : {})
}
}
const progressWindow = await createMigrationProgressWindow()
let execution: MigrationExecutionResult
try {
execution = await executeMigration(assessment.sourceRoot, roots.current, {
decryptLegacySecrets: runLegacySecretHelper,
agentRoots: () => getAgentCredentialRoots(),
now: () => new Date(),
onProgress: (message) => updateMigrationProgress(progressWindow, message)
})
updateMigrationProgress(progressWindow, '迁移完成,正在启动 TraceMemo…')
const messageBoxOptions = {
type: execution.state.status === 'completed' ? ('info' as const) : ('warning' as const),
title: 'TraceMemo 数据迁移',
message:
execution.state.status === 'completed'
? 'WechatExplorer 数据迁移完成'
: '部分数据未能迁移',
detail:
execution.state.status === 'completed'
? '核心用户资产已复制到 TraceMemo。旧目录仍完整保留。'
: '旧目录没有被修改。请保留旧数据并在下次启动时重试;无法迁移的 API Token 不会被静默替换。',
buttons: ['好']
}
if (progressWindow && !progressWindow.isDestroyed()) {
await dialog.showMessageBox(progressWindow, messageBoxOptions)
} else {
await dialog.showMessageBox(messageBoxOptions)
}
} finally {
closeMigrationProgress(progressWindow)
}
return {
assessment,
action: 'migrated',
execution,
tokenGenerationBlocked: execution.tokenGenerationBlocked,
tokenBlockReason: execution.tokenBlockReason
}
}