From 3f5901766d4d7d0a7780185c7f2d67c455dc274f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:19:37 +0800 Subject: [PATCH] feat: Add S3 Backup (#6802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: import opendal * feat: 添加S3备份支持及相关设置界面 - 在IpcChannel中新增S3备份相关IPC事件,支持备份、恢复、 列表、删除文件及连接检测 - 在ipc主进程注册对应的S3备份处理函数,集成backupManager - 新增S3设置页面,支持配置Endpoint、Region、Bucket、AccessKey等 参数,并提供同步和备份策略的UI控制 - 删除未使用的RemoteStorage.ts,简化代码库 提升备份功能的灵活性,支持S3作为远程存储目标 * feat(S3 Backup): 完善S3备份功能 - 支持自动备份 - 优化设置前端 - 优化备份恢复代码 * feat(i18n): add S3 storage translations * feat(settings): 优化数据设置页面和S3设置页面UI * feat(settings): optimize S3 settings state structure and update usage * refactor: simplify S3 backup and restore modal logic * feat(s3 backup): improve S3 settings defaults and modal props * fix(i18n): optimize S3 access key translations * feat(backup): optimize logging and progress reporting * fix(settings): set S3 maxBackups as unlimited by default * chore(package): restore opendal dependency in package.json --------- Co-authored-by: suyao --- package.json | 1 + packages/shared/IpcChannel.ts | 5 + src/main/ipc.ts | 5 + src/main/services/BackupManager.ts | 234 +++++++++-- src/main/services/RemoteStorage.ts | 126 +++--- src/preload/index.ts | 19 +- .../src/components/S3BackupManager.tsx | 298 ++++++++++++++ src/renderer/src/components/S3Modals.tsx | 258 ++++++++++++ src/renderer/src/i18n/locales/en-us.json | 64 +++ src/renderer/src/i18n/locales/ja-jp.json | 64 +++ src/renderer/src/i18n/locales/ru-ru.json | 64 +++ src/renderer/src/i18n/locales/zh-cn.json | 66 +++- src/renderer/src/i18n/locales/zh-tw.json | 66 +++- src/renderer/src/init.ts | 4 +- .../settings/DataSettings/DataSettings.tsx | 10 +- .../settings/DataSettings/S3Settings.tsx | 276 +++++++++++++ src/renderer/src/services/BackupService.ts | 366 ++++++++++++++++-- src/renderer/src/store/backup.ts | 11 +- src/renderer/src/store/settings.ts | 34 +- src/renderer/src/types/index.ts | 12 + yarn.lock | 80 ++++ 21 files changed, 1941 insertions(+), 122 deletions(-) create mode 100644 src/renderer/src/components/S3BackupManager.tsx create mode 100644 src/renderer/src/components/S3Modals.tsx create mode 100644 src/renderer/src/pages/settings/DataSettings/S3Settings.tsx diff --git a/package.json b/package.json index 88c2295312..5455f348b2 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "npx-scope-finder": "^1.2.0", "officeparser": "^4.1.1", "openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch", + "opendal": "0.47.11", "p-queue": "^8.1.0", "playwright": "^1.52.0", "prettier": "^3.5.3", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index daea5dad6e..ca49bd40c5 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -153,6 +153,11 @@ export enum IpcChannel { Backup_CheckConnection = 'backup:checkConnection', Backup_CreateDirectory = 'backup:createDirectory', Backup_DeleteWebdavFile = 'backup:deleteWebdavFile', + Backup_BackupToS3 = 'backup:backupToS3', + Backup_RestoreFromS3 = 'backup:restoreFromS3', + Backup_ListS3Files = 'backup:listS3Files', + Backup_DeleteS3File = 'backup:deleteS3File', + Backup_CheckS3Connection = 'backup:checkS3Connection', // zip Zip_Compress = 'zip:compress', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 8c6810bcdc..af043c7c8c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -344,6 +344,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection) ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory) ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile) + ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3) + ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3) + ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files) + ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File) + ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection) // file ipcMain.handle(IpcChannel.File_Open, fileManager.open) diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index e994e90bed..6e0c813e6d 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -1,5 +1,6 @@ import { IpcChannel } from '@shared/IpcChannel' import { WebDavConfig } from '@types' +import { S3Config } from '@types' import archiver from 'archiver' import { exec } from 'child_process' import { app } from 'electron' @@ -10,6 +11,7 @@ import * as path from 'path' import { CreateDirectoryOptions, FileStat } from 'webdav' import { getDataPath } from '../utils' +import S3Storage from './RemoteStorage' import WebDav from './WebDav' import { windowService } from './WindowService' @@ -25,6 +27,11 @@ class BackupManager { this.restoreFromWebdav = this.restoreFromWebdav.bind(this) this.listWebdavFiles = this.listWebdavFiles.bind(this) this.deleteWebdavFile = this.deleteWebdavFile.bind(this) + this.backupToS3 = this.backupToS3.bind(this) + this.restoreFromS3 = this.restoreFromS3.bind(this) + this.listS3Files = this.listS3Files.bind(this) + this.deleteS3File = this.deleteS3File.bind(this) + this.checkS3Connection = this.checkS3Connection.bind(this) } private async setWritableRecursive(dirPath: string): Promise { @@ -85,7 +92,11 @@ class BackupManager { const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.BackupProgress, processData) - Logger.log('[BackupManager] backup progress', processData) + // 只在关键阶段记录日志:开始、结束和主要阶段转换点 + const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed'] + if (logStages.includes(processData.stage) || processData.progress === 100) { + Logger.log('[BackupManager] backup progress', processData) + } } try { @@ -147,18 +158,23 @@ class BackupManager { let totalBytes = 0 let processedBytes = 0 - // 首先计算总文件数和总大小 + // 首先计算总文件数和总大小,但不记录详细日志 const calculateTotals = async (dirPath: string) => { - const items = await fs.readdir(dirPath, { withFileTypes: true }) - for (const item of items) { - const fullPath = path.join(dirPath, item.name) - if (item.isDirectory()) { - await calculateTotals(fullPath) - } else { - totalEntries++ - const stats = await fs.stat(fullPath) - totalBytes += stats.size + 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 calculateTotals(fullPath) + } else { + totalEntries++ + const stats = await fs.stat(fullPath) + totalBytes += stats.size + } } + } catch (error) { + // 仅在出错时记录日志 + Logger.error('[BackupManager] Error calculating totals:', error) } } @@ -230,7 +246,11 @@ class BackupManager { const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData) - Logger.log('[BackupManager] restore progress', processData) + // 只在关键阶段记录日志 + const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed'] + if (logStages.includes(processData.stage) || processData.progress === 100) { + Logger.log('[BackupManager] restore progress', processData) + } } try { @@ -382,21 +402,54 @@ class BackupManager { destination: string, onProgress: (size: number) => void ): Promise { - const items = await fs.readdir(source, { withFileTypes: true }) + // 先统计总文件数 + let totalFiles = 0 + let processedFiles = 0 + let lastProgressReported = 0 - for (const item of items) { - const sourcePath = path.join(source, item.name) - const destPath = path.join(destination, item.name) + // 计算总文件数 + const countFiles = async (dir: string): Promise => { + let count = 0 + const items = await fs.readdir(dir, { withFileTypes: true }) + for (const item of items) { + if (item.isDirectory()) { + count += await countFiles(path.join(dir, item.name)) + } else { + count++ + } + } + return count + } - 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) + totalFiles = await countFiles(source) + + // 复制文件并更新进度 + const copyDir = async (src: string, dest: string): Promise => { + const items = await fs.readdir(src, { withFileTypes: true }) + + for (const item of items) { + const sourcePath = path.join(src, item.name) + const destPath = path.join(dest, item.name) + + if (item.isDirectory()) { + await fs.ensureDir(destPath) + await copyDir(sourcePath, destPath) + } else { + const stats = await fs.stat(sourcePath) + await fs.copy(sourcePath, destPath) + processedFiles++ + + // 只在进度变化超过5%时报告进度 + const currentProgress = Math.floor((processedFiles / totalFiles) * 100) + if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) { + lastProgressReported = currentProgress + onProgress(stats.size) + } + } } } + + await copyDir(source, destination) } async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) { @@ -423,6 +476,141 @@ class BackupManager { throw new Error(error.message || 'Failed to delete backup file') } } + + async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) { + // 获取设备名 + const os = require('os') + const deviceName = os.hostname ? os.hostname() : 'device' + const timestamp = new Date() + .toISOString() + .replace(/[-:T.Z]/g, '') + .slice(0, 14) + const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip` + + // 不记录详细日志,只记录开始和结束 + Logger.log(`[BackupManager] Starting S3 backup to ${filename}`) + + const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile) + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + try { + const fileBuffer = await fs.promises.readFile(backupedFilePath) + const result = await s3Client.putFileContents(filename, fileBuffer) + await fs.remove(backupedFilePath) + + Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`) + return result + } catch (error) { + Logger.error(`[BackupManager] S3 backup failed:`, error) + await fs.remove(backupedFilePath) + throw error + } + } + + async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { + const filename = s3Config.fileName || 'cherry-studio.backup.zip' + + // 只记录开始和结束或错误 + Logger.log(`[BackupManager] Starting restore from S3: ${filename}`) + + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + try { + const retrievedFile = await s3Client.getFileContents(filename) + const backupedFilePath = path.join(this.backupDir, filename) + if (!fs.existsSync(this.backupDir)) { + fs.mkdirSync(this.backupDir, { recursive: true }) + } + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(backupedFilePath) + writeStream.write(retrievedFile as Buffer) + writeStream.end() + writeStream.on('finish', () => resolve()) + writeStream.on('error', (error) => reject(error)) + }) + + Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`) + return await this.restore(_, backupedFilePath) + } catch (error: any) { + Logger.error('[BackupManager] Failed to restore from S3:', error) + throw new Error(error.message || 'Failed to restore backup file') + } + } + + listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => { + try { + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + const entries = await s3Client.instance?.list('/') + const files: Array<{ fileName: string; modifiedTime: string; size: number }> = [] + if (entries) { + for await (const entry of entries) { + const path = entry.path() + if (path.endsWith('.zip')) { + const meta = await s3Client.instance!.stat(path) + if (meta.isFile()) { + files.push({ + fileName: path.replace(/^\/+/, ''), + modifiedTime: meta.lastModified || '', + size: Number(meta.contentLength || 0n) + }) + } + } + } + } + return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime()) + } catch (error: any) { + Logger.error('Failed to list S3 files:', error) + throw new Error(error.message || 'Failed to list backup files') + } + } + + async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) { + try { + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + return await s3Client.deleteFile(fileName) + } catch (error: any) { + Logger.error('Failed to delete S3 file:', error) + throw new Error(error.message || 'Failed to delete backup file') + } + } + + async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + return await s3Client.checkConnection() + } } export default BackupManager diff --git a/src/main/services/RemoteStorage.ts b/src/main/services/RemoteStorage.ts index b62489bbbe..4efc57b6c6 100644 --- a/src/main/services/RemoteStorage.ts +++ b/src/main/services/RemoteStorage.ts @@ -1,57 +1,83 @@ -// import Logger from 'electron-log' -// import { Operator } from 'opendal' +import Logger from 'electron-log' +import type { Operator as OperatorType } from 'opendal' +const { Operator } = require('opendal') -// export default class RemoteStorage { -// public instance: Operator | undefined +export default class S3Storage { + public instance: OperatorType | undefined -// /** -// * -// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk" -// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options. -// * -// * For example, use minio as remote storage: -// * -// * ```typescript -// * const storage = new RemoteStorage('s3', { -// * endpoint: 'http://localhost:9000', -// * region: 'us-east-1', -// * bucket: 'testbucket', -// * access_key_id: 'user', -// * secret_access_key: 'password', -// * root: '/path/to/basepath', -// * }) -// * ``` -// */ -// constructor(scheme: string, options?: Record | undefined | null) { -// this.instance = new Operator(scheme, options) + /** + * + * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk" + * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options. + * + * For example, use minio as remote storage: + * + * ```typescript + * const storage = new S3Storage('s3', { + * endpoint: 'http://localhost:9000', + * region: 'us-east-1', + * bucket: 'testbucket', + * access_key_id: 'user', + * secret_access_key: 'password', + * root: '/path/to/basepath', + * }) + * ``` + */ + constructor(scheme: string, options?: Record | undefined | null) { + this.instance = new Operator(scheme, options) -// this.putFileContents = this.putFileContents.bind(this) -// this.getFileContents = this.getFileContents.bind(this) -// } + this.putFileContents = this.putFileContents.bind(this) + this.getFileContents = this.getFileContents.bind(this) + } -// public putFileContents = async (filename: string, data: string | Buffer) => { -// if (!this.instance) { -// return new Error('RemoteStorage client not initialized') -// } + public putFileContents = async (filename: string, data: string | Buffer) => { + if (!this.instance) { + return new Error('RemoteStorage client not initialized') + } -// try { -// return await this.instance.write(filename, data) -// } catch (error) { -// Logger.error('[RemoteStorage] Error putting file contents:', error) -// throw error -// } -// } + try { + return await this.instance.write(filename, data) + } catch (error) { + Logger.error('[RemoteStorage] Error putting file contents:', error) + throw error + } + } -// public getFileContents = async (filename: string) => { -// if (!this.instance) { -// throw new Error('RemoteStorage client not initialized') -// } + public getFileContents = async (filename: string) => { + if (!this.instance) { + throw new Error('RemoteStorage client not initialized') + } -// try { -// return await this.instance.read(filename) -// } catch (error) { -// Logger.error('[RemoteStorage] Error getting file contents:', error) -// throw error -// } -// } -// } + try { + return await this.instance.read(filename) + } catch (error) { + Logger.error('[RemoteStorage] Error getting file contents:', error) + throw error + } + } + + public deleteFile = async (filename: string) => { + if (!this.instance) { + throw new Error('RemoteStorage client not initialized') + } + try { + return await this.instance.delete(filename) + } catch (error) { + Logger.error('[RemoteStorage] Error deleting file:', error) + throw error + } + } + + public checkConnection = async () => { + if (!this.instance) { + throw new Error('RemoteStorage client not initialized') + } + try { + // 检查根目录是否可访问 + return await this.instance.stat('/') + } catch (error) { + Logger.error('[RemoteStorage] Error checking connection:', error) + throw error + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 8412e00bc3..f6e49ece10 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,16 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { electronAPI } from '@electron-toolkit/preload' import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types' +import { + FileType, + KnowledgeBaseParams, + KnowledgeItem, + MCPServer, + S3Config, + Shortcut, + ThemeMode, + WebDavConfig +} from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' import { Notification } from 'src/renderer/src/types/notification' import { CreateDirectoryOptions } from 'webdav' @@ -71,7 +80,13 @@ const api = { createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options), deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) => - ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig) + ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig), + backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config), + restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config), + listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config), + deleteS3File: (fileName: string, s3Config: S3Config) => + ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config), + checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config) }, file: { select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options), diff --git a/src/renderer/src/components/S3BackupManager.tsx b/src/renderer/src/components/S3BackupManager.tsx new file mode 100644 index 0000000000..ecc9ed88ef --- /dev/null +++ b/src/renderer/src/components/S3BackupManager.tsx @@ -0,0 +1,298 @@ +import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons' +import { restoreFromS3 } from '@renderer/services/BackupService' +import { formatFileSize } from '@renderer/utils' +import { Button, Modal, Table, Tooltip } from 'antd' +import dayjs from 'dayjs' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface BackupFile { + fileName: string + modifiedTime: string + size: number +} + +interface S3Config { + endpoint: string + region: string + bucket: string + access_key_id: string + secret_access_key: string + root?: string +} + +interface S3BackupManagerProps { + visible: boolean + onClose: () => void + s3Config: { + endpoint?: string + region?: string + bucket?: string + access_key_id?: string + secret_access_key?: string + root?: string + } + restoreMethod?: (fileName: string) => Promise +} + +export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S3BackupManagerProps) { + const [backupFiles, setBackupFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedRowKeys, setSelectedRowKeys] = useState([]) + const [deleting, setDeleting] = useState(false) + const [restoring, setRestoring] = useState(false) + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 5, + total: 0 + }) + const { t } = useTranslation() + + const { endpoint, region, bucket, access_key_id, secret_access_key, root } = s3Config + + const fetchBackupFiles = useCallback(async () => { + if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + setLoading(true) + try { + const files = await window.api.backup.listS3Files({ + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root + } as S3Config) + setBackupFiles(files) + setPagination((prev) => ({ + ...prev, + total: files.length + })) + } catch (error: any) { + window.message.error(t('settings.data.s3.manager.files.fetch.error', { message: error.message })) + } finally { + setLoading(false) + } + }, [endpoint, region, bucket, access_key_id, secret_access_key, root, t]) + + useEffect(() => { + if (visible) { + fetchBackupFiles() + setSelectedRowKeys([]) + setPagination((prev) => ({ + ...prev, + current: 1 + })) + } + }, [visible, fetchBackupFiles]) + + const handleTableChange = (pagination: any) => { + setPagination(pagination) + } + + const handleDeleteSelected = async () => { + if (selectedRowKeys.length === 0) { + window.message.warning(t('settings.data.s3.manager.select.warning')) + return + } + + if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.manager.delete.confirm.title'), + icon: , + content: t('settings.data.s3.manager.delete.confirm.multiple', { count: selectedRowKeys.length }), + okText: t('settings.data.s3.manager.delete.confirm.title'), + cancelText: t('common.cancel'), + centered: true, + onOk: async () => { + setDeleting(true) + try { + // 依次删除选中的文件 + for (const key of selectedRowKeys) { + await window.api.backup.deleteS3File(key.toString(), { + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root + } as S3Config) + } + window.message.success( + t('settings.data.s3.manager.delete.success.multiple', { count: selectedRowKeys.length }) + ) + setSelectedRowKeys([]) + await fetchBackupFiles() + } catch (error: any) { + window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message })) + } finally { + setDeleting(false) + } + } + }) + } + + const handleDeleteSingle = async (fileName: string) => { + if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.manager.delete.confirm.title'), + icon: , + content: t('settings.data.s3.manager.delete.confirm.single', { fileName }), + okText: t('settings.data.s3.manager.delete.confirm.title'), + cancelText: t('common.cancel'), + centered: true, + onOk: async () => { + setDeleting(true) + try { + await window.api.backup.deleteS3File(fileName, { + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root + } as S3Config) + window.message.success(t('settings.data.s3.manager.delete.success.single')) + await fetchBackupFiles() + } catch (error: any) { + window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message })) + } finally { + setDeleting(false) + } + } + }) + } + + const handleRestore = async (fileName: string) => { + if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.restore.confirm.title'), + icon: , + content: t('settings.data.s3.restore.confirm.content'), + okText: t('settings.data.s3.restore.confirm.ok'), + cancelText: t('settings.data.s3.restore.confirm.cancel'), + centered: true, + onOk: async () => { + setRestoring(true) + try { + await (restoreMethod || restoreFromS3)(fileName) + window.message.success(t('settings.data.s3.restore.success')) + onClose() // 关闭模态框 + } catch (error: any) { + window.message.error(t('settings.data.s3.restore.error', { message: error.message })) + } finally { + setRestoring(false) + } + } + }) + } + + const columns = [ + { + title: t('settings.data.s3.manager.columns.fileName'), + dataIndex: 'fileName', + key: 'fileName', + ellipsis: { + showTitle: false + }, + render: (fileName: string) => ( + + {fileName} + + ) + }, + { + title: t('settings.data.s3.manager.columns.modifiedTime'), + dataIndex: 'modifiedTime', + key: 'modifiedTime', + width: 180, + render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss') + }, + { + title: t('settings.data.s3.manager.columns.size'), + dataIndex: 'size', + key: 'size', + width: 120, + render: (size: number) => formatFileSize(size) + }, + { + title: t('settings.data.s3.manager.columns.actions'), + key: 'action', + width: 160, + render: (_: any, record: BackupFile) => ( + <> + + + + ) + } + ] + + const rowSelection = { + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[]) => { + setSelectedRowKeys(selectedRowKeys) + } + } + + return ( + } onClick={fetchBackupFiles} disabled={loading}> + {t('settings.data.s3.manager.refresh')} + , + , + + ]}> + + + ) +} diff --git a/src/renderer/src/components/S3Modals.tsx b/src/renderer/src/components/S3Modals.tsx new file mode 100644 index 0000000000..a74ad2e9ca --- /dev/null +++ b/src/renderer/src/components/S3Modals.tsx @@ -0,0 +1,258 @@ +import { backupToS3, handleData } from '@renderer/services/BackupService' +import { formatFileSize } from '@renderer/utils' +import { Input, Modal, Select, Spin } from 'antd' +import dayjs from 'dayjs' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface BackupFile { + fileName: string + modifiedTime: string + size: number +} + +export function useS3BackupModal() { + const [customFileName, setCustomFileName] = useState('') + const [isModalVisible, setIsModalVisible] = useState(false) + const [backuping, setBackuping] = useState(false) + + const handleBackup = async () => { + setBackuping(true) + try { + await backupToS3({ customFileName, showMessage: true }) + } finally { + setBackuping(false) + setIsModalVisible(false) + } + } + + const handleCancel = () => { + setIsModalVisible(false) + } + + const showBackupModal = useCallback(async () => { + // 获取默认文件名 + const deviceType = await window.api.system.getDeviceType() + const hostname = await window.api.system.getHostname() + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + setCustomFileName(defaultFileName) + setIsModalVisible(true) + }, []) + + return { + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName, + showBackupModal + } +} + +type S3BackupModalProps = { + isModalVisible: boolean + handleBackup: () => Promise + handleCancel: () => void + backuping: boolean + customFileName: string + setCustomFileName: (value: string) => void +} + +export function S3BackupModal({ + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName +}: S3BackupModalProps) { + const { t } = useTranslation() + + return ( + + setCustomFileName(e.target.value)} + placeholder={t('settings.data.s3.backup.modal.filename.placeholder')} + /> + + ) +} + +interface UseS3RestoreModalProps { + endpoint: string | undefined + region: string | undefined + bucket: string | undefined + access_key_id: string | undefined + secret_access_key: string | undefined + root?: string | undefined +} + +export function useS3RestoreModal({ + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root +}: UseS3RestoreModalProps) { + const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false) + const [restoring, setRestoring] = useState(false) + const [selectedFile, setSelectedFile] = useState(null) + const [loadingFiles, setLoadingFiles] = useState(false) + const [backupFiles, setBackupFiles] = useState([]) + const { t } = useTranslation() + + const showRestoreModal = useCallback(async () => { + if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error({ content: t('settings.data.s3.manager.config.incomplete'), key: 's3-error' }) + return + } + + setIsRestoreModalVisible(true) + setLoadingFiles(true) + try { + const files = await window.api.backup.listS3Files({ + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root + }) + setBackupFiles(files) + } catch (error: any) { + window.message.error({ + content: t('settings.data.s3.manager.files.fetch.error', { message: error.message }), + key: 'list-files-error' + }) + } finally { + setLoadingFiles(false) + } + }, [endpoint, region, bucket, access_key_id, secret_access_key, root, t]) + + const handleRestore = useCallback(async () => { + if (!selectedFile || !endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error({ + content: !selectedFile + ? t('settings.data.s3.restore.file.required') + : t('settings.data.s3.restore.config.incomplete'), + key: 'restore-error' + }) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.restore.confirm.title'), + content: t('settings.data.s3.restore.confirm.content'), + okText: t('settings.data.s3.restore.confirm.ok'), + cancelText: t('settings.data.s3.restore.confirm.cancel'), + centered: true, + onOk: async () => { + setRestoring(true) + try { + const data = await window.api.backup.restoreFromS3({ + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root, + fileName: selectedFile + }) + await handleData(JSON.parse(data)) + window.message.success(t('settings.data.s3.restore.success')) + setIsRestoreModalVisible(false) + } catch (error: any) { + window.message.error({ + content: t('settings.data.s3.restore.error', { message: error.message }), + key: 'restore-error' + }) + } finally { + setRestoring(false) + } + } + }) + }, [selectedFile, endpoint, region, bucket, access_key_id, secret_access_key, root, t]) + + const handleCancel = () => { + setIsRestoreModalVisible(false) + } + + return { + isRestoreModalVisible, + handleRestore, + handleCancel, + restoring, + selectedFile, + setSelectedFile, + loadingFiles, + backupFiles, + showRestoreModal + } +} + +type S3RestoreModalProps = ReturnType + +export function S3RestoreModal({ + isRestoreModalVisible, + handleRestore, + handleCancel, + restoring, + selectedFile, + setSelectedFile, + loadingFiles, + backupFiles +}: S3RestoreModalProps) { + const { t } = useTranslation() + + return ( + +
+ setEndpoint(e.target.value)} + style={{ width: 250 }} + type="url" + onBlur={() => dispatch(setS3({ ...s3, endpoint: endpoint || '' }))} + /> + + + + {t('settings.data.s3.region')} + setRegion(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, region: region || '' }))} + /> + + + + {t('settings.data.s3.bucket')} + setBucket(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, bucket: bucket || '' }))} + /> + + + + {t('settings.data.s3.accessKeyId')} + setAccessKeyId(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, accessKeyId: accessKeyId || '' }))} + /> + + + + {t('settings.data.s3.secretAccessKey')} + setSecretAccessKey(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, secretAccessKey: secretAccessKey || '' }))} + /> + + + + {t('settings.data.s3.root')} + setRoot(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, root: root || '' }))} + /> + + + + {t('settings.data.s3.backup.operation')} + + + + + + + + {t('settings.data.s3.autoSync')} + + + + + {t('settings.data.s3.maxBackups')} + + + + + {t('settings.data.s3.skipBackupFile')} + + + + {t('settings.data.s3.skipBackupFile.help')} + + {syncInterval > 0 && ( + <> + + + {t('settings.data.s3.syncStatus')} + {renderSyncStatus()} + + + )} + <> + + + + + + ) +} + +export default S3Settings diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index 3d78b2752a..b99ea6c77e 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -4,11 +4,62 @@ import { upgradeToV7 } from '@renderer/databases/upgrades' import i18n from '@renderer/i18n' import store from '@renderer/store' import { setWebDAVSyncState } from '@renderer/store/backup' +import { setS3SyncState } from '@renderer/store/backup' import { uuid } from '@renderer/utils' import dayjs from 'dayjs' import { NotificationService } from './NotificationService' +// 重试删除S3文件的辅助函数 +async function deleteS3FileWithRetry(fileName: string, s3Config: any, maxRetries = 3) { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await window.api.backup.deleteS3File(fileName, s3Config) + Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`) + return true + } catch (error: any) { + lastError = error + Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message) + + // 如果不是最后一次尝试,等待一段时间再重试 + if (attempt < maxRetries) { + const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟 + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError) + return false +} + +// 重试删除WebDAV文件的辅助函数 +async function deleteWebdavFileWithRetry(fileName: string, webdavConfig: any, maxRetries = 3) { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await window.api.backup.deleteWebdavFile(fileName, webdavConfig) + Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`) + return true + } catch (error: any) { + lastError = error + Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message) + + // 如果不是最后一次尝试,等待一段时间再重试 + if (attempt < maxRetries) { + const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟 + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError) + return false +} + export async function backup(skipBackupFile: boolean) { const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip` const fileContnet = await getBackupData() @@ -161,17 +212,21 @@ export async function backupToWebdav({ // 文件已按修改时间降序排序,所以最旧的文件在末尾 const filesToDelete = currentDeviceFiles.slice(webdavMaxBackups) - for (const file of filesToDelete) { - try { - await window.api.backup.deleteWebdavFile(file.fileName, { - webdavHost, - webdavUser, - webdavPass, - webdavPath - }) - Logger.log(`[Backup] Deleted old backup file: ${file.fileName}`) - } catch (error) { - Logger.error(`[Backup] Failed to delete old backup file: ${file.fileName}`, error) + Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`) + + // 串行删除文件,避免并发请求导致的问题 + for (let i = 0; i < filesToDelete.length; i++) { + const file = filesToDelete[i] + await deleteWebdavFileWithRetry(file.fileName, { + webdavHost, + webdavUser, + webdavPass, + webdavPath + }) + + // 在删除操作之间添加短暂延迟,避免请求过于频繁 + if (i < filesToDelete.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 500)) } } } @@ -242,6 +297,201 @@ export async function restoreFromWebdav(fileName?: string) { } } +// 备份到 S3 +export async function backupToS3({ + showMessage = false, + customFileName = '', + autoBackupProcess = false +}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) { + const notificationService = NotificationService.getInstance() + if (isManualBackupRunning) { + Logger.log('[Backup] Manual backup already in progress') + return + } + + // force set showMessage to false when auto backup process + if (autoBackupProcess) { + showMessage = false + } + + isManualBackupRunning = true + + store.dispatch(setS3SyncState({ syncing: true, lastSyncError: null })) + + const { + s3: { + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + accessKeyId: s3AccessKeyId, + secretAccessKey: s3SecretAccessKey, + root: s3Root, + maxBackups: s3MaxBackups, + skipBackupFile: s3SkipBackupFile + } + } = store.getState().settings + let deviceType = 'unknown' + let hostname = 'unknown' + try { + deviceType = (await window.api.system.getDeviceType()) || 'unknown' + hostname = (await window.api.system.getHostname()) || 'unknown' + } catch (error) { + Logger.error('[Backup] Failed to get device type or hostname:', error) + } + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip` + const backupData = await getBackupData() + + // 上传文件 + try { + await window.api.backup.backupToS3(backupData, { + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + access_key_id: s3AccessKeyId, + secret_access_key: s3SecretAccessKey, + root: s3Root, + fileName: finalFileName, + skipBackupFile: s3SkipBackupFile + }) + + // S3上传成功 + store.dispatch( + setS3SyncState({ + lastSyncError: null + }) + ) + notificationService.send({ + id: uuid(), + type: 'success', + title: i18n.t('common.success'), + message: i18n.t('message.backup.success'), + silent: false, + timestamp: Date.now(), + source: 'backup' + }) + showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) + + // 清理旧备份文件 + if (s3MaxBackups > 0) { + try { + // 获取所有备份文件 + const files = await window.api.backup.listS3Files({ + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + access_key_id: s3AccessKeyId, + secret_access_key: s3SecretAccessKey, + root: s3Root + }) + + // 筛选当前设备的备份文件 + const currentDeviceFiles = files.filter((file) => { + // 检查文件名是否包含当前设备的标识信息 + return file.fileName.includes(deviceType) && file.fileName.includes(hostname) + }) + + // 如果当前设备的备份文件数量超过最大保留数量,删除最旧的文件 + if (currentDeviceFiles.length > s3MaxBackups) { + // 文件已按修改时间降序排序,所以最旧的文件在末尾 + const filesToDelete = currentDeviceFiles.slice(s3MaxBackups) + + Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`) + + // 串行删除文件,避免并发请求导致的问题 + for (let i = 0; i < filesToDelete.length; i++) { + const file = filesToDelete[i] + await deleteS3FileWithRetry(file.fileName, { + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + access_key_id: s3AccessKeyId, + secret_access_key: s3SecretAccessKey, + root: s3Root + }) + + // 在删除操作之间添加短暂延迟,避免请求过于频繁 + if (i < filesToDelete.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + } + } catch (error) { + Logger.error('[Backup] Failed to clean up old backup files:', error) + } + } + } catch (error: any) { + // if auto backup process, throw error + if (autoBackupProcess) { + throw error + } + notificationService.send({ + id: uuid(), + type: 'error', + title: i18n.t('message.backup.failed'), + message: error.message, + silent: false, + timestamp: Date.now(), + source: 'backup' + }) + store.dispatch(setS3SyncState({ lastSyncError: error.message })) + console.error('[Backup] backupToS3: Error uploading file to S3:', error) + showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' }) + throw error + } finally { + if (!autoBackupProcess) { + store.dispatch( + setS3SyncState({ + lastSyncTime: Date.now(), + syncing: false + }) + ) + } + isManualBackupRunning = false + } +} + +// 从 S3 恢复 +export async function restoreFromS3(fileName?: string) { + const { + s3: { + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + accessKeyId: s3AccessKeyId, + secretAccessKey: s3SecretAccessKey, + root: s3Root + } + } = store.getState().settings + let data = '' + + try { + data = await window.api.backup.restoreFromS3({ + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + access_key_id: s3AccessKeyId, + secret_access_key: s3SecretAccessKey, + root: s3Root, + fileName + }) + } catch (error: any) { + console.error('[Backup] restoreFromS3: Error downloading file from S3:', error) + window.modal.error({ + title: i18n.t('message.restore.failed'), + content: error.message + }) + } + + try { + await handleData(JSON.parse(data)) + } catch (error) { + console.error('[Backup] Error downloading file from S3:', error) + window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) + } +} + let autoSyncStarted = false let syncTimeout: NodeJS.Timeout | null = null let isAutoBackupRunning = false @@ -252,9 +502,17 @@ export function startAutoSync(immediate = false) { return } - const { webdavAutoSync, webdavHost } = store.getState().settings + const { + webdavAutoSync, + webdavHost, + s3: { autoSync: s3AutoSync, endpoint: s3Endpoint } + } = store.getState().settings - if (!webdavAutoSync || !webdavHost) { + // 检查WebDAV或S3自动同步配置 + const hasWebdavConfig = webdavAutoSync && webdavHost + const hasS3Config = s3AutoSync && s3Endpoint + + if (!hasWebdavConfig && !hasS3Config) { Logger.log('[AutoSync] Invalid sync settings, auto sync disabled') return } @@ -277,22 +535,29 @@ export function startAutoSync(immediate = false) { syncTimeout = null } - const { webdavSyncInterval } = store.getState().settings - const { webdavSync } = store.getState().backup + const { + webdavSyncInterval: _webdavSyncInterval, + s3: { syncInterval: _s3SyncInterval } + } = store.getState().settings + const { webdavSync, s3Sync } = store.getState().backup - if (webdavSyncInterval <= 0) { + // 使用当前激活的同步配置 + const syncInterval = hasWebdavConfig ? _webdavSyncInterval : _s3SyncInterval + const lastSyncTime = hasWebdavConfig ? webdavSync?.lastSyncTime : s3Sync?.lastSyncTime + + if (syncInterval <= 0) { Logger.log('[AutoSync] Invalid sync interval, auto sync disabled') stopAutoSync() return } // 用户指定的自动备份时间间隔(毫秒) - const requiredInterval = webdavSyncInterval * 60 * 1000 + const requiredInterval = syncInterval * 60 * 1000 let timeUntilNextSync = 1000 //also immediate switch (type) { - case 'fromLastSyncTime': // 如果存在最后一次同步WebDAV的时间,以它为参考计算下一次同步的时间 - timeUntilNextSync = Math.max(1000, (webdavSync?.lastSyncTime || 0) + requiredInterval - Date.now()) + case 'fromLastSyncTime': // 如果存在最后一次同步的时间,以它为参考计算下一次同步的时间 + timeUntilNextSync = Math.max(1000, (lastSyncTime || 0) + requiredInterval - Date.now()) break case 'fromNow': timeUntilNextSync = requiredInterval @@ -301,8 +566,9 @@ export function startAutoSync(immediate = false) { syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' Logger.log( - `[AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( + `[AutoSync] Next ${backupType} sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( (timeUntilNextSync / 1000) % 60 )} seconds` ) @@ -321,17 +587,28 @@ export function startAutoSync(immediate = false) { while (retryCount < maxRetries) { try { - Logger.log(`[AutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' + Logger.log(`[AutoSync] Starting auto ${backupType} backup... (attempt ${retryCount + 1}/${maxRetries})`) - await backupToWebdav({ autoBackupProcess: true }) - - store.dispatch( - setWebDAVSyncState({ - lastSyncError: null, - lastSyncTime: Date.now(), - syncing: false - }) - ) + if (hasWebdavConfig) { + await backupToWebdav({ autoBackupProcess: true }) + store.dispatch( + setWebDAVSyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) + } else if (hasS3Config) { + await backupToS3({ autoBackupProcess: true }) + store.dispatch( + setS3SyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) + } isAutoBackupRunning = false scheduleNextBackup() @@ -340,20 +617,31 @@ export function startAutoSync(immediate = false) { } catch (error: any) { retryCount++ if (retryCount === maxRetries) { - Logger.error('[AutoSync] Auto backup failed after all retries:', error) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' + Logger.error(`[AutoSync] Auto ${backupType} backup failed after all retries:`, error) - store.dispatch( - setWebDAVSyncState({ - lastSyncError: 'Auto backup failed', - lastSyncTime: Date.now(), - syncing: false - }) - ) + if (hasWebdavConfig) { + store.dispatch( + setWebDAVSyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) + } else if (hasS3Config) { + store.dispatch( + setS3SyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) + } //only show 1 time error modal, and autoback stopped until user click ok await window.modal.error({ title: i18n.t('message.backup.failed'), - content: `[WebDAV Auto Backup] ${new Date().toLocaleString()} ` + error.message + content: `[${backupType} Auto Backup] ${new Date().toLocaleString()} ` + error.message }) scheduleNextBackup('fromNow') diff --git a/src/renderer/src/store/backup.ts b/src/renderer/src/store/backup.ts index a8b7d342c5..0740032efb 100644 --- a/src/renderer/src/store/backup.ts +++ b/src/renderer/src/store/backup.ts @@ -8,6 +8,7 @@ export interface WebDAVSyncState { export interface BackupState { webdavSync: WebDAVSyncState + s3Sync: WebDAVSyncState } const initialState: BackupState = { @@ -15,6 +16,11 @@ const initialState: BackupState = { lastSyncTime: null, syncing: false, lastSyncError: null + }, + s3Sync: { + lastSyncTime: null, + syncing: false, + lastSyncError: null } } @@ -24,9 +30,12 @@ const backupSlice = createSlice({ reducers: { setWebDAVSyncState: (state, action: PayloadAction>) => { state.webdavSync = { ...state.webdavSync, ...action.payload } + }, + setS3SyncState: (state, action: PayloadAction>) => { + state.s3Sync = { ...state.s3Sync, ...action.payload } } } }) -export const { setWebDAVSyncState } = backupSlice.actions +export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions export default backupSlice.reducer diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 7d8e14ed11..8afbafc2a7 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -37,6 +37,19 @@ export type UserTheme = { colorPrimary: string } +export interface S3Config { + endpoint: string + region: string + bucket: string + accessKeyId: string + secretAccessKey: string + root: string + autoSync: boolean + syncInterval: number + maxBackups: number + skipBackupFile: boolean +} + export interface SettingsState { showAssistants: boolean showTopics: boolean @@ -185,6 +198,7 @@ export interface SettingsState { knowledgeEmbed: boolean } defaultPaintingProvider: PaintingProvider + s3: S3Config } export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' @@ -329,7 +343,19 @@ export const initialState: SettingsState = { backup: false, knowledgeEmbed: false }, - defaultPaintingProvider: 'aihubmix' + defaultPaintingProvider: 'aihubmix', + s3: { + endpoint: '', + region: '', + bucket: '', + accessKeyId: '', + secretAccessKey: '', + root: '', + autoSync: false, + syncInterval: 0, + maxBackups: 0, + skipBackupFile: false + } } const settingsSlice = createSlice({ @@ -693,6 +719,9 @@ const settingsSlice = createSlice({ }, setDefaultPaintingProvider: (state, action: PayloadAction) => { state.defaultPaintingProvider = action.payload + }, + setS3: (state, action: PayloadAction) => { + state.s3 = action.payload } } }) @@ -801,7 +830,8 @@ export const { setOpenAISummaryText, setOpenAIServiceTier, setNotificationSettings, - setDefaultPaintingProvider + setDefaultPaintingProvider, + setS3 } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 3b4cc5cdc3..448f04c647 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -730,4 +730,16 @@ export interface StoreSyncAction { export type OpenAISummaryText = 'auto' | 'concise' | 'detailed' | 'off' export type OpenAIServiceTier = 'auto' | 'default' | 'flex' + +export type S3Config = { + endpoint: string + region: string + bucket: string + access_key_id: string + secret_access_key: string + root?: string + fileName?: string + skipBackupFile?: boolean +} + export type { Message } from './newMessage' diff --git a/yarn.lock b/yarn.lock index 2386409f15..1d1df92837 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3198,6 +3198,55 @@ __metadata: languageName: node linkType: hard +"@opendal/lib-darwin-arm64@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-darwin-arm64@npm:0.47.11" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@opendal/lib-darwin-x64@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-darwin-x64@npm:0.47.11" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@opendal/lib-linux-arm64-gnu@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-linux-arm64-gnu@npm:0.47.11" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@opendal/lib-linux-arm64-musl@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-linux-arm64-musl@npm:0.47.11" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@opendal/lib-linux-x64-gnu@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-linux-x64-gnu@npm:0.47.11" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@opendal/lib-win32-arm64-msvc@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-win32-arm64-msvc@npm:0.47.11" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@opendal/lib-win32-x64-msvc@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-win32-x64-msvc@npm:0.47.11" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@parcel/watcher-android-arm64@npm:2.5.1": version: 2.5.1 resolution: "@parcel/watcher-android-arm64@npm:2.5.1" @@ -5711,6 +5760,7 @@ __metadata: npx-scope-finder: "npm:^1.2.0" officeparser: "npm:^4.1.1" openai: "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch" + opendal: "npm:0.47.11" os-proxy-config: "npm:^1.1.2" p-queue: "npm:^8.1.0" playwright: "npm:^1.52.0" @@ -14128,6 +14178,36 @@ __metadata: languageName: node linkType: hard +"opendal@npm:0.47.11": + version: 0.47.11 + resolution: "opendal@npm:0.47.11" + dependencies: + "@opendal/lib-darwin-arm64": "npm:0.47.11" + "@opendal/lib-darwin-x64": "npm:0.47.11" + "@opendal/lib-linux-arm64-gnu": "npm:0.47.11" + "@opendal/lib-linux-arm64-musl": "npm:0.47.11" + "@opendal/lib-linux-x64-gnu": "npm:0.47.11" + "@opendal/lib-win32-arm64-msvc": "npm:0.47.11" + "@opendal/lib-win32-x64-msvc": "npm:0.47.11" + dependenciesMeta: + "@opendal/lib-darwin-arm64": + optional: true + "@opendal/lib-darwin-x64": + optional: true + "@opendal/lib-linux-arm64-gnu": + optional: true + "@opendal/lib-linux-arm64-musl": + optional: true + "@opendal/lib-linux-x64-gnu": + optional: true + "@opendal/lib-win32-arm64-msvc": + optional: true + "@opendal/lib-win32-x64-msvc": + optional: true + checksum: 10c0/0783da2651bb27ac693ce38938d12b00124530fb965364517eef3de17b3ff898cdecf06260a79a7d70745d57c2ba952a753a4bab52e0831aa7232c3a69120225 + languageName: node + linkType: hard + "option@npm:~0.2.1": version: 0.2.4 resolution: "option@npm:0.2.4"