fix(data): defer data directory migration until restart

This commit is contained in:
digua committed 2026-06-03 00:46:34 +08:00
1 parent f978845dde
commit 04c97ca9cf
9 files changed
+384 -92

No files matched your search

+18
View File
@@ -12,6 +12,7 @@ import {
migrateFromLegacyDir,
ensureAppDirs,
cleanupPendingDeleteDir,
applyPendingDataDirMigration,
needsUnifiedDirMigration,
migrateToUnifiedDirs,
verifyDataPath,
@@ -89,6 +90,9 @@ class MainProcess {
// E2E 测试模式:跳过遗留数据迁移
// 遗留迁移会删除 Documents/ChatLab,在本地测试时可能破坏用户数据
if (!this.isTestMode) {
// 应用上次设置页登记的数据目录切换;必须早于任何数据库连接初始化
this.applyPendingDataDirMigrationIfNeeded()
// 清理上次切换目录后的旧数据目录
cleanupPendingDeleteDir()
@@ -133,6 +137,20 @@ class MainProcess {
this.mainAppEvents()
}
// 应用设置页登记的数据目录切换(重启期执行)
applyPendingDataDirMigrationIfNeeded() {
const result = applyPendingDataDirMigration()
if (result.skipped) {
console.log('[Main] No pending data directory migration')
return
}
if (result.success) {
console.log('[Main] Pending data directory migration completed')
} else {
console.error('[Main] Pending data directory migration failed:', result.error)
}
}
// 从旧目录迁移数据(Documents/ChatLab → userData/data)
migrateDataIfNeeded() {
if (needsLegacyMigration()) {
+2 -2
View File
@@ -128,7 +128,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
}
const { shell } = await import('electron')
const { getDefaultUserDataDir, getCustomDataDir, getDownloadsDir } = await import('./paths')
const { getDefaultUserDataDir, getUserDataDir, getDownloadsDir } = await import('./paths')
const ctx: HttpRouteContext = {
dbManager: newDbManager,
@@ -151,7 +151,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
},
downloadsDir: getDownloadsDir(),
defaultUserDataDir: getDefaultUserDataDir(),
isCustomDataDir: Boolean(getCustomDataDir()),
isCustomDataDir: path.resolve(getUserDataDir()) !== path.resolve(getDefaultUserDataDir()),
runAgentStream: createElectronRunAgentStream(),
}
+4 -1
View File
@@ -54,12 +54,15 @@ export function registerCacheHandlers(_context: IpcContext): void {
return { success: false, error: result.error }
}
ensureAppDirs()
if (result.requiresRelaunch === false) {
ensureAppDirs()
}
return {
success: true,
from: result.from,
to: result.to,
requiresRelaunch: result.requiresRelaunch,
}
})
}
+81 -86
View File
@@ -30,13 +30,19 @@ import {
copyDirMerge,
copyDirRecursive,
ensureMarkerFile,
isDirectorySafeToUse,
isExistingChatLabDir,
isInsideAppInstallDir,
isPathSafe,
isSubPath,
writeMigrationLog,
} from './utils/pathUtils'
import {
createPendingDataDirMigration,
isDirectoryEmptyOrMissing,
isExistingUserDataDir,
isUserDataDirSafeToUse,
runPendingDataDirMigration,
type PendingDataDirMigration,
} from './utils/dataDirSwitch'
// 缓存路径,避免重复计算
let _systemDataDir: string | null = null
@@ -48,8 +54,6 @@ const STORAGE_CONFIG_FILE = 'storage.json'
// ChatLab 数据目录标记文件(用于更严格的目录识别)
const CHATLAB_MARKER_FILE = '.chatlab'
// ChatLab 数据目录关键子目录(用于识别已有数据)
const CHATLAB_REQUIRED_DIRS = ['databases', 'settings']
// ==================== 新路径体系 ====================
@@ -124,6 +128,7 @@ function getStorageConfigPath(): string {
interface StorageConfig {
dataDir?: string
pendingDeleteDir?: string
pendingDataDirMigration?: PendingDataDirMigration
}
function readStorageConfig(): StorageConfig {
@@ -167,117 +172,107 @@ export function getCustomDataDir(): string | null {
export function setCustomDataDir(
dataDir: string | null,
migrate: boolean = true
): { success: boolean; error?: string; from?: string; to?: string } {
): { success: boolean; error?: string; from?: string; to?: string; requiresRelaunch?: boolean } {
const normalized = typeof dataDir === 'string' ? dataDir.trim() : ''
const oldDir = getUserDataDir()
try {
if (!normalized) {
const newDir = getDefaultUserDataDir()
if (migrate && oldDir !== newDir && isSubPath(oldDir, newDir)) {
return { success: false, error: '目标目录不能是当前数据目录的子目录' }
}
writeConfigField('data', 'user_data_dir', newDir)
writeConfigField('data', 'electron_migration_done', true)
_userDataDir = 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] Errors during migration:', migrateResult.errors)
writeMigrationLog(
getLogsDir(),
`切换为默认目录迁移失败: 从 ${oldDir} 到 ${newDir},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项,错误 ${migrateResult.errors.length} 项`,
ensureDir
)
} else {
canDeleteOldDir = true
writeMigrationLog(
getLogsDir(),
`切换为默认目录迁移成功: 从 ${oldDir} 到 ${newDir},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项`,
ensureDir
)
}
}
if (canDeleteOldDir) {
writeStorageConfig({ pendingDeleteDir: oldDir })
}
return { success: true, from: oldDir, to: newDir }
if (process.env.CHATLAB_DATA_DIR) {
return { success: false, error: 'CHATLAB_DATA_DIR 已设置,不能在界面中切换数据目录' }
}
if (!path.isAbsolute(normalized)) {
const newDir = normalized || getDefaultUserDataDir()
if (!path.isAbsolute(newDir)) {
return { success: false, error: '数据目录必须是绝对路径' }
}
if (migrate && oldDir !== normalized && isSubPath(oldDir, normalized)) {
if (migrate && oldDir !== newDir && isSubPath(oldDir, newDir)) {
return { success: false, error: '目标目录不能是当前数据目录的子目录' }
}
if (!isPathSafe(normalized)) {
if (!isPathSafe(newDir)) {
return { success: false, error: '不能使用系统关键目录作为数据目录' }
}
try {
const exePath = app.getPath('exe')
if (isInsideAppInstallDir(normalized, exePath)) {
if (isInsideAppInstallDir(newDir, exePath)) {
return { success: false, error: '不能将数据目录放在应用安装目录下,应用更新时该目录会被清空' }
}
} catch {
// 获取 exe 路径失败时跳过此检查
}
if (!isDirectorySafeToUse(normalized, CHATLAB_MARKER_FILE, CHATLAB_REQUIRED_DIRS)) {
if (!isUserDataDirSafeToUse(newDir)) {
return { success: false, error: '目标目录不为空且不包含 ChatLab 数据,请选择空目录或已有数据目录' }
}
ensureDir(normalized)
_userDataDir = 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] Errors during migration:', migrateResult.errors)
writeMigrationLog(
getLogsDir(),
`切换目录迁移失败: 从 ${oldDir} 到 ${normalized},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项,错误 ${migrateResult.errors.length} 项`,
ensureDir
)
} else {
canDeleteOldDir = true
writeMigrationLog(
getLogsDir(),
`切换目录迁移成功: 从 ${oldDir} 到 ${normalized},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项`,
ensureDir
)
}
if (path.resolve(oldDir) === path.resolve(newDir)) {
const config = readStorageConfig()
writeStorageConfig({ ...config, pendingDataDirMigration: undefined })
return { success: true, from: oldDir, to: newDir, requiresRelaunch: false }
}
writeConfigField('data', 'user_data_dir', normalized)
writeConfigField('data', 'electron_migration_done', true)
const config = readStorageConfig()
const pending = createPendingDataDirMigration({
from: oldDir,
to: newDir,
migrate,
targetWasEmpty: isDirectoryEmptyOrMissing(newDir),
})
writeStorageConfig({ ...config, pendingDataDirMigration: pending })
if (canDeleteOldDir) {
writeStorageConfig({ pendingDeleteDir: oldDir })
}
return { success: true, from: oldDir, to: normalized }
return { success: true, from: oldDir, to: newDir, requiresRelaunch: true }
} catch (error) {
console.error('[Paths] Error setting custom data dir:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export function applyPendingDataDirMigration(): { success: boolean; skipped?: boolean; error?: string } {
const config = readStorageConfig()
const pending = config.pendingDataDirMigration
if (!pending) return { success: true, skipped: true }
const result = runPendingDataDirMigration(pending, {
writeUserDataDir(dir) {
writeConfigField('data', 'user_data_dir', dir)
writeConfigField('data', 'electron_migration_done', true)
_userDataDir = dir
},
clearPendingMigration() {
const latest = readStorageConfig()
writeStorageConfig({ ...latest, pendingDataDirMigration: undefined })
},
markPendingDeleteDir(dir) {
const latest = readStorageConfig()
writeStorageConfig({ ...latest, pendingDeleteDir: dir })
},
log(message) {
writeMigrationLog(getLogsDir(), message, ensureDir)
},
})
if (!result.success) {
const error = result.errors.join('; ') || '数据目录迁移失败'
console.warn('[Paths] Pending data dir migration failed:', error)
writeMigrationLog(
getLogsDir(),
`切换目录迁移失败: 从 ${pending.from} 到 ${pending.to},复制 ${result.copied} 项,跳过 ${result.skipped} 项,错误 ${result.errors.length} 项: ${error}`,
ensureDir
)
return { success: false, error }
}
writeMigrationLog(
getLogsDir(),
`切换目录迁移成功: 从 ${pending.from} 到 ${pending.to},复制 ${result.copied} 项,跳过 ${result.skipped} 项`,
ensureDir
)
return { success: true }
}
/**
* 清理待删除的旧数据目录(应用启动时调用)
*/
@@ -292,25 +287,25 @@ export function cleanupPendingDeleteDir(): void {
if (pendingDir === currentDir) {
console.log('[Paths] Skipping cleanup: pending dir is same as current dir')
writeStorageConfig({ dataDir: config.dataDir })
writeStorageConfig({ ...config, pendingDeleteDir: undefined })
return
}
if (!isPathSafe(pendingDir)) {
console.log('[Paths] Skipping cleanup: pending dir is a system directory:', pendingDir)
writeStorageConfig({ dataDir: config.dataDir })
writeStorageConfig({ ...config, pendingDeleteDir: undefined })
return
}
if (fs.existsSync(pendingDir) && !isExistingChatLabDir(pendingDir, CHATLAB_MARKER_FILE, CHATLAB_REQUIRED_DIRS)) {
if (fs.existsSync(pendingDir) && !isExistingUserDataDir(pendingDir)) {
console.log('[Paths] Skipping cleanup: pending dir is not a ChatLab data dir:', pendingDir)
writeStorageConfig({ dataDir: config.dataDir })
writeStorageConfig({ ...config, pendingDeleteDir: undefined })
return
}
if (!fs.existsSync(pendingDir)) {
console.log('[Paths] Pending dir does not exist, skipping cleanup:', pendingDir)
writeStorageConfig({ dataDir: config.dataDir })
writeStorageConfig({ ...config, pendingDeleteDir: undefined })
return
}
@@ -318,7 +313,7 @@ export function cleanupPendingDeleteDir(): void {
fs.rmSync(pendingDir, { recursive: true, force: true })
console.log('[Paths] Old data directory deleted:', pendingDir)
writeStorageConfig({ dataDir: config.dataDir })
writeStorageConfig({ ...config, pendingDeleteDir: undefined })
} catch (error) {
console.error('[Paths] Failed to clean up old directory:', error)
}
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { createPendingDataDirMigration, runPendingDataDirMigration, isExistingUserDataDir } from './dataDirSwitch'
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-data-switch-'))
}
function writeFile(filePath: string, content = 'data'): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content, 'utf-8')
}
test('createPendingDataDirMigration records a restart-time migration without mutating config', () => {
const pending = createPendingDataDirMigration({
from: '/old/data',
to: '/new/data',
migrate: true,
targetWasEmpty: true,
})
assert.equal(pending.from, '/old/data')
assert.equal(pending.to, '/new/data')
assert.equal(pending.migrate, true)
assert.equal(pending.deleteSourceOnSuccess, true)
assert.match(pending.createdAt, /^\d{4}-\d{2}-\d{2}T/)
})
test('runPendingDataDirMigration writes config only after copy succeeds', () => {
const root = makeTempDir()
const source = path.join(root, 'source')
const target = path.join(root, 'target')
writeFile(path.join(source, 'databases', 'session.db'), 'sqlite')
let configuredDir = source
let pendingCleared = false
let pendingDeleteDir: string | null = null
const result = runPendingDataDirMigration(
createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }),
{
writeUserDataDir(dir) {
configuredDir = dir
},
clearPendingMigration() {
pendingCleared = true
},
markPendingDeleteDir(dir) {
pendingDeleteDir = dir
},
}
)
assert.equal(result.success, true)
assert.equal(configuredDir, target)
assert.equal(pendingCleared, true)
assert.equal(pendingDeleteDir, source)
assert.equal(fs.readFileSync(path.join(target, 'databases', 'session.db'), 'utf-8'), 'sqlite')
})
test('runPendingDataDirMigration keeps old config and pending task when copy fails', () => {
const root = makeTempDir()
const source = path.join(root, 'source')
const target = path.join(root, 'target')
writeFile(path.join(source, 'databases', 'session.db'), 'sqlite')
let configuredDir = source
let pendingCleared = false
const result = runPendingDataDirMigration(
createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }),
{
copyDirMerge() {
return { copied: 0, skipped: 0, errors: ['copy failed'] }
},
writeUserDataDir(dir) {
configuredDir = dir
},
clearPendingMigration() {
pendingCleared = true
},
}
)
assert.equal(result.success, false)
assert.equal(configuredDir, source)
assert.equal(pendingCleared, false)
assert.equal(fs.existsSync(path.join(target, 'databases', 'session.db')), false)
})
test('runPendingDataDirMigration fails when source directory is missing', () => {
const root = makeTempDir()
const source = path.join(root, 'missing-source')
const target = path.join(root, 'target')
let configuredDir = source
let pendingCleared = false
const result = runPendingDataDirMigration(
createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }),
{
writeUserDataDir(dir) {
configuredDir = dir
},
clearPendingMigration() {
pendingCleared = true
},
}
)
assert.equal(result.success, false)
assert.equal(configuredDir, source)
assert.equal(pendingCleared, false)
assert.equal(fs.existsSync(target), false)
})
test('isExistingUserDataDir accepts current user data layout without settings directory', () => {
const root = makeTempDir()
const dataDir = path.join(root, 'data')
writeFile(path.join(dataDir, '.chatlab'), 'ChatLab Data Directory')
fs.mkdirSync(path.join(dataDir, 'databases'), { recursive: true })
assert.equal(isExistingUserDataDir(dataDir), true)
})
+144
View File
@@ -0,0 +1,144 @@
import * as fs from 'fs'
import * as path from 'path'
import { copyDirMerge, type CopyStats } from './pathUtils'
const CHATLAB_MARKER_FILE = '.chatlab'
const USER_DATA_REQUIRED_DIRS = ['databases']
export interface PendingDataDirMigration {
from: string
to: string
migrate: boolean
deleteSourceOnSuccess: boolean
createdAt: string
}
export interface RunPendingDataDirMigrationDeps {
copyDirMerge?: typeof copyDirMerge
ensureDir?: (dirPath: string) => void
writeUserDataDir: (dir: string) => void
clearPendingMigration: () => void
markPendingDeleteDir?: (dir: string) => void
log?: (message: string) => void
}
export interface RunPendingDataDirMigrationResult {
success: boolean
from: string
to: string
copied: number
skipped: number
errors: string[]
}
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
}
function hasChatLabUserDataStructure(entries: string[]): boolean {
return entries.includes(CHATLAB_MARKER_FILE) && USER_DATA_REQUIRED_DIRS.every((dir) => entries.includes(dir))
}
export function isExistingUserDataDir(dirPath: string): boolean {
if (!fs.existsSync(dirPath)) return false
try {
return hasChatLabUserDataStructure(fs.readdirSync(dirPath))
} catch {
return false
}
}
export function isUserDataDirSafeToUse(dirPath: string): boolean {
if (!fs.existsSync(dirPath)) return true
try {
const entries = fs.readdirSync(dirPath)
if (entries.length === 0) return true
return hasChatLabUserDataStructure(entries)
} catch {
return false
}
}
export function isDirectoryEmptyOrMissing(dirPath: string): boolean {
if (!fs.existsSync(dirPath)) return true
try {
return fs.readdirSync(dirPath).length === 0
} catch {
return false
}
}
export function createPendingDataDirMigration(input: {
from: string
to: string
migrate: boolean
targetWasEmpty: boolean
}): PendingDataDirMigration {
return {
from: input.from,
to: input.to,
migrate: input.migrate,
deleteSourceOnSuccess: input.migrate && input.targetWasEmpty,
createdAt: new Date().toISOString(),
}
}
export function runPendingDataDirMigration(
pending: PendingDataDirMigration,
deps: RunPendingDataDirMigrationDeps
): RunPendingDataDirMigrationResult {
const copy = deps.copyDirMerge ?? copyDirMerge
const mkdir = deps.ensureDir ?? ensureDir
let stats: CopyStats = { copied: 0, skipped: 0, errors: [] }
if (pending.migrate && path.resolve(pending.from) !== path.resolve(pending.to)) {
if (!fs.existsSync(pending.from)) {
return {
success: false,
from: pending.from,
to: pending.to,
copied: 0,
skipped: 0,
errors: [`源数据目录不存在: ${pending.from}`],
}
}
stats = copy(pending.from, pending.to, mkdir)
deps.log?.(
`数据目录迁移完成: 从 ${pending.from} 到 ${pending.to},复制 ${stats.copied} 项,跳过 ${stats.skipped} 项,错误 ${stats.errors.length} 项`
)
if (stats.errors.length > 0) {
return {
success: false,
from: pending.from,
to: pending.to,
copied: stats.copied,
skipped: stats.skipped,
errors: stats.errors,
}
}
} else {
mkdir(pending.to)
}
deps.writeUserDataDir(pending.to)
deps.clearPendingMigration()
if (pending.deleteSourceOnSuccess && path.resolve(pending.from) !== path.resolve(pending.to)) {
deps.markPendingDeleteDir?.(pending.from)
}
return {
success: true,
from: pending.from,
to: pending.to,
copied: stats.copied,
skipped: stats.skipped,
errors: [],
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ export const cacheApi = {
setDataDir: (
path: string | null,
migrate: boolean = true
): Promise<{ success: boolean; error?: string; from?: string; to?: string }> => {
): Promise<{ success: boolean; error?: string; from?: string; to?: string; requiresRelaunch?: boolean }> => {
return ipcRenderer.invoke('cache:setDataDir', { path, migrate })
},
}
+1 -1
View File
@@ -261,7 +261,7 @@ interface CacheApi {
setDataDir: (
path: string | null,
migrate?: boolean
) => Promise<{ success: boolean; error?: string; from?: string; to?: string }>
) => Promise<{ success: boolean; error?: string; from?: string; to?: string; requiresRelaunch?: boolean }>
}
// Network API 类型 - 网络代理配置
@@ -163,7 +163,12 @@ async function applyDataDirChange(newDir: string | null, migrate: boolean) {
return
}
// 迁移成功,显示强制重启弹窗
if (result.requiresRelaunch === false) {
await loadDataDir()
return
}
// 已登记重启期迁移任务,重启后会在数据库服务启动前执行
showRelaunchModal.value = true
} catch (error) {
dataDirError.value = error instanceof Error ? error.message : String(error)