diff --git a/package.json b/package.json index a04e19e2..251f52d2 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "fs-extra": "^11.2.0", "html2canvas": "^1.4.1", "unzipper": "^0.12.3", - "webdav": "^5.7.1" + "webdav": "4.11.4" }, "devDependencies": { "@anthropic-ai/sdk": "^0.24.3", diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 8f330339..c75f41aa 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -29,8 +29,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle('reload', () => mainWindow.reload()) - ipcMain.handle('backup:save', backupManager.backup) + ipcMain.handle('backup:backup', backupManager.backup) ipcMain.handle('backup:restore', backupManager.restore) + ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav) + ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav) ipcMain.handle('file:open', fileManager.open) ipcMain.handle('file:save', fileManager.save) diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index 513289c6..de38b74f 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -1,3 +1,4 @@ +import { WebDavConfig } from '@types' import archiver from 'archiver' import { app } from 'electron' import Logger from 'electron-log' @@ -5,16 +6,25 @@ import * as fs from 'fs-extra' import * as path from 'path' import * as unzipper from 'unzipper' +import WebDav from './WebDav' + class BackupManager { - private tempDir: string + private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp') + private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup') constructor() { - this.tempDir = path.join(app.getPath('temp'), 'CherryStudio', 'backup') this.backup = this.backup.bind(this) this.restore = this.restore.bind(this) + this.backupToWebdav = this.backupToWebdav.bind(this) + this.restoreFromWebdav = this.restoreFromWebdav.bind(this) } - async backup(_: Electron.IpcMainInvokeEvent, data: string, fileName: string, destinationPath: string): Promise { + async backup( + _: Electron.IpcMainInvokeEvent, + fileName: string, + data: string, + destinationPath: string = this.backupDir + ): Promise { try { // 创建临时目录 await fs.ensureDir(this.tempDir) @@ -29,7 +39,7 @@ class BackupManager { await fs.copy(sourcePath, tempDataDir) // 创建 zip 文件 - const output = fs.createWriteStream(path.join(destinationPath, `${fileName}.zip`)) + const output = fs.createWriteStream(path.join(destinationPath, fileName)) const archive = archiver('zip', { zlib: { level: 9 } }) archive.pipe(output) @@ -40,42 +50,60 @@ class BackupManager { await fs.remove(this.tempDir) Logger.log('Backup completed successfully') + + const backupedFilePath = path.join(destinationPath, fileName) + + return backupedFilePath } catch (error) { Logger.error('Backup failed:', error) throw error } } - async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<{ data: string; success: boolean }> { - try { - // 创建临时目录 - await fs.ensureDir(this.tempDir) + async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise { + // 创建临时目录 + await fs.ensureDir(this.tempDir) - // 解压备份文件到临时目录 - await fs - .createReadStream(backupPath) - .pipe(unzipper.Extract({ path: this.tempDir })) - .promise() + // 解压备份文件到临时目录 + await fs + .createReadStream(backupPath) + .pipe(unzipper.Extract({ path: this.tempDir })) + .promise() - // 读取 data.json - const dataPath = path.join(this.tempDir, 'data.json') - const data = await fs.readFile(dataPath, 'utf-8') + // 读取 data.json + const dataPath = path.join(this.tempDir, 'data.json') + const data = await fs.readFile(dataPath, 'utf-8') - // 恢复 Data 目录 - const sourcePath = path.join(this.tempDir, 'Data') - const destPath = path.join(app.getPath('userData'), 'Data') - await fs.remove(destPath) - await fs.copy(sourcePath, destPath) + // 恢复 Data 目录 + const sourcePath = path.join(this.tempDir, 'Data') + const destPath = path.join(app.getPath('userData'), 'Data') + await fs.remove(destPath) + await fs.copy(sourcePath, destPath) - // 清理临时目录 - await fs.remove(this.tempDir) + // 清理临时目录 + await fs.remove(this.tempDir) - Logger.log('Restore completed successfully') - return { data, success: true } - } catch (error) { - Logger.error('Restore failed:', error) - return { data: '', success: false } - } + Logger.log('Restore completed successfully') + + return data + } + + async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) { + const 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 = 'cherry-studio.backup.zip' + const webdavClient = new WebDav(webdavConfig) + const retrievedFile = await webdavClient.getFileContents(filename) + const backupedFilePath = path.join(this.backupDir, filename) + await fs.writeFileSync(backupedFilePath, retrievedFile as Buffer) + return await this.restore(_, backupedFilePath) } } diff --git a/src/main/services/WebDav.ts b/src/main/services/WebDav.ts new file mode 100644 index 00000000..f57f6c6f --- /dev/null +++ b/src/main/services/WebDav.ts @@ -0,0 +1,66 @@ +import { WebDavConfig } from '@types' +import Logger from 'electron-log' +import Stream from 'stream' +import { BufferLike, createClient, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav' + +export default class WebDav { + public instance: WebDAVClient | undefined + private webdavPath: string + + constructor(params: WebDavConfig) { + this.webdavPath = params.webdavPath.replace('/', '') + + this.instance = createClient(params.webdavHost, { + username: params.webdavUser, + password: params.webdavPass + }) + + this.putFileContents = this.putFileContents.bind(this) + this.getFileContents = this.getFileContents.bind(this) + } + + public putFileContents = async ( + filename: string, + data: string | BufferLike | Stream.Readable, + options?: PutFileContentsOptions + ) => { + if (!this.instance) { + return new Error('WebDAV client not initialized') + } + + try { + if (!(await this.instance.exists(this.webdavPath))) { + await this.instance.createDirectory(this.webdavPath, { + recursive: true + }) + } + } catch (error) { + Logger.error('[WebDAV] Error creating directory on WebDAV:', error) + throw error + } + + const remoteFilePath = `${this.webdavPath}/${filename}` + + try { + return await this.instance.putFileContents(remoteFilePath, data, options) + } catch (error) { + Logger.error('[WebDAV] Error putting file contents on WebDAV:', error) + throw error + } + } + + public getFileContents = async (filename: string, options?: GetFileContentsOptions) => { + if (!this.instance) { + throw new Error('WebDAV client not initialized') + } + + const remoteFilePath = `${this.webdavPath}/${filename}` + + try { + return await this.instance.getFileContents(remoteFilePath, options) + } catch (error) { + Logger.error('[WebDAV] Error getting file contents on WebDAV:', error) + throw error + } + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e769a6c7..864e5ff0 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,6 +1,8 @@ import { ElectronAPI } from '@electron-toolkit/preload' import { FileType } from '@renderer/types' +import { WebDavConfig } from '@renderer/types' import type { OpenDialogOptions } from 'electron' +import { Readable } from 'stream' declare global { interface Window { @@ -20,8 +22,10 @@ declare global { compress: (text: string) => Promise decompress: (text: Buffer) => Promise backup: { - save: (data: string, fileName: string, destinationPath: string) => Promise - restore: (backupPath: string) => Promise<{ data: string; success: boolean }> + backup: (fileName: string, data: string, destinationPath?: string) => Promise + restore: (backupPath: string) => Promise + backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise + restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise } file: { select: (options?: OpenDialogOptions) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index c6db5a1b..51288d97 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,5 @@ import { electronAPI } from '@electron-toolkit/preload' +import { WebDavConfig } from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron' // Custom APIs for renderer @@ -11,10 +12,12 @@ const api = { minApp: (url: string) => ipcRenderer.invoke('minapp', url), reload: () => ipcRenderer.invoke('reload'), backup: { - save: (data: string, fileName: string, destinationPath: string) => { - ipcRenderer.invoke('backup:save', data, fileName, destinationPath) - }, - restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath) + backup: (fileName: string, data: string, destinationPath?: string) => + ipcRenderer.invoke('backup:backup', fileName, data, destinationPath), + restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath), + backupToWebdav: (data: string, webdavConfig: WebDavConfig) => + ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig), + restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig) }, file: { select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options), diff --git a/src/renderer/src/i18n/en-us.json b/src/renderer/src/i18n/en-us.json index 19fdfd46..c6108dc2 100644 --- a/src/renderer/src/i18n/en-us.json +++ b/src/renderer/src/i18n/en-us.json @@ -46,12 +46,14 @@ "error.enter.api.host": "Please enter your API host first", "error.enter.model": "Please select a model first", "error.invalid.proxy.url": "Invalid proxy URL", + "error.invalid.webdav": "Invalid WebDAV settings", "api.connection.failed": "Connection failed", "api.connection.success": "Connection successful", "chat.completion.paused": "Chat completion paused", "switch.disabled": "Switching is disabled while the assistant is generating", "restore.success": "Restored successfully", "backup.success": "Backup successful", + "backup.failed": "Backup failed", "reset.confirm.content": "Are you sure you want to clear all data?", "reset.double.confirm.title": "DATA LOST !!!", "reset.double.confirm.content": "All data will be lost, do you want to continue?", @@ -180,11 +182,14 @@ "general.backup.title": "Data Backup and Recovery", "general.backup.button": "Backup", "general.restore.button": "Restore", + "general.view_webdav_settings": "View WebDAV settings", "general.webdav.title": "WebDAV", - "general.webdav.host": "WebDAV Host, e.g. http://localhost:8080", + "general.webdav.host": "WebDAV Host", + "general.webdav.host.placeholder": "http://localhost:8080", "general.webdav.user": "WebDAV User", "general.webdav.password": "WebDAV Password", - "general.webdav.path": "WebDAV Path, e.g. /backup", + "general.webdav.path": "WebDAV Path", + "general.webdav.path.placeholder": "/backup", "general.webdav.backup.button": "Backup to WebDAV", "general.webdav.restore.button": "Restore from WebDAV", "general.reset.title": "Data Reset", diff --git a/src/renderer/src/i18n/zh-cn.json b/src/renderer/src/i18n/zh-cn.json index eaf5f3c9..67ba4dba 100644 --- a/src/renderer/src/i18n/zh-cn.json +++ b/src/renderer/src/i18n/zh-cn.json @@ -46,12 +46,14 @@ "error.enter.api.host": "请输入您的 API 地址", "error.enter.model": "请选择一个模型", "error.invalid.proxy.url": "无效的代理地址", + "error.invalid.webdav": "无效的 WebDAV 设置", "api.connection.failed": "连接失败", "api.connection.success": "连接成功", "chat.completion.paused": "会话已停止", "switch.disabled": "模型回复完成后才能切换", "restore.success": "恢复成功", "backup.success": "备份成功", + "backup.failed": "备份失败", "reset.confirm.content": "确定要重置所有数据吗?", "reset.double.confirm.title": "数据丢失!!!", "reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?", @@ -182,11 +184,14 @@ "general.restore.button": "恢复", "general.reset.title": "重置数据", "general.reset.button": "重置", + "general.view_webdav_settings": "查看 WebDAV 设置", "general.webdav.title": "WebDAV", - "general.webdav.host": "WebDAV Host, e.g. http://localhost:8080", - "general.webdav.user": "WebDAV User", - "general.webdav.password": "WebDAV Password", - "general.webdav.path": "WebDAV Path, e.g. /backup", + "general.webdav.host": "WebDAV 地址", + "general.webdav.host.placeholder": "http://localhost:8080", + "general.webdav.user": "WebDAV 用户名", + "general.webdav.password": "WebDAV 密码", + "general.webdav.path": "WebDAV 路径", + "general.webdav.path.placeholder": "/backup", "general.webdav.backup.button": "备份到 WebDAV", "general.webdav.restore.button": "从 WebDAV 恢复", "general.check_update_setting": "更新设置", diff --git a/src/renderer/src/i18n/zh-tw.json b/src/renderer/src/i18n/zh-tw.json index 6af19d4d..4c140530 100644 --- a/src/renderer/src/i18n/zh-tw.json +++ b/src/renderer/src/i18n/zh-tw.json @@ -46,12 +46,14 @@ "error.enter.api.host": "請先輸入您的 API 主機地址", "error.enter.model": "請先選擇一個模型", "error.invalid.proxy.url": "無效的代理 URL", + "error.invalid.webdav": "無效的 WebDAV 設定", "api.connection.failed": "連接失敗", "api.connection.success": "連接成功", "chat.completion.paused": "聊天完成已暫停", "switch.disabled": "助手生成回覆時無法切換", "restore.success": "恢復成功", "backup.success": "備份成功", + "backup.failed": "備份失敗", "reset.confirm.content": "確定要清除所有資料嗎?", "reset.double.confirm.title": "資料將會丟失!!!", "reset.double.confirm.content": "所有資料將會被清除,您確定要繼續嗎?", @@ -180,11 +182,14 @@ "general.backup.title": "資料備份與復原", "general.backup.button": "備份", "general.restore.button": "復原", + "general.view_webdav_settings": "查看 WebDAV 設定", "general.webdav.title": "WebDAV", - "general.webdav.host": "WebDAV Host, e.g. http://localhost:8080", - "general.webdav.user": "WebDAV User", - "general.webdav.password": "WebDAV Password", - "general.webdav.path": "WebDAV Path, e.g. /backup", + "general.webdav.host": "WebDAV 主機位址", + "general.webdav.host.placeholder": "http://localhost:8080", + "general.webdav.user": "WebDAV 使用者名稱", + "general.webdav.password": "WebDAV 密碼", + "general.webdav.path": "WebDAV Path", + "general.webdav.path.placeholder": "/backup", "general.webdav.backup.button": "從 WebDAV 備份", "general.webdav.restore.button": "從 WebDAV 恢復", "general.reset.title": "資料重置", diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx deleted file mode 100644 index 6aedd008..00000000 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons' -import { HStack } from '@renderer/components/Layout' -import { isMac } from '@renderer/config/constant' -import { useSettings } from '@renderer/hooks/useSettings' -import i18n from '@renderer/i18n' -import { backup, reset, restore, backupToWebdav, restoreFromWebdav } from '@renderer/services/backup' -import { useAppDispatch } from '@renderer/store' -import { setClickAssistantToShowTopic, setLanguage, setManualUpdateCheck } from '@renderer/store/settings' -import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings' -import { - setWebdavHost as _setWebdavHost, - setWebdavPass as _setWebdavPass, - setWebdavPath as _setWebdavPath, - setWebdavUser as _setWebdavUser -} from '@renderer/store/settings' -import { ThemeMode } from '@renderer/types' -import { isValidProxyUrl } from '@renderer/utils' -import { Button, Input, Select, Switch } from 'antd' -import { FC, useState } from 'react' -import { useTranslation } from 'react-i18next' - -import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '.' - -const GeneralSettings: FC = () => { - const { - language, - proxyUrl: storeProxyUrl, - theme, - windowStyle, - topicPosition, - clickAssistantToShowTopic, - manualUpdateCheck, - setTheme, - setWindowStyle, - setTopicPosition, - - webdavHost: webDAVHost, - webdavUser: webDAVUser, - webdavPass: webDAVPass, - webdavPath: webDAVPath - } = useSettings() - const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) - const [webdavHost, setWebdavHost] = useState(webDAVHost) - const [webdavUser, setWebdavUser] = useState(webDAVUser) - const [webdavPass, setWebdavPass] = useState(webDAVPass) - const [webdavPath, setWebdavPath] = useState(webDAVPath) - - const dispatch = useAppDispatch() - const { t } = useTranslation() - - const onSelectLanguage = (value: string) => { - dispatch(setLanguage(value)) - localStorage.setItem('language', value) - i18n.changeLanguage(value) - } - - const onSetProxyUrl = () => { - if (proxyUrl && !isValidProxyUrl(proxyUrl)) { - window.message.error({ content: t('message.error.invalid.proxy.url'), key: 'proxy-error' }) - return - } - - dispatch(_setProxyUrl(proxyUrl)) - window.api.setProxy(proxyUrl) - } - - const onSetWebdav = () => { - if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) { - window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' }) - return - } - - console.log('webdav', webdavHost, webdavUser, webdavPass, webdavPath) - - dispatch(_setWebdavHost(webdavHost)) - dispatch(_setWebdavUser(webdavUser)) - dispatch(_setWebdavPass(webdavPass)) - dispatch(_setWebdavPath(webdavPath)) - } - - return ( - - {t('settings.general.title')} - - - {t('common.language')} - - - {isMac && ( - <> - - - {t('settings.theme.window.style.title')} - - - - {topicPosition === 'left' && ( - <> - - {t('settings.advanced.click_assistant_switch_to_topics')} - dispatch(setClickAssistantToShowTopic(checked))} - /> - - - - )} - - {t('settings.general.check_update_setting')} - setProxyUrl(e.target.value)} - style={{ width: 180 }} - onBlur={() => onSetProxyUrl()} - type="url" - /> - - - - {/* 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path */} - {t('settings.general.webdav.title')} - - setWebdavHost(e.target.value)} - style={{ width: 280 }} - type="url" - onBlur={onSetWebdav} - /> - setWebdavUser(e.target.value)} - style={{ width: 120 }} - onBlur={onSetWebdav} - /> - setWebdavPass(e.target.value)} - style={{ width: 140 }} - onBlur={onSetWebdav} - /> - setWebdavPath(e.target.value)} - style={{ width: 220 }} - onBlur={onSetWebdav} - /> - - - - - {t('settings.general.backup.title')} - - - - {/* 添加 在线备份 在线还原 按钮 */} - - - - - - - {t('settings.general.reset.title')} - - - - - - - ) -} - -export default GeneralSettings diff --git a/src/renderer/src/pages/settings/GeneralSettings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings/GeneralSettings.tsx new file mode 100644 index 00000000..d9c82f66 --- /dev/null +++ b/src/renderer/src/pages/settings/GeneralSettings/GeneralSettings.tsx @@ -0,0 +1,195 @@ +import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons' +import { HStack, VStack } from '@renderer/components/Layout' +import { isMac } from '@renderer/config/constant' +import { useSettings } from '@renderer/hooks/useSettings' +import i18n from '@renderer/i18n' +import { backup, reset, restore } from '@renderer/services/backup' +import { useAppDispatch } from '@renderer/store' +import { setClickAssistantToShowTopic, setLanguage, setManualUpdateCheck } from '@renderer/store/settings' +import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings' +import { ThemeMode } from '@renderer/types' +import { isValidProxyUrl } from '@renderer/utils' +import { Button, Input, Select, Switch } from 'antd' +import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Link, Route, Routes } from 'react-router-dom' + +import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '..' +import WebDavSettings from './WebDavSettings' + +const GeneralSettings: FC = () => { + const { + language, + proxyUrl: storeProxyUrl, + theme, + windowStyle, + topicPosition, + clickAssistantToShowTopic, + manualUpdateCheck, + setTheme, + setWindowStyle, + setTopicPosition + } = useSettings() + const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) + + const dispatch = useAppDispatch() + const { t } = useTranslation() + + const onSelectLanguage = (value: string) => { + dispatch(setLanguage(value)) + localStorage.setItem('language', value) + i18n.changeLanguage(value) + } + + const onSetProxyUrl = () => { + if (proxyUrl && !isValidProxyUrl(proxyUrl)) { + window.message.error({ content: t('message.error.invalid.proxy.url'), key: 'proxy-error' }) + return + } + + dispatch(_setProxyUrl(proxyUrl)) + window.api.setProxy(proxyUrl) + } + + return ( + + + {t('settings.general.title')} + + + {t('common.language')} + + + {isMac && ( + <> + + + {t('settings.theme.window.style.title')} + + + + {topicPosition === 'left' && ( + <> + + {t('settings.advanced.click_assistant_switch_to_topics')} + dispatch(setClickAssistantToShowTopic(checked))} + /> + + + + )} + + {t('settings.general.check_update_setting')} + setProxyUrl(e.target.value)} + style={{ width: 180 }} + onBlur={() => onSetProxyUrl()} + type="url" + /> + + + + {t('settings.general.webdav.title')} + + + {t('settings.general.view_webdav_settings')} + + + + + + {t('settings.general.backup.title')} + + + + + + + + {t('settings.general.reset.title')} + + + + + + + } + /> + } /> + + ) +} + +export default GeneralSettings diff --git a/src/renderer/src/pages/settings/GeneralSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings/WebDavSettings.tsx new file mode 100644 index 00000000..9449983c --- /dev/null +++ b/src/renderer/src/pages/settings/GeneralSettings/WebDavSettings.tsx @@ -0,0 +1,137 @@ +import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' +import { useSettings } from '@renderer/hooks/useSettings' +import { backupToWebdav, restoreFromWebdav } from '@renderer/services/backup' +import { useAppDispatch } from '@renderer/store' +import { + setWebdavHost as _setWebdavHost, + setWebdavPass as _setWebdavPass, + setWebdavPath as _setWebdavPath, + setWebdavUser as _setWebdavUser +} from '@renderer/store/settings' +import { Breadcrumb, Button, Input } from 'antd' +import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '..' + +const WebDavSettings: FC = () => { + const { + webdavHost: webDAVHost, + webdavUser: webDAVUser, + webdavPass: webDAVPass, + webdavPath: webDAVPath + } = useSettings() + + const [webdavHost, setWebdavHost] = useState(webDAVHost) + const [webdavUser, setWebdavUser] = useState(webDAVUser) + const [webdavPass, setWebdavPass] = useState(webDAVPass) + const [webdavPath, setWebdavPath] = useState(webDAVPath) + + const [backuping, setBackuping] = useState(false) + const [restoring, setRestoring] = useState(false) + + const dispatch = useAppDispatch() + + const { t } = useTranslation() + + // 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path + + const onBackup = async () => { + if (!webdavHost) { + window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' }) + return + } + setBackuping(true) + await backupToWebdav() + setBackuping(false) + } + + const onRestore = async () => { + if (!webdavHost) { + window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' }) + return + } + setRestoring(true) + await restoreFromWebdav() + setRestoring(false) + } + + return ( + + + {t('settings.general.webdav.title')} + + + {t('settings.general.webdav.host')} + setWebdavHost(e.target.value)} + style={{ width: 250 }} + type="url" + onBlur={() => dispatch(_setWebdavHost(webdavHost || ''))} + /> + + + + {t('settings.general.webdav.user')} + setWebdavUser(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(_setWebdavUser(webdavUser || ''))} + /> + + + + {t('settings.general.webdav.password')} + setWebdavPass(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(_setWebdavPass(webdavPass || ''))} + /> + + + + {t('settings.general.webdav.path')} + setWebdavPath(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(_setWebdavPath(webdavPath || ''))} + /> + + + + {t('settings.general.backup.title')} + + {/* 添加 在线备份 在线还原 按钮 */} + + + + + + + ) +} + +export default WebDavSettings diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index 36b6ab55..25790fc0 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -8,7 +8,7 @@ import styled from 'styled-components' import AboutSettings from './AboutSettings' import AssistantSettings from './AssistantSettings' -import GeneralSettings from './GeneralSettings' +import GeneralSettings from './GeneralSettings/GeneralSettings' import ModelSettings from './ModelSettings' import ProvidersList from './ProviderSettings' @@ -16,7 +16,7 @@ const SettingsPage: FC = () => { const { pathname } = useLocation() const { t } = useTranslation() - const isRoute = (path: string): string => (pathname === path ? 'active' : '') + const isRoute = (path: string): string => (pathname.startsWith(path) ? 'active' : '') return ( @@ -65,7 +65,7 @@ const SettingsPage: FC = () => { } /> } /> } /> - } /> + } /> } /> diff --git a/src/renderer/src/services/backup.ts b/src/renderer/src/services/backup.ts index 44b8f10e..a88e740a 100644 --- a/src/renderer/src/services/backup.ts +++ b/src/renderer/src/services/backup.ts @@ -1,29 +1,15 @@ import db from '@renderer/databases' import i18n from '@renderer/i18n' +import store from '@renderer/store' import dayjs from 'dayjs' import localforage from 'localforage' -import store from '@renderer/store' - -import { createClient } from 'webdav' export async function backup() { - const version = 3 - const time = new Date().getTime() - - const data = { - time, - version, - localStorage, - indexedDB: await backupDatabase() - } - - const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}` - const fileContnet = JSON.stringify(data) - + const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip` + const fileContnet = await getBackupData() const selectFolder = await window.api.file.selectFolder() - if (selectFolder) { - await window.api.backup.save(fileContnet, filename, selectFolder) + await window.api.backup.backup(filename, fileContnet, selectFolder) window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) } } @@ -38,7 +24,7 @@ export async function restore() { // zip backup file if (file?.fileName.endsWith('.zip')) { const restoreData = await window.api.backup.restore(file.filePath) - data = JSON.parse(restoreData.data) + data = JSON.parse(restoreData) } else { data = JSON.parse(await window.api.decompress(file.content)) } @@ -78,127 +64,73 @@ export async function reset() { // 备份到 webdav export async function backupToWebdav() { - // 先走之前的 backup 流程,存储到临时文件 - const version = 3 - const time = new Date().getTime() - - const data = { - time, - version, - localStorage, - indexedDB: await backupDatabase() - } - - const filename = `cherry-studio.backup.json` - const fileContent = JSON.stringify(data) - - // 获取 userSetting 里的 WebDAV 配置 const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings - // console.log('backup.backupToWebdav', webdavHost, webdavUser, webdavPass, webdavPath) - let host = webdavHost - if (!host.startsWith('http://') && !host.startsWith('https://')) { - host = `http://${host}` - } - console.log('backup.backupToWebdav', host) + const backupData = await getBackupData() - // 创建 WebDAV 客户端 - const client = createClient( - host, // WebDAV 服务器地址 - { - username: webdavUser, // 用户名 - password: webdavPass // 密码 - } - ) - - // 上传文件到 WebDAV - const remoteFilePath = `${webdavPath}/${filename}` - - // 先检查创建目录 - try { - if (!(await client.exists(webdavPath))) { - await client.createDirectory(webdavPath) - } - } catch (error) { - console.error('Error creating directory on WebDAV:', error) - } + console.debug({ + webdavHost, + webdavUser, + webdavPass, + webdavPath + }) // 上传文件 try { - await client.putFileContents(remoteFilePath, fileContent, { overwrite: true }) - console.log('File uploaded successfully!') - window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) - } catch (error) { - console.error('Error uploading file to WebDAV:', error) + const success = await window.api.backup.backupToWebdav(backupData, { + webdavHost, + webdavUser, + webdavPass, + webdavPath + }) + if (success) { + window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) + } else { + window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' }) + } + } catch (error: any) { + console.error('[backup] backupToWebdav: Error uploading file to WebDAV:', error) + window.modal.error({ + title: i18n.t('message.backup.failed'), + content: error.message + }) } } // 从 webdav 恢复 export async function restoreFromWebdav() { - const filename = `cherry-studio.backup.json` - - // 获取 userSetting 里的 WebDAV 配置 const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings - // console.log('backup.restoreFromWebdav', webdavHost, webdavUser, webdavPass, webdavPath) + let data = '' - let host = webdavHost - if (!host.startsWith('http://') && !host.startsWith('https://')) { - host = `http://${host}` - } - console.log('backup.restoreFromWebdav', host) - - // 创建 WebDAV 客户端 - const client = createClient( - host, // WebDAV 服务器地址 - { - username: webdavUser, // 用户名 - password: webdavPass // 密码 - } - ) - - // 上传文件到 WebDAV - const remoteFilePath = `${webdavPath}/${filename}` - - // 下载文件 try { - // 下载文件内容 - const fileContent = await client.getFileContents(remoteFilePath, { format: 'text' }) - console.log('File downloaded successfully!', fileContent) + data = await window.api.backup.restoreFromWebdav({ webdavHost, webdavUser, webdavPass, webdavPath }) + } catch (error: any) { + console.error('[backup] restoreFromWebdav: Error downloading file from WebDAV:', error) + window.modal.error({ + title: i18n.t('message.restore.failed'), + content: error.message + }) + } - // 处理文件内容 - const data = parseFileContent(fileContent.toString()) - console.log('Parsed file content:', data) - - await handleData(data) + try { + await handleData(JSON.parse(data)) } catch (error) { - console.error('Error downloading file from WebDAV:', error) + console.error('[backup] Error downloading file from WebDAV:', error) window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) } } -/************************************* Backup Utils ************************************** */ - -function parseFileContent(fileContent: string | Buffer | { data: string | Buffer } | ArrayBuffer): any { - let fileContentString: string - - if (typeof fileContent === 'string') { - fileContentString = fileContent - } else if (Buffer.isBuffer(fileContent)) { - fileContentString = fileContent.toString('utf-8') - } else if (fileContent instanceof ArrayBuffer) { - fileContentString = Buffer.from(fileContent).toString('utf-8') - } else if (fileContent && typeof fileContent.data === 'string') { - fileContentString = fileContent.data - } else if (fileContent && Buffer.isBuffer(fileContent.data)) { - fileContentString = fileContent.data.toString('utf-8') - } else { - throw new Error('Unsupported file content type') - } - - return JSON.parse(fileContentString) +async function getBackupData() { + return JSON.stringify({ + time: new Date().getTime(), + version: 3, + localStorage, + indexedDB: await backupDatabase() + }) } -async function handleData(data: any) { +/************************************* Backup Utils ************************************** */ +async function handleData(data: Record) { if (data.version === 1) { await clearDatabase() diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 6bb20d98..416fedce 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -116,3 +116,10 @@ export enum ThemeMode { dark = 'dark', auto = 'auto' } + +export type WebDavConfig = { + webdavHost: string + webdavUser: string + webdavPass: string + webdavPath: string +} diff --git a/yarn.lock b/yarn.lock index eda06878..31da9761 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2292,6 +2292,7 @@ __metadata: unzipper: "npm:^0.12.3" uuid: "npm:^10.0.0" vite: "npm:^5.0.12" + webdav: "npm:4.11.4" peerDependencies: react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 @@ -2826,6 +2827,16 @@ __metadata: languageName: node linkType: hard +"axios@npm:^0.27.2": + version: 0.27.2 + resolution: "axios@npm:0.27.2" + dependencies: + follow-redirects: "npm:^1.14.9" + form-data: "npm:^4.0.0" + checksum: 10c0/76d673d2a90629944b44d6f345f01e58e9174690f635115d5ffd4aca495d99bcd8f95c590d5ccb473513f5ebc1d1a6e8934580d0c57cdd0498c3a101313ef771 + languageName: node + linkType: hard + "axios@npm:^1.7.3": version: 1.7.7 resolution: "axios@npm:1.7.7" @@ -2865,6 +2876,13 @@ __metadata: languageName: node linkType: hard +"base-64@npm:^1.0.0": + version: 1.0.0 + resolution: "base-64@npm:1.0.0" + checksum: 10c0/d886cb3236cee0bed9f7075675748b59b32fad623ddb8ce1793c790306aa0f76a03238cad4b3fb398abda6527ce08a5588388533a4ccade0b97e82b9da660e28 + languageName: node + linkType: hard + "base64-arraybuffer@npm:^1.0.2": version: 1.0.2 resolution: "base64-arraybuffer@npm:1.0.2" @@ -3075,6 +3093,13 @@ __metadata: languageName: node linkType: hard +"byte-length@npm:^1.0.2": + version: 1.0.2 + resolution: "byte-length@npm:1.0.2" + checksum: 10c0/98778b938318494c2eadedf83b415e63da406d905575101eb102b7eefb5fafbbe21fbe83001914283664bb2fb93bd46f99245af2e8420a928ba90ffbb58041a1 + languageName: node + linkType: hard + "cac@npm:^6.7.14": version: 6.7.14 resolution: "cac@npm:6.7.14" @@ -3265,6 +3290,13 @@ __metadata: languageName: node linkType: hard +"charenc@npm:0.0.2": + version: 0.0.2 + resolution: "charenc@npm:0.0.2" + checksum: 10c0/a45ec39363a16799d0f9365c8dd0c78e711415113c6f14787a22462ef451f5013efae8a28f1c058f81fc01f2a6a16955f7a5fd0cd56247ce94a45349c89877d8 + languageName: node + linkType: hard + "chokidar@npm:>=3.0.0 <4.0.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" @@ -3586,6 +3618,13 @@ __metadata: languageName: node linkType: hard +"crypt@npm:0.0.2": + version: 0.0.2 + resolution: "crypt@npm:0.0.2" + checksum: 10c0/adbf263441dd801665d5425f044647533f39f4612544071b1471962209d235042fb703c27eea2795c7c53e1dfc242405173003f83cf4f4761a633d11f9653f18 + languageName: node + linkType: hard + "css-box-model@npm:^1.2.1": version: 1.2.1 resolution: "css-box-model@npm:1.2.1" @@ -4825,6 +4864,17 @@ __metadata: languageName: node linkType: hard +"fast-xml-parser@npm:^4.2.4": + version: 4.5.0 + resolution: "fast-xml-parser@npm:4.5.0" + dependencies: + strnum: "npm:^1.0.5" + bin: + fxparser: src/cli/cli.js + checksum: 10c0/71d206c9e137f5c26af88d27dde0108068a5d074401901d643c500c36e95dfd828333a98bda020846c41f5b9b364e2b0e9be5b19b0bdcab5cf31559c07b80a95 + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.17.1 resolution: "fastq@npm:1.17.1" @@ -4944,7 +4994,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.6": +"follow-redirects@npm:^1.14.9, follow-redirects@npm:^1.15.6": version: 1.15.9 resolution: "follow-redirects@npm:1.15.9" peerDependenciesMeta: @@ -5627,6 +5677,15 @@ __metadata: languageName: node linkType: hard +"he@npm:^1.2.0": + version: 1.2.0 + resolution: "he@npm:1.2.0" + bin: + he: bin/he + checksum: 10c0/a27d478befe3c8192f006cdd0639a66798979dfa6e2125c6ac582a19a5ebfec62ad83e8382e6036170d873f46e4536a7e795bf8b95bf7c247f4cc0825ccc8c17 + languageName: node + linkType: hard + "highlight.js@npm:^10.4.1, highlight.js@npm:~10.7.0": version: 10.7.3 resolution: "highlight.js@npm:10.7.3" @@ -5659,6 +5718,13 @@ __metadata: languageName: node linkType: hard +"hot-patcher@npm:^1.0.0": + version: 1.0.0 + resolution: "hot-patcher@npm:1.0.0" + checksum: 10c0/0c3ee3e1cb45f8b09ecb6d9af11b35b05f94b0767e09a303d89a7b6073b55ee98bd5c9b563ff17bfa1add55bbe3ff7598bbbb8c035578e05dd12631f2351cdb6 + languageName: node + linkType: hard + "html-parse-stringify@npm:^3.0.1": version: 3.0.1 resolution: "html-parse-stringify@npm:3.0.1" @@ -6015,6 +6081,13 @@ __metadata: languageName: node linkType: hard +"is-buffer@npm:~1.1.6": + version: 1.1.6 + resolution: "is-buffer@npm:1.1.6" + checksum: 10c0/ae18aa0b6e113d6c490ad1db5e8df9bdb57758382b313f5a22c9c61084875c6396d50bbf49315f5b1926d142d74dfb8d31b40d993a383e0a158b15fea7a82234 + languageName: node + linkType: hard + "is-callable@npm:^1.1.3, is-callable@npm:^1.1.4, is-callable@npm:^1.2.7": version: 1.2.7 resolution: "is-callable@npm:1.2.7" @@ -6655,6 +6728,13 @@ __metadata: languageName: node linkType: hard +"layerr@npm:^0.1.2": + version: 0.1.2 + resolution: "layerr@npm:0.1.2" + checksum: 10c0/e329ec13a31cd676c2fdf2127d43b794dab692991d7fa64cfd752d36e0c17799341e208b4727d944373d0a8c91fdd263023d66498e3152f8672238de47f9c602 + languageName: node + linkType: hard + "lazy-val@npm:^1.0.4, lazy-val@npm:^1.0.5": version: 1.0.5 resolution: "lazy-val@npm:1.0.5" @@ -6905,6 +6985,17 @@ __metadata: languageName: node linkType: hard +"md5@npm:^2.3.0": + version: 2.3.0 + resolution: "md5@npm:2.3.0" + dependencies: + charenc: "npm:0.0.2" + crypt: "npm:0.0.2" + is-buffer: "npm:~1.1.6" + checksum: 10c0/14a21d597d92e5b738255fbe7fe379905b8cb97e0a49d44a20b58526a646ec5518c337b817ce0094ca94d3e81a3313879c4c7b510d250c282d53afbbdede9110 + languageName: node + linkType: hard + "mdast-util-find-and-replace@npm:^3.0.0": version: 3.0.1 resolution: "mdast-util-find-and-replace@npm:3.0.1" @@ -7768,6 +7859,13 @@ __metadata: languageName: node linkType: hard +"nested-property@npm:^4.0.0": + version: 4.0.0 + resolution: "nested-property@npm:4.0.0" + checksum: 10c0/7bc0514f3d10460cc07ea27a39ce75f81471a28b8b019d4bfd9eda41dcd92c1fcb291598d7e168ae8bf1324109b36325e3a563d6aa2537d13015bea9258b8b72 + languageName: node + linkType: hard + "node-addon-api@npm:^1.6.3": version: 1.7.2 resolution: "node-addon-api@npm:1.7.2" @@ -8236,6 +8334,13 @@ __metadata: languageName: node linkType: hard +"path-posix@npm:^1.0.0": + version: 1.0.0 + resolution: "path-posix@npm:1.0.0" + checksum: 10c0/00fbadb9b60fb513f316f92e0b5535e55d832f4f20067586d151f6d7bed57178dec31b1a0f514694500a9a1f2b69798c066a3cdcf0b0289cfee63e39845bfd02 + languageName: node + linkType: hard + "path-scurry@npm:^1.11.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" @@ -8603,6 +8708,13 @@ __metadata: languageName: node linkType: hard +"querystringify@npm:^2.1.1": + version: 2.2.0 + resolution: "querystringify@npm:2.2.0" + checksum: 10c0/3258bc3dbdf322ff2663619afe5947c7926a6ef5fb78ad7d384602974c467fadfc8272af44f5eb8cddd0d011aae8fabf3a929a8eee4b86edcc0a21e6bd10f9aa + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -9668,6 +9780,13 @@ __metadata: languageName: node linkType: hard +"requires-port@npm:^1.0.0": + version: 1.0.0 + resolution: "requires-port@npm:1.0.0" + checksum: 10c0/b2bfdd09db16c082c4326e573a82c0771daaf7b53b9ce8ad60ea46aa6e30aaf475fe9b164800b89f93b748d2c234d8abff945d2551ba47bf5698e04cd7713267 + languageName: node + linkType: hard + "reselect@npm:^5.1.0": version: 5.1.1 resolution: "reselect@npm:5.1.1" @@ -10445,6 +10564,13 @@ __metadata: languageName: node linkType: hard +"strnum@npm:^1.0.5": + version: 1.0.5 + resolution: "strnum@npm:1.0.5" + checksum: 10c0/64fb8cc2effbd585a6821faa73ad97d4b553c8927e49086a162ffd2cc818787643390b89d567460a8e74300148d11ac052e21c921ef2049f2987f4b1b89a7ff1 + languageName: node + linkType: hard + "strtok3@npm:^6.2.4": version: 6.3.0 resolution: "strtok3@npm:6.3.0" @@ -11083,6 +11209,23 @@ __metadata: languageName: node linkType: hard +"url-join@npm:^4.0.1": + version: 4.0.1 + resolution: "url-join@npm:4.0.1" + checksum: 10c0/ac65e2c7c562d7b49b68edddcf55385d3e922bc1dd5d90419ea40b53b6de1607d1e45ceb71efb9d60da02c681d13c6cb3a1aa8b13fc0c989dfc219df97ee992d + languageName: node + linkType: hard + +"url-parse@npm:^1.5.10": + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" + dependencies: + querystringify: "npm:^2.1.1" + requires-port: "npm:^1.0.0" + checksum: 10c0/bd5aa9389f896974beb851c112f63b466505a04b4807cea2e5a3b7092f6fbb75316f0491ea84e44f66fed55f1b440df5195d7e3a8203f64fcefa19d182f5be87 + languageName: node + linkType: hard + "use-memo-one@npm:^1.1.3": version: 1.1.3 resolution: "use-memo-one@npm:1.1.3" @@ -11300,6 +11443,27 @@ __metadata: languageName: node linkType: hard +"webdav@npm:4.11.4": + version: 4.11.4 + resolution: "webdav@npm:4.11.4" + dependencies: + axios: "npm:^0.27.2" + base-64: "npm:^1.0.0" + byte-length: "npm:^1.0.2" + fast-xml-parser: "npm:^4.2.4" + he: "npm:^1.2.0" + hot-patcher: "npm:^1.0.0" + layerr: "npm:^0.1.2" + md5: "npm:^2.3.0" + minimatch: "npm:^5.1.0" + nested-property: "npm:^4.0.0" + path-posix: "npm:^1.0.0" + url-join: "npm:^4.0.1" + url-parse: "npm:^1.5.10" + checksum: 10c0/8b1ae47c4df6c3ee832ff1abe05ca8c03c5284519a07b41ae816e417688de0aae6f9c5ced04ecedd14daae5ec7367c6af010fda55d3458b424e51a95a817133e + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1"