mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
fix(data): defer data directory migration until restart
This commit is contained in:
1 parent
f978845dde
commit
04c97ca9cf
9 files changed
+384
-92
No files matched your search
@@ -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()) {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user