10f4fde0e3
* refactor(main): 使用枚举管理 IPC 通道 - 新增 IpcChannel 枚举,用于统一管理所有的 IPC 通道 - 修改相关代码,使用 IpcChannel 枚举替代硬编码的字符串通道名称 - 此改动有助于提高代码的可维护性和可读性,避免因通道名称变更导致的错误 * refactor(ipc): 将字符串通道名称替换为 IpcChannel 枚举 - 在多个文件中将硬编码的字符串通道名称替换为 IpcChannel 枚举值 - 更新了相关文件的导入,增加了对 IpcChannel 的引用 - 通过使用枚举来管理 IPC 通道名称,提高了代码的可维护性和可读性 * refactor(ipc): 调整 IPC 通道枚举和预加载脚本 - 移除了 IpcChannel 枚举中的未使用注释 - 更新了预加载脚本中 IpcChannel 的导入路径 * refactor(ipc): 更新 IpcChannel导入路径 - 将 IpcChannel 的导入路径从 @main/enum/IpcChannel 修改为 @shared/IpcChannel - 此修改涉及多个文件,包括 AppUpdater、BackupManager、EditMcpJsonPopup 等 - 同时移除了 tsconfig.web.json 中对 src/main/**/* 的引用 * refactor(ipc): 添加 ReduxStoreReady 事件并更新事件监听 - 在 IpcChannel 枚举中添加 ReduxStoreReady 事件 - 更新 ReduxService 中的事件监听,使用新的枚举值 * refactor(main): 重构 ReduxService 中的状态变化事件处理 - 将状态变化事件名称定义为常量 STATUS_CHANGE_EVENT - 更新事件监听和触发使用新的常量 - 优化了代码结构,提高了可维护性 * refactor(i18n): 优化国际化配置和语言选择逻辑 - 在多个文件中引入 defaultLanguage 常量,统一默认语言设置 - 调整 i18n 初始化和语言变更逻辑,使用新配置 - 更新相关组件和 Hook 中的语言选择逻辑 * refactor(ConfigManager): 重构配置管理器 - 添加 ConfigKeys 枚举,用于统一配置项的键名 - 引入 defaultLanguage,作为默认语言设置 - 重构 get 和 set 方法,使用 ConfigKeys 枚举作为键名 - 优化类型定义和方法签名,提高代码可读性和可维护性 * refactor(ConfigManager): 重命名配置键 ZoomFactor 将配置键 zoomFactor 重命名为 ZoomFactor,以符合命名规范。 更新了相关方法和属性以反映这一变更。 * refactor(shared): 重构常量定义并优化文件大小格式化逻辑 - 在 constant.ts 中添加 KB、MB、GB 常量定义 - 将 defaultLanguage 移至 constant.ts - 更新 ConfigManager、useAppInit、i18n、GeneralSettings 等文件中的导入路径 - 优化 formatFileSize 函数,使用新定义的常量 * refactor(FileSize): 使用 GB/MB/KB 等常量处理文件大小计算 * refactor(ipc): 将字符串通道名称替换为 IpcChannel 枚举 - 在多个文件中将硬编码的字符串通道名称替换为 IpcChannel 枚举值 - 更新了相关文件的导入,增加了对 IpcChannel 的引用 - 通过使用枚举来管理 IPC 通道名称,提高了代码的可维护性和可读性 * refactor(ipc): 更新 IpcChannel导入路径 - 将 IpcChannel 的导入路径从 @main/enum/IpcChannel 修改为 @shared/IpcChannel - 此修改涉及多个文件,包括 AppUpdater、BackupManager、EditMcpJsonPopup 等 - 同时移除了 tsconfig.web.json 中对 src/main/**/* 的引用 * refactor(i18n): 优化国际化配置和语言选择逻辑 - 在多个文件中引入 defaultLanguage 常量,统一默认语言设置 - 调整 i18n 初始化和语言变更逻辑,使用新配置 - 更新相关组件和 Hook 中的语言选择逻辑 * refactor(shared): 重构常量定义并优化文件大小格式化逻辑 - 在 constant.ts 中添加 KB、MB、GB 常量定义 - 将 defaultLanguage 移至 constant.ts - 更新 ConfigManager、useAppInit、i18n、GeneralSettings 等文件中的导入路径 - 优化 formatFileSize 函数,使用新定义的常量 * refactor: 移除重复的导入语句 - 在 HomeWindow.tsx 和 useAppInit.ts 文件中移除了重复的 defaultLanguage导入语句 - 这个改动简化了代码结构,提高了代码的可读性和维护性
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import { WebDavConfig } from '@types'
|
|
import AdmZip from 'adm-zip'
|
|
import { exec } from 'child_process'
|
|
import { app } from 'electron'
|
|
import Logger from 'electron-log'
|
|
import * as fs from 'fs-extra'
|
|
import * as path from 'path'
|
|
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
|
|
|
|
import WebDav from './WebDav'
|
|
import { windowService } from './WindowService'
|
|
import { IpcChannel } from '@shared/IpcChannel'
|
|
|
|
class BackupManager {
|
|
private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp')
|
|
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
|
|
|
|
constructor() {
|
|
this.checkConnection = this.checkConnection.bind(this)
|
|
this.backup = this.backup.bind(this)
|
|
this.restore = this.restore.bind(this)
|
|
this.backupToWebdav = this.backupToWebdav.bind(this)
|
|
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
|
|
this.listWebdavFiles = this.listWebdavFiles.bind(this)
|
|
}
|
|
|
|
private async setWritableRecursive(dirPath: string): Promise<void> {
|
|
try {
|
|
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
|
|
|
for (const item of items) {
|
|
const fullPath = path.join(dirPath, item.name)
|
|
|
|
// 先处理子目录
|
|
if (item.isDirectory()) {
|
|
await this.setWritableRecursive(fullPath)
|
|
}
|
|
|
|
// 统一设置权限(Windows需要特殊处理)
|
|
await this.forceSetWritable(fullPath)
|
|
}
|
|
|
|
// 确保根目录权限
|
|
await this.forceSetWritable(dirPath)
|
|
} catch (error) {
|
|
Logger.error(`权限设置失败:${dirPath}`, error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// 新增跨平台权限设置方法
|
|
private async forceSetWritable(targetPath: string): Promise<void> {
|
|
try {
|
|
// Windows系统需要先取消只读属性
|
|
if (process.platform === 'win32') {
|
|
await fs.chmod(targetPath, 0o666) // Windows会忽略权限位但能移除只读
|
|
} else {
|
|
const stats = await fs.stat(targetPath)
|
|
const mode = stats.isDirectory() ? 0o777 : 0o666
|
|
await fs.chmod(targetPath, mode)
|
|
}
|
|
|
|
// 双重保险:使用文件属性命令(Windows专用)
|
|
if (process.platform === 'win32') {
|
|
await exec(`attrib -R "${targetPath}" /L /D`)
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
Logger.warn(`权限设置警告:${targetPath}`, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
async backup(
|
|
_: Electron.IpcMainInvokeEvent,
|
|
fileName: string,
|
|
data: string,
|
|
destinationPath: string = this.backupDir
|
|
): Promise<string> {
|
|
const mainWindow = windowService.getMainWindow()
|
|
|
|
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
|
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
|
|
Logger.log('[BackupManager] backup progress', processData)
|
|
}
|
|
|
|
try {
|
|
await fs.ensureDir(this.tempDir)
|
|
onProgress({ stage: 'preparing', progress: 0, total: 100 })
|
|
|
|
// 使用流的方式写入 data.json
|
|
const tempDataPath = path.join(this.tempDir, 'data.json')
|
|
await new Promise<void>((resolve, reject) => {
|
|
const writeStream = fs.createWriteStream(tempDataPath)
|
|
writeStream.write(data)
|
|
writeStream.end()
|
|
|
|
writeStream.on('finish', () => resolve())
|
|
writeStream.on('error', (error) => reject(error))
|
|
})
|
|
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
|
|
|
|
// 复制 Data 目录到临时目录
|
|
const sourcePath = path.join(app.getPath('userData'), 'Data')
|
|
const tempDataDir = path.join(this.tempDir, 'Data')
|
|
|
|
// 获取源目录总大小
|
|
const totalSize = await this.getDirSize(sourcePath)
|
|
let copiedSize = 0
|
|
|
|
// 使用流式复制
|
|
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
|
|
copiedSize += size
|
|
const progress = Math.min(80, 20 + Math.floor((copiedSize / totalSize) * 60))
|
|
onProgress({ stage: 'copying_files', progress, total: 100 })
|
|
})
|
|
|
|
await this.setWritableRecursive(tempDataDir)
|
|
onProgress({ stage: 'compressing', progress: 80, total: 100 })
|
|
|
|
// 使用 adm-zip 创建压缩文件
|
|
const zip = new AdmZip()
|
|
zip.addLocalFolder(this.tempDir)
|
|
const backupedFilePath = path.join(destinationPath, fileName)
|
|
zip.writeZip(backupedFilePath)
|
|
|
|
// 清理临时目录
|
|
await fs.remove(this.tempDir)
|
|
onProgress({ stage: 'completed', progress: 100, total: 100 })
|
|
|
|
Logger.log('[BackupManager] Backup completed successfully')
|
|
return backupedFilePath
|
|
} catch (error) {
|
|
Logger.error('[BackupManager] Backup failed:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> {
|
|
const mainWindow = windowService.getMainWindow()
|
|
|
|
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
|
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
|
|
Logger.log('[BackupManager] restore progress', processData)
|
|
}
|
|
|
|
try {
|
|
// 创建临时目录
|
|
await fs.ensureDir(this.tempDir)
|
|
onProgress({ stage: 'preparing', progress: 0, total: 100 })
|
|
|
|
Logger.log('[backup] step 1: unzip backup file', this.tempDir)
|
|
// 使用 adm-zip 解压
|
|
const zip = new AdmZip(backupPath)
|
|
zip.extractAllTo(this.tempDir, true) // true 表示覆盖已存在的文件
|
|
onProgress({ stage: 'extracting', progress: 20, total: 100 })
|
|
|
|
Logger.log('[backup] step 2: read data.json')
|
|
// 读取 data.json
|
|
const dataPath = path.join(this.tempDir, 'data.json')
|
|
const data = await fs.readFile(dataPath, 'utf-8')
|
|
onProgress({ stage: 'reading_data', progress: 40, total: 100 })
|
|
|
|
Logger.log('[backup] step 3: restore Data directory')
|
|
// 恢复 Data 目录
|
|
const sourcePath = path.join(this.tempDir, 'Data')
|
|
const destPath = path.join(app.getPath('userData'), 'Data')
|
|
|
|
// 获取源目录总大小
|
|
const totalSize = await this.getDirSize(sourcePath)
|
|
let copiedSize = 0
|
|
|
|
await this.setWritableRecursive(destPath)
|
|
await fs.remove(destPath)
|
|
|
|
// 使用流式复制
|
|
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
|
|
copiedSize += size
|
|
const progress = Math.min(90, 40 + Math.floor((copiedSize / totalSize) * 50))
|
|
onProgress({ stage: 'copying_files', progress, total: 100 })
|
|
})
|
|
|
|
Logger.log('[backup] step 4: clean up temp directory')
|
|
// 清理临时目录
|
|
await this.setWritableRecursive(this.tempDir)
|
|
await fs.remove(this.tempDir)
|
|
onProgress({ stage: 'completed', progress: 100, total: 100 })
|
|
|
|
Logger.log('[backup] step 5: Restore completed successfully')
|
|
|
|
return data
|
|
} catch (error) {
|
|
Logger.error('[backup] Restore failed:', error)
|
|
await fs.remove(this.tempDir).catch(() => {})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
|
|
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
|
|
const backupedFilePath = await this.backup(_, filename, data)
|
|
const webdavClient = new WebDav(webdavConfig)
|
|
return await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
|
|
overwrite: true
|
|
})
|
|
}
|
|
|
|
async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
|
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
|
|
const webdavClient = new WebDav(webdavConfig)
|
|
try {
|
|
const retrievedFile = await webdavClient.getFileContents(filename)
|
|
const backupedFilePath = path.join(this.backupDir, filename)
|
|
|
|
if (!fs.existsSync(this.backupDir)) {
|
|
fs.mkdirSync(this.backupDir, { recursive: true })
|
|
}
|
|
|
|
// 使用流的方式写入文件
|
|
await new Promise<void>((resolve, reject) => {
|
|
const writeStream = fs.createWriteStream(backupedFilePath)
|
|
writeStream.write(retrievedFile as Buffer)
|
|
writeStream.end()
|
|
|
|
writeStream.on('finish', () => resolve())
|
|
writeStream.on('error', (error) => reject(error))
|
|
})
|
|
|
|
return await this.restore(_, backupedFilePath)
|
|
} catch (error: any) {
|
|
Logger.error('[backup] Failed to restore from WebDAV:', error)
|
|
throw new Error(error.message || 'Failed to restore backup file')
|
|
}
|
|
}
|
|
|
|
listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => {
|
|
try {
|
|
const client = createClient(config.webdavHost, {
|
|
username: config.webdavUser,
|
|
password: config.webdavPass
|
|
})
|
|
|
|
const response = await client.getDirectoryContents(config.webdavPath)
|
|
const files = Array.isArray(response) ? response : response.data
|
|
|
|
return files
|
|
.filter((file: FileStat) => file.type === 'file' && file.basename.endsWith('.zip'))
|
|
.map((file: FileStat) => ({
|
|
fileName: file.basename,
|
|
modifiedTime: file.lastmod,
|
|
size: file.size
|
|
}))
|
|
.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
|
|
} catch (error: any) {
|
|
Logger.error('Failed to list WebDAV files:', error)
|
|
throw new Error(error.message || 'Failed to list backup files')
|
|
}
|
|
}
|
|
|
|
private async getDirSize(dirPath: string): Promise<number> {
|
|
let size = 0
|
|
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
|
|
|
for (const item of items) {
|
|
const fullPath = path.join(dirPath, item.name)
|
|
if (item.isDirectory()) {
|
|
size += await this.getDirSize(fullPath)
|
|
} else {
|
|
const stats = await fs.stat(fullPath)
|
|
size += stats.size
|
|
}
|
|
}
|
|
return size
|
|
}
|
|
|
|
private async copyDirWithProgress(
|
|
source: string,
|
|
destination: string,
|
|
onProgress: (size: number) => void
|
|
): Promise<void> {
|
|
const items = await fs.readdir(source, { withFileTypes: true })
|
|
|
|
for (const item of items) {
|
|
const sourcePath = path.join(source, item.name)
|
|
const destPath = path.join(destination, item.name)
|
|
|
|
if (item.isDirectory()) {
|
|
await fs.ensureDir(destPath)
|
|
await this.copyDirWithProgress(sourcePath, destPath, onProgress)
|
|
} else {
|
|
const stats = await fs.stat(sourcePath)
|
|
await fs.copy(sourcePath, destPath)
|
|
onProgress(stats.size)
|
|
}
|
|
}
|
|
}
|
|
|
|
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
|
const webdavClient = new WebDav(webdavConfig)
|
|
return await webdavClient.checkConnection()
|
|
}
|
|
|
|
async createDirectory(
|
|
_: Electron.IpcMainInvokeEvent,
|
|
webdavConfig: WebDavConfig,
|
|
path: string,
|
|
options?: CreateDirectoryOptions
|
|
) {
|
|
const webdavClient = new WebDav(webdavConfig)
|
|
return await webdavClient.createDirectory(path, options)
|
|
}
|
|
}
|
|
|
|
export default BackupManager
|