diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index 87ec271d..257f7e25 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -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()) { diff --git a/apps/desktop/main/internal-api.ts b/apps/desktop/main/internal-api.ts index 07bc10bf..16fe5c71 100644 --- a/apps/desktop/main/internal-api.ts +++ b/apps/desktop/main/internal-api.ts @@ -128,7 +128,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise 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) } diff --git a/apps/desktop/main/utils/dataDirSwitch.test.ts b/apps/desktop/main/utils/dataDirSwitch.test.ts new file mode 100644 index 00000000..c56f0cd5 --- /dev/null +++ b/apps/desktop/main/utils/dataDirSwitch.test.ts @@ -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) +}) diff --git a/apps/desktop/main/utils/dataDirSwitch.ts b/apps/desktop/main/utils/dataDirSwitch.ts new file mode 100644 index 00000000..4413b1a5 --- /dev/null +++ b/apps/desktop/main/utils/dataDirSwitch.ts @@ -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: [], + } +} diff --git a/apps/desktop/preload/apis/utils.ts b/apps/desktop/preload/apis/utils.ts index 6c4d5de2..d0b3f3e9 100644 --- a/apps/desktop/preload/apis/utils.ts +++ b/apps/desktop/preload/apis/utils.ts @@ -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 }) }, } diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index f9bf551f..b5203b90 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -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 类型 - 网络代理配置 diff --git a/src/components/common/Settings/DataStorage/StorageManageSection.vue b/src/components/common/Settings/DataStorage/StorageManageSection.vue index e91e88dc..f9149d93 100644 --- a/src/components/common/Settings/DataStorage/StorageManageSection.vue +++ b/src/components/common/Settings/DataStorage/StorageManageSection.vue @@ -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)