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