mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-04-26 22:32:42 +08:00
572 lines
17 KiB
TypeScript
572 lines
17 KiB
TypeScript
/**
|
||
* 统一路径管理模块
|
||
* 所有应用数据存储在 app.getPath('userData') 目录下
|
||
*
|
||
* 各平台路径:
|
||
* - Windows: %APPDATA%/ChatLab (例如 C:\Users\xxx\AppData\Roaming\ChatLab)
|
||
* - macOS: ~/Library/Application Support/ChatLab
|
||
* - Linux: ~/.config/ChatLab
|
||
*/
|
||
|
||
import { app } from 'electron'
|
||
import * as fs from 'fs'
|
||
import * as path from 'path'
|
||
import {
|
||
copyDirMerge,
|
||
copyDirRecursive,
|
||
ensureMarkerFile,
|
||
isDirectorySafeToUse,
|
||
isExistingChatLabDir,
|
||
isPathSafe,
|
||
isSubPath,
|
||
writeMigrationLog,
|
||
} from './utils/pathUtils'
|
||
|
||
// 缓存路径,避免重复计算
|
||
let _appDataDir: string | null = null
|
||
let _legacyDataDir: string | null = null
|
||
|
||
// 存储配置文件名(放在 userData 根目录,避免受自定义数据目录影响)
|
||
const STORAGE_CONFIG_FILE = 'storage.json'
|
||
|
||
// ChatLab 数据目录标记文件(用于更严格的目录识别)
|
||
const CHATLAB_MARKER_FILE = '.chatlab'
|
||
// ChatLab 数据目录关键子目录(用于识别已有数据)
|
||
const CHATLAB_REQUIRED_DIRS = ['databases', 'settings']
|
||
|
||
/**
|
||
* 获取应用数据根目录
|
||
* 使用 userData/data 子目录,与 Electron 缓存隔离
|
||
*/
|
||
export function getAppDataDir(): string {
|
||
if (_appDataDir) return _appDataDir
|
||
|
||
// 优先读取用户自定义数据目录
|
||
const customDir = getCustomDataDir()
|
||
if (customDir) {
|
||
_appDataDir = customDir
|
||
return _appDataDir
|
||
}
|
||
|
||
// 回退到默认路径
|
||
_appDataDir = getDefaultAppDataDir()
|
||
return _appDataDir
|
||
}
|
||
|
||
/**
|
||
* 获取默认的数据根目录(userData/data)
|
||
*/
|
||
function getDefaultAppDataDir(): string {
|
||
try {
|
||
const userDataPath = app.getPath('userData')
|
||
// 使用子目录存放应用数据,避免与 Electron 缓存混淆
|
||
return path.join(userDataPath, 'data')
|
||
} catch (error) {
|
||
console.error('[Paths] Error getting userData path:', error)
|
||
return path.join(process.cwd(), 'userData', 'data')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取存储配置文件路径(userData 根目录)
|
||
*/
|
||
function getStorageConfigPath(): string {
|
||
try {
|
||
return path.join(app.getPath('userData'), STORAGE_CONFIG_FILE)
|
||
} catch (error) {
|
||
console.error('[Paths] Error getting storage config path:', error)
|
||
return path.join(process.cwd(), STORAGE_CONFIG_FILE)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 存储配置接口
|
||
*/
|
||
interface StorageConfig {
|
||
dataDir?: string
|
||
// 待删除的旧目录(下次启动时清理)
|
||
pendingDeleteDir?: string
|
||
}
|
||
|
||
/**
|
||
* 读取存储配置
|
||
*/
|
||
function readStorageConfig(): StorageConfig {
|
||
const configPath = getStorageConfigPath()
|
||
if (!fs.existsSync(configPath)) return {}
|
||
|
||
try {
|
||
const content = fs.readFileSync(configPath, 'utf-8')
|
||
const data = JSON.parse(content) as StorageConfig
|
||
return data || {}
|
||
} catch (error) {
|
||
console.error('[Paths] Error reading storage config:', error)
|
||
}
|
||
|
||
return {}
|
||
}
|
||
|
||
/**
|
||
* 保存存储配置
|
||
*/
|
||
function writeStorageConfig(config: StorageConfig): void {
|
||
const configPath = getStorageConfigPath()
|
||
try {
|
||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8')
|
||
} catch (error) {
|
||
console.error('[Paths] Error writing storage config:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取用户自定义数据目录
|
||
*/
|
||
export function getCustomDataDir(): string | null {
|
||
const config = readStorageConfig()
|
||
const dataDir = config.dataDir?.trim()
|
||
if (!dataDir) return null
|
||
|
||
// 只接受绝对路径
|
||
if (!path.isAbsolute(dataDir)) {
|
||
console.warn('[Paths] Invalid custom data dir (not absolute):', dataDir)
|
||
return null
|
||
}
|
||
|
||
return dataDir
|
||
}
|
||
|
||
/**
|
||
* 设置自定义数据目录
|
||
* @param dataDir 目标目录(为空则恢复默认)
|
||
* @param migrate 是否迁移现有数据(合并复制,不会覆盖目标文件)
|
||
*/
|
||
export function setCustomDataDir(
|
||
dataDir: string | null,
|
||
migrate: boolean = true
|
||
): { success: boolean; error?: string; from?: string; to?: string } {
|
||
const normalized = typeof dataDir === 'string' ? dataDir.trim() : ''
|
||
const oldDir = getAppDataDir()
|
||
|
||
try {
|
||
if (!normalized) {
|
||
// 恢复默认路径
|
||
const newDir = getDefaultAppDataDir()
|
||
|
||
// 防止目标目录是当前目录的子目录,避免递归复制
|
||
if (migrate && oldDir !== newDir && isSubPath(oldDir, newDir)) {
|
||
return { success: false, error: '目标目录不能是当前数据目录的子目录' }
|
||
}
|
||
|
||
// 先清除自定义配置,切回默认目录
|
||
writeStorageConfig({})
|
||
_appDataDir = newDir
|
||
|
||
let canDeleteOldDir = false
|
||
if (migrate && oldDir !== newDir) {
|
||
const migrateResult = copyDirMerge(oldDir, newDir, ensureDir)
|
||
console.log(
|
||
`[Paths] 数据迁移完成: 复制 ${migrateResult.copied} 项, 跳过 ${migrateResult.skipped} 项, 错误 ${migrateResult.errors.length} 项`
|
||
)
|
||
if (migrateResult.errors.length > 0) {
|
||
console.warn('[Paths] 迁移过程中出现错误:', migrateResult.errors)
|
||
console.warn('[Paths] 迁移失败,旧数据目录将不会自动删除')
|
||
// 迁移失败日志写入 app.log
|
||
writeMigrationLog(
|
||
getLogsDir(),
|
||
`切换为默认目录迁移失败: 从 ${oldDir} 到 ${newDir},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项,错误 ${migrateResult.errors.length} 项`,
|
||
ensureDir
|
||
)
|
||
} else {
|
||
canDeleteOldDir = true
|
||
// 迁移成功日志写入 app.log
|
||
writeMigrationLog(
|
||
getLogsDir(),
|
||
`切换为默认目录迁移成功: 从 ${oldDir} 到 ${newDir},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项`,
|
||
ensureDir
|
||
)
|
||
}
|
||
}
|
||
|
||
// 迁移成功才标记删除旧目录,避免数据丢失
|
||
if (canDeleteOldDir) {
|
||
writeStorageConfig({ pendingDeleteDir: oldDir })
|
||
}
|
||
|
||
return { success: true, from: oldDir, to: newDir }
|
||
}
|
||
|
||
if (!path.isAbsolute(normalized)) {
|
||
return { success: false, error: '数据目录必须是绝对路径' }
|
||
}
|
||
|
||
// 防止目标目录是当前目录的子目录,避免递归复制
|
||
if (migrate && oldDir !== normalized && isSubPath(oldDir, normalized)) {
|
||
return { success: false, error: '目标目录不能是当前数据目录的子目录' }
|
||
}
|
||
|
||
// 安全检查:不能使用系统关键目录
|
||
if (!isPathSafe(normalized)) {
|
||
return { success: false, error: '不能使用系统关键目录作为数据目录' }
|
||
}
|
||
|
||
// 安全检查:目标目录应为空或已有 ChatLab 数据
|
||
if (!isDirectorySafeToUse(normalized, CHATLAB_MARKER_FILE, CHATLAB_REQUIRED_DIRS)) {
|
||
return { success: false, error: '目标目录不为空且不包含 ChatLab 数据,请选择空目录或已有数据目录' }
|
||
}
|
||
|
||
// 确保目录存在
|
||
ensureDir(normalized)
|
||
|
||
_appDataDir = normalized
|
||
|
||
let canDeleteOldDir = false
|
||
if (migrate && oldDir !== normalized) {
|
||
const migrateResult = copyDirMerge(oldDir, normalized, ensureDir)
|
||
console.log(
|
||
`[Paths] 数据迁移完成: 复制 ${migrateResult.copied} 项, 跳过 ${migrateResult.skipped} 项, 错误 ${migrateResult.errors.length} 项`
|
||
)
|
||
if (migrateResult.errors.length > 0) {
|
||
console.warn('[Paths] 迁移过程中出现错误:', migrateResult.errors)
|
||
console.warn('[Paths] 迁移失败,旧数据目录将不会自动删除')
|
||
// 迁移失败日志写入 app.log
|
||
writeMigrationLog(
|
||
getLogsDir(),
|
||
`切换目录迁移失败: 从 ${oldDir} 到 ${normalized},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项,错误 ${migrateResult.errors.length} 项`,
|
||
ensureDir
|
||
)
|
||
} else {
|
||
canDeleteOldDir = true
|
||
// 迁移成功日志写入 app.log
|
||
writeMigrationLog(
|
||
getLogsDir(),
|
||
`切换目录迁移成功: 从 ${oldDir} 到 ${normalized},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项`,
|
||
ensureDir
|
||
)
|
||
}
|
||
}
|
||
|
||
// 迁移成功才标记删除旧目录,避免数据丢失
|
||
const config: StorageConfig = { dataDir: normalized }
|
||
if (canDeleteOldDir) {
|
||
config.pendingDeleteDir = oldDir
|
||
}
|
||
writeStorageConfig(config)
|
||
|
||
return { success: true, from: oldDir, to: normalized }
|
||
} catch (error) {
|
||
console.error('[Paths] Error setting custom data dir:', error)
|
||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理待删除的旧数据目录(应用启动时调用)
|
||
*/
|
||
export function cleanupPendingDeleteDir(): void {
|
||
try {
|
||
const config = readStorageConfig()
|
||
const pendingDir = config.pendingDeleteDir
|
||
|
||
if (!pendingDir) return
|
||
|
||
// 获取当前数据目录
|
||
const currentDir = getAppDataDir()
|
||
|
||
// 安全检查:不能删除当前正在使用的目录
|
||
if (pendingDir === currentDir) {
|
||
console.log('[Paths] 跳过清理:待删除目录与当前目录相同')
|
||
// 清除待删除标记
|
||
writeStorageConfig({ dataDir: config.dataDir })
|
||
return
|
||
}
|
||
|
||
// 安全检查:不能删除系统关键目录
|
||
if (!isPathSafe(pendingDir)) {
|
||
console.log('[Paths] 跳过清理:待删除目录是系统关键目录:', pendingDir)
|
||
// 清除待删除标记
|
||
writeStorageConfig({ dataDir: config.dataDir })
|
||
return
|
||
}
|
||
|
||
// 安全检查:确保待删除目录确实是 ChatLab 数据目录(标记文件 + 关键子目录)
|
||
if (fs.existsSync(pendingDir) && !isExistingChatLabDir(pendingDir, CHATLAB_MARKER_FILE, CHATLAB_REQUIRED_DIRS)) {
|
||
console.log('[Paths] 跳过清理:待删除目录不是 ChatLab 数据目录:', pendingDir)
|
||
// 清除待删除标记
|
||
writeStorageConfig({ dataDir: config.dataDir })
|
||
return
|
||
}
|
||
|
||
// 检查目录是否存在
|
||
if (!fs.existsSync(pendingDir)) {
|
||
console.log('[Paths] 待删除目录不存在,跳过清理:', pendingDir)
|
||
// 清除待删除标记
|
||
writeStorageConfig({ dataDir: config.dataDir })
|
||
return
|
||
}
|
||
|
||
// 删除旧目录
|
||
console.log('[Paths] 正在清理旧数据目录:', pendingDir)
|
||
fs.rmSync(pendingDir, { recursive: true, force: true })
|
||
console.log('[Paths] 旧数据目录已删除:', pendingDir)
|
||
|
||
// 清除待删除标记
|
||
writeStorageConfig({ dataDir: config.dataDir })
|
||
} catch (error) {
|
||
console.error('[Paths] 清理旧目录失败:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取旧版数据目录(Documents/ChatLab)
|
||
* 用于数据迁移检测
|
||
*/
|
||
export function getLegacyDataDir(): string {
|
||
if (_legacyDataDir) return _legacyDataDir
|
||
|
||
try {
|
||
const docPath = app.getPath('documents')
|
||
_legacyDataDir = path.join(docPath, 'ChatLab')
|
||
} catch (error) {
|
||
console.error('[Paths] Error getting documents path:', error)
|
||
_legacyDataDir = path.join(process.cwd(), 'ChatLab')
|
||
}
|
||
|
||
return _legacyDataDir
|
||
}
|
||
|
||
/**
|
||
* 获取系统下载目录
|
||
* 用于用户导出文件的默认位置
|
||
*/
|
||
export function getDownloadsDir(): string {
|
||
try {
|
||
return app.getPath('downloads')
|
||
} catch (error) {
|
||
console.error('[Paths] Error getting downloads path:', error)
|
||
return path.join(process.cwd(), 'downloads')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取数据库目录
|
||
*/
|
||
export function getDatabaseDir(): string {
|
||
return path.join(getAppDataDir(), 'databases')
|
||
}
|
||
|
||
/**
|
||
* 获取 AI 数据目录(对话历史、LLM 配置)
|
||
*/
|
||
export function getAiDataDir(): string {
|
||
return path.join(getAppDataDir(), 'ai')
|
||
}
|
||
|
||
/**
|
||
* 获取设置目录
|
||
*/
|
||
export function getSettingsDir(): string {
|
||
return path.join(getAppDataDir(), 'settings')
|
||
}
|
||
|
||
/**
|
||
* 获取临时文件目录
|
||
*/
|
||
export function getTempDir(): string {
|
||
return path.join(getAppDataDir(), 'temp')
|
||
}
|
||
|
||
/**
|
||
* 获取日志目录
|
||
*/
|
||
export function getLogsDir(): string {
|
||
return path.join(getAppDataDir(), 'logs')
|
||
}
|
||
|
||
/**
|
||
* 确保目录存在
|
||
*/
|
||
export function ensureDir(dirPath: string): void {
|
||
if (!fs.existsSync(dirPath)) {
|
||
fs.mkdirSync(dirPath, { recursive: true })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 确保所有应用目录存在
|
||
*/
|
||
export function ensureAppDirs(): void {
|
||
ensureDir(getDatabaseDir())
|
||
ensureDir(getAiDataDir())
|
||
ensureDir(getSettingsDir())
|
||
ensureDir(getTempDir())
|
||
ensureDir(getLogsDir())
|
||
// 写入数据目录标记文件
|
||
ensureMarkerFile(getAppDataDir(), CHATLAB_MARKER_FILE)
|
||
}
|
||
|
||
// ==================== 数据迁移 ====================
|
||
|
||
/**
|
||
* 检查是否需要从 Documents/ChatLab 迁移数据
|
||
*/
|
||
export function needsLegacyMigration(): boolean {
|
||
const legacyDir = getLegacyDataDir()
|
||
|
||
// 检查 Documents/ChatLab 是否存在
|
||
if (fs.existsSync(legacyDir)) {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 从指定源目录迁移数据到目标目录
|
||
* 采用合并策略:只复制不存在的文件,不覆盖已存在的文件
|
||
*/
|
||
function migrateDirectory(
|
||
srcDir: string,
|
||
destDir: string,
|
||
subDirs: string[]
|
||
): { migratedDirs: string[]; skippedDirs: string[] } {
|
||
const migratedDirs: string[] = []
|
||
const skippedDirs: string[] = []
|
||
|
||
for (const subDir of subDirs) {
|
||
const srcSubPath = path.join(srcDir, subDir)
|
||
const destSubPath = path.join(destDir, subDir)
|
||
|
||
// 如果源子目录不存在或为空,跳过
|
||
if (!fs.existsSync(srcSubPath)) {
|
||
continue
|
||
}
|
||
|
||
const srcFiles = fs.readdirSync(srcSubPath).filter((f) => !f.startsWith('.'))
|
||
if (srcFiles.length === 0) {
|
||
continue
|
||
}
|
||
|
||
// 确保目标子目录存在
|
||
ensureDir(destSubPath)
|
||
|
||
// 获取目标目录中已存在的文件
|
||
const existingFiles = new Set(fs.readdirSync(destSubPath))
|
||
|
||
// 合并策略:只复制目标目录中不存在的文件
|
||
let copiedCount = 0
|
||
let skippedCount = 0
|
||
|
||
for (const file of srcFiles) {
|
||
const srcPath = path.join(srcSubPath, file)
|
||
const destPath = path.join(destSubPath, file)
|
||
|
||
// 如果目标文件已存在,跳过(不覆盖)
|
||
if (existingFiles.has(file)) {
|
||
console.log(`[Paths] Skipping ${subDir}/${file}: already exists in destination`)
|
||
skippedCount++
|
||
continue
|
||
}
|
||
|
||
const stat = fs.statSync(srcPath)
|
||
if (stat.isDirectory()) {
|
||
copyDirRecursive(srcPath, destPath, ensureDir)
|
||
} else {
|
||
fs.copyFileSync(srcPath, destPath)
|
||
}
|
||
copiedCount++
|
||
}
|
||
|
||
if (copiedCount > 0) {
|
||
migratedDirs.push(subDir)
|
||
console.log(`[Paths] Migrated ${subDir}: ${copiedCount} items copied, ${skippedCount} skipped`)
|
||
} else if (skippedCount > 0) {
|
||
skippedDirs.push(subDir)
|
||
console.log(`[Paths] ${subDir}: all ${skippedCount} items already exist in destination`)
|
||
}
|
||
}
|
||
|
||
return { migratedDirs, skippedDirs }
|
||
}
|
||
|
||
/**
|
||
* 执行从 Documents/ChatLab 到新目录的数据迁移
|
||
* 迁移整个目录的所有内容,采用合并策略:只复制不存在的文件,不覆盖已存在的文件
|
||
* 只有在所有数据都成功迁移后才删除旧目录
|
||
*/
|
||
export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string[]; error?: string } {
|
||
const legacyDir = getLegacyDataDir()
|
||
const newDir = getAppDataDir()
|
||
|
||
try {
|
||
if (!fs.existsSync(legacyDir)) {
|
||
return { success: true, migratedDirs: [] }
|
||
}
|
||
|
||
// 获取旧目录下的所有子目录和文件
|
||
const entries = fs.readdirSync(legacyDir, { withFileTypes: true })
|
||
const dirsToMigrate = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name)
|
||
const filesToMigrate = entries.filter((e) => e.isFile() && !e.name.startsWith('.')).map((e) => e.name)
|
||
|
||
const result = migrateDirectory(legacyDir, newDir, dirsToMigrate)
|
||
|
||
// 迁移根目录下的文件
|
||
ensureDir(newDir)
|
||
for (const file of filesToMigrate) {
|
||
const srcPath = path.join(legacyDir, file)
|
||
const destPath = path.join(newDir, file)
|
||
if (!fs.existsSync(destPath)) {
|
||
fs.copyFileSync(srcPath, destPath)
|
||
}
|
||
}
|
||
|
||
// 构建迁移摘要
|
||
const summary: string[] = []
|
||
summary.push(`Migration from ${legacyDir} to ${newDir}`)
|
||
|
||
// 迁移成功,删除旧目录
|
||
fs.rmSync(legacyDir, { recursive: true, force: true })
|
||
summary.push('Status: Success, legacy directory removed')
|
||
|
||
if (result.migratedDirs.length > 0) {
|
||
summary.push(`Migrated dirs: ${result.migratedDirs.join(', ')}`)
|
||
}
|
||
if (filesToMigrate.length > 0) {
|
||
summary.push(`Migrated files: ${filesToMigrate.length}`)
|
||
}
|
||
|
||
// 写入迁移日志
|
||
writeMigrationLog(getLogsDir(), summary.join(' | '), ensureDir)
|
||
|
||
return { success: true, migratedDirs: result.migratedDirs }
|
||
} catch (error) {
|
||
console.error('[Paths] Migration failed:', error)
|
||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||
writeMigrationLog(getLogsDir(), `Migration failed: ${errorMsg}`, ensureDir)
|
||
return {
|
||
success: false,
|
||
migratedDirs: [],
|
||
error: errorMsg,
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除旧版数据目录(可选,供用户确认后调用)
|
||
*/
|
||
export function removeLegacyDir(): boolean {
|
||
const legacyDir = getLegacyDataDir()
|
||
|
||
if (!fs.existsSync(legacyDir)) {
|
||
return true
|
||
}
|
||
|
||
try {
|
||
fs.rmSync(legacyDir, { recursive: true, force: true })
|
||
console.log(`[Paths] Removed legacy directory: ${legacyDir}`)
|
||
return true
|
||
} catch (error) {
|
||
console.error('[Paths] Failed to remove legacy directory:', error)
|
||
return false
|
||
}
|
||
}
|