Files
ChatLab/electron/main/paths.ts
2026-02-03 00:25:09 +08:00

572 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 统一路径管理模块
* 所有应用数据存储在 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
}
}