fix: 修复数据目录迁移的bug (#7386)

* fix: move initAppDataDir function inline and remove export from utils/file.ts

* fix some bugs

* fix shouldcopy error

* fix: handle appDataPath initialization and update logic in file.ts; update defaultChecked in DataSettings component

* fix: improve appDataPath handling and migration logic in file.ts

* fix: add error message for selecting the same app data path in DataSettings component and update localization files

* fix: ensure migration confirmation modal is shown correctly in DataSettings component

* feat: add new IPC channel for retrieving data path from arguments and update related components for migration handling

* fix: update app data path validation to check for prefix match in DataSettings component

* refactor: simplify data migration logic in DataSettings component by removing unnecessary flag

* fix: update initAppDataDir invocation to check for app packaging status in bootstrap.ts
This commit is contained in:
beyondkmp 2025-06-22 10:32:23 +08:00 committed by GitHub
parent 60b37876b1
commit c660aaba3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 240 additions and 74 deletions

View File

@ -20,6 +20,9 @@ export enum IpcChannel {
App_Copy = 'app:copy',
App_SetStopQuitApp = 'app:set-stop-quit-app',
App_SetAppDataPath = 'app:set-app-data-path',
App_GetDataPathFromArgs = 'app:get-data-path-from-args',
App_FlushAppData = 'app:flush-app-data',
App_IsNotEmptyDir = 'app:is-not-empty-dir',
App_RelaunchApp = 'app:relaunch-app',
App_IsBinaryExist = 'app:is-binary-exist',
App_GetBinaryPath = 'app:get-binary-path',

5
src/main/bootstrap.ts Normal file
View File

@ -0,0 +1,5 @@
import { app } from 'electron'
import { initAppDataDir } from './utils/file'
app.isPackaged && initAppDataDir()

View File

@ -1,7 +1,11 @@
// don't reorder this file, it's used to initialize the app data dir and
// other which should be run before the main process is ready
// eslint-disable-next-line
import './bootstrap'
import '@main/config'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { initAppDataDir } from '@main/utils/file'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
@ -22,7 +26,6 @@ import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
initAppDataDir()
Logger.initialize()
/**

View File

@ -34,7 +34,7 @@ import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateConfig } from './utils/file'
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateAppDataConfig } from './utils/file'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
@ -218,10 +218,28 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// Set app data path
ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => {
updateConfig(filePath)
updateAppDataConfig(filePath)
app.setPath('userData', filePath)
})
ipcMain.handle(IpcChannel.App_GetDataPathFromArgs, () => {
return process.argv
.slice(1)
.find((arg) => arg.startsWith('--new-data-path='))
?.split('--new-data-path=')[1]
})
ipcMain.handle(IpcChannel.App_FlushAppData, () => {
BrowserWindow.getAllWindows().forEach((w) => {
w.webContents.session.flushStorageData()
w.webContents.session.cookies.flushStore()
})
})
ipcMain.handle(IpcChannel.App_IsNotEmptyDir, async (_, path: string) => {
return fs.readdirSync(path).length > 0
})
// Copy user data to new location
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string) => {
try {
@ -234,8 +252,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// Relaunch app
ipcMain.handle(IpcChannel.App_RelaunchApp, () => {
app.relaunch()
ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => {
app.relaunch(options)
app.exit(0)
})

View File

@ -8,6 +8,20 @@ import { FileType, FileTypes } from '@types'
import { app } from 'electron'
import { v4 as uuidv4 } from 'uuid'
export function initAppDataDir() {
const appDataPath = getAppDataPathFromConfig()
if (appDataPath) {
app.setPath('userData', appDataPath)
return
}
if (isPortable) {
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data'))
return
}
}
// 创建文件类型映射表,提高查找效率
const fileTypeMap = new Map<string, FileTypes>()
@ -35,46 +49,70 @@ export function hasWritePermission(path: string) {
function getAppDataPathFromConfig() {
try {
const configPath = path.join(getConfigDir(), 'config.json')
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (config.appDataPath && fs.existsSync(config.appDataPath) && hasWritePermission(config.appDataPath)) {
return config.appDataPath
}
if (!fs.existsSync(configPath)) {
return null
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (!config.appDataPath) {
return null
}
let appDataPath = null
// 兼容旧版本
if (config.appDataPath && typeof config.appDataPath === 'string') {
appDataPath = config.appDataPath
// 将旧版本数据迁移到新版本
appDataPath && updateAppDataConfig(appDataPath)
} else {
appDataPath = config.appDataPath.find(
(item: { executablePath: string }) => item.executablePath === app.getPath('exe')
)?.dataPath
}
if (appDataPath && fs.existsSync(appDataPath) && hasWritePermission(appDataPath)) {
return appDataPath
}
return null
} catch (error) {
return null
}
return null
}
export function initAppDataDir() {
const appDataPath = getAppDataPathFromConfig()
if (appDataPath) {
app.setPath('userData', appDataPath)
return
}
if (isPortable) {
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data'))
return
}
}
export function updateConfig(appDataPath: string) {
export function updateAppDataConfig(appDataPath: string) {
const configDir = getConfigDir()
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true })
}
// config.json
// appDataPath: [{ executablePath: string, dataPath: string }]
const configPath = path.join(getConfigDir(), 'config.json')
if (!fs.existsSync(configPath)) {
fs.writeFileSync(configPath, JSON.stringify({ appDataPath }, null, 2))
fs.writeFileSync(
configPath,
JSON.stringify({ appDataPath: [{ executablePath: app.getPath('exe'), dataPath: appDataPath }] }, null, 2)
)
return
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
config.appDataPath = appDataPath
if (!config.appDataPath || (config.appDataPath && typeof config.appDataPath !== 'object')) {
config.appDataPath = []
}
const existingPath = config.appDataPath.find(
(item: { executablePath: string }) => item.executablePath === app.getPath('exe')
)
if (existingPath) {
existingPath.dataPath = appDataPath
} else {
config.appDataPath.push({ executablePath: app.getPath('exe'), dataPath: appDataPath })
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
}

View File

@ -29,9 +29,12 @@ const api = {
select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options),
hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path),
setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path),
getDataPathFromArgs: () => ipcRenderer.invoke(IpcChannel.App_GetDataPathFromArgs),
copy: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath),
setStopQuitApp: (stop: boolean, reason: string) => ipcRenderer.invoke(IpcChannel.App_SetStopQuitApp, stop, reason),
relaunchApp: () => ipcRenderer.invoke(IpcChannel.App_RelaunchApp),
flushAppData: () => ipcRenderer.invoke(IpcChannel.App_FlushAppData),
isNotEmptyDir: (path: string) => ipcRenderer.invoke(IpcChannel.App_IsNotEmptyDir, path),
relaunchApp: (options?: Electron.RelaunchOptions) => ipcRenderer.invoke(IpcChannel.App_RelaunchApp, options),
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),

View File

@ -30,6 +30,14 @@ export function useAppInit() {
console.timeEnd('init')
}, [])
useEffect(() => {
window.api.getDataPathFromArgs().then((dataPath) => {
if (dataPath) {
window.navigate('/settings/data', { replace: true })
}
})
}, [])
useUpdateHandler()
useFullScreenNotice()

View File

@ -1087,10 +1087,10 @@
"app_data": "App Data",
"app_data.select": "Modify Directory",
"app_data.select_title": "Change App Data Directory",
"app_data.restart_notice": "The app will need to restart to apply the changes",
"app_data.copy_data_option": "Copy data from original directory to new directory",
"app_data.restart_notice": "The app may need to restart multiple times to apply the changes",
"app_data.copy_data_option": "Copy data, will automatically restart after copying the original directory data to the new directory",
"app_data.copy_time_notice": "Copying data may take a while, do not force quit app",
"app_data.path_changed_without_copy": "Path changed successfully, but data not copied",
"app_data.path_changed_without_copy": "Path changed successfully",
"app_data.copying_warning": "Data copying, do not force quit app",
"app_data.copying": "Copying data to new location...",
"app_data.copy_success": "Successfully copied data to new location",
@ -1103,6 +1103,9 @@
"app_data.select_error_root_path": "New path cannot be the root path",
"app_data.select_error_write_permission": "New path does not have write permission",
"app_data.stop_quit_app_reason": "The app is currently migrating data and cannot be exited",
"app_data.select_not_empty_dir": "New path is not empty",
"app_data.select_not_empty_dir_content": "New path is not empty, if you select copy, it will overwrite the data in the new path, there is a risk of data loss, continue?",
"app_data.select_error_same_path": "New path is the same as the old path, please select another path",
"app_knowledge": "Knowledge Base Files",
"app_knowledge.button.delete": "Delete File",
"app_knowledge.remove_all": "Remove Knowledge Base Files",

View File

@ -1085,10 +1085,10 @@
"app_data": "アプリデータ",
"app_data.select": "ディレクトリを変更",
"app_data.select_title": "アプリデータディレクトリの変更",
"app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります",
"app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます",
"app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください",
"app_data.path_changed_without_copy": "パスが変更されましたが、データがコピーされていません",
"app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります",
"app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます",
"app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください",
"app_data.path_changed_without_copy": "パスが変更されました",
"app_data.copying_warning": "データコピー中、アプリを強制終了しないでください",
"app_data.copying": "新しい場所にデータをコピーしています...",
"app_data.copy_success": "データを新しい場所に正常にコピーしました",
@ -1101,6 +1101,9 @@
"app_data.select_error_root_path": "新しいパスはルートパスにできません",
"app_data.select_error_write_permission": "新しいパスに書き込み権限がありません",
"app_data.stop_quit_app_reason": "アプリは現在データを移行しているため、終了できません",
"app_data.select_not_empty_dir": "新しいパスは空ではありません",
"app_data.select_not_empty_dir_content": "新しいパスは空ではありません。コピーを選択すると、新しいパスのデータが上書きされます。データが失われるリスクがあります。続行しますか?",
"app_data.select_error_same_path": "新しいパスは元のパスと同じです。別のパスを選択してください",
"app_knowledge": "知識ベースファイル",
"app_knowledge.button.delete": "ファイルを削除",
"app_knowledge.remove_all": "ナレッジベースファイルを削除",

View File

@ -1085,10 +1085,10 @@
"app_data": "Данные приложения",
"app_data.select": "Изменить директорию",
"app_data.select_title": "Изменить директорию данных приложения",
"app_data.restart_notice": "Для применения изменений потребуется перезапуск приложения",
"app_data.copy_data_option": "Копировать данные из исходной директории в новую директорию",
"app_data.restart_notice": "Для применения изменений может потребоваться несколько перезапусков приложения",
"app_data.copy_data_option": "Копировать данные, будет автоматически перезапущено после копирования данных из исходной директории в новую директорию",
"app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы",
"app_data.path_changed_without_copy": "Путь изменен успешно, но данные не скопированы",
"app_data.path_changed_without_copy": "Путь изменен успешно",
"app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение",
"app_data.copying": "Копирование данных в новое место...",
"app_data.copy_success": "Данные успешно скопированы в новое место",
@ -1101,6 +1101,9 @@
"app_data.select_error_root_path": "Новый путь не может быть корневым",
"app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись",
"app_data.stop_quit_app_reason": "Приложение в настоящее время перемещает данные и не может быть закрыто",
"app_data.select_not_empty_dir": "Новый путь не пуст",
"app_data.select_not_empty_dir_content": "Новый путь не пуст, если вы выбираете копирование, он перезапишет данные в новом пути, есть риск потери данных, продолжить?",
"app_data.select_error_same_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь",
"app_knowledge": "Файлы базы знаний",
"app_knowledge.button.delete": "Удалить файл",
"app_knowledge.remove_all": "Удалить файлы базы знаний",

View File

@ -1087,10 +1087,10 @@
"app_data": "应用数据",
"app_data.select": "修改目录",
"app_data.select_title": "更改应用数据目录",
"app_data.restart_notice": "应用需要重启以应用更改",
"app_data.copy_data_option": "复制数据,开启后会将原始目录数据复制到新目录",
"app_data.restart_notice": "应用可能会重启多次以应用更改",
"app_data.copy_data_option": "复制数据,会自动重启后将原始目录数据复制到新目录",
"app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用",
"app_data.path_changed_without_copy": "路径已更改成功,但数据未复制",
"app_data.path_changed_without_copy": "路径已更改成功",
"app_data.copying_warning": "数据复制中不要强制退出app",
"app_data.copying": "正在将数据复制到新位置...",
"app_data.copy_success": "已成功复制数据到新位置",
@ -1103,6 +1103,9 @@
"app_data.select_error_root_path": "新路径不能是根路径",
"app_data.select_error_write_permission": "新路径没有写入权限",
"app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出",
"app_data.select_not_empty_dir": "新路径不为空",
"app_data.select_not_empty_dir_content": "新路径不为空,选择复制将覆盖新路径中的数据, 有数据丢失的风险,是否继续?",
"app_data.select_error_same_path": "新路径与旧路径相同,请选择其他路径",
"app_knowledge": "知识库文件",
"app_knowledge.button.delete": "删除文件",
"app_knowledge.remove_all": "删除知识库文件",

View File

@ -1087,10 +1087,10 @@
"app_data": "應用數據",
"app_data.select": "修改目錄",
"app_data.select_title": "變更應用數據目錄",
"app_data.restart_notice": "變更數據目錄後需要重啟應用才能生效",
"app_data.copy_data_option": "複製數據, 開啟後會將原始目錄數據複製到新目錄",
"app_data.restart_notice": "變更數據目錄後可能需要重啟應用才能生效",
"app_data.copy_data_option": "複製數據, 會自動重啟後將原始目錄數據複製到新目錄",
"app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用",
"app_data.path_changed_without_copy": "路徑已變更成功,但數據未複製",
"app_data.path_changed_without_copy": "路徑已變更成功",
"app_data.copying_warning": "數據複製中,不要強制退出應用",
"app_data.copying": "正在複製數據到新位置...",
"app_data.copy_success": "成功複製數據到新位置",
@ -1103,6 +1103,9 @@
"app_data.select_error_root_path": "新路徑不能是根路徑",
"app_data.select_error_write_permission": "新路徑沒有寫入權限",
"app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出",
"app_data.select_not_empty_dir": "新路徑不為空",
"app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據, 有數據丟失的風險,是否繼續?",
"app_data.select_error_same_path": "新路徑與舊路徑相同,請選擇其他路徑",
"app_knowledge": "知識庫文件",
"app_knowledge.button.delete": "刪除檔案",
"app_knowledge.remove_all": "刪除知識庫檔案",

View File

@ -202,6 +202,12 @@ const DataSettings: FC = () => {
return
}
// check new app data path is same as old app data path
if (newAppDataPath.startsWith(appInfo!.appDataPath)) {
window.message.error(t('settings.data.app_data.select_error_same_path'))
return
}
// check new app data path has write permission
const hasWritePermission = await window.api.hasWritePermission(newAppDataPath)
if (!hasWritePermission) {
@ -213,22 +219,34 @@ const DataSettings: FC = () => {
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>{t('settings.data.app_data.migration_title')}</div>
)
const migrationClassName = 'migration-modal'
const messageKey = 'data-migration'
// 显示确认对话框
showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName, messageKey)
if (await window.api.isNotEmptyDir(newAppDataPath)) {
const modal = window.modal.confirm({
title: t('settings.data.app_data.select_not_empty_dir'),
content: t('settings.data.app_data.select_not_empty_dir_content'),
centered: true,
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: () => {
modal.destroy()
// 显示确认对话框
showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName)
}
})
return
}
showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName)
}
// 显示确认迁移的对话框
const showMigrationConfirmModal = (
const showMigrationConfirmModal = async (
originalPath: string,
newPath: string,
title: React.ReactNode,
className: string,
messageKey: string
className: string
) => {
// 复制数据选项状态
let shouldCopyData = true
let shouldCopyData = !(await window.api.isNotEmptyDir(newPath))
// 创建路径内容组件
const PathsContent = () => (
@ -248,7 +266,7 @@ const DataSettings: FC = () => {
<div>
<MigrationPathRow style={{ marginTop: '20px', flexDirection: 'row', alignItems: 'center' }}>
<Switch
defaultChecked={true}
defaultChecked={shouldCopyData}
onChange={(checked) => {
shouldCopyData = checked
}}
@ -290,22 +308,17 @@ const DataSettings: FC = () => {
// 立即关闭确认对话框
modal.destroy()
// 设置停止退出应用
window.api.setStopQuitApp(true, t('settings.data.app_data.stop_quit_app_reason'))
if (shouldCopyData) {
// 如果选择复制数据,显示进度模态框并执行迁移
const { loadingModal, progressInterval, updateProgress } = showProgressModal(title, className, PathsContent)
try {
await startMigration(originalPath, newPath, progressInterval, updateProgress, loadingModal, messageKey)
} catch (error) {
if (progressInterval) {
clearInterval(progressInterval)
}
loadingModal.destroy()
throw error
}
window.message.info({
content: t('settings.data.app_data.restart_notice'),
duration: 3
})
setTimeout(() => {
window.api.relaunchApp({
args: ['--new-data-path=' + newPath]
})
}, 300)
} else {
// 如果不复制数据,直接设置新的应用数据路径
await window.api.setAppDataPath(newPath)
@ -324,12 +337,7 @@ const DataSettings: FC = () => {
} catch (error) {
window.api.setStopQuitApp(false, '')
window.message.error({
content:
(shouldCopyData
? t('settings.data.app_data.copy_failed')
: t('settings.data.app_data.path_change_failed')) +
': ' +
error,
content: t('settings.data.app_data.path_change_failed') + ': ' + error,
duration: 5
})
}
@ -337,6 +345,68 @@ const DataSettings: FC = () => {
})
}
useEffect(() => {
const handleDataMigration = async () => {
const newDataPath = await window.api.getDataPathFromArgs()
if (!newDataPath) return
const originalPath = (await window.api.getAppInfo())?.appDataPath
if (!originalPath) return
const title = (
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>{t('settings.data.app_data.migration_title')}</div>
)
const className = 'migration-modal'
const messageKey = 'data-migration'
// Create PathsContent component for this specific migration
const PathsContent = () => (
<div>
<MigrationPathRow>
<MigrationPathLabel>{t('settings.data.app_data.original_path')}:</MigrationPathLabel>
<MigrationPathValue>{originalPath}</MigrationPathValue>
</MigrationPathRow>
<MigrationPathRow style={{ marginTop: '16px' }}>
<MigrationPathLabel>{t('settings.data.app_data.new_path')}:</MigrationPathLabel>
<MigrationPathValue>{newDataPath}</MigrationPathValue>
</MigrationPathRow>
</div>
)
const { loadingModal, progressInterval, updateProgress } = showProgressModal(title, className, PathsContent)
try {
window.api.setStopQuitApp(true, t('settings.data.app_data.stop_quit_app_reason'))
await startMigration(originalPath, newDataPath, progressInterval, updateProgress, loadingModal, messageKey)
// 更新应用数据路径
setAppInfo(await window.api.getAppInfo())
// 通知用户并重启应用
setTimeout(() => {
window.message.success(t('settings.data.app_data.select_success'))
window.api.setStopQuitApp(false, '')
window.api.relaunchApp({
args: ['--user-data-dir=' + newDataPath]
})
}, 1000)
} catch (error) {
window.api.setStopQuitApp(false, '')
window.message.error({
content: t('settings.data.app_data.copy_failed') + ': ' + error,
key: messageKey,
duration: 5
})
} finally {
if (progressInterval) {
clearInterval(progressInterval)
}
loadingModal.destroy()
}
}
handleDataMigration()
}, [])
// 显示进度模态框
const showProgressModal = (title: React.ReactNode, className: string, PathsContent: React.FC) => {
let currentProgress = 0
@ -411,6 +481,9 @@ const DataSettings: FC = () => {
loadingModal: { destroy: () => void },
messageKey: string
): Promise<void> => {
// flush app data
await window.api.flushAppData()
// 开始复制过程
const copyResult = await window.api.copy(originalPath, newPath)