mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 21:47:00 +08:00
- 将 WechatExplorer 产品身份升级为 TraceMemo - 更新 appId、Runtime Identity、Reader Skill 和 API 环境变量 - 增加旧用户数据、Knowledge、Token 与 AI Provider 安全迁移 - 保留 Windows WeFlow 和旧版配置兼容 - 完善首次启动迁移测试及 v2.2.0 发布文档 - 移除 macOS Intel x64 构建与发布支持
125 lines
4.3 KiB
TypeScript
125 lines
4.3 KiB
TypeScript
import { safeStorage } from 'electron'
|
|
import fs from 'fs-extra'
|
|
import path from 'path'
|
|
import { LEGACY_MIGRATION_RESULT_FD_ENV, LEGACY_MIGRATION_SOURCE_ENV } from './app-data-bootstrap'
|
|
import { isValidDatabaseKey } from './database-key-store'
|
|
|
|
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
|
|
|
|
export interface LegacySecretFailure {
|
|
asset: string
|
|
error: string
|
|
}
|
|
|
|
export interface LegacySecretBundle {
|
|
token?: string
|
|
aiProviderKeys?: { version: 1; keys: Record<string, string> }
|
|
imageKeys?: {
|
|
version: 1
|
|
accounts: Record<string, { xorKey: string; aesKey: string; updatedAt: number }>
|
|
}
|
|
legacyDatabaseKey?: string
|
|
databaseKeys: Record<string, string>
|
|
failures: LegacySecretFailure[]
|
|
}
|
|
|
|
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
return (
|
|
Boolean(value) &&
|
|
typeof value === 'object' &&
|
|
Object.values(value as Record<string, unknown>).every((item) => typeof item === 'string')
|
|
)
|
|
}
|
|
|
|
function decryptFile(filePath: string): string {
|
|
return safeStorage.decryptString(fs.readFileSync(filePath))
|
|
}
|
|
|
|
export function decryptLegacySecrets(sourceRoot: string): LegacySecretBundle {
|
|
const result: LegacySecretBundle = { databaseKeys: {}, failures: [] }
|
|
if (!safeStorage.isEncryptionAvailable()) {
|
|
result.failures.push({ asset: 'safeStorage', error: '系统安全存储不可用' })
|
|
return result
|
|
}
|
|
|
|
const readSecret = (
|
|
asset: string,
|
|
relativePath: string,
|
|
apply: (plain: string) => void
|
|
): void => {
|
|
const filePath = path.join(sourceRoot, relativePath)
|
|
if (!fs.pathExistsSync(filePath)) return
|
|
try {
|
|
apply(decryptFile(filePath))
|
|
} catch {
|
|
result.failures.push({ asset, error: '旧加密数据无法解密或格式无效' })
|
|
}
|
|
}
|
|
|
|
readSecret('local-api-token.bin', 'local-api-token.bin', (plain) => {
|
|
if (!TOKEN_PATTERN.test(plain)) throw new Error('invalid token')
|
|
result.token = plain
|
|
})
|
|
|
|
readSecret('ai-provider-keys.bin', 'ai-provider-keys.bin', (plain) => {
|
|
const parsed = JSON.parse(plain) as { version?: unknown; keys?: unknown }
|
|
if (parsed.version !== 1 || !isStringRecord(parsed.keys))
|
|
throw new Error('invalid provider keys')
|
|
result.aiProviderKeys = { version: 1, keys: parsed.keys }
|
|
})
|
|
|
|
readSecret('wechat-image-keys.bin', 'wechat-image-keys.bin', (plain) => {
|
|
const parsed = JSON.parse(plain) as {
|
|
version?: unknown
|
|
accounts?: Record<string, { xorKey?: unknown; aesKey?: unknown; updatedAt?: unknown }>
|
|
}
|
|
if (
|
|
parsed.version !== 1 ||
|
|
!parsed.accounts ||
|
|
Object.values(parsed.accounts).some(
|
|
(entry) =>
|
|
!entry ||
|
|
typeof entry.xorKey !== 'string' ||
|
|
typeof entry.aesKey !== 'string' ||
|
|
typeof entry.updatedAt !== 'number'
|
|
)
|
|
) {
|
|
throw new Error('invalid image keys')
|
|
}
|
|
result.imageKeys = {
|
|
version: 1,
|
|
accounts: parsed.accounts as NonNullable<LegacySecretBundle['imageKeys']>['accounts']
|
|
}
|
|
})
|
|
|
|
readSecret('wechat-db-key.bin', 'wechat-db-key.bin', (plain) => {
|
|
if (!isValidDatabaseKey(plain)) throw new Error('invalid legacy database key')
|
|
result.legacyDatabaseKey = plain.trim().replace(/^0x/i, '')
|
|
})
|
|
|
|
const databaseKeysRoot = path.join(sourceRoot, 'database-keys')
|
|
if (fs.pathExistsSync(databaseKeysRoot)) {
|
|
for (const entry of fs.readdirSync(databaseKeysRoot, { withFileTypes: true })) {
|
|
if (!entry.isFile() || !entry.name.endsWith('.bin')) continue
|
|
readSecret(`database-keys/${entry.name}`, path.join('database-keys', entry.name), (plain) => {
|
|
if (!isValidDatabaseKey(plain)) throw new Error('invalid database key')
|
|
result.databaseKeys[entry.name] = plain.trim().replace(/^0x/i, '')
|
|
})
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
export function writeLegacySecretHelperResult(result: LegacySecretBundle): void {
|
|
const fd = Number(process.env[LEGACY_MIGRATION_RESULT_FD_ENV] || '3')
|
|
if (!Number.isInteger(fd) || fd < 3) throw new Error('invalid migration result pipe')
|
|
fs.writeFileSync(fd, JSON.stringify(result), 'utf8')
|
|
}
|
|
|
|
export function runLegacySafeStorageHelper(): void {
|
|
const sourceRoot = process.env[LEGACY_MIGRATION_SOURCE_ENV]?.trim()
|
|
if (!sourceRoot) throw new Error('legacy migration source is missing')
|
|
writeLegacySecretHelperResult(decryptLegacySecrets(path.resolve(sourceRoot)))
|
|
}
|